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
|
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):
|
class SettingsResponse(BaseModel):
|
||||||
show_thumbnails: bool
|
show_thumbnails: bool
|
||||||
preferred_startup_path_left: str | None = None
|
preferred_startup_path_left: str | None = None
|
||||||
preferred_startup_path_right: str | None = None
|
preferred_startup_path_right: str | None = None
|
||||||
selected_theme: str
|
selected_theme: str
|
||||||
selected_color_mode: str
|
selected_color_mode: str
|
||||||
|
zip_download_limits: ZipDownloadLimitsResponse
|
||||||
|
|
||||||
|
|
||||||
class SettingsUpdateRequest(BaseModel):
|
class SettingsUpdateRequest(BaseModel):
|
||||||
|
|||||||
Binary file not shown.
@@ -1,9 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from backend.app.api.errors import AppError
|
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.db.settings_repository import SettingsRepository
|
||||||
from backend.app.security.path_guard import PathGuard
|
from backend.app.security.path_guard import PathGuard
|
||||||
|
from backend.app.services.file_ops_service import ZIP_DOWNLOAD_PREFLIGHT_LIMITS
|
||||||
|
|
||||||
|
|
||||||
VALID_THEMES = {
|
VALID_THEMES = {
|
||||||
@@ -38,6 +39,13 @@ class SettingsService:
|
|||||||
preferred_startup_path_right=preferred_right,
|
preferred_startup_path_right=preferred_right,
|
||||||
selected_theme=selected_theme,
|
selected_theme=selected_theme,
|
||||||
selected_color_mode=selected_color_mode,
|
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:
|
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())
|
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:
|
def test_settings_default_response(self) -> None:
|
||||||
response = self._request("GET", "/api/settings")
|
response = self._request("GET", "/api/settings")
|
||||||
|
|
||||||
@@ -61,6 +71,7 @@ class SettingsApiGoldenTest(unittest.TestCase):
|
|||||||
"preferred_startup_path_right": None,
|
"preferred_startup_path_right": None,
|
||||||
"selected_theme": "default",
|
"selected_theme": "default",
|
||||||
"selected_color_mode": "dark",
|
"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,
|
"preferred_startup_path_right": None,
|
||||||
"selected_theme": "default",
|
"selected_theme": "default",
|
||||||
"selected_color_mode": "dark",
|
"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",
|
"preferred_startup_path_right": "storage1/docs",
|
||||||
"selected_theme": "default",
|
"selected_theme": "default",
|
||||||
"selected_color_mode": "dark",
|
"selected_color_mode": "dark",
|
||||||
|
"zip_download_limits": self._default_zip_download_limits(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -112,6 +125,7 @@ class SettingsApiGoldenTest(unittest.TestCase):
|
|||||||
"preferred_startup_path_right": "storage1/docs",
|
"preferred_startup_path_right": "storage1/docs",
|
||||||
"selected_theme": "default",
|
"selected_theme": "default",
|
||||||
"selected_color_mode": "dark",
|
"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()["preferred_startup_path_right"], None)
|
||||||
self.assertEqual(response.json()["selected_theme"], "default")
|
self.assertEqual(response.json()["selected_theme"], "default")
|
||||||
self.assertEqual(response.json()["selected_color_mode"], "dark")
|
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:
|
def test_settings_preferred_startup_path_right_persistence(self) -> None:
|
||||||
response = self._request("POST", "/api/settings", {"preferred_startup_path_right": "storage1/docs"})
|
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()["preferred_startup_path_right"], "storage1/docs")
|
||||||
self.assertEqual(response.json()["selected_theme"], "default")
|
self.assertEqual(response.json()["selected_theme"], "default")
|
||||||
self.assertEqual(response.json()["selected_color_mode"], "dark")
|
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:
|
def test_settings_preferred_startup_path_empty_string_resets_only_left_to_null(self) -> None:
|
||||||
self._request(
|
self._request(
|
||||||
@@ -149,6 +165,7 @@ class SettingsApiGoldenTest(unittest.TestCase):
|
|||||||
self.assertEqual(response.json()["preferred_startup_path_right"], "storage1/docs")
|
self.assertEqual(response.json()["preferred_startup_path_right"], "storage1/docs")
|
||||||
self.assertEqual(response.json()["selected_theme"], "default")
|
self.assertEqual(response.json()["selected_theme"], "default")
|
||||||
self.assertEqual(response.json()["selected_color_mode"], "dark")
|
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:
|
def test_settings_selected_theme_persistence(self) -> None:
|
||||||
response = self._request("POST", "/api/settings", {"selected_theme": "midnight"})
|
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.status_code, 200)
|
||||||
self.assertEqual(response.json()["selected_theme"], "midnight")
|
self.assertEqual(response.json()["selected_theme"], "midnight")
|
||||||
self.assertEqual(response.json()["selected_color_mode"], "dark")
|
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:
|
def test_settings_selected_theme_accepts_new_built_in_family(self) -> None:
|
||||||
response = self._request("POST", "/api/settings", {"selected_theme": "commander-electric"})
|
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.status_code, 200)
|
||||||
self.assertEqual(response.json()["selected_theme"], "commander-electric")
|
self.assertEqual(response.json()["selected_theme"], "commander-electric")
|
||||||
self.assertEqual(response.json()["selected_color_mode"], "dark")
|
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:
|
def test_settings_selected_color_mode_persistence(self) -> None:
|
||||||
response = self._request("POST", "/api/settings", {"selected_color_mode": "light"})
|
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.status_code, 200)
|
||||||
self.assertEqual(response.json()["selected_theme"], "default")
|
self.assertEqual(response.json()["selected_theme"], "default")
|
||||||
self.assertEqual(response.json()["selected_color_mode"], "light")
|
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:
|
def test_settings_rejects_invalid_selected_theme(self) -> None:
|
||||||
response = self._request("POST", "/api/settings", {"selected_theme": "unknown"})
|
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="rename-apply-btn"', body)
|
||||||
self.assertIn('id="settings-general-tab"', body)
|
self.assertIn('id="settings-general-tab"', body)
|
||||||
self.assertIn('id="settings-interface-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-logs-tab"', body)
|
||||||
self.assertIn('id="settings-show-thumbnails"', body)
|
self.assertIn('id="settings-show-thumbnails"', body)
|
||||||
self.assertIn("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("Preferred startup path (right)", body)
|
||||||
self.assertIn('id="settings-general-save-btn"', body)
|
self.assertIn('id="settings-general-save-btn"', body)
|
||||||
self.assertIn('id="settings-interface-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="settings-logs-list"', body)
|
||||||
self.assertIn('id="viewer-content"', body)
|
self.assertIn('id="viewer-content"', body)
|
||||||
self.assertIn('id="editor-modal"', 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 markZipDownloadReady(fileName)', app_js)
|
||||||
self.assertIn('function markZipDownloadFailed(err)', app_js)
|
self.assertIn('function markZipDownloadFailed(err)', app_js)
|
||||||
self.assertIn('function closeDownloadModal()', app_js)
|
self.assertIn('function closeDownloadModal()', app_js)
|
||||||
|
self.assertIn('function zipDownloadRequestKey(paths)', app_js)
|
||||||
self.assertIn('function contextMenuElements()', app_js)
|
self.assertIn('function contextMenuElements()', app_js)
|
||||||
self.assertIn('function openContextMenu(pane, entry, event)', app_js)
|
self.assertIn('function openContextMenu(pane, entry, event)', app_js)
|
||||||
self.assertIn('function closeContextMenu()', 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('async function downloadFileRequest(paths)', app_js)
|
||||||
self.assertIn('const zipDownload = isZipDownloadSelection(selectedItems);', app_js)
|
self.assertIn('const zipDownload = isZipDownloadSelection(selectedItems);', app_js)
|
||||||
self.assertIn('openZipDownloadModal(selectedItems);', app_js)
|
self.assertIn('openZipDownloadModal(selectedItems);', app_js)
|
||||||
self.assertIn('statusText: "preparing"', app_js)
|
self.assertIn('targetText: "Preparing download..."', app_js)
|
||||||
self.assertIn('statusText: "packaging items"', app_js)
|
self.assertIn('statusText: "Preparing download..."', app_js)
|
||||||
self.assertIn('statusText: "ready"', app_js)
|
self.assertIn('countText: "Preparing zip download"', app_js)
|
||||||
self.assertIn('statusText: `failed: ${err.message}`', app_js)
|
self.assertIn('countText: "Zip preflight and packaging"', app_js)
|
||||||
self.assertIn('countText: "Step 1/3"', app_js)
|
self.assertIn('statusText: "Download started"', app_js)
|
||||||
self.assertIn('countText: "Step 2/3"', app_js)
|
self.assertIn('countText: "Browser download started"', app_js)
|
||||||
self.assertIn('countText: "Step 3/3"', 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 applyContextMenuSelection()', app_js)
|
||||||
self.assertIn('function startContextMenuOpen()', app_js)
|
self.assertIn('function startContextMenuOpen()', app_js)
|
||||||
self.assertIn('function startContextMenuEdit()', 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('settings.interfaceSaveButton.onclick = handleInterfaceSave;', app_js)
|
||||||
self.assertIn('preferredStartupPathLeft', app_js)
|
self.assertIn('preferredStartupPathLeft', app_js)
|
||||||
self.assertIn('preferredStartupPathRight', 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_theme', app_js)
|
||||||
self.assertIn('selected_color_mode', app_js)
|
self.assertIn('selected_color_mode', app_js)
|
||||||
self.assertNotIn("localStorage", 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('paneState("right").currentPath = settingsState.preferredStartupPathRight || "/Volumes";', app_js)
|
||||||
self.assertIn('applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode);', app_js)
|
self.assertIn('applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode);', app_js)
|
||||||
self.assertIn('settings.interfaceTab.onclick = () => setSettingsTab("interface");', 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('"/api/settings"', app_js)
|
||||||
self.assertIn('function uploadElements()', app_js)
|
self.assertIn('function uploadElements()', app_js)
|
||||||
self.assertIn('function openUploadPicker()', app_js)
|
self.assertIn('function openUploadPicker()', app_js)
|
||||||
|
|||||||
+103
-18
@@ -85,6 +85,7 @@ let downloadProgressState = {
|
|||||||
active: false,
|
active: false,
|
||||||
archiveLabel: "",
|
archiveLabel: "",
|
||||||
totalItems: 0,
|
totalItems: 0,
|
||||||
|
requestKey: null,
|
||||||
};
|
};
|
||||||
let folderUploadPlanState = {
|
let folderUploadPlanState = {
|
||||||
targetPane: "left",
|
targetPane: "left",
|
||||||
@@ -103,6 +104,7 @@ let settingsState = {
|
|||||||
preferredStartupPathRight: null,
|
preferredStartupPathRight: null,
|
||||||
selectedTheme: "default",
|
selectedTheme: "default",
|
||||||
selectedColorMode: "dark",
|
selectedColorMode: "dark",
|
||||||
|
zipDownloadLimits: null,
|
||||||
};
|
};
|
||||||
const VALID_THEME_FAMILIES = [
|
const VALID_THEME_FAMILIES = [
|
||||||
"default",
|
"default",
|
||||||
@@ -368,6 +370,10 @@ function isZipDownloadSelection(items) {
|
|||||||
return items.length > 1 || (items.length === 1 && items[0].kind === "directory");
|
return items.length > 1 || (items.length === 1 && items[0].kind === "directory");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function zipDownloadRequestKey(paths) {
|
||||||
|
return paths.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
function selectedItemCountLabel(totalItems) {
|
function selectedItemCountLabel(totalItems) {
|
||||||
return `${totalItems} selected item${totalItems === 1 ? "" : "s"}`;
|
return `${totalItems} selected item${totalItems === 1 ? "" : "s"}`;
|
||||||
}
|
}
|
||||||
@@ -399,17 +405,19 @@ function updateDownloadModalDisplay(info) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openZipDownloadModal(selectedItems) {
|
function openZipDownloadModal(selectedItems) {
|
||||||
|
const requestPaths = selectedItems.map((item) => item.path);
|
||||||
downloadProgressState.active = true;
|
downloadProgressState.active = true;
|
||||||
downloadProgressState.archiveLabel = "ZIP archive";
|
downloadProgressState.archiveLabel = "ZIP archive";
|
||||||
downloadProgressState.totalItems = selectedItems.length;
|
downloadProgressState.totalItems = selectedItems.length;
|
||||||
|
downloadProgressState.requestKey = zipDownloadRequestKey(requestPaths);
|
||||||
setDownloadModalVisible(true);
|
setDownloadModalVisible(true);
|
||||||
updateDownloadModalDisplay({
|
updateDownloadModalDisplay({
|
||||||
active: true,
|
active: true,
|
||||||
targetText: "Preparing ZIP download",
|
targetText: "Preparing download...",
|
||||||
currentFileText: `Selection: ${selectedItemCountLabel(selectedItems.length)}`,
|
currentFileText: `Selection: ${selectedItemCountLabel(selectedItems.length)}`,
|
||||||
countText: "Step 1/3",
|
countText: "Preparing zip download",
|
||||||
statusText: "preparing",
|
statusText: "Preparing download...",
|
||||||
percent: 33,
|
percent: 20,
|
||||||
});
|
});
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (!downloadProgressState.active) {
|
if (!downloadProgressState.active) {
|
||||||
@@ -417,11 +425,11 @@ function openZipDownloadModal(selectedItems) {
|
|||||||
}
|
}
|
||||||
updateDownloadModalDisplay({
|
updateDownloadModalDisplay({
|
||||||
active: true,
|
active: true,
|
||||||
targetText: "Preparing ZIP download",
|
targetText: "Preparing download...",
|
||||||
currentFileText: `Packaging ${selectedItemCountLabel(downloadProgressState.totalItems)}`,
|
currentFileText: `Packaging ${selectedItemCountLabel(downloadProgressState.totalItems)}`,
|
||||||
countText: "Step 2/3",
|
countText: "Zip preflight and packaging",
|
||||||
statusText: "packaging items",
|
statusText: "Preparing download...",
|
||||||
percent: 66,
|
percent: 55,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -431,24 +439,24 @@ function markZipDownloadReady(fileName) {
|
|||||||
downloadProgressState.archiveLabel = fileName || "ZIP archive";
|
downloadProgressState.archiveLabel = fileName || "ZIP archive";
|
||||||
updateDownloadModalDisplay({
|
updateDownloadModalDisplay({
|
||||||
active: false,
|
active: false,
|
||||||
targetText: `Ready: ${downloadProgressState.archiveLabel}`,
|
targetText: `Download started: ${downloadProgressState.archiveLabel}`,
|
||||||
currentFileText: `Prepared ${selectedItemCountLabel(downloadProgressState.totalItems)}`,
|
currentFileText: `Prepared ${selectedItemCountLabel(downloadProgressState.totalItems)}`,
|
||||||
countText: "Step 3/3",
|
countText: "Browser download started",
|
||||||
statusText: "ready",
|
statusText: "Download started",
|
||||||
percent: 100,
|
percent: 100,
|
||||||
});
|
});
|
||||||
window.setTimeout(closeDownloadModal, 240);
|
window.setTimeout(closeDownloadModal, 480);
|
||||||
}
|
}
|
||||||
|
|
||||||
function markZipDownloadFailed(err) {
|
function markZipDownloadFailed(err) {
|
||||||
downloadProgressState.active = false;
|
downloadProgressState.active = false;
|
||||||
updateDownloadModalDisplay({
|
updateDownloadModalDisplay({
|
||||||
active: false,
|
active: false,
|
||||||
targetText: "Preparing ZIP download",
|
targetText: "Preparing download...",
|
||||||
currentFileText: `Selection: ${selectedItemCountLabel(downloadProgressState.totalItems)}`,
|
currentFileText: `Selection: ${selectedItemCountLabel(downloadProgressState.totalItems)}`,
|
||||||
countText: "Step 2/3",
|
countText: "Zip download failed",
|
||||||
statusText: `failed: ${err.message}`,
|
statusText: err.message || "Download failed",
|
||||||
percent: 66,
|
percent: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,6 +466,7 @@ function closeDownloadModal() {
|
|||||||
}
|
}
|
||||||
downloadProgressState.archiveLabel = "";
|
downloadProgressState.archiveLabel = "";
|
||||||
downloadProgressState.totalItems = 0;
|
downloadProgressState.totalItems = 0;
|
||||||
|
downloadProgressState.requestKey = null;
|
||||||
updateDownloadModalDisplay({
|
updateDownloadModalDisplay({
|
||||||
active: false,
|
active: false,
|
||||||
targetText: "",
|
targetText: "",
|
||||||
@@ -622,12 +631,19 @@ async function startDownloadSelected() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const zipDownload = isZipDownloadSelection(selectedItems);
|
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) {
|
if (zipDownload) {
|
||||||
openZipDownloadModal(selectedItems);
|
openZipDownloadModal(selectedItems);
|
||||||
|
setStatus("Preparing download...");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const selected = selectedItems[0];
|
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 url = URL.createObjectURL(blob);
|
||||||
const anchor = document.createElement("a");
|
const anchor = document.createElement("a");
|
||||||
anchor.href = url;
|
anchor.href = url;
|
||||||
@@ -680,9 +696,11 @@ function settingsElements() {
|
|||||||
closeButton: document.getElementById("settings-close-btn"),
|
closeButton: document.getElementById("settings-close-btn"),
|
||||||
generalTab: document.getElementById("settings-general-tab"),
|
generalTab: document.getElementById("settings-general-tab"),
|
||||||
interfaceTab: document.getElementById("settings-interface-tab"),
|
interfaceTab: document.getElementById("settings-interface-tab"),
|
||||||
|
downloadsTab: document.getElementById("settings-downloads-tab"),
|
||||||
logsTab: document.getElementById("settings-logs-tab"),
|
logsTab: document.getElementById("settings-logs-tab"),
|
||||||
generalPanel: document.getElementById("settings-general-panel"),
|
generalPanel: document.getElementById("settings-general-panel"),
|
||||||
interfacePanel: document.getElementById("settings-interface-panel"),
|
interfacePanel: document.getElementById("settings-interface-panel"),
|
||||||
|
downloadsPanel: document.getElementById("settings-downloads-panel"),
|
||||||
showThumbnailsInput: document.getElementById("settings-show-thumbnails"),
|
showThumbnailsInput: document.getElementById("settings-show-thumbnails"),
|
||||||
startupPathLeftInput: document.getElementById("settings-startup-path-left"),
|
startupPathLeftInput: document.getElementById("settings-startup-path-left"),
|
||||||
startupPathRightInput: document.getElementById("settings-startup-path-right"),
|
startupPathRightInput: document.getElementById("settings-startup-path-right"),
|
||||||
@@ -691,6 +709,11 @@ function settingsElements() {
|
|||||||
selectedThemeInput: document.getElementById("settings-selected-theme"),
|
selectedThemeInput: document.getElementById("settings-selected-theme"),
|
||||||
interfaceError: document.getElementById("settings-interface-error"),
|
interfaceError: document.getElementById("settings-interface-error"),
|
||||||
interfaceSaveButton: document.getElementById("settings-interface-save-btn"),
|
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"),
|
logsPanel: document.getElementById("settings-logs-panel"),
|
||||||
logsList: document.getElementById("settings-logs-list"),
|
logsList: document.getElementById("settings-logs-list"),
|
||||||
logsError: document.getElementById("settings-logs-error"),
|
logsError: document.getElementById("settings-logs-error"),
|
||||||
@@ -1661,6 +1684,7 @@ async function loadSettings() {
|
|||||||
settingsState.preferredStartupPathRight = data.preferred_startup_path_right || null;
|
settingsState.preferredStartupPathRight = data.preferred_startup_path_right || null;
|
||||||
settingsState.selectedTheme = VALID_THEME_FAMILIES.includes(data.selected_theme) ? data.selected_theme : "default";
|
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.selectedColorMode = VALID_COLOR_MODES.includes(data.selected_color_mode) ? data.selected_color_mode : "dark";
|
||||||
|
settingsState.zipDownloadLimits = data.zip_download_limits || null;
|
||||||
const elements = settingsElements();
|
const elements = settingsElements();
|
||||||
if (elements.showThumbnailsInput) {
|
if (elements.showThumbnailsInput) {
|
||||||
elements.showThumbnailsInput.checked = settingsState.showThumbnails;
|
elements.showThumbnailsInput.checked = settingsState.showThumbnails;
|
||||||
@@ -1674,6 +1698,7 @@ async function loadSettings() {
|
|||||||
if (elements.selectedThemeInput) {
|
if (elements.selectedThemeInput) {
|
||||||
elements.selectedThemeInput.value = settingsState.selectedTheme;
|
elements.selectedThemeInput.value = settingsState.selectedTheme;
|
||||||
}
|
}
|
||||||
|
renderDownloadSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSettings(update) {
|
async function saveSettings(update) {
|
||||||
@@ -1683,6 +1708,7 @@ async function saveSettings(update) {
|
|||||||
settingsState.preferredStartupPathRight = data.preferred_startup_path_right || null;
|
settingsState.preferredStartupPathRight = data.preferred_startup_path_right || null;
|
||||||
settingsState.selectedTheme = VALID_THEME_FAMILIES.includes(data.selected_theme) ? data.selected_theme : "default";
|
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.selectedColorMode = VALID_COLOR_MODES.includes(data.selected_color_mode) ? data.selected_color_mode : "dark";
|
||||||
|
settingsState.zipDownloadLimits = data.zip_download_limits || null;
|
||||||
const elements = settingsElements();
|
const elements = settingsElements();
|
||||||
if (elements.showThumbnailsInput) {
|
if (elements.showThumbnailsInput) {
|
||||||
elements.showThumbnailsInput.checked = settingsState.showThumbnails;
|
elements.showThumbnailsInput.checked = settingsState.showThumbnails;
|
||||||
@@ -1696,6 +1722,7 @@ async function saveSettings(update) {
|
|||||||
if (elements.selectedThemeInput) {
|
if (elements.selectedThemeInput) {
|
||||||
elements.selectedThemeInput.value = settingsState.selectedTheme;
|
elements.selectedThemeInput.value = settingsState.selectedTheme;
|
||||||
}
|
}
|
||||||
|
renderDownloadSettings();
|
||||||
applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode);
|
applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode);
|
||||||
renderPaneItems("left");
|
renderPaneItems("left");
|
||||||
renderPaneItems("right");
|
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));
|
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) {
|
function monacoLanguageForName(name) {
|
||||||
const lower = (name || "").toLowerCase();
|
const lower = (name || "").toLowerCase();
|
||||||
if (lower === "dockerfile" || lower === "containerfile") {
|
if (lower === "dockerfile" || lower === "containerfile") {
|
||||||
@@ -3302,18 +3380,22 @@ async function submitSearch() {
|
|||||||
|
|
||||||
function setSettingsTab(tab) {
|
function setSettingsTab(tab) {
|
||||||
const elements = settingsElements();
|
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 isGeneral = settingsState.activeTab === "general";
|
||||||
const isInterface = settingsState.activeTab === "interface";
|
const isInterface = settingsState.activeTab === "interface";
|
||||||
|
const isDownloads = settingsState.activeTab === "downloads";
|
||||||
const isLogs = settingsState.activeTab === "logs";
|
const isLogs = settingsState.activeTab === "logs";
|
||||||
elements.generalTab.classList.toggle("is-active", isGeneral);
|
elements.generalTab.classList.toggle("is-active", isGeneral);
|
||||||
elements.generalTab.setAttribute("aria-selected", isGeneral ? "true" : "false");
|
elements.generalTab.setAttribute("aria-selected", isGeneral ? "true" : "false");
|
||||||
elements.interfaceTab.classList.toggle("is-active", isInterface);
|
elements.interfaceTab.classList.toggle("is-active", isInterface);
|
||||||
elements.interfaceTab.setAttribute("aria-selected", isInterface ? "true" : "false");
|
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.classList.toggle("is-active", isLogs);
|
||||||
elements.logsTab.setAttribute("aria-selected", isLogs ? "true" : "false");
|
elements.logsTab.setAttribute("aria-selected", isLogs ? "true" : "false");
|
||||||
elements.generalPanel.classList.toggle("hidden", !isGeneral);
|
elements.generalPanel.classList.toggle("hidden", !isGeneral);
|
||||||
elements.interfacePanel.classList.toggle("hidden", !isInterface);
|
elements.interfacePanel.classList.toggle("hidden", !isInterface);
|
||||||
|
elements.downloadsPanel.classList.toggle("hidden", !isDownloads);
|
||||||
elements.logsPanel.classList.toggle("hidden", !isLogs);
|
elements.logsPanel.classList.toggle("hidden", !isLogs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3430,6 +3512,8 @@ async function openSettings(tab = "general") {
|
|||||||
}
|
}
|
||||||
(settingsState.activeTab === "logs"
|
(settingsState.activeTab === "logs"
|
||||||
? elements.logsTab
|
? elements.logsTab
|
||||||
|
: settingsState.activeTab === "downloads"
|
||||||
|
? elements.downloadsTab
|
||||||
: settingsState.activeTab === "interface"
|
: settingsState.activeTab === "interface"
|
||||||
? elements.interfaceTab
|
? elements.interfaceTab
|
||||||
: elements.generalTab).focus();
|
: elements.generalTab).focus();
|
||||||
@@ -4107,6 +4191,7 @@ function setupEvents() {
|
|||||||
settings.closeButton.onclick = closeSettings;
|
settings.closeButton.onclick = closeSettings;
|
||||||
settings.generalTab.onclick = () => setSettingsTab("general");
|
settings.generalTab.onclick = () => setSettingsTab("general");
|
||||||
settings.interfaceTab.onclick = () => setSettingsTab("interface");
|
settings.interfaceTab.onclick = () => setSettingsTab("interface");
|
||||||
|
settings.downloadsTab.onclick = () => setSettingsTab("downloads");
|
||||||
settings.logsTab.onclick = async () => {
|
settings.logsTab.onclick = async () => {
|
||||||
setSettingsTab("logs");
|
setSettingsTab("logs");
|
||||||
await loadHistoryForSettings();
|
await loadHistoryForSettings();
|
||||||
|
|||||||
@@ -1029,6 +1029,34 @@ button:disabled {
|
|||||||
min-height: 180px;
|
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 {
|
.settings-placeholder-title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -155,6 +155,7 @@
|
|||||||
<div class="settings-tabs" role="tablist" aria-label="Settings tabs">
|
<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-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-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>
|
<button id="settings-logs-tab" class="settings-tab" type="button" role="tab" aria-selected="false">Logs</button>
|
||||||
</div>
|
</div>
|
||||||
<section id="settings-general-panel" class="settings-panel" role="tabpanel" aria-labelledby="settings-general-tab">
|
<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>
|
<button id="settings-interface-save-btn" type="button">Save</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<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-error" class="error"></div>
|
||||||
<div id="settings-logs-list" class="settings-log-list"></div>
|
<div id="settings-logs-list" class="settings-log-list"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user