fix: make file tree loading more reliable

Avoid gitignore filtering during folder browsing
Show folder load errors instead of empty folders
Add timeout fallback for gitignore checks
This commit is contained in:
Bohdan Triapitsyn
2026-06-05 18:58:50 +03:00
parent 3e0a105dfe
commit de0ebbac8b
5 changed files with 146 additions and 16 deletions
@@ -419,14 +419,13 @@ export const SidebarFilesTree: React.FC = () => {
inFlightDirsRef.current = new Set(inFlightDirsRef.current); inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.add(normalizedDir); inFlightDirsRef.current.add(normalizedDir);
const respectGitignore = !showGitignored;
const listPromise = files.listDirectory const listPromise = files.listDirectory
? files.listDirectory(normalizedDir, { respectGitignore }).then((result) => result.entries.map((entry) => ({ ? files.listDirectory(normalizedDir).then((result) => result.entries.map((entry) => ({
name: entry.name, name: entry.name,
path: entry.path, path: entry.path,
isDirectory: entry.isDirectory, isDirectory: entry.isDirectory,
}))) })))
: opencodeClient.listLocalDirectory(normalizedDir, { respectGitignore }).then((result) => result.map((entry) => ({ : opencodeClient.listLocalDirectory(normalizedDir).then((result) => result.map((entry) => ({
name: entry.name, name: entry.name,
path: entry.path, path: entry.path,
isDirectory: entry.isDirectory, isDirectory: entry.isDirectory,
@@ -458,7 +457,7 @@ export const SidebarFilesTree: React.FC = () => {
inFlightDirsRef.current = new Set(inFlightDirsRef.current); inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.delete(normalizedDir); inFlightDirsRef.current.delete(normalizedDir);
}); });
}, [files, mapDirectoryEntries, showGitignored]); }, [files, mapDirectoryEntries]);
const refreshRoot = React.useCallback(async () => { const refreshRoot = React.useCallback(async () => {
if (!root) return; if (!root) return;
@@ -809,6 +808,15 @@ export const SidebarFilesTree: React.FC = () => {
/> />
{isDir && isExpanded && ( {isDir && isExpanded && (
<ul className="flex flex-col gap-1 ml-3 pl-3 border-l border-border/40 relative"> <ul className="flex flex-col gap-1 ml-3 pl-3 border-l border-border/40 relative">
{loadErrorsByDir[node.path] ? (
<li className="flex items-center gap-2 px-2 py-1 typography-meta text-muted-foreground">
<span className="min-w-0 flex-1 truncate text-[var(--status-error)]" title={loadErrorsByDir[node.path]}>{loadErrorsByDir[node.path]}</span>
<Button variant="ghost" size="xs" className="h-6 gap-1" onClick={() => void refreshDirectory(node.path)}>
<Icon name="refresh" className="h-3.5 w-3.5" />
{t('sidebarFilesTree.actions.refreshTitle')}
</Button>
</li>
) : null}
{renderTree(node.path, depth + 1)} {renderTree(node.path, depth + 1)}
</ul> </ul>
)} )}
+35 -7
View File
@@ -775,6 +775,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}, [openFiles.length]); }, [openFiles.length]);
const [childrenByDir, setChildrenByDir] = React.useState<Record<string, FileNode[]>>({}); const [childrenByDir, setChildrenByDir] = React.useState<Record<string, FileNode[]>>({});
const [loadErrorsByDir, setLoadErrorsByDir] = React.useState<Record<string, string>>({});
const loadedDirsRef = React.useRef<Set<string>>(new Set()); const loadedDirsRef = React.useRef<Set<string>>(new Set());
const inFlightDirsRef = React.useRef<Set<string>>(new Set()); const inFlightDirsRef = React.useRef<Set<string>>(new Set());
const activeDirectoryLoadIdsRef = React.useRef<Map<string, number>>(new Map()); const activeDirectoryLoadIdsRef = React.useRef<Map<string, number>>(new Map());
@@ -1037,14 +1038,13 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const isCurrentRequest = () => activeDirectoryLoadIdsRef.current.get(normalizedDir) === requestId; const isCurrentRequest = () => activeDirectoryLoadIdsRef.current.get(normalizedDir) === requestId;
const respectGitignore = !showGitignored;
const listPromise = files.listDirectory const listPromise = files.listDirectory
? files.listDirectory(normalizedDir, { respectGitignore }).then((result) => result.entries.map((entry) => ({ ? files.listDirectory(normalizedDir).then((result) => result.entries.map((entry) => ({
name: entry.name, name: entry.name,
path: entry.path, path: entry.path,
isDirectory: entry.isDirectory, isDirectory: entry.isDirectory,
}))) })))
: opencodeClient.listLocalDirectory(normalizedDir, { respectGitignore }).then((result) => result.map((entry) => ({ : opencodeClient.listLocalDirectory(normalizedDir).then((result) => result.map((entry) => ({
name: entry.name, name: entry.name,
path: entry.path, path: entry.path,
isDirectory: entry.isDirectory, isDirectory: entry.isDirectory,
@@ -1060,16 +1060,24 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
loadedDirsRef.current = new Set(loadedDirsRef.current); loadedDirsRef.current = new Set(loadedDirsRef.current);
loadedDirsRef.current.add(normalizedDir); loadedDirsRef.current.add(normalizedDir);
setLoadErrorsByDir((prev) => {
if (!prev[normalizedDir]) return prev;
const next = { ...prev };
delete next[normalizedDir];
return next;
});
setChildrenByDir((prev) => ({ ...prev, [normalizedDir]: mapped })); setChildrenByDir((prev) => ({ ...prev, [normalizedDir]: mapped }));
}) })
.catch(() => { .catch((error) => {
if (!isCurrentRequest()) { if (!isCurrentRequest()) {
return; return;
} }
setChildrenByDir((prev) => ({ const message = error instanceof Error ? error.message : String(error ?? '');
console.error('Failed to load files directory:', error);
setLoadErrorsByDir((prev) => ({
...prev, ...prev,
[normalizedDir]: prev[normalizedDir] ?? [], [normalizedDir]: message,
})); }));
}) })
.finally(() => { .finally(() => {
@@ -1082,7 +1090,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
inFlightDirsRef.current = new Set(inFlightDirsRef.current); inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.delete(normalizedDir); inFlightDirsRef.current.delete(normalizedDir);
}); });
}, [files, mapDirectoryEntries, showGitignored]); }, [files, mapDirectoryEntries]);
const refreshRoot = React.useCallback(async () => { const refreshRoot = React.useCallback(async () => {
if (!root) { if (!root) {
@@ -1092,6 +1100,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
loadedDirsRef.current = new Set(); loadedDirsRef.current = new Set();
inFlightDirsRef.current = new Set(); inFlightDirsRef.current = new Set();
activeDirectoryLoadIdsRef.current = new Map(); activeDirectoryLoadIdsRef.current = new Map();
setLoadErrorsByDir({});
setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {})); setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {}));
await loadDirectory(root); await loadDirectory(root);
@@ -1148,6 +1157,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
loadedDirsRef.current = new Set(); loadedDirsRef.current = new Set();
inFlightDirsRef.current = new Set(); inFlightDirsRef.current = new Set();
activeDirectoryLoadIdsRef.current = new Map(); activeDirectoryLoadIdsRef.current = new Map();
setLoadErrorsByDir({});
setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {})); setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {}));
void loadDirectory(root); void loadDirectory(root);
} }
@@ -2090,6 +2100,15 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
/> />
{isDir && isExpanded && ( {isDir && isExpanded && (
<ul className="flex flex-col gap-1 ml-3 pl-3 border-l border-border/40 relative"> <ul className="flex flex-col gap-1 ml-3 pl-3 border-l border-border/40 relative">
{loadErrorsByDir[node.path] ? (
<li className="flex items-center gap-2 px-2 py-1 typography-meta text-muted-foreground">
<span className="min-w-0 flex-1 truncate text-[var(--status-error)]" title={loadErrorsByDir[node.path]}>{loadErrorsByDir[node.path]}</span>
<Button variant="ghost" size="xs" className="h-6 gap-1" onClick={() => void refreshDirectory(node.path)}>
<Icon name="refresh" className="size-3.5" />
{t('filesView.tree.actions.refreshTitle')}
</Button>
</li>
) : null}
{renderTree(node.path, depth + 1)} {renderTree(node.path, depth + 1)}
</ul> </ul>
)} )}
@@ -3475,6 +3494,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
); );
const hasTree = Boolean(root && childrenByDir[root]); const hasTree = Boolean(root && childrenByDir[root]);
const rootLoadError = root ? loadErrorsByDir[root] : null;
const treePanel = ( const treePanel = (
<section className={cn( <section className={cn(
@@ -3585,6 +3605,14 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
</li> </li>
); );
}) })
) : rootLoadError ? (
<li className="flex flex-col gap-2 px-2 py-1 typography-meta text-muted-foreground">
<span className="text-[var(--status-error)]">{rootLoadError}</span>
<Button variant="outline" size="xs" className="w-fit gap-1.5" onClick={() => void refreshRoot()}>
<Icon name="refresh" className="size-3.5" />
{t('filesView.tree.actions.refreshTitle')}
</Button>
</li>
) : hasTree ? ( ) : hasTree ? (
renderTree(root, 0) renderTree(root, 0)
) : ( ) : (
@@ -6,6 +6,14 @@ import { execGit } from './bridge-git-process-runtime';
const MAX_FILE_ATTACH_SIZE_BYTES = 10 * 1024 * 1024; const MAX_FILE_ATTACH_SIZE_BYTES = 10 * 1024 * 1024;
const createGitCheckIgnoreTimeoutMs = () => {
const raw = Number(process.env.OPENCHAMBER_GIT_CHECK_IGNORE_TIMEOUT_MS);
if (Number.isFinite(raw) && raw >= 0) return raw;
return 2500;
};
const GIT_CHECK_IGNORE_TIMEOUT_MS = createGitCheckIgnoreTimeoutMs();
const guessMimeTypeFromExtension = (ext: string) => { const guessMimeTypeFromExtension = (ext: string) => {
switch (ext) { switch (ext) {
case '.png': case '.png':
@@ -114,12 +122,35 @@ const isPathInside = (candidatePath: string, parentPath: string): boolean => {
export const normalizeFsPath = (value: string) => value.replace(/\\/g, '/'); export const normalizeFsPath = (value: string) => value.replace(/\\/g, '/');
const execGitCheckIgnore = async (args: string[], cwd: string): Promise<{ stdout: string; stderr: string; exitCode: number } | null> => {
if (GIT_CHECK_IGNORE_TIMEOUT_MS <= 0) {
return execGit(args, cwd);
}
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
execGit(args, cwd),
new Promise<null>((resolve) => {
timeout = setTimeout(() => resolve(null), GIT_CHECK_IGNORE_TIMEOUT_MS);
}),
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
};
const gitCheckIgnoreNames = async (cwd: string, names: string[]): Promise<Set<string>> => { const gitCheckIgnoreNames = async (cwd: string, names: string[]): Promise<Set<string>> => {
if (names.length === 0) { if (names.length === 0) {
return new Set(); return new Set();
} }
const result = await execGit(['check-ignore', '--', ...names], cwd); const result = await execGitCheckIgnore(['check-ignore', '--', ...names], cwd);
if (!result) {
return new Set();
}
if (result.exitCode !== 0 || !result.stdout) { if (result.exitCode !== 0 || !result.stdout) {
return new Set(); return new Set();
} }
@@ -137,7 +168,10 @@ const gitCheckIgnorePaths = async (cwd: string, paths: string[]): Promise<Set<st
return new Set(); return new Set();
} }
const result = await execGit(['check-ignore', '--', ...paths], cwd); const result = await execGitCheckIgnore(['check-ignore', '--', ...paths], cwd);
if (!result) {
return new Set();
}
if (result.exitCode !== 0 || !result.stdout) { if (result.exitCode !== 0 || !result.stdout) {
return new Set(); return new Set();
} }
+35 -1
View File
@@ -51,6 +51,12 @@ const createGitReadCacheTtlMs = () => {
return 30 * 1000; return 30 * 1000;
}; };
const createGitCheckIgnoreTimeoutMs = () => {
const raw = Number(process.env.OPENCHAMBER_GIT_CHECK_IGNORE_TIMEOUT_MS);
if (Number.isFinite(raw) && raw >= 0) return raw;
return 2500;
};
const normalizeCommand = (command: unknown): string => const normalizeCommand = (command: unknown): string =>
typeof command === 'string' ? command.trim().replace(/\s+/g, ' ') : ''; typeof command === 'string' ? command.trim().replace(/\s+/g, ' ') : '';
@@ -60,6 +66,7 @@ const isCacheableGitReadCommand = (command: string): boolean => {
}; };
const GIT_READ_CACHE_TTL_MS = createGitReadCacheTtlMs(); const GIT_READ_CACHE_TTL_MS = createGitReadCacheTtlMs();
const GIT_CHECK_IGNORE_TIMEOUT_MS = createGitCheckIgnoreTimeoutMs();
const GIT_READ_CACHE_MAX_ENTRIES = 500; const GIT_READ_CACHE_MAX_ENTRIES = 500;
const GIT_READ_CACHE_MAX_BYTES = 1024 * 1024; const GIT_READ_CACHE_MAX_BYTES = 1024 * 1024;
const gitReadCache = new Map<string, { result: FsExecCommandResult; at: number }>(); const gitReadCache = new Map<string, { result: FsExecCommandResult; at: number }>();
@@ -115,6 +122,30 @@ type FsDeps = {
readUriAsAttachment: (uri: vscode.Uri, name: string) => Promise<ReadUriAsAttachmentResult>; readUriAsAttachment: (uri: vscode.Uri, name: string) => Promise<ReadUriAsAttachmentResult>;
}; };
const runGitCheckIgnore = async (
execGit: FsDeps['execGit'],
args: string[],
cwd: string,
): Promise<{ stdout: string; stderr: string; exitCode: number } | null> => {
if (GIT_CHECK_IGNORE_TIMEOUT_MS <= 0) {
return execGit(args, cwd);
}
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
execGit(args, cwd),
new Promise<null>((resolve) => {
timeout = setTimeout(() => resolve(null), GIT_CHECK_IGNORE_TIMEOUT_MS);
}),
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
};
export async function handleFsBridgeMessage( export async function handleFsBridgeMessage(
message: BridgeMessageInput, message: BridgeMessageInput,
deps: FsDeps, deps: FsDeps,
@@ -177,7 +208,10 @@ export async function handleFsBridgeMessage(
} }
try { try {
const result = await deps.execGit(['check-ignore', '--', ...pathsToCheck], normalized); const result = await runGitCheckIgnore(deps.execGit, ['check-ignore', '--', ...pathsToCheck], normalized);
if (!result) {
return { id, type, success: true, data: { entries, directory: normalized, path: normalized } };
}
const ignoredNames = new Set( const ignoredNames = new Set(
result.stdout result.stdout
.split('\n') .split('\n')
+28 -2
View File
@@ -18,6 +18,12 @@ const createGitReadCacheTtlMs = () => {
return 30 * 1000; return 30 * 1000;
}; };
const createGitCheckIgnoreTimeoutMs = () => {
const raw = Number(process.env.OPENCHAMBER_GIT_CHECK_IGNORE_TIMEOUT_MS);
if (Number.isFinite(raw) && raw >= 0) return raw;
return 2500;
};
// Only deterministic, side-effect-free git plumbing path queries are cacheable. // Only deterministic, side-effect-free git plumbing path queries are cacheable.
// Anything outside this allowlist (including any non-git command) runs normally // Anything outside this allowlist (including any non-git command) runs normally
// — we never cache arbitrary exec. // — we never cache arbitrary exec.
@@ -279,6 +285,7 @@ export const registerFsRoutes = (app, dependencies) => {
const execJobs = new Map(); const execJobs = new Map();
const commandTimeoutMs = createCommandTimeoutMs(); const commandTimeoutMs = createCommandTimeoutMs();
const gitReadCacheTtlMs = createGitReadCacheTtlMs(); const gitReadCacheTtlMs = createGitReadCacheTtlMs();
const gitCheckIgnoreTimeoutMs = createGitCheckIgnoreTimeoutMs();
const gitReadCache = new Map(); const gitReadCache = new Map();
const inFlightGitReadCache = new Map(); const inFlightGitReadCache = new Map();
@@ -1065,9 +1072,28 @@ export const registerFsRoutes = (app, dependencies) => {
}); });
let stdout = ''; let stdout = '';
let settled = false;
let timeout = null;
const finish = (value) => {
if (settled) return;
settled = true;
if (timeout) clearTimeout(timeout);
resolve(value);
};
if (gitCheckIgnoreTimeoutMs > 0) {
timeout = setTimeout(() => {
try {
child.kill('SIGKILL');
} catch {
}
finish('');
}, gitCheckIgnoreTimeoutMs);
}
child.stdout.on('data', (data) => { stdout += data.toString(); }); child.stdout.on('data', (data) => { stdout += data.toString(); });
child.on('close', () => resolve(stdout)); child.on('close', () => finish(stdout));
child.on('error', () => resolve('')); child.on('error', () => finish(''));
}); });
result.split('\n').filter(Boolean).forEach((name) => { result.split('\n').filter(Boolean).forEach((name) => {