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
+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())