Fix manual project order across paired runtime hosts (#8894)
* fix(sidebar): persist manual project order across hosts Co-authored-by: Orca <help@stably.ai> * fix(sidebar): scope desktop repo reorder by host Co-authored-by: Orca <help@stably.ai> * fix(web): cover host-scoped repo reorder API Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
45a772cb42
commit
a2d3451efd
|
|
@ -1127,6 +1127,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
ipcMain.removeHandler('repos:remove')
|
||||
ipcMain.removeHandler('repos:removeForHost')
|
||||
ipcMain.removeHandler('repos:reorder')
|
||||
ipcMain.removeHandler('repos:reorderForHost')
|
||||
ipcMain.removeHandler('repos:update')
|
||||
ipcMain.removeHandler('projects:list')
|
||||
ipcMain.removeHandler('projects:update')
|
||||
|
|
@ -1928,6 +1929,26 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'repos:reorderForHost',
|
||||
(
|
||||
_event,
|
||||
args: { orderedIds: string[]; hostId: string }
|
||||
): { status: 'applied' | 'rejected' } => {
|
||||
const hostId = normalizeExecutionHostId(args?.hostId)
|
||||
if (!hostId) {
|
||||
return { status: 'rejected' }
|
||||
}
|
||||
const ids = Array.isArray(args?.orderedIds) ? args.orderedIds : []
|
||||
const applied = store.reorderReposForHost(ids, hostId)
|
||||
if (applied) {
|
||||
notifyReposChanged(mainWindow)
|
||||
return { status: 'applied' }
|
||||
}
|
||||
return { status: 'rejected' }
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('repos:remove', async (_event, args: { repoId: string }) => {
|
||||
store.removeProject(args.repoId)
|
||||
invalidateAuthorizedRootsCache()
|
||||
|
|
|
|||
|
|
@ -3317,6 +3317,62 @@ describe('Store', () => {
|
|||
expect(store.getWorktreeMeta('shared::/remote/repo/wt')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reorderReposForHost independently reorders local and SSH rows with shared ids', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo({ id: 'shared', path: '/local/shared' }))
|
||||
store.addRepo(
|
||||
makeRepo({
|
||||
id: 'shared',
|
||||
path: '/ssh/shared',
|
||||
connectionId: 'target'
|
||||
})
|
||||
)
|
||||
store.addRepo(makeRepo({ id: 'local-two', path: '/local/two' }))
|
||||
store.addRepo(
|
||||
makeRepo({
|
||||
id: 'ssh-two',
|
||||
path: '/ssh/two',
|
||||
connectionId: 'target'
|
||||
})
|
||||
)
|
||||
|
||||
expect(store.reorderReposForHost(['local-two', 'shared'], 'local')).toBe(true)
|
||||
expect(store.getRepos().map((repo) => repo.path)).toEqual([
|
||||
'/local/two',
|
||||
'/ssh/shared',
|
||||
'/local/shared',
|
||||
'/ssh/two'
|
||||
])
|
||||
|
||||
expect(store.reorderReposForHost(['ssh-two', 'shared'], 'ssh:target')).toBe(true)
|
||||
expect(store.getRepos().map((repo) => repo.path)).toEqual([
|
||||
'/local/two',
|
||||
'/ssh/two',
|
||||
'/local/shared',
|
||||
'/ssh/shared'
|
||||
])
|
||||
})
|
||||
|
||||
it('reorderReposForHost rejects stale or duplicate host permutations without mutation', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo({ id: 'local-one', path: '/local/one' }))
|
||||
store.addRepo(makeRepo({ id: 'local-two', path: '/local/two' }))
|
||||
store.addRepo(
|
||||
makeRepo({
|
||||
id: 'ssh-one',
|
||||
path: '/ssh/one',
|
||||
connectionId: 'target',
|
||||
executionHostId: 'ssh:target'
|
||||
})
|
||||
)
|
||||
const originalPaths = store.getRepos().map((repo) => repo.path)
|
||||
|
||||
expect(store.reorderReposForHost(['local-two'], 'local')).toBe(false)
|
||||
expect(store.reorderReposForHost(['local-one', 'local-one'], 'local')).toBe(false)
|
||||
expect(store.reorderReposForHost(['missing', 'local-two'], 'local')).toBe(false)
|
||||
expect(store.getRepos().map((repo) => repo.path)).toEqual(originalPaths)
|
||||
})
|
||||
|
||||
it('removeProjectForHost prunes the SSH host meta (tagged hostId) for a shared id', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo({ id: 'shared', path: '/local/repo' }))
|
||||
|
|
@ -5298,6 +5354,25 @@ describe('Store', () => {
|
|||
expect(ui.dismissedUpdateVersion).toBeNull()
|
||||
})
|
||||
|
||||
it('round-trips and normalizes the host-qualified manual repo order', async () => {
|
||||
const store = await createStore()
|
||||
store.updateUI({
|
||||
manualRepoOrder: [
|
||||
{ hostId: 'runtime:node-b', repoId: 'shared' },
|
||||
{ hostId: 'bogus', repoId: 'ignored' },
|
||||
{ hostId: 'runtime:node-b', repoId: 'shared' },
|
||||
{ hostId: 'local', repoId: 'alpha' }
|
||||
] as never
|
||||
})
|
||||
store.flush()
|
||||
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getUI().manualRepoOrder).toEqual([
|
||||
{ hostId: 'runtime:node-b', repoId: 'shared' },
|
||||
{ hostId: 'local', repoId: 'alpha' }
|
||||
])
|
||||
})
|
||||
|
||||
it('updateUI persists sanitized per-worktree dotfile visibility', async () => {
|
||||
const store = await createStore()
|
||||
store.updateUI({
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@ import {
|
|||
} from '../shared/feature-interactions'
|
||||
import { normalizeContextualTourIds } from '../shared/contextual-tours'
|
||||
import { normalizeFeatureTipIds } from '../shared/feature-tips'
|
||||
import { normalizeManualRepoOrder } from '../shared/manual-repo-order'
|
||||
import {
|
||||
DEFAULT_WORKSPACE_STATUS_ID,
|
||||
clampWorkspaceBoardColumnWidth,
|
||||
|
|
@ -4270,6 +4271,37 @@ export class Store {
|
|||
return true
|
||||
}
|
||||
|
||||
// Why: repo ids are unique only within an execution host, and renderer drags
|
||||
// persist one complete permutation per host when local and SSH repos coexist.
|
||||
reorderReposForHost(orderedIds: string[], hostId: ExecutionHostId): boolean {
|
||||
const current = this.state.repos
|
||||
const hostRepos = current.filter((repo) => getRepoExecutionHostId(repo) === hostId)
|
||||
if (orderedIds.length !== hostRepos.length) {
|
||||
return false
|
||||
}
|
||||
const byId = new Map(hostRepos.map((repo) => [repo.id, repo]))
|
||||
if (byId.size !== hostRepos.length) {
|
||||
return false
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const reorderedHostRepos: Repo[] = []
|
||||
for (const id of orderedIds) {
|
||||
const repo = typeof id === 'string' && !seen.has(id) ? byId.get(id) : undefined
|
||||
if (!repo) {
|
||||
return false
|
||||
}
|
||||
seen.add(id)
|
||||
reorderedHostRepos.push(repo)
|
||||
}
|
||||
let nextHostIndex = 0
|
||||
this.state.repos = current.map((repo) =>
|
||||
getRepoExecutionHostId(repo) === hostId ? reorderedHostRepos[nextHostIndex++] : repo
|
||||
)
|
||||
this.syncProjectHostSetupCompatibilityState()
|
||||
this.scheduleSave()
|
||||
return true
|
||||
}
|
||||
|
||||
removeProject(id: string): void {
|
||||
this.state.repos = this.state.repos.filter((r) => r.id !== id)
|
||||
this.syncProjectHostSetupCompatibilityState()
|
||||
|
|
@ -5409,6 +5441,7 @@ export class Store {
|
|||
this.state.ui?.visibleWorkspaceHostIds
|
||||
),
|
||||
workspaceHostOrder: normalizeExecutionHostOrder(this.state.ui?.workspaceHostOrder),
|
||||
manualRepoOrder: normalizeManualRepoOrder(this.state.ui?.manualRepoOrder),
|
||||
browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(
|
||||
this.state.ui?.browserDefaultZoomLevel
|
||||
),
|
||||
|
|
@ -5494,6 +5527,10 @@ export class Store {
|
|||
updates.workspaceHostOrder !== undefined
|
||||
? normalizeExecutionHostOrder(updates.workspaceHostOrder)
|
||||
: normalizeExecutionHostOrder(this.state.ui?.workspaceHostOrder),
|
||||
manualRepoOrder:
|
||||
updates.manualRepoOrder !== undefined
|
||||
? normalizeManualRepoOrder(updates.manualRepoOrder)
|
||||
: normalizeManualRepoOrder(this.state.ui?.manualRepoOrder),
|
||||
browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(
|
||||
updates.browserDefaultZoomLevel ?? this.state.ui?.browserDefaultZoomLevel
|
||||
),
|
||||
|
|
|
|||
|
|
@ -191,6 +191,9 @@ export const UiUpdate = z
|
|||
workspaceHostScope: z.string().optional(),
|
||||
visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(),
|
||||
workspaceHostOrder: z.array(z.string()).optional(),
|
||||
manualRepoOrder: z
|
||||
.array(z.object({ hostId: z.string(), repoId: z.string() }).strict())
|
||||
.optional(),
|
||||
hideDefaultBranchWorkspace: z.boolean().optional(),
|
||||
hideAutomationGeneratedWorkspaces: z.boolean().optional(),
|
||||
filterRepoIds: StringArray.optional(),
|
||||
|
|
|
|||
|
|
@ -269,7 +269,8 @@ describe('client UI RPC methods', () => {
|
|||
contextualToursSeenIds: ['tasks'],
|
||||
contextualToursAutoEligible: true,
|
||||
usageEmptyStateDismissed: true,
|
||||
browserDefaultZoomLevel: 1.5
|
||||
browserDefaultZoomLevel: 1.5,
|
||||
manualRepoOrder: [{ hostId: 'runtime:node-b', repoId: 'repo-b' }]
|
||||
}
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
|
|
@ -310,7 +311,8 @@ describe('client UI RPC methods', () => {
|
|||
contextualToursSeenIds: ['tasks'],
|
||||
contextualToursAutoEligible: true,
|
||||
usageEmptyStateDismissed: true,
|
||||
browserDefaultZoomLevel: 1.5
|
||||
browserDefaultZoomLevel: 1.5,
|
||||
manualRepoOrder: [{ hostId: 'runtime:node-b', repoId: 'repo-b' }]
|
||||
}
|
||||
const response = await dispatcher.dispatch(makeRequest('ui.set', payload))
|
||||
|
||||
|
|
|
|||
|
|
@ -114,24 +114,34 @@ describe('migrateUiHostScopeSshTargetId', () => {
|
|||
const makeUi = (overrides: Partial<PersistedUIState>): PersistedUIState =>
|
||||
({ ...overrides }) as PersistedUIState
|
||||
|
||||
it('re-points scope, visible hosts, and host order, deduping collisions', () => {
|
||||
it('re-points scope, visible hosts, host order, and manual repo order', () => {
|
||||
const ui = makeUi({
|
||||
workspaceHostScope: `ssh:${OLD_ID}`,
|
||||
visibleWorkspaceHostIds: ['local', `ssh:${OLD_ID}`, `ssh:${NEW_ID}`],
|
||||
workspaceHostOrder: [`ssh:${OLD_ID}`, 'local']
|
||||
workspaceHostOrder: [`ssh:${OLD_ID}`, 'local'],
|
||||
manualRepoOrder: [
|
||||
{ hostId: `ssh:${OLD_ID}`, repoId: 'remote-repo' },
|
||||
{ hostId: `ssh:${NEW_ID}`, repoId: 'remote-repo' },
|
||||
{ hostId: 'local', repoId: 'local-repo' }
|
||||
]
|
||||
})
|
||||
|
||||
expect(migrateUiHostScopeSshTargetId(ui, OLD_ID, NEW_ID)).toBe(true)
|
||||
expect(ui.workspaceHostScope).toBe(`ssh:${NEW_ID}`)
|
||||
expect(ui.visibleWorkspaceHostIds).toEqual(['local', `ssh:${NEW_ID}`])
|
||||
expect(ui.workspaceHostOrder).toEqual([`ssh:${NEW_ID}`, 'local'])
|
||||
expect(ui.manualRepoOrder).toEqual([
|
||||
{ hostId: `ssh:${NEW_ID}`, repoId: 'remote-repo' },
|
||||
{ hostId: 'local', repoId: 'local-repo' }
|
||||
])
|
||||
})
|
||||
|
||||
it('returns false when the old host id appears nowhere', () => {
|
||||
const ui = makeUi({
|
||||
workspaceHostScope: 'all',
|
||||
visibleWorkspaceHostIds: ['local'],
|
||||
workspaceHostOrder: ['local']
|
||||
workspaceHostOrder: ['local'],
|
||||
manualRepoOrder: [{ hostId: 'local', repoId: 'local-repo' }]
|
||||
})
|
||||
|
||||
expect(migrateUiHostScopeSshTargetId(ui, OLD_ID, NEW_ID)).toBe(false)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { PersistedUIState, WorkspaceSessionState } from '../../shared/types'
|
||||
import { parseAppSshPtyId, toAppSshPtyId } from '../../shared/ssh-pty-id'
|
||||
import { toSshExecutionHostId } from '../../shared/execution-host'
|
||||
import { normalizeManualRepoOrder } from '../../shared/manual-repo-order'
|
||||
|
||||
/**
|
||||
* Carrier sweep for SSH target re-adoption (see ssh-target-readoption.ts).
|
||||
|
|
@ -112,5 +113,13 @@ export function migrateUiHostScopeSshTargetId(
|
|||
]
|
||||
changed = true
|
||||
}
|
||||
if (ui.manualRepoOrder?.some((entry) => entry.hostId === oldHostId)) {
|
||||
ui.manualRepoOrder = normalizeManualRepoOrder(
|
||||
ui.manualRepoOrder.map((entry) =>
|
||||
entry.hostId === oldHostId ? { ...entry, hostId: newHostId } : entry
|
||||
)
|
||||
)
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
|
|
|||
|
|
@ -964,6 +964,10 @@ export type PreloadApi = {
|
|||
// other hosts (local or a re-added SSH target) intact.
|
||||
removeForHost: (args: { repoId: string; hostId: string }) => Promise<void>
|
||||
reorder: (args: { orderedIds: string[] }) => Promise<{ status: 'applied' | 'rejected' }>
|
||||
reorderForHost: (args: {
|
||||
orderedIds: string[]
|
||||
hostId: string
|
||||
}) => Promise<{ status: 'applied' | 'rejected' }>
|
||||
update: (args: {
|
||||
repoId: string
|
||||
updates: Partial<
|
||||
|
|
|
|||
|
|
@ -568,6 +568,8 @@ const api = {
|
|||
|
||||
reorder: (args) => ipcRenderer.invoke('repos:reorder', args),
|
||||
|
||||
reorderForHost: (args) => ipcRenderer.invoke('repos:reorderForHost', args),
|
||||
|
||||
update: (args) => ipcRenderer.invoke('repos:update', args),
|
||||
|
||||
pickFolder: () => ipcRenderer.invoke('repos:pickFolder'),
|
||||
|
|
|
|||
|
|
@ -29,9 +29,11 @@ const reposRemove = vi.fn()
|
|||
const reposRemoveForHost = vi.fn()
|
||||
const reposUpdate = vi.fn()
|
||||
const reposReorder = vi.fn()
|
||||
const reposReorderForHost = vi.fn()
|
||||
const ptyKill = vi.fn()
|
||||
const runtimeEnvironmentCall = vi.fn()
|
||||
const runtimeEnvironmentTransportCall = vi.fn()
|
||||
const uiSet = vi.fn()
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
|
|
@ -63,9 +65,12 @@ beforeEach(() => {
|
|||
reposRemoveForHost.mockReset()
|
||||
reposUpdate.mockReset()
|
||||
reposReorder.mockReset()
|
||||
reposReorderForHost.mockReset()
|
||||
ptyKill.mockReset()
|
||||
runtimeEnvironmentCall.mockReset()
|
||||
runtimeEnvironmentTransportCall.mockReset()
|
||||
uiSet.mockReset()
|
||||
uiSet.mockResolvedValue(undefined)
|
||||
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
|
||||
return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
|
||||
})
|
||||
|
|
@ -75,10 +80,12 @@ beforeEach(() => {
|
|||
remove: reposRemove,
|
||||
removeForHost: reposRemoveForHost,
|
||||
update: reposUpdate,
|
||||
reorder: reposReorder
|
||||
reorder: reposReorder,
|
||||
reorderForHost: reposReorderForHost
|
||||
},
|
||||
pty: { kill: ptyKill },
|
||||
runtimeEnvironments: { call: runtimeEnvironmentTransportCall }
|
||||
runtimeEnvironments: { call: runtimeEnvironmentTransportCall },
|
||||
ui: { set: uiSet }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -457,7 +464,7 @@ describe('repo slice host identity routing', () => {
|
|||
})
|
||||
|
||||
it('reorders duplicate repo ids once per owning host', async () => {
|
||||
reposReorder.mockResolvedValue({ status: 'applied' })
|
||||
reposReorderForHost.mockResolvedValue({ status: 'applied' })
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-duplicate-reorder',
|
||||
ok: true,
|
||||
|
|
@ -470,7 +477,17 @@ describe('repo slice host identity routing', () => {
|
|||
await store.getState().reorderRepos(['same-repo', 'same-repo'])
|
||||
|
||||
expect(store.getState().repos).toEqual([localDuplicate, remoteDuplicate])
|
||||
expect(reposReorder).toHaveBeenCalledWith({ orderedIds: ['same-repo'] })
|
||||
expect(reposReorderForHost).toHaveBeenCalledWith({
|
||||
hostId: 'local',
|
||||
orderedIds: ['same-repo']
|
||||
})
|
||||
expect(reposReorder).not.toHaveBeenCalled()
|
||||
expect(uiSet).toHaveBeenCalledWith({
|
||||
manualRepoOrder: [
|
||||
{ hostId: 'local', repoId: 'same-repo' },
|
||||
{ hostId: 'runtime:env-1', repoId: 'same-repo' }
|
||||
]
|
||||
})
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'repo.reorder',
|
||||
|
|
@ -478,4 +495,72 @@ describe('repo slice host identity routing', () => {
|
|||
timeoutMs: 15_000
|
||||
})
|
||||
})
|
||||
|
||||
it('persists a complete cross-host overlay alongside host-local permutations', async () => {
|
||||
reposReorderForHost.mockResolvedValue({ status: 'applied' })
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-cross-host-reorder',
|
||||
ok: true,
|
||||
result: { status: 'applied' },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
const alpha = { ...localDuplicate, id: 'alpha' }
|
||||
const bravo = { ...localDuplicate, id: 'bravo' }
|
||||
const charlie = { ...remoteDuplicate, id: 'charlie' }
|
||||
const delta = { ...remoteDuplicate, id: 'delta' }
|
||||
const store = createTestStore()
|
||||
store.setState({ repos: [alpha, bravo, charlie, delta] })
|
||||
|
||||
await store.getState().reorderRepos(['alpha', 'charlie', 'bravo', 'delta'])
|
||||
|
||||
expect(reposReorderForHost).toHaveBeenCalledWith({
|
||||
hostId: 'local',
|
||||
orderedIds: ['alpha', 'bravo']
|
||||
})
|
||||
expect(reposReorder).not.toHaveBeenCalled()
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'repo.reorder',
|
||||
params: { orderedIds: ['charlie', 'delta'] },
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(uiSet).toHaveBeenCalledWith({
|
||||
manualRepoOrder: [
|
||||
{ hostId: 'local', repoId: 'alpha' },
|
||||
{ hostId: 'runtime:env-1', repoId: 'charlie' },
|
||||
{ hostId: 'local', repoId: 'bravo' },
|
||||
{ hostId: 'runtime:env-1', repoId: 'delta' }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('persists local and direct SSH permutations through host-scoped IPC', async () => {
|
||||
reposReorderForHost.mockResolvedValue({ status: 'applied' })
|
||||
const alpha = { ...localDuplicate, id: 'alpha' }
|
||||
const bravo = { ...localDuplicate, id: 'bravo' }
|
||||
const charlie = {
|
||||
...localDuplicate,
|
||||
id: 'charlie',
|
||||
path: '/ssh/charlie',
|
||||
connectionId: 'target',
|
||||
executionHostId: undefined
|
||||
}
|
||||
const delta = { ...charlie, id: 'delta', path: '/ssh/delta' }
|
||||
const store = createTestStore()
|
||||
store.setState({ repos: [alpha, charlie, bravo, delta] })
|
||||
|
||||
await store.getState().reorderRepos(['bravo', 'delta', 'alpha', 'charlie'])
|
||||
|
||||
expect(reposReorderForHost).toHaveBeenCalledTimes(2)
|
||||
expect(reposReorderForHost).toHaveBeenCalledWith({
|
||||
hostId: 'local',
|
||||
orderedIds: ['bravo', 'alpha']
|
||||
})
|
||||
expect(reposReorderForHost).toHaveBeenCalledWith({
|
||||
hostId: 'ssh:target',
|
||||
orderedIds: ['delta', 'charlie']
|
||||
})
|
||||
expect(reposReorder).not.toHaveBeenCalled()
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
import { createTestStore } from './store-test-helpers'
|
||||
import {
|
||||
createCompatibleRuntimeStatusResponseIfNeeded,
|
||||
type RuntimeEnvironmentCallRequest
|
||||
} from '../../runtime/runtime-compatibility-test-fixture'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
|
||||
|
||||
const reposList = vi.fn()
|
||||
const projectsList = vi.fn()
|
||||
const listHostSetups = vi.fn()
|
||||
const runtimeEnvironmentsList = vi.fn()
|
||||
const runtimeEnvironmentCall = vi.fn()
|
||||
const runtimeEnvironmentTransportCall = vi.fn()
|
||||
|
||||
const reposByEnvironment: Record<string, Repo[]> = {
|
||||
'node-a': [
|
||||
{ id: 'alpha', path: '/alpha', displayName: 'alpha', badgeColor: '#000', addedAt: 1 },
|
||||
{ id: 'bravo', path: '/bravo', displayName: 'bravo', badgeColor: '#000', addedAt: 2 }
|
||||
],
|
||||
'node-b': [
|
||||
{ id: 'charlie', path: '/charlie', displayName: 'charlie', badgeColor: '#000', addedAt: 1 },
|
||||
{ id: 'delta', path: '/delta', displayName: 'delta', badgeColor: '#000', addedAt: 2 }
|
||||
]
|
||||
}
|
||||
|
||||
type RepoListResolver = (value: unknown) => void
|
||||
|
||||
beforeEach(() => {
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
reposList.mockReset().mockResolvedValue([])
|
||||
projectsList.mockReset().mockResolvedValue([])
|
||||
listHostSetups.mockReset().mockResolvedValue([])
|
||||
runtimeEnvironmentsList.mockReset().mockResolvedValue([
|
||||
{ id: 'node-a', name: 'A' },
|
||||
{ id: 'node-b', name: 'B' }
|
||||
])
|
||||
runtimeEnvironmentCall.mockReset()
|
||||
runtimeEnvironmentTransportCall.mockReset()
|
||||
runtimeEnvironmentTransportCall.mockImplementation(
|
||||
(args: RuntimeEnvironmentCallRequest) =>
|
||||
createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
|
||||
)
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
repos: { list: reposList },
|
||||
projects: { list: projectsList, listHostSetups },
|
||||
runtimeEnvironments: {
|
||||
list: runtimeEnvironmentsList,
|
||||
call: runtimeEnvironmentTransportCall
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function loadWithCompletionOrder(completionOrder: string[]): Promise<string[]> {
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
const repoListResolvers = new Map<string, RepoListResolver>()
|
||||
runtimeEnvironmentCall.mockImplementation(
|
||||
(args: RuntimeEnvironmentCallRequest & { selector?: string }) => {
|
||||
if (args.method === 'repo.list' && args.selector) {
|
||||
return new Promise((resolve) => repoListResolvers.set(args.selector!, resolve))
|
||||
}
|
||||
const result = args.method === 'project.list' ? { projects: [] } : { setups: [] }
|
||||
return { id: `rpc-${args.method}`, ok: true, result, _meta: { runtimeId: 'runtime' } }
|
||||
}
|
||||
)
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
manualRepoOrder: [
|
||||
{ hostId: 'runtime:node-a', repoId: 'alpha' },
|
||||
{ hostId: 'runtime:node-b', repoId: 'charlie' },
|
||||
{ hostId: 'runtime:node-a', repoId: 'bravo' },
|
||||
{ hostId: 'runtime:node-b', repoId: 'delta' }
|
||||
]
|
||||
})
|
||||
|
||||
const load = store.getState().fetchReposForAllHosts()
|
||||
await vi.waitFor(() => expect(repoListResolvers.size).toBe(2))
|
||||
for (const environmentId of completionOrder) {
|
||||
repoListResolvers.get(environmentId)?.({
|
||||
id: `rpc-repo-${environmentId}`,
|
||||
ok: true,
|
||||
result: { repos: reposByEnvironment[environmentId] },
|
||||
_meta: { runtimeId: environmentId }
|
||||
})
|
||||
await Promise.resolve()
|
||||
}
|
||||
await load
|
||||
expect(store.getState().manualRepoOrder).toHaveLength(4)
|
||||
return store.getState().repos.map((repo) => `${repo.executionHostId}:${repo.id}`)
|
||||
}
|
||||
|
||||
describe('manual repo order hydration', () => {
|
||||
it('restores the same cross-host order for either catalog completion order', async () => {
|
||||
const expected = [
|
||||
'runtime:node-a:alpha',
|
||||
'runtime:node-b:charlie',
|
||||
'runtime:node-a:bravo',
|
||||
'runtime:node-b:delta'
|
||||
]
|
||||
|
||||
await expect(loadWithCompletionOrder(['node-b', 'node-a'])).resolves.toEqual(expected)
|
||||
await expect(loadWithCompletionOrder(['node-a', 'node-b'])).resolves.toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
|
@ -44,6 +44,7 @@ export const reposCloneRemote: Mock = vi.fn()
|
|||
export const reposRemove: Mock = vi.fn()
|
||||
export const reposUpdate: Mock = vi.fn()
|
||||
export const reposReorder: Mock = vi.fn()
|
||||
export const reposReorderForHost: Mock = vi.fn()
|
||||
export const projectsCreateHostSetup: Mock = vi.fn()
|
||||
export const projectsSetupExistingFolder: Mock = vi.fn()
|
||||
export const projectsUpdateHostSetup: Mock = vi.fn()
|
||||
|
|
@ -54,6 +55,7 @@ export const ptyKill: Mock = vi.fn()
|
|||
export const runtimeEnvironmentCall: Mock = vi.fn()
|
||||
export const runtimeEnvironmentTransportCall: Mock = vi.fn()
|
||||
export const orcaProfileFindProjectProfiles: Mock = vi.fn()
|
||||
export const uiSet: Mock = vi.fn()
|
||||
|
||||
// Registers the per-test reset + window stub. Call once inside the suite's module scope.
|
||||
export function installReposRuntimeRoutingHarness(): void {
|
||||
|
|
@ -71,6 +73,7 @@ export function installReposRuntimeRoutingHarness(): void {
|
|||
reposRemove.mockReset()
|
||||
reposUpdate.mockReset()
|
||||
reposReorder.mockReset()
|
||||
reposReorderForHost.mockReset()
|
||||
projectsCreateHostSetup.mockReset()
|
||||
projectsSetupExistingFolder.mockReset()
|
||||
projectsUpdateHostSetup.mockReset()
|
||||
|
|
@ -81,6 +84,8 @@ export function installReposRuntimeRoutingHarness(): void {
|
|||
orcaProfileFindProjectProfiles.mockReset()
|
||||
runtimeEnvironmentCall.mockReset()
|
||||
runtimeEnvironmentTransportCall.mockReset()
|
||||
uiSet.mockReset()
|
||||
uiSet.mockResolvedValue(undefined)
|
||||
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
|
||||
return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
|
||||
})
|
||||
|
|
@ -94,7 +99,8 @@ export function installReposRuntimeRoutingHarness(): void {
|
|||
pickFolder: reposPickFolder,
|
||||
remove: reposRemove,
|
||||
update: reposUpdate,
|
||||
reorder: reposReorder
|
||||
reorder: reposReorder,
|
||||
reorderForHost: reposReorderForHost
|
||||
},
|
||||
projects: {
|
||||
update: projectsUpdate,
|
||||
|
|
@ -110,7 +116,8 @@ export function installReposRuntimeRoutingHarness(): void {
|
|||
findProjectProfiles: orcaProfileFindProjectProfiles
|
||||
},
|
||||
pty: { kill: ptyKill },
|
||||
runtimeEnvironments: { call: runtimeEnvironmentTransportCall }
|
||||
runtimeEnvironments: { call: runtimeEnvironmentTransportCall },
|
||||
ui: { set: uiSet }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import {
|
|||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
|
||||
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
|
||||
import { applyManualRepoOrder, getManualRepoOrder } from '../../../../shared/manual-repo-order'
|
||||
import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups'
|
||||
import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path'
|
||||
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
|
||||
|
|
@ -1567,7 +1568,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
// Drop rows on unknown SSH targets that a live-host sibling supersedes.
|
||||
const result = mergeFetchedRepoCatalog(catalog, s.repos)
|
||||
const reconciliation = reconcileSupersededSshRepos(result.repos, s)
|
||||
const prunedRepos = reconciliation.repos
|
||||
const prunedRepos = applyManualRepoOrder(reconciliation.repos, s.manualRepoOrder)
|
||||
const validRepoIds = new Set(prunedRepos.map((repo) => repo.id))
|
||||
const projectCompatibility = projectCompatibilityForReconciledRepos(
|
||||
prunedRepos,
|
||||
|
|
@ -1617,7 +1618,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
set((s) => {
|
||||
const result = mergeFetchedRepoCatalog(catalog, s.repos)
|
||||
const reconciliation = reconcileSupersededSshRepos(result.repos, s)
|
||||
const finalizedRepos = reconciliation.repos
|
||||
const finalizedRepos = applyManualRepoOrder(reconciliation.repos, s.manualRepoOrder)
|
||||
const validRepoIds = new Set(finalizedRepos.map((repo) => repo.id))
|
||||
const projectCompatibility = projectCompatibilityForReconciledRepos(
|
||||
finalizedRepos,
|
||||
|
|
@ -1683,7 +1684,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
set((s) => {
|
||||
const result = mergeFetchedRepoCatalog(catalog, s.repos)
|
||||
const reconciliation = reconcileSupersededSshRepos(result.repos, s)
|
||||
const finalizedRepos = reconciliation.repos
|
||||
const finalizedRepos = applyManualRepoOrder(reconciliation.repos, s.manualRepoOrder)
|
||||
const projectCompatibility = projectCompatibilityForReconciledRepos(
|
||||
finalizedRepos,
|
||||
catalog.projectHostSetupCompatibility
|
||||
|
|
@ -3122,8 +3123,10 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
// Caller passed a non-permutation — refuse to apply locally.
|
||||
return
|
||||
}
|
||||
const manualRepoOrder = getManualRepoOrder(next)
|
||||
set({
|
||||
repos: next,
|
||||
manualRepoOrder,
|
||||
folderWorkspacePathStatuses: {}
|
||||
})
|
||||
try {
|
||||
|
|
@ -3131,23 +3134,31 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
// so split the cross-host order into per-host permutations and dispatch one
|
||||
// reorder per owner host.
|
||||
const groups = splitRepoReorderByHost(orderedIds, next, get().settings)
|
||||
const results = await Promise.all(
|
||||
groups.map(async (group) => {
|
||||
const parsed = parseExecutionHostId(group.hostId)
|
||||
const target =
|
||||
parsed?.kind === 'runtime'
|
||||
? ({ kind: 'environment', environmentId: parsed.environmentId } as const)
|
||||
: ({ kind: 'local' } as const)
|
||||
return target.kind === 'local'
|
||||
? window.api.repos.reorder({ orderedIds: group.orderedIds })
|
||||
: callRuntimeRpc<{ status: 'applied' | 'rejected' }>(
|
||||
target,
|
||||
'repo.reorder',
|
||||
{ orderedIds: group.orderedIds },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
})
|
||||
)
|
||||
const [results] = await Promise.all([
|
||||
Promise.all(
|
||||
groups.map(async (group) => {
|
||||
const parsed = parseExecutionHostId(group.hostId)
|
||||
const target =
|
||||
parsed?.kind === 'runtime'
|
||||
? ({ kind: 'environment', environmentId: parsed.environmentId } as const)
|
||||
: ({ kind: 'local' } as const)
|
||||
return target.kind === 'local'
|
||||
? window.api.repos.reorderForHost({
|
||||
hostId: group.hostId,
|
||||
orderedIds: group.orderedIds
|
||||
})
|
||||
: callRuntimeRpc<{ status: 'applied' | 'rejected' }>(
|
||||
target,
|
||||
'repo.reorder',
|
||||
{ orderedIds: group.orderedIds },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
})
|
||||
),
|
||||
// Why: servers can only persist their local permutations. The desktop
|
||||
// profile owns the cross-host relationships needed after a cold load.
|
||||
window.api.ui.set({ manualRepoOrder })
|
||||
])
|
||||
if (results.some((result) => result.status === 'rejected')) {
|
||||
await get().fetchReposForAllHosts()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
JiraIssue,
|
||||
LinearIssue,
|
||||
PersistedUIState,
|
||||
Repo,
|
||||
TerminalTab,
|
||||
Worktree
|
||||
} from '../../../../shared/types'
|
||||
|
|
@ -725,6 +726,8 @@ describe('createUISlice hydratePersistedUI', () => {
|
|||
expect(createUIStore().getState().visibleWorkspaceHostIds).toBeNull()
|
||||
expect(getDefaultUIState().workspaceHostOrder).toEqual([])
|
||||
expect(createUIStore().getState().workspaceHostOrder).toEqual([])
|
||||
expect(getDefaultUIState().manualRepoOrder).toEqual([])
|
||||
expect(createUIStore().getState().manualRepoOrder).toEqual([])
|
||||
})
|
||||
|
||||
it('defaults the persisted active view to terminal', () => {
|
||||
|
|
@ -971,6 +974,41 @@ describe('createUISlice hydratePersistedUI', () => {
|
|||
expect(store.getState().workspaceHostOrder).toEqual(['ssh:win%20vm', 'local'])
|
||||
})
|
||||
|
||||
it('hydrates and immediately applies the manual cross-host repo order', () => {
|
||||
const store = createUIStore()
|
||||
const local: Repo = {
|
||||
id: 'same',
|
||||
path: '/local',
|
||||
displayName: 'Local',
|
||||
badgeColor: '#000',
|
||||
addedAt: 1,
|
||||
executionHostId: 'local'
|
||||
}
|
||||
const remote: Repo = {
|
||||
...local,
|
||||
path: '/remote',
|
||||
displayName: 'Remote',
|
||||
executionHostId: 'runtime:node-b'
|
||||
}
|
||||
store.setState({ repos: [local, remote] })
|
||||
|
||||
store.getState().hydratePersistedUI(
|
||||
makePersistedUI({
|
||||
manualRepoOrder: [
|
||||
{ hostId: 'runtime:node-b', repoId: 'same' },
|
||||
{ hostId: 'invalid' as never, repoId: 'ignored' },
|
||||
{ hostId: 'local', repoId: 'same' }
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
expect(store.getState().manualRepoOrder).toEqual([
|
||||
{ hostId: 'runtime:node-b', repoId: 'same' },
|
||||
{ hostId: 'local', repoId: 'same' }
|
||||
])
|
||||
expect(store.getState().repos).toEqual([remote, local])
|
||||
})
|
||||
|
||||
it('falls back to all hosts for invalid persisted workspace host scopes', () => {
|
||||
const store = createUIStore()
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
GitHubWorkItem,
|
||||
JiraIssue,
|
||||
LinearIssue,
|
||||
ManualRepoOrderEntry,
|
||||
PersistedTrustedOrcaHooks,
|
||||
PersistedUIState,
|
||||
StatusBarItem,
|
||||
|
|
@ -30,6 +31,10 @@ import type {
|
|||
VisibleWorkspaceHostIds,
|
||||
TopLevelView
|
||||
} from '../../../../shared/types'
|
||||
import {
|
||||
applyManualRepoOrder,
|
||||
normalizeManualRepoOrder
|
||||
} from '../../../../shared/manual-repo-order'
|
||||
import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display'
|
||||
import {
|
||||
DEFAULT_USAGE_PERCENTAGE_DISPLAY,
|
||||
|
|
@ -889,6 +894,7 @@ export type UISlice = {
|
|||
setVisibleWorkspaceHostIds: (ids: VisibleWorkspaceHostIds) => void
|
||||
workspaceHostOrder: WorkspaceHostOrder
|
||||
setWorkspaceHostOrder: (ids: WorkspaceHostOrder) => void
|
||||
manualRepoOrder: ManualRepoOrderEntry[]
|
||||
hideDefaultBranchWorkspace: boolean
|
||||
setHideDefaultBranchWorkspace: (v: boolean) => void
|
||||
hideAutomationGeneratedWorkspaces: boolean
|
||||
|
|
@ -2060,6 +2066,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
|||
set({ workspaceHostOrder })
|
||||
window.api.ui.set({ workspaceHostOrder }).catch(console.error)
|
||||
},
|
||||
manualRepoOrder: [],
|
||||
|
||||
hideDefaultBranchWorkspace: false,
|
||||
setHideDefaultBranchWorkspace: (v) => set({ hideDefaultBranchWorkspace: v }),
|
||||
|
|
@ -2372,6 +2379,8 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
|||
|
||||
hydratePersistedUI: (ui, source = 'sync') =>
|
||||
set((s) => {
|
||||
const manualRepoOrder = normalizeManualRepoOrder(ui.manualRepoOrder)
|
||||
const orderedRepos = applyManualRepoOrder(s.repos, manualRepoOrder)
|
||||
const validRepoIds = new Set(s.repos.map((repo) => repo.id))
|
||||
const persistedFilterRepoIds = sanitizePersistedRepoIds(ui.filterRepoIds)
|
||||
// Why: persisted UI from pre-rename builds used sidekick* keys. Read
|
||||
|
|
@ -2474,6 +2483,10 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
|||
workspaceHostScope: normalizeExecutionHostScope(ui.workspaceHostScope),
|
||||
visibleWorkspaceHostIds: normalizeHydratedVisibleWorkspaceHostIds(ui),
|
||||
workspaceHostOrder: normalizeExecutionHostOrder(ui.workspaceHostOrder),
|
||||
manualRepoOrder,
|
||||
// Why: UI state can arrive after a catalog or from another client; apply
|
||||
// the desktop-owned overlay immediately instead of waiting for a refetch.
|
||||
repos: orderedRepos,
|
||||
hideDefaultBranchWorkspace: ui.hideDefaultBranchWorkspace ?? false,
|
||||
hideAutomationGeneratedWorkspaces: ui.hideAutomationGeneratedWorkspaces === true,
|
||||
showDotfilesByWorktree: sanitizeShowDotfilesByWorktree(ui.showDotfilesByWorktree),
|
||||
|
|
|
|||
|
|
@ -1780,6 +1780,14 @@ describe('web repos preload API', () => {
|
|||
vi.doUnmock('./web-runtime-client')
|
||||
})
|
||||
|
||||
it('rejects desktop host-scoped reorders in paired web clients', async () => {
|
||||
const { api } = await installApi('Linux')
|
||||
|
||||
await expect(
|
||||
api.repos.reorderForHost({ hostId: 'ssh:target', orderedIds: ['repo-1'] })
|
||||
).rejects.toThrow('Host-scoped project reordering is unavailable in paired web clients.')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/home/alice', '/home/alice/orca/projects'],
|
||||
['/', '/orca/projects'],
|
||||
|
|
|
|||
|
|
@ -1264,6 +1264,11 @@ function createReposApi(): NonNullable<Partial<PreloadApi>['repos']> {
|
|||
throw new Error('Forgetting a host is unavailable in paired web clients.')
|
||||
},
|
||||
reorder: async ({ orderedIds }) => callRuntimeResult('repo.reorder', { orderedIds }),
|
||||
// Why: this path persists desktop-owned local or SSH rows. Paired web
|
||||
// clients own only their single runtime, which uses repo.reorder directly.
|
||||
reorderForHost: async () => {
|
||||
throw new Error('Host-scoped project reordering is unavailable in paired web clients.')
|
||||
},
|
||||
update: async ({ repoId, updates }) =>
|
||||
(await callRuntimeResult<{ repo: Repo }>('repo.update', { repo: repoId, updates })).repo,
|
||||
pickFolder: () => Promise.resolve(null),
|
||||
|
|
|
|||
|
|
@ -481,6 +481,7 @@ export function getDefaultUIState(): PersistedUIState {
|
|||
workspaceHostScope: 'all',
|
||||
visibleWorkspaceHostIds: null,
|
||||
workspaceHostOrder: [],
|
||||
manualRepoOrder: [],
|
||||
showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES,
|
||||
hideDefaultBranchWorkspace: false,
|
||||
hideAutomationGeneratedWorkspaces: false,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { ManualRepoOrderEntry, Repo } from './types'
|
||||
import {
|
||||
applyManualRepoOrder,
|
||||
getManualRepoOrder,
|
||||
normalizeManualRepoOrder
|
||||
} from './manual-repo-order'
|
||||
|
||||
function repo(id: string, hostId: Repo['executionHostId']): Repo {
|
||||
return {
|
||||
id,
|
||||
path: `/${hostId}/${id}`,
|
||||
displayName: id,
|
||||
badgeColor: '#000',
|
||||
addedAt: 1,
|
||||
executionHostId: hostId
|
||||
}
|
||||
}
|
||||
|
||||
const localAlpha = repo('alpha', 'local')
|
||||
const localBravo = repo('bravo', 'local')
|
||||
const remoteCharlie = repo('charlie', 'runtime:node-b')
|
||||
const remoteDelta = repo('delta', 'runtime:node-b')
|
||||
|
||||
describe('manual repo order', () => {
|
||||
it('preserves source order when no overlay exists', () => {
|
||||
expect(applyManualRepoOrder([localBravo, localAlpha], [])).toEqual([localBravo, localAlpha])
|
||||
})
|
||||
|
||||
it('restores a host-qualified cross-host interleaving', () => {
|
||||
const order = getManualRepoOrder([localAlpha, remoteCharlie, localBravo, remoteDelta])
|
||||
|
||||
expect(
|
||||
applyManualRepoOrder([remoteCharlie, remoteDelta, localAlpha, localBravo], order)
|
||||
).toEqual([localAlpha, remoteCharlie, localBravo, remoteDelta])
|
||||
})
|
||||
|
||||
it('places a late host into its saved positions', () => {
|
||||
const order = getManualRepoOrder([localAlpha, remoteCharlie, localBravo, remoteDelta])
|
||||
const localOnly = applyManualRepoOrder([localBravo, localAlpha], order)
|
||||
|
||||
expect(applyManualRepoOrder([...localOnly, remoteDelta, remoteCharlie], order)).toEqual([
|
||||
localAlpha,
|
||||
remoteCharlie,
|
||||
localBravo,
|
||||
remoteDelta
|
||||
])
|
||||
})
|
||||
|
||||
it('distinguishes duplicate bare repo ids on different hosts', () => {
|
||||
const local = repo('same-id', 'local')
|
||||
const remote = repo('same-id', 'runtime:node-b')
|
||||
const order = getManualRepoOrder([remote, local])
|
||||
|
||||
expect(applyManualRepoOrder([local, remote], order)).toEqual([remote, local])
|
||||
})
|
||||
|
||||
it('appends unranked repos in their source order', () => {
|
||||
const newOne = repo('new-one', 'local')
|
||||
const newTwo = repo('new-two', 'runtime:node-b')
|
||||
const order = getManualRepoOrder([remoteCharlie, localAlpha])
|
||||
|
||||
expect(applyManualRepoOrder([newTwo, localAlpha, newOne, remoteCharlie], order)).toEqual([
|
||||
remoteCharlie,
|
||||
localAlpha,
|
||||
newTwo,
|
||||
newOne
|
||||
])
|
||||
})
|
||||
|
||||
it('normalizes malformed, invalid-host, and duplicate entries', () => {
|
||||
const value = [
|
||||
null,
|
||||
{ hostId: 'bogus', repoId: 'bad-host' },
|
||||
{ hostId: 'local', repoId: '' },
|
||||
{ hostId: 'runtime:node-b', repoId: 'same-id' },
|
||||
{ hostId: 'runtime:node-b', repoId: 'same-id' },
|
||||
{ hostId: 'local', repoId: 'same-id' }
|
||||
] as unknown as ManualRepoOrderEntry[]
|
||||
|
||||
expect(normalizeManualRepoOrder(value)).toEqual([
|
||||
{ hostId: 'runtime:node-b', repoId: 'same-id' },
|
||||
{ hostId: 'local', repoId: 'same-id' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { getRepoExecutionHostId, normalizeExecutionHostId } from './execution-host'
|
||||
import type { ManualRepoOrderEntry, Repo } from './types'
|
||||
|
||||
function getEntryKey(entry: ManualRepoOrderEntry): string {
|
||||
return `${entry.hostId}\0${entry.repoId}`
|
||||
}
|
||||
|
||||
export function normalizeManualRepoOrder(value: unknown): ManualRepoOrderEntry[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
const entries: ManualRepoOrderEntry[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const candidate of value) {
|
||||
if (!candidate || typeof candidate !== 'object') {
|
||||
continue
|
||||
}
|
||||
const raw = candidate as { hostId?: unknown; repoId?: unknown }
|
||||
const hostId = typeof raw.hostId === 'string' ? normalizeExecutionHostId(raw.hostId) : null
|
||||
const repoId = typeof raw.repoId === 'string' ? raw.repoId : ''
|
||||
if (!hostId || !repoId.trim()) {
|
||||
continue
|
||||
}
|
||||
const entry = { hostId, repoId }
|
||||
const key = getEntryKey(entry)
|
||||
if (seen.has(key)) {
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
entries.push(entry)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
export function getManualRepoOrder(repos: readonly Repo[]): ManualRepoOrderEntry[] {
|
||||
return normalizeManualRepoOrder(
|
||||
repos.map((repo) => ({ hostId: getRepoExecutionHostId(repo), repoId: repo.id }))
|
||||
)
|
||||
}
|
||||
|
||||
export function applyManualRepoOrder(
|
||||
repos: readonly Repo[],
|
||||
order: readonly ManualRepoOrderEntry[] | null | undefined
|
||||
): Repo[] {
|
||||
const normalized = normalizeManualRepoOrder(order)
|
||||
if (normalized.length === 0) {
|
||||
return [...repos]
|
||||
}
|
||||
const rankByKey = new Map(normalized.map((entry, index) => [getEntryKey(entry), index]))
|
||||
return repos
|
||||
.map((repo, index) => ({
|
||||
repo,
|
||||
index,
|
||||
rank: rankByKey.get(getEntryKey({ hostId: getRepoExecutionHostId(repo), repoId: repo.id }))
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.rank === undefined && b.rank === undefined) {
|
||||
return a.index - b.index
|
||||
}
|
||||
if (a.rank === undefined) {
|
||||
return 1
|
||||
}
|
||||
if (b.rank === undefined) {
|
||||
return -1
|
||||
}
|
||||
return a.rank - b.rank || a.index - b.index
|
||||
})
|
||||
.map(({ repo }) => repo)
|
||||
}
|
||||
|
|
@ -3244,6 +3244,10 @@ export type ProjectOrderBy = 'manual' | 'recent'
|
|||
export type WorkspaceHostScope = 'all' | 'local' | `ssh:${string}` | `runtime:${string}`
|
||||
export type VisibleWorkspaceHostIds = Exclude<WorkspaceHostScope, 'all'>[] | null
|
||||
export type WorkspaceHostOrder = Exclude<WorkspaceHostScope, 'all'>[]
|
||||
export type ManualRepoOrderEntry = {
|
||||
hostId: WorkspaceHostOrder[number]
|
||||
repoId: string
|
||||
}
|
||||
|
||||
/** The active top-level section shown in the main content area. */
|
||||
export type TopLevelView =
|
||||
|
|
@ -3291,6 +3295,9 @@ export type PersistedUIState = {
|
|||
/** User-defined sidebar order for host sections. Missing/new hosts append in
|
||||
* the discovered host order. */
|
||||
workspaceHostOrder?: WorkspaceHostOrder
|
||||
/** Desktop-owned all-host repo order. Host-qualified identities preserve a
|
||||
* manual cross-host interleaving while each host owns its local permutation. */
|
||||
manualRepoOrder?: ManualRepoOrderEntry[]
|
||||
/** Deprecated legacy positive-form setting. Ignored on hydration. */
|
||||
showSleepingWorkspaces?: boolean
|
||||
/** Deprecated legacy name used by a short-lived build. Ignored on hydration. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue