-
+ {/* 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..d1e95db8c74 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx
@@ -24,6 +24,7 @@ let issueCache: Record = {}
let prCache: Record = {}
let workspacePortScan: WorkspacePortScanResult | null = null
let settings: Partial | null = null
+let activityStatus = 'inactive'
vi.mock('@/store', () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
@@ -60,7 +61,7 @@ vi.mock('@/components/ui/tooltip', () => ({
}))
vi.mock('./use-worktree-activity-status', () => ({
- useWorktreeActivityStatus: () => 'inactive'
+ useWorktreeActivityStatus: () => activityStatus
}))
vi.mock('./CacheTimer', () => ({
@@ -162,6 +163,7 @@ describe('WorktreeCard linked PR display', () => {
prCache = {}
workspacePortScan = null
settings = null
+ activityStatus = 'inactive'
})
it('keeps linked GH PR status out of the left status slot by default', async () => {
@@ -229,11 +231,10 @@ describe('WorktreeCard linked PR display', () => {
)
expect(unreadMarkup).not.toContain('aria-label="Mark as read"')
- expect(unreadMarkup).toContain('PR checks: Failed · Unread')
+ expect(unreadMarkup).toContain('Inactive · Unread')
+ expect(unreadMarkup).toContain('PR checks: Failed')
expect(unreadMarkup).not.toContain('Mark read')
- expect(unreadMarkup).toContain('size-[13px] translate-x-px')
- expect(unreadMarkup).not.toContain('lucide-bell')
- expect(unreadMarkup).not.toContain('text-amber-500')
+ expect(unreadMarkup).toContain('size-[13px]')
expect(unreadMarkup).toContain('data-worktree-status-lane-unread=""')
expect(unreadMarkup).toContain('data-worktree-unread-alert=""')
expect(unreadMarkup).toContain('bg-amber-500')
@@ -241,7 +242,7 @@ describe('WorktreeCard linked PR display', () => {
expect(getInlineRenameTitleTag(readMarkup)).toContain('font-normal text-foreground/80')
}, 20_000)
- it('shows linked GH PR status in the left status slot before hosted review details are cached when new card style is on', async () => {
+ it('shows linked GH PR status in the title row before hosted review details are cached when new card style is on', async () => {
settings = { experimentalNewWorktreeCardStyle: true }
const { default: WorktreeCard } = await import('./WorktreeCard')
@@ -629,9 +630,10 @@ describe('WorktreeCard linked PR display', () => {
expect(markup).not.toContain('58941')
})
- it('renders linked PR status in the left status slot instead of the right metadata list', async () => {
+ it('renders activity and linked PR status in independent sidebar lanes', async () => {
settings = { experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status']
+ activityStatus = 'active'
hostedReviewCache = {
'local::repo-1::feature/local-branch': {
data: makeHostedReview({ status: 'failure' }),
@@ -645,12 +647,13 @@ describe('WorktreeCard linked PR display', () => {
)
expect(markup).toContain('PR checks: Failed')
- expect(markup).toContain('text-rose-500/85')
+ expect(markup).toContain('Active')
+ expect(markup).toContain('bg-emerald-500')
+ expect(markup).toContain('data-worktree-card-review-status=""')
expect(markup).not.toContain('Linked PR #456')
- expect(markup).not.toContain('CI checks')
})
- it('uses branch PR cache for the status slot before hosted-review metadata warms', async () => {
+ it('uses branch PR cache for the title-row status before hosted-review metadata warms', async () => {
settings = { experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status']
prCache = {
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx
index b64ffd91cc5..26a2d020235 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx
@@ -26,6 +26,7 @@ import { LinearAgentSkillSetupPrompt } from './LinearAgentSkillSetupPrompt'
import WorktreeCardAgents from './WorktreeCardAgents'
import { useWorktreeAgentRows } from './useWorktreeAgentRows'
import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot'
+import { WorktreeCardReviewStatus } from './WorktreeCardReviewStatus'
import { cn } from '@/lib/utils'
import { WorktreeCardSshHostControl } from './WorktreeCardSshHostControl'
import { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation'
@@ -1207,11 +1208,13 @@ const WorktreeCard = React.memo(function WorktreeCard({
!!conflictOperation && conflictOperation !== 'unknown' && conflictOperation !== 'rebase'
const hasMetadataBadge = showConflictOperationBadge
const showUnreadQuickAction = !affiliateListMode && showStatus && !newCardStyle
- // Why: the slot owns the unread/status lane; legacy keeps the bell toggle, the new card keeps the glyph passive.
+ // Why: the slot owns activity and unread; review/check state has its own title-row indicator.
const showCombinedStatusSlot = showStatus
+ const showReviewStatus = newCardStyle && showStatus && statusLaneReview !== null
const showTitleRowPrimary = compactCards && worktree.isMainWorktree && !isFolder
const showMetaRowDetails = !newCardStyle && !compactCards && (hasDetails || hasPorts)
- const showTitleRowIndicators = (newCardStyle || compactCards) && (hasDetails || hasPorts)
+ const showTitleRowIndicators =
+ (newCardStyle || compactCards) && (hasDetails || hasPorts || showReviewStatus)
// Why: grouped views can hide the repo badge; don't reserve a blank metadata lane unless there's real content.
const hasDetailedMetaRowContent = Boolean(
(showRepoBadgeInMetaRow && repo) ||
@@ -1370,7 +1373,12 @@ const WorktreeCard = React.memo(function WorktreeCard({
detailsAndPortsContent
)
const titleRowIndicators = showTitleRowIndicators ? (
- {detailsAndPorts}
+
+ {showReviewStatus && statusLaneReview ? (
+
+ ) : null}
+ {detailsAndPorts}
+
) : null
const hasSecondaryCardContent =
hasMetaRow || !!remoteBranchConflict || showInlineAgentList || showLineageChildChip
@@ -1404,7 +1412,6 @@ const WorktreeCard = React.memo(function WorktreeCard({
unreadTooltip={unreadTooltip}
onPointerDown={stopQuickActionPointerPropagation}
onToggleUnread={handleToggleUnreadQuick}
- prDisplay={statusLaneReview}
newCardStyle={newCardStyle}
hasBranchIdentity={hasPathIdentityEnabled && Boolean(branchIdentityDisplay)}
/>
diff --git a/src/renderer/src/components/sidebar/WorktreeCardReviewStatus.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardReviewStatus.test.tsx
new file mode 100644
index 00000000000..dab06044df9
--- /dev/null
+++ b/src/renderer/src/components/sidebar/WorktreeCardReviewStatus.test.tsx
@@ -0,0 +1,53 @@
+import { renderToStaticMarkup } from 'react-dom/server'
+import type { ReactNode } from 'react'
+import { describe, expect, it, vi } from 'vitest'
+import { WorktreeCardReviewStatus } from './WorktreeCardReviewStatus'
+import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
+
+vi.mock('@/components/ui/tooltip', () => ({
+ Tooltip: ({ children }: { children: ReactNode }) => <>{children}>,
+ TooltipContent: ({ children }: { children: ReactNode }) => <>{children}>,
+ TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}>
+}))
+
+type ReviewOverrides = {
+ provider?: Exclude
+ state?: WorktreeCardPrDisplay['state']
+ status?: WorktreeCardPrDisplay['status']
+}
+
+function renderReview(overrides: ReviewOverrides = {}): string {
+ const review: WorktreeCardPrDisplay = {
+ provider: 'github',
+ number: 123,
+ title: 'Review me',
+ state: 'open',
+ ...overrides
+ }
+ return renderToStaticMarkup()
+}
+
+describe('WorktreeCardReviewStatus', () => {
+ it.each([
+ [{ state: 'merged' }, 'PR: Merged'],
+ [{ state: 'closed' }, 'PR: Closed'],
+ [{ state: 'draft' }, 'PR: Draft'],
+ [{ status: 'failure' }, 'PR checks: Failed'],
+ [{ status: 'pending' }, 'PR checks: Pending'],
+ [{ status: 'success' }, 'PR checks: Passing'],
+ [{}, 'PR: Open']
+ ] as const)('describes review state %o as %s', (overrides, label) => {
+ expect(renderReview(overrides)).toContain(label)
+ })
+
+ it('uses the compact generic review glyph in its own lane', () => {
+ const markup = renderReview({ provider: 'gitlab', status: 'pending' })
+
+ expect(markup).toContain('data-worktree-card-review-status=""')
+ expect(markup).toContain('MR checks: Pending')
+ expect(markup).toContain('viewBox="0 0 16 16"')
+ expect(markup).toContain('size-[13px]')
+ expect(markup).toContain('text-amber-500/85')
+ expect(markup).not.toContain('lucide-git-merge')
+ })
+})
diff --git a/src/renderer/src/components/sidebar/WorktreeCardReviewStatus.tsx b/src/renderer/src/components/sidebar/WorktreeCardReviewStatus.tsx
new file mode 100644
index 00000000000..cab5ba030c1
--- /dev/null
+++ b/src/renderer/src/components/sidebar/WorktreeCardReviewStatus.tsx
@@ -0,0 +1,52 @@
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
+import { getReviewLabel, ReviewIcon } from './worktree-review-helpers'
+
+type WorktreeCardReviewStatusProps = {
+ review: WorktreeCardPrDisplay
+}
+
+function getReviewStatusTooltip(review: WorktreeCardPrDisplay): string {
+ const label = getReviewLabel(review)
+ if (review.state === 'merged') {
+ return `${label}: Merged`
+ }
+ if (review.state === 'closed') {
+ return `${label}: Closed`
+ }
+ if (review.state === 'draft') {
+ return `${label}: Draft`
+ }
+ if (review.status === 'failure') {
+ return `${label} checks: Failed`
+ }
+ if (review.status === 'pending') {
+ return `${label} checks: Pending`
+ }
+ if (review.status === 'success') {
+ return `${label} checks: Passing`
+ }
+ return `${label}: Open`
+}
+
+export function WorktreeCardReviewStatus({
+ review
+}: WorktreeCardReviewStatusProps): React.JSX.Element {
+ const tooltip = getReviewStatusTooltip(review)
+ return (
+
+
+
+
+ {tooltip}
+
+
+
+ {tooltip}
+
+
+ )
+}
diff --git a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx
index 8f204527c9a..bd1971bb614 100644
--- a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx
@@ -2,7 +2,6 @@ import { renderToStaticMarkup } from 'react-dom/server'
import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot'
-import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
const mocks = vi.hoisted(() => ({
status: 'active'
@@ -23,21 +22,6 @@ describe('WorktreeCardStatusSlot', () => {
mocks.status = 'active'
})
- const review: WorktreeCardPrDisplay = {
- provider: 'github',
- number: 123,
- title: 'Review me',
- state: 'open',
- status: 'failure'
- }
- const gitlabReview: WorktreeCardPrDisplay = {
- provider: 'gitlab',
- number: 456,
- title: 'Review me',
- state: 'open',
- status: 'pending'
- }
-
it('lets the unread bell replace the visual status dot by default', () => {
const markup = renderToStaticMarkup(
{
expect(markup).toContain('bg-emerald-500')
})
- it('keeps the quiet active dot ahead of PR status by default', () => {
- const markup = renderToStaticMarkup(
-
- )
-
- expect(markup).toContain('Active')
- expect(markup).toContain('bg-emerald-500')
- expect(markup).not.toContain('PR checks: Failed')
- })
-
- it('keeps the active dot ahead of PR status when new card style is on', () => {
- const markup = renderToStaticMarkup(
-
- )
-
- expect(markup).toContain('Active')
- 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).not.toContain('lucide-git-branch')
})
- it('uses the unified compact review glyph for GitLab MR status', () => {
- mocks.status = 'inactive'
- const markup = renderToStaticMarkup(
-
- )
-
- expect(markup).toContain('MR checks: Pending')
- expect(markup).toContain('viewBox="0 0 16 16"')
- expect(markup).toContain('size-[13px] translate-x-px')
- expect(markup).toContain('text-amber-500/85')
- expect(markup).not.toContain('lucide-git-merge')
- })
-
- it('keeps the done dot ahead of PR status when new card style is on', () => {
+ it('shows done activity in new card style', () => {
mocks.status = 'done'
const markup = renderToStaticMarkup(
{
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
- prDisplay={review}
newCardStyle
/>
)
expect(markup).toContain('Done')
expect(markup).toContain('bg-emerald-500')
- expect(markup).not.toContain('PR checks: Failed')
- })
-
- it('uses PR status instead of the inactive dot when new card style is on', () => {
- mocks.status = 'inactive'
- const markup = renderToStaticMarkup(
-
- )
-
- expect(markup).toContain('PR checks: Failed')
- expect(markup).toContain('text-rose-500/85')
- expect(markup).not.toContain('bg-neutral-500/40')
})
it('uses a branch icon with branch-only tooltip copy on quiet rows', () => {
@@ -363,7 +261,7 @@ describe('WorktreeCardStatusSlot', () => {
expect(markup).not.toContain('lucide-git-branch')
})
- it('keeps working activity ahead of PR status in new card style', () => {
+ it('shows working activity in new card style', () => {
mocks.status = 'working'
const markup = renderToStaticMarkup(
{
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
- prDisplay={review}
newCardStyle
/>
)
@@ -382,10 +279,9 @@ describe('WorktreeCardStatusSlot', () => {
expect(markup).toContain('Working')
expect(markup).toContain('inline-flex size-5 items-center justify-center')
expect(markup).toContain('border-yellow-500')
- expect(markup).not.toContain('PR checks: Failed')
})
- it('keeps permission activity ahead of PR status in new card style', () => {
+ it('shows permission activity in new card style', () => {
mocks.status = 'permission'
const markup = renderToStaticMarkup(
{
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
- prDisplay={review}
newCardStyle
/>
)
@@ -404,59 +299,6 @@ describe('WorktreeCardStatusSlot', () => {
expect(markup).toContain('Needs permission')
expect(markup).toContain('lucide-message-circle-question-mark')
expect(markup).toContain('text-amber-500')
- expect(markup).not.toContain('PR checks: Failed')
- })
-
- it('keeps unread ahead of PR status by default', () => {
- const markup = renderToStaticMarkup(
-
- )
-
- expect(markup).toContain('aria-label="Mark as read"')
- expect(markup).toContain('Mark as read')
- expect(markup).not.toContain('Active · Mark as read')
- expect(markup).not.toContain('PR checks: Failed')
- expect(markup).not.toContain('bg-emerald-500')
- expect(markup).toContain('text-amber-500')
- })
-
- it('overlays an unread badge on PR status when new card style is on', () => {
- mocks.status = 'inactive'
- const markup = renderToStaticMarkup(
-
- )
-
- expect(markup).not.toContain('aria-label="Mark as read"')
- expect(markup).not.toContain('Mark as read')
- expect(markup).toContain('PR checks: Failed · Unread')
- expect(markup).toContain('data-worktree-status-lane-unread=""')
- expect(markup).toContain('data-worktree-unread-alert=""')
- expect(markup).not.toContain('group/unread')
- expect(markup).not.toContain('cursor-pointer')
- expect(markup).toContain('text-rose-500/85')
- expect(markup).toContain('bg-amber-500')
- expect(markup).not.toContain('lucide-bell')
- expect(markup).not.toContain('text-amber-500')
- expect(markup).not.toContain('bg-emerald-500')
})
it('overlays an unread badge on the branch icon in new card style', () => {
diff --git a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx
index 527236c5549..d1a96bcdfea 100644
--- a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx
@@ -3,12 +3,10 @@ import { Bell, GitBranch } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
-import { getWorktreeStatusLabel, type WorktreeStatus } from '@/lib/worktree-status'
+import { getWorktreeStatusLabel } from '@/lib/worktree-status'
import { FilledBellIcon } from './WorktreeCardHelpers'
import StatusIndicator from './StatusIndicator'
import { useWorktreeActivityStatus } from './use-worktree-activity-status'
-import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
-import { getReviewLabel, ReviewIcon } from './worktree-review-helpers'
type WorktreeCardStatusSlotProps = {
worktreeId: string
@@ -18,24 +16,20 @@ type WorktreeCardStatusSlotProps = {
unreadTooltip: string
onToggleUnread: React.MouseEventHandler
onPointerDown: React.PointerEventHandler
- prDisplay?: WorktreeCardPrDisplay | null
newCardStyle?: boolean
hasBranchIdentity?: boolean
branchIdentityLabel?: string
className?: string
}
-// Passive identity only replaces status when the workspace is inactive.
-const IDENTITY_REPLACEABLE_STATUSES = new Set(['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.
+// Why: the passive label names the identity cue without claiming review state.
function getDefaultBranchIdentityLabel(): string {
return translate('auto.components.sidebar.WorktreeCardStatusSlot.branchIdentity', 'Branch')
}
// Why: branch-style SVGs are optically left-heavy; this keeps them aligned with
// the centered activity dots in the shared status column.
-const compactReviewAndBranchStatusIconClassName = 'size-[13px] translate-x-px'
-const branchStatusIconClassName = `${compactReviewAndBranchStatusIconClassName} text-muted-foreground/70`
+const compactBranchStatusIconClassName = 'size-[13px] translate-x-px'
+const branchStatusIconClassName = `${compactBranchStatusIconClassName} text-muted-foreground/70`
// Why: a left-edge badge overlays unread on the status glyph without widening
// the lane or indenting the title; ring-sidebar cuts the dot out from busy icons.
const newCardUnreadAlertClassName =
@@ -64,29 +58,6 @@ function overlayNewCardUnreadStatus(
)
}
-function getReviewStatusTooltip(review: WorktreeCardPrDisplay): string {
- const label = getReviewLabel(review)
- if (review.state === 'merged') {
- return `${label}: Merged`
- }
- if (review.state === 'closed') {
- return `${label}: Closed`
- }
- if (review.state === 'draft') {
- return `${label}: Draft`
- }
- if (review.status === 'failure') {
- return `${label} checks: Failed`
- }
- if (review.status === 'pending') {
- return `${label} checks: Pending`
- }
- if (review.status === 'success') {
- return `${label} checks: Passing`
- }
- return `${label}: Open`
-}
-
export function WorktreeCardStatusSlot({
worktreeId,
showStatus,
@@ -95,7 +66,6 @@ export function WorktreeCardStatusSlot({
unreadTooltip,
onToggleUnread,
onPointerDown,
- prDisplay = null,
newCardStyle = false,
hasBranchIdentity = false,
branchIdentityLabel,
@@ -103,70 +73,43 @@ export function WorktreeCardStatusSlot({
}: WorktreeCardStatusSlotProps): React.JSX.Element | null {
const status = useWorktreeActivityStatus(worktreeId)
const statusLabel = getWorktreeStatusLabel(status) || status
- const canShowReviewStatus =
- newCardStyle && showStatus && prDisplay !== null && IDENTITY_REPLACEABLE_STATUSES.has(status)
const canShowBranchStatus =
- newCardStyle &&
- showStatus &&
- hasBranchIdentity &&
- prDisplay === null &&
- IDENTITY_REPLACEABLE_STATUSES.has(status)
- const passiveStatusLabel =
- canShowReviewStatus && prDisplay
- ? getReviewStatusTooltip(prDisplay)
- : canShowBranchStatus
- ? (branchIdentityLabel ?? getDefaultBranchIdentityLabel())
- : statusLabel
+ newCardStyle && showStatus && hasBranchIdentity && status === 'inactive'
+ const passiveStatusLabel = canShowBranchStatus
+ ? (branchIdentityLabel ?? getDefaultBranchIdentityLabel())
+ : statusLabel
const passiveStatusTooltip =
newCardStyle && isUnread ? `${passiveStatusLabel} · Unread` : passiveStatusLabel
// Why: working and permission already own the new-card status lane, but
// unread state should still surface in tooltip/sr-only copy and reappear afterward.
const showNewCardUnreadAlert =
newCardStyle && isUnread && showStatus && status !== 'working' && status !== 'permission'
- const reviewStatusIconClassName = compactReviewAndBranchStatusIconClassName
const branchStatusIcon =
- const passiveStatus =
- canShowReviewStatus && prDisplay ? (
-
-