perf(file-explorer): batch and debounce ignored checks (#8103)

This commit is contained in:
Neil 2026-07-10 19:10:01 -07:00 committed by GitHub
parent 69776e8d2b
commit 3d3e0ea0b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 475 additions and 153 deletions

View File

@ -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()
})
})

View File

@ -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<string[]> {
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<string[]> {
if (relativePaths.length === 0) {
return []
}
const ignored = new Set<string>()
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)
}
}

View File

@ -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

View File

@ -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<GitExec>().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<GitExec>().mockRejectedValue(noMatches)
await expect(
checkIgnoredPathsOp(git, {
worktreePath: '/repo',
paths: ['src/index.ts']
})
).resolves.toEqual([])
})
})

View File

@ -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<string, unknown>
): Promise<string[]> {
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
}

View File

@ -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<Buffer>

View File

@ -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<string, unknown>
): Promise<string[]> {
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
}
}

View File

@ -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) {

View File

@ -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'

View File

@ -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<IgnoredPathResult | null>(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
})
}

View File

@ -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'])
})
})

View File

@ -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'

View File

@ -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<string>
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<IgnoredPathResult | null>(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])

View File

@ -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']])
})
})

View File

@ -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)
}

View File

@ -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)
})
})

View File

@ -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)
}