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