mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat(source-control): make group order configurable (#2188)
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: brennanb2025 <brennankbenson@gmail.com>
This commit is contained in:
co-authored by
Neil
brennanb2025
parent
7320c1e8ba
commit
067c88fe55
@@ -85,6 +85,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
openLinksInAppPreferencePrompted: false,
|
||||
rightSidebarOpenByDefault: true,
|
||||
sourceControlViewMode: 'list',
|
||||
sourceControlGroupOrder: 'changes-first',
|
||||
showTitlebarAppName: true,
|
||||
showTasksButton: true,
|
||||
floatingTerminalEnabled: false,
|
||||
|
||||
@@ -89,6 +89,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
openLinksInAppPreferencePrompted: false,
|
||||
rightSidebarOpenByDefault: true,
|
||||
sourceControlViewMode: 'list',
|
||||
sourceControlGroupOrder: 'changes-first',
|
||||
showTitlebarAppName: true,
|
||||
showTasksButton: true,
|
||||
floatingTerminalEnabled: false,
|
||||
|
||||
@@ -470,6 +470,7 @@ describe('Store', () => {
|
||||
const settings = store.getSettings()
|
||||
expect(settings.branchPrefix).toBe('git-username')
|
||||
expect(settings.refreshLocalBaseRefOnWorktreeCreate).toBe(false)
|
||||
expect(settings.sourceControlGroupOrder).toBe('changes-first')
|
||||
expect(settings.theme).toBe('system')
|
||||
expect(settings.appIcon).toBe('classic')
|
||||
expect(settings.appFontFamily).toBe('Geist')
|
||||
@@ -2051,6 +2052,21 @@ describe('Store', () => {
|
||||
expect(store.getSettings().terminalShortcutPolicy).toBe('orca-first')
|
||||
})
|
||||
|
||||
it('normalizes malformed source control group order on load', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [],
|
||||
worktreeMeta: {},
|
||||
settings: { sourceControlGroupOrder: 'tracked-first' },
|
||||
ui: {},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
workspaceSession: {}
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first')
|
||||
})
|
||||
|
||||
it('repairs drifted task provider defaults on load', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
@@ -3965,6 +3981,17 @@ describe('Store', () => {
|
||||
expect(store.getSettings().sourceControlViewMode).toBe('tree')
|
||||
})
|
||||
|
||||
it('updateSettings persists sourceControlGroupOrder as a user setting', async () => {
|
||||
const store = await createStore()
|
||||
expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first')
|
||||
|
||||
store.updateSettings({ sourceControlGroupOrder: 'staged-first' })
|
||||
expect(store.getSettings().sourceControlGroupOrder).toBe('staged-first')
|
||||
|
||||
store.updateSettings({ sourceControlGroupOrder: 'tracked-first' as never })
|
||||
expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first')
|
||||
})
|
||||
|
||||
it('updateSettings normalizes terminal shortcut policy', async () => {
|
||||
const store = await createStore()
|
||||
|
||||
@@ -4020,16 +4047,18 @@ describe('Store', () => {
|
||||
|
||||
const store = await createStore()
|
||||
expect(store.getSettings().sourceControlViewMode).toBe('list')
|
||||
expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first')
|
||||
|
||||
store.updateSettings({ sourceControlViewMode: 'tree' })
|
||||
store.updateSettings({ sourceControlViewMode: 'tree', sourceControlGroupOrder: 'staged-first' })
|
||||
store.flush()
|
||||
|
||||
const persisted = readDataFile() as {
|
||||
settings?: { sourceControlViewMode?: string }
|
||||
settings?: { sourceControlGroupOrder?: string; sourceControlViewMode?: string }
|
||||
workspaceSession?: typeof workspaceSession
|
||||
worktreeMeta?: Record<string, unknown>
|
||||
}
|
||||
expect(persisted.settings?.sourceControlViewMode).toBe('tree')
|
||||
expect(persisted.settings?.sourceControlGroupOrder).toBe('staged-first')
|
||||
expect(persisted.workspaceSession).toEqual({
|
||||
...getDefaultWorkspaceSession(),
|
||||
...workspaceSession
|
||||
@@ -4041,9 +4070,13 @@ describe('Store', () => {
|
||||
expect(collectPropertyPaths(persisted, 'sourceControlViewMode')).toEqual([
|
||||
'settings.sourceControlViewMode'
|
||||
])
|
||||
expect(collectPropertyPaths(persisted, 'sourceControlGroupOrder')).toEqual([
|
||||
'settings.sourceControlGroupOrder'
|
||||
])
|
||||
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getSettings().sourceControlViewMode).toBe('tree')
|
||||
expect(reloaded.getSettings().sourceControlGroupOrder).toBe('staged-first')
|
||||
expect(reloaded.getWorkspaceSession().activeWorktreeId).toBe('repo1::/worktree-a')
|
||||
})
|
||||
|
||||
|
||||
@@ -128,6 +128,7 @@ import { normalizeTaskProviderSettings } from '../shared/task-providers'
|
||||
import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../shared/auto-rename-branch-from-work-settings'
|
||||
import { normalizeOpenInApplications } from '../shared/open-in-applications'
|
||||
import { normalizeTerminalShortcutPolicy } from '../shared/keybindings'
|
||||
import { normalizeSourceControlGroupOrder } from '../shared/source-control-group-order'
|
||||
import { normalizeAppIconId } from '../shared/app-icon'
|
||||
import { normalizeTerminalCustomThemes } from '../shared/terminal-custom-themes'
|
||||
import {
|
||||
@@ -2657,6 +2658,15 @@ export class Store {
|
||||
parsed.settings?.compactWorktreeCards ??
|
||||
parsed.settings?.experimentalCompactWorktreeCards ??
|
||||
defaults.settings.compactWorktreeCards
|
||||
const normalizedSourceControlGroupOrder = normalizeSourceControlGroupOrder(
|
||||
parsed.settings?.sourceControlGroupOrder
|
||||
)
|
||||
if (
|
||||
parsed.settings?.sourceControlGroupOrder !== undefined &&
|
||||
parsed.settings.sourceControlGroupOrder !== normalizedSourceControlGroupOrder
|
||||
) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
result = {
|
||||
...defaults,
|
||||
...parsed,
|
||||
@@ -2732,6 +2742,7 @@ export class Store {
|
||||
}),
|
||||
notifications: normalizeNotificationSettings(parsed.settings?.notifications),
|
||||
sourceControlAi: migratedSourceControlAi,
|
||||
sourceControlGroupOrder: normalizedSourceControlGroupOrder,
|
||||
// Why: new builds read sourceControlAi, but rollback builds still
|
||||
// write commitMessageAi; after merging those writes, refresh the
|
||||
// legacy projection for continued rollback compatibility.
|
||||
@@ -4556,6 +4567,11 @@ export class Store {
|
||||
updates.terminalShortcutPolicy
|
||||
)
|
||||
}
|
||||
if ('sourceControlGroupOrder' in updates) {
|
||||
sanitizedUpdates.sourceControlGroupOrder = normalizeSourceControlGroupOrder(
|
||||
updates.sourceControlGroupOrder
|
||||
)
|
||||
}
|
||||
if ('appIcon' in updates) {
|
||||
sanitizedUpdates.appIcon = normalizeAppIconId(updates.appIcon)
|
||||
}
|
||||
|
||||
@@ -77,11 +77,22 @@ import { getFileTypeIcon } from '@/lib/file-type-icons'
|
||||
import {
|
||||
buildGitStatusSourceControlTree,
|
||||
buildSourceControlTree,
|
||||
applyGitStatusEntryAreasToSourceControlTree,
|
||||
collectSourceControlTreeFileEntries,
|
||||
compactSourceControlTree,
|
||||
flattenSourceControlTree,
|
||||
namespaceSourceControlTreeDirectoryKeys,
|
||||
type SourceControlTreeNode
|
||||
} from './source-control-tree'
|
||||
import {
|
||||
buildSourceControlDisplaySections,
|
||||
getSourceControlSectionViewAction,
|
||||
resolveSourceControlGroupOrder,
|
||||
SOURCE_CONTROL_AREAS,
|
||||
type SourceControlDisplaySectionId,
|
||||
type SourceControlEntryGroups,
|
||||
type SourceControlSectionArea
|
||||
} from './source-control-section-order'
|
||||
import {
|
||||
buildActiveOpenFileSignature,
|
||||
buildActiveOpenRowKeys
|
||||
@@ -426,12 +437,7 @@ const PRIMARY_ICONS: Partial<
|
||||
create_pr: GitPullRequestArrow
|
||||
}
|
||||
|
||||
// Why: unstaged ("Changes") is listed first so that conflict files — which
|
||||
// are assigned area:'unstaged' by the parser — appear above "Staged Changes".
|
||||
// This keeps unresolved conflicts visible at the top of the list where the
|
||||
// user won't miss them.
|
||||
const SECTION_ORDER = ['unstaged', 'staged', 'untracked'] as const
|
||||
const SECTION_LABELS: Record<(typeof SECTION_ORDER)[number], { key: string; fallback: string }> = {
|
||||
const SECTION_LABELS: Record<SourceControlSectionArea, { key: string; fallback: string }> = {
|
||||
staged: {
|
||||
key: 'auto.components.right.sidebar.SourceControl.48a003c1b1',
|
||||
fallback: 'Staged Changes'
|
||||
@@ -445,6 +451,10 @@ const SECTION_LABELS: Record<(typeof SECTION_ORDER)[number], { key: string; fall
|
||||
fallback: 'Untracked Files'
|
||||
}
|
||||
}
|
||||
const CONFLICTS_SECTION_LABEL = {
|
||||
key: 'auto.components.right.sidebar.SourceControl.conflictsSection',
|
||||
fallback: 'Conflicts'
|
||||
}
|
||||
|
||||
const BRANCH_REFRESH_INTERVAL_MS = 5000
|
||||
// Why: row action buttons host Radix Tooltip triggers. Keeping the overlay
|
||||
@@ -543,7 +553,7 @@ export function normalizeSourceControlViewMode(value: unknown): SourceControlVie
|
||||
|
||||
type GitStatusSourceControlTreeNode = SourceControlTreeNode<
|
||||
GitStatusEntry,
|
||||
(typeof SECTION_ORDER)[number]
|
||||
SourceControlSectionArea
|
||||
>
|
||||
type SourceControlTreeDirectoryNode = Extract<GitStatusSourceControlTreeNode, { type: 'directory' }>
|
||||
type BranchSourceControlTreeNode = SourceControlTreeNode<GitBranchChangeEntry, 'branch'>
|
||||
@@ -563,11 +573,8 @@ function getSourceControlDirectoryActionPaths(
|
||||
): SourceControlDirectoryActionPaths {
|
||||
const entries = collectSourceControlTreeFileEntries(node)
|
||||
return {
|
||||
stagePaths:
|
||||
node.area === 'unstaged' || node.area === 'untracked'
|
||||
? getStageAllPaths(entries, node.area)
|
||||
: [],
|
||||
unstagePaths: node.area === 'staged' ? getUnstageAllPaths(entries) : [],
|
||||
stagePaths: entries.filter(isStageableStatusEntry).map((entry) => entry.path),
|
||||
unstagePaths: getUnstageAllPaths(entries),
|
||||
discardPaths:
|
||||
node.area === 'unstaged' || node.area === 'untracked'
|
||||
? getDiscardAllPaths(entries, node.area)
|
||||
@@ -919,6 +926,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
settings?.sourceControlViewMode
|
||||
)
|
||||
const sourceControlViewMode = persistedSourceControlViewMode
|
||||
const sourceControlGroupOrder = resolveSourceControlGroupOrder(settings?.sourceControlGroupOrder)
|
||||
const [collapsedTreeDirs, setCollapsedTreeDirs] = useState<Set<string>>(new Set())
|
||||
const [baseRefDialogOpen, setBaseRefDialogOpen] = useState(false)
|
||||
const [pendingDiscard, setPendingDiscard] = useState<PendingDiscardConfirmation | null>(null)
|
||||
@@ -1458,15 +1466,11 @@ function SourceControlInner(): React.JSX.Element {
|
||||
// create_pr, unmount the composer, and cancel the in-flight generation.
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const groups = {
|
||||
staged: [] as GitStatusEntry[],
|
||||
unstaged: [] as GitStatusEntry[],
|
||||
untracked: [] as GitStatusEntry[]
|
||||
}
|
||||
const groups: SourceControlEntryGroups = { staged: [], unstaged: [], untracked: [] }
|
||||
for (const entry of entries) {
|
||||
groups[entry.area].push(entry)
|
||||
}
|
||||
for (const area of SECTION_ORDER) {
|
||||
for (const area of SOURCE_CONTROL_AREAS) {
|
||||
groups[area].sort(compareGitStatusEntries)
|
||||
}
|
||||
return groups
|
||||
@@ -1484,6 +1488,19 @@ function SourceControlInner(): React.JSX.Element {
|
||||
[fileFilterState, grouped]
|
||||
)
|
||||
|
||||
const displaySections = useMemo(
|
||||
() => buildSourceControlDisplaySections(filteredGrouped, sourceControlGroupOrder),
|
||||
[filteredGrouped, sourceControlGroupOrder]
|
||||
)
|
||||
const unfilteredDisplaySections = useMemo(
|
||||
() => buildSourceControlDisplaySections(grouped, sourceControlGroupOrder),
|
||||
[grouped, sourceControlGroupOrder]
|
||||
)
|
||||
const unfilteredDisplaySectionsById = useMemo(
|
||||
() => new Map(unfilteredDisplaySections.map((section) => [section.id, section])),
|
||||
[unfilteredDisplaySections]
|
||||
)
|
||||
|
||||
const filteredBranchEntries = useMemo(
|
||||
() => filterSourceControlPathEntries(branchEntries, fileFilterState),
|
||||
[branchEntries, fileFilterState]
|
||||
@@ -1491,39 +1508,46 @@ function SourceControlInner(): React.JSX.Element {
|
||||
|
||||
const flatEntries = useMemo(() => {
|
||||
const arr: FlatEntry[] = []
|
||||
for (const area of SECTION_ORDER) {
|
||||
if (!collapsedSections.has(area)) {
|
||||
for (const entry of filteredGrouped[area]) {
|
||||
arr.push({ key: `${area}::${entry.path}`, entry, area })
|
||||
for (const section of displaySections) {
|
||||
if (!collapsedSections.has(section.id)) {
|
||||
for (const entry of section.items) {
|
||||
arr.push({ key: `${entry.area}::${entry.path}`, entry, area: entry.area })
|
||||
}
|
||||
}
|
||||
}
|
||||
return arr
|
||||
}, [filteredGrouped, collapsedSections])
|
||||
}, [collapsedSections, displaySections])
|
||||
|
||||
const treeRootsByArea = useMemo(
|
||||
() => ({
|
||||
staged: compactSourceControlTree(
|
||||
buildGitStatusSourceControlTree('staged', filteredGrouped.staged)
|
||||
),
|
||||
unstaged: compactSourceControlTree(
|
||||
buildGitStatusSourceControlTree('unstaged', filteredGrouped.unstaged)
|
||||
),
|
||||
untracked: compactSourceControlTree(
|
||||
buildGitStatusSourceControlTree('untracked', filteredGrouped.untracked)
|
||||
const treeRootsBySection = useMemo(() => {
|
||||
const roots: Partial<Record<SourceControlDisplaySectionId, GitStatusSourceControlTreeNode[]>> =
|
||||
{}
|
||||
for (const section of displaySections) {
|
||||
const sectionRoots = compactSourceControlTree(
|
||||
buildGitStatusSourceControlTree(section.area, section.items)
|
||||
)
|
||||
}),
|
||||
[filteredGrouped]
|
||||
)
|
||||
roots[section.id] =
|
||||
section.id === 'conflicts'
|
||||
? applyGitStatusEntryAreasToSourceControlTree(
|
||||
// Why: conflict rows can mirror normal paths, so their folder
|
||||
// collapse keys must not share state with normal area sections.
|
||||
namespaceSourceControlTreeDirectoryKeys(sectionRoots, 'conflicts')
|
||||
)
|
||||
: sectionRoots
|
||||
}
|
||||
return roots
|
||||
}, [displaySections])
|
||||
|
||||
const visibleTreeRowsByArea = useMemo(
|
||||
() => ({
|
||||
staged: flattenSourceControlTree(treeRootsByArea.staged, collapsedTreeDirs),
|
||||
unstaged: flattenSourceControlTree(treeRootsByArea.unstaged, collapsedTreeDirs),
|
||||
untracked: flattenSourceControlTree(treeRootsByArea.untracked, collapsedTreeDirs)
|
||||
}),
|
||||
[collapsedTreeDirs, treeRootsByArea]
|
||||
)
|
||||
const visibleTreeRowsBySection = useMemo(() => {
|
||||
const rows: Partial<Record<SourceControlDisplaySectionId, GitStatusSourceControlTreeNode[]>> =
|
||||
{}
|
||||
for (const section of displaySections) {
|
||||
rows[section.id] = flattenSourceControlTree(
|
||||
treeRootsBySection[section.id] ?? [],
|
||||
collapsedTreeDirs
|
||||
)
|
||||
}
|
||||
return rows
|
||||
}, [collapsedTreeDirs, displaySections, treeRootsBySection])
|
||||
|
||||
const branchTreeRoots = useMemo(
|
||||
() => compactSourceControlTree(buildSourceControlTree('branch', filteredBranchEntries)),
|
||||
@@ -1540,18 +1564,24 @@ function SourceControlInner(): React.JSX.Element {
|
||||
}
|
||||
|
||||
const arr: FlatEntry[] = []
|
||||
for (const area of SECTION_ORDER) {
|
||||
if (collapsedSections.has(area)) {
|
||||
for (const section of displaySections) {
|
||||
if (collapsedSections.has(section.id)) {
|
||||
continue
|
||||
}
|
||||
for (const node of visibleTreeRowsByArea[area]) {
|
||||
for (const node of visibleTreeRowsBySection[section.id] ?? []) {
|
||||
if (node.type === 'file') {
|
||||
arr.push({ key: node.key, entry: node.entry, area: node.area })
|
||||
}
|
||||
}
|
||||
}
|
||||
return arr
|
||||
}, [collapsedSections, flatEntries, sourceControlViewMode, visibleTreeRowsByArea])
|
||||
}, [
|
||||
collapsedSections,
|
||||
displaySections,
|
||||
flatEntries,
|
||||
sourceControlViewMode,
|
||||
visibleTreeRowsBySection
|
||||
])
|
||||
|
||||
const [isExecutingBulk, setIsExecutingBulk] = useState(false)
|
||||
const unresolvedConflicts = useMemo(
|
||||
@@ -4186,49 +4216,6 @@ function SourceControlInner(): React.JSX.Element {
|
||||
]
|
||||
)
|
||||
|
||||
// Why: "Stage all" on the Changes section intentionally skips unresolved
|
||||
// conflict rows. `git add` on a conflicted file silently clears the `u`
|
||||
// record — the only live signal we have — before the user has reviewed it,
|
||||
// which mirrors the per-row Stage suppression above.
|
||||
const handleStageAllInArea = useCallback(
|
||||
async (area: 'unstaged' | 'untracked') => {
|
||||
if (!worktreePath || isExecutingBulk) {
|
||||
return
|
||||
}
|
||||
const paths = getStageAllPaths(grouped[area], area)
|
||||
if (paths.length === 0) {
|
||||
return
|
||||
}
|
||||
setIsExecutingBulk(true)
|
||||
try {
|
||||
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
|
||||
await bulkStageRuntimeGitPaths(
|
||||
{
|
||||
// Why: route staging by the repo OWNER host, not the focused runtime.
|
||||
settings: activeRepoSettings,
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
connectionId
|
||||
},
|
||||
paths
|
||||
)
|
||||
await refreshActiveGitStatusAfterMutation()
|
||||
clearSelection()
|
||||
} finally {
|
||||
setIsExecutingBulk(false)
|
||||
}
|
||||
},
|
||||
[
|
||||
activeRepoSettings,
|
||||
worktreePath,
|
||||
grouped,
|
||||
activeWorktreeId,
|
||||
isExecutingBulk,
|
||||
clearSelection,
|
||||
refreshActiveGitStatusAfterMutation
|
||||
]
|
||||
)
|
||||
|
||||
// Why: 'stage' primary stages every unstaged + untracked path in one
|
||||
// bulkStage call. It bypasses handleActionInvoke because that handler is
|
||||
// typed to DropdownActionKind and 'stage' is intentionally not in the
|
||||
@@ -4310,42 +4297,6 @@ function SourceControlInner(): React.JSX.Element {
|
||||
}
|
||||
}, [createPrHeaderAction, handleCreatePullRequest, runCreatePrIntent])
|
||||
|
||||
const handleUnstageAll = useCallback(async () => {
|
||||
if (!worktreePath || isExecutingBulk) {
|
||||
return
|
||||
}
|
||||
const paths = getUnstageAllPaths(grouped.staged)
|
||||
if (paths.length === 0) {
|
||||
return
|
||||
}
|
||||
setIsExecutingBulk(true)
|
||||
try {
|
||||
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
|
||||
await bulkUnstageRuntimeGitPaths(
|
||||
{
|
||||
// Why: route unstaging by the repo OWNER host, not the focused runtime.
|
||||
settings: activeRepoSettings,
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
connectionId
|
||||
},
|
||||
paths
|
||||
)
|
||||
await refreshActiveGitStatusAfterMutation()
|
||||
clearSelection()
|
||||
} finally {
|
||||
setIsExecutingBulk(false)
|
||||
}
|
||||
}, [
|
||||
activeRepoSettings,
|
||||
worktreePath,
|
||||
grouped.staged,
|
||||
activeWorktreeId,
|
||||
isExecutingBulk,
|
||||
clearSelection,
|
||||
refreshActiveGitStatusAfterMutation
|
||||
])
|
||||
|
||||
const branchCompareInFlightRef = useRef(false)
|
||||
const branchCompareRerunRef = useRef(false)
|
||||
const branchCompareRunPromiseRef = useRef<Promise<void> | null>(null)
|
||||
@@ -4986,11 +4937,11 @@ function SourceControlInner(): React.JSX.Element {
|
||||
)
|
||||
|
||||
const requestDiscardAllInArea = useCallback(
|
||||
(area: DiscardAllArea): void => {
|
||||
(area: DiscardAllArea, confirmedPaths?: readonly string[]): void => {
|
||||
if (!worktreePath || !activeWorktreeId || isExecutingBulk) {
|
||||
return
|
||||
}
|
||||
const paths = getDiscardAllPaths(grouped[area], area)
|
||||
const paths = confirmedPaths ? [...confirmedPaths] : getDiscardAllPaths(grouped[area], area)
|
||||
if (paths.length === 0) {
|
||||
return
|
||||
}
|
||||
@@ -5413,35 +5364,32 @@ function SourceControlInner(): React.JSX.Element {
|
||||
|
||||
{hasFilteredUncommittedEntries && (
|
||||
<>
|
||||
{SECTION_ORDER.map((area) => {
|
||||
const items = filteredGrouped[area]
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
const isCollapsed = collapsedSections.has(area)
|
||||
{displaySections.map((section) => {
|
||||
const { area, id, items } = section
|
||||
const isCollapsed = collapsedSections.has(id)
|
||||
// Why: "Stage all"/"Unstage all" operate on the *unfiltered*
|
||||
// group for the area — acting on just the filter-visible subset
|
||||
// would surprise users who don't realize a filter is active.
|
||||
// The +/- is hidden when the filter is active to avoid that
|
||||
// mismatch between what's shown and what would be staged.
|
||||
// Why: visibility and execution both resolve paths through the
|
||||
// same helpers (`getStageAllPaths`/`getUnstageAllPaths`/
|
||||
// `getDiscardAllPaths`) so the button can never show for a set
|
||||
// the handler would then filter to empty.
|
||||
const stageAllPaths =
|
||||
area === 'unstaged' || area === 'untracked'
|
||||
? getStageAllPaths(grouped[area], area)
|
||||
: []
|
||||
// same eligibility rules as the handlers so the button can
|
||||
// never show for a set the handler would then filter to empty.
|
||||
const actionSection = unfilteredDisplaySectionsById.get(id) ?? section
|
||||
const actionItems = actionSection.items
|
||||
const stageAllPaths = actionItems
|
||||
.filter(isStageableStatusEntry)
|
||||
.map((entry) => entry.path)
|
||||
const unstageAllPaths = getUnstageAllPaths(actionItems)
|
||||
const discardAllPaths = getDiscardAllPaths(actionItems, area)
|
||||
const canStageAll = !normalizedFilter && stageAllPaths.length > 0
|
||||
const canUnstageAll =
|
||||
!normalizedFilter &&
|
||||
area === 'staged' &&
|
||||
getUnstageAllPaths(grouped.staged).length > 0
|
||||
const canRevertAll =
|
||||
!normalizedFilter && getDiscardAllPaths(grouped[area], area).length > 0
|
||||
const sectionLabel = SECTION_LABELS[area]
|
||||
const canUnstageAll = !normalizedFilter && unstageAllPaths.length > 0
|
||||
const canRevertAll = !normalizedFilter && discardAllPaths.length > 0
|
||||
const sectionLabel =
|
||||
id === 'conflicts' ? CONFLICTS_SECTION_LABEL : SECTION_LABELS[area]
|
||||
const sectionViewAction = getSourceControlSectionViewAction(actionSection)
|
||||
return (
|
||||
<div key={area}>
|
||||
<div key={id}>
|
||||
<SectionHeader
|
||||
label={translate(sectionLabel.key, sectionLabel.fallback)}
|
||||
count={items.length}
|
||||
@@ -5449,7 +5397,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
items.filter((entry) => entry.conflictStatus === 'unresolved').length
|
||||
}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={() => toggleSection(area)}
|
||||
onToggle={() => toggleSection(id)}
|
||||
actions={
|
||||
<>
|
||||
{/* Why: bulk action buttons are hover-only on
|
||||
@@ -5483,7 +5431,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
requestDiscardAllInArea(area)
|
||||
requestDiscardAllInArea(area, discardAllPaths)
|
||||
}}
|
||||
disabled={isExecutingBulk}
|
||||
/>
|
||||
@@ -5497,9 +5445,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
if (area === 'unstaged' || area === 'untracked') {
|
||||
void handleStageAllInArea(area)
|
||||
}
|
||||
void handleStageAllPaths(stageAllPaths)
|
||||
}}
|
||||
disabled={isExecutingBulk}
|
||||
/>
|
||||
@@ -5513,22 +5459,42 @@ function SourceControlInner(): React.JSX.Element {
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void handleUnstageAll()
|
||||
void handleUnstagePaths(unstageAllPaths)
|
||||
}}
|
||||
disabled={isExecutingBulk}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{items.some((entry) => entry.conflictStatus === 'unresolved') ? (
|
||||
{sectionViewAction ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-[10px] text-muted-foreground hover:text-foreground"
|
||||
className={
|
||||
items.some((entry) => entry.conflictStatus === 'unresolved')
|
||||
? 'h-6 px-1.5 text-[10px] text-muted-foreground hover:text-foreground'
|
||||
: 'h-auto px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground'
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (activeWorktreeId && worktreePath) {
|
||||
openAllDiffs(activeWorktreeId, worktreePath, undefined, area)
|
||||
if (!activeWorktreeId || !worktreePath) {
|
||||
return
|
||||
}
|
||||
if (sectionViewAction.kind === 'conflict-review') {
|
||||
openConflictReview(
|
||||
activeWorktreeId,
|
||||
worktreePath,
|
||||
sectionViewAction.entries,
|
||||
'live-summary'
|
||||
)
|
||||
} else {
|
||||
openAllDiffs(
|
||||
activeWorktreeId,
|
||||
worktreePath,
|
||||
undefined,
|
||||
sectionViewAction.area,
|
||||
sectionViewAction.entries
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -5537,31 +5503,13 @@ function SourceControlInner(): React.JSX.Element {
|
||||
'View all'
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (activeWorktreeId && worktreePath) {
|
||||
openAllDiffs(activeWorktreeId, worktreePath, undefined, area)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.SourceControl.48db37cca9',
|
||||
'View all'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{!isCollapsed &&
|
||||
(sourceControlViewMode === 'tree'
|
||||
? visibleTreeRowsByArea[area].map((node) => {
|
||||
? (visibleTreeRowsBySection[id] ?? []).map((node) => {
|
||||
if (node.type === 'directory') {
|
||||
return (
|
||||
<SourceControlTreeDirectoryRow
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { GitStatusEntry } from '../../../../shared/types'
|
||||
import {
|
||||
buildSourceControlDisplaySections,
|
||||
getConflictReviewEntries,
|
||||
getSourceControlSectionViewAction,
|
||||
resolveSourceControlGroupOrder,
|
||||
splitPinnedSourceControlConflicts,
|
||||
type SourceControlEntryGroups
|
||||
} from './source-control-section-order'
|
||||
|
||||
function entry(partial: Partial<GitStatusEntry> & { path: string }): GitStatusEntry {
|
||||
return {
|
||||
area: 'unstaged',
|
||||
status: 'modified',
|
||||
...partial
|
||||
}
|
||||
}
|
||||
|
||||
function groups(partial: Partial<SourceControlEntryGroups>): SourceControlEntryGroups {
|
||||
return {
|
||||
staged: [],
|
||||
unstaged: [],
|
||||
untracked: [],
|
||||
...partial
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveSourceControlGroupOrder', () => {
|
||||
it('keeps Changes first by default', () => {
|
||||
expect(resolveSourceControlGroupOrder(undefined)).toEqual(['unstaged', 'staged', 'untracked'])
|
||||
})
|
||||
|
||||
it('supports staged-first and untracked-first presets', () => {
|
||||
expect(resolveSourceControlGroupOrder('staged-first')).toEqual([
|
||||
'staged',
|
||||
'unstaged',
|
||||
'untracked'
|
||||
])
|
||||
expect(resolveSourceControlGroupOrder('untracked-first')).toEqual([
|
||||
'untracked',
|
||||
'unstaged',
|
||||
'staged'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSourceControlDisplaySections', () => {
|
||||
it('uses the configured order for normal sections', () => {
|
||||
const sections = buildSourceControlDisplaySections(
|
||||
groups({
|
||||
staged: [entry({ area: 'staged', path: 'staged.ts' })],
|
||||
unstaged: [entry({ area: 'unstaged', path: 'changed.ts' })],
|
||||
untracked: [entry({ area: 'untracked', path: 'new.ts', status: 'untracked' })]
|
||||
}),
|
||||
resolveSourceControlGroupOrder('staged-first')
|
||||
)
|
||||
|
||||
expect(sections.map((section) => section.id)).toEqual(['staged', 'unstaged', 'untracked'])
|
||||
})
|
||||
|
||||
it('keeps conflicts pinned before the configured normal order', () => {
|
||||
const sections = buildSourceControlDisplaySections(
|
||||
groups({
|
||||
staged: [entry({ area: 'staged', path: 'staged.ts' })],
|
||||
unstaged: [
|
||||
entry({
|
||||
area: 'unstaged',
|
||||
path: 'conflict.ts',
|
||||
conflictKind: 'both_modified',
|
||||
conflictStatus: 'unresolved'
|
||||
}),
|
||||
entry({ area: 'unstaged', path: 'changed.ts' })
|
||||
],
|
||||
untracked: [entry({ area: 'untracked', path: 'new.ts', status: 'untracked' })]
|
||||
}),
|
||||
resolveSourceControlGroupOrder('staged-first')
|
||||
)
|
||||
|
||||
expect(sections.map((section) => section.id)).toEqual([
|
||||
'conflicts',
|
||||
'staged',
|
||||
'unstaged',
|
||||
'untracked'
|
||||
])
|
||||
})
|
||||
|
||||
it('pins conflict rows and removes them from the normal Changes section', () => {
|
||||
const unresolved = entry({
|
||||
area: 'unstaged',
|
||||
path: 'conflict.ts',
|
||||
conflictStatus: 'unresolved'
|
||||
})
|
||||
const resolved = entry({
|
||||
area: 'unstaged',
|
||||
path: 'resolved.ts',
|
||||
conflictStatus: 'resolved_locally'
|
||||
})
|
||||
const normal = entry({ area: 'unstaged', path: 'normal.ts' })
|
||||
const input = groups({ unstaged: [unresolved, resolved, normal] })
|
||||
|
||||
const split = splitPinnedSourceControlConflicts(input)
|
||||
const sections = buildSourceControlDisplaySections(
|
||||
input,
|
||||
resolveSourceControlGroupOrder('changes-first')
|
||||
)
|
||||
|
||||
expect(split.pinnedConflicts.map((item) => item.path)).toEqual(['conflict.ts', 'resolved.ts'])
|
||||
expect(split.normalGroups.unstaged.map((item) => item.path)).toEqual(['normal.ts'])
|
||||
expect(sections.map((section) => section.id)).toEqual(['conflicts', 'unstaged'])
|
||||
expect(sections[0]?.items.map((item) => item.path)).toEqual(['conflict.ts', 'resolved.ts'])
|
||||
expect(sections[1]?.items.map((item) => item.path)).toEqual(['normal.ts'])
|
||||
})
|
||||
|
||||
it('pins locally resolved staged conflicts and removes them from Staged Changes', () => {
|
||||
const resolvedStaged = entry({
|
||||
area: 'staged',
|
||||
path: 'resolved-staged.ts',
|
||||
conflictKind: 'both_modified',
|
||||
conflictStatus: 'resolved_locally'
|
||||
})
|
||||
const staged = entry({ area: 'staged', path: 'staged.ts' })
|
||||
const input = groups({ staged: [resolvedStaged, staged] })
|
||||
|
||||
const split = splitPinnedSourceControlConflicts(input)
|
||||
const sections = buildSourceControlDisplaySections(
|
||||
input,
|
||||
resolveSourceControlGroupOrder('staged-first')
|
||||
)
|
||||
|
||||
expect(split.pinnedConflicts).toEqual([resolvedStaged])
|
||||
expect(split.normalGroups.staged).toEqual([staged])
|
||||
expect(sections.map((section) => section.id)).toEqual(['conflicts', 'staged'])
|
||||
expect(sections[0]?.items[0]?.area).toBe('staged')
|
||||
expect(sections[1]?.items).toEqual([staged])
|
||||
})
|
||||
|
||||
it('builds review entries only for unresolved conflicts', () => {
|
||||
expect(
|
||||
getConflictReviewEntries([
|
||||
entry({
|
||||
area: 'unstaged',
|
||||
path: 'conflict.ts',
|
||||
conflictKind: 'both_modified',
|
||||
conflictStatus: 'unresolved'
|
||||
}),
|
||||
entry({
|
||||
area: 'unstaged',
|
||||
path: 'resolved.ts',
|
||||
conflictKind: 'both_modified',
|
||||
conflictStatus: 'resolved_locally'
|
||||
})
|
||||
])
|
||||
).toEqual([{ path: 'conflict.ts', conflictKind: 'both_modified' }])
|
||||
})
|
||||
|
||||
it('routes the pinned Conflicts section to conflict review', () => {
|
||||
const sections = buildSourceControlDisplaySections(
|
||||
groups({
|
||||
unstaged: [
|
||||
entry({
|
||||
area: 'unstaged',
|
||||
path: 'conflict.ts',
|
||||
conflictKind: 'both_modified',
|
||||
conflictStatus: 'unresolved'
|
||||
}),
|
||||
entry({ area: 'unstaged', path: 'normal.ts' })
|
||||
]
|
||||
}),
|
||||
resolveSourceControlGroupOrder('changes-first')
|
||||
)
|
||||
|
||||
expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({
|
||||
kind: 'conflict-review',
|
||||
entries: [{ path: 'conflict.ts', conflictKind: 'both_modified' }]
|
||||
})
|
||||
expect(getSourceControlSectionViewAction(sections[1]!)).toEqual({
|
||||
kind: 'combined-diff',
|
||||
area: 'unstaged',
|
||||
entries: [entry({ area: 'unstaged', path: 'normal.ts' })]
|
||||
})
|
||||
})
|
||||
|
||||
it('scopes normal combined-diff actions to the conflict-split section items', () => {
|
||||
const pinned = entry({
|
||||
area: 'unstaged',
|
||||
path: 'resolved.ts',
|
||||
conflictKind: 'both_modified',
|
||||
conflictStatus: 'resolved_locally'
|
||||
})
|
||||
const normal = entry({ area: 'unstaged', path: 'normal.ts' })
|
||||
const sections = buildSourceControlDisplaySections(
|
||||
groups({ unstaged: [pinned, normal] }),
|
||||
resolveSourceControlGroupOrder('changes-first')
|
||||
)
|
||||
|
||||
expect(getSourceControlSectionViewAction(sections[1]!)).toEqual({
|
||||
kind: 'combined-diff',
|
||||
area: 'unstaged',
|
||||
entries: [normal]
|
||||
})
|
||||
})
|
||||
|
||||
it('routes locally resolved-only conflict sections to combined diff', () => {
|
||||
const resolved = entry({
|
||||
area: 'unstaged',
|
||||
path: 'resolved.ts',
|
||||
conflictKind: 'both_modified',
|
||||
conflictStatus: 'resolved_locally'
|
||||
})
|
||||
const sections = buildSourceControlDisplaySections(
|
||||
groups({
|
||||
unstaged: [resolved]
|
||||
}),
|
||||
resolveSourceControlGroupOrder('changes-first')
|
||||
)
|
||||
|
||||
expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({
|
||||
kind: 'combined-diff',
|
||||
area: 'unstaged',
|
||||
entries: [resolved]
|
||||
})
|
||||
})
|
||||
|
||||
it('uses a generic combined diff action for mixed-area resolved conflict sections', () => {
|
||||
const unstaged = entry({
|
||||
area: 'unstaged',
|
||||
path: 'resolved-unstaged.ts',
|
||||
conflictKind: 'both_modified',
|
||||
conflictStatus: 'resolved_locally'
|
||||
})
|
||||
const staged = entry({
|
||||
area: 'staged',
|
||||
path: 'resolved-staged.ts',
|
||||
conflictKind: 'both_modified',
|
||||
conflictStatus: 'resolved_locally'
|
||||
})
|
||||
const sections = buildSourceControlDisplaySections(
|
||||
groups({
|
||||
staged: [staged],
|
||||
unstaged: [unstaged]
|
||||
}),
|
||||
resolveSourceControlGroupOrder('staged-first')
|
||||
)
|
||||
|
||||
expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({
|
||||
kind: 'combined-diff',
|
||||
entries: [unstaged, staged]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import { normalizeSourceControlGroupOrder } from '../../../../shared/source-control-group-order'
|
||||
import type { GitStatusEntry, SourceControlGroupOrder } from '../../../../shared/types'
|
||||
|
||||
export const SOURCE_CONTROL_AREAS = ['unstaged', 'staged', 'untracked'] as const
|
||||
export type SourceControlSectionArea = (typeof SOURCE_CONTROL_AREAS)[number]
|
||||
export type SourceControlDisplaySectionId = SourceControlSectionArea | 'conflicts'
|
||||
|
||||
export type SourceControlEntryGroups = Record<SourceControlSectionArea, GitStatusEntry[]>
|
||||
|
||||
export type SourceControlDisplaySection = {
|
||||
id: SourceControlDisplaySectionId
|
||||
area: SourceControlSectionArea
|
||||
items: GitStatusEntry[]
|
||||
}
|
||||
|
||||
export type SourceControlConflictReviewEntry = {
|
||||
path: string
|
||||
conflictKind: NonNullable<GitStatusEntry['conflictKind']>
|
||||
}
|
||||
|
||||
export type SourceControlSectionViewAction =
|
||||
| { kind: 'conflict-review'; entries: SourceControlConflictReviewEntry[] }
|
||||
| { kind: 'combined-diff'; area?: SourceControlSectionArea; entries: GitStatusEntry[] }
|
||||
|
||||
const ORDER_BY_PRESET: Record<SourceControlGroupOrder, readonly SourceControlSectionArea[]> = {
|
||||
'changes-first': ['unstaged', 'staged', 'untracked'],
|
||||
'staged-first': ['staged', 'unstaged', 'untracked'],
|
||||
'untracked-first': ['untracked', 'unstaged', 'staged']
|
||||
}
|
||||
|
||||
export function resolveSourceControlGroupOrder(
|
||||
value: SourceControlGroupOrder | null | undefined
|
||||
): readonly SourceControlSectionArea[] {
|
||||
return ORDER_BY_PRESET[normalizeSourceControlGroupOrder(value)]
|
||||
}
|
||||
|
||||
export function isPinnedConflictEntry(entry: GitStatusEntry): boolean {
|
||||
return entry.conflictStatus === 'unresolved' || entry.conflictStatus === 'resolved_locally'
|
||||
}
|
||||
|
||||
export function getConflictReviewEntries(
|
||||
entries: readonly GitStatusEntry[]
|
||||
): SourceControlConflictReviewEntry[] {
|
||||
return entries
|
||||
.filter((entry) => entry.conflictStatus === 'unresolved' && entry.conflictKind)
|
||||
.map((entry) => ({
|
||||
path: entry.path,
|
||||
conflictKind: entry.conflictKind!
|
||||
}))
|
||||
}
|
||||
|
||||
export function getSourceControlSectionViewAction(
|
||||
section: SourceControlDisplaySection
|
||||
): SourceControlSectionViewAction | null {
|
||||
if (section.id === 'conflicts') {
|
||||
const entries = getConflictReviewEntries(section.items)
|
||||
if (entries.length > 0) {
|
||||
return { kind: 'conflict-review', entries }
|
||||
}
|
||||
if (section.items.length === 0) {
|
||||
return null
|
||||
}
|
||||
const [firstItem] = section.items
|
||||
const area = section.items.every((item) => item.area === firstItem?.area)
|
||||
? firstItem?.area
|
||||
: undefined
|
||||
return area
|
||||
? { kind: 'combined-diff', area, entries: section.items }
|
||||
: { kind: 'combined-diff', entries: section.items }
|
||||
}
|
||||
return { kind: 'combined-diff', area: section.area, entries: section.items }
|
||||
}
|
||||
|
||||
export type SplitSourceControlGroups = {
|
||||
pinnedConflicts: GitStatusEntry[]
|
||||
normalGroups: SourceControlEntryGroups
|
||||
}
|
||||
|
||||
export function splitPinnedSourceControlConflicts(
|
||||
groups: SourceControlEntryGroups
|
||||
): SplitSourceControlGroups {
|
||||
const pinnedConflicts = SOURCE_CONTROL_AREAS.flatMap((area) =>
|
||||
groups[area].filter(isPinnedConflictEntry)
|
||||
)
|
||||
// Why: preserve referential identity of `groups` when nothing is pinned so
|
||||
// downstream memos (tree rebuilds, etc.) don't fire on every status refresh.
|
||||
if (pinnedConflicts.length === 0) {
|
||||
return { pinnedConflicts, normalGroups: groups }
|
||||
}
|
||||
return {
|
||||
pinnedConflicts,
|
||||
normalGroups: {
|
||||
staged: groups.staged.filter((entry) => !isPinnedConflictEntry(entry)),
|
||||
unstaged: groups.unstaged.filter((entry) => !isPinnedConflictEntry(entry)),
|
||||
untracked: groups.untracked.filter((entry) => !isPinnedConflictEntry(entry))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSourceControlDisplaySectionsFromSplit(
|
||||
split: SplitSourceControlGroups,
|
||||
order: readonly SourceControlSectionArea[]
|
||||
): SourceControlDisplaySection[] {
|
||||
const { pinnedConflicts, normalGroups } = split
|
||||
const sections: SourceControlDisplaySection[] = []
|
||||
|
||||
if (pinnedConflicts.length > 0) {
|
||||
sections.push({ id: 'conflicts', area: 'unstaged', items: pinnedConflicts })
|
||||
}
|
||||
|
||||
for (const area of order) {
|
||||
const items = normalGroups[area]
|
||||
if (items.length > 0) {
|
||||
sections.push({ id: area, area, items })
|
||||
}
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
export function buildSourceControlDisplaySections(
|
||||
groups: SourceControlEntryGroups,
|
||||
order: readonly SourceControlSectionArea[]
|
||||
): SourceControlDisplaySection[] {
|
||||
return buildSourceControlDisplaySectionsFromSplit(
|
||||
splitPinnedSourceControlConflicts(groups),
|
||||
order
|
||||
)
|
||||
}
|
||||
@@ -3,9 +3,11 @@ import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/ty
|
||||
import {
|
||||
buildGitStatusSourceControlTree,
|
||||
buildSourceControlTree,
|
||||
applyGitStatusEntryAreasToSourceControlTree,
|
||||
collectSourceControlTreeFileEntries,
|
||||
compactSourceControlTree,
|
||||
flattenSourceControlTree
|
||||
flattenSourceControlTree,
|
||||
namespaceSourceControlTreeDirectoryKeys
|
||||
} from './source-control-tree'
|
||||
|
||||
function entry(partial: Partial<GitStatusEntry> & { path: string }): GitStatusEntry {
|
||||
@@ -138,4 +140,52 @@ describe('buildSourceControlTree', () => {
|
||||
'file:src/renderer/index.ts'
|
||||
])
|
||||
})
|
||||
|
||||
it('can namespace directory keys without changing file entry areas', () => {
|
||||
const tree = compactSourceControlTree(
|
||||
buildGitStatusSourceControlTree('unstaged', [
|
||||
entry({
|
||||
path: 'src/conflict.ts',
|
||||
conflictKind: 'both_modified',
|
||||
conflictStatus: 'unresolved'
|
||||
})
|
||||
])
|
||||
)
|
||||
|
||||
const namespaced = namespaceSourceControlTreeDirectoryKeys(tree, 'conflicts')
|
||||
const rows = flattenSourceControlTree(namespaced, new Set())
|
||||
const directory = rows.find((node) => node.type === 'directory')
|
||||
const file = rows.find((node) => node.type === 'file')
|
||||
|
||||
expect(directory?.key).toBe('dir::conflicts::src')
|
||||
expect(file?.key).toBe('unstaged::src/conflict.ts')
|
||||
expect(file?.area).toBe('unstaged')
|
||||
})
|
||||
|
||||
it('can preserve file node areas from mixed conflict section entries', () => {
|
||||
const tree = compactSourceControlTree(
|
||||
buildGitStatusSourceControlTree('unstaged', [
|
||||
entry({
|
||||
area: 'staged',
|
||||
path: 'src/resolved.ts',
|
||||
conflictKind: 'both_modified',
|
||||
conflictStatus: 'resolved_locally'
|
||||
})
|
||||
])
|
||||
)
|
||||
|
||||
const rows = flattenSourceControlTree(
|
||||
applyGitStatusEntryAreasToSourceControlTree(
|
||||
namespaceSourceControlTreeDirectoryKeys(tree, 'conflicts')
|
||||
),
|
||||
new Set()
|
||||
)
|
||||
const directory = rows.find((node) => node.type === 'directory')
|
||||
const file = rows.find((node) => node.type === 'file')
|
||||
|
||||
expect(directory?.key).toBe('dir::conflicts::src')
|
||||
expect(directory?.area).toBe('unstaged')
|
||||
expect(file?.key).toBe('staged::src/resolved.ts')
|
||||
expect(file?.area).toBe('staged')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -215,6 +215,55 @@ export function compactSourceControlTree<Entry extends SourceControlTreeEntry, A
|
||||
return nodes.map((node) => compactNode(node, 0))
|
||||
}
|
||||
|
||||
export function namespaceSourceControlTreeDirectoryKeys<
|
||||
Entry extends SourceControlTreeEntry,
|
||||
Area extends string
|
||||
>(
|
||||
nodes: SourceControlTreeNode<Entry, Area>[],
|
||||
namespace: string
|
||||
): SourceControlTreeNode<Entry, Area>[] {
|
||||
const namespaceNode = (
|
||||
node: SourceControlTreeNode<Entry, Area>
|
||||
): SourceControlTreeNode<Entry, Area> => {
|
||||
if (node.type === 'file') {
|
||||
return node
|
||||
}
|
||||
|
||||
// Why: pinned conflict folders share git area semantics with Changes, but
|
||||
// collapse state is UI-section-local and needs a distinct directory key.
|
||||
return {
|
||||
...node,
|
||||
key: `dir::${namespace}::${node.path}`,
|
||||
children: node.children.map(namespaceNode)
|
||||
}
|
||||
}
|
||||
|
||||
return nodes.map(namespaceNode)
|
||||
}
|
||||
|
||||
export function applyGitStatusEntryAreasToSourceControlTree(
|
||||
nodes: SourceControlTreeNode<GitStatusEntry, SourceControlTreeArea>[]
|
||||
): SourceControlTreeNode<GitStatusEntry, SourceControlTreeArea>[] {
|
||||
const applyEntryArea = (
|
||||
node: SourceControlTreeNode<GitStatusEntry, SourceControlTreeArea>
|
||||
): SourceControlTreeNode<GitStatusEntry, SourceControlTreeArea> => {
|
||||
if (node.type === 'file') {
|
||||
return {
|
||||
...node,
|
||||
key: `${node.entry.area}::${node.entry.path}`,
|
||||
area: node.entry.area
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
children: node.children.map(applyEntryArea)
|
||||
}
|
||||
}
|
||||
|
||||
return nodes.map(applyEntryArea)
|
||||
}
|
||||
|
||||
export function collectSourceControlTreeFileEntries<
|
||||
Entry extends SourceControlTreeEntry,
|
||||
Area extends string
|
||||
|
||||
@@ -1,11 +1,60 @@
|
||||
import os from 'node:os'
|
||||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import { translate } from '../../i18n/i18n'
|
||||
import { useAppStore } from '../../store'
|
||||
import { shouldOpenAutoRenameBranchAdvanced } from './AutoRenameBranchFromWorkSetting'
|
||||
import { GitPane, shouldShowAutoRenameBranchSetting } from './GitPane'
|
||||
import {
|
||||
GitPane,
|
||||
SourceControlGroupOrderSetting,
|
||||
getGitPaneSearchEntries,
|
||||
shouldShowAutoRenameBranchSetting
|
||||
} from './GitPane'
|
||||
import { TooltipProvider } from '../ui/tooltip'
|
||||
import { matchesSettingsSearch } from './settings-search'
|
||||
import { SettingsSegmentedControl } from './SettingsFormControls'
|
||||
|
||||
type ReactElementLike = {
|
||||
type: unknown
|
||||
props: Record<string, unknown>
|
||||
}
|
||||
|
||||
function visit(node: unknown, cb: (node: ReactElementLike) => void): void {
|
||||
if (node == null || typeof node === 'string' || typeof node === 'number') {
|
||||
return
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((entry) => visit(entry, cb))
|
||||
return
|
||||
}
|
||||
const element = node as ReactElementLike
|
||||
cb(element)
|
||||
for (const [key, value] of Object.entries(element.props ?? {})) {
|
||||
if (key.startsWith('on')) {
|
||||
continue
|
||||
}
|
||||
visit(value, cb)
|
||||
}
|
||||
}
|
||||
|
||||
function findSegmentedControl(node: unknown): ReactElementLike {
|
||||
let found: ReactElementLike | null = null
|
||||
const label = translate(
|
||||
'auto.components.settings.GitPane.sourceControlGroupOrderTitle',
|
||||
'Source Control Group Order'
|
||||
)
|
||||
visit(node, (entry) => {
|
||||
if (entry.type === SettingsSegmentedControl && entry.props.ariaLabel === label) {
|
||||
found = entry
|
||||
}
|
||||
})
|
||||
if (!found) {
|
||||
throw new Error('segmented control not found')
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
function renderGitPane(searchQuery: string): string {
|
||||
useAppStore.setState({ settingsSearchQuery: searchQuery })
|
||||
@@ -14,7 +63,7 @@ function renderGitPane(searchQuery: string): string {
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(GitPane, {
|
||||
settings: getDefaultSettings('/tmp'),
|
||||
settings: getDefaultSettings(os.homedir()),
|
||||
updateSettings: () => {},
|
||||
writeSourceControlAiSettings: async () => {},
|
||||
displayedGitUsername: 'brennan',
|
||||
@@ -68,4 +117,50 @@ describe('GitPane', () => {
|
||||
expect(markup).toContain('local-only commits')
|
||||
expect(markup).not.toContain('Refresh Local Base Ref')
|
||||
})
|
||||
|
||||
it('renders Source Control group order in Git settings', () => {
|
||||
const markup = renderGitPane('group order')
|
||||
|
||||
expect(markup).toContain(
|
||||
translate(
|
||||
'auto.components.settings.GitPane.sourceControlGroupOrderTitle',
|
||||
'Source Control Group Order'
|
||||
)
|
||||
)
|
||||
expect(markup).toContain(
|
||||
translate('auto.components.settings.GitPane.changesFirst', 'Changes first')
|
||||
)
|
||||
expect(markup).toContain(
|
||||
translate('auto.components.settings.GitPane.stagedFirst', 'Staged first')
|
||||
)
|
||||
expect(markup).toContain(
|
||||
translate('auto.components.settings.GitPane.untrackedFirst', 'Untracked first')
|
||||
)
|
||||
})
|
||||
|
||||
it('updates Source Control group order only when the selected option changes', () => {
|
||||
const updateSettings = vi.fn()
|
||||
const element = SourceControlGroupOrderSetting({
|
||||
settings: {
|
||||
...getDefaultSettings(os.homedir()),
|
||||
sourceControlGroupOrder: 'changes-first'
|
||||
},
|
||||
updateSettings
|
||||
})
|
||||
|
||||
const control = findSegmentedControl(element)
|
||||
const onChange = control.props.onChange as (value: string) => void
|
||||
|
||||
onChange('staged-first')
|
||||
expect(updateSettings).toHaveBeenCalledWith({ sourceControlGroupOrder: 'staged-first' })
|
||||
|
||||
updateSettings.mockClear()
|
||||
onChange('changes-first')
|
||||
expect(updateSettings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('includes Source Control group order search metadata', () => {
|
||||
expect(matchesSettingsSearch('staged', getGitPaneSearchEntries())).toBe(true)
|
||||
expect(matchesSettingsSearch('group order', getGitPaneSearchEntries())).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import type { GlobalSettings, SourceControlGroupOrder } from '../../../../shared/types'
|
||||
import type { SourceControlAiSettingsPatch } from '../../../../shared/source-control-ai-types'
|
||||
import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from '../../../../shared/source-control-group-order'
|
||||
import { Input } from '../ui/input'
|
||||
import { Label } from '../ui/label'
|
||||
import { useAppStore } from '../../store'
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
getKeepLocalMainUpToDateTitle
|
||||
} from './keep-local-main-up-to-date-setting'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { SettingsRow, SettingsSegmentedControl } from './SettingsFormControls'
|
||||
|
||||
export { getGitPaneSearchEntries }
|
||||
|
||||
@@ -32,6 +34,14 @@ const KEEP_LOCAL_MAIN_UP_TO_DATE_KEYWORDS = [
|
||||
'safely',
|
||||
'worktree'
|
||||
]
|
||||
const SOURCE_CONTROL_GROUP_ORDER_KEYWORDS = [
|
||||
'group order',
|
||||
'changes first',
|
||||
'staged first',
|
||||
'untracked first',
|
||||
'source control',
|
||||
'git changes'
|
||||
]
|
||||
|
||||
export function shouldShowAutoRenameBranchSetting(
|
||||
searchQuery: string,
|
||||
@@ -54,6 +64,68 @@ type GitPaneProps = {
|
||||
settingsSearchQuery?: string
|
||||
}
|
||||
|
||||
export function SourceControlGroupOrderSetting({
|
||||
settings,
|
||||
updateSettings
|
||||
}: {
|
||||
settings: GlobalSettings
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => void | Promise<void>
|
||||
}): React.JSX.Element {
|
||||
const value = settings.sourceControlGroupOrder ?? DEFAULT_SOURCE_CONTROL_GROUP_ORDER
|
||||
const title = translate(
|
||||
'auto.components.settings.GitPane.sourceControlGroupOrderTitle',
|
||||
'Source Control Group Order'
|
||||
)
|
||||
const description = translate(
|
||||
'auto.components.settings.GitPane.sourceControlGroupOrderDescription',
|
||||
'Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.'
|
||||
)
|
||||
|
||||
return (
|
||||
<SearchableSetting
|
||||
title={title}
|
||||
description={description}
|
||||
keywords={SOURCE_CONTROL_GROUP_ORDER_KEYWORDS}
|
||||
className="max-w-none"
|
||||
>
|
||||
<SettingsRow
|
||||
label={title}
|
||||
description={description}
|
||||
alignTop
|
||||
control={
|
||||
<SettingsSegmentedControl<SourceControlGroupOrder>
|
||||
value={value}
|
||||
onChange={(nextValue) => {
|
||||
if (nextValue !== value) {
|
||||
void updateSettings({ sourceControlGroupOrder: nextValue })
|
||||
}
|
||||
}}
|
||||
ariaLabel={title}
|
||||
size="sm"
|
||||
options={[
|
||||
{
|
||||
value: 'changes-first',
|
||||
label: translate('auto.components.settings.GitPane.changesFirst', 'Changes first')
|
||||
},
|
||||
{
|
||||
value: 'staged-first',
|
||||
label: translate('auto.components.settings.GitPane.stagedFirst', 'Staged first')
|
||||
},
|
||||
{
|
||||
value: 'untracked-first',
|
||||
label: translate(
|
||||
'auto.components.settings.GitPane.untrackedFirst',
|
||||
'Untracked first'
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
)
|
||||
}
|
||||
|
||||
export function GitPane({
|
||||
settings,
|
||||
updateSettings,
|
||||
@@ -196,6 +268,23 @@ export function GitPane({
|
||||
</button>
|
||||
</SearchableSetting>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, {
|
||||
title: translate(
|
||||
'auto.components.settings.GitPane.sourceControlGroupOrderTitle',
|
||||
'Source Control Group Order'
|
||||
),
|
||||
description: translate(
|
||||
'auto.components.settings.GitPane.sourceControlGroupOrderDescription',
|
||||
'Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.'
|
||||
),
|
||||
keywords: SOURCE_CONTROL_GROUP_ORDER_KEYWORDS
|
||||
}) ? (
|
||||
<SourceControlGroupOrderSetting
|
||||
key="source-control-group-order"
|
||||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
/>
|
||||
) : null,
|
||||
shouldShowAutoRenameBranchSetting(searchQuery, hasUnsavedBranchPromptChanges) ? (
|
||||
<AutoRenameBranchFromWorkSetting
|
||||
key="auto-rename-branch-from-work"
|
||||
|
||||
@@ -43,6 +43,30 @@ export const getGitPaneSearchEntries = createLocalizedCatalog(() => [
|
||||
...translateSearchKeyword('auto.components.settings.git.search.035134fcd9', 'worktree')
|
||||
]
|
||||
},
|
||||
{
|
||||
title: translate(
|
||||
'auto.components.settings.git.search.sourceControlGroupOrderTitle',
|
||||
'Source Control Group Order'
|
||||
),
|
||||
description: translate(
|
||||
'auto.components.settings.git.search.sourceControlGroupOrderDescription',
|
||||
'Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.'
|
||||
),
|
||||
keywords: [
|
||||
...translateSearchKeyword('auto.components.settings.git.search.groupOrder', 'group order'),
|
||||
...translateSearchKeyword('auto.components.settings.git.search.changesFirst', 'changes first'),
|
||||
...translateSearchKeyword('auto.components.settings.git.search.stagedFirst', 'staged first'),
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.git.search.untrackedFirst',
|
||||
'untracked first'
|
||||
),
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.git.search.sourceControl',
|
||||
'source control'
|
||||
),
|
||||
...translateSearchKeyword('auto.components.settings.git.search.gitChanges', 'git changes')
|
||||
]
|
||||
},
|
||||
...getAutoRenameBranchSearchEntries(),
|
||||
{
|
||||
title: translate('auto.components.settings.git.search.bc7d9f69ce', 'Orca Attribution'),
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": "表示"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": "여기에서 연 충돌에서 파생된 로컬 세션 상태입니다.",
|
||||
|
||||
@@ -4984,7 +4984,12 @@
|
||||
"f35007e6e8": "git 用户名",
|
||||
"3d172725cc": "没有任何",
|
||||
"1f32ba27a6": "自定义",
|
||||
"a182c5125e": "git用户名"
|
||||
"a182c5125e": "git用户名",
|
||||
"sourceControlGroupOrderTitle": "源代码管理分组顺序",
|
||||
"sourceControlGroupOrderDescription": "选择在源代码管理中优先显示“更改”、“已暂存更改”还是“未跟踪文件”。",
|
||||
"changesFirst": "更改优先",
|
||||
"stagedFirst": "已暂存优先",
|
||||
"untrackedFirst": "未跟踪优先"
|
||||
},
|
||||
"HiddenExperimentalGroup": {
|
||||
"d0f914a528": "占位符切换",
|
||||
@@ -7073,7 +7078,15 @@
|
||||
"1d2fae1fa2": "git 用户名",
|
||||
"f83c8937c4": "分支命名",
|
||||
"5ecd91c5ef": "创建工作树时添加到分支名称的前缀。",
|
||||
"68bd65fdb8": "分支前缀"
|
||||
"68bd65fdb8": "分支前缀",
|
||||
"sourceControlGroupOrderTitle": "源代码管理分组顺序",
|
||||
"sourceControlGroupOrderDescription": "选择在源代码管理中优先显示“更改”、“已暂存更改”还是“未跟踪文件”。",
|
||||
"groupOrder": "分组顺序",
|
||||
"changesFirst": "更改优先",
|
||||
"stagedFirst": "已暂存优先",
|
||||
"untrackedFirst": "未跟踪优先",
|
||||
"sourceControl": "源代码管理",
|
||||
"gitChanges": "git 更改"
|
||||
}
|
||||
},
|
||||
"input": {
|
||||
@@ -8448,6 +8461,7 @@
|
||||
"a1f2c8d901": "查看"
|
||||
},
|
||||
"SourceControl": {
|
||||
"conflictsSection": "冲突",
|
||||
"1406954883": "清除所有笔记...",
|
||||
"cc05b2d088": "在文件资源管理器中打开",
|
||||
"03194cfff4": "本地会话状态源自您在此处打开的冲突。",
|
||||
@@ -9147,7 +9161,7 @@
|
||||
}
|
||||
},
|
||||
"SourceControlEntryContextMenu": {
|
||||
"a1f2c8d901": "View"
|
||||
"a1f2c8d901": "查看"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '../../runtime/runtime-compatibility-test-fixture'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import type { GitStatusEntry } from '../../../../shared/types'
|
||||
|
||||
const { toastErrorMock } = vi.hoisted(() => ({
|
||||
toastErrorMock: vi.fn()
|
||||
@@ -2592,6 +2593,38 @@ describe('createEditorSlice combined diff exclusions', () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses a supplied combined diff entry snapshot instead of the whole area', () => {
|
||||
const store = createEditorStore()
|
||||
const normalEntry: GitStatusEntry = {
|
||||
path: 'src/normal.ts',
|
||||
status: 'modified',
|
||||
area: 'unstaged'
|
||||
}
|
||||
|
||||
store.getState().setGitStatus('wt-1', {
|
||||
conflictOperation: 'merge',
|
||||
entries: [
|
||||
{
|
||||
path: 'src/resolved.ts',
|
||||
status: 'modified',
|
||||
area: 'unstaged',
|
||||
conflictKind: 'both_modified',
|
||||
conflictStatus: 'resolved_locally'
|
||||
},
|
||||
normalEntry
|
||||
]
|
||||
})
|
||||
store.getState().openAllDiffs('wt-1', '/repo', undefined, 'unstaged', [normalEntry])
|
||||
|
||||
expect(store.getState().openFiles[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'wt-1::all-diffs::uncommitted::unstaged',
|
||||
uncommittedEntriesSnapshot: [normalEntry],
|
||||
skippedConflicts: []
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createEditorSlice remote branch actions', () => {
|
||||
|
||||
@@ -500,7 +500,8 @@ export type EditorSlice = {
|
||||
worktreeId: string,
|
||||
worktreePath: string,
|
||||
alternate?: CombinedDiffAlternate,
|
||||
areaFilter?: string
|
||||
areaFilter?: string,
|
||||
entriesSnapshot?: GitStatusEntry[]
|
||||
) => void
|
||||
openConflictFile: (
|
||||
worktreeId: string,
|
||||
@@ -2630,7 +2631,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
)
|
||||
},
|
||||
|
||||
openAllDiffs: (worktreeId, worktreePath, alternate, areaFilter) => {
|
||||
openAllDiffs: (worktreeId, worktreePath, alternate, areaFilter, entriesSnapshot) => {
|
||||
const id = areaFilter
|
||||
? `${worktreeId}::all-diffs::uncommitted::${areaFilter}`
|
||||
: `${worktreeId}::all-diffs::uncommitted`
|
||||
@@ -2640,12 +2641,14 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
] ?? 'All Changes')
|
||||
: 'All Changes'
|
||||
set((s) => {
|
||||
const relevantEntries = (s.gitStatusByWorktree[worktreeId] ?? []).filter((entry) => {
|
||||
if (areaFilter) {
|
||||
return entry.area === areaFilter
|
||||
}
|
||||
return entry.area !== 'untracked'
|
||||
})
|
||||
const relevantEntries =
|
||||
entriesSnapshot ??
|
||||
(s.gitStatusByWorktree[worktreeId] ?? []).filter((entry) => {
|
||||
if (areaFilter) {
|
||||
return entry.area === areaFilter
|
||||
}
|
||||
return entry.area !== 'untracked'
|
||||
})
|
||||
const skippedConflicts = relevantEntries
|
||||
.filter((entry) => entry.conflictStatus === 'unresolved' && entry.conflictKind)
|
||||
.map((entry) => ({ path: entry.path, conflictKind: entry.conflictKind! }))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
DEFAULT_LEFT_SIDEBAR_TINT_COLOR,
|
||||
DEFAULT_LEFT_SIDEBAR_TINT_OPACITY
|
||||
} from './left-sidebar-appearance'
|
||||
import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from './source-control-group-order'
|
||||
|
||||
export { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults'
|
||||
export {
|
||||
@@ -265,6 +266,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
||||
rightSidebarOpenByDefault: true,
|
||||
showGitIgnoredFiles: true,
|
||||
sourceControlViewMode: 'list',
|
||||
sourceControlGroupOrder: DEFAULT_SOURCE_CONTROL_GROUP_ORDER,
|
||||
showTitlebarAppName: true,
|
||||
showTasksButton: true,
|
||||
showAutomationsButton: true,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_SOURCE_CONTROL_GROUP_ORDER,
|
||||
normalizeSourceControlGroupOrder
|
||||
} from './source-control-group-order'
|
||||
|
||||
describe('normalizeSourceControlGroupOrder', () => {
|
||||
it('keeps supported source control group orders', () => {
|
||||
expect(normalizeSourceControlGroupOrder('changes-first')).toBe('changes-first')
|
||||
expect(normalizeSourceControlGroupOrder('staged-first')).toBe('staged-first')
|
||||
expect(normalizeSourceControlGroupOrder('untracked-first')).toBe('untracked-first')
|
||||
})
|
||||
|
||||
it('falls back to the default for malformed values', () => {
|
||||
expect(normalizeSourceControlGroupOrder('tracked-first')).toBe(
|
||||
DEFAULT_SOURCE_CONTROL_GROUP_ORDER
|
||||
)
|
||||
expect(normalizeSourceControlGroupOrder(undefined)).toBe(DEFAULT_SOURCE_CONTROL_GROUP_ORDER)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user