From 76f5ed3e98493acbaf39471dfaa74ad0db965a08 Mon Sep 17 00:00:00 2001 From: kodi Date: Thu, 12 Mar 2026 11:45:56 +0100 Subject: [PATCH] feat: CMD-ENTER file info toegevoegd --- project_docs/FILE_INFO_V1_DESIGN.md | 170 ++++++++++++++++++ .../__pycache__/routes_files.cpython-313.pyc | Bin 3605 -> 3974 bytes .../api/__pycache__/schemas.cpython-313.pyc | Bin 7842 -> 8333 bytes webui/backend/app/api/routes_files.py | 10 +- webui/backend/app/api/schemas.py | 13 ++ .../filesystem_adapter.cpython-313.pyc | Bin 8562 -> 10113 bytes webui/backend/app/fs/filesystem_adapter.py | 27 +++ .../file_ops_service.cpython-313.pyc | Bin 17860 -> 18687 bytes .../backend/app/services/file_ops_service.py | 19 +- .../test_api_info_golden.cpython-313.pyc | Bin 0 -> 7977 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 10891 -> 11361 bytes .../tests/golden/test_api_info_golden.py | 121 +++++++++++++ .../tests/golden/test_ui_smoke_golden.py | 6 + webui/html/app.js | 80 +++++++++ webui/html/index.html | 9 + webui/html/style.css | 23 +++ 16 files changed, 476 insertions(+), 2 deletions(-) create mode 100644 project_docs/FILE_INFO_V1_DESIGN.md create mode 100644 webui/backend/tests/golden/__pycache__/test_api_info_golden.cpython-313.pyc create mode 100644 webui/backend/tests/golden/test_api_info_golden.py diff --git a/project_docs/FILE_INFO_V1_DESIGN.md b/project_docs/FILE_INFO_V1_DESIGN.md new file mode 100644 index 0000000..91aabf0 --- /dev/null +++ b/project_docs/FILE_INFO_V1_DESIGN.md @@ -0,0 +1,170 @@ +# File Info v1 Design + +## 1. Doel +File info voegt nu waarde toe omdat de huidige UI sterk gericht is op navigatie en acties, maar nog weinig context geeft over het geselecteerde item zelf. Een compacte read-only infomodal helpt bij controle vóór rename/move/delete, bij het onderscheiden van vergelijkbare items, en bij snelle inspectie van een directory of bestand zonder de workspace te verlaten. + +Dit past goed binnen de dual-pane workflow zolang het een lichte, tijdelijke overlay blijft en geen extra paneel of blijvende schermruimte vraagt. + +## 2. Startgedrag +Aanbevolen v1-gedrag: +- Mac: `Cmd+Enter` +- Windows/Linux: `Ctrl+Enter` +- Alleen actief bij exact 1 geselecteerd item +- Geen extra zichtbare knop in topbar of functiebalk in v1 + +Reden: +- De interface blijft rustig +- De feature blijft beschikbaar voor power users +- Het voorkomt nieuwe visuele drukte in de bestaande workspace + +Latere uitbreiding: +- Een functiebalkknop zoals `Info` of `F9` kan later logisch zijn, maar hoort niet in deze slice + +## 3. Scope +In scope voor v1: +- Exact 1 file +- Exact 1 directory +- Read-only modal +- Geen acties vanuit de modal +- Geen thumbnails +- Geen preview, edit of playback in deze modal + +Niet in scope: +- Multi-select info +- Bestandsinhoud tonen +- Directory tree analyse +- Checksums/hashes +- ACL/permissions-editor + +## 4. Minimale informatievelden +Aanbevolen minimale velden in v1: +- `name` +- `path` +- `type` +- `size` +- `modified` +- `root` +- `extension` voor files waar zinvol +- `content_type` voor files waar zinvol +- `owner` +- `group` + +Toelichting: +- `name`, `path`, `type`, `modified` en `root` zijn bijna altijd nuttig +- `size` is nuttig voor files; voor directories alleen als veilig/goedkoop beschikbaar +- `extension` is nuttig voor file-context +- `content_type` is nuttig als lichte afgeleide metadata, niet als zware contentanalyse +- `owner/group` is nuttig voor troubleshooting op mounted storage + +## 5. Directory-info +Veilige v1-richting voor directories: +- Toon: + - naam + - pad + - type = directory + - modified time + - root/context + - owner/group indien beschikbaar +- Toon niet standaard: + - recursieve grootte + - totale child count via diepe scan + +Aanbeveling: +- Geen recursieve directorygrootte in v1 +- Geen child count tenzij die goedkoop via directe listing kan worden opgehaald en duidelijk als shallow count wordt gelabeld + +Motivatie: +- Grote trees kunnen duur zijn +- Dit verhoogt regressierisico en latentie zonder kernwaarde voor v1 + +## 6. Backend-impact +Aanbevolen backendrichting: +- Nieuw read-only endpoint, bijvoorbeeld: + - `GET /api/files/info?path=...` + +Herbruik bestaande infrastructuur: +- `path_guard` voor alle padvalidatie +- bestaande whitelist/root containment +- bestaande not-found/type/security foutmapping +- bestaande filesystem-adapter voor `stat`-achtige metadata + +Veiligheidsmodel: +- Alleen metadata lezen +- Geen filesystem-mutatie +- Geen directory traversal buiten whitelist +- Geen volgen van escapes buiten toegestane roots + +Waarschijnlijk benodigde backendvelden in response: +- `name` +- `path` +- `type` +- `size` +- `modified` +- `root` +- `extension` +- `content_type` +- `owner` +- `group` + +## 7. Frontend-impact +Aanbevolen UI-richting: +- Aparte info-modal +- Geen hergebruik van de tekst-viewer of edit-modal +- Zelfde lichte modalstructuur als bestaande modals, voor consistentie + +Gedrag: +- Open via `Cmd+Enter` of `Ctrl+Enter` +- Sluiten via `X` en `Escape` +- Terwijl modal open is, geen paneelkeyboardnavigatie +- Geen interferentie met gewone `Enter` + +Samenwerking met bestaande openflows: +- Gewoon `Enter` blijft directory openen en video openen waar nu al afgesproken +- `Cmd/Ctrl+Enter` wordt exclusief voor File Info +- Daardoor blijft bestaand open-gedrag intact + +## 8. Regressierisico +Belangrijkste risico's: +- Keyboardconflict met bestaand `Enter` +- Focusconflict met bestaande modals +- Onbedoeld openen bij multi-select of lege selectie +- Verwarring met bestaande view/edit/video flows + +Laag-risico aanpak: +- Alleen reageren bij exact 1 selectie +- Alleen `Cmd/Ctrl+Enter`, niet gewone `Enter` +- Eigen modal-open check in de globale keyboard handler +- Geen wijziging aan bestaande browse/selectie/open-directory logica + +## 9. Teststrategie +Backend golden tests: +- file info success +- directory info success +- path not found +- traversal blocked +- invalid root alias +- file/directory response shape + +UI smoke/regressietests: +- info-modal container aanwezig +- keyboard wiring voor `Cmd/Ctrl+Enter` aanwezig +- geen extra zichtbare knop toegevoegd in functiebalk/topbar + +Handmatige validatie: +- Exact 1 file geselecteerd -> info opent +- Exact 1 directory geselecteerd -> info opent +- Multi-select -> niets doen +- Lege selectie -> niets doen +- `Escape` sluit modal +- Gewone `Enter` blijft bestaande open-semantiek houden + +## 10. Aanbeveling +Aanbevolen v1-richting met laag regressierisico: +- Nieuw read-only endpoint `GET /api/files/info?path=...` +- Aparte compacte info-modal +- Alleen openen via `Cmd+Enter` op Mac en `Ctrl+Enter` op Windows/Linux +- Alleen bij exact 1 geselecteerd item +- Directory-info beperkt houden tot goedkope metadata +- Geen zichtbare extra knop in deze fase + +Dit levert een bruikbare infofunctie op zonder de huidige dual-pane workflow, keyboardflow of bestaande modals onnodig te verstoren. diff --git a/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc b/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc index 4754228192668d7237c9f061634fdfc8ea72d889..0b536505af6ef7bd75b0a1638070cdda220164b5 100644 GIT binary patch delta 1598 zcmah}&1(}u6rb7MY`&VbP1>|g+hh}?b}bkS{jfGv1>4vPf{r&Aijr-KR8uBtrCt&h zq=KSlQ2Gb-(p#}7{{;_{BBCr7^d#OQ5v1V3d9zJvVx=4M=6%g?e)Hzdyy|%p^;dm9 z58~g%>o?iw?p1#$USBXr8C5DjU_kmHCriE9+o4x{7x z007i~#BKP9XH+tjI5OIXupJ%55mft;F!6@b9pw%l+M7zd_OXg}Xr#$#J_4+p_!_{% z`(X80e#3JFRu5@zYXIx!V596MZZ8I4GHGqtmsH!8J(6A$Xh7%%ggPd|AVI@>1f2*u zz(y2?M*aiVUMqA1y#r+jf+R{p4fLY_saA*4T1QV8tn1EEBb^Ok+FX|&Vv*DnQTGFma>#J@9VNX^CTqpaFLg}&}y{$z}?5HYNDm8aQ#tB06%afvwWj$ z(-Fdb?28hcA$Sxg(1<*XX@VyKHe~3MjV)jgZnhs#L7e9Qd?yz_PzhB$s44g&^)$71 zV+~4m%?E%&G{B<%yM2-&v26tu$Zgw65L2jo-+&7$u(WE*Xo7@SkQ7UP;=oH zH8Xi2&Ua*2+Hx#qGiHvCaOW_4?%Gu~uF)iW=8oD2lmr|4_}_A#122dP_R&366pMMm z2P@Jb7nVxR!Xmv#o4BE?RHXRoQs@|%{sVE>A#pdvU1C#~oG(xcCuAwNbGiB2YW$+u zSgkge#s``Lu}{a2!|S()Wyb|8^^i`4QVM0InGat9@UD+n)3zj=x+@$%Mq(=(Nnn&|!nl#f^tJ4-^rL`2R;G@!qxbP|n4u~aVNQq9lN$axc z!XoG*TwLhRr3*pq%7uSHD(GgQOK~eyDcyO_ofgxyoxsO;?>Xl?k9+dG_&OimjzmHP zKDPd;`oU;Mv-EcJ)V*PoFv2<(67xx(lw4ovo=@?VNo;k1umCfrC{NqzTczg5RA-ol zrW9KnAa=63nVQV9&Kz0Nmgod3!^gAe>GM`(VLN&tG}FT(ZJ|9FDrRYsf_7%DtIeDP z^JJ^0&soflqcV&8Wld23eq8Aztv}Yw5^ak4V-%C9A!oJUY41C(pG#q(696N}ygW`?;^aA>O#zU9icgodO8SjKk_6x_5##Z0CPY05MB?7JAo6yx(57NEJ1~7e5OU=8JG1DG>?k@;OT3lKs~kM zG_*$?#ESMxFrX>D+0o@W;Fp4wlZPOZb2bKaa;qi`_WF9z?q#oAT6p zgL531r!Bf}Z9m&xkda!k|Gpg8+0UvY!%@h>d`~&5y{e)|*t_7)DvDHiUAznT(zy5% z9$DM#%Uyw2_q1gfs*#H%_e4JUS(K7bhhvo!dQY~261h>HG5yFbuhg7N{3_hxzp4HQ s@V81S-68|ONa-6XZIRNBMrcLZ0hfyMSK=0#+NedYQ?VO;p diff --git a/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc b/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc index 15aee84e5a9a0c09fa58a894ee8be469ad9d3292..a9f14ee88c19298510fd8c09d0eda0a6d5ac0aa0 100644 GIT binary patch delta 1424 zcmZvc&u>#l5XZg2vE%31&%b}X_lyms3h9ADQ4a})lB7T_5lh5CTL?*<=3!Y*{E~go zZ9q{Qh(aQjMnn?<>QAYa$^o^4f+|iG9DC^{Qe?U6ACNOhl{i%0@5U`fswICsvoo_Z zJ3H%d4}aDld>RNSF7era^QTH8uog_Q&+m6HwP`8WZddV;<|@89vg3^%@Nl_!u6(%X z$b)-rXZ>uIj{0Axo2g*AZNy#lbqjM5k|HG!_Z0m?>uhOWsRdeEhtz^C%_lWgY9X$a zJw4%O=y+wy%-1ej!=^oB)ogR?H0{?@g~D^CQgd?ixq?+Q`75X?E6{Xv%W|G8w!>St zrq7&n%$i-XYJ3nL?bb&kqS;>NR(*zdilrVuol0w(+~h2Ff**eucn^rslR%V0YLE@l zxF>!nsV}^}^6G|LZv?}OLk%Ucm~R-lh5X9p4R@}Q-Lr7=hOq8wq>P1QD}5Voqa|&+ z4H{B&PzW75h>Uy+mH8- z@ef)vmxj&Rx@kLGSK%&8->#JoM0YB6ECN^Q{ix3BtrGXuZy*UY<;U4^mFW_CUb=mGY)(Vd;-YpSLPuC*Qqz&M=YtKaQY>l zWJH_s*OV)80EpCt#;$CqOBA!WVdw(nO~`}ogt`cnP%C|v_OaXaN1}_oT0adW6WTYz zcsVjvv7Pe+mQ|fD@oL^Nr!Vk}2#Bm87Jwfw3(${Rj($+OX)BrXe}~oWoO1t~?h9ef z_6mr3>F{4WL0{@Q`XKfFkvnjfWxYHqFZBpMvg#MtjCtV`bRylAmCYOfAD{nB!79uE z^4Y^;3gYx6eO&y3V8Gwe;_M-fYDp#!sI0*oct~Gs3AQHN`I}bCK7v73n}H;28iuqF zIE~|~>kt7r+Xh~3I2_w>$HZg3mfwCS>vG4{XqJB0ceBUzmu`sEy0KTeg=pY04H>!@ zQlV!TS;o|We4FwG%9)l!i4l{NA#a~AL6z6@DI|Gnc^FyLElOoF$```I#MdsQeU)XC8o_dO#A$TnqM_l>^q&d z6XKU&*~~Z`D-}WZ=tE7u%rbVN_Ttu;vMxLAxu#i?M%~Sh-!{YKvDsKcpGwm^cOC1c zR8`Q{Cbn}RNeAu*D!4RiQySO+Jyja2`w#>M#J68A@c2a09xhnI+#^HO?o;VN`BguR zB@%cC&XJ8z#X7e$`DLom@2)*hJ?6lwC8oYrxB3iN@{2&7&)7T6F}5(3W(Yqu7&GG z#apm=O9f`d83W!NQn7#Mw3#v{1~Vf(i8E|E0zi&-eHzncJEHz!Wls1Kya(aGSFZYR z_@t@8iH2}o%<8iD<$dU5LVaeCU zaPVHhAyDg}ZMUYCItu51*>Vi%ebCC*X`yO}<&VV1Lq7G5*pX9Y#Vw+2ozMJJFlAM% zf(!#HG3B9xB}5O@7`+Xp;v2Y4>JP)hy*n0j=5CtTNt5fkN$qNtOFjvJ9Y%;Ax_p!k zyXlOk(U0;deLZET?Qno?Q865{_lqQc1-Od-om++pY>Dr1ChgVVEY(GqXYpp~2@p@z z#;7%tZIXY`A>ojdEJsm}ksKC2i-3Gd`7n7bp8!(jGX0E%ZO_G;iO=YxNc1Cpk8b<{ Dsv-yD diff --git a/webui/backend/app/api/routes_files.py b/webui/backend/app/api/routes_files.py index 87befbf..a6799d6 100644 --- a/webui/backend/app/api/routes_files.py +++ b/webui/backend/app/api/routes_files.py @@ -3,7 +3,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends, Request from fastapi.responses import StreamingResponse -from backend.app.api.schemas import DeleteRequest, DeleteResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, ViewResponse +from backend.app.api.schemas import DeleteRequest, DeleteResponse, FileInfoResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, ViewResponse from backend.app.dependencies import get_file_ops_service from backend.app.services.file_ops_service import FileOpsService @@ -43,6 +43,14 @@ async def view( return service.view(path=path, for_edit=for_edit) +@router.get("/info", response_model=FileInfoResponse) +async def info( + path: str, + service: FileOpsService = Depends(get_file_ops_service), +) -> FileInfoResponse: + return service.info(path=path) + + @router.get("/video") async def video( path: str, diff --git a/webui/backend/app/api/schemas.py b/webui/backend/app/api/schemas.py index d011282..863cfeb 100644 --- a/webui/backend/app/api/schemas.py +++ b/webui/backend/app/api/schemas.py @@ -81,6 +81,19 @@ class SaveResponse(BaseModel): modified: str +class FileInfoResponse(BaseModel): + name: str + path: str + type: str + size: int | None = None + modified: str + root: str + extension: str | None = None + content_type: str | None = None + owner: str | None = None + group: str | None = None + + class TaskListItem(BaseModel): id: str operation: str diff --git a/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc b/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc index a49c9cd4b093910e0347ac05e1285a8f65c7fca0..5d90b54309baecfea28061eff84a357daae1f9b8 100644 GIT binary patch delta 3586 zcmb7GU2Gf25x%`U{*xkQQPh#t52ZMcDO*2o%`}!Qr;e=@wo|9!S%DEcVJYz_Uql}1 zy(45gb?F32A=NMx=W06+f+hyqI_g6mM=wS5(f|&MJhb&s3kjD54cZ_;+ZQ)B&>}66 z&g_YjtvU}qNZ-xw%r`r;JF~~nA9?=qrdxr4mxJe_v+t-cL{2wJOEcgcKr%SXShQgB{Bz|33zRh`55;s`VGqhlN@rZEHcR{N6-ssU2>c3WRhE!WEYb> zAi0_3m7}tUNj_kBndAq_$E1K9ljCv#4s9;na@=Wh5pqCRujt!GlCmX;5c&Y z&oFz2GhdP-X0XF8X|Ssr?5HMoa+wuES~QRFtpcBRnwO;(^Ao|vyt1_Ks{@weO+9;)EYWen*DrIWegQlH^LDli9q1a<#l2(|XNjho!$>Q5JNo_Zt zl3G$Q2;K5dRux@m5z!!Qp5;>YYypbh5>(Bw+%Db7Vp3+!v13H977FT7%TX*(D-@H~ zm14mXD>>sZ#e$^k5Lytz=!;}^7rYbfF`pCr>K@E9t7?U^75|@>>R(aff$CAhaVNN? zV9Q)>ws!Kl8F48To^dd^$qSz7N8z?}kG=BPo8gP`3-KG_zQu6rS~xW`deixp%KFUc zZ8sNRzZmPk9_xS0xKx|3-H2_Q4bJ#K2}|epoZhpP?7P%H-+rlczVk=BUKQW)pZEVE zeE%;Gd?Y0nrT*&@l8(KVIvl8O9DRM$PriRO_~0#$ zc%n;T>E!550i=IJuwSM7Lcb3->1^-r6!#wK9c|&Rq}oP9!uvsj*wQxIFTCGJ04Ly1 zYCH=U$glYhcF?pBCX>VxRk+i1*lI38w*Zwa5*{mf_=ZaJGVI=Pa zums(p6fPT%?qb8Dq9uJSDCXR99H}a_27RNJRKp5sWm+K_UD)m0LWUP) z-@9ZTOL_pj==h~zp?3#T9ci4w?t+GJ`{%r~-d9s^rDnX>-CO98sRwqidk~axoKC9X zO))Rfe)EGsH#q-$U?4h%bUZ_(H_ZiR120eg(cJ@7Pw;RTQs;R)0R?1QKyxe`*g)93 zUo~wa2hGo#dI+S3cggj$D zAL;_%7ebG;H`1tTMOB+xWBg@kAj0TanD$QiW__muuoQ6P%UlO7VY%JC&V}aDNFG;S zrNSnj^3$P(|1<}AAKUjyjPI}+36H?O9SCnClM5%p+xhwloX1MU4OZ~})uHTHD&F!K zw5m``L0a%_=?r3(r zW+>)xn?J@@JAgh;p;Dl^X}jIdn7OtVh_~7{OpJxgZR13K4J?xUYJf?H<(;O`hgkzY zI%@+vht}fi!t^Yzfpb-5AdC~lFbQBus)95zx^%H1iB4DK^9V|ioBq&pM znnFp{Sa!<|N(apE#0OgDzU#Cs#LSYygFedovdN)Rv1OKI9@In=_L@& z?#`b2ZV+s(jM5Vb>`>TYquH@Gqgp8{nWCy28T9$|<^=k9`p#9ELrdIW))ZKhl^wf2 z4zIXJSM(i#hMp}|X9i4MHMk=i=9gYZE^7w2?VAx+sXqk$vEW3mSj>$VmBjZF7>7+| zE0SZ5s!`~B2+nN)U9r&4qf}>UVrkf^vmoAv;a#Sze%PA#vLy6{wS=FdL+1KtE7HY= zbmzYL?I_WAveo=;*KmCgnz1`)93>T@gm46*icmv1j_@+V>j-Zm{2ajY60WCo`iU0rr delta 2221 zcmZuyU2GIp6rMXfyPfV|x83cw-Tv&h<;Srt)M$fJN)&;>76G{iL`l|dcS?uuPjhD0 z()zOy4S^;Enk$K-5BR`C_3}OqPztxA9hppA8uqxAmY^H0?#(F-y-B^7D=f z_jP2|KX3X{O}Sb*Uou^Pv9e%-!CcKkn$0jS1_LAFqu>OKi2K29wN>V-&eC6jq0!OZ zqoXc+$?!o0X|7(X6s#G`EVz1p%B~cvj%jFKNVBaa6S|QFJqSjt8?Y0L&prWI3qp{ zJ<-`uGtqQ5EilvgK}_i3G1e^hhJRx5x)J$DolO!mK`?@_wiIp7Di>s_FSyza?%F}4 z8KOvVgSnhln#bC6IagcA7w4dlk~0+Z*$zN}hL6Z$(JUJw7$(PvxE?!%8~9x;&0^xu z*kdeHA8NhJ;uLkY`Mfi0Q@3R@SmYmVV{Dh$5$|Skk&W+f3u6FJBmgEI(W(!I#jSWh z>U_A`C5F4gtr%ZrdEcNSq|8L8w1dse)dOrv&p z$G)DmWmL;Wt33A*`J3d<*;3uYc9iqK1Ys2G0|m5I=WK;Jyu|hS-t%sni~|=N$RBA9?4`SZD{MaQ&V-7I6+DxdZA zCrE06I6lZPc(6A}Hp+nu=m65NH3XUhyI>-{i&>(YK7hC4Zu%+qxZIUDh)PXq_~o+cewlOL;(aBn zEZ1!44RNu(e`kD=gde(cR{uDguix&kuu~nR*hP>cS>4-LDg*`y1_}HGTL`ui$g@#_ z_EOT<`->d3JhxALm`I4)V355gmIn`SjiYl_F#_^Nd#%5Nc-mB@QlwktRG}(sGgJp`D6%Dg)tMQ25;t|D;CE6E;(i!Uq^J7!>L8U-4^{A;qt+TPtf8_S{ApvPxQzkq~#bj@R}R z>j(iSI7RR>L5^UW;4Hxs!6kxaf_D(yFntxQ>5}QpRtnzvx94)6GnaQ9u%__4W9M@4 zF6B@!K#YL8Qi4v~Y}0G(lJc7>mbabklPn_OM+9H+Gga&m6D8<92_Pcd`?H(XTzwDT s{%X dict: + stat = path.stat() + owner = None + group = None + try: + owner = pwd.getpwuid(stat.st_uid).pw_name + except (KeyError, ImportError, AttributeError): + owner = None + try: + group = grp.getgrgid(stat.st_gid).gr_name + except (KeyError, ImportError, AttributeError): + group = None + + content_type, _ = mimetypes.guess_type(path.name) + return { + "name": path.name, + "size": int(stat.st_size) if path.is_file() else None, + "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat().replace("+00:00", "Z"), + "owner": owner, + "group": group, + "content_type": content_type, + "extension": path.suffix.lower() or None, + } + def list_directory(self, directory: Path, show_hidden: bool) -> tuple[list[dict], list[dict]]: directories: list[dict] = [] files: list[dict] = [] diff --git a/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc index 452d4042d645120ef20c5cb32c320565405575ac..6e4dec5c5d2e8040097dc07cda2f480110cca34e 100644 GIT binary patch delta 4078 zcma)9du&tJ8Nb)B_?6g><2)QN&I7_F;DkIN0TL3Lgc3^7I6${TXPNjU+{pNP=iY=Q zfXu2&rRp?|9<5?pTfyj1SvNuFuA8*2V$!sS_Q&RRTCyu`e@tDs{j;@e|Csj2*!P|5 z#D=16w*0%_{mysJ`JU%H{_zX){i|}#N7dC{2|gn)eOnD5yjs&Jzjd|avHYZ;`Qs9i zi1TpuWDTnkwCiwSGRT60t~wl=tYx)8yIB~1b;JW-FY&?GPpU}`36NmI!y-71{J%{ZqWzW!8+*3^nL6s^_$1>x*V%TUA$ZkkRnKKo zOG({W%22iiB{~qcBA^m%8-VG8JyPc=>xLJ_WvSIG26SznO;gjCrn;eKQ--Q#t$BSo zxu1V4G?*7woWS>!2x3`Ok_{jXBJ4rf3!pTx26&heMs+Q-K$CP)#g%6*3x{wl3MF4@A&*)0xVsDV>d?fXi|UBk;n8^N1q|2M}Bc`we#}Tm5%n&B= zlx9q5^V#$`V;W-zac~0RafCwvikD$1EW3^YWd=&hr<3TO1Jjf}A=sr@ttrlq;It_O z;f|v~>A3M)$l`G;XvYg%!iQYGilFf2rlDgP4Ym^j)12)>coN~$2%kZCl!t4DhuKp& zF5Fe6>d6_fltFeyd*uCmUv$G%*%ehNDC|xmRkqt%VYjdX4G^a2PN0MpmH{(`TT#_1 zQyz>r2P@0~spS~g5*zrF@#lgME6e{D-*!x7OB)E9{%I|1V6GcWIm!&&YaU?%;XJ}3 zfby_;OvRyc)Mxoz%U&p?TPUHjj%GY9hHe4BppK1PMp?^V?kbYhVypfD)FmI zPj~6*!6)$!dfO$%HziCpJ!zHEX+vX6rb8sax+_f&T?Ak2SkA&~r*=L|ndzKm+I$W& z9$h6e-gJm?STnKIOb636gFS}=IF(&NcpgCURAgQwFiekbq>Q8rZ9-%sbEBxJO%b%y zILT3thlqmdK1YpII%TAE2aKchMUt5gHJhK&%rF-1(VR{wTTrKI0$2Yh2C`q;T^AQD~X9>V&dKK!7J|f0-IJt zP1m**LoKVJ_)17AhLqKK*Ghb#7#~=P?=Qypuf#`-@zJZzllKuJ6_G^s%n)77LHp>FuM3*F`iIjHjiAGQmUMhN4ka2B{s_%|5D-$!#i;GsX4?No6{c(9z3y1GnF zC*(LYpdn||4E17P1Ng)-&#SwK<%z-*-8W^qg@3T~v6dSkc3VIhgMb$T#m1W80Y^EbDFd22HgY%?ghRzO;eW58dte333GuP|i#*2P z*wrRK%YV6R(o)o3sR-}f{mQ5A;r_%>dk?^Wx^Qhboo1xjx z@9v86&w5?*D|~oh!iD~smiYCaI{xaw?_EEGSN3!M`N7t%p91+n=x|fB;h}RXnbpoG zRb6|N|9bGC+){|{`H5{)Sy7QXPF(a30`3Myaevwq!+~cwpjX;6Wj2T80T3Z1-z3^W_ck$bn<3 zyx<}*tPNjUr^Eu{K&w;@o%Sy;I5~bXKBYmgL^0EmR;LZn9Pm!YTNDBT_hcv^|@`=h(^n^2$&;W)w!!ZQe8 zMEDBAHvvpP-VfAiX!vKfG#f@fb{=*UVGIFF(YmqU0?Lde;jau%fxl6Z?`P(r9Fob( zl0o@E7!jcre);rj?;gF8rBg$k7E-UnT7K0}Au$9(Vj21kBH%JWm>PX#Wxo_gxO-h<3O&^;FY4hl7n?6q3rEH6F?UUvf$2RBQ zq)90lejv2$u2$W{w!2*x5D0e5546P+KcGTFLS2a!KM~>zg!oY&5-)*-5E6p<&UIp^ zyJ%zO^W2#^b7$tv!~ea_e)2r4|3_V2$e_RJFMiLuFnznegS~ybcd8IGxWWCWA}8x) zou&h)>Q6Sv22IzTYCPE_n}`m|X8Ld8we$(`FnuDtj@R=B-Z)SzTQ$FFph`x0GjHLo zM74Doaf3%Y4c<0Vg%=x!Y`@lScKi{f$=q-e4f3r#sQI;I zql?#;_@T>TzKw_QGy-qvPezEhorgR3Rx;}}GqOpso>x_T>eN}^uILV4uT59UhLWsd zz^6XpYn|9`@EC6_2^+~=H*eZxu8DYY-n@xNIg~Bes?46^uY3i6_ipmf4KG+(%U%@e zMAEs>BzX6IavxmZt!~vGVQuP1wbxm@GDCY=P+ba*wQSMn@<}POP9mRl7G+$$6*{{8 z0Qf$!nzCz&imm_2Z7?=a42G`+7JG=MOR7s-c0Q9_OW4j@M#x>z_z<8M04JnL;0EX& z$t5B8(t{cB+#1pXJGU%T!VRZ|?O55QW972mxnUgMuYT5axS*q$$MXe%HVY@^LBJuv zVZc)aX1i>oFE=WMoy)9k)ijI3SxKt$&8h>DAa!ojtd$XSIcGMvoK4S2nUiuB!WqCZz;OaI zBykm9T<3^#8%oZn6G+d|l#r)1yL4CUj?1TU>@+~9V~!{rUA%r}v6y#hyg1u(A`q^) z6m@UQ_<7uh90cqKpw{I@z%zi40^Tpf%{s&K66BKvZjEIp7AR6l)zvw~Ce-E5uDNn5 zYM`j&{y3;|-2RHVbrc9dhhqDQ(ouK;h~lG8%g@qkFt&A57!Uh)sO8vmjZgZi>bv{S z>q3c>B{!1FWgV2cvz8aq#%mnFGGGO8gTQ<;GPwv*`N9{}m%2x(haPqBJ+l#p3_}U8 zF>A^Jxxs~;Oo+7Qco(!va@5L$0Xhm( zJ?z`bvg)^eX82R!<^XvW?H?+@buXqTvjD+Hh^EgoRc16jagpx{7w6XqTwg|H-GIoZ za%mxNf!z*vWGR=n)T92+LOC0#KQmZPgwCwnBv$hx<A5l2G#wNdw(r~(?_8#hIIrZ!zvpGg=+7I*eRhsqc&Y>M_SbhJ{ z^WGZ3)~J-@@om5Z0G5VXB|GSgdY&@w&;r1)H*39$)T`u!ZM@&+V^iwKhkKZ<9v?pG zIqIu)L>+nRji-xvf3?)JuMZCO1F#S%Dm~=21A1Cl1W9rmQ^u=xZ{*FYzEP9Cpyo$s zDbKHu{xk4H*m|h$j>VgQfD@HhI<4Lv>tbJ5e;7N)V#UGnM^!t@@7G0yi7LMVhyk{# z=;Uekrb2Tz*ayVpi6a6#R?}t9tU6md(^w2FghsbC}xj)ZiCWt>}R^EvRrd(z-6)FT%=HHBTenbqo#DjXzIZRZ+L@(akm=o znUMDtiV=>oZnqmbWhaF+8K@vN0~_QVLT zn60XR=0IUP6b1ksAON2Sya4zb0gWk#e!``egtM4S%W?3rujCzo?!sA6`WM-2M7hxf z{Zz#f`k72ceRY{?K9Q*G3+OikLO@)EeOCQ-W@oWuc92D4kU%hJcBuJdS@q7boh+jM zbgY-vsn+AWuIMWLEVT5RybtQzfbRmd;ff|Co*Nva-M5?(6Y}@;h{0%M7yZr082hym fe{6I-Hu`^KEdAVAQg@Djtl@fq_5a-f>Q4S2JedB* diff --git a/webui/backend/app/services/file_ops_service.py b/webui/backend/app/services/file_ops_service.py index fe354af..5b3a402 100644 --- a/webui/backend/app/services/file_ops_service.py +++ b/webui/backend/app/services/file_ops_service.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path from backend.app.api.errors import AppError -from backend.app.api.schemas import DeleteResponse, MkdirResponse, RenameResponse, SaveResponse, ViewResponse +from backend.app.api.schemas import DeleteResponse, FileInfoResponse, MkdirResponse, RenameResponse, SaveResponse, ViewResponse from backend.app.db.history_repository import HistoryRepository from backend.app.fs.filesystem_adapter import FilesystemAdapter from backend.app.security.path_guard import PathGuard @@ -245,6 +245,23 @@ class FileOpsService: content=preview["content"], ) + def info(self, path: str) -> FileInfoResponse: + resolved_target = self._path_guard.resolve_existing_path(path) + metadata = self._filesystem.stat_info(resolved_target.absolute) + + return FileInfoResponse( + name=metadata["name"], + path=resolved_target.relative, + type="directory" if resolved_target.absolute.is_dir() else "file", + size=metadata["size"], + modified=metadata["modified"], + root=resolved_target.alias, + extension=metadata["extension"], + content_type=metadata["content_type"], + owner=metadata["owner"], + group=metadata["group"], + ) + def save(self, path: str, content: str, expected_modified: str) -> SaveResponse: resolved_target = self._path_guard.resolve_existing_path(path) diff --git a/webui/backend/tests/golden/__pycache__/test_api_info_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_info_golden.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5cf52aded80bf306fb92f8eae2422ccdb4f50201 GIT binary patch literal 7977 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 delta 322 zcmaDD(H+Y7nU|M~0SH!4+LYNRvytx~Gt(c=&3r8G9FuRdsZ0*#QJFlKr)IJ(ugGLw z-cS~OhG6^2=Xl#09X5yYy<*%PC?L(WxmsuuBcuFgIT2H)$*0BmC;t@X-`pTp#yFWx zB3_nJD%g@GnGs|M6tDtmW(Eev&-d6S^RcLGUMAtrI=NQPicw{fyfULIh)@F&>Of-h zb~)b3A@Yi>n!#GZ+LNo~RXH_-b%M1mnex~s*RhJRgt7+fP7YvI5DH}r)-z>-SfRj> z&Y-Ek`HOrQ#?YW(F3iA~B$G09Nc& AM*si- diff --git a/webui/backend/tests/golden/test_api_info_golden.py b/webui/backend/tests/golden/test_api_info_golden.py new file mode 100644 index 0000000..9071e19 --- /dev/null +++ b/webui/backend/tests/golden/test_api_info_golden.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import asyncio +import sys +import tempfile +import unittest +from pathlib import Path + +import httpx + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from backend.app.dependencies import get_file_ops_service +from backend.app.fs.filesystem_adapter import FilesystemAdapter +from backend.app.main import app +from backend.app.security.path_guard import PathGuard +from backend.app.services.file_ops_service import FileOpsService + + +class FileInfoApiGoldenTest(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) / "root" + self.root.mkdir(parents=True, exist_ok=True) + self.service = FileOpsService(path_guard=PathGuard({"storage1": str(self.root)}), filesystem=FilesystemAdapter()) + + async def _override_file_ops_service() -> FileOpsService: + return self.service + + app.dependency_overrides[get_file_ops_service] = _override_file_ops_service + + def tearDown(self) -> None: + app.dependency_overrides.clear() + self.temp_dir.cleanup() + + def _request(self, path: str) -> httpx.Response: + async def _run() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + return await client.get("/api/files/info", params={"path": path}) + + return asyncio.run(_run()) + + def test_file_info_success(self) -> None: + file_path = self.root / "docs.txt" + file_path.write_text("hello", encoding="utf-8") + + response = self._request("storage1/docs.txt") + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["name"], "docs.txt") + self.assertEqual(payload["path"], "storage1/docs.txt") + self.assertEqual(payload["type"], "file") + self.assertEqual(payload["size"], 5) + self.assertEqual(payload["root"], "storage1") + self.assertEqual(payload["extension"], ".txt") + self.assertEqual(payload["content_type"], "text/plain") + self.assertIn("modified", payload) + self.assertIn("owner", payload) + self.assertIn("group", payload) + + def test_directory_info_success(self) -> None: + directory = self.root / "Media" + directory.mkdir() + + response = self._request("storage1/Media") + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["name"], "Media") + self.assertEqual(payload["path"], "storage1/Media") + self.assertEqual(payload["type"], "directory") + self.assertIsNone(payload["size"]) + self.assertEqual(payload["root"], "storage1") + self.assertIsNone(payload["extension"]) + + def test_info_path_not_found(self) -> None: + response = self._request("storage1/missing.txt") + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["error"]["code"], "path_not_found") + + def test_info_traversal_blocked(self) -> None: + response = self._request("storage1/../etc/passwd") + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error"]["code"], "path_traversal_detected") + + def test_info_invalid_root_alias(self) -> None: + response = self._request("unknown/item.txt") + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error"]["code"], "invalid_root_alias") + + def test_info_response_shape(self) -> None: + file_path = self.root / "movie.mp4" + file_path.write_bytes(b"012345") + + response = self._request("storage1/movie.mp4") + + self.assertEqual(response.status_code, 200) + self.assertEqual( + set(response.json().keys()), + { + "name", + "path", + "type", + "size", + "modified", + "root", + "extension", + "content_type", + "owner", + "group", + }, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 65a49f2..304d8df 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -57,6 +57,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="search-modal"', body) self.assertIn('id="search-input"', body) self.assertIn('id="search-results"', body) + self.assertIn('id="info-modal"', body) self.assertIn('id="rename-popup"', body) self.assertIn('id="rename-input"', body) self.assertIn('id="rename-apply-btn"', body) @@ -84,6 +85,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="wildcard-popup"', body) self.assertIn('id="wildcard-pattern-input"', body) self.assertNotIn('id="search-btn"', body) + self.assertNotIn('id="info-btn"', body) self.assertNotIn('id="bookmarks-panel"', body) self.assertNotIn('id="tasks-panel"', body) @@ -113,10 +115,14 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('document.getElementById("settings-btn").onclick = () => openSettings("general");', app_js) self.assertIn('function openSearch()', app_js) self.assertIn('async function submitSearch()', app_js) + self.assertIn('async function openInfo()', app_js) + self.assertIn('document.getElementById("info-modal")', app_js) + self.assertIn("`/api/files/info?", app_js) self.assertIn('document.getElementById("search-input")', app_js) self.assertIn("`/api/search?", app_js) self.assertIn('event.key.toLowerCase() === "f"', app_js) self.assertIn('(event.metaKey || event.ctrlKey)', app_js) + self.assertIn('const isInfoShortcut = event.key === "Enter"', app_js) self.assertIn('if (event.key === "F1") {', app_js) self.assertIn('if (event.key === "F2") {', app_js) self.assertIn('function openSettings(tab = "general")', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index 658d4c8..0f82fa4 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -217,6 +217,15 @@ function searchElements() { }; } +function infoElements() { + return { + overlay: document.getElementById("info-modal"), + closeButton: document.getElementById("info-close-btn"), + error: document.getElementById("info-error"), + grid: document.getElementById("info-grid"), + }; +} + async function apiRequest(method, url, body) { const options = { method, headers: {} }; if (body !== undefined) { @@ -1410,6 +1419,55 @@ function closeVideoViewer() { video.player.load(); } +function isInfoOpen() { + return !infoElements().overlay.classList.contains("hidden"); +} + +function closeInfo() { + const elements = infoElements(); + elements.overlay.classList.add("hidden"); + elements.error.textContent = ""; + elements.grid.innerHTML = ""; +} + +function renderInfoField(label, value) { + const grid = infoElements().grid; + const labelNode = document.createElement("div"); + labelNode.className = "info-label"; + labelNode.textContent = label; + const valueNode = document.createElement("div"); + valueNode.className = "info-value"; + valueNode.textContent = value == null || value === "" ? "-" : String(value); + grid.append(labelNode, valueNode); +} + +async function openInfo() { + const selectedItems = activePaneState().selectedItems; + if (selectedItems.length !== 1) { + return; + } + const selected = selectedItems[0]; + const elements = infoElements(); + elements.overlay.classList.remove("hidden"); + elements.error.textContent = ""; + elements.grid.innerHTML = ""; + try { + const data = await apiRequest("GET", `/api/files/info?${new URLSearchParams({ path: selected.path }).toString()}`); + renderInfoField("Name", data.name); + renderInfoField("Path", data.path); + renderInfoField("Type", data.type); + renderInfoField("Size", data.size); + renderInfoField("Modified", formatModified(data.modified)); + renderInfoField("Root", data.root); + renderInfoField("Extension", data.extension); + renderInfoField("Content type", data.content_type); + renderInfoField("Owner", data.owner); + renderInfoField("Group", data.group); + } catch (err) { + elements.error.textContent = err.message; + } +} + function isSearchOpen() { return !searchElements().overlay.classList.contains("hidden"); } @@ -1804,6 +1862,13 @@ function clearSelectionForActivePane() { } function handleKeyboardShortcuts(event) { + if (isInfoOpen()) { + if (event.key === "Escape") { + event.preventDefault(); + closeInfo(); + } + return; + } if (isSearchOpen()) { if (event.key === "Escape") { event.preventDefault(); @@ -1891,6 +1956,13 @@ function handleKeyboardShortcuts(event) { return; } + const isInfoShortcut = event.key === "Enter" && !event.shiftKey && !event.altKey && (event.metaKey || event.ctrlKey); + if (isInfoShortcut) { + event.preventDefault(); + openInfo(); + return; + } + const isSearchShortcut = event.key.toLowerCase() === "f" && event.shiftKey && !event.altKey && (event.metaKey || event.ctrlKey); if (isSearchShortcut) { event.preventDefault(); @@ -2039,6 +2111,14 @@ function setupEvents() { } }; + const info = infoElements(); + info.closeButton.onclick = closeInfo; + info.overlay.onclick = (event) => { + if (event.target === info.overlay) { + closeInfo(); + } + }; + const wildcard = wildcardPopupElements(); wildcard.cancelButton.onclick = closeWildcardPopup; wildcard.applyButton.onclick = submitWildcardPopup; diff --git a/webui/html/index.html b/webui/html/index.html index 2398b07..0bff28a 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -111,6 +111,15 @@ + +