mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
co-authored by
Claude Opus 4.7
Jinjing
parent
2e5c03c31e
commit
2ad01ead7d
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user