feat: file edit added

This commit is contained in:
kodi
2026-03-11 14:09:44 +01:00
parent ba6a369f78
commit b93cb01879
18 changed files with 701 additions and 16 deletions
+241
View File
@@ -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
+15 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends 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.dependencies import get_file_ops_service
from backend.app.services.file_ops_service import FileOpsService from backend.app.services.file_ops_service import FileOpsService
@@ -36,6 +36,19 @@ async def delete(
@router.get("/view", response_model=ViewResponse) @router.get("/view", response_model=ViewResponse)
async def view( async def view(
path: str, path: str,
for_edit: bool = False,
service: FileOpsService = Depends(get_file_ops_service), service: FileOpsService = Depends(get_file_ops_service),
) -> ViewResponse: ) -> 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,
)
+13
View File
@@ -65,9 +65,22 @@ class ViewResponse(BaseModel):
encoding: str encoding: str
truncated: bool truncated: bool
size: int size: int
modified: str
content: 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): class TaskListItem(BaseModel):
id: str id: str
operation: str operation: str
@@ -65,11 +65,25 @@ class FilesystemAdapter:
limit = max_bytes + 1 limit = max_bytes + 1
with path.open("rb") as in_f: with path.open("rb") as in_f:
raw = in_f.read(limit) raw = in_f.read(limit)
modified = self.modified_iso(path)
truncated = size > max_bytes or len(raw) > max_bytes truncated = size > max_bytes or len(raw) > max_bytes
if truncated: if truncated:
raw = raw[:max_bytes] raw = raw[:max_bytes]
return { return {
"size": size, "size": size,
"modified": modified,
"truncated": truncated, "truncated": truncated,
"content": raw.decode(encoding, errors="replace"), "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")
+73 -2
View File
@@ -3,11 +3,12 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from backend.app.api.errors import AppError 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.fs.filesystem_adapter import FilesystemAdapter
from backend.app.security.path_guard import PathGuard from backend.app.security.path_guard import PathGuard
TEXT_PREVIEW_MAX_BYTES = 256 * 1024 TEXT_PREVIEW_MAX_BYTES = 256 * 1024
TEXT_EDIT_MAX_BYTES = 256 * 1024
TEXT_CONTENT_TYPES = { TEXT_CONTENT_TYPES = {
".txt": "text/plain", ".txt": "text/plain",
".log": "text/plain", ".log": "text/plain",
@@ -146,7 +147,7 @@ class FileOpsService:
return DeleteResponse(path=resolved_target.relative) 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) resolved_target = self._path_guard.resolve_existing_path(path)
if resolved_target.absolute.is_dir(): if resolved_target.absolute.is_dir():
@@ -173,6 +174,14 @@ class FileOpsService:
details={"path": resolved_target.relative}, 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: try:
preview = self._filesystem.read_text_preview( preview = self._filesystem.read_text_preview(
resolved_target.absolute, resolved_target.absolute,
@@ -194,9 +203,71 @@ class FileOpsService:
encoding="utf-8", encoding="utf-8",
truncated=preview["truncated"], truncated=preview["truncated"],
size=preview["size"], size=preview["size"],
modified=preview["modified"],
content=preview["content"], 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 @staticmethod
def _join_relative(base: str, name: str) -> str: def _join_relative(base: str, name: str) -> str:
return f"{base}/{name}" if base else name return f"{base}/{name}" if base else name
@@ -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()
@@ -49,18 +49,15 @@ class ViewApiGoldenTest(unittest.TestCase):
response = self._request("storage1/notes.md") response = self._request("storage1/notes.md")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual( body = response.json()
response.json(), self.assertEqual(body["path"], "storage1/notes.md")
{ self.assertEqual(body["name"], "notes.md")
"path": "storage1/notes.md", self.assertEqual(body["content_type"], "text/markdown")
"name": "notes.md", self.assertEqual(body["encoding"], "utf-8")
"content_type": "text/markdown", self.assertFalse(body["truncated"])
"encoding": "utf-8", self.assertEqual(body["size"], len("# title\nhello\n".encode("utf-8")))
"truncated": False, self.assertEqual(body["content"], "# title\nhello\n")
"size": len("# title\nhello\n".encode("utf-8")), self.assertIn("modified", body)
"content": "# title\nhello\n",
},
)
def test_view_unsupported_type(self) -> None: def test_view_unsupported_type(self) -> None:
(self.root / "report.pdf").write_bytes(b"%PDF-1.4") (self.root / "report.pdf").write_bytes(b"%PDF-1.4")
@@ -37,6 +37,10 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('id="edit-btn"', body) self.assertIn('id="edit-btn"', body)
self.assertIn('id="viewer-modal"', body) self.assertIn('id="viewer-modal"', body)
self.assertIn('id="viewer-content"', 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="mkdir-btn"', body)
self.assertIn('id="copy-btn"', body) self.assertIn('id="copy-btn"', body)
self.assertIn('id="move-btn"', body) self.assertIn('id="move-btn"', body)
+134
View File
@@ -23,6 +23,11 @@ let state = {
}; };
const ROW_JUMP_STEP = 10; const ROW_JUMP_STEP = 10;
let wildcardDialogMode = "select"; let wildcardDialogMode = "select";
let editorState = {
path: null,
originalContent: "",
modified: null,
};
function paneState(pane) { function paneState(pane) {
return state.panes[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) { async function apiRequest(method, url, body) {
const options = { method, headers: {} }; const options = { method, headers: {} };
if (body !== undefined) { if (body !== undefined) {
@@ -158,12 +177,25 @@ function updateActionButtons() {
const exactlyOne = count === 1; const exactlyOne = count === 1;
const allFiles = hasSelection && selectedItems.every((item) => item.kind === "file"); const allFiles = hasSelection && selectedItems.every((item) => item.kind === "file");
document.getElementById("view-btn").disabled = !exactlyOne || !allFiles; 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("rename-btn").disabled = !exactlyOne;
document.getElementById("delete-btn").disabled = !hasSelection; document.getElementById("delete-btn").disabled = !hasSelection;
document.getElementById("copy-btn").disabled = !allFiles; document.getElementById("copy-btn").disabled = !allFiles;
document.getElementById("move-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) { function currentParentPath(path) {
if (!path.includes("/")) { if (!path.includes("/")) {
return null; return null;
@@ -650,6 +682,10 @@ function isViewerOpen() {
return !viewerElements().overlay.classList.contains("hidden"); return !viewerElements().overlay.classList.contains("hidden");
} }
function isEditorOpen() {
return !editorElements().overlay.classList.contains("hidden");
}
function escapeRegExp(text) { function escapeRegExp(text) {
return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
} }
@@ -739,6 +775,33 @@ function closeViewer() {
viewer.content.textContent = ""; 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() { async function openViewer() {
const selectedItems = activePaneState().selectedItems; const selectedItems = activePaneState().selectedItems;
if (selectedItems.length !== 1 || selectedItems[0].kind !== "file") { 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) { function moveCurrentRow(delta) {
const pane = state.activePane; const pane = state.activePane;
const model = paneState(pane); const model = paneState(pane);
@@ -821,6 +937,13 @@ function clearSelectionForActivePane() {
} }
function handleKeyboardShortcuts(event) { function handleKeyboardShortcuts(event) {
if (isEditorOpen()) {
if (event.key === "Escape") {
event.preventDefault();
attemptCloseEditor();
}
return;
}
if (isViewerOpen()) { if (isViewerOpen()) {
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault(); event.preventDefault();
@@ -910,6 +1033,7 @@ function setupEvents() {
setupPaneEvents("right"); setupPaneEvents("right");
document.addEventListener("keydown", handleKeyboardShortcuts); document.addEventListener("keydown", handleKeyboardShortcuts);
document.getElementById("view-btn").onclick = openViewer; document.getElementById("view-btn").onclick = openViewer;
document.getElementById("edit-btn").onclick = openEditor;
document.getElementById("rename-btn").onclick = renameSelected; document.getElementById("rename-btn").onclick = renameSelected;
document.getElementById("delete-btn").onclick = deleteSelected; document.getElementById("delete-btn").onclick = deleteSelected;
document.getElementById("copy-btn").onclick = startCopySelected; document.getElementById("copy-btn").onclick = startCopySelected;
@@ -943,6 +1067,16 @@ function setupEvents() {
closeViewer(); 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() { async function init() {
+15
View File
@@ -99,6 +99,21 @@
</div> </div>
</div> </div>
<div id="editor-modal" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="editor-title">
<div class="popup-card viewer-card">
<button id="editor-close-btn" class="viewer-close" type="button" aria-label="Close editor">X</button>
<h3 id="editor-title">Edit</h3>
<div id="editor-file-name" class="popup-meta"></div>
<div id="editor-file-path" class="popup-meta"></div>
<div id="editor-error" class="error"></div>
<textarea id="editor-content" class="editor-content" spellcheck="false"></textarea>
<div class="popup-actions">
<button id="editor-save-btn" type="button">Save</button>
<button id="editor-cancel-btn" type="button">Cancel</button>
</div>
</div>
</div>
<script src="/ui/app.js"></script> <script src="/ui/app.js"></script>
</body> </body>
</html> </html>
+15
View File
@@ -397,6 +397,21 @@ button:disabled {
user-select: text; 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) { @media (max-width: 1200px) {
.workspace { .workspace {
grid-template-columns: 1fr; grid-template-columns: 1fr;