From 067c88fe557709ea0aee09272ecab06deb9dca83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leynier=20Guti=C3=A9rrez=20Gonz=C3=A1lez?= Date: Fri, 19 Jun 2026 19:50:05 -0600 Subject: [PATCH] feat(source-control): make group order configurable (#2188) Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: brennanb2025 --- .../runtime-home-service.test.ts | 1 + src/main/codex-accounts/service.test.ts | 1 + src/main/persistence.test.ts | 37 ++- src/main/persistence.ts | 16 + .../right-sidebar/SourceControl.tsx | 308 ++++++++---------- .../source-control-section-order.test.ts | 251 ++++++++++++++ .../source-control-section-order.ts | 129 ++++++++ .../right-sidebar/source-control-tree.test.ts | 52 ++- .../right-sidebar/source-control-tree.ts | 49 +++ .../src/components/settings/GitPane.test.ts | 101 +++++- .../src/components/settings/GitPane.tsx | 91 +++++- .../src/components/settings/git-search.ts | 24 ++ src/renderer/src/i18n/locales/en.json | 18 +- src/renderer/src/i18n/locales/es.json | 20 +- src/renderer/src/i18n/locales/ja.json | 20 +- src/renderer/src/i18n/locales/ko.json | 18 +- src/renderer/src/i18n/locales/zh.json | 20 +- src/renderer/src/store/slices/editor.test.ts | 33 ++ src/renderer/src/store/slices/editor.ts | 19 +- src/shared/constants.test.ts | 4 + src/shared/constants.ts | 2 + src/shared/source-control-group-order.test.ts | 20 ++ src/shared/source-control-group-order.ts | 9 + src/shared/types.ts | 3 + 24 files changed, 1038 insertions(+), 208 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/source-control-section-order.test.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control-section-order.ts create mode 100644 src/shared/source-control-group-order.test.ts create mode 100644 src/shared/source-control-group-order.ts diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index dd29c2ebcc3..20e3e85e56c 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -85,6 +85,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings openLinksInAppPreferencePrompted: false, rightSidebarOpenByDefault: true, sourceControlViewMode: 'list', + sourceControlGroupOrder: 'changes-first', showTitlebarAppName: true, showTasksButton: true, floatingTerminalEnabled: false, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 577e0af28d9..f8acfcf1e1c 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -89,6 +89,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings openLinksInAppPreferencePrompted: false, rightSidebarOpenByDefault: true, sourceControlViewMode: 'list', + sourceControlGroupOrder: 'changes-first', showTitlebarAppName: true, showTasksButton: true, floatingTerminalEnabled: false, diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index d63f9cf31ac..9523d407f26 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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 } 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') }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index c99eb434391..ac952c3208c 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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) } diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 89f76302fc7..18cd594da51 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -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 = { 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 type BranchSourceControlTreeNode = SourceControlTreeNode @@ -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>(new Set()) const [baseRefDialogOpen, setBaseRefDialogOpen] = useState(false) const [pendingDiscard, setPendingDiscard] = useState(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> = + {} + 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> = + {} + 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 | 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 ( -
+
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} /> )}
- {items.some((entry) => entry.conflictStatus === 'unresolved') ? ( + {sectionViewAction ? ( - ) : ( - - )} + ) : null} } /> {!isCollapsed && (sourceControlViewMode === 'tree' - ? visibleTreeRowsByArea[area].map((node) => { + ? (visibleTreeRowsBySection[id] ?? []).map((node) => { if (node.type === 'directory') { return ( & { path: string }): GitStatusEntry { + return { + area: 'unstaged', + status: 'modified', + ...partial + } +} + +function groups(partial: Partial): 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] + }) + }) +}) 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 new file mode 100644 index 00000000000..557db90b556 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-section-order.ts @@ -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 + +export type SourceControlDisplaySection = { + id: SourceControlDisplaySectionId + area: SourceControlSectionArea + items: GitStatusEntry[] +} + +export type SourceControlConflictReviewEntry = { + path: string + conflictKind: NonNullable +} + +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 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 + ) +} 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 422c6ceb0a1..f585c1c35ef 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 @@ -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 & { 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') + }) }) 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 15f33ffa354..5668e8d2023 100644 --- a/src/renderer/src/components/right-sidebar/source-control-tree.ts +++ b/src/renderer/src/components/right-sidebar/source-control-tree.ts @@ -215,6 +215,55 @@ export function compactSourceControlTree compactNode(node, 0)) } +export function namespaceSourceControlTreeDirectoryKeys< + Entry extends SourceControlTreeEntry, + Area extends string +>( + nodes: SourceControlTreeNode[], + namespace: string +): SourceControlTreeNode[] { + const namespaceNode = ( + node: SourceControlTreeNode + ): SourceControlTreeNode => { + 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[] +): SourceControlTreeNode[] { + const applyEntryArea = ( + node: SourceControlTreeNode + ): SourceControlTreeNode => { + 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 diff --git a/src/renderer/src/components/settings/GitPane.test.ts b/src/renderer/src/components/settings/GitPane.test.ts index 58789758dce..ab2ef19c1b7 100644 --- a/src/renderer/src/components/settings/GitPane.test.ts +++ b/src/renderer/src/components/settings/GitPane.test.ts @@ -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 +} + +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) + }) }) diff --git a/src/renderer/src/components/settings/GitPane.tsx b/src/renderer/src/components/settings/GitPane.tsx index c4611e0751c..2579350fe73 100644 --- a/src/renderer/src/components/settings/GitPane.tsx +++ b/src/renderer/src/components/settings/GitPane.tsx @@ -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) => 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, @@ -196,6 +268,23 @@ 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, shouldShowAutoRenameBranchSetting(searchQuery, hasUnsavedBranchPromptChanges) ? ( [ ...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'), diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d3d7658ed73..af0a1a19c90 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 81b891b774c..88599463274 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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" } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index a731daaac34..05c20be116d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": "表示" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 810ac22639a..60a7d7784dd 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": "여기에서 연 충돌에서 파생된 로컬 세션 상태입니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 6f76695349f..ab309022418 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": "查看" } } }, diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index 7bad0ba2cac..8117b6a8749 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -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', () => { diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 7e15b9ba5f7..8b431ff7e5f 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -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 = (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 = (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! })) diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index 62e3467021a..5437b717747 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -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) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index b502d4cc71f..6a57d5fb3dd 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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, diff --git a/src/shared/source-control-group-order.test.ts b/src/shared/source-control-group-order.test.ts new file mode 100644 index 00000000000..165d2359c3b --- /dev/null +++ b/src/shared/source-control-group-order.test.ts @@ -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) + }) +}) diff --git a/src/shared/source-control-group-order.ts b/src/shared/source-control-group-order.ts new file mode 100644 index 00000000000..acfe5160ac7 --- /dev/null +++ b/src/shared/source-control-group-order.ts @@ -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 +} diff --git a/src/shared/types.ts b/src/shared/types.ts index a207ba1e6d9..a3c5119e57b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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