diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md index b41f9f61a74..034d5cb23c1 100644 --- a/docs/STYLEGUIDE.md +++ b/docs/STYLEGUIDE.md @@ -57,6 +57,7 @@ For diff status, file-tree decorations, and the changes view, use the git decora | `--git-decoration-renamed` | Renamed | | `--git-decoration-untracked` | Untracked | | `--git-decoration-copied` | Copied | +| `--git-decoration-ignored` | Ignored by git | Use these *only* for git status. Don't reuse them for unrelated state colors — that breaks the convention. diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 24f2648d77d..1fb923fa19c 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -405,6 +405,52 @@ describe('getStatus', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) expect(result.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) }) + + it('omits --ignored and ignoredPaths when includeIgnored is not requested', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) + + const result = await getStatus('/repo') + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + [ + '-c', + 'core.quotePath=false', + 'status', + '--porcelain=v2', + '--branch', + '--untracked-files=all' + ], + { cwd: '/repo' } + ) + expect('ignoredPaths' in result).toBe(false) + }) + + it('parses ! porcelain v2 records into ignoredPaths when includeIgnored is true', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: '! dist/\n! .env\n! coverage/\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/', '.env', 'coverage/']) + expect(result.entries).toEqual([]) + }) }) describe('getStagedCommitContext', () => { diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 1b74904fde7..45803e80290 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -21,11 +21,19 @@ const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024 const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES const BULK_CHUNK_SIZE = 100 +export type GetStatusOptions = { + includeIgnored?: boolean +} + /** * Parse `git status --porcelain=v2` output into structured entries. */ -export async function getStatus(worktreePath: string): Promise { +export async function getStatus( + worktreePath: string, + options: GetStatusOptions = {} +): Promise { const entries: GitStatusEntry[] = [] + const ignoredPaths: string[] = [] let head: string | undefined let branch: string | undefined let upstreamName: string | undefined @@ -39,10 +47,18 @@ export async function getStatus(worktreePath: string): Promise // etc.) as raw UTF-8 instead of git's default C-style octal escapes wrapped // in double quotes. Without it, the parsed entry.path is unreadable in the // sidebar and downstream `git show :"docs/\346..."` lookups silently miss. - const statusPromise = gitExecFileAsync( - ['-c', 'core.quotePath=false', 'status', '--porcelain=v2', '--branch', '--untracked-files=all'], - { cwd: worktreePath } - ) + const statusArgs = [ + '-c', + 'core.quotePath=false', + 'status', + '--porcelain=v2', + '--branch', + '--untracked-files=all' + ] + if (options.includeIgnored) { + statusArgs.push('--ignored=matching') + } + const statusPromise = gitExecFileAsync(statusArgs, { cwd: worktreePath }) const conflictOperation = await conflictPromise try { @@ -116,6 +132,8 @@ export async function getStatus(worktreePath: string): Promise // Untracked file const path = line.slice(2) entries.push({ path, status: 'untracked', area: 'untracked' }) + } else if (line.startsWith('! ')) { + ignoredPaths.push(line.slice(2)) } else if (line.startsWith('u ')) { const unmergedEntry = await parseUnmergedEntry(worktreePath, line) if (unmergedEntry) { @@ -133,6 +151,7 @@ export async function getStatus(worktreePath: string): Promise conflictOperation, head, branch, + ...(options.includeIgnored ? { ignoredPaths } : {}), ...(statusSucceeded ? { upstreamStatus: upstreamName diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 5196bdb475b..e94d20d9ce1 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -470,7 +470,31 @@ describe('registerFilesystemHandlers', () => { expect(listWorktreesMock).not.toHaveBeenCalled() expect(realpathMock).not.toHaveBeenCalledWith(WORKTREE_FEATURE_PATH) - expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH) + expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { includeIgnored: false }) + }) + + it('forwards includeIgnored through local and SSH git status IPC', async () => { + registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) + getStatusMock.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + const sshProvider = { + getStatus: vi.fn().mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + } + getSshGitProviderMock.mockReturnValue(sshProvider) + + registerFilesystemHandlers(store as never) + + await handlers.get('git:status')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + includeIgnored: true + }) + await handlers.get('git:status')!(null, { + worktreePath: '/remote/repo', + connectionId: 'ssh-1', + includeIgnored: true + }) + + expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { includeIgnored: true }) + expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', { includeIgnored: true }) }) it('rejects git file paths that escape the selected worktree', async () => { diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index f35adbca549..2223c64ed9a 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -532,17 +532,18 @@ export function registerFilesystemHandlers( 'git:status', async ( _event, - args: { worktreePath: string; connectionId?: string } + args: { worktreePath: string; connectionId?: string; includeIgnored?: boolean } ): Promise => { + const options = { includeIgnored: args.includeIgnored ?? false } if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(`No git provider for connection "${args.connectionId}"`) } - return provider.getStatus(args.worktreePath) + return provider.getStatus(args.worktreePath, options) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - return getStatus(worktreePath) + return getStatus(worktreePath, options) } ) diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 636fa229d60..8c948fdaefb 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -41,6 +41,22 @@ describe('SshGitProvider', () => { expect(result).toEqual(statusResult) }) + it('getStatus forwards includeIgnored only when requested', async () => { + const statusResult = { entries: [], conflictOperation: 'unknown', ignoredPaths: ['dist/'] } + mux.request.mockResolvedValue(statusResult) + + await provider.getStatus('/home/user/repo', { includeIgnored: true }) + await provider.getStatus('/home/user/repo', { includeIgnored: false }) + + expect(mux.request).toHaveBeenNthCalledWith(1, 'git.status', { + worktreePath: '/home/user/repo', + includeIgnored: true + }) + expect(mux.request).toHaveBeenNthCalledWith(2, 'git.status', { + worktreePath: '/home/user/repo' + }) + }) + it('commit sends git.commit request', async () => { const commitResult = { success: true } mux.request.mockResolvedValue(commitResult) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index e73e92f7658..24b236a24de 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -31,8 +31,15 @@ export class SshGitProvider implements IGitProvider { return this.connectionId } - async getStatus(worktreePath: string): Promise { - return (await this.mux.request('git.status', { worktreePath })) as GitStatusResult + async getStatus( + worktreePath: string, + options?: { includeIgnored?: boolean } + ): Promise { + const includeIgnoredArgs = options?.includeIgnored ? { includeIgnored: true } : {} + return (await this.mux.request('git.status', { + worktreePath, + ...includeIgnoredArgs + })) as GitStatusResult } async commit( diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index da8e8758ac6..278aa0d9ff8 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -142,7 +142,10 @@ export type IFilesystemProvider = { // ─── Git Provider ─────────────────────────────────────────────────── export type IGitProvider = { - getStatus(worktreePath: string): Promise + getStatus( + worktreePath: string, + options?: { includeIgnored?: boolean } + ): Promise commit(worktreePath: string, message: string): Promise<{ success: boolean; error?: string }> getStagedCommitContext(worktreePath: string): Promise getDiff( diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index 9c405a04599..51051c99427 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -38,16 +38,21 @@ export type RuntimeGitCommandHost = { export class RuntimeGitCommands { constructor(private readonly host: RuntimeGitCommandHost) {} - async getRuntimeGitStatus(worktreeSelector: string): Promise { + async getRuntimeGitStatus( + worktreeSelector: string, + options?: { includeIgnored?: boolean } + ): 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.getStatus(target.worktree.path) + return options + ? provider.getStatus(target.worktree.path, options) + : provider.getStatus(target.worktree.path) } - return getGitStatus(target.worktree.path) + return options ? getGitStatus(target.worktree.path, options) : getGitStatus(target.worktree.path) } async getRuntimeGitConflictOperation(worktreeSelector: string): Promise { diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index 0bd8a810710..d3edfde093f 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -30,6 +30,30 @@ describe('git RPC methods', () => { }) }) + it('forwards includeIgnored for status requests', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRuntimeGitStatus: vi.fn().mockResolvedValue({ + entries: [], + conflictOperation: 'unknown', + ignoredPaths: ['dist/'] + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('git.status', { worktree: 'id:wt-1', includeIgnored: true }) + ) + + expect(runtime.getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1', { + includeIgnored: true + }) + expect(response).toMatchObject({ + ok: true, + result: { ignoredPaths: ['dist/'] } + }) + }) + 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 3d4e6274046..014e08a5abf 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -8,6 +8,10 @@ const WorktreeSelector = z.object({ .pipe(z.string().min(1, 'Missing worktree selector')) }) +const GitStatusParams = WorktreeSelector.extend({ + includeIgnored: z.boolean().optional() +}) + const GitFilePath = WorktreeSelector.extend({ filePath: z .unknown() @@ -73,8 +77,11 @@ const GitRemoteFileUrl = WorktreeSelector.extend({ export const GIT_METHODS: RpcMethod[] = [ defineMethod({ name: 'git.status', - params: WorktreeSelector, - handler: async (params, { runtime }) => runtime.getRuntimeGitStatus(params.worktree) + params: GitStatusParams, + handler: async (params, { runtime }) => + params.includeIgnored === undefined + ? runtime.getRuntimeGitStatus(params.worktree) + : runtime.getRuntimeGitStatus(params.worktree, { includeIgnored: params.includeIgnored }) }), defineMethod({ name: 'git.conflictOperation', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 69dfe6e1339..2cd66348d00 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1321,7 +1321,11 @@ export type PreloadApi = { onFsChanged: (callback: (payload: FsChangedPayload) => void) => () => void } git: { - status: (args: { worktreePath: string; connectionId?: string }) => Promise + status: (args: { + worktreePath: string + connectionId?: string + includeIgnored?: boolean + }) => Promise conflictOperation: (args: { worktreePath: string connectionId?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 936464def91..ea337c4d89b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1873,8 +1873,11 @@ const api = { }, git: { - status: (args: { worktreePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('git:status', args), + status: (args: { + worktreePath: string + connectionId?: string + includeIgnored?: boolean + }): Promise => ipcRenderer.invoke('git:status', args), conflictOperation: (args: { worktreePath: string; connectionId?: string }): Promise => ipcRenderer.invoke('git:conflictOperation', args), diff: (args: { diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index baa3b115deb..7576a67a207 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -60,8 +60,10 @@ export async function getStatusOp( ahead: number behind: number } + ignoredPaths?: string[] }> { const worktreePath = params.worktreePath as string + const includeIgnored = params.includeIgnored === true const conflictOperation = await detectConflictOperation(worktreePath) const entries: Record[] = [] let head: string | undefined @@ -74,28 +76,31 @@ export async function getStatusOp( behind: number } | undefined + let ignoredPaths: string[] = [] try { // Why: -c core.quotePath=false keeps non-ASCII filenames as raw UTF-8 in // git's stdout instead of C-style octal escapes; without it the parsed // entry.path renders as gibberish in the source-control sidebar and // downstream blob lookups miss. - const { stdout } = await git( - [ - '-c', - 'core.quotePath=false', - 'status', - '--porcelain=v2', - '--branch', - '--untracked-files=all' - ], - worktreePath - ) + const statusArgs = [ + '-c', + 'core.quotePath=false', + 'status', + '--porcelain=v2', + '--branch', + '--untracked-files=all' + ] + if (includeIgnored) { + statusArgs.push('--ignored=matching') + } + const { stdout } = await git(statusArgs, worktreePath) const parsed = parseStatusOutput(stdout) entries.push(...parsed.entries) head = parsed.head branch = parsed.branch upstreamStatus = parsed.upstreamStatus + ignoredPaths = parsed.ignoredPaths for (const uLine of parsed.unmergedLines) { const entry = parseUnmergedEntry(worktreePath, uLine) @@ -107,5 +112,12 @@ export async function getStatusOp( // not a git repo or git not available } - return { entries, conflictOperation, head, branch, upstreamStatus } + return { + entries, + conflictOperation, + head, + branch, + upstreamStatus, + ...(includeIgnored ? { ignoredPaths } : {}) + } } diff --git a/src/relay/git-handler-utils.test.ts b/src/relay/git-handler-utils.test.ts index 2ab5da3b5e7..ad2b7964960 100644 --- a/src/relay/git-handler-utils.test.ts +++ b/src/relay/git-handler-utils.test.ts @@ -28,4 +28,13 @@ describe('parseStatusOutput', () => { expect(result.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) }) + + it('parses ignored porcelain records separately from actionable entries', () => { + const result = parseStatusOutput(['! dist/', '! .env', '? scratch.txt', ''].join('\n')) + + expect(result.ignoredPaths).toEqual(['dist/', '.env']) + expect(result.entries).toEqual([ + { path: 'scratch.txt', status: 'untracked', area: 'untracked' } + ]) + }) }) diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index a4104c449ff..3e40cdeb365 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -92,6 +92,30 @@ describe('GitHandler', () => { expect(untracked!.area).toBe('untracked') }) + it('returns ignored paths only when requested', 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 defaultResult = (await dispatcher.callRequest('git.status', { + worktreePath: tmpDir + })) as { + ignoredPaths?: string[] + } + const ignoredResult = (await dispatcher.callRequest('git.status', { + worktreePath: tmpDir, + includeIgnored: true + })) as { + ignoredPaths?: string[] + } + + expect('ignoredPaths' in defaultResult).toBe(false) + expect(ignoredResult.ignoredPaths).toEqual(expect.arrayContaining(['dist/', '.env'])) + }) + it('detects modified files', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'file.txt'), 'original') diff --git a/src/relay/git-status-output-parser.ts b/src/relay/git-status-output-parser.ts index 1299943401a..78f66e2129a 100644 --- a/src/relay/git-status-output-parser.ts +++ b/src/relay/git-status-output-parser.ts @@ -22,6 +22,7 @@ export function parseStatusChar(char: string): string { export function parseStatusOutput(stdout: string): { entries: Record[] unmergedLines: string[] + ignoredPaths: string[] head?: string branch?: string upstreamStatus: { @@ -33,6 +34,7 @@ export function parseStatusOutput(stdout: string): { } { const entries: Record[] = [] const unmergedLines: string[] = [] + const ignoredPaths: string[] = [] let head: string | undefined let branch: string | undefined let upstreamName: string | undefined @@ -108,6 +110,8 @@ export function parseStatusOutput(stdout: string): { } } else if (line.startsWith('? ')) { entries.push({ path: line.slice(2), status: 'untracked', area: 'untracked' }) + } else if (line.startsWith('! ')) { + ignoredPaths.push(line.slice(2)) } else if (line.startsWith('u ')) { unmergedLines.push(line) } @@ -116,6 +120,7 @@ export function parseStatusOutput(stdout: string): { return { entries, unmergedLines, + ignoredPaths, head, branch, upstreamStatus: upstreamName diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index dffa6e36926..1f9b60dcbb2 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -114,6 +114,7 @@ --git-decoration-renamed: #007acc; --git-decoration-untracked: #007100; --git-decoration-copied: #007acc; + --git-decoration-ignored: #8c8c8c; } /* ── Dark Mode ───────────────────────────────────────── */ @@ -158,6 +159,7 @@ --git-decoration-renamed: #73c991; --git-decoration-untracked: #73c991; --git-decoration-copied: #73c991; + --git-decoration-ignored: #6e6e6e; } /* ── Base Layer ──────────────────────────────────────── */ diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index ef6a70b5f88..3a0dcef4989 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -10,7 +10,7 @@ import { FileExplorerToolbar } from './FileExplorerToolbar' import { FileExplorerTreeStatus } from './FileExplorerTreeStatus' import { FileExplorerVirtualRows } from './FileExplorerVirtualRows' import { splitPathSegments } from './path-tree' -import { buildFolderStatusMap, buildStatusMap } from './status-display' +import { buildFolderStatusMap, buildIgnoredSet, buildStatusMap } from './status-display' import { useFileDeletion } from './useFileDeletion' import { useFileExplorerAutoReveal } from './useFileExplorerAutoReveal' import { useFileExplorerHandlers } from './useFileExplorerHandlers' @@ -39,6 +39,7 @@ 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) @@ -95,8 +96,15 @@ 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, @@ -370,6 +378,7 @@ function FileExplorerInner(): React.JSX.Element { dismissInlineInput={dismissInlineInput} folderStatusByRelativePath={folderStatusByRelativePath} statusByRelativePath={statusByRelativePath} + ignoredByRelativePath={ignoredByRelativePath} expanded={expanded} dirCache={dirCache} selectedPaths={selectedPaths} diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index d407575b9e1..7d046c167d3 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useRef } from 'react' import { ChevronRight, + CircleSlash, Copy, ExternalLink, Eye, @@ -198,6 +199,7 @@ type FileExplorerRowProps = { isFlashing: boolean nodeStatus: GitFileStatus | null statusColor: string | null + isIgnored: boolean deleteShortcutLabel: string targetDir: string targetDepth: number @@ -226,6 +228,7 @@ export function FileExplorerRow({ isFlashing, nodeStatus, statusColor, + isIgnored, deleteShortcutLabel, targetDir, targetDepth, @@ -311,8 +314,18 @@ export function FileExplorerRow({ )} { // Why: the row itself swallows double-click for "pin preview" / // directory toggle. Scope rename to the filename text only so @@ -324,14 +337,20 @@ export function FileExplorerRow({ > {node.name} - {nodeStatus && ( + {nodeStatus ? ( {STATUS_LABELS[nodeStatus]} - )} + ) : isIgnored ? ( + + ) : null} void folderStatusByRelativePath: Map statusByRelativePath: Map + ignoredByRelativePath: Set expanded: Set dirCache: Record selectedPaths: Set @@ -52,6 +53,7 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re dismissInlineInput, folderStatusByRelativePath, statusByRelativePath, + ignoredByRelativePath, expanded, dirCache, selectedPaths, @@ -121,6 +123,11 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re const nodeStatus = n.isDirectory ? (folderStatusByRelativePath.get(normalizedRelativePath) ?? null) : (statusByRelativePath.get(normalizedRelativePath) ?? null) + const isIgnored = shouldShowIgnoredDecoration( + nodeStatus, + ignoredByRelativePath, + normalizedRelativePath + ) const rowParentDir = n.isDirectory ? n.path : dirname(n.path) const sourceParentDir = dragSourcePath ? dirname(dragSourcePath) : null @@ -145,6 +152,7 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re isFlashing={flashingPath === n.path} nodeStatus={nodeStatus} statusColor={nodeStatus ? STATUS_COLORS[nodeStatus] : null} + isIgnored={isIgnored} deleteShortcutLabel={deleteShortcutLabel} targetDir={n.isDirectory ? n.path : dirname(n.path)} targetDepth={n.isDirectory ? n.depth + 1 : n.depth} diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts index dc612f389ec..ec861c87ef7 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts @@ -40,7 +40,11 @@ describe('refreshGitStatusForWorktree', () => { deps }) - expect(gitStatus).toHaveBeenCalledWith({ worktreePath: '/repo', connectionId: 'ssh-1' }) + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: 'ssh-1', + includeIgnored: true + }) expect(deps.setGitStatus).toHaveBeenCalledWith('wt-1', status) expect(deps.updateWorktreeGitIdentity).toHaveBeenCalledWith('wt-1', { head: 'abc123', @@ -76,4 +80,27 @@ describe('refreshGitStatusForWorktree', () => { expect(deps.setUpstreamStatus).not.toHaveBeenCalled() expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-2', '/repo', 'ssh-2') }) + + it('omits ignored-file status when the setting is disabled', async () => { + const status: GitStatusResult = { + entries: [], + conflictOperation: 'unknown' + } + const gitStatus = vi.fn().mockResolvedValue(status) + vi.stubGlobal('window', { api: { git: { status: gitStatus } } }) + const deps = makeDeps() + + await refreshGitStatusForWorktree({ + settings: { activeRuntimeEnvironmentId: null, showGitIgnoredFiles: false }, + worktreeId: 'wt-3', + worktreePath: '/repo', + deps + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: undefined + }) + expect(deps.setGitStatus).toHaveBeenCalledWith('wt-3', status) + }) }) diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.ts index d7fafc21d8e..450120afd68 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.ts @@ -22,18 +22,22 @@ export async function refreshGitStatusForWorktree({ connectionId, deps }: { - settings?: Pick | null + settings?: Pick | null worktreeId: string worktreePath: string connectionId?: string deps: GitStatusRefreshDeps }): Promise { - const status = (await getRuntimeGitStatus({ - settings, - worktreeId, - worktreePath, - connectionId - })) as GitStatusResult + const includeIgnored = settings?.showGitIgnoredFiles ?? true + const status = (await getRuntimeGitStatus( + { + settings, + worktreeId, + worktreePath, + connectionId + }, + { includeIgnored } + )) as GitStatusResult deps.setGitStatus(worktreeId, status) // Why: branch switches can happen inside a terminal. `git status --branch` diff --git a/src/renderer/src/components/right-sidebar/status-display.test.ts b/src/renderer/src/components/right-sidebar/status-display.test.ts new file mode 100644 index 00000000000..5ec169088d9 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/status-display.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { buildIgnoredSet, isPathIgnored, shouldShowIgnoredDecoration } from './status-display' + +describe('buildIgnoredSet', () => { + it('returns an empty set when ignoredPaths is undefined', () => { + expect(buildIgnoredSet(undefined).size).toBe(0) + }) + + it('strips trailing slash from directory entries so lookups by TreeNode.relativePath hit', () => { + const set = buildIgnoredSet(['dist/', 'node_modules/', '.env']) + expect(set.has('dist')).toBe(true) + expect(set.has('node_modules')).toBe(true) + expect(set.has('.env')).toBe(true) + expect(set.has('dist/')).toBe(false) + }) +}) + +describe('isPathIgnored', () => { + it('returns false on an empty set without walking ancestors', () => { + expect(isPathIgnored(new Set(), 'a/b/c.ts')).toBe(false) + }) + + it('matches direct hits', () => { + expect(isPathIgnored(new Set(['.env']), '.env')).toBe(true) + }) + + it('inherits ignored status from an ancestor directory', () => { + const ignored = new Set(['dist']) + expect(isPathIgnored(ignored, 'dist/index.js')).toBe(true) + expect(isPathIgnored(ignored, 'dist/sub/deep/file.js')).toBe(true) + }) + + it('does not match sibling paths that share a prefix', () => { + const ignored = new Set(['dist']) + expect(isPathIgnored(ignored, 'distance.ts')).toBe(false) + }) +}) + +describe('shouldShowIgnoredDecoration', () => { + it('shows ignored decoration only when no real git status exists', () => { + const ignored = new Set(['dist']) + + expect(shouldShowIgnoredDecoration(null, ignored, 'dist/index.js')).toBe(true) + expect(shouldShowIgnoredDecoration('modified', ignored, 'dist/index.js')).toBe(false) + expect(shouldShowIgnoredDecoration('untracked', ignored, 'dist/index.js')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/status-display.ts b/src/renderer/src/components/right-sidebar/status-display.ts index c8a7ee7b447..92fa3e7769b 100644 --- a/src/renderer/src/components/right-sidebar/status-display.ts +++ b/src/renderer/src/components/right-sidebar/status-display.ts @@ -95,3 +95,43 @@ export function buildFolderStatusMap(entries: GitStatusEntry[]): Map, relativePath: string): boolean { + if (ignored.size === 0) { + return false + } + if (ignored.has(relativePath)) { + return true + } + let candidate = relativePath + for (;;) { + const idx = candidate.lastIndexOf('/') + if (idx <= 0) { + return false + } + candidate = candidate.slice(0, idx) + if (ignored.has(candidate)) { + return true + } + } +} + +export function shouldShowIgnoredDecoration( + nodeStatus: GitFileStatus | null, + ignored: Set, + relativePath: string +): boolean { + return !nodeStatus && isPathIgnored(ignored, relativePath) +} + +export function buildIgnoredSet(ignoredPaths: readonly string[] | undefined): Set { + const set = new Set() + if (!ignoredPaths) { + return set + } + for (const rawPath of ignoredPaths) { + const trimmed = rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath + set.add(normalizeRelativePath(trimmed)) + } + return set +} diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index ee1d6567d75..ecb7872aefb 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -1,3 +1,4 @@ +import type React from 'react' import type { GlobalSettings, StatusBarItem } from '../../../../shared/types' import { Label } from '../ui/label' import { Separator } from '../ui/separator' @@ -16,6 +17,35 @@ type AppearancePaneProps = { fontSuggestions: string[] } +function ToggleSwitchButton({ + checked, + onToggle, + ariaLabel +}: { + checked: boolean + onToggle: () => void + ariaLabel?: string +}): React.JSX.Element { + return ( + + ) +} + const STATUS_BAR_TOGGLES: readonly { id: StatusBarItem title: string @@ -98,6 +128,11 @@ const LAYOUT_ENTRIES: SettingsSearchEntry[] = [ title: 'Open Right Sidebar by Default', description: 'Automatically expand the file explorer panel when creating a new worktree.', keywords: ['layout', 'file explorer', 'sidebar'] + }, + { + title: 'Show Git-Ignored Files', + description: 'Dim files matched by .gitignore in the file explorer.', + keywords: ['git', 'gitignore', 'ignored', 'file explorer', 'sidebar', 'hide'] } ] @@ -248,24 +283,33 @@ export function AppearancePane({ Automatically expand the file explorer panel when creating a new worktree.

