feat(file-explorer): show gitignored files with dimmed italic decoration (#1941)

* feat(file-explorer): show gitignored files with dimmed italic decoration

Surfaces `.gitignore`d files in the right-sidebar file explorer with an
italicised, dimmed filename and a CircleSlash icon in the same trailing
slot used by the git status letter. A tracked change always wins — the
ignored decoration only applies when no other git status is present.

Gated behind a new `showGitIgnoredFiles` global setting (default on) so
heavy SSH workspaces can keep the smaller payload by skipping
`--ignored=matching` on `git status`.

`ignoredPaths` lives as a peer field on GitStatusResult rather than an
extension of GitFileStatus/GitStagingArea, so Source Control's
staging-area grouping is untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(file-explorer): trim redundant comments from gitignored decoration

Removes duplicated "Why:" explanations that ended up restating the same
backward-compat rationale across five files (relay, ssh provider, runtime
git commands, RPC handler, renderer git client) plus a few comments that
narrated the mechanism the code already shows.

Net: -39 lines of comment across 10 files; no behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(file-explorer): drop remaining comments from gitignored decoration

The code reads well without them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* review: harden gitignored file decorations

- clear ignored decoration cache when ignored status is disabled or omitted
- keep ignored decoration state scoped across worktree and runtime cleanup
- add coverage for local, SSH, runtime, relay, and Explorer precedence

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
Diego Lorente del Castillo
2026-05-15 18:58:08 -07:00
committed by GitHub
co-authored by Claude Opus 4.7 Jinjing
parent 2e5c03c31e
commit 2ad01ead7d
38 changed files with 630 additions and 120 deletions
+1
View File
@@ -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.
+46
View File
@@ -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', () => {
+24 -5
View File
@@ -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<GitStatusResult> {
export async function getStatus(
worktreePath: string,
options: GetStatusOptions = {}
): Promise<GitStatusResult> {
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<GitStatusResult>
// 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<GitStatusResult>
// 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<GitStatusResult>
conflictOperation,
head,
branch,
...(options.includeIgnored ? { ignoredPaths } : {}),
...(statusSucceeded
? {
upstreamStatus: upstreamName
+25 -1
View File
@@ -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 () => {
+4 -3
View File
@@ -532,17 +532,18 @@ export function registerFilesystemHandlers(
'git:status',
async (
_event,
args: { worktreePath: string; connectionId?: string }
args: { worktreePath: string; connectionId?: string; includeIgnored?: boolean }
): Promise<GitStatusResult> => {
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)
}
)
@@ -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)
+9 -2
View File
@@ -31,8 +31,15 @@ export class SshGitProvider implements IGitProvider {
return this.connectionId
}
async getStatus(worktreePath: string): Promise<GitStatusResult> {
return (await this.mux.request('git.status', { worktreePath })) as GitStatusResult
async getStatus(
worktreePath: string,
options?: { includeIgnored?: boolean }
): Promise<GitStatusResult> {
const includeIgnoredArgs = options?.includeIgnored ? { includeIgnored: true } : {}
return (await this.mux.request('git.status', {
worktreePath,
...includeIgnoredArgs
})) as GitStatusResult
}
async commit(
+4 -1
View File
@@ -142,7 +142,10 @@ export type IFilesystemProvider = {
// ─── Git Provider ───────────────────────────────────────────────────
export type IGitProvider = {
getStatus(worktreePath: string): Promise<GitStatusResult>
getStatus(
worktreePath: string,
options?: { includeIgnored?: boolean }
): Promise<GitStatusResult>
commit(worktreePath: string, message: string): Promise<{ success: boolean; error?: string }>
getStagedCommitContext(worktreePath: string): Promise<CommitMessageDraftContext | null>
getDiff(
+8 -3
View File
@@ -38,16 +38,21 @@ export type RuntimeGitCommandHost = {
export class RuntimeGitCommands {
constructor(private readonly host: RuntimeGitCommandHost) {}
async getRuntimeGitStatus(worktreeSelector: string): Promise<GitStatusResult> {
async getRuntimeGitStatus(
worktreeSelector: string,
options?: { includeIgnored?: boolean }
): Promise<GitStatusResult> {
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<GitConflictOperation> {
+24
View File
@@ -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',
+9 -2
View File
@@ -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',
+5 -1
View File
@@ -1321,7 +1321,11 @@ export type PreloadApi = {
onFsChanged: (callback: (payload: FsChangedPayload) => void) => () => void
}
git: {
status: (args: { worktreePath: string; connectionId?: string }) => Promise<GitStatusResult>
status: (args: {
worktreePath: string
connectionId?: string
includeIgnored?: boolean
}) => Promise<GitStatusResult>
conflictOperation: (args: {
worktreePath: string
connectionId?: string
+5 -2
View File
@@ -1873,8 +1873,11 @@ const api = {
},
git: {
status: (args: { worktreePath: string; connectionId?: string }): Promise<unknown> =>
ipcRenderer.invoke('git:status', args),
status: (args: {
worktreePath: string
connectionId?: string
includeIgnored?: boolean
}): Promise<unknown> => ipcRenderer.invoke('git:status', args),
conflictOperation: (args: { worktreePath: string; connectionId?: string }): Promise<unknown> =>
ipcRenderer.invoke('git:conflictOperation', args),
diff: (args: {
+24 -12
View File
@@ -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<string, unknown>[] = []
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 } : {})
}
}
+9
View File
@@ -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' }
])
})
})
+24
View File
@@ -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')
+5
View File
@@ -22,6 +22,7 @@ export function parseStatusChar(char: string): string {
export function parseStatusOutput(stdout: string): {
entries: Record<string, unknown>[]
unmergedLines: string[]
ignoredPaths: string[]
head?: string
branch?: string
upstreamStatus: {
@@ -33,6 +34,7 @@ export function parseStatusOutput(stdout: string): {
} {
const entries: Record<string, unknown>[] = []
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
+2
View File
@@ -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 ──────────────────────────────────────── */
@@ -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<string>()),
[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}
@@ -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({
</>
)}
<span
className={cn('truncate', isSelected && !nodeStatus && 'text-accent-foreground')}
style={nodeStatus ? { color: statusColor ?? undefined } : undefined}
className={cn(
'truncate',
isSelected && !nodeStatus && !isIgnored && 'text-accent-foreground',
isIgnored && 'italic'
)}
style={
nodeStatus
? { color: statusColor ?? undefined }
: isIgnored
? { color: 'var(--git-decoration-ignored)' }
: undefined
}
onDoubleClick={(e) => {
// 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}
</span>
{nodeStatus && (
{nodeStatus ? (
<span
className="ml-auto shrink-0 text-[10px] font-semibold tracking-wide mr-2"
style={{ color: statusColor ?? undefined }}
>
{STATUS_LABELS[nodeStatus]}
</span>
)}
) : isIgnored ? (
<CircleSlash
aria-label="Ignored by .gitignore"
className="ml-auto size-3 shrink-0 mr-2"
style={{ color: 'var(--git-decoration-ignored)' }}
/>
) : null}
</button>
</ContextMenuTrigger>
<ContextMenuContent
@@ -4,7 +4,7 @@ import { dirname, normalizeRelativePath } from '@/lib/path'
import { cn } from '@/lib/utils'
import type { GitFileStatus } from '../../../../shared/types'
import { FileExplorerRow, InlineInputRow, type InlineInput } from './FileExplorerRow'
import { STATUS_COLORS } from './status-display'
import { shouldShowIgnoredDecoration, STATUS_COLORS } from './status-display'
import type { DirCache, TreeNode } from './file-explorer-types'
import { countVisibleFileExplorerSelections } from './file-explorer-selection'
@@ -17,6 +17,7 @@ type FileExplorerVirtualRowsProps = {
dismissInlineInput: () => void
folderStatusByRelativePath: Map<string, GitFileStatus | null>
statusByRelativePath: Map<string, GitFileStatus>
ignoredByRelativePath: Set<string>
expanded: Set<string>
dirCache: Record<string, DirCache>
selectedPaths: Set<string>
@@ -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}
@@ -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)
})
})
@@ -22,18 +22,22 @@ export async function refreshGitStatusForWorktree({
connectionId,
deps
}: {
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId' | 'showGitIgnoredFiles'> | null
worktreeId: string
worktreePath: string
connectionId?: string
deps: GitStatusRefreshDeps
}): Promise<void> {
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`
@@ -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)
})
})
@@ -95,3 +95,43 @@ export function buildFolderStatusMap(entries: GitStatusEntry[]): Map<string, Git
export function shouldPropagateStatus(status: GitFileStatus): boolean {
return status !== 'deleted'
}
export function isPathIgnored(ignored: Set<string>, 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<string>,
relativePath: string
): boolean {
return !nodeStatus && isPathIgnored(ignored, relativePath)
}
export function buildIgnoredSet(ignoredPaths: readonly string[] | undefined): Set<string> {
const set = new Set<string>()
if (!ignoredPaths) {
return set
}
for (const rawPath of ignoredPaths) {
const trimmed = rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath
set.add(normalizeRelativePath(trimmed))
}
return set
}
@@ -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 (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
onClick={onToggle}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
checked ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
checked ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
)
}
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.
</p>
</div>
<button
role="switch"
aria-checked={settings.rightSidebarOpenByDefault}
onClick={() =>
updateSettings({
rightSidebarOpenByDefault: !settings.rightSidebarOpenByDefault
})
<ToggleSwitchButton
checked={settings.rightSidebarOpenByDefault}
onToggle={() =>
updateSettings({ rightSidebarOpenByDefault: !settings.rightSidebarOpenByDefault })
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.rightSidebarOpenByDefault ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.rightSidebarOpenByDefault ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
/>
</SearchableSetting>
<SearchableSetting
title="Show Git-Ignored Files"
description="Dim files matched by .gitignore in the file explorer."
keywords={['git', 'gitignore', 'ignored', 'file explorer', 'sidebar', 'hide']}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Show Git-Ignored Files</Label>
<p className="text-xs text-muted-foreground">
Dim files matched by .gitignore in the file explorer. Turn off to skip the extra git
status work on large repos.
</p>
</div>
<ToggleSwitchButton
checked={settings.showGitIgnoredFiles ?? true}
onToggle={() =>
updateSettings({ showGitIgnoredFiles: !(settings.showGitIgnoredFiles ?? true) })
}
/>
</SearchableSetting>
</section>
) : null,
@@ -288,24 +332,12 @@ export function AppearancePane({
<Label>Titlebar App Name</Label>
<p className="text-xs text-muted-foreground">Show Orca in the titlebar.</p>
</div>
<button
role="switch"
aria-checked={settings.showTitlebarAppName}
onClick={() =>
updateSettings({
showTitlebarAppName: !settings.showTitlebarAppName
})
<ToggleSwitchButton
checked={settings.showTitlebarAppName}
onToggle={() =>
updateSettings({ showTitlebarAppName: !settings.showTitlebarAppName })
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.showTitlebarAppName ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.showTitlebarAppName ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
/>
</SearchableSetting>
</section>
) : null,
@@ -333,22 +365,11 @@ export function AppearancePane({
<Label>{toggle.title}</Label>
<p className="text-xs text-muted-foreground">{toggle.toggleDescription}</p>
</div>
<button
type="button"
role="switch"
aria-label={toggle.title}
aria-checked={enabled}
onClick={() => toggleStatusBarItem(toggle.id)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
enabled ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
enabled ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
<ToggleSwitchButton
checked={enabled}
onToggle={() => toggleStatusBarItem(toggle.id)}
ariaLabel={toggle.title}
/>
</SearchableSetting>
)
})}
@@ -372,24 +393,10 @@ export function AppearancePane({
Show the Tasks button at the top of the left sidebar.
</p>
</div>
<button
role="switch"
aria-checked={settings.showTasksButton}
onClick={() =>
updateSettings({
showTasksButton: !settings.showTasksButton
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.showTasksButton ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.showTasksButton ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
<ToggleSwitchButton
checked={settings.showTasksButton}
onToggle={() => updateSettings({ showTasksButton: !settings.showTasksButton })}
/>
</SearchableSetting>
</section>
) : null
@@ -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',
@@ -27,18 +27,23 @@ export function getRuntimeGitScope(
return target.kind === 'environment' ? `runtime:${target.environmentId}` : connectionId
}
export async function getRuntimeGitStatus(context: RuntimeGitContext): Promise<GitStatusResult> {
export async function getRuntimeGitStatus(
context: RuntimeGitContext,
options?: { includeIgnored?: boolean }
): Promise<GitStatusResult> {
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<GitStatusResult>(
target,
'git.status',
{ worktree: context.worktreeId },
{ worktree: context.worktreeId, ...includeIgnoredArgs },
{ timeoutMs: 15_000 }
)
}
@@ -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()
+19 -1
View File
@@ -313,6 +313,7 @@ export type EditorSlice = {
// Git status cache
gitStatusByWorktree: Record<string, GitStatusEntry[]>
gitIgnoredPathsByWorktree: Record<string, string[]>
gitConflictOperationByWorktree: Record<string, GitConflictOperation>
trackedConflictPathsByWorktree: Record<string, Record<string, GitConflictKind>>
trackConflictPath: (worktreeId: string, path: string, conflictKind: GitConflictKind) => void
@@ -1824,6 +1825,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
// Git status
gitStatusByWorktree: {},
gitIgnoredPathsByWorktree: {},
gitConflictOperationByWorktree: {},
trackedConflictPathsByWorktree: {},
trackConflictPath: (worktreeId, path, conflictKind) =>
@@ -1912,7 +1914,20 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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 },
@@ -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({})
@@ -78,6 +78,7 @@ function runtimeScopedStateReset(): Partial<AppState> {
markdownViewMode: {},
editorViewMode: {},
editorCursorLine: {},
gitIgnoredPathsByWorktree: {},
activeFileId: null,
activeFileIdByWorktree: {},
activeTabTypeByWorktree: {},
@@ -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<AppState>)
@@ -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()
@@ -624,6 +624,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
// 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<AppState, [], [], WorktreeSlice>
editorViewMode: nextEditorViewMode,
expandedDirs: nextExpandedDirs,
gitStatusByWorktree: nextGitStatusByWorktree,
gitIgnoredPathsByWorktree: nextGitIgnoredPathsByWorktree,
gitConflictOperationByWorktree: nextGitConflictOperationByWorktree,
trackedConflictPathsByWorktree: nextTrackedConflictPathsByWorktree,
gitBranchChangesByWorktree: nextGitBranchChangesByWorktree,
@@ -1359,6 +1362,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
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),
+2 -2
View File
@@ -464,9 +464,9 @@ function createFileApi(): NonNullable<Partial<PreloadApi>['fs']> {
function createGitApi(): NonNullable<Partial<PreloadApi>['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)
+8
View File
@@ -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)
})
})
+1
View File
@@ -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',
+2
View File
@@ -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