diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 4c6fef6ef..007af79b5 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -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() diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index b2919fc88..3564b00b8 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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({ diff --git a/src/main/persistence.ts b/src/main/persistence.ts index a72a9238f..d407b9362 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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() + 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 ), diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 5c1823a4e..b5adcf617 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -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(), diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index a3f3dbe88..bf55cfb07 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -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)) diff --git a/src/main/ssh/ssh-target-id-migration.test.ts b/src/main/ssh/ssh-target-id-migration.test.ts index fa6507d62..762842865 100644 --- a/src/main/ssh/ssh-target-id-migration.test.ts +++ b/src/main/ssh/ssh-target-id-migration.test.ts @@ -114,24 +114,34 @@ describe('migrateUiHostScopeSshTargetId', () => { const makeUi = (overrides: Partial): 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) diff --git a/src/main/ssh/ssh-target-id-migration.ts b/src/main/ssh/ssh-target-id-migration.ts index a917c4393..f7fe153a2 100644 --- a/src/main/ssh/ssh-target-id-migration.ts +++ b/src/main/ssh/ssh-target-id-migration.ts @@ -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 } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 47d9f9d3f..92ba7bd92 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -964,6 +964,10 @@ export type PreloadApi = { // other hosts (local or a re-added SSH target) intact. removeForHost: (args: { repoId: string; hostId: string }) => Promise reorder: (args: { orderedIds: string[] }) => Promise<{ status: 'applied' | 'rejected' }> + reorderForHost: (args: { + orderedIds: string[] + hostId: string + }) => Promise<{ status: 'applied' | 'rejected' }> update: (args: { repoId: string updates: Partial< diff --git a/src/preload/index.ts b/src/preload/index.ts index 24b67568f..05e1d62da 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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'), diff --git a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts index 56d98ca2c..449290d81 100644 --- a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts +++ b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts @@ -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() { 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() + }) }) diff --git a/src/renderer/src/store/slices/repos-manual-order-hydration.test.ts b/src/renderer/src/store/slices/repos-manual-order-hydration.test.ts new file mode 100644 index 000000000..91374d477 --- /dev/null +++ b/src/renderer/src/store/slices/repos-manual-order-hydration.test.ts @@ -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 = { + '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 { + clearRuntimeCompatibilityCacheForTests() + const repoListResolvers = new Map() + 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) + }) +}) diff --git a/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts b/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts index 738a805bb..ef96a644e 100644 --- a/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts +++ b/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts @@ -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 } } }) }) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 165807f27..476092c2c 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -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 = (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 = (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 = (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 = (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 = (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() } diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 64c8f0e4a..f7fa68279 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -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() diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 26d87bb20..0693b2f89 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -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 = (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 = (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 = (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), diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index ba8fefd90..749eab542 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -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'], diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index d9d22e130..ac19ddce7 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1264,6 +1264,11 @@ function createReposApi(): NonNullable['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), diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 950cc9994..22064cbe6 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -481,6 +481,7 @@ export function getDefaultUIState(): PersistedUIState { workspaceHostScope: 'all', visibleWorkspaceHostIds: null, workspaceHostOrder: [], + manualRepoOrder: [], showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES, hideDefaultBranchWorkspace: false, hideAutomationGeneratedWorkspaces: false, diff --git a/src/shared/manual-repo-order.test.ts b/src/shared/manual-repo-order.test.ts new file mode 100644 index 000000000..298ba4a10 --- /dev/null +++ b/src/shared/manual-repo-order.test.ts @@ -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' } + ]) + }) +}) diff --git a/src/shared/manual-repo-order.ts b/src/shared/manual-repo-order.ts new file mode 100644 index 000000000..1ba8c886c --- /dev/null +++ b/src/shared/manual-repo-order.ts @@ -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() + 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) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 6c1ebaf8c..d08684fd4 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3244,6 +3244,10 @@ export type ProjectOrderBy = 'manual' | 'recent' export type WorkspaceHostScope = 'all' | 'local' | `ssh:${string}` | `runtime:${string}` export type VisibleWorkspaceHostIds = Exclude[] | null export type WorkspaceHostOrder = Exclude[] +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. */