diff --git a/src/main/git/check-ignored-paths.test.ts b/src/main/git/check-ignored-paths.test.ts new file mode 100644 index 00000000000..3338ee23027 --- /dev/null +++ b/src/main/git/check-ignored-paths.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { checkIgnoredPaths } from './check-ignored-paths' +import { gitExecFileAsync } from './runner' + +vi.mock('./runner', () => ({ + gitExecFileAsync: vi.fn() +})) + +const gitExecFileAsyncMock = vi.mocked(gitExecFileAsync) + +describe('checkIgnoredPaths', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + it('returns ignored paths from git check-ignore output', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'dist/bundle.js\n.env\n', 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' } + ) + }) + + 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([]) + }) +}) diff --git a/src/main/git/check-ignored-paths.ts b/src/main/git/check-ignored-paths.ts new file mode 100644 index 00000000000..c61d4784f2b --- /dev/null +++ b/src/main/git/check-ignored-paths.ts @@ -0,0 +1,42 @@ +import { gitExecFileAsync } from './runner' + +const CHECK_IGNORE_CHUNK_SIZE = 100 + +type GitExecError = Error & { stdout?: string; code?: number | string } + +function parseCheckIgnoreOutput(stdout: string): string[] { + return stdout.split(/\r?\n/).filter(Boolean) +} + +async function runCheckIgnoreChunk( + worktreePath: string, + relativePaths: string[] +): Promise { + try { + const { stdout } = await gitExecFileAsync( + ['-c', 'core.quotePath=false', 'check-ignore', '--', ...relativePaths], + { cwd: worktreePath } + ) + return parseCheckIgnoreOutput(stdout) + } catch (error) { + const gitError = error as GitExecError + if (gitError.code === 1) { + return parseCheckIgnoreOutput(gitError.stdout ?? '') + } + throw error + } +} + +export async function checkIgnoredPaths( + worktreePath: string, + relativePaths: string[] +): Promise { + 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)) { + ignored.add(ignoredPath) + } + } + return Array.from(ignored) +} diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index ea0af25a26a..b96098e99a9 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -358,6 +358,30 @@ describe('getStatus', () => { ]) }) + it('omits ignored files by default and parses them when requested', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: '! dist/\n! generated/file.js\n' + }) + + const result = await getStatus('/repo', { includeIgnored: true }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + [ + '-c', + 'core.quotePath=false', + 'status', + '--porcelain=v2', + '--branch', + '--untracked-files=all', + '--ignored=matching' + ], + { cwd: '/repo' } + ) + expect(result.ignoredPaths).toEqual(['dist/', 'generated/file.js']) + }) + it('parses branch identity from porcelain v2 branch headers', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index e94d20d9ce1..b9c12aea63d 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -25,6 +25,7 @@ const { bulkUnstageFilesMock, bulkDiscardChangesMock, discardChangesMock, + checkIgnoredPathsMock, listWorktreesMock, resolveCommitMessageSettingsMock, generateCommitMessageFromContextMock, @@ -53,6 +54,7 @@ const { bulkUnstageFilesMock: vi.fn(), bulkDiscardChangesMock: vi.fn(), discardChangesMock: vi.fn(), + checkIgnoredPathsMock: vi.fn(), listWorktreesMock: vi.fn(), resolveCommitMessageSettingsMock: vi.fn(), generateCommitMessageFromContextMock: vi.fn(), @@ -95,6 +97,10 @@ vi.mock('../git/status', () => ({ discardChanges: discardChangesMock })) +vi.mock('../git/check-ignored-paths', () => ({ + checkIgnoredPaths: checkIgnoredPathsMock +})) + vi.mock('../git/worktree', () => ({ listWorktrees: listWorktreesMock })) @@ -497,6 +503,39 @@ describe('registerFilesystemHandlers', () => { expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', { includeIgnored: true }) }) + it('checks ignored paths through local and SSH git providers', async () => { + registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) + checkIgnoredPathsMock.mockResolvedValue(['dist/bundle.js']) + const sshProvider = { + checkIgnoredPaths: vi.fn().mockResolvedValue(['build/output.js']) + } + getSshGitProviderMock.mockReturnValue(sshProvider) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('git:checkIgnored')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + paths: ['dist/bundle.js', 'src/index.ts'] + }) + ).resolves.toEqual(['dist/bundle.js']) + await expect( + handlers.get('git:checkIgnored')!(null, { + worktreePath: '/remote/repo', + connectionId: 'ssh-1', + paths: ['build/output.js'] + }) + ).resolves.toEqual(['build/output.js']) + + expect(checkIgnoredPathsMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, [ + path.join('dist', 'bundle.js'), + path.join('src', 'index.ts') + ]) + expect(sshProvider.checkIgnoredPaths).toHaveBeenCalledWith('/remote/repo', [ + path.join('build', 'output.js') + ]) + }) + it('rejects git file paths that escape the selected worktree', async () => { registerFilesystemHandlers(store as never) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index d96f9a733b7..6dafcd3fcfc 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -58,6 +58,7 @@ import { import { getPullRequestDraftContext } from '../text-generation/pull-request-context' import { getUpstreamStatus } from '../git/upstream' import { gitFetch, gitPull, gitPush } from '../git/remote' +import { checkIgnoredPaths } from '../git/check-ignored-paths' import { assertGitPushTargetShape } from '../../shared/git-push-target-validation' import { validateGitPushTarget } from '../git/push-target-validation' import { getRemoteFileUrl } from '../git/repo' @@ -516,6 +517,26 @@ export function registerFilesystemHandlers( } ) + ipcMain.handle( + 'git:checkIgnored', + async ( + _event, + args: { worktreePath: string; paths: string[]; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const paths = args.paths.map((p) => validateGitRelativeFilePath(args.worktreePath, p)) + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.checkIgnoredPaths(args.worktreePath, paths) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const paths = args.paths.map((p) => validateGitRelativeFilePath(worktreePath, p)) + return checkIgnoredPaths(worktreePath, paths) + } + ) + ipcMain.handle( 'git:history', async ( diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index d74124435e0..79ca0852ee2 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -595,6 +595,7 @@ describe('Store', () => { expect(store.getSettings().editorAutoSaveDelayMs).toBe(1000) expect(store.getSettings().refreshLocalBaseRefOnWorktreeCreate).toBe(false) expect(store.getSettings().rightSidebarOpenByDefault).toBe(true) + expect(store.getSettings().showGitIgnoredFiles).toBe(true) expect(store.getSettings().showTasksButton).toBe(true) expect(store.getSettings().combinedDiffFileTreeVisibleByDefault).toBe(false) expect(store.getSettings().visibleTaskProviders).toEqual(['github', 'gitlab', 'linear']) diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 0d517126438..4983f142927 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: this suite covers the SSH git provider's one-RPC-per-method contract; splitting it would duplicate the shared mux fixture. */ import { describe, expect, it, vi, beforeEach } from 'vitest' import { SshGitProvider } from './ssh-git-provider' @@ -57,6 +58,18 @@ describe('SshGitProvider', () => { }) }) + it('checkIgnoredPaths sends git.checkIgnored request', async () => { + mux.request.mockResolvedValue(['dist/bundle.js']) + + const result = await provider.checkIgnoredPaths('/home/user/repo', ['dist/bundle.js']) + + expect(mux.request).toHaveBeenCalledWith('git.checkIgnored', { + worktreePath: '/home/user/repo', + paths: ['dist/bundle.js'] + }) + expect(result).toEqual(['dist/bundle.js']) + }) + it('getHistory sends git.history request', async () => { const historyResult = { items: [], diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 55f112482e3..f1c3b07085d 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -44,6 +44,13 @@ export class SshGitProvider implements IGitProvider { })) as GitStatusResult } + async checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise { + return (await this.mux.request('git.checkIgnored', { + worktreePath, + paths: relativePaths + })) as string[] + } + async getHistory( worktreePath: string, options: GitHistoryOptions = {} diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index b930f1c5ea6..32c6fdfb6ba 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -145,6 +145,7 @@ export type IFilesystemProvider = { export type IGitProvider = { getStatus(worktreePath: string, options?: { includeIgnored?: boolean }): Promise + checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise getHistory(worktreePath: string, options?: GitHistoryOptions): Promise commit(worktreePath: string, message: string): Promise<{ success: boolean; error?: string }> getStagedCommitContext(worktreePath: string): Promise diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index 46c1f30e9ca..7aaad5bc7b3 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -35,6 +35,7 @@ import { getHistory as getGitHistory } from '../git/history' import { getUpstreamStatus } from '../git/upstream' import { gitFetch, gitPull, gitPush } from '../git/remote' import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import { checkIgnoredPaths } from '../git/check-ignored-paths' import { cancelGenerateCommitMessageLocal, cancelGeneratePullRequestFieldsLocal, @@ -95,6 +96,21 @@ export class RuntimeGitCommands { : getGitStatus(target.worktree.path) } + async checkRuntimeGitIgnoredPaths( + worktreeSelector: string, + relativePaths: string[] + ): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + return provider.checkIgnoredPaths(target.worktree.path, relativePaths) + } + return checkIgnoredPaths(target.worktree.path, relativePaths) + } + async getRuntimeGitHistory( worktreeSelector: string, options: GitHistoryOptions = {} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b4d46100bcf..408e6dbdcfc 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1225,6 +1225,8 @@ export class OrcaRuntimeService { getRuntimeGitStatus: RuntimeGitCommands['getRuntimeGitStatus'] = this.gitCommands.getRuntimeGitStatus.bind(this.gitCommands) + checkRuntimeGitIgnoredPaths: RuntimeGitCommands['checkRuntimeGitIgnoredPaths'] = + this.gitCommands.checkRuntimeGitIgnoredPaths.bind(this.gitCommands) getRuntimeGitHistory: RuntimeGitCommands['getRuntimeGitHistory'] = this.gitCommands.getRuntimeGitHistory.bind(this.gitCommands) getRuntimeGitConflictOperation: RuntimeGitCommands['getRuntimeGitConflictOperation'] = diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts index 69b2c21f53c..41f1207bcdb 100644 --- a/src/main/runtime/rpc/methods/git-params.ts +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -11,6 +11,10 @@ export const GitStatusParams = WorktreeSelector.extend({ includeIgnored: z.boolean().optional() }) +export const GitCheckIgnored = WorktreeSelector.extend({ + paths: z.array(z.string().min(1, 'Missing path')).max(2000) +}) + export const GitFilePath = WorktreeSelector.extend({ filePath: z .unknown() diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index 47d9b079ee6..5a23d49f08b 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: git RPC methods share one dispatcher fixture, and keeping the contract cases together makes method coverage easy to audit. */ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' @@ -54,6 +55,30 @@ describe('git RPC methods', () => { }) }) + it('returns ignored paths for selected explorer rows', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + checkRuntimeGitIgnoredPaths: vi.fn().mockResolvedValue(['dist/bundle.js']) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('git.checkIgnored', { + worktree: 'id:wt-1', + paths: ['dist/bundle.js', 'src/index.ts'] + }) + ) + + expect(runtime.checkRuntimeGitIgnoredPaths).toHaveBeenCalledWith('id:wt-1', [ + 'dist/bundle.js', + 'src/index.ts' + ]) + expect(response).toMatchObject({ + ok: true, + result: ['dist/bundle.js'] + }) + }) + it('returns a worktree file diff', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index 2eda43d678a..64e7c63c895 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -4,6 +4,7 @@ import { GitBranchCompare, GitBranchDiff, GitBulkPaths, + GitCheckIgnored, GitCommit, GitCommitCompare, GitCommitDiff, @@ -27,6 +28,12 @@ export const GIT_METHODS: RpcMethod[] = [ ? runtime.getRuntimeGitStatus(params.worktree) : runtime.getRuntimeGitStatus(params.worktree, { includeIgnored: params.includeIgnored }) }), + defineMethod({ + name: 'git.checkIgnored', + params: GitCheckIgnored, + handler: async (params, { runtime }) => + runtime.checkRuntimeGitIgnoredPaths(params.worktree, params.paths) + }), defineMethod({ name: 'git.history', params: GitHistory, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 81d4a6b89d1..667dfc0dbd1 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1366,6 +1366,11 @@ export type PreloadApi = { connectionId?: string includeIgnored?: boolean }) => Promise + checkIgnored: (args: { + worktreePath: string + paths: string[] + connectionId?: string + }) => Promise history: ( args: { worktreePath: string; connectionId?: string } & GitHistoryOptions ) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 614049e9e1b..0bad7d8aa8d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1917,6 +1917,11 @@ const api = { connectionId?: string includeIgnored?: boolean }): Promise => ipcRenderer.invoke('git:status', args), + checkIgnored: (args: { + worktreePath: string + paths: string[] + connectionId?: string + }): Promise => ipcRenderer.invoke('git:checkIgnored', args), history: ( args: { worktreePath: string; connectionId?: string } & GitHistoryOptions ): Promise => ipcRenderer.invoke('git:history', args), diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index 7576a67a207..f9a89973992 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -121,3 +121,34 @@ export async function getStatusOp( ...(includeIgnored ? { ignoredPaths } : {}) } } + +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.test.ts b/src/relay/git-handler.test.ts index 5ddcc934d57..2c1b063362b 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -37,6 +37,7 @@ describe('GitHandler', () => { it('registers all expected handlers', () => { const methods = Array.from(dispatcher._requestHandlers.keys()) expect(methods).toContain('git.status') + expect(methods).toContain('git.checkIgnored') expect(methods).toContain('git.history') expect(methods).toContain('git.commit') expect(methods).toContain('git.diff') @@ -144,6 +145,23 @@ describe('GitHandler', () => { expect(ignoredResult.ignoredPaths).toEqual(expect.arrayContaining(['dist/', '.env'])) }) + it('checks ignored status for selected paths', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, '.gitignore'), 'dist/\n.env\n') + gitCommit(tmpDir, 'initial') + mkdirSync(path.join(tmpDir, 'dist'), { recursive: true }) + writeFileSync(path.join(tmpDir, 'dist', 'bundle.js'), 'compiled') + writeFileSync(path.join(tmpDir, '.env'), 'TOKEN=secret') + + const result = (await dispatcher.callRequest('git.checkIgnored', { + worktreePath: tmpDir, + paths: ['dist/bundle.js', 'src/index.ts', '.env'] + })) as string[] + + expect(result).toEqual(expect.arrayContaining(['dist/bundle.js', '.env'])) + expect(result).not.toContain('src/index.ts') + }) + it('detects modified files', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'file.txt'), 'original') diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 39002a2b3d9..06bc0016509 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -16,7 +16,7 @@ import { } from './git-handler-ops' import { commitCompare as commitCompareOp, commitDiffEntry } from './git-handler-commit-diff-ops' import { commitChangesRelay, addWorktreeOp, removeWorktreeOp } from './git-handler-worktree-ops' -import { detectConflictOperation, getStatusOp } from './git-handler-status-ops' +import { checkIgnoredPathsOp, detectConflictOperation, getStatusOp } from './git-handler-status-ops' import { resolveRelayPushTarget } from './git-handler-push-target' import { normalizeGitErrorMessage, isNoUpstreamError } from '../shared/git-remote-error' import { loadGitHistoryFromExecutor } from '../shared/git-history' @@ -37,6 +37,7 @@ export class GitHandler { private registerHandlers(): void { this.dispatcher.onRequest('git.status', (p) => this.getStatus(p)) + this.dispatcher.onRequest('git.checkIgnored', (p) => this.checkIgnored(p)) this.dispatcher.onRequest('git.history', (p) => this.history(p)) this.dispatcher.onRequest('git.commit', (p) => this.commit(p)) this.dispatcher.onRequest('git.diff', (p) => this.getDiff(p)) @@ -87,6 +88,10 @@ export class GitHandler { return getStatusOp(this.git.bind(this), params) } + private async checkIgnored(params: Record) { + return checkIgnoredPathsOp(this.git.bind(this), params) + } + private async history(params: Record) { const worktreePath = params.worktreePath as string return loadGitHistoryFromExecutor(this.git.bind(this), worktreePath, { diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx index 97e5fdf949e..1e2722b6220 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { ListCollapse, Loader2, RefreshCw } from 'lucide-react' +import { EyeOff, ListCollapse, Loader2, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { FileExplorerToolbar } from './FileExplorerToolbar' import { FileExplorerRow, shouldShowCollapseFolderAction } from './FileExplorerRow' @@ -52,6 +52,29 @@ function findCollapseAllButton(node: unknown): ReactElementLike { return found } +function findGitIgnoredButton(node: unknown): ReactElementLike { + let found: ReactElementLike | null = null + visit(node, (entry) => { + if (entry.type === Button && entry.props['aria-label'] === 'Hide Git Ignored Files') { + found = entry + } + }) + if (!found) { + throw new Error('git ignored button not found') + } + return found +} + +function queryGitIgnoredButton(node: unknown): ReactElementLike | null { + let found: ReactElementLike | null = null + visit(node, (entry) => { + if (entry.type === Button && entry.props['aria-label'] === 'Hide Git Ignored Files') { + found = entry + } + }) + return found +} + function findFileExplorerRow(node: unknown): ReactElementLike { let found: ReactElementLike | null = null visit(node, (entry) => { @@ -103,15 +126,23 @@ function makeRefreshState( } } +function makeToolbar(overrides: Partial[0]> = {}) { + return FileExplorerToolbar({ + repoName: 'orca', + refresh: makeRefreshState(), + canCollapseAll: false, + onCollapseAll: vi.fn(), + showGitIgnoredFilesToggle: true, + showGitIgnoredFiles: true, + onToggleGitIgnoredFiles: vi.fn(), + ...overrides + }) +} + describe('FileExplorerToolbar', () => { it('fires the refresh action from the icon button', () => { const onRefresh = vi.fn() - const element = FileExplorerToolbar({ - repoName: 'orca', - refresh: makeRefreshState({ handleRefresh: onRefresh }), - canCollapseAll: false, - onCollapseAll: vi.fn() - }) + const element = makeToolbar({ refresh: makeRefreshState({ handleRefresh: onRefresh }) }) const button = findRefreshButton(element) ;(button.props.onClick as () => void)() @@ -124,12 +155,7 @@ describe('FileExplorerToolbar', () => { it('shows the repo name in a truncated label', () => { const repoName = 'really-long-repo-name-that-should-not-push-refresh-offscreen' - const element = FileExplorerToolbar({ - repoName, - refresh: makeRefreshState(), - canCollapseAll: false, - onCollapseAll: vi.fn() - }) + const element = makeToolbar({ repoName }) const label = findRepoNameLabel(element, repoName) @@ -139,11 +165,8 @@ describe('FileExplorerToolbar', () => { }) it('disables the refresh button and shows a spinner while refreshing', () => { - const element = FileExplorerToolbar({ - repoName: 'orca', - refresh: makeRefreshState({ isRefreshing: true, showRefreshSpinner: true }), - canCollapseAll: false, - onCollapseAll: vi.fn() + const element = makeToolbar({ + refresh: makeRefreshState({ isRefreshing: true, showRefreshSpinner: true }) }) const button = findRefreshButton(element) @@ -155,9 +178,7 @@ describe('FileExplorerToolbar', () => { it('fires the collapse all action from the icon button', () => { const onCollapseAll = vi.fn() - const element = FileExplorerToolbar({ - repoName: 'orca', - refresh: makeRefreshState(), + const element = makeToolbar({ canCollapseAll: true, onCollapseAll }) @@ -171,18 +192,30 @@ describe('FileExplorerToolbar', () => { }) it('disables collapse all when no directories are expanded', () => { - const element = FileExplorerToolbar({ - repoName: 'orca', - refresh: makeRefreshState(), - canCollapseAll: false, - onCollapseAll: vi.fn() - }) + const element = makeToolbar({ canCollapseAll: false }) const button = findCollapseAllButton(element) expect(button.props.disabled).toBe(true) expect(hasIcon(button, ListCollapse)).toBe(true) }) + + it('fires the git ignored visibility toggle from the icon button', () => { + const onToggleGitIgnoredFiles = vi.fn() + const element = makeToolbar({ onToggleGitIgnoredFiles }) + + const button = findGitIgnoredButton(element) + ;(button.props.onClick as () => void)() + + expect(onToggleGitIgnoredFiles).toHaveBeenCalledTimes(1) + expect(hasIcon(button, EyeOff)).toBe(true) + }) + + it('hides the git ignored visibility toggle for non-git folders', () => { + const element = makeToolbar({ showGitIgnoredFilesToggle: false }) + + expect(queryGitIgnoredButton(element)).toBeNull() + }) }) describe('FileExplorerRow collapse folder action', () => { diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index 9facefccc99..c7f98e20e5b 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -6,12 +6,13 @@ import { useActiveWorktree, useRepoById } from '@/store/selectors' import { basename, dirname } from '@/lib/path' import { ScrollArea } from '@/components/ui/scroll-area' import { cn } from '@/lib/utils' +import { isGitRepoKind } from '../../../../shared/repo-kind' import { FileExplorerBackgroundMenu } from './FileExplorerBackgroundMenu' import { FileExplorerToolbar } from './FileExplorerToolbar' import { FileExplorerTreeStatus } from './FileExplorerTreeStatus' import { FileExplorerVirtualRows } from './FileExplorerVirtualRows' import { splitPathSegments } from './path-tree' -import { buildFolderStatusMap, buildIgnoredSet, buildStatusMap } from './status-display' +import { buildFolderStatusMap, buildStatusMap } from './status-display' import { useFileDeletion } from './useFileDeletion' import { useFileExplorerAutoReveal } from './useFileExplorerAutoReveal' import { useFileExplorerHandlers } from './useFileExplorerHandlers' @@ -26,6 +27,7 @@ import { useFileExplorerManualRefresh } from './useFileExplorerManualRefresh' import { useFileExplorerTree } from './useFileExplorerTree' import { useFileExplorerWatch } from './useFileExplorerWatch' import { useFileExplorerSelection } from './useFileExplorerSelection' +import { useFileExplorerGitIgnoredRows } from './useFileExplorerGitIgnoredRows' function FileExplorerInner(): React.JSX.Element { const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) @@ -42,12 +44,12 @@ function FileExplorerInner(): React.JSX.Element { const pinFile = useAppStore((s) => s.pinFile) const activeFileId = useAppStore((s) => s.activeFileId) const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree) - const showGitIgnoredFiles = useAppStore((s) => s.settings?.showGitIgnoredFiles ?? true) const openFiles = useAppStore((s) => s.openFiles) const closeFile = useAppStore((s) => s.closeFile) const worktreePath = activeWorktree?.path ?? null const repoName = activeRepo?.displayName ?? (worktreePath ? basename(worktreePath) : '') + const activeRepoSupportsGit = activeRepo ? isGitRepoKind(activeRepo) : false const expanded = useMemo( () => @@ -59,7 +61,6 @@ function FileExplorerInner(): React.JSX.Element { dirCache, setDirCache, flatRows, - rowsByPath, rootCache, rootError, loadDir, @@ -67,6 +68,13 @@ function FileExplorerInner(): React.JSX.Element { refreshDir, resetAndLoad } = useFileExplorerTree(worktreePath, expanded, activeWorktreeId) + const { + visibleFlatRows, + rowsByPath, + ignoredByRelativePath, + showGitIgnoredFiles, + toggleGitIgnoredFiles + } = useFileExplorerGitIgnoredRows(activeWorktreeId, worktreePath, flatRows, activeRepoSupportsGit) const manualRefresh = useFileExplorerManualRefresh(refreshTree) const canCollapseAll = expanded.size > 0 const handleCollapseAll = useCallback(() => { @@ -93,7 +101,7 @@ function FileExplorerInner(): React.JSX.Element { selectRowWithModifiers, preserveSelectionForContextMenu, copyPathsForNode - } = useFileExplorerSelection(flatRows, isMac) + } = useFileExplorerSelection(visibleFlatRows, isMac) const clearFlashTimeout = useCallback(() => { if (flashTimeoutRef.current !== null) { @@ -106,15 +114,8 @@ function FileExplorerInner(): React.JSX.Element { () => (activeWorktreeId ? (gitStatusByWorktree[activeWorktreeId] ?? []) : []), [activeWorktreeId, gitStatusByWorktree] ) - const ignoredPaths = useAppStore((s) => - activeWorktreeId ? (s.gitIgnoredPathsByWorktree[activeWorktreeId] ?? null) : null - ) const statusByRelativePath = useMemo(() => buildStatusMap(entries), [entries]) const folderStatusByRelativePath = useMemo(() => buildFolderStatusMap(entries), [entries]) - const ignoredByRelativePath = useMemo( - () => (showGitIgnoredFiles ? buildIgnoredSet(ignoredPaths ?? undefined) : new Set()), - [ignoredPaths, showGitIgnoredFiles] - ) const { deleteShortcutLabel, requestDelete } = useFileDeletion({ activeWorktreeId, @@ -199,7 +200,7 @@ function FileExplorerInner(): React.JSX.Element { activeWorktreeId, worktreePath, expanded, - flatRows, + flatRows: visibleFlatRows, scrollRef, refreshDir }) @@ -226,7 +227,7 @@ function FileExplorerInner(): React.JSX.Element { setSelectedPath: setSingleSelectedPath }) - const totalCount = flatRows.length + (inlineInputIndex >= 0 ? 1 : 0) + const totalCount = visibleFlatRows.length + (inlineInputIndex >= 0 ? 1 : 0) const virtualizer = useVirtualizer({ count: totalCount, @@ -239,9 +240,9 @@ function FileExplorerInner(): React.JSX.Element { return '__inline_input__' } const rowIndex = index > inlineInputIndex ? index - 1 : index - return flatRows[rowIndex]?.path ?? `__fallback_${index}` + return visibleFlatRows[rowIndex]?.path ?? `__fallback_${index}` } - return flatRows[index]?.path ?? `__fallback_${index}` + return visibleFlatRows[index]?.path ?? `__fallback_${index}` } }) @@ -254,7 +255,7 @@ function FileExplorerInner(): React.JSX.Element { dirCache, rootCache, rowsByPath, - flatRows, + flatRows: visibleFlatRows, loadDir, setSelectedPath: setSingleSelectedPath, setFlashingPath, @@ -269,7 +270,7 @@ function FileExplorerInner(): React.JSX.Element { pendingExplorerReveal, openFiles, rowsByPath, - flatRows, + flatRows: visibleFlatRows, setSelectedPath: setSingleSelectedPath, virtualizer }) @@ -283,7 +284,7 @@ function FileExplorerInner(): React.JSX.Element { const selectedNode = selectedPath ? (rowsByPath.get(selectedPath) ?? null) : null useFileExplorerKeys({ containerRef: explorerShellRef, - flatRows, + flatRows: visibleFlatRows, inlineInput, selectedPaths, selectedNode, @@ -302,7 +303,7 @@ function FileExplorerInner(): React.JSX.Element { const handleDuplicate = useFileDuplicate({ activeWorktreeId, worktreePath, refreshDir }) const handleRowClick = useCallback( - (node: (typeof flatRows)[number], event: React.MouseEvent) => + (node: (typeof visibleFlatRows)[number], event: React.MouseEvent) => selectRowWithModifiers(node, event, handleClick), [handleClick, selectRowWithModifiers] ) @@ -328,7 +329,7 @@ function FileExplorerInner(): React.JSX.Element { // and empty states so the data-native-file-drop-target marker is always // present. Without this, external file drops would have no target surface // when the tree is empty, still loading, or showing a read error. - const isEmptyState = flatRows.length === 0 && !inlineInput + const isEmptyState = visibleFlatRows.length === 0 && !inlineInput const isLoading = isEmptyState && (rootCache?.loading ?? true) const hasError = isEmptyState && !isLoading && !!rootError const isEmpty = isEmptyState && !isLoading && !hasError @@ -342,6 +343,9 @@ function FileExplorerInner(): React.JSX.Element { refresh={manualRefresh} canCollapseAll={canCollapseAll} onCollapseAll={handleCollapseAll} + showGitIgnoredFilesToggle={activeRepoSupportsGit} + showGitIgnoredFiles={showGitIgnoredFiles} + onToggleGitIgnoredFiles={toggleGitIgnoredFiles} /> void + showGitIgnoredFilesToggle: boolean + showGitIgnoredFiles: boolean + onToggleGitIgnoredFiles: () => void } export function FileExplorerToolbar({ repoName, refresh, canCollapseAll, - onCollapseAll + onCollapseAll, + showGitIgnoredFilesToggle, + showGitIgnoredFiles, + onToggleGitIgnoredFiles }: FileExplorerToolbarProps): React.JSX.Element { + const gitIgnoredLabel = showGitIgnoredFiles ? 'Hide Git Ignored Files' : 'Show Git Ignored Files' return (
{repoName} + {showGitIgnoredFilesToggle ? ( + + + + + + {gitIgnoredLabel} + + + ) : null}