fix: address review findings (#2023)
This commit is contained in:
parent
8ec8044d14
commit
ccd6350868
|
|
@ -15,6 +15,23 @@ const TEST_LEAF_1 = '11111111-1111-4111-8111-111111111111'
|
|||
const TEST_LEAF_2 = '22222222-2222-4222-8222-222222222222'
|
||||
const TEST_LEAF_LIVE = '33333333-3333-4333-8333-333333333333'
|
||||
const TEST_LEAF_EXPIRED = '44444444-4444-4444-8444-444444444444'
|
||||
const REORDERED_DEFAULT_WORKSPACE_STATUSES = [
|
||||
{ id: 'completed', label: 'Completed', color: 'conductor-done', icon: 'conductor-done' },
|
||||
{ id: 'in-review', label: 'In review', color: 'conductor-review', icon: 'conductor-review' },
|
||||
{
|
||||
id: 'in-progress',
|
||||
label: 'In progress',
|
||||
color: 'conductor-progress',
|
||||
icon: 'conductor-progress'
|
||||
},
|
||||
{ id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' }
|
||||
]
|
||||
const LEGACY_DEFAULT_WORKSPACE_STATUSES = [
|
||||
{ id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' },
|
||||
{ id: 'in-progress', label: 'In progress', color: 'blue', icon: 'circle-dot' },
|
||||
{ id: 'in-review', label: 'In review', color: 'violet', icon: 'git-pull-request' },
|
||||
{ id: 'completed', label: 'Completed', color: 'emerald', icon: 'circle-check' }
|
||||
]
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
|
|
@ -924,6 +941,127 @@ describe('Store', () => {
|
|||
expect(store.getUI().sortBy).toBe('recent')
|
||||
})
|
||||
|
||||
it('repairs the known-bad reordered default workspace statuses once on load', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: { workspaceStatuses: REORDERED_DEFAULT_WORKSPACE_STATUSES },
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
workspaceSession: {}
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
const ui = store.getUI()
|
||||
expect(ui.workspaceStatuses?.map((status) => status.id)).toEqual([
|
||||
'todo',
|
||||
'in-progress',
|
||||
'in-review',
|
||||
'completed'
|
||||
])
|
||||
expect(ui._workspaceStatusesDefaultOrderMigrated).toBe(true)
|
||||
|
||||
store.flush()
|
||||
const persisted = readDataFile() as {
|
||||
ui?: {
|
||||
workspaceStatuses?: typeof REORDERED_DEFAULT_WORKSPACE_STATUSES
|
||||
_workspaceStatusesDefaultOrderMigrated?: boolean
|
||||
_workspaceStatusesDefaultVisualsMigrated?: boolean
|
||||
}
|
||||
}
|
||||
expect(persisted.ui?._workspaceStatusesDefaultOrderMigrated).toBe(true)
|
||||
expect(persisted.ui?._workspaceStatusesDefaultVisualsMigrated).toBe(true)
|
||||
expect(persisted.ui?.workspaceStatuses?.map((status) => status.id)).toEqual([
|
||||
'todo',
|
||||
'in-progress',
|
||||
'in-review',
|
||||
'completed'
|
||||
])
|
||||
})
|
||||
|
||||
it('migrates legacy default workspace status visuals once on load', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: {
|
||||
workspaceStatuses: LEGACY_DEFAULT_WORKSPACE_STATUSES,
|
||||
_workspaceStatusesDefaultOrderMigrated: true
|
||||
},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
workspaceSession: {}
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
expect(store.getUI().workspaceStatuses).toEqual([
|
||||
{ id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' },
|
||||
{
|
||||
id: 'in-progress',
|
||||
label: 'In progress',
|
||||
color: 'conductor-progress',
|
||||
icon: 'conductor-progress'
|
||||
},
|
||||
{ id: 'in-review', label: 'In review', color: 'conductor-review', icon: 'conductor-review' },
|
||||
{ id: 'completed', label: 'Completed', color: 'conductor-done', icon: 'conductor-done' }
|
||||
])
|
||||
expect(store.getUI()._workspaceStatusesDefaultVisualsMigrated).toBe(true)
|
||||
|
||||
store.flush()
|
||||
const persisted = readDataFile() as {
|
||||
ui?: {
|
||||
_workspaceStatusesDefaultVisualsMigrated?: boolean
|
||||
}
|
||||
}
|
||||
expect(persisted.ui?._workspaceStatusesDefaultVisualsMigrated).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves legacy-looking workspace status visuals after the load migration', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: {
|
||||
workspaceStatuses: LEGACY_DEFAULT_WORKSPACE_STATUSES,
|
||||
_workspaceStatusesDefaultOrderMigrated: true,
|
||||
_workspaceStatusesDefaultVisualsMigrated: true
|
||||
},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
workspaceSession: {}
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
const inProgress = store
|
||||
.getUI()
|
||||
.workspaceStatuses?.find((status) => status.id === 'in-progress')
|
||||
expect(inProgress).toMatchObject({ color: 'blue', icon: 'circle-dot' })
|
||||
})
|
||||
|
||||
it('preserves intentionally reordered default workspace statuses after the load migration', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: {
|
||||
workspaceStatuses: REORDERED_DEFAULT_WORKSPACE_STATUSES,
|
||||
_workspaceStatusesDefaultOrderMigrated: true
|
||||
},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
workspaceSession: {}
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
expect(store.getUI().workspaceStatuses?.map((status) => status.id)).toEqual([
|
||||
'completed',
|
||||
'in-review',
|
||||
'in-progress',
|
||||
'todo'
|
||||
])
|
||||
})
|
||||
|
||||
// ── terminalMacOptionAsAlt migration (issue #903) ───────────────────
|
||||
|
||||
it('migrates legacy "true" terminalMacOptionAsAlt to "auto" on first load', async () => {
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ import {
|
|||
DEFAULT_WORKSPACE_STATUS_ID,
|
||||
clampWorkspaceBoardOpacity,
|
||||
normalizeWorkspaceBoardCompact,
|
||||
normalizePersistedWorkspaceStatuses,
|
||||
normalizeWorkspaceStatuses
|
||||
} from '../shared/workspace-statuses'
|
||||
|
||||
|
|
@ -157,7 +158,7 @@ function backupPath(dataFile: string, index: number): string {
|
|||
}
|
||||
|
||||
function normalizeGroupBy(groupBy: unknown): PersistedState['ui']['groupBy'] {
|
||||
if (groupBy === 'none' || groupBy === 'repo' || groupBy === 'pr-status') {
|
||||
if (groupBy === 'flat' || groupBy === 'none' || groupBy === 'repo' || groupBy === 'pr-status') {
|
||||
return groupBy
|
||||
}
|
||||
if (groupBy === 'workspace-status') {
|
||||
|
|
@ -997,6 +998,7 @@ export class Store {
|
|||
private pendingWrite: Promise<void> | null = null
|
||||
private writeGeneration = 0
|
||||
private gitUsernameCache = new Map<string, string>()
|
||||
private loadNeedsSave = false
|
||||
|
||||
constructor() {
|
||||
const loaded = this.load()
|
||||
|
|
@ -1022,9 +1024,11 @@ export class Store {
|
|||
this.state.legacyPaneKeyAliasEntries = entries
|
||||
this.scheduleSave()
|
||||
})
|
||||
if (normalized.changed) {
|
||||
if (normalized.changed || this.loadNeedsSave) {
|
||||
// Why: upgraded sessions may contain legacy pane:1 leaves. Rewrite them at
|
||||
// the main persistence boundary so older renderer writes cannot revive them.
|
||||
// Other one-shot load migrations also set loadNeedsSave to persist their
|
||||
// guard flags before the next restart.
|
||||
this.scheduleSave()
|
||||
}
|
||||
}
|
||||
|
|
@ -1216,6 +1220,25 @@ export class Store {
|
|||
const rawSort = parsed.ui?.sortBy
|
||||
const sort = normalizeSortBy(rawSort)
|
||||
const migrate = !parsed.ui?._sortBySmartMigrated && rawSort === 'recent'
|
||||
const workspaceStatusesDefaultOrderMigrated =
|
||||
parsed.ui?._workspaceStatusesDefaultOrderMigrated === true
|
||||
// Why: visual migration has its own guard so later user choices
|
||||
// of valid legacy color/icon IDs are preserved by runtime writes.
|
||||
const workspaceStatusesDefaultVisualsMigrated =
|
||||
parsed.ui?._workspaceStatusesDefaultVisualsMigrated === true
|
||||
const workspaceStatuses = normalizePersistedWorkspaceStatuses(
|
||||
parsed.ui?.workspaceStatuses,
|
||||
{
|
||||
repairReorderedDefaultStatuses: !workspaceStatusesDefaultOrderMigrated,
|
||||
migrateLegacyDefaultStatusVisuals: !workspaceStatusesDefaultVisualsMigrated
|
||||
}
|
||||
)
|
||||
if (
|
||||
!workspaceStatusesDefaultOrderMigrated ||
|
||||
!workspaceStatusesDefaultVisualsMigrated
|
||||
) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
// Why: the 'inline-agents' card property was added after the
|
||||
// feature shipped behind an experimental toggle. Now that the
|
||||
// feature is default-on for everyone, every existing user needs
|
||||
|
|
@ -1263,6 +1286,9 @@ export class Store {
|
|||
...defaults.ui,
|
||||
...parsed.ui,
|
||||
sortBy: migrate ? ('smart' as const) : sort,
|
||||
workspaceStatuses,
|
||||
_workspaceStatusesDefaultOrderMigrated: true,
|
||||
_workspaceStatusesDefaultVisualsMigrated: true,
|
||||
_sortBySmartMigrated: true,
|
||||
...(migratedCardProps !== undefined
|
||||
? { worktreeCardProperties: migratedCardProps }
|
||||
|
|
@ -2034,9 +2060,10 @@ export class Store {
|
|||
sortBy: updates.sortBy
|
||||
? normalizeSortBy(updates.sortBy)
|
||||
: normalizeSortBy(this.state.ui?.sortBy),
|
||||
workspaceStatuses: normalizeWorkspaceStatuses(
|
||||
updates.workspaceStatuses ?? this.state.ui?.workspaceStatuses
|
||||
),
|
||||
workspaceStatuses:
|
||||
updates.workspaceStatuses !== undefined
|
||||
? normalizeWorkspaceStatuses(updates.workspaceStatuses)
|
||||
: normalizeWorkspaceStatuses(this.state.ui?.workspaceStatuses),
|
||||
workspaceBoardOpacity: clampWorkspaceBoardOpacity(
|
||||
updates.workspaceBoardOpacity ?? this.state.ui?.workspaceBoardOpacity
|
||||
),
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import SidebarFilter from './SidebarFilter'
|
|||
import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer'
|
||||
|
||||
const GROUP_BY_OPTIONS = [
|
||||
{ id: 'flat', label: 'None' },
|
||||
{ id: 'none', label: 'Status' },
|
||||
{ id: 'pr-status', label: 'PR' },
|
||||
{ id: 'repo', label: 'Repo' }
|
||||
|
|
@ -33,7 +34,7 @@ const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [
|
|||
{ id: 'pr', label: 'Linked PR' },
|
||||
{ id: 'comment', label: 'Comment' },
|
||||
// Why: toggles the inline "Agent activity" list rendered below each
|
||||
// workspace card body (see WorktreeCard → WorktreeCardAgents). Off hides
|
||||
// workspace card body (see WorktreeCard -> WorktreeCardAgents). Off hides
|
||||
// the list; there is no alternate surface.
|
||||
{ id: 'inline-agents', label: 'Agent activity' }
|
||||
]
|
||||
|
|
@ -226,7 +227,7 @@ const SidebarHeader = React.memo(function SidebarHeader() {
|
|||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex h-8 items-center justify-between px-2 gap-2"
|
||||
className="mt-2 flex h-8 items-center justify-between px-2 gap-2"
|
||||
onPointerEnter={handleWorkspaceHeaderPointerEnter}
|
||||
onPointerLeave={handleWorkspaceHeaderPointerLeave}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -698,7 +698,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
// First header sits directly under SidebarHeader, which already
|
||||
// supplies its own spacing — only offset secondary group headers.
|
||||
vItem.index !== firstHeaderIndex && 'mt-2',
|
||||
row.repo ? 'overflow-hidden' : row.tone
|
||||
row.repo && 'overflow-hidden'
|
||||
)}
|
||||
onDragOver={
|
||||
isPinnedHeader
|
||||
|
|
@ -736,10 +736,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
}
|
||||
className={cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-[4px]',
|
||||
row.repo && 'text-muted-foreground'
|
||||
row.repo ? 'text-muted-foreground' : row.tone
|
||||
)}
|
||||
>
|
||||
<row.icon className="size-3.5" />
|
||||
<row.icon className={row.repo ? 'size-3.5' : 'size-3'} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import React from 'react'
|
||||
|
||||
export function ConductorDoneIcon({ className }: { className?: string }): React.JSX.Element {
|
||||
return React.createElement(
|
||||
'svg',
|
||||
{
|
||||
className,
|
||||
viewBox: '0 0 12 12',
|
||||
fill: 'none',
|
||||
'aria-hidden': true
|
||||
},
|
||||
React.createElement('circle', { cx: 6, cy: 6, r: 5.1, fill: 'currentColor' }),
|
||||
React.createElement('path', {
|
||||
d: 'M4 6.05 5.25 7.25 8.05 4.7',
|
||||
stroke: 'white',
|
||||
strokeWidth: 1.25,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function ConductorReviewIcon({ className }: { className?: string }): React.JSX.Element {
|
||||
return React.createElement(
|
||||
'svg',
|
||||
{
|
||||
className,
|
||||
viewBox: '0 0 12 12',
|
||||
fill: 'none',
|
||||
'aria-hidden': true
|
||||
},
|
||||
React.createElement('circle', {
|
||||
cx: 6,
|
||||
cy: 6,
|
||||
r: 4.9,
|
||||
fill: 'var(--background)',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.45
|
||||
}),
|
||||
React.createElement('path', {
|
||||
d: 'M4.15 6.05 5.25 7.05 7.7 4.75',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.2,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function ConductorProgressIcon({ className }: { className?: string }): React.JSX.Element {
|
||||
return React.createElement(
|
||||
'svg',
|
||||
{
|
||||
className,
|
||||
viewBox: '0 0 12 12',
|
||||
fill: 'none',
|
||||
'aria-hidden': true
|
||||
},
|
||||
React.createElement('circle', {
|
||||
cx: 6,
|
||||
cy: 6,
|
||||
r: 4.9,
|
||||
fill: 'var(--background)',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.45
|
||||
}),
|
||||
React.createElement('path', {
|
||||
d: 'M6 3.75v2.7',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.25,
|
||||
strokeLinecap: 'round'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
|
@ -1,16 +1,14 @@
|
|||
import type React from 'react'
|
||||
import React from 'react'
|
||||
import {
|
||||
Ban,
|
||||
Circle,
|
||||
CircleAlert,
|
||||
CircleCheckBig,
|
||||
CircleDashed,
|
||||
CircleDot,
|
||||
CircleEllipsis,
|
||||
CirclePause,
|
||||
CirclePlay,
|
||||
Flag,
|
||||
GitPullRequest,
|
||||
Timer
|
||||
} from 'lucide-react'
|
||||
import type { WorkspaceStatus, WorkspaceStatusDefinition } from '../../../../shared/types'
|
||||
|
|
@ -26,6 +24,11 @@ import {
|
|||
getWorkspaceStatusGroupKey,
|
||||
isWorkspaceStatusId
|
||||
} from '../../../../shared/workspace-statuses'
|
||||
import {
|
||||
ConductorDoneIcon,
|
||||
ConductorProgressIcon,
|
||||
ConductorReviewIcon
|
||||
} from './workspace-status-icons'
|
||||
|
||||
export {
|
||||
DEFAULT_WORKSPACE_STATUS_COLOR_ID,
|
||||
|
|
@ -122,22 +125,50 @@ export const WORKSPACE_STATUS_COLOR_OPTIONS: WorkspaceStatusColorOption[] = [
|
|||
swatch: 'bg-zinc-500',
|
||||
border: 'border-t-zinc-500/70',
|
||||
laneTint: 'bg-zinc-500/[0.04]'
|
||||
},
|
||||
{
|
||||
id: 'conductor-done',
|
||||
label: 'Conductor Done',
|
||||
tone: 'text-[#c7a594]',
|
||||
swatch: 'bg-[#c7a594]',
|
||||
border: 'border-t-[#c7a594]/70',
|
||||
laneTint: 'bg-[#c7a594]/[0.04]'
|
||||
},
|
||||
{
|
||||
id: 'conductor-review',
|
||||
label: 'Conductor Review',
|
||||
tone: 'text-[#16a34a]',
|
||||
swatch: 'bg-[#16a34a]',
|
||||
border: 'border-t-[#16a34a]/70',
|
||||
laneTint: 'bg-[#16a34a]/[0.04]'
|
||||
},
|
||||
{
|
||||
id: 'conductor-progress',
|
||||
label: 'Conductor Progress',
|
||||
tone: 'text-[#d4a300]',
|
||||
swatch: 'bg-[#d4a300]',
|
||||
border: 'border-t-[#d4a300]/70',
|
||||
laneTint: 'bg-[#d4a300]/[0.04]'
|
||||
}
|
||||
]
|
||||
|
||||
export const WORKSPACE_STATUS_ICON_OPTIONS: WorkspaceStatusIconOption[] = [
|
||||
{ id: 'circle', label: 'Circle', icon: Circle },
|
||||
{ id: 'circle-dot', label: 'Dot', icon: CircleDot },
|
||||
{ id: 'circle-progress', label: 'Progress', icon: ConductorProgressIcon },
|
||||
{ id: 'circle-dashed', label: 'Dashed', icon: CircleDashed },
|
||||
{ id: 'circle-ellipsis', label: 'Waiting', icon: CircleEllipsis },
|
||||
{ id: 'git-pull-request', label: 'Review', icon: GitPullRequest },
|
||||
{ id: 'git-pull-request', label: 'Review', icon: ConductorReviewIcon },
|
||||
{ id: 'timer', label: 'Timer', icon: Timer },
|
||||
{ id: 'flag', label: 'Flag', icon: Flag },
|
||||
{ id: 'circle-alert', label: 'Alert', icon: CircleAlert },
|
||||
{ id: 'circle-pause', label: 'Paused', icon: CirclePause },
|
||||
{ id: 'circle-play', label: 'Play', icon: CirclePlay },
|
||||
{ id: 'circle-check', label: 'Done', icon: CircleCheckBig },
|
||||
{ id: 'ban', label: 'Blocked', icon: Ban }
|
||||
{ id: 'circle-check', label: 'Done', icon: ConductorDoneIcon },
|
||||
{ id: 'ban', label: 'Blocked', icon: Ban },
|
||||
{ id: 'conductor-done', label: 'Done', icon: ConductorDoneIcon },
|
||||
{ id: 'conductor-review', label: 'In review', icon: ConductorReviewIcon },
|
||||
{ id: 'conductor-progress', label: 'In progress', icon: ConductorProgressIcon }
|
||||
]
|
||||
|
||||
const FALLBACK_COLOR_OPTION: WorkspaceStatusColorOption = WORKSPACE_STATUS_COLOR_OPTIONS[0] ?? {
|
||||
|
|
@ -167,16 +198,16 @@ const DEFAULT_STATUS_VISUALS: Record<
|
|||
icon: 'circle'
|
||||
},
|
||||
'in-progress': {
|
||||
color: 'blue',
|
||||
icon: 'circle-dot'
|
||||
color: 'conductor-progress',
|
||||
icon: 'conductor-progress'
|
||||
},
|
||||
'in-review': {
|
||||
color: 'violet',
|
||||
icon: 'git-pull-request'
|
||||
color: 'conductor-review',
|
||||
icon: 'conductor-review'
|
||||
},
|
||||
completed: {
|
||||
color: 'emerald',
|
||||
icon: 'circle-check'
|
||||
color: 'conductor-done',
|
||||
icon: 'conductor-done'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,26 @@ describe('buildRows with pinned worktrees', () => {
|
|||
expect(rows[1]).toMatchObject({ type: 'item', worktree: { id: 'wt-pinned' } })
|
||||
})
|
||||
|
||||
it('renders a flat list without status headers in groupBy flat', () => {
|
||||
const rows = buildRows('flat', [unpinned1, unpinned2], repoMap, null, new Set())
|
||||
|
||||
expect(rows).toMatchObject([
|
||||
{ type: 'item', worktree: { id: 'wt-1' } },
|
||||
{ type: 'item', worktree: { id: 'wt-2' } }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps pinned worktrees above the flat list', () => {
|
||||
const rows = buildRows('flat', [unpinned1, pinned, unpinned2], repoMap, null, new Set())
|
||||
|
||||
expect(rows).toMatchObject([
|
||||
{ type: 'header', key: 'pinned', count: 1 },
|
||||
{ type: 'item', worktree: { id: 'wt-pinned' } },
|
||||
{ type: 'item', worktree: { id: 'wt-1' } },
|
||||
{ type: 'item', worktree: { id: 'wt-2' } }
|
||||
])
|
||||
})
|
||||
|
||||
it('emits status headers for unpinned worktrees in groupBy none', () => {
|
||||
const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set())
|
||||
expect(rows[2]).toMatchObject({
|
||||
|
|
@ -310,6 +330,7 @@ describe('getRepoGroupOrdering', () => {
|
|||
['repo', 'smart', 'visible-worktree-order'],
|
||||
['repo', 'name', 'manual'],
|
||||
['repo', 'repo', 'manual'],
|
||||
['flat', 'recent', 'manual'],
|
||||
['none', 'recent', 'manual'],
|
||||
['pr-status', 'recent', 'manual']
|
||||
] as const)('uses %s/%s -> %s', (groupBy, sortBy, expected) => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/* eslint-disable max-lines -- Why: sidebar row construction keeps every grouping mode in one pure module so reveal, virtualized rendering, and tests share the same flat row contract. */
|
||||
import { CircleCheckBig, CircleDot, CircleX, Folder, GitPullRequest, Pin } from 'lucide-react'
|
||||
import { CircleX, Folder, Pin } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
import type {
|
||||
Repo,
|
||||
|
|
@ -14,12 +14,19 @@ import {
|
|||
getWorkspaceStatusGroupKey,
|
||||
getWorkspaceStatusVisualMeta
|
||||
} from './workspace-status'
|
||||
import {
|
||||
ConductorDoneIcon,
|
||||
ConductorProgressIcon,
|
||||
ConductorReviewIcon
|
||||
} from './workspace-status-icons'
|
||||
import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses'
|
||||
import type { SortBy } from './smart-sort'
|
||||
|
||||
export { branchName }
|
||||
|
||||
export type WorktreeGroupBy = 'none' | 'repo' | 'pr-status'
|
||||
// Why: `none` is the legacy persisted value for Status grouping. The actual
|
||||
// ungrouped sidebar mode is `flat`.
|
||||
export type WorktreeGroupBy = 'flat' | 'none' | 'repo' | 'pr-status'
|
||||
export type RepoGroupOrdering = 'manual' | 'visible-worktree-order'
|
||||
|
||||
export function getRepoGroupOrdering(groupBy: WorktreeGroupBy, sortBy: SortBy): RepoGroupOrdering {
|
||||
|
|
@ -67,18 +74,18 @@ export const PR_GROUP_META: Record<
|
|||
> = {
|
||||
done: {
|
||||
label: 'Done',
|
||||
icon: CircleCheckBig,
|
||||
tone: 'text-emerald-700 dark:text-emerald-200'
|
||||
icon: ConductorDoneIcon,
|
||||
tone: 'text-[#c7a594]'
|
||||
},
|
||||
'in-review': {
|
||||
label: 'In review',
|
||||
icon: GitPullRequest,
|
||||
tone: 'text-sky-700 dark:text-sky-200'
|
||||
icon: ConductorReviewIcon,
|
||||
tone: 'text-[#16a34a]'
|
||||
},
|
||||
'in-progress': {
|
||||
label: 'In progress',
|
||||
icon: CircleDot,
|
||||
tone: 'text-amber-700 dark:text-amber-200'
|
||||
icon: ConductorProgressIcon,
|
||||
tone: 'text-[#d4a300]'
|
||||
},
|
||||
closed: {
|
||||
label: 'Closed',
|
||||
|
|
@ -369,6 +376,15 @@ export function buildRows(
|
|||
)
|
||||
const unpinned = pinnedIds.size > 0 ? worktrees.filter((w) => !pinnedIds.has(w.id)) : worktrees
|
||||
|
||||
if (groupBy === 'flat') {
|
||||
appendWorktreeRows(result, unpinned, repoMap, lineageById, worktreeMap, {
|
||||
nestLineage,
|
||||
showLineageContext: nestLineage,
|
||||
collapsedGroups
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
const grouped = new Map<string, { label: string; items: Worktree[]; repo?: Repo }>()
|
||||
for (const w of unpinned) {
|
||||
let key: string
|
||||
|
|
@ -501,6 +517,9 @@ export function getGroupKeyForWorktree(
|
|||
prCache: Record<string, unknown> | null,
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses()
|
||||
): string | null {
|
||||
if (groupBy === 'flat') {
|
||||
return null
|
||||
}
|
||||
if (groupBy === 'none') {
|
||||
return getWorkspaceStatusGroupKey(getWorkspaceStatus(worktree, workspaceStatuses))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -349,7 +349,7 @@ export type UISlice = {
|
|||
) => void
|
||||
markOrcaHookRepoAlwaysTrusted: (repoId: string) => void
|
||||
clearOrcaHookTrustForRepo: (repoId: string) => void
|
||||
groupBy: 'none' | 'repo' | 'pr-status'
|
||||
groupBy: 'flat' | 'none' | 'repo' | 'pr-status'
|
||||
setGroupBy: (g: UISlice['groupBy']) => void
|
||||
showWorkspaceLineage: boolean
|
||||
setShowWorkspaceLineage: (v: boolean) => void
|
||||
|
|
|
|||
|
|
@ -353,6 +353,8 @@ export function getDefaultUIState(): PersistedUIState {
|
|||
workspaceStatuses: cloneDefaultWorkspaceStatuses(),
|
||||
workspaceBoardOpacity: 1,
|
||||
workspaceBoardCompact: false,
|
||||
_workspaceStatusesDefaultOrderMigrated: true,
|
||||
_workspaceStatusesDefaultVisualsMigrated: true,
|
||||
statusBarItems: [...DEFAULT_STATUS_BAR_ITEMS],
|
||||
statusBarVisible: true,
|
||||
dismissedUpdateVersion: null,
|
||||
|
|
|
|||
|
|
@ -1680,7 +1680,7 @@ export type PersistedUIState = {
|
|||
lastActiveWorktreeId: string | null
|
||||
sidebarWidth: number
|
||||
rightSidebarWidth: number
|
||||
groupBy: 'none' | 'repo' | 'pr-status'
|
||||
groupBy: 'flat' | 'none' | 'repo' | 'pr-status'
|
||||
showWorkspaceLineage?: boolean
|
||||
sortBy: 'name' | 'smart' | 'recent' | 'repo'
|
||||
showActiveOnly: boolean
|
||||
|
|
@ -1698,6 +1698,13 @@ export type PersistedUIState = {
|
|||
workspaceStatuses?: WorkspaceStatusDefinition[]
|
||||
workspaceBoardOpacity?: number
|
||||
workspaceBoardCompact?: boolean
|
||||
/** One-shot migration flag for a short-lived build that persisted the
|
||||
* default workspace statuses in reverse workflow order. Once stamped,
|
||||
* user-authored status ordering is never inferred from IDs/labels again. */
|
||||
_workspaceStatusesDefaultOrderMigrated?: boolean
|
||||
/** One-shot migration flag for the old default blue/violet/emerald status
|
||||
* visuals. Once stamped, valid user-authored colors/icons are preserved. */
|
||||
_workspaceStatusesDefaultVisualsMigrated?: boolean
|
||||
statusBarItems: StatusBarItem[]
|
||||
statusBarVisible: boolean
|
||||
dismissedUpdateVersion: string | null
|
||||
|
|
|
|||
|
|
@ -0,0 +1,187 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
cloneDefaultWorkspaceStatuses,
|
||||
normalizePersistedWorkspaceStatuses,
|
||||
normalizeWorkspaceStatuses
|
||||
} from './workspace-statuses'
|
||||
|
||||
describe('workspace status visuals', () => {
|
||||
it('keeps todo first by default', () => {
|
||||
expect(cloneDefaultWorkspaceStatuses().map((status) => status.id)).toEqual([
|
||||
'todo',
|
||||
'in-progress',
|
||||
'in-review',
|
||||
'completed'
|
||||
])
|
||||
})
|
||||
|
||||
it('migrates the old default status visuals without reordering the board', () => {
|
||||
const statuses = normalizePersistedWorkspaceStatuses(
|
||||
[
|
||||
{ id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' },
|
||||
{ id: 'in-progress', label: 'In progress', color: 'blue', icon: 'circle-dot' },
|
||||
{ id: 'in-review', label: 'In review', color: 'violet', icon: 'git-pull-request' },
|
||||
{ id: 'completed', label: 'Completed', color: 'emerald', icon: 'circle-check' }
|
||||
],
|
||||
{ migrateLegacyDefaultStatusVisuals: true }
|
||||
)
|
||||
|
||||
expect(statuses.map((status) => status.id)).toEqual([
|
||||
'todo',
|
||||
'in-progress',
|
||||
'in-review',
|
||||
'completed'
|
||||
])
|
||||
expect(statuses).toEqual(cloneDefaultWorkspaceStatuses())
|
||||
})
|
||||
|
||||
it('preserves explicit status order while migrating default visuals', () => {
|
||||
const statuses = normalizePersistedWorkspaceStatuses(
|
||||
[
|
||||
{ id: 'completed', label: 'Completed', color: 'emerald', icon: 'circle-check' },
|
||||
{ id: 'in-review', label: 'In review', color: 'violet', icon: 'git-pull-request' },
|
||||
{ id: 'in-progress', label: 'In progress', color: 'blue', icon: 'circle-dot' },
|
||||
{ id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' }
|
||||
],
|
||||
{ migrateLegacyDefaultStatusVisuals: true }
|
||||
)
|
||||
|
||||
expect(statuses.map((status) => status.id)).toEqual([
|
||||
'completed',
|
||||
'in-review',
|
||||
'in-progress',
|
||||
'todo'
|
||||
])
|
||||
expect(statuses[0]).toMatchObject({
|
||||
color: 'conductor-done',
|
||||
icon: 'conductor-done'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves default-label reordered statuses unless the one-shot repair is requested', () => {
|
||||
const statuses = normalizePersistedWorkspaceStatuses([
|
||||
{ id: 'completed', label: 'Completed', color: 'conductor-done', icon: 'conductor-done' },
|
||||
{ id: 'in-review', label: 'In review', color: 'conductor-review', icon: 'conductor-review' },
|
||||
{
|
||||
id: 'in-progress',
|
||||
label: 'In progress',
|
||||
color: 'conductor-progress',
|
||||
icon: 'conductor-progress'
|
||||
},
|
||||
{ id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' }
|
||||
])
|
||||
|
||||
expect(statuses.map((status) => status.id)).toEqual([
|
||||
'completed',
|
||||
'in-review',
|
||||
'in-progress',
|
||||
'todo'
|
||||
])
|
||||
})
|
||||
|
||||
it('repairs the exact PR-introduced default status reorder when migration-gated', () => {
|
||||
const statuses = normalizePersistedWorkspaceStatuses(
|
||||
[
|
||||
{ id: 'completed', label: 'Completed', color: 'conductor-done', icon: 'conductor-done' },
|
||||
{
|
||||
id: 'in-review',
|
||||
label: 'In review',
|
||||
color: 'conductor-review',
|
||||
icon: 'conductor-review'
|
||||
},
|
||||
{
|
||||
id: 'in-progress',
|
||||
label: 'In progress',
|
||||
color: 'conductor-progress',
|
||||
icon: 'conductor-progress'
|
||||
},
|
||||
{ id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' }
|
||||
],
|
||||
{ repairReorderedDefaultStatuses: true }
|
||||
)
|
||||
|
||||
expect(statuses).toEqual(cloneDefaultWorkspaceStatuses())
|
||||
})
|
||||
|
||||
it('does not repair reordered default-label statuses with a different raw shape', () => {
|
||||
const statuses = normalizePersistedWorkspaceStatuses(
|
||||
[
|
||||
{ id: 'completed', label: 'Completed', color: 'emerald', icon: 'circle-check' },
|
||||
{ id: 'in-review', label: 'In review', color: 'violet', icon: 'git-pull-request' },
|
||||
{ id: 'in-progress', label: 'In progress', color: 'blue', icon: 'circle-dot' },
|
||||
{ id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' }
|
||||
],
|
||||
{ repairReorderedDefaultStatuses: true }
|
||||
)
|
||||
|
||||
expect(statuses.map((status) => status.id)).toEqual([
|
||||
'completed',
|
||||
'in-review',
|
||||
'in-progress',
|
||||
'todo'
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves custom persisted status layouts in their saved order', () => {
|
||||
const statuses = normalizePersistedWorkspaceStatuses([
|
||||
{ id: 'completed', label: 'Shipped', color: 'conductor-done', icon: 'conductor-done' },
|
||||
{ id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' }
|
||||
])
|
||||
|
||||
expect(statuses.map((status) => status.id)).toEqual(['completed', 'todo'])
|
||||
})
|
||||
|
||||
it('uses Conductor-style visuals for the default status icons', () => {
|
||||
const statuses = cloneDefaultWorkspaceStatuses()
|
||||
const inProgress = statuses.find((status) => status.id === 'in-progress')
|
||||
const inReview = statuses.find((status) => status.id === 'in-review')
|
||||
const completed = statuses.find((status) => status.id === 'completed')
|
||||
|
||||
expect(inProgress).toMatchObject({
|
||||
color: 'conductor-progress',
|
||||
icon: 'conductor-progress'
|
||||
})
|
||||
expect(inReview).toMatchObject({
|
||||
color: 'conductor-review',
|
||||
icon: 'conductor-review'
|
||||
})
|
||||
expect(completed).toMatchObject({
|
||||
color: 'conductor-done',
|
||||
icon: 'conductor-done'
|
||||
})
|
||||
})
|
||||
|
||||
it('migrates the old in-progress blue dot default only when requested', () => {
|
||||
const statuses = normalizePersistedWorkspaceStatuses(
|
||||
[{ id: 'in-progress', label: 'In progress', color: 'blue', icon: 'circle-dot' }],
|
||||
{ migrateLegacyDefaultStatusVisuals: true }
|
||||
)
|
||||
|
||||
expect(statuses[0]).toMatchObject({
|
||||
color: 'conductor-progress',
|
||||
icon: 'conductor-progress'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves valid legacy visuals for default-label statuses at runtime', () => {
|
||||
const statuses = normalizeWorkspaceStatuses([
|
||||
{ id: 'in-progress', label: 'In progress', color: 'blue', icon: 'circle-dot' }
|
||||
])
|
||||
|
||||
expect(statuses[0]).toMatchObject({
|
||||
color: 'blue',
|
||||
icon: 'circle-dot'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps intentional custom in-progress visuals', () => {
|
||||
const statuses = normalizeWorkspaceStatuses([
|
||||
{ id: 'in-progress', label: 'Doing', color: 'blue', icon: 'circle-dot' }
|
||||
])
|
||||
|
||||
expect(statuses[0]).toMatchObject({
|
||||
color: 'blue',
|
||||
icon: 'circle-dot'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -3,6 +3,9 @@ import type { Worktree, WorkspaceStatus, WorkspaceStatusDefinition } from './typ
|
|||
const WORKSPACE_STATUS_GROUP_PREFIX = 'workspace-status:'
|
||||
const MAX_STATUS_LABEL_LENGTH = 32
|
||||
const MAX_WORKSPACE_STATUSES = 12
|
||||
type WorkspaceStatusNormalizationOptions = {
|
||||
migrateLegacyDefaultStatusVisuals?: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_WORKSPACE_STATUS_ID: WorkspaceStatus = 'in-progress'
|
||||
export const DEFAULT_WORKSPACE_STATUS_COLOR_ID = 'neutral'
|
||||
|
|
@ -16,12 +19,16 @@ export const WORKSPACE_STATUS_COLOR_IDS = [
|
|||
'amber',
|
||||
'emerald',
|
||||
'rose',
|
||||
'zinc'
|
||||
'zinc',
|
||||
'conductor-done',
|
||||
'conductor-review',
|
||||
'conductor-progress'
|
||||
] as const
|
||||
|
||||
export const WORKSPACE_STATUS_ICON_IDS = [
|
||||
'circle',
|
||||
'circle-dot',
|
||||
'circle-progress',
|
||||
'circle-dashed',
|
||||
'circle-ellipsis',
|
||||
'git-pull-request',
|
||||
|
|
@ -31,21 +38,29 @@ export const WORKSPACE_STATUS_ICON_IDS = [
|
|||
'circle-pause',
|
||||
'circle-play',
|
||||
'circle-check',
|
||||
'ban'
|
||||
'ban',
|
||||
'conductor-done',
|
||||
'conductor-review',
|
||||
'conductor-progress'
|
||||
] as const
|
||||
|
||||
const DEFAULT_STATUS_VISUALS: Record<string, { color: string; icon: string }> = {
|
||||
todo: { color: 'neutral', icon: 'circle' },
|
||||
'in-progress': { color: 'blue', icon: 'circle-dot' },
|
||||
'in-review': { color: 'violet', icon: 'git-pull-request' },
|
||||
completed: { color: 'emerald', icon: 'circle-check' }
|
||||
'in-progress': { color: 'conductor-progress', icon: 'conductor-progress' },
|
||||
'in-review': { color: 'conductor-review', icon: 'conductor-review' },
|
||||
completed: { color: 'conductor-done', icon: 'conductor-done' }
|
||||
}
|
||||
|
||||
export const DEFAULT_WORKSPACE_STATUSES = [
|
||||
{ id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' },
|
||||
{ id: 'in-progress', label: 'In progress', color: 'blue', icon: 'circle-dot' },
|
||||
{ id: 'in-review', label: 'In review', color: 'violet', icon: 'git-pull-request' },
|
||||
{ id: 'completed', label: 'Completed', color: 'emerald', icon: 'circle-check' }
|
||||
{
|
||||
id: 'in-progress',
|
||||
label: 'In progress',
|
||||
color: 'conductor-progress',
|
||||
icon: 'conductor-progress'
|
||||
},
|
||||
{ id: 'in-review', label: 'In review', color: 'conductor-review', icon: 'conductor-review' },
|
||||
{ id: 'completed', label: 'Completed', color: 'conductor-done', icon: 'conductor-done' }
|
||||
] as const satisfies readonly WorkspaceStatusDefinition[]
|
||||
|
||||
export function cloneDefaultWorkspaceStatuses(): WorkspaceStatusDefinition[] {
|
||||
|
|
@ -80,7 +95,22 @@ function sanitizeWorkspaceStatusId(value: unknown, fallbackLabel: string): Works
|
|||
return trimmed.replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'status'
|
||||
}
|
||||
|
||||
function sanitizeWorkspaceStatusColor(value: unknown, statusId: string, index: number): string {
|
||||
function sanitizeWorkspaceStatusColor(
|
||||
value: unknown,
|
||||
statusId: string,
|
||||
label: string,
|
||||
index: number,
|
||||
options: WorkspaceStatusNormalizationOptions
|
||||
): string {
|
||||
if (
|
||||
options.migrateLegacyDefaultStatusVisuals === true &&
|
||||
((statusId === 'in-progress' && label === 'In progress' && value === 'blue') ||
|
||||
(statusId === 'in-review' && label === 'In review' && value === 'violet') ||
|
||||
(statusId === 'completed' && label === 'Completed' && value === 'emerald')) &&
|
||||
DEFAULT_STATUS_VISUALS[statusId]
|
||||
) {
|
||||
return DEFAULT_STATUS_VISUALS[statusId]?.color ?? DEFAULT_WORKSPACE_STATUS_COLOR_ID
|
||||
}
|
||||
if (typeof value === 'string' && WORKSPACE_STATUS_COLOR_IDS.some((id) => id === value)) {
|
||||
return value
|
||||
}
|
||||
|
|
@ -91,7 +121,23 @@ function sanitizeWorkspaceStatusColor(value: unknown, statusId: string, index: n
|
|||
return WORKSPACE_STATUS_COLOR_IDS[index % WORKSPACE_STATUS_COLOR_IDS.length]
|
||||
}
|
||||
|
||||
function sanitizeWorkspaceStatusIcon(value: unknown, statusId: string): string {
|
||||
function sanitizeWorkspaceStatusIcon(
|
||||
value: unknown,
|
||||
statusId: string,
|
||||
label: string,
|
||||
options: WorkspaceStatusNormalizationOptions
|
||||
): string {
|
||||
if (
|
||||
options.migrateLegacyDefaultStatusVisuals === true &&
|
||||
((statusId === 'in-progress' &&
|
||||
label === 'In progress' &&
|
||||
(value === 'circle-dot' || value === 'circle-progress')) ||
|
||||
(statusId === 'in-review' && label === 'In review' && value === 'git-pull-request') ||
|
||||
(statusId === 'completed' && label === 'Completed' && value === 'circle-check')) &&
|
||||
DEFAULT_STATUS_VISUALS[statusId]
|
||||
) {
|
||||
return DEFAULT_STATUS_VISUALS[statusId]?.icon ?? DEFAULT_WORKSPACE_STATUS_ICON_ID
|
||||
}
|
||||
if (typeof value === 'string' && WORKSPACE_STATUS_ICON_IDS.some((id) => id === value)) {
|
||||
return value
|
||||
}
|
||||
|
|
@ -116,7 +162,10 @@ export function makeWorkspaceStatusId(
|
|||
return `status-${Date.now().toString(36)}`
|
||||
}
|
||||
|
||||
export function normalizeWorkspaceStatuses(value: unknown): WorkspaceStatusDefinition[] {
|
||||
function normalizeWorkspaceStatusesInternal(
|
||||
value: unknown,
|
||||
options: WorkspaceStatusNormalizationOptions
|
||||
): WorkspaceStatusDefinition[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return cloneDefaultWorkspaceStatuses()
|
||||
}
|
||||
|
|
@ -138,12 +187,71 @@ export function normalizeWorkspaceStatuses(value: unknown): WorkspaceStatusDefin
|
|||
statuses.push({
|
||||
id,
|
||||
label,
|
||||
color: sanitizeWorkspaceStatusColor(raw.color, id, statuses.length),
|
||||
icon: sanitizeWorkspaceStatusIcon(raw.icon, id)
|
||||
color: sanitizeWorkspaceStatusColor(raw.color, id, label, statuses.length, options),
|
||||
icon: sanitizeWorkspaceStatusIcon(raw.icon, id, label, options)
|
||||
})
|
||||
}
|
||||
|
||||
return statuses.length > 0 ? statuses : cloneDefaultWorkspaceStatuses()
|
||||
if (statuses.length === 0) {
|
||||
return cloneDefaultWorkspaceStatuses()
|
||||
}
|
||||
|
||||
return statuses
|
||||
}
|
||||
|
||||
export function normalizeWorkspaceStatuses(value: unknown): WorkspaceStatusDefinition[] {
|
||||
return normalizeWorkspaceStatusesInternal(value, {})
|
||||
}
|
||||
|
||||
const PR_REORDERED_DEFAULT_STATUS_IDS = ['completed', 'in-review', 'in-progress', 'todo'] as const
|
||||
|
||||
const PR_REORDERED_DEFAULT_STATUSES = PR_REORDERED_DEFAULT_STATUS_IDS.map((id) => {
|
||||
const status = DEFAULT_WORKSPACE_STATUSES.find((entry) => entry.id === id)
|
||||
if (!status) {
|
||||
throw new Error(`Missing default workspace status: ${id}`)
|
||||
}
|
||||
return { ...status }
|
||||
})
|
||||
|
||||
function isKnownBadPRReorderedDefaultStatusPayload(value: unknown): boolean {
|
||||
if (!Array.isArray(value) || value.length !== PR_REORDERED_DEFAULT_STATUSES.length) {
|
||||
return false
|
||||
}
|
||||
return value.every((rawStatus, index) => {
|
||||
if (!rawStatus || typeof rawStatus !== 'object' || Array.isArray(rawStatus)) {
|
||||
return false
|
||||
}
|
||||
const raw = rawStatus as Record<string, unknown>
|
||||
const expected = PR_REORDERED_DEFAULT_STATUSES[index]
|
||||
return (
|
||||
Object.keys(raw).length === 4 &&
|
||||
raw.id === expected.id &&
|
||||
raw.label === expected.label &&
|
||||
raw.color === expected.color &&
|
||||
raw.icon === expected.icon
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizePersistedWorkspaceStatuses(
|
||||
value: unknown,
|
||||
options: {
|
||||
repairReorderedDefaultStatuses?: boolean
|
||||
migrateLegacyDefaultStatusVisuals?: boolean
|
||||
} = {}
|
||||
): WorkspaceStatusDefinition[] {
|
||||
// Why: this PR briefly wrote the default columns in reverse workflow order.
|
||||
// The repair is one-shot and checks the raw payload, because normalized
|
||||
// IDs/labels are indistinguishable from a user-authored column reorder.
|
||||
if (
|
||||
options.repairReorderedDefaultStatuses === true &&
|
||||
isKnownBadPRReorderedDefaultStatusPayload(value)
|
||||
) {
|
||||
return cloneDefaultWorkspaceStatuses()
|
||||
}
|
||||
return normalizeWorkspaceStatusesInternal(value, {
|
||||
migrateLegacyDefaultStatusVisuals: options.migrateLegacyDefaultStatusVisuals
|
||||
})
|
||||
}
|
||||
|
||||
export function clampWorkspaceBoardOpacity(value: unknown): number {
|
||||
|
|
|
|||
Loading…
Reference in New Issue