-
-
+ {/* Why: the line total belongs beside HEAD — it measures this branch's work.
+ The base line keeps the commit count, which measures the comparison. */}
+
+
+
+
+ {headTrailing}
{/* Why: spinner/actions sit on the base line — they describe compare state, not HEAD. */}
void
onRetry: () => void
}): React.JSX.Element | null {
@@ -397,6 +413,7 @@ export function SourceControlBranchContextRow({
onChangeBaseRef={onChangeBaseRef}
changeBaseTitle={changeBaseTitle}
trailing={trailing}
+ headTrailing={}
/>
)
diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-context-stats.ts b/src/renderer/src/components/right-sidebar/source-control-branch-context-stats.ts
index 238e3d97113..ed4b801897d 100644
--- a/src/renderer/src/components/right-sidebar/source-control-branch-context-stats.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-branch-context-stats.ts
@@ -30,11 +30,15 @@ function formatBehindBaseTitle(count: number, baseRef: string): string {
)
}
+// Why: ahead/behind carry no color of their own. Green and red are reserved for
+// the line-total chip that sits beside them — an `↑1` in added-green next to
+// `+1,114` reads as one quantity when they count different things (commits vs
+// lines). The ↑/↓ glyph already carries direction.
export type SourceControlBranchContextStat = {
key: string
label: string
title?: string
- tone: 'default' | 'ahead' | 'behind' | 'muted'
+ tone: 'default' | 'muted'
}
export function resolveSourceControlDisplayedBaseRef(
@@ -111,7 +115,7 @@ export function buildSourceControlBranchContextStats({
key: 'upstream-ahead',
label: `↑${upstreamStatus.ahead}`,
title: formatAheadOfBaseTitle(upstreamStatus.ahead, upstreamLabel),
- tone: 'ahead'
+ tone: 'muted'
})
}
if (upstreamStatus.behind > 0) {
@@ -119,7 +123,7 @@ export function buildSourceControlBranchContextStats({
key: 'upstream-behind',
label: `↓${upstreamStatus.behind}`,
title: formatBehindBaseTitle(upstreamStatus.behind, upstreamLabel),
- tone: 'behind'
+ tone: 'muted'
})
}
}
@@ -139,7 +143,7 @@ export function buildSourceControlBranchContextStats({
key: 'compare-ahead',
label: `↑${commitsAhead}`,
title: formatAheadOfBaseTitle(commitsAhead, baseLabel),
- tone: 'ahead'
+ tone: 'muted'
})
}
}
diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-line-total-chip.tsx b/src/renderer/src/components/right-sidebar/source-control-branch-line-total-chip.tsx
new file mode 100644
index 00000000000..d7256ac0e8b
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/source-control-branch-line-total-chip.tsx
@@ -0,0 +1,74 @@
+import React, { useMemo } from 'react'
+import type { GitBranchLineTotal } from '../../../../shared/git-status-types'
+import { getIntlLocale, translate } from '@/i18n/i18n'
+
+// Why: raw digits, not the grouped display string — screen readers announce
+// "8,259" as two numbers in several locales.
+function buildAccessibleLabel(added: number, removed: number): string {
+ if (added > 0 && removed > 0) {
+ return translate(
+ 'auto.components.right.sidebar.source.control.branch.line.total.chip.daa8e8e59b',
+ '{{value0}} additions, {{value1}} deletions',
+ { value0: added, value1: removed }
+ )
+ }
+ if (added > 0) {
+ return translate(
+ 'auto.components.right.sidebar.source.control.branch.line.total.chip.8a9b97b666',
+ '{{value0}} additions',
+ { value0: added }
+ )
+ }
+ return translate(
+ 'auto.components.right.sidebar.source.control.branch.line.total.chip.52c366d88d',
+ '{{value0}} deletions',
+ { value0: removed }
+ )
+}
+
+// Absent, incomplete and genuinely-empty all render as nothing: no `+0 -0`, no
+// spinner, no reserved width. Not clickable — `openBranchAllDiffs` is narrower.
+export const SourceControlBranchLineTotalChip = React.memo(
+ function SourceControlBranchLineTotalChip({
+ branchLineTotal
+ }: {
+ branchLineTotal: GitBranchLineTotal | null | undefined
+ }): React.JSX.Element | null {
+ const added = branchLineTotal?.added ?? 0
+ const removed = branchLineTotal?.removed ?? 0
+ const hasAdded = added > 0
+ const hasRemoved = removed > 0
+ // Full precision and app-locale-aware; a status tick that leaves the counts
+ // alone must not rebuild these strings.
+ const locale = getIntlLocale()
+ const addedText = useMemo(() => added.toLocaleString(locale), [added, locale])
+ const removedText = useMemo(() => removed.toLocaleString(locale), [removed, locale])
+ const accessibleLabel = useMemo(() => buildAccessibleLabel(added, removed), [added, removed])
+
+ if (!hasAdded && !hasRemoved) {
+ return null
+ }
+
+ // Why: no fixed `ch` width — that clips at 5+ digits; `tabular-nums` alone
+ // keeps digits from jittering between refreshes.
+ return (
+
+ {hasAdded ? (
+
+ +{addedText}
+
+ ) : null}
+ {hasRemoved ? (
+
+ -{removedText}
+
+ ) : null}
+
+ )
+ }
+)
diff --git a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts
index 48e28160af6..6f6632feea8 100644
--- a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts
@@ -111,4 +111,13 @@ describe('getDiscardAreaConfirmationCopy', () => {
confirmLabel: 'Discard all'
})
})
+
+ it('discloses permanent deletion for combined Changes', () => {
+ expect(getDiscardAreaConfirmationCopy('unstaged', 3, true)).toEqual({
+ title: 'Discard changes and delete untracked files?',
+ description:
+ 'Tracked changes will be reverted and untracked files will be permanently deleted. This cannot be undone.',
+ confirmLabel: 'Delete and discard'
+ })
+ })
})
diff --git a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.ts b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.ts
index ff020c434ec..03a6805e7f0 100644
--- a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.ts
@@ -62,8 +62,25 @@ export function getDiscardEntryConfirmationCopy(
export function getDiscardAreaConfirmationCopy(
area: DiscardAllArea,
- count: number
+ count: number,
+ hasUntracked = false
): DiscardConfirmationCopy {
+ if (area === 'unstaged' && hasUntracked) {
+ return {
+ title: translate(
+ 'auto.components.right.sidebar.source.control.discard.confirmation.mixedTitle',
+ 'Discard changes and delete untracked files?'
+ ),
+ description: translate(
+ 'auto.components.right.sidebar.source.control.discard.confirmation.mixedDescription',
+ 'Tracked changes will be reverted and untracked files will be permanently deleted. This cannot be undone.'
+ ),
+ confirmLabel: translate(
+ 'auto.components.right.sidebar.source.control.discard.confirmation.mixedConfirm',
+ 'Delete and discard'
+ )
+ }
+ }
switch (area) {
case 'untracked':
return {
diff --git a/src/renderer/src/components/right-sidebar/source-control-discard-dialog.tsx b/src/renderer/src/components/right-sidebar/source-control-discard-dialog.tsx
index 6741a72ac55..25e2366b616 100644
--- a/src/renderer/src/components/right-sidebar/source-control-discard-dialog.tsx
+++ b/src/renderer/src/components/right-sidebar/source-control-discard-dialog.tsx
@@ -20,7 +20,12 @@ import { translate } from '@/i18n/i18n'
export type PendingDiscardConfirmation =
| { kind: 'entry'; entry: GitStatusEntry }
- | { kind: 'area'; area: DiscardAllArea; paths: readonly string[] }
+ | {
+ kind: 'area'
+ area: DiscardAllArea
+ paths: readonly string[]
+ hasUntracked?: boolean
+ }
export function focusDiscardDialogConfirmButton(
event: Event,
@@ -51,7 +56,11 @@ export function SourceControlDiscardDialog({
if (pendingDiscard.kind === 'entry') {
return getDiscardEntryConfirmationCopy(pendingDiscard.entry)
}
- return getDiscardAreaConfirmationCopy(pendingDiscard.area, pendingDiscard.paths.length)
+ return getDiscardAreaConfirmationCopy(
+ pendingDiscard.area,
+ pendingDiscard.paths.length,
+ pendingDiscard.hasUntracked
+ )
}, [pendingDiscard])
const PendingDiscardIcon = pendingDiscardCopy?.confirmLabel.startsWith('Delete') ? Trash : Undo2
diff --git a/src/renderer/src/components/right-sidebar/source-control-file-filter.test.ts b/src/renderer/src/components/right-sidebar/source-control-file-filter.test.ts
index 33ef043e7af..927f0c45328 100644
--- a/src/renderer/src/components/right-sidebar/source-control-file-filter.test.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-file-filter.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
SOURCE_CONTROL_FILE_FILTER_QUERY_MAX_BYTES,
+ filterAndSortSourceControlPathEntries,
filterSourceControlGroupedPathEntries,
filterSourceControlPathEntries,
getSourceControlFileFilterState,
@@ -9,6 +10,26 @@ import {
} from './source-control-file-filter'
describe('source-control-file-filter', () => {
+ it('naturally orders committed branch rows without mutating store input', () => {
+ const entries = [
+ { path: 'migrations/100.sql' },
+ { path: 'migrations/9.sql' },
+ { path: 'migrations/99.sql' }
+ ]
+
+ expect(
+ filterAndSortSourceControlPathEntries(entries, {
+ normalizedFilter: '',
+ tooLarge: false
+ }).map((entry) => entry.path)
+ ).toEqual(['migrations/9.sql', 'migrations/99.sql', 'migrations/100.sql'])
+ expect(entries.map((entry) => entry.path)).toEqual([
+ 'migrations/100.sql',
+ 'migrations/9.sql',
+ 'migrations/99.sql'
+ ])
+ })
+
it('normalizes bounded queries and filters entries by path', () => {
const filter = getSourceControlFileFilterState(' SRC/button ')
diff --git a/src/renderer/src/components/right-sidebar/source-control-file-filter.ts b/src/renderer/src/components/right-sidebar/source-control-file-filter.ts
index fe84061d5bc..4a4e765c889 100644
--- a/src/renderer/src/components/right-sidebar/source-control-file-filter.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-file-filter.ts
@@ -1,4 +1,5 @@
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
+import { compareFileNames } from '../../../../shared/file-name-sort'
export const SOURCE_CONTROL_FILE_FILTER_QUERY_MAX_BYTES = 2 * 1024
@@ -48,6 +49,15 @@ export function filterSourceControlPathEntries
return entries.filter((entry) => entry.path.toLowerCase().includes(filter.normalizedFilter))
}
+export function filterAndSortSourceControlPathEntries(
+ entries: T[],
+ filter: SourceControlFileFilterState
+): T[] {
+ return [...filterSourceControlPathEntries(entries, filter)].sort((a, b) =>
+ compareFileNames(a.path, b.path)
+ )
+}
+
export function filterSourceControlGroupedPathEntries(
grouped: SourceControlGroupedPathEntries,
filter: SourceControlFileFilterState
diff --git a/src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx b/src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx
index a58958bf0fb..4147569b7de 100644
--- a/src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx
+++ b/src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { SourceControlHeaderToolbar } from './source-control-header-toolbar'
import type { GitBranchCompareSummary } from '../../../../shared/types'
+import type { GitBranchLineTotal } from '../../../../shared/git-status-types'
import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
import type { PrimaryAction } from './source-control-primary-action'
@@ -38,6 +39,7 @@ function renderToolbar(options?: {
headDisplay?: WorktreeGitIdentityDisplay | null
branchSummary?: GitBranchCompareSummary | null
compareBaseRef?: string | null
+ branchLineTotal?: GitBranchLineTotal | null
}): string {
return renderToStaticMarkup(
)
}
@@ -125,4 +128,17 @@ describe('SourceControlHeaderToolbar branch identity', () => {
expect(markup).not.toContain('→')
expect(markup).toContain('Create PR')
})
+
+ it('threads the branch line total down to the base-ref line', () => {
+ const markup = renderToolbar({ branchLineTotal: { added: 24, removed: 3, mergeBase: 'base' } })
+
+ expect(markup).toContain('aria-label="24 additions, 3 deletions"')
+ expect(markup).toContain('+24')
+ expect(markup).toContain('-3')
+ })
+
+ it('renders exactly as before when no branch line total is supplied', () => {
+ expect(renderToolbar({ branchLineTotal: undefined })).toBe(renderToolbar())
+ expect(renderToolbar()).not.toContain('data-testid="source-control-branch-line-total"')
+ })
})
diff --git a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx b/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx
index f6bf15c87b9..e04ca32eaca 100644
--- a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx
+++ b/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx
@@ -5,6 +5,7 @@ import type {
GitUpstreamStatus,
SourceControlViewMode
} from '../../../../shared/types'
+import type { GitBranchLineTotal } from '../../../../shared/git-status-types'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { PrimaryAction } from './source-control-primary-action'
import { Button } from '@/components/ui/button'
@@ -41,6 +42,7 @@ type SourceControlHeaderToolbarProps = {
headDisplay?: WorktreeGitIdentityDisplay | null
upstreamStatus?: GitUpstreamStatus
manualReviewUrl?: string | null
+ branchLineTotal?: GitBranchLineTotal | null
}
function HostedReviewToolbarLink({
@@ -146,7 +148,8 @@ export function SourceControlHeaderToolbar({
compareBaseRef,
headDisplay = null,
upstreamStatus,
- manualReviewUrl
+ manualReviewUrl,
+ branchLineTotal
}: SourceControlHeaderToolbarProps): React.JSX.Element {
const filterInputRef = useRef(null)
const normalizedFilter = filterQuery.trim()
@@ -293,6 +296,7 @@ export function SourceControlHeaderToolbar({
headDisplay={headDisplay}
upstreamStatus={upstreamStatus}
manualReviewUrl={manualReviewUrl}
+ branchLineTotal={branchLineTotal}
onChangeBaseRef={onChangeBaseRef}
onRetry={onRefreshBranchCompare}
/>
diff --git a/src/renderer/src/components/right-sidebar/source-control-section-order.test.ts b/src/renderer/src/components/right-sidebar/source-control-section-order.test.ts
index fa563470d66..91378833b0d 100644
--- a/src/renderer/src/components/right-sidebar/source-control-section-order.test.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-section-order.test.ts
@@ -4,7 +4,8 @@ import {
buildSourceControlDisplaySections,
getConflictReviewEntries,
getSourceControlSectionViewAction,
- resolveSourceControlGroupOrder,
+ mergeUntrackedIntoChanges,
+ SOURCE_CONTROL_GROUP_ORDER,
splitPinnedSourceControlConflicts,
type SourceControlEntryGroups
} from './source-control-section-order'
@@ -26,37 +27,38 @@ function groups(partial: Partial): SourceControlEntryG
}
}
-describe('resolveSourceControlGroupOrder', () => {
- it('keeps Changes first by default', () => {
- expect(resolveSourceControlGroupOrder(undefined)).toEqual(['unstaged', 'staged', 'untracked'])
+describe('SOURCE_CONTROL_GROUP_ORDER', () => {
+ it('follows the edit, stage, commit workflow', () => {
+ expect(SOURCE_CONTROL_GROUP_ORDER).toEqual(['unstaged', 'staged', 'untracked'])
})
+})
- it('supports staged-first and untracked-first presets', () => {
- expect(resolveSourceControlGroupOrder('staged-first')).toEqual([
- 'staged',
- 'unstaged',
- 'untracked'
- ])
- expect(resolveSourceControlGroupOrder('untracked-first')).toEqual([
- 'untracked',
- 'unstaged',
- 'staged'
- ])
+describe('mergeUntrackedIntoChanges', () => {
+ it('folds untracked entries into Changes without changing their Git area', () => {
+ const unstaged = entry({ area: 'unstaged', path: 'changed.ts' })
+ const untracked = entry({ area: 'untracked', path: 'new.ts', status: 'untracked' })
+ const merged = mergeUntrackedIntoChanges(
+ groups({ unstaged: [unstaged], untracked: [untracked] })
+ )
+
+ expect(merged.unstaged).toEqual([unstaged, untracked])
+ expect(merged.untracked).toEqual([])
+ expect(merged.unstaged[1]?.area).toBe('untracked')
})
})
describe('buildSourceControlDisplaySections', () => {
- it('uses the configured order for normal sections', () => {
+ it('uses the fixed workflow order for normal sections', () => {
const sections = buildSourceControlDisplaySections(
groups({
staged: [entry({ area: 'staged', path: 'staged.ts' })],
unstaged: [entry({ area: 'unstaged', path: 'changed.ts' })],
untracked: [entry({ area: 'untracked', path: 'new.ts', status: 'untracked' })]
}),
- resolveSourceControlGroupOrder('staged-first')
+ SOURCE_CONTROL_GROUP_ORDER
)
- expect(sections.map((section) => section.id)).toEqual(['staged', 'unstaged', 'untracked'])
+ expect(sections.map((section) => section.id)).toEqual(['unstaged', 'staged', 'untracked'])
})
it('keeps conflicts pinned before the configured normal order', () => {
@@ -74,13 +76,13 @@ describe('buildSourceControlDisplaySections', () => {
],
untracked: [entry({ area: 'untracked', path: 'new.ts', status: 'untracked' })]
}),
- resolveSourceControlGroupOrder('staged-first')
+ SOURCE_CONTROL_GROUP_ORDER
)
expect(sections.map((section) => section.id)).toEqual([
'conflicts',
- 'staged',
'unstaged',
+ 'staged',
'untracked'
])
})
@@ -100,10 +102,7 @@ describe('buildSourceControlDisplaySections', () => {
const input = groups({ unstaged: [unresolved, resolved, normal] })
const split = splitPinnedSourceControlConflicts(input)
- const sections = buildSourceControlDisplaySections(
- input,
- resolveSourceControlGroupOrder('changes-first')
- )
+ const sections = buildSourceControlDisplaySections(input, SOURCE_CONTROL_GROUP_ORDER)
expect(split.pinnedConflicts.map((item) => item.path)).toEqual(['conflict.ts', 'resolved.ts'])
expect(split.normalGroups.unstaged.map((item) => item.path)).toEqual(['normal.ts'])
@@ -123,10 +122,7 @@ describe('buildSourceControlDisplaySections', () => {
const input = groups({ staged: [resolvedStaged, staged] })
const split = splitPinnedSourceControlConflicts(input)
- const sections = buildSourceControlDisplaySections(
- input,
- resolveSourceControlGroupOrder('staged-first')
- )
+ const sections = buildSourceControlDisplaySections(input, SOURCE_CONTROL_GROUP_ORDER)
expect(split.pinnedConflicts).toEqual([resolvedStaged])
expect(split.normalGroups.staged).toEqual([staged])
@@ -167,7 +163,7 @@ describe('buildSourceControlDisplaySections', () => {
entry({ area: 'unstaged', path: 'normal.ts' })
]
}),
- resolveSourceControlGroupOrder('changes-first')
+ SOURCE_CONTROL_GROUP_ORDER
)
expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({
@@ -191,7 +187,7 @@ describe('buildSourceControlDisplaySections', () => {
const normal = entry({ area: 'unstaged', path: 'normal.ts' })
const sections = buildSourceControlDisplaySections(
groups({ unstaged: [pinned, normal] }),
- resolveSourceControlGroupOrder('changes-first')
+ SOURCE_CONTROL_GROUP_ORDER
)
expect(getSourceControlSectionViewAction(sections[1]!)).toEqual({
@@ -212,7 +208,7 @@ describe('buildSourceControlDisplaySections', () => {
groups({
unstaged: [resolved]
}),
- resolveSourceControlGroupOrder('changes-first')
+ SOURCE_CONTROL_GROUP_ORDER
)
expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({
@@ -240,7 +236,7 @@ describe('buildSourceControlDisplaySections', () => {
staged: [staged],
unstaged: [unstaged]
}),
- resolveSourceControlGroupOrder('staged-first')
+ SOURCE_CONTROL_GROUP_ORDER
)
expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({
diff --git a/src/renderer/src/components/right-sidebar/source-control-section-order.ts b/src/renderer/src/components/right-sidebar/source-control-section-order.ts
index 557db90b556..91cc948855b 100644
--- a/src/renderer/src/components/right-sidebar/source-control-section-order.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-section-order.ts
@@ -1,5 +1,4 @@
-import { normalizeSourceControlGroupOrder } from '../../../../shared/source-control-group-order'
-import type { GitStatusEntry, SourceControlGroupOrder } from '../../../../shared/types'
+import type { GitStatusEntry } from '../../../../shared/types'
export const SOURCE_CONTROL_AREAS = ['unstaged', 'staged', 'untracked'] as const
export type SourceControlSectionArea = (typeof SOURCE_CONTROL_AREAS)[number]
@@ -22,16 +21,23 @@ export type SourceControlSectionViewAction =
| { kind: 'conflict-review'; entries: SourceControlConflictReviewEntry[] }
| { kind: 'combined-diff'; area?: SourceControlSectionArea; entries: GitStatusEntry[] }
-const ORDER_BY_PRESET: Record = {
- 'changes-first': ['unstaged', 'staged', 'untracked'],
- 'staged-first': ['staged', 'unstaged', 'untracked'],
- 'untracked-first': ['untracked', 'unstaged', 'staged']
-}
+export const SOURCE_CONTROL_GROUP_ORDER: readonly SourceControlSectionArea[] = [
+ 'unstaged',
+ 'staged',
+ 'untracked'
+]
-export function resolveSourceControlGroupOrder(
- value: SourceControlGroupOrder | null | undefined
-): readonly SourceControlSectionArea[] {
- return ORDER_BY_PRESET[normalizeSourceControlGroupOrder(value)]
+export function mergeUntrackedIntoChanges(
+ groups: SourceControlEntryGroups
+): SourceControlEntryGroups {
+ if (groups.untracked.length === 0) {
+ return groups
+ }
+ return {
+ staged: groups.staged,
+ unstaged: [...groups.unstaged, ...groups.untracked],
+ untracked: []
+ }
}
export function isPinnedConflictEntry(entry: GitStatusEntry): boolean {
diff --git a/src/renderer/src/components/right-sidebar/source-control-status-sort.test.ts b/src/renderer/src/components/right-sidebar/source-control-status-sort.test.ts
index 506e49c2949..595ba62fe34 100644
--- a/src/renderer/src/components/right-sidebar/source-control-status-sort.test.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-status-sort.test.ts
@@ -22,7 +22,9 @@ function referenceCompare(a: GitStatusEntry, b: GitStatusEntry): number {
}
return 2
}
- return rank(a) - rank(b) || a.path.localeCompare(b.path, undefined, { numeric: true })
+ // 'en' pinned to match the shared collator — an undefined locale would make
+ // this oracle environment-dependent (sv/cs/da collate differently).
+ return rank(a) - rank(b) || a.path.localeCompare(b.path, 'en', { numeric: true })
}
describe('compareGitStatusEntries', () => {
diff --git a/src/renderer/src/components/right-sidebar/source-control-status-sort.ts b/src/renderer/src/components/right-sidebar/source-control-status-sort.ts
index f5ee065bc70..ccedb093ffb 100644
--- a/src/renderer/src/components/right-sidebar/source-control-status-sort.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-status-sort.ts
@@ -1,14 +1,10 @@
import type { GitStatusEntry } from '../../../../shared/types'
-
-// Why hoisted: localeCompare with an options object resolves a fresh ICU collator
-// on every comparison, so a changed-file sort paid for one per O(n log n) step.
-export const sourceControlPathCollator = new Intl.Collator(undefined, { numeric: true })
+import { compareFileNames } from '../../../../shared/file-name-sort'
export function compareGitStatusEntries(a: GitStatusEntry, b: GitStatusEntry): number {
- return (
- getConflictSortRank(a) - getConflictSortRank(b) ||
- sourceControlPathCollator.compare(a.path, b.path)
- )
+ // Why: compareFileNames (not the raw collator) so numeric-collation ties
+ // ("2" vs "02") stay a total order shared with the File Explorer.
+ return getConflictSortRank(a) - getConflictSortRank(b) || compareFileNames(a.path, b.path)
}
function getConflictSortRank(entry: GitStatusEntry): number {
diff --git a/src/renderer/src/components/right-sidebar/source-control-tree.test.ts b/src/renderer/src/components/right-sidebar/source-control-tree.test.ts
index f585c1c35ef..421b5f3cd06 100644
--- a/src/renderer/src/components/right-sidebar/source-control-tree.test.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-tree.test.ts
@@ -39,6 +39,24 @@ describe('buildSourceControlTree', () => {
])
})
+ it('orders numbered sibling directories naturally, matching the file rows', () => {
+ const tree = buildSourceControlTree('unstaged', [
+ entry({ path: 'migrations/100 - b/a.sql' }),
+ entry({ path: 'migrations/9 - a/a.sql' }),
+ entry({ path: 'migrations/99 - c/a.sql' })
+ ])
+
+ expect(labels(flattenSourceControlTree(tree, new Set()))).toEqual([
+ 'directory:migrations',
+ 'directory:migrations/9 - a',
+ 'file:migrations/9 - a/a.sql',
+ 'directory:migrations/99 - c',
+ 'file:migrations/99 - c/a.sql',
+ 'directory:migrations/100 - b',
+ 'file:migrations/100 - b/a.sql'
+ ])
+ })
+
it('keeps conflict rows ahead of normal rows within a directory', () => {
const tree = buildGitStatusSourceControlTree('unstaged', [
entry({ path: 'src/a.ts' }),
diff --git a/src/renderer/src/components/right-sidebar/source-control-tree.ts b/src/renderer/src/components/right-sidebar/source-control-tree.ts
index 6bf94893b07..2ecca32c0bd 100644
--- a/src/renderer/src/components/right-sidebar/source-control-tree.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-tree.ts
@@ -1,7 +1,8 @@
import { normalizeRelativePath } from '@/lib/path'
import type { GitStatusEntry, GitStagingArea } from '../../../../shared/types'
+import { compareFileNames } from '../../../../shared/file-name-sort'
import { splitPathSegments } from './path-tree'
-import { compareGitStatusEntries, sourceControlPathCollator } from './source-control-status-sort'
+import { compareGitStatusEntries } from './source-control-status-sort'
export type SourceControlTreeArea = Extract
// Why: committed branch rows share the same path tree but do not carry
@@ -51,7 +52,7 @@ type MutableDirectoryNode(
@@ -90,7 +91,9 @@ function finalizeDirectoryNode a.name.localeCompare(b.name))
+ // Why: keep directory ordering consistent with the numeric path collator used
+ // for the file rows one line below.
+ directories.sort((a, b) => compareFileNames(a.name, b.name))
files.sort((a, b) => compareEntries(a.entry, b.entry))
const fileCount =
files.length + directories.reduce((count, directory) => count + directory.fileCount, 0)
diff --git a/src/renderer/src/components/settings/GitPane.test.ts b/src/renderer/src/components/settings/GitPane.test.ts
index 3275eb9a183..2947f866fdd 100644
--- a/src/renderer/src/components/settings/GitPane.test.ts
+++ b/src/renderer/src/components/settings/GitPane.test.ts
@@ -6,12 +6,7 @@ import { getDefaultSettings } from '../../../../shared/constants'
import { translate } from '../../i18n/i18n'
import { useAppStore } from '../../store'
import { shouldOpenAutoRenameBranchAdvanced } from './AutoRenameBranchFromWorkSetting'
-import {
- GitPane,
- SourceControlGroupOrderSetting,
- getGitPaneSearchEntries,
- shouldShowAutoRenameBranchSetting
-} from './GitPane'
+import { GitPane, getGitPaneSearchEntries, shouldShowAutoRenameBranchSetting } from './GitPane'
import { TooltipProvider } from '../ui/tooltip'
import { matchesSettingsSearch } from './settings-search'
import { SettingsSegmentedControl } from './SettingsFormControls'
@@ -40,23 +35,6 @@ function visit(node: unknown, cb: (node: ReactElementLike) => void): void {
}
}
-function findSegmentedControl(node: unknown): ReactElementLike {
- let found: ReactElementLike | null = null
- const label = translate(
- 'auto.components.settings.GitPane.sourceControlGroupOrderTitle',
- 'Source Control Group Order'
- )
- visit(node, (entry) => {
- if (entry.type === SettingsSegmentedControl && entry.props.ariaLabel === label) {
- found = entry
- }
- })
- if (!found) {
- throw new Error('segmented control not found')
- }
- return found
-}
-
function findCompareBaseSegmentedControl(node: unknown): ReactElementLike {
let found: ReactElementLike | null = null
const label = translate(
@@ -136,52 +114,6 @@ describe('GitPane', () => {
expect(markup).not.toContain('Refresh Local Base Ref')
})
- it('renders Source Control group order in Git settings', () => {
- const markup = renderGitPane('group order')
-
- expect(markup).toContain(
- translate(
- 'auto.components.settings.GitPane.sourceControlGroupOrderTitle',
- 'Source Control Group Order'
- )
- )
- expect(markup).toContain(
- translate('auto.components.settings.GitPane.changesFirst', 'Changes first')
- )
- expect(markup).toContain(
- translate('auto.components.settings.GitPane.stagedFirst', 'Staged first')
- )
- expect(markup).toContain(
- translate('auto.components.settings.GitPane.untrackedFirst', 'Untracked first')
- )
- })
-
- it('updates Source Control group order only when the selected option changes', () => {
- const updateSettings = vi.fn()
- const element = SourceControlGroupOrderSetting({
- settings: {
- ...getDefaultSettings(os.homedir()),
- sourceControlGroupOrder: 'changes-first'
- },
- updateSettings
- })
-
- const control = findSegmentedControl(element)
- const onChange = control.props.onChange as (value: string) => void
-
- onChange('staged-first')
- expect(updateSettings).toHaveBeenCalledWith({ sourceControlGroupOrder: 'staged-first' })
-
- updateSettings.mockClear()
- onChange('changes-first')
- expect(updateSettings).not.toHaveBeenCalled()
- })
-
- it('includes Source Control group order search metadata', () => {
- expect(matchesSettingsSearch('staged', getGitPaneSearchEntries())).toBe(true)
- expect(matchesSettingsSearch('group order', getGitPaneSearchEntries())).toBe(true)
- })
-
it('renders the default compare base setting in Git settings', () => {
const markup = renderGitPane('compare base')
diff --git a/src/renderer/src/components/settings/GitPane.tsx b/src/renderer/src/components/settings/GitPane.tsx
index da1a34c3fe8..538315e3e7d 100644
--- a/src/renderer/src/components/settings/GitPane.tsx
+++ b/src/renderer/src/components/settings/GitPane.tsx
@@ -1,7 +1,6 @@
import { useEffect, useRef, useState } from 'react'
-import type { GlobalSettings, SourceControlGroupOrder } from '../../../../shared/types'
+import type { GlobalSettings } from '../../../../shared/types'
import type { SourceControlAiSettingsPatch } from '../../../../shared/source-control-ai-types'
-import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from '../../../../shared/source-control-group-order'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { useAppStore } from '../../store'
@@ -20,7 +19,6 @@ import {
getKeepLocalMainUpToDateTitle
} from './keep-local-main-up-to-date-setting'
import { translate } from '@/i18n/i18n'
-import { SettingsRow, SettingsSegmentedControl } from './SettingsFormControls'
export { getGitPaneSearchEntries }
@@ -40,14 +38,6 @@ const KEEP_LOCAL_MAIN_UP_TO_DATE_KEYWORDS = [
'safely',
'worktree'
]
-const SOURCE_CONTROL_GROUP_ORDER_KEYWORDS = [
- 'group order',
- 'changes first',
- 'staged first',
- 'untracked first',
- 'source control',
- 'git changes'
-]
export function shouldShowAutoRenameBranchSetting(
searchQuery: string,
@@ -70,68 +60,6 @@ type GitPaneProps = {
settingsSearchQuery?: string
}
-export function SourceControlGroupOrderSetting({
- settings,
- updateSettings
-}: {
- settings: GlobalSettings
- updateSettings: (updates: Partial) => void | Promise
-}): React.JSX.Element {
- const value = settings.sourceControlGroupOrder ?? DEFAULT_SOURCE_CONTROL_GROUP_ORDER
- const title = translate(
- 'auto.components.settings.GitPane.sourceControlGroupOrderTitle',
- 'Source Control Group Order'
- )
- const description = translate(
- 'auto.components.settings.GitPane.sourceControlGroupOrderDescription',
- 'Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.'
- )
-
- return (
-
-
- value={value}
- onChange={(nextValue) => {
- if (nextValue !== value) {
- void updateSettings({ sourceControlGroupOrder: nextValue })
- }
- }}
- ariaLabel={title}
- size="sm"
- options={[
- {
- value: 'changes-first',
- label: translate('auto.components.settings.GitPane.changesFirst', 'Changes first')
- },
- {
- value: 'staged-first',
- label: translate('auto.components.settings.GitPane.stagedFirst', 'Staged first')
- },
- {
- value: 'untracked-first',
- label: translate(
- 'auto.components.settings.GitPane.untrackedFirst',
- 'Untracked first'
- )
- }
- ]}
- />
- }
- />
-
- )
-}
-
export function GitPane({
settings,
updateSettings,
@@ -297,23 +225,6 @@ export function GitPane({
) : null,
- matchesSettingsSearch(searchQuery, {
- title: translate(
- 'auto.components.settings.GitPane.sourceControlGroupOrderTitle',
- 'Source Control Group Order'
- ),
- description: translate(
- 'auto.components.settings.GitPane.sourceControlGroupOrderDescription',
- 'Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.'
- ),
- keywords: SOURCE_CONTROL_GROUP_ORDER_KEYWORDS
- }) ? (
-
- ) : null,
compareAgainstUpstreamMatchesSearch(searchQuery) ? (
[
...translateSearchKeyword('auto.components.settings.git.search.035134fcd9', 'worktree')
]
},
- {
- title: translate(
- 'auto.components.settings.git.search.sourceControlGroupOrderTitle',
- 'Source Control Group Order'
- ),
- description: translate(
- 'auto.components.settings.git.search.sourceControlGroupOrderDescription',
- 'Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.'
- ),
- keywords: [
- ...translateSearchKeyword('auto.components.settings.git.search.groupOrder', 'group order'),
- ...translateSearchKeyword(
- 'auto.components.settings.git.search.changesFirst',
- 'changes first'
- ),
- ...translateSearchKeyword('auto.components.settings.git.search.stagedFirst', 'staged first'),
- ...translateSearchKeyword(
- 'auto.components.settings.git.search.untrackedFirst',
- 'untracked first'
- ),
- ...translateSearchKeyword(
- 'auto.components.settings.git.search.sourceControl',
- 'source control'
- ),
- ...translateSearchKeyword('auto.components.settings.git.search.gitChanges', 'git changes')
- ]
- },
{
title: translate(
'auto.components.settings.git.search.compareAgainstUpstreamTitle',
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx
index a219841b949..fef27ea71cc 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx
@@ -60,7 +60,7 @@ vi.mock('@/components/ui/tooltip', () => ({
}))
vi.mock('./use-worktree-activity-status', () => ({
- useWorktreeActivityStatus: () => 'inactive'
+ useWorktreeActivityStatus: () => 'active'
}))
vi.mock('./CacheTimer', () => ({
@@ -171,8 +171,8 @@ describe('WorktreeCard linked PR display', () => {
)
- expect(markup).toContain('Inactive')
- expect(markup).toContain('bg-neutral-500/40')
+ expect(markup).toContain('Active')
+ expect(markup).toContain('bg-emerald-500')
expect(markup).not.toContain('PR: Open')
expect(markup).not.toContain('Linked PR #456')
}, 20_000)
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx
index 54322d38ffc..7456cce2faa 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx
@@ -21,7 +21,6 @@ let tabsByWorktree: Record = {}
let ptyIdsByTabId: Record = {}
let browserTabsByWorktree: Record = {}
let settings: Partial | null = null
-let activityStatus = 'idle'
let projectGroups: unknown[] = []
let workspaceDeleteModifierPressed = false
let gitConflictOperationByWorktree: Record = {}
@@ -61,7 +60,7 @@ vi.mock('@/components/ui/tooltip', () => ({
}))
vi.mock('./use-worktree-activity-status', () => ({
- useWorktreeActivityStatus: () => activityStatus
+ useWorktreeActivityStatus: () => 'idle'
}))
vi.mock('./CacheTimer', () => ({
@@ -145,7 +144,6 @@ describe('WorktreeCard quick actions', () => {
projectGroups = []
workspaceDeleteModifierPressed = false
gitConflictOperationByWorktree = {}
- activityStatus = 'idle'
})
it('marks the unread toggle as a workspace-board-preserving action', () => {
@@ -254,19 +252,6 @@ describe('WorktreeCard quick actions', () => {
expect(markup).not.toContain('lucide-git-branch')
})
- it('keeps the quiet status dot when the branch card property is off', () => {
- settings = { experimentalNewWorktreeCardStyle: true }
- worktreeCardProperties = ['status']
- activityStatus = 'inactive'
-
- const markup = renderToStaticMarkup(
-
- )
-
- expect(markup).toContain('bg-neutral-500/40')
- expect(markup).not.toContain('lucide-git-branch')
- })
-
it('does not render a pending first-agent rename title badge', () => {
const markup = renderToStaticMarkup(
) : null}
diff --git a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx
index 8f204527c9a..8911c1783c5 100644
--- a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx
@@ -192,7 +192,7 @@ describe('WorktreeCardStatusSlot', () => {
expect(markup).not.toContain('PR checks: Failed')
})
- it('keeps the active dot ahead of PR status when new card style is on', () => {
+ it('uses PR status instead of the quiet active dot when new card style is on', () => {
const markup = renderToStaticMarkup(
{
/>
)
- expect(markup).toContain('Active')
+ expect(markup).toContain('PR checks: Failed')
expect(markup).toContain('inline-flex size-5 items-center justify-center')
- expect(markup).toContain('bg-emerald-500')
- expect(markup).not.toContain('PR checks: Failed')
- })
-
- it('keeps the emerald activity dot ahead of branch identity when new card style is on', () => {
- const markup = renderToStaticMarkup(
-
- )
-
- expect(markup).toContain('Active')
- expect(markup).toContain('bg-emerald-500')
- expect(markup).not.toContain('lucide-git-branch')
+ expect(markup).toContain('size-[13px] translate-x-px')
+ expect(markup).toContain('text-rose-500/85')
+ expect(markup).not.toContain('bg-emerald-500')
})
it('uses the unified compact review glyph for GitLab MR status', () => {
- mocks.status = 'inactive'
const markup = renderToStaticMarkup(
{
expect(markup).not.toContain('lucide-git-merge')
})
- it('keeps the done dot ahead of PR status when new card style is on', () => {
+ it('uses PR status instead of the quiet done dot when new card style is on', () => {
mocks.status = 'done'
const markup = renderToStaticMarkup(
{
/>
)
- expect(markup).toContain('Done')
- expect(markup).toContain('bg-emerald-500')
- expect(markup).not.toContain('PR checks: Failed')
+ expect(markup).toContain('PR checks: Failed')
+ expect(markup).not.toContain('bg-emerald-500')
})
it('uses PR status instead of the inactive dot when new card style is on', () => {
@@ -298,8 +277,7 @@ describe('WorktreeCardStatusSlot', () => {
expect(markup).not.toContain('bg-neutral-500/40')
})
- it('uses a branch icon with branch-only tooltip copy on quiet rows', () => {
- mocks.status = 'inactive'
+ it('uses a branch icon with branch-only tooltip copy by default', () => {
const markup = renderToStaticMarkup(
{
})
it('uses context-aware branch or folder path tooltip copy', () => {
- mocks.status = 'inactive'
const markup = renderToStaticMarkup(
{
})
it('overlays an unread badge on PR status when new card style is on', () => {
- mocks.status = 'inactive'
const markup = renderToStaticMarkup(
{
})
it('overlays an unread badge on the branch icon in new card style', () => {
- mocks.status = 'inactive'
const markup = renderToStaticMarkup(
(['inactive'])
+const QUIET_REVIEW_REPLACEABLE_STATUSES = new Set(['active', 'done', 'inactive'])
// Why: a missing review display can also mean provider state is unavailable,
// so the passive label names the identity cue without claiming no review exists.
function getDefaultBranchIdentityLabel(): string {
@@ -104,13 +103,16 @@ export function WorktreeCardStatusSlot({
const status = useWorktreeActivityStatus(worktreeId)
const statusLabel = getWorktreeStatusLabel(status) || status
const canShowReviewStatus =
- newCardStyle && showStatus && prDisplay !== null && IDENTITY_REPLACEABLE_STATUSES.has(status)
+ newCardStyle &&
+ showStatus &&
+ prDisplay !== null &&
+ QUIET_REVIEW_REPLACEABLE_STATUSES.has(status)
const canShowBranchStatus =
newCardStyle &&
showStatus &&
hasBranchIdentity &&
prDisplay === null &&
- IDENTITY_REPLACEABLE_STATUSES.has(status)
+ QUIET_REVIEW_REPLACEABLE_STATUSES.has(status)
const passiveStatusLabel =
canShowReviewStatus && prDisplay
? getReviewStatusTooltip(prDisplay)
diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts
index 959a8069db3..cd3cfd99999 100644
--- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts
+++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts
@@ -542,6 +542,19 @@ describe('startParkedTerminalByteWatcher', () => {
dispose()
})
+ // Why: retained gauges would inflate every later high-water profile.
+ it('dispose drops the processor from the pty side-effect census', async () => {
+ await import('./pty-side-effect-pending-census')
+ const { collectRendererMemoryProfileCounts } = await import('@/lib/renderer-memory-profile')
+ expect(collectRendererMemoryProfileCounts()['ptySideEffects.processors']).toBe(0)
+
+ const { dispose } = await startWatcher()
+ expect(collectRendererMemoryProfileCounts()['ptySideEffects.processors']).toBe(1)
+
+ dispose()
+ expect(collectRendererMemoryProfileCounts()['ptySideEffects.processors']).toBe(0)
+ })
+
it('disposes the previous watcher when a new one starts for the same PTY', async () => {
await startWatcher({ paneId: 1 })
const second = await startWatcher({ paneId: 2 })
diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts
index faa9601cbfa..42dff141d34 100644
--- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts
+++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts
@@ -303,6 +303,9 @@ export function startParkedTerminalByteWatcher(
return
}
disposed = true
+ // Why first: each park/reveal cycle owns a distinct processor gauge, and the store/IPC
+ // teardown below must not be able to throw its way past the census drop.
+ processor?.disposePendingSideEffectGauge()
// Why: unhide BEFORE the reveal remount registers pane handlers, so main resumes delivery and emits the restore marker the pane consumes.
releaseHiddenDeliveryClaim?.()
stopMode2031Responder?.()
diff --git a/src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts b/src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts
new file mode 100644
index 00000000000..eb9a5724d52
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts
@@ -0,0 +1,130 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { collectRendererMemoryProfileCounts } from '@/lib/renderer-memory-profile'
+import { registerPtySideEffectPendingGauge } from './pty-side-effect-pending-census'
+
+function ptySideEffectCounts(): { pending: number; retained: number; processors: number } {
+ const counts = collectRendererMemoryProfileCounts()
+ return {
+ pending: counts['ptySideEffects.pending'],
+ retained: counts['ptySideEffects.retained'],
+ processors: counts['ptySideEffects.processors']
+ }
+}
+
+function fixedGauge(pending: number, retained = pending): { dispose: () => void } {
+ // Why the local: the census holds gauges weakly, so a test gauge needs a strong owner too.
+ const gauge = { pending: () => pending, retained: () => retained }
+ return { dispose: registerPtySideEffectPendingGauge(gauge) }
+}
+
+describe('pty side-effect pending census', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('sums registered gauges and drops disposed ones', () => {
+ const before = ptySideEffectCounts()
+ const a = fixedGauge(3, 5)
+ const b = fixedGauge(4, 4)
+
+ let counts = ptySideEffectCounts()
+ expect(counts.pending).toBe(before.pending + 7)
+ expect(counts.retained).toBe(before.retained + 9)
+ expect(counts.processors).toBe(before.processors + 2)
+
+ a.dispose()
+ counts = ptySideEffectCounts()
+ expect(counts.pending).toBe(before.pending + 4)
+ expect(counts.retained).toBe(before.retained + 4)
+ expect(counts.processors).toBe(before.processors + 1)
+
+ b.dispose()
+ expect(ptySideEffectCounts()).toEqual(before)
+ })
+
+ it('ignores a repeated dispose instead of dropping another gauge', () => {
+ const before = ptySideEffectCounts()
+ const a = fixedGauge(3)
+ const b = fixedGauge(4)
+
+ a.dispose()
+ a.dispose()
+
+ expect(ptySideEffectCounts().processors).toBe(before.processors + 1)
+ b.dispose()
+ expect(ptySideEffectCounts()).toEqual(before)
+ })
+
+ it('stops tracking new gauges past the cap so a missed dispose cannot grow it', () => {
+ const before = ptySideEffectCounts()
+ const owners = Array.from({ length: 600 }, () => fixedGauge(1))
+ try {
+ expect(ptySideEffectCounts().processors).toBe(512)
+ } finally {
+ for (const owner of owners) {
+ owner.dispose()
+ }
+ }
+ expect(ptySideEffectCounts()).toEqual(before)
+ })
+
+ it('tracks a live output processor queue through enqueue, drain, and dispose', async () => {
+ vi.useFakeTimers()
+ const { createPtyOutputProcessor } = await import('./pty-transport')
+ const before = ptySideEffectCounts()
+ const processor = createPtyOutputProcessor({ onTitleChange: vi.fn() })
+
+ expect(ptySideEffectCounts()).toEqual({
+ pending: before.pending,
+ retained: before.retained,
+ processors: before.processors + 1
+ })
+
+ processor.processData('\x1b]0;census-title\x07', { onData: vi.fn() })
+ expect(ptySideEffectCounts().pending).toBe(before.pending + 1)
+
+ await vi.runOnlyPendingTimersAsync()
+ expect(ptySideEffectCounts().pending).toBe(before.pending)
+
+ processor.processData('\x1b]0;census-title-2\x07', { onData: vi.fn() })
+ expect(ptySideEffectCounts().pending).toBe(before.pending + 1)
+ processor.clearAccumulatedState()
+ expect(ptySideEffectCounts().pending).toBe(before.pending)
+
+ processor.disposePendingSideEffectGauge()
+ expect(ptySideEffectCounts()).toEqual(before)
+ })
+
+ // Why: a partly drained queue still holds every entry until compaction, so depth alone
+ // would understate the retained bytes this census exists to attribute.
+ it('reports drained-but-uncompacted entries as retained after a bounded drain', async () => {
+ vi.useFakeTimers()
+ const { createPtyOutputProcessor } = await import('./pty-transport')
+ const before = ptySideEffectCounts()
+ const processor = createPtyOutputProcessor({ onTitleChange: vi.fn() })
+
+ for (let i = 0; i < 100; i += 1) {
+ processor.processData(`\x1b]0;census-title-${i}\x07`, { onData: vi.fn() })
+ }
+ expect(ptySideEffectCounts()).toEqual({
+ pending: before.pending + 100,
+ retained: before.retained + 100,
+ processors: before.processors + 1
+ })
+
+ // One drain applies MAX_PTY_SIDE_EFFECTS_PER_DRAIN entries but stays under the compaction threshold.
+ await vi.runOnlyPendingTimersAsync()
+ expect(ptySideEffectCounts().pending).toBe(before.pending + 36)
+ expect(ptySideEffectCounts().retained).toBe(before.retained + 100)
+
+ processor.flushPendingSideEffects()
+ expect(ptySideEffectCounts()).toEqual({
+ pending: before.pending,
+ retained: before.retained,
+ processors: before.processors + 1
+ })
+
+ processor.disposePendingSideEffectGauge()
+ expect(ptySideEffectCounts()).toEqual(before)
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.ts b/src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.ts
new file mode 100644
index 00000000000..53f1f98c4e2
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.ts
@@ -0,0 +1,65 @@
+import { registerRendererMemoryProfileContributor } from '@/lib/renderer-memory-profile'
+
+/** Queue counts for one PTY output processor, read at heap-highwater time. */
+export type PtySideEffectGauge = {
+ /** Entries still awaiting apply; saturates at MAX_PENDING_PTY_SIDE_EFFECTS. */
+ pending: () => number
+ /** Entries the queue array still holds — drained-but-uncompacted ones included, so this is the count that tracks retained bytes. */
+ retained: () => number
+}
+
+// Why weak: a diagnostic for leaks must not be able to cause one. The owning processor holds
+// the only strong reference, so a teardown path that forgets to dispose strands an empty
+// WeakRef here instead of pinning the processor's whole closure (callbacks, tracker, terminal).
+const gaugeRefs = new Set>()
+
+// Why: bounds this module even if registrations outrun both disposal and collection.
+const MAX_TRACKED_GAUGES = 512
+
+function pruneCollectedGauges(): void {
+ for (const ref of gaugeRefs) {
+ if (ref.deref() === undefined) {
+ gaugeRefs.delete(ref)
+ }
+ }
+}
+
+export function registerPtySideEffectPendingGauge(gauge: PtySideEffectGauge): () => void {
+ if (gaugeRefs.size >= MAX_TRACKED_GAUGES) {
+ pruneCollectedGauges()
+ }
+ if (gaugeRefs.size >= MAX_TRACKED_GAUGES) {
+ return () => undefined
+ }
+ gaugeRefs.add(new WeakRef(gauge))
+ // Why delete by identity: this closure is handed to the processor, so closing over `gauge`
+ // is what makes the processor — never this module — the gauge's strong owner.
+ return () => {
+ for (const ref of gaugeRefs) {
+ if (ref.deref() === gauge) {
+ gaugeRefs.delete(ref)
+ return
+ }
+ }
+ }
+}
+
+// Why processor count is the signal: each queue is capped at MAX_PENDING_PTY_SIDE_EFFECTS and
+// evicts oldest-first, so one processor can never grow without bound. Aggregate growth means
+// processors outliving their panes; queues parked at the cap mean a drain starved by throttling.
+registerRendererMemoryProfileContributor('ptySideEffects', () => {
+ let pending = 0
+ let retained = 0
+ let processors = 0
+ for (const ref of gaugeRefs) {
+ const gauge = ref.deref()
+ if (!gauge) {
+ gaugeRefs.delete(ref)
+ continue
+ }
+ pending += gauge.pending()
+ retained += gauge.retained()
+ processors += 1
+ }
+ return { pending, retained, processors }
+})
diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts
index 3833468ed0d..8c82e7584a1 100644
--- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts
+++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts
@@ -183,6 +183,9 @@ export type PtyTransport = {
scrollbackRows?: number
}) => Promise
preserve?: () => void
+ /** Hand the live PTY to a successor without process teardown. Terminal for this instance:
+ * it also drops the transport's output processor from the pty side-effect memory census,
+ * so a reattached one would run untracked. Create a new transport instead. */
detach?: (options?: { preserveExitObserver?: boolean }) => void
destroy?: () => void | Promise
}
diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts
index c9973b82311..72b340d2620 100644
--- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts
+++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts
@@ -118,6 +118,43 @@ describe('createIpcPtyTransport', () => {
expect(kill).not.toHaveBeenCalled()
})
+ // Why: retained gauges would inflate every later high-water profile.
+ it.each(['detach', 'destroy'] as const)(
+ 'drops its side-effect gauge from the census on %s',
+ async (teardown) => {
+ await import('./pty-side-effect-pending-census')
+ const { collectRendererMemoryProfileCounts } = await import('@/lib/renderer-memory-profile')
+ const { createIpcPtyTransport } = await import('./pty-transport')
+ expect(collectRendererMemoryProfileCounts()['ptySideEffects.processors']).toBe(0)
+
+ const transport = createIpcPtyTransport({})
+ await transport.connect({ url: '', callbacks: {} })
+ expect(collectRendererMemoryProfileCounts()['ptySideEffects.processors']).toBe(1)
+
+ transport[teardown]?.()
+
+ expect(collectRendererMemoryProfileCounts()['ptySideEffects.processors']).toBe(0)
+ }
+ )
+
+ // Why: teardown that already failed is exactly when a stranded gauge would pin the processor.
+ it('drops its side-effect gauge even when destroy throws mid-disconnect', async () => {
+ await import('./pty-side-effect-pending-census')
+ const { collectRendererMemoryProfileCounts } = await import('@/lib/renderer-memory-profile')
+ const { createIpcPtyTransport } = await import('./pty-transport')
+ const kill = window.api.pty.kill as unknown as ReturnType
+ const transport = createIpcPtyTransport({})
+ await transport.connect({ url: '', callbacks: {} })
+ expect(collectRendererMemoryProfileCounts()['ptySideEffects.processors']).toBe(1)
+
+ kill.mockImplementationOnce(() => {
+ throw new Error('ipc channel closed')
+ })
+
+ expect(() => transport.destroy?.()).toThrow('ipc channel closed')
+ expect(collectRendererMemoryProfileCounts()['ptySideEffects.processors']).toBe(0)
+ })
+
it('retires an adopted PTY when recovery disconnects before a replacement spawn', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
const spawn = window.api.pty.spawn as unknown as ReturnType
diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts
index d612b13cc8f..95528b43070 100644
--- a/src/renderer/src/components/terminal-pane/pty-transport.ts
+++ b/src/renderer/src/components/terminal-pane/pty-transport.ts
@@ -43,6 +43,10 @@ import {
type ProcessedAgentStatusChunk
} from '../../../../shared/agent-status-osc'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
+import {
+ registerPtySideEffectPendingGauge,
+ type PtySideEffectGauge
+} from './pty-side-effect-pending-census'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
// Re-export public API so existing consumers keep working.
@@ -153,6 +157,7 @@ export function createPtyOutputProcessor({
flushPendingSideEffects: () => void
resetBellDetector: () => void
resetAgentStatusCarry: () => void
+ disposePendingSideEffectGauge: () => void
} {
const bellDetector = createBellDetector()
// Why let: a model-restore marker drops bytes; recreating the parser stops a partial OSC-9999 carry from swallowing the next chunk's head.
@@ -165,6 +170,12 @@ export function createPtyOutputProcessor({
let pendingSideEffects: PendingPtySideEffect[] = []
let pendingSideEffectIndex = 0
let pendingWorkingTitleSideEffects = 0
+ // Why both counts: drained entries survive until compaction, so depth alone understates what the array retains.
+ const pendingSideEffectGauge: PtySideEffectGauge = {
+ pending: () => pendingSideEffects.length - pendingSideEffectIndex,
+ retained: () => pendingSideEffects.length
+ }
+ const disposePendingSideEffectGauge = registerPtySideEffectPendingGauge(pendingSideEffectGauge)
const agentTracker =
onAgentBecameIdle || onAgentBecameWorking || onAgentExited
? createAgentStatusTracker(
@@ -507,7 +518,8 @@ export function createPtyOutputProcessor({
resetBellDetector: () => bellDetector.reset(),
resetAgentStatusCarry: () => {
processAgentStatusChunk = createAgentStatusOscProcessor()
- }
+ },
+ disposePendingSideEffectGauge
}
}
@@ -989,6 +1001,9 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
},
detach(options) {
+ // Why first: the successor transport owns the PTY after detach, and nothing below may
+ // throw its way past the census drop — a stranded gauge outlives the transport.
+ outputProcessor.disposePendingSideEffectGauge()
clearAccumulatedState()
inputWriteQueue.clear()
if (ptyId) {
@@ -1086,7 +1101,13 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
destroy() {
destroyed = true
- this.disconnect()
+ // Why finally: disconnect runs pty.kill IPC and consumer onDisconnect callbacks; a throw
+ // there must not strand the gauge in the very path where teardown already went wrong.
+ try {
+ this.disconnect()
+ } finally {
+ outputProcessor.disposePendingSideEffectGauge()
+ }
}
}
}
diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts
index 1b82b9850ad..0292d0b1749 100644
--- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts
+++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts
@@ -388,6 +388,27 @@ describe('createRemoteRuntimePtyTransport', () => {
transport.destroy?.()
})
+ // Why: retained gauges would inflate every later high-water profile.
+ it.each(['detach', 'destroy'] as const)(
+ 'drops its side-effect gauge from the census on %s',
+ async (teardown) => {
+ await import('./pty-side-effect-pending-census')
+ const { collectRendererMemoryProfileCounts } = await import('@/lib/renderer-memory-profile')
+ const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
+ expect(collectRendererMemoryProfileCounts()['ptySideEffects.processors']).toBe(0)
+
+ const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' })
+ transport.attach({ existingPtyId: 'remote:terminal-1', callbacks: {} })
+ await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
+ expect(collectRendererMemoryProfileCounts()['ptySideEffects.processors']).toBe(1)
+
+ transport[teardown]?.()
+
+ expect(collectRendererMemoryProfileCounts()['ptySideEffects.processors']).toBe(0)
+ transport.destroy?.()
+ }
+ )
+
it('recovers when the first restored-terminal subscription attempt is offline', async () => {
vi.useFakeTimers()
try {
diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
index e0322101e59..d34bd384511 100644
--- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
+++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
@@ -2291,6 +2291,9 @@ export function createRemoteRuntimePtyTransport(
},
detach() {
+ // Why first: the successor transport owns the PTY after detach, and the batcher flushes
+ // below can throw past the census drop — a stranded gauge outlives the transport.
+ outputProcessor.disposePendingSideEffectGauge()
lifecycleEpoch += 1
attachGeneration += 1
cancelTerminalCreateRetryWait()
@@ -2516,7 +2519,13 @@ export function createRemoteRuntimePtyTransport(
destroy() {
destroyed = true
setAttachmentUnavailable()
- this.disconnect()
+ // Why finally: disconnect runs consumer onDisconnect/onPtyExit callbacks; a throw there
+ // must not strand the gauge in the very path where teardown already went wrong.
+ try {
+ this.disconnect()
+ } finally {
+ outputProcessor.disposePendingSideEffectGauge()
+ }
recovery.dispose()
inputBatcher.clear()
viewportBatcher.clear()
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index c4913683d8e..3f940076736 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -6183,11 +6183,6 @@
"3d172725cc": "None",
"1f32ba27a6": "Custom",
"a182c5125e": "Git Username",
- "sourceControlGroupOrderTitle": "Source Control Group Order",
- "sourceControlGroupOrderDescription": "Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.",
- "changesFirst": "Changes first",
- "stagedFirst": "Staged first",
- "untrackedFirst": "Untracked first",
"compareAgainstUpstreamTitle": "Default Compare Base",
"compareAgainstUpstreamDescription": "Choose which base Source Control uses by default for committed-change comparisons. Branch upstream follows the current branch automatically and falls back to the repository default branch when no upstream exists. You can still change the compare base per worktree from that worktree's Git panel. Pull Request and rebase targets don't change.",
"compareBaseRepositoryDefault": "Repository default",
@@ -8463,14 +8458,7 @@
"f83c8937c4": "branch naming",
"5ecd91c5ef": "Prefix added to branch names when creating worktrees.",
"68bd65fdb8": "Branch Prefix",
- "sourceControlGroupOrderTitle": "Source Control Group Order",
- "sourceControlGroupOrderDescription": "Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.",
- "groupOrder": "group order",
- "changesFirst": "changes first",
- "stagedFirst": "staged first",
- "untrackedFirst": "untracked first",
"sourceControl": "source control",
- "gitChanges": "git changes",
"compareAgainstUpstreamTitle": "Default Compare Base",
"compareAgainstUpstreamDescription": "Choose which base Source Control uses by default for committed-change comparisons. Branch upstream follows the current branch automatically and falls back to the repository default branch when no upstream exists. You can still change the compare base per worktree from that worktree's Git panel. Pull Request and rebase targets don't change.",
"compareBase": "compare base",
@@ -10805,6 +10793,8 @@
"a4e93c21d7": "Current branch: {{value0}}",
"c7d4e2f801": "Change base ref: {{value0}}",
"f3a1b8c204": "upstream",
+ "discardAllMixedUntracked": "Discard changes and delete untracked files",
+ "discardFolderMixedUntracked": "Discard changes and delete untracked files in folder",
"createPrIntentEmptyGeneratedBody": "Generated review details did not include a description. Retry Create PR.",
"createPrIntentGenerateDetailsFailed": "Could not generate review details. Retry Create PR."
},
@@ -11230,7 +11220,10 @@
"40e9357b2a": "This will restore the file from HEAD and discard the deletion. This cannot be undone.",
"5c0bdbc4cb": "Restore \"{{value0}}\"?",
"d97bf697c9": "This will permanently delete this file. This cannot be undone.",
- "96c772bee9": "Delete \"{{value0}}\"?"
+ "96c772bee9": "Delete \"{{value0}}\"?",
+ "mixedTitle": "Discard changes and delete untracked files?",
+ "mixedDescription": "Tracked changes will be reverted and untracked files will be permanently deleted. This cannot be undone.",
+ "mixedConfirm": "Delete and discard"
},
"dialog": {
"3bc61dc989": "Cancel",
@@ -11299,6 +11292,17 @@
"8f9a0b1c2d": "Nothing to commit. Branch is up to date.",
"h3i4j5k607": "Checking whether this branch can create a {{value0}}…"
}
+ },
+ "branch": {
+ "line": {
+ "total": {
+ "chip": {
+ "daa8e8e59b": "{{value0}} additions, {{value1}} deletions",
+ "8a9b97b666": "{{value0}} additions",
+ "52c366d88d": "{{value0}} deletions"
+ }
+ }
+ }
}
}
},
@@ -14856,7 +14860,7 @@
"other": "Other…",
"otherPlaceholder": "Type your answer",
"cancel": "Cancel",
- "send": "Send answer",
+ "send": "Submit",
"next": "Next",
"skip": "Skip",
"sending": "Sending…"
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 68a9bcb8fbd..33b14ec6fa7 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -5966,11 +5966,6 @@
"3d172725cc": "Ninguno",
"1f32ba27a6": "Personalizado",
"a182c5125e": "Usuario de Git",
- "sourceControlGroupOrderTitle": "Orden de grupos de control de código fuente",
- "sourceControlGroupOrderDescription": "Elige si Cambios, Cambios preparados o Archivos sin seguimiento aparecen primero en Control de código fuente.",
- "changesFirst": "Cambios primero",
- "stagedFirst": "Cambios preparados primero",
- "untrackedFirst": "Archivos sin seguimiento primero",
"compareAgainstUpstreamTitle": "Base de comparación predeterminada",
"compareAgainstUpstreamDescription": "Elige qué base usa Control de código fuente de forma predeterminada para las comparaciones de cambios confirmados. El upstream de la rama sigue automáticamente la rama actual y recurre a la rama predeterminada del repositorio cuando no hay upstream. Aun así, puedes cambiar la base de comparación por worktree desde el panel Git de ese worktree. Los objetivos de Pull Request y rebase no cambian.",
"compareBaseRepositoryDefault": "Predeterminada del repositorio",
@@ -8236,14 +8231,7 @@
"f83c8937c4": "nombres de ramas",
"5ecd91c5ef": "Prefijo agregado a los nombres de ramas al crear worktrees.",
"68bd65fdb8": "Prefijo de rama",
- "sourceControlGroupOrderTitle": "Orden de grupos de control de código fuente",
- "sourceControlGroupOrderDescription": "Elige si Cambios, Cambios preparados o Archivos sin seguimiento aparecen primero en Control de código fuente.",
- "groupOrder": "orden de grupos",
- "changesFirst": "cambios primero",
- "stagedFirst": "cambios preparados primero",
- "untrackedFirst": "archivos sin seguimiento primero",
"sourceControl": "control de código fuente",
- "gitChanges": "cambios de git",
"compareAgainstUpstreamTitle": "Base de comparación predeterminada",
"compareAgainstUpstreamDescription": "Elige qué base usa Control de código fuente de forma predeterminada para las comparaciones de cambios confirmados. El upstream de la rama sigue automáticamente la rama actual y recurre a la rama predeterminada del repositorio cuando no hay upstream. Aun así, puedes cambiar la base de comparación por worktree desde el panel Git de ese worktree. Los objetivos de Pull Request y rebase no cambian.",
"compareBase": "base de comparación",
@@ -14526,7 +14514,7 @@
"other": "Otro…",
"otherPlaceholder": "Escribe tu respuesta",
"cancel": "Cancelar",
- "send": "Enviar respuesta",
+ "send": "Enviar",
"next": "Siguiente",
"skip": "Omitir",
"sending": "Enviando…"
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 9ef5a69cbae..24c94d5c823 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -5988,11 +5988,6 @@
"3d172725cc": "なし",
"1f32ba27a6": "カスタム",
"a182c5125e": "Gitのユーザー名",
- "sourceControlGroupOrderTitle": "ソース管理のグループ順序",
- "sourceControlGroupOrderDescription": "ソース管理で「変更」「ステージ済みの変更」「未追跡ファイル」のどれを先に表示するかを選択します。",
- "changesFirst": "変更を先頭",
- "stagedFirst": "ステージ済みを先頭",
- "untrackedFirst": "未追跡を先頭",
"compareAgainstUpstreamTitle": "既定の比較ベース",
"compareAgainstUpstreamDescription": "ソース管理がコミット済み変更の比較に既定で使うベースを選択します。ブランチの上流は現在のブランチに自動的に追従し、上流がない場合はリポジトリの既定ブランチにフォールバックします。比較ベースは各 worktree の Git パネルから worktree ごとに変更できます。Pull Request やリベースの対象は変更されません。",
"compareBaseRepositoryDefault": "リポジトリ既定",
@@ -8258,14 +8253,7 @@
"f83c8937c4": "ブランチの命名",
"5ecd91c5ef": "ワークツリーの作成時にブランチ名に追加されるプレフィックス。",
"68bd65fdb8": "ブランチプレフィックス",
- "sourceControlGroupOrderTitle": "ソース管理のグループ順序",
- "sourceControlGroupOrderDescription": "ソース管理で「変更」「ステージ済みの変更」「未追跡ファイル」のどれを先に表示するかを選択します。",
- "groupOrder": "グループ順序",
- "changesFirst": "変更を先頭",
- "stagedFirst": "ステージ済みを先頭",
- "untrackedFirst": "未追跡を先頭",
"sourceControl": "ソース管理",
- "gitChanges": "git の変更",
"compareAgainstUpstreamTitle": "既定の比較ベース",
"compareAgainstUpstreamDescription": "ソース管理がコミット済み変更の比較に既定で使うベースを選択します。ブランチの上流は現在のブランチに自動的に追従し、上流がない場合はリポジトリの既定ブランチにフォールバックします。比較ベースは各 worktree の Git パネルから worktree ごとに変更できます。Pull Request やリベースの対象は変更されません。",
"compareBase": "比較基準",
@@ -14526,7 +14514,7 @@
"other": "他の…",
"otherPlaceholder": "答えを入力してください",
"cancel": "キャンセル",
- "send": "回答を送信する",
+ "send": "送信",
"next": "次へ",
"skip": "スキップ",
"sending": "送信中…"
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index d6c3e34fcd1..ead7ea624f4 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -5951,11 +5951,6 @@
"3d172725cc": "없음",
"1f32ba27a6": "사용자 정의",
"a182c5125e": "Git 사용자 이름",
- "sourceControlGroupOrderTitle": "소스 제어 그룹 순서",
- "sourceControlGroupOrderDescription": "소스 제어에서 변경 사항, 스테이징된 변경 사항 또는 추적되지 않는 파일 중 무엇을 먼저 표시할지 선택합니다.",
- "changesFirst": "변경 사항 먼저",
- "stagedFirst": "스테이징된 항목 먼저",
- "untrackedFirst": "추적되지 않는 파일 먼저",
"compareAgainstUpstreamTitle": "기본 비교 기준",
"compareAgainstUpstreamDescription": "소스 제어가 커밋된 변경 사항 비교에 기본으로 사용할 기준을 선택합니다. 브랜치 업스트림은 현재 브랜치를 자동으로 따라가며, 업스트림이 없으면 저장소 기본 브랜치로 돌아갑니다. 비교 기준은 해당 worktree의 Git 패널에서 worktree별로 변경할 수 있습니다. Pull Request 및 리베이스 대상은 변경되지 않습니다.",
"compareBaseRepositoryDefault": "저장소 기본값",
@@ -8221,14 +8216,7 @@
"f83c8937c4": "브랜치 이름 지정",
"5ecd91c5ef": "작업 트리를 생성할 때 브랜치 이름에 접두사가 추가됩니다.",
"68bd65fdb8": "브랜치 접두사",
- "sourceControlGroupOrderTitle": "소스 제어 그룹 순서",
- "sourceControlGroupOrderDescription": "소스 제어에서 변경 사항, 스테이징된 변경 사항 또는 추적되지 않는 파일 중 무엇을 먼저 표시할지 선택합니다.",
- "groupOrder": "그룹 순서",
- "changesFirst": "변경 사항 먼저",
- "stagedFirst": "스테이징된 항목 먼저",
- "untrackedFirst": "추적되지 않는 파일 먼저",
"sourceControl": "소스 제어",
- "gitChanges": "git 변경 사항",
"compareAgainstUpstreamTitle": "기본 비교 기준",
"compareAgainstUpstreamDescription": "소스 제어가 커밋된 변경 사항 비교에 기본으로 사용할 기준을 선택합니다. 브랜치 업스트림은 현재 브랜치를 자동으로 따라가며, 업스트림이 없으면 저장소 기본 브랜치로 돌아갑니다. 비교 기준은 해당 worktree의 Git 패널에서 worktree별로 변경할 수 있습니다. Pull Request 및 리베이스 대상은 변경되지 않습니다.",
"compareBase": "비교 기준",
@@ -14537,7 +14525,7 @@
"other": "다른…",
"otherPlaceholder": "답변을 입력하세요",
"cancel": "취소",
- "send": "답변 보내기",
+ "send": "제출",
"next": "다음",
"skip": "건너뛰기",
"sending": "전송 중…"
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index c5f255fe4e9..b64faff2472 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -5963,11 +5963,6 @@
"3d172725cc": "没有任何",
"1f32ba27a6": "自定义",
"a182c5125e": "git用户名",
- "sourceControlGroupOrderTitle": "源代码管理分组顺序",
- "sourceControlGroupOrderDescription": "选择在源代码管理中优先显示“更改”、“已暂存更改”还是“未跟踪文件”。",
- "changesFirst": "更改优先",
- "stagedFirst": "已暂存优先",
- "untrackedFirst": "未跟踪优先",
"compareAgainstUpstreamTitle": "默认对比基准",
"compareAgainstUpstreamDescription": "选择源代码管理在比较已提交变更时默认使用的基准。分支上游会自动跟随当前分支;如果没有上游,则回退到仓库默认分支。你仍然可以在对应 worktree 的 Git 面板中按 worktree 更改对比基准。Pull Request 和变基目标不会改变。",
"compareBaseRepositoryDefault": "仓库默认",
@@ -8233,14 +8228,7 @@
"f83c8937c4": "分支命名",
"5ecd91c5ef": "创建工作树时添加到分支名称的前缀。",
"68bd65fdb8": "分支前缀",
- "sourceControlGroupOrderTitle": "源代码管理分组顺序",
- "sourceControlGroupOrderDescription": "选择在源代码管理中优先显示“更改”、“已暂存更改”还是“未跟踪文件”。",
- "groupOrder": "分组顺序",
- "changesFirst": "更改优先",
- "stagedFirst": "已暂存优先",
- "untrackedFirst": "未跟踪优先",
"sourceControl": "源代码管理",
- "gitChanges": "git 更改",
"compareAgainstUpstreamTitle": "默认对比基准",
"compareAgainstUpstreamDescription": "选择源代码管理在比较已提交变更时默认使用的基准。分支上游会自动跟随当前分支;如果没有上游,则回退到仓库默认分支。你仍然可以在对应 worktree 的 Git 面板中按 worktree 更改对比基准。Pull Request 和变基目标不会改变。",
"compareBase": "对比基准",
@@ -14546,7 +14534,7 @@
"other": "其他…",
"otherPlaceholder": "输入你的回答",
"cancel": "取消",
- "send": "发送回答",
+ "send": "提交",
"next": "下一步",
"skip": "跳过",
"sending": "正在发送…"
diff --git a/src/renderer/src/runtime/runtime-git-client-branch-line-total.test.ts b/src/renderer/src/runtime/runtime-git-client-branch-line-total.test.ts
new file mode 100644
index 00000000000..07d2db533a9
--- /dev/null
+++ b/src/renderer/src/runtime/runtime-git-client-branch-line-total.test.ts
@@ -0,0 +1,133 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { getRuntimeGitStatus } from './runtime-git-client'
+import {
+ createCompatibleRuntimeStatusResponseIfNeeded,
+ type RuntimeEnvironmentCallRequest
+} from './runtime-compatibility-test-fixture'
+import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client'
+
+const MERGE_BASE = '1f3c0d9a5b6e7f8091a2b3c4d5e6f708192a3b4c'
+
+const gitStatus = vi.fn()
+const gitCancelStatus = vi.fn()
+const runtimeEnvironmentCall = vi.fn()
+const runtimeEnvironmentTransportCall = vi.fn()
+
+beforeEach(() => {
+ clearRuntimeCompatibilityCacheForTests()
+ gitStatus.mockReset()
+ gitStatus.mockResolvedValue({ entries: [], conflictOperation: 'unknown' })
+ gitCancelStatus.mockReset()
+ gitCancelStatus.mockResolvedValue(undefined)
+ runtimeEnvironmentCall.mockReset()
+ runtimeEnvironmentCall.mockResolvedValue({
+ id: 'rpc-1',
+ ok: true,
+ result: { entries: [], conflictOperation: 'unknown' },
+ _meta: { runtimeId: 'remote-runtime' }
+ })
+ runtimeEnvironmentTransportCall.mockReset()
+ runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
+ return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
+ })
+ vi.stubGlobal('window', {
+ api: {
+ git: { status: gitStatus, cancelStatus: gitCancelStatus },
+ runtimeEnvironments: { call: runtimeEnvironmentTransportCall }
+ }
+ })
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('branch line total merge base on git status requests', () => {
+ it('forwards the merge base to local git status only when the chip asked for it', async () => {
+ const context = {
+ settings: { activeRuntimeEnvironmentId: null },
+ worktreeId: 'wt-1',
+ worktreePath: '/repo'
+ }
+
+ await getRuntimeGitStatus(context, { branchLineTotalMergeBase: MERGE_BASE })
+ await getRuntimeGitStatus(context, {})
+ await getRuntimeGitStatus(context)
+
+ expect(gitStatus).toHaveBeenNthCalledWith(1, {
+ worktreePath: '/repo',
+ connectionId: undefined,
+ branchLineTotalMergeBase: MERGE_BASE
+ })
+ expect(gitStatus).toHaveBeenNthCalledWith(2, {
+ worktreePath: '/repo',
+ connectionId: undefined
+ })
+ expect(gitStatus).toHaveBeenNthCalledWith(3, {
+ worktreePath: '/repo',
+ connectionId: undefined
+ })
+ })
+
+ it('forwards the merge base through the active runtime environment', async () => {
+ await getRuntimeGitStatus(
+ {
+ settings: { activeRuntimeEnvironmentId: 'env-1' },
+ worktreeId: 'wt-1',
+ worktreePath: '/repo'
+ },
+ { branchLineTotalMergeBase: MERGE_BASE }
+ )
+
+ expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
+ selector: 'env-1',
+ method: 'git.status',
+ params: { worktree: 'id:wt-1', branchLineTotalMergeBase: MERGE_BASE },
+ timeoutMs: 15_000
+ })
+ })
+
+ it('omits the merge base param entirely for a remote status with no visible chip', async () => {
+ // Why: Rule-1 wire safety — an old server must see the exact params it
+ // already understands, and a hidden chip must not cost a remote diff.
+ await getRuntimeGitStatus({
+ settings: { activeRuntimeEnvironmentId: 'env-1' },
+ worktreeId: 'wt-1',
+ worktreePath: '/repo'
+ })
+
+ expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
+ selector: 'env-1',
+ method: 'git.status',
+ params: { worktree: 'id:wt-1' },
+ timeoutMs: 15_000
+ })
+ })
+
+ it('keeps the merge base alongside reuse and cache-bypass flags', async () => {
+ await getRuntimeGitStatus(
+ {
+ settings: { activeRuntimeEnvironmentId: 'env-1' },
+ worktreeId: 'wt-1',
+ worktreePath: '/repo'
+ },
+ {
+ reuseLineStats: true,
+ bypassEffectiveUpstreamNegativeCache: true,
+ branchLineTotalMergeBase: MERGE_BASE
+ }
+ )
+
+ expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
+ selector: 'env-1',
+ method: 'git.status',
+ params: {
+ worktree: 'id:wt-1',
+ bypassEffectiveUpstreamNegativeCache: true,
+ reuseLineStats: true,
+ branchLineTotalMergeBase: MERGE_BASE
+ },
+ timeoutMs: 15_000
+ })
+ })
+})
diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts
index 0a820ad18c2..f4b5931d696 100644
--- a/src/renderer/src/runtime/runtime-git-client.ts
+++ b/src/renderer/src/runtime/runtime-git-client.ts
@@ -139,6 +139,7 @@ export async function getRuntimeGitStatus(
includeIgnored?: boolean
bypassEffectiveUpstreamNegativeCache?: boolean
reuseLineStats?: boolean
+ branchLineTotalMergeBase?: string
signal?: AbortSignal
}
): Promise {
@@ -148,6 +149,9 @@ export async function getRuntimeGitStatus(
? { bypassEffectiveUpstreamNegativeCache: true }
: {}
const lineStatsReuseArgs = options?.reuseLineStats ? { reuseLineStats: true } : {}
+ const branchLineTotalArgs = options?.branchLineTotalMergeBase
+ ? { branchLineTotalMergeBase: options.branchLineTotalMergeBase }
+ : {}
if (target.kind === 'local' || !context.worktreeId) {
return callLocalGitStatus(
{
@@ -155,7 +159,8 @@ export async function getRuntimeGitStatus(
connectionId: context.connectionId,
...includeIgnoredArgs,
...upstreamCacheBypassArgs,
- ...lineStatsReuseArgs
+ ...lineStatsReuseArgs,
+ ...branchLineTotalArgs
},
options?.signal
)
@@ -167,7 +172,8 @@ export async function getRuntimeGitStatus(
worktree: toRuntimeWorktreeSelector(context.worktreeId),
...includeIgnoredArgs,
...upstreamCacheBypassArgs,
- ...lineStatsReuseArgs
+ ...lineStatsReuseArgs,
+ ...branchLineTotalArgs
},
{
timeoutMs: 15_000,
diff --git a/src/renderer/src/runtime/runtime-server-directory-browser.test.ts b/src/renderer/src/runtime/runtime-server-directory-browser.test.ts
index 07740c37726..6c7579bc7a2 100644
--- a/src/renderer/src/runtime/runtime-server-directory-browser.test.ts
+++ b/src/renderer/src/runtime/runtime-server-directory-browser.test.ts
@@ -29,7 +29,12 @@ beforeEach(() => {
result: {
resolvedPath: '/home/me',
pathFlavor: 'posix',
- entries: [{ name: 'repo', isDirectory: true, isSymlink: false }]
+ entries: [
+ { name: '100 - file.txt', isDirectory: false, isSymlink: false },
+ { name: '9 - file.txt', isDirectory: false, isSymlink: false },
+ { name: '10 - repo', isDirectory: true, isSymlink: false },
+ { name: '99 - file.txt', isDirectory: false, isSymlink: false }
+ ]
},
_meta: { runtimeId: 'remote-runtime' }
})
@@ -48,7 +53,12 @@ describe('runtime server directory browser', () => {
await expect(browseRuntimeServerDirectory('env-1', '~')).resolves.toEqual({
resolvedPath: '/home/me',
pathFlavor: 'posix',
- entries: [{ name: 'repo', isDirectory: true, isSymlink: false }]
+ entries: [
+ { name: '10 - repo', isDirectory: true, isSymlink: false },
+ { name: '9 - file.txt', isDirectory: false, isSymlink: false },
+ { name: '99 - file.txt', isDirectory: false, isSymlink: false },
+ { name: '100 - file.txt', isDirectory: false, isSymlink: false }
+ ]
})
expect(runtimeEnvironmentCall).toHaveBeenLastCalledWith({
diff --git a/src/renderer/src/runtime/runtime-server-directory-browser.ts b/src/renderer/src/runtime/runtime-server-directory-browser.ts
index 5d7c0a13ade..b28e7755f16 100644
--- a/src/renderer/src/runtime/runtime-server-directory-browser.ts
+++ b/src/renderer/src/runtime/runtime-server-directory-browser.ts
@@ -1,4 +1,5 @@
import type { DirEntry, FilesystemPathFlavor } from '../../../shared/types'
+import { sortDirEntries } from '../../../shared/file-name-sort'
import { callRuntimeRpc } from './runtime-rpc-client'
export type RuntimeServerDirectoryListing = {
@@ -11,10 +12,11 @@ export async function browseRuntimeServerDirectory(
environmentId: string,
path: string
): Promise {
- return callRuntimeRpc(
+ const listing = await callRuntimeRpc(
{ kind: 'environment', environmentId },
'files.browseServerDir',
{ path },
{ timeoutMs: 15_000 }
)
+ return { ...listing, entries: sortDirEntries(listing.entries) }
}
diff --git a/src/renderer/src/store/slices/degraded-repo-hydration.test.ts b/src/renderer/src/store/slices/degraded-repo-hydration.test.ts
index 93902bb9d49..48690c43f51 100644
--- a/src/renderer/src/store/slices/degraded-repo-hydration.test.ts
+++ b/src/renderer/src/store/slices/degraded-repo-hydration.test.ts
@@ -16,7 +16,7 @@ import {
buildWorkspaceSessionPayload,
shouldPersistWorkspaceSession
} from '@/lib/workspace-session'
-import { createTestStore, makeTab } from './store-test-helpers'
+import { createTestStore, makeTab, makeWorktree } from './store-test-helpers'
const WORKTREE_ID = 'repo1::/path/degraded'
const TERMINAL_ID = 'terminal-degraded'
@@ -199,6 +199,39 @@ it('keeps a terminal-free degraded workspace selected through hydration and pers
expect(restored.activeTabType).toBe('browser')
})
+it('keeps chrome when a non-authoritative fallback lists only some of the repo worktrees', () => {
+ const store = createTestStore()
+ // Why: an SSH metadata fallback can be non-empty yet partial (host-less metas are skipped on multi-owner
+ // repos, agent-scratch stays hidden). A partial list must not read as proof the missing worktree was deleted.
+ const sibling = makeWorktree({
+ id: 'repo1::/path/sibling',
+ repoId: 'repo1',
+ path: '/path/sibling'
+ })
+ store.setState({
+ repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }],
+ worktreesByRepo: { repo1: [sibling] },
+ detectedWorktreesByRepo: {
+ repo1: {
+ repoId: 'repo1',
+ authoritative: false,
+ source: 'metadata-fallback',
+ worktrees: []
+ }
+ }
+ })
+ const session = makeDegradedRepoSession()
+ store.getState().hydrateWorkspaceSession(session)
+ store.getState().hydrateTabsSession(session)
+ store.getState().hydrateEditorSession(session)
+ store.getState().hydrateBrowserSession(session)
+
+ const state = store.getState()
+ expect(state.tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([TERMINAL_ID])
+ expect(state.openFiles.map((file) => file.id)).toEqual([EDITOR_FILE_ID])
+ expect(state.browserTabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([BROWSER_ID])
+})
+
it('drops terminal-free chrome when an authoritative scan proves deletion', () => {
const store = createTestStore()
hydrateWithRepoScan(store, makeTerminalFreeDegradedRepoSession(), true)
diff --git a/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts b/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts
index 0fb4e42fd19..1a9693bdf5f 100644
--- a/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts
+++ b/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts
@@ -45,13 +45,21 @@ export function buildValidWorktreeIdsForSessionHydration(
.map((worktree) => worktree.id)
)
const knownRepoIds = new Set(catalog.repos.map((repo) => repo.id))
+ const detectedWorktreesByRepo = catalog.detectedWorktreesByRepo ?? {}
const repoIdsWithLoadedWorktrees = new Set(
Object.entries(worktreesByRepo)
- .filter(([, worktrees]) => worktrees.length > 0)
+ // Why (#1158): a metadata fallback can be non-empty yet partial (host-less metas are skipped on
+ // multi-owner repos, agent-scratch stays hidden), so it cannot prove deletion the way a real listing can.
+ // Only an explicitly non-authoritative result is disqualified; a repo with no detection entry at all
+ // still counts as loaded, as before.
+ .filter(
+ ([repoId, worktrees]) =>
+ worktrees.length > 0 && detectedWorktreesByRepo[repoId]?.authoritative !== false
+ )
.map(([repoId]) => repoId)
)
const repoIdsWithAuthoritativeDetectedWorktrees = new Set(
- Object.entries(catalog.detectedWorktreesByRepo ?? {})
+ Object.entries(detectedWorktreesByRepo)
.filter(([, detected]) => detected?.authoritative)
.map(([repoId]) => repoId)
)
diff --git a/src/renderer/src/store/slices/editor-branch-line-total.test.ts b/src/renderer/src/store/slices/editor-branch-line-total.test.ts
new file mode 100644
index 00000000000..c4f88540951
--- /dev/null
+++ b/src/renderer/src/store/slices/editor-branch-line-total.test.ts
@@ -0,0 +1,225 @@
+import { createStore, type StoreApi } from 'zustand/vanilla'
+import { describe, expect, it, vi } from 'vitest'
+import { createEditorSlice } from './editor'
+import type { AppState } from '../types'
+import type { GitStatusResult } from '../../../../shared/types'
+
+const MERGE_BASE = '1f3c0d9a5b6e7f8091a2b3c4d5e6f708192a3b4c'
+
+function createEditorStore(): StoreApi {
+ // Only the editor slice + activeWorktreeId are needed for these tests.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return createStore()((...args: any[]) => ({
+ activeWorktreeId: 'wt-1',
+ tabsByWorktree: {},
+ browserTabsByWorktree: {},
+ activeBrowserTabId: null,
+ activeBrowserTabIdByWorktree: {},
+ repos: [{ id: 'repo-1', path: '/repo' }],
+ worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] },
+ folderWorkspaces: [],
+ projectGroups: [],
+ recordFeatureInteraction: vi.fn(),
+ ...createEditorSlice(...(args as Parameters))
+ })) as unknown as StoreApi
+}
+
+function status(overrides: Partial = {}): GitStatusResult {
+ return {
+ conflictOperation: 'unknown',
+ entries: [{ path: 'src/index.ts', status: 'modified', area: 'unstaged' }],
+ head: 'head-1',
+ ignoredPaths: [],
+ ...overrides
+ }
+}
+
+describe('createEditorSlice branch line total', () => {
+ it('stores a total published with the status result', () => {
+ const store = createEditorStore()
+
+ store
+ .getState()
+ .setGitStatus(
+ 'wt-1',
+ status({ branchLineTotal: { added: 8259, removed: 670, mergeBase: MERGE_BASE } })
+ )
+
+ expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toEqual({
+ added: 8259,
+ removed: 670,
+ mergeBase: MERGE_BASE
+ })
+ })
+
+ it('stores an exact zero total rather than treating it as absent', () => {
+ const store = createEditorStore()
+
+ store
+ .getState()
+ .setGitStatus(
+ 'wt-1',
+ status({ branchLineTotal: { added: 0, removed: 0, mergeBase: MERGE_BASE } })
+ )
+
+ expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toEqual({
+ added: 0,
+ removed: 0,
+ mergeBase: MERGE_BASE
+ })
+ })
+
+ it('clears a stored total when a later status omits the field', () => {
+ const store = createEditorStore()
+ store
+ .getState()
+ .setGitStatus(
+ 'wt-1',
+ status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } })
+ )
+
+ // Why: an omitted field means "not known exact" — an old server, a failed
+ // numstat, or a timeout. Keeping the old number renders a confident lie.
+ store.getState().setGitStatus('wt-1', status())
+
+ expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toBeUndefined()
+ })
+
+ it('clears a stored total when the listing hits the entry cap', () => {
+ const store = createEditorStore()
+ store
+ .getState()
+ .setGitStatus(
+ 'wt-1',
+ status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } })
+ )
+
+ store.getState().setGitStatus(
+ 'wt-1',
+ status({
+ entries: [{ path: 'generated/a.ts', status: 'untracked', area: 'untracked' }],
+ didHitLimit: true,
+ statusLength: 2
+ })
+ )
+
+ expect(store.getState().gitStatusHugeByWorktree['wt-1']).toEqual({ limit: 1 })
+ expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toBeUndefined()
+ })
+
+ it('never substitutes zero or NaN for a total an old server never sent', () => {
+ const store = createEditorStore()
+
+ store.getState().setGitStatus('wt-1', status())
+
+ const stored = store.getState().gitBranchLineTotalByWorktree['wt-1']
+ expect(stored).toBeUndefined()
+ expect(stored ?? null).toBeNull()
+ })
+
+ it('keeps totals independent per worktree', () => {
+ const store = createEditorStore()
+ store
+ .getState()
+ .setGitStatus(
+ 'wt-1',
+ status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } })
+ )
+ store
+ .getState()
+ .setGitStatus(
+ 'wt-2',
+ status({ branchLineTotal: { added: 1, removed: 1, mergeBase: 'other' } })
+ )
+
+ store.getState().setGitStatus('wt-1', status())
+
+ expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toBeUndefined()
+ expect(store.getState().gitBranchLineTotalByWorktree['wt-2']).toEqual({
+ added: 1,
+ removed: 1,
+ mergeBase: 'other'
+ })
+ })
+
+ it('replaces the total when the fork point moves', () => {
+ const store = createEditorStore()
+ store
+ .getState()
+ .setGitStatus(
+ 'wt-1',
+ status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } })
+ )
+
+ store
+ .getState()
+ .setGitStatus(
+ 'wt-1',
+ status({ branchLineTotal: { added: 24, removed: 3, mergeBase: 'rebased' } })
+ )
+
+ expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toEqual({
+ added: 24,
+ removed: 3,
+ mergeBase: 'rebased'
+ })
+ })
+
+ it('does not produce a new state object for an unchanged status tick', () => {
+ const store = createEditorStore()
+ const tick = status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } })
+ store.getState().setGitStatus('wt-1', tick)
+ const before = store.getState()
+ const listener = vi.fn()
+ const unsubscribe = store.subscribe(listener)
+
+ store.getState().setGitStatus('wt-1', {
+ ...tick,
+ entries: [...tick.entries],
+ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE }
+ })
+ unsubscribe()
+
+ expect(store.getState()).toBe(before)
+ expect(listener).not.toHaveBeenCalled()
+ })
+
+ it('produces a new state object when only the total changed', () => {
+ const store = createEditorStore()
+ const tick = status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } })
+ store.getState().setGitStatus('wt-1', tick)
+ const before = store.getState()
+
+ store.getState().setGitStatus('wt-1', {
+ ...tick,
+ branchLineTotal: { added: 25, removed: 3, mergeBase: MERGE_BASE }
+ })
+
+ expect(store.getState()).not.toBe(before)
+ expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toEqual({
+ added: 25,
+ removed: 3,
+ mergeBase: MERGE_BASE
+ })
+ })
+
+ it('leaves other worktree entries referentially stable when one total changes', () => {
+ const store = createEditorStore()
+ store
+ .getState()
+ .setGitStatus(
+ 'wt-2',
+ status({ branchLineTotal: { added: 1, removed: 1, mergeBase: 'other' } })
+ )
+ const otherTotal = store.getState().gitBranchLineTotalByWorktree['wt-2']
+
+ store
+ .getState()
+ .setGitStatus(
+ 'wt-1',
+ status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } })
+ )
+
+ expect(store.getState().gitBranchLineTotalByWorktree['wt-2']).toBe(otherTotal)
+ })
+})
diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts
index bf17d61a3ea..e3123db9cd1 100644
--- a/src/renderer/src/store/slices/editor.ts
+++ b/src/renderer/src/store/slices/editor.ts
@@ -45,6 +45,7 @@ import type {
WorkspaceSessionState,
WorkspaceVisibleTabType
} from '../../../../shared/types'
+import type { GitBranchLineTotal } from '../../../../shared/git-status-types'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { clampMarkdownTocPanelWidth } from '../../../../shared/markdown-toc-panel-width'
import {
@@ -629,6 +630,8 @@ export type EditorSlice = {
gitStatusHeadByWorktree: Record
// Why: set when status hit the entry limit; SCM shows "too many changes" and pauses polling. `{ limit }` when huge, else absent.
gitStatusHugeByWorktree: Record
+ // Why: absent means "not known exact" (stale fork point, old server, capped listing); never fall back to a previous total.
+ gitBranchLineTotalByWorktree: Record
gitIgnoredPathsByWorktree: Record
gitConflictOperationByWorktree: Record
trackedConflictPathsByWorktree: Record>
@@ -4086,6 +4089,7 @@ export const createEditorSlice: StateCreator = (s
gitStatusByWorktree: {},
gitStatusHeadByWorktree: {},
gitStatusHugeByWorktree: {},
+ gitBranchLineTotalByWorktree: {},
gitIgnoredPathsByWorktree: {},
gitConflictOperationByWorktree: {},
trackedConflictPathsByWorktree: {},
@@ -4187,6 +4191,18 @@ export const createEditorSlice: StateCreator = (s
const prevHuge = s.gitStatusHugeByWorktree[worktreeId]
const nextHuge = status.didHitLimit ? { limit: nextEntries.length } : undefined
const hugeUnchanged = (prevHuge?.limit ?? null) === (nextHuge?.limit ?? null)
+
+ const prevBranchLineTotal = s.gitBranchLineTotalByWorktree[worktreeId] ?? null
+ // Why: an omitted field means "not known exact"; keeping the old total would render a confidently wrong chip.
+ const nextBranchLineTotal = status.branchLineTotal ?? null
+ const branchLineTotalUnchanged =
+ prevBranchLineTotal === nextBranchLineTotal ||
+ (prevBranchLineTotal !== null &&
+ nextBranchLineTotal !== null &&
+ prevBranchLineTotal.added === nextBranchLineTotal.added &&
+ prevBranchLineTotal.removed === nextBranchLineTotal.removed &&
+ prevBranchLineTotal.mergeBase === nextBranchLineTotal.mergeBase)
+
const prevStatusHead = s.gitStatusHeadByWorktree[worktreeId]
const nextStatusHead = getKnownGitHead(status.head)
const statusHeadUnchanged = prevStatusHead === nextStatusHead
@@ -4206,12 +4222,23 @@ export const createEditorSlice: StateCreator = (s
operationUnchanged &&
ignoredUnchanged &&
hugeUnchanged &&
+ branchLineTotalUnchanged &&
statusHeadUnchanged &&
!shouldInvalidateBranchCompare
) {
return s
}
+ const nextBranchLineTotalMap = branchLineTotalUnchanged
+ ? s.gitBranchLineTotalByWorktree
+ : nextBranchLineTotal
+ ? { ...s.gitBranchLineTotalByWorktree, [worktreeId]: nextBranchLineTotal }
+ : (() => {
+ const copy = { ...s.gitBranchLineTotalByWorktree }
+ delete copy[worktreeId]
+ return copy
+ })()
+
const nextHugeMap = hugeUnchanged
? s.gitStatusHugeByWorktree
: nextHuge
@@ -4244,6 +4271,7 @@ export const createEditorSlice: StateCreator = (s
return {
openFiles: nextOpenFiles,
gitStatusHugeByWorktree: nextHugeMap,
+ gitBranchLineTotalByWorktree: nextBranchLineTotalMap,
gitStatusHeadByWorktree: nextStatusHeadMap,
gitStatusByWorktree: statusUnchanged
? s.gitStatusByWorktree
diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts
index 1d2b822dffd..72d099a2731 100644
--- a/src/renderer/src/store/slices/worktrees.test.ts
+++ b/src/renderer/src/store/slices/worktrees.test.ts
@@ -21,6 +21,8 @@ import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rp
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import type {
HostQualifiedDetectedWorktreeResult,
+ HostQualifiedKnownWorktreeResult,
+ ListKnownWorktreesForExecutionHostArgs,
ListDetectedWorktreesArgs
} from '../../../../shared/detected-worktree-provider-contract'
import type { DirectSshAuthority, SshProviderEpoch } from '../../../../shared/ssh-types'
@@ -105,12 +107,17 @@ const listDetectedMock = vi.fn<
return qualifyDetectedResult(args, result)
})
+const listKnownForExecutionHostMock = vi.fn<
+ (args: ListKnownWorktreesForExecutionHostArgs) => Promise
+>(async (args) => ({ status: 'rejected', ...args }))
+
const mockApi = {
worktrees: {
create: vi.fn(),
prefetchCreateBase: vi.fn().mockResolvedValue(undefined),
list: worktreeListMock,
listDetected: listDetectedMock,
+ listKnownForExecutionHost: listKnownForExecutionHostMock,
cancelListDetected: vi.fn().mockResolvedValue(undefined),
listLineage: vi.fn().mockResolvedValue({}),
remove: vi.fn().mockResolvedValue(undefined),
@@ -153,6 +160,7 @@ import {
createWorktreeSlice,
getHostedReviewLinkMutationGenerationForTests,
getHostedReviewLinkWorktreeAliasCountForTests,
+ resetAuthoritativelyRemovedWorktreeMemoryForTests,
resetHostedReviewLinkMutationGenerationForTests
} from './worktrees'
import type { PendingWorktreeCreation } from '@/lib/pending-worktree-creation'
@@ -439,6 +447,7 @@ describe('fetchWorktrees', () => {
vi.clearAllMocks()
resetRemoteRuntimeMocks()
clearHugeRepoWarningDismissalsForTests()
+ resetAuthoritativelyRemovedWorktreeMemoryForTests()
})
it('does not notify subscribers when the fetched payload is unchanged', async () => {
@@ -1350,6 +1359,365 @@ describe('fetchWorktrees', () => {
expect(store.getState().detectedWorktreesByRepo).toBe(detectedWorktreesByRepo)
})
+ it('shows persisted secondary worktrees while SSH is connecting', async () => {
+ const store = createTestStore()
+ const sshRepo = {
+ id: 'repo-ssh',
+ path: '/home/orca/repo',
+ displayName: 'SSH Repo',
+ badgeColor: '#000',
+ addedAt: 0,
+ connectionId: 'ssh-1'
+ }
+ const queued = makeWorktree({
+ id: 'repo-ssh::/home/orca/queued',
+ repoId: 'repo-ssh',
+ path: '/home/orca/queued',
+ displayName: 'queued'
+ })
+ const detected = makeDetectedResult('repo-ssh', [queued], {
+ authoritative: false,
+ source: 'metadata-fallback'
+ })
+ listKnownForExecutionHostMock.mockResolvedValueOnce({
+ status: 'complete',
+ repoId: sshRepo.id,
+ executionHostId: 'ssh:ssh-1',
+ result: detected
+ })
+ store.setState({
+ repos: [sshRepo],
+ sshConnectionStates: new Map([
+ [
+ 'ssh-1',
+ {
+ targetId: 'ssh-1',
+ status: 'connecting',
+ error: null,
+ reconnectAttempt: 0,
+ providerEpoch: null
+ }
+ ]
+ ])
+ } as Partial)
+
+ await expect(store.getState().fetchWorktrees(sshRepo.id)).resolves.toBe(false)
+
+ expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([
+ { ...queued, hostId: 'ssh:ssh-1' }
+ ])
+ expect(listKnownForExecutionHostMock).toHaveBeenCalledWith({
+ repoId: sshRepo.id,
+ executionHostId: 'ssh:ssh-1'
+ })
+ expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled()
+ })
+
+ it('adds metadata rows without replacing richer cached SSH worktrees', async () => {
+ const store = createTestStore()
+ const sshRepo = {
+ id: 'repo-ssh',
+ path: '/home/orca/repo',
+ displayName: 'SSH Repo',
+ badgeColor: '#000',
+ addedAt: 0,
+ connectionId: 'ssh-1'
+ }
+ const existing = makeWorktree({
+ id: 'repo-ssh::/home/orca/existing',
+ repoId: 'repo-ssh',
+ path: '/home/orca/existing',
+ hostId: 'ssh:ssh-1',
+ head: 'live-head',
+ branch: 'refs/heads/live-branch'
+ })
+ const metadataExisting = { ...existing, head: '', branch: '' }
+ const queued = makeWorktree({
+ id: 'repo-ssh::/home/orca/queued',
+ repoId: 'repo-ssh',
+ path: '/home/orca/queued'
+ })
+ listKnownForExecutionHostMock.mockResolvedValueOnce({
+ status: 'complete',
+ repoId: sshRepo.id,
+ executionHostId: 'ssh:ssh-1',
+ result: makeDetectedResult('repo-ssh', [metadataExisting, queued], {
+ authoritative: false,
+ source: 'metadata-fallback'
+ })
+ })
+ store.setState({
+ repos: [sshRepo],
+ sshConnectionStates: new Map(),
+ worktreesByRepo: { [sshRepo.id]: [existing] }
+ } as Partial)
+
+ await store.getState().fetchWorktrees(sshRepo.id)
+
+ expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([
+ existing,
+ { ...queued, hostId: 'ssh:ssh-1' }
+ ])
+ })
+
+ it('inserts metadata rows inside the SSH block instead of past sibling hosts', async () => {
+ const store = createTestStore()
+ const sshRepo = {
+ id: 'repo-shared',
+ path: '/home/orca/repo',
+ displayName: 'SSH Repo',
+ badgeColor: '#000',
+ addedAt: 0,
+ connectionId: 'ssh-1'
+ }
+ const localRepo = { ...sshRepo, path: '/local/repo', connectionId: undefined }
+ const sshExisting = makeWorktree({
+ id: 'repo-shared::/home/orca/existing',
+ repoId: sshRepo.id,
+ path: '/home/orca/existing',
+ hostId: 'ssh:ssh-1'
+ })
+ const localExisting = makeWorktree({
+ id: 'repo-shared::/local/existing',
+ repoId: sshRepo.id,
+ path: '/local/existing',
+ hostId: LOCAL_EXECUTION_HOST_ID
+ })
+ const queued = makeWorktree({
+ id: 'repo-shared::/home/orca/queued',
+ repoId: sshRepo.id,
+ path: '/home/orca/queued'
+ })
+ listKnownForExecutionHostMock.mockResolvedValueOnce({
+ status: 'complete',
+ repoId: sshRepo.id,
+ executionHostId: 'ssh:ssh-1',
+ result: makeDetectedResult(sshRepo.id, [queued], {
+ authoritative: false,
+ source: 'metadata-fallback'
+ })
+ })
+ store.setState({
+ repos: [sshRepo, localRepo],
+ sshConnectionStates: new Map(),
+ worktreesByRepo: { [sshRepo.id]: [sshExisting, localExisting] }
+ } as Partial)
+
+ await store.getState().fetchWorktrees(sshRepo.id, { executionHostId: 'ssh:ssh-1' })
+
+ expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([
+ sshExisting,
+ { ...queued, hostId: 'ssh:ssh-1' },
+ localExisting
+ ])
+ })
+
+ it('drops metadata rows when SSH authority lands during the read', async () => {
+ const store = createTestStore()
+ const sshRepo = {
+ id: 'repo-ssh',
+ path: '/home/orca/repo',
+ displayName: 'SSH Repo',
+ badgeColor: '#000',
+ addedAt: 0,
+ connectionId: 'ssh-1'
+ }
+ const live = makeWorktree({
+ id: 'repo-ssh::/home/orca/live',
+ repoId: 'repo-ssh',
+ path: '/home/orca/live',
+ hostId: 'ssh:ssh-1'
+ })
+ // Why: deleted on the host, so an authoritative scan already purged it; the late metadata write must not resurrect it.
+ const purged = makeWorktree({
+ id: 'repo-ssh::/home/orca/purged',
+ repoId: 'repo-ssh',
+ path: '/home/orca/purged'
+ })
+ listKnownForExecutionHostMock.mockImplementationOnce(async (args) => {
+ store.setState({
+ sshConnectionStates: new Map([
+ [
+ TEST_SSH_AUTHORITY.targetId,
+ {
+ targetId: TEST_SSH_AUTHORITY.targetId,
+ status: 'connected',
+ error: null,
+ reconnectAttempt: 0,
+ providerEpoch: TEST_SSH_AUTHORITY.providerEpoch,
+ connectionGeneration: TEST_SSH_AUTHORITY.connectionGeneration
+ }
+ ]
+ ]),
+ worktreesByRepo: { [sshRepo.id]: [live] }
+ } as Partial)
+ return {
+ status: 'complete',
+ repoId: args.repoId,
+ executionHostId: args.executionHostId,
+ result: makeDetectedResult(args.repoId, [live, purged], {
+ authoritative: false,
+ source: 'metadata-fallback'
+ })
+ }
+ })
+ store.setState({
+ repos: [sshRepo],
+ sshConnectionStates: new Map()
+ } as Partial)
+
+ await store.getState().fetchWorktrees(sshRepo.id)
+
+ expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live])
+ })
+
+ it('replaces metadata rows once the authoritative SSH scan lands', async () => {
+ const store = createTestStore()
+ const sshRepo = {
+ id: 'repo-ssh',
+ path: '/home/orca/repo',
+ displayName: 'SSH Repo',
+ badgeColor: '#000',
+ addedAt: 0,
+ connectionId: 'ssh-1'
+ }
+ const live = makeWorktree({
+ id: 'repo-ssh::/home/orca/live',
+ repoId: 'repo-ssh',
+ path: '/home/orca/live',
+ hostId: 'ssh:ssh-1'
+ })
+ const stale = makeWorktree({
+ id: 'repo-ssh::/home/orca/stale',
+ repoId: 'repo-ssh',
+ path: '/home/orca/stale'
+ })
+ listKnownForExecutionHostMock.mockResolvedValueOnce({
+ status: 'complete',
+ repoId: sshRepo.id,
+ executionHostId: 'ssh:ssh-1',
+ result: makeDetectedResult(sshRepo.id, [stale], {
+ authoritative: false,
+ source: 'metadata-fallback'
+ })
+ })
+ const connectedStates = createTestStore().getState().sshConnectionStates
+ store.setState({ repos: [sshRepo], sshConnectionStates: new Map() } as Partial)
+
+ await store.getState().fetchWorktrees(sshRepo.id)
+ expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([
+ { ...stale, hostId: 'ssh:ssh-1' }
+ ])
+
+ worktreeListMock.mockResolvedValueOnce([live])
+ store.setState({ sshConnectionStates: connectedStates } as Partial)
+ await store.getState().fetchWorktrees(sshRepo.id)
+
+ expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live])
+ })
+
+ it('keeps the repo detection entry authoritative while appending metadata rows', async () => {
+ const store = createTestStore()
+ const sshRepo = {
+ id: 'repo-shared',
+ path: '/home/orca/repo',
+ displayName: 'SSH Repo',
+ badgeColor: '#000',
+ addedAt: 0,
+ connectionId: 'ssh-1'
+ }
+ const localRepo = { ...sshRepo, path: '/local/repo', connectionId: undefined }
+ const scanned = makeWorktree({
+ id: 'repo-shared::/local/scanned',
+ repoId: sshRepo.id,
+ path: '/local/scanned'
+ })
+ const fromMetadata = makeWorktree({
+ id: 'repo-shared::/home/orca/queued',
+ repoId: sshRepo.id,
+ path: '/home/orca/queued'
+ })
+ const authoritative = makeDetectedResult(sshRepo.id, [scanned])
+ listKnownForExecutionHostMock.mockResolvedValueOnce({
+ status: 'complete',
+ repoId: sshRepo.id,
+ executionHostId: 'ssh:ssh-1',
+ result: makeDetectedResult(sshRepo.id, [fromMetadata], {
+ authoritative: false,
+ source: 'metadata-fallback'
+ })
+ })
+ store.setState({
+ repos: [sshRepo, localRepo],
+ sshConnectionStates: new Map(),
+ detectedWorktreesByRepo: { [sshRepo.id]: authoritative }
+ } as Partial)
+
+ await store.getState().fetchWorktrees(sshRepo.id, { executionHostId: 'ssh:ssh-1' })
+
+ const detected = store.getState().detectedWorktreesByRepo[sshRepo.id]
+ // Why: this entry is shared with the co-owning local host, so the fallback must not demote its scan.
+ expect(detected?.authoritative).toBe(true)
+ expect(detected?.source).toBe('git')
+ expect(detected?.worktrees.map((worktree) => worktree.id)).toEqual([
+ scanned.id,
+ fromMetadata.id
+ ])
+ })
+
+ it('does not resurrect worktrees an authoritative SSH scan already removed', async () => {
+ const store = createTestStore()
+ const sshRepo = {
+ id: 'repo-ssh',
+ path: '/home/orca/repo',
+ displayName: 'SSH Repo',
+ badgeColor: '#000',
+ addedAt: 0,
+ connectionId: 'ssh-1'
+ }
+ const live = makeWorktree({
+ id: 'repo-ssh::/home/orca/live',
+ repoId: 'repo-ssh',
+ path: '/home/orca/live',
+ hostId: 'ssh:ssh-1'
+ })
+ const deletedOnRemote = makeWorktree({
+ id: 'repo-ssh::/home/orca/deleted',
+ repoId: 'repo-ssh',
+ path: '/home/orca/deleted'
+ })
+ const metadataResult = () => ({
+ status: 'complete' as const,
+ repoId: sshRepo.id,
+ executionHostId: 'ssh:ssh-1' as const,
+ result: makeDetectedResult(sshRepo.id, [deletedOnRemote], {
+ authoritative: false,
+ source: 'metadata-fallback'
+ })
+ })
+ const connectedStates = createTestStore().getState().sshConnectionStates
+ listKnownForExecutionHostMock.mockResolvedValueOnce(metadataResult())
+ store.setState({ repos: [sshRepo], sshConnectionStates: new Map() } as Partial)
+
+ await store.getState().fetchWorktrees(sshRepo.id)
+ expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([
+ { ...deletedOnRemote, hostId: 'ssh:ssh-1' }
+ ])
+
+ // The host connects and an authoritative scan proves the worktree is gone.
+ worktreeListMock.mockResolvedValueOnce([live])
+ store.setState({ sshConnectionStates: connectedStates } as Partial)
+ await store.getState().fetchWorktrees(sshRepo.id)
+ expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live])
+
+ // The host drops again; persisted metadata still lists the deleted worktree.
+ listKnownForExecutionHostMock.mockResolvedValueOnce(metadataResult())
+ store.setState({ sshConnectionStates: new Map() } as Partial)
+ await store.getState().fetchWorktrees(sshRepo.id)
+
+ expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live])
+ })
+
it('keeps worktree maps byte-identical for stale and malformed direct results', async () => {
const store = createTestStore()
const existing = makeWorktree({
@@ -5023,6 +5391,10 @@ describe('removeWorktree state cleanup', () => {
'repo1::/path/wt1': 'head-1',
'repo1::/path/wt2': 'head-2'
},
+ gitBranchLineTotalByWorktree: {
+ 'repo1::/path/wt1': { added: 24, removed: 3, mergeBase: 'base-1' },
+ 'repo1::/path/wt2': { added: 1, removed: 0, mergeBase: 'base-2' }
+ },
gitIgnoredPathsByWorktree: {
'repo1::/path/wt1': ['dist/'],
'repo1::/path/wt2': ['coverage/']
@@ -5061,6 +5433,9 @@ describe('removeWorktree state cleanup', () => {
expect(store.getState().gitStatusHeadByWorktree).toEqual({
'repo1::/path/wt2': 'head-2'
})
+ expect(store.getState().gitBranchLineTotalByWorktree).toEqual({
+ 'repo1::/path/wt2': { added: 1, removed: 0, mergeBase: 'base-2' }
+ })
expect(store.getState().gitIgnoredPathsByWorktree).toEqual({
'repo1::/path/wt2': ['coverage/']
})
@@ -7536,6 +7911,63 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => {
addedAt: 0
}
+ it.each([false, true])(
+ 'hydrates connecting SSH worktrees with hydration purge completed=%s',
+ async (hasHydratedWorktreePurge) => {
+ const store = createTestStore()
+ const sshRepo = {
+ id: 'repo-ssh',
+ path: '/home/orca/repo',
+ displayName: 'SSH Repo',
+ badgeColor: '#000',
+ addedAt: 0,
+ connectionId: 'ssh-1'
+ }
+ const queued = makeWorktree({
+ id: 'repo-ssh::/home/orca/queued',
+ repoId: 'repo-ssh',
+ path: '/home/orca/queued',
+ displayName: 'queued'
+ })
+ listKnownForExecutionHostMock.mockResolvedValueOnce({
+ status: 'complete',
+ repoId: sshRepo.id,
+ executionHostId: 'ssh:ssh-1',
+ result: makeDetectedResult(sshRepo.id, [queued], {
+ authoritative: false,
+ source: 'metadata-fallback'
+ })
+ })
+ store.setState({
+ repos: [sshRepo],
+ hasHydratedWorktreePurge,
+ sshConnectionStates: new Map([
+ [
+ 'ssh-1',
+ {
+ targetId: 'ssh-1',
+ status: 'connecting',
+ error: null,
+ reconnectAttempt: 0,
+ providerEpoch: null
+ }
+ ]
+ ])
+ } as Partial)
+
+ await store.getState().fetchAllWorktrees()
+
+ expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([
+ { ...queued, hostId: 'ssh:ssh-1' }
+ ])
+ expect(listKnownForExecutionHostMock).toHaveBeenCalledWith({
+ repoId: sshRepo.id,
+ executionHostId: 'ssh:ssh-1'
+ })
+ expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled()
+ }
+ )
+
it('preserves resolved inline legacy lineage when side-map hydration is absent', async () => {
const store = createTestStore()
const parent = makeWorktree({
@@ -8257,6 +8689,10 @@ describe('purgeWorktreeTerminalState direct (design §4.4)', () => {
'repoA::/a/wt1': 'head-1',
'repoA::/a/wt2': 'head-2'
},
+ gitBranchLineTotalByWorktree: {
+ 'repoA::/a/wt1': { added: 24, removed: 3, mergeBase: 'base-1' },
+ 'repoA::/a/wt2': { added: 1, removed: 0, mergeBase: 'base-2' }
+ },
gitBranchCompareRequestStatusHeadByWorktree: {
'repoA::/a/wt1': 'head-1',
'repoA::/a/wt2': 'head-2'
@@ -8300,6 +8736,9 @@ describe('purgeWorktreeTerminalState direct (design §4.4)', () => {
expect(s.editorDrafts).toEqual({ 'file-99': 'other' })
expect(s.markdownFrontmatterVisible).toEqual({ 'file-99': true })
expect(s.gitStatusHeadByWorktree).toEqual({ 'repoA::/a/wt2': 'head-2' })
+ expect(s.gitBranchLineTotalByWorktree).toEqual({
+ 'repoA::/a/wt2': { added: 1, removed: 0, mergeBase: 'base-2' }
+ })
expect(s.gitBranchCompareRequestStatusHeadByWorktree).toEqual({
'repoA::/a/wt2': 'head-2'
})
@@ -8808,6 +9247,7 @@ describe('migrateWorktreeIdentity', () => {
groupsByWorktree: { [OLD]: [{ id: 'group1', worktreeId: OLD }] },
gitStatusByWorktree: { [OLD]: [{ path: 'a.ts' }] },
gitStatusHeadByWorktree: { [OLD]: 'head-old' },
+ gitBranchLineTotalByWorktree: { [OLD]: { added: 24, removed: 3, mergeBase: 'base-old' } },
gitBranchCompareRequestStatusHeadByWorktree: { [OLD]: 'head-old' },
lastVisitedAtByWorktreeId: { [OLD]: 123 },
defaultTerminalTabsAppliedByWorktreeId: { [OLD]: true },
@@ -8855,6 +9295,12 @@ describe('migrateWorktreeIdentity', () => {
expect(s.groupsByWorktree[NEW]?.[0]?.worktreeId).toBe(NEW)
expect(s.gitStatusByWorktree[NEW]).toEqual([{ path: 'a.ts' }])
expect(s.gitStatusHeadByWorktree[NEW]).toBe('head-old')
+ expect(s.gitBranchLineTotalByWorktree[OLD]).toBeUndefined()
+ expect(s.gitBranchLineTotalByWorktree[NEW]).toEqual({
+ added: 24,
+ removed: 3,
+ mergeBase: 'base-old'
+ })
expect(s.gitBranchCompareRequestStatusHeadByWorktree[NEW]).toBe('head-old')
expect(s.rightSidebarExplorerViewByWorktree[OLD]).toBeUndefined()
expect(s.rightSidebarExplorerViewByWorktree[NEW]).toBe('search')
diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts
index 6cd073abbe1..3859cec22fc 100644
--- a/src/renderer/src/store/slices/worktrees.ts
+++ b/src/renderer/src/store/slices/worktrees.ts
@@ -121,6 +121,7 @@ import {
import { getTerminalActivationSpawnSuppression } from './terminal-activation-spawn-suppression'
import type {
HostQualifiedDetectedWorktreeResult,
+ HostQualifiedKnownWorktreeResult,
ListDetectedWorktreesArgs,
ProviderRequestId,
SshExecutionHostId
@@ -2228,6 +2229,7 @@ const WORKTREE_ID_KEYED_MAP_KEYS = [
'activeGroupIdByWorktree',
'gitStatusByWorktree',
'gitStatusHeadByWorktree',
+ 'gitBranchLineTotalByWorktree',
'gitIgnoredPathsByWorktree',
'gitConflictOperationByWorktree',
'trackedConflictPathsByWorktree',
@@ -2695,6 +2697,7 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial {
if (
!isCurrentDetectedWorktreeRefresh(s, args.refresh) ||
@@ -2948,6 +2953,10 @@ function mergeFetchedWorktrees(
args.refresh.result,
args.hostId
)
+ authoritativelyRemovedIds = removedIds
+ if (args.refresh.result.authoritative) {
+ authoritativelySeenIds = args.refresh.result.worktrees.map((worktree) => worktree.id)
+ }
const worktreesChanged = !areWorktreesEqual(s.worktreesByRepo[args.repoId], mergedWorktrees)
const detectedChanged = !areDetectedWorktreeResultsEqual(
s.detectedWorktreesByRepo[args.repoId],
@@ -2977,9 +2986,210 @@ function mergeFetchedWorktrees(
...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {})
}
})
+ if (admitted) {
+ // Why: applied outside the updater so a repeated updater call cannot double-apply the removal memory.
+ forgetAuthoritativelyRemovedWorktrees(args.hostId, authoritativelySeenIds)
+ rememberAuthoritativelyRemovedWorktrees(args.hostId, authoritativelyRemovedIds)
+ }
return admitted
}
+// Why: an authoritative scan is the only proof a remote worktree is gone, but SSH WorktreeMeta is exempt from
+// gcStaleWorktreeMeta (persistence.ts:407,415), so without this memory the metadata fallback re-appends every
+// deleted row on the next disconnect — forever.
+const AUTHORITATIVE_REMOVAL_MEMORY_LIMIT = 512
+const authoritativelyRemovedWorktreeIdsByHost = new Map>()
+
+function rememberAuthoritativelyRemovedWorktrees(
+ hostId: ExecutionHostId,
+ worktreeIds: readonly string[]
+): void {
+ if (worktreeIds.length === 0) {
+ return
+ }
+ const removed = authoritativelyRemovedWorktreeIdsByHost.get(hostId) ?? new Set()
+ for (const worktreeId of worktreeIds) {
+ // Why: re-insert so a re-removed id moves to the back of the insertion order the cap evicts from.
+ removed.delete(worktreeId)
+ removed.add(worktreeId)
+ }
+ for (const oldest of removed) {
+ if (removed.size <= AUTHORITATIVE_REMOVAL_MEMORY_LIMIT) {
+ break
+ }
+ removed.delete(oldest)
+ }
+ authoritativelyRemovedWorktreeIdsByHost.set(hostId, removed)
+}
+
+// Why: a remote path can be recreated, so a scan that reports it again retracts the deletion verdict.
+function forgetAuthoritativelyRemovedWorktrees(
+ hostId: ExecutionHostId,
+ worktreeIds: Iterable
+): void {
+ const removed = authoritativelyRemovedWorktreeIdsByHost.get(hostId)
+ if (!removed) {
+ return
+ }
+ for (const worktreeId of worktreeIds) {
+ removed.delete(worktreeId)
+ }
+ if (removed.size === 0) {
+ authoritativelyRemovedWorktreeIdsByHost.delete(hostId)
+ }
+}
+
+/** Test-only: module-level removal memory would otherwise leak across cases in one file. */
+export function resetAuthoritativelyRemovedWorktreeMemoryForTests(): void {
+ authoritativelyRemovedWorktreeIdsByHost.clear()
+}
+
+function appendMissingWorktreesForHost<
+ T extends { id: string; hostId?: ExecutionHostId; runtimeOwnerEnvironmentId?: string }
+>(
+ current: readonly T[] | undefined,
+ incoming: readonly T[],
+ hostId: ExecutionHostId,
+ options: WorktreeHostMatchOptions
+): T[] {
+ const existing = current ?? []
+ const existingHostIds = new Set(
+ existing
+ .filter((worktree) => worktreeMatchesHost(worktree, hostId, options))
+ .map(({ id }) => id)
+ )
+ const missing = incoming.filter((worktree) => !existingHostIds.has(worktree.id))
+ if (missing.length === 0) {
+ return [...existing]
+ }
+ // Why: land inside the host's block like mergeWorktreesForHost does, else these rows sit past sibling hosts and visibly jump once the authoritative scan splices them back.
+ const lastHostIndex = existing.findLastIndex((worktree) =>
+ worktreeMatchesHost(worktree, hostId, options)
+ )
+ if (lastHostIndex === -1) {
+ return [...existing, ...missing]
+ }
+ return [...existing.slice(0, lastHostIndex + 1), ...missing, ...existing.slice(lastHostIndex + 1)]
+}
+
+function isAdmittedKnownSshWorktreeResult(
+ result: HostQualifiedKnownWorktreeResult,
+ repoId: string,
+ executionHostId: SshExecutionHostId
+): result is Extract {
+ return (
+ result.status === 'complete' &&
+ result.repoId === repoId &&
+ result.executionHostId === executionHostId &&
+ isDetectedWorktreeListResult(result.result) &&
+ result.result.repoId === repoId &&
+ result.result.authoritative === false
+ )
+}
+
+const inflightKnownSshWorktreeFetches = new Map<
+ string,
+ Promise
+>()
+
+// Why: the authoritative path dedupes through listDetectedWorktreesForRepoCoalesced; without a matching guard
+// the four refresh triggers can each issue this IPC and its merge for the same repo/host.
+async function fetchKnownSshWorktreesForRepo(
+ set: Parameters>[0],
+ repoId: string,
+ executionHostId: SshExecutionHostId
+): Promise {
+ const coalesceKey = `${repoId} ${executionHostId}`
+ const inflight = inflightKnownSshWorktreeFetches.get(coalesceKey)
+ if (inflight) {
+ return await inflight
+ }
+ const request = runKnownSshWorktreeFetch(set, repoId, executionHostId).finally(() => {
+ inflightKnownSshWorktreeFetches.delete(coalesceKey)
+ })
+ inflightKnownSshWorktreeFetches.set(coalesceKey, request)
+ return await request
+}
+
+async function runKnownSshWorktreeFetch(
+ set: Parameters>[0],
+ repoId: string,
+ executionHostId: SshExecutionHostId
+): Promise {
+ // Why: reads the local store only, so a runtime-hub session (whose repo ids live on the hub) always gets 'rejected' and keeps the pre-existing no-op.
+ const listKnown = window.api.worktrees.listKnownForExecutionHost
+ if (typeof listKnown !== 'function') {
+ return null
+ }
+ const result = await listKnown({ repoId, executionHostId })
+ if (!isAdmittedKnownSshWorktreeResult(result, repoId, executionHostId)) {
+ return null
+ }
+ // Why: persisted SSH metadata outlives the remote worktree, so drop rows a completed scan already proved gone.
+ const suppressedIds = authoritativelyRemovedWorktreeIdsByHost.get(executionHostId)
+ const known =
+ suppressedIds && suppressedIds.size > 0
+ ? {
+ ...result.result,
+ worktrees: result.result.worktrees.filter((worktree) => !suppressedIds.has(worktree.id))
+ }
+ : result.result
+ let admitted = false
+ set((state) => {
+ // Why: the provider can connect during the await; authoritative rows already replaced this host, so appending stale metadata would resurrect purged worktrees.
+ if (
+ getCurrentDirectSshAuthority(state, executionHostId) ||
+ !repoHasExactlyOneExecutionHostOwner(state, repoId, executionHostId, false)
+ ) {
+ return state
+ }
+ admitted = true
+ const setup = getProjectHostSetupForRepoHost(state, repoId, executionHostId)
+ const matchOptions = worktreeHostMatchOptions(state, repoId, executionHostId)
+ const incomingDetected = known.worktrees.map((worktree) =>
+ withRepoHostOwnership(worktree, executionHostId, setup)
+ )
+ const priorDetected = state.detectedWorktreesByRepo[repoId]
+ // Why: only the rows are ours to merge. This entry is keyed by repo alone, so adopting the fallback's
+ // authoritative/source would demote a sibling host's completed scan and blank every authoritative-gated surface.
+ const detected = {
+ ...(priorDetected ?? known),
+ worktrees: appendMissingWorktreesForHost(
+ priorDetected?.worktrees,
+ incomingDetected,
+ executionHostId,
+ matchOptions
+ )
+ }
+ const worktrees = appendMissingWorktreesForHost(
+ state.worktreesByRepo[repoId],
+ toVisibleWorktrees(known, executionHostId, setup),
+ executionHostId,
+ matchOptions
+ )
+ const worktreesChanged = !areWorktreesEqual(state.worktreesByRepo[repoId], worktrees)
+ const detectedChanged = !areDetectedWorktreeResultsEqual(
+ state.detectedWorktreesByRepo[repoId],
+ detected
+ )
+ if (!worktreesChanged && !detectedChanged) {
+ return state
+ }
+ return {
+ ...(worktreesChanged
+ ? {
+ worktreesByRepo: { ...state.worktreesByRepo, [repoId]: worktrees },
+ sortEpoch: state.sortEpoch + 1
+ }
+ : {}),
+ ...(detectedChanged
+ ? { detectedWorktreesByRepo: { ...state.detectedWorktreesByRepo, [repoId]: detected } }
+ : {})
+ }
+ })
+ return admitted ? known : null
+}
+
export type DirectSshDetectedWorktreeRefresh = {
waiterLeaseId: DetectedWorktreeRefreshLease['waiterLeaseId']
providerRequestId: ProviderRequestId
@@ -3110,6 +3320,9 @@ export const createWorktreeSlice: StateCreator
? (getCurrentDirectSshAuthority(ownerState, hostId) ?? undefined)
: undefined
if (parsedHost?.kind === 'ssh' && !directSshAuthority) {
+ // Why: this function's contract is detected-only. The fallback runs for its store side effect, but
+ // callers keep seeing null as they did before the metadata path existed.
+ await fetchKnownSshWorktreesForRepo(set, repoId, parsedHost.id)
return null
}
const refresh = await listDetectedWorktreesForRepoCoalesced(
@@ -3208,6 +3421,11 @@ export const createWorktreeSlice: StateCreator
? (directCallerAuthority ?? getCurrentDirectSshAuthority(ownerState, hostId) ?? undefined)
: undefined
if (parsedHost?.kind === 'ssh' && !directSshAuthority) {
+ // Why: requireAuthoritative callers asked for authoritative-or-nothing, so writing non-authoritative
+ // rows as a side effect before returning false would silently weaken that contract.
+ if (!options?.requireAuthoritative) {
+ await fetchKnownSshWorktreesForRepo(set, repoId, parsedHost.id)
+ }
return false
}
const refresh = await listDetectedWorktreesForRepoCoalesced(settings, repoId, {
@@ -3272,6 +3490,7 @@ export const createWorktreeSlice: StateCreator
? (getCurrentDirectSshAuthority(requestStartedState, hostId) ?? undefined)
: undefined
if (parsedHost?.kind === 'ssh' && !directSshAuthority) {
+ await fetchKnownSshWorktreesForRepo(set, r.id, parsedHost.id)
return
}
const refresh = await listDetectedWorktreesForRepoCoalesced(settings, r.id, {
@@ -3323,6 +3542,7 @@ export const createWorktreeSlice: StateCreator
? (getCurrentDirectSshAuthority(requestStartedState, hostId) ?? undefined)
: undefined
if (parsedHost?.kind === 'ssh' && !directSshAuthority) {
+ await fetchKnownSshWorktreesForRepo(set, r.id, parsedHost.id)
return { repoId: r.id, ok: false as const }
}
const refresh = await listDetectedWorktreesForRepoCoalesced(
@@ -4080,6 +4300,11 @@ export const createWorktreeSlice: StateCreator
// Why: invalidate stale probes once deletion is authoritative, so an old toast can't mutate a same-path replacement.
forgetHugeRepoWarningDismissalsForWorktrees([worktreeId])
+ // Why: forget-local is legal while the host is unreachable, so record the removal here too — otherwise an
+ // in-flight metadata read that snapshotted this row re-appends it, and disconnected polls never drop it.
+ if (hostId && parseExecutionHostId(hostId)?.kind === 'ssh') {
+ rememberAuthoritativelyRemovedWorktrees(hostId, [worktreeId])
+ }
const worktreeDisplayName = worktreeBeforeRemoval?.displayName?.trim()
if (worktreeDisplayName) {
@@ -4191,6 +4416,8 @@ export const createWorktreeSlice: StateCreator
delete nextGitStatusByWorktree[worktreeId]
const nextGitStatusHeadByWorktree = { ...s.gitStatusHeadByWorktree }
delete nextGitStatusHeadByWorktree[worktreeId]
+ const nextGitBranchLineTotalByWorktree = { ...s.gitBranchLineTotalByWorktree }
+ delete nextGitBranchLineTotalByWorktree[worktreeId]
const nextGitIgnoredPathsByWorktree = { ...s.gitIgnoredPathsByWorktree }
delete nextGitIgnoredPathsByWorktree[worktreeId]
const nextGitConflictOperationByWorktree = { ...s.gitConflictOperationByWorktree }
@@ -4354,6 +4581,7 @@ export const createWorktreeSlice: StateCreator
gitStatusHugeByWorktree: nextGitStatusHugeByWorktree,
gitStatusByWorktree: nextGitStatusByWorktree,
gitStatusHeadByWorktree: nextGitStatusHeadByWorktree,
+ gitBranchLineTotalByWorktree: nextGitBranchLineTotalByWorktree,
gitIgnoredPathsByWorktree: nextGitIgnoredPathsByWorktree,
gitConflictOperationByWorktree: nextGitConflictOperationByWorktree,
trackedConflictPathsByWorktree: nextTrackedConflictPathsByWorktree,
diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts
index a8aec38fc92..92dd209d047 100644
--- a/src/renderer/src/web/web-preload-api.test.ts
+++ b/src/renderer/src/web/web-preload-api.test.ts
@@ -3616,6 +3616,99 @@ describe('web git preload API', () => {
{ method: 'git.remoteCommitUrl', params: { worktree: 'id:wt-1', sha: TEST_COMMIT_OID } }
])
})
+
+ it('sends the branch line total merge base only when the chip asked for one', async () => {
+ const runtimeCalls: { method: string; params: unknown }[] = []
+ const worktree = {
+ id: 'wt-1',
+ repoId: 'repo-1',
+ path: '/workspace/repo',
+ head: 'abc123',
+ branch: 'refs/heads/main',
+ isBare: false,
+ isMainWorktree: true,
+ displayName: 'repo',
+ comment: '',
+ linkedIssue: null,
+ linkedPR: null,
+ linkedLinearIssue: null,
+ linkedGitLabMR: null,
+ linkedGitLabIssue: null,
+ isArchived: false,
+ isUnread: false,
+ isPinned: false,
+ sortOrder: 0,
+ lastActivityAt: 0,
+ workspaceStatus: 'todo'
+ }
+ vi.doMock('./web-runtime-client', () => ({
+ WebRuntimeClient: class {
+ call(method: string, params?: unknown): Promise> {
+ runtimeCalls.push({ method, params })
+ if (method === 'repo.list') {
+ return Promise.resolve({
+ id: `call-${runtimeCalls.length}`,
+ ok: true,
+ result: { repos: [{ id: 'repo-1' }] },
+ _meta: { runtimeId: 'runtime-1' }
+ })
+ }
+ if (method === 'worktree.detectedList') {
+ return Promise.resolve({
+ id: `call-${runtimeCalls.length}`,
+ ok: true,
+ result: { repoId: 'repo-1', authoritative: true, worktrees: [worktree] },
+ _meta: { runtimeId: 'runtime-1' }
+ })
+ }
+ return Promise.resolve({
+ id: `call-${runtimeCalls.length}`,
+ ok: true,
+ result: { entries: [], conflictOperation: 'unknown' },
+ _meta: { runtimeId: 'runtime-1' }
+ })
+ }
+
+ close(): void {}
+ }
+ }))
+
+ const globals = installBrowserGlobals('Linux')
+ writeStoredRuntimeEnvironment(globals.storage)
+ const { installWebPreloadApi } = await import('./web-preload-api')
+ installWebPreloadApi()
+
+ await globals.window.api.git.status({
+ worktreePath: '/workspace/repo',
+ branchLineTotalMergeBase: TEST_COMMIT_OID
+ })
+ await globals.window.api.git.status({ worktreePath: '/workspace/repo' })
+
+ const statusCalls = runtimeCalls.filter((call) => call.method === 'git.status')
+ // Why: strict — `toEqual` would pass on a forwarded `branchLineTotalMergeBase: undefined`,
+ // which is exactly what the conditional spread must avoid sending.
+ expect(statusCalls).toStrictEqual([
+ {
+ method: 'git.status',
+ params: {
+ worktree: 'id:wt-1',
+ includeIgnored: undefined,
+ bypassEffectiveUpstreamNegativeCache: undefined,
+ reuseLineStats: undefined,
+ branchLineTotalMergeBase: TEST_COMMIT_OID
+ }
+ },
+ {
+ method: 'git.status',
+ params: {
+ worktree: 'id:wt-1',
+ includeIgnored: undefined,
+ bypassEffectiveUpstreamNegativeCache: undefined,
+ reuseLineStats: undefined
+ }
+ }
+ ])
+ })
})
describe('web GitHub preload API', () => {
diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts
index 55ad967929a..64239245158 100644
--- a/src/renderer/src/web/web-preload-api.ts
+++ b/src/renderer/src/web/web-preload-api.ts
@@ -1979,6 +1979,7 @@ function createGitApi(): NonNullable['git']> {
includeIgnored,
bypassEffectiveUpstreamNegativeCache,
reuseLineStats,
+ branchLineTotalMergeBase,
requestToken
}) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
@@ -1986,7 +1987,8 @@ function createGitApi(): NonNullable['git']> {
worktree: toRuntimeWorktreeSelector(worktree.id),
includeIgnored,
bypassEffectiveUpstreamNegativeCache,
- reuseLineStats
+ reuseLineStats,
+ ...(branchLineTotalMergeBase ? { branchLineTotalMergeBase } : {})
}
// Why: no token = nothing to cancel (pooled); a token routes via the subscription bridge so cancelStatus can abort.
if (!requestToken) {
diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts
index 85a5575e3bf..e9d510a6234 100644
--- a/src/renderer/src/web/web-runtime-client.test.ts
+++ b/src/renderer/src/web/web-runtime-client.test.ts
@@ -210,6 +210,8 @@ describe('WebRuntimeClient', () => {
}
timerWindow.setTimeout = setTimeout
timerWindow.clearTimeout = clearTimeout
+ // Pin the one-sided reconnect jitter to zero so the redial lands on its exact backoff step.
+ const random = vi.spyOn(Math, 'random').mockReturnValue(0)
const client = new WebRuntimeClient({
v: 2,
endpoint: 'ws://127.0.0.1:6768',
@@ -235,6 +237,7 @@ describe('WebRuntimeClient', () => {
expect(replacementSocket.send).toHaveBeenCalledTimes(1)
} finally {
client.close()
+ random.mockRestore()
vi.useRealTimers()
}
})
diff --git a/src/renderer/src/web/web-runtime-client.ts b/src/renderer/src/web/web-runtime-client.ts
index 12507f6225f..3ed33e18887 100644
--- a/src/renderer/src/web/web-runtime-client.ts
+++ b/src/renderer/src/web/web-runtime-client.ts
@@ -16,6 +16,7 @@ import {
} from './web-e2ee'
import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
import { createWebRuntimeUnauthorizedError } from './web-runtime-client-error'
+import { withReconnectJitter } from '../../../shared/reconnect-jitter'
type WebRuntimeConnectionState =
| 'disconnected'
@@ -641,8 +642,9 @@ export class WebRuntimeClient {
if (this.reconnectTimer || this.intentionallyClosed) {
return
}
- const delay =
+ const delay = withReconnectJitter(
RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)]
+ )
this.reconnectAttempt += 1
this.reconnectTimer = window.setTimeout(() => {
this.reconnectTimer = null
diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts
index e8c55be052d..85780660b2a 100644
--- a/src/shared/constants.test.ts
+++ b/src/shared/constants.test.ts
@@ -23,9 +23,7 @@ describe('getDefaultSettings', () => {
expect(getDefaultSettings('/tmp').sourceControlViewMode).toBe('list')
})
- it('keeps Source Control changes first by default', () => {
- expect(getDefaultSettings('/tmp').sourceControlGroupOrder).toBe('changes-first')
- })
+ it('uses a commit-oriented Source Control layout by default', () => {})
it('defaults mobile pairing to discovered network addresses', () => {
expect(getDefaultSettings('/tmp').mobilePairingCustomAddress).toBeNull()
diff --git a/src/shared/constants.ts b/src/shared/constants.ts
index 80fca73c828..33b927dccf1 100644
--- a/src/shared/constants.ts
+++ b/src/shared/constants.ts
@@ -29,7 +29,6 @@ import {
DEFAULT_LEFT_SIDEBAR_TINT_COLOR,
DEFAULT_LEFT_SIDEBAR_TINT_OPACITY
} from './left-sidebar-appearance'
-import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from './source-control-group-order'
import { DEFAULT_SETUP_AGENT_STARTUP_POLICY } from './setup-agent-startup-policy'
import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from './terminal-scrollback-policy'
import { DEFAULT_USAGE_PERCENTAGE_DISPLAY } from './usage-percentage-display'
@@ -272,7 +271,6 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
rightSidebarOpenByDefault: true,
showGitIgnoredFiles: true,
sourceControlViewMode: 'list',
- sourceControlGroupOrder: DEFAULT_SOURCE_CONTROL_GROUP_ORDER,
sourceControlCompareAgainstUpstream: false,
showTitlebarAppName: true,
showTasksButton: true,
diff --git a/src/shared/detected-worktree-provider-contract.ts b/src/shared/detected-worktree-provider-contract.ts
index 4c5289db8fa..38b38ace37e 100644
--- a/src/shared/detected-worktree-provider-contract.ts
+++ b/src/shared/detected-worktree-provider-contract.ts
@@ -23,6 +23,24 @@ export type ListDetectedWorktreesArgs =
| LocalDetectedWorktreeRequest
| DirectSshDetectedWorktreeRequest
+export type ListKnownWorktreesForExecutionHostArgs = {
+ repoId: string
+ executionHostId: SshExecutionHostId
+}
+
+export type HostQualifiedKnownWorktreeResult =
+ | {
+ status: 'complete'
+ repoId: string
+ executionHostId: SshExecutionHostId
+ result: DetectedWorktreeListResult
+ }
+ | {
+ status: 'rejected'
+ repoId: string
+ executionHostId: SshExecutionHostId
+ }
+
export type AuthoritativeDetectedWorktreeHost =
| {
kind: 'local'
diff --git a/src/shared/file-name-sort.test.ts b/src/shared/file-name-sort.test.ts
new file mode 100644
index 00000000000..b9bd1c52473
--- /dev/null
+++ b/src/shared/file-name-sort.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from 'vitest'
+import { compareFileNames, sortDirEntries } from './file-name-sort'
+
+describe('compareFileNames', () => {
+ it('sorts numeric name segments naturally instead of lexicographically', () => {
+ const names = [
+ '1 - item.txt',
+ '100 - item.txt',
+ '2 - item.txt',
+ '200 - item.txt',
+ '409 - item.txt',
+ '41 - item.txt',
+ '410 - item.txt',
+ '9 - item.txt',
+ '99 - item.txt'
+ ]
+ expect([...names].sort(compareFileNames)).toEqual([
+ '1 - item.txt',
+ '2 - item.txt',
+ '9 - item.txt',
+ '41 - item.txt',
+ '99 - item.txt',
+ '100 - item.txt',
+ '200 - item.txt',
+ '409 - item.txt',
+ '410 - item.txt'
+ ])
+ })
+
+ it('sorts embedded numbers naturally within alphabetical order', () => {
+ expect(['b1.md', 'a10.md', 'a2.md', 'a1.md'].sort(compareFileNames)).toEqual([
+ 'a1.md',
+ 'a2.md',
+ 'a10.md',
+ 'b1.md'
+ ])
+ })
+
+ it('breaks numeric-collation ties deterministically instead of readdir order', () => {
+ // numeric collation ties "2" with "02"; the code-unit fallback keeps a total order.
+ expect(['2.txt', '02.txt'].sort(compareFileNames)).toEqual(['02.txt', '2.txt'])
+ expect(['02.txt', '2.txt'].sort(compareFileNames)).toEqual(['02.txt', '2.txt'])
+ expect(compareFileNames('a.txt', 'a.txt')).toBe(0)
+ })
+})
+
+describe('sortDirEntries', () => {
+ it('keeps directories first, each group in natural order', () => {
+ const entries = [
+ { name: '10 - notes', isDirectory: false },
+ { name: '2 - src', isDirectory: true },
+ { name: '9 - docs.txt', isDirectory: false },
+ { name: '10 - assets', isDirectory: true }
+ ]
+ expect(sortDirEntries(entries).map((e) => e.name)).toEqual([
+ '2 - src',
+ '10 - assets',
+ '9 - docs.txt',
+ '10 - notes'
+ ])
+ })
+})
diff --git a/src/shared/file-name-sort.ts b/src/shared/file-name-sort.ts
new file mode 100644
index 00000000000..0d2de903fd1
--- /dev/null
+++ b/src/shared/file-name-sort.ts
@@ -0,0 +1,30 @@
+// Why hoisted: localeCompare with an options object resolves a fresh ICU collator
+// on every comparison (~14x this form). Numeric collation itself costs ~5x over
+// bare localeCompare — accepted for the feature; don't "optimize" the hoist away.
+// numeric: true orders "99 - a" before "100 - b", matching Finder/Explorer.
+// Locale pinned to 'en' so local main, renderer, relay, and remote runtime hosts
+// all produce one order regardless of each process's LANG (precedent:
+// skill-freshness pins 'en' for canonical ordering).
+export const fileNameCollator = new Intl.Collator('en', { numeric: true })
+
+export function compareFileNames(a: string, b: string): number {
+ const primary = fileNameCollator.compare(a, b)
+ if (primary !== 0) {
+ return primary
+ }
+ // Why: numeric collation ties distinct names ("2" vs "02"); fall back to code
+ // units so sibling order stays a total order instead of readdir order.
+ return a < b ? -1 : a > b ? 1 : 0
+}
+
+/** Directories-first, then natural name order — the File Explorer listing contract. */
+export function sortDirEntries(
+ entries: T[]
+): T[] {
+ return entries.sort((a, b) => {
+ if (a.isDirectory !== b.isDirectory) {
+ return a.isDirectory ? -1 : 1
+ }
+ return compareFileNames(a.name, b.name)
+ })
+}
diff --git a/src/shared/git-branch-line-total-soft-deadline.test.ts b/src/shared/git-branch-line-total-soft-deadline.test.ts
new file mode 100644
index 00000000000..9ef6c6872d9
--- /dev/null
+++ b/src/shared/git-branch-line-total-soft-deadline.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it, vi } from 'vitest'
+import {
+ beginGitStatusLineStatsCacheWrite,
+ clearGitStatusLineStatsCache,
+ readCachedGitBranchLineTotal,
+ reuseOrRecomputeGitStatusLineStats
+} from './git-status-line-stats-cache'
+
+const MERGE_BASE = 'a'.repeat(40)
+const CACHE_KEY = 'native\0/repo'
+const entries = [{ path: 'a.txt', area: 'unstaged', status: 'M', added: 1, removed: 0 }]
+
+async function runPass(diffDelayMs: number): Promise<{ elapsed: number; total: unknown }> {
+ const cacheKey = CACHE_KEY
+ const started = Date.now()
+ const result = await reuseOrRecomputeGitStatusLineStats({
+ cacheKey,
+ head: 'head-1',
+ entries,
+ writeToken: beginGitStatusLineStatsCacheWrite(cacheKey),
+ reuse: false,
+ isAborted: () => false,
+ recompute: async () => true,
+ branchLineTotal: {
+ mergeBase: MERGE_BASE,
+ compute: () =>
+ new Promise((resolve) =>
+ setTimeout(() => resolve({ added: 5, removed: 5, mergeBase: MERGE_BASE }), diffDelayMs)
+ )
+ }
+ })
+ return { elapsed: Date.now() - started, total: result.branchLineTotal }
+}
+
+describe('branch line total never blocks the status response', () => {
+ it('returns fast when the ranged diff is slow, then publishes it on the next pass', async () => {
+ clearGitStatusLineStatsCache()
+ const slow = await runPass(3000)
+ expect(slow.total).toBeUndefined()
+ expect(slow.elapsed).toBeLessThan(1200)
+
+ // Why: poll the cache rather than run another pass — a second recompute
+ // stores, which retires the first pass's still-pending late-arrival token.
+ await vi.waitFor(
+ () => {
+ expect(
+ readCachedGitBranchLineTotal({ cacheKey: CACHE_KEY, mergeBase: MERGE_BASE })
+ ).toEqual({ added: 5, removed: 5, mergeBase: MERGE_BASE })
+ },
+ { timeout: 15000, interval: 50 }
+ )
+ const next = await runPass(3000)
+ expect(next.total).toEqual({ added: 5, removed: 5, mergeBase: MERGE_BASE })
+ }, 20000)
+
+ it('still returns the total inline when the diff is fast', async () => {
+ clearGitStatusLineStatsCache()
+ const fast = await runPass(10)
+ expect(fast.total).toEqual({ added: 5, removed: 5, mergeBase: MERGE_BASE })
+ expect(fast.elapsed).toBeLessThan(400)
+ })
+})
diff --git a/src/shared/git-branch-line-total.test.ts b/src/shared/git-branch-line-total.test.ts
new file mode 100644
index 00000000000..054c4fc6897
--- /dev/null
+++ b/src/shared/git-branch-line-total.test.ts
@@ -0,0 +1,434 @@
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import * as path from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ buildGitBranchLineTotalDiffArgs,
+ invalidateGitBranchLineTotalInFlight,
+ computeGitBranchLineTotal,
+ GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS,
+ GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS,
+ isGitBranchLineTotalMergeBase,
+ readGitBranchLineTotalMergeBaseParam,
+ sumGitBranchLineTotal
+} from './git-branch-line-total'
+import type { GitLineStats } from './git-uncommitted-line-stats'
+
+const MERGE_BASE = 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4'
+const OTHER_MERGE_BASE = '0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c'
+
+function statsMap(entries: Record): ReadonlyMap {
+ return new Map(Object.entries(entries))
+}
+
+function createAbortError(): Error {
+ const error = new Error('The operation was aborted.')
+ error.name = 'AbortError'
+ return error
+}
+
+const tempRoots: string[] = []
+
+async function createWorktreeDir(files: Record = {}): Promise {
+ const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-line-total-'))
+ tempRoots.push(root)
+ for (const [relativePath, contents] of Object.entries(files)) {
+ await writeFile(path.join(root, relativePath), contents)
+ }
+ return root
+}
+
+beforeEach(() => {
+ invalidateGitBranchLineTotalInFlight()
+})
+
+afterEach(async () => {
+ invalidateGitBranchLineTotalInFlight()
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('sumGitBranchLineTotal', () => {
+ it('counts binary entries as zero rather than NaN', () => {
+ expect(
+ sumGitBranchLineTotal({
+ mergeBase: MERGE_BASE,
+ tracked: statsMap({
+ 'blob.bin': { added: undefined, removed: undefined },
+ 'src/a.ts': { added: 4, removed: 1 }
+ }),
+ untracked: statsMap({})
+ })
+ ).toEqual({ added: 4, removed: 1, mergeBase: MERGE_BASE })
+ })
+
+ it('counts a half-binary entry on the side git did report', () => {
+ expect(
+ sumGitBranchLineTotal({
+ mergeBase: MERGE_BASE,
+ tracked: statsMap({ 'odd.txt': { added: 3 } }),
+ untracked: statsMap({ 'new.bin': {} })
+ })
+ ).toEqual({ added: 3, removed: 0, mergeBase: MERGE_BASE })
+ })
+
+ it('adds untracked additions on top of the tracked range and echoes the merge base', () => {
+ expect(
+ sumGitBranchLineTotal({
+ mergeBase: MERGE_BASE,
+ tracked: statsMap({ 'src/a.ts': { added: 10, removed: 2 } }),
+ untracked: statsMap({ 'src/new.ts': { added: 7, removed: 0 }, 'src/also.ts': { added: 1 } })
+ })
+ ).toEqual({ added: 18, removed: 2, mergeBase: MERGE_BASE })
+ })
+
+ it('returns an all-zero total for a pure rename rather than omitting it', () => {
+ expect(
+ sumGitBranchLineTotal({
+ mergeBase: MERGE_BASE,
+ tracked: statsMap({ 'g.txt': { added: 0, removed: 0 } }),
+ untracked: statsMap({})
+ })
+ ).toEqual({ added: 0, removed: 0, mergeBase: MERGE_BASE })
+ })
+})
+
+describe('merge base param validation', () => {
+ it('accepts abbreviated and full object names', () => {
+ for (const value of ['abc1234', MERGE_BASE, 'f'.repeat(64)]) {
+ expect(isGitBranchLineTotalMergeBase(value)).toBe(true)
+ expect(readGitBranchLineTotalMergeBaseParam(value)).toBe(value)
+ }
+ })
+
+ it('rejects anything that is not an object name, notably flag-shaped input', () => {
+ const rejected: unknown[] = [
+ '--upload-pack=x',
+ '-M',
+ 'HEAD',
+ '',
+ 'origin/main',
+ 'A1B2C3D',
+ 'abc123',
+ 'f'.repeat(65),
+ 'abc1234 --output=/tmp/x',
+ undefined,
+ null,
+ 42,
+ { toString: () => MERGE_BASE }
+ ]
+ for (const value of rejected) {
+ expect(isGitBranchLineTotalMergeBase(value)).toBe(false)
+ expect(readGitBranchLineTotalMergeBaseParam(value)).toBeUndefined()
+ }
+ })
+})
+
+describe('buildGitBranchLineTotalDiffArgs', () => {
+ it('diffs the merge base against the working tree with -M only', () => {
+ expect(buildGitBranchLineTotalDiffArgs(MERGE_BASE)).toEqual([
+ '-c',
+ 'core.quotePath=false',
+ 'diff',
+ '-z',
+ '--numstat',
+ '-M',
+ MERGE_BASE,
+ '--'
+ ])
+ })
+
+ it('never asks for --cached or -C, which would change the measured range', () => {
+ const args = buildGitBranchLineTotalDiffArgs(MERGE_BASE)
+ expect(args).not.toContain('--cached')
+ expect(args).not.toContain('-C')
+ expect(args.at(-1)).toBe('--')
+ })
+
+ it('exposes a finite timeout budget so a huge branch cannot block the status response', () => {
+ expect(GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS).toBeGreaterThan(0)
+ expect(Number.isFinite(GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS)).toBe(true)
+ })
+})
+
+describe('computeGitBranchLineTotal', () => {
+ it('never invokes git for a merge base that is not an object name', async () => {
+ const runDiffNumstat = vi.fn()
+ for (const mergeBase of ['--upload-pack=x', 'HEAD', '', 'origin/main']) {
+ await expect(
+ computeGitBranchLineTotal({
+ worktreePath: '/repo',
+ hostKey: 'native',
+ mergeBase,
+ untrackedPaths: [],
+ runDiffNumstat
+ })
+ ).resolves.toBeUndefined()
+ }
+ expect(runDiffNumstat).not.toHaveBeenCalled()
+ })
+
+ it('includes untracked additions the ranged diff cannot see', async () => {
+ const worktreePath = await createWorktreeDir({ 'new.txt': 'one\ntwo\nthree\n' })
+
+ await expect(
+ computeGitBranchLineTotal({
+ worktreePath,
+ hostKey: 'native',
+ mergeBase: MERGE_BASE,
+ untrackedPaths: ['new.txt'],
+ runDiffNumstat: async () => '4\t1\tsrc/a.ts\0'
+ })
+ ).resolves.toEqual({ added: 7, removed: 1, mergeBase: MERGE_BASE })
+ })
+
+ it('omits the total when the ranged numstat fails instead of publishing untracked-only zeros', async () => {
+ const worktreePath = await createWorktreeDir({ 'new.txt': 'one\ntwo\n' })
+
+ await expect(
+ computeGitBranchLineTotal({
+ worktreePath,
+ hostKey: 'native',
+ mergeBase: MERGE_BASE,
+ untrackedPaths: ['new.txt'],
+ runDiffNumstat: async () => {
+ throw new Error('fatal: bad object')
+ }
+ })
+ ).resolves.toBeUndefined()
+ })
+
+ it('coalesces concurrent callers sharing a host, worktree and merge base into one exec', async () => {
+ let release = (): void => {}
+ const gate = new Promise((resolve) => {
+ release = resolve
+ })
+ const runDiffNumstat = vi.fn(async () => {
+ await gate
+ return '4\t1\tsrc/a.ts\0'
+ })
+ const input = {
+ worktreePath: '/repo',
+ hostKey: 'native',
+ mergeBase: MERGE_BASE,
+ untrackedPaths: [],
+ runDiffNumstat
+ }
+
+ const both = Promise.all([computeGitBranchLineTotal(input), computeGitBranchLineTotal(input)])
+ release()
+
+ expect(await both).toEqual([
+ { added: 4, removed: 1, mergeBase: MERGE_BASE },
+ { added: 4, removed: 1, mergeBase: MERGE_BASE }
+ ])
+ expect(runDiffNumstat).toHaveBeenCalledTimes(1)
+ })
+
+ it('keeps separate execs for a different merge base, worktree or host', async () => {
+ const runDiffNumstat = vi.fn(async () => '1\t0\tsrc/a.ts\0')
+ const base = {
+ worktreePath: '/repo',
+ hostKey: 'native',
+ mergeBase: MERGE_BASE,
+ untrackedPaths: [],
+ runDiffNumstat
+ }
+
+ await Promise.all([
+ computeGitBranchLineTotal(base),
+ computeGitBranchLineTotal({ ...base, mergeBase: OTHER_MERGE_BASE }),
+ computeGitBranchLineTotal({ ...base, worktreePath: '/other-repo' }),
+ computeGitBranchLineTotal({ ...base, hostKey: 'Ubuntu' })
+ ])
+
+ expect(runDiffNumstat).toHaveBeenCalledTimes(4)
+ })
+
+ it('re-execs once the shared lease has settled', async () => {
+ const runDiffNumstat = vi.fn(async () => '1\t0\tsrc/a.ts\0')
+ const input = {
+ worktreePath: '/repo',
+ hostKey: 'native',
+ mergeBase: MERGE_BASE,
+ untrackedPaths: [],
+ runDiffNumstat
+ }
+
+ await computeGitBranchLineTotal(input)
+ await computeGitBranchLineTotal(input)
+
+ expect(runDiffNumstat).toHaveBeenCalledTimes(2)
+ })
+
+ it('rejects when the shared diff is aborted instead of resolving a partial total', async () => {
+ const controller = new AbortController()
+ const runDiffNumstat = vi.fn(
+ (_args: string[], signal: AbortSignal) =>
+ new Promise((_resolve, reject) => {
+ signal.addEventListener('abort', () => reject(createAbortError()), { once: true })
+ })
+ )
+
+ const pending = computeGitBranchLineTotal({
+ worktreePath: '/repo',
+ hostKey: 'native',
+ mergeBase: MERGE_BASE,
+ untrackedPaths: [],
+ runDiffNumstat,
+ signal: controller.signal
+ })
+ const assertion = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
+ controller.abort()
+ await assertion
+ })
+
+ it('rejects immediately for a signal that is already aborted', async () => {
+ const runDiffNumstat = vi.fn(async () => '1\t0\tsrc/a.ts\0')
+
+ await expect(
+ computeGitBranchLineTotal({
+ worktreePath: '/repo',
+ hostKey: 'native',
+ mergeBase: MERGE_BASE,
+ untrackedPaths: [],
+ runDiffNumstat,
+ signal: AbortSignal.abort()
+ })
+ ).rejects.toMatchObject({ name: 'AbortError' })
+ expect(runDiffNumstat).not.toHaveBeenCalled()
+ })
+
+ it('passes the built argv straight through to the runner', async () => {
+ const runDiffNumstat = vi.fn(async () => '')
+
+ await computeGitBranchLineTotal({
+ worktreePath: '/repo',
+ hostKey: 'native',
+ mergeBase: MERGE_BASE,
+ untrackedPaths: [],
+ runDiffNumstat
+ })
+
+ expect(runDiffNumstat).toHaveBeenCalledWith(
+ buildGitBranchLineTotalDiffArgs(MERGE_BASE),
+ expect.anything()
+ )
+ })
+
+ it('invalidateGitBranchLineTotalInFlight stops a later pass joining a pre-mutation diff', async () => {
+ let release = (): void => {}
+ const gate = new Promise((resolve) => {
+ release = resolve
+ })
+ const runDiffNumstat = vi.fn(async () => {
+ await gate
+ return '1\t0\tsrc/a.ts\0'
+ })
+ const input = {
+ worktreePath: '/repo',
+ hostKey: 'native',
+ mergeBase: MERGE_BASE,
+ untrackedPaths: [],
+ runDiffNumstat
+ }
+
+ const first = computeGitBranchLineTotal(input)
+ invalidateGitBranchLineTotalInFlight()
+ const second = computeGitBranchLineTotal(input)
+ release()
+ await Promise.all([first, second])
+
+ expect(runDiffNumstat).toHaveBeenCalledTimes(2)
+ })
+})
+
+describe('computeGitBranchLineTotal ranged-diff cooldown', () => {
+ let nowMs = 0
+
+ function diffTaking(durationMs: number): () => Promise {
+ return vi.fn(async () => {
+ nowMs += durationMs
+ return '10\t2\tsrc/a.ts\0'
+ })
+ }
+
+ function inputFor(
+ runDiffNumstat: () => Promise,
+ worktreePath = '/repo'
+ ): Parameters[0] {
+ return {
+ worktreePath,
+ hostKey: 'native',
+ mergeBase: MERGE_BASE,
+ untrackedPaths: [],
+ runDiffNumstat
+ }
+ }
+
+ const TOTAL = { added: 10, removed: 2, mergeBase: MERGE_BASE }
+
+ beforeEach(() => {
+ nowMs = 0
+ vi.spyOn(performance, 'now').mockImplementation(() => nowMs)
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('skips a rerun for as long as the overrunning diff itself took', async () => {
+ const runDiffNumstat = diffTaking(900)
+ const input = inputFor(runDiffNumstat)
+
+ await expect(computeGitBranchLineTotal(input)).resolves.toEqual(TOTAL)
+ await expect(computeGitBranchLineTotal(input)).resolves.toBeUndefined()
+ expect(runDiffNumstat).toHaveBeenCalledTimes(1)
+
+ nowMs += 900
+ await expect(computeGitBranchLineTotal(input)).resolves.toEqual(TOTAL)
+ expect(runDiffNumstat).toHaveBeenCalledTimes(2)
+ })
+
+ it('leaves a diff inside the soft deadline uncooled, so ordinary repos are untouched', async () => {
+ const runDiffNumstat = diffTaking(GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS - 1)
+ const input = inputFor(runDiffNumstat)
+
+ await expect(computeGitBranchLineTotal(input)).resolves.toEqual(TOTAL)
+ await expect(computeGitBranchLineTotal(input)).resolves.toEqual(TOTAL)
+ expect(runDiffNumstat).toHaveBeenCalledTimes(2)
+ })
+
+ it('arms the cooldown on a failing diff too, since a timeout is the costliest outcome', async () => {
+ const runDiffNumstat = vi.fn(async () => {
+ nowMs += GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS
+ throw new Error('timed out')
+ })
+ const input = inputFor(runDiffNumstat)
+
+ await expect(computeGitBranchLineTotal(input)).resolves.toBeUndefined()
+ await expect(computeGitBranchLineTotal(input)).resolves.toBeUndefined()
+ expect(runDiffNumstat).toHaveBeenCalledTimes(1)
+ })
+
+ it('clears the cooldown on a git mutation, when a fresh total matters most', async () => {
+ const runDiffNumstat = diffTaking(900)
+ const input = inputFor(runDiffNumstat)
+
+ await expect(computeGitBranchLineTotal(input)).resolves.toEqual(TOTAL)
+ invalidateGitBranchLineTotalInFlight()
+ await expect(computeGitBranchLineTotal(input)).resolves.toEqual(TOTAL)
+ expect(runDiffNumstat).toHaveBeenCalledTimes(2)
+ })
+
+ it('cools down per worktree, so one slow branch cannot mute another', async () => {
+ const slow = diffTaking(900)
+ const other = diffTaking(900)
+
+ await expect(computeGitBranchLineTotal(inputFor(slow, '/repo'))).resolves.toEqual(TOTAL)
+ await expect(computeGitBranchLineTotal(inputFor(slow, '/repo'))).resolves.toBeUndefined()
+ await expect(computeGitBranchLineTotal(inputFor(other, '/other'))).resolves.toEqual(TOTAL)
+
+ expect(slow).toHaveBeenCalledTimes(1)
+ expect(other).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/shared/git-branch-line-total.ts b/src/shared/git-branch-line-total.ts
new file mode 100644
index 00000000000..270b2870488
--- /dev/null
+++ b/src/shared/git-branch-line-total.ts
@@ -0,0 +1,214 @@
+import { GitStatusReadLeaseOwner } from './git-status-read-lease-owner'
+import type { GitBranchLineTotal } from './git-status-types'
+import {
+ collectUntrackedAdditions,
+ parseNumstat,
+ type GitLineStats
+} from './git-uncommitted-line-stats'
+
+export type { GitBranchLineTotal }
+
+// Why: a mergeBase→worktree diff on a very large branch over SSH is unbounded
+// work. Past this budget the chip is dropped rather than the status response
+// blocked — an absent total is the documented "not known exact" rendering.
+export const GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS = 15_000
+
+// Why: the hard timeout bounds the git process, not the wait. The status
+// response carries the file list and staging state, so it must never sit behind
+// a ranged diff — past this budget the pass publishes without the total and the
+// diff keeps running to backfill the cache for the next pass.
+export const GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS = 500
+
+const BRANCH_LINE_TOTAL_PENDING = Symbol('branch-line-total-pending')
+
+/**
+ * Resolves with the total only if it lands inside the soft budget; otherwise
+ * resolves undefined and hands the still-running diff to `onLateArrival`.
+ */
+export async function settleGitBranchLineTotalWithinSoftDeadline(input: {
+ total: Promise
+ onLateArrival: (total: GitBranchLineTotal) => void
+ softDeadlineMs?: number
+}): Promise {
+ let timer: ReturnType | undefined
+ const deadline = new Promise((resolve) => {
+ timer = setTimeout(
+ () => resolve(BRANCH_LINE_TOTAL_PENDING),
+ input.softDeadlineMs ?? GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS
+ )
+ // Why: a pending status timer must not hold the process open at shutdown.
+ timer.unref?.()
+ })
+ const settled = await Promise.race([input.total, deadline])
+ clearTimeout(timer)
+ if (settled !== BRANCH_LINE_TOTAL_PENDING) {
+ return settled
+ }
+ void input.total.then((late) => {
+ if (late) {
+ input.onLateArrival(late)
+ }
+ })
+ return undefined
+}
+
+/**
+ * `git diff ` with no second rev and no `--cached` compares that
+ * commit to the working tree, so committed + staged + unstaged collapse into one
+ * correctly-deduplicated result. Summing the per-area status rows instead would
+ * double-count any line touched in two areas.
+ *
+ * `-M` only (not `-M -C`): matches the working-tree numstat the CHANGES rows are
+ * built from, and avoids `-C`'s cost on wide diffs. The trailing `--` keeps the
+ * OID parsed as a rev even if a path of the same name exists.
+ */
+export function buildGitBranchLineTotalDiffArgs(mergeBase: string): string[] {
+ return ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', mergeBase, '--']
+}
+
+/**
+ * The merge base reaches the host as untrusted RPC input and is spliced into a
+ * git argv. Only an object name shape is ever legitimate here, so anything else
+ * (notably a leading `-`) is rejected before it can act as a flag.
+ */
+export function isGitBranchLineTotalMergeBase(value: unknown): value is string {
+ return typeof value === 'string' && /^[0-9a-f]{7,64}$/.test(value)
+}
+
+/** Reads the request param without trusting its type; undefined disables the work entirely. */
+export function readGitBranchLineTotalMergeBaseParam(value: unknown): string | undefined {
+ return isGitBranchLineTotalMergeBase(value) ? value : undefined
+}
+
+export function sumGitBranchLineTotal(input: {
+ mergeBase: string
+ tracked: ReadonlyMap
+ untracked: ReadonlyMap
+}): GitBranchLineTotal {
+ let added = 0
+ let removed = 0
+ for (const stats of input.tracked.values()) {
+ // Binary files parse to undefined in numstat and contribute nothing, matching
+ // the per-file rows.
+ added += stats.added ?? 0
+ removed += stats.removed ?? 0
+ }
+ for (const stats of input.untracked.values()) {
+ added += stats.added ?? 0
+ removed += stats.removed ?? 0
+ }
+ return { added, removed, mergeBase: input.mergeBase }
+}
+
+// Why: one shared exec per (host, worktree, mergeBase). The renderer's own
+// in-flight refs only dedupe a single renderer's calls; a second window or an
+// fs-watcher burst would otherwise run the ranged diff twice concurrently.
+const rangedNumstatLeaseOwner = new GitStatusReadLeaseOwner