mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
test(sidebar): the desktop window and a phone reading the feed draw the same list (dark) (#30426)
* test(sidebar): the desktop window and a feed reader draw the same list (dark) * fix(sidebar): a feed reader lists a paired server's folders and nested workspaces as the window does (dark) * fix(sidebar): the paired-server shadow compares the lineage the server's check keeps (dark) * fix(sidebar): paired-server rows carry the unread flag Smart Done reads (dark)
This commit is contained in:
@@ -29,6 +29,11 @@
|
||||
"../src/main/runtime/sidebar-inputs/sidebar-clock.ts",
|
||||
"../src/main/runtime/sidebar-inputs/sidebar-retention-owner.ts",
|
||||
"../src/main/runtime/sidebar-inputs/producer-revision.ts",
|
||||
"../src/main/runtime/sidebar-inputs/sidebar-inputs-feed.ts",
|
||||
"../src/main/runtime/sidebar-inputs/sidebar-inputs-feed-log.ts",
|
||||
"../src/main/runtime/sidebar-inputs/sidebar-inputs-feed-pages.ts",
|
||||
"../src/main/runtime/sidebar-inputs/sidebar-inputs-feed-pins.ts",
|
||||
"../src/main/runtime/sidebar-inputs/sidebar-inputs-feed.test-fixture.ts",
|
||||
"../src/main/jira/adf-markdown.ts",
|
||||
"../src/main/jira/adf-text-marks.ts",
|
||||
"../src/main/jira/adf-media-destination.ts",
|
||||
|
||||
@@ -155,7 +155,10 @@ describe('PairedServerRowsProducer', () => {
|
||||
branch: 'main',
|
||||
displayName: 'repo::/a',
|
||||
isArchived: false,
|
||||
isPinned: false
|
||||
isPinned: false,
|
||||
isMainWorktree: false,
|
||||
lineage: null,
|
||||
workspaceStatus: 'in-progress'
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -303,6 +306,70 @@ describe('PairedServerRowsProducer', () => {
|
||||
expect(call).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('carries what the list sorts, groups and filters by, dropping a value this build cannot read', async () => {
|
||||
const { hosts, call, watches } = fakeHosts([{ id: 'box', live: true }])
|
||||
call.mockResolvedValueOnce(
|
||||
psReply([
|
||||
row('repo::/a', {
|
||||
isMainWorktree: true,
|
||||
workspaceStatus: 'in-review',
|
||||
sortOrder: 4,
|
||||
manualOrder: 2,
|
||||
lastActivityAt: 900,
|
||||
smartTier: 'done',
|
||||
smartAt: 800,
|
||||
createdAt: 100,
|
||||
creatorProvenance: { kind: 'paired-device', deviceId: 'phone-1' },
|
||||
defaultBranch: true,
|
||||
cliCreated: true,
|
||||
unread: true,
|
||||
worktreeInstanceId: 'i-a',
|
||||
parentWorktreeId: 'repo::/p',
|
||||
lineageWorktreeInstanceId: 'i-a',
|
||||
parentWorktreeInstanceId: 'i-p'
|
||||
}),
|
||||
row('repo::/b', { smartTier: 'a-tier-from-later', sortOrder: 'x' })
|
||||
])
|
||||
)
|
||||
const producer = new PairedServerRowsProducer({
|
||||
hasDemand: () => true,
|
||||
hostLabelOverrides: () => new Map()
|
||||
})
|
||||
producer.attach(hosts)
|
||||
watches.get('box')?.onChange()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const [newer, unreadable] = rowsOf(producer, 'runtime:box')?.rows ?? []
|
||||
expect(newer).toMatchObject({
|
||||
isMainWorktree: true,
|
||||
workspaceStatus: 'in-review',
|
||||
sortOrder: 4,
|
||||
manualOrder: 2,
|
||||
recentAt: 900,
|
||||
smartTier: 'done',
|
||||
smartAt: 800,
|
||||
createdAt: 100,
|
||||
creatorProvenance: { kind: 'paired-device', deviceId: 'phone-1' },
|
||||
defaultBranch: true,
|
||||
cliCreated: true,
|
||||
isUnread: true,
|
||||
instanceId: 'i-a',
|
||||
lineage: {
|
||||
parentWorktreeId: 'repo::/p',
|
||||
worktreeInstanceId: 'i-a',
|
||||
parentWorktreeInstanceId: 'i-p'
|
||||
}
|
||||
})
|
||||
expect(unreadable).toMatchObject({
|
||||
key: 'runtime:box|repo::/b',
|
||||
isMainWorktree: false,
|
||||
lineage: null,
|
||||
workspaceStatus: 'in-progress'
|
||||
})
|
||||
expect(unreadable?.smartTier).toBeUndefined()
|
||||
expect(unreadable?.sortOrder).toBeUndefined()
|
||||
})
|
||||
|
||||
it("keys rows by host, so two servers' same bare id never collide, and fills older servers' gaps", async () => {
|
||||
const { hosts, call, watches } = fakeHosts([
|
||||
{ id: 'one', live: true },
|
||||
@@ -335,7 +402,10 @@ describe('PairedServerRowsProducer', () => {
|
||||
branch: 'b',
|
||||
displayName: '',
|
||||
isArchived: false,
|
||||
isPinned: false
|
||||
isPinned: false,
|
||||
isMainWorktree: false,
|
||||
lineage: null,
|
||||
workspaceStatus: 'in-progress'
|
||||
}
|
||||
])
|
||||
expect(rowsOf(producer, 'runtime:two')?.rows.map((entry) => [entry.key, entry.kind])).toEqual([
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { WorkspaceCreationRegistry } from './workspace-creation-registry'
|
||||
import type { WorkspaceRowsProducer } from './workspace-rows-producer'
|
||||
import type { SidebarInputsFeedDomain } from './sidebar-inputs-feed'
|
||||
import { readDesktopSidebarFacts } from './desktop-sidebar-facts'
|
||||
import { buildPairedServerReviewFacts } from '../../../shared/sidebar-list/workspace-review-facts'
|
||||
|
||||
/** This host's store and connection registries, read the way sidebarInputs.desktopFacts reads them. */
|
||||
export function readRuntimeDesktopSidebarFacts(store: RuntimeStore): DesktopSidebarFacts {
|
||||
@@ -122,7 +123,14 @@ export function sidebarInputsFeedDomains(
|
||||
},
|
||||
{
|
||||
name: 'review',
|
||||
read: async () => (await facts.getWorkspaceReviewFacts()).facts,
|
||||
read: async () => ({
|
||||
// A paired server's rows by the window's cache too; this host's own rows win an id both list.
|
||||
...buildPairedServerReviewFacts(
|
||||
pairedServerRows.snapshot().servers,
|
||||
requireStore().getGitHubCache().pr
|
||||
),
|
||||
...(await facts.getWorkspaceReviewFacts()).facts
|
||||
}),
|
||||
// The window's PR cache saves announce no change.
|
||||
watch: watchCatalog,
|
||||
rereadOnRead: true
|
||||
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
export const initialState = useAppStore.getInitialState()
|
||||
|
||||
/** The WorktreeList row pipeline, hook for hook, without the virtualized viewport. */
|
||||
function useGoldenSidebarPipeline() {
|
||||
export function useGoldenSidebarPipeline() {
|
||||
const repoMap = useRepoMap()
|
||||
const worktreeMap = useWorktreeMap()
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import type { AppState } from '@/store/types'
|
||||
import { getAllWorktreesFromState } from '@/store/selectors'
|
||||
import { getProjectHostSetupProjectionFromState } from '@/store/project-host-setup-selector'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
getSettingsFocusedExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import { withRepoHostOwnership } from '@/store/slices/worktrees/listing/worktree-host-ownership'
|
||||
import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides'
|
||||
import { getPinnedWorktreeDisplayPolicy } from '../../../../shared/sidebar-list/row-types'
|
||||
import type { HostSectionRow } from '../../../../shared/sidebar-list/host-section-rows'
|
||||
import { buildWorkspaceRows } from '../../../../shared/sidebar-list/workspace-rows'
|
||||
import {
|
||||
buildPairedServerReviewFacts,
|
||||
buildWorkspaceReviewFacts
|
||||
} from '../../../../shared/sidebar-list/workspace-review-facts'
|
||||
import type {
|
||||
SidebarFeedCatalog,
|
||||
SidebarFeedView
|
||||
} from '../../../../shared/sidebar-list/sidebar-feed-rows'
|
||||
import type { SmartStampFields } from '../../../../shared/sidebar-list/sort-stamps'
|
||||
import { GOLDEN_NOW, folder, group, lineage } from './sidebar-list-golden-factories.test-fixture'
|
||||
import { sidebarWorkspaceRowFilters } from './sidebar-filter-actions'
|
||||
import {
|
||||
readWindowWorkspaceActivityFacts,
|
||||
toLocallyOwnedRows
|
||||
} from '@/lib/sidebar-shadow/workspace-activity-shadow'
|
||||
import { readWindowDesktopSidebarFacts } from '@/lib/sidebar-shadow/desktop-facts-shadow'
|
||||
import {
|
||||
readWindowPairedServerHosts,
|
||||
readWindowPairedServerRows
|
||||
} from '@/lib/sidebar-shadow/paired-server-rows-shadow'
|
||||
import type { SidebarInputsFeedDomain } from '../../../../main/runtime/sidebar-inputs/sidebar-inputs-feed'
|
||||
|
||||
const SERVER_HOST_ID = 'runtime:env-1'
|
||||
|
||||
const buildServer: PublicKnownRuntimeEnvironment = {
|
||||
id: 'env-1',
|
||||
name: 'Build server',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastUsedAt: null,
|
||||
runtimeId: null,
|
||||
endpoints: [{ id: 'ws', kind: 'websocket', label: 'WebSocket', endpoint: 'ws://127.0.0.1:1' }],
|
||||
preferredEndpointId: 'ws'
|
||||
}
|
||||
|
||||
// Stamps on every host's rows, so Recent and Smart order across local, SSH and the paired server.
|
||||
const STAMPS: Record<string, SmartStampFields & { lastActivityAt?: number; isUnread?: boolean }> = {
|
||||
Zeta: { smartTier: 'needs-you', smartAt: GOLDEN_NOW - 600_000 },
|
||||
// Done holds only while unread: feat-a and api-y were not read yet.
|
||||
'feat-a': { smartTier: 'done', smartAt: GOLDEN_NOW - 60_000, isUnread: true },
|
||||
'api-y': {
|
||||
smartTier: 'done',
|
||||
smartAt: GOLDEN_NOW - 59_000,
|
||||
lastActivityAt: GOLDEN_NOW - 2_000,
|
||||
isUnread: true
|
||||
},
|
||||
'remote-x': { smartTier: 'working', smartAt: GOLDEN_NOW - 30_000 },
|
||||
// Read since it finished, so it ranks as Rest.
|
||||
'feat-b': { smartTier: 'done', smartAt: GOLDEN_NOW - 1_000 },
|
||||
'gitlab-mr': { smartTier: 'working', smartAt: GOLDEN_NOW - 900_000 }
|
||||
}
|
||||
|
||||
/**
|
||||
* What the parity suite adds to the golden store: stamps, a paired server the desktop knows with
|
||||
* a nested workspace and a folder workspace, an SSH connection, a saved host order and a creation
|
||||
* this host runs.
|
||||
*/
|
||||
export function parityWorldOverrides(base: Partial<AppState>): Partial<AppState> {
|
||||
const repoById = new Map((base.repos ?? []).map((repo) => [repo.id, repo]))
|
||||
const worktreesByRepo: AppState['worktreesByRepo'] = {}
|
||||
for (const [repoId, worktrees] of Object.entries(base.worktreesByRepo ?? {})) {
|
||||
const repo = repoById.get(repoId)
|
||||
worktreesByRepo[repoId] = worktrees.map((worktree) => {
|
||||
const branch = worktree.branch.replace('refs/heads/', '')
|
||||
const stamps = {
|
||||
...STAMPS[branch],
|
||||
...(branch === 'Zeta'
|
||||
? { cliProvenance: { kind: 'created-by-cli' as const, createdAt: 1 } }
|
||||
: {})
|
||||
}
|
||||
// Hosts stamped as the window's catalog fetch stamps them.
|
||||
const listed = repo ? withRepoHostOwnership(worktree, getRepoExecutionHostId(repo)) : worktree
|
||||
return { ...listed, ...stamps }
|
||||
})
|
||||
}
|
||||
const pending = Object.values(base.pendingWorktreeCreations ?? {})[0]
|
||||
const [serverMain, serverChild] = worktreesByRepo.api ?? []
|
||||
return {
|
||||
worktreesByRepo,
|
||||
// A server workspace nested under the server's main, and a server folder workspace.
|
||||
worktreeLineageById: {
|
||||
...base.worktreeLineageById,
|
||||
...(serverMain && serverChild ? { [serverChild.id]: lineage(serverChild, serverMain) } : {})
|
||||
},
|
||||
projectGroups: [
|
||||
...(base.projectGroups ?? []),
|
||||
group('pg-server-folders', 'Server folders', {
|
||||
parentPath: '/srv/folders',
|
||||
createdFrom: 'folder-scan',
|
||||
executionHostId: SERVER_HOST_ID,
|
||||
tabOrder: 3
|
||||
})
|
||||
],
|
||||
folderWorkspaces: [
|
||||
...(base.folderWorkspaces ?? []),
|
||||
folder('fw-server', 'pg-server-folders', { executionHostId: SERVER_HOST_ID, sortOrder: 3 })
|
||||
],
|
||||
runtimeEnvironments: [buildServer],
|
||||
sshConnectionStates: new Map([
|
||||
['gpu-vm', { targetId: 'gpu-vm', status: 'connected', error: null, reconnectAttempt: 0 }]
|
||||
]),
|
||||
// A creation main runs: one on a paired server is that server's own (see exclusions).
|
||||
pendingWorktreeCreations: pending
|
||||
? { [pending.creationId]: { ...pending, request: { ...pending.request, repoId: 'orca' } } }
|
||||
: {},
|
||||
workspaceHostOrder: ['ssh:gpu-vm', SERVER_HOST_ID, 'local'],
|
||||
// Desktop-only cards (imported, new external) are no feed input, so neither side gets them.
|
||||
detectedWorktreesByRepo: {}
|
||||
}
|
||||
}
|
||||
|
||||
/** The occurrence the parity suite opens; a pinned duplicate highlights in both places. */
|
||||
export function parityActiveState(
|
||||
worktreeId: string,
|
||||
hostId: AppState['activeWorkspaceExecutionHostId']
|
||||
): Partial<AppState> {
|
||||
return { activeWorktreeId: worktreeId, activeWorkspaceExecutionHostId: hostId }
|
||||
}
|
||||
|
||||
/**
|
||||
* The feed domains main would publish for this store, read through the same projections the
|
||||
* shadow comparison holds equal to main's producers in every e2e run.
|
||||
*/
|
||||
export function feedDomainsFromWindowState(state: AppState): SidebarInputsFeedDomain[] {
|
||||
const worktrees = getAllWorktreesFromState(state)
|
||||
const servers = readWindowPairedServerHosts(state, new Set([SERVER_HOST_ID]))
|
||||
const serverRows = readWindowPairedServerRows(state, new Set(servers.keys()))
|
||||
const domain = (name: string, read: () => Record<string, unknown>): SidebarInputsFeedDomain => ({
|
||||
name,
|
||||
read,
|
||||
watch: () => () => {}
|
||||
})
|
||||
return [
|
||||
domain('workspaces', () =>
|
||||
Object.fromEntries(
|
||||
buildWorkspaceRows({
|
||||
worktrees,
|
||||
folderWorkspaces: state.folderWorkspaces,
|
||||
repos: state.repos,
|
||||
lineageById: state.worktreeLineageById
|
||||
})
|
||||
)
|
||||
),
|
||||
domain('activity', () =>
|
||||
Object.fromEntries(
|
||||
toLocallyOwnedRows(state, readWindowWorkspaceActivityFacts(state, GOLDEN_NOW))
|
||||
)
|
||||
),
|
||||
domain('review', () => ({
|
||||
...buildPairedServerReviewFacts(
|
||||
[{ hostId: SERVER_HOST_ID, rows: [...serverRows.values()] }],
|
||||
state.prCache
|
||||
),
|
||||
...Object.fromEntries(
|
||||
toLocallyOwnedRows(
|
||||
state,
|
||||
new Map(Object.entries(buildWorkspaceReviewFacts(worktrees, state.repos, state.prCache)))
|
||||
)
|
||||
)
|
||||
})),
|
||||
domain('creations', () =>
|
||||
Object.fromEntries(
|
||||
Object.values(state.pendingWorktreeCreations ?? {}).map((creation) => [
|
||||
creation.creationId,
|
||||
{ repoId: creation.request.repoId }
|
||||
])
|
||||
)
|
||||
),
|
||||
domain('temporal', () => ({ sessionId: 'session-1', retention: null })),
|
||||
domain('pairedServerHosts', () =>
|
||||
Object.fromEntries(
|
||||
[...servers].map(([hostId, host]) => [hostId, { freshness: 'live', host }])
|
||||
)
|
||||
),
|
||||
domain('pairedServerRows', () => Object.fromEntries(serverRows)),
|
||||
domain('desktop', () => readWindowDesktopSidebarFacts(state))
|
||||
]
|
||||
}
|
||||
|
||||
/** What the host's own phone reads beside the feed: repo.list, groups and ui.get/settings.get. */
|
||||
export function phoneInputsFromWindowState(state: AppState): {
|
||||
catalog: SidebarFeedCatalog
|
||||
view: SidebarFeedView
|
||||
} {
|
||||
const projection = getProjectHostSetupProjectionFromState(state)
|
||||
return {
|
||||
catalog: {
|
||||
repos: state.repos,
|
||||
projectGroups: state.projectGroups ?? [],
|
||||
// The projection the window publishes for the phone (mobileRelay.projectSections).
|
||||
projectGrouping: {
|
||||
projects: projection.projects,
|
||||
projectHostSetups: projection.setups
|
||||
}
|
||||
},
|
||||
view: {
|
||||
groupBy: state.groupBy,
|
||||
sortBy: state.sortBy,
|
||||
projectOrderBy: state.projectOrderBy,
|
||||
collapsedGroups: state.collapsedGroups,
|
||||
filters: sidebarWorkspaceRowFilters(state),
|
||||
workspaceHostScope: state.workspaceHostScope,
|
||||
visibleWorkspaceHostIds: state.visibleWorkspaceHostIds,
|
||||
workspaceStatuses: state.workspaceStatuses,
|
||||
pinnedDisplayPolicy: getPinnedWorktreeDisplayPolicy(state.settings),
|
||||
defaultHostId: getSettingsFocusedExecutionHostId(state.settings),
|
||||
hostLabelOverrides: getHostDisplayLabelOverrides(state.settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What both adapters must agree on, one line per row in occurrence order: headers with their
|
||||
* counts and collapse keys, lineage keys, and which occurrences are active. Labels a client draws
|
||||
* from its own icons, host health and card contents are presentation and left out.
|
||||
*/
|
||||
export function projectParityRows(
|
||||
rows: readonly HostSectionRow[],
|
||||
activeRowKeys: ReadonlySet<string>
|
||||
): string[] {
|
||||
return rows.flatMap((row): string[] => {
|
||||
switch (row.type) {
|
||||
case 'host-header':
|
||||
return [
|
||||
`HOST ${row.key} host=${row.hostId} label=${row.label} count=${row.count}${row.collapsed ? ' collapsed' : ''}`
|
||||
]
|
||||
case 'header':
|
||||
return [
|
||||
`HEADER ${row.key} collapseKey=${row.collapseKey ?? '-'} label=${row.label ?? '-'} icon=${JSON.stringify(row.icon)} count=${row.count} hostId=${row.hostId ?? '-'}`
|
||||
]
|
||||
case 'item':
|
||||
return [
|
||||
`ITEM ${row.rowKey} section=${row.sectionKey} depth=${row.depth}/${row.groupDepth} lineageKey=${row.lineageGroupKey ?? '-'}${row.lineageCollapsed ? ' lineageCollapsed' : ''} children=${row.lineageChildCount}${activeRowKeys.has(row.rowKey) ? ' ACTIVE' : ''}`
|
||||
]
|
||||
case 'folder-workspace':
|
||||
return [
|
||||
`FOLDER ${row.key} group=${row.projectGroup.id} depth=${row.depth}/${row.groupDepth}${activeRowKeys.has(row.key) ? ' ACTIVE' : ''}`
|
||||
]
|
||||
case 'pending-creation':
|
||||
return [`PENDING ${row.key}`]
|
||||
// Desktop-only cards: the feed never carries detected worktrees (see exclusions).
|
||||
case 'imported-worktrees-card':
|
||||
case 'new-external-worktrees-inbox':
|
||||
return []
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
/**
|
||||
* One store goes in; the desktop window's row pipeline (switch on) and a phone reading main's feed
|
||||
* (feed pages, the shared installer, buildSidebarFeedRows) must draw the same list. The goldens
|
||||
* check that list is right; this checks only that the two adapters agree.
|
||||
*
|
||||
* Never compared, as no feed input carries them: the window's drag previews, search, hover hold
|
||||
* and agent-send reveal; its imported-worktrees and new-external-worktrees cards; its create
|
||||
* preflight; host-header health and detail.
|
||||
* Known gaps the phone shows differently, so the fixture leaves them out: a creation a paired
|
||||
* server runs; a paired server's activity under "Hide sleeping" (the window reads its own tabs);
|
||||
* a PR unlinked on a server workspace; an SSH workspace reached through a paired server.
|
||||
*/
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, renderHook } from '@testing-library/react'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { SidebarInputsFeed } from '../../../../main/runtime/sidebar-inputs/sidebar-inputs-feed'
|
||||
import { TestPhone } from '../../../../main/runtime/sidebar-inputs/sidebar-inputs-feed.test-fixture'
|
||||
import { isActiveSidebarWorkspace } from '../../../../shared/sidebar-list/active-workspace'
|
||||
import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree'
|
||||
import type { HostSectionRow } from '../../../../shared/sidebar-list/host-section-rows'
|
||||
import type { readSidebarFeedDomains } from '../../../../shared/sidebar-list/sidebar-feed-domains'
|
||||
import type { buildSidebarFeedRows } from '../../../../shared/sidebar-list/sidebar-feed-rows'
|
||||
import { GOLDEN_NOW } from './sidebar-list-golden-factories.test-fixture'
|
||||
import {
|
||||
GOLDEN_WORKTREE_IDS,
|
||||
buildGoldenStoreState,
|
||||
goldenLiveTerminalState,
|
||||
type GoldenView
|
||||
} from './sidebar-list-golden-store.test-fixture'
|
||||
import {
|
||||
GROUP_BYS,
|
||||
initialState,
|
||||
useGoldenSidebarPipeline
|
||||
} from './sidebar-list-golden-suite.test-fixture'
|
||||
import {
|
||||
feedDomainsFromWindowState,
|
||||
parityActiveState,
|
||||
parityWorldOverrides,
|
||||
phoneInputsFromWindowState,
|
||||
projectParityRows
|
||||
} from './sidebar-list-parity.test-fixture'
|
||||
|
||||
const SWITCH = '../../../../shared/sidebar-inputs-switch'
|
||||
vi.mock('../../../../shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true }))
|
||||
vi.mock('@/lib/sidebar-list-session', () => ({ useSidebarListSession: () => {} }))
|
||||
|
||||
// The phone page is built without the switch, so its adapter loads in a module graph with it off.
|
||||
let phoneAdapter: {
|
||||
readSidebarFeedDomains: typeof readSidebarFeedDomains
|
||||
buildSidebarFeedRows: typeof buildSidebarFeedRows
|
||||
}
|
||||
beforeAll(async () => {
|
||||
vi.resetModules()
|
||||
vi.doUnmock(SWITCH)
|
||||
expect(
|
||||
(await import('../../../../shared/sidebar-inputs-switch')).ORCA_SIDEBAR_INPUTS_IS_MAIN
|
||||
).toBe(false)
|
||||
phoneAdapter = {
|
||||
...(await import('../../../../shared/sidebar-list/sidebar-feed-domains')),
|
||||
...(await import('../../../../shared/sidebar-list/sidebar-feed-rows'))
|
||||
}
|
||||
})
|
||||
|
||||
const SORT_BYS: AppState['sortBy'][] = ['manual', 'name', 'recent', 'smart', 'repo']
|
||||
const PROJECT_ORDER_BYS: AppState['projectOrderBy'][] = ['manual', 'recent']
|
||||
|
||||
function desktopActiveRowKeys(rows: readonly HostSectionRow[], state: AppState): Set<string> {
|
||||
const active = {
|
||||
worktreeId: state.activeWorktreeId,
|
||||
hostId: state.activeWorkspaceExecutionHostId ?? null
|
||||
}
|
||||
const keys = new Set<string>()
|
||||
for (const row of rows) {
|
||||
if (row.type === 'item' && isActiveSidebarWorkspace(row.worktree, active)) {
|
||||
keys.add(row.rowKey)
|
||||
} else if (
|
||||
row.type === 'folder-workspace' &&
|
||||
isActiveSidebarWorkspace(folderWorkspaceToWorktree(row.folderWorkspace), active)
|
||||
) {
|
||||
keys.add(row.key)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
function desktopList(state: Partial<AppState>): string[] {
|
||||
useAppStore.setState(state)
|
||||
const { result, unmount } = renderHook(() => useGoldenSidebarPipeline())
|
||||
const { sectionRows } = result.current.rowModel
|
||||
const lines = projectParityRows(
|
||||
sectionRows,
|
||||
desktopActiveRowKeys(sectionRows, useAppStore.getState())
|
||||
)
|
||||
unmount()
|
||||
return lines
|
||||
}
|
||||
|
||||
async function phoneList(state: Partial<AppState>): Promise<string[]> {
|
||||
useAppStore.setState(state)
|
||||
const full = useAppStore.getState()
|
||||
const feed = new SidebarInputsFeed(
|
||||
() => feedDomainsFromWindowState(full),
|
||||
() => {}
|
||||
)
|
||||
const phone = new TestPhone(feed, 'phone-1')
|
||||
phone.refresh()
|
||||
await phone.settle()
|
||||
phone.disconnect()
|
||||
const installed = phone.state.installed
|
||||
if (!installed) {
|
||||
throw new Error('the feed installed nothing')
|
||||
}
|
||||
const { catalog, view } = phoneInputsFromWindowState(full)
|
||||
const { sectionRows, activeRowKeys } = phoneAdapter.buildSidebarFeedRows(
|
||||
phoneAdapter.readSidebarFeedDomains(installed),
|
||||
catalog,
|
||||
view
|
||||
)
|
||||
return projectParityRows(sectionRows, activeRowKeys)
|
||||
}
|
||||
|
||||
function worldState(view: GoldenView, extra: Partial<AppState> = {}): Partial<AppState> {
|
||||
const base = buildGoldenStoreState(view)
|
||||
return { ...base, ...parityWorldOverrides(base), ...extra }
|
||||
}
|
||||
|
||||
/** Both adapters over one store; the desktop's list is the expected side. */
|
||||
async function expectAdaptersAgree(state: Partial<AppState>): Promise<string[]> {
|
||||
const desktop = desktopList(state)
|
||||
useAppStore.setState(initialState, true)
|
||||
const phone = await phoneList(state)
|
||||
useAppStore.setState(initialState, true)
|
||||
expect(phone.join('\n')).toBe(desktop.join('\n'))
|
||||
return desktop
|
||||
}
|
||||
|
||||
/** Every other header collapsed, and the first lineage parent. */
|
||||
function collapsedKeysOf(lines: readonly string[]): Set<string> {
|
||||
const keys = lines.flatMap((line) => {
|
||||
const header = /^HEADER (\S+) collapseKey=(\S+)/.exec(line)
|
||||
if (header) {
|
||||
return [header[2] === '-' ? header[1] : header[2]]
|
||||
}
|
||||
const host = /^HOST (\S+)/.exec(line)
|
||||
return host ? [host[1]] : []
|
||||
})
|
||||
const collapsed = new Set(keys.filter((_, index) => index % 2 === 0))
|
||||
const lineage = lines
|
||||
.map((line) => / lineageKey=(\S+)/.exec(line)?.[1])
|
||||
.find((key) => key && key !== '-')
|
||||
if (lineage) {
|
||||
collapsed.add(lineage)
|
||||
}
|
||||
return collapsed
|
||||
}
|
||||
|
||||
describe('sidebar list parity: desktop window and feed reader', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(GOLDEN_NOW)
|
||||
useAppStore.setState(initialState, true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
useAppStore.setState(initialState, true)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
for (const groupBy of GROUP_BYS) {
|
||||
it(`agrees for groupBy=${groupBy} across sort, project order, pinned policy and collapse`, async () => {
|
||||
for (const sortBy of SORT_BYS) {
|
||||
for (const projectOrderBy of PROJECT_ORDER_BYS) {
|
||||
for (const pinnedInGroups of [false, true]) {
|
||||
const view: GoldenView = {
|
||||
groupBy,
|
||||
sortBy,
|
||||
projectOrderBy,
|
||||
pinnedInGroups,
|
||||
collapsedGroups: new Set()
|
||||
}
|
||||
// Bare id: every occurrence highlights, a pinned duplicate included.
|
||||
const active = parityActiveState(GOLDEN_WORKTREE_IDS.orcaFeatA, null)
|
||||
const expanded = await expectAdaptersAgree(worldState(view, active))
|
||||
expect(expanded.filter((line) => line.endsWith(' ACTIVE')).length).toBe(
|
||||
pinnedInGroups ? 2 : 1
|
||||
)
|
||||
await expectAdaptersAgree(
|
||||
worldState({ ...view, collapsedGroups: collapsedKeysOf(expanded) }, active)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Each scenario with a line its expanded list must hold, so an empty list cannot pass.
|
||||
const SCENARIOS: Record<string, [Partial<AppState>, RegExp]> = {
|
||||
'host sections': [
|
||||
{ visibleWorkspaceHostIds: ['local', 'ssh:gpu-vm', 'runtime:env-1'] },
|
||||
/^HOST \S+ host=runtime:env-1 label=Build server /
|
||||
],
|
||||
'single host scope': [{ workspaceHostScope: 'ssh:gpu-vm' }, /^ITEM .*ssh:gpu-vm\|/],
|
||||
'paired server scope': [{ workspaceHostScope: 'runtime:env-1' }, /^ITEM .*runtime:env-1\|/],
|
||||
'repo filter': [{ filterRepoIds: ['orca', 'lab'] }, /^ITEM .*\|lab::/],
|
||||
'hide sleeping': [
|
||||
{ showSleepingWorkspaces: false, ...goldenLiveTerminalState() },
|
||||
/^ITEM .*\|orca::\/w\/feat-a /
|
||||
],
|
||||
'hide default branch': [{ hideDefaultBranchWorkspace: true }, /^ITEM .*api-y /],
|
||||
// The same id on two hosts: only the SSH copy is open.
|
||||
'open on one of two hosts': [
|
||||
parityActiveState('shared::/w/same', 'ssh:gpu-vm'),
|
||||
/^ITEM \S*ssh:gpu-vm\|shared::\/w\/same .* ACTIVE$/
|
||||
],
|
||||
'open folder workspace': [
|
||||
parityActiveState('folder:fw-remote', 'ssh:gpu-vm'),
|
||||
/^FOLDER folder-workspace:fw-remote .* ACTIVE$/
|
||||
],
|
||||
'open server folder workspace': [
|
||||
parityActiveState('folder:fw-server', 'runtime:env-1'),
|
||||
/^FOLDER folder-workspace:fw-server group=pg-server-folders .* ACTIVE$/
|
||||
],
|
||||
'hide CLI-created': [
|
||||
{ hideCliCreatedWorkspaces: true },
|
||||
/^ITEM .*runtime:env-1\|api::\/w\/api-y /
|
||||
]
|
||||
}
|
||||
|
||||
for (const [name, [overrides, shows]] of Object.entries(SCENARIOS)) {
|
||||
it(`agrees with ${name} across groupBy, pinned policy and collapse`, async () => {
|
||||
for (const groupBy of GROUP_BYS) {
|
||||
for (const pinnedInGroups of [false, true]) {
|
||||
const view: GoldenView = {
|
||||
groupBy,
|
||||
sortBy: 'manual',
|
||||
projectOrderBy: 'manual',
|
||||
pinnedInGroups,
|
||||
collapsedGroups: new Set()
|
||||
}
|
||||
const expanded = await expectAdaptersAgree(worldState(view, overrides))
|
||||
expect(expanded.some((line) => shows.test(line))).toBe(true)
|
||||
await expectAdaptersAgree(
|
||||
worldState({ ...view, collapsedGroups: collapsedKeysOf(expanded) }, overrides)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -7,10 +7,8 @@ import type {
|
||||
WorktreeLineage
|
||||
} from '../../../../../../shared/worktree/lineage-types'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import {
|
||||
composeWorktreeHostIdentity,
|
||||
getWorktreeHostIdentity
|
||||
} from '../../../../../../shared/worktree/host-qualified-identity'
|
||||
import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
|
||||
import { isActiveSidebarWorkspace } from '../../../../../../shared/sidebar-list/active-workspace'
|
||||
import type { FolderWorkspacePathStatus } from '../../../../../../shared/folder-workspace-path-status'
|
||||
import { isConfirmedStaleFolderPathStatus } from '../../../../../../shared/folder-workspace-path-status'
|
||||
import { folderWorkspaceToWorktree } from '../../../../../../shared/folder-workspace-worktree'
|
||||
@@ -64,11 +62,10 @@ export function renderFolderWorkspaceVirtualRow(args: {
|
||||
const { ctx, row, vItem } = args
|
||||
const folderWorktree = folderWorkspaceToWorktree(row.folderWorkspace)
|
||||
const folderWorktreeIdentity = getWorktreeHostIdentity(folderWorktree)
|
||||
const isActiveWorktree =
|
||||
ctx.activeWorktreeId === folderWorktree.id &&
|
||||
(!ctx.activeWorkspaceExecutionHostId ||
|
||||
folderWorktreeIdentity ===
|
||||
composeWorktreeHostIdentity(ctx.activeWorkspaceExecutionHostId, folderWorktree.id))
|
||||
const isActiveWorktree = isActiveSidebarWorkspace(folderWorktree, {
|
||||
worktreeId: ctx.activeWorktreeId,
|
||||
hostId: ctx.activeWorkspaceExecutionHostId ?? null
|
||||
})
|
||||
const pathStatus = ctx.getCachedFolderWorkspacePathStatus({
|
||||
scope: 'folder-workspace',
|
||||
folderWorkspaceId: row.folderWorkspace.id
|
||||
|
||||
@@ -3,10 +3,8 @@ import { cn } from '@/lib/utils'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import {
|
||||
composeWorktreeHostIdentity,
|
||||
getWorktreeHostIdentity
|
||||
} from '../../../../../../shared/worktree/host-qualified-identity'
|
||||
import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
|
||||
import { isActiveSidebarWorkspace } from '../../../../../../shared/sidebar-list/active-workspace'
|
||||
import WorktreeCard, { type ActiveSurfaceVariant } from '../../WorktreeCard'
|
||||
import { PINNED_GROUP_KEY } from '../../../../../../shared/sidebar-list/group-keys'
|
||||
import type { WorktreeGroupBy } from '../../../../../../shared/sidebar-list/row-types'
|
||||
@@ -123,11 +121,10 @@ export function renderWorktreeItemRow(
|
||||
ctx.worktreeDragState.draggingWorktreeId &&
|
||||
(ctx.worktreeDragState.lineageDropTargetId === itemRow.worktree.id ||
|
||||
ctx.nativeLineageDropTargetId === itemRow.worktree.id)
|
||||
const isActiveWorktree =
|
||||
ctx.activeWorktreeId === itemRow.worktree.id &&
|
||||
(!ctx.activeWorkspaceExecutionHostId ||
|
||||
worktreeIdentity ===
|
||||
composeWorktreeHostIdentity(ctx.activeWorkspaceExecutionHostId, itemRow.worktree.id))
|
||||
const isActiveWorktree = isActiveSidebarWorkspace(itemRow.worktree, {
|
||||
worktreeId: ctx.activeWorktreeId,
|
||||
hostId: ctx.activeWorkspaceExecutionHostId ?? null
|
||||
})
|
||||
return (
|
||||
<div
|
||||
key={itemRow.rowKey}
|
||||
|
||||
@@ -13,7 +13,9 @@ describe('readWindowPairedServerRows', () => {
|
||||
makeWorktree('other', 'Unlisted server', { hostId: 'runtime:gone' })
|
||||
]
|
||||
},
|
||||
folderWorkspaces: [makeFolderWorkspace({ executionHostId: 'runtime:box', name: 'Notes' })]
|
||||
folderWorkspaces: [makeFolderWorkspace({ executionHostId: 'runtime:box', name: 'Notes' })],
|
||||
repos: [],
|
||||
worktreeLineageById: {}
|
||||
},
|
||||
new Set(['runtime:box'])
|
||||
)
|
||||
@@ -27,7 +29,14 @@ describe('readWindowPairedServerRows', () => {
|
||||
branch: 'refs/heads/main',
|
||||
displayName: 'On the server',
|
||||
isArchived: false,
|
||||
isPinned: true
|
||||
isPinned: true,
|
||||
isMainWorktree: false,
|
||||
isUnread: false,
|
||||
lineage: null,
|
||||
workspaceStatus: 'in-progress',
|
||||
sortOrder: 0,
|
||||
defaultBranch: false,
|
||||
recentAt: 0
|
||||
},
|
||||
'runtime:box|folder:folder-1': expect.objectContaining({
|
||||
kind: 'folder',
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useAppStore, type AppState } from '@/store'
|
||||
import { getAllWorktreesFromState } from '@/store/selectors'
|
||||
import { getAllWorktreesFromState, getRepoMapFromState } from '@/store/selectors'
|
||||
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../../../shared/workspace-statuses'
|
||||
import { getWorkspaceRowKindFacts } from '../../../../shared/worktree/workspace-kinds'
|
||||
import { projectResolvedWorktreeLineage } from '../../../../shared/resolved-worktree-lineage'
|
||||
import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types'
|
||||
import {
|
||||
folderWorkspaceRepoId,
|
||||
folderWorkspaceToWorktree
|
||||
@@ -17,15 +21,35 @@ import { defineSidebarShadowDomain, type SidebarShadowMainRead } from './sidebar
|
||||
|
||||
/** The paired-server rows the window fetched itself, keyed by host-qualified id, for these hosts. */
|
||||
export function readWindowPairedServerRows(
|
||||
state: Pick<AppState, 'worktreesByRepo' | 'folderWorkspaces'>,
|
||||
state: Pick<AppState, 'worktreesByRepo' | 'folderWorkspaces' | 'repos' | 'worktreeLineageById'>,
|
||||
hostIds: ReadonlySet<string>
|
||||
): Map<string, PairedServerWorkspaceRow> {
|
||||
const rows = new Map<string, PairedServerWorkspaceRow>()
|
||||
const repoById = getRepoMapFromState(state)
|
||||
// The server sends only edges its own check keeps, so the window checks its copy the same way.
|
||||
const checkedLineageByKey = new Map<string, WorktreeLineage | null>()
|
||||
for (const hostId of hostIds) {
|
||||
const hostWorktrees = getAllWorktreesFromState(state).filter(
|
||||
(worktree) => worktree.hostId === hostId
|
||||
)
|
||||
for (const worktree of projectResolvedWorktreeLineage(
|
||||
hostWorktrees,
|
||||
state.worktreeLineageById
|
||||
)) {
|
||||
checkedLineageByKey.set(getWorktreeHostIdentity(worktree), worktree.lineage)
|
||||
}
|
||||
}
|
||||
const add = (worktree: Worktree, kind: PairedServerWorkspaceRow['kind']): void => {
|
||||
if (!worktree.hostId || !hostIds.has(worktree.hostId)) {
|
||||
return
|
||||
}
|
||||
const key = getWorktreeHostIdentity(worktree)
|
||||
const lineage = checkedLineageByKey.get(key)
|
||||
// The server's reply carries no automation name, so the row leaves it out too.
|
||||
const { automationName: _automationName, ...kindFacts } = getWorkspaceRowKindFacts(
|
||||
worktree,
|
||||
repoById.get(worktree.repoId)
|
||||
)
|
||||
rows.set(key, {
|
||||
key,
|
||||
worktreeId: worktree.id,
|
||||
@@ -35,7 +59,26 @@ export function readWindowPairedServerRows(
|
||||
branch: worktree.branch,
|
||||
displayName: worktree.displayName,
|
||||
isArchived: worktree.isArchived,
|
||||
isPinned: worktree.isPinned
|
||||
isPinned: worktree.isPinned,
|
||||
isMainWorktree: worktree.isMainWorktree,
|
||||
isUnread: worktree.isUnread,
|
||||
instanceId: worktree.instanceId,
|
||||
lineage: lineage
|
||||
? {
|
||||
parentWorktreeId: lineage.parentWorktreeId,
|
||||
worktreeInstanceId: lineage.worktreeInstanceId,
|
||||
parentWorktreeInstanceId: lineage.parentWorktreeInstanceId
|
||||
}
|
||||
: null,
|
||||
workspaceStatus: worktree.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID,
|
||||
sortOrder: worktree.sortOrder,
|
||||
manualOrder: worktree.manualOrder,
|
||||
createdAt: worktree.createdAt,
|
||||
creatorProvenance: worktree.creatorProvenance,
|
||||
...kindFacts,
|
||||
recentAt: worktree.lastActivityAt,
|
||||
smartTier: worktree.smartTier,
|
||||
smartAt: worktree.smartAt
|
||||
})
|
||||
}
|
||||
for (const worktree of getAllWorktreesFromState(state)) {
|
||||
@@ -87,9 +130,27 @@ export const pairedServerRowsShadow = defineSidebarShadowDomain<
|
||||
}
|
||||
},
|
||||
readWindow: (main) => readWindowPairedServerRows(useAppStore.getState(), main.liveHostIds),
|
||||
equals: (window, main) => JSON.stringify(window ?? null) === JSON.stringify(main ?? null)
|
||||
// Stamps and unread move with every server event, so a fetch either side made a moment apart differs.
|
||||
equals: (window, main) =>
|
||||
JSON.stringify(withoutStamps(window)) === JSON.stringify(withoutStamps(main))
|
||||
})
|
||||
|
||||
function withoutStamps(
|
||||
row: PairedServerWorkspaceRow | undefined
|
||||
): Partial<PairedServerWorkspaceRow> | null {
|
||||
if (!row) {
|
||||
return null
|
||||
}
|
||||
const {
|
||||
recentAt: _recentAt,
|
||||
smartTier: _smartTier,
|
||||
smartAt: _smartAt,
|
||||
isUnread: _isUnread,
|
||||
...rest
|
||||
} = row
|
||||
return rest
|
||||
}
|
||||
|
||||
/** The window's header facts for these servers, from its own status, environment and label state. */
|
||||
export function readWindowPairedServerHosts(
|
||||
state: Pick<
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ExecutionHostId } from '../execution-host'
|
||||
import {
|
||||
composeWorktreeHostIdentity,
|
||||
getWorktreeHostIdentity
|
||||
} from '../worktree/host-qualified-identity'
|
||||
import type { Worktree } from '../worktree/types'
|
||||
|
||||
/** The desktop's open workspace, as its session saves it; both null when nothing is open. */
|
||||
export type ActiveSidebarWorkspace = {
|
||||
worktreeId: string | null
|
||||
hostId: ExecutionHostId | null
|
||||
}
|
||||
|
||||
/** Every occurrence of the open workspace highlights, so a pinned duplicate shows twice. */
|
||||
export function isActiveSidebarWorkspace(
|
||||
worktree: Pick<Worktree, 'id' | 'hostId'>,
|
||||
active: ActiveSidebarWorkspace
|
||||
): boolean {
|
||||
return (
|
||||
active.worktreeId === worktree.id &&
|
||||
(!active.hostId ||
|
||||
getWorktreeHostIdentity(worktree) === composeWorktreeHostIdentity(active.hostId, worktree.id))
|
||||
)
|
||||
}
|
||||
@@ -2,19 +2,45 @@ import { z } from 'zod'
|
||||
import type { ExecutionHostId } from '../execution-host'
|
||||
import { composeWorktreeHostIdentity } from '../worktree/host-qualified-identity'
|
||||
import { openEnum } from '../zod-salvage'
|
||||
import { DEFAULT_WORKSPACE_STATUS_ID } from '../workspace-statuses'
|
||||
import { SMART_TIERS } from './sort-stamps'
|
||||
import { WorkspaceRowSchema } from './workspace-rows'
|
||||
|
||||
/** One paired-server workspace as the sidebar lists it, keyed by its host-qualified id. */
|
||||
const PairedServerWorkspaceRowSchema = z.object({
|
||||
key: z.string(),
|
||||
worktreeId: z.string(),
|
||||
repoId: z.string(),
|
||||
kind: z.enum(['git', 'folder']),
|
||||
path: z.string(),
|
||||
branch: z.string(),
|
||||
displayName: z.string(),
|
||||
isArchived: z.boolean(),
|
||||
isPinned: z.boolean()
|
||||
})
|
||||
const PairedServerWorkspaceRowSchema = z
|
||||
.object({
|
||||
key: z.string(),
|
||||
worktreeId: z.string(),
|
||||
repoId: z.string(),
|
||||
kind: z.enum(['git', 'folder']),
|
||||
path: z.string(),
|
||||
branch: z.string(),
|
||||
displayName: z.string(),
|
||||
isArchived: z.boolean(),
|
||||
isPinned: z.boolean()
|
||||
})
|
||||
.extend(
|
||||
// What the list sorts, groups and filters by, as the server stamped it; older desktops omit them.
|
||||
// `lineage` is the edge the server's own check kept, not its stored record.
|
||||
WorkspaceRowSchema.pick({
|
||||
isMainWorktree: true,
|
||||
isUnread: true,
|
||||
instanceId: true,
|
||||
lineage: true,
|
||||
workspaceStatus: true,
|
||||
sortOrder: true,
|
||||
manualOrder: true,
|
||||
createdAt: true,
|
||||
creatorProvenance: true,
|
||||
defaultBranch: true,
|
||||
automationCreated: true,
|
||||
cliCreated: true,
|
||||
detachedHead: true,
|
||||
recentAt: true,
|
||||
smartTier: true,
|
||||
smartAt: true
|
||||
}).partial().shape
|
||||
)
|
||||
|
||||
/** See PairedServerHost; health and compatibility stay open strings, as a reader only compares them. */
|
||||
const PairedServerHostFactsSchema = z.object({
|
||||
@@ -52,7 +78,25 @@ const ServerWorktreePsRowSchema = z.object({
|
||||
branch: z.string().catch(''),
|
||||
displayName: z.string().catch(''),
|
||||
isArchived: z.boolean().catch(false),
|
||||
isPinned: z.boolean().catch(false)
|
||||
isPinned: z.boolean().catch(false),
|
||||
isMainWorktree: z.boolean().catch(false),
|
||||
unread: z.boolean().optional().catch(undefined),
|
||||
worktreeInstanceId: z.string().optional().catch(undefined),
|
||||
parentWorktreeId: z.string().nullable().optional().catch(undefined),
|
||||
lineageWorktreeInstanceId: z.string().optional().catch(undefined),
|
||||
parentWorktreeInstanceId: z.string().optional().catch(undefined),
|
||||
workspaceStatus: z.string().catch(DEFAULT_WORKSPACE_STATUS_ID),
|
||||
sortOrder: z.number().optional().catch(undefined),
|
||||
manualOrder: z.number().optional().catch(undefined),
|
||||
createdAt: z.number().optional().catch(undefined),
|
||||
creatorProvenance: WorkspaceRowSchema.shape.creatorProvenance.catch(undefined),
|
||||
defaultBranch: z.boolean().optional().catch(undefined),
|
||||
automationCreated: z.literal(true).optional().catch(undefined),
|
||||
cliCreated: z.literal(true).optional().catch(undefined),
|
||||
detachedHead: z.literal(true).optional().catch(undefined),
|
||||
lastActivityAt: z.number().optional().catch(undefined),
|
||||
smartTier: z.enum(SMART_TIERS).optional().catch(undefined),
|
||||
smartAt: z.number().optional().catch(undefined)
|
||||
})
|
||||
|
||||
/** A server's own `worktree.ps` row, with defaults for what older servers omit; null without an id. */
|
||||
@@ -64,10 +108,30 @@ export function normalizePairedServerRow(
|
||||
if (!parsed.success) {
|
||||
return null
|
||||
}
|
||||
const { workspaceKind, ...fields } = parsed.data
|
||||
const {
|
||||
workspaceKind,
|
||||
lastActivityAt,
|
||||
unread,
|
||||
worktreeInstanceId,
|
||||
parentWorktreeId,
|
||||
lineageWorktreeInstanceId,
|
||||
parentWorktreeInstanceId,
|
||||
...fields
|
||||
} = parsed.data
|
||||
return {
|
||||
key: composeWorktreeHostIdentity(hostId, fields.worktreeId),
|
||||
kind: workspaceKind === 'folder-workspace' ? 'folder' : 'git',
|
||||
...fields
|
||||
...fields,
|
||||
...(unread !== undefined ? { isUnread: unread } : {}),
|
||||
...(worktreeInstanceId !== undefined ? { instanceId: worktreeInstanceId } : {}),
|
||||
lineage:
|
||||
parentWorktreeId && lineageWorktreeInstanceId && parentWorktreeInstanceId
|
||||
? {
|
||||
parentWorktreeId,
|
||||
worktreeInstanceId: lineageWorktreeInstanceId,
|
||||
parentWorktreeInstanceId
|
||||
}
|
||||
: null,
|
||||
recentAt: lastActivityAt
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { z } from 'zod'
|
||||
import { DesktopSidebarFactsSchema, type DesktopSidebarFacts } from './desktop-sidebar-facts'
|
||||
import type { InstalledSidebarInputs } from './sidebar-inputs-feed-installer'
|
||||
import {
|
||||
PairedServerRowsSnapshotSchema,
|
||||
type PairedServerHostFacts,
|
||||
type PairedServerWorkspaceRow
|
||||
} from './paired-server-rows'
|
||||
import { SidebarTemporalSnapshotSchema, type SidebarRetention } from './sidebar-temporal-snapshot'
|
||||
import {
|
||||
WorkspaceActivityFactsSnapshotSchema,
|
||||
type WorkspaceActivityFacts
|
||||
} from './workspace-activity-facts'
|
||||
import { WorkspaceCreationsSnapshotSchema, type WorkspaceCreation } from './workspace-creations'
|
||||
import {
|
||||
WorkspaceReviewFactsSnapshotSchema,
|
||||
type WorkspaceReviewFact
|
||||
} from './workspace-review-facts'
|
||||
import { WorkspaceRowsSnapshotSchema, type WorkspaceRow } from './workspace-rows'
|
||||
|
||||
/** One installed feed, typed per domain; an entity this build cannot parse is left out. */
|
||||
export type SidebarFeedDomains = {
|
||||
workspaces: ReadonlyMap<string, WorkspaceRow>
|
||||
activity: ReadonlyMap<string, WorkspaceActivityFacts>
|
||||
review: ReadonlyMap<string, WorkspaceReviewFact>
|
||||
creations: ReadonlyMap<string, WorkspaceCreation>
|
||||
pairedServerHosts: ReadonlyMap<
|
||||
string,
|
||||
{ freshness: 'live' | 'unverifiable'; host: PairedServerHostFacts | null }
|
||||
>
|
||||
pairedServerRows: ReadonlyMap<string, PairedServerWorkspaceRow>
|
||||
/** Null until the host's desktop facts arrive whole. */
|
||||
desktop: DesktopSidebarFacts | null
|
||||
retention: SidebarRetention | null
|
||||
}
|
||||
|
||||
const entitySchemas = {
|
||||
workspaces: WorkspaceRowsSnapshotSchema.shape.rows.valueType,
|
||||
activity: WorkspaceActivityFactsSnapshotSchema.shape.facts.valueType,
|
||||
review: WorkspaceReviewFactsSnapshotSchema.shape.facts.valueType,
|
||||
creations: WorkspaceCreationsSnapshotSchema.shape.creations.valueType,
|
||||
pairedServerHosts: PairedServerRowsSnapshotSchema.shape.servers.element.pick({
|
||||
freshness: true,
|
||||
host: true
|
||||
}),
|
||||
pairedServerRows: PairedServerRowsSnapshotSchema.shape.servers.element.shape.rows.element
|
||||
}
|
||||
|
||||
function readDomain<Schema extends z.ZodType>(
|
||||
installed: InstalledSidebarInputs,
|
||||
name: string,
|
||||
schema: Schema
|
||||
): Map<string, z.infer<Schema>> {
|
||||
const entities = new Map<string, z.infer<Schema>>()
|
||||
for (const [key, value] of installed.domains.get(name) ?? []) {
|
||||
const parsed = schema.safeParse(value)
|
||||
if (parsed.success) {
|
||||
entities.set(key, parsed.data)
|
||||
}
|
||||
}
|
||||
return entities
|
||||
}
|
||||
|
||||
/** The domains main publishes (sidebar-inputs-feed-domains.ts), read off an installed feed. */
|
||||
export function readSidebarFeedDomains(installed: InstalledSidebarInputs): SidebarFeedDomains {
|
||||
// The desktop and temporal domains publish each field as its own entity.
|
||||
const desktop = DesktopSidebarFactsSchema.safeParse(
|
||||
Object.fromEntries(installed.domains.get('desktop') ?? [])
|
||||
)
|
||||
const temporal = SidebarTemporalSnapshotSchema.pick({ retention: true }).safeParse(
|
||||
Object.fromEntries(installed.domains.get('temporal') ?? [])
|
||||
)
|
||||
return {
|
||||
workspaces: readDomain(installed, 'workspaces', entitySchemas.workspaces),
|
||||
activity: readDomain(installed, 'activity', entitySchemas.activity),
|
||||
review: readDomain(installed, 'review', entitySchemas.review),
|
||||
creations: readDomain(installed, 'creations', entitySchemas.creations),
|
||||
pairedServerHosts: readDomain(installed, 'pairedServerHosts', entitySchemas.pairedServerHosts),
|
||||
pairedServerRows: readDomain(installed, 'pairedServerRows', entitySchemas.pairedServerRows),
|
||||
desktop: desktop.success ? desktop.data : null,
|
||||
retention: temporal.success ? temporal.data.retention : null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import {
|
||||
ALL_EXECUTION_HOSTS_SCOPE,
|
||||
getSshTargetIdForExecutionHost,
|
||||
getWorktreeExecutionHostId,
|
||||
normalizeExecutionHostOrder,
|
||||
parseExecutionHostId,
|
||||
type ExecutionHostId,
|
||||
type ExecutionHostScope
|
||||
} from '../execution-host'
|
||||
import { buildExecutionHostRegistry } from '../execution-host-registry'
|
||||
import type { ProjectGroup } from '../project-group-types'
|
||||
import { getLogicalRepoOrderRankById } from '../project-section-order'
|
||||
import type { ProjectGroupingModel } from '../project-section-grouping'
|
||||
import type { Repo } from '../repo-types'
|
||||
import type { ProjectOrderBy } from '../ui-chrome-types'
|
||||
import {
|
||||
filterFolderWorkspacesFromOtherDevices,
|
||||
isWorkspaceOwnedBySomeoneElse
|
||||
} from '../workspace-creator-visibility'
|
||||
import type { WorkspaceRowFilters } from '../workspace-view/workspace-row-filters'
|
||||
import { getWorktreeHostIdentity } from '../worktree/host-qualified-identity'
|
||||
import { folderWorkspaceToWorktree } from '../folder-workspace-worktree'
|
||||
import { isActiveSidebarWorkspace } from './active-workspace'
|
||||
import type { WorkspaceStatusDefinition, Worktree } from '../worktree/types'
|
||||
import { getEmptyProjectPlaceholderRepoIds } from './empty-project-placeholder-repos'
|
||||
import {
|
||||
filterFolderWorkspacesForVisibleHosts,
|
||||
filterProjectGroupsForVisibleHosts,
|
||||
getVisibleSidebarHostIdSet
|
||||
} from './host-filtering'
|
||||
import { deferHostSectionCollapse, scopeHostSectionCollapse } from './host-section-collapse'
|
||||
import { orderHostSectionOptions } from './host-section-order'
|
||||
import {
|
||||
addHostSectionRows,
|
||||
type HostSectionOption,
|
||||
type HostSectionRow
|
||||
} from './host-section-rows'
|
||||
import type { PinnedWorktreeDisplayPolicy, Row, WorktreeGroupBy } from './row-types'
|
||||
import type { SidebarFeedDomains } from './sidebar-feed-domains'
|
||||
import {
|
||||
readFeedLineage,
|
||||
readFeedPairedServerRow,
|
||||
readFeedWorkspaceRow,
|
||||
type SidebarFeedWorkspace
|
||||
} from './sidebar-feed-worktrees'
|
||||
import { planSidebarRows } from './sidebar-row-plan'
|
||||
import { sortWorktreesByStamps, type SortBy } from './smart-sort'
|
||||
import { selectVisibleSidebarWorktrees } from './visible-worktrees'
|
||||
import { isIdleWorkspaceActivity } from './workspace-activity-facts'
|
||||
import { prGroupOfReviewFact } from './workspace-review-facts'
|
||||
|
||||
/** The host's view settings a reader shows the list with; the host's own phone reads them off ui.get. */
|
||||
export type SidebarFeedView = {
|
||||
groupBy: WorktreeGroupBy
|
||||
sortBy: SortBy
|
||||
projectOrderBy: ProjectOrderBy
|
||||
collapsedGroups: ReadonlySet<string>
|
||||
filters: WorkspaceRowFilters
|
||||
workspaceHostScope: ExecutionHostScope
|
||||
visibleWorkspaceHostIds: readonly ExecutionHostId[] | null
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[]
|
||||
pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy
|
||||
/** The host the desktop has focused (its settings' active runtime), where unowned rows land. */
|
||||
defaultHostId: ExecutionHostId
|
||||
hostLabelOverrides: ReadonlyMap<ExecutionHostId, string>
|
||||
}
|
||||
|
||||
/** The catalog the feed does not carry: every host's repos (repo.list), groups and projects. */
|
||||
export type SidebarFeedCatalog = {
|
||||
repos: readonly Repo[]
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
projectGrouping: ProjectGroupingModel
|
||||
}
|
||||
|
||||
export type SidebarFeedRows = {
|
||||
visibleWorktrees: Worktree[]
|
||||
rows: Row[]
|
||||
sectionRows: HostSectionRow[]
|
||||
/** Row keys of every occurrence of the desktop's open workspace; a pinned duplicate shows twice. */
|
||||
activeRowKeys: ReadonlySet<string>
|
||||
}
|
||||
|
||||
function readFeedWorkspaces(domains: SidebarFeedDomains): SidebarFeedWorkspace[] {
|
||||
return [
|
||||
...[...domains.workspaces].flatMap(
|
||||
([identity, row]) => readFeedWorkspaceRow(identity, row) ?? []
|
||||
),
|
||||
...[...domains.pairedServerRows.values()].flatMap((row) => readFeedPairedServerRow(row) ?? [])
|
||||
]
|
||||
}
|
||||
|
||||
function buildFeedHostOptions(
|
||||
domains: SidebarFeedDomains,
|
||||
repos: readonly Repo[],
|
||||
view: SidebarFeedView
|
||||
): HostSectionOption[] {
|
||||
const servers = [...domains.pairedServerHosts].flatMap(([hostId, { host }]) => {
|
||||
const parsed = parseExecutionHostId(hostId)
|
||||
return parsed?.kind === 'runtime' && host ? [{ parsed, host }] : []
|
||||
})
|
||||
const serverLabelByHostId = new Map<ExecutionHostId, string>(
|
||||
servers.map(({ parsed, host }) => [parsed.id, host.label])
|
||||
)
|
||||
const focused = parseExecutionHostId(view.defaultHostId)
|
||||
return buildExecutionHostRegistry({
|
||||
repos,
|
||||
settings: {
|
||||
activeRuntimeEnvironmentId: focused?.kind === 'runtime' ? focused.environmentId : null
|
||||
},
|
||||
sshTargetLabels: new Map(
|
||||
Object.entries(domains.desktop?.sshHosts ?? {}).flatMap(([hostId, host]) => {
|
||||
const targetId = getSshTargetIdForExecutionHost(hostId)
|
||||
return targetId ? [[targetId, host.label] as const] : []
|
||||
})
|
||||
),
|
||||
runtimeEnvironments: servers.map(({ parsed, host }) => ({
|
||||
id: parsed.environmentId,
|
||||
name: host.label
|
||||
})),
|
||||
hostLabelOverrides: view.hostLabelOverrides
|
||||
}).map((host) => {
|
||||
const label = serverLabelByHostId.get(host.id)
|
||||
return label ? { ...host, label } : host
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The sidebar a feed reader shows: the host's published inputs through the same builder, filters
|
||||
* and host sections the desktop window runs, with no clock. The window's transient overlays (drag
|
||||
* previews, search, hover hold, agent-send reveal) and its desktop-only cards are not inputs here.
|
||||
*/
|
||||
export function buildSidebarFeedRows(
|
||||
domains: SidebarFeedDomains,
|
||||
catalog: SidebarFeedCatalog,
|
||||
view: SidebarFeedView
|
||||
): SidebarFeedRows {
|
||||
const { repos, projectGroups } = catalog
|
||||
const { defaultHostId, groupBy } = view
|
||||
const workspaces = readFeedWorkspaces(domains)
|
||||
const byIdentity = new Map(workspaces.map((entry) => [entry.identity, entry]))
|
||||
const gitWorkspaces = workspaces.filter((entry) => entry.folderWorkspace === null)
|
||||
const allWorktrees = gitWorkspaces.map((entry) => entry.worktree)
|
||||
const folderWorkspaces = workspaces.flatMap((entry) => entry.folderWorkspace ?? [])
|
||||
const repoMap = new Map(repos.map((repo) => [repo.id, repo]))
|
||||
const worktreeMap = new Map<string, Worktree>()
|
||||
const ancestorById = new Map<string, Worktree>()
|
||||
const worktreesByRepo: Record<string, Worktree[]> = {}
|
||||
for (const worktree of allWorktrees) {
|
||||
if (!worktreeMap.has(worktree.id)) {
|
||||
worktreeMap.set(worktree.id, worktree)
|
||||
}
|
||||
if (!worktree.isArchived) {
|
||||
ancestorById.set(worktree.id, worktree)
|
||||
}
|
||||
const repoWorktrees = worktreesByRepo[worktree.repoId]
|
||||
if (repoWorktrees) {
|
||||
repoWorktrees.push(worktree)
|
||||
} else {
|
||||
worktreesByRepo[worktree.repoId] = [worktree]
|
||||
}
|
||||
}
|
||||
const lineageById = readFeedLineage(workspaces)
|
||||
const retained = new Set(domains.retention?.retainedIds ?? [])
|
||||
const factsOf = (worktree: Worktree) => byIdentity.get(getWorktreeHostIdentity(worktree))?.facts
|
||||
const pairedDeviceIdsByEnvironment = new Map(
|
||||
[...domains.pairedServerHosts].flatMap(([hostId, { host }]) => {
|
||||
const environmentId = parseExecutionHostId(hostId)
|
||||
return environmentId?.kind === 'runtime' && host?.pairedDeviceId
|
||||
? [[environmentId.environmentId, host.pairedDeviceId] as const]
|
||||
: []
|
||||
})
|
||||
)
|
||||
|
||||
const sorted = sortWorktreesByStamps(
|
||||
allWorktrees.filter((worktree) => !worktree.isArchived),
|
||||
view.sortBy,
|
||||
repoMap
|
||||
)
|
||||
const visibleWorktrees = selectVisibleSidebarWorktrees({
|
||||
rows: allWorktrees,
|
||||
ancestorById,
|
||||
rankById: new Map(sorted.map((worktree, index) => [worktree.id, index])),
|
||||
filters: view.filters,
|
||||
facts: {
|
||||
isArchived: (worktree) => worktree.isArchived,
|
||||
isFolderWorkspace: () => false,
|
||||
repoId: (worktree) => worktree.repoId,
|
||||
hostId: (worktree) => {
|
||||
const repo = repoMap.get(worktree.repoId)
|
||||
return repo ? getWorktreeExecutionHostId(worktree, repo, defaultHostId) : null
|
||||
},
|
||||
isOtherClient: (worktree) =>
|
||||
isWorkspaceOwnedBySomeoneElse(worktree, pairedDeviceIdsByEnvironment),
|
||||
isDefaultBranch: (worktree) => factsOf(worktree)?.defaultBranch === true,
|
||||
isAutomationCreated: (worktree) => factsOf(worktree)?.automationCreated === true,
|
||||
isCliCreated: (worktree) => factsOf(worktree)?.cliCreated === true,
|
||||
isDetachedHead: (worktree) => factsOf(worktree)?.detachedHead === true,
|
||||
isMainWorktree: (worktree) => worktree.isMainWorktree,
|
||||
isSleeping: (worktree) => {
|
||||
const activity = domains.activity.get(worktree.id)
|
||||
return !retained.has(worktree.id) && (!activity || isIdleWorkspaceActivity(activity))
|
||||
}
|
||||
},
|
||||
worktreeLineageById: lineageById,
|
||||
filterRepoIds: view.filters.filterRepoIds,
|
||||
preserveLineageParentOrder: view.sortBy === 'manual'
|
||||
})
|
||||
|
||||
const visibleHostIdSet = getVisibleSidebarHostIdSet(
|
||||
view.visibleWorkspaceHostIds,
|
||||
view.workspaceHostScope
|
||||
)
|
||||
const visibleRepos = visibleHostIdSet
|
||||
? repos.filter((repo) =>
|
||||
visibleHostIdSet.has(getWorktreeExecutionHostId({}, repo, defaultHostId))
|
||||
)
|
||||
: repos
|
||||
const hostVisibleFolderWorkspaces = filterFolderWorkspacesForVisibleHosts(
|
||||
folderWorkspaces,
|
||||
projectGroups,
|
||||
visibleHostIdSet,
|
||||
defaultHostId
|
||||
)
|
||||
const visibleFolderWorkspaces = view.filters.hideWorkspacesFromOtherDevices
|
||||
? filterFolderWorkspacesFromOtherDevices(
|
||||
hostVisibleFolderWorkspaces,
|
||||
pairedDeviceIdsByEnvironment
|
||||
)
|
||||
: hostVisibleFolderWorkspaces
|
||||
const listed = new Set(workspaces.map((entry) => entry.worktree.id))
|
||||
const pendingCreations = [...domains.creations].flatMap(([creationId, creation]) =>
|
||||
creation.worktreeId !== undefined && listed.has(creation.worktreeId)
|
||||
? []
|
||||
: [{ creationId, repoId: creation.repoId }]
|
||||
)
|
||||
const hostOptions = buildFeedHostOptions(domains, repos, view)
|
||||
// The catalog's order, not the feed's repoOrder: only it places a paired server's projects.
|
||||
const repoOrder = getLogicalRepoOrderRankById(repos.map((repo) => repo.id))
|
||||
const plan = planSidebarRows({
|
||||
groupBy,
|
||||
worktrees: visibleWorktrees,
|
||||
repoMap,
|
||||
prGroupOf: (worktree) => prGroupOfReviewFact(domains.review.get(worktree.id)),
|
||||
repoOrder,
|
||||
workspaceStatuses: view.workspaceStatuses,
|
||||
projectOrderBy: view.projectOrderBy,
|
||||
lineageById,
|
||||
worktreeMap,
|
||||
nestLineage: true,
|
||||
projectGroups: filterProjectGroupsForVisibleHosts(
|
||||
projectGroups,
|
||||
visibleHostIdSet,
|
||||
defaultHostId
|
||||
),
|
||||
placeholderRepoIds: getEmptyProjectPlaceholderRepoIds({
|
||||
groupBy,
|
||||
repos: visibleRepos,
|
||||
worktreesByRepo,
|
||||
visibleWorktrees,
|
||||
filterRepoIds: view.filters.filterRepoIds
|
||||
}),
|
||||
importedWorktreesByRepo: new Map(),
|
||||
newExternalWorktreesInboxByRepo: new Map(),
|
||||
pendingCreations,
|
||||
projectGrouping: catalog.projectGrouping,
|
||||
folderWorkspaces: visibleFolderWorkspaces,
|
||||
hostLabelById: new Map(hostOptions.map((host) => [host.id, host.label])),
|
||||
defaultHostId,
|
||||
pinnedDisplayPolicy: view.pinnedDisplayPolicy,
|
||||
filterRepoIds: view.filters.filterRepoIds
|
||||
})
|
||||
const collapsedGroups = new Set(view.collapsedGroups)
|
||||
const hostScoped =
|
||||
view.workspaceHostScope !== ALL_EXECUTION_HOSTS_SCOPE || view.visibleWorkspaceHostIds !== null
|
||||
const rows = plan.render(hostScoped ? deferHostSectionCollapse(collapsedGroups) : collapsedGroups)
|
||||
const sectioned = addHostSectionRows({
|
||||
rows,
|
||||
hostOptions: orderHostSectionOptions(
|
||||
hostOptions,
|
||||
normalizeExecutionHostOrder(domains.desktop?.workspaceHostOrder)
|
||||
),
|
||||
workspaceHostScope: view.workspaceHostScope,
|
||||
visibleWorkspaceHostIds: view.visibleWorkspaceHostIds,
|
||||
defaultHostId,
|
||||
collapsedHostKeys: collapsedGroups,
|
||||
forceCollapseHosts: false,
|
||||
preferProjectGrouping: true
|
||||
})
|
||||
const sectionRows = hostScoped
|
||||
? scopeHostSectionCollapse({ rows: sectioned, collapsedGroups })
|
||||
: sectioned
|
||||
const active = {
|
||||
worktreeId: domains.desktop?.desktopActive.worktreeId ?? null,
|
||||
hostId: parseExecutionHostId(domains.desktop?.desktopActive.hostId)?.id ?? null
|
||||
}
|
||||
const activeRowKeys = new Set<string>()
|
||||
for (const row of sectionRows) {
|
||||
if (row.type === 'item' && isActiveSidebarWorkspace(row.worktree, active)) {
|
||||
activeRowKeys.add(row.rowKey)
|
||||
} else if (
|
||||
row.type === 'folder-workspace' &&
|
||||
isActiveSidebarWorkspace(folderWorkspaceToWorktree(row.folderWorkspace), active)
|
||||
) {
|
||||
activeRowKeys.add(row.key)
|
||||
}
|
||||
}
|
||||
return { visibleWorktrees, rows, sectionRows, activeRowKeys }
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
getSshTargetIdForExecutionHost,
|
||||
parseExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../execution-host'
|
||||
import type { FolderWorkspace } from '../folder-workspace-types'
|
||||
import { projectGroupIdFromRepoId } from '../folder-workspace-worktree'
|
||||
import { parseWorkspaceKey } from '../workspace-scope'
|
||||
import { getExecutionHostIdFromWorktreeHostIdentity } from '../worktree/host-qualified-identity'
|
||||
import type { WorktreeLineage } from '../worktree/lineage-types'
|
||||
import type { Worktree } from '../worktree/types'
|
||||
import type { PairedServerWorkspaceRow } from './paired-server-rows'
|
||||
import type { WorkspaceRow } from './workspace-rows'
|
||||
|
||||
/** What the filters read off a row that the host decided, not the reader (workspace-kinds.ts). */
|
||||
export type SidebarFeedRowFacts = Partial<
|
||||
Pick<
|
||||
WorkspaceRow,
|
||||
'defaultBranch' | 'automationCreated' | 'cliCreated' | 'detachedHead' | 'creatorProvenance'
|
||||
>
|
||||
>
|
||||
|
||||
/** One feed workspace as the shared builder reads it, keyed by its identity. */
|
||||
export type SidebarFeedWorkspace = {
|
||||
identity: string
|
||||
worktree: Worktree
|
||||
facts: SidebarFeedRowFacts
|
||||
/** Set on a folder row: the builder lists folder workspaces apart from worktrees. */
|
||||
folderWorkspace: FolderWorkspace | null
|
||||
lineage: WorkspaceRow['lineage']
|
||||
}
|
||||
|
||||
/** The fields both a host's own rows and a paired server's rows carry for the list. */
|
||||
type FeedRowFields = Pick<
|
||||
WorkspaceRow,
|
||||
'repoId' | 'kind' | 'path' | 'branch' | 'displayName' | 'isArchived' | 'isPinned'
|
||||
> &
|
||||
Partial<
|
||||
Pick<
|
||||
WorkspaceRow,
|
||||
| 'isMainWorktree'
|
||||
| 'isUnread'
|
||||
| 'instanceId'
|
||||
| 'lineage'
|
||||
| 'workspaceStatus'
|
||||
| 'sortOrder'
|
||||
| 'manualOrder'
|
||||
| 'createdAt'
|
||||
| 'recentAt'
|
||||
| 'smartTier'
|
||||
| 'smartAt'
|
||||
>
|
||||
> &
|
||||
SidebarFeedRowFacts
|
||||
|
||||
function readFeedRow(
|
||||
identity: string,
|
||||
id: string,
|
||||
ownerHostId: ExecutionHostId,
|
||||
row: FeedRowFields,
|
||||
extra: Partial<
|
||||
Pick<
|
||||
Worktree,
|
||||
| 'comment'
|
||||
| 'linkedIssue'
|
||||
| 'linkedPR'
|
||||
| 'linkedLinearIssue'
|
||||
| 'linkedGitLabMR'
|
||||
| 'linkedGitLabIssue'
|
||||
| 'linkedItems'
|
||||
>
|
||||
> = {}
|
||||
): SidebarFeedWorkspace {
|
||||
// The identity's host, not the owner: a local row whose metadata names no host keeps `|id`.
|
||||
const hostId = getExecutionHostIdFromWorktreeHostIdentity(identity)
|
||||
const owner = parseExecutionHostId(ownerHostId)
|
||||
const { defaultBranch, automationCreated, cliCreated, detachedHead, creatorProvenance } = row
|
||||
const worktree: Worktree = {
|
||||
id,
|
||||
...(hostId ? { hostId } : {}),
|
||||
...(owner?.kind === 'runtime' ? { runtimeOwnerEnvironmentId: owner.environmentId } : {}),
|
||||
...(row.instanceId !== undefined ? { instanceId: row.instanceId } : {}),
|
||||
repoId: row.repoId,
|
||||
path: row.path,
|
||||
branch: row.branch,
|
||||
head: '',
|
||||
isBare: false,
|
||||
displayName: row.displayName,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isMainWorktree: row.isMainWorktree ?? false,
|
||||
isArchived: row.isArchived,
|
||||
isPinned: row.isPinned,
|
||||
isUnread: row.isUnread ?? false,
|
||||
workspaceStatus: row.workspaceStatus,
|
||||
sortOrder: row.sortOrder ?? 0,
|
||||
...(row.manualOrder !== undefined ? { manualOrder: row.manualOrder } : {}),
|
||||
...(row.createdAt !== undefined ? { createdAt: row.createdAt } : {}),
|
||||
...(creatorProvenance ? { creatorProvenance } : {}),
|
||||
lastActivityAt: row.recentAt ?? 0,
|
||||
...(row.smartTier !== undefined ? { smartTier: row.smartTier, smartAt: row.smartAt } : {}),
|
||||
...extra
|
||||
}
|
||||
return {
|
||||
identity,
|
||||
worktree,
|
||||
facts: { defaultBranch, automationCreated, cliCreated, detachedHead, creatorProvenance },
|
||||
folderWorkspace: row.kind === 'folder' ? folderWorkspaceOf(worktree, ownerHostId) : null,
|
||||
lineage: row.lineage ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function folderWorkspaceOf(
|
||||
worktree: Worktree,
|
||||
ownerHostId: ExecutionHostId
|
||||
): FolderWorkspace | null {
|
||||
const id = parseWorkspaceKey(worktree.id)
|
||||
const projectGroupId = projectGroupIdFromRepoId(worktree.repoId)
|
||||
if (id?.type !== 'folder' || !projectGroupId) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id: id.folderWorkspaceId,
|
||||
projectGroupId,
|
||||
name: worktree.displayName,
|
||||
folderPath: worktree.path,
|
||||
executionHostId: ownerHostId,
|
||||
connectionId: getSshTargetIdForExecutionHost(ownerHostId),
|
||||
...(worktree.creatorProvenance ? { creatorProvenance: worktree.creatorProvenance } : {}),
|
||||
linkedTask: null,
|
||||
...(worktree.linkedItems ? { linkedItems: worktree.linkedItems } : {}),
|
||||
comment: worktree.comment,
|
||||
isArchived: worktree.isArchived,
|
||||
isUnread: worktree.isUnread,
|
||||
isPinned: worktree.isPinned,
|
||||
sortOrder: worktree.sortOrder,
|
||||
...(worktree.manualOrder !== undefined ? { manualOrder: worktree.manualOrder } : {}),
|
||||
workspaceStatus: worktree.workspaceStatus,
|
||||
lastActivityAt: worktree.lastActivityAt,
|
||||
...(worktree.smartTier !== undefined
|
||||
? { smartTier: worktree.smartTier, smartAt: worktree.smartAt }
|
||||
: {}),
|
||||
createdAt: worktree.createdAt ?? 0,
|
||||
updatedAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
/** A row this host lists in its own catalog (workspace-rows.ts), back in the builder's shape. */
|
||||
export function readFeedWorkspaceRow(
|
||||
identity: string,
|
||||
row: WorkspaceRow
|
||||
): SidebarFeedWorkspace | null {
|
||||
const owner = parseExecutionHostId(row.hostId)
|
||||
return owner
|
||||
? readFeedRow(identity, row.id, owner.id, row, {
|
||||
comment: row.comment,
|
||||
linkedIssue: row.linkedIssue,
|
||||
linkedPR: row.linkedPR,
|
||||
linkedLinearIssue: row.linkedLinearIssue,
|
||||
linkedGitLabMR: row.linkedGitLabMR,
|
||||
linkedGitLabIssue: row.linkedGitLabIssue,
|
||||
linkedItems: row.linkedItems
|
||||
})
|
||||
: null
|
||||
}
|
||||
|
||||
/** A paired server's row, as the server's own list reports it; older desktops send fewer fields. */
|
||||
export function readFeedPairedServerRow(
|
||||
row: PairedServerWorkspaceRow
|
||||
): SidebarFeedWorkspace | null {
|
||||
const owner = parseExecutionHostId(getExecutionHostIdFromWorktreeHostIdentity(row.key))
|
||||
return owner?.kind === 'runtime' ? readFeedRow(row.key, row.worktreeId, owner.id, row) : null
|
||||
}
|
||||
|
||||
/** The rows' stored lineage edges as the builder's lineage map, keyed by bare id as the window's. */
|
||||
export function readFeedLineage(
|
||||
workspaces: readonly SidebarFeedWorkspace[]
|
||||
): Record<string, WorktreeLineage> {
|
||||
const lineageById: Record<string, WorktreeLineage> = {}
|
||||
for (const { worktree, lineage } of workspaces) {
|
||||
if (lineage && !(worktree.id in lineageById)) {
|
||||
// The builder validates only the edge; provenance stays the host's.
|
||||
lineageById[worktree.id] = {
|
||||
worktreeId: worktree.id,
|
||||
...lineage,
|
||||
origin: 'manual',
|
||||
capture: { source: 'manual-action', confidence: 'explicit' },
|
||||
createdAt: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
return lineageById
|
||||
}
|
||||
@@ -79,6 +79,41 @@ export function compareWorktreeSortLabel(
|
||||
)
|
||||
}
|
||||
|
||||
function worktreeSortReads(
|
||||
repoMap: Map<string, Pick<Repo, 'displayName'>>,
|
||||
labels: WorktreeSortLabels | undefined
|
||||
) {
|
||||
return {
|
||||
label: (worktree: Worktree): string => labels?.get(worktree) ?? getWorktreeSortLabel(worktree),
|
||||
identity: getWorktreeHostIdentity,
|
||||
repoLabel: (worktree: Worktree) => repoMap.get(worktree.repoId)?.displayName ?? '',
|
||||
// Why fallback to sortOrder: existing users have a persisted smart-sort snapshot but no
|
||||
// manualOrder yet, so Manual starts from that order instead of alphabetizing.
|
||||
manualRank: (worktree: Worktree) => worktree.manualOrder ?? worktree.sortOrder
|
||||
}
|
||||
}
|
||||
|
||||
function buildStampComparator(
|
||||
sortBy: SortBy,
|
||||
repoMap: Map<string, Pick<Repo, 'displayName'>>,
|
||||
labels: WorktreeSortLabels | undefined
|
||||
): (a: Worktree, b: Worktree) => number {
|
||||
return buildWorkspaceRowComparator<Worktree>(sortBy, {
|
||||
...worktreeSortReads(repoMap, labels),
|
||||
recentActivity: recentStamp,
|
||||
compareSmart: compareSmartStamps
|
||||
})
|
||||
}
|
||||
|
||||
/** Sorts in place by the owning hosts' stamps, reading no clock: the order every stamp reader shares. */
|
||||
export function sortWorktreesByStamps<W extends Worktree>(
|
||||
worktrees: W[],
|
||||
sortBy: SortBy,
|
||||
repoMap: Map<string, Pick<Repo, 'displayName'>>
|
||||
): W[] {
|
||||
return worktrees.sort(buildStampComparator(sortBy, repoMap, buildWorktreeSortLabels(worktrees)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a comparator for sorting worktrees based on the current sort mode.
|
||||
*
|
||||
@@ -95,24 +130,12 @@ export function buildWorktreeComparator(
|
||||
attentionByWorktree: Map<string, WorktreeAttention>,
|
||||
labels?: WorktreeSortLabels
|
||||
): (a: Worktree, b: Worktree) => number {
|
||||
const read = {
|
||||
label: (worktree: Worktree): string => labels?.get(worktree) ?? getWorktreeSortLabel(worktree),
|
||||
identity: getWorktreeHostIdentity,
|
||||
repoLabel: (worktree: Worktree) => repoMap.get(worktree.repoId)?.displayName ?? '',
|
||||
// Why fallback to sortOrder: existing users have a persisted smart-sort snapshot but no
|
||||
// manualOrder yet, so Manual starts from that order instead of alphabetizing.
|
||||
manualRank: (worktree: Worktree) => worktree.manualOrder ?? worktree.sortOrder
|
||||
}
|
||||
// Switch point: Recent and Smart read the owning host's stamps, with no clock.
|
||||
if (ORCA_SIDEBAR_INPUTS_IS_MAIN) {
|
||||
return buildWorkspaceRowComparator<Worktree>(sortBy, {
|
||||
...read,
|
||||
recentActivity: recentStamp,
|
||||
compareSmart: compareSmartStamps
|
||||
})
|
||||
return buildStampComparator(sortBy, repoMap, labels)
|
||||
}
|
||||
return buildWorkspaceRowComparator<Worktree>(sortBy, {
|
||||
...read,
|
||||
...worktreeSortReads(repoMap, labels),
|
||||
recentActivity: (worktree) => effectiveRecentActivity(worktree, now),
|
||||
compareSmart: (a, b) => {
|
||||
const aw = attentionByWorktree.get(a.id) ?? IDLE
|
||||
@@ -213,7 +236,7 @@ export function rankWorktreesForSort<W extends Worktree>(args: {
|
||||
const labels = buildWorktreeSortLabels(worktrees)
|
||||
// Switch point: stamps need no cold phase, latch or attention map.
|
||||
if (ORCA_SIDEBAR_INPUTS_IS_MAIN) {
|
||||
worktrees.sort(buildWorktreeComparator(sortBy, args.repoMap, now, new Map(), labels))
|
||||
worktrees.sort(buildStampComparator(sortBy, args.repoMap, labels))
|
||||
return { sorted: worktrees, attentionByWorktree: null, detectedLiveSmartSignal: false }
|
||||
}
|
||||
let detectedLiveSmartSignal = false
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { Worktree } from '../worktree/types'
|
||||
import { getGitHubPRCacheKeyCandidates } from '../github/github-cache-key'
|
||||
import { isGitHubPRSuppressed } from '../worktree/github-pr-suppression'
|
||||
import { getPRGroupKeyForState, type PRGroupKey } from '../workspace-view/workspace-pr-groups'
|
||||
import type { PairedServerWorkspaceRow } from './paired-server-rows'
|
||||
import { parseExecutionHostId } from '../execution-host'
|
||||
|
||||
/** Grouping by PR reads GitHub PRs only, so only a row with a GitHub PR carries a fact. */
|
||||
const WorkspaceReviewFactSchema = z.object({
|
||||
@@ -74,3 +76,28 @@ export function buildWorkspaceReviewFacts(
|
||||
}
|
||||
return facts
|
||||
}
|
||||
|
||||
/** A paired server's rows against this desktop's own cache, under the server's host-scoped keys. */
|
||||
export function buildPairedServerReviewFacts(
|
||||
servers: readonly { hostId: string; rows: readonly PairedServerWorkspaceRow[] }[],
|
||||
prCache: PRCacheView
|
||||
): Record<string, WorkspaceReviewFact> {
|
||||
const facts: Record<string, WorkspaceReviewFact> = {}
|
||||
for (const server of servers) {
|
||||
const hostId = parseExecutionHostId(server.hostId)?.id
|
||||
for (const row of hostId ? server.rows : []) {
|
||||
const fact =
|
||||
row.kind === 'git'
|
||||
? readWorkspaceReviewFact(
|
||||
{ branch: row.branch, linkedPR: null },
|
||||
{ id: row.repoId, path: '', executionHostId: hostId },
|
||||
prCache
|
||||
)
|
||||
: null
|
||||
if (fact) {
|
||||
facts[row.worktreeId] = fact
|
||||
}
|
||||
}
|
||||
}
|
||||
return facts
|
||||
}
|
||||
|
||||
@@ -87,17 +87,21 @@ describe('buildWorkspaceRows', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('takes the host from the repo when the row has none, and leaves paired-server rows out', () => {
|
||||
it('keys a row whose metadata names no host as the window does, its owner from the repo', () => {
|
||||
const sshOnly = { ...sshRepo, id: 'repo-ssh' }
|
||||
const rows = buildWorkspaceRows({
|
||||
worktrees: [
|
||||
worktree(),
|
||||
// The window stamps a remote repo's host on every row it lists.
|
||||
worktree({ id: 'repo-ssh::/remote/b', repoId: 'repo-ssh' }),
|
||||
worktree({ id: 'repo-2::/x', repoId: 'repo-2', hostId: 'runtime:env-1' })
|
||||
],
|
||||
folderWorkspaces: [{ ...folder, executionHostId: 'runtime:env-1' }],
|
||||
repos: [localRepo],
|
||||
repos: [localRepo, sshOnly],
|
||||
lineageById: {}
|
||||
})
|
||||
expect([...rows.keys()]).toEqual(['local|repo-1::/r/a'])
|
||||
expect([...rows.keys()]).toEqual(['|repo-1::/r/a', 'ssh:box|repo-ssh::/remote/b'])
|
||||
expect(rows.get('|repo-1::/r/a')?.hostId).toBe('local')
|
||||
})
|
||||
|
||||
it('carries the stored lineage edge for the builder to validate', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
getRepoExecutionHostId,
|
||||
parseExecutionHostId,
|
||||
type ExecutionHostId
|
||||
@@ -20,7 +21,7 @@ import { SMART_TIERS } from './sort-stamps'
|
||||
* One workspace this host lists, as the sidebar reads it: catalog, metadata and the host's sort
|
||||
* stamps. Activity and PR state are their own domains, so plain terminal output never changes a row.
|
||||
*/
|
||||
const WorkspaceRowSchema = z.object({
|
||||
export const WorkspaceRowSchema = z.object({
|
||||
id: z.string(),
|
||||
hostId: z.string(),
|
||||
kind: z.enum(['git', 'folder']),
|
||||
@@ -44,6 +45,8 @@ const WorkspaceRowSchema = z.object({
|
||||
workspaceStatus: z.string(),
|
||||
comment: z.string(),
|
||||
manualOrder: z.number().optional(),
|
||||
/** The saved order Manual falls back to where a row has no manualOrder; older hosts omit it. */
|
||||
sortOrder: z.number().optional(),
|
||||
createdAt: z.number().optional(),
|
||||
creatorProvenance: z
|
||||
.union([
|
||||
@@ -71,7 +74,8 @@ const WorkspaceRowSchema = z.object({
|
||||
smartAt: z.number().optional()
|
||||
})
|
||||
|
||||
/** Keyed by host-qualified id, the form the frozen sort order and paired-server rows use. */
|
||||
/** Keyed by the row's identity as every client keys it: a local row whose metadata names no host
|
||||
* keeps `|id`, while `hostId` still says which host owns it. */
|
||||
export const WorkspaceRowsSnapshotSchema = z.object({
|
||||
revision: z.number(),
|
||||
rows: z.record(z.string(), WorkspaceRowSchema)
|
||||
@@ -111,6 +115,7 @@ function toWorkspaceRow(
|
||||
workspaceStatus: worktree.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID,
|
||||
comment: worktree.comment,
|
||||
...(worktree.manualOrder !== undefined ? { manualOrder: worktree.manualOrder } : {}),
|
||||
sortOrder: worktree.sortOrder,
|
||||
...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}),
|
||||
...(worktree.creatorProvenance ? { creatorProvenance: worktree.creatorProvenance } : {}),
|
||||
linkedIssue: worktree.linkedIssue,
|
||||
@@ -151,8 +156,11 @@ export function buildWorkspaceRows(catalog: {
|
||||
): void => {
|
||||
const hostId = worktree.hostId ?? (repo ? getRepoExecutionHostId(repo) : undefined)
|
||||
if (hostId && parseExecutionHostId(hostId)?.kind !== 'runtime') {
|
||||
// As the window stamps rows (worktree-host-ownership.ts): a remote repo's host always, a
|
||||
// local row only the host its metadata names.
|
||||
const identityHostId = hostId === LOCAL_EXECUTION_HOST_ID ? worktree.hostId : hostId
|
||||
rows.set(
|
||||
composeWorktreeHostIdentity(hostId, worktree.id),
|
||||
composeWorktreeHostIdentity(identityHostId, worktree.id),
|
||||
toWorkspaceRow(worktree, hostId, kind, repo, lineage)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user