- + /> + + + +
+ +

+ Dim files matched by .gitignore in the file explorer. Turn off to skip the extra git + status work on large repos. +

+
+ + updateSettings({ showGitIgnoredFiles: !(settings.showGitIgnoredFiles ?? true) }) + } + />
) : null, @@ -288,24 +332,12 @@ export function AppearancePane({

Show Orca in the titlebar.

- + /> ) : null, @@ -333,22 +365,11 @@ export function AppearancePane({

{toggle.toggleDescription}

- + toggleStatusBarItem(toggle.id)} + ariaLabel={toggle.title} + /> ) })} @@ -372,24 +393,10 @@ export function AppearancePane({ Show the Tasks button at the top of the left sidebar.

- + updateSettings({ showTasksButton: !settings.showTasksButton })} + /> ) : null diff --git a/src/renderer/src/runtime/runtime-git-client.test.ts b/src/renderer/src/runtime/runtime-git-client.test.ts index 19888c32ae8..0958aaa807e 100644 --- a/src/renderer/src/runtime/runtime-git-client.test.ts +++ b/src/renderer/src/runtime/runtime-git-client.test.ts @@ -64,6 +64,37 @@ describe('runtime git client', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) + it('forwards includeIgnored to local git status only when enabled', async () => { + gitStatus.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + + await getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { includeIgnored: true } + ) + await getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { includeIgnored: false } + ) + + expect(gitStatus).toHaveBeenNthCalledWith(1, { + worktreePath: '/repo', + connectionId: undefined, + includeIgnored: true + }) + expect(gitStatus).toHaveBeenNthCalledWith(2, { + worktreePath: '/repo', + connectionId: undefined + }) + }) + it('routes status and diffs through the active runtime environment', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', @@ -105,6 +136,31 @@ describe('runtime git client', () => { }) }) + it('forwards includeIgnored through the active runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { entries: [], conflictOperation: 'unknown', ignoredPaths: ['dist/'] }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { includeIgnored: true } + ) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'git.status', + params: { worktree: 'wt-1', includeIgnored: true }, + timeoutMs: 15_000 + }) + }) + it('routes bulk stage and remote operations through the active runtime', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts index 759edbd624c..27a61ac7aa7 100644 --- a/src/renderer/src/runtime/runtime-git-client.ts +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -27,18 +27,23 @@ export function getRuntimeGitScope( return target.kind === 'environment' ? `runtime:${target.environmentId}` : connectionId } -export async function getRuntimeGitStatus(context: RuntimeGitContext): Promise { +export async function getRuntimeGitStatus( + context: RuntimeGitContext, + options?: { includeIgnored?: boolean } +): Promise { const target = getActiveRuntimeTarget(context.settings) + const includeIgnoredArgs = options?.includeIgnored ? { includeIgnored: true } : {} if (target.kind === 'local' || !context.worktreeId) { return window.api.git.status({ worktreePath: context.worktreePath, - connectionId: context.connectionId + connectionId: context.connectionId, + ...includeIgnoredArgs }) } return callRuntimeRpc( target, 'git.status', - { worktree: context.worktreeId }, + { worktree: context.worktreeId, ...includeIgnoredArgs }, { timeoutMs: 15_000 } ) } diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index 9c8b8f8d233..c06dd8b9acb 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -682,6 +682,24 @@ describe('createEditorSlice editor drafts', () => { }) describe('createEditorSlice conflict status reconciliation', () => { + it('clears ignored path cache when status refresh omits ignored paths', () => { + const store = createEditorStore() + + store.getState().setGitStatus('wt-1', { + conflictOperation: 'unknown', + entries: [], + ignoredPaths: ['dist/', '.env'] + }) + expect(store.getState().gitIgnoredPathsByWorktree['wt-1']).toEqual(['dist/', '.env']) + + store.getState().setGitStatus('wt-1', { + conflictOperation: 'unknown', + entries: [] + }) + + expect(store.getState().gitIgnoredPathsByWorktree['wt-1']).toEqual([]) + }) + it('tracks unresolved conflicts when opened through the conflict-safe entry point', () => { const store = createEditorStore() diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 28af586219f..4cda8ffe205 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -313,6 +313,7 @@ export type EditorSlice = { // Git status cache gitStatusByWorktree: Record + gitIgnoredPathsByWorktree: Record gitConflictOperationByWorktree: Record trackedConflictPathsByWorktree: Record> trackConflictPath: (worktreeId: string, path: string, conflictKind: GitConflictKind) => void @@ -1824,6 +1825,7 @@ export const createEditorSlice: StateCreator = (s // Git status gitStatusByWorktree: {}, + gitIgnoredPathsByWorktree: {}, gitConflictOperationByWorktree: {}, trackedConflictPathsByWorktree: {}, trackConflictPath: (worktreeId, path, conflictKind) => @@ -1912,7 +1914,20 @@ export const createEditorSlice: StateCreator = (s const openFilesUnchanged = nextOpenFiles === s.openFiles const operationUnchanged = prevOperation === status.conflictOperation - if (statusUnchanged && trackedUnchanged && openFilesUnchanged && operationUnchanged) { + const prevIgnored = s.gitIgnoredPathsByWorktree[worktreeId] + const nextIgnored = status.ignoredPaths ?? [] + const ignoredUnchanged = + prevIgnored !== undefined && + prevIgnored.length === nextIgnored.length && + prevIgnored.every((p, i) => p === nextIgnored[i]) + + if ( + statusUnchanged && + trackedUnchanged && + openFilesUnchanged && + operationUnchanged && + ignoredUnchanged + ) { return s } @@ -1921,6 +1936,9 @@ export const createEditorSlice: StateCreator = (s gitStatusByWorktree: statusUnchanged ? s.gitStatusByWorktree : { ...s.gitStatusByWorktree, [worktreeId]: nextEntries }, + gitIgnoredPathsByWorktree: ignoredUnchanged + ? s.gitIgnoredPathsByWorktree + : { ...s.gitIgnoredPathsByWorktree, [worktreeId]: nextIgnored }, gitConflictOperationByWorktree: operationUnchanged ? s.gitConflictOperationByWorktree : { ...s.gitConflictOperationByWorktree, [worktreeId]: status.conflictOperation }, diff --git a/src/renderer/src/store/slices/settings.test.ts b/src/renderer/src/store/slices/settings.test.ts index 0b762ef5e40..ccef71d5c57 100644 --- a/src/renderer/src/store/slices/settings.test.ts +++ b/src/renderer/src/store/slices/settings.test.ts @@ -120,6 +120,7 @@ describe('createSettingsSlice runtime switching', () => { markdownViewMode: { '/env-1/repo/stale.md': 'rich' }, editorViewMode: { '/env-1/repo/stale.md': 'changes' }, editorCursorLine: { '/env-1/repo/stale.md': 4 }, + gitIgnoredPathsByWorktree: { 'repo-env-1::/env-1/repo': ['dist/'] }, prCache: { '/env-1/repo::main': { data: null, fetchedAt: Date.now() } }, linearIssueCache: { 'LIN-1': { data: { id: 'LIN-1' } as never, fetchedAt: Date.now() } } }) @@ -170,6 +171,7 @@ describe('createSettingsSlice runtime switching', () => { expect(store.getState().markdownViewMode).toEqual({}) expect(store.getState().editorViewMode).toEqual({}) expect(store.getState().editorCursorLine).toEqual({}) + expect(store.getState().gitIgnoredPathsByWorktree).toEqual({}) expect(store.getState().ptyIdsByTabId).toEqual({}) expect(store.getState().browserTabsByWorktree).toEqual({}) expect(store.getState().prCache).toEqual({}) diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index ea3d5b386ed..6ea2d9c00f1 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -78,6 +78,7 @@ function runtimeScopedStateReset(): Partial { markdownViewMode: {}, editorViewMode: {}, editorCursorLine: {}, + gitIgnoredPathsByWorktree: {}, activeFileId: null, activeFileIdByWorktree: {}, activeTabTypeByWorktree: {}, diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 22ec15d7b21..14cfbbbe262 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -76,6 +76,7 @@ function createTestStore() { editorViewMode: {}, expandedDirs: {}, gitStatusByWorktree: {}, + gitIgnoredPathsByWorktree: {}, gitConflictOperationByWorktree: {}, trackedConflictPathsByWorktree: {}, gitBranchChangesByWorktree: {}, @@ -901,6 +902,10 @@ describe('removeWorktree state cleanup', () => { 'repo1::/path/wt1': [{ path: 'a.ts' }], 'repo1::/path/wt2': [{ path: 'b.ts' }] }, + gitIgnoredPathsByWorktree: { + 'repo1::/path/wt1': ['dist/'], + 'repo1::/path/wt2': ['coverage/'] + }, gitConflictOperationByWorktree: { 'repo1::/path/wt1': 'merge', 'repo1::/path/wt2': 'unknown' @@ -928,6 +933,9 @@ describe('removeWorktree state cleanup', () => { expect(store.getState().gitStatusByWorktree).toEqual({ 'repo1::/path/wt2': [{ path: 'b.ts' }] }) + expect(store.getState().gitIgnoredPathsByWorktree).toEqual({ + 'repo1::/path/wt2': ['coverage/'] + }) expect(store.getState().gitConflictOperationByWorktree).toEqual({ 'repo1::/path/wt2': 'unknown' }) @@ -1385,6 +1393,11 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { 'repoA::/a/wt1': [{ id: 'tab-A', worktreeId: 'repoA::/a/wt1' }], 'repoA::/a/zombie': [{ id: 'tab-zombie', worktreeId: 'repoA::/a/zombie' }], 'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }] + }, + gitIgnoredPathsByWorktree: { + 'repoA::/a/wt1': ['dist/'], + 'repoA::/a/zombie': ['coverage/'], + 'repoB::/b/wt1': ['build/'] } } as unknown as Partial) @@ -1396,6 +1409,10 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { 'repoA::/a/wt1': [{ id: 'tab-A', worktreeId: 'repoA::/a/wt1' }], 'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }] }) + expect(store.getState().gitIgnoredPathsByWorktree).toEqual({ + 'repoA::/a/wt1': ['dist/'], + 'repoB::/b/wt1': ['build/'] + }) // Second call must not re-run the purge even if new stale ids appear. store.setState({ @@ -1455,6 +1472,10 @@ describe('purgeWorktreeTerminalState direct (design §4.4)', () => { } ], editorDrafts: { 'file-1': 'draft', 'file-99': 'other' }, + gitIgnoredPathsByWorktree: { + 'repoA::/a/wt1': ['dist/'], + 'repoA::/a/wt2': ['coverage/'] + }, activeWorktreeId: 'repoA::/a/wt1', worktreeLineageById: { 'repoA::/a/wt1': makeLineage({ worktreeId: 'repoA::/a/wt1' }), @@ -1479,6 +1500,7 @@ describe('purgeWorktreeTerminalState direct (design §4.4)', () => { expect(s.runtimePaneTitlesByTabId).toEqual({ 'tab-3': 'bash' }) expect(s.openFiles).toEqual([]) expect(s.editorDrafts).toEqual({ 'file-99': 'other' }) + expect(s.gitIgnoredPathsByWorktree).toEqual({ 'repoA::/a/wt2': ['coverage/'] }) expect(s.activeWorktreeId).toBeNull() expect(s.activeFileId).toBeNull() expect(s.activeTabId).toBeNull() diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 1b10165f037..04129dc6cc7 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -624,6 +624,8 @@ export const createWorktreeSlice: StateCreator // request keys indefinitely in a long-lived renderer session. const nextGitStatusByWorktree = { ...s.gitStatusByWorktree } delete nextGitStatusByWorktree[worktreeId] + const nextGitIgnoredPathsByWorktree = { ...s.gitIgnoredPathsByWorktree } + delete nextGitIgnoredPathsByWorktree[worktreeId] const nextGitConflictOperationByWorktree = { ...s.gitConflictOperationByWorktree } delete nextGitConflictOperationByWorktree[worktreeId] const nextTrackedConflictPathsByWorktree = { ...s.trackedConflictPathsByWorktree } @@ -717,6 +719,7 @@ export const createWorktreeSlice: StateCreator editorViewMode: nextEditorViewMode, expandedDirs: nextExpandedDirs, gitStatusByWorktree: nextGitStatusByWorktree, + gitIgnoredPathsByWorktree: nextGitIgnoredPathsByWorktree, gitConflictOperationByWorktree: nextGitConflictOperationByWorktree, trackedConflictPathsByWorktree: nextTrackedConflictPathsByWorktree, gitBranchChangesByWorktree: nextGitBranchChangesByWorktree, @@ -1359,6 +1362,7 @@ export const createWorktreeSlice: StateCreator activeGroupIdByWorktree: omitByWorktree(s.activeGroupIdByWorktree), // Git status caches gitStatusByWorktree: omitByWorktree(s.gitStatusByWorktree), + gitIgnoredPathsByWorktree: omitByWorktree(s.gitIgnoredPathsByWorktree), gitConflictOperationByWorktree: omitByWorktree(s.gitConflictOperationByWorktree), trackedConflictPathsByWorktree: omitByWorktree(s.trackedConflictPathsByWorktree), gitBranchChangesByWorktree: omitByWorktree(s.gitBranchChangesByWorktree), diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 43da686a3f1..2e9e1654d00 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -464,9 +464,9 @@ function createFileApi(): NonNullable['fs']> { function createGitApi(): NonNullable['git']> { return { - status: async ({ worktreePath }) => { + status: async ({ worktreePath, includeIgnored }) => { const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.status', { worktree: worktree.id }) + return callRuntimeResult('git.status', { worktree: worktree.id, includeIgnored }) }, conflictOperation: async ({ worktreePath }) => { const worktree = await resolveRuntimeWorktreeByPath(worktreePath) diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts new file mode 100644 index 00000000000..adcfd3031a0 --- /dev/null +++ b/src/shared/constants.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultSettings } from './constants' + +describe('getDefaultSettings', () => { + it('enables gitignored file decorations by default', () => { + expect(getDefaultSettings('/tmp').showGitIgnoredFiles).toBe(true) + }) +}) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index b5c5b77eedd..653991c3b85 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -211,6 +211,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { terminalScrollbackBytes: 10_000_000, openLinksInApp: true, rightSidebarOpenByDefault: true, + showGitIgnoredFiles: true, showTitlebarAppName: true, showTasksButton: true, ctrlTabOrderMode: 'mru', diff --git a/src/shared/types.ts b/src/shared/types.ts index 29ede1fbe8a..802ddd141bc 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1345,6 +1345,7 @@ export type GlobalSettings = { * until the user explicitly wants worktree-scoped in-app browsing. */ openLinksInApp: boolean rightSidebarOpenByDefault: boolean + showGitIgnoredFiles?: boolean /** Whether to show the Orca app name in the titlebar. */ showTitlebarAppName: boolean /** Why: some users do not use the Tasks feature and prefer to keep the @@ -1970,6 +1971,7 @@ export type GitStatusResult = { // Why: porcelain v2 status already includes upstream/ahead/behind metadata. // Folding it in lets refresh polling avoid a second pair of git subprocesses. upstreamStatus?: GitUpstreamStatus + ignoredPaths?: string[] } // Why: when hasUpstream is false, ahead/behind are placeholder zeros, not a