From 8ea2bd1498539838c18dc9ff51c70091402fccdd Mon Sep 17 00:00:00 2001 From: kodi Date: Sat, 14 Mar 2026 13:38:44 +0100 Subject: [PATCH] feat: download - download dwnload limieten in settings --- .../api/__pycache__/schemas.cpython-313.pyc | Bin 9568 -> 9984 bytes webui/backend/app/api/schemas.py | 9 ++ .../settings_service.cpython-313.pyc | Bin 6224 -> 6704 bytes .../backend/app/services/settings_service.py | 10 +- webui/backend/data/tasks.db | Bin 200704 -> 200704 bytes .../test_api_settings_golden.cpython-313.pyc | Bin 14572 -> 16517 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 37403 -> 39191 bytes .../tests/golden/test_api_settings_golden.py | 26 ++++ .../tests/golden/test_ui_smoke_golden.py | 33 ++++- webui/html/app.js | 121 +++++++++++++++--- webui/html/base.css | 28 ++++ webui/html/index.html | 27 ++++ 12 files changed, 228 insertions(+), 26 deletions(-) diff --git a/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc b/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc index d68e0a4947d62de301c3e9f45349bcf90fe2bb94..90d63395bb5c4250b78fa8bca19d785517a52c38 100644 GIT binary patch delta 1646 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 delta 1380 zcmZuxO>7%g5PolMuh;9fW9P@UouArGoo4gXI#MEd2GR@%?wnfbmM z&%EbvM)&g3$B~H2#4{SLSHFnwMw5Jf_uSwM8D=oUKO4NJfa2MK+0Zo=)C?P8hWs{% zun{zr5ec*;JHw1nni=YtL_&-~WG$>~yI*FxwEMk|D(|fq`5Ko`iP)+SAH>@6M`=AU ze4)Ixg_nKvJc%Fs`ooF{`xxVof8q7-d_Qx=2#p8MGF`)H`kyS<64)|?9pv0y;H7LD`m$l zIqT+z={4=PKk=?wH491`R>cHesOb`Pl4uajJKLu1*fiTr26}Ku8{>oct@bz1*1wJ< zd6fiw`$Yv)&ceB$y#G z4Y*l_9OtnX>*fXgD3%xGd+`)Lj1_W85z@IQ@YNBs@HBG(^R00zbhsEsP7Vdm3*f z(z3U}o2Yk8iDs%DpUAHf<8}P6V`%t>Fm?Zvc3_EOCre(!rG(CJ-KF;|*JM%jm{FTk_vn%^-?2)X-q#f-b!4h5fm-pIpdo2E!e3SF^BSz?{{txV6 BAs+w$ 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 6a2df30a80048e97369fc5f63c73169d46b887b5..b66e45806ce957cba027654564748f819d930f8e 100644 GIT binary patch delta 281 zcmZozz|*jRXM!|i|3n#Q#{R~H)&$0_2~11u+4>kQ7@5y+=Wt+>W@PVTDP(!aa&`J$ zXD0b+u1sv(109*vm>8oc8w!L^f9cF5z-44*WNu|-8O10(J5n`CJr0sD8{tu z>GD_67urNt8Og6Hx zFgLek6y6S0TT;ix%rlpPZw+q&&)m(10{uKDjs8u-JfV{E3=EC23=F2G#27aH;$$YX m=^H*WsZG!1V^)`exz@$D|5UU79%2$et_sgPnlRF3Rr5#-r&;hP`8ZLb%3)o=LF|q&IQiNoEw~rxpHvs z@E8u{0b9;)R)F&`=LJ{6TqQU!b3SmD%vFK&G3N(Y#asZKpSd8o0CUygg3N`$RWmnB z4CTeLP85M|mcRAt@POBS*u>lWg6!qK4y|CkS+=>R7Z(IQ$!o5+hojBnziWnIcI zR*p-Emo&6}9ra@8exos*nEhk|}5t|kbBT>FZy_8hjY<<>apP)o#G78B@{ z=YAs4SjA9vuIe@zM|nD4(PYuP=;?|r!b3QsL?805^iMRmTxec#u6e~b&5vI#FDv)_ z&O1v7TbDiIZ6Rdsn>zzkYIIngFxromosrehiM|G8nJ%L3Mp+9Ib~H4 zlrH?os?S3oK-oxN^nEDcZG+NUxx?P!Rnlavk3=y$402oq^p#0<3Y0o(XIW-4fIk?{qjPgYhyoKs{3oW~$N^z*p6SoXotVaJA{Ms=a;ne%So11jQ02uzZ72!KQydiXL@hH4~bX4IF|GMR*$lJy00T27j}lDr$&$;N}i zC&;yru4@|jC$0~u*6759VC^p}O{wF~DPbZxK3#~Yu)vC!x^?c0!YM@AOULFvN=IuN zZPn-PkKuZcmaKOjcj+VnnYnPf*dBTs_Rx1qW^b&7i|@+s1~;}jy@&uhG4SPuWyX$x z#c;adp<$Ye+Hb&_lS~h%WJA(rI0!T~J#yQl;c;=gLFb^dTualgSg`fqH9d_0t6&3P zOgq}`RB9-@P5iGF!u98cVs zxy}6bAWTZ3%bag8VQ!MlkwIC4gSQXPT61OuZ`;H4zSbUc9!3dgKWYt-SfP8iDZPa# z6s5qpAB)qBR~L+v95QB)#X_Vgm-Y1Pcv#3cqQAv!h3N*~w!`FN_C#BpEGr#q)IF?j zR^fz?r?jM@A1WC$=Tp=ziL{cEbd7zOC<$W*Va}o~`I#Y1E(#U*%JwVojeIhSVZahn z_Ls)vVe#PSPMP6oqpn8kd zhIiaHzN<)AGtV^p{gO3=9Hnhb8_DOidud;=$4XX_cUN4fS#Yjq!4$`elh4z$OKbiH D4POQZ delta 1889 zcma)+ZA@EL7{`0s-rG0&1}#)5(D5=sMlT2qa16$_Y{15w7xaTJP)mWrXyF!y!zmCQ z%&9Tq3DcP+f+jkR@g+5WAThEpCceZ3%`_qUafzBqhO2%wKF?{PFf!x)@bB-O_B{Xp zbNaLwXW8vB%U!eCsKVz5OEj2X_=&}&p8gS+i>fD83X@feT4sty&XCoasv)hSMbdB* zWS9JaED#SdMZ|rP@9U6sob*U~P6i|cCnJ)PlL^VhDFrEolNrgJ*+WJaL<`@vB3U`v zkZhdnNOn%CNU5CCkkU9gkQ|(xNKQ`aa=MZ+pe@c^cS5<<8GT4|OP$prO@@`>$f(aB zkR}69jrc;ofq+sva&AlxV9#N?Utz<21cYike3M-TE7|sX z9!Ke=n*45f07?`Oz@v1~l3!OR5x=uUitjQ(JcpZ+Ww6#b;X>BSnw$D2X!K@rziSIs zB3k~k$A;HU+HlwZuo=x+6sO}hrrhORv`|WL>1QZgj}AejQn9=5W21S|RL_g79lJBP z?dXnQwR*=blkt?suYRX5 zwYIJw3HF781B2snuRVA9PT_g+Dit4u!u^ByElzj2r}$K=KRklJr9S*A1d6OD!E0q2VJbBm)_#1`RrID<6#B=!OA7CR|<^#e*>J!L?t*`ot^YQwoqe$&#Gni zB^Jw!7SuRdQL-3CnM#=m4Tr*$A*nCyk0`H@Q=$`J?RF>AwNdsh&(rdVi|uNC0S%L=qwy zBbgw1mSmP>p5!vgt0X}38hmlM=JY7yoGPZa3SBIgrxrYa>-9o@%wZOcu^bypK`zQs zrW2Z3EH$oH;yi)WvZXD^(KGeKjJ*M0HRQ98;g5#?^rITq%3g2!$z8hUE{&=9@=gl` HkGTH@QoP2w 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 618d0ab9efb42dca2afb78130399de13613121e8..18ff6c443f1cb818ebeeefe5b8e5868a2bf4ea24 100644 GIT binary patch delta 2047 zcmah}eQXnD81LJU?KajAvTkk1WVd#6Ev^MkW#SlPAFvQW2W>a9z_z1(+a9#na(B(x zg*bskKq8F2B%s8v#HjFxiWiebV+el`{m*V`GD9$$2r3|v2#SJC-+S90<11crzvulu z&+ne+z31+IPQ0f*_Nmr%!)Pqm;H$mwGj_Fb+T_uJXV6C7=UR(RHtAa|Xp6y$u69_E z(P2gAPK*4S^|%hLvA;tCckOw>;njJ&%8|D(w&pGCFrop24c#fk>w1VEAoPO4g<_Z~ zX%80c$9W5mp;@~N-Rd#w7i$8qBgwH3ydi6A&X%J0>rG{m)CiYIF%d!jvc5_?_5>Sg za|%!tSuQ4c)u1=ca4_MN=tqXVNoJ>)6=70vN{x?Kh$)dtcm-C1PO0J1vN0u7luf{b z%=KuQ5N0^9$R=SbBR;mIFq%xT+;(p|m0-i8PN`>LO)p)1BRat*Sy7-F9@0V__kfP3 zc$#m3QOH9s3~7do(6|bh66vjw4#ycT1|xJFM}6{?>xK>-mCrR@GN2C^o8?p9M@7*Qj~yj^W%8`= z3NT!oSHtPhwIw#~*KTCeH^_^gt~aB%{Z=%zau;gvvZCKs`U!98AYA^cV<%7?U<8f( zUGhlR5+TlpY^X%x;9u0+=Pc;~0ZoX)4q91)P+4)Q4V5cPJ@)F*4|%0NR8jO9kh{OR0tc1H zO7Tshf&K0Gl48(>#nO*jQX7|9P4T zZ)ak715Rn-eMO6JL1Qm3!U#TVb5nKlsSyG4YNdNJdaevtyeEk=Y+_8w)I7Hm@^sZgBQ!T8>74_oc!R_T!F&--exH-_rtL>%R+S=%Q6n77biY6ntA8IhgM3fGD zT4mc{%e=AqbH3L)G8WEPV%JcyvE`Qj!6g>mWjNVF=!<0Q$5oh--j zqX9hf+*?OQD0rm02NVJ552v_@;1(dU9;x9$MShO;k}>AaPcx6ao;?dHJByRrSKOJl{*-gbZ5(NItv)Fvu{z77U^d z;|strN{)M|q}I@KYtYb--Wva=F^s!&UY}%GjvvNLzFL7w#YrtsrXraHT)~f^i6i~C a^`&4HI8M#jJyUkiO%0*fhfNa`3;qMM9MPQs delta 1173 zcmZvae@qi+7{~AJwe(hKuM7P}+hHvNvek7CCnyeMK+Tk)SdJkw?5#a&gDv&i`lH)m ze{2C_tl|?bfn?ES$^2{L-k2>W{@9W&nM+(DO<0Won2B>{QzK5y=5+7f6|yFKle^FR z{eGYKz4y7h%$KT(&s2svyhB zk^M0*)YBJq56l#Gg5szh2=1@8V~g5sgl#4htT!29iN*xinq}f4)5kOz8WpqHc1kX~ zY*UI`-%_Y8Q@E{6ul7>DQTWI{gpOmz@r@SWcC#vMt9nQy2QGejR)e2=*CFU#!NNTc z3tL`c!RcY~&6VGys=E#LzFKtvNv-6EhLENxG*@0-aNb{QD_9tBGC_IM2hdYzf-jm@ z$@*}qQPyYkY;0a7YX|8|s+0O7#Mhc%L-K;pN#oK+KN3m>_wyXsbVt^#Rax-q=CmGv z-7$dZUpO_i)h(rO$1wMGlom%!^ z)8EU7_X@$au4V5FqWEe=h{ws_`=LK&4)iCZ3FV3PZeK!(xmJ2>-37j4zyAMrEwOPj zP*;ro{Z|kU?wc*uUL^g+?-}v-Prg&Z)nOK{rdjyv2n%h)Zx(qcEjgwhT%$JlbA*M- zqb%5uEnMZ$Mal+P-vaTO4x#83=nQ?liot`u8+4`0#ID4`U?FN+Din`(Lj05#RI8(z2Dox#d ztEL6UGe1@G>*RpBHc 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 @@
+