From 3d3e0ea0b186479f643d733f982791ed96511feb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:10:01 -0700 Subject: [PATCH] perf(file-explorer): batch and debounce ignored checks (#8103) --- src/main/git/check-ignored-paths.test.ts | 37 ++++-- src/main/git/check-ignored-paths.ts | 38 +++--- src/main/git/runner.ts | 3 +- src/relay/git-handler-check-ignore.test.ts | 44 +++++++ src/relay/git-handler-check-ignore.ts | 35 ++++++ src/relay/git-handler-ops.ts | 2 +- src/relay/git-handler-status-ops.ts | 31 ----- src/relay/git-handler.ts | 8 +- ...le-explorer-runtime-owner-boundary.test.ts | 2 +- .../use-file-explorer-ignored-paths.ts | 114 ++++++++++++++++++ ...orerVisibleRowProjection.debounce.test.tsx | 98 +++++++++++++++ ...seFileExplorerVisibleRowProjection.test.ts | 7 +- .../useFileExplorerVisibleRowProjection.ts | 89 +------------- src/shared/git-check-ignore-stdio.test.ts | 38 ++++++ src/shared/git-check-ignore-stdio.ts | 42 +++++++ src/shared/subprocess-stdin-write.test.ts | 30 +++++ src/shared/subprocess-stdin-write.ts | 10 ++ 17 files changed, 475 insertions(+), 153 deletions(-) create mode 100644 src/relay/git-handler-check-ignore.test.ts create mode 100644 src/relay/git-handler-check-ignore.ts create mode 100644 src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.ts create mode 100644 src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx create mode 100644 src/shared/git-check-ignore-stdio.test.ts create mode 100644 src/shared/git-check-ignore-stdio.ts create mode 100644 src/shared/subprocess-stdin-write.test.ts create mode 100644 src/shared/subprocess-stdin-write.ts diff --git a/src/main/git/check-ignored-paths.test.ts b/src/main/git/check-ignored-paths.test.ts index 3338ee230..398d7c186 100644 --- a/src/main/git/check-ignored-paths.test.ts +++ b/src/main/git/check-ignored-paths.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { checkIgnoredPaths } from './check-ignored-paths' import { gitExecFileAsync } from './runner' +import { GIT_CHECK_IGNORE_TIMEOUT_MS } from '../../shared/git-check-ignore-stdio' vi.mock('./runner', () => ({ gitExecFileAsync: vi.fn() @@ -14,29 +15,43 @@ describe('checkIgnoredPaths', () => { }) it('returns ignored paths from git check-ignore output', async () => { - gitExecFileAsyncMock.mockResolvedValue({ stdout: 'dist/bundle.js\n.env\n', stderr: '' }) + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'dist/bundle.js\0.env\0', stderr: '' }) await expect( checkIgnoredPaths('/repo', ['dist/bundle.js', 'src/index.ts', '.env']) ).resolves.toEqual(['dist/bundle.js', '.env']) expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - [ - '-c', - 'core.quotePath=false', - 'check-ignore', - '--', - 'dist/bundle.js', - 'src/index.ts', - '.env' - ], - { cwd: '/repo' } + ['-c', 'core.quotePath=false', 'check-ignore', '-z', '--stdin'], + { + cwd: '/repo', + stdin: 'dist/bundle.js\0src/index.ts\0.env\0', + timeout: GIT_CHECK_IGNORE_TIMEOUT_MS + } ) }) + it('checks ten thousand paths with one bounded subprocess', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + const paths = Array.from({ length: 10_000 }, (_, index) => `generated/file-${index}.js`) + + await expect(checkIgnoredPaths('/repo', paths)).resolves.toEqual([]) + + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(gitExecFileAsyncMock.mock.calls[0]?.[1]).toMatchObject({ + timeout: GIT_CHECK_IGNORE_TIMEOUT_MS + }) + expect(gitExecFileAsyncMock.mock.calls[0]?.[1].stdin?.split('\0')).toHaveLength(10_001) + }) + it('treats exit code 1 as no ignored paths', async () => { gitExecFileAsyncMock.mockRejectedValue(Object.assign(new Error('no matches'), { code: 1 })) await expect(checkIgnoredPaths('/repo', ['src/index.ts'])).resolves.toEqual([]) }) + + it('skips the subprocess for an empty path list', async () => { + await expect(checkIgnoredPaths('/repo', [])).resolves.toEqual([]) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/git/check-ignored-paths.ts b/src/main/git/check-ignored-paths.ts index 4a03bbb31..65e2a88f7 100644 --- a/src/main/git/check-ignored-paths.ts +++ b/src/main/git/check-ignored-paths.ts @@ -1,30 +1,32 @@ import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' import { gitExecFileAsync } from './runner' - -const CHECK_IGNORE_CHUNK_SIZE = 100 +import { + encodeGitCheckIgnorePaths, + GIT_CHECK_IGNORE_STDIN_ARGS, + GIT_CHECK_IGNORE_TIMEOUT_MS, + parseGitCheckIgnorePaths, + splitGitCheckIgnorePathsByStdinBytes +} from '../../shared/git-check-ignore-stdio' type GitExecError = Error & { stdout?: string; code?: number | string } -function parseCheckIgnoreOutput(stdout: string): string[] { - return stdout.split(/\r?\n/).filter(Boolean) -} - -async function runCheckIgnoreChunk( +async function runCheckIgnoredPaths( worktreePath: string, relativePaths: string[], - options: GitRuntimeOptions = {} + options: GitRuntimeOptions ): Promise { try { - const { stdout } = await gitExecFileAsync( - ['-c', 'core.quotePath=false', 'check-ignore', '--', ...relativePaths], - gitOptionsForWorktree(worktreePath, options) - ) - return parseCheckIgnoreOutput(stdout) + const { stdout } = await gitExecFileAsync([...GIT_CHECK_IGNORE_STDIN_ARGS], { + ...gitOptionsForWorktree(worktreePath, options), + stdin: encodeGitCheckIgnorePaths(relativePaths), + timeout: GIT_CHECK_IGNORE_TIMEOUT_MS + }) + return parseGitCheckIgnorePaths(stdout) } catch (error) { const gitError = error as GitExecError if (gitError.code === 1) { - return parseCheckIgnoreOutput(gitError.stdout ?? '') + return parseGitCheckIgnorePaths(gitError.stdout ?? '') } throw error } @@ -35,10 +37,12 @@ export async function checkIgnoredPaths( relativePaths: string[], options: GitRuntimeOptions = {} ): Promise { + if (relativePaths.length === 0) { + return [] + } const ignored = new Set() - for (let i = 0; i < relativePaths.length; i += CHECK_IGNORE_CHUNK_SIZE) { - const chunk = relativePaths.slice(i, i + CHECK_IGNORE_CHUNK_SIZE) - for (const ignoredPath of await runCheckIgnoreChunk(worktreePath, chunk, options)) { + for (const chunk of splitGitCheckIgnorePathsByStdinBytes(relativePaths)) { + for (const ignoredPath of await runCheckIgnoredPaths(worktreePath, chunk, options)) { ignored.add(ignoredPath) } } diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index a167b4eb8..29990ad9c 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -36,6 +36,7 @@ import { quotePosixShell } from '../../shared/wsl-login-shell-command' import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../../shared/git-output-locale' +import { endSubprocessStdin } from '../../shared/subprocess-stdin-write' // ─── Core resolution ──────────────────────────────────────────────── @@ -430,7 +431,7 @@ function execFileCapture( child.once('error', (error) => finish(error)) if (options.stdin !== undefined) { - child.stdin?.end(options.stdin) + endSubprocessStdin(child.stdin, options.stdin) } // Why: Node's native execFile timeout waits for the child to exit after diff --git a/src/relay/git-handler-check-ignore.test.ts b/src/relay/git-handler-check-ignore.test.ts new file mode 100644 index 000000000..678798bf7 --- /dev/null +++ b/src/relay/git-handler-check-ignore.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' +import type { GitExec } from './git-handler-ops' +import { checkIgnoredPathsOp } from './git-handler-check-ignore' +import { GIT_CHECK_IGNORE_TIMEOUT_MS } from '../shared/git-check-ignore-stdio' + +describe('checkIgnoredPathsOp', () => { + it('passes exact paths over one bounded NUL-delimited stdin invocation', async () => { + const git = vi.fn().mockResolvedValue({ + stdout: 'dist/bundle.js\0line\nbreak.txt\0', + stderr: '' + }) + + await expect( + checkIgnoredPathsOp(git, { + worktreePath: '/repo', + paths: ['dist/bundle.js', 'line\nbreak.txt', 'src/index.ts'] + }) + ).resolves.toEqual(['dist/bundle.js', 'line\nbreak.txt']) + + expect(git).toHaveBeenCalledWith( + ['-c', 'core.quotePath=false', 'check-ignore', '-z', '--stdin'], + '/repo', + { + stdin: 'dist/bundle.js\0line\nbreak.txt\0src/index.ts\0', + timeout: GIT_CHECK_IGNORE_TIMEOUT_MS + } + ) + }) + + it('treats git exit code 1 as no ignored paths', async () => { + const noMatches = Object.assign(new Error('no ignored paths'), { + code: 1, + stdout: '' + }) + const git = vi.fn().mockRejectedValue(noMatches) + + await expect( + checkIgnoredPathsOp(git, { + worktreePath: '/repo', + paths: ['src/index.ts'] + }) + ).resolves.toEqual([]) + }) +}) diff --git a/src/relay/git-handler-check-ignore.ts b/src/relay/git-handler-check-ignore.ts new file mode 100644 index 000000000..e458aba7a --- /dev/null +++ b/src/relay/git-handler-check-ignore.ts @@ -0,0 +1,35 @@ +import type { GitExec } from './git-handler-ops' +import { + encodeGitCheckIgnorePaths, + GIT_CHECK_IGNORE_STDIN_ARGS, + GIT_CHECK_IGNORE_TIMEOUT_MS, + parseGitCheckIgnorePaths, + splitGitCheckIgnorePathsByStdinBytes +} from '../shared/git-check-ignore-stdio' + +export async function checkIgnoredPathsOp( + git: GitExec, + params: Record +): Promise { + const worktreePath = params.worktreePath as string + const paths = Array.isArray(params.paths) + ? params.paths.filter((path): path is string => typeof path === 'string' && path.length > 0) + : [] + const ignored: string[] = [] + for (const chunk of splitGitCheckIgnorePathsByStdinBytes(paths)) { + try { + const { stdout } = await git([...GIT_CHECK_IGNORE_STDIN_ARGS], worktreePath, { + stdin: encodeGitCheckIgnorePaths(chunk), + timeout: GIT_CHECK_IGNORE_TIMEOUT_MS + }) + ignored.push(...parseGitCheckIgnorePaths(stdout)) + } catch (error) { + const gitError = error as Error & { code?: number | string; stdout?: string } + if (gitError.code !== 1) { + throw error + } + ignored.push(...parseGitCheckIgnorePaths(gitError.stdout ?? '')) + } + } + return ignored +} diff --git a/src/relay/git-handler-ops.ts b/src/relay/git-handler-ops.ts index 1ff93a4a2..0bb862fbc 100644 --- a/src/relay/git-handler-ops.ts +++ b/src/relay/git-handler-ops.ts @@ -16,7 +16,7 @@ import { readWorkingDiffFile } from './git-working-file-read' export type GitExec = ( args: string[], cwd: string, - opts?: { maxBuffer?: number; disableOptionalLocks?: boolean; stdin?: string } + opts?: { maxBuffer?: number; disableOptionalLocks?: boolean; stdin?: string; timeout?: number } ) => Promise<{ stdout: string; stderr: string }> export type GitBufferExec = (args: string[], cwd: string) => Promise diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index 34e96b906..4f48ec9b6 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -249,34 +249,3 @@ function shouldProbeEffectiveUpstreamStatus( const parsed = splitRemoteBranchName(upstreamName) return parsed?.remoteName === 'origin' && parsed.branchName !== branchName } - -function parseCheckIgnoreOutput(stdout: string): string[] { - return stdout.split(/\r?\n/).filter(Boolean) -} - -export async function checkIgnoredPathsOp( - git: GitExec, - params: Record -): Promise { - const worktreePath = params.worktreePath as string - const paths = Array.isArray(params.paths) - ? params.paths.filter((path): path is string => typeof path === 'string' && path.length > 0) - : [] - if (paths.length === 0) { - return [] - } - - try { - const { stdout } = await git( - ['-c', 'core.quotePath=false', 'check-ignore', '--', ...paths], - worktreePath - ) - return parseCheckIgnoreOutput(stdout) - } catch (error) { - const gitError = error as Error & { code?: number | string; stdout?: string } - if (gitError.code === 1) { - return parseCheckIgnoreOutput(gitError.stdout ?? '') - } - throw error - } -} diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 533c06f27..67e78821d 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -39,7 +39,8 @@ import { } from './git-handler-worktree-ops' import { forceDeletePreservedRelayBranch } from './git-handler-branch-cleanup' import { refreshLocalBaseRefForWorktreeCreateOp } from './git-handler-local-base-ref-refresh' -import { checkIgnoredPathsOp, detectConflictOperation, getStatusOp } from './git-handler-status-ops' +import { detectConflictOperation, getStatusOp } from './git-handler-status-ops' +import { checkIgnoredPathsOp } from './git-handler-check-ignore' import { resolveRelayPushTarget } from './git-handler-push-target' import { isNoUpstreamError, normalizeGitErrorMessage } from '../shared/git-remote-error' import { upstreamOnlyCommitsArePatchEquivalent } from '../shared/git-upstream-status' @@ -68,6 +69,7 @@ import { } from '../shared/git-worktree-command-capabilities' import { GitResponseStreamRegistry } from './git-response-stream' import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol' +import { endSubprocessStdin } from '../shared/subprocess-stdin-write' const execFileAsync = promisify(execFile) const MAX_GIT_BUFFER = 10 * 1024 * 1024 @@ -151,7 +153,7 @@ function execFileWithStdin( finish(null, stdout, stderr) }) child.once('error', (error) => finish(error)) - child.stdin?.end(stdin) + endSubprocessStdin(child.stdin, stdin) }) } @@ -290,6 +292,7 @@ export class GitHandler { signal?: AbortSignal nonInteractive?: boolean stdin?: string + timeout?: number } ): Promise<{ stdout: string; stderr: string }> { const env = buildRelayGitEnv() @@ -307,6 +310,7 @@ export class GitHandler { env, encoding: 'utf-8', maxBuffer: opts?.maxBuffer ?? MAX_GIT_BUFFER, + timeout: opts?.timeout, signal: opts?.signal } satisfies ExecFileOptions if (opts?.stdin !== undefined) { diff --git a/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts index 9282328cc..19c8b2a9b 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts @@ -15,7 +15,7 @@ describe('right sidebar file/git runtime ownership boundaries', () => { 'src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts', 'src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts', 'src/renderer/src/components/right-sidebar/useFileDuplicate.ts', - 'src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts', + 'src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.ts', 'src/renderer/src/components/right-sidebar/useGitStatusPolling.ts', 'src/renderer/src/components/right-sidebar/useFileSearchRunner.ts', 'src/renderer/src/components/quick-open-file-list.ts' diff --git a/src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.ts b/src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.ts new file mode 100644 index 000000000..9f420b6ab --- /dev/null +++ b/src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.ts @@ -0,0 +1,114 @@ +import { useEffect, useState } from 'react' +import { getConnectionId } from '@/lib/connection-context' +import { getRuntimeGitIgnoredPaths } from '@/runtime/runtime-git-client' +import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' + +const EMPTY_IGNORED_PATHS: readonly string[] = [] +export const FILE_EXPLORER_IGNORED_QUERY_DEBOUNCE_MS = 300 + +export type IgnoredPathResult = { + activeWorktreeId: string + paths: string[] + worktreePath: string +} + +export function getEffectiveFileExplorerIgnoredPaths({ + activeWorktreeId, + canLoadIgnoredPaths, + ignoredPathResult, + worktreePath +}: { + activeWorktreeId: string | null + canLoadIgnoredPaths: boolean + ignoredPathResult: IgnoredPathResult | null + worktreePath: string | null +}): readonly string[] { + const ignoredPathResultMatchesCurrentWorktree = + ignoredPathResult !== null && + ignoredPathResult.activeWorktreeId === activeWorktreeId && + ignoredPathResult.worktreePath === worktreePath + + if (!canLoadIgnoredPaths || !ignoredPathResultMatchesCurrentWorktree) { + return EMPTY_IGNORED_PATHS + } + + // Why: expanding folders changes the query before the async ignored refresh returns. + // Keep same-worktree answers so known ignored rows do not flash as normal text. + return ignoredPathResult.paths +} + +export function useFileExplorerIgnoredPaths({ + activeWorktreeId, + canLoadIgnoredPaths, + relativePaths, + shouldDebounceIgnoredQuery, + worktreePath +}: { + activeWorktreeId: string | null + canLoadIgnoredPaths: boolean + relativePaths: readonly string[] + shouldDebounceIgnoredQuery: boolean + worktreePath: string | null +}): readonly string[] { + const [ignoredPathResult, setIgnoredPathResult] = useState(null) + + useEffect(() => { + if (!canLoadIgnoredPaths || !activeWorktreeId || !worktreePath) { + return + } + + let canceled = false + const refresh = (): void => { + const connectionId = getConnectionId(activeWorktreeId) ?? undefined + void getRuntimeGitIgnoredPaths( + { + settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + [...relativePaths] + ) + .then((paths) => { + if (!canceled) { + setIgnoredPathResult({ activeWorktreeId, paths, worktreePath }) + } + }) + .catch(() => { + if (!canceled) { + setIgnoredPathResult({ activeWorktreeId, paths: [], worktreePath }) + } + }) + } + + // Why: every filter keystroke changes relativePaths. Waiting for a short + // quiet window prevents obsolete queries from launching uncancellable Git + // subprocess chains while the visible name projection stays immediate. + const timer = shouldDebounceIgnoredQuery + ? window.setTimeout(refresh, FILE_EXPLORER_IGNORED_QUERY_DEBOUNCE_MS) + : null + if (timer === null) { + refresh() + } + + return () => { + canceled = true + if (timer !== null) { + window.clearTimeout(timer) + } + } + }, [ + activeWorktreeId, + canLoadIgnoredPaths, + relativePaths, + shouldDebounceIgnoredQuery, + worktreePath + ]) + + return getEffectiveFileExplorerIgnoredPaths({ + activeWorktreeId, + canLoadIgnoredPaths, + ignoredPathResult, + worktreePath + }) +} diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx new file mode 100644 index 000000000..b6dd71c8a --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx @@ -0,0 +1,98 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { useFileExplorerVisibleRowProjection } from './useFileExplorerVisibleRowProjection' +import { FILE_EXPLORER_IGNORED_QUERY_DEBOUNCE_MS } from './use-file-explorer-ignored-paths' + +const getRuntimeGitIgnoredPathsMock = vi.hoisted(() => vi.fn()) +vi.mock('@/runtime/runtime-git-client', () => ({ + getRuntimeGitIgnoredPaths: getRuntimeGitIgnoredPathsMock +})) + +const initialAppState = useAppStore.getInitialState() +const relativePaths = Array.from({ length: 5_000 }, (_, index) => `src/generated-${index}.ts`) + +function useProjection(query: string) { + return useFileExplorerVisibleRowProjection('worktree-1', '/repo', {}, new Set(), true, true, { + query, + relativePaths + }) +} + +function useTreeProjection() { + return useFileExplorerVisibleRowProjection( + 'worktree-1', + '/repo', + { + '/repo': { + children: [ + { + name: 'src', + path: '/repo/src', + relativePath: 'src', + isDirectory: true, + depth: 0 + } + ], + loading: false + } + }, + new Set(), + true, + true, + null + ) +} + +describe('file explorer ignored-path query debounce', () => { + beforeEach(() => { + vi.useFakeTimers() + getRuntimeGitIgnoredPathsMock.mockReset().mockResolvedValue([]) + useAppStore.setState(initialAppState, true) + useAppStore.setState({ + settings: { activeRuntimeEnvironmentId: null } as AppState['settings'] + }) + }) + + afterEach(() => { + cleanup() + useAppStore.setState(initialAppState, true) + vi.useRealTimers() + }) + + it('coalesces a broad typing burst into one final ignored-path request', async () => { + const hook = renderHook(({ query }) => useProjection(query), { + initialProps: { query: 's' } + }) + + await act(async () => vi.advanceTimersByTimeAsync(100)) + hook.rerender({ query: 'sr' }) + await act(async () => vi.advanceTimersByTimeAsync(100)) + hook.rerender({ query: 'src' }) + + await act(async () => vi.advanceTimersByTimeAsync(FILE_EXPLORER_IGNORED_QUERY_DEBOUNCE_MS - 1)) + expect(getRuntimeGitIgnoredPathsMock).not.toHaveBeenCalled() + + await act(async () => vi.advanceTimersByTimeAsync(1)) + expect(getRuntimeGitIgnoredPathsMock).toHaveBeenCalledTimes(1) + expect(getRuntimeGitIgnoredPathsMock.mock.calls[0]?.[1]).toHaveLength(relativePaths.length) + }) + + it('cancels the pending ignored-path request when the explorer unmounts', async () => { + const hook = renderHook(() => useProjection('src')) + hook.unmount() + + await act(async () => vi.advanceTimersByTimeAsync(FILE_EXPLORER_IGNORED_QUERY_DEBOUNCE_MS)) + expect(getRuntimeGitIgnoredPathsMock).not.toHaveBeenCalled() + }) + + it('keeps ordinary expanded-tree ignored checks immediate', () => { + renderHook(() => useTreeProjection()) + + expect(getRuntimeGitIgnoredPathsMock).toHaveBeenCalledTimes(1) + expect(getRuntimeGitIgnoredPathsMock.mock.calls[0]?.[1]).toEqual(['src']) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.test.ts b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.test.ts index 600d4aa76..692ffebe7 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.test.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.test.ts @@ -2,9 +2,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { DirCache, TreeNode } from './file-explorer-types' import { createVisibleFileExplorerRowProjection, - getEffectiveFileExplorerIgnoredPaths, getFileExplorerIgnoredQueryRelativePaths } from './useFileExplorerVisibleRowProjection' +import { getEffectiveFileExplorerIgnoredPaths } from './use-file-explorer-ignored-paths' import { FILE_EXPLORER_NAME_FILTER_QUERY_MAX_BYTES, getFileExplorerNameFilterExpandedPaths, @@ -336,8 +336,6 @@ describe('file explorer visible row projection', () => { }) it('keeps same-worktree ignored paths while an expanded-folder query is loading', () => { - const previousRelativePaths = ['out', 'src'] - expect( getEffectiveFileExplorerIgnoredPaths({ activeWorktreeId: 'worktree-1', @@ -345,7 +343,6 @@ describe('file explorer visible row projection', () => { ignoredPathResult: { activeWorktreeId: 'worktree-1', paths: ['out'], - relativePaths: previousRelativePaths, worktreePath: '/repo' }, worktreePath: '/repo' @@ -372,7 +369,6 @@ describe('file explorer visible row projection', () => { ignoredPathResult: { activeWorktreeId: 'worktree-1', paths: ['out'], - relativePaths: ['out'], worktreePath: '/repo' }, worktreePath: '/repo' @@ -386,7 +382,6 @@ describe('file explorer visible row projection', () => { ignoredPathResult: { activeWorktreeId: 'worktree-1', paths: ['out'], - relativePaths: ['out'], worktreePath: '/repo' }, worktreePath: '/other-repo' diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts index acda16138..813555af1 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts @@ -1,8 +1,5 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useMemo } from 'react' import { useAppStore } from '@/store' -import { getConnectionId } from '@/lib/connection-context' -import { getRuntimeGitIgnoredPaths } from '@/runtime/runtime-git-client' -import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' import { isDotfileRelativePath } from './file-explorer-entries' import type { DirCache, TreeNode } from './file-explorer-types' import { @@ -16,17 +13,10 @@ import { getFileExplorerNameFilterIgnoredQueryRelativePaths, type FileExplorerNameFilterProjectionSource } from './file-explorer-name-filter-projection' +import { useFileExplorerIgnoredPaths } from './use-file-explorer-ignored-paths' -const EMPTY_IGNORED_PATHS: readonly string[] = [] const EMPTY_RELATIVE_PATHS: string[] = [] -export type IgnoredPathResult = { - activeWorktreeId: string - paths: string[] - relativePaths: readonly string[] - worktreePath: string -} - type VisibleFileExplorerRowProjectionOptions = { ignoredSet: Set nameFilter?: FileExplorerNameFilterProjectionSource | null @@ -119,31 +109,6 @@ export function createVisibleFileExplorerRowProjection( return createFileExplorerRowProjectionFromParts(visibleFlatRows, rowsByPath) } -export function getEffectiveFileExplorerIgnoredPaths({ - activeWorktreeId, - canLoadIgnoredPaths, - ignoredPathResult, - worktreePath -}: { - activeWorktreeId: string | null - canLoadIgnoredPaths: boolean - ignoredPathResult: IgnoredPathResult | null - worktreePath: string | null -}): readonly string[] { - const ignoredPathResultMatchesCurrentWorktree = - ignoredPathResult !== null && - ignoredPathResult.activeWorktreeId === activeWorktreeId && - ignoredPathResult.worktreePath === worktreePath - - if (!canLoadIgnoredPaths || !ignoredPathResultMatchesCurrentWorktree) { - return EMPTY_IGNORED_PATHS - } - - // Why: expanding folders changes the query before the async ignored refresh returns. - // Keep same-worktree answers so known ignored rows do not flash as normal text. - return ignoredPathResult.paths -} - export function useFileExplorerVisibleRowProjection( activeWorktreeId: string | null, worktreePath: string | null, @@ -163,7 +128,6 @@ export function useFileExplorerVisibleRowProjection( const settings = useAppStore((s) => s.settings) const updateSettings = useAppStore((s) => s.updateSettings) const showGitIgnoredFiles = settings?.showGitIgnoredFiles ?? true - const [ignoredPathResult, setIgnoredPathResult] = useState(null) const relativePaths = useMemo( () => activeRepoSupportsGit @@ -181,53 +145,12 @@ export function useFileExplorerVisibleRowProjection( Boolean(activeWorktreeId) && Boolean(worktreePath) && relativePaths.length > 0 - - useEffect(() => { - if (!canLoadIgnoredPaths || !activeWorktreeId || !worktreePath) { - return - } - - let canceled = false - const connectionId = getConnectionId(activeWorktreeId) ?? undefined - void getRuntimeGitIgnoredPaths( - { - settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), - worktreeId: activeWorktreeId, - worktreePath, - connectionId - }, - [...relativePaths] - ) - .then((nextIgnoredPaths) => { - if (!canceled) { - setIgnoredPathResult({ - activeWorktreeId, - paths: nextIgnoredPaths, - relativePaths, - worktreePath - }) - } - }) - .catch(() => { - if (!canceled) { - setIgnoredPathResult({ - activeWorktreeId, - paths: [], - relativePaths, - worktreePath - }) - } - }) - - return () => { - canceled = true - } - }, [activeWorktreeId, canLoadIgnoredPaths, relativePaths, worktreePath]) - - const effectiveIgnoredPaths = getEffectiveFileExplorerIgnoredPaths({ + const shouldDebounceIgnoredQuery = nameFilter !== null + const effectiveIgnoredPaths = useFileExplorerIgnoredPaths({ activeWorktreeId, canLoadIgnoredPaths, - ignoredPathResult, + relativePaths, + shouldDebounceIgnoredQuery, worktreePath }) const ignoredSet = useMemo(() => buildIgnoredSet(effectiveIgnoredPaths), [effectiveIgnoredPaths]) diff --git a/src/shared/git-check-ignore-stdio.test.ts b/src/shared/git-check-ignore-stdio.test.ts new file mode 100644 index 000000000..e0b7ed789 --- /dev/null +++ b/src/shared/git-check-ignore-stdio.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { + encodeGitCheckIgnorePaths, + GIT_CHECK_IGNORE_STDIN_ARGS, + parseGitCheckIgnorePaths, + splitGitCheckIgnorePathsByStdinBytes +} from './git-check-ignore-stdio' + +describe('git check-ignore stdio', () => { + it('uses the NUL-delimited stdin command shape', () => { + expect(GIT_CHECK_IGNORE_STDIN_ARGS).toEqual([ + '-c', + 'core.quotePath=false', + 'check-ignore', + '-z', + '--stdin' + ]) + expect(encodeGitCheckIgnorePaths(['dist/bundle.js', 'line\nbreak.txt', '-leading.txt'])).toBe( + 'dist/bundle.js\0line\nbreak.txt\0-leading.txt\0' + ) + }) + + it('parses exact paths without treating embedded newlines as records', () => { + expect(parseGitCheckIgnorePaths('dist/bundle.js\0line\nbreak.txt\0-leading.txt\0')).toEqual([ + 'dist/bundle.js', + 'line\nbreak.txt', + '-leading.txt' + ]) + }) + + it('bounds stdin chunks by encoded bytes without splitting a path', () => { + expect(splitGitCheckIgnorePathsByStdinBytes(['abcd', 'efgh', 'ijk'], 10)).toEqual([ + ['abcd', 'efgh'], + ['ijk'] + ]) + expect(splitGitCheckIgnorePathsByStdinBytes(['éé', 'a'], 5)).toEqual([['éé'], ['a']]) + }) +}) diff --git a/src/shared/git-check-ignore-stdio.ts b/src/shared/git-check-ignore-stdio.ts new file mode 100644 index 000000000..40eb3c5ca --- /dev/null +++ b/src/shared/git-check-ignore-stdio.ts @@ -0,0 +1,42 @@ +export const GIT_CHECK_IGNORE_TIMEOUT_MS = 15_000 +export const GIT_CHECK_IGNORE_STDIN_CHUNK_BYTES = 1024 * 1024 + +export const GIT_CHECK_IGNORE_STDIN_ARGS = [ + '-c', + 'core.quotePath=false', + 'check-ignore', + '-z', + '--stdin' +] as const + +export function encodeGitCheckIgnorePaths(paths: readonly string[]): string { + return `${paths.join('\0')}\0` +} + +export function splitGitCheckIgnorePathsByStdinBytes( + paths: readonly string[], + maxBytes: number = GIT_CHECK_IGNORE_STDIN_CHUNK_BYTES +): string[][] { + const encoder = new TextEncoder() + const chunks: string[][] = [] + let chunk: string[] = [] + let chunkBytes = 0 + for (const path of paths) { + const pathBytes = encoder.encode(path).byteLength + 1 + if (chunk.length > 0 && chunkBytes + pathBytes > maxBytes) { + chunks.push(chunk) + chunk = [] + chunkBytes = 0 + } + chunk.push(path) + chunkBytes += pathBytes + } + if (chunk.length > 0) { + chunks.push(chunk) + } + return chunks +} + +export function parseGitCheckIgnorePaths(stdout: string): string[] { + return stdout.split('\0').filter((path) => path.length > 0) +} diff --git a/src/shared/subprocess-stdin-write.test.ts b/src/shared/subprocess-stdin-write.test.ts new file mode 100644 index 000000000..49fd8e94f --- /dev/null +++ b/src/shared/subprocess-stdin-write.test.ts @@ -0,0 +1,30 @@ +import { EventEmitter } from 'node:events' +import type { Writable } from 'node:stream' +import { describe, expect, it, vi } from 'vitest' +import { endSubprocessStdin } from './subprocess-stdin-write' + +describe('endSubprocessStdin', () => { + it('handles an early-exit pipe error emitted while ending a large write', () => { + const pipeError = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }) + const stdin = new EventEmitter() as Writable + stdin.end = vi.fn(() => { + stdin.emit('error', pipeError) + return stdin + }) as Writable['end'] + expect(() => endSubprocessStdin(stdin, 'x'.repeat(1_000_000))).not.toThrow() + + expect(stdin.listenerCount('error')).toBe(0) + }) + + it('keeps the error handler attached for a pipe failure after the caller times out', () => { + const stdin = new EventEmitter() as Writable + stdin.end = vi.fn(() => stdin) as Writable['end'] + endSubprocessStdin(stdin, 'payload') + expect(stdin.listenerCount('error')).toBe(1) + + const pipeError = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }) + expect(() => stdin.emit('error', pipeError)).not.toThrow() + + expect(stdin.listenerCount('error')).toBe(0) + }) +}) diff --git a/src/shared/subprocess-stdin-write.ts b/src/shared/subprocess-stdin-write.ts new file mode 100644 index 000000000..4fa262845 --- /dev/null +++ b/src/shared/subprocess-stdin-write.ts @@ -0,0 +1,10 @@ +import type { Writable } from 'node:stream' + +export function endSubprocessStdin(stdin: Writable | null | undefined, input: string): void { + if (!stdin) { + return + } + // Why: early exit or timeout can close a large write; the child callback owns the command result. + stdin.once('error', () => {}) + stdin.end(input) +}