Skip to content

Commit cffe6a1

Browse files
dorlugasigalCopilot
andcommitted
fix(code-viewer): lazy-load file tree, allow in-root symlinks, auto-refresh git status
- File tree lazy-loads children on expand (root depth=1, subdirs on first expand) - Search triggers one-time deep load so matches include unexpanded dirs - Removed all filtering from /file-tree so node_modules and dotfiles appear - Replaced hard symlink rejection with realpath containment check on /download, /file-raw, /file-content — in-root symlinks now resolve, only escapes are blocked - Fix CodePanel stretch: rows no longer spread to viewport height on short files - GitChanges auto-refreshes every 3s while visible + on window focus/visibility Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d0e77ce commit cffe6a1

12 files changed

Lines changed: 589 additions & 141 deletions

File tree

docs/api.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ Download a file from within a session's working directory.
313313
**Response (403):**
314314

315315
```json
316-
{ "error": "Symbolic links are not allowed" }
316+
{ "error": "Symlink target is outside session directory" }
317317
```
318318

319319
**Response (404):**
@@ -379,7 +379,7 @@ Get the text content of a file. Used for in-browser file preview (e.g., markdown
379379
**Response (403):**
380380

381381
```json
382-
{ "error": "Symbolic links are not allowed" }
382+
{ "error": "Symlink target is outside session directory" }
383383
```
384384

385385
**Response (404):**
@@ -431,7 +431,7 @@ Serve a file inline from a session's working directory. Unlike the `/download` e
431431
**Response (403):**
432432

433433
```json
434-
{ "error": "Symbolic links are not allowed" }
434+
{ "error": "Symlink target is outside session directory" }
435435
```
436436

437437
**Response (404):**

src/frontend/src/components/CodeViewer/CodePanel.module.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
.container {
99
display: flex;
1010
flex: 1;
11+
align-items: flex-start;
1112
overflow: auto;
1213
background: var(--bg);
1314
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;

src/frontend/src/components/CodeViewer/CodeViewer.tsx

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ export default function CodeViewer({ sessionId, onClose, initialView }: CodeView
2828
const {
2929
fileTree,
3030
setFileTree,
31+
mergeChildren,
32+
markDirLoaded,
33+
loadedDirs,
34+
deepLoaded,
35+
setDeepLoaded,
3136
openFiles,
3237
activeFilePath,
3338
expandedDirs,
@@ -111,13 +116,13 @@ export default function CodeViewer({ sessionId, onClose, initialView }: CodeView
111116
};
112117
}, [blameEnabled, activeFilePath, sessionId, setGitBlame]);
113118

114-
// Load file tree on mount
119+
// Load root file tree on mount (lazy — just depth=1).
115120
useEffect(() => {
116121
let cancelled = false;
117122
setTreeLoading(true);
118123
setTreeError(null);
119124

120-
fetchFileTree(sessionId)
125+
fetchFileTree(sessionId, 1)
121126
.then(({ tree }) => {
122127
if (!cancelled) {
123128
setFileTree(tree);
@@ -136,6 +141,45 @@ export default function CodeViewer({ sessionId, onClose, initialView }: CodeView
136141
};
137142
}, [sessionId, setFileTree]);
138143

144+
// Lazy-load children of a directory when the user expands it for the first time.
145+
const handleToggleDir = useCallback(
146+
(dirPath: string) => {
147+
const wasExpanded = expandedDirs.has(dirPath);
148+
toggleDir(dirPath);
149+
if (wasExpanded) return; // collapsing — nothing to fetch
150+
if (loadedDirs.has(dirPath) || deepLoaded) return;
151+
152+
// Mark as loaded optimistically to prevent duplicate requests.
153+
markDirLoaded(dirPath);
154+
fetchFileTree(sessionId, 1, dirPath)
155+
.then(({ tree }) => {
156+
mergeChildren(dirPath, tree);
157+
})
158+
.catch(() => {
159+
// Silent: user can retry by collapsing + re-expanding.
160+
});
161+
},
162+
[sessionId, expandedDirs, loadedDirs, deepLoaded, toggleDir, markDirLoaded, mergeChildren],
163+
);
164+
165+
// When the user starts searching, fetch a deep tree once so search can find files
166+
// inside directories the user hasn't expanded yet.
167+
const handleSearchQueryChange = useCallback(
168+
(query: string) => {
169+
if (!query.trim() || deepLoaded) return;
170+
setDeepLoaded(true);
171+
fetchFileTree(sessionId, 5)
172+
.then(({ tree }) => {
173+
setFileTree(tree);
174+
setDeepLoaded(true);
175+
})
176+
.catch(() => {
177+
setDeepLoaded(false); // allow retry
178+
});
179+
},
180+
[sessionId, deepLoaded, setFileTree, setDeepLoaded],
181+
);
182+
139183
const handleFileSelect = useCallback(
140184
async (filePath: string) => {
141185
if (openFiles.has(filePath)) {
@@ -335,7 +379,8 @@ export default function CodeViewer({ sessionId, onClose, initialView }: CodeView
335379
expandedDirs={expandedDirs}
336380
activeFilePath={activeFilePath}
337381
onFileSelect={handleFileSelect}
338-
onToggleDir={toggleDir}
382+
onToggleDir={handleToggleDir}
383+
onSearchQueryChange={handleSearchQueryChange}
339384
loading={treeLoading}
340385
/>
341386
) : (

src/frontend/src/components/CodeViewer/FileExplorer.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useMemo, useRef, forwardRef, useImperativeHandle } from 'react';
1+
import { useState, useMemo, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
22
import { type FileTreeNode } from '@/stores/codeViewerStore';
33
import { getFileIconUrl } from './fileIcons';
44
import styles from './FileExplorer.module.css';
@@ -13,6 +13,7 @@ interface FileExplorerProps {
1313
activeFilePath: string | null;
1414
onFileSelect: (path: string) => void;
1515
onToggleDir: (path: string) => void;
16+
onSearchQueryChange?: (query: string) => void;
1617
loading?: boolean;
1718
}
1819

@@ -120,7 +121,7 @@ function SearchResults({
120121
}
121122

122123
const FileExplorer = forwardRef<FileExplorerHandle, FileExplorerProps>(function FileExplorer(
123-
{ tree, expandedDirs, activeFilePath, onFileSelect, onToggleDir, loading },
124+
{ tree, expandedDirs, activeFilePath, onFileSelect, onToggleDir, onSearchQueryChange, loading },
124125
ref,
125126
) {
126127
const [search, setSearch] = useState('');
@@ -132,6 +133,10 @@ const FileExplorer = forwardRef<FileExplorerHandle, FileExplorerProps>(function
132133
},
133134
}));
134135

136+
useEffect(() => {
137+
onSearchQueryChange?.(search);
138+
}, [search, onSearchQueryChange]);
139+
135140
const allFiles = useMemo(() => {
136141
if (!tree) return [];
137142
const files: FileTreeNode[] = [];

src/frontend/src/components/CodeViewer/GitChanges.tsx

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useState } from 'react';
1+
import { useCallback, useEffect, useRef, useState } from 'react';
22
import { useCodeViewerStore } from '@/stores/codeViewerStore';
33
import { fetchGitStatus, fetchGitDiff } from '@/services/api';
44
import styles from './GitChanges.module.css';
@@ -43,26 +43,72 @@ export default function GitChanges({ sessionId }: GitChangesProps) {
4343
const { gitStatus, setGitStatus, setGitDiff, setDiffFile, diffFile } = useCodeViewerStore();
4444
const [loading, setLoading] = useState(false);
4545
const [error, setError] = useState<string | null>(null);
46+
const inFlightRef = useRef(false);
4647

47-
const loadStatus = useCallback(async () => {
48-
setLoading(true);
49-
setError(null);
50-
try {
51-
const status = await fetchGitStatus(sessionId);
52-
setGitStatus(status);
53-
} catch (err) {
54-
setError(err instanceof Error ? err.message : 'Failed to load git status');
55-
} finally {
56-
setLoading(false);
57-
}
58-
}, [sessionId, setGitStatus]);
48+
const loadStatus = useCallback(
49+
async (showSpinner = true) => {
50+
if (inFlightRef.current) return;
51+
inFlightRef.current = true;
52+
if (showSpinner) setLoading(true);
53+
try {
54+
const status = await fetchGitStatus(sessionId);
55+
setGitStatus(status);
56+
setError(null);
57+
} catch (err) {
58+
setError(err instanceof Error ? err.message : 'Failed to load git status');
59+
} finally {
60+
if (showSpinner) setLoading(false);
61+
inFlightRef.current = false;
62+
}
63+
},
64+
[sessionId, setGitStatus],
65+
);
5966

6067
useEffect(() => {
6168
if (!gitStatus) {
6269
loadStatus();
6370
}
6471
}, [gitStatus, loadStatus]);
6572

73+
useEffect(() => {
74+
const POLL_MS = 3000;
75+
let timer: ReturnType<typeof setInterval> | null = null;
76+
77+
const start = () => {
78+
if (timer) return;
79+
timer = setInterval(() => {
80+
if (document.visibilityState === 'visible') {
81+
loadStatus(false);
82+
}
83+
}, POLL_MS);
84+
};
85+
const stop = () => {
86+
if (timer) {
87+
clearInterval(timer);
88+
timer = null;
89+
}
90+
};
91+
const onVisibility = () => {
92+
if (document.visibilityState === 'visible') {
93+
loadStatus(false);
94+
start();
95+
} else {
96+
stop();
97+
}
98+
};
99+
const onFocus = () => loadStatus(false);
100+
101+
if (document.visibilityState === 'visible') start();
102+
document.addEventListener('visibilitychange', onVisibility);
103+
window.addEventListener('focus', onFocus);
104+
105+
return () => {
106+
stop();
107+
document.removeEventListener('visibilitychange', onVisibility);
108+
window.removeEventListener('focus', onFocus);
109+
};
110+
}, [loadStatus]);
111+
66112
const handleFileClick = useCallback(
67113
async (path: string, staged: boolean, untracked: boolean) => {
68114
setDiffFile(path);
@@ -181,7 +227,7 @@ export default function GitChanges({ sessionId }: GitChangesProps) {
181227
</span>
182228
<button
183229
className={styles.refreshBtn}
184-
onClick={loadStatus}
230+
onClick={() => loadStatus(true)}
185231
disabled={loading}
186232
title="Refresh git status"
187233
aria-label="Refresh git status"

src/frontend/src/components/FolderBrowser/FolderBrowser.module.css

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,32 @@
66
overflow: hidden;
77
}
88

9+
.pathInput {
10+
width: 100%;
11+
box-sizing: border-box;
12+
padding: 0.55rem 0.7rem;
13+
font-size: 0.9rem;
14+
background: var(--bg);
15+
color: var(--text);
16+
border: 1px solid var(--border);
17+
border-radius: 8px;
18+
margin-bottom: 0.4rem;
19+
font-family:
20+
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
21+
monospace;
22+
/* Larger tap target on touch devices */
23+
min-height: 2.5rem;
24+
}
25+
26+
.pathInput:focus {
27+
outline: none;
28+
border-color: var(--accent);
29+
}
30+
31+
.pathInput::placeholder {
32+
color: var(--text-muted);
33+
}
34+
935
.breadcrumb {
1036
display: flex;
1137
align-items: center;

0 commit comments

Comments
 (0)