From b93cb018794d9369293ffe728c110d36c29bd372 Mon Sep 17 00:00:00 2001 From: kodi Date: Wed, 11 Mar 2026 14:09:44 +0100 Subject: [PATCH] feat: file edit added --- project_docs/UI_EDIT_V1_DESIGN.md | 241 ++++++++++++++++++ .../__pycache__/routes_files.cpython-313.pyc | Bin 2277 -> 2852 bytes .../api/__pycache__/schemas.cpython-313.pyc | Bin 5977 -> 6459 bytes webui/backend/app/api/routes_files.py | 17 +- webui/backend/app/api/schemas.py | 13 + .../filesystem_adapter.cpython-313.pyc | Bin 5173 -> 6190 bytes webui/backend/app/fs/filesystem_adapter.py | 14 + .../file_ops_service.cpython-313.pyc | Bin 8381 -> 10666 bytes .../backend/app/services/file_ops_service.py | 75 +++++- .../test_api_edit_golden.cpython-313.pyc | Bin 0 -> 11550 bytes .../test_api_view_golden.cpython-313.pyc | Bin 7424 -> 7859 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 4993 -> 5283 bytes .../tests/golden/test_api_edit_golden.py | 168 ++++++++++++ .../tests/golden/test_api_view_golden.py | 21 +- .../tests/golden/test_ui_smoke_golden.py | 4 + webui/html/app.js | 134 ++++++++++ webui/html/index.html | 15 ++ webui/html/style.css | 15 ++ 18 files changed, 701 insertions(+), 16 deletions(-) create mode 100644 project_docs/UI_EDIT_V1_DESIGN.md create mode 100644 webui/backend/tests/golden/__pycache__/test_api_edit_golden.cpython-313.pyc create mode 100644 webui/backend/tests/golden/test_api_edit_golden.py diff --git a/project_docs/UI_EDIT_V1_DESIGN.md b/project_docs/UI_EDIT_V1_DESIGN.md new file mode 100644 index 0000000..1b6a2ca --- /dev/null +++ b/project_docs/UI_EDIT_V1_DESIGN.md @@ -0,0 +1,241 @@ +# UI_EDIT_V1_DESIGN.md + +## 1. Scope + +`Edit v1` is een eenvoudige teksteditor in de webui, gekoppeld aan de functiebalkactie `Edit`. + +In scope: +- alleen tekstbestanden +- alleen files, geen directories +- openen, wijzigen en opslaan van tekstinhoud +- eenvoudige modal-editor + +Out of scope: +- geen binary files +- geen PDF +- geen rich text +- geen collaborative editing +- geen autosave + +--- + +## 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 + +De allowlist blijft gelijk aan `View v1`, zodat `View` en `Edit` inhoudelijk consistent zijn. + +--- + +## 3. UI/UX + +### Openen +- `Edit` opent via de functiebalk +- alleen geldig bij exact 1 geselecteerde file +- alleen bij ondersteund teksttype + +### Modal +- openen in modal boven de bestaande dual-pane UI +- modal bevat: + - titel/header + - bestandsnaam + - volledig pad + - bewerkbaar tekstgebied + - `Save` + - `Cancel` + - rechtsboven `X` + +### Sluiten +- `Cancel` sluit zonder opslaan +- `X` sluit zonder opslaan +- `Escape`: + - als geen onopgeslagen wijzigingen: direct sluiten + - als wel onopgeslagen wijzigingen: waarschuwing/bevestiging tonen + +### Inhoud +- scrollbaar tekstgebied +- monospace presentatie +- selecteerbaar en bewerkbaar +- geen syntax highlighting als dat extra dependencies vraagt + +### Dirty state +- modal houdt een eenvoudige `isDirty` status bij +- verschil tussen originele inhoud en huidige inhoud bepaalt of waarschuwing nodig is + +--- + +## 4. Backend + +### Nieuwe endpoint(s) + +Voorstel: +- hergebruik `GET /api/files/view?path=...` voor initial read +- nieuw write-endpoint: + - `POST /api/files/save` + +Voorstel request shape: +- `path` +- `content` +- `expected_modified` of vergelijkbare timestamp/hash alleen als conflictcheck in v1 wordt gekozen + +Voorstel response shape: +- `path` +- `size` +- `modified` + +### Relatie met bestaand view-model + +`Edit` gebruikt dezelfde type-allowlist en dezelfde padvalidatie als `View`. + +Pragmatische lijn: +- `View` blijft read-only preview +- `Edit` leest initieel via `View` of een gedeelde servicefunctie +- `Save` schrijft alleen naar hetzelfde pad binnen whitelist + +### Validatie + +- alle paden via bestaand `path_guard` +- directories afwijzen +- unsupported types afwijzen +- write alleen binnen whitelisted roots + +### Grote bestanden + +Voorstel: +- dezelfde leeslimiet als `View` is **niet** voldoende voor edit +- `Edit v1` moet alleen openen tot een veilige editorlimiet, bijvoorbeeld `256 KiB` of `512 KiB` +- boven die limiet: + - openen blokkeren met duidelijke foutmelding + - geen partial edit voor grote bestanden in v1 + +Reden: +- partial content bewerken zonder volledige file-context is onveilig en verwarrend + +--- + +## 5. Veiligheid en conflictgedrag + +### Wijziging intussen op schijf + +Voorstel v1: +- **wel** eenvoudige optimistic locking / modified timestamp check + +Mechaniek: +- read-response bevat `modified` +- save-request stuurt `expected_modified` +- backend vergelijkt actuele `mtime` +- mismatch geeft conflictfout + +Voordeel: +- beperkt risico op stil overschrijven +- technisch klein genoeg voor v1 + +### Readonly/permissieproblemen + +Bij save: +- permissieprobleem of readonly file -> `io_error` of specifieker `permission_denied` als we die foutcode toevoegen + +Voorstel: +- als bestaande foutset compact moet blijven, map dit in v1 naar `io_error` met duidelijke boodschap + +### Foutmodel + +Minimaal: +- `path_not_found` +- `path_traversal_detected` +- `invalid_root_alias` +- `type_conflict` +- `unsupported_type` +- `conflict` +- `io_error` + +`conflict` wordt gebruikt voor modified-timestamp mismatch. + +--- + +## 6. Scopebeperking + +Niet in v1: +- geen syntax highlighting als dat extra dependencies vraagt +- geen undo/redo systeem buiten browser-native textarea gedrag +- geen find/replace +- geen multi-file edit +- geen directory edit +- geen split view diff + +--- + +## 7. Impactanalyse + +Waarschijnlijk te wijzigen backendbestanden: +- `webui/backend/app/api/routes_files.py` +- `webui/backend/app/api/schemas.py` +- `webui/backend/app/services/file_ops_service.py` +- `webui/backend/app/fs/filesystem_adapter.py` +- nieuwe golden tests voor save/edit flow + +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` + +### Regressierisico + +- `Edit` enabled/disabled toestand kan verkeerd meelopen met huidige selectie +- modal-keyboardgedrag kan botsen met paneelnavigatie +- save-conflict of dirty-state kan leiden tot onduidelijk UX-gedrag +- onveilige overschrijving zonder conflictcheck moet vermeden worden + +Mitigatie: +- dezelfde selectievoorwaarden als `View` +- keyboard shortcuts blokkeren zolang editor open is +- expliciete dirty-state en save-conflict handling + +--- + +## 8. Teststrategie + +### Golden tests + +Voor backend: +- edit/open success voor ondersteund tekstbestand +- save success +- unsupported type +- directory -> type conflict +- path not found +- traversal attempt +- conflict bij gewijzigde file +- io_error bij write failure + +### UI smoke/regressietests + +Aanpassen: +- `Edit` knop aanwezig in functiebalk +- edit-modal container aanwezig in HTML +- save/cancel controls aanwezig + +### Handmatige validatie + +- `Edit` enabled bij exact 1 ondersteunde file +- `Edit` disabled bij: + - geen selectie + - meerdere selectie + - directoryselectie + - unsupported filetype +- modal opent met juiste inhoud +- `Save` schrijft wijziging correct weg +- `Cancel` sluit zonder opslaan +- `Escape` sluit alleen veilig volgens dirty-state regel +- conflictmelding bij tussentijdse externe wijziging 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 6642e42bf310ff46f0ecbeb4641e296633098549..7ee3b6be80220b1122bcd7c3a84e6cff376e3964 100644 GIT binary patch delta 1333 zcmZ`(O>7%Q6rPz~&#oQs`X~O24UV0Zv`axHNmB@o5NcHpsnkQ3iXK)fZM;ccg}9yF z1cX$%KL^?il^G!_aY2X+r>b|3Jy(f1U{;Eh9xB9bi$Mi(;k~ufCaD-}zj^b%Z{EC_ zH{&On9|~sAFcJjX@k~v;6E3!4> zC}YItwl+(}urs`1VUWGe)}6Q=pJk3ZMx69lG+)fyi88sYUZyhxrO4yd&h{vc_=26> z#}|;7Z_vJ=U9`=8f+7ee_pXa0JLQ;;K12JOqpV7H|Ll^T*%xu-e|D2~jKtpU zjM&5b=9ZUpR#`rzhiP1f)X5Cl7uMYc_v)JK`9Tr}@&x;r=O7o6mnW4|yntpweyKdF zzlvrRp$OnHfLcwI(J>lugO^^H(9QIj@opV9UyNjOB6%1C{~~^wZwV1(ey1EsOwGlQ%mVD zGLA7v06Ybtwv*-obR*#Bl>mMx*GZTp$>Fpml zTdx07p581^hZ;$iLZT))K#UKz_0|+UV{w7|7dir-lt=>vT)6(4SQT*?>`3tWy^sie zNMdLgkEijRKS%lk4F!6?Op50Vfh7sli>=nmbL~KC?uwP%9c#cF$XDdI{9{d^Ell}0 zuhNt(YKPl{tXpevzXJ723zmwmY;+^>Y6Z%&>qkY8&hJOk11E{DZJ^IVA=Xyh_rx*q z;S2P>2MiTT=@uFLjZ8dK30-E-fLvyOMYu&~x5)dCFQ!x8HATY4D3j?J9}` z==12>i$bk79rvl_ldYQr0Rx@(}p&l>i+_YJy|lXZKF%L!4)4QKgv zb$}J1tf?M^+(#p`uKy4Mx_f{yTJ$7?WQ?0cGRbf`@|NpJUP{D1@`}GC`B~^Eyzp)! z%)&!V0#wQz6-~n-HgUKwth^j2Z^WSQdGW){{MO9;#$406&=eMS@c|MSZ=BlXmXZiF zSdufPWn;~#7*_f(R%2L=ycExplVT8!lh@LbnvPN^4zqj#;Ru5uQ@FEY+)Z*l$S9rr zYi&^mVG?H!TkdyQhqeP;zQ^eY>BiNKbkiAa3enxNK+St@FIq}UI7nk`W5S47rD96h zsMM?_z$)bma4eYNd*ed>-?xRxVs8 z4{cepZ3`oX{IX5f_SRLZ(oHml+a(IKj?6j{&Egl0iuM1S(4{$8CuZ8xH%rUaHDeB+ zpmXe|moHOo^9Z39M885}M*y_Q?ND=(>sZ=?R0}SAwtE}nTlUzx)Rurev;RnSB#0!M piF5DA&bLJY#XdTeHc!V-O4!P2o#g!~)0r+0-02QSBYd2G@;BH?uZI8t diff --git a/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc b/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc index d5915e94c2e00c09dd0611863504a35b6f3e7f0f..629abb5aa097c25a23a0e6ba9aab61a8f5447297 100644 GIT binary patch delta 1391 zcmZ9MO-vI}5XbkG(w1$v-&9%z6se_wV8xn%#76X>RWOF)S3tDNiWQW)yNHUx7_`R3 zC}ubtFUG`^#-j%h-aL3AF-W5E;Ki624ZWCn@cpM4KQ?*4eQ##o%)FU>`>x|fr*}ou zR7E~t#-0@lHTS)BqWxJy*iv;$vy$5sQ*x={s;yP9P!uD^(#=xZ`IoeQY)dlzyxsQR zCRRwTe&uL}Z8I%aw~A)^=7`d?y@?)#RLVF?js%nmdhLjdgjC+$*bV`(K@L-sxTP!w z!i)CslB$)mMu_#~S9j1SXVLySeeR`_Yo{X(!!*5dHHx&{-Af%c zUC}NGfz6n70_?ww{lAnJJ&*xg|F!6m7MG(L2!ZZ@wHcgKoTeSst@O~Y(pR-LU>mmQ zJUDJnGpfhDNgp-1``j(q7zdhY(w*@2VhU`R!)&8R?zrC@nCoBC{H4B1a=oiX>}hl7 z@)z|1{lb)PTA%k|rweEVc1tV=^h-tkl%lO#N6<4*O7@>Pw_xd5Ife|@Y5LBMQ-SF1lP;olM zPHo;FO($W=Sq)&80|tSEz#)qHc8Mc2;@cKHECtJ|Y2^)+DsLG_>4~qUgB_fL@hG4J zr-3ox3{U{h0+r-XU<#b1Mn8!YGMT;f)35obpx|Uyqszjm6A%JZ)ElT56KwrB5Ds7Y zZN2QY^5*#Q!l*Gwa!0%f1po`vBOy=E&GYz5`zjSEvYX+c=D!B(|AIy>2m#if$BMgQ z(bHgF+@N4+lR6D4aD(=RVq%(WdM%V|x(x;Qmut$t`E$Dr#bM;A&R`1Mrf9fD%y0u| z!;#p$lwAo<6ijPq=j7!0xx6u+we)kxjAPgroy9%?b_Uv8NoEO2Uzl8q~MjA`L@;#Vk09-Y`68wSkcgJ5BH<5e8<>rEM zMfhZ#rg5FtL>kna(u0sgUe-)xp~WQv^W9}dzRHm*Q9qw7EAmzLREZ$u@>TZGhsew? D7T7Q5 delta 1042 zcmY+DO-~b16oz{nn3kDNzY86dHjSk~NGQ@MDG{w`0zw6w27>}ZP5BZ?b*6#{u1H{u zIsOV4M0BYOgMYxdFwjJ!3wQ1yCc4r4p2~tI^Yq^Ly!YI5zutBocDRppUA4(yX?kyA z*ZJHX7Ph?>VXv7>wspQ4vwyJ*i!RoFP-4o$7O1umR+^l*+1HEr}F0HjYldZoh<6SQM*C9l@yaGAApdYhZ_T2?s+ zYn)zcEuIXT08&^9`ldB&x_2vUav>Qcp2I!}KcUlZ^Etfog z86NSZV|j#uocxZQ65F-|t?6@3Q#EOxlkR(dM@Qj=!5jpJfGltm7^aH%nz%&~Uypx8 z3Ki8V7tHcZajH-@bM(L$@3;%!I4}WB0{4I^U>Ya@_kn8hV`u`S^wmdVj8EvDU-!-< zz{UKZeJ>PIix7c%5`iNz80pz~` diff --git a/webui/backend/app/api/routes_files.py b/webui/backend/app/api/routes_files.py index 6a13490..e09e51c 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, ViewResponse +from backend.app.api.schemas import DeleteRequest, DeleteResponse, 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 @@ -36,6 +36,19 @@ async def delete( @router.get("/view", response_model=ViewResponse) async def view( path: str, + for_edit: bool = False, service: FileOpsService = Depends(get_file_ops_service), ) -> ViewResponse: - return service.view(path=path) + return service.view(path=path, for_edit=for_edit) + + +@router.post("/save", response_model=SaveResponse) +async def save( + request: SaveRequest, + service: FileOpsService = Depends(get_file_ops_service), +) -> SaveResponse: + return service.save( + path=request.path, + content=request.content, + expected_modified=request.expected_modified, + ) diff --git a/webui/backend/app/api/schemas.py b/webui/backend/app/api/schemas.py index d9a7f60..3a1dec7 100644 --- a/webui/backend/app/api/schemas.py +++ b/webui/backend/app/api/schemas.py @@ -65,9 +65,22 @@ class ViewResponse(BaseModel): encoding: str truncated: bool size: int + modified: str content: str +class SaveRequest(BaseModel): + path: str + content: str + expected_modified: str + + +class SaveResponse(BaseModel): + path: str + size: int + modified: 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 f01de93d1a590b5a77ef2c9a923209eff8c32b4e..830ceb9d80359a5cd6f097a8dcce54b23e7557fe 100644 GIT binary patch delta 1798 zcmZWpTWl0n7(Qn%yEC(w*>*~I+X5X*8#|O%3_&Qdaxq{cNeO#gv6fht-6>t%?vgXZ zr3-O+(1dCXk`rPZO(;*wgSRv$A|@urM-sc%q|Jm7AAIq}P@7=FljlF%i@J+zbpc9BYIKBuOGGR+L*Q5*1cErm+)o(n4Zj z?t-)NMNJZ?iPO5%I@^!0$>G~Lko?MH_N%g!Z^`3rOjGZLY@n^PfjW(G5A(x!IQD4| zLN9@TfHmsBgf;`+&F<@W@oqL{+`_wO_eRc3ksk2B46twZZfpmRXq?Lk;pLN7(!4#!p#j)zwx8ENl(2$l4V-n|mcst6qq6O@q@R0?O5 zjF!=lhtuY2?fNq#6PYOe5>fjUmPl->4h_idXcRl4!M|W*ng;0N;NT$SQd*{-qtK1u z1#tr1r{_w!tncQ7aIReP-IDKU)PcASuniys@CJtA_^#?DxbGJj3jULkTOnV0G54 z&Y9Qet!-DeMdmg)TRIk#t)En`S7y4u>HoMgmmFBq)r9d7sk-rR48=EnH1gO3 z;eTj6*e?IvZXE0l=M-+QE-q#Lu0CKVjke}Dw-Veh*=U&GUwvh8T4gt*tx`I~CgnDa z*jaN!b?x4W)F*W!M;>%AE%FLST363e1LFcM`0j}BUi6<^x&@#E0M10W0(1h1VV3HO+T7tm#}z3lXdY}n{ET{H zY~7W)g-Q1IbGU;3Fs*A7lM^3ax~kkalM80oZ)Vp_{`2u$<3DOYMejsw=Ai}iXw5u2 zZysAPhic}~+*_yS@~7v`u{mu_=yw}AA-bJ+qaX_HWpfQXtKz?F>~2sF?*`$ z+LvuO$NxIwih9l;?cKxc*H8QobQOO9nALit`dj1+IruxQr;3RdJ3X1YiC%_NJ#%14 zbsE@%#upE~0KNMG_5-{E@G8I&02ptY1tm@tHvW+RgH&n6lz?SB(;E-3qw O$1pD7e~@^HoBa=FzkA{U delta 1038 zcmY+COK4L;6o${_-rQGu)5KcSMr%_Q+N%}?TOYL%tZ%_0j-`slm^QaHB~A1str~?c zbg7^WDlPzs8OBXI&$c5^{l{>3oK}4K0wLWI?@t^t6`I%wn{@TZF(XX!S zfLs&D9_7zFSE3IlCuDUM1^^ic3~`1UcX=oYqdMw5$~C3Jyq4=q89c_rN|`*)4W%sE zXDVe&Whv$G1-yP>_(ERKBfqN}UW4w4?JWx8JExAai<;@NAXKSV#f-krXpjfO zMzJ#d+E_}mVd_Wt24kMMW!_A}Ngo;tT#=hQ%CA{6mx zznhSrns(Q+h$*!Oz`k}fanD{YzSS)h&oxUNs~v1O)YEkVAkErj53O7jAO%Mv;zw;` zi`8e%?`OjR{U(RLkPbz&Pw%t(%>%C37;D<5t$-F-4fCBj1~42-L9@(BLGP}#)(peU zGkQNs18_fKxrDs9;Uku?>}ld=EIDanD|zbX6o#B|P$JL@elaj|{!l6Bdp4>Yt|!AD z%bv~*o~iid!19aI&KE~8Nlv(sAIn#8K#gPe6y~HV+Y+hyg)uVY=x1}8iZAxYJJ=;L z8Q&q^##^^HE_;_qUawxM-fDZe>2mc=B0Xd3G3PVrwlf=nXw#+4_i{uAJYCS^YA;-; zXR&z~JOfwCJv0cb#NqmNlXT9x`h--0-digAvQ0gZYD@pufG1`7+YhvI1yT7(O%yQa z>-c{xmdL81QEM(*4aiaXy_GyHy=v`Hwou3p7W~w?6rJOrL{<%tSBBd+%K5+1$0=34 zN}kl8Lj-y*KUDFe;?AP=-Wswt5VjDu5_S;w5cUx=go0R_Sj>u|E3t%ii2g*oH=C>p zJ2N1Gb<|-=cvj$KGQ;DQar848l>Zj6U%X7T>@Jd2ed^t;?y*vCyx?!av(liyRc?~_ btTD!>VcAD;KS1j=G|U>?7<0Y=QH}os|5V=6 diff --git a/webui/backend/app/fs/filesystem_adapter.py b/webui/backend/app/fs/filesystem_adapter.py index 1cfb58e..4d431be 100644 --- a/webui/backend/app/fs/filesystem_adapter.py +++ b/webui/backend/app/fs/filesystem_adapter.py @@ -65,11 +65,25 @@ class FilesystemAdapter: limit = max_bytes + 1 with path.open("rb") as in_f: raw = in_f.read(limit) + modified = self.modified_iso(path) truncated = size > max_bytes or len(raw) > max_bytes if truncated: raw = raw[:max_bytes] return { "size": size, + "modified": modified, "truncated": truncated, "content": raw.decode(encoding, errors="replace"), } + + def write_text_file(self, path: Path, content: str, encoding: str = "utf-8") -> dict: + path.write_text(content, encoding=encoding) + return { + "size": int(path.stat().st_size), + "modified": self.modified_iso(path), + } + + @staticmethod + def modified_iso(path: Path) -> str: + stat = path.stat() + return datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat().replace("+00:00", "Z") 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 484366ee8b0a942a3f79537079d982df27946f56..f1f9a05ebf85e65a60000a9e33f9a873eb356218 100644 GIT binary patch delta 3322 zcmZ`*TWlN06`duQ&*f9(lFNt06)oAKAF}*Vtw$WkcBD2=BG+EAuq&r5MXe;-5-H5C zWZ4O#76FR1A8CbY3!&&o``e-c3Zq5PqDX+o{wr!mDabBr*g%n@MS((T?0opE_YOr- zuAPy1=FXivcjnICGc$iU{R=JdncwdvQ1;FLx?JpA4=BP<*7u#da!#U>N;IN5UiO{y zQ$LrTF9*&AX%J)=4Z$z0x#7>FdErmee41YiXu*CD?cn-Qzl}x|k|kO=NwkhZ8%{$A zjjsRhkl58f8CsUKM2KbB#n|pt7W;BGoHqVX0`&(=K+| zH7O+6yDnA8-ukoankZ!03%PB^8MK9>9 zWy362=9+E?urC6$&>ID+FBHu(nBat#qqtBm8uU0CIGZb>N1#EEAz%(kH3Q5 z?HHWU%!#{VXfIKOE?gn3t`f-#i?`Mz?+9@_Xj?@B0ouZ619mpz85Qj8bVz3Jdm`+9 zFwA}_$3Qa>>*V?&ro`gBeT=uGtl!~ISH!$nQV5yC{oqA&LH^q{G0%qvZGGGNB5VWb zaDu!SCvdZ3ILaQ!yy=l25>kB93DTFMyTan4JOOu7M7hjqz zyn6al;mqa9F%6R3nK*pDo!NLWQ(Q~V)lP5A4KWum<|Y^Cpx<&WQGKPXUvDzg1u9H? zD3JL#5?#(8gU0wH`@o-K?dL8_a_ZwV`AsFy z-gWn|N^&|br#HS{llvOc)W%D-=-yAmo{)Eoc-Z~qX!1i}ZtO6*eb_rL+iwrY$3ylz zAqU7Mk*RXj_7$YmupRjE6sbf`D%3Ygh3#VkwmGSkFCY|}rF~Ojh=!NEjopn!hcr(s z_geK7o+G?GgIRc4A+yk1hFy>%v5Se8dAsJT=BrUFj!W9e^`{t$7hcOxo1yHnS8hfU z%iG{`3;;2mQ{64c;5H=5Yfi1nIj7d*oc)=cbM4|>kY9EkyEx}+#oOW>P|kJQ^)%(^ z6_}s42`2QiNAZkg6jyZm8g$*qSGYF6kA0CTHW6XmaIQhx`t8a`qE0;vtv?-@g9+H^2Ml#@uFb@20fxtM$}@TIxVOHB?Ity_cyEU#JaV*i2o#>sA_4YEv5E4l@m91RQpIi_5<2O(cC+9-26--ln^@MXS~WnnN;BOi^7P*`*2klb$GXK zvSXP(A0L2b6FHg9Wy&_{Wq-=-6D0O1)4RsM%_4f@T>TD8Zy|8+PtIt$p97XHFX+$H z4KTqMg@IS?mR%5pdnEe_Q9dCD?vus8ki~oC)IBozAhG8zi8W&CT@rcVQyTCuB^$nY Z!>=^_nTC{R@2G0+IcX zQPac+eKRQ&q7VKBCK|OdCPovYU`#Oa>%n9VffqDBs8#xE;yJVZ01{`D-#zo2Gv~}X zXU^<<))%9pd%>WOz*>7s4Y5y8+BNc)b}&(LLg~fYJ9TP)&EsD<4FqeF*yzG=#kf2{91N&K*GUF@(nv zJOHMb(vIIW&Dt2*W==>oOenKjE~@k!w5$J>Rte2WAF~~ zc`!qdt=ta2ulh5nB<*L#+p(eJ$8mlFVGQAaek6sSi*zDp|8XTb2h{Z@&j3nD7lNP_Elj7-UTCKRITq4xnqPw7~ zQ)DtC-u3Mer=v+R*zQYUHw?Qf>U0WR%Q1yU6R4m}b_v6uShBoRsujip!Z))1jpEme z4eMFbL#C6hy@co_-$h78o5mu@vC(PMog2)QHbh(UAhon%l|d_2v{9`{i{!9tJ1J5# zKEWqJ<#L-xO}48*|wz5Rx1v)fvL9& zp9Z_lz&dF=i#5j21I3?5un{f*U_YU}S#dsvQw+Lwi!G3vQTu*7cCpHNLh_vD3%0{v zc5HEAn{jcg_?Q-sM=;$F-+(;s)N7nAl-NtHn~YZl?*rZlTvc(Y8Gop8k-Q4a^@jK? zo)UldG{o7|sJN4w2`5tTJo!Un$KPR3#J5U3;%wKU_OGaU!XVcT-iF=jd*p*+S2m-3L&WK9hNcA1{z_xwY_6BWdy+E^Y^7$PUSqSReA%w# z=h*|hXT;aJoboLZf8?H8!W(8PGC?x86VL>BZ@3?!9RXi-NX#(G@>#(1NU7=O%xUln z*IO#GhouTW6fNZjO7bC5Tcwukl7&P*i%qw+P10Bm@ONp`#HK$k*U`YnF6>RoUiQ@; zZoFztR4dF3it)Y!yYXuCqX-iS&mj~6ATRxmYceV-u{pD5bYZ<}T^DOLpBR g_D{sVMULJgLw7oRzb8piG>5_sg&z8cAj(kw10cDcN&o-= diff --git a/webui/backend/app/services/file_ops_service.py b/webui/backend/app/services/file_ops_service.py index 963dabe..67eaedd 100644 --- a/webui/backend/app/services/file_ops_service.py +++ b/webui/backend/app/services/file_ops_service.py @@ -3,11 +3,12 @@ from __future__ import annotations from pathlib import Path from backend.app.api.errors import AppError -from backend.app.api.schemas import DeleteResponse, MkdirResponse, RenameResponse, ViewResponse +from backend.app.api.schemas import DeleteResponse, MkdirResponse, RenameResponse, SaveResponse, ViewResponse from backend.app.fs.filesystem_adapter import FilesystemAdapter from backend.app.security.path_guard import PathGuard TEXT_PREVIEW_MAX_BYTES = 256 * 1024 +TEXT_EDIT_MAX_BYTES = 256 * 1024 TEXT_CONTENT_TYPES = { ".txt": "text/plain", ".log": "text/plain", @@ -146,7 +147,7 @@ class FileOpsService: return DeleteResponse(path=resolved_target.relative) - def view(self, path: str) -> ViewResponse: + def view(self, path: str, for_edit: bool = False) -> ViewResponse: resolved_target = self._path_guard.resolve_existing_path(path) if resolved_target.absolute.is_dir(): @@ -173,6 +174,14 @@ class FileOpsService: details={"path": resolved_target.relative}, ) + if for_edit and resolved_target.absolute.stat().st_size > TEXT_EDIT_MAX_BYTES: + raise AppError( + code="file_too_large", + message="File is too large for edit", + status_code=409, + details={"path": resolved_target.relative}, + ) + try: preview = self._filesystem.read_text_preview( resolved_target.absolute, @@ -194,9 +203,71 @@ class FileOpsService: encoding="utf-8", truncated=preview["truncated"], size=preview["size"], + modified=preview["modified"], content=preview["content"], ) + def save(self, path: str, content: str, expected_modified: str) -> SaveResponse: + 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 save", + status_code=409, + details={"path": resolved_target.relative}, + ) + if self._content_type_for(resolved_target.absolute) is None: + raise AppError( + code="unsupported_type", + message="File type is not supported for edit", + status_code=409, + details={"path": resolved_target.relative}, + ) + if len(content.encode("utf-8")) > TEXT_EDIT_MAX_BYTES: + raise AppError( + code="file_too_large", + message="File is too large for edit", + status_code=409, + details={"path": resolved_target.relative}, + ) + + current_modified = self._filesystem.modified_iso(resolved_target.absolute) + if current_modified != expected_modified: + raise AppError( + code="conflict", + message="File changed since it was opened", + status_code=409, + details={"path": resolved_target.relative}, + ) + + try: + saved = self._filesystem.write_text_file( + resolved_target.absolute, + content=content, + encoding="utf-8", + ) + except OSError as exc: + raise AppError( + code="io_error", + message="Filesystem operation failed", + status_code=500, + details={"reason": str(exc)}, + ) + + return SaveResponse( + path=resolved_target.relative, + size=saved["size"], + modified=saved["modified"], + ) + @staticmethod def _join_relative(base: str, name: str) -> str: return f"{base}/{name}" if base else name diff --git a/webui/backend/tests/golden/__pycache__/test_api_edit_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_edit_golden.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8f449a7dc3e6019498a0bc5040ff8afb22817971 GIT binary patch literal 11550 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 delta 615 zcmdmN+hE1_nU|M~0SFc)Y|K2zw~=oeH#aYk%gn&Q_*sT=@_O#MlXHZ{n3;mOCl`uJ zi?aqZ2D1k<2XjP&RDnS-QxN}TMp5C(_XId)Oc`NCHlFEMcFC&<#}$J?2~o*BovFBfhuotmK2rdB`21orrcsF&a6tk#h#p>SCX1n zqRBD2p3j_*IVUym7F%jwa(+sxCiCRmd{V5%K$kL1X5>$1>Sman#_uzEuLy_b4IYtx z-%j6d{|6GlT+~O9mMnIeDFUG-LQ? zE(r%FS5J_L9f&}+=@wUFadB!41&_eoVT z#%|`6)?kzh1W84Kh*g9EOF3ljA xk`rBN>Bd9uFNO{A_ZkbnN68M1~4!vFz7P`%T3nfXlIn4yp7{tXq8}Qimg&=N@huZ zk#25&N@9*ul`xV}a(-S(YF>#_l?akxabj7jZc<5}Qk58zXmVm+a%v7peDh?^0463) zrp-^e1sNyb;E@y6S&?#u!}taVPd{%b?*%cv4J=nUOeeqPdBb{AR?16s^F7{cOpGp@ zw+i?%vg)v!e~;PBA>_@dXUAwcgX6k{=0yq3FAPA=2PY;57M_l(%gmBDSa{lfn|xdS OKQJ?}NEL|zJqG|3j$6C{ delta 157 zcmZ3i*{II@nU|M~0SL|{Y|NC|$orp}X$i|_K9=julV@^>O#aC(0H$R*cv99V G?g0RnwJt0G diff --git a/webui/backend/tests/golden/test_api_edit_golden.py b/webui/backend/tests/golden/test_api_edit_golden.py new file mode 100644 index 0000000..529e6fa --- /dev/null +++ b/webui/backend/tests/golden/test_api_edit_golden.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import asyncio +import sys +import tempfile +import time +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 FailingWriteFilesystemAdapter(FilesystemAdapter): + def write_text_file(self, path: Path, content: str, encoding: str = "utf-8") -> dict: + raise OSError("forced write failure") + + +class EditApiGoldenTest(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.path_guard = PathGuard({"storage1": str(self.root)}) + self._set_service(FilesystemAdapter()) + + def tearDown(self) -> None: + app.dependency_overrides.clear() + self.temp_dir.cleanup() + + def _set_service(self, filesystem: FilesystemAdapter) -> None: + service = FileOpsService(path_guard=self.path_guard, filesystem=filesystem) + + async def _override_file_ops_service() -> FileOpsService: + return service + + app.dependency_overrides[get_file_ops_service] = _override_file_ops_service + + def _request(self, method: str, url: str, params: dict | None = None, payload: dict | None = None) -> httpx.Response: + async def _run() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + if method == "GET": + return await client.get(url, params=params) + return await client.post(url, json=payload) + + return asyncio.run(_run()) + + def test_edit_view_success(self) -> None: + file_path = self.root / "notes.txt" + file_path.write_text("hello", encoding="utf-8") + + response = self._request("GET", "/api/files/view", params={"path": "storage1/notes.txt", "for_edit": "true"}) + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["path"], "storage1/notes.txt") + self.assertEqual(body["name"], "notes.txt") + self.assertEqual(body["content"], "hello") + self.assertFalse(body["truncated"]) + self.assertIn("modified", body) + + def test_save_success(self) -> None: + file_path = self.root / "notes.txt" + file_path.write_text("hello", encoding="utf-8") + initial = self._request("GET", "/api/files/view", params={"path": "storage1/notes.txt", "for_edit": "true"}).json() + + response = self._request( + "POST", + "/api/files/save", + payload={ + "path": "storage1/notes.txt", + "content": "changed", + "expected_modified": initial["modified"], + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(file_path.read_text(encoding="utf-8"), "changed") + self.assertEqual(response.json()["path"], "storage1/notes.txt") + self.assertEqual(response.json()["size"], len("changed".encode("utf-8"))) + + def test_unsupported_type(self) -> None: + (self.root / "report.pdf").write_bytes(b"%PDF-1.4") + + response = self._request("GET", "/api/files/view", params={"path": "storage1/report.pdf", "for_edit": "true"}) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "unsupported_type") + + def test_directory_type_conflict(self) -> None: + (self.root / "docs").mkdir() + + response = self._request("GET", "/api/files/view", params={"path": "storage1/docs", "for_edit": "true"}) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "type_conflict") + + def test_path_not_found(self) -> None: + response = self._request("POST", "/api/files/save", payload={"path": "storage1/missing.txt", "content": "x", "expected_modified": "2026-01-01T00:00:00Z"}) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["error"]["code"], "path_not_found") + + def test_traversal_attempt(self) -> None: + response = self._request("POST", "/api/files/save", payload={"path": "storage1/../etc/passwd", "content": "x", "expected_modified": "2026-01-01T00:00:00Z"}) + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error"]["code"], "path_traversal_detected") + + def test_conflict_when_file_changed(self) -> None: + file_path = self.root / "notes.txt" + file_path.write_text("hello", encoding="utf-8") + initial = self._request("GET", "/api/files/view", params={"path": "storage1/notes.txt", "for_edit": "true"}).json() + time.sleep(0.02) + file_path.write_text("changed elsewhere", encoding="utf-8") + + response = self._request( + "POST", + "/api/files/save", + payload={ + "path": "storage1/notes.txt", + "content": "local edit", + "expected_modified": initial["modified"], + }, + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "conflict") + + def test_io_error_on_save_failure(self) -> None: + file_path = self.root / "notes.txt" + file_path.write_text("hello", encoding="utf-8") + initial = self._request("GET", "/api/files/view", params={"path": "storage1/notes.txt", "for_edit": "true"}).json() + self._set_service(FailingWriteFilesystemAdapter()) + + response = self._request( + "POST", + "/api/files/save", + payload={ + "path": "storage1/notes.txt", + "content": "local edit", + "expected_modified": initial["modified"], + }, + ) + + self.assertEqual(response.status_code, 500) + self.assertEqual(response.json()["error"]["code"], "io_error") + + def test_file_too_large_for_edit(self) -> None: + content = "x" * (300 * 1024) + (self.root / "big.txt").write_text(content, encoding="utf-8") + + response = self._request("GET", "/api/files/view", params={"path": "storage1/big.txt", "for_edit": "true"}) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "file_too_large") + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/backend/tests/golden/test_api_view_golden.py b/webui/backend/tests/golden/test_api_view_golden.py index 13dbc05..dd37697 100644 --- a/webui/backend/tests/golden/test_api_view_golden.py +++ b/webui/backend/tests/golden/test_api_view_golden.py @@ -49,18 +49,15 @@ class ViewApiGoldenTest(unittest.TestCase): 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", - }, - ) + body = response.json() + self.assertEqual(body["path"], "storage1/notes.md") + self.assertEqual(body["name"], "notes.md") + self.assertEqual(body["content_type"], "text/markdown") + self.assertEqual(body["encoding"], "utf-8") + self.assertFalse(body["truncated"]) + self.assertEqual(body["size"], len("# title\nhello\n".encode("utf-8"))) + self.assertEqual(body["content"], "# title\nhello\n") + self.assertIn("modified", body) def test_view_unsupported_type(self) -> None: (self.root / "report.pdf").write_bytes(b"%PDF-1.4") diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index a18c991..921881b 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -37,6 +37,10 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="edit-btn"', body) self.assertIn('id="viewer-modal"', body) self.assertIn('id="viewer-content"', body) + self.assertIn('id="editor-modal"', body) + self.assertIn('id="editor-content"', body) + self.assertIn('id="editor-save-btn"', body) + self.assertIn('id="editor-cancel-btn"', 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 c08fc4c..9744379 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -23,6 +23,11 @@ let state = { }; const ROW_JUMP_STEP = 10; let wildcardDialogMode = "select"; +let editorState = { + path: null, + originalContent: "", + modified: null, +}; function paneState(pane) { return state.panes[pane]; @@ -70,6 +75,20 @@ function viewerElements() { }; } +function editorElements() { + return { + overlay: document.getElementById("editor-modal"), + title: document.getElementById("editor-title"), + fileName: document.getElementById("editor-file-name"), + filePath: document.getElementById("editor-file-path"), + error: document.getElementById("editor-error"), + content: document.getElementById("editor-content"), + closeButton: document.getElementById("editor-close-btn"), + saveButton: document.getElementById("editor-save-btn"), + cancelButton: document.getElementById("editor-cancel-btn"), + }; +} + async function apiRequest(method, url, body) { const options = { method, headers: {} }; if (body !== undefined) { @@ -158,12 +177,25 @@ function updateActionButtons() { const exactlyOne = count === 1; const allFiles = hasSelection && selectedItems.every((item) => item.kind === "file"); document.getElementById("view-btn").disabled = !exactlyOne || !allFiles; + document.getElementById("edit-btn").disabled = !exactlyOne || !allFiles || !isEditableSelection(selectedItems[0] || null); document.getElementById("rename-btn").disabled = !exactlyOne; document.getElementById("delete-btn").disabled = !hasSelection; document.getElementById("copy-btn").disabled = !allFiles; document.getElementById("move-btn").disabled = !allFiles; } +function isEditableSelection(item) { + if (!item || item.kind !== "file") { + return false; + } + const name = item.name || ""; + const lower = name.toLowerCase(); + if (lower === "dockerfile" || lower === "containerfile") { + return true; + } + return [".txt", ".log", ".md", ".yml", ".yaml", ".json", ".js", ".css", ".html"].some((suffix) => lower.endsWith(suffix)); +} + function currentParentPath(path) { if (!path.includes("/")) { return null; @@ -650,6 +682,10 @@ function isViewerOpen() { return !viewerElements().overlay.classList.contains("hidden"); } +function isEditorOpen() { + return !editorElements().overlay.classList.contains("hidden"); +} + function escapeRegExp(text) { return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); } @@ -739,6 +775,33 @@ function closeViewer() { viewer.content.textContent = ""; } +function editorIsDirty() { + return editorElements().content.value !== editorState.originalContent; +} + +function resetEditorState() { + editorState = { + path: null, + originalContent: "", + modified: null, + }; +} + +function attemptCloseEditor() { + if (editorIsDirty() && !window.confirm("Discard unsaved changes?")) { + return; + } + closeEditor(); +} + +function closeEditor() { + const editor = editorElements(); + editor.overlay.classList.add("hidden"); + editor.error.textContent = ""; + editor.content.value = ""; + resetEditorState(); +} + async function openViewer() { const selectedItems = activePaneState().selectedItems; if (selectedItems.length !== 1 || selectedItems[0].kind !== "file") { @@ -766,6 +829,59 @@ async function openViewer() { } } +async function openEditor() { + const selectedItems = activePaneState().selectedItems; + if (selectedItems.length !== 1 || !isEditableSelection(selectedItems[0])) { + return; + } + const selected = selectedItems[0]; + const editor = editorElements(); + editor.overlay.classList.remove("hidden"); + editor.title.textContent = "Edit"; + editor.fileName.textContent = selected.name; + editor.filePath.textContent = selected.path; + editor.error.textContent = ""; + editor.content.value = ""; + editor.content.disabled = true; + editor.saveButton.disabled = true; + try { + const data = await apiRequest("GET", `/api/files/view?${new URLSearchParams({ path: selected.path, for_edit: "true" }).toString()}`); + editor.fileName.textContent = data.name; + editor.filePath.textContent = data.path; + editor.content.value = data.content; + editor.content.disabled = false; + editor.saveButton.disabled = false; + editorState.path = data.path; + editorState.originalContent = data.content; + editorState.modified = data.modified; + editor.content.focus(); + } catch (err) { + editor.error.textContent = err.message; + } +} + +async function saveEditor() { + if (!editorState.path) { + return; + } + const editor = editorElements(); + editor.error.textContent = ""; + try { + const response = await apiRequest("POST", "/api/files/save", { + path: editorState.path, + content: editor.content.value, + expected_modified: editorState.modified, + }); + editorState.originalContent = editor.content.value; + editorState.modified = response.modified; + setStatus(`Saved ${response.path}`); + closeEditor(); + await loadBrowsePane(state.activePane); + } catch (err) { + editor.error.textContent = err.message; + } +} + function moveCurrentRow(delta) { const pane = state.activePane; const model = paneState(pane); @@ -821,6 +937,13 @@ function clearSelectionForActivePane() { } function handleKeyboardShortcuts(event) { + if (isEditorOpen()) { + if (event.key === "Escape") { + event.preventDefault(); + attemptCloseEditor(); + } + return; + } if (isViewerOpen()) { if (event.key === "Escape") { event.preventDefault(); @@ -910,6 +1033,7 @@ function setupEvents() { setupPaneEvents("right"); document.addEventListener("keydown", handleKeyboardShortcuts); document.getElementById("view-btn").onclick = openViewer; + document.getElementById("edit-btn").onclick = openEditor; document.getElementById("rename-btn").onclick = renameSelected; document.getElementById("delete-btn").onclick = deleteSelected; document.getElementById("copy-btn").onclick = startCopySelected; @@ -943,6 +1067,16 @@ function setupEvents() { closeViewer(); } }; + + const editor = editorElements(); + editor.closeButton.onclick = attemptCloseEditor; + editor.cancelButton.onclick = attemptCloseEditor; + editor.saveButton.onclick = saveEditor; + editor.overlay.onclick = (event) => { + if (event.target === editor.overlay) { + attemptCloseEditor(); + } + }; } async function init() { diff --git a/webui/html/index.html b/webui/html/index.html index 64c5375..a47c820 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -99,6 +99,21 @@ + + diff --git a/webui/html/style.css b/webui/html/style.css index a009e36..e0a239b 100644 --- a/webui/html/style.css +++ b/webui/html/style.css @@ -397,6 +397,21 @@ button:disabled { user-select: text; } +.editor-content { + margin: 6px 0 8px 0; + padding: 10px; + min-height: 280px; + max-height: calc(100vh - 220px); + width: 100%; + resize: vertical; + 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; +} + @media (max-width: 1200px) { .workspace { grid-template-columns: 1fr;