feat(source-control): make group order configurable (#2188)

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: brennanb2025 <brennankbenson@gmail.com>
This commit is contained in:
Leynier Gutiérrez González
2026-06-19 18:50:05 -07:00
committed by GitHub
co-authored by Neil brennanb2025
parent 7320c1e8ba
commit 067c88fe55
24 changed files with 1038 additions and 208 deletions
@@ -85,6 +85,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
openLinksInAppPreferencePrompted: false,
rightSidebarOpenByDefault: true,
sourceControlViewMode: 'list',
sourceControlGroupOrder: 'changes-first',
showTitlebarAppName: true,
showTasksButton: true,
floatingTerminalEnabled: false,
+1
View File
@@ -89,6 +89,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
openLinksInAppPreferencePrompted: false,
rightSidebarOpenByDefault: true,
sourceControlViewMode: 'list',
sourceControlGroupOrder: 'changes-first',
showTitlebarAppName: true,
showTasksButton: true,
floatingTerminalEnabled: false,
+35 -2
View File
@@ -470,6 +470,7 @@ describe('Store', () => {
const settings = store.getSettings()
expect(settings.branchPrefix).toBe('git-username')
expect(settings.refreshLocalBaseRefOnWorktreeCreate).toBe(false)
expect(settings.sourceControlGroupOrder).toBe('changes-first')
expect(settings.theme).toBe('system')
expect(settings.appIcon).toBe('classic')
expect(settings.appFontFamily).toBe('Geist')
@@ -2051,6 +2052,21 @@ describe('Store', () => {
expect(store.getSettings().terminalShortcutPolicy).toBe('orca-first')
})
it('normalizes malformed source control group order on load', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: { sourceControlGroupOrder: 'tracked-first' },
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first')
})
it('repairs drifted task provider defaults on load', async () => {
writeDataFile({
schemaVersion: 1,
@@ -3965,6 +3981,17 @@ describe('Store', () => {
expect(store.getSettings().sourceControlViewMode).toBe('tree')
})
it('updateSettings persists sourceControlGroupOrder as a user setting', async () => {
const store = await createStore()
expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first')
store.updateSettings({ sourceControlGroupOrder: 'staged-first' })
expect(store.getSettings().sourceControlGroupOrder).toBe('staged-first')
store.updateSettings({ sourceControlGroupOrder: 'tracked-first' as never })
expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first')
})
it('updateSettings normalizes terminal shortcut policy', async () => {
const store = await createStore()
@@ -4020,16 +4047,18 @@ describe('Store', () => {
const store = await createStore()
expect(store.getSettings().sourceControlViewMode).toBe('list')
expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first')
store.updateSettings({ sourceControlViewMode: 'tree' })
store.updateSettings({ sourceControlViewMode: 'tree', sourceControlGroupOrder: 'staged-first' })
store.flush()
const persisted = readDataFile() as {
settings?: { sourceControlViewMode?: string }
settings?: { sourceControlGroupOrder?: string; sourceControlViewMode?: string }
workspaceSession?: typeof workspaceSession
worktreeMeta?: Record<string, unknown>
}
expect(persisted.settings?.sourceControlViewMode).toBe('tree')
expect(persisted.settings?.sourceControlGroupOrder).toBe('staged-first')
expect(persisted.workspaceSession).toEqual({
...getDefaultWorkspaceSession(),
...workspaceSession
@@ -4041,9 +4070,13 @@ describe('Store', () => {
expect(collectPropertyPaths(persisted, 'sourceControlViewMode')).toEqual([
'settings.sourceControlViewMode'
])
expect(collectPropertyPaths(persisted, 'sourceControlGroupOrder')).toEqual([
'settings.sourceControlGroupOrder'
])
const reloaded = await createStore()
expect(reloaded.getSettings().sourceControlViewMode).toBe('tree')
expect(reloaded.getSettings().sourceControlGroupOrder).toBe('staged-first')
expect(reloaded.getWorkspaceSession().activeWorktreeId).toBe('repo1::/worktree-a')
})
+16
View File
@@ -128,6 +128,7 @@ import { normalizeTaskProviderSettings } from '../shared/task-providers'
import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../shared/auto-rename-branch-from-work-settings'
import { normalizeOpenInApplications } from '../shared/open-in-applications'
import { normalizeTerminalShortcutPolicy } from '../shared/keybindings'
import { normalizeSourceControlGroupOrder } from '../shared/source-control-group-order'
import { normalizeAppIconId } from '../shared/app-icon'
import { normalizeTerminalCustomThemes } from '../shared/terminal-custom-themes'
import {
@@ -2657,6 +2658,15 @@ export class Store {
parsed.settings?.compactWorktreeCards ??
parsed.settings?.experimentalCompactWorktreeCards ??
defaults.settings.compactWorktreeCards
const normalizedSourceControlGroupOrder = normalizeSourceControlGroupOrder(
parsed.settings?.sourceControlGroupOrder
)
if (
parsed.settings?.sourceControlGroupOrder !== undefined &&
parsed.settings.sourceControlGroupOrder !== normalizedSourceControlGroupOrder
) {
this.loadNeedsSave = true
}
result = {
...defaults,
...parsed,
@@ -2732,6 +2742,7 @@ export class Store {
}),
notifications: normalizeNotificationSettings(parsed.settings?.notifications),
sourceControlAi: migratedSourceControlAi,
sourceControlGroupOrder: normalizedSourceControlGroupOrder,
// Why: new builds read sourceControlAi, but rollback builds still
// write commitMessageAi; after merging those writes, refresh the
// legacy projection for continued rollback compatibility.
@@ -4556,6 +4567,11 @@ export class Store {
updates.terminalShortcutPolicy
)
}
if ('sourceControlGroupOrder' in updates) {
sanitizedUpdates.sourceControlGroupOrder = normalizeSourceControlGroupOrder(
updates.sourceControlGroupOrder
)
}
if ('appIcon' in updates) {
sanitizedUpdates.appIcon = normalizeAppIconId(updates.appIcon)
}
@@ -77,11 +77,22 @@ import { getFileTypeIcon } from '@/lib/file-type-icons'
import {
buildGitStatusSourceControlTree,
buildSourceControlTree,
applyGitStatusEntryAreasToSourceControlTree,
collectSourceControlTreeFileEntries,
compactSourceControlTree,
flattenSourceControlTree,
namespaceSourceControlTreeDirectoryKeys,
type SourceControlTreeNode
} from './source-control-tree'
import {
buildSourceControlDisplaySections,
getSourceControlSectionViewAction,
resolveSourceControlGroupOrder,
SOURCE_CONTROL_AREAS,
type SourceControlDisplaySectionId,
type SourceControlEntryGroups,
type SourceControlSectionArea
} from './source-control-section-order'
import {
buildActiveOpenFileSignature,
buildActiveOpenRowKeys
@@ -426,12 +437,7 @@ const PRIMARY_ICONS: Partial<
create_pr: GitPullRequestArrow
}
// Why: unstaged ("Changes") is listed first so that conflict files — which
// are assigned area:'unstaged' by the parser — appear above "Staged Changes".
// This keeps unresolved conflicts visible at the top of the list where the
// user won't miss them.
const SECTION_ORDER = ['unstaged', 'staged', 'untracked'] as const
const SECTION_LABELS: Record<(typeof SECTION_ORDER)[number], { key: string; fallback: string }> = {
const SECTION_LABELS: Record<SourceControlSectionArea, { key: string; fallback: string }> = {
staged: {
key: 'auto.components.right.sidebar.SourceControl.48a003c1b1',
fallback: 'Staged Changes'
@@ -445,6 +451,10 @@ const SECTION_LABELS: Record<(typeof SECTION_ORDER)[number], { key: string; fall
fallback: 'Untracked Files'
}
}
const CONFLICTS_SECTION_LABEL = {
key: 'auto.components.right.sidebar.SourceControl.conflictsSection',
fallback: 'Conflicts'
}
const BRANCH_REFRESH_INTERVAL_MS = 5000
// Why: row action buttons host Radix Tooltip triggers. Keeping the overlay
@@ -543,7 +553,7 @@ export function normalizeSourceControlViewMode(value: unknown): SourceControlVie
type GitStatusSourceControlTreeNode = SourceControlTreeNode<
GitStatusEntry,
(typeof SECTION_ORDER)[number]
SourceControlSectionArea
>
type SourceControlTreeDirectoryNode = Extract<GitStatusSourceControlTreeNode, { type: 'directory' }>
type BranchSourceControlTreeNode = SourceControlTreeNode<GitBranchChangeEntry, 'branch'>
@@ -563,11 +573,8 @@ function getSourceControlDirectoryActionPaths(
): SourceControlDirectoryActionPaths {
const entries = collectSourceControlTreeFileEntries(node)
return {
stagePaths:
node.area === 'unstaged' || node.area === 'untracked'
? getStageAllPaths(entries, node.area)
: [],
unstagePaths: node.area === 'staged' ? getUnstageAllPaths(entries) : [],
stagePaths: entries.filter(isStageableStatusEntry).map((entry) => entry.path),
unstagePaths: getUnstageAllPaths(entries),
discardPaths:
node.area === 'unstaged' || node.area === 'untracked'
? getDiscardAllPaths(entries, node.area)
@@ -919,6 +926,7 @@ function SourceControlInner(): React.JSX.Element {
settings?.sourceControlViewMode
)
const sourceControlViewMode = persistedSourceControlViewMode
const sourceControlGroupOrder = resolveSourceControlGroupOrder(settings?.sourceControlGroupOrder)
const [collapsedTreeDirs, setCollapsedTreeDirs] = useState<Set<string>>(new Set())
const [baseRefDialogOpen, setBaseRefDialogOpen] = useState(false)
const [pendingDiscard, setPendingDiscard] = useState<PendingDiscardConfirmation | null>(null)
@@ -1458,15 +1466,11 @@ function SourceControlInner(): React.JSX.Element {
// create_pr, unmount the composer, and cancel the in-flight generation.
const grouped = useMemo(() => {
const groups = {
staged: [] as GitStatusEntry[],
unstaged: [] as GitStatusEntry[],
untracked: [] as GitStatusEntry[]
}
const groups: SourceControlEntryGroups = { staged: [], unstaged: [], untracked: [] }
for (const entry of entries) {
groups[entry.area].push(entry)
}
for (const area of SECTION_ORDER) {
for (const area of SOURCE_CONTROL_AREAS) {
groups[area].sort(compareGitStatusEntries)
}
return groups
@@ -1484,6 +1488,19 @@ function SourceControlInner(): React.JSX.Element {
[fileFilterState, grouped]
)
const displaySections = useMemo(
() => buildSourceControlDisplaySections(filteredGrouped, sourceControlGroupOrder),
[filteredGrouped, sourceControlGroupOrder]
)
const unfilteredDisplaySections = useMemo(
() => buildSourceControlDisplaySections(grouped, sourceControlGroupOrder),
[grouped, sourceControlGroupOrder]
)
const unfilteredDisplaySectionsById = useMemo(
() => new Map(unfilteredDisplaySections.map((section) => [section.id, section])),
[unfilteredDisplaySections]
)
const filteredBranchEntries = useMemo(
() => filterSourceControlPathEntries(branchEntries, fileFilterState),
[branchEntries, fileFilterState]
@@ -1491,39 +1508,46 @@ function SourceControlInner(): React.JSX.Element {
const flatEntries = useMemo(() => {
const arr: FlatEntry[] = []
for (const area of SECTION_ORDER) {
if (!collapsedSections.has(area)) {
for (const entry of filteredGrouped[area]) {
arr.push({ key: `${area}::${entry.path}`, entry, area })
for (const section of displaySections) {
if (!collapsedSections.has(section.id)) {
for (const entry of section.items) {
arr.push({ key: `${entry.area}::${entry.path}`, entry, area: entry.area })
}
}
}
return arr
}, [filteredGrouped, collapsedSections])
}, [collapsedSections, displaySections])
const treeRootsByArea = useMemo(
() => ({
staged: compactSourceControlTree(
buildGitStatusSourceControlTree('staged', filteredGrouped.staged)
),
unstaged: compactSourceControlTree(
buildGitStatusSourceControlTree('unstaged', filteredGrouped.unstaged)
),
untracked: compactSourceControlTree(
buildGitStatusSourceControlTree('untracked', filteredGrouped.untracked)
const treeRootsBySection = useMemo(() => {
const roots: Partial<Record<SourceControlDisplaySectionId, GitStatusSourceControlTreeNode[]>> =
{}
for (const section of displaySections) {
const sectionRoots = compactSourceControlTree(
buildGitStatusSourceControlTree(section.area, section.items)
)
}),
[filteredGrouped]
)
roots[section.id] =
section.id === 'conflicts'
? applyGitStatusEntryAreasToSourceControlTree(
// Why: conflict rows can mirror normal paths, so their folder
// collapse keys must not share state with normal area sections.
namespaceSourceControlTreeDirectoryKeys(sectionRoots, 'conflicts')
)
: sectionRoots
}
return roots
}, [displaySections])
const visibleTreeRowsByArea = useMemo(
() => ({
staged: flattenSourceControlTree(treeRootsByArea.staged, collapsedTreeDirs),
unstaged: flattenSourceControlTree(treeRootsByArea.unstaged, collapsedTreeDirs),
untracked: flattenSourceControlTree(treeRootsByArea.untracked, collapsedTreeDirs)
}),
[collapsedTreeDirs, treeRootsByArea]
)
const visibleTreeRowsBySection = useMemo(() => {
const rows: Partial<Record<SourceControlDisplaySectionId, GitStatusSourceControlTreeNode[]>> =
{}
for (const section of displaySections) {
rows[section.id] = flattenSourceControlTree(
treeRootsBySection[section.id] ?? [],
collapsedTreeDirs
)
}
return rows
}, [collapsedTreeDirs, displaySections, treeRootsBySection])
const branchTreeRoots = useMemo(
() => compactSourceControlTree(buildSourceControlTree('branch', filteredBranchEntries)),
@@ -1540,18 +1564,24 @@ function SourceControlInner(): React.JSX.Element {
}
const arr: FlatEntry[] = []
for (const area of SECTION_ORDER) {
if (collapsedSections.has(area)) {
for (const section of displaySections) {
if (collapsedSections.has(section.id)) {
continue
}
for (const node of visibleTreeRowsByArea[area]) {
for (const node of visibleTreeRowsBySection[section.id] ?? []) {
if (node.type === 'file') {
arr.push({ key: node.key, entry: node.entry, area: node.area })
}
}
}
return arr
}, [collapsedSections, flatEntries, sourceControlViewMode, visibleTreeRowsByArea])
}, [
collapsedSections,
displaySections,
flatEntries,
sourceControlViewMode,
visibleTreeRowsBySection
])
const [isExecutingBulk, setIsExecutingBulk] = useState(false)
const unresolvedConflicts = useMemo(
@@ -4186,49 +4216,6 @@ function SourceControlInner(): React.JSX.Element {
]
)
// Why: "Stage all" on the Changes section intentionally skips unresolved
// conflict rows. `git add` on a conflicted file silently clears the `u`
// record — the only live signal we have — before the user has reviewed it,
// which mirrors the per-row Stage suppression above.
const handleStageAllInArea = useCallback(
async (area: 'unstaged' | 'untracked') => {
if (!worktreePath || isExecutingBulk) {
return
}
const paths = getStageAllPaths(grouped[area], area)
if (paths.length === 0) {
return
}
setIsExecutingBulk(true)
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
await bulkStageRuntimeGitPaths(
{
// Why: route staging by the repo OWNER host, not the focused runtime.
settings: activeRepoSettings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
},
paths
)
await refreshActiveGitStatusAfterMutation()
clearSelection()
} finally {
setIsExecutingBulk(false)
}
},
[
activeRepoSettings,
worktreePath,
grouped,
activeWorktreeId,
isExecutingBulk,
clearSelection,
refreshActiveGitStatusAfterMutation
]
)
// Why: 'stage' primary stages every unstaged + untracked path in one
// bulkStage call. It bypasses handleActionInvoke because that handler is
// typed to DropdownActionKind and 'stage' is intentionally not in the
@@ -4310,42 +4297,6 @@ function SourceControlInner(): React.JSX.Element {
}
}, [createPrHeaderAction, handleCreatePullRequest, runCreatePrIntent])
const handleUnstageAll = useCallback(async () => {
if (!worktreePath || isExecutingBulk) {
return
}
const paths = getUnstageAllPaths(grouped.staged)
if (paths.length === 0) {
return
}
setIsExecutingBulk(true)
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
await bulkUnstageRuntimeGitPaths(
{
// Why: route unstaging by the repo OWNER host, not the focused runtime.
settings: activeRepoSettings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
},
paths
)
await refreshActiveGitStatusAfterMutation()
clearSelection()
} finally {
setIsExecutingBulk(false)
}
}, [
activeRepoSettings,
worktreePath,
grouped.staged,
activeWorktreeId,
isExecutingBulk,
clearSelection,
refreshActiveGitStatusAfterMutation
])
const branchCompareInFlightRef = useRef(false)
const branchCompareRerunRef = useRef(false)
const branchCompareRunPromiseRef = useRef<Promise<void> | null>(null)
@@ -4986,11 +4937,11 @@ function SourceControlInner(): React.JSX.Element {
)
const requestDiscardAllInArea = useCallback(
(area: DiscardAllArea): void => {
(area: DiscardAllArea, confirmedPaths?: readonly string[]): void => {
if (!worktreePath || !activeWorktreeId || isExecutingBulk) {
return
}
const paths = getDiscardAllPaths(grouped[area], area)
const paths = confirmedPaths ? [...confirmedPaths] : getDiscardAllPaths(grouped[area], area)
if (paths.length === 0) {
return
}
@@ -5413,35 +5364,32 @@ function SourceControlInner(): React.JSX.Element {
{hasFilteredUncommittedEntries && (
<>
{SECTION_ORDER.map((area) => {
const items = filteredGrouped[area]
if (items.length === 0) {
return null
}
const isCollapsed = collapsedSections.has(area)
{displaySections.map((section) => {
const { area, id, items } = section
const isCollapsed = collapsedSections.has(id)
// Why: "Stage all"/"Unstage all" operate on the *unfiltered*
// group for the area — acting on just the filter-visible subset
// would surprise users who don't realize a filter is active.
// The +/- is hidden when the filter is active to avoid that
// mismatch between what's shown and what would be staged.
// Why: visibility and execution both resolve paths through the
// same helpers (`getStageAllPaths`/`getUnstageAllPaths`/
// `getDiscardAllPaths`) so the button can never show for a set
// the handler would then filter to empty.
const stageAllPaths =
area === 'unstaged' || area === 'untracked'
? getStageAllPaths(grouped[area], area)
: []
// same eligibility rules as the handlers so the button can
// never show for a set the handler would then filter to empty.
const actionSection = unfilteredDisplaySectionsById.get(id) ?? section
const actionItems = actionSection.items
const stageAllPaths = actionItems
.filter(isStageableStatusEntry)
.map((entry) => entry.path)
const unstageAllPaths = getUnstageAllPaths(actionItems)
const discardAllPaths = getDiscardAllPaths(actionItems, area)
const canStageAll = !normalizedFilter && stageAllPaths.length > 0
const canUnstageAll =
!normalizedFilter &&
area === 'staged' &&
getUnstageAllPaths(grouped.staged).length > 0
const canRevertAll =
!normalizedFilter && getDiscardAllPaths(grouped[area], area).length > 0
const sectionLabel = SECTION_LABELS[area]
const canUnstageAll = !normalizedFilter && unstageAllPaths.length > 0
const canRevertAll = !normalizedFilter && discardAllPaths.length > 0
const sectionLabel =
id === 'conflicts' ? CONFLICTS_SECTION_LABEL : SECTION_LABELS[area]
const sectionViewAction = getSourceControlSectionViewAction(actionSection)
return (
<div key={area}>
<div key={id}>
<SectionHeader
label={translate(sectionLabel.key, sectionLabel.fallback)}
count={items.length}
@@ -5449,7 +5397,7 @@ function SourceControlInner(): React.JSX.Element {
items.filter((entry) => entry.conflictStatus === 'unresolved').length
}
isCollapsed={isCollapsed}
onToggle={() => toggleSection(area)}
onToggle={() => toggleSection(id)}
actions={
<>
{/* Why: bulk action buttons are hover-only on
@@ -5483,7 +5431,7 @@ function SourceControlInner(): React.JSX.Element {
}
onClick={(event) => {
event.stopPropagation()
requestDiscardAllInArea(area)
requestDiscardAllInArea(area, discardAllPaths)
}}
disabled={isExecutingBulk}
/>
@@ -5497,9 +5445,7 @@ function SourceControlInner(): React.JSX.Element {
)}
onClick={(event) => {
event.stopPropagation()
if (area === 'unstaged' || area === 'untracked') {
void handleStageAllInArea(area)
}
void handleStageAllPaths(stageAllPaths)
}}
disabled={isExecutingBulk}
/>
@@ -5513,22 +5459,42 @@ function SourceControlInner(): React.JSX.Element {
)}
onClick={(event) => {
event.stopPropagation()
void handleUnstageAll()
void handleUnstagePaths(unstageAllPaths)
}}
disabled={isExecutingBulk}
/>
)}
</div>
{items.some((entry) => entry.conflictStatus === 'unresolved') ? (
{sectionViewAction ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-1.5 text-[10px] text-muted-foreground hover:text-foreground"
className={
items.some((entry) => entry.conflictStatus === 'unresolved')
? 'h-6 px-1.5 text-[10px] text-muted-foreground hover:text-foreground'
: 'h-auto px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground'
}
onClick={(e) => {
e.stopPropagation()
if (activeWorktreeId && worktreePath) {
openAllDiffs(activeWorktreeId, worktreePath, undefined, area)
if (!activeWorktreeId || !worktreePath) {
return
}
if (sectionViewAction.kind === 'conflict-review') {
openConflictReview(
activeWorktreeId,
worktreePath,
sectionViewAction.entries,
'live-summary'
)
} else {
openAllDiffs(
activeWorktreeId,
worktreePath,
undefined,
sectionViewAction.area,
sectionViewAction.entries
)
}
}}
>
@@ -5537,31 +5503,13 @@ function SourceControlInner(): React.JSX.Element {
'View all'
)}
</Button>
) : (
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
if (activeWorktreeId && worktreePath) {
openAllDiffs(activeWorktreeId, worktreePath, undefined, area)
}
}}
>
{translate(
'auto.components.right.sidebar.SourceControl.48db37cca9',
'View all'
)}
</Button>
)}
) : null}
</>
}
/>
{!isCollapsed &&
(sourceControlViewMode === 'tree'
? visibleTreeRowsByArea[area].map((node) => {
? (visibleTreeRowsBySection[id] ?? []).map((node) => {
if (node.type === 'directory') {
return (
<SourceControlTreeDirectoryRow
@@ -0,0 +1,251 @@
import { describe, expect, it } from 'vitest'
import type { GitStatusEntry } from '../../../../shared/types'
import {
buildSourceControlDisplaySections,
getConflictReviewEntries,
getSourceControlSectionViewAction,
resolveSourceControlGroupOrder,
splitPinnedSourceControlConflicts,
type SourceControlEntryGroups
} from './source-control-section-order'
function entry(partial: Partial<GitStatusEntry> & { path: string }): GitStatusEntry {
return {
area: 'unstaged',
status: 'modified',
...partial
}
}
function groups(partial: Partial<SourceControlEntryGroups>): SourceControlEntryGroups {
return {
staged: [],
unstaged: [],
untracked: [],
...partial
}
}
describe('resolveSourceControlGroupOrder', () => {
it('keeps Changes first by default', () => {
expect(resolveSourceControlGroupOrder(undefined)).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('buildSourceControlDisplaySections', () => {
it('uses the configured 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')
)
expect(sections.map((section) => section.id)).toEqual(['staged', 'unstaged', 'untracked'])
})
it('keeps conflicts pinned before the configured normal order', () => {
const sections = buildSourceControlDisplaySections(
groups({
staged: [entry({ area: 'staged', path: 'staged.ts' })],
unstaged: [
entry({
area: 'unstaged',
path: 'conflict.ts',
conflictKind: 'both_modified',
conflictStatus: 'unresolved'
}),
entry({ area: 'unstaged', path: 'changed.ts' })
],
untracked: [entry({ area: 'untracked', path: 'new.ts', status: 'untracked' })]
}),
resolveSourceControlGroupOrder('staged-first')
)
expect(sections.map((section) => section.id)).toEqual([
'conflicts',
'staged',
'unstaged',
'untracked'
])
})
it('pins conflict rows and removes them from the normal Changes section', () => {
const unresolved = entry({
area: 'unstaged',
path: 'conflict.ts',
conflictStatus: 'unresolved'
})
const resolved = entry({
area: 'unstaged',
path: 'resolved.ts',
conflictStatus: 'resolved_locally'
})
const normal = entry({ area: 'unstaged', path: 'normal.ts' })
const input = groups({ unstaged: [unresolved, resolved, normal] })
const split = splitPinnedSourceControlConflicts(input)
const sections = buildSourceControlDisplaySections(
input,
resolveSourceControlGroupOrder('changes-first')
)
expect(split.pinnedConflicts.map((item) => item.path)).toEqual(['conflict.ts', 'resolved.ts'])
expect(split.normalGroups.unstaged.map((item) => item.path)).toEqual(['normal.ts'])
expect(sections.map((section) => section.id)).toEqual(['conflicts', 'unstaged'])
expect(sections[0]?.items.map((item) => item.path)).toEqual(['conflict.ts', 'resolved.ts'])
expect(sections[1]?.items.map((item) => item.path)).toEqual(['normal.ts'])
})
it('pins locally resolved staged conflicts and removes them from Staged Changes', () => {
const resolvedStaged = entry({
area: 'staged',
path: 'resolved-staged.ts',
conflictKind: 'both_modified',
conflictStatus: 'resolved_locally'
})
const staged = entry({ area: 'staged', path: 'staged.ts' })
const input = groups({ staged: [resolvedStaged, staged] })
const split = splitPinnedSourceControlConflicts(input)
const sections = buildSourceControlDisplaySections(
input,
resolveSourceControlGroupOrder('staged-first')
)
expect(split.pinnedConflicts).toEqual([resolvedStaged])
expect(split.normalGroups.staged).toEqual([staged])
expect(sections.map((section) => section.id)).toEqual(['conflicts', 'staged'])
expect(sections[0]?.items[0]?.area).toBe('staged')
expect(sections[1]?.items).toEqual([staged])
})
it('builds review entries only for unresolved conflicts', () => {
expect(
getConflictReviewEntries([
entry({
area: 'unstaged',
path: 'conflict.ts',
conflictKind: 'both_modified',
conflictStatus: 'unresolved'
}),
entry({
area: 'unstaged',
path: 'resolved.ts',
conflictKind: 'both_modified',
conflictStatus: 'resolved_locally'
})
])
).toEqual([{ path: 'conflict.ts', conflictKind: 'both_modified' }])
})
it('routes the pinned Conflicts section to conflict review', () => {
const sections = buildSourceControlDisplaySections(
groups({
unstaged: [
entry({
area: 'unstaged',
path: 'conflict.ts',
conflictKind: 'both_modified',
conflictStatus: 'unresolved'
}),
entry({ area: 'unstaged', path: 'normal.ts' })
]
}),
resolveSourceControlGroupOrder('changes-first')
)
expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({
kind: 'conflict-review',
entries: [{ path: 'conflict.ts', conflictKind: 'both_modified' }]
})
expect(getSourceControlSectionViewAction(sections[1]!)).toEqual({
kind: 'combined-diff',
area: 'unstaged',
entries: [entry({ area: 'unstaged', path: 'normal.ts' })]
})
})
it('scopes normal combined-diff actions to the conflict-split section items', () => {
const pinned = entry({
area: 'unstaged',
path: 'resolved.ts',
conflictKind: 'both_modified',
conflictStatus: 'resolved_locally'
})
const normal = entry({ area: 'unstaged', path: 'normal.ts' })
const sections = buildSourceControlDisplaySections(
groups({ unstaged: [pinned, normal] }),
resolveSourceControlGroupOrder('changes-first')
)
expect(getSourceControlSectionViewAction(sections[1]!)).toEqual({
kind: 'combined-diff',
area: 'unstaged',
entries: [normal]
})
})
it('routes locally resolved-only conflict sections to combined diff', () => {
const resolved = entry({
area: 'unstaged',
path: 'resolved.ts',
conflictKind: 'both_modified',
conflictStatus: 'resolved_locally'
})
const sections = buildSourceControlDisplaySections(
groups({
unstaged: [resolved]
}),
resolveSourceControlGroupOrder('changes-first')
)
expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({
kind: 'combined-diff',
area: 'unstaged',
entries: [resolved]
})
})
it('uses a generic combined diff action for mixed-area resolved conflict sections', () => {
const unstaged = entry({
area: 'unstaged',
path: 'resolved-unstaged.ts',
conflictKind: 'both_modified',
conflictStatus: 'resolved_locally'
})
const staged = entry({
area: 'staged',
path: 'resolved-staged.ts',
conflictKind: 'both_modified',
conflictStatus: 'resolved_locally'
})
const sections = buildSourceControlDisplaySections(
groups({
staged: [staged],
unstaged: [unstaged]
}),
resolveSourceControlGroupOrder('staged-first')
)
expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({
kind: 'combined-diff',
entries: [unstaged, staged]
})
})
})
@@ -0,0 +1,129 @@
import { normalizeSourceControlGroupOrder } from '../../../../shared/source-control-group-order'
import type { GitStatusEntry, SourceControlGroupOrder } from '../../../../shared/types'
export const SOURCE_CONTROL_AREAS = ['unstaged', 'staged', 'untracked'] as const
export type SourceControlSectionArea = (typeof SOURCE_CONTROL_AREAS)[number]
export type SourceControlDisplaySectionId = SourceControlSectionArea | 'conflicts'
export type SourceControlEntryGroups = Record<SourceControlSectionArea, GitStatusEntry[]>
export type SourceControlDisplaySection = {
id: SourceControlDisplaySectionId
area: SourceControlSectionArea
items: GitStatusEntry[]
}
export type SourceControlConflictReviewEntry = {
path: string
conflictKind: NonNullable<GitStatusEntry['conflictKind']>
}
export type SourceControlSectionViewAction =
| { kind: 'conflict-review'; entries: SourceControlConflictReviewEntry[] }
| { kind: 'combined-diff'; area?: SourceControlSectionArea; entries: GitStatusEntry[] }
const ORDER_BY_PRESET: Record<SourceControlGroupOrder, readonly SourceControlSectionArea[]> = {
'changes-first': ['unstaged', 'staged', 'untracked'],
'staged-first': ['staged', 'unstaged', 'untracked'],
'untracked-first': ['untracked', 'unstaged', 'staged']
}
export function resolveSourceControlGroupOrder(
value: SourceControlGroupOrder | null | undefined
): readonly SourceControlSectionArea[] {
return ORDER_BY_PRESET[normalizeSourceControlGroupOrder(value)]
}
export function isPinnedConflictEntry(entry: GitStatusEntry): boolean {
return entry.conflictStatus === 'unresolved' || entry.conflictStatus === 'resolved_locally'
}
export function getConflictReviewEntries(
entries: readonly GitStatusEntry[]
): SourceControlConflictReviewEntry[] {
return entries
.filter((entry) => entry.conflictStatus === 'unresolved' && entry.conflictKind)
.map((entry) => ({
path: entry.path,
conflictKind: entry.conflictKind!
}))
}
export function getSourceControlSectionViewAction(
section: SourceControlDisplaySection
): SourceControlSectionViewAction | null {
if (section.id === 'conflicts') {
const entries = getConflictReviewEntries(section.items)
if (entries.length > 0) {
return { kind: 'conflict-review', entries }
}
if (section.items.length === 0) {
return null
}
const [firstItem] = section.items
const area = section.items.every((item) => item.area === firstItem?.area)
? firstItem?.area
: undefined
return area
? { kind: 'combined-diff', area, entries: section.items }
: { kind: 'combined-diff', entries: section.items }
}
return { kind: 'combined-diff', area: section.area, entries: section.items }
}
export type SplitSourceControlGroups = {
pinnedConflicts: GitStatusEntry[]
normalGroups: SourceControlEntryGroups
}
export function splitPinnedSourceControlConflicts(
groups: SourceControlEntryGroups
): SplitSourceControlGroups {
const pinnedConflicts = SOURCE_CONTROL_AREAS.flatMap((area) =>
groups[area].filter(isPinnedConflictEntry)
)
// Why: preserve referential identity of `groups` when nothing is pinned so
// downstream memos (tree rebuilds, etc.) don't fire on every status refresh.
if (pinnedConflicts.length === 0) {
return { pinnedConflicts, normalGroups: groups }
}
return {
pinnedConflicts,
normalGroups: {
staged: groups.staged.filter((entry) => !isPinnedConflictEntry(entry)),
unstaged: groups.unstaged.filter((entry) => !isPinnedConflictEntry(entry)),
untracked: groups.untracked.filter((entry) => !isPinnedConflictEntry(entry))
}
}
}
export function buildSourceControlDisplaySectionsFromSplit(
split: SplitSourceControlGroups,
order: readonly SourceControlSectionArea[]
): SourceControlDisplaySection[] {
const { pinnedConflicts, normalGroups } = split
const sections: SourceControlDisplaySection[] = []
if (pinnedConflicts.length > 0) {
sections.push({ id: 'conflicts', area: 'unstaged', items: pinnedConflicts })
}
for (const area of order) {
const items = normalGroups[area]
if (items.length > 0) {
sections.push({ id: area, area, items })
}
}
return sections
}
export function buildSourceControlDisplaySections(
groups: SourceControlEntryGroups,
order: readonly SourceControlSectionArea[]
): SourceControlDisplaySection[] {
return buildSourceControlDisplaySectionsFromSplit(
splitPinnedSourceControlConflicts(groups),
order
)
}
@@ -3,9 +3,11 @@ import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/ty
import {
buildGitStatusSourceControlTree,
buildSourceControlTree,
applyGitStatusEntryAreasToSourceControlTree,
collectSourceControlTreeFileEntries,
compactSourceControlTree,
flattenSourceControlTree
flattenSourceControlTree,
namespaceSourceControlTreeDirectoryKeys
} from './source-control-tree'
function entry(partial: Partial<GitStatusEntry> & { path: string }): GitStatusEntry {
@@ -138,4 +140,52 @@ describe('buildSourceControlTree', () => {
'file:src/renderer/index.ts'
])
})
it('can namespace directory keys without changing file entry areas', () => {
const tree = compactSourceControlTree(
buildGitStatusSourceControlTree('unstaged', [
entry({
path: 'src/conflict.ts',
conflictKind: 'both_modified',
conflictStatus: 'unresolved'
})
])
)
const namespaced = namespaceSourceControlTreeDirectoryKeys(tree, 'conflicts')
const rows = flattenSourceControlTree(namespaced, new Set())
const directory = rows.find((node) => node.type === 'directory')
const file = rows.find((node) => node.type === 'file')
expect(directory?.key).toBe('dir::conflicts::src')
expect(file?.key).toBe('unstaged::src/conflict.ts')
expect(file?.area).toBe('unstaged')
})
it('can preserve file node areas from mixed conflict section entries', () => {
const tree = compactSourceControlTree(
buildGitStatusSourceControlTree('unstaged', [
entry({
area: 'staged',
path: 'src/resolved.ts',
conflictKind: 'both_modified',
conflictStatus: 'resolved_locally'
})
])
)
const rows = flattenSourceControlTree(
applyGitStatusEntryAreasToSourceControlTree(
namespaceSourceControlTreeDirectoryKeys(tree, 'conflicts')
),
new Set()
)
const directory = rows.find((node) => node.type === 'directory')
const file = rows.find((node) => node.type === 'file')
expect(directory?.key).toBe('dir::conflicts::src')
expect(directory?.area).toBe('unstaged')
expect(file?.key).toBe('staged::src/resolved.ts')
expect(file?.area).toBe('staged')
})
})
@@ -215,6 +215,55 @@ export function compactSourceControlTree<Entry extends SourceControlTreeEntry, A
return nodes.map((node) => compactNode(node, 0))
}
export function namespaceSourceControlTreeDirectoryKeys<
Entry extends SourceControlTreeEntry,
Area extends string
>(
nodes: SourceControlTreeNode<Entry, Area>[],
namespace: string
): SourceControlTreeNode<Entry, Area>[] {
const namespaceNode = (
node: SourceControlTreeNode<Entry, Area>
): SourceControlTreeNode<Entry, Area> => {
if (node.type === 'file') {
return node
}
// Why: pinned conflict folders share git area semantics with Changes, but
// collapse state is UI-section-local and needs a distinct directory key.
return {
...node,
key: `dir::${namespace}::${node.path}`,
children: node.children.map(namespaceNode)
}
}
return nodes.map(namespaceNode)
}
export function applyGitStatusEntryAreasToSourceControlTree(
nodes: SourceControlTreeNode<GitStatusEntry, SourceControlTreeArea>[]
): SourceControlTreeNode<GitStatusEntry, SourceControlTreeArea>[] {
const applyEntryArea = (
node: SourceControlTreeNode<GitStatusEntry, SourceControlTreeArea>
): SourceControlTreeNode<GitStatusEntry, SourceControlTreeArea> => {
if (node.type === 'file') {
return {
...node,
key: `${node.entry.area}::${node.entry.path}`,
area: node.entry.area
}
}
return {
...node,
children: node.children.map(applyEntryArea)
}
}
return nodes.map(applyEntryArea)
}
export function collectSourceControlTreeFileEntries<
Entry extends SourceControlTreeEntry,
Area extends string
@@ -1,11 +1,60 @@
import os from 'node:os'
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import { translate } from '../../i18n/i18n'
import { useAppStore } from '../../store'
import { shouldOpenAutoRenameBranchAdvanced } from './AutoRenameBranchFromWorkSetting'
import { GitPane, shouldShowAutoRenameBranchSetting } from './GitPane'
import {
GitPane,
SourceControlGroupOrderSetting,
getGitPaneSearchEntries,
shouldShowAutoRenameBranchSetting
} from './GitPane'
import { TooltipProvider } from '../ui/tooltip'
import { matchesSettingsSearch } from './settings-search'
import { SettingsSegmentedControl } from './SettingsFormControls'
type ReactElementLike = {
type: unknown
props: Record<string, unknown>
}
function visit(node: unknown, cb: (node: ReactElementLike) => void): void {
if (node == null || typeof node === 'string' || typeof node === 'number') {
return
}
if (Array.isArray(node)) {
node.forEach((entry) => visit(entry, cb))
return
}
const element = node as ReactElementLike
cb(element)
for (const [key, value] of Object.entries(element.props ?? {})) {
if (key.startsWith('on')) {
continue
}
visit(value, cb)
}
}
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 renderGitPane(searchQuery: string): string {
useAppStore.setState({ settingsSearchQuery: searchQuery })
@@ -14,7 +63,7 @@ function renderGitPane(searchQuery: string): string {
TooltipProvider,
null,
React.createElement(GitPane, {
settings: getDefaultSettings('/tmp'),
settings: getDefaultSettings(os.homedir()),
updateSettings: () => {},
writeSourceControlAiSettings: async () => {},
displayedGitUsername: 'brennan',
@@ -68,4 +117,50 @@ describe('GitPane', () => {
expect(markup).toContain('local-only commits')
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)
})
})
@@ -1,5 +1,6 @@
import type { GlobalSettings } from '../../../../shared/types'
import type { GlobalSettings, SourceControlGroupOrder } 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'
@@ -13,6 +14,7 @@ import {
getKeepLocalMainUpToDateTitle
} from './keep-local-main-up-to-date-setting'
import { translate } from '@/i18n/i18n'
import { SettingsRow, SettingsSegmentedControl } from './SettingsFormControls'
export { getGitPaneSearchEntries }
@@ -32,6 +34,14 @@ 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,
@@ -54,6 +64,68 @@ type GitPaneProps = {
settingsSearchQuery?: string
}
export function SourceControlGroupOrderSetting({
settings,
updateSettings
}: {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void | Promise<void>
}): 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 (
<SearchableSetting
title={title}
description={description}
keywords={SOURCE_CONTROL_GROUP_ORDER_KEYWORDS}
className="max-w-none"
>
<SettingsRow
label={title}
description={description}
alignTop
control={
<SettingsSegmentedControl<SourceControlGroupOrder>
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'
)
}
]}
/>
}
/>
</SearchableSetting>
)
}
export function GitPane({
settings,
updateSettings,
@@ -196,6 +268,23 @@ export function GitPane({
</button>
</SearchableSetting>
) : 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
}) ? (
<SourceControlGroupOrderSetting
key="source-control-group-order"
settings={settings}
updateSettings={updateSettings}
/>
) : null,
shouldShowAutoRenameBranchSetting(searchQuery, hasUnsavedBranchPromptChanges) ? (
<AutoRenameBranchFromWorkSetting
key="auto-rename-branch-from-work"
@@ -43,6 +43,30 @@ export const getGitPaneSearchEntries = createLocalizedCatalog(() => [
...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')
]
},
...getAutoRenameBranchSearchEntries(),
{
title: translate('auto.components.settings.git.search.bc7d9f69ce', 'Orca Attribution'),
+16 -2
View File
@@ -5036,7 +5036,12 @@
"f35007e6e8": "git-username",
"3d172725cc": "None",
"1f32ba27a6": "Custom",
"a182c5125e": "Git Username"
"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"
},
"HiddenExperimentalGroup": {
"d0f914a528": "Placeholder toggle",
@@ -7125,7 +7130,15 @@
"1d2fae1fa2": "git username",
"f83c8937c4": "branch naming",
"5ecd91c5ef": "Prefix added to branch names when creating worktrees.",
"68bd65fdb8": "Branch Prefix"
"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"
}
},
"input": {
@@ -8448,6 +8461,7 @@
"a1f2c8d901": "View"
},
"SourceControl": {
"conflictsSection": "Conflicts",
"1406954883": "Clear all notes...",
"cc05b2d088": "Open in File Explorer",
"03194cfff4": "Local session state derived from a conflict you opened here.",
+17 -3
View File
@@ -4999,7 +4999,12 @@
"f35007e6e8": "nombre de usuario git",
"3d172725cc": "Ninguno",
"1f32ba27a6": "Costumbre",
"a182c5125e": "Nombre de usuario"
"a182c5125e": "Nombre de usuario",
"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"
},
"HiddenExperimentalGroup": {
"d0f914a528": "Alternar marcador de posición",
@@ -7088,7 +7093,15 @@
"1d2fae1fa2": "nombre de usuario git",
"f83c8937c4": "denominación de sucursales",
"5ecd91c5ef": "Prefijo agregado a los nombres de las ramas al crear árboles de trabajo.",
"68bd65fdb8": "Prefijo de rama"
"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": "sin seguimiento primero",
"sourceControl": "control de código fuente",
"gitChanges": "cambios de git"
}
},
"input": {
@@ -8448,6 +8461,7 @@
"a1f2c8d901": "Ver"
},
"SourceControl": {
"conflictsSection": "Conflictos",
"1406954883": "Borrar todas las notas...",
"cc05b2d088": "Abrir en el Explorador de archivos",
"03194cfff4": "Estado de la sesión local derivado de un conflicto que abrió aquí.",
@@ -9147,7 +9161,7 @@
}
},
"SourceControlEntryContextMenu": {
"a1f2c8d901": "View"
"a1f2c8d901": "Ver"
}
}
},
+17 -3
View File
@@ -5021,7 +5021,12 @@
"f35007e6e8": "git ユーザー名",
"3d172725cc": "なし",
"1f32ba27a6": "カスタム",
"a182c5125e": "Gitのユーザー名"
"a182c5125e": "Gitのユーザー名",
"sourceControlGroupOrderTitle": "ソース管理のグループ順序",
"sourceControlGroupOrderDescription": "ソース管理で「変更」「ステージ済みの変更」「未追跡ファイル」のどれを先に表示するかを選択します。",
"changesFirst": "変更を先頭",
"stagedFirst": "ステージ済みを先頭",
"untrackedFirst": "未追跡を先頭"
},
"HiddenExperimentalGroup": {
"d0f914a528": "プレースホルダーの切り替え",
@@ -7110,7 +7115,15 @@
"1d2fae1fa2": "git ユーザー名",
"f83c8937c4": "ブランチの命名",
"5ecd91c5ef": "ワークツリーの作成時にブランチ名に追加されるプレフィックス。",
"68bd65fdb8": "ブランチプレフィックス"
"68bd65fdb8": "ブランチプレフィックス",
"sourceControlGroupOrderTitle": "ソース管理のグループ順序",
"sourceControlGroupOrderDescription": "ソース管理で「変更」「ステージ済みの変更」「未追跡ファイル」のどれを先に表示するかを選択します。",
"groupOrder": "グループ順序",
"changesFirst": "変更を先頭",
"stagedFirst": "ステージ済みを先頭",
"untrackedFirst": "未追跡を先頭",
"sourceControl": "ソース管理",
"gitChanges": "git の変更"
}
},
"input": {
@@ -8448,6 +8461,7 @@
"a1f2c8d901": "表示"
},
"SourceControl": {
"conflictsSection": "競合",
"1406954883": "すべてのメモをクリアします...",
"cc05b2d088": "ファイルエクスプローラーで開く",
"03194cfff4": "ここで開いた競合から派生したローカル セッション状態。",
@@ -9147,7 +9161,7 @@
}
},
"SourceControlEntryContextMenu": {
"a1f2c8d901": "View"
"a1f2c8d901": "表示"
}
}
},
+16 -2
View File
@@ -4984,7 +4984,12 @@
"f35007e6e8": "git-username",
"3d172725cc": "없음",
"1f32ba27a6": "Custom",
"a182c5125e": "Git 사용자 이름"
"a182c5125e": "Git 사용자 이름",
"sourceControlGroupOrderTitle": "소스 제어 그룹 순서",
"sourceControlGroupOrderDescription": "소스 제어에서 변경 사항, 스테이징된 변경 사항 또는 추적되지 않는 파일 중 무엇을 먼저 표시할지 선택합니다.",
"changesFirst": "변경 사항 먼저",
"stagedFirst": "스테이징된 항목 먼저",
"untrackedFirst": "추적되지 않는 파일 먼저"
},
"HiddenExperimentalGroup": {
"d0f914a528": "자리 표시자 토글",
@@ -7073,7 +7078,15 @@
"1d2fae1fa2": "Git 사용자 이름",
"f83c8937c4": "브랜치 이름 지정",
"5ecd91c5ef": "작업 트리를 생성할 때 브랜치 이름에 접두사가 추가됩니다.",
"68bd65fdb8": "브랜치 접두사"
"68bd65fdb8": "브랜치 접두사",
"sourceControlGroupOrderTitle": "소스 제어 그룹 순서",
"sourceControlGroupOrderDescription": "소스 제어에서 변경 사항, 스테이징된 변경 사항 또는 추적되지 않는 파일 중 무엇을 먼저 표시할지 선택합니다.",
"groupOrder": "그룹 순서",
"changesFirst": "변경 사항 먼저",
"stagedFirst": "스테이징된 항목 먼저",
"untrackedFirst": "추적되지 않는 파일 먼저",
"sourceControl": "소스 제어",
"gitChanges": "git 변경 사항"
}
},
"input": {
@@ -8448,6 +8461,7 @@
"a1f2c8d901": "보기"
},
"SourceControl": {
"conflictsSection": "충돌",
"1406954883": "모든 메모 지우기...",
"cc05b2d088": "파일 탐색기에서 열기",
"03194cfff4": "여기에서 연 충돌에서 파생된 로컬 세션 상태입니다.",
+17 -3
View File
@@ -4984,7 +4984,12 @@
"f35007e6e8": "git 用户名",
"3d172725cc": "没有任何",
"1f32ba27a6": "自定义",
"a182c5125e": "git用户名"
"a182c5125e": "git用户名",
"sourceControlGroupOrderTitle": "源代码管理分组顺序",
"sourceControlGroupOrderDescription": "选择在源代码管理中优先显示“更改”、“已暂存更改”还是“未跟踪文件”。",
"changesFirst": "更改优先",
"stagedFirst": "已暂存优先",
"untrackedFirst": "未跟踪优先"
},
"HiddenExperimentalGroup": {
"d0f914a528": "占位符切换",
@@ -7073,7 +7078,15 @@
"1d2fae1fa2": "git 用户名",
"f83c8937c4": "分支命名",
"5ecd91c5ef": "创建工作树时添加到分支名称的前缀。",
"68bd65fdb8": "分支前缀"
"68bd65fdb8": "分支前缀",
"sourceControlGroupOrderTitle": "源代码管理分组顺序",
"sourceControlGroupOrderDescription": "选择在源代码管理中优先显示“更改”、“已暂存更改”还是“未跟踪文件”。",
"groupOrder": "分组顺序",
"changesFirst": "更改优先",
"stagedFirst": "已暂存优先",
"untrackedFirst": "未跟踪优先",
"sourceControl": "源代码管理",
"gitChanges": "git 更改"
}
},
"input": {
@@ -8448,6 +8461,7 @@
"a1f2c8d901": "查看"
},
"SourceControl": {
"conflictsSection": "冲突",
"1406954883": "清除所有笔记...",
"cc05b2d088": "在文件资源管理器中打开",
"03194cfff4": "本地会话状态源自您在此处打开的冲突。",
@@ -9147,7 +9161,7 @@
}
},
"SourceControlEntryContextMenu": {
"a1f2c8d901": "View"
"a1f2c8d901": "查看"
}
}
},
@@ -11,6 +11,7 @@ import {
} from '../../runtime/runtime-compatibility-test-fixture'
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import type { GitStatusEntry } from '../../../../shared/types'
const { toastErrorMock } = vi.hoisted(() => ({
toastErrorMock: vi.fn()
@@ -2592,6 +2593,38 @@ describe('createEditorSlice combined diff exclusions', () => {
})
)
})
it('uses a supplied combined diff entry snapshot instead of the whole area', () => {
const store = createEditorStore()
const normalEntry: GitStatusEntry = {
path: 'src/normal.ts',
status: 'modified',
area: 'unstaged'
}
store.getState().setGitStatus('wt-1', {
conflictOperation: 'merge',
entries: [
{
path: 'src/resolved.ts',
status: 'modified',
area: 'unstaged',
conflictKind: 'both_modified',
conflictStatus: 'resolved_locally'
},
normalEntry
]
})
store.getState().openAllDiffs('wt-1', '/repo', undefined, 'unstaged', [normalEntry])
expect(store.getState().openFiles[0]).toEqual(
expect.objectContaining({
id: 'wt-1::all-diffs::uncommitted::unstaged',
uncommittedEntriesSnapshot: [normalEntry],
skippedConflicts: []
})
)
})
})
describe('createEditorSlice remote branch actions', () => {
+11 -8
View File
@@ -500,7 +500,8 @@ export type EditorSlice = {
worktreeId: string,
worktreePath: string,
alternate?: CombinedDiffAlternate,
areaFilter?: string
areaFilter?: string,
entriesSnapshot?: GitStatusEntry[]
) => void
openConflictFile: (
worktreeId: string,
@@ -2630,7 +2631,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
)
},
openAllDiffs: (worktreeId, worktreePath, alternate, areaFilter) => {
openAllDiffs: (worktreeId, worktreePath, alternate, areaFilter, entriesSnapshot) => {
const id = areaFilter
? `${worktreeId}::all-diffs::uncommitted::${areaFilter}`
: `${worktreeId}::all-diffs::uncommitted`
@@ -2640,12 +2641,14 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
] ?? 'All Changes')
: 'All Changes'
set((s) => {
const relevantEntries = (s.gitStatusByWorktree[worktreeId] ?? []).filter((entry) => {
if (areaFilter) {
return entry.area === areaFilter
}
return entry.area !== 'untracked'
})
const relevantEntries =
entriesSnapshot ??
(s.gitStatusByWorktree[worktreeId] ?? []).filter((entry) => {
if (areaFilter) {
return entry.area === areaFilter
}
return entry.area !== 'untracked'
})
const skippedConflicts = relevantEntries
.filter((entry) => entry.conflictStatus === 'unresolved' && entry.conflictKind)
.map((entry) => ({ path: entry.path, conflictKind: entry.conflictKind! }))
+4
View File
@@ -21,6 +21,10 @@ describe('getDefaultSettings', () => {
expect(getDefaultSettings('/tmp').sourceControlViewMode).toBe('list')
})
it('keeps Source Control changes first by default', () => {
expect(getDefaultSettings('/tmp').sourceControlGroupOrder).toBe('changes-first')
})
it('keeps first-work branch auto-renaming on by default for new settings', () => {
expect(getDefaultSettings('/tmp').autoRenameBranchFromWork).toBe(true)
expect(getDefaultSettings('/tmp').autoRenameBranchFromWorkDefaultedOn).toBe(true)
+2
View File
@@ -28,6 +28,7 @@ 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'
export { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults'
export {
@@ -265,6 +266,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
rightSidebarOpenByDefault: true,
showGitIgnoredFiles: true,
sourceControlViewMode: 'list',
sourceControlGroupOrder: DEFAULT_SOURCE_CONTROL_GROUP_ORDER,
showTitlebarAppName: true,
showTasksButton: true,
showAutomationsButton: true,
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_SOURCE_CONTROL_GROUP_ORDER,
normalizeSourceControlGroupOrder
} from './source-control-group-order'
describe('normalizeSourceControlGroupOrder', () => {
it('keeps supported source control group orders', () => {
expect(normalizeSourceControlGroupOrder('changes-first')).toBe('changes-first')
expect(normalizeSourceControlGroupOrder('staged-first')).toBe('staged-first')
expect(normalizeSourceControlGroupOrder('untracked-first')).toBe('untracked-first')
})
it('falls back to the default for malformed values', () => {
expect(normalizeSourceControlGroupOrder('tracked-first')).toBe(
DEFAULT_SOURCE_CONTROL_GROUP_ORDER
)
expect(normalizeSourceControlGroupOrder(undefined)).toBe(DEFAULT_SOURCE_CONTROL_GROUP_ORDER)
})
})
+9
View File
@@ -0,0 +1,9 @@
import type { SourceControlGroupOrder } from './types'
export const DEFAULT_SOURCE_CONTROL_GROUP_ORDER: SourceControlGroupOrder = 'changes-first'
export function normalizeSourceControlGroupOrder(value: unknown): SourceControlGroupOrder {
return value === 'changes-first' || value === 'staged-first' || value === 'untracked-first'
? value
: DEFAULT_SOURCE_CONTROL_GROUP_ORDER
}
+3
View File
@@ -2316,6 +2316,7 @@ export type OpenInApplication = {
}
export type SourceControlViewMode = 'list' | 'tree'
export type SourceControlGroupOrder = 'changes-first' | 'staged-first' | 'untracked-first'
export type LeftSidebarAppearanceMode = 'default' | 'match-terminal' | 'tinted'
@@ -2497,6 +2498,8 @@ export type GlobalSettings = {
showGitIgnoredFiles?: boolean
/** Preferred Source Control changes layout. Per-user, not per-workspace. */
sourceControlViewMode: SourceControlViewMode
/** Preferred Source Control group order. Per-user, not per-workspace. */
sourceControlGroupOrder: SourceControlGroupOrder
/** Whether to show the Orca app name in the titlebar. */
showTitlebarAppName: boolean
/** Why: some users do not use the Tasks feature and prefer to keep the