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 6642e42..7ee3b6b 100644 Binary files a/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc and b/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc differ diff --git a/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc b/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc index d5915e9..629abb5 100644 Binary files a/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc and b/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc differ 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 f01de93..830ceb9 100644 Binary files a/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc and b/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc differ 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 484366e..f1f9a05 100644 Binary files a/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc and b/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc differ 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 0000000..8f449a7 Binary files /dev/null and b/webui/backend/tests/golden/__pycache__/test_api_edit_golden.cpython-313.pyc differ 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 index 69365aa..5d94e20 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_api_view_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_api_view_golden.cpython-313.pyc differ 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 4cd27a9..b1c2629 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc differ 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 @@ +
+