diff --git a/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc b/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc index d68e0a4..90d6339 100644 Binary files a/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc and b/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc differ diff --git a/webui/backend/app/api/schemas.py b/webui/backend/app/api/schemas.py index d228d04..bdd982e 100644 --- a/webui/backend/app/api/schemas.py +++ b/webui/backend/app/api/schemas.py @@ -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): diff --git a/webui/backend/app/services/__pycache__/settings_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/settings_service.cpython-313.pyc index 808d9e4..3fb499a 100644 Binary files a/webui/backend/app/services/__pycache__/settings_service.cpython-313.pyc and b/webui/backend/app/services/__pycache__/settings_service.cpython-313.pyc differ diff --git a/webui/backend/app/services/settings_service.py b/webui/backend/app/services/settings_service.py index 67bb70c..9d81b7c 100644 --- a/webui/backend/app/services/settings_service.py +++ b/webui/backend/app/services/settings_service.py @@ -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: diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 6a2df30..b66e458 100644 Binary files a/webui/backend/data/tasks.db and b/webui/backend/data/tasks.db differ diff --git a/webui/backend/tests/golden/__pycache__/test_api_settings_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_settings_golden.cpython-313.pyc index b586f81..a5e98f7 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_api_settings_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_api_settings_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index 618d0ab..18ff6c4 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/test_api_settings_golden.py b/webui/backend/tests/golden/test_api_settings_golden.py index a45dfcb..11af19e 100644 --- a/webui/backend/tests/golden/test_api_settings_golden.py +++ b/webui/backend/tests/golden/test_api_settings_golden.py @@ -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"}) diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 180e06e..6682215 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -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) diff --git a/webui/html/app.js b/webui/html/app.js index d0aa8ec..804eab8 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -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(); diff --git a/webui/html/base.css b/webui/html/base.css index a0c30b0..9afd796 100644 --- a/webui/html/base.css +++ b/webui/html/base.css @@ -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; diff --git a/webui/html/index.html b/webui/html/index.html index ed7b507..5165c86 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -155,6 +155,7 @@
+
@@ -198,6 +199,32 @@
+