perf(file-explorer): batch and debounce ignored checks (#8103)
This commit is contained in:
parent
69776e8d2b
commit
3d3e0ea0b1
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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']])
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue