diff --git a/.oxlintrc.json b/.oxlintrc.json index 0f679f17d52..4cdb4f2c0e2 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -150,5 +150,10 @@ } } ], - "ignorePatterns": ["**/node_modules", "**/dist", "**/out", "tests/e2e/.cross-version-checkouts"] + "ignorePatterns": [ + "**/node_modules", + "**/dist", + "**/out", + "tests/e2e/.cross-version-checkouts" + ] } diff --git a/config/scripts/relay-watcher-fault-harness.mjs b/config/scripts/relay-watcher-fault-harness.mjs index b27a1f945d9..12e36f72490 100644 --- a/config/scripts/relay-watcher-fault-harness.mjs +++ b/config/scripts/relay-watcher-fault-harness.mjs @@ -109,7 +109,9 @@ function waitForStdoutSentinel(proc, protocol, stderr) { settled = true proc.stdout.off('data', onData) rejectPromise( - new Error(`process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}`) + new Error( + `process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}` + ) ) } proc.stdout.on('data', onData) @@ -160,7 +162,11 @@ function createRelayClient(entryPath, args, env, protocol) { }) const waitForMessage = (startIndex, predicate, label) => - pollUntil(() => messages.slice(startIndex).find(predicate), label, streams.stderr) + pollUntil( + () => messages.slice(startIndex).find(predicate), + label, + streams.stderr + ) const request = async (method, params = {}) => { const id = nextSequence++ @@ -311,10 +317,8 @@ async function main() { ) await relay.request('fs.watch', { rootPath: watchRoot }) - const firstWatcherPid = await waitForWatcherPid( - pidFile, - undefined, - () => `${daemonStreams.stderr()}\n${relay.stderr()}` + const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, () => + `${daemonStreams.stderr()}\n${relay.stderr()}` ) const beforePath = join(watchRoot, 'before.txt') startIndex = relay.messageCount() @@ -326,10 +330,8 @@ async function main() { const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV' startIndex = relay.messageCount() process.kill(firstWatcherPid, faultSignal) - const replacementWatcherPid = await waitForWatcherPid( - pidFile, - firstWatcherPid, - () => `${daemonStreams.stderr()}\n${relay.stderr()}` + const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, () => + `${daemonStreams.stderr()}\n${relay.stderr()}` ) await relay.waitForNotification(startIndex, 'fs.changed', (params) => Array.isArray(params.events) diff --git a/mobile/src/source-control/mobile-git-status.test.ts b/mobile/src/source-control/mobile-git-status.test.ts index 007fb317ce9..a9e2b554f91 100644 --- a/mobile/src/source-control/mobile-git-status.test.ts +++ b/mobile/src/source-control/mobile-git-status.test.ts @@ -29,8 +29,11 @@ describe('mobile source control status helpers', () => { it('builds sections in the mobile source control order', () => { const sections = buildMobileSourceControlSections(entries) - expect(sections.map((section) => section.title)).toEqual(['Changes', 'Staged Changes']) - expect(sections[0]?.data.map((entry) => entry.path)).toEqual(['a.ts', 'new.ts']) + expect(sections.map((section) => section.title)).toEqual([ + 'Changes', + 'Untracked Files', + 'Staged Changes' + ]) }) it('computes actionable path sets', () => { diff --git a/mobile/src/source-control/mobile-git-status.ts b/mobile/src/source-control/mobile-git-status.ts index 2d7b0795918..65fcf61e211 100644 --- a/mobile/src/source-control/mobile-git-status.ts +++ b/mobile/src/source-control/mobile-git-status.ts @@ -19,7 +19,7 @@ export type MobileSourceControlSection = { unstaged: 'Changes', @@ -59,13 +59,7 @@ export function buildMobileSourceControlSections ({ area, title: AREA_TITLES[area], - data: entries - .filter((entry) => - area === 'unstaged' - ? entry.area === 'unstaged' || entry.area === 'untracked' - : entry.area === area - ) - .sort(compareGitStatusEntries) + data: entries.filter((entry) => entry.area === area).sort(compareGitStatusEntries) })).filter((section) => section.data.length > 0) } diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index f802fecb7e7..8d3a52fa823 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -105,6 +105,7 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings { openLinksInAppPreferencePrompted: false, rightSidebarOpenByDefault: true, sourceControlViewMode: 'list', + sourceControlGroupOrder: 'changes-first', sourceControlCompareAgainstUpstream: false, showTitlebarAppName: true, showTasksButton: true, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 79bd2ea7bbe..47fd0c67850 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -97,6 +97,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings openLinksInAppPreferencePrompted: false, rightSidebarOpenByDefault: true, sourceControlViewMode: 'list', + sourceControlGroupOrder: 'changes-first', sourceControlCompareAgainstUpstream: false, showTitlebarAppName: true, showTasksButton: true, diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 0a48bbbb343..059402ae9ed 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -676,6 +676,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') @@ -2783,6 +2784,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, @@ -5801,21 +5817,15 @@ describe('Store', () => { expect(store.getSettings().sourceControlViewMode).toBe('tree') }) - it('drops retired Source Control order preferences', async () => { - writeDataFile({ - ...getDefaultPersistedState(testState.dir), - settings: { - ...getDefaultPersistedState(testState.dir).settings, - sourceControlGroupOrder: 'changes-first', - sourceControlHierarchyDefaultedV2: true - } - } as never) - + it('updateSettings persists sourceControlGroupOrder as a user setting', async () => { const store = await createStore() - store.flush() - const persisted = readDataFile() as { settings?: Record } - expect(persisted.settings).not.toHaveProperty('sourceControlGroupOrder') - expect(persisted.settings).not.toHaveProperty('sourceControlHierarchyDefaultedV2') + 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 () => { @@ -5873,15 +5883,18 @@ describe('Store', () => { const store = await createStore() expect(store.getSettings().sourceControlViewMode).toBe('list') - store.updateSettings({ sourceControlViewMode: 'tree' }) + expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first') + + 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 @@ -5893,9 +5906,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 565b6fd0032..5d581a7f6e6 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -183,6 +183,7 @@ import { } from '../shared/mobile-pairing-custom-address' 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 { @@ -661,22 +662,12 @@ function readLegacyTerminalScrollbackSettings(settings: unknown): LegacyTerminal : {} } -function stripRetiredSettingsFields( +function stripLegacyTerminalScrollbackBytes( settings: Partial | undefined ): Partial { - const { - terminalScrollbackBytes: _legacyScrollbackBytes, - sourceControlGroupOrder: _sourceControlGroupOrder, - sourceControlHierarchyDefaultedV2: _sourceControlHierarchyDefaultedV2, - ...rest - } = (settings ?? {}) as Partial & { - terminalScrollbackBytes?: unknown - sourceControlGroupOrder?: unknown - sourceControlHierarchyDefaultedV2?: unknown - } + const { terminalScrollbackBytes: _legacyScrollbackBytes, ...rest } = (settings ?? + {}) as Partial & { terminalScrollbackBytes?: unknown } void _legacyScrollbackBytes - void _sourceControlGroupOrder - void _sourceControlHierarchyDefaultedV2 return rest } @@ -3349,6 +3340,15 @@ export class Store { ) { this.loadNeedsSave = true } + const normalizedSourceControlGroupOrder = normalizeSourceControlGroupOrder( + parsed.settings?.sourceControlGroupOrder + ) + if ( + parsed.settings?.sourceControlGroupOrder !== undefined && + parsed.settings.sourceControlGroupOrder !== normalizedSourceControlGroupOrder + ) { + this.loadNeedsSave = true + } result = { ...defaults, ...parsed, @@ -3370,7 +3370,7 @@ export class Store { settings: { ...defaults.settings, // Why (#7977): keep persisted experimentalNewWorktreeCardStyle:true — v1.4.130's onboarding auto-wrote it as a plain boolean, so it's indistinguishable from a real opt-in; only the default changed. - ...stripRetiredSettingsFields(parsed.settings), + ...stripLegacyTerminalScrollbackBytes(parsed.settings), prBotAuthorOverrides: normalizePRBotAuthorOverrides( parsed.settings?.prBotAuthorOverrides ), @@ -3444,6 +3444,7 @@ export class Store { }), notifications: normalizeNotificationSettings(parsed.settings?.notifications), sourceControlAi: migratedSourceControlAi, + sourceControlGroupOrder: normalizedSourceControlGroupOrder, // Why: rollback builds still read commitMessageAi, so refresh the legacy projection from sourceControlAi for compat. commitMessageAi: projectSourceControlAiToLegacyCommitMessageAi( migratedSourceControlAi, @@ -5714,7 +5715,7 @@ export class Store { updates: Partial, options: { notifyListeners?: boolean; originWebContentsId?: number } = {} ): GlobalSettings { - const sanitizedUpdates = stripRetiredSettingsFields(updates) + const sanitizedUpdates = stripLegacyTerminalScrollbackBytes(updates) // Why: coerce to boolean here (not the IPC edge) so every write path is covered and a truthy non-bool can't persist as "tray-minimize on". if ('minimizeToTrayOnClose' in updates) { sanitizedUpdates.minimizeToTrayOnClose = updates.minimizeToTrayOnClose === true @@ -5782,6 +5783,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/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index a8f2e432d94..5426f28d97c 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -10764,7 +10764,10 @@ export class OrcaRuntimeService { // A spawn published (or admission pending) this generation already // attaches the provider stream; a replacement under a reused id must not // read as the discovered never-attached session it replaced. - if (this.spawnPublishedPtys.has(ptyId) || this.pendingPtyRegistrationIncarnations.has(ptyId)) { + if ( + this.spawnPublishedPtys.has(ptyId) || + this.pendingPtyRegistrationIncarnations.has(ptyId) + ) { return false } // SSH panes have their own lease/reattach machinery. diff --git a/src/renderer/src/components/editor/CombinedDiffFileTree.tsx b/src/renderer/src/components/editor/CombinedDiffFileTree.tsx index dd6f7b99b93..fc59c2dd75c 100644 --- a/src/renderer/src/components/editor/CombinedDiffFileTree.tsx +++ b/src/renderer/src/components/editor/CombinedDiffFileTree.tsx @@ -10,7 +10,7 @@ import { compactSourceControlTree, flattenSourceControlTree } from '@/components/right-sidebar/source-control-tree' -import type { GitBranchChangeEntry, GitStagingArea } from '../../../../shared/types' +import type { GitBranchChangeEntry, GitStagingArea, GitStatusEntry } from '../../../../shared/types' import { getEntryExtension, getFilteredCombinedDiffFileTreeEntries, @@ -22,11 +22,6 @@ import { import { CombinedDiffFileTreeRow, type CombinedDiffTreeNode } from './combined-diff-file-tree-row' import { useCombinedDiffFileTreeResize } from './use-combined-diff-file-tree-resize' import { translate } from '@/i18n/i18n' -import { - mergeUntrackedIntoChanges, - SOURCE_CONTROL_GROUP_ORDER, - type SourceControlEntryGroups -} from '@/components/right-sidebar/source-control-section-order' export { createCombinedDiffSectionIndexMap, @@ -35,6 +30,7 @@ export { handleCombinedDiffFileTreeNavigation } from './combined-diff-file-tree-model' +const UNCOMMITTED_AREA_ORDER: readonly GitStagingArea[] = ['unstaged', 'staged', 'untracked'] const UNCOMMITTED_AREA_LABELS: Record = { unstaged: 'Changes', staged: 'Staged Changes', @@ -43,35 +39,26 @@ const UNCOMMITTED_AREA_LABELS: Record = { function buildUncommittedRows( entries: readonly CombinedDiffFileTreeEntry[], - collapsedDirectoryKeys: ReadonlySet, - areaOrder: readonly GitStagingArea[] + collapsedDirectoryKeys: ReadonlySet ): { area: GitStagingArea; label: string; rows: CombinedDiffTreeNode[] }[] { - const groups: SourceControlEntryGroups = { staged: [], unstaged: [], untracked: [] } - for (const entry of entries) { - if (isGitStatusEntry(entry)) { - groups[entry.area].push(entry) - } - } - const displayGroups = mergeUntrackedIntoChanges(groups) - - return areaOrder - .map((area) => { - const areaEntries = displayGroups[area] - if (areaEntries.length === 0) { - return null - } - - const roots = compactSourceControlTree(buildGitStatusSourceControlTree(area, areaEntries)) - return { - area, - label: UNCOMMITTED_AREA_LABELS[area], - rows: flattenSourceControlTree(roots, collapsedDirectoryKeys) as CombinedDiffTreeNode[] - } - }) - .filter( - (group): group is { area: GitStagingArea; label: string; rows: CombinedDiffTreeNode[] } => - Boolean(group) + return UNCOMMITTED_AREA_ORDER.map((area) => { + const areaEntries = entries.filter( + (entry): entry is GitStatusEntry => isGitStatusEntry(entry) && entry.area === area ) + if (areaEntries.length === 0) { + return null + } + + const roots = compactSourceControlTree(buildGitStatusSourceControlTree(area, areaEntries)) + return { + area, + label: UNCOMMITTED_AREA_LABELS[area], + rows: flattenSourceControlTree(roots, collapsedDirectoryKeys) as CombinedDiffTreeNode[] + } + }).filter( + (group): group is { area: GitStagingArea; label: string; rows: CombinedDiffTreeNode[] } => + Boolean(group) + ) } function buildBranchRows( @@ -166,7 +153,7 @@ export function CombinedDiffFileTree({ const uncommittedGroups = React.useMemo( () => mode === 'all' || mode === 'uncommitted' - ? buildUncommittedRows(filteredEntries, collapsedDirectoryKeys, SOURCE_CONTROL_GROUP_ORDER) + ? buildUncommittedRows(filteredEntries, collapsedDirectoryKeys) : [], [collapsedDirectoryKeys, filteredEntries, mode] ) diff --git a/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx b/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx index 792799376ea..6914f9b3aba 100644 --- a/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx +++ b/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx @@ -13,10 +13,7 @@ export function GitHubMarkdownComposerEditorPane({ const scrollContainerRef = useRef(null) return ( -
+
entry.area === 'untracked') + ? getDiscardAllPaths(entries, node.area) + : [] } } @@ -1067,6 +1060,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) @@ -1802,19 +1796,13 @@ function SourceControlInner(): React.JSX.Element { [fileFilterState, grouped] ) - const mergedGrouped = useMemo(() => mergeUntrackedIntoChanges(grouped), [grouped]) - const mergedFilteredGrouped = useMemo( - () => mergeUntrackedIntoChanges(filteredGrouped), - [filteredGrouped] - ) - const displaySections = useMemo( - () => buildSourceControlDisplaySections(mergedFilteredGrouped, SOURCE_CONTROL_GROUP_ORDER), - [mergedFilteredGrouped] + () => buildSourceControlDisplaySections(filteredGrouped, sourceControlGroupOrder), + [filteredGrouped, sourceControlGroupOrder] ) const unfilteredDisplaySections = useMemo( - () => buildSourceControlDisplaySections(mergedGrouped, SOURCE_CONTROL_GROUP_ORDER), - [mergedGrouped] + () => buildSourceControlDisplaySections(grouped, sourceControlGroupOrder), + [grouped, sourceControlGroupOrder] ) const unfilteredDisplaySectionsById = useMemo( () => new Map(unfilteredDisplaySections.map((section) => [section.id, section])), @@ -5412,9 +5400,7 @@ function SourceControlInner(): React.JSX.Element { if (!worktreePath || !activeWorktreeId || isExecutingBulk) { return } - const paths = confirmedPaths - ? [...confirmedPaths] - : getDiscardAllPaths(mergedGrouped[area], discardAllAreaFilter(area)) + const paths = confirmedPaths ? [...confirmedPaths] : getDiscardAllPaths(grouped[area], area) if (paths.length === 0) { return } @@ -5486,7 +5472,7 @@ function SourceControlInner(): React.JSX.Element { activeRepoSettings, worktreePath, activeWorktreeId, - mergedGrouped, + grouped, isExecutingBulk, clearSelection, discardMany, @@ -5500,21 +5486,13 @@ function SourceControlInner(): React.JSX.Element { if (!worktreePath || !activeWorktreeId || isExecutingBulk) { return } - const paths = confirmedPaths - ? [...confirmedPaths] - : getDiscardAllPaths(mergedGrouped[area], discardAllAreaFilter(area)) + const paths = confirmedPaths ? [...confirmedPaths] : getDiscardAllPaths(grouped[area], area) if (paths.length === 0) { return } - setPendingDiscard({ - kind: 'area', - area, - paths, - hasUntracked: - area === 'unstaged' && mergedGrouped.unstaged.some((entry) => entry.area === 'untracked') - }) + setPendingDiscard({ kind: 'area', area, paths }) }, - [activeWorktreeId, isExecutingBulk, mergedGrouped, worktreePath] + [activeWorktreeId, grouped, isExecutingBulk, worktreePath] ) const requestDiscardEntry = useCallback( @@ -5936,8 +5914,7 @@ function SourceControlInner(): React.JSX.Element { .filter(isStageableStatusEntry) .map((entry) => entry.path) const unstageAllPaths = getUnstageAllPaths(actionItems) - const discardAllPaths = getDiscardAllPaths(actionItems, discardAllAreaFilter(area)) - const discardHasUntracked = actionItems.some((entry) => entry.area === 'untracked') + const discardAllPaths = getDiscardAllPaths(actionItems, area) const canStageAll = !normalizedFilter && stageAllPaths.length > 0 const canUnstageAll = !normalizedFilter && unstageAllPaths.length > 0 const canRevertAll = !normalizedFilter && discardAllPaths.length > 0 @@ -5960,7 +5937,7 @@ function SourceControlInner(): React.JSX.Element {
{canRevertAll && ( { event.stopPropagation() @@ -6083,12 +6055,11 @@ function SourceControlInner(): React.JSX.Element { isExecutingBulk={isExecutingBulk} isCollapsed={collapsedTreeDirs.has(node.key)} onToggle={() => toggleTreeDir(node.key)} - onRequestDiscardPaths={(discardArea, paths, hasUntracked) => + onRequestDiscardPaths={(discardArea, paths) => setPendingDiscard({ kind: 'area', area: discardArea, - paths, - hasUntracked + paths }) } onStagePaths={handleStageAllPaths} @@ -7727,11 +7698,7 @@ function SourceControlTreeDirectoryRow({ isExecutingBulk: boolean isCollapsed: boolean onToggle: () => void - onRequestDiscardPaths: ( - area: DiscardAllArea, - paths: readonly string[], - hasUntracked: boolean - ) => void + onRequestDiscardPaths: (area: DiscardAllArea, paths: readonly string[]) => void onStagePaths: (paths: readonly string[]) => Promise onUnstagePaths: (paths: readonly string[]) => Promise }): React.JSX.Element { @@ -7770,30 +7737,21 @@ function SourceControlTreeDirectoryRow({
{canDiscard && ( { event.stopPropagation() - onRequestDiscardPaths( - node.area, - actionPaths.discardPaths, - actionPaths.discardHasUntracked - ) + onRequestDiscardPaths(node.area, actionPaths.discardPaths) }} disabled={isExecutingBulk} /> diff --git a/src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts b/src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts index d8f76c48f3f..e808af6da27 100644 --- a/src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts +++ b/src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts @@ -30,15 +30,6 @@ describe('getDiscardAllPaths', () => { expect(getDiscardAllPaths(entries, 'untracked')).toEqual(['c.ts']) }) - it('accepts mixed Changes areas when untracked files are combined', () => { - const entries: GitStatusEntry[] = [ - entry({ path: 'changed.ts', area: 'unstaged' }), - entry({ path: 'new.ts', area: 'untracked', status: 'untracked' }), - entry({ path: 'ready.ts', area: 'staged' }) - ] - expect(getDiscardAllPaths(entries, ['unstaged', 'untracked'])).toEqual(['changed.ts', 'new.ts']) - }) - it('skips entries with an unresolved conflict', () => { const entries: GitStatusEntry[] = [ entry({ path: 'clean.ts', area: 'unstaged' }), diff --git a/src/renderer/src/components/right-sidebar/discard-all-sequence.ts b/src/renderer/src/components/right-sidebar/discard-all-sequence.ts index 073ba321a2a..c52854fcd91 100644 --- a/src/renderer/src/components/right-sidebar/discard-all-sequence.ts +++ b/src/renderer/src/components/right-sidebar/discard-all-sequence.ts @@ -9,13 +9,12 @@ export type DiscardAllArea = 'staged' | 'unstaged' | 'untracked' */ export function getDiscardAllPaths( entries: readonly GitStatusEntry[], - area: DiscardAllArea | readonly DiscardAllArea[] + area: DiscardAllArea ): string[] { - const areas = Array.isArray(area) ? area : [area] return entries .filter( (entry) => - areas.includes(entry.area) && + entry.area === area && entry.conflictStatus !== 'unresolved' && entry.conflictStatus !== 'resolved_locally' ) diff --git a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts index 6f6632feea8..48e28160af6 100644 --- a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts @@ -111,13 +111,4 @@ describe('getDiscardAreaConfirmationCopy', () => { confirmLabel: 'Discard all' }) }) - - it('discloses permanent deletion for combined Changes', () => { - expect(getDiscardAreaConfirmationCopy('unstaged', 3, true)).toEqual({ - title: 'Discard changes and delete untracked files?', - description: - 'Tracked changes will be reverted and untracked files will be permanently deleted. This cannot be undone.', - confirmLabel: 'Delete and discard' - }) - }) }) diff --git a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.ts b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.ts index 03a6805e7f0..ff020c434ec 100644 --- a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.ts +++ b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.ts @@ -62,25 +62,8 @@ export function getDiscardEntryConfirmationCopy( export function getDiscardAreaConfirmationCopy( area: DiscardAllArea, - count: number, - hasUntracked = false + count: number ): DiscardConfirmationCopy { - if (area === 'unstaged' && hasUntracked) { - return { - title: translate( - 'auto.components.right.sidebar.source.control.discard.confirmation.mixedTitle', - 'Discard changes and delete untracked files?' - ), - description: translate( - 'auto.components.right.sidebar.source.control.discard.confirmation.mixedDescription', - 'Tracked changes will be reverted and untracked files will be permanently deleted. This cannot be undone.' - ), - confirmLabel: translate( - 'auto.components.right.sidebar.source.control.discard.confirmation.mixedConfirm', - 'Delete and discard' - ) - } - } switch (area) { case 'untracked': return { diff --git a/src/renderer/src/components/right-sidebar/source-control-discard-dialog.tsx b/src/renderer/src/components/right-sidebar/source-control-discard-dialog.tsx index 25e2366b616..6741a72ac55 100644 --- a/src/renderer/src/components/right-sidebar/source-control-discard-dialog.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-discard-dialog.tsx @@ -20,12 +20,7 @@ import { translate } from '@/i18n/i18n' export type PendingDiscardConfirmation = | { kind: 'entry'; entry: GitStatusEntry } - | { - kind: 'area' - area: DiscardAllArea - paths: readonly string[] - hasUntracked?: boolean - } + | { kind: 'area'; area: DiscardAllArea; paths: readonly string[] } export function focusDiscardDialogConfirmButton( event: Event, @@ -56,11 +51,7 @@ export function SourceControlDiscardDialog({ if (pendingDiscard.kind === 'entry') { return getDiscardEntryConfirmationCopy(pendingDiscard.entry) } - return getDiscardAreaConfirmationCopy( - pendingDiscard.area, - pendingDiscard.paths.length, - pendingDiscard.hasUntracked - ) + return getDiscardAreaConfirmationCopy(pendingDiscard.area, pendingDiscard.paths.length) }, [pendingDiscard]) const PendingDiscardIcon = pendingDiscardCopy?.confirmLabel.startsWith('Delete') ? Trash : Undo2 diff --git a/src/renderer/src/components/right-sidebar/source-control-section-order.test.ts b/src/renderer/src/components/right-sidebar/source-control-section-order.test.ts index 91378833b0d..fa563470d66 100644 --- a/src/renderer/src/components/right-sidebar/source-control-section-order.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-section-order.test.ts @@ -4,8 +4,7 @@ import { buildSourceControlDisplaySections, getConflictReviewEntries, getSourceControlSectionViewAction, - mergeUntrackedIntoChanges, - SOURCE_CONTROL_GROUP_ORDER, + resolveSourceControlGroupOrder, splitPinnedSourceControlConflicts, type SourceControlEntryGroups } from './source-control-section-order' @@ -27,38 +26,37 @@ function groups(partial: Partial): SourceControlEntryG } } -describe('SOURCE_CONTROL_GROUP_ORDER', () => { - it('follows the edit, stage, commit workflow', () => { - expect(SOURCE_CONTROL_GROUP_ORDER).toEqual(['unstaged', 'staged', 'untracked']) +describe('resolveSourceControlGroupOrder', () => { + it('keeps Changes first by default', () => { + expect(resolveSourceControlGroupOrder(undefined)).toEqual(['unstaged', 'staged', 'untracked']) }) -}) -describe('mergeUntrackedIntoChanges', () => { - it('folds untracked entries into Changes without changing their Git area', () => { - const unstaged = entry({ area: 'unstaged', path: 'changed.ts' }) - const untracked = entry({ area: 'untracked', path: 'new.ts', status: 'untracked' }) - const merged = mergeUntrackedIntoChanges( - groups({ unstaged: [unstaged], untracked: [untracked] }) - ) - - expect(merged.unstaged).toEqual([unstaged, untracked]) - expect(merged.untracked).toEqual([]) - expect(merged.unstaged[1]?.area).toBe('untracked') + 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 fixed workflow order for normal sections', () => { + 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' })] }), - SOURCE_CONTROL_GROUP_ORDER + resolveSourceControlGroupOrder('staged-first') ) - expect(sections.map((section) => section.id)).toEqual(['unstaged', 'staged', 'untracked']) + expect(sections.map((section) => section.id)).toEqual(['staged', 'unstaged', 'untracked']) }) it('keeps conflicts pinned before the configured normal order', () => { @@ -76,13 +74,13 @@ describe('buildSourceControlDisplaySections', () => { ], untracked: [entry({ area: 'untracked', path: 'new.ts', status: 'untracked' })] }), - SOURCE_CONTROL_GROUP_ORDER + resolveSourceControlGroupOrder('staged-first') ) expect(sections.map((section) => section.id)).toEqual([ 'conflicts', - 'unstaged', 'staged', + 'unstaged', 'untracked' ]) }) @@ -102,7 +100,10 @@ describe('buildSourceControlDisplaySections', () => { const input = groups({ unstaged: [unresolved, resolved, normal] }) const split = splitPinnedSourceControlConflicts(input) - const sections = buildSourceControlDisplaySections(input, SOURCE_CONTROL_GROUP_ORDER) + 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']) @@ -122,7 +123,10 @@ describe('buildSourceControlDisplaySections', () => { const input = groups({ staged: [resolvedStaged, staged] }) const split = splitPinnedSourceControlConflicts(input) - const sections = buildSourceControlDisplaySections(input, SOURCE_CONTROL_GROUP_ORDER) + const sections = buildSourceControlDisplaySections( + input, + resolveSourceControlGroupOrder('staged-first') + ) expect(split.pinnedConflicts).toEqual([resolvedStaged]) expect(split.normalGroups.staged).toEqual([staged]) @@ -163,7 +167,7 @@ describe('buildSourceControlDisplaySections', () => { entry({ area: 'unstaged', path: 'normal.ts' }) ] }), - SOURCE_CONTROL_GROUP_ORDER + resolveSourceControlGroupOrder('changes-first') ) expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({ @@ -187,7 +191,7 @@ describe('buildSourceControlDisplaySections', () => { const normal = entry({ area: 'unstaged', path: 'normal.ts' }) const sections = buildSourceControlDisplaySections( groups({ unstaged: [pinned, normal] }), - SOURCE_CONTROL_GROUP_ORDER + resolveSourceControlGroupOrder('changes-first') ) expect(getSourceControlSectionViewAction(sections[1]!)).toEqual({ @@ -208,7 +212,7 @@ describe('buildSourceControlDisplaySections', () => { groups({ unstaged: [resolved] }), - SOURCE_CONTROL_GROUP_ORDER + resolveSourceControlGroupOrder('changes-first') ) expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({ @@ -236,7 +240,7 @@ describe('buildSourceControlDisplaySections', () => { staged: [staged], unstaged: [unstaged] }), - SOURCE_CONTROL_GROUP_ORDER + resolveSourceControlGroupOrder('staged-first') ) expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({ diff --git a/src/renderer/src/components/right-sidebar/source-control-section-order.ts b/src/renderer/src/components/right-sidebar/source-control-section-order.ts index 91cc948855b..557db90b556 100644 --- a/src/renderer/src/components/right-sidebar/source-control-section-order.ts +++ b/src/renderer/src/components/right-sidebar/source-control-section-order.ts @@ -1,4 +1,5 @@ -import type { GitStatusEntry } from '../../../../shared/types' +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] @@ -21,23 +22,16 @@ export type SourceControlSectionViewAction = | { kind: 'conflict-review'; entries: SourceControlConflictReviewEntry[] } | { kind: 'combined-diff'; area?: SourceControlSectionArea; entries: GitStatusEntry[] } -export const SOURCE_CONTROL_GROUP_ORDER: readonly SourceControlSectionArea[] = [ - 'unstaged', - 'staged', - 'untracked' -] +const ORDER_BY_PRESET: Record = { + 'changes-first': ['unstaged', 'staged', 'untracked'], + 'staged-first': ['staged', 'unstaged', 'untracked'], + 'untracked-first': ['untracked', 'unstaged', 'staged'] +} -export function mergeUntrackedIntoChanges( - groups: SourceControlEntryGroups -): SourceControlEntryGroups { - if (groups.untracked.length === 0) { - return groups - } - return { - staged: groups.staged, - unstaged: [...groups.unstaged, ...groups.untracked], - untracked: [] - } +export function resolveSourceControlGroupOrder( + value: SourceControlGroupOrder | null | undefined +): readonly SourceControlSectionArea[] { + return ORDER_BY_PRESET[normalizeSourceControlGroupOrder(value)] } export function isPinnedConflictEntry(entry: GitStatusEntry): boolean { diff --git a/src/renderer/src/components/settings/GitPane.test.ts b/src/renderer/src/components/settings/GitPane.test.ts index 2947f866fdd..3275eb9a183 100644 --- a/src/renderer/src/components/settings/GitPane.test.ts +++ b/src/renderer/src/components/settings/GitPane.test.ts @@ -6,7 +6,12 @@ import { getDefaultSettings } from '../../../../shared/constants' import { translate } from '../../i18n/i18n' import { useAppStore } from '../../store' import { shouldOpenAutoRenameBranchAdvanced } from './AutoRenameBranchFromWorkSetting' -import { GitPane, getGitPaneSearchEntries, shouldShowAutoRenameBranchSetting } from './GitPane' +import { + GitPane, + SourceControlGroupOrderSetting, + getGitPaneSearchEntries, + shouldShowAutoRenameBranchSetting +} from './GitPane' import { TooltipProvider } from '../ui/tooltip' import { matchesSettingsSearch } from './settings-search' import { SettingsSegmentedControl } from './SettingsFormControls' @@ -35,6 +40,23 @@ function visit(node: unknown, cb: (node: ReactElementLike) => void): void { } } +function findSegmentedControl(node: unknown): ReactElementLike { + let found: ReactElementLike | null = null + const label = translate( + 'auto.components.settings.GitPane.sourceControlGroupOrderTitle', + 'Source Control Group Order' + ) + visit(node, (entry) => { + if (entry.type === SettingsSegmentedControl && entry.props.ariaLabel === label) { + found = entry + } + }) + if (!found) { + throw new Error('segmented control not found') + } + return found +} + function findCompareBaseSegmentedControl(node: unknown): ReactElementLike { let found: ReactElementLike | null = null const label = translate( @@ -114,6 +136,52 @@ describe('GitPane', () => { expect(markup).not.toContain('Refresh Local Base Ref') }) + it('renders Source Control group order in Git settings', () => { + const markup = renderGitPane('group order') + + expect(markup).toContain( + translate( + 'auto.components.settings.GitPane.sourceControlGroupOrderTitle', + 'Source Control Group Order' + ) + ) + expect(markup).toContain( + translate('auto.components.settings.GitPane.changesFirst', 'Changes first') + ) + expect(markup).toContain( + translate('auto.components.settings.GitPane.stagedFirst', 'Staged first') + ) + expect(markup).toContain( + translate('auto.components.settings.GitPane.untrackedFirst', 'Untracked first') + ) + }) + + it('updates Source Control group order only when the selected option changes', () => { + const updateSettings = vi.fn() + const element = SourceControlGroupOrderSetting({ + settings: { + ...getDefaultSettings(os.homedir()), + sourceControlGroupOrder: 'changes-first' + }, + updateSettings + }) + + const control = findSegmentedControl(element) + const onChange = control.props.onChange as (value: string) => void + + onChange('staged-first') + expect(updateSettings).toHaveBeenCalledWith({ sourceControlGroupOrder: 'staged-first' }) + + updateSettings.mockClear() + onChange('changes-first') + expect(updateSettings).not.toHaveBeenCalled() + }) + + it('includes Source Control group order search metadata', () => { + expect(matchesSettingsSearch('staged', getGitPaneSearchEntries())).toBe(true) + expect(matchesSettingsSearch('group order', getGitPaneSearchEntries())).toBe(true) + }) + it('renders the default compare base setting in Git settings', () => { const markup = renderGitPane('compare base') diff --git a/src/renderer/src/components/settings/GitPane.tsx b/src/renderer/src/components/settings/GitPane.tsx index 538315e3e7d..da1a34c3fe8 100644 --- a/src/renderer/src/components/settings/GitPane.tsx +++ b/src/renderer/src/components/settings/GitPane.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react' -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' @@ -19,6 +20,7 @@ import { getKeepLocalMainUpToDateTitle } from './keep-local-main-up-to-date-setting' import { translate } from '@/i18n/i18n' +import { SettingsRow, SettingsSegmentedControl } from './SettingsFormControls' export { getGitPaneSearchEntries } @@ -38,6 +40,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, @@ -60,6 +70,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, @@ -225,6 +297,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, compareAgainstUpstreamMatchesSearch(searchQuery) ? ( [ ...translateSearchKeyword('auto.components.settings.git.search.035134fcd9', 'worktree') ] }, + { + title: translate( + 'auto.components.settings.git.search.sourceControlGroupOrderTitle', + 'Source Control Group Order' + ), + description: translate( + 'auto.components.settings.git.search.sourceControlGroupOrderDescription', + 'Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.git.search.groupOrder', 'group order'), + ...translateSearchKeyword( + 'auto.components.settings.git.search.changesFirst', + 'changes first' + ), + ...translateSearchKeyword('auto.components.settings.git.search.stagedFirst', 'staged first'), + ...translateSearchKeyword( + 'auto.components.settings.git.search.untrackedFirst', + 'untracked first' + ), + ...translateSearchKeyword( + 'auto.components.settings.git.search.sourceControl', + 'source control' + ), + ...translateSearchKeyword('auto.components.settings.git.search.gitChanges', 'git changes') + ] + }, { title: translate( 'auto.components.settings.git.search.compareAgainstUpstreamTitle', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 926a8f2e48a..b25a212f7a2 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6206,6 +6206,11 @@ "3d172725cc": "None", "1f32ba27a6": "Custom", "a182c5125e": "Git Username", + "sourceControlGroupOrderTitle": "Source Control Group Order", + "sourceControlGroupOrderDescription": "Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.", + "changesFirst": "Changes first", + "stagedFirst": "Staged first", + "untrackedFirst": "Untracked first", "compareAgainstUpstreamTitle": "Default Compare Base", "compareAgainstUpstreamDescription": "Choose which base Source Control uses by default for committed-change comparisons. Branch upstream follows the current branch automatically and falls back to the repository default branch when no upstream exists. You can still change the compare base per worktree from that worktree's Git panel. Pull Request and rebase targets don't change.", "compareBaseRepositoryDefault": "Repository default", @@ -8481,7 +8486,14 @@ "f83c8937c4": "branch naming", "5ecd91c5ef": "Prefix added to branch names when creating worktrees.", "68bd65fdb8": "Branch Prefix", + "sourceControlGroupOrderTitle": "Source Control Group Order", + "sourceControlGroupOrderDescription": "Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.", + "groupOrder": "group order", + "changesFirst": "changes first", + "stagedFirst": "staged first", + "untrackedFirst": "untracked first", "sourceControl": "source control", + "gitChanges": "git changes", "compareAgainstUpstreamTitle": "Default Compare Base", "compareAgainstUpstreamDescription": "Choose which base Source Control uses by default for committed-change comparisons. Branch upstream follows the current branch automatically and falls back to the repository default branch when no upstream exists. You can still change the compare base per worktree from that worktree's Git panel. Pull Request and rebase targets don't change.", "compareBase": "compare base", @@ -10822,8 +10834,6 @@ "a4e93c21d7": "Current branch: {{value0}}", "c7d4e2f801": "Change base ref: {{value0}}", "f3a1b8c204": "upstream", - "discardAllMixedUntracked": "Discard changes and delete untracked files", - "discardFolderMixedUntracked": "Discard changes and delete untracked files in folder", "createPrIntentEmptyGeneratedBody": "Generated review details did not include a description. Retry Create PR.", "createPrIntentGenerateDetailsFailed": "Could not generate review details. Retry Create PR." }, @@ -11249,10 +11259,7 @@ "40e9357b2a": "This will restore the file from HEAD and discard the deletion. This cannot be undone.", "5c0bdbc4cb": "Restore \"{{value0}}\"?", "d97bf697c9": "This will permanently delete this file. This cannot be undone.", - "96c772bee9": "Delete \"{{value0}}\"?", - "mixedTitle": "Discard changes and delete untracked files?", - "mixedDescription": "Tracked changes will be reverted and untracked files will be permanently deleted. This cannot be undone.", - "mixedConfirm": "Delete and discard" + "96c772bee9": "Delete \"{{value0}}\"?" }, "dialog": { "3bc61dc989": "Cancel", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 60c1f6a7a61..ac0f5c012bf 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -5966,6 +5966,11 @@ "3d172725cc": "Ninguno", "1f32ba27a6": "Personalizado", "a182c5125e": "Usuario de Git", + "sourceControlGroupOrderTitle": "Orden de grupos de control de código fuente", + "sourceControlGroupOrderDescription": "Elige si Cambios, Cambios preparados o Archivos sin seguimiento aparecen primero en Control de código fuente.", + "changesFirst": "Cambios primero", + "stagedFirst": "Cambios preparados primero", + "untrackedFirst": "Archivos sin seguimiento primero", "compareAgainstUpstreamTitle": "Base de comparación predeterminada", "compareAgainstUpstreamDescription": "Elige qué base usa Control de código fuente de forma predeterminada para las comparaciones de cambios confirmados. El upstream de la rama sigue automáticamente la rama actual y recurre a la rama predeterminada del repositorio cuando no hay upstream. Aun así, puedes cambiar la base de comparación por worktree desde el panel Git de ese worktree. Los objetivos de Pull Request y rebase no cambian.", "compareBaseRepositoryDefault": "Predeterminada del repositorio", @@ -8231,7 +8236,14 @@ "f83c8937c4": "nombres de ramas", "5ecd91c5ef": "Prefijo agregado a los nombres de ramas al crear worktrees.", "68bd65fdb8": "Prefijo de rama", + "sourceControlGroupOrderTitle": "Orden de grupos de control de código fuente", + "sourceControlGroupOrderDescription": "Elige si Cambios, Cambios preparados o Archivos sin seguimiento aparecen primero en Control de código fuente.", + "groupOrder": "orden de grupos", + "changesFirst": "cambios primero", + "stagedFirst": "cambios preparados primero", + "untrackedFirst": "archivos sin seguimiento primero", "sourceControl": "control de código fuente", + "gitChanges": "cambios de git", "compareAgainstUpstreamTitle": "Base de comparación predeterminada", "compareAgainstUpstreamDescription": "Elige qué base usa Control de código fuente de forma predeterminada para las comparaciones de cambios confirmados. El upstream de la rama sigue automáticamente la rama actual y recurre a la rama predeterminada del repositorio cuando no hay upstream. Aun así, puedes cambiar la base de comparación por worktree desde el panel Git de ese worktree. Los objetivos de Pull Request y rebase no cambian.", "compareBase": "base de comparación", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e9099533abc..d239b240df0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -5988,6 +5988,11 @@ "3d172725cc": "なし", "1f32ba27a6": "カスタム", "a182c5125e": "Gitのユーザー名", + "sourceControlGroupOrderTitle": "ソース管理のグループ順序", + "sourceControlGroupOrderDescription": "ソース管理で「変更」「ステージ済みの変更」「未追跡ファイル」のどれを先に表示するかを選択します。", + "changesFirst": "変更を先頭", + "stagedFirst": "ステージ済みを先頭", + "untrackedFirst": "未追跡を先頭", "compareAgainstUpstreamTitle": "既定の比較ベース", "compareAgainstUpstreamDescription": "ソース管理がコミット済み変更の比較に既定で使うベースを選択します。ブランチの上流は現在のブランチに自動的に追従し、上流がない場合はリポジトリの既定ブランチにフォールバックします。比較ベースは各 worktree の Git パネルから worktree ごとに変更できます。Pull Request やリベースの対象は変更されません。", "compareBaseRepositoryDefault": "リポジトリ既定", @@ -8253,7 +8258,14 @@ "f83c8937c4": "ブランチの命名", "5ecd91c5ef": "ワークツリーの作成時にブランチ名に追加されるプレフィックス。", "68bd65fdb8": "ブランチプレフィックス", + "sourceControlGroupOrderTitle": "ソース管理のグループ順序", + "sourceControlGroupOrderDescription": "ソース管理で「変更」「ステージ済みの変更」「未追跡ファイル」のどれを先に表示するかを選択します。", + "groupOrder": "グループ順序", + "changesFirst": "変更を先頭", + "stagedFirst": "ステージ済みを先頭", + "untrackedFirst": "未追跡を先頭", "sourceControl": "ソース管理", + "gitChanges": "git の変更", "compareAgainstUpstreamTitle": "既定の比較ベース", "compareAgainstUpstreamDescription": "ソース管理がコミット済み変更の比較に既定で使うベースを選択します。ブランチの上流は現在のブランチに自動的に追従し、上流がない場合はリポジトリの既定ブランチにフォールバックします。比較ベースは各 worktree の Git パネルから worktree ごとに変更できます。Pull Request やリベースの対象は変更されません。", "compareBase": "比較基準", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5c3ab64d74e..77f37c36a19 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -5951,6 +5951,11 @@ "3d172725cc": "없음", "1f32ba27a6": "사용자 정의", "a182c5125e": "Git 사용자 이름", + "sourceControlGroupOrderTitle": "소스 제어 그룹 순서", + "sourceControlGroupOrderDescription": "소스 제어에서 변경 사항, 스테이징된 변경 사항 또는 추적되지 않는 파일 중 무엇을 먼저 표시할지 선택합니다.", + "changesFirst": "변경 사항 먼저", + "stagedFirst": "스테이징된 항목 먼저", + "untrackedFirst": "추적되지 않는 파일 먼저", "compareAgainstUpstreamTitle": "기본 비교 기준", "compareAgainstUpstreamDescription": "소스 제어가 커밋된 변경 사항 비교에 기본으로 사용할 기준을 선택합니다. 브랜치 업스트림은 현재 브랜치를 자동으로 따라가며, 업스트림이 없으면 저장소 기본 브랜치로 돌아갑니다. 비교 기준은 해당 worktree의 Git 패널에서 worktree별로 변경할 수 있습니다. Pull Request 및 리베이스 대상은 변경되지 않습니다.", "compareBaseRepositoryDefault": "저장소 기본값", @@ -8216,7 +8221,14 @@ "f83c8937c4": "브랜치 이름 지정", "5ecd91c5ef": "작업 트리를 생성할 때 브랜치 이름에 접두사가 추가됩니다.", "68bd65fdb8": "브랜치 접두사", + "sourceControlGroupOrderTitle": "소스 제어 그룹 순서", + "sourceControlGroupOrderDescription": "소스 제어에서 변경 사항, 스테이징된 변경 사항 또는 추적되지 않는 파일 중 무엇을 먼저 표시할지 선택합니다.", + "groupOrder": "그룹 순서", + "changesFirst": "변경 사항 먼저", + "stagedFirst": "스테이징된 항목 먼저", + "untrackedFirst": "추적되지 않는 파일 먼저", "sourceControl": "소스 제어", + "gitChanges": "git 변경 사항", "compareAgainstUpstreamTitle": "기본 비교 기준", "compareAgainstUpstreamDescription": "소스 제어가 커밋된 변경 사항 비교에 기본으로 사용할 기준을 선택합니다. 브랜치 업스트림은 현재 브랜치를 자동으로 따라가며, 업스트림이 없으면 저장소 기본 브랜치로 돌아갑니다. 비교 기준은 해당 worktree의 Git 패널에서 worktree별로 변경할 수 있습니다. Pull Request 및 리베이스 대상은 변경되지 않습니다.", "compareBase": "비교 기준", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 6671acc8ff6..b71ed76c0e6 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -5963,6 +5963,11 @@ "3d172725cc": "没有任何", "1f32ba27a6": "自定义", "a182c5125e": "git用户名", + "sourceControlGroupOrderTitle": "源代码管理分组顺序", + "sourceControlGroupOrderDescription": "选择在源代码管理中优先显示“更改”、“已暂存更改”还是“未跟踪文件”。", + "changesFirst": "更改优先", + "stagedFirst": "已暂存优先", + "untrackedFirst": "未跟踪优先", "compareAgainstUpstreamTitle": "默认对比基准", "compareAgainstUpstreamDescription": "选择源代码管理在比较已提交变更时默认使用的基准。分支上游会自动跟随当前分支;如果没有上游,则回退到仓库默认分支。你仍然可以在对应 worktree 的 Git 面板中按 worktree 更改对比基准。Pull Request 和变基目标不会改变。", "compareBaseRepositoryDefault": "仓库默认", @@ -8228,7 +8233,14 @@ "f83c8937c4": "分支命名", "5ecd91c5ef": "创建工作树时添加到分支名称的前缀。", "68bd65fdb8": "分支前缀", + "sourceControlGroupOrderTitle": "源代码管理分组顺序", + "sourceControlGroupOrderDescription": "选择在源代码管理中优先显示“更改”、“已暂存更改”还是“未跟踪文件”。", + "groupOrder": "分组顺序", + "changesFirst": "更改优先", + "stagedFirst": "已暂存优先", + "untrackedFirst": "未跟踪优先", "sourceControl": "源代码管理", + "gitChanges": "git 更改", "compareAgainstUpstreamTitle": "默认对比基准", "compareAgainstUpstreamDescription": "选择源代码管理在比较已提交变更时默认使用的基准。分支上游会自动跟随当前分支;如果没有上游,则回退到仓库默认分支。你仍然可以在对应 worktree 的 Git 面板中按 worktree 更改对比基准。Pull Request 和变基目标不会改变。", "compareBase": "对比基准", diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index 00d7fd39e48..f3a6ff1b89e 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -23,7 +23,9 @@ describe('getDefaultSettings', () => { expect(getDefaultSettings('/tmp').sourceControlViewMode).toBe('list') }) - it('uses a commit-oriented Source Control layout by default', () => {}) + it('keeps Source Control changes first by default', () => { + expect(getDefaultSettings('/tmp').sourceControlGroupOrder).toBe('changes-first') + }) it('defaults mobile pairing to discovered network addresses', () => { expect(getDefaultSettings('/tmp').mobilePairingCustomAddress).toBeNull() diff --git a/src/shared/constants.ts b/src/shared/constants.ts index c7eb50e18f0..728fec9bd61 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -29,6 +29,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' import { DEFAULT_SETUP_AGENT_STARTUP_POLICY } from './setup-agent-startup-policy' import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from './terminal-scrollback-policy' import { DEFAULT_USAGE_PERCENTAGE_DISPLAY } from './usage-percentage-display' @@ -271,6 +272,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { rightSidebarOpenByDefault: true, showGitIgnoredFiles: true, sourceControlViewMode: 'list', + sourceControlGroupOrder: DEFAULT_SOURCE_CONTROL_GROUP_ORDER, sourceControlCompareAgainstUpstream: false, showTitlebarAppName: true, showTasksButton: true, diff --git a/src/shared/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 1c68a69b54b..cfa041d396f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2704,6 +2704,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' @@ -2889,6 +2890,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 /** Compare base defaults to the branch upstream instead of the repo default; affects only the compare/diff view, not the PR/rebase target. Per-user. */ sourceControlCompareAgainstUpstream: boolean /** Whether to show the Orca app name in the titlebar. */ diff --git a/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts b/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts index 151a4b02bbc..5cc07ef0e5b 100644 --- a/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts +++ b/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts @@ -65,9 +65,8 @@ async function installUnreadableOrcaYamlFault(electronApp: ElectronApplication): /** orca.yaml becomes readable again — every later check runs the production handler. */ async function healOrcaYamlRead(electronApp: ElectronApplication): Promise { await electronApp.evaluate(() => { - ;( - globalThis as typeof globalThis & { __orcaE2eOrcaYamlUnreadable?: boolean } - ).__orcaE2eOrcaYamlUnreadable = false + ;(globalThis as typeof globalThis & { __orcaE2eOrcaYamlUnreadable?: boolean }) + .__orcaE2eOrcaYamlUnreadable = false }) }