Files
orca/src/relay/git-status-output-parser.ts
2ad01ead7d 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>
2026-05-15 18:58:08 -07:00

147 lines
4.0 KiB
TypeScript

export function parseStatusChar(char: string): string {
switch (char) {
case 'M':
return 'modified'
case 'A':
return 'added'
case 'D':
return 'deleted'
case 'R':
return 'renamed'
case 'C':
return 'copied'
default:
return 'modified'
}
}
/**
* Parse `git status --porcelain=v2` output into structured entries.
* Does NOT handle unmerged entries (those require worktree access).
*/
export function parseStatusOutput(stdout: string): {
entries: Record<string, unknown>[]
unmergedLines: string[]
ignoredPaths: string[]
head?: string
branch?: string
upstreamStatus: {
hasUpstream: boolean
upstreamName?: string
ahead: number
behind: number
}
} {
const entries: Record<string, unknown>[] = []
const unmergedLines: string[] = []
const ignoredPaths: string[] = []
let head: string | undefined
let branch: string | undefined
let upstreamName: string | undefined
let upstreamAheadBehind: { ahead: number; behind: number } | null = null
for (const line of stdout.split(/\r?\n/)) {
if (!line) {
continue
}
if (line.startsWith('# branch.oid ')) {
head = line.slice('# branch.oid '.length).trim()
continue
}
if (line.startsWith('# branch.head ')) {
const branchHead = line.slice('# branch.head '.length).trim()
branch = branchHead && branchHead !== '(detached)' ? `refs/heads/${branchHead}` : ''
continue
}
if (line.startsWith('# branch.upstream ')) {
upstreamName = line.slice('# branch.upstream '.length).trim() || undefined
continue
}
if (line.startsWith('# branch.ab ')) {
upstreamAheadBehind = parseBranchAheadBehind(line)
continue
}
if (line.startsWith('1 ') || line.startsWith('2 ')) {
const parts = line.split(' ')
const xy = parts[1]
const indexStatus = xy[0]
const worktreeStatus = xy[1]
if (line.startsWith('2 ')) {
// Why: porcelain v2 type-2 format is `2 XY sub mH mI mW hH hI Xscore path\torigPath`.
// The new path is the last space-delimited token before the tab; origPath follows the tab.
const tabParts = line.split('\t')
const spaceParts = tabParts[0].split(' ')
const filePath = spaceParts.at(-1)!
const oldPath = tabParts[1]
if (indexStatus !== '.') {
entries.push({
path: filePath,
status: parseStatusChar(indexStatus),
area: 'staged',
oldPath
})
}
if (worktreeStatus !== '.') {
entries.push({
path: filePath,
status: parseStatusChar(worktreeStatus),
area: 'unstaged',
oldPath
})
}
} else {
const filePath = parts.slice(8).join(' ')
if (indexStatus !== '.') {
entries.push({ path: filePath, status: parseStatusChar(indexStatus), area: 'staged' })
}
if (worktreeStatus !== '.') {
entries.push({
path: filePath,
status: parseStatusChar(worktreeStatus),
area: 'unstaged'
})
}
}
} 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)
}
}
return {
entries,
unmergedLines,
ignoredPaths,
head,
branch,
upstreamStatus: upstreamName
? {
hasUpstream: true,
upstreamName,
ahead: upstreamAheadBehind?.ahead ?? 0,
behind: upstreamAheadBehind?.behind ?? 0
}
: { hasUpstream: false, ahead: 0, behind: 0 }
}
}
function parseBranchAheadBehind(line: string): { ahead: number; behind: number } | null {
const match = line.match(/^# branch\.ab \+(\d+) -(\d+)$/)
if (!match) {
return null
}
return {
ahead: Number.parseInt(match[1], 10),
behind: Number.parseInt(match[2], 10)
}
}