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:
@@ -419,14 +419,13 @@ export const SidebarFilesTree: React.FC = () => {
|
||||
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
|
||||
inFlightDirsRef.current.add(normalizedDir);
|
||||
|
||||
const respectGitignore = !showGitignored;
|
||||
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,
|
||||
path: entry.path,
|
||||
isDirectory: entry.isDirectory,
|
||||
})))
|
||||
: opencodeClient.listLocalDirectory(normalizedDir, { respectGitignore }).then((result) => result.map((entry) => ({
|
||||
: opencodeClient.listLocalDirectory(normalizedDir).then((result) => result.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
isDirectory: entry.isDirectory,
|
||||
@@ -458,7 +457,7 @@ export const SidebarFilesTree: React.FC = () => {
|
||||
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
|
||||
inFlightDirsRef.current.delete(normalizedDir);
|
||||
});
|
||||
}, [files, mapDirectoryEntries, showGitignored]);
|
||||
}, [files, mapDirectoryEntries]);
|
||||
|
||||
const refreshRoot = React.useCallback(async () => {
|
||||
if (!root) return;
|
||||
@@ -809,6 +808,15 @@ export const SidebarFilesTree: React.FC = () => {
|
||||
/>
|
||||
{isDir && isExpanded && (
|
||||
<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)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
@@ -775,6 +775,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
}, [openFiles.length]);
|
||||
|
||||
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 inFlightDirsRef = React.useRef<Set<string>>(new Set());
|
||||
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 respectGitignore = !showGitignored;
|
||||
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,
|
||||
path: entry.path,
|
||||
isDirectory: entry.isDirectory,
|
||||
})))
|
||||
: opencodeClient.listLocalDirectory(normalizedDir, { respectGitignore }).then((result) => result.map((entry) => ({
|
||||
: opencodeClient.listLocalDirectory(normalizedDir).then((result) => result.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
isDirectory: entry.isDirectory,
|
||||
@@ -1060,16 +1060,24 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
|
||||
loadedDirsRef.current = new Set(loadedDirsRef.current);
|
||||
loadedDirsRef.current.add(normalizedDir);
|
||||
setLoadErrorsByDir((prev) => {
|
||||
if (!prev[normalizedDir]) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[normalizedDir];
|
||||
return next;
|
||||
});
|
||||
setChildrenByDir((prev) => ({ ...prev, [normalizedDir]: mapped }));
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error) => {
|
||||
if (!isCurrentRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setChildrenByDir((prev) => ({
|
||||
const message = error instanceof Error ? error.message : String(error ?? '');
|
||||
console.error('Failed to load files directory:', error);
|
||||
setLoadErrorsByDir((prev) => ({
|
||||
...prev,
|
||||
[normalizedDir]: prev[normalizedDir] ?? [],
|
||||
[normalizedDir]: message,
|
||||
}));
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -1082,7 +1090,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
|
||||
inFlightDirsRef.current.delete(normalizedDir);
|
||||
});
|
||||
}, [files, mapDirectoryEntries, showGitignored]);
|
||||
}, [files, mapDirectoryEntries]);
|
||||
|
||||
const refreshRoot = React.useCallback(async () => {
|
||||
if (!root) {
|
||||
@@ -1092,6 +1100,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
loadedDirsRef.current = new Set();
|
||||
inFlightDirsRef.current = new Set();
|
||||
activeDirectoryLoadIdsRef.current = new Map();
|
||||
setLoadErrorsByDir({});
|
||||
setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {}));
|
||||
|
||||
await loadDirectory(root);
|
||||
@@ -1148,6 +1157,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
loadedDirsRef.current = new Set();
|
||||
inFlightDirsRef.current = new Set();
|
||||
activeDirectoryLoadIdsRef.current = new Map();
|
||||
setLoadErrorsByDir({});
|
||||
setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {}));
|
||||
void loadDirectory(root);
|
||||
}
|
||||
@@ -2090,6 +2100,15 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
/>
|
||||
{isDir && isExpanded && (
|
||||
<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)}
|
||||
</ul>
|
||||
)}
|
||||
@@ -3475,6 +3494,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
);
|
||||
|
||||
const hasTree = Boolean(root && childrenByDir[root]);
|
||||
const rootLoadError = root ? loadErrorsByDir[root] : null;
|
||||
|
||||
const treePanel = (
|
||||
<section className={cn(
|
||||
@@ -3585,6 +3605,14 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
</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 ? (
|
||||
renderTree(root, 0)
|
||||
) : (
|
||||
|
||||
@@ -6,6 +6,14 @@ import { execGit } from './bridge-git-process-runtime';
|
||||
|
||||
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) => {
|
||||
switch (ext) {
|
||||
case '.png':
|
||||
@@ -114,12 +122,35 @@ const isPathInside = (candidatePath: string, parentPath: string): boolean => {
|
||||
|
||||
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>> => {
|
||||
if (names.length === 0) {
|
||||
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) {
|
||||
return new Set();
|
||||
}
|
||||
@@ -137,7 +168,10 @@ const gitCheckIgnorePaths = async (cwd: string, paths: string[]): Promise<Set<st
|
||||
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) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
@@ -51,6 +51,12 @@ const createGitReadCacheTtlMs = () => {
|
||||
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 =>
|
||||
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_CHECK_IGNORE_TIMEOUT_MS = createGitCheckIgnoreTimeoutMs();
|
||||
const GIT_READ_CACHE_MAX_ENTRIES = 500;
|
||||
const GIT_READ_CACHE_MAX_BYTES = 1024 * 1024;
|
||||
const gitReadCache = new Map<string, { result: FsExecCommandResult; at: number }>();
|
||||
@@ -115,6 +122,30 @@ type FsDeps = {
|
||||
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(
|
||||
message: BridgeMessageInput,
|
||||
deps: FsDeps,
|
||||
@@ -177,7 +208,10 @@ export async function handleFsBridgeMessage(
|
||||
}
|
||||
|
||||
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(
|
||||
result.stdout
|
||||
.split('\n')
|
||||
|
||||
@@ -18,6 +18,12 @@ const createGitReadCacheTtlMs = () => {
|
||||
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.
|
||||
// Anything outside this allowlist (including any non-git command) runs normally
|
||||
// — we never cache arbitrary exec.
|
||||
@@ -279,6 +285,7 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
const execJobs = new Map();
|
||||
const commandTimeoutMs = createCommandTimeoutMs();
|
||||
const gitReadCacheTtlMs = createGitReadCacheTtlMs();
|
||||
const gitCheckIgnoreTimeoutMs = createGitCheckIgnoreTimeoutMs();
|
||||
const gitReadCache = new Map();
|
||||
const inFlightGitReadCache = new Map();
|
||||
|
||||
@@ -1065,9 +1072,28 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
});
|
||||
|
||||
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.on('close', () => resolve(stdout));
|
||||
child.on('error', () => resolve(''));
|
||||
child.on('close', () => finish(stdout));
|
||||
child.on('error', () => finish(''));
|
||||
});
|
||||
|
||||
result.split('\n').filter(Boolean).forEach((name) => {
|
||||
|
||||
Reference in New Issue
Block a user