From ba6a369f785495e3530c732ee123013b29da2cd8 Mon Sep 17 00:00:00 2001 From: kodi Date: Wed, 11 Mar 2026 13:53:59 +0100 Subject: [PATCH] feat: file viewer added --- project_docs/UI_VIEW_V1_DESIGN.md | 223 ++++++++++++++++++ .../__pycache__/routes_files.cpython-313.pyc | Bin 1892 -> 2277 bytes .../api/__pycache__/schemas.cpython-313.pyc | Bin 5631 -> 5977 bytes webui/backend/app/api/routes_files.py | 10 +- webui/backend/app/api/schemas.py | 10 + .../filesystem_adapter.cpython-313.pyc | Bin 4357 -> 5173 bytes webui/backend/app/fs/filesystem_adapter.py | 14 ++ .../file_ops_service.cpython-313.pyc | Bin 5934 -> 8381 bytes .../backend/app/services/file_ops_service.py | 77 +++++- .../test_api_view_golden.cpython-313.pyc | Bin 0 -> 7424 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 4851 -> 4993 bytes .../tests/golden/test_api_view_golden.py | 107 +++++++++ .../tests/golden/test_ui_smoke_golden.py | 2 + webui/html/app.js | 67 ++++++ webui/html/index.html | 11 + webui/html/style.css | 31 +++ 16 files changed, 550 insertions(+), 2 deletions(-) create mode 100644 project_docs/UI_VIEW_V1_DESIGN.md create mode 100644 webui/backend/tests/golden/__pycache__/test_api_view_golden.cpython-313.pyc create mode 100644 webui/backend/tests/golden/test_api_view_golden.py diff --git a/project_docs/UI_VIEW_V1_DESIGN.md b/project_docs/UI_VIEW_V1_DESIGN.md new file mode 100644 index 0000000..69d88c9 --- /dev/null +++ b/project_docs/UI_VIEW_V1_DESIGN.md @@ -0,0 +1,223 @@ +# UI_VIEW_V1_DESIGN.md + +## 1. Scope + +`View v1` is een eenvoudige read-only file viewer in de webui, gekoppeld aan de functiebalkactie `View`. + +In scope: +- alleen read-only weergave +- alleen files, geen directories +- openen vanuit de bestaande UI +- eenvoudige modalweergave + +Out of scope: +- geen editfunctionaliteit +- geen save +- geen inline rename +- geen compare +- geen syntax-aware editor + +Backendwijzigingen: +- een nieuw read-only file-read endpoint is waarschijnlijk nodig +- alleen als veilig en strikt binnen het bestaande whitelist/path_guard model + +--- + +## 2. Ondersteunde bestandstypen in v1 + +Voorstel v1: + +- `txt`: ja +- `log`: ja +- `md`: ja +- `yml` / `yaml`: ja +- `json`: ja +- `js`: ja +- `css`: ja +- `html`: ja +- `Dockerfile`: ja +- `Containerfile`: ja + +### PDF + +Voorstel: **niet in v1** + +Motivatie: +- PDF-preview vraagt om aparte rendering of browser-embedgedrag +- dat vergroot complexiteit, testoppervlak en afhankelijkheid van browserverschillen +- `View v1` blijft daardoor gefocust op tekstbestanden + +Gevolg: +- PDF en andere niet-ondersteunde typen geven een duidelijke `unsupported preview` melding in de modal + +--- + +## 3. UI/UX + +### Openen +- `View` wordt gestart via de functiebalk +- werkt alleen op het actieve paneel +- alleen geldig bij exact 1 geselecteerd item +- alleen geldig als dat item een file is + +### Presentatie +- openen in een modal boven de bestaande dual-pane UI +- modal bevat: + - titel/header + - bestandsnaam + - volledig pad + - read-only contentgebied + - rechtsboven een `X` + +### Sluiten +- klik op `X` sluit modal +- `Escape` sluit modal +- klik buiten modal mag optioneel sluiten, maar hoeft niet verplicht in v1 + +### Inhoud +- contentgebied is verticaal scrollbaar +- tekst blijft selecteerbaar en kopieerbaar +- monospace weergave voor tekstinhoud +- geen bewerkcontrols + +--- + +## 4. Technische aanpak + +### Frontend + +Voorstel: +- toevoegen van een viewer-modal in `index.html` +- `View` knop wordt enabled bij precies 1 geselecteerde file +- `app.js` opent modal en haalt previewdata op via nieuw backend-endpoint + +### Backend + +Waarschijnlijk nieuw endpoint nodig: +- `GET /api/files/view?path=...` + +Voorstel response shape: +- `path` +- `name` +- `content_type` +- `encoding` +- `truncated` +- `size` +- `content` + +Voorbeeldgedrag: +- tekstbestand: inhoud als UTF-8 string terug +- unsupported type: nette 409/400-achtige applicatiefout of expliciete supported=false response + +### Preview-keuze / typebepaling + +Voorstel v1: +- eerst extensie- en bestandsnaamgebaseerde allowlist +- speciale namen: + - `Dockerfile` + - `Containerfile` +- optioneel secundair op mime gokken, maar niet leidend maken + +### Grote bestanden + +Voorstel: +- harde limiet op previewgrootte, bijvoorbeeld `256 KB` of `512 KB` +- backend leest maximaal tot die limiet +- response bevat `truncated = true` als bestand groter is + +Dit voorkomt: +- grote memory responses +- trage modal-openingen +- onnodige load voor logbestanden + +### Unsupported bestandstypen + +Voorstel: +- backend of frontend classificeert bestand als niet-previewbaar +- modal toont compacte melding: + - bestandstype niet ondersteund in `View v1` + +Geen fallback naar download of externe viewer in v1. + +--- + +## 5. Security en scopebeperking + +- alle padvalidatie via bestaand `path_guard` +- alleen paden binnen whitelist +- geen directoryweergave via viewer +- geen write/save-endpoint +- geen downloadmanager +- geen externe viewer libraries in v1 + +Voor tekstpreview: +- inhoud alleen server-side lezen via gecontroleerde backendroute +- geen directe file-URL of browser file access + +--- + +## 6. Impactanalyse + +Waarschijnlijk te wijzigen frontendbestanden: +- `webui/html/index.html` +- `webui/html/app.js` +- `webui/html/style.css` +- `webui/backend/tests/golden/test_ui_smoke_golden.py` + +Waarschijnlijk te wijzigen backendbestanden: +- `webui/backend/app/api/routes_files.py` of aparte view-route +- `webui/backend/app/api/schemas.py` +- `webui/backend/app/services/file_ops_service.py` of aparte view service +- `webui/backend/app/fs/filesystem_adapter.py` +- eventueel nieuwe golden tests voor view-endpoint + +### Regressierisico + +- functiebalk enabled/disabled logica kan fout lopen bij `View` +- modal-keyboardinteractie kan bestaande keyboard shortcuts blokkeren of lekken +- grote bestanden of binair ogende inhoud kunnen previewflow verstoren +- pad/securityvalidatie moet identiek streng blijven als bij browse/file ops + +Mitigatie: +- `View` alleen bij exact 1 fileselectie +- modal-open toestand blokkeert gewone navigatieshortcuts +- size limit en type allowlist in backend + +--- + +## 7. Teststrategie + +### Golden tests + +Voor backend indien endpoint wordt toegevoegd: +- view success voor ondersteund tekstbestand +- unsupported type +- directory geselecteerd -> type conflict +- path not found +- traversal attempt +- invalid root alias +- truncated response voor groot bestand + +### UI smoke tests + +Aan te passen: +- modalcontainer aanwezig in HTML +- `View` knop aanwezig in functiebalk + +Niet nodig in smoke: +- volledige interactieflow headless afdwingen, tenzij de huidige stack dat eenvoudig ondersteunt + +### Handmatige validatie + +- `View` enabled bij exact 1 geselecteerde file +- `View` disabled bij: + - geen selectie + - meerdere selectie + - directoryselectie +- modal opent correct +- pad en bestandsnaam zichtbaar +- tekstinhoud scrollbaar +- selectie/kopiƫren van tekst werkt +- `Escape` en `X` sluiten modal +- unsupported type geeft nette melding +- groot bestand wordt veilig afgekapt 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 6aa47d3b99f0f81b1d981b9480f915f36e3b09e6..6642e42bf310ff46f0ecbeb4641e296633098549 100644 GIT binary patch delta 924 zcmZuv%}*0S6rb6hPCwXP`U$ltEGsR2uc7CeYx=8ZS1fKoPhCupDNNCkfebRPj%iGDeAjsAn4kG|RFLDypoTk(S)dTOd6K`DLaL|W zHN07=K;o#N)!#(AgN84L=E*!hD+YkEP)l6@u3$$KGL$w=xTWEv_)ru!v^G%$M8nS1 z&Vq7uYQ*rjiMwY5sV-i{L)gtX@EvL?x97}5y1 z!j8Xq`XAnXz>Cwd-R%Zsb>D4V>Ads4v+_p8wE0YP$$deDbNb4FAt+j}t>V4gPdP QrYR%o%pP<}urzG)8#*n)-2eap delta 547 zcmZvYPfG$p7{=$_U1!zY_0MXGhMGaOSO*UsgeWM2P!Mt;2=^3ikyysK9{mb|&9P6= zrTP$^y9NtE2g9r3F@k0mqp}9($NSFnJiGJGYjmLNV@(SpzMQ@L)4I~vQ?T1#+N|Ue zK}4yBDjL*pVH(aOOD-USiCP94u_E;a5&pf1-wKdGS+bM@va~BmBqJo4Mopy& zYZE7piD>^N2c8}!p())EI$ME>Ax88mL;NIc8tegzfY}Hd>_8geDE~T*v2A$=8=^GW zrTjRb7G*{t&e4%L@_CvR2Xh`+Pboo)-6@A9Q>vLjMZ%O^d}Oz5$M)L4*{sNB*_N+# zTr)9Eh}JJKE1+?>nA%R4p3?wtzAWOgzb!N;+L<3Z{zq4Zo?zFw!QOEOLM*8k_a}d7 zclixn<)Ynjs5i7XlepXs8gn9VQrEwJ);{XC>h;&sZ>qL`*!9NQD+G4TNZ+6uLqq?85E>X~3wd z!com6p1pX*5Kdmab1@hbQaAns7!M?H@}h5Esc!Nxm$6isgk}qq1&sbVe2P%aW@~L6%nU!r3KS)bi>IzBfRtEE$U~KX01LZj)oJ8w&+TFIv}7(vA`@mGG#6w6_I5ib_uhb?DN!%JS44=e7l7v8G6i$7ea7zl( zlzY{#Fb!&Bl1H z-i(Vw=4yFDUt8C0=VS@fS&J7v^{c(XGnsnnM3TQUT2-}UUV}L`1G525TdC)}UZ-F` z$(M>&3Xc4(0^10&0(*fNS^8ftZEooGa#melKLRR^E~AqHjmHRfipv*!B*`Wms(sf@ zj374YvL%Z8EsM&|NKIo>&24DCBtSmc)3%KY_EgvfusPXdTf@LOYurW?u?SGvC_*MNF}i_?hzc502-fDtKgEwwMBGJxtt6^C%J`4KfbC^Q)hF%@*TfhZ z>yE*YHj_wFo4qP_e^wEH*|D;-Vbil_StDgQ)Z)TZnnUQaLvX6S6Vx|@=bV?Q=Nh~W GZ~X=8>FOc? delta 687 zcmZ9J&ubGw6vsQEG-Wr_-Ry4GZX_vUiv~5GL=+Tlt1(7PvHmtqv$!ROHLac99;#Lk zf?k9o5Be_%o+|z;BDJTUdl023L8|k8)r$l3VP@WU-uup*uhrkj&JEMLIYa~U6n!>}wC5e6=h6{1T9aoE zHFO>Y)b!jP)OgdkX_mh9mz2vP`aTd)X6WY#D68v6Cpesyg#XT5Ih zcc9H_nvS#|>G=Oe>2zM^6x-o`MYQ3BqDPK<(&#`B^r+z$MTcAY(w!Mm2pk5umHdt} zwq2kGYetMEg92|<#AF*6=!NGOS9IX1^SGO&55^Z4<6fxZdOF{mRo8JZSin61IyD8b zu?0>rRv0!{akgKbD=lFUtkRQ0A&b>iC7`#3*$V$Te**Wm4P^xI)7Tx?TcQJg$ynAA Pp=Xk%^Zq(*`zwC{(9fPe diff --git a/webui/backend/app/api/routes_files.py b/webui/backend/app/api/routes_files.py index 35fa518..6a13490 100644 --- a/webui/backend/app/api/routes_files.py +++ b/webui/backend/app/api/routes_files.py @@ -2,7 +2,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends -from backend.app.api.schemas import DeleteRequest, DeleteResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse +from backend.app.api.schemas import DeleteRequest, DeleteResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, ViewResponse from backend.app.dependencies import get_file_ops_service from backend.app.services.file_ops_service import FileOpsService @@ -31,3 +31,11 @@ async def delete( service: FileOpsService = Depends(get_file_ops_service), ) -> DeleteResponse: return service.delete(path=request.path) + + +@router.get("/view", response_model=ViewResponse) +async def view( + path: str, + service: FileOpsService = Depends(get_file_ops_service), +) -> ViewResponse: + return service.view(path=path) diff --git a/webui/backend/app/api/schemas.py b/webui/backend/app/api/schemas.py index f02d2fc..d9a7f60 100644 --- a/webui/backend/app/api/schemas.py +++ b/webui/backend/app/api/schemas.py @@ -58,6 +58,16 @@ class DeleteResponse(BaseModel): path: str +class ViewResponse(BaseModel): + path: str + name: str + content_type: str + encoding: str + truncated: bool + size: int + content: str + + 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 414430ccc03f40c3fab172c54432447ad02c8b2b..f01de93d1a590b5a77ef2c9a923209eff8c32b4e 100644 GIT binary patch delta 1119 zcmYjP-)j_C6ux)o_w3H@WV6wj)?^#S?107=v~+DkYeMZ)gXDHhgO+i!bE6YBv+13i zXx73CRan8$U4{}LrB8(lRZ3rcD*gj)@WEy9N1;!B^9LC4srSxijd$QX-?`uU&OO{a z*GK;-WnUSF4%k{df6xCxZ)bmNtg-ATa1IcN07)T81Z1Gp5Lgn4h_XmXn1pgjOqh&J zBqdBieMnB2%IcJaX^g1}(@`GvBc1alNYbc)22gq@%b*M~E@;-CxbM{Oq!?2BOX8JHpgJvf-=}Ntuy`Y|FD+&$%0I={W3Wfr0@4 zj;FeL5qvXs;u-*xu(VU|WdSIHv+16DPU(5=6t4_aTkX}l5&(0oqn#2#px9!=R_Byi zqi5ZXwtxgAmQzR?7Y>3Cz+up9;jzIMQ$;Ys4lRP2FDHc&um~-A=07F?a+<>d1{REE zOh}D{mOzHsMyye?8dO}0y;!N#0*V9LuoN=J9m5>>5d?Agnn5g7aS+QMuGBniX~g2f z$6U~s-OJ9xI>lkE;(*D1aG`sIRP~pAO3o#A;$9*iVGIdr6 zUi0y#_`oT@io^Ag;^mW`yFxJ;?^eB2^cnBB!Qd)*oXfY4ZI;{R=+j62hqn3;J+Ps>2A4I|9kJ`3q>^b=Rnl- z?KJDO;k*g?>)+2$8NvfYpL#F)D?0;MqTyVzvBCuM9Ro`y6D-6+NC}zbyM)88E+&^t zrWW9L8p67?`Vn0`@(J_vck8yk$XW8{ zK4))-h(*t@P%9hV&wV>gs!WRq94DkM^(&4`De)Ip*^4_438D|p_u;YVE3=e)EvKdO z_E1LBUjPPC!~7fu(Vynv(JQP2|0Bcw44w-R!Y5$#Utl}~V^6@~wmc1?{t|E`{jB1D DlM?%5 delta 363 zcmdn0(W=DvnU|M~0SI~{He_~iOypZ%p8@1eWr$%YVhm;|VhUy~Vh-jBW|CsaVku$? z<_%^Bvsi=qf?2>UwqX8XRxpb_SRj}U%;Es5V+XT1fh-O%iz`?#SSXka#Li+Y;tu96 z;z{S!6uu?smYI`UTv=R_n(LU7SWvRri*X9?J`jg9qHMqXBakE-}WB?MA*9toTrQQikaODC` ZV+7)2>B#~jV!Wn|mY*1aM3FF13;;u&PT>Fm diff --git a/webui/backend/app/fs/filesystem_adapter.py b/webui/backend/app/fs/filesystem_adapter.py index e44e757..1cfb58e 100644 --- a/webui/backend/app/fs/filesystem_adapter.py +++ b/webui/backend/app/fs/filesystem_adapter.py @@ -59,3 +59,17 @@ class FilesystemAdapter: if on_progress: on_progress(out_f.tell()) shutil.copystat(src, dst, follow_symlinks=False) + + def read_text_preview(self, path: Path, max_bytes: int, encoding: str = "utf-8") -> dict: + size = int(path.stat().st_size) + limit = max_bytes + 1 + with path.open("rb") as in_f: + raw = in_f.read(limit) + truncated = size > max_bytes or len(raw) > max_bytes + if truncated: + raw = raw[:max_bytes] + return { + "size": size, + "truncated": truncated, + "content": raw.decode(encoding, errors="replace"), + } 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 1b5ee64188a0f02499375a232ff631dbf1054676..484366ee8b0a942a3f79537079d982df27946f56 100644 GIT binary patch delta 3010 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 ViewResponse: + resolved_target = self._path_guard.resolve_existing_path(path) + + if resolved_target.absolute.is_dir(): + raise AppError( + code="type_conflict", + message="Source must be a file", + status_code=409, + details={"path": resolved_target.relative}, + ) + if not resolved_target.absolute.is_file(): + raise AppError( + code="type_conflict", + message="Unsupported path type for view", + status_code=409, + details={"path": resolved_target.relative}, + ) + + content_type = self._content_type_for(resolved_target.absolute) + if content_type is None: + raise AppError( + code="unsupported_type", + message="File type is not supported for preview", + status_code=409, + details={"path": resolved_target.relative}, + ) + + try: + preview = self._filesystem.read_text_preview( + resolved_target.absolute, + max_bytes=TEXT_PREVIEW_MAX_BYTES, + encoding="utf-8", + ) + except OSError as exc: + raise AppError( + code="io_error", + message="Filesystem operation failed", + status_code=500, + details={"reason": str(exc)}, + ) + + return ViewResponse( + path=resolved_target.relative, + name=resolved_target.absolute.name, + content_type=content_type, + encoding="utf-8", + truncated=preview["truncated"], + size=preview["size"], + content=preview["content"], + ) + @staticmethod def _join_relative(base: str, name: str) -> str: return f"{base}/{name}" if base else name + + @staticmethod + def _content_type_for(path: Path) -> str | None: + special_name = SPECIAL_TEXT_FILENAMES.get(path.name.lower()) + if special_name: + return special_name + return TEXT_CONTENT_TYPES.get(path.suffix.lower()) diff --git a/webui/backend/tests/golden/__pycache__/test_api_view_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_view_golden.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..69365aa6422e3a50fa02dca789b67943365a469b GIT binary patch literal 7424 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 literal 0 HcmV?d00001 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 18fabcdeaa0b389fc96a4604dfc9ba0225ee81cc..4cd27a97342ecbf8f5687204bd7e659cb826dd7b 100644 GIT binary patch delta 233 zcmeyY+NjR=nU|M~0SL|{Y|ND5-pKclnQ_TxUX}^WlVe#$7{w<0u_`l)Pwrz^W|RPt zf|8-krc59W3=9ek`V7HRlb^A-GfGd+k^t5B{aV<068D37#Uc2I;t)+OWt7N UY4>gNZT0`a%)la5BnEUS0BZ+CEdT%j delta 159 zcmZov|E$XQnU|M~0SLTfHfHYN+Q|2hnK5QFFUthx$($S_jAE02u`3IThccToffO+? zC@|f@FG^^BVE}SIlrb`}@N`sNW|q9c!qe{C None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) / "root" + self.root.mkdir(parents=True, exist_ok=True) + path_guard = PathGuard({"storage1": str(self.root)}) + service = FileOpsService(path_guard=path_guard, filesystem=FilesystemAdapter()) + + async def _override_file_ops_service() -> FileOpsService: + return 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/view", params={"path": path}) + + return asyncio.run(_run()) + + def test_view_supported_text_success(self) -> None: + file_path = self.root / "notes.md" + file_path.write_text("# title\nhello\n", encoding="utf-8") + + response = self._request("storage1/notes.md") + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "path": "storage1/notes.md", + "name": "notes.md", + "content_type": "text/markdown", + "encoding": "utf-8", + "truncated": False, + "size": len("# title\nhello\n".encode("utf-8")), + "content": "# title\nhello\n", + }, + ) + + def test_view_unsupported_type(self) -> None: + (self.root / "report.pdf").write_bytes(b"%PDF-1.4") + + response = self._request("storage1/report.pdf") + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "unsupported_type") + + def test_view_directory_type_conflict(self) -> None: + (self.root / "docs").mkdir() + + response = self._request("storage1/docs") + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "type_conflict") + + def test_view_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_view_traversal_attempt(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_view_truncated_response_for_large_file(self) -> None: + content = "x" * (300 * 1024) + (self.root / "big.log").write_text(content, encoding="utf-8") + + response = self._request("storage1/big.log") + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertTrue(body["truncated"]) + self.assertEqual(body["size"], len(content.encode("utf-8"))) + self.assertEqual(len(body["content"]), 256 * 1024) + + +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 51f086c..a18c991 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -35,6 +35,8 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="function-bar"', body) self.assertIn('id="view-btn"', body) self.assertIn('id="edit-btn"', body) + self.assertIn('id="viewer-modal"', body) + self.assertIn('id="viewer-content"', body) self.assertIn('id="mkdir-btn"', body) self.assertIn('id="copy-btn"', body) self.assertIn('id="move-btn"', body) diff --git a/webui/html/app.js b/webui/html/app.js index a58daac..c08fc4c 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -58,6 +58,18 @@ function showActionSummary(action, successes, failures, firstError) { setStatus(base); } +function viewerElements() { + return { + overlay: document.getElementById("viewer-modal"), + title: document.getElementById("viewer-title"), + fileName: document.getElementById("viewer-file-name"), + filePath: document.getElementById("viewer-file-path"), + error: document.getElementById("viewer-error"), + content: document.getElementById("viewer-content"), + closeButton: document.getElementById("viewer-close-btn"), + }; +} + async function apiRequest(method, url, body) { const options = { method, headers: {} }; if (body !== undefined) { @@ -145,6 +157,7 @@ function updateActionButtons() { const hasSelection = count > 0; const exactlyOne = count === 1; const allFiles = hasSelection && selectedItems.every((item) => item.kind === "file"); + document.getElementById("view-btn").disabled = !exactlyOne || !allFiles; document.getElementById("rename-btn").disabled = !exactlyOne; document.getElementById("delete-btn").disabled = !hasSelection; document.getElementById("copy-btn").disabled = !allFiles; @@ -633,6 +646,10 @@ function isWildcardPopupOpen() { return !wildcardPopupElements().overlay.classList.contains("hidden"); } +function isViewerOpen() { + return !viewerElements().overlay.classList.contains("hidden"); +} + function escapeRegExp(text) { return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); } @@ -715,6 +732,40 @@ function openWildcardPopup(mode) { elements.input.focus(); } +function closeViewer() { + const viewer = viewerElements(); + viewer.overlay.classList.add("hidden"); + viewer.error.textContent = ""; + viewer.content.textContent = ""; +} + +async function openViewer() { + const selectedItems = activePaneState().selectedItems; + if (selectedItems.length !== 1 || selectedItems[0].kind !== "file") { + return; + } + const selected = selectedItems[0]; + const viewer = viewerElements(); + viewer.overlay.classList.remove("hidden"); + viewer.title.textContent = "View"; + viewer.fileName.textContent = selected.name; + viewer.filePath.textContent = selected.path; + viewer.error.textContent = ""; + viewer.content.textContent = "Loading..."; + try { + const data = await apiRequest("GET", `/api/files/view?${new URLSearchParams({ path: selected.path }).toString()}`); + viewer.fileName.textContent = data.name; + viewer.filePath.textContent = data.path; + viewer.content.textContent = data.content; + if (data.truncated) { + viewer.error.textContent = "Preview truncated for safety"; + } + } catch (err) { + viewer.content.textContent = ""; + viewer.error.textContent = err.message; + } +} + function moveCurrentRow(delta) { const pane = state.activePane; const model = paneState(pane); @@ -770,6 +821,13 @@ function clearSelectionForActivePane() { } function handleKeyboardShortcuts(event) { + if (isViewerOpen()) { + if (event.key === "Escape") { + event.preventDefault(); + closeViewer(); + } + return; + } if (isWildcardPopupOpen()) { return; } @@ -851,6 +909,7 @@ function setupEvents() { setupPaneEvents("left"); setupPaneEvents("right"); document.addEventListener("keydown", handleKeyboardShortcuts); + document.getElementById("view-btn").onclick = openViewer; document.getElementById("rename-btn").onclick = renameSelected; document.getElementById("delete-btn").onclick = deleteSelected; document.getElementById("copy-btn").onclick = startCopySelected; @@ -876,6 +935,14 @@ function setupEvents() { closeWildcardPopup(); } }; + + const viewer = viewerElements(); + viewer.closeButton.onclick = closeViewer; + viewer.overlay.onclick = (event) => { + if (event.target === viewer.overlay) { + closeViewer(); + } + }; } async function init() { diff --git a/webui/html/index.html b/webui/html/index.html index b4cdfc5..64c5375 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -88,6 +88,17 @@ + + diff --git a/webui/html/style.css b/webui/html/style.css index 654550a..a009e36 100644 --- a/webui/html/style.css +++ b/webui/html/style.css @@ -366,6 +366,37 @@ button:disabled { justify-content: flex-end; } +.viewer-card { + position: relative; + width: min(960px, calc(100vw - 32px)); + max-height: calc(100vh - 32px); + display: flex; + flex-direction: column; +} + +.viewer-close { + position: absolute; + top: 10px; + right: 10px; + min-width: 32px; + padding: 2px 8px; +} + +.viewer-content { + margin: 6px 0 0 0; + padding: 10px; + min-height: 240px; + max-height: calc(100vh - 180px); + overflow: auto; + border: 1px solid var(--border); + background: #f8fafc; + color: var(--text); + font: 12px/1.45 "SFMono-Regular", Consolas, "Liberation Mono", monospace; + white-space: pre-wrap; + word-break: break-word; + user-select: text; +} + @media (max-width: 1200px) { .workspace { grid-template-columns: 1fr;