From 232450ee1182c8ac1066d0accb66fa1a5c8e87a4 Mon Sep 17 00:00:00 2001 From: dimakis Date: Wed, 1 Apr 2026 08:04:55 +0100 Subject: [PATCH] feat: file browser with markdown viewer New /files route with directory browsing and file viewing. Markdown files render with react-markdown + remark-gfm (headings, tables, code blocks, links). Non-markdown files display as preformatted text. Navigation: back button, parent directory (..), breadcrumb via URL params. Files quick action card added to home screen. Cherry-picked from feat/markdown-viewer and adapted for the refactored codebase (session-stabilization changes). Made-with: Cursor --- frontend/src/App.tsx | 13 +- frontend/src/pages/FileViewer.tsx | 140 +++++++++++++++++ frontend/src/pages/SessionList.tsx | 2 + frontend/src/styles/global.css | 243 +++++++++++++++++++++++++++++ 4 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/FileViewer.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 575494ef..ea31ae44 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'; import { Login } from './pages/Login'; import { SessionList } from './pages/SessionList'; import { ChatView } from './pages/ChatView'; +import { FileViewer } from './pages/FileViewer'; function ProtectedRoute({ children }: { children: React.ReactNode }) { const [auth, setAuth] = useState<'loading' | 'ok' | 'denied'>('loading'); @@ -11,8 +12,10 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { .then((r) => setAuth(r.ok ? 'ok' : 'denied')) .catch(() => setAuth('denied')); }, []); - if (auth === 'loading') return null; if (auth === 'denied') return ; + if (auth === 'loading') { + return
; + } return <>{children}; } @@ -45,6 +48,14 @@ export function App() { } /> + + + + } + /> ); diff --git a/frontend/src/pages/FileViewer.tsx b/frontend/src/pages/FileViewer.tsx new file mode 100644 index 00000000..bd30e14d --- /dev/null +++ b/frontend/src/pages/FileViewer.tsx @@ -0,0 +1,140 @@ +import { useState, useEffect } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +interface DirEntry { + name: string; + isDir: boolean; +} + +export function FileViewer() { + const [searchParams, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); + + const filePath = searchParams.get('path') || ''; + const dirPath = searchParams.get('dir') || ''; + const isViewing = !!filePath; + + const [content, setContent] = useState(''); + const [ext, setExt] = useState(''); + const [entries, setEntries] = useState([]); + const [currentDir, setCurrentDir] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + setLoading(true); + setError(''); + + if (isViewing) { + fetch(`/api/files/read?path=${encodeURIComponent(filePath)}`) + .then((r) => { + if (!r.ok) throw new Error('Failed to load file'); + return r.json(); + }) + .then((data) => { + setContent(data.content); + setExt(data.ext); + }) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + } else { + fetch(`/api/files?dir=${encodeURIComponent(dirPath)}`) + .then((r) => { + if (!r.ok) throw new Error('Failed to load directory'); + return r.json(); + }) + .then((data) => { + setEntries(data.entries); + setCurrentDir(data.dir); + }) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + } + }, [filePath, dirPath, isViewing]); + + function openEntry(entry: DirEntry) { + const full = currentDir ? `${currentDir}/${entry.name}` : entry.name; + if (entry.isDir) { + setSearchParams({ dir: full }); + } else { + setSearchParams({ path: full }); + } + } + + function goUp() { + if (!currentDir) return; + const parent = currentDir.replace(/\/[^/]+$/, ''); + if (parent === currentDir) { + setSearchParams({}); + } else { + setSearchParams({ dir: parent }); + } + } + + function handleBack() { + if (isViewing) { + const parentDir = filePath.replace(/\/[^/]+$/, ''); + setSearchParams(parentDir ? { dir: parentDir } : {}); + setContent(''); + setExt(''); + } else if (currentDir) { + goUp(); + } else { + navigate('/'); + } + } + + const isMarkdown = ['.md', '.mdx'].includes(ext); + const fileName = filePath.split('/').pop() || ''; + const dirName = currentDir.split('/').pop() || 'Files'; + + return ( +
+
+ + {isViewing ? fileName : dirName} +
+ +
+ {loading &&

Loading...

} + {error &&

{error}

} + + {!loading && !error && isViewing && isMarkdown && ( +
+ {content} +
+ )} + + {!loading && !error && isViewing && !isMarkdown && ( +
{content}
+ )} + + {!loading && !error && !isViewing && ( +
+ {currentDir && ( + + )} + {entries.map((entry) => ( + + ))} + {entries.length === 0 &&

Empty directory

} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/SessionList.tsx b/frontend/src/pages/SessionList.tsx index f9ca782d..7dbf26c7 100644 --- a/frontend/src/pages/SessionList.tsx +++ b/frontend/src/pages/SessionList.tsx @@ -52,6 +52,8 @@ function buildQuickActions(repoPath: string): QuickAction[] { }); } + actions.push({ label: 'Files', desc: 'Browse repo files', path: '/files' }); + return actions; } diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 6c42ca6e..0163a950 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -2083,6 +2083,249 @@ textarea:focus { border-radius: 8px; } +/* ===== File Viewer ===== */ + +.viewer-page { + display: flex; + flex-direction: column; + height: 100dvh; + height: 100svh; + position: fixed; + inset: 0; + background: var(--bg); +} + +.viewer-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0.75rem; + background: var(--surface); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + min-height: 3rem; +} + +.viewer-header-back { + font-size: 0.85rem; + padding: 0.35rem 0.6rem; + background: transparent; + color: var(--text); + flex-shrink: 0; + min-width: unset; +} + +.viewer-header-back:hover { + background: var(--border); +} + +.viewer-header-title { + font-size: 0.95rem; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.viewer-content { + flex: 1; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + min-height: 0; +} + +.viewer-status { + text-align: center; + color: var(--text-dim); + padding: 2rem 1rem; + font-size: 0.9rem; +} + +.viewer-status--error { + color: var(--danger); +} + +.viewer-dir { + display: flex; + flex-direction: column; +} + +.viewer-entry { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border); + background: transparent; + color: var(--text); + font-size: 0.9rem; + text-align: left; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + border-radius: 0; + width: 100%; +} + +.viewer-entry:active { + background: rgba(108, 99, 255, 0.08); +} + +.viewer-entry-icon { + font-family: var(--code-font); + font-size: 0.85rem; + color: var(--accent); + width: 1rem; + text-align: center; + flex-shrink: 0; +} + +.viewer-entry--up .viewer-entry-icon { + color: var(--text-dim); +} + +.viewer-entry-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.viewer-entry--dir .viewer-entry-name { + font-weight: 600; +} + +.viewer-markdown { + padding: 1rem; + font-size: 0.9rem; + line-height: 1.6; + color: var(--text); + -webkit-user-select: text; + user-select: text; +} + +.viewer-markdown h1, +.viewer-markdown h2, +.viewer-markdown h3, +.viewer-markdown h4 { + font-weight: 600; + margin: 1em 0 0.4em; + color: #fff; +} + +.viewer-markdown h1 { + font-size: 1.3em; +} +.viewer-markdown h2 { + font-size: 1.15em; +} +.viewer-markdown h3 { + font-size: 1.05em; +} + +.viewer-markdown h1:first-child, +.viewer-markdown h2:first-child, +.viewer-markdown h3:first-child { + margin-top: 0; +} + +.viewer-markdown p { + margin-bottom: 0.6em; +} + +.viewer-markdown ul, +.viewer-markdown ol { + padding-left: 1.3em; + margin-bottom: 0.6em; +} + +.viewer-markdown li { + margin-bottom: 0.2em; +} + +.viewer-markdown code { + font-family: var(--code-font); + font-size: 0.82em; + background: var(--code-bg); + padding: 0.15em 0.35em; + border-radius: 4px; + color: #d4bfff; +} + +.viewer-markdown pre { + margin: 0.6em 0; + padding: 0.75rem; + background: var(--code-bg); + border: 1px solid var(--border); + border-radius: 8px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.viewer-markdown pre code { + background: transparent; + padding: 0; + font-size: 0.8rem; + line-height: 1.5; + color: var(--text); +} + +.viewer-markdown blockquote { + border-left: 3px solid var(--accent); + padding-left: 0.75em; + margin: 0.5em 0; + color: var(--text-dim); +} + +.viewer-markdown table { + border-collapse: collapse; + width: 100%; + margin: 0.5em 0; + font-size: 0.85em; +} + +.viewer-markdown th, +.viewer-markdown td { + border: 1px solid var(--border); + padding: 0.35em 0.6em; + text-align: left; +} + +.viewer-markdown th { + background: var(--code-bg); + font-weight: 600; + color: #fff; +} + +.viewer-markdown strong { + font-weight: 600; + color: #fff; +} + +.viewer-markdown a { + color: #a5a0ff; + text-decoration: underline; + text-underline-offset: 2px; +} + +.viewer-markdown hr { + border: none; + border-top: 1px solid var(--border); + margin: 0.75em 0; +} + +.viewer-code { + padding: 1rem; + font-family: var(--code-font); + font-size: 0.8rem; + line-height: 1.5; + color: var(--text); + white-space: pre-wrap; + word-break: break-word; + margin: 0; +} + /* ===== Scrollbar styling ===== */ ::-webkit-scrollbar {