feat: file viewer added

This commit is contained in:
kodi
2026-03-11 13:53:59 +01:00
parent 31a42d34c7
commit ba6a369f78
16 changed files with 550 additions and 2 deletions
+9 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends
from backend.app.api.schemas import DeleteRequest, DeleteResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse
from backend.app.api.schemas import DeleteRequest, DeleteResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, ViewResponse
from backend.app.dependencies import get_file_ops_service
from backend.app.services.file_ops_service import FileOpsService
@@ -31,3 +31,11 @@ async def delete(
service: FileOpsService = Depends(get_file_ops_service),
) -> DeleteResponse:
return service.delete(path=request.path)
@router.get("/view", response_model=ViewResponse)
async def view(
path: str,
service: FileOpsService = Depends(get_file_ops_service),
) -> ViewResponse:
return service.view(path=path)
+10
View File
@@ -58,6 +58,16 @@ class DeleteResponse(BaseModel):
path: str
class ViewResponse(BaseModel):
path: str
name: str
content_type: str
encoding: str
truncated: bool
size: int
content: str
class TaskListItem(BaseModel):
id: str
operation: str
@@ -59,3 +59,17 @@ class FilesystemAdapter:
if on_progress:
on_progress(out_f.tell())
shutil.copystat(src, dst, follow_symlinks=False)
def read_text_preview(self, path: Path, max_bytes: int, encoding: str = "utf-8") -> dict:
size = int(path.stat().st_size)
limit = max_bytes + 1
with path.open("rb") as in_f:
raw = in_f.read(limit)
truncated = size > max_bytes or len(raw) > max_bytes
if truncated:
raw = raw[:max_bytes]
return {
"size": size,
"truncated": truncated,
"content": raw.decode(encoding, errors="replace"),
}
+76 -1
View File
@@ -3,10 +3,27 @@ from __future__ import annotations
from pathlib import Path
from backend.app.api.errors import AppError
from backend.app.api.schemas import DeleteResponse, MkdirResponse, RenameResponse
from backend.app.api.schemas import DeleteResponse, MkdirResponse, RenameResponse, ViewResponse
from backend.app.fs.filesystem_adapter import FilesystemAdapter
from backend.app.security.path_guard import PathGuard
TEXT_PREVIEW_MAX_BYTES = 256 * 1024
TEXT_CONTENT_TYPES = {
".txt": "text/plain",
".log": "text/plain",
".md": "text/markdown",
".yml": "text/yaml",
".yaml": "text/yaml",
".json": "application/json",
".js": "text/javascript",
".css": "text/css",
".html": "text/html",
}
SPECIAL_TEXT_FILENAMES = {
"dockerfile": "text/plain",
"containerfile": "text/plain",
}
class FileOpsService:
def __init__(self, path_guard: PathGuard, filesystem: FilesystemAdapter):
@@ -129,6 +146,64 @@ class FileOpsService:
return DeleteResponse(path=resolved_target.relative)
def view(self, path: str) -> ViewResponse:
resolved_target = self._path_guard.resolve_existing_path(path)
if resolved_target.absolute.is_dir():
raise AppError(
code="type_conflict",
message="Source must be a file",
status_code=409,
details={"path": resolved_target.relative},
)
if not resolved_target.absolute.is_file():
raise AppError(
code="type_conflict",
message="Unsupported path type for view",
status_code=409,
details={"path": resolved_target.relative},
)
content_type = self._content_type_for(resolved_target.absolute)
if content_type is None:
raise AppError(
code="unsupported_type",
message="File type is not supported for preview",
status_code=409,
details={"path": resolved_target.relative},
)
try:
preview = self._filesystem.read_text_preview(
resolved_target.absolute,
max_bytes=TEXT_PREVIEW_MAX_BYTES,
encoding="utf-8",
)
except OSError as exc:
raise AppError(
code="io_error",
message="Filesystem operation failed",
status_code=500,
details={"reason": str(exc)},
)
return ViewResponse(
path=resolved_target.relative,
name=resolved_target.absolute.name,
content_type=content_type,
encoding="utf-8",
truncated=preview["truncated"],
size=preview["size"],
content=preview["content"],
)
@staticmethod
def _join_relative(base: str, name: str) -> str:
return f"{base}/{name}" if base else name
@staticmethod
def _content_type_for(path: Path) -> str | None:
special_name = SPECIAL_TEXT_FILENAMES.get(path.name.lower())
if special_name:
return special_name
return TEXT_CONTENT_TYPES.get(path.suffix.lower())
@@ -0,0 +1,107 @@
from __future__ import annotations
import asyncio
import sys
import tempfile
import unittest
from pathlib import Path
import httpx
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
from backend.app.dependencies import get_file_ops_service
from backend.app.fs.filesystem_adapter import FilesystemAdapter
from backend.app.main import app
from backend.app.security.path_guard import PathGuard
from backend.app.services.file_ops_service import FileOpsService
class ViewApiGoldenTest(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)
path_guard = PathGuard({"storage1": str(self.root)})
service = FileOpsService(path_guard=path_guard, filesystem=FilesystemAdapter())
async def _override_file_ops_service() -> FileOpsService:
return service
app.dependency_overrides[get_file_ops_service] = _override_file_ops_service
def tearDown(self) -> None:
app.dependency_overrides.clear()
self.temp_dir.cleanup()
def _request(self, path: str) -> httpx.Response:
async def _run() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
return await client.get("/api/files/view", params={"path": path})
return asyncio.run(_run())
def test_view_supported_text_success(self) -> None:
file_path = self.root / "notes.md"
file_path.write_text("# title\nhello\n", encoding="utf-8")
response = self._request("storage1/notes.md")
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.json(),
{
"path": "storage1/notes.md",
"name": "notes.md",
"content_type": "text/markdown",
"encoding": "utf-8",
"truncated": False,
"size": len("# title\nhello\n".encode("utf-8")),
"content": "# title\nhello\n",
},
)
def test_view_unsupported_type(self) -> None:
(self.root / "report.pdf").write_bytes(b"%PDF-1.4")
response = self._request("storage1/report.pdf")
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["error"]["code"], "unsupported_type")
def test_view_directory_type_conflict(self) -> None:
(self.root / "docs").mkdir()
response = self._request("storage1/docs")
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["error"]["code"], "type_conflict")
def test_view_path_not_found(self) -> None:
response = self._request("storage1/missing.txt")
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["error"]["code"], "path_not_found")
def test_view_traversal_attempt(self) -> None:
response = self._request("storage1/../etc/passwd")
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error"]["code"], "path_traversal_detected")
def test_view_truncated_response_for_large_file(self) -> None:
content = "x" * (300 * 1024)
(self.root / "big.log").write_text(content, encoding="utf-8")
response = self._request("storage1/big.log")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertTrue(body["truncated"])
self.assertEqual(body["size"], len(content.encode("utf-8")))
self.assertEqual(len(body["content"]), 256 * 1024)
if __name__ == "__main__":
unittest.main()
@@ -35,6 +35,8 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('id="function-bar"', body)
self.assertIn('id="view-btn"', body)
self.assertIn('id="edit-btn"', body)
self.assertIn('id="viewer-modal"', body)
self.assertIn('id="viewer-content"', body)
self.assertIn('id="mkdir-btn"', body)
self.assertIn('id="copy-btn"', body)
self.assertIn('id="move-btn"', body)
+67
View File
@@ -58,6 +58,18 @@ function showActionSummary(action, successes, failures, firstError) {
setStatus(base);
}
function viewerElements() {
return {
overlay: document.getElementById("viewer-modal"),
title: document.getElementById("viewer-title"),
fileName: document.getElementById("viewer-file-name"),
filePath: document.getElementById("viewer-file-path"),
error: document.getElementById("viewer-error"),
content: document.getElementById("viewer-content"),
closeButton: document.getElementById("viewer-close-btn"),
};
}
async function apiRequest(method, url, body) {
const options = { method, headers: {} };
if (body !== undefined) {
@@ -145,6 +157,7 @@ function updateActionButtons() {
const hasSelection = count > 0;
const exactlyOne = count === 1;
const allFiles = hasSelection && selectedItems.every((item) => item.kind === "file");
document.getElementById("view-btn").disabled = !exactlyOne || !allFiles;
document.getElementById("rename-btn").disabled = !exactlyOne;
document.getElementById("delete-btn").disabled = !hasSelection;
document.getElementById("copy-btn").disabled = !allFiles;
@@ -633,6 +646,10 @@ function isWildcardPopupOpen() {
return !wildcardPopupElements().overlay.classList.contains("hidden");
}
function isViewerOpen() {
return !viewerElements().overlay.classList.contains("hidden");
}
function escapeRegExp(text) {
return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
}
@@ -715,6 +732,40 @@ function openWildcardPopup(mode) {
elements.input.focus();
}
function closeViewer() {
const viewer = viewerElements();
viewer.overlay.classList.add("hidden");
viewer.error.textContent = "";
viewer.content.textContent = "";
}
async function openViewer() {
const selectedItems = activePaneState().selectedItems;
if (selectedItems.length !== 1 || selectedItems[0].kind !== "file") {
return;
}
const selected = selectedItems[0];
const viewer = viewerElements();
viewer.overlay.classList.remove("hidden");
viewer.title.textContent = "View";
viewer.fileName.textContent = selected.name;
viewer.filePath.textContent = selected.path;
viewer.error.textContent = "";
viewer.content.textContent = "Loading...";
try {
const data = await apiRequest("GET", `/api/files/view?${new URLSearchParams({ path: selected.path }).toString()}`);
viewer.fileName.textContent = data.name;
viewer.filePath.textContent = data.path;
viewer.content.textContent = data.content;
if (data.truncated) {
viewer.error.textContent = "Preview truncated for safety";
}
} catch (err) {
viewer.content.textContent = "";
viewer.error.textContent = err.message;
}
}
function moveCurrentRow(delta) {
const pane = state.activePane;
const model = paneState(pane);
@@ -770,6 +821,13 @@ function clearSelectionForActivePane() {
}
function handleKeyboardShortcuts(event) {
if (isViewerOpen()) {
if (event.key === "Escape") {
event.preventDefault();
closeViewer();
}
return;
}
if (isWildcardPopupOpen()) {
return;
}
@@ -851,6 +909,7 @@ function setupEvents() {
setupPaneEvents("left");
setupPaneEvents("right");
document.addEventListener("keydown", handleKeyboardShortcuts);
document.getElementById("view-btn").onclick = openViewer;
document.getElementById("rename-btn").onclick = renameSelected;
document.getElementById("delete-btn").onclick = deleteSelected;
document.getElementById("copy-btn").onclick = startCopySelected;
@@ -876,6 +935,14 @@ function setupEvents() {
closeWildcardPopup();
}
};
const viewer = viewerElements();
viewer.closeButton.onclick = closeViewer;
viewer.overlay.onclick = (event) => {
if (event.target === viewer.overlay) {
closeViewer();
}
};
}
async function init() {
+11
View File
@@ -88,6 +88,17 @@
</div>
</div>
<div id="viewer-modal" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="viewer-title">
<div class="popup-card viewer-card">
<button id="viewer-close-btn" class="viewer-close" type="button" aria-label="Close viewer">X</button>
<h3 id="viewer-title">View</h3>
<div id="viewer-file-name" class="popup-meta"></div>
<div id="viewer-file-path" class="popup-meta"></div>
<div id="viewer-error" class="error"></div>
<pre id="viewer-content" class="viewer-content"></pre>
</div>
</div>
<script src="/ui/app.js"></script>
</body>
</html>
+31
View File
@@ -366,6 +366,37 @@ button:disabled {
justify-content: flex-end;
}
.viewer-card {
position: relative;
width: min(960px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
display: flex;
flex-direction: column;
}
.viewer-close {
position: absolute;
top: 10px;
right: 10px;
min-width: 32px;
padding: 2px 8px;
}
.viewer-content {
margin: 6px 0 0 0;
padding: 10px;
min-height: 240px;
max-height: calc(100vh - 180px);
overflow: auto;
border: 1px solid var(--border);
background: #f8fafc;
color: var(--text);
font: 12px/1.45 "SFMono-Regular", Consolas, "Liberation Mono", monospace;
white-space: pre-wrap;
word-break: break-word;
user-select: text;
}
@media (max-width: 1200px) {
.workspace {
grid-template-columns: 1fr;