diff --git a/packages/ui/src/components/layout/SidebarFilesTree.tsx b/packages/ui/src/components/layout/SidebarFilesTree.tsx index 687bb560..3bd2f595 100644 --- a/packages/ui/src/components/layout/SidebarFilesTree.tsx +++ b/packages/ui/src/components/layout/SidebarFilesTree.tsx @@ -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 && ( )} diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index d5c86d32..3159f23a 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -775,6 +775,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { }, [openFiles.length]); const [childrenByDir, setChildrenByDir] = React.useState>({}); + const [loadErrorsByDir, setLoadErrorsByDir] = React.useState>({}); const loadedDirsRef = React.useRef>(new Set()); const inFlightDirsRef = React.useRef>(new Set()); const activeDirectoryLoadIdsRef = React.useRef>(new Map()); @@ -1037,14 +1038,13 @@ export const FilesView: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ mode = 'full' }) => { /> {isDir && isExpanded && (
    + {loadErrorsByDir[node.path] ? ( +
  • + {loadErrorsByDir[node.path]} + +
  • + ) : null} {renderTree(node.path, depth + 1)}
)} @@ -3475,6 +3494,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { ); const hasTree = Boolean(root && childrenByDir[root]); + const rootLoadError = root ? loadErrorsByDir[root] : null; const treePanel = (
= ({ mode = 'full' }) => { ); }) + ) : rootLoadError ? ( +
  • + {rootLoadError} + +
  • ) : hasTree ? ( renderTree(root, 0) ) : ( diff --git a/packages/vscode/src/bridge-fs-helpers-runtime.ts b/packages/vscode/src/bridge-fs-helpers-runtime.ts index 847c0b3d..cdfdfea6 100644 --- a/packages/vscode/src/bridge-fs-helpers-runtime.ts +++ b/packages/vscode/src/bridge-fs-helpers-runtime.ts @@ -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 | undefined; + try { + return await Promise.race([ + execGit(args, cwd), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(null), GIT_CHECK_IGNORE_TIMEOUT_MS); + }), + ]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +}; + const gitCheckIgnoreNames = async (cwd: string, names: string[]): Promise> => { 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 { 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(); @@ -115,6 +122,30 @@ type FsDeps = { readUriAsAttachment: (uri: vscode.Uri, name: string) => Promise; }; +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 | undefined; + try { + return await Promise.race([ + execGit(args, cwd), + new Promise((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') diff --git a/packages/web/server/lib/fs/routes.js b/packages/web/server/lib/fs/routes.js index 14ddcbea..10da2314 100644 --- a/packages/web/server/lib/fs/routes.js +++ b/packages/web/server/lib/fs/routes.js @@ -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) => {