feat: download - download dwnload limieten in settings
This commit is contained in:
Binary file not shown.
@@ -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):
|
||||
|
||||
Binary file not shown.
@@ -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.
Binary file not shown.
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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user