feat(file-explorer): add gitignored visibility toggle (#2022)

This commit is contained in:
Leynier Gutiérrez González
2026-05-17 18:33:47 -04:00
committed by GitHub
parent 0dc0995bd1
commit 231bad8e19
30 changed files with 647 additions and 69 deletions
+42
View File
@@ -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([])
})
})
+42
View File
@@ -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<string[]> {
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<string[]> {
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)) {
ignored.add(ignoredPath)
}
}
return Array.from(ignored)
}
+24
View File
@@ -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)
+39
View File
@@ -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)
+21
View File
@@ -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<string[]> => {
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 (
+1
View File
@@ -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'])
@@ -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: [],
+7
View File
@@ -44,6 +44,13 @@ export class SshGitProvider implements IGitProvider {
})) as GitStatusResult
}
async checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise<string[]> {
return (await this.mux.request('git.checkIgnored', {
worktreePath,
paths: relativePaths
})) as string[]
}
async getHistory(
worktreePath: string,
options: GitHistoryOptions = {}
+1
View File
@@ -145,6 +145,7 @@ export type IFilesystemProvider = {
export type IGitProvider = {
getStatus(worktreePath: string, options?: { includeIgnored?: boolean }): Promise<GitStatusResult>
checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise<string[]>
getHistory(worktreePath: string, options?: GitHistoryOptions): Promise<GitHistoryResult>
commit(worktreePath: string, message: string): Promise<{ success: boolean; error?: string }>
getStagedCommitContext(worktreePath: string): Promise<CommitMessageDraftContext | null>
+16
View File
@@ -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<string[]> {
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 = {}
+2
View File
@@ -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'] =
@@ -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()
+25
View File
@@ -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',
+7
View File
@@ -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,
+5
View File
@@ -1366,6 +1366,11 @@ export type PreloadApi = {
connectionId?: string
includeIgnored?: boolean
}) => Promise<GitStatusResult>
checkIgnored: (args: {
worktreePath: string
paths: string[]
connectionId?: string
}) => Promise<string[]>
history: (
args: { worktreePath: string; connectionId?: string } & GitHistoryOptions
) => Promise<GitHistoryResult>
+5
View File
@@ -1917,6 +1917,11 @@ const api = {
connectionId?: string
includeIgnored?: boolean
}): Promise<unknown> => ipcRenderer.invoke('git:status', args),
checkIgnored: (args: {
worktreePath: string
paths: string[]
connectionId?: string
}): Promise<string[]> => ipcRenderer.invoke('git:checkIgnored', args),
history: (
args: { worktreePath: string; connectionId?: string } & GitHistoryOptions
): Promise<GitHistoryResult> => ipcRenderer.invoke('git:history', args),
+31
View File
@@ -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<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
}
}
+18
View File
@@ -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')
+6 -1
View File
@@ -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<string, unknown>) {
return checkIgnoredPathsOp(this.git.bind(this), params)
}
private async history(params: Record<string, unknown>) {
const worktreePath = params.worktreePath as string
return loadGitHistoryFromExecutor(this.git.bind(this), worktreePath, {
@@ -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<Parameters<typeof FileExplorerToolbar>[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', () => {
@@ -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<string>()),
[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<HTMLButtonElement>) =>
(node: (typeof visibleFlatRows)[number], event: React.MouseEvent<HTMLButtonElement>) =>
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}
/>
<ScrollArea
className={cn(
@@ -396,7 +400,7 @@ function FileExplorerInner(): React.JSX.Element {
<FileExplorerVirtualRows
virtualizer={virtualizer}
inlineInputIndex={inlineInputIndex}
flatRows={flatRows}
flatRows={visibleFlatRows}
inlineInput={inlineInput}
handleInlineSubmit={handleInlineSubmit}
dismissInlineInput={dismissInlineInput}
@@ -1,5 +1,5 @@
import React from 'react'
import { ListCollapse, Loader2, RefreshCw } from 'lucide-react'
import { Eye, EyeOff, ListCollapse, Loader2, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
@@ -12,14 +12,21 @@ type FileExplorerToolbarProps = {
}
canCollapseAll: boolean
onCollapseAll: () => 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 (
<div className="flex h-8 min-h-8 items-center gap-2 border-b border-border px-2">
<span
@@ -28,6 +35,25 @@ export function FileExplorerToolbar({
>
{repoName}
</span>
{showGitIgnoredFilesToggle ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-foreground"
aria-label={gitIgnoredLabel}
onClick={onToggleGitIgnoredFiles}
>
{showGitIgnoredFiles ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{gitIgnoredLabel}
</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -42,8 +42,7 @@ describe('refreshGitStatusForWorktree', () => {
expect(gitStatus).toHaveBeenCalledWith({
worktreePath: '/repo',
connectionId: 'ssh-1',
includeIgnored: true
connectionId: 'ssh-1'
})
expect(deps.setGitStatus).toHaveBeenCalledWith('wt-1', status)
expect(deps.updateWorktreeGitIdentity).toHaveBeenCalledWith('wt-1', {
@@ -81,7 +80,7 @@ describe('refreshGitStatusForWorktree', () => {
expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-2', '/repo', 'ssh-2')
})
it('omits ignored-file status when the setting is disabled', async () => {
it('leaves ignored-file discovery to the File Explorer instead of status polling', async () => {
const status: GitStatusResult = {
entries: [],
conflictOperation: 'unknown'
@@ -91,7 +90,7 @@ describe('refreshGitStatusForWorktree', () => {
const deps = makeDeps()
await refreshGitStatusForWorktree({
settings: { activeRuntimeEnvironmentId: null, showGitIgnoredFiles: false },
settings: { activeRuntimeEnvironmentId: null },
worktreeId: 'wt-3',
worktreePath: '/repo',
deps
@@ -22,22 +22,18 @@ export async function refreshGitStatusForWorktree({
connectionId,
deps
}: {
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId' | 'showGitIgnoredFiles'> | null
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
worktreeId: string
worktreePath: string
connectionId?: string
deps: GitStatusRefreshDeps
}): Promise<void> {
const includeIgnored = settings?.showGitIgnoredFiles ?? true
const status = (await getRuntimeGitStatus(
{
settings,
worktreeId,
worktreePath,
connectionId
},
{ includeIgnored }
)) as GitStatusResult
const status = (await getRuntimeGitStatus({
settings,
worktreeId,
worktreePath,
connectionId
})) as GitStatusResult
deps.setGitStatus(worktreeId, status)
// Why: branch switches can happen inside a terminal. `git status --branch`
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import type { TreeNode } from './file-explorer-types'
import { getVisibleFileExplorerRows } from './useFileExplorerGitIgnoredRows'
function row(relativePath: string): TreeNode {
return {
name: relativePath.split('/').at(-1) ?? relativePath,
path: `/repo/${relativePath}`,
relativePath,
isDirectory: false,
depth: 0
}
}
describe('getVisibleFileExplorerRows', () => {
it('keeps ignored files visible when the toggle is on', () => {
const rows = [row('src/index.ts'), row('dist/bundle.js')]
expect(getVisibleFileExplorerRows(rows, new Set(['dist']), true)).toBe(rows)
})
it('filters ignored files and descendants when the toggle is off', () => {
const rows = [
row('src/index.ts'),
row('dist'),
row('dist/bundle.js'),
row('dist2/bundle.js'),
row('.env')
]
expect(
getVisibleFileExplorerRows(rows, new Set(['dist', '.env']), false).map((r) => r.relativePath)
).toEqual(['src/index.ts', 'dist2/bundle.js'])
})
})
@@ -0,0 +1,98 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useAppStore } from '@/store'
import { getConnectionId } from '@/lib/connection-context'
import { getRuntimeGitIgnoredPaths } from '@/runtime/runtime-git-client'
import type { TreeNode } from './file-explorer-types'
import { buildIgnoredSet, isPathIgnored } from './status-display'
export function getVisibleFileExplorerRows(
flatRows: TreeNode[],
ignoredSet: Set<string>,
showGitIgnoredFiles: boolean
): TreeNode[] {
return showGitIgnoredFiles
? flatRows
: flatRows.filter((row) => !isPathIgnored(ignoredSet, row.relativePath))
}
export function useFileExplorerGitIgnoredRows(
activeWorktreeId: string | null,
worktreePath: string | null,
flatRows: TreeNode[],
activeRepoSupportsGit: boolean
): {
visibleFlatRows: TreeNode[]
rowsByPath: Map<string, TreeNode>
ignoredByRelativePath: Set<string>
showGitIgnoredFiles: boolean
toggleGitIgnoredFiles: () => void
} {
const settings = useAppStore((s) => s.settings)
const updateSettings = useAppStore((s) => s.updateSettings)
const showGitIgnoredFiles = settings?.showGitIgnoredFiles ?? true
const [ignoredPaths, setIgnoredPaths] = useState<string[]>([])
const relativePaths = useMemo(() => flatRows.map((row) => row.relativePath), [flatRows])
useEffect(() => {
if (
!activeRepoSupportsGit ||
!activeWorktreeId ||
!worktreePath ||
relativePaths.length === 0
) {
setIgnoredPaths([])
return
}
let canceled = false
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
void getRuntimeGitIgnoredPaths(
{
settings: useAppStore.getState().settings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
},
relativePaths
)
.then((nextIgnoredPaths) => {
if (!canceled) {
setIgnoredPaths(nextIgnoredPaths)
}
})
.catch(() => {
if (!canceled) {
setIgnoredPaths([])
}
})
return () => {
canceled = true
}
}, [activeRepoSupportsGit, activeWorktreeId, relativePaths, worktreePath])
const ignoredSet = useMemo(() => buildIgnoredSet(ignoredPaths), [ignoredPaths])
const visibleFlatRows = useMemo(
() => getVisibleFileExplorerRows(flatRows, ignoredSet, showGitIgnoredFiles),
[flatRows, ignoredSet, showGitIgnoredFiles]
)
const rowsByPath = useMemo(
() => new Map(visibleFlatRows.map((row) => [row.path, row])),
[visibleFlatRows]
)
const ignoredByRelativePath = useMemo(
() => (showGitIgnoredFiles ? ignoredSet : new Set<string>()),
[ignoredSet, showGitIgnoredFiles]
)
const toggleGitIgnoredFiles = useCallback(() => {
void updateSettings({ showGitIgnoredFiles: !showGitIgnoredFiles })
}, [showGitIgnoredFiles, updateSettings])
return {
visibleFlatRows,
rowsByPath,
ignoredByRelativePath,
showGitIgnoredFiles,
toggleGitIgnoredFiles
}
}
@@ -293,15 +293,14 @@ export function AppearancePane({
<SearchableSetting
title="Show Git-Ignored Files"
description="Dim files matched by .gitignore in the file explorer."
description="Show files matched by .gitignore in the file explorer."
keywords={['git', 'gitignore', 'ignored', 'file explorer', 'sidebar', 'hide']}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Show Git-Ignored Files</Label>
<p className="text-xs text-muted-foreground">
Dim files matched by .gitignore in the file explorer. Turn off to skip the extra git
status work on large repos.
Turn off to hide files matched by .gitignore from the file explorer.
</p>
</div>
<ToggleSwitchButton
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- Why: runtime git routing tests share compatibility-cache and IPC stubs; splitting would hide cross-environment contract drift. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
bulkDiscardRuntimeGitPaths,
@@ -7,6 +8,7 @@ import {
generateRuntimeCommitMessage,
getRuntimeGitDiff,
getRuntimeGitHistory,
getRuntimeGitIgnoredPaths,
getRuntimeGitStatus,
pushRuntimeGit
} from './runtime-git-client'
@@ -17,6 +19,7 @@ import {
import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client'
const gitStatus = vi.fn()
const gitCheckIgnored = vi.fn()
const gitDiff = vi.fn()
const gitHistory = vi.fn()
const gitBulkStage = vi.fn()
@@ -32,6 +35,7 @@ const runtimeCall = vi.fn()
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
gitStatus.mockReset()
gitCheckIgnored.mockReset()
gitDiff.mockReset()
gitHistory.mockReset()
gitBulkStage.mockReset()
@@ -50,6 +54,7 @@ beforeEach(() => {
api: {
git: {
status: gitStatus,
checkIgnored: gitCheckIgnored,
diff: gitDiff,
history: gitHistory,
bulkStage: gitBulkStage,
@@ -111,6 +116,28 @@ describe('runtime git client', () => {
})
})
it('checks ignored paths through local git IPC', async () => {
gitCheckIgnored.mockResolvedValue(['dist/bundle.js'])
const result = await getRuntimeGitIgnoredPaths(
{
settings: { activeRuntimeEnvironmentId: null },
worktreeId: 'wt-1',
worktreePath: '/repo',
connectionId: 'ssh-1'
},
['dist/bundle.js', 'src/index.ts']
)
expect(gitCheckIgnored).toHaveBeenCalledWith({
worktreePath: '/repo',
connectionId: 'ssh-1',
paths: ['dist/bundle.js', 'src/index.ts']
})
expect(result).toEqual(['dist/bundle.js'])
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
})
it('uses local git IPC for history when no remote runtime is active', async () => {
gitHistory.mockResolvedValue({
items: [],
@@ -219,6 +246,32 @@ describe('runtime git client', () => {
})
})
it('checks ignored paths through the active runtime environment', async () => {
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-1',
ok: true,
result: ['dist/bundle.js'],
_meta: { runtimeId: 'remote-runtime' }
})
const result = await getRuntimeGitIgnoredPaths(
{
settings: { activeRuntimeEnvironmentId: 'env-1' },
worktreeId: 'wt-1',
worktreePath: '/repo'
},
['dist/bundle.js']
)
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'git.checkIgnored',
params: { worktree: 'wt-1', paths: ['dist/bundle.js'] },
timeoutMs: 15_000
})
expect(result).toEqual(['dist/bundle.js'])
})
it('routes bulk mutations and remote operations through the active runtime', async () => {
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-1',
@@ -86,6 +86,29 @@ export async function getRuntimeGitStatus(
)
}
export async function getRuntimeGitIgnoredPaths(
context: RuntimeGitContext,
paths: string[]
): Promise<string[]> {
const target = getActiveRuntimeTarget(context.settings)
if (paths.length === 0) {
return []
}
if (target.kind === 'local' || !context.worktreeId) {
return window.api.git.checkIgnored({
worktreePath: context.worktreePath,
connectionId: context.connectionId,
paths
})
}
return callRuntimeRpc<string[]>(
target,
'git.checkIgnored',
{ worktree: context.worktreeId, paths },
{ timeoutMs: 15_000 }
)
}
export async function getRuntimeGitHistory(
context: RuntimeGitContext,
options: GitHistoryOptions = {}
+4
View File
@@ -497,6 +497,10 @@ function createGitApi(): NonNullable<Partial<PreloadApi>['git']> {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.status', { worktree: worktree.id, includeIgnored })
},
checkIgnored: async ({ worktreePath, paths }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.checkIgnored', { worktree: worktree.id, paths })
},
history: async ({ worktreePath, limit, baseRef }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.history', { worktree: worktree.id, limit, baseRef })