diff --git a/navi/api/routes/sessions.py b/navi/api/routes/sessions.py index d3fb32c..4143570 100644 --- a/navi/api/routes/sessions.py +++ b/navi/api/routes/sessions.py @@ -28,7 +28,7 @@ from navi.core.name_generator import generate_session_name from navi.exceptions import ProfileNotFound from navi.memory import MemoryStore -from navi.session_files import delete_session_dir, ensure_session_dir, is_forbidden, safe_filename, session_dir +from navi.session_files import delete_session_dir, ensure_session_dir, is_forbidden, list_session_files, safe_filename, session_dir log = structlog.get_logger() @@ -305,6 +305,22 @@ } +@router.get("/{session_id}/files") +async def list_session_files_endpoint( + session_id: str, + store: Annotated[SessionStore, Depends(get_session_store)], + user: Annotated[User, Depends(require_user)], +) -> dict: + """Return all files and directories in the session's file directory (recursive, depth 10).""" + session = await store.get(session_id) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + check_session_access(session, user, permission="navi.files.read_all") + + files = list_session_files(session_id) + return {"session_id": session_id, "files": files} + + @router.get("/{session_id}/files/{filename}") async def download_file( session_id: str, diff --git a/navi/session_files.py b/navi/session_files.py index aa46208..6fb9c1a 100644 --- a/navi/session_files.py +++ b/navi/session_files.py @@ -7,6 +7,7 @@ import asyncio import shutil +from datetime import datetime, timezone from pathlib import Path import structlog @@ -74,6 +75,45 @@ log.info("session_files.orphan_deleted", session_id=session_id) +def list_session_files(session_id: str, max_depth: int = 10) -> list[dict]: + """Recursively list files and directories in the session directory. + + Returns a flat list ordered by path, each item containing: + { path, size, is_dir, modified_at }. + Hidden files (starting with '.') are included. + """ + base = session_dir(session_id) + if not base.exists(): + return [] + + out = [] + _scan_dir(base, base, out, current_depth=0, max_depth=max_depth) + return out + + +def _scan_dir(root: Path, current: Path, out: list, current_depth: int, max_depth: int) -> None: + if current_depth > max_depth: + return + try: + entries = sorted(current.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())) + except (PermissionError, OSError): + return + for entry in entries: + try: + stat = entry.stat() + rel = entry.relative_to(root).as_posix() + out.append({ + "path": rel, + "size": stat.st_size if entry.is_file() else 0, + "is_dir": entry.is_dir(), + "modified_at": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(), + }) + if entry.is_dir(): + _scan_dir(root, entry, out, current_depth + 1, max_depth) + except (PermissionError, OSError): + continue + + async def cleanup_loop(store) -> None: """Background task: remove orphaned session file dirs every hour.""" while True: diff --git a/webclient/src/api/index.js b/webclient/src/api/index.js index 73a3f4a..a9f9da6 100644 --- a/webclient/src/api/index.js +++ b/webclient/src/api/index.js @@ -104,6 +104,10 @@ } // ─── Files ───────────────────────────────────────────────────────────────── +export function listSessionFiles(sessionId) { + return request('GET', `/sessions/${sessionId}/files`) +} + export async function uploadFile(sessionId, file) { const form = new FormData() form.append('file', file) diff --git a/webclient/src/components/artifacts/ArtifactsPanel.vue b/webclient/src/components/artifacts/ArtifactsPanel.vue index cc93c5c..7f65671 100644 --- a/webclient/src/components/artifacts/ArtifactsPanel.vue +++ b/webclient/src/components/artifacts/ArtifactsPanel.vue @@ -33,6 +33,15 @@ Links {{ links.length }} + @@ -165,6 +174,54 @@ + + @@ -249,6 +306,17 @@ } return out }) + +const filesFlat = computed(() => chat.files) +const fileTree = computed(() => { + const list = filesFlat.value + return list.map(item => { + const parts = item.path.split('/') + const name = parts[parts.length - 1] + const depth = parts.length - 1 + return { ...item, name, depth } + }) +}) const selectedSourceLanguage = computed(() => selected.value ? sourceLanguage(selected.value) : 'plaintext') const highlightedSource = computed(() => highlightSource(sourceText.value, selectedSourceLanguage.value)) const isDesktop = computed(() => viewportWidth.value > DRAWER_DESKTOP_BREAKPOINT) @@ -494,6 +562,63 @@ window.open(l.url, '_blank', 'noopener,noreferrer') } } + +function fileIcon(item) { + if (item.is_dir) return 'ph ph-folder' + const ext = item.name.split('.').pop()?.toLowerCase() || '' + const map = { + js: 'ph ph-file-js', + ts: 'ph ph-file-ts', + py: 'ph ph-file-py', + html: 'ph ph-file-html', + css: 'ph ph-file-css', + json: 'ph ph-file-json', + md: 'ph ph-file-text', + txt: 'ph ph-file-text', + jpg: 'ph ph-file-image', + jpeg: 'ph ph-file-image', + png: 'ph ph-file-image', + gif: 'ph ph-file-image', + svg: 'ph ph-file-image', + webp: 'ph ph-file-image', + mp4: 'ph ph-file-video', + webm: 'ph ph-file-video', + mov: 'ph ph-file-video', + mp3: 'ph ph-file-audio', + wav: 'ph ph-file-audio', + ogg: 'ph ph-file-audio', + pdf: 'ph ph-file-pdf', + zip: 'ph ph-file-zip', + tar: 'ph ph-file-zip', + gz: 'ph ph-file-zip', + rar: 'ph ph-file-zip', + } + return map[ext] || 'ph ph-file' +} + +function fileMeta(item) { + if (item.is_dir) return 'Directory' + const size = item.size + if (size < 1024) return `${size} B` + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB` + return `${(size / (1024 * 1024)).toFixed(1)} MB` +} + +function fileIndent(item) { + return { paddingLeft: `${12 + item.depth * 16}px` } +} + +function fileDownloadUrl(path) { + const sessionId = chat.currentId + if (!sessionId) return '#' + return `/api/sessions/${sessionId}/files/${encodeURIComponent(path)}?download=1` +} + +function isInlineFile(path) { + const ext = path.split('.').pop()?.toLowerCase() || '' + const inlineExts = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'html', 'txt', 'md', 'pdf'] + return inlineExts.includes(ext) +}