feat: download - download dwnload limieten in settings

This commit is contained in:
kodi
2026-03-14 13:38:44 +01:00
parent ea337338e3
commit 8ea2bd1498
12 changed files with 228 additions and 26 deletions
+9
View File
@@ -103,12 +103,21 @@ class FileInfoResponse(BaseModel):
height: int | None = None
class ZipDownloadLimitsResponse(BaseModel):
max_items: int
max_total_input_bytes: int
max_individual_file_bytes: int
scan_timeout_seconds: float
symlink_policy: str
class SettingsResponse(BaseModel):
show_thumbnails: bool
preferred_startup_path_left: str | None = None
preferred_startup_path_right: str | None = None
selected_theme: str
selected_color_mode: str
zip_download_limits: ZipDownloadLimitsResponse
class SettingsUpdateRequest(BaseModel):
@@ -1,9 +1,10 @@
from __future__ import annotations
from backend.app.api.errors import AppError
from backend.app.api.schemas import SettingsResponse, SettingsUpdateRequest
from backend.app.api.schemas import SettingsResponse, SettingsUpdateRequest, ZipDownloadLimitsResponse
from backend.app.db.settings_repository import SettingsRepository
from backend.app.security.path_guard import PathGuard
from backend.app.services.file_ops_service import ZIP_DOWNLOAD_PREFLIGHT_LIMITS
VALID_THEMES = {
@@ -38,6 +39,13 @@ class SettingsService:
preferred_startup_path_right=preferred_right,
selected_theme=selected_theme,
selected_color_mode=selected_color_mode,
zip_download_limits=ZipDownloadLimitsResponse(
max_items=ZIP_DOWNLOAD_PREFLIGHT_LIMITS.max_items,
max_total_input_bytes=ZIP_DOWNLOAD_PREFLIGHT_LIMITS.max_total_input_bytes,
max_individual_file_bytes=ZIP_DOWNLOAD_PREFLIGHT_LIMITS.max_individual_file_bytes,
scan_timeout_seconds=ZIP_DOWNLOAD_PREFLIGHT_LIMITS.scan_timeout_seconds,
symlink_policy="not_allowed",
),
)
def update_settings(self, request: SettingsUpdateRequest) -> SettingsResponse:
Binary file not shown.
@@ -49,6 +49,16 @@ class SettingsApiGoldenTest(unittest.TestCase):
return asyncio.run(_run())
@staticmethod
def _default_zip_download_limits() -> dict:
return {
"max_items": 1000,
"max_total_input_bytes": 2147483648,
"max_individual_file_bytes": 524288000,
"scan_timeout_seconds": 10.0,
"symlink_policy": "not_allowed",
}
def test_settings_default_response(self) -> None:
response = self._request("GET", "/api/settings")
@@ -61,6 +71,7 @@ class SettingsApiGoldenTest(unittest.TestCase):
"preferred_startup_path_right": None,
"selected_theme": "default",
"selected_color_mode": "dark",
"zip_download_limits": self._default_zip_download_limits(),
},
)
@@ -79,6 +90,7 @@ class SettingsApiGoldenTest(unittest.TestCase):
"preferred_startup_path_right": None,
"selected_theme": "default",
"selected_color_mode": "dark",
"zip_download_limits": self._default_zip_download_limits(),
},
)
@@ -102,6 +114,7 @@ class SettingsApiGoldenTest(unittest.TestCase):
"preferred_startup_path_right": "storage1/docs",
"selected_theme": "default",
"selected_color_mode": "dark",
"zip_download_limits": self._default_zip_download_limits(),
},
)
self.assertEqual(
@@ -112,6 +125,7 @@ class SettingsApiGoldenTest(unittest.TestCase):
"preferred_startup_path_right": "storage1/docs",
"selected_theme": "default",
"selected_color_mode": "dark",
"zip_download_limits": self._default_zip_download_limits(),
},
)
@@ -123,6 +137,7 @@ class SettingsApiGoldenTest(unittest.TestCase):
self.assertEqual(response.json()["preferred_startup_path_right"], None)
self.assertEqual(response.json()["selected_theme"], "default")
self.assertEqual(response.json()["selected_color_mode"], "dark")
self.assertEqual(response.json()["zip_download_limits"], self._default_zip_download_limits())
def test_settings_preferred_startup_path_right_persistence(self) -> None:
response = self._request("POST", "/api/settings", {"preferred_startup_path_right": "storage1/docs"})
@@ -132,6 +147,7 @@ class SettingsApiGoldenTest(unittest.TestCase):
self.assertEqual(response.json()["preferred_startup_path_right"], "storage1/docs")
self.assertEqual(response.json()["selected_theme"], "default")
self.assertEqual(response.json()["selected_color_mode"], "dark")
self.assertEqual(response.json()["zip_download_limits"], self._default_zip_download_limits())
def test_settings_preferred_startup_path_empty_string_resets_only_left_to_null(self) -> None:
self._request(
@@ -149,6 +165,7 @@ class SettingsApiGoldenTest(unittest.TestCase):
self.assertEqual(response.json()["preferred_startup_path_right"], "storage1/docs")
self.assertEqual(response.json()["selected_theme"], "default")
self.assertEqual(response.json()["selected_color_mode"], "dark")
self.assertEqual(response.json()["zip_download_limits"], self._default_zip_download_limits())
def test_settings_selected_theme_persistence(self) -> None:
response = self._request("POST", "/api/settings", {"selected_theme": "midnight"})
@@ -156,6 +173,7 @@ class SettingsApiGoldenTest(unittest.TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["selected_theme"], "midnight")
self.assertEqual(response.json()["selected_color_mode"], "dark")
self.assertEqual(response.json()["zip_download_limits"], self._default_zip_download_limits())
def test_settings_selected_theme_accepts_new_built_in_family(self) -> None:
response = self._request("POST", "/api/settings", {"selected_theme": "commander-electric"})
@@ -163,6 +181,7 @@ class SettingsApiGoldenTest(unittest.TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["selected_theme"], "commander-electric")
self.assertEqual(response.json()["selected_color_mode"], "dark")
self.assertEqual(response.json()["zip_download_limits"], self._default_zip_download_limits())
def test_settings_selected_color_mode_persistence(self) -> None:
response = self._request("POST", "/api/settings", {"selected_color_mode": "light"})
@@ -170,6 +189,13 @@ class SettingsApiGoldenTest(unittest.TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["selected_theme"], "default")
self.assertEqual(response.json()["selected_color_mode"], "light")
self.assertEqual(response.json()["zip_download_limits"], self._default_zip_download_limits())
def test_settings_includes_read_only_zip_download_limits(self) -> None:
response = self._request("GET", "/api/settings")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["zip_download_limits"], self._default_zip_download_limits())
def test_settings_rejects_invalid_selected_theme(self) -> None:
response = self._request("POST", "/api/settings", {"selected_theme": "unknown"})
@@ -120,6 +120,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('id="rename-apply-btn"', body)
self.assertIn('id="settings-general-tab"', body)
self.assertIn('id="settings-interface-tab"', body)
self.assertIn('id="settings-downloads-tab"', body)
self.assertIn('id="settings-logs-tab"', body)
self.assertIn('id="settings-show-thumbnails"', body)
self.assertIn("Show thumbnails", body)
@@ -141,6 +142,13 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn("Preferred startup path (right)", body)
self.assertIn('id="settings-general-save-btn"', body)
self.assertIn('id="settings-interface-save-btn"', body)
self.assertIn('id="settings-downloads-panel"', body)
self.assertIn('id="settings-download-max-items"', body)
self.assertIn('id="settings-download-max-total-size"', body)
self.assertIn('id="settings-download-max-file-size"', body)
self.assertIn('id="settings-download-scan-timeout"', body)
self.assertIn('id="settings-download-symlink-policy"', body)
self.assertIn("ZIP download limits are shown for reference and cannot be changed here.", body)
self.assertIn('id="settings-logs-list"', body)
self.assertIn('id="viewer-content"', body)
self.assertIn('id="editor-modal"', body)
@@ -224,6 +232,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('function markZipDownloadReady(fileName)', app_js)
self.assertIn('function markZipDownloadFailed(err)', app_js)
self.assertIn('function closeDownloadModal()', app_js)
self.assertIn('function zipDownloadRequestKey(paths)', app_js)
self.assertIn('function contextMenuElements()', app_js)
self.assertIn('function openContextMenu(pane, entry, event)', app_js)
self.assertIn('function closeContextMenu()', app_js)
@@ -231,13 +240,16 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('async function downloadFileRequest(paths)', app_js)
self.assertIn('const zipDownload = isZipDownloadSelection(selectedItems);', app_js)
self.assertIn('openZipDownloadModal(selectedItems);', app_js)
self.assertIn('statusText: "preparing"', app_js)
self.assertIn('statusText: "packaging items"', app_js)
self.assertIn('statusText: "ready"', app_js)
self.assertIn('statusText: `failed: ${err.message}`', app_js)
self.assertIn('countText: "Step 1/3"', app_js)
self.assertIn('countText: "Step 2/3"', app_js)
self.assertIn('countText: "Step 3/3"', app_js)
self.assertIn('targetText: "Preparing download..."', app_js)
self.assertIn('statusText: "Preparing download..."', app_js)
self.assertIn('countText: "Preparing zip download"', app_js)
self.assertIn('countText: "Zip preflight and packaging"', app_js)
self.assertIn('statusText: "Download started"', app_js)
self.assertIn('countText: "Browser download started"', app_js)
self.assertIn('countText: "Zip download failed"', app_js)
self.assertIn('statusText: err.message || "Download failed"', app_js)
self.assertIn('downloadProgressState.requestKey === requestKey', app_js)
self.assertIn('setStatus("Preparing download...");', app_js)
self.assertIn('function applyContextMenuSelection()', app_js)
self.assertIn('function startContextMenuOpen()', app_js)
self.assertIn('function startContextMenuEdit()', app_js)
@@ -304,6 +316,12 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('settings.interfaceSaveButton.onclick = handleInterfaceSave;', app_js)
self.assertIn('preferredStartupPathLeft', app_js)
self.assertIn('preferredStartupPathRight', app_js)
self.assertIn('zipDownloadLimits', app_js)
self.assertIn('zip_download_limits', app_js)
self.assertIn('function renderDownloadSettings()', app_js)
self.assertIn('function formatBinarySize(bytes)', app_js)
self.assertIn('function formatSeconds(seconds)', app_js)
self.assertIn('function formatSymlinkPolicy(policy)', app_js)
self.assertIn('selected_theme', app_js)
self.assertIn('selected_color_mode', app_js)
self.assertNotIn("localStorage", app_js)
@@ -314,6 +332,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('paneState("right").currentPath = settingsState.preferredStartupPathRight || "/Volumes";', app_js)
self.assertIn('applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode);', app_js)
self.assertIn('settings.interfaceTab.onclick = () => setSettingsTab("interface");', app_js)
self.assertIn('settings.downloadsTab.onclick = () => setSettingsTab("downloads");', app_js)
self.assertIn('"/api/settings"', app_js)
self.assertIn('function uploadElements()', app_js)
self.assertIn('function openUploadPicker()', app_js)
+103 -18
View File
@@ -85,6 +85,7 @@ let downloadProgressState = {
active: false,
archiveLabel: "",
totalItems: 0,
requestKey: null,
};
let folderUploadPlanState = {
targetPane: "left",
@@ -103,6 +104,7 @@ let settingsState = {
preferredStartupPathRight: null,
selectedTheme: "default",
selectedColorMode: "dark",
zipDownloadLimits: null,
};
const VALID_THEME_FAMILIES = [
"default",
@@ -368,6 +370,10 @@ function isZipDownloadSelection(items) {
return items.length > 1 || (items.length === 1 && items[0].kind === "directory");
}
function zipDownloadRequestKey(paths) {
return paths.join("\n");
}
function selectedItemCountLabel(totalItems) {
return `${totalItems} selected item${totalItems === 1 ? "" : "s"}`;
}
@@ -399,17 +405,19 @@ function updateDownloadModalDisplay(info) {
}
function openZipDownloadModal(selectedItems) {
const requestPaths = selectedItems.map((item) => item.path);
downloadProgressState.active = true;
downloadProgressState.archiveLabel = "ZIP archive";
downloadProgressState.totalItems = selectedItems.length;
downloadProgressState.requestKey = zipDownloadRequestKey(requestPaths);
setDownloadModalVisible(true);
updateDownloadModalDisplay({
active: true,
targetText: "Preparing ZIP download",
targetText: "Preparing download...",
currentFileText: `Selection: ${selectedItemCountLabel(selectedItems.length)}`,
countText: "Step 1/3",
statusText: "preparing",
percent: 33,
countText: "Preparing zip download",
statusText: "Preparing download...",
percent: 20,
});
requestAnimationFrame(() => {
if (!downloadProgressState.active) {
@@ -417,11 +425,11 @@ function openZipDownloadModal(selectedItems) {
}
updateDownloadModalDisplay({
active: true,
targetText: "Preparing ZIP download",
targetText: "Preparing download...",
currentFileText: `Packaging ${selectedItemCountLabel(downloadProgressState.totalItems)}`,
countText: "Step 2/3",
statusText: "packaging items",
percent: 66,
countText: "Zip preflight and packaging",
statusText: "Preparing download...",
percent: 55,
});
});
}
@@ -431,24 +439,24 @@ function markZipDownloadReady(fileName) {
downloadProgressState.archiveLabel = fileName || "ZIP archive";
updateDownloadModalDisplay({
active: false,
targetText: `Ready: ${downloadProgressState.archiveLabel}`,
targetText: `Download started: ${downloadProgressState.archiveLabel}`,
currentFileText: `Prepared ${selectedItemCountLabel(downloadProgressState.totalItems)}`,
countText: "Step 3/3",
statusText: "ready",
countText: "Browser download started",
statusText: "Download started",
percent: 100,
});
window.setTimeout(closeDownloadModal, 240);
window.setTimeout(closeDownloadModal, 480);
}
function markZipDownloadFailed(err) {
downloadProgressState.active = false;
updateDownloadModalDisplay({
active: false,
targetText: "Preparing ZIP download",
targetText: "Preparing download...",
currentFileText: `Selection: ${selectedItemCountLabel(downloadProgressState.totalItems)}`,
countText: "Step 2/3",
statusText: `failed: ${err.message}`,
percent: 66,
countText: "Zip download failed",
statusText: err.message || "Download failed",
percent: 0,
});
}
@@ -458,6 +466,7 @@ function closeDownloadModal() {
}
downloadProgressState.archiveLabel = "";
downloadProgressState.totalItems = 0;
downloadProgressState.requestKey = null;
updateDownloadModalDisplay({
active: false,
targetText: "",
@@ -622,12 +631,19 @@ async function startDownloadSelected() {
return;
}
const zipDownload = isZipDownloadSelection(selectedItems);
const selectedPaths = selectedItems.map((item) => item.path);
const requestKey = zipDownloadRequestKey(selectedPaths);
if (zipDownload && downloadProgressState.active && downloadProgressState.requestKey === requestKey) {
setStatus("Preparing download...");
return;
}
if (zipDownload) {
openZipDownloadModal(selectedItems);
setStatus("Preparing download...");
}
try {
const selected = selectedItems[0];
const { blob, fileName } = await downloadFileRequest(selectedItems.map((item) => item.path));
const { blob, fileName } = await downloadFileRequest(selectedPaths);
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
@@ -680,9 +696,11 @@ function settingsElements() {
closeButton: document.getElementById("settings-close-btn"),
generalTab: document.getElementById("settings-general-tab"),
interfaceTab: document.getElementById("settings-interface-tab"),
downloadsTab: document.getElementById("settings-downloads-tab"),
logsTab: document.getElementById("settings-logs-tab"),
generalPanel: document.getElementById("settings-general-panel"),
interfacePanel: document.getElementById("settings-interface-panel"),
downloadsPanel: document.getElementById("settings-downloads-panel"),
showThumbnailsInput: document.getElementById("settings-show-thumbnails"),
startupPathLeftInput: document.getElementById("settings-startup-path-left"),
startupPathRightInput: document.getElementById("settings-startup-path-right"),
@@ -691,6 +709,11 @@ function settingsElements() {
selectedThemeInput: document.getElementById("settings-selected-theme"),
interfaceError: document.getElementById("settings-interface-error"),
interfaceSaveButton: document.getElementById("settings-interface-save-btn"),
downloadMaxItems: document.getElementById("settings-download-max-items"),
downloadMaxTotalSize: document.getElementById("settings-download-max-total-size"),
downloadMaxFileSize: document.getElementById("settings-download-max-file-size"),
downloadScanTimeout: document.getElementById("settings-download-scan-timeout"),
downloadSymlinkPolicy: document.getElementById("settings-download-symlink-policy"),
logsPanel: document.getElementById("settings-logs-panel"),
logsList: document.getElementById("settings-logs-list"),
logsError: document.getElementById("settings-logs-error"),
@@ -1661,6 +1684,7 @@ async function loadSettings() {
settingsState.preferredStartupPathRight = data.preferred_startup_path_right || null;
settingsState.selectedTheme = VALID_THEME_FAMILIES.includes(data.selected_theme) ? data.selected_theme : "default";
settingsState.selectedColorMode = VALID_COLOR_MODES.includes(data.selected_color_mode) ? data.selected_color_mode : "dark";
settingsState.zipDownloadLimits = data.zip_download_limits || null;
const elements = settingsElements();
if (elements.showThumbnailsInput) {
elements.showThumbnailsInput.checked = settingsState.showThumbnails;
@@ -1674,6 +1698,7 @@ async function loadSettings() {
if (elements.selectedThemeInput) {
elements.selectedThemeInput.value = settingsState.selectedTheme;
}
renderDownloadSettings();
}
async function saveSettings(update) {
@@ -1683,6 +1708,7 @@ async function saveSettings(update) {
settingsState.preferredStartupPathRight = data.preferred_startup_path_right || null;
settingsState.selectedTheme = VALID_THEME_FAMILIES.includes(data.selected_theme) ? data.selected_theme : "default";
settingsState.selectedColorMode = VALID_COLOR_MODES.includes(data.selected_color_mode) ? data.selected_color_mode : "dark";
settingsState.zipDownloadLimits = data.zip_download_limits || null;
const elements = settingsElements();
if (elements.showThumbnailsInput) {
elements.showThumbnailsInput.checked = settingsState.showThumbnails;
@@ -1696,6 +1722,7 @@ async function saveSettings(update) {
if (elements.selectedThemeInput) {
elements.selectedThemeInput.value = settingsState.selectedTheme;
}
renderDownloadSettings();
applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode);
renderPaneItems("left");
renderPaneItems("right");
@@ -1728,6 +1755,57 @@ function isEditableSelection(item) {
return [".txt", ".log", ".md", ".yml", ".yaml", ".json", ".js", ".py", ".css", ".html"].some((suffix) => lower.endsWith(suffix));
}
function formatBinarySize(bytes) {
const value = Number(bytes);
if (!Number.isFinite(value) || value < 0) {
return "-";
}
if (value < 1024) {
return `${value} B`;
}
const units = ["KiB", "MiB", "GiB", "TiB"];
let scaled = value;
let unitIndex = -1;
do {
scaled /= 1024;
unitIndex += 1;
} while (scaled >= 1024 && unitIndex < units.length - 1);
const digits = scaled >= 10 || unitIndex === 0 ? 0 : 1;
return `${scaled.toFixed(digits)} ${units[unitIndex]}`;
}
function formatSeconds(seconds) {
const value = Number(seconds);
if (!Number.isFinite(value) || value < 0) {
return "-";
}
return `${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)} seconds`;
}
function formatSymlinkPolicy(policy) {
return policy === "not_allowed" ? "Rejected / not allowed" : (policy || "-");
}
function renderDownloadSettings() {
const elements = settingsElements();
const limits = settingsState.zipDownloadLimits || {};
if (elements.downloadMaxItems) {
elements.downloadMaxItems.textContent = limits.max_items ? `${limits.max_items} items` : "-";
}
if (elements.downloadMaxTotalSize) {
elements.downloadMaxTotalSize.textContent = formatBinarySize(limits.max_total_input_bytes);
}
if (elements.downloadMaxFileSize) {
elements.downloadMaxFileSize.textContent = formatBinarySize(limits.max_individual_file_bytes);
}
if (elements.downloadScanTimeout) {
elements.downloadScanTimeout.textContent = formatSeconds(limits.scan_timeout_seconds);
}
if (elements.downloadSymlinkPolicy) {
elements.downloadSymlinkPolicy.textContent = formatSymlinkPolicy(limits.symlink_policy);
}
}
function monacoLanguageForName(name) {
const lower = (name || "").toLowerCase();
if (lower === "dockerfile" || lower === "containerfile") {
@@ -3302,18 +3380,22 @@ async function submitSearch() {
function setSettingsTab(tab) {
const elements = settingsElements();
settingsState.activeTab = tab === "logs" ? "logs" : (tab === "interface" ? "interface" : "general");
settingsState.activeTab = tab === "logs" ? "logs" : (tab === "downloads" ? "downloads" : (tab === "interface" ? "interface" : "general"));
const isGeneral = settingsState.activeTab === "general";
const isInterface = settingsState.activeTab === "interface";
const isDownloads = settingsState.activeTab === "downloads";
const isLogs = settingsState.activeTab === "logs";
elements.generalTab.classList.toggle("is-active", isGeneral);
elements.generalTab.setAttribute("aria-selected", isGeneral ? "true" : "false");
elements.interfaceTab.classList.toggle("is-active", isInterface);
elements.interfaceTab.setAttribute("aria-selected", isInterface ? "true" : "false");
elements.downloadsTab.classList.toggle("is-active", isDownloads);
elements.downloadsTab.setAttribute("aria-selected", isDownloads ? "true" : "false");
elements.logsTab.classList.toggle("is-active", isLogs);
elements.logsTab.setAttribute("aria-selected", isLogs ? "true" : "false");
elements.generalPanel.classList.toggle("hidden", !isGeneral);
elements.interfacePanel.classList.toggle("hidden", !isInterface);
elements.downloadsPanel.classList.toggle("hidden", !isDownloads);
elements.logsPanel.classList.toggle("hidden", !isLogs);
}
@@ -3430,6 +3512,8 @@ async function openSettings(tab = "general") {
}
(settingsState.activeTab === "logs"
? elements.logsTab
: settingsState.activeTab === "downloads"
? elements.downloadsTab
: settingsState.activeTab === "interface"
? elements.interfaceTab
: elements.generalTab).focus();
@@ -4107,6 +4191,7 @@ function setupEvents() {
settings.closeButton.onclick = closeSettings;
settings.generalTab.onclick = () => setSettingsTab("general");
settings.interfaceTab.onclick = () => setSettingsTab("interface");
settings.downloadsTab.onclick = () => setSettingsTab("downloads");
settings.logsTab.onclick = async () => {
setSettingsTab("logs");
await loadHistoryForSettings();
+28
View File
@@ -1029,6 +1029,34 @@ button:disabled {
min-height: 180px;
}
.settings-readonly-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.settings-readonly-item {
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
padding: 8px 10px;
}
.settings-readonly-label {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
margin-bottom: 4px;
}
.settings-readonly-value {
font-size: 14px;
word-break: break-word;
}
.settings-placeholder-title {
font-size: 13px;
font-weight: 700;
+27
View File
@@ -155,6 +155,7 @@
<div class="settings-tabs" role="tablist" aria-label="Settings tabs">
<button id="settings-general-tab" class="settings-tab is-active" type="button" role="tab" aria-selected="true">General</button>
<button id="settings-interface-tab" class="settings-tab" type="button" role="tab" aria-selected="false">Interface</button>
<button id="settings-downloads-tab" class="settings-tab" type="button" role="tab" aria-selected="false">Downloads</button>
<button id="settings-logs-tab" class="settings-tab" type="button" role="tab" aria-selected="false">Logs</button>
</div>
<section id="settings-general-panel" class="settings-panel" role="tabpanel" aria-labelledby="settings-general-tab">
@@ -198,6 +199,32 @@
<button id="settings-interface-save-btn" type="button">Save</button>
</div>
</section>
<section id="settings-downloads-panel" class="settings-panel hidden" role="tabpanel" aria-labelledby="settings-downloads-tab">
<div class="settings-placeholder-title">Downloads</div>
<div class="popup-meta">ZIP download limits are shown for reference and cannot be changed here.</div>
<div class="settings-readonly-list">
<div class="settings-readonly-item">
<div class="settings-readonly-label">Max items</div>
<div id="settings-download-max-items" class="settings-readonly-value"></div>
</div>
<div class="settings-readonly-item">
<div class="settings-readonly-label">Max total input size</div>
<div id="settings-download-max-total-size" class="settings-readonly-value"></div>
</div>
<div class="settings-readonly-item">
<div class="settings-readonly-label">Max individual file size</div>
<div id="settings-download-max-file-size" class="settings-readonly-value"></div>
</div>
<div class="settings-readonly-item">
<div class="settings-readonly-label">Scan timeout</div>
<div id="settings-download-scan-timeout" class="settings-readonly-value"></div>
</div>
<div class="settings-readonly-item">
<div class="settings-readonly-label">Symlink policy</div>
<div id="settings-download-symlink-policy" class="settings-readonly-value"></div>
</div>
</div>
</section>
<section id="settings-logs-panel" class="settings-panel hidden" role="tabpanel" aria-labelledby="settings-logs-tab">
<div id="settings-logs-error" class="error"></div>
<div id="settings-logs-list" class="settings-log-list"></div>