mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(sidebar): the phone reads its workspace list from the sidebar inputs (dark) (#30462)
* feat(mobile): the phone page lists workspaces from the host's sidebar feed (dark) * fix(mobile): settle pending option changes by latest intent; feed carries the desktop's focused host and host names (dark) * fix(sidebar): the feed carries the desktop's pinned placement; e2e compares the phone list to the desktop sidebar (dark) * test(sidebar): the phone list e2e also expands and unpins * fix(sidebar): phone feed list review fixes (dark) A collapsed or worktree.ps-failing feed list no longer shows an empty or error state; a queued worktree read uses the latest client; a superseded option change does not report a refusal; the window shadow leaves the focused host to main's test (focus-read ratchet). * fix(sidebar): phone feed tests typecheck; e2e helper no longer pulls the native runtime types Mobile test fixtures fill the fields the test project's types require; the feed trigger type lives with the feed reader, so the e2e helper's imports stop dragging react-native globals into the e2e program; React Doctor's two blocking findings.
This commit is contained in:
@@ -34,6 +34,8 @@ const entryPoint = join(mobileDir, 'web-entry', 'index.tsx')
|
||||
const { MOBILE_WEB_BUNDLE_DIR: defaultOutDir } = createRequire(import.meta.url)(
|
||||
'./verify-packaged-mobile-web-bundle.cjs'
|
||||
)
|
||||
// The desktop serves this page, so a dark switch the desktop was built with reaches it too.
|
||||
const { turnedOnDarkBuildDefines } = createRequire(import.meta.url)('../dark-build-switches.cjs')
|
||||
|
||||
/**
|
||||
* Every shim the app bundle needs, each one a documented Metro/RN-Web gap. `appliesTo` reads the
|
||||
@@ -347,7 +349,9 @@ export function mobileWebAppBuildOptions(routes) {
|
||||
__DEV__: 'false',
|
||||
'process.env.NODE_ENV': '"production"',
|
||||
'process.env.EXPO_OS': '"web"',
|
||||
'process.env.EXPO_ROUTER_IMPORT_MODE': '"sync"'
|
||||
'process.env.EXPO_ROUTER_IMPORT_MODE': '"sync"',
|
||||
// Only a switch asked for: an off build stays byte-identical, and its switch reads off.
|
||||
...turnedOnDarkBuildDefines()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,12 @@ export const hostScreenSecondaryStyles = StyleSheet.create({
|
||||
color: colors.textMuted,
|
||||
marginTop: 2
|
||||
},
|
||||
optionPending: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
marginTop: spacing.xs
|
||||
},
|
||||
clearFiltersText: {
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary
|
||||
|
||||
@@ -135,6 +135,7 @@ async function mountScreen(first: RpcClient, hostCapabilities: string[]): Promis
|
||||
fetchRepoMetadata: async () => {},
|
||||
hostCapabilities,
|
||||
hostId: 'host-1',
|
||||
sidebarFeed: null,
|
||||
state,
|
||||
syncViewSettingsFromDesktop: async () => {}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
ALL_EXECUTION_HOSTS_SCOPE,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
getLocalExecutionHostLabel,
|
||||
normalizeExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../src/shared/execution-host'
|
||||
import type { DesktopSidebarFacts } from '../../../src/shared/sidebar-list/desktop-sidebar-facts'
|
||||
import type { ProjectGroup } from '../../../src/shared/project-group-types'
|
||||
import type { Repo } from '../../../src/shared/repo-types'
|
||||
import type {
|
||||
SidebarFeedCatalog,
|
||||
SidebarFeedView
|
||||
} from '../../../src/shared/sidebar-list/sidebar-feed-rows'
|
||||
import type { ProjectOrderBy } from '../../../src/shared/ui-chrome-types'
|
||||
import type { ServedProjectSections } from '../worktree/project-sections-operations'
|
||||
import type { MobileViewState } from '../worktree/workspace-view-settings'
|
||||
import type { SectionRepo } from '../worktree/workspace-list-types'
|
||||
|
||||
/**
|
||||
* The view the desktop's sidebar shows, from the shared UI state and settings this phone mirrors.
|
||||
* The host list is passed resolved with an "all" scope, which every builder step reads the same
|
||||
* as the desktop's legacy scope.
|
||||
*/
|
||||
export function buildHostFeedView(args: {
|
||||
view: MobileViewState
|
||||
projectOrderBy: ProjectOrderBy
|
||||
/** The feed's desktop facts: its focused host, its names for its hosts, pinned placement. */
|
||||
desktop: DesktopSidebarFacts | null
|
||||
/** The desktop's platform names its own host; the phone's must never. */
|
||||
hostPlatform: NodeJS.Platform | null
|
||||
}): SidebarFeedView {
|
||||
const { view, desktop, hostPlatform } = args
|
||||
const hostLabelOverrides = new Map<ExecutionHostId, string>(
|
||||
Object.entries(desktop?.hostLabels ?? {}).flatMap(([hostId, label]) => {
|
||||
const id = normalizeExecutionHostId(hostId)
|
||||
return id ? [[id, label] as const] : []
|
||||
})
|
||||
)
|
||||
if (hostPlatform && !hostLabelOverrides.has(LOCAL_EXECUTION_HOST_ID)) {
|
||||
hostLabelOverrides.set(LOCAL_EXECUTION_HOST_ID, getLocalExecutionHostLabel(hostPlatform))
|
||||
}
|
||||
return {
|
||||
groupBy: view.groupBy,
|
||||
sortBy: view.sortBy,
|
||||
projectOrderBy: args.projectOrderBy,
|
||||
collapsedGroups: new Set(view.collapsedGroups),
|
||||
filters: view.filters,
|
||||
workspaceHostScope: ALL_EXECUTION_HOSTS_SCOPE,
|
||||
visibleWorkspaceHostIds: view.filters.visibleHostIds,
|
||||
workspaceStatuses: view.workspaceStatuses,
|
||||
pinnedDisplayPolicy: desktop?.showPinnedInGroups ? 'duplicate-in-groups' : 'single-location',
|
||||
defaultHostId: normalizeExecutionHostId(desktop?.focusedHostId) ?? LOCAL_EXECUTION_HOST_ID,
|
||||
hostLabelOverrides
|
||||
}
|
||||
}
|
||||
|
||||
function catalogRepo(repo: SectionRepo): Repo {
|
||||
return {
|
||||
id: repo.id,
|
||||
path: repo.path,
|
||||
displayName: repo.displayName,
|
||||
badgeColor: repo.badgeColor ?? '',
|
||||
addedAt: repo.addedAt ?? 0,
|
||||
...(repo.connectionId !== undefined ? { connectionId: repo.connectionId } : {}),
|
||||
...(repo.executionHostId ? { executionHostId: repo.executionHostId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog the feed does not carry: the one the desktop window publishes (every host, paired
|
||||
* servers included), or on a host with no window its own repos and project groups.
|
||||
*/
|
||||
export function buildHostFeedCatalog(
|
||||
served: ServedProjectSections | null,
|
||||
hostRepos: readonly SectionRepo[],
|
||||
hostProjectGroups: readonly ProjectGroup[]
|
||||
): SidebarFeedCatalog {
|
||||
return served
|
||||
? {
|
||||
repos: served.repos.map(catalogRepo),
|
||||
projectGroups: served.projectGroups ?? hostProjectGroups,
|
||||
projectGrouping: { projects: served.projects, projectHostSetups: served.setups }
|
||||
}
|
||||
: {
|
||||
repos: hostRepos.map(catalogRepo),
|
||||
projectGroups: hostProjectGroups,
|
||||
projectGrouping: { projects: [], projectHostSetups: [] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { z } from 'zod'
|
||||
import { SidebarInputsReadResultSchema } from '../../../src/shared/sidebar-list/sidebar-inputs-feed-contract'
|
||||
import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation'
|
||||
import { rpcResultVariant } from '../transport/rpc-operation-result-reader'
|
||||
import { isMobileMethodUnavailableError } from '../transport/mobile-method-unavailable'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
|
||||
// The host's sidebar feed (src/main/runtime/sidebar-inputs/), read only by a page built with
|
||||
// ORCA_SIDEBAR_INPUTS=main. A host without it answers method_not_found, or forbidden from an
|
||||
// older mobile allowlist; either means this page is newer than the host.
|
||||
|
||||
export const sidebarInputsRead = bindDeferredRpcOperation(
|
||||
defineRpcOperation({
|
||||
name: 'sidebarInputs.read-or-skip',
|
||||
method: 'sidebarInputs.read',
|
||||
acceptance: 'success-result-or-skip',
|
||||
barrier: 'after-caller-barrier',
|
||||
read: rpcResultVariant('sidebar-inputs', SidebarInputsReadResultSchema)
|
||||
})
|
||||
)
|
||||
|
||||
export const sidebarRepoIconsRead = bindDeferredRpcOperation(
|
||||
defineRpcOperation({
|
||||
name: 'sidebarInputs.repoIcons-or-skip',
|
||||
method: 'sidebarInputs.repoIcons',
|
||||
acceptance: 'success-result-or-skip',
|
||||
barrier: 'after-caller-barrier',
|
||||
read: rpcResultVariant(
|
||||
'repo-icons',
|
||||
z.object({ icons: z.array(z.object({ id: z.string(), dataUrl: z.string().nullable() })) })
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
export const setCollapsedWrite = bindDeferredRpcOperation(
|
||||
defineRpcOperation({
|
||||
name: 'ui.setCollapsed-or-skip',
|
||||
method: 'ui.setCollapsed',
|
||||
acceptance: 'success-result-or-skip',
|
||||
barrier: 'after-caller-barrier',
|
||||
read: rpcResultVariant(
|
||||
'collapse-written',
|
||||
z.object({ collapsed: z.boolean(), applied: z.boolean() })
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
/** A reply either answers, says the host lacks the method, or failed (retry on the next trigger). */
|
||||
export type HostMethodReply<Value> =
|
||||
| { kind: 'answered'; value: Value }
|
||||
| { kind: 'unavailable' }
|
||||
| { kind: 'failed' }
|
||||
|
||||
export function readHostMethodReply<Value>(
|
||||
reply: RpcResponse,
|
||||
interpret: (reply: RpcResponse) => { accepted: true; value: Value } | { accepted: false }
|
||||
): HostMethodReply<Value> {
|
||||
if (!reply.ok) {
|
||||
return isMobileMethodUnavailableError(reply.error.code, reply.error.message)
|
||||
? { kind: 'unavailable' }
|
||||
: { kind: 'failed' }
|
||||
}
|
||||
try {
|
||||
const verdict = interpret(reply)
|
||||
return verdict.accepted ? { kind: 'answered', value: verdict.value } : { kind: 'failed' }
|
||||
} catch {
|
||||
return { kind: 'failed' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SidebarInputsFeed } from '../../../src/main/runtime/sidebar-inputs/sidebar-inputs-feed'
|
||||
import { testDomain } from '../../../src/main/runtime/sidebar-inputs/sidebar-inputs-feed.test-fixture'
|
||||
import type { SidebarInputsChangedEvent } from '../../../src/shared/sidebar-list/sidebar-inputs-feed-contract'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import type { UnvalidatedRpcRequestPort } from '../transport/unvalidated-rpc-request-port'
|
||||
import { HostSidebarFeedReader, type HostSidebarFeedRead } from './host-sidebar-feed-reader'
|
||||
import { readHostSidebarFeed } from './use-host-sidebar-feed'
|
||||
|
||||
function ok(result: unknown): RpcResponse {
|
||||
return { id: 'r', ok: true, result, _meta: { runtimeId: 'runtime' } }
|
||||
}
|
||||
|
||||
function refused(code: string, message = 'nope'): RpcResponse {
|
||||
return { id: 'r', ok: false, error: { code, message }, _meta: { runtimeId: 'runtime' } }
|
||||
}
|
||||
|
||||
/** The host's real feed behind a phone client that answers every read from it. */
|
||||
function hostWithFeed() {
|
||||
const notices: SidebarInputsChangedEvent[] = []
|
||||
const workspaces = testDomain('workspaces', { a: { id: 'a' } })
|
||||
const feed = new SidebarInputsFeed(
|
||||
() => [workspaces.domain],
|
||||
(event) => notices.push(event)
|
||||
)
|
||||
feed.acquire('phone')
|
||||
const sendRequest = vi.fn(async (_method: string, params: unknown) =>
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reader sends only sidebarInputs.read params.
|
||||
ok(await feed.read(params as Parameters<typeof feed.read>[0]))
|
||||
)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reader calls only sendRequest.
|
||||
const client = { sendRequest } as unknown as UnvalidatedRpcRequestPort
|
||||
const reads: HostSidebarFeedRead[] = []
|
||||
const reader = new HostSidebarFeedReader(client, (read) => reads.push(read))
|
||||
return { feed, workspaces, notices, sendRequest, reader, reads }
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
for (let tick = 0; tick < 20; tick += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
}
|
||||
|
||||
describe('HostSidebarFeedReader', () => {
|
||||
it('installs the host’s snapshot, then follows each notice to the newer revision', async () => {
|
||||
const host = hostWithFeed()
|
||||
host.reader.refresh()
|
||||
await settle()
|
||||
const first = host.reads.at(-1)!.installed!
|
||||
expect(first.domains.get('workspaces')?.get('a')).toEqual({ id: 'a' })
|
||||
|
||||
host.workspaces.set('b', { id: 'b' })
|
||||
await vi.waitFor(() => expect(host.notices.at(-1)?.revision).toBeGreaterThan(first.revision))
|
||||
const notice = host.notices.at(-1)!
|
||||
host.reader.changed(notice)
|
||||
await settle()
|
||||
const next = host.reads.at(-1)!.installed!
|
||||
expect(next.revision).toBeGreaterThan(first.revision)
|
||||
expect(next.domains.get('workspaces')?.get('b')).toEqual({ id: 'b' })
|
||||
|
||||
// A duplicate or late notice is already covered: no read goes out.
|
||||
const sent = host.sendRequest.mock.calls.length
|
||||
host.reader.changed(notice)
|
||||
await settle()
|
||||
expect(host.sendRequest.mock.calls.length).toBe(sent)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['method_not_found', 'unknown method'],
|
||||
['forbidden', "Method 'sidebarInputs.read' is not available to mobile clients"]
|
||||
])('reads a host without the feed (%s) as unavailable', async (code, message) => {
|
||||
const reads: HostSidebarFeedRead[] = []
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reader calls only sendRequest.
|
||||
const client = {
|
||||
sendRequest: async () => refused(code, message)
|
||||
} as unknown as UnvalidatedRpcRequestPort
|
||||
new HostSidebarFeedReader(client, (read) => reads.push(read)).refresh()
|
||||
await settle()
|
||||
expect(reads.at(-1)).toEqual({ installed: null, unavailable: true })
|
||||
expect(readHostSidebarFeed(reads.at(-1)!)).toEqual({ kind: 'reopen' })
|
||||
})
|
||||
|
||||
it('keeps what it installed through a dropped read, and reads again on the next refresh', async () => {
|
||||
const host = hostWithFeed()
|
||||
host.reader.refresh()
|
||||
await settle()
|
||||
host.sendRequest.mockRejectedValueOnce(new Error('client_disconnected'))
|
||||
host.reader.refresh()
|
||||
await settle()
|
||||
expect(host.reads.at(-1)!.installed).not.toBeNull()
|
||||
host.workspaces.set('c', { id: 'c' })
|
||||
host.reader.refresh()
|
||||
await settle()
|
||||
expect(host.reads.at(-1)!.installed!.domains.get('workspaces')?.get('c')).toEqual({ id: 'c' })
|
||||
})
|
||||
|
||||
it('drops a reply that lands after the screen let go of its host', async () => {
|
||||
const host = hostWithFeed()
|
||||
host.reader.refresh()
|
||||
host.reader.dispose()
|
||||
await settle()
|
||||
expect(host.reads).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('readHostSidebarFeed', () => {
|
||||
const installed = {
|
||||
generation: 'g',
|
||||
revision: 3,
|
||||
schemaVersion: 1,
|
||||
semanticsVersion: 1,
|
||||
domains: new Map()
|
||||
}
|
||||
|
||||
it('is loading until a batch installs, then ready at that revision', () => {
|
||||
expect(readHostSidebarFeed({ installed: null, unavailable: false })).toEqual({
|
||||
kind: 'loading'
|
||||
})
|
||||
expect(readHostSidebarFeed({ installed, unavailable: false })).toMatchObject({ kind: 'ready' })
|
||||
})
|
||||
|
||||
it('asks to reopen when the host means other things by its inputs', () => {
|
||||
expect(
|
||||
readHostSidebarFeed({ installed: { ...installed, semanticsVersion: 2 }, unavailable: false })
|
||||
).toEqual({ kind: 'reopen' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
INITIAL_SIDEBAR_INPUTS_FEED_STATE,
|
||||
refreshSidebarInputs,
|
||||
sidebarInputsChanged,
|
||||
sidebarInputsReadFailed,
|
||||
sidebarInputsReplied,
|
||||
type InstalledSidebarInputs,
|
||||
type SidebarInputsFeedRequest,
|
||||
type SidebarInputsFeedStep
|
||||
} from '../../../src/shared/sidebar-list/sidebar-inputs-feed-installer'
|
||||
import type { SidebarInputsChangedEvent } from '../../../src/shared/sidebar-list/sidebar-inputs-feed-contract'
|
||||
import type { UnvalidatedRpcRequestPort } from '../transport/unvalidated-rpc-request-port'
|
||||
import { readHostMethodReply, sidebarInputsRead } from './host-sidebar-feed-operations'
|
||||
|
||||
/** What the worktree refresh drives the feed with. */
|
||||
export type HostSidebarFeedTriggers = {
|
||||
refresh: () => void
|
||||
changed: (notice: Pick<SidebarInputsChangedEvent, 'generation' | 'revision'>) => void
|
||||
/** Each new install; returns the unsubscribe. */
|
||||
onInstalled: (listener: () => void) => () => void
|
||||
}
|
||||
|
||||
export type HostSidebarFeedRead = {
|
||||
installed: InstalledSidebarInputs | null
|
||||
/** The host has no feed (method_not_found, or forbidden by an older mobile allowlist). */
|
||||
unavailable: boolean
|
||||
}
|
||||
|
||||
/** One host connection's sidebar feed, driven through the shared installer. */
|
||||
export class HostSidebarFeedReader {
|
||||
private state = INITIAL_SIDEBAR_INPUTS_FEED_STATE
|
||||
private unavailable = false
|
||||
private disposed = false
|
||||
|
||||
constructor(
|
||||
private readonly client: UnvalidatedRpcRequestPort,
|
||||
private readonly onRead: (read: HostSidebarFeedRead) => void
|
||||
) {}
|
||||
|
||||
/** On every stream ready and return to the foreground; supersedes a read in flight. */
|
||||
refresh(): void {
|
||||
this.apply(refreshSidebarInputs(this.state))
|
||||
}
|
||||
|
||||
changed(notice: Pick<SidebarInputsChangedEvent, 'generation' | 'revision'>): void {
|
||||
this.apply(sidebarInputsChanged(this.state, notice))
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true
|
||||
}
|
||||
|
||||
private apply(step: SidebarInputsFeedStep): void {
|
||||
const before = this.state.installed
|
||||
this.state = step.state
|
||||
if (step.state.installed !== before) {
|
||||
this.onRead({ installed: step.state.installed, unavailable: this.unavailable })
|
||||
}
|
||||
if (step.request) {
|
||||
this.send(step.request)
|
||||
}
|
||||
}
|
||||
|
||||
private send(request: SidebarInputsFeedRequest): void {
|
||||
const failed = (): void => {
|
||||
this.state = sidebarInputsReadFailed(this.state, request.id)
|
||||
}
|
||||
sidebarInputsRead.request(this.client, request.params).then(
|
||||
(reply) => {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
const read = readHostMethodReply(reply, sidebarInputsRead.interpret)
|
||||
if (read.kind === 'answered') {
|
||||
this.apply(sidebarInputsReplied(this.state, request.id, read.value))
|
||||
return
|
||||
}
|
||||
failed()
|
||||
if (read.kind === 'unavailable' && !this.unavailable) {
|
||||
this.unavailable = true
|
||||
this.onRead({ installed: this.state.installed, unavailable: true })
|
||||
}
|
||||
},
|
||||
// A dropped connection: the next ready reads again.
|
||||
failed
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildSidebarFeedRows,
|
||||
type SidebarFeedView
|
||||
} from '../../../src/shared/sidebar-list/sidebar-feed-rows'
|
||||
import type { ExecutionHostScope } from '../../../src/shared/execution-host'
|
||||
import { resolveVisibleWorkspaceHostIds } from '../../../src/shared/workspace-view/workspace-host-visibility'
|
||||
import { buildHostFeedCatalog, buildHostFeedView } from './host-sidebar-feed-inputs'
|
||||
import { buildHostFeedSections } from './host-sidebar-feed-sections'
|
||||
import { SERVED_SECTIONS, feedDomains, liveRow, phoneView } from './host-sidebar-feed.test-fixture'
|
||||
import type { MobileViewState } from '../worktree/workspace-view-settings'
|
||||
|
||||
const catalog = buildHostFeedCatalog(SERVED_SECTIONS, [], [])
|
||||
|
||||
function phoneFeedView(view: MobileViewState, showPinnedInGroups = false): SidebarFeedView {
|
||||
return buildHostFeedView({
|
||||
view,
|
||||
projectOrderBy: 'manual',
|
||||
desktop: { ...feedDomains().desktop!, showPinnedInGroups },
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
}
|
||||
|
||||
function sectionsFor(
|
||||
view: MobileViewState,
|
||||
options: {
|
||||
showPinnedInGroups?: boolean
|
||||
live?: ReturnType<typeof liveRow>[]
|
||||
search?: string
|
||||
} = {}
|
||||
) {
|
||||
const built = buildSidebarFeedRows(
|
||||
feedDomains(),
|
||||
catalog,
|
||||
phoneFeedView(view, options.showPinnedInGroups)
|
||||
)
|
||||
return buildHostFeedSections({
|
||||
rows: built.sectionRows,
|
||||
activeRowKeys: built.activeRowKeys,
|
||||
liveRows: options.live ?? [],
|
||||
repoHostIdByRepoId: new Map([['lab', 'ssh:gpu']]),
|
||||
search: options.search ?? ''
|
||||
})
|
||||
}
|
||||
|
||||
function outline(sections: ReturnType<typeof sectionsFor>): string[] {
|
||||
return sections.flatMap((section) => [
|
||||
`${section.key} "${section.title}" ${String(section.count)}${section.icon ? ' pin' : ''}`,
|
||||
...section.data.map(
|
||||
(row) =>
|
||||
` ${row.sectionListKey} depth=${String(row.lineageDepth)}${row.isActive ? ' ACTIVE' : ''}`
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
describe('the phone list from the sidebar feed', () => {
|
||||
it('draws the shared builder’s headers, order, nesting and single highlight', () => {
|
||||
const lines = outline(sectionsFor(phoneView()))
|
||||
// The pinned parent carries its nested child.
|
||||
expect(lines[0]).toMatch(/ "Pinned" 2 pin$/)
|
||||
expect(lines.filter((line) => line.endsWith(' ACTIVE'))).toHaveLength(1)
|
||||
expect(lines.some((line) => line.includes('orca::/w/child depth=1'))).toBe(true)
|
||||
expect(lines.some((line) => line.includes('"lab"'))).toBe(true)
|
||||
})
|
||||
|
||||
it('highlights every occurrence when pinned rows also show in their groups', () => {
|
||||
const lines = outline(sectionsFor(phoneView(), { showPinnedInGroups: true }))
|
||||
expect(lines.filter((line) => line.endsWith(' ACTIVE'))).toHaveLength(2)
|
||||
})
|
||||
|
||||
// The phone sends its host list resolved with an "all" scope; the desktop keeps its legacy scope.
|
||||
const scopes: [ExecutionHostScope, string[] | null][] = [
|
||||
['ssh:gpu', null],
|
||||
['all', ['local', 'ssh:gpu']],
|
||||
['all', ['ssh:gpu']],
|
||||
['local', ['local', 'ssh:gpu']]
|
||||
]
|
||||
it.each(scopes)('builds the desktop’s rows for scope %s and hosts %j', (scope, hostIds) => {
|
||||
const visibleHostIds = resolveVisibleWorkspaceHostIds(hostIds, scope)
|
||||
const view = phoneView({ filters: { ...phoneView().filters, visibleHostIds } })
|
||||
const desktopView: SidebarFeedView = {
|
||||
...phoneFeedView(view),
|
||||
workspaceHostScope: scope,
|
||||
visibleWorkspaceHostIds: hostIds && hostIds.length > 0 ? visibleHostIds : null
|
||||
}
|
||||
for (const groupBy of ['repo', 'none', 'workspace-status', 'pr-status'] as const) {
|
||||
const phone = buildSidebarFeedRows(
|
||||
feedDomains(),
|
||||
catalog,
|
||||
phoneFeedView({ ...view, groupBy })
|
||||
)
|
||||
const desktop = buildSidebarFeedRows(feedDomains(), catalog, { ...desktopView, groupBy })
|
||||
expect(JSON.stringify(phone.sectionRows)).toBe(JSON.stringify(desktop.sectionRows))
|
||||
}
|
||||
})
|
||||
|
||||
it('names the desktop’s own host by its platform, not the phone’s', () => {
|
||||
const view = phoneView({
|
||||
filters: { ...phoneView().filters, visibleHostIds: ['local', 'ssh:gpu'] }
|
||||
})
|
||||
const built = buildSidebarFeedRows(feedDomains(), catalog, phoneFeedView(view))
|
||||
const sections = buildHostFeedSections({
|
||||
rows: built.sectionRows,
|
||||
activeRowKeys: built.activeRowKeys,
|
||||
liveRows: [],
|
||||
repoHostIdByRepoId: new Map(),
|
||||
search: ''
|
||||
})
|
||||
expect(sections.map((section) => section.title)).toEqual(
|
||||
expect.arrayContaining(['Local Mac', 'GPU box'])
|
||||
)
|
||||
})
|
||||
|
||||
it('draws each row with the live content worktree.ps served, matched by host', () => {
|
||||
const sections = sectionsFor(phoneView(), {
|
||||
live: [
|
||||
liveRow({ worktreeId: 'orca::/w/child', liveTerminalCount: 2, status: 'working' }),
|
||||
liveRow({ worktreeId: 'lab::/w/gpu', hostId: 'ssh:gpu', repoId: 'lab', unread: true })
|
||||
]
|
||||
})
|
||||
const rows = sections.flatMap((section) => section.data)
|
||||
expect(rows.find((row) => row.worktreeId === 'orca::/w/child')).toMatchObject({
|
||||
liveTerminalCount: 2,
|
||||
status: 'working',
|
||||
lineageDepth: 1
|
||||
})
|
||||
expect(rows.find((row) => row.worktreeId === 'lab::/w/gpu')).toMatchObject({ unread: true })
|
||||
// Not listed by worktree.ps yet: drawn from the feed alone.
|
||||
expect(rows.find((row) => row.worktreeId === 'orca::/w/parent')).toMatchObject({
|
||||
displayName: 'orca::/w/parent',
|
||||
liveTerminalCount: 0,
|
||||
isPinned: true
|
||||
})
|
||||
})
|
||||
|
||||
it('never draws one host’s live row under another host’s workspace of the same id', () => {
|
||||
const sections = sectionsFor(phoneView(), {
|
||||
live: [liveRow({ worktreeId: 'lab::/w/gpu', hostId: 'local', liveTerminalCount: 5 })]
|
||||
})
|
||||
const remote = sections
|
||||
.flatMap((section) => section.data)
|
||||
.find((row) => row.worktreeId === 'lab::/w/gpu')
|
||||
expect(remote).toMatchObject({ hostId: 'ssh:gpu', liveTerminalCount: 0 })
|
||||
})
|
||||
|
||||
it('keeps a collapsed section’s header and count with no rows under it', () => {
|
||||
const expanded = sectionsFor(phoneView())
|
||||
const lab = expanded.find((section) => section.title === 'lab')!
|
||||
const collapsed = sectionsFor(phoneView({ collapsedGroups: [lab.key] }))
|
||||
expect(collapsed.find((section) => section.key === lab.key)).toMatchObject({
|
||||
count: 1,
|
||||
data: []
|
||||
})
|
||||
})
|
||||
|
||||
it('narrows rows by search and drops sections left with none', () => {
|
||||
const sections = sectionsFor(phoneView(), { search: 'gpu' })
|
||||
expect(sections.flatMap((section) => section.data.map((row) => row.worktreeId))).toEqual([
|
||||
'lab::/w/gpu'
|
||||
])
|
||||
expect(sections.map((section) => section.title)).toEqual(['lab'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
getWorktreeExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../src/shared/execution-host'
|
||||
import { folderWorkspaceToWorktree } from '../../../src/shared/folder-workspace-worktree'
|
||||
import { getFolderWorkspaceHostId } from '../../../src/shared/sidebar-list/folder-workspace-host-id'
|
||||
import type { HostSectionRow } from '../../../src/shared/sidebar-list/host-section-rows'
|
||||
import type { GroupHeaderRow } from '../../../src/shared/sidebar-list/row-types'
|
||||
import { PR_GROUP_LABELS } from '../../../src/shared/workspace-view/workspace-pr-groups'
|
||||
import type { Worktree as HostWorktree } from '../../../src/shared/worktree/types'
|
||||
import { composeWorktreeHostIdentity } from '../../../src/shared/worktree/host-qualified-identity'
|
||||
import type { Section, Worktree } from '../worktree/workspace-list-types'
|
||||
import { resolveWorktreeHostId } from '../worktree/worktree-host-context-labels'
|
||||
|
||||
/** What the host screen draws from a feed-built list, besides its rows' own live content. */
|
||||
export type HostFeedListSources = {
|
||||
rows: readonly HostSectionRow[]
|
||||
activeRowKeys: ReadonlySet<string>
|
||||
/** The rows worktree.ps and the desktop's server lists served, for what each row draws. */
|
||||
liveRows: readonly Worktree[]
|
||||
repoHostIdByRepoId: ReadonlyMap<string, ExecutionHostId>
|
||||
search: string
|
||||
}
|
||||
|
||||
/** The builder names data headers; Pinned, All and the PR lanes each client names itself. */
|
||||
function headerTitle(row: GroupHeaderRow): string {
|
||||
switch (row.icon.kind) {
|
||||
case 'pinned':
|
||||
return row.label ?? 'Pinned'
|
||||
case 'all':
|
||||
return row.label ?? 'All'
|
||||
case 'pr-status':
|
||||
return row.label ?? PR_GROUP_LABELS[row.icon.prGroup]
|
||||
case 'project':
|
||||
case 'workspace-status':
|
||||
return row.label ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
function indexLiveRows(
|
||||
rows: readonly Worktree[],
|
||||
repoHostIdByRepoId: ReadonlyMap<string, ExecutionHostId>
|
||||
): (id: string, hostId: ExecutionHostId) => Worktree | undefined {
|
||||
const byIdentity = new Map<string, Worktree>()
|
||||
for (const row of rows) {
|
||||
const identity = composeWorktreeHostIdentity(
|
||||
resolveWorktreeHostId(row, repoHostIdByRepoId),
|
||||
row.worktreeId
|
||||
)
|
||||
if (!byIdentity.has(identity)) {
|
||||
byIdentity.set(identity, row)
|
||||
}
|
||||
}
|
||||
// The same id can live on two hosts; only the row's own host's copy draws it.
|
||||
return (id, hostId) => byIdentity.get(composeWorktreeHostIdentity(hostId, id))
|
||||
}
|
||||
|
||||
/** A row worktree.ps has not listed yet: drawn from the feed alone, with no live content. */
|
||||
function feedOnlyRow(
|
||||
worktree: HostWorktree,
|
||||
hostId: ExecutionHostId,
|
||||
repoName: string,
|
||||
workspaceKind: Worktree['workspaceKind']
|
||||
): Worktree {
|
||||
return {
|
||||
worktreeId: worktree.id,
|
||||
repoId: worktree.repoId,
|
||||
hostId,
|
||||
workspaceKind,
|
||||
repo: repoName,
|
||||
branch: worktree.branch,
|
||||
displayName: worktree.displayName,
|
||||
path: worktree.path,
|
||||
isArchived: worktree.isArchived,
|
||||
isMainWorktree: worktree.isMainWorktree,
|
||||
liveTerminalCount: 0,
|
||||
hasAttachedPty: false,
|
||||
unread: worktree.isUnread,
|
||||
isPinned: worktree.isPinned,
|
||||
linkedPR: null
|
||||
}
|
||||
}
|
||||
|
||||
function matchesSearch(row: Worktree, query: string): boolean {
|
||||
return (
|
||||
row.displayName.toLowerCase().includes(query) ||
|
||||
row.branch.toLowerCase().includes(query) ||
|
||||
row.repo.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The phone's sections from the list the shared builder made from the feed: the desktop's headers,
|
||||
* order, nesting and highlight, each row drawn with the live content the phone already reads.
|
||||
* Search narrows the rows here, as the desktop's own search never reaches the feed.
|
||||
*/
|
||||
export function buildHostFeedSections(sources: HostFeedListSources): Section[] {
|
||||
const liveRowOf = indexLiveRows(sources.liveRows, sources.repoHostIdByRepoId)
|
||||
const query = sources.search.trim().toLowerCase()
|
||||
const sections: Section[] = []
|
||||
const rowOf = (
|
||||
worktree: HostWorktree,
|
||||
hostId: ExecutionHostId,
|
||||
repoName: string,
|
||||
workspaceKind: Worktree['workspaceKind']
|
||||
): Worktree =>
|
||||
liveRowOf(worktree.id, hostId) ?? feedOnlyRow(worktree, hostId, repoName, workspaceKind)
|
||||
const push = (section: Section) => sections.push(section)
|
||||
const current = (): Section => {
|
||||
const last = sections.at(-1)
|
||||
if (last) {
|
||||
return last
|
||||
}
|
||||
const first: Section = { key: 'all', title: '', data: [] }
|
||||
push(first)
|
||||
return first
|
||||
}
|
||||
|
||||
for (const row of sources.rows) {
|
||||
switch (row.type) {
|
||||
case 'host-header':
|
||||
push({ key: row.key, title: row.label, count: row.count, data: [] })
|
||||
break
|
||||
case 'header':
|
||||
push({
|
||||
key: row.collapseKey ?? row.key,
|
||||
title: headerTitle(row),
|
||||
count: row.count,
|
||||
...(row.icon.kind === 'pinned' ? { icon: 'pin' as const } : {}),
|
||||
...(row.repo ? { repo: row.repo } : {}),
|
||||
data: []
|
||||
})
|
||||
break
|
||||
case 'item': {
|
||||
// Local as the fallback, as the phone resolves its own rows: the desktop's focus is not an owner.
|
||||
const hostId = getWorktreeExecutionHostId(row.worktree, row.repo, LOCAL_EXECUTION_HOST_ID)
|
||||
const live = rowOf(row.worktree, hostId, row.repo?.displayName ?? '', 'git')
|
||||
current().data.push({
|
||||
...live,
|
||||
isPinned: row.worktree.isPinned,
|
||||
isActive: sources.activeRowKeys.has(row.rowKey),
|
||||
sectionListKey: row.rowKey,
|
||||
lineageDepth: row.depth,
|
||||
lineageChildCount: row.lineageChildCount,
|
||||
lineageCollapsed: row.lineageCollapsed === true,
|
||||
isLastLineageChild: row.isLastLineageChild,
|
||||
...(row.lineageGroupKey ? { lineageGroupKey: row.lineageGroupKey } : {}),
|
||||
showProjectName: row.showProjectName === true,
|
||||
// The desktop's mixed-host rule names the host; a health word alone still shows.
|
||||
hostContextLabel:
|
||||
row.hostContextLabel ??
|
||||
(live.hostContextHealthLabel ? live.hostContextLabel : undefined)
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'folder-workspace': {
|
||||
const worktree = folderWorkspaceToWorktree(row.folderWorkspace)
|
||||
const hostId = getFolderWorkspaceHostId(
|
||||
row.folderWorkspace,
|
||||
row.projectGroup,
|
||||
LOCAL_EXECUTION_HOST_ID
|
||||
)
|
||||
current().data.push({
|
||||
...rowOf(worktree, hostId, row.projectGroup.name, 'folder-workspace'),
|
||||
isPinned: row.folderWorkspace.isPinned,
|
||||
isActive: sources.activeRowKeys.has(row.key),
|
||||
sectionListKey: row.key,
|
||||
lineageDepth: 0
|
||||
})
|
||||
break
|
||||
}
|
||||
// A creation in progress and the desktop-only cards draw nothing on the phone.
|
||||
case 'pending-creation':
|
||||
case 'imported-worktrees-card':
|
||||
case 'new-external-worktrees-inbox':
|
||||
break
|
||||
}
|
||||
}
|
||||
return query ? narrowSections(sections, query) : sections
|
||||
}
|
||||
|
||||
/** A section whose title matches keeps every row; otherwise only matching rows, counted as shown. */
|
||||
function narrowSections(sections: readonly Section[], query: string): Section[] {
|
||||
return sections.flatMap((section) => {
|
||||
if (section.title.toLowerCase().includes(query)) {
|
||||
return [section]
|
||||
}
|
||||
const data = section.data.filter((row) => matchesSearch(row, query))
|
||||
return data.length > 0 ? [{ ...section, data, count: data.length }] : []
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { SidebarFeedDomains } from '../../../src/shared/sidebar-list/sidebar-feed-domains'
|
||||
import type { WorkspaceRow } from '../../../src/shared/sidebar-list/workspace-rows'
|
||||
import type { ServedProjectSections } from '../worktree/project-sections-operations'
|
||||
import { INITIAL_MOBILE_VIEW, type MobileViewState } from '../worktree/workspace-view-settings'
|
||||
import type { Worktree } from '../worktree/workspace-list-types'
|
||||
|
||||
export function feedRow(overrides: Partial<WorkspaceRow> & Pick<WorkspaceRow, 'id'>): WorkspaceRow {
|
||||
return {
|
||||
hostId: 'local',
|
||||
kind: 'git',
|
||||
repoId: 'orca',
|
||||
path: `/w/${overrides.id}`,
|
||||
branch: overrides.id,
|
||||
displayName: overrides.id,
|
||||
isMainWorktree: false,
|
||||
lineage: null,
|
||||
isPinned: false,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
workspaceStatus: 'in-progress',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
linkedGitLabMR: null,
|
||||
linkedGitLabIssue: null,
|
||||
linkedItems: [],
|
||||
defaultBranch: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/** Two local workspaces (one pinned, one its nested child), one on an SSH host. */
|
||||
export const FEED_ROWS = {
|
||||
parent: feedRow({
|
||||
id: 'orca::/w/parent',
|
||||
isPinned: true,
|
||||
instanceId: 'i-parent',
|
||||
manualOrder: 1
|
||||
}),
|
||||
child: feedRow({
|
||||
id: 'orca::/w/child',
|
||||
instanceId: 'i-child',
|
||||
manualOrder: 2,
|
||||
lineage: {
|
||||
parentWorktreeId: 'orca::/w/parent',
|
||||
worktreeInstanceId: 'i-child',
|
||||
parentWorktreeInstanceId: 'i-parent'
|
||||
}
|
||||
}),
|
||||
remote: feedRow({ id: 'lab::/w/gpu', hostId: 'ssh:gpu', repoId: 'lab', manualOrder: 3 })
|
||||
}
|
||||
|
||||
export function feedDomains(overrides: Partial<SidebarFeedDomains> = {}): SidebarFeedDomains {
|
||||
return {
|
||||
workspaces: new Map([
|
||||
['|orca::/w/parent', FEED_ROWS.parent],
|
||||
['|orca::/w/child', FEED_ROWS.child],
|
||||
['ssh:gpu|lab::/w/gpu', FEED_ROWS.remote]
|
||||
]),
|
||||
activity: new Map(),
|
||||
review: new Map(),
|
||||
creations: new Map(),
|
||||
pairedServerHosts: new Map(),
|
||||
pairedServerRows: new Map(),
|
||||
repoIcons: new Map(),
|
||||
desktop: {
|
||||
desktopActive: { hostId: null, worktreeId: 'orca::/w/parent' },
|
||||
pinnedWorkspaceIds: ['orca::/w/parent'],
|
||||
repoOrder: [
|
||||
{ hostId: 'local', repoId: 'orca' },
|
||||
{ hostId: 'ssh:gpu', repoId: 'lab' }
|
||||
],
|
||||
workspaceHostOrder: [],
|
||||
sshHosts: { 'ssh:gpu': { label: 'GPU box', status: 'connected' } },
|
||||
wslHosts: {}
|
||||
},
|
||||
retention: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
type ServedRepo = ServedProjectSections['repos'][number]
|
||||
|
||||
function servedRepo(fields: Pick<ServedRepo, 'id' | 'path'> & Partial<ServedRepo>): ServedRepo {
|
||||
return {
|
||||
displayName: fields.id,
|
||||
badgeColor: undefined,
|
||||
addedAt: undefined,
|
||||
connectionId: undefined,
|
||||
executionHostId: undefined,
|
||||
...fields
|
||||
}
|
||||
}
|
||||
|
||||
export const SERVED_SECTIONS: ServedProjectSections = {
|
||||
repos: [
|
||||
servedRepo({ id: 'orca', path: '/r/orca', badgeColor: '#f00' }),
|
||||
servedRepo({ id: 'lab', path: '/r/lab', connectionId: 'gpu' })
|
||||
],
|
||||
projects: [],
|
||||
setups: [],
|
||||
projectOrderBy: 'manual',
|
||||
projectGroups: undefined
|
||||
}
|
||||
|
||||
export function phoneView(overrides: Partial<MobileViewState> = {}): MobileViewState {
|
||||
return { ...INITIAL_MOBILE_VIEW, groupBy: 'repo', sortBy: 'manual', ...overrides }
|
||||
}
|
||||
|
||||
/** A row worktree.ps served, with the live content the list draws. */
|
||||
export function liveRow(overrides: Partial<Worktree> & Pick<Worktree, 'worktreeId'>): Worktree {
|
||||
return {
|
||||
repoId: 'orca',
|
||||
repo: 'orca',
|
||||
branch: overrides.worktreeId,
|
||||
displayName: overrides.worktreeId,
|
||||
path: `/w/${overrides.worktreeId}`,
|
||||
liveTerminalCount: 0,
|
||||
hasAttachedPty: false,
|
||||
unread: false,
|
||||
isPinned: false,
|
||||
linkedPR: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
MobileViewPatch,
|
||||
MobileViewState,
|
||||
WorkspaceViewSettings
|
||||
} from '../worktree/workspace-view-settings'
|
||||
|
||||
/**
|
||||
* A Workspace options change this phone sent that the desktop has not answered yet. The list shows
|
||||
* its desired value meanwhile; a slow answer is never a refusal.
|
||||
*/
|
||||
export type PendingViewChange = { opId: number; sentAt: number } & (
|
||||
| { kind: 'view'; patch: MobileViewPatch; payload: WorkspaceViewSettings }
|
||||
| { kind: 'collapse'; key: string; collapsed: boolean }
|
||||
)
|
||||
|
||||
/** After this long without an answer the control says it is still waiting, and keeps the value. */
|
||||
export const PENDING_VIEW_CHANGE_STALE_MS = 10_000
|
||||
|
||||
export function applyViewPatch(view: MobileViewState, patch: MobileViewPatch): MobileViewState {
|
||||
return { ...view, ...patch, filters: { ...view.filters, ...patch.filters } }
|
||||
}
|
||||
|
||||
export function applyCollapse(
|
||||
view: MobileViewState,
|
||||
key: string,
|
||||
collapsed: boolean
|
||||
): MobileViewState {
|
||||
const rest = view.collapsedGroups.filter((candidate) => candidate !== key)
|
||||
return { ...view, collapsedGroups: collapsed ? [...rest, key] : rest }
|
||||
}
|
||||
|
||||
/** The view with every unanswered change laid over it, oldest first. */
|
||||
export function withPendingViewChanges(
|
||||
view: MobileViewState,
|
||||
pending: readonly PendingViewChange[]
|
||||
): MobileViewState {
|
||||
return pending.reduce(
|
||||
(current, change) =>
|
||||
change.kind === 'view'
|
||||
? applyViewPatch(current, change.patch)
|
||||
: applyCollapse(current, change.key, change.collapsed),
|
||||
view
|
||||
)
|
||||
}
|
||||
|
||||
/** Each stored value the change sets, keyed by what it writes; a collapse writes its one key. */
|
||||
function writesOf(change: PendingViewChange): Map<string, (ui: WorkspaceViewSettings) => boolean> {
|
||||
if (change.kind === 'collapse') {
|
||||
return new Map([
|
||||
[
|
||||
`collapse:${change.key}`,
|
||||
(ui) => (ui.collapsedGroups?.includes(change.key) ?? false) === change.collapsed
|
||||
]
|
||||
])
|
||||
}
|
||||
return new Map(
|
||||
Object.entries(change.payload).map(([field, value]) => [
|
||||
field,
|
||||
(ui) => JSON.stringify(new Map(Object.entries(ui)).get(field)) === JSON.stringify(value)
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
/** Adds a change; an earlier one whose every write it repeats is no longer the user's intent. */
|
||||
export function addPendingViewChange(
|
||||
pending: readonly PendingViewChange[],
|
||||
change: PendingViewChange
|
||||
): PendingViewChange[] {
|
||||
const writes = writesOf(change)
|
||||
return [
|
||||
...pending.filter((earlier) => [...writesOf(earlier).keys()].some((key) => !writes.has(key))),
|
||||
change
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* The changes the desktop's values do not hold yet. Only changes sent before the read are judged
|
||||
* by it, and a value a later change also writes is that change's to settle.
|
||||
*/
|
||||
export function reconcilePendingViewChanges(
|
||||
pending: readonly PendingViewChange[],
|
||||
ui: WorkspaceViewSettings,
|
||||
sentByOpId: number
|
||||
): PendingViewChange[] {
|
||||
return pending.filter((change, index) => {
|
||||
if (change.opId > sentByOpId) {
|
||||
return true
|
||||
}
|
||||
const later = new Set(pending.slice(index + 1).flatMap((next) => [...writesOf(next).keys()]))
|
||||
return [...writesOf(change)].some(([key, holds]) => !later.has(key) && !holds(ui))
|
||||
})
|
||||
}
|
||||
@@ -63,7 +63,7 @@ function listWith(fields: { actionError: string; setActionError: (value: string)
|
||||
router: { push: () => {} },
|
||||
sectionsResult: { rawSections: [], sections: [], repoById: new Map() },
|
||||
setDismissedNotice: () => {},
|
||||
settings: { activeFilterCount: 0 },
|
||||
settings: { activeFilterCount: 0, pendingViewChanges: { pending: [], stale: false } },
|
||||
state: {
|
||||
actionError: fields.actionError,
|
||||
setActionError: fields.setActionError,
|
||||
|
||||
@@ -88,7 +88,11 @@ function listProps(worktrees: Worktree[]) {
|
||||
reconnectAttempts: 0,
|
||||
relayRecovery: { pairingRejected: false },
|
||||
sectionsResult: { rawSections: sections, sections, repoById: stable.repoById },
|
||||
settings: { activeFilterCount: 0, toggleWorktreeLineage: noop },
|
||||
settings: {
|
||||
activeFilterCount: 0,
|
||||
toggleWorktreeLineage: noop,
|
||||
pendingViewChanges: { pending: [], stale: false }
|
||||
},
|
||||
state: {
|
||||
actionError: '',
|
||||
setActionTarget: noop,
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Platform, Pressable, RefreshControl, SectionList, Text, View } from 'react-native'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
SectionList,
|
||||
Text,
|
||||
View
|
||||
} from 'react-native'
|
||||
import { ChevronDown, ChevronRight, Pin } from 'lucide-react-native'
|
||||
import { AuthFailedBanner } from '../components/AuthFailedBanner'
|
||||
import { HostDiagnosticsLink } from '../components/HostDiagnosticsLink'
|
||||
@@ -10,7 +18,10 @@ import { NewWorkspaceFab, FAB_SIZE } from '../components/NewWorkspaceFab'
|
||||
import { WorktreeListRow } from '../components/WorktreeListRow'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
import { getWorktreeRowIdentity } from '../worktree/worktree-host-row-identity'
|
||||
import { HostWorkspaceListStates } from '../worktree/host-workspace-list-states'
|
||||
import {
|
||||
HostWorkspaceListReopen,
|
||||
HostWorkspaceListStates
|
||||
} from '../worktree/host-workspace-list-states'
|
||||
import { getWorktreeStatus } from '../worktree/workspace-list-sections'
|
||||
import { repoColor } from '../worktree/repo-color'
|
||||
import { getWorktreeCardDetails } from '../../../src/shared/worktree/card-properties'
|
||||
@@ -44,6 +55,12 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
|
||||
state
|
||||
} = controller
|
||||
const { rawSections, sections, repoById } = sectionsResult
|
||||
const { pending } = settings.pendingViewChanges
|
||||
// A collapse the desktop has not answered spins on its header.
|
||||
const pendingCollapseKeys = useMemo(
|
||||
() => new Set(pending.flatMap((change) => (change.kind === 'collapse' ? [change.key] : []))),
|
||||
[pending]
|
||||
)
|
||||
const repoLookById = useMemo(() => buildRepoLookById(repoById), [repoById])
|
||||
const { worktreeCardProperties, compactWorktreeCards, newCardStyle } = state.view
|
||||
const cardDetails = useMemo(
|
||||
@@ -113,17 +130,26 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
|
||||
</View>
|
||||
)}
|
||||
|
||||
<HostWorkspaceListStates
|
||||
connState={connState}
|
||||
worktreesLoaded={state.worktreesLoaded}
|
||||
displayCount={displayWorktrees.length}
|
||||
sectionCount={sections.length}
|
||||
catalogError={state.catalogError}
|
||||
search={state.search}
|
||||
activeFilterCount={controller.activeFilterCount}
|
||||
/>
|
||||
{/* A feed this page cannot read: the host is older or newer than the page. */}
|
||||
{controller.feed?.kind === 'reopen' ? (
|
||||
<HostWorkspaceListReopen />
|
||||
) : (
|
||||
<HostWorkspaceListStates
|
||||
connState={connState}
|
||||
// A feed-built list is loaded once the feed installs; rows draw before worktree.ps answers.
|
||||
worktreesLoaded={
|
||||
controller.feed ? controller.feed.kind === 'ready' : state.worktreesLoaded
|
||||
}
|
||||
// A feed-built list counts its sections, so collapsed ones and a failing worktree.ps never blank it.
|
||||
displayCount={controller.feed ? sections.length : displayWorktrees.length}
|
||||
sectionCount={sections.length}
|
||||
catalogError={state.catalogError}
|
||||
search={state.search}
|
||||
activeFilterCount={controller.activeFilterCount}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sections.length > 0 && (
|
||||
{sections.length > 0 && controller.feed?.kind !== 'reopen' && (
|
||||
<SectionList
|
||||
ref={activeWorktreeScroll.sectionListRef}
|
||||
sections={sections}
|
||||
@@ -147,8 +173,8 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
|
||||
return null
|
||||
}
|
||||
const isCollapsed = controller.collapsedGroups.has(section.key)
|
||||
const rawSection = rawSections.find((s) => s.key === section.key)
|
||||
const count = rawSection?.data.length ?? 0
|
||||
const count =
|
||||
section.count ?? rawSections.find((s) => s.key === section.key)?.data.length ?? 0
|
||||
// Why: the desktop's header draws its anchor repo's colour and icon.
|
||||
const repoSectionLook =
|
||||
state.view.groupBy === 'repo' && section.repo
|
||||
@@ -180,6 +206,13 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
|
||||
) : null}
|
||||
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||
<Text style={styles.sectionCount}>{count}</Text>
|
||||
{pendingCollapseKeys.has(section.key) && (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={colors.textMuted}
|
||||
style={styles.sectionIcon}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useMemo } from 'react'
|
||||
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../src/shared/sidebar-inputs-switch'
|
||||
import { buildSidebarFeedRows } from '../../../src/shared/sidebar-list/sidebar-feed-rows'
|
||||
import { repoIconFeedKey } from '../../../src/shared/sidebar-list/repo-icon-data-urls-contract'
|
||||
import type { ProjectOrderBy } from '../../../src/shared/ui-chrome-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { buildProjectSectionCatalog } from '../worktree/project-section-catalog'
|
||||
import { useWorkspaceSections } from '../worktree/use-workspace-sections'
|
||||
import { buildFilterRepos, type WorkspaceSectionRepo } from '../worktree/workspace-filter-repos'
|
||||
import type { Section, SectionRepo, Worktree } from '../worktree/workspace-list-types'
|
||||
import { rowFiltersForView } from '../worktree/workspace-view-settings'
|
||||
import { buildHostFeedCatalog, buildHostFeedView } from './host-sidebar-feed-inputs'
|
||||
import { buildHostFeedSections } from './host-sidebar-feed-sections'
|
||||
import { useHostRepoIconDataUrls } from './use-host-repo-icon-data-urls'
|
||||
import type { HostSidebarFeed } from './use-host-sidebar-feed'
|
||||
import type { HostScreenState } from './use-host-screen-state'
|
||||
|
||||
export type HostListSectionsInputs = {
|
||||
client: RpcClient | null
|
||||
/** Null in a build that lists without the feed. */
|
||||
feed: HostSidebarFeed | null
|
||||
/** The rows worktree.ps and the desktop's server lists served, with display state applied. */
|
||||
displayWorktrees: Worktree[]
|
||||
state: HostScreenState
|
||||
}
|
||||
|
||||
export type HostListSections = {
|
||||
sections: Section[]
|
||||
/** Each section before collapse; a feed-built section carries its own count instead. */
|
||||
rawSections: Section[]
|
||||
uniqueRepos: WorkspaceSectionRepo[]
|
||||
/** Each row's repo by id, as the desktop's cards look theirs up for colour and icon. */
|
||||
repoById: ReadonlyMap<string, SectionRepo>
|
||||
collapsedGroups: Set<string>
|
||||
/** The desktop's value once read; the served catalog's (or the default) before. */
|
||||
projectOrderBy: ProjectOrderBy
|
||||
}
|
||||
|
||||
function useCollapsedGroups(state: HostScreenState): Set<string> {
|
||||
return useMemo(() => new Set(state.view.collapsedGroups), [state.view.collapsedGroups])
|
||||
}
|
||||
|
||||
function useFilterRepos(
|
||||
state: HostScreenState,
|
||||
displayWorktrees: Worktree[]
|
||||
): WorkspaceSectionRepo[] {
|
||||
const catalogRepos = state.servedProjectSections?.repos ?? state.desktopRepos
|
||||
return useMemo(
|
||||
() => buildFilterRepos(catalogRepos, displayWorktrees),
|
||||
[catalogRepos, displayWorktrees]
|
||||
)
|
||||
}
|
||||
|
||||
/** The phone's own list rules, for a page built without the feed. */
|
||||
function useLegacyHostListSections({
|
||||
displayWorktrees,
|
||||
state
|
||||
}: HostListSectionsInputs): HostListSections {
|
||||
const projectCatalog = useMemo(
|
||||
() =>
|
||||
buildProjectSectionCatalog(
|
||||
state.servedProjectSections,
|
||||
state.desktopRepos,
|
||||
state.view.filters.visibleHostIds,
|
||||
state.view.projectOrderBy
|
||||
),
|
||||
[
|
||||
state.servedProjectSections,
|
||||
state.desktopRepos,
|
||||
state.view.filters.visibleHostIds,
|
||||
state.view.projectOrderBy
|
||||
]
|
||||
)
|
||||
const collapsedGroups = useCollapsedGroups(state)
|
||||
const rowFilters = useMemo(() => rowFiltersForView(state.view), [state.view])
|
||||
const sectionsResult = useWorkspaceSections({
|
||||
displayWorktrees,
|
||||
sortMode: state.view.sortBy,
|
||||
filters: rowFilters,
|
||||
search: state.search,
|
||||
groupMode: state.view.groupBy,
|
||||
pinnedIds: state.pinnedIds,
|
||||
showPinnedInGroups: state.showPinnedInGroups,
|
||||
projectCatalog,
|
||||
filterCatalogRepos: state.servedProjectSections?.repos ?? state.desktopRepos,
|
||||
collapsedGroups,
|
||||
workspaceStatuses: state.view.workspaceStatuses,
|
||||
repoHostIdByRepoId: state.repoHostIdByRepoId
|
||||
})
|
||||
return { ...sectionsResult, collapsedGroups, projectOrderBy: projectCatalog.projectOrderBy }
|
||||
}
|
||||
|
||||
const NO_SECTIONS: Section[] = []
|
||||
|
||||
/** The desktop sidebar's own list, built by the shared builder from the host's feed. */
|
||||
function useFeedHostListSections({
|
||||
client,
|
||||
feed,
|
||||
displayWorktrees,
|
||||
state
|
||||
}: HostListSectionsInputs): HostListSections {
|
||||
const collapsedGroups = useCollapsedGroups(state)
|
||||
const uniqueRepos = useFilterRepos(state, displayWorktrees)
|
||||
const projectOrderBy =
|
||||
state.view.projectOrderBy ?? state.servedProjectSections?.projectOrderBy ?? 'manual'
|
||||
const domains = feed?.kind === 'ready' ? feed.domains : null
|
||||
const catalog = useMemo(
|
||||
() =>
|
||||
buildHostFeedCatalog(
|
||||
state.servedProjectSections,
|
||||
state.desktopRepos,
|
||||
state.hostProjectGroups
|
||||
),
|
||||
[state.servedProjectSections, state.desktopRepos, state.hostProjectGroups]
|
||||
)
|
||||
const view = useMemo(
|
||||
() =>
|
||||
buildHostFeedView({
|
||||
view: state.view,
|
||||
projectOrderBy,
|
||||
desktop: domains?.desktop ?? null,
|
||||
hostPlatform: state.hostPlatform
|
||||
}),
|
||||
[state.view, projectOrderBy, domains, state.hostPlatform]
|
||||
)
|
||||
const built = useMemo(
|
||||
() => (domains ? buildSidebarFeedRows(domains, catalog, view) : null),
|
||||
[domains, catalog, view]
|
||||
)
|
||||
const sections = useMemo(
|
||||
() =>
|
||||
built
|
||||
? buildHostFeedSections({
|
||||
rows: built.sectionRows,
|
||||
activeRowKeys: built.activeRowKeys,
|
||||
liveRows: displayWorktrees,
|
||||
repoHostIdByRepoId: state.repoHostIdByRepoId,
|
||||
search: state.search
|
||||
})
|
||||
: NO_SECTIONS,
|
||||
[built, displayWorktrees, state.repoHostIdByRepoId, state.search]
|
||||
)
|
||||
|
||||
const iconIds = useMemo(() => [...new Set(domains?.repoIcons.values() ?? [])], [domains])
|
||||
const iconDataUrls = useHostRepoIconDataUrls(client, iconIds)
|
||||
const repoById = useMemo(() => {
|
||||
// The desktop's repo.list still carries uploads, which the served catalog withholds.
|
||||
const hostIcons = new Map(
|
||||
state.desktopRepos.map((repo) => [repoIconFeedKey(repo), repo.repoIcon])
|
||||
)
|
||||
const repos = state.servedProjectSections?.repos ?? state.desktopRepos
|
||||
return new Map(
|
||||
repos.map((repo) => {
|
||||
const key = repoIconFeedKey(repo)
|
||||
const iconId = domains?.repoIcons.get(key)
|
||||
const dataUrl = iconId ? iconDataUrls.get(iconId) : undefined
|
||||
const repoIcon = dataUrl
|
||||
? { type: 'image', src: dataUrl }
|
||||
: (repo.repoIcon ?? hostIcons.get(key))
|
||||
return [repo.id, { ...repo, repoIcon }]
|
||||
})
|
||||
)
|
||||
}, [state.servedProjectSections, state.desktopRepos, domains, iconDataUrls])
|
||||
|
||||
return { sections, rawSections: sections, uniqueRepos, repoById, collapsedGroups, projectOrderBy }
|
||||
}
|
||||
|
||||
/** Switch point: the list comes from the host's sidebar feed, or the phone builds it itself. */
|
||||
export const useHostListSections = ORCA_SIDEBAR_INPUTS_IS_MAIN
|
||||
? useFeedHostListSections
|
||||
: useLegacyHostListSections
|
||||
@@ -0,0 +1,222 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../../src/shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true }))
|
||||
|
||||
import {
|
||||
SetCollapsedParams,
|
||||
type SetCollapsedRequest
|
||||
} from '../../../src/shared/rpc-contract/workspace-view-collapse-params'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import { INITIAL_MOBILE_VIEW } from '../worktree/workspace-view-settings'
|
||||
import {
|
||||
PENDING_VIEW_CHANGE_STALE_MS,
|
||||
addPendingViewChange,
|
||||
reconcilePendingViewChanges,
|
||||
withPendingViewChanges,
|
||||
type PendingViewChange
|
||||
} from './host-view-pending-changes'
|
||||
import {
|
||||
useSwitchedHostPendingViewChanges,
|
||||
type HostPendingViewChanges,
|
||||
type PendingViewOutcome
|
||||
} from './use-host-pending-view-changes'
|
||||
|
||||
function ok(): RpcResponse {
|
||||
return {
|
||||
id: 'r',
|
||||
ok: true,
|
||||
result: { collapsed: true, applied: true },
|
||||
_meta: { runtimeId: 'x' }
|
||||
}
|
||||
}
|
||||
|
||||
function refused(code: string): RpcResponse {
|
||||
return { id: 'r', ok: false, error: { code, message: code }, _meta: { runtimeId: 'x' } }
|
||||
}
|
||||
|
||||
function mount(sendRequest: (method: string, params: unknown) => Promise<RpcResponse>) {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook calls only sendRequest.
|
||||
const client = { sendRequest: vi.fn(sendRequest) } as unknown as RpcClient
|
||||
let changes: HostPendingViewChanges | null = null
|
||||
function Probe(): null {
|
||||
changes = useSwitchedHostPendingViewChanges(client)
|
||||
return null
|
||||
}
|
||||
act(() => {
|
||||
create(createElement(Probe))
|
||||
})
|
||||
return { client, current: () => changes! }
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('the phone’s own option changes', () => {
|
||||
it('show as pending until the desktop acknowledges them', async () => {
|
||||
let answer: (reply: RpcResponse) => void = () => {}
|
||||
const screen = mount(() => new Promise((resolve) => (answer = resolve)))
|
||||
let outcome: Promise<PendingViewOutcome> | null = null
|
||||
act(() => {
|
||||
outcome = screen.current().controls.sendView({ groupBy: 'none' }, { groupBy: 'none' })
|
||||
})
|
||||
expect(screen.current().status.pending).toHaveLength(1)
|
||||
answer(ok())
|
||||
await flush()
|
||||
expect(await outcome).toBe('settled')
|
||||
expect(screen.current().status.pending).toEqual([])
|
||||
})
|
||||
|
||||
it('drop a refused change and say it was refused', async () => {
|
||||
const screen = mount(async () => refused('invalid_params'))
|
||||
let outcome: Promise<PendingViewOutcome> | null = null
|
||||
act(() => {
|
||||
outcome = screen.current().controls.sendView({ groupBy: 'none' }, { groupBy: 'none' })
|
||||
})
|
||||
await flush()
|
||||
expect(await outcome).toBe('refused')
|
||||
expect(screen.current().status.pending).toEqual([])
|
||||
})
|
||||
|
||||
it('stay quiet about a refusal of a change a later one replaced', async () => {
|
||||
const answers: ((reply: RpcResponse) => void)[] = []
|
||||
const screen = mount(() => new Promise((resolve) => answers.push(resolve)))
|
||||
let first: Promise<PendingViewOutcome> | null = null
|
||||
act(() => {
|
||||
first = screen.current().controls.sendView({ sortBy: 'name' }, { sortBy: 'name' })
|
||||
})
|
||||
act(() => void screen.current().controls.sendView({ sortBy: 'recent' }, { sortBy: 'recent' }))
|
||||
answers[0](refused('invalid_params'))
|
||||
await flush()
|
||||
expect(await first).toBe('waiting')
|
||||
expect(screen.current().status.pending).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keep a change the desktop has not answered, saying so after ten seconds', () => {
|
||||
vi.useFakeTimers()
|
||||
const screen = mount(() => new Promise(() => {}))
|
||||
act(() => void screen.current().controls.sendView({ sortBy: 'name' }, { sortBy: 'name' }))
|
||||
expect(screen.current().status.stale).toBe(false)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(PENDING_VIEW_CHANGE_STALE_MS)
|
||||
})
|
||||
expect(screen.current().status.stale).toBe(true)
|
||||
expect(screen.current().status.pending).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keep a change whose request dropped, and resend it after a reconnect', async () => {
|
||||
const screen = mount(async () => {
|
||||
throw new Error('client_disconnected')
|
||||
})
|
||||
act(() => void screen.current().controls.sendView({ sortBy: 'name' }, { sortBy: 'name' }))
|
||||
await flush()
|
||||
expect(screen.current().status.pending).toHaveLength(1)
|
||||
const sentByOpId = screen.current().controls.lastOpId()
|
||||
act(() => {
|
||||
screen.current().controls.reconcile({ sortBy: 'recent' }, { reconnected: true, sentByOpId })
|
||||
})
|
||||
expect(screen.client.sendRequest).toHaveBeenCalledTimes(2)
|
||||
act(() => {
|
||||
screen.current().controls.reconcile({ sortBy: 'name' }, { reconnected: false, sentByOpId })
|
||||
})
|
||||
expect(screen.current().status.pending).toEqual([])
|
||||
})
|
||||
|
||||
it('report a host without ui.setCollapsed, which then takes the whole list', async () => {
|
||||
const screen = mount(async () => refused('method_not_found'))
|
||||
let outcome: Promise<PendingViewOutcome> | null = null
|
||||
act(() => {
|
||||
outcome = screen.current().controls.sendCollapse('repo:orca', true)
|
||||
})
|
||||
await flush()
|
||||
expect(await outcome).toBe('unavailable')
|
||||
expect(screen.current().status.pending).toEqual([])
|
||||
})
|
||||
|
||||
it('send collapses under one writer with a growing seq', async () => {
|
||||
const sent: SetCollapsedRequest[] = []
|
||||
const screen = mount(async (_method, params) => {
|
||||
sent.push(SetCollapsedParams.parse(params))
|
||||
return ok()
|
||||
})
|
||||
act(() => void screen.current().controls.sendCollapse('a', true))
|
||||
act(() => void screen.current().controls.sendCollapse('a', false))
|
||||
await flush()
|
||||
const [first, second] = sent
|
||||
expect(second.opId.writer).toBe(first.opId.writer)
|
||||
expect(second.opId.seq).toBeGreaterThan(first.opId.seq)
|
||||
})
|
||||
|
||||
it('keep the same controls while changes come and go', async () => {
|
||||
const screen = mount(async () => ok())
|
||||
const before = screen.current().controls
|
||||
act(() => void screen.current().controls.sendView({ sortBy: 'name' }, { sortBy: 'name' }))
|
||||
await flush()
|
||||
expect(screen.current().controls).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pending changes over the desktop’s values', () => {
|
||||
const collapse = (opId: number, collapsed: boolean): PendingViewChange => ({
|
||||
opId,
|
||||
sentAt: 0,
|
||||
kind: 'collapse',
|
||||
key: 'k',
|
||||
collapsed
|
||||
})
|
||||
|
||||
it('lay the desired values over the view, oldest first', () => {
|
||||
const view = withPendingViewChanges(INITIAL_MOBILE_VIEW, [
|
||||
{ opId: 1, sentAt: 0, kind: 'view', patch: { groupBy: 'none' }, payload: {} },
|
||||
collapse(2, true),
|
||||
{ opId: 3, sentAt: 0, kind: 'view', patch: { groupBy: 'pr-status' }, payload: {} }
|
||||
])
|
||||
expect(view.groupBy).toBe('pr-status')
|
||||
expect(view.collapsedGroups).toEqual(['k'])
|
||||
})
|
||||
|
||||
it('keep only the latest intent for a section toggled twice', () => {
|
||||
const pending = addPendingViewChange([collapse(1, true)], collapse(2, false))
|
||||
expect(pending.map((change) => change.opId)).toEqual([2])
|
||||
// The desktop already shows it expanded, the latest intent, so nothing waits.
|
||||
expect(reconcilePendingViewChanges(pending, { collapsedGroups: [] }, 2)).toEqual([])
|
||||
})
|
||||
|
||||
it('judge only changes sent before the read', () => {
|
||||
const pending = [collapse(3, true)]
|
||||
expect(reconcilePendingViewChanges(pending, { collapsedGroups: ['k'] }, 2)).toHaveLength(1)
|
||||
expect(reconcilePendingViewChanges(pending, { collapsedGroups: ['k'] }, 3)).toEqual([])
|
||||
})
|
||||
|
||||
it('leave a value a later change also writes to that change', () => {
|
||||
const narrowed: PendingViewChange = {
|
||||
opId: 1,
|
||||
sentAt: 0,
|
||||
kind: 'view',
|
||||
patch: {},
|
||||
payload: { visibleWorkspaceHostIds: ['ssh:a'], workspaceHostScope: 'ssh:a' }
|
||||
}
|
||||
const widened: PendingViewChange = {
|
||||
opId: 2,
|
||||
sentAt: 0,
|
||||
kind: 'view',
|
||||
patch: {},
|
||||
payload: { visibleWorkspaceHostIds: ['ssh:a', 'local'] }
|
||||
}
|
||||
const pending = addPendingViewChange([narrowed], widened)
|
||||
expect(pending).toHaveLength(2)
|
||||
// The scope the first wrote holds; its host list is the second's to settle.
|
||||
const waiting = reconcilePendingViewChanges(pending, { workspaceHostScope: 'ssh:a' }, 2)
|
||||
expect(waiting.map((change) => change.opId)).toEqual([2])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../src/shared/sidebar-inputs-switch'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { MobileViewPatch, WorkspaceViewSettings } from '../worktree/workspace-view-settings'
|
||||
import { hostViewSettingsWrite } from './host-screen-operations'
|
||||
import { readHostMethodReply, setCollapsedWrite } from './host-sidebar-feed-operations'
|
||||
import {
|
||||
PENDING_VIEW_CHANGE_STALE_MS,
|
||||
addPendingViewChange,
|
||||
reconcilePendingViewChanges,
|
||||
type PendingViewChange
|
||||
} from './host-view-pending-changes'
|
||||
|
||||
/** How the desktop answered: `waiting` keeps the change (no answer is not a refusal). */
|
||||
export type PendingViewOutcome = 'settled' | 'refused' | 'unavailable' | 'waiting'
|
||||
|
||||
export type PendingViewStatus = {
|
||||
pending: readonly PendingViewChange[]
|
||||
/** Some change has waited past PENDING_VIEW_CHANGE_STALE_MS. */
|
||||
stale: boolean
|
||||
}
|
||||
|
||||
/** Stable for one client, so callers' callbacks never change with what is pending. */
|
||||
export type PendingViewControls = {
|
||||
sendView: (patch: MobileViewPatch, payload: WorkspaceViewSettings) => Promise<PendingViewOutcome>
|
||||
/** `unavailable`: the host has no ui.setCollapsed. */
|
||||
sendCollapse: (key: string, collapsed: boolean) => Promise<PendingViewOutcome>
|
||||
/** The last change sent; a read sent after it judges every change up to it. */
|
||||
lastOpId: () => number
|
||||
/** The changes still waiting once the desktop's values are read; after a reconnect each is resent. */
|
||||
reconcile: (
|
||||
ui: WorkspaceViewSettings,
|
||||
options: { reconnected: boolean; sentByOpId: number }
|
||||
) => PendingViewChange[]
|
||||
}
|
||||
|
||||
export type HostPendingViewChanges = { status: PendingViewStatus; controls: PendingViewControls }
|
||||
|
||||
type OwnedChanges = { client: RpcClient | null; pending: readonly PendingViewChange[] }
|
||||
|
||||
const NO_CHANGES: readonly PendingViewChange[] = []
|
||||
|
||||
/** The phone's own option changes until the desktop answers each, for the client that sent them. */
|
||||
function useHostPendingViewChanges(client: RpcClient | null): HostPendingViewChanges {
|
||||
const [owned, setOwned] = useState<OwnedChanges>({ client, pending: NO_CHANGES })
|
||||
const ownedRef = useRef(owned)
|
||||
const seqRef = useRef(0)
|
||||
// A fresh writer per screen: its seq restarts at zero.
|
||||
const [writer] = useState(
|
||||
() => `phone-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
||||
)
|
||||
// Another host's changes never show or settle here.
|
||||
const pending = owned.client === client ? owned.pending : NO_CHANGES
|
||||
const [stale, setStale] = useState(false)
|
||||
|
||||
const current = useCallback(
|
||||
() => (ownedRef.current.client === client ? ownedRef.current.pending : NO_CHANGES),
|
||||
[client]
|
||||
)
|
||||
const write = useCallback(
|
||||
(next: readonly PendingViewChange[]) => {
|
||||
ownedRef.current = { client, pending: next }
|
||||
setOwned(ownedRef.current)
|
||||
},
|
||||
[client]
|
||||
)
|
||||
|
||||
const send = useCallback(
|
||||
async (change: PendingViewChange): Promise<PendingViewOutcome> => {
|
||||
if (!client) {
|
||||
return 'waiting'
|
||||
}
|
||||
let outcome: PendingViewOutcome
|
||||
try {
|
||||
if (change.kind === 'view') {
|
||||
const reply = await hostViewSettingsWrite.request(client, change.payload)
|
||||
outcome = reply.ok ? 'settled' : 'refused'
|
||||
} else {
|
||||
const reply = await setCollapsedWrite.request(client, {
|
||||
key: change.key,
|
||||
collapsed: change.collapsed,
|
||||
opId: { writer, seq: change.opId }
|
||||
})
|
||||
const unavailable =
|
||||
readHostMethodReply(reply, setCollapsedWrite.interpret).kind === 'unavailable'
|
||||
outcome = unavailable ? 'unavailable' : reply.ok ? 'settled' : 'refused'
|
||||
}
|
||||
} catch {
|
||||
return 'waiting'
|
||||
}
|
||||
// A change a later one superseded is no longer the user's to hear about.
|
||||
if (!current().some((candidate) => candidate.opId === change.opId)) {
|
||||
return 'waiting'
|
||||
}
|
||||
write(current().filter((candidate) => candidate.opId !== change.opId))
|
||||
return outcome
|
||||
},
|
||||
[client, writer, current, write]
|
||||
)
|
||||
|
||||
const add = useCallback(
|
||||
(change: PendingViewChange) => {
|
||||
write(addPendingViewChange(current(), change))
|
||||
return send(change)
|
||||
},
|
||||
[current, write, send]
|
||||
)
|
||||
const nextOp = (): { opId: number; sentAt: number } => {
|
||||
seqRef.current += 1
|
||||
return { opId: seqRef.current, sentAt: Date.now() }
|
||||
}
|
||||
|
||||
const sendView = useCallback(
|
||||
(patch: MobileViewPatch, payload: WorkspaceViewSettings) =>
|
||||
add({ ...nextOp(), kind: 'view', patch, payload }),
|
||||
[add]
|
||||
)
|
||||
const sendCollapse = useCallback(
|
||||
(key: string, collapsed: boolean) => add({ ...nextOp(), kind: 'collapse', key, collapsed }),
|
||||
[add]
|
||||
)
|
||||
const lastOpId = useCallback(() => seqRef.current, [])
|
||||
const reconcile = useCallback(
|
||||
(ui: WorkspaceViewSettings, options: { reconnected: boolean; sentByOpId: number }) => {
|
||||
const waiting = reconcilePendingViewChanges(current(), ui, options.sentByOpId)
|
||||
if (waiting.length !== current().length) {
|
||||
write(waiting)
|
||||
}
|
||||
if (options.reconnected) {
|
||||
// Each sets a desired value, so sending one again is safe.
|
||||
waiting.forEach((change) => void send(change))
|
||||
}
|
||||
return waiting
|
||||
},
|
||||
[current, write, send]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const oldest = pending[0]
|
||||
if (!oldest) {
|
||||
setStale(false)
|
||||
return
|
||||
}
|
||||
const wait = oldest.sentAt + PENDING_VIEW_CHANGE_STALE_MS - Date.now()
|
||||
setStale(wait <= 0)
|
||||
if (wait <= 0) {
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => setStale(true), wait)
|
||||
return () => clearTimeout(timer)
|
||||
}, [pending])
|
||||
|
||||
const controls = useMemo(
|
||||
() => ({ sendView, sendCollapse, lastOpId, reconcile }),
|
||||
[sendView, sendCollapse, lastOpId, reconcile]
|
||||
)
|
||||
const status = useMemo(() => ({ pending, stale }), [pending, stale])
|
||||
return { status, controls }
|
||||
}
|
||||
|
||||
/** Switch point: a page built without the feed sends its changes best-effort, with no pending state. */
|
||||
export const useSwitchedHostPendingViewChanges: (
|
||||
client: RpcClient | null
|
||||
) => HostPendingViewChanges | null = ORCA_SIDEBAR_INPUTS_IS_MAIN
|
||||
? useHostPendingViewChanges
|
||||
: () => null
|
||||
@@ -27,7 +27,7 @@ describe('useHostProjectSections', () => {
|
||||
client,
|
||||
connState: 'connected',
|
||||
hostId,
|
||||
state: { clientRef, setServedProjectSections: () => {} }
|
||||
state: { clientRef, setServedProjectSections: () => {}, setHostProjectGroups: () => {} }
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../src/shared/sidebar-inputs-switch'
|
||||
import {
|
||||
hostProjectGroupsRead,
|
||||
projectSectionsRead,
|
||||
readServedProjectSections
|
||||
} from '../worktree/project-sections-operations'
|
||||
import { readHostMethodReply } from './host-sidebar-feed-operations'
|
||||
import type { HostScreenState } from './use-host-screen-state'
|
||||
|
||||
const PROJECT_SECTIONS_REFRESH_MS = 60_000
|
||||
@@ -17,10 +20,10 @@ export function useHostProjectSections(args: {
|
||||
client: RpcClient | null
|
||||
connState: ConnectionState
|
||||
hostId: string | undefined
|
||||
state: Pick<HostScreenState, 'clientRef' | 'setServedProjectSections'>
|
||||
state: Pick<HostScreenState, 'clientRef' | 'setServedProjectSections' | 'setHostProjectGroups'>
|
||||
}): (options?: { force?: boolean }) => Promise<void> {
|
||||
const { client, connState, hostId, state } = args
|
||||
const { clientRef, setServedProjectSections } = state
|
||||
const { clientRef, setServedProjectSections, setHostProjectGroups } = state
|
||||
const inFlight = useRef(new WeakSet<RpcClient>())
|
||||
// Per client, so a reused screen for another host fetches its catalog at once.
|
||||
const fetchedAt = useRef(new WeakMap<RpcClient, number>())
|
||||
@@ -46,11 +49,19 @@ export function useHostProjectSections(args: {
|
||||
if (sections.kind === 'answered') {
|
||||
fetchedAt.current.set(client, Date.now())
|
||||
setServedProjectSections(sections.value)
|
||||
// Switch point: with no window catalog's groups, a feed-built list reads the host's own.
|
||||
if (ORCA_SIDEBAR_INPUTS_IS_MAIN && !sections.value?.projectGroups) {
|
||||
const reply = await hostProjectGroupsRead.request(client).catch(() => null)
|
||||
const groups = reply && readHostMethodReply(reply, hostProjectGroupsRead.interpret)
|
||||
if (groups?.kind === 'answered' && clientRef.current === client) {
|
||||
setHostProjectGroups(groups.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
inFlight.current.delete(client)
|
||||
}
|
||||
},
|
||||
[client, clientRef, connState, hostId, setServedProjectSections]
|
||||
[client, clientRef, connState, hostId, setServedProjectSections, setHostProjectGroups]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { MAX_REPO_ICON_IDS_PER_READ } from '../../../src/shared/sidebar-list/repo-icon-data-urls-contract'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { readHostMethodReply, sidebarRepoIconsRead } from './host-sidebar-feed-operations'
|
||||
|
||||
const NO_DATA_URLS: ReadonlyMap<string, string | null> = new Map()
|
||||
|
||||
/**
|
||||
* The data URL behind each icon id the feed names, read once per id and client. Null: the host has
|
||||
* no image within its caps, and the phone draws its own badge. Ids are content hashes, so a changed
|
||||
* icon arrives as a new id.
|
||||
*/
|
||||
export function useHostRepoIconDataUrls(
|
||||
client: RpcClient | null,
|
||||
ids: readonly string[]
|
||||
): ReadonlyMap<string, string | null> {
|
||||
// Keyed by client: another host's icons never show here.
|
||||
const [read, setRead] = useState<{
|
||||
client: RpcClient | null
|
||||
dataUrls: ReadonlyMap<string, string | null>
|
||||
}>({ client, dataUrls: NO_DATA_URLS })
|
||||
const askedRef = useRef<{ client: RpcClient | null; ids: Set<string> }>({
|
||||
client,
|
||||
ids: new Set()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
if (askedRef.current.client !== client) {
|
||||
askedRef.current = { client, ids: new Set() }
|
||||
}
|
||||
const asked = askedRef.current.ids
|
||||
const wanted = ids.filter((id) => !asked.has(id))
|
||||
for (let start = 0; start < wanted.length; start += MAX_REPO_ICON_IDS_PER_READ) {
|
||||
const batch = wanted.slice(start, start + MAX_REPO_ICON_IDS_PER_READ)
|
||||
batch.forEach((id) => asked.add(id))
|
||||
void sidebarRepoIconsRead
|
||||
.request(client, { ids: batch })
|
||||
.then((reply) => readHostMethodReply(reply, sidebarRepoIconsRead.interpret))
|
||||
.catch(() => ({ kind: 'failed' as const }))
|
||||
.then((reply) => {
|
||||
if (reply.kind === 'failed') {
|
||||
// Asked again on the next feed install; a host without the method is not.
|
||||
batch.forEach((id) => asked.delete(id))
|
||||
}
|
||||
if (reply.kind !== 'answered' || askedRef.current.client !== client) {
|
||||
return
|
||||
}
|
||||
setRead((current) => {
|
||||
const next = new Map(current.client === client ? current.dataUrls : NO_DATA_URLS)
|
||||
reply.value.icons.forEach(({ id, dataUrl }) => next.set(id, dataUrl))
|
||||
return { client, dataUrls: next }
|
||||
})
|
||||
})
|
||||
}
|
||||
}, [client, ids])
|
||||
|
||||
return read.client === client ? read.dataUrls : NO_DATA_URLS
|
||||
}
|
||||
@@ -18,15 +18,14 @@ import {
|
||||
useReconnectAttempt,
|
||||
useRelayRecoveryStatus
|
||||
} from '../transport/client-context-connection-metrics'
|
||||
import { useWorkspaceSections } from '../worktree/use-workspace-sections'
|
||||
import { useHostDisplayWorktrees } from './use-host-display-worktrees'
|
||||
import { buildProjectSectionCatalog } from '../worktree/project-section-catalog'
|
||||
import { useHostListSections } from './use-host-list-sections'
|
||||
import { useSwitchedHostSidebarFeed } from './use-host-sidebar-feed'
|
||||
import { useHostProjectSections } from './use-host-project-sections'
|
||||
import { useHostRepoMetadata, type FetchHostRepoMetadata } from './use-host-repo-metadata'
|
||||
import { useHostScreenIdentity } from './use-host-screen-identity'
|
||||
import { useHostScreenState } from './use-host-screen-state'
|
||||
import { useHostViewSettings } from './use-host-view-settings'
|
||||
import { rowFiltersForView } from '../worktree/workspace-view-settings'
|
||||
import { countActiveWorkspaceFilters } from '../../../src/shared/workspace-view/workspace-row-filters'
|
||||
import { useHostWorktreeActions } from './use-host-worktree-actions'
|
||||
import { useHostWorktreeCatalog } from './use-host-worktree-catalog'
|
||||
@@ -89,6 +88,7 @@ export function useHostScreenController({
|
||||
},
|
||||
[fetchProjectSections, fetchRepoMetadataOnly]
|
||||
)
|
||||
const sidebarFeed = useSwitchedHostSidebarFeed(client)
|
||||
const catalog = useHostWorktreeCatalog({
|
||||
client,
|
||||
connState,
|
||||
@@ -96,6 +96,7 @@ export function useHostScreenController({
|
||||
fetchRepoMetadata,
|
||||
hostCapabilities,
|
||||
hostId,
|
||||
sidebarFeed: sidebarFeed?.triggers ?? null,
|
||||
state,
|
||||
syncViewSettingsFromDesktop: settings.syncViewSettingsFromDesktop
|
||||
})
|
||||
@@ -120,40 +121,9 @@ export function useHostScreenController({
|
||||
const showNewWorktree = resolvedRouteActionState.showNewWorktree
|
||||
|
||||
const displayWorktrees = useHostDisplayWorktrees(connState, state, hostId)
|
||||
const projectCatalog = useMemo(
|
||||
() =>
|
||||
buildProjectSectionCatalog(
|
||||
state.servedProjectSections,
|
||||
state.desktopRepos,
|
||||
state.view.filters.visibleHostIds,
|
||||
state.view.projectOrderBy
|
||||
),
|
||||
[
|
||||
state.servedProjectSections,
|
||||
state.desktopRepos,
|
||||
state.view.filters.visibleHostIds,
|
||||
state.view.projectOrderBy
|
||||
]
|
||||
)
|
||||
const collapsedGroups = useMemo(
|
||||
() => new Set(state.view.collapsedGroups),
|
||||
[state.view.collapsedGroups]
|
||||
)
|
||||
const rowFilters = useMemo(() => rowFiltersForView(state.view), [state.view])
|
||||
const sectionsResult = useWorkspaceSections({
|
||||
displayWorktrees,
|
||||
sortMode: state.view.sortBy,
|
||||
filters: rowFilters,
|
||||
search: state.search,
|
||||
groupMode: state.view.groupBy,
|
||||
pinnedIds: state.pinnedIds,
|
||||
showPinnedInGroups: state.showPinnedInGroups,
|
||||
projectCatalog,
|
||||
filterCatalogRepos: state.servedProjectSections?.repos ?? state.desktopRepos,
|
||||
collapsedGroups,
|
||||
workspaceStatuses: state.view.workspaceStatuses,
|
||||
repoHostIdByRepoId: state.repoHostIdByRepoId
|
||||
})
|
||||
const feed = sidebarFeed?.feed ?? null
|
||||
const sectionsResult = useHostListSections({ client, feed, displayWorktrees, state })
|
||||
const { collapsedGroups, projectOrderBy } = sectionsResult
|
||||
const { uniqueRepos } = sectionsResult
|
||||
const activeFilterCount = useMemo(
|
||||
() =>
|
||||
@@ -172,8 +142,9 @@ export function useHostScreenController({
|
||||
activeWorktreeScroll,
|
||||
catalog,
|
||||
collapsedGroups,
|
||||
feed,
|
||||
// Resolved: the desktop's value once read; the served catalog's (or the default) before.
|
||||
projectOrderBy: projectCatalog.projectOrderBy,
|
||||
projectOrderBy,
|
||||
client,
|
||||
connState,
|
||||
contentMaxWidth,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect } from 'react'
|
||||
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../src/shared/sidebar-inputs-switch'
|
||||
import { getCachedWorktrees } from '../cache/worktree-cache'
|
||||
import { loadPinnedIds } from '../storage/preferences'
|
||||
import { loadHosts, updateLastConnected } from '../transport/host-store'
|
||||
@@ -37,7 +38,8 @@ export function useHostScreenIdentity(args: {
|
||||
|
||||
// Load persisted pins from local cache; view settings are no longer local (they sync via ui.get).
|
||||
useEffect(() => {
|
||||
if (!hostId) {
|
||||
// Switch point: a feed-built list takes pins from the host alone.
|
||||
if (!hostId || ORCA_SIDEBAR_INPUTS_IS_MAIN) {
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Worktree } from '../worktree/workspace-list-sections'
|
||||
import type { ServedProjectSections } from '../worktree/project-sections-operations'
|
||||
import type { SectionRepo } from '../worktree/workspace-list-types'
|
||||
import { NO_SERVER_WORKSPACES, type ServerWorkspaces } from '../worktree/server-workspaces'
|
||||
import type { ProjectGroup } from '../../../src/shared/project-group-types'
|
||||
|
||||
export function useHostScreenState(hostId: string | undefined, action: string | undefined) {
|
||||
const [initialCache] = useState(() =>
|
||||
@@ -81,6 +82,8 @@ export function useHostScreenState(hostId: string | undefined, action: string |
|
||||
const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set())
|
||||
// Desktop's "Also show pinned worktrees in their original lists"; off by default.
|
||||
const [showPinnedInGroups, setShowPinnedInGroups] = useState(false)
|
||||
// A feed-built list's project groups on a host with no window to publish them.
|
||||
const [hostProjectGroups, setHostProjectGroups] = useState<readonly ProjectGroup[]>([])
|
||||
|
||||
return {
|
||||
actionTarget,
|
||||
@@ -88,6 +91,7 @@ export function useHostScreenState(hostId: string | undefined, action: string |
|
||||
clientRef,
|
||||
confirmDelete,
|
||||
confirmRemoveHost,
|
||||
hostProjectGroups,
|
||||
actionError,
|
||||
error,
|
||||
fetchRepoMetadataInFlightRef,
|
||||
@@ -115,6 +119,7 @@ export function useHostScreenState(hostId: string | undefined, action: string |
|
||||
setCatalogError,
|
||||
setConfirmDelete,
|
||||
setConfirmRemoveHost,
|
||||
setHostProjectGroups,
|
||||
setActionError,
|
||||
setError,
|
||||
setHostLabelById,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../src/shared/sidebar-inputs-switch'
|
||||
import {
|
||||
SIDEBAR_INPUTS_SEMANTICS_VERSION,
|
||||
type SidebarInputsChangedEvent
|
||||
} from '../../../src/shared/sidebar-list/sidebar-inputs-feed-contract'
|
||||
import {
|
||||
readSidebarFeedDomains,
|
||||
type SidebarFeedDomains
|
||||
} from '../../../src/shared/sidebar-list/sidebar-feed-domains'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import {
|
||||
HostSidebarFeedReader,
|
||||
type HostSidebarFeedRead,
|
||||
type HostSidebarFeedTriggers
|
||||
} from './host-sidebar-feed-reader'
|
||||
|
||||
/** What this page's list rules mean; a host publishing another meaning would build another list. */
|
||||
const SUPPORTED_SIDEBAR_SEMANTICS_VERSIONS: readonly number[] = [SIDEBAR_INPUTS_SEMANTICS_VERSION]
|
||||
|
||||
export type HostSidebarFeed =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'ready'; domains: SidebarFeedDomains }
|
||||
/** The host lacks the feed or means other things by it: only reopening the page can match it. */
|
||||
| { kind: 'reopen' }
|
||||
|
||||
export type HostSidebarFeedControls = {
|
||||
feed: HostSidebarFeed
|
||||
/** Stable for the screen's life, so the refresh lifecycle never restarts for them. */
|
||||
triggers: HostSidebarFeedTriggers
|
||||
}
|
||||
|
||||
const NOTHING_READ: HostSidebarFeedRead = { installed: null, unavailable: false }
|
||||
|
||||
export function readHostSidebarFeed({
|
||||
installed,
|
||||
unavailable
|
||||
}: HostSidebarFeedRead): HostSidebarFeed {
|
||||
if (unavailable) {
|
||||
return { kind: 'reopen' }
|
||||
}
|
||||
if (!installed) {
|
||||
return { kind: 'loading' }
|
||||
}
|
||||
if (!SUPPORTED_SIDEBAR_SEMANTICS_VERSIONS.includes(installed.semanticsVersion)) {
|
||||
return { kind: 'reopen' }
|
||||
}
|
||||
return { kind: 'ready', domains: readSidebarFeedDomains(installed) }
|
||||
}
|
||||
|
||||
/** The connected host's installed sidebar feed; a new client is a new host, read from scratch. */
|
||||
export function useHostSidebarFeed(client: RpcClient | null): HostSidebarFeedControls {
|
||||
const readerRef = useRef<HostSidebarFeedReader | null>(null)
|
||||
const installListenersRef = useRef(new Set<() => void>())
|
||||
const [read, setRead] = useState(NOTHING_READ)
|
||||
|
||||
useEffect(() => {
|
||||
setRead(NOTHING_READ)
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
let lastInstalled: HostSidebarFeedRead['installed'] = null
|
||||
const reader = new HostSidebarFeedReader(client, (next) => {
|
||||
setRead(next)
|
||||
if (next.installed && next.installed !== lastInstalled) {
|
||||
installListenersRef.current.forEach((listener) => listener())
|
||||
}
|
||||
lastInstalled = next.installed
|
||||
})
|
||||
readerRef.current = reader
|
||||
return () => {
|
||||
reader.dispose()
|
||||
if (readerRef.current === reader) {
|
||||
readerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [client])
|
||||
|
||||
const triggers = useMemo(
|
||||
(): HostSidebarFeedTriggers => ({
|
||||
refresh: () => readerRef.current?.refresh(),
|
||||
changed: (notice: Pick<SidebarInputsChangedEvent, 'generation' | 'revision'>) =>
|
||||
readerRef.current?.changed(notice),
|
||||
onInstalled: (listener: () => void) => {
|
||||
installListenersRef.current.add(listener)
|
||||
return () => installListenersRef.current.delete(listener)
|
||||
}
|
||||
}),
|
||||
[]
|
||||
)
|
||||
const feed = useMemo(() => readHostSidebarFeed(read), [read])
|
||||
return { feed, triggers }
|
||||
}
|
||||
|
||||
/** Switch point: a page built without the feed reads none. */
|
||||
export const useSwitchedHostSidebarFeed: (
|
||||
client: RpcClient | null
|
||||
) => HostSidebarFeedControls | null = ORCA_SIDEBAR_INPUTS_IS_MAIN ? useHostSidebarFeed : () => null
|
||||
@@ -27,17 +27,28 @@ import type {
|
||||
} from '../../../src/shared/ui-chrome-types'
|
||||
import { getWorktreeCardModeUpdates } from '../../../src/shared/worktree/card-properties'
|
||||
import type { HostScreenState } from './use-host-screen-state'
|
||||
import { applyCollapse, withPendingViewChanges } from './host-view-pending-changes'
|
||||
import {
|
||||
useSwitchedHostPendingViewChanges,
|
||||
type PendingViewStatus
|
||||
} from './use-host-pending-view-changes'
|
||||
import type { FilterState } from '../worktree/workspace-list-types'
|
||||
|
||||
const REFUSED_NOTE = 'The desktop did not take that change.'
|
||||
const NOTHING_PENDING: PendingViewStatus = { pending: [], stale: false }
|
||||
|
||||
export function useHostViewSettings(args: {
|
||||
client: RpcClient | null
|
||||
connState: ConnectionState
|
||||
state: HostScreenState
|
||||
}) {
|
||||
const { client, connState, state } = args
|
||||
const { clientRef, setShowPinnedInGroups, setView, viewRef } = state
|
||||
const { clientRef, setActionError, setShowPinnedInGroups, setView, viewRef } = state
|
||||
|
||||
const localEditsRef = useRef(0)
|
||||
// Null unless the page lists from the feed: the changes the desktop has not answered.
|
||||
const pendingViewChanges = useSwitchedHostPendingViewChanges(client)
|
||||
const pendingChanges = pendingViewChanges?.controls ?? null
|
||||
|
||||
// The one writer of the view: the ref is its synchronous mirror for edits made before a render.
|
||||
const applyViewState = useCallback((next: MobileViewState) => {
|
||||
@@ -45,6 +56,69 @@ export function useHostViewSettings(args: {
|
||||
setView(next)
|
||||
}, [])
|
||||
|
||||
// Merge the desktop's shared view settings (PersistedUIState) onto local state so desktop changes appear here.
|
||||
const syncViewSettingsFromDesktop = useCallback(
|
||||
async (options: { reconnected?: boolean } = {}) => {
|
||||
if (!client || connState !== 'connected') {
|
||||
return
|
||||
}
|
||||
const requestClient = client
|
||||
const editsAtRequest = localEditsRef.current
|
||||
const opsAtRequest = pendingChanges?.lastOpId() ?? 0
|
||||
try {
|
||||
// Each half applies on its own, so one failing read does not hold back the other.
|
||||
const [uiReply, settingsReply] = await Promise.all([
|
||||
hostViewSettingsRead.request(requestClient).catch(() => null),
|
||||
hostListSettingsRead.request(requestClient).catch(() => null)
|
||||
])
|
||||
// Why the client, not the host id: a new host always arrives with a new client.
|
||||
if (clientRef.current !== requestClient) {
|
||||
return
|
||||
}
|
||||
const hostSettings = settingsReply
|
||||
? hostListSettingsRead.interpret(settingsReply)
|
||||
: { accepted: false as const }
|
||||
if (hostSettings.accepted) {
|
||||
setShowPinnedInGroups(hostSettings.value.showPinnedWorktreesInGroups)
|
||||
}
|
||||
const uiRead = uiReply ? hostViewSettingsRead.interpret(uiReply) : null
|
||||
const read = uiRead?.accepted ? uiRead.value : undefined
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
|
||||
const ui = read as WorkspaceViewSettings | undefined
|
||||
// What the desktop had not answered when the read went out still shows as asked.
|
||||
const waiting =
|
||||
pendingChanges && ui
|
||||
? pendingChanges.reconcile(ui, {
|
||||
reconnected: options.reconnected === true,
|
||||
sentByOpId: opsAtRequest
|
||||
})
|
||||
: []
|
||||
// Why skip after a local edit: this reply may predate it; the edit's own change event re-reads.
|
||||
if (localEditsRef.current !== editsAtRequest) {
|
||||
return
|
||||
}
|
||||
let next = ui
|
||||
? withPendingViewChanges(applyDesktopViewSettings(viewRef.current, ui), waiting)
|
||||
: viewRef.current
|
||||
if (hostSettings.accepted) {
|
||||
const { compactWorktreeCards, newCardStyle } = hostSettings.value
|
||||
next = { ...next, compactWorktreeCards, newCardStyle }
|
||||
}
|
||||
if (next !== viewRef.current) {
|
||||
applyViewState(next)
|
||||
}
|
||||
} catch {
|
||||
// Transient transport failure; retry on the next focus/connect.
|
||||
}
|
||||
},
|
||||
[client, connState, applyViewState, pendingChanges]
|
||||
)
|
||||
// A refusal undoes the change by re-reading what the desktop holds.
|
||||
const refused = useCallback(() => {
|
||||
setActionError(REFUSED_NOTE)
|
||||
void syncViewSettingsFromDesktop()
|
||||
}, [setActionError, syncViewSettingsFromDesktop])
|
||||
|
||||
// Apply the change locally, then patch the desktop's shared store (ui.set) so both apps stay in sync.
|
||||
const persistViewSettings = useCallback(
|
||||
(patch: MobileViewPatch, options: { cardLayoutPreset?: boolean } = {}) => {
|
||||
@@ -66,57 +140,19 @@ export function useHostViewSettings(args: {
|
||||
if (Object.keys(payload).length === 0) {
|
||||
return
|
||||
}
|
||||
if (pendingChanges) {
|
||||
void pendingChanges
|
||||
.sendView(patch, payload)
|
||||
.then((outcome) => outcome === 'refused' && refused())
|
||||
return
|
||||
}
|
||||
void hostViewSettingsWrite.request(client, payload).catch(() => {
|
||||
// Best-effort: view settings are a convenience preference.
|
||||
})
|
||||
},
|
||||
[client, applyViewState]
|
||||
[client, applyViewState, pendingChanges, refused]
|
||||
)
|
||||
|
||||
// Merge the desktop's shared view settings (PersistedUIState) onto local state so desktop changes appear here.
|
||||
const syncViewSettingsFromDesktop = useCallback(async () => {
|
||||
if (!client || connState !== 'connected') {
|
||||
return
|
||||
}
|
||||
const requestClient = client
|
||||
const editsAtRequest = localEditsRef.current
|
||||
try {
|
||||
// Each half applies on its own, so one failing read does not hold back the other.
|
||||
const [uiReply, settingsReply] = await Promise.all([
|
||||
hostViewSettingsRead.request(requestClient).catch(() => null),
|
||||
hostListSettingsRead.request(requestClient).catch(() => null)
|
||||
])
|
||||
// Why the client, not the host id: a new host always arrives with a new client.
|
||||
if (clientRef.current !== requestClient) {
|
||||
return
|
||||
}
|
||||
const hostSettings = settingsReply
|
||||
? hostListSettingsRead.interpret(settingsReply)
|
||||
: { accepted: false as const }
|
||||
if (hostSettings.accepted) {
|
||||
setShowPinnedInGroups(hostSettings.value.showPinnedWorktreesInGroups)
|
||||
}
|
||||
// Why skip after a local edit: this reply may predate it; the edit's own change event re-reads.
|
||||
if (localEditsRef.current !== editsAtRequest) {
|
||||
return
|
||||
}
|
||||
const uiRead = uiReply ? hostViewSettingsRead.interpret(uiReply) : null
|
||||
const read = uiRead?.accepted ? uiRead.value : undefined
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
|
||||
const ui = read as WorkspaceViewSettings | undefined
|
||||
let next = ui ? applyDesktopViewSettings(viewRef.current, ui) : viewRef.current
|
||||
if (hostSettings.accepted) {
|
||||
const { compactWorktreeCards, newCardStyle } = hostSettings.value
|
||||
next = { ...next, compactWorktreeCards, newCardStyle }
|
||||
}
|
||||
if (next !== viewRef.current) {
|
||||
applyViewState(next)
|
||||
}
|
||||
} catch {
|
||||
// Transient transport failure; retry on the next focus/connect.
|
||||
}
|
||||
}, [client, connState, applyViewState])
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(value: MobileViewState['sortBy']) => {
|
||||
persistViewSettings({ sortBy: value })
|
||||
@@ -210,21 +246,37 @@ export function useHostViewSettings(args: {
|
||||
|
||||
const toggleCollapsed = useCallback(
|
||||
(key: string) => {
|
||||
// One section's change, so another device's edit to a sibling section is kept.
|
||||
if (pendingChanges && client) {
|
||||
const collapsed = !viewRef.current.collapsedGroups.includes(key)
|
||||
localEditsRef.current += 1
|
||||
applyViewState(applyCollapse(viewRef.current, key, collapsed))
|
||||
void pendingChanges.sendCollapse(key, collapsed).then((outcome) => {
|
||||
if (outcome === 'unavailable') {
|
||||
persistViewSettings({ collapsedGroups: [...viewRef.current.collapsedGroups] })
|
||||
} else if (outcome === 'refused') {
|
||||
refused()
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
const next = new Set(viewRef.current.collapsedGroups)
|
||||
if (!next.delete(key)) {
|
||||
next.add(key)
|
||||
}
|
||||
persistViewSettings({ collapsedGroups: [...next] })
|
||||
},
|
||||
[persistViewSettings]
|
||||
[client, applyViewState, persistViewSettings, pendingChanges, refused]
|
||||
)
|
||||
const toggleWorktreeLineage = useCallback(
|
||||
(item: Worktree) => toggleCollapsed(getMobileWorkspaceLineageGroupKey(item)),
|
||||
(item: Worktree) =>
|
||||
toggleCollapsed(item.lineageGroupKey ?? getMobileWorkspaceLineageGroupKey(item)),
|
||||
[toggleCollapsed]
|
||||
)
|
||||
|
||||
return {
|
||||
clearFilters,
|
||||
pendingViewChanges: pendingViewChanges?.status ?? NOTHING_PENDING,
|
||||
setAgentActivityDisplayMode,
|
||||
setCardLayout,
|
||||
setProjectOrderBy,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from 'react'
|
||||
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../src/shared/sidebar-inputs-switch'
|
||||
import type { useRouter } from 'expo-router'
|
||||
import { floatingWorkspaceSessionPath } from '../session/floating-workspace'
|
||||
import { savePinnedIds } from '../storage/preferences'
|
||||
@@ -95,6 +96,10 @@ export function useHostWorktreeActions(args: {
|
||||
|
||||
const updateLocalPins = useCallback(
|
||||
(worktreeId: string, pinned: boolean) => {
|
||||
// Switch point: a feed-built list keeps no pin cache of its own.
|
||||
if (ORCA_SIDEBAR_INPUTS_IS_MAIN) {
|
||||
return
|
||||
}
|
||||
setPinnedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (pinned) {
|
||||
@@ -252,7 +257,10 @@ export function useHostWorktreeActions(args: {
|
||||
if (routeHost === null) {
|
||||
return
|
||||
}
|
||||
setOptimisticActiveWorktreeIdentity(getWorktreeRowIdentity(item))
|
||||
// Switch point: a feed-built list highlights only what the desktop has open.
|
||||
if (!ORCA_SIDEBAR_INPUTS_IS_MAIN) {
|
||||
setOptimisticActiveWorktreeIdentity(getWorktreeRowIdentity(item))
|
||||
}
|
||||
const rowClient = clientForRow(item)
|
||||
if (rowClient && connState === 'connected') {
|
||||
void worktreeActivate
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../src/shared/sidebar-inputs-switch'
|
||||
import { useFocusEffect } from 'expo-router'
|
||||
import { setCachedWorktrees } from '../cache/worktree-cache'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
@@ -6,6 +7,7 @@ import type { ConnectionState } from '../transport/types'
|
||||
import { RpcIncompatibleReplyError } from '../transport/rpc-incompatible-reply-error'
|
||||
import { useWorktreeResync } from '../transport/use-worktree-resync'
|
||||
import { startHostWorktreeRefresh } from '../worktree/host-worktree-refresh'
|
||||
import type { HostSidebarFeedTriggers } from './host-sidebar-feed-reader'
|
||||
import { areWorktreeListsEqual } from '../worktree/worktree-list-snapshot'
|
||||
import {
|
||||
clearConfirmedActiveWorktreeIdentity,
|
||||
@@ -28,8 +30,10 @@ export function useHostWorktreeCatalog(args: {
|
||||
fetchRepoMetadata: FetchHostRepoMetadata
|
||||
hostCapabilities: readonly string[]
|
||||
hostId: string | undefined
|
||||
/** Null in a build that lists without the feed. */
|
||||
sidebarFeed: HostSidebarFeedTriggers | null
|
||||
state: HostScreenState
|
||||
syncViewSettingsFromDesktop: () => Promise<void>
|
||||
syncViewSettingsFromDesktop: (options?: { reconnected?: boolean }) => Promise<void>
|
||||
}) {
|
||||
const {
|
||||
client,
|
||||
@@ -38,6 +42,7 @@ export function useHostWorktreeCatalog(args: {
|
||||
fetchRepoMetadata,
|
||||
hostCapabilities,
|
||||
hostId,
|
||||
sidebarFeed,
|
||||
state,
|
||||
syncViewSettingsFromDesktop
|
||||
} = args
|
||||
@@ -87,8 +92,10 @@ export function useHostWorktreeCatalog(args: {
|
||||
[hostCapabilities]
|
||||
)
|
||||
|
||||
// A re-read asked for during a flight: that flight may predate the change that asked.
|
||||
const fetchWorktreesQueuedRef = useRef(false)
|
||||
const fetchWorktrees = useCallback(
|
||||
async (options: { allowDuringModal?: boolean } = {}) => {
|
||||
async (options: { allowDuringModal?: boolean; queueIfInFlight?: boolean } = {}) => {
|
||||
if (!client || connState !== 'connected' || !hostId) {
|
||||
return
|
||||
}
|
||||
@@ -97,6 +104,7 @@ export function useHostWorktreeCatalog(args: {
|
||||
}
|
||||
// Why: prevent slow remote hosts from stacking overlapping worktree.ps requests during polling.
|
||||
if (fetchWorktreesInFlightRef.current) {
|
||||
fetchWorktreesQueuedRef.current ||= options.queueIfInFlight === true
|
||||
return
|
||||
}
|
||||
fetchWorktreesInFlightRef.current = true
|
||||
@@ -150,16 +158,22 @@ export function useHostWorktreeCatalog(args: {
|
||||
setSleptIds((prev) => retainLiveSleptWorktreeIdentities(prev, confirmed))
|
||||
|
||||
// Sync pin state from server so desktop-initiated pins reflect without relying on stale AsyncStorage.
|
||||
// Switch point: a feed-built list keeps no pin cache of its own.
|
||||
const serverPinned = new Set(confirmed.filter((w) => w.isPinned).map((w) => w.worktreeId))
|
||||
setPinnedIds((prev) => {
|
||||
if (serverPinned.size === prev.size && [...serverPinned].every((id) => prev.has(id))) {
|
||||
return prev
|
||||
}
|
||||
if (hostId) {
|
||||
void savePinnedIds(hostId, serverPinned)
|
||||
}
|
||||
return serverPinned
|
||||
})
|
||||
if (!ORCA_SIDEBAR_INPUTS_IS_MAIN) {
|
||||
setPinnedIds((prev) => {
|
||||
if (
|
||||
serverPinned.size === prev.size &&
|
||||
[...serverPinned].every((id) => prev.has(id))
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
if (hostId) {
|
||||
void savePinnedIds(hostId, serverPinned)
|
||||
}
|
||||
return serverPinned
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Will retry on reconnect
|
||||
@@ -172,10 +186,19 @@ export function useHostWorktreeCatalog(args: {
|
||||
}
|
||||
} finally {
|
||||
fetchWorktreesInFlightRef.current = false
|
||||
if (fetchWorktreesQueuedRef.current) {
|
||||
fetchWorktreesQueuedRef.current = false
|
||||
// The latest render's fetch, without this flight's modal override: the client may have changed.
|
||||
void fetchWorktreesRef.current({ queueIfInFlight: true })
|
||||
}
|
||||
}
|
||||
},
|
||||
[client, connState, hostId, fetchServerRows]
|
||||
)
|
||||
const fetchWorktreesRef = useRef(fetchWorktrees)
|
||||
useEffect(() => {
|
||||
fetchWorktreesRef.current = fetchWorktrees
|
||||
}, [fetchWorktrees])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
@@ -194,9 +217,17 @@ export function useHostWorktreeCatalog(args: {
|
||||
client,
|
||||
fetchWorktrees,
|
||||
fetchRepoMetadata,
|
||||
syncViewSettings: syncViewSettingsFromDesktop
|
||||
syncViewSettings: syncViewSettingsFromDesktop,
|
||||
sidebarFeed
|
||||
})
|
||||
}, [client, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
|
||||
}, [
|
||||
client,
|
||||
connState,
|
||||
fetchWorktrees,
|
||||
fetchRepoMetadata,
|
||||
syncViewSettingsFromDesktop,
|
||||
sidebarFeed
|
||||
])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
|
||||
@@ -57,7 +57,8 @@ function buildController(options: SheetOptions) {
|
||||
setFilter: vi.fn(),
|
||||
setProjectOrderBy: vi.fn(),
|
||||
toggleCardProperty: vi.fn(),
|
||||
toggleRepoFilter: vi.fn()
|
||||
toggleRepoFilter: vi.fn(),
|
||||
pendingViewChanges: { pending: [], stale: false }
|
||||
}
|
||||
const controller = {
|
||||
activeFilterCount: 0,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, type ReactNode } from 'react'
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { useBackClaim } from '../navigation/use-back-claim'
|
||||
@@ -75,6 +75,22 @@ export function WorkspaceOptionsSheet({ controller }: { controller: HostScreenCo
|
||||
)
|
||||
}
|
||||
|
||||
/** The phone's own changes the desktop has not answered; a slow answer is not a refusal. */
|
||||
function WorkspaceOptionsPending({ settings }: Pick<HostScreenController, 'settings'>) {
|
||||
const { pending, stale } = settings.pendingViewChanges
|
||||
if (!pending.some((change) => change.kind === 'view')) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<View style={styles.optionPending} accessibilityLiveRegion="polite">
|
||||
<ActivityIndicator size="small" color={colors.textMuted} />
|
||||
<Text style={styles.filterModalSubtitle}>
|
||||
{stale ? 'Still waiting for the desktop' : 'Saving to the desktop'}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// Its own component so the host list is built only while the sheet is open.
|
||||
function WorkspaceOptionsContent({
|
||||
controller,
|
||||
@@ -134,6 +150,7 @@ function WorkspaceOptionsContent({
|
||||
<View style={styles.filterModalHeading}>
|
||||
<Text style={styles.filterModalTitle}>{WORKSPACE_OPTIONS_TITLE}</Text>
|
||||
<Text style={styles.filterModalSubtitle}>{WORKSPACE_VIEW_SHARED_NOTE}</Text>
|
||||
<WorkspaceOptionsPending settings={settings} />
|
||||
</View>
|
||||
{controller.activeFilterCount > 0 && (
|
||||
<Pressable onPress={settings.clearFilters}>
|
||||
|
||||
@@ -45,6 +45,18 @@ export function HostWorkspaceListStates(
|
||||
return null
|
||||
}
|
||||
|
||||
/** The host serves a list this page cannot build: a page from the host's own build can. */
|
||||
export function HostWorkspaceListReopen() {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.emptyText}>Reopen to update</Text>
|
||||
<Text style={styles.catalogErrorDetail}>
|
||||
This host lists workspaces in a way this page does not know yet.
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
centered: {
|
||||
flex: 1,
|
||||
|
||||
@@ -41,7 +41,9 @@ describe('startHostWorktreeRefresh', () => {
|
||||
stop = null
|
||||
})
|
||||
|
||||
function start(): void {
|
||||
function start(
|
||||
sidebarFeed: Parameters<typeof startHostWorktreeRefresh>[0]['sidebarFeed'] = null
|
||||
): void {
|
||||
const client = {
|
||||
subscribe: vi.fn(
|
||||
(_method: string, _params: unknown, listener: (payload: unknown) => void) => {
|
||||
@@ -50,7 +52,13 @@ describe('startHostWorktreeRefresh', () => {
|
||||
}
|
||||
)
|
||||
} as unknown as RpcClient
|
||||
stop = startHostWorktreeRefresh({ client, fetchWorktrees, fetchRepoMetadata, syncViewSettings })
|
||||
stop = startHostWorktreeRefresh({
|
||||
client,
|
||||
fetchWorktrees,
|
||||
fetchRepoMetadata,
|
||||
syncViewSettings,
|
||||
sidebarFeed
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -155,4 +163,52 @@ describe('startHostWorktreeRefresh', () => {
|
||||
expect(fetchWorktrees).toHaveBeenCalledTimes(2)
|
||||
expect(fetchRepoMetadata).toHaveBeenCalledWith({ force: true, queueIfInFlight: true })
|
||||
})
|
||||
|
||||
describe('with the host’s sidebar feed', () => {
|
||||
it('reads it at start, on every ready and on foreground, and never polls', async () => {
|
||||
const sidebarFeed = { refresh: vi.fn(), changed: vi.fn(), onInstalled: vi.fn(() => () => {}) }
|
||||
start(sidebarFeed)
|
||||
expect(sidebarFeed.refresh).toHaveBeenCalledTimes(1)
|
||||
eventListener?.({ type: 'ready', subscriptionId: 'events-1' })
|
||||
expect(sidebarFeed.refresh).toHaveBeenCalledTimes(2)
|
||||
eventListener?.({ type: 'ready', subscriptionId: 'events-2' })
|
||||
expect(sidebarFeed.refresh).toHaveBeenCalledTimes(3)
|
||||
expect(syncViewSettings).toHaveBeenCalledWith({ reconnected: true })
|
||||
appState.listener?.('active')
|
||||
expect(sidebarFeed.refresh).toHaveBeenCalledTimes(4)
|
||||
|
||||
fetchWorktrees.mockClear()
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(fetchWorktrees).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-reads each row’s live content on every install, queued behind a read in flight', () => {
|
||||
let installed = (): void => {}
|
||||
const stopFollowing = vi.fn()
|
||||
const sidebarFeed = {
|
||||
refresh: vi.fn(),
|
||||
changed: vi.fn(),
|
||||
onInstalled: vi.fn((listener: () => void) => {
|
||||
installed = listener
|
||||
return stopFollowing
|
||||
})
|
||||
}
|
||||
start(sidebarFeed)
|
||||
fetchWorktrees.mockClear()
|
||||
installed()
|
||||
expect(fetchWorktrees).toHaveBeenCalledWith({ queueIfInFlight: true })
|
||||
stop?.()
|
||||
stop = null
|
||||
expect(stopFollowing).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hands each change notice to the feed', () => {
|
||||
const sidebarFeed = { refresh: vi.fn(), changed: vi.fn(), onInstalled: vi.fn(() => () => {}) }
|
||||
start(sidebarFeed)
|
||||
eventListener?.({ type: 'sidebarInputsChanged', generation: 'g', revision: 4 })
|
||||
expect(sidebarFeed.changed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ generation: 'g', revision: 4 })
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { AppState } from 'react-native'
|
||||
import type { RuntimeClientEventStreamMessage } from '../../../src/shared/runtime-client-events'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { HostSidebarFeedTriggers } from '../host-screen/host-sidebar-feed-reader'
|
||||
|
||||
const WORKTREE_REFRESH_MS = 3000
|
||||
|
||||
type WorktreeRefreshOptions = { allowDuringModal?: boolean }
|
||||
type WorktreeRefreshOptions = { allowDuringModal?: boolean; queueIfInFlight?: boolean }
|
||||
type RepoRefreshOptions = { force?: boolean; queueIfInFlight?: boolean }
|
||||
|
||||
type HostWorktreeRefreshArgs = {
|
||||
@@ -12,14 +13,17 @@ type HostWorktreeRefreshArgs = {
|
||||
fetchWorktrees: (options?: WorktreeRefreshOptions) => Promise<void>
|
||||
fetchRepoMetadata: (options?: RepoRefreshOptions) => Promise<void>
|
||||
/** Re-reads the Workspace options the phone shares with the desktop. */
|
||||
syncViewSettings: () => Promise<void>
|
||||
syncViewSettings: (options?: { reconnected?: boolean }) => Promise<void>
|
||||
/** The host's sidebar feed, which replaces the poll: its notices say when anything changed. */
|
||||
sidebarFeed?: HostSidebarFeedTriggers | null
|
||||
}
|
||||
|
||||
export function startHostWorktreeRefresh({
|
||||
client,
|
||||
fetchWorktrees,
|
||||
fetchRepoMetadata,
|
||||
syncViewSettings
|
||||
syncViewSettings,
|
||||
sidebarFeed
|
||||
}: HostWorktreeRefreshArgs): () => void {
|
||||
let stale = false
|
||||
let eventStreamReady = false
|
||||
@@ -28,6 +32,7 @@ export function startHostWorktreeRefresh({
|
||||
if (AppState.currentState !== 'active') {
|
||||
return
|
||||
}
|
||||
sidebarFeed?.refresh()
|
||||
void fetchWorktrees({ allowDuringModal: true })
|
||||
void fetchRepoMetadata({ queueIfInFlight: true })
|
||||
}
|
||||
@@ -37,18 +42,20 @@ export function startHostWorktreeRefresh({
|
||||
refreshOnForeground()
|
||||
}
|
||||
})
|
||||
const interval = setInterval(() => {
|
||||
if (AppState.currentState !== 'active') {
|
||||
return
|
||||
}
|
||||
void fetchWorktrees()
|
||||
// Why: desktop Settings repo edits (icon/color/name, repo removal) now emit `reposChanged`
|
||||
// (#11994), but a host on an older build does not; keep this periodic repo.list as the
|
||||
// convergence safety-net. fetchRepoMetadata self-throttles to REPO_METADATA_REFRESH_MS
|
||||
// (60s), so this is ~1 request/min while foregrounded — the AppState gate is what removes
|
||||
// the waste (both stop while backgrounded).
|
||||
void fetchRepoMetadata()
|
||||
}, WORKTREE_REFRESH_MS)
|
||||
const interval = sidebarFeed
|
||||
? null
|
||||
: setInterval(() => {
|
||||
if (AppState.currentState !== 'active') {
|
||||
return
|
||||
}
|
||||
void fetchWorktrees()
|
||||
// Why: desktop Settings repo edits (icon/color/name, repo removal) now emit `reposChanged`
|
||||
// (#11994), but a host on an older build does not; keep this periodic repo.list as the
|
||||
// convergence safety-net. fetchRepoMetadata self-throttles to REPO_METADATA_REFRESH_MS
|
||||
// (60s), so this is ~1 request/min while foregrounded — the AppState gate is what removes
|
||||
// the waste (both stop while backgrounded).
|
||||
void fetchRepoMetadata()
|
||||
}, WORKTREE_REFRESH_MS)
|
||||
const unsubscribe = client.subscribe(
|
||||
'runtime.clientEvents.subscribe',
|
||||
null,
|
||||
@@ -58,13 +65,15 @@ export function startHostWorktreeRefresh({
|
||||
}
|
||||
const event = payload as RuntimeClientEventStreamMessage | { type: 'error' }
|
||||
if (event.type === 'ready') {
|
||||
// Every ready: a change between the last read and this subscription sent no notice.
|
||||
sidebarFeed?.refresh()
|
||||
const replayedAfterReconnect = eventStreamReady
|
||||
eventStreamReady = true
|
||||
if (replayedAfterReconnect) {
|
||||
// Why: client events are not queued while disconnected, so re-read both snapshots after replay.
|
||||
void fetchWorktrees()
|
||||
void fetchRepoMetadata({ force: true, queueIfInFlight: true })
|
||||
void syncViewSettings()
|
||||
void syncViewSettings({ reconnected: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -72,7 +81,9 @@ export function startHostWorktreeRefresh({
|
||||
eventStreamReady = false
|
||||
return
|
||||
}
|
||||
if (event.type === 'reposChanged') {
|
||||
if (event.type === 'sidebarInputsChanged') {
|
||||
sidebarFeed?.changed(event)
|
||||
} else if (event.type === 'reposChanged') {
|
||||
// Why: folder workspace mutations publish reposChanged, and worktree.ps owns this catalog.
|
||||
void fetchWorktrees()
|
||||
void fetchRepoMetadata({ force: true, queueIfInFlight: true })
|
||||
@@ -87,12 +98,20 @@ export function startHostWorktreeRefresh({
|
||||
}
|
||||
)
|
||||
|
||||
// A row's live content (agents, terminals) may follow any install; worktree.ps carries it.
|
||||
const stopFollowingFeed = sidebarFeed?.onInstalled(
|
||||
() => void fetchWorktrees({ queueIfInFlight: true })
|
||||
)
|
||||
sidebarFeed?.refresh()
|
||||
void fetchWorktrees()
|
||||
void fetchRepoMetadata({ force: true, queueIfInFlight: true })
|
||||
|
||||
return () => {
|
||||
stale = true
|
||||
clearInterval(interval)
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
stopFollowingFeed?.()
|
||||
appStateSubscription.remove()
|
||||
unsubscribe()
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ describe('buildProjectSectionCatalog', () => {
|
||||
repos: [repo('r1'), repo('r1', 'runtime:m4air')],
|
||||
projects: [],
|
||||
setups: [],
|
||||
projectOrderBy: 'manual'
|
||||
projectOrderBy: 'manual',
|
||||
projectGroups: undefined
|
||||
}
|
||||
const catalog = buildProjectSectionCatalog(
|
||||
served,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from 'zod'
|
||||
import { MOBILE_PROJECT_SECTIONS_METHOD } from '../../../src/shared/mobile-project-sections-contract'
|
||||
import { normalizeExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import type { ProjectHostSetupMethod } from '../../../src/shared/project-types'
|
||||
import type { ProjectGroup, ProjectGroupCreatedFrom } from '../../../src/shared/project-group-types'
|
||||
import type { RepoKind } from '../../../src/shared/repo-types'
|
||||
import type { ProjectOrderBy } from '../../../src/shared/ui-chrome-types'
|
||||
import { hostUnionArms, salvagedOptional, salvagingArray } from '../../../src/shared/zod-salvage'
|
||||
@@ -56,12 +57,35 @@ const setupSchema = z.object({
|
||||
executionHostId: salvagedOptional('executionHostId', hostIdSchema)
|
||||
})
|
||||
|
||||
const PROJECT_GROUP_CREATED_FROM_ARMS = hostUnionArms<ProjectGroupCreatedFrom>({
|
||||
manual: true,
|
||||
'folder-scan': true,
|
||||
migration: true
|
||||
})
|
||||
|
||||
const projectGroupSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
parentPath: z.string().nullable(),
|
||||
connectionId: salvagedOptional('connectionId', z.string().nullable()),
|
||||
executionHostId: salvagedOptional('executionHostId', z.string().nullable()),
|
||||
parentGroupId: z.string().nullable(),
|
||||
createdFrom: z.enum(PROJECT_GROUP_CREATED_FROM_ARMS).catch('manual'),
|
||||
tabOrder: z.number(),
|
||||
isCollapsed: z.boolean(),
|
||||
color: z.string().nullable(),
|
||||
createdAt: z.number(),
|
||||
updatedAt: z.number()
|
||||
}) satisfies z.ZodType<ProjectGroup, unknown>
|
||||
|
||||
const catalogSchema = z.object({
|
||||
repos: salvagingArray(repoSchema),
|
||||
projects: salvagingArray(z.object({ id: z.string(), displayName: z.string() })),
|
||||
setups: salvagingArray(setupSchema),
|
||||
// Until the phone has read the shared UI value (projectOrderBy, ui.get), it orders by this.
|
||||
projectOrderBy: z.enum(PROJECT_ORDER_BY_ARMS).catch('manual')
|
||||
projectOrderBy: z.enum(PROJECT_ORDER_BY_ARMS).catch('manual'),
|
||||
// Every host's project groups, for a list built from the sidebar feed; older desktops omit it.
|
||||
projectGroups: salvagedOptional('projectGroups', salvagingArray(projectGroupSchema))
|
||||
})
|
||||
|
||||
/** The desktop sidebar's Project grouping inputs, as this phone reads them. */
|
||||
@@ -81,6 +105,20 @@ export const projectSectionsRead = bindDeferredRpcOperation(
|
||||
})
|
||||
)
|
||||
|
||||
/** A host's own project groups, which a feed-built list files folder workspaces under. */
|
||||
export const hostProjectGroupsRead = bindDeferredRpcOperation(
|
||||
defineRpcOperation({
|
||||
name: 'projectGroup.host-groups-or-skip',
|
||||
method: 'projectGroup.list',
|
||||
acceptance: 'success-result-or-skip',
|
||||
barrier: 'after-caller-barrier',
|
||||
read: rpcResultVariant(
|
||||
'project-groups',
|
||||
z.object({ groups: salvagingArray(projectGroupSchema) }).transform(({ groups }) => groups)
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* The desktop's sidebar catalog: absent (null) on a desktop that predates the read, and left
|
||||
* as it was after a transient failure, so a dropped reply never regroups the list.
|
||||
|
||||
@@ -57,6 +57,8 @@ export type Worktree = {
|
||||
lineageDepth?: number
|
||||
lineageChildCount?: number
|
||||
lineageCollapsed?: boolean
|
||||
/** The collapse key the shared builder gave this row's children; set only on a feed-built list. */
|
||||
lineageGroupKey?: string
|
||||
isLastLineageChild?: boolean
|
||||
liveTerminalCount: number
|
||||
hasAttachedPty: boolean
|
||||
@@ -90,5 +92,7 @@ export type Section = {
|
||||
icon?: 'pin'
|
||||
/** Under Project grouping, the repo whose colour and icon the header shows, as on the desktop. */
|
||||
repo?: SectionRepo
|
||||
/** The header's own count from a feed-built list, which a collapsed section still shows. */
|
||||
count?: number
|
||||
data: Worktree[]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ALL_RPC_METHODS } from './rpc/methods'
|
||||
import { MOBILE_RPC_METHOD_ALLOWLIST } from './runtime-rpc/runtime-rpc-mobile-method-allowlist'
|
||||
|
||||
@@ -46,6 +46,13 @@ const MOBILE_DYNAMIC_RPC_METHODS = [
|
||||
'agentSession.release'
|
||||
]
|
||||
|
||||
// Why: only a page built with ORCA_SIDEBAR_INPUTS=main calls these, and only such a host serves them.
|
||||
const MOBILE_SIDEBAR_INPUTS_RPC_METHODS = [
|
||||
'sidebarInputs.read',
|
||||
'sidebarInputs.repoIcons',
|
||||
'ui.setCollapsed'
|
||||
]
|
||||
|
||||
const MOBILE_STREAMING_CLEANUP_RPC_METHODS = [
|
||||
// Why: shared-control unsubscribe methods are sent from generated cleanup
|
||||
// paths, so literal mobile source scanning cannot discover every one.
|
||||
@@ -98,7 +105,9 @@ function mobileLiteralRpcMethods(): string[] {
|
||||
}
|
||||
|
||||
function mobileRpcMethods(): string[] {
|
||||
return [...new Set([...mobileLiteralRpcMethods(), ...MOBILE_DYNAMIC_RPC_METHODS])].sort()
|
||||
return [...new Set([...mobileLiteralRpcMethods(), ...MOBILE_DYNAMIC_RPC_METHODS])]
|
||||
.filter((method) => !MOBILE_SIDEBAR_INPUTS_RPC_METHODS.includes(method))
|
||||
.sort()
|
||||
}
|
||||
|
||||
function mobileRpcAllowlist(): ReadonlySet<string> {
|
||||
@@ -151,6 +160,30 @@ describe('mobile RPC allowlist', () => {
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
|
||||
it('allows and registers the sidebar feed the phone reads only in a switched-on build', async () => {
|
||||
expect(mobileLiteralRpcMethods()).toEqual(
|
||||
expect.arrayContaining(MOBILE_SIDEBAR_INPUTS_RPC_METHODS)
|
||||
)
|
||||
for (const method of MOBILE_SIDEBAR_INPUTS_RPC_METHODS) {
|
||||
expect(mobileRpcAllowlist().has(method)).toBe(false)
|
||||
}
|
||||
vi.resetModules()
|
||||
vi.doMock('../../shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true }))
|
||||
try {
|
||||
const { MOBILE_RPC_METHOD_ALLOWLIST: switchedOn } =
|
||||
await import('./runtime-rpc/runtime-rpc-mobile-method-allowlist')
|
||||
const { ALL_RPC_METHODS: switchedOnMethods } = await import('./rpc/methods')
|
||||
const registered = new Set<string>(switchedOnMethods.map((method) => method.name))
|
||||
for (const method of MOBILE_SIDEBAR_INPUTS_RPC_METHODS) {
|
||||
expect(switchedOn.has(method)).toBe(true)
|
||||
expect(registered.has(method)).toBe(true)
|
||||
}
|
||||
} finally {
|
||||
vi.doUnmock('../../shared/sidebar-inputs-switch')
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
|
||||
it('allows every cleanup RPC for mobile streaming subscriptions', () => {
|
||||
const allowed = mobileRpcAllowlist()
|
||||
const missing = MOBILE_STREAMING_CLEANUP_RPC_METHODS.filter((method) => !allowed.has(method))
|
||||
|
||||
@@ -106,6 +106,7 @@ class OrcaRuntimeService extends OrcaRuntimeSidebarBase {
|
||||
pairedServerRows: requireSwitchedOn(this.pairedServerRows),
|
||||
workspaceCreations: requireSwitchedOn(this.workspaceCreations),
|
||||
workspaceRows: requireSwitchedOn(this.workspaceRows),
|
||||
repoIcons: requireSwitchedOn(this.repoIconDataUrls),
|
||||
store: () => this.store,
|
||||
onClientEvent: (listener, options) => this.onClientEvent(listener, options)
|
||||
}),
|
||||
|
||||
@@ -108,6 +108,8 @@ export const PAIRED_DESKTOP_METHODS = [
|
||||
'settings.updateTerminalQuickCommands',
|
||||
'terminal.getAutoRestoreFit',
|
||||
'terminal.setAutoRestoreFit',
|
||||
// Switch point: the per-section collapse write and the sidebar feed exist only with ORCA_SIDEBAR_INPUTS=main.
|
||||
...(ORCA_SIDEBAR_INPUTS_IS_MAIN ? (['ui.setCollapsed', 'sidebarInputs.read'] as const) : [])
|
||||
// Switch point: the per-section collapse write, the sidebar feed and its icons exist only with ORCA_SIDEBAR_INPUTS=main.
|
||||
...(ORCA_SIDEBAR_INPUTS_IS_MAIN
|
||||
? (['ui.setCollapsed', 'sidebarInputs.read', 'sidebarInputs.repoIcons'] as const)
|
||||
: [])
|
||||
] as const
|
||||
|
||||
@@ -161,6 +161,8 @@ export type RuntimeStore = {
|
||||
terminalModelQueryAuthority?: GlobalSettings['terminalModelQueryAuthority']
|
||||
worktreeVisibilityDefaults?: GlobalSettings['worktreeVisibilityDefaults']
|
||||
hostSettingOverrides?: GlobalSettings['hostSettingOverrides']
|
||||
activeRuntimeEnvironmentId?: GlobalSettings['activeRuntimeEnvironmentId']
|
||||
showPinnedWorktreesInGroups?: GlobalSettings['showPinnedWorktreesInGroups']
|
||||
agentSkillSharingEnabled?: GlobalSettings['agentSkillSharingEnabled']
|
||||
nativeChatSessionOptions?: GlobalSettings['nativeChatSessionOptions']
|
||||
nativeChatPermissionMode?: GlobalSettings['nativeChatPermissionMode']
|
||||
|
||||
@@ -89,6 +89,25 @@ function sources(overrides: {
|
||||
}
|
||||
|
||||
describe('readDesktopSidebarFacts', () => {
|
||||
it('publishes the focused host and the user’s host names from the settings', () => {
|
||||
const base = sources({})
|
||||
const facts = readDesktopSidebarFacts({
|
||||
...base,
|
||||
store: {
|
||||
...base.store,
|
||||
getSettings: () => ({
|
||||
activeRuntimeEnvironmentId: 'env-1',
|
||||
showPinnedWorktreesInGroups: true,
|
||||
hostSettingOverrides: { 'ssh:box': { displayLabel: ' GPU ' }, local: {} }
|
||||
})
|
||||
}
|
||||
})
|
||||
expect(facts.focusedHostId).toBe('runtime:env-1')
|
||||
expect(facts.hostLabels).toEqual({ 'ssh:box': 'GPU' })
|
||||
expect(facts.showPinnedInGroups).toBe(true)
|
||||
expect(readDesktopSidebarFacts(sources({})).focusedHostId).toBe('local')
|
||||
})
|
||||
|
||||
it('publishes the desktop focus the window saved to the local session', () => {
|
||||
const facts = readDesktopSidebarFacts(
|
||||
sources({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
getRepoExecutionHostId,
|
||||
getSettingsFocusedExecutionHostId,
|
||||
normalizeExecutionHostOrder,
|
||||
toRuntimeExecutionHostId,
|
||||
toSshExecutionHostId,
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
} from '../../../shared/execution-host'
|
||||
import { applyManualRepoOrder } from '../../../shared/manual-repo-order'
|
||||
import type { DesktopSidebarFacts } from '../../../shared/sidebar-list/desktop-sidebar-facts'
|
||||
import type { GlobalSettings } from '../../../shared/global-settings-types'
|
||||
import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides'
|
||||
import type { SshConnectionState, SshTarget } from '../../../shared/ssh-types'
|
||||
import type { WslHostStatus } from '../../../shared/wsl-host-types'
|
||||
import type { PersistedUIState } from '../../../shared/persisted-ui-state-types'
|
||||
@@ -31,6 +34,10 @@ export type DesktopSidebarFactSources = {
|
||||
hostId: ExecutionHostId
|
||||
) => Pick<WorkspaceSessionState, 'activeWorktreeId' | 'activeWorkspaceExecutionHostId'>
|
||||
getUI?: () => Pick<PersistedUIState, 'manualRepoOrder' | 'workspaceHostOrder'>
|
||||
getSettings?: () => Pick<
|
||||
GlobalSettings,
|
||||
'activeRuntimeEnvironmentId' | 'hostSettingOverrides' | 'showPinnedWorktreesInGroups'
|
||||
>
|
||||
}
|
||||
/** The registry's list, which leaves out targets a paired server owns; the window lists the same. */
|
||||
sshTargets: readonly Pick<SshTarget, 'id' | 'label'>[]
|
||||
@@ -45,6 +52,7 @@ export function readDesktopSidebarFacts(sources: DesktopSidebarFactSources): Des
|
||||
// Global session fields live on the local slice alone (workspace-session-host-field-ownership.ts).
|
||||
const session = store.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID)
|
||||
const ui = store.getUI?.()
|
||||
const settings = store.getSettings?.()
|
||||
const repos = runtimeVisibleRepos(store)
|
||||
const repoHostIds = new Set<ExecutionHostId>(repos.map((repo) => getRepoExecutionHostId(repo)))
|
||||
|
||||
@@ -84,6 +92,9 @@ export function readDesktopSidebarFacts(sources: DesktopSidebarFactSources): Des
|
||||
toRuntimeExecutionHostId(status.environmentId),
|
||||
{ distro: status.distro, state: status.state }
|
||||
])
|
||||
)
|
||||
),
|
||||
focusedHostId: getSettingsFocusedExecutionHostId(settings),
|
||||
hostLabels: Object.fromEntries(getHostDisplayLabelOverrides(settings)),
|
||||
showPinnedInGroups: settings?.showPinnedWorktreesInGroups === true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,22 @@ describe('RepoIconDataUrls without an encoder (orcad)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('RepoIconDataUrls.idsByRepo', () => {
|
||||
it('names each repo’s image by host and repo, the ids a read answers', async () => {
|
||||
const remote = { ...repo('a', AVATAR), connectionId: 'box' }
|
||||
const { icons } = setup([
|
||||
repo('a', AVATAR),
|
||||
remote,
|
||||
repo('glyph', { type: 'emoji', emoji: 'x' }),
|
||||
repo('none', null)
|
||||
])
|
||||
const id = repoIconId(AVATAR)!
|
||||
|
||||
expect(icons.idsByRepo()).toEqual({ 'local|a': id, 'ssh:box|a': id })
|
||||
expect((await icons.read([id])).icons[0].dataUrl).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RepoIconDataUrls with an encoder (desktop)', () => {
|
||||
it('re-encodes a fetched avatar at 64 px', async () => {
|
||||
const encoder = vi.fn<RepoIconPngEncoder>(() => ENCODED)
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Repo } from '../../../shared/repo-types'
|
||||
import { readRasterImageDimensions } from '../../../shared/raster-image-dimensions'
|
||||
import {
|
||||
MAX_REPO_ICON_DATA_URL_BYTES,
|
||||
repoIconFeedKey,
|
||||
type RepoIconDataUrlsReply
|
||||
} from '../../../shared/sidebar-list/repo-icon-data-urls-contract'
|
||||
import {
|
||||
@@ -77,10 +78,22 @@ export class RepoIconDataUrls {
|
||||
return { icons: ids.map((id, index) => ({ id, dataUrl: dataUrls[index] })) }
|
||||
}
|
||||
|
||||
/** Each repo's icon id by `hostId|repoId`, which the feed publishes; repos without an image are absent. */
|
||||
idsByRepo(): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
this.identifiedIcons().map(({ repo, id }) => [repoIconFeedKey(repo), id])
|
||||
)
|
||||
}
|
||||
|
||||
private currentIcons(): Map<string, ImageRepoIcon> {
|
||||
const icons = new Map<string, ImageRepoIcon>()
|
||||
return new Map(this.identifiedIcons().map(({ icon, id }) => [id, icon]))
|
||||
}
|
||||
|
||||
private identifiedIcons(): { repo: Repo; icon: ImageRepoIcon; id: string }[] {
|
||||
const identified: { repo: Repo; icon: ImageRepoIcon; id: string }[] = []
|
||||
const idsBySrc = new Map<string, { source: string; id: string }>()
|
||||
for (const { repoIcon } of this.options.repos()) {
|
||||
for (const repo of this.options.repos()) {
|
||||
const { repoIcon } = repo
|
||||
if (repoIcon?.type !== 'image') {
|
||||
continue
|
||||
}
|
||||
@@ -90,11 +103,11 @@ export class RepoIconDataUrls {
|
||||
const id = known?.source === repoIcon.source ? known.id : repoIconId(repoIcon)
|
||||
if (id) {
|
||||
idsBySrc.set(repoIcon.src, { source: repoIcon.source, id })
|
||||
icons.set(id, repoIcon)
|
||||
identified.push({ repo, icon: repoIcon, id })
|
||||
}
|
||||
}
|
||||
this.idsBySrc = idsBySrc
|
||||
return icons
|
||||
return identified
|
||||
}
|
||||
|
||||
private dataUrlFor(id: string, icon: ImageRepoIcon | undefined): Promise<string | null> {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { OrcaRuntimeWithSidebarTemporal } from './orca-runtime-sidebar-temp
|
||||
import type { PairedServerRowsProducer } from './paired-server-rows-producer'
|
||||
import type { WorkspaceCreationRegistry } from './workspace-creation-registry'
|
||||
import type { WorkspaceRowsProducer } from './workspace-rows-producer'
|
||||
import type { RepoIconDataUrls } from './repo-icon-data-urls'
|
||||
import type { SidebarInputsFeedDomain } from './sidebar-inputs-feed'
|
||||
import { readDesktopSidebarFacts } from './desktop-sidebar-facts'
|
||||
import { buildPairedServerReviewFacts } from '../../../shared/sidebar-list/workspace-review-facts'
|
||||
@@ -37,6 +38,7 @@ export type SidebarInputsFeedSources = {
|
||||
pairedServerRows: PairedServerRowsProducer
|
||||
workspaceCreations: WorkspaceCreationRegistry
|
||||
workspaceRows: WorkspaceRowsProducer
|
||||
repoIcons: Pick<RepoIconDataUrls, 'idsByRepo'>
|
||||
store: () => RuntimeStore | null
|
||||
onClientEvent: (
|
||||
listener: (event: RuntimeClientEvent) => void,
|
||||
@@ -118,12 +120,19 @@ export function sidebarInputsFeedDomains(
|
||||
const detaches = [
|
||||
watchCatalog(changed),
|
||||
store?.onUIChanged?.(changed),
|
||||
store?.onWorkspaceSessionWritten?.(changed)
|
||||
store?.onWorkspaceSessionWritten?.(changed),
|
||||
store?.onSettingsChanged?.(changed)
|
||||
]
|
||||
return () => detaches.forEach((detach) => detach?.())
|
||||
},
|
||||
rereadOnRead: true
|
||||
},
|
||||
{
|
||||
// Ids only: a reader fetches the images through sidebarInputs.repoIcons.
|
||||
name: 'repoIcons',
|
||||
read: () => sources.repoIcons.idsByRepo(),
|
||||
watch: watchCatalog
|
||||
},
|
||||
{
|
||||
name: 'review',
|
||||
read: async () => ({
|
||||
|
||||
@@ -46,6 +46,7 @@ const NO_FEED: SidebarFeedDomains = {
|
||||
creations: new Map(),
|
||||
pairedServerHosts: new Map(),
|
||||
pairedServerRows: new Map(),
|
||||
repoIcons: new Map(),
|
||||
desktop: null,
|
||||
retention: null
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ function domains(
|
||||
creations: new Map(),
|
||||
pairedServerHosts: new Map(),
|
||||
pairedServerRows: new Map(serverRows.map((row) => [row.key, row])),
|
||||
repoIcons: new Map(),
|
||||
desktop: null,
|
||||
retention: null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ProjectGroup } from '../../../../shared/project-group-types'
|
||||
|
||||
const group: ProjectGroup = {
|
||||
id: 'g1',
|
||||
name: 'Notes',
|
||||
parentPath: null,
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
}
|
||||
|
||||
async function catalogWith(switchedOn: boolean) {
|
||||
vi.resetModules()
|
||||
vi.doMock('../../../../shared/sidebar-inputs-switch', () => ({
|
||||
ORCA_SIDEBAR_INPUTS_IS_MAIN: switchedOn
|
||||
}))
|
||||
const { getProjectSectionCatalogFromState } = await import('./project-section-catalog')
|
||||
return getProjectSectionCatalogFromState({
|
||||
repos: [],
|
||||
projects: [],
|
||||
projectHostSetups: [],
|
||||
projectOrderBy: 'manual',
|
||||
projectGroups: [group]
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock('../../../../shared/sidebar-inputs-switch')
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('the sidebar catalog the window publishes for the phone', () => {
|
||||
it('carries project groups only for a page reading the sidebar feed', async () => {
|
||||
expect(await catalogWith(false)).not.toHaveProperty('projectGroups')
|
||||
expect((await catalogWith(true))?.projectGroups).toEqual([group])
|
||||
})
|
||||
})
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection'
|
||||
import { getProjectHostSetupProjectionFromState } from '@/store/project-host-setup-selector'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../shared/sidebar-inputs-switch'
|
||||
|
||||
type CatalogSource = {
|
||||
repos: AppState['repos']
|
||||
projectGroups: AppState['projectGroups'] | undefined
|
||||
projection: ProjectHostSetupProjection
|
||||
projectOrderBy: AppState['projectOrderBy']
|
||||
catalog: MobileProjectSectionCatalog
|
||||
@@ -17,7 +19,8 @@ let cachedCatalogSource: CatalogSource | null = null
|
||||
|
||||
/** What the sidebar groups Project headers by: state.repos in its order and the selector's projection. */
|
||||
export function getProjectSectionCatalogFromState(
|
||||
state: Pick<AppState, 'repos' | 'projects' | 'projectHostSetups' | 'projectOrderBy'>
|
||||
state: Pick<AppState, 'repos' | 'projects' | 'projectHostSetups' | 'projectOrderBy'> &
|
||||
Partial<Pick<AppState, 'projectGroups'>>
|
||||
): MobileProjectSectionCatalog | null {
|
||||
// Why: graph publication must not fail on a store slice that holds no repo catalog.
|
||||
if (!Array.isArray(state.repos)) {
|
||||
@@ -28,14 +31,22 @@ export function getProjectSectionCatalogFromState(
|
||||
if (
|
||||
cached &&
|
||||
cached.repos === state.repos &&
|
||||
cached.projectGroups === state.projectGroups &&
|
||||
cached.projection === projection &&
|
||||
cached.projectOrderBy === state.projectOrderBy
|
||||
) {
|
||||
return cached.catalog
|
||||
}
|
||||
const catalog = buildMobileProjectSectionCatalog(state.repos, projection, state.projectOrderBy)
|
||||
const catalog = buildMobileProjectSectionCatalog(
|
||||
state.repos,
|
||||
projection,
|
||||
state.projectOrderBy,
|
||||
// Switch point: a phone reading the feed lists folder workspaces under these.
|
||||
ORCA_SIDEBAR_INPUTS_IS_MAIN ? state.projectGroups : undefined
|
||||
)
|
||||
cachedCatalogSource = {
|
||||
repos: state.repos,
|
||||
projectGroups: state.projectGroups,
|
||||
projection,
|
||||
projectOrderBy: state.projectOrderBy,
|
||||
catalog
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ProjectGroup } from './project-group-types'
|
||||
import type { ProjectHostSetupProjection } from './project-host-setup-projection'
|
||||
import type { ProjectSectionProject, ProjectSectionSetup } from './project-section-grouping'
|
||||
import type { RepoIcon } from './repo-icon'
|
||||
@@ -17,6 +18,8 @@ export type MobileProjectSectionCatalog = {
|
||||
projects: ProjectSectionProject[]
|
||||
setups: ProjectSectionSetup[]
|
||||
projectOrderBy: ProjectOrderBy
|
||||
/** Every host's project groups, which a feed reader lists folder workspaces under; older desktops omit it. */
|
||||
projectGroups?: ProjectGroup[]
|
||||
}
|
||||
|
||||
/** Null until a desktop window has published its sidebar catalog. */
|
||||
@@ -42,7 +45,8 @@ function sectionRepo(repo: Repo): MobileProjectSectionRepo {
|
||||
export function buildMobileProjectSectionCatalog(
|
||||
repos: readonly Repo[],
|
||||
projection: ProjectHostSetupProjection,
|
||||
projectOrderBy: ProjectOrderBy
|
||||
projectOrderBy: ProjectOrderBy,
|
||||
projectGroups?: readonly ProjectGroup[]
|
||||
): MobileProjectSectionCatalog {
|
||||
return {
|
||||
repos: repos.map(sectionRepo),
|
||||
@@ -59,6 +63,7 @@ export function buildMobileProjectSectionCatalog(
|
||||
...(setup.connectionId !== undefined ? { connectionId: setup.connectionId } : {}),
|
||||
...(setup.executionHostId ? { executionHostId: setup.executionHostId } : {})
|
||||
})),
|
||||
projectOrderBy
|
||||
projectOrderBy,
|
||||
...(projectGroups ? { projectGroups: [...projectGroups] } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,13 @@ export const DesktopSidebarFactsSchema = z.object({
|
||||
/** Configured SSH targets by host id; status is null until a connection is attempted. */
|
||||
sshHosts: z.record(z.string(), z.object({ label: z.string(), status: z.string().nullable() })),
|
||||
/** WSL hosts by host id, where this host can run them. */
|
||||
wslHosts: z.record(z.string(), z.object({ distro: z.string(), state: z.string() }))
|
||||
wslHosts: z.record(z.string(), z.object({ distro: z.string(), state: z.string() })),
|
||||
/** The host the desktop has focused, where its sidebar files rows that name no owner. */
|
||||
focusedHostId: z.string().optional(),
|
||||
/** The user's own names for hosts, by host id. */
|
||||
hostLabels: z.record(z.string(), z.string()).optional(),
|
||||
/** "Also show pinned workspaces in their groups"; absent reads as off. */
|
||||
showPinnedInGroups: z.boolean().optional()
|
||||
})
|
||||
|
||||
export type DesktopSidebarFacts = z.infer<typeof DesktopSidebarFactsSchema>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { getRepoExecutionHostId } from '../execution-host'
|
||||
import type { Repo } from '../repo-types'
|
||||
|
||||
/** Longest data URL one icon may reply with. */
|
||||
export const MAX_REPO_ICON_DATA_URL_BYTES = 16 * 1024
|
||||
@@ -18,3 +20,10 @@ export const RepoIconDataUrlsParams = /* @__PURE__ */ (() =>
|
||||
export type RepoIconDataUrlsReply = {
|
||||
icons: { id: string; dataUrl: string | null }[]
|
||||
}
|
||||
|
||||
/** A repo's key in the feed's `repoIcons` domain: one repo id can be registered on two hosts. */
|
||||
export function repoIconFeedKey(
|
||||
repo: Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>
|
||||
): string {
|
||||
return `${getRepoExecutionHostId(repo)}|${repo.id}`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Repo } from '../repo-types'
|
||||
import type { WorkspaceStatusDefinition, Worktree } from '../worktree/types'
|
||||
import type { ProjectGroupingModel } from '../project-section-grouping'
|
||||
import type { ProjectSectionModel } from '../project-section-grouping'
|
||||
import { ALL_GROUP_KEY } from './group-keys'
|
||||
import type { SidebarPRGroupReader, WorktreeGroupBy } from './row-types'
|
||||
import { getGroupKeyForWorktree } from './worktree-group-keys'
|
||||
@@ -20,7 +20,7 @@ export function getRenderedNaturalAnchorRepoIds({
|
||||
prGroupOf: SidebarPRGroupReader
|
||||
collapsedGroups: ReadonlySet<string>
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[]
|
||||
projectGrouping?: ProjectGroupingModel
|
||||
projectGrouping?: ProjectSectionModel
|
||||
}): Set<string> {
|
||||
const renderedRepoIds = new Set<string>()
|
||||
if (groupBy === 'none') {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { z } from 'zod'
|
||||
import { z } from 'zod'
|
||||
import { DesktopSidebarFactsSchema, type DesktopSidebarFacts } from './desktop-sidebar-facts'
|
||||
import type { InstalledSidebarInputs } from './sidebar-inputs-feed-installer'
|
||||
import {
|
||||
@@ -29,6 +29,8 @@ export type SidebarFeedDomains = {
|
||||
{ freshness: 'live' | 'unverifiable'; host: PairedServerHostFacts | null }
|
||||
>
|
||||
pairedServerRows: ReadonlyMap<string, PairedServerWorkspaceRow>
|
||||
/** Icon ids by `hostId|repoId` (repoIconFeedKey) for this host's repos; a paired server's have none. */
|
||||
repoIcons: ReadonlyMap<string, string>
|
||||
/** Null until the host's desktop facts arrive whole. */
|
||||
desktop: DesktopSidebarFacts | null
|
||||
retention: SidebarRetention | null
|
||||
@@ -43,7 +45,8 @@ const entitySchemas = {
|
||||
freshness: true,
|
||||
host: true
|
||||
}),
|
||||
pairedServerRows: PairedServerRowsSnapshotSchema.shape.servers.element.shape.rows.element
|
||||
pairedServerRows: PairedServerRowsSnapshotSchema.shape.servers.element.shape.rows.element,
|
||||
repoIcons: z.string()
|
||||
}
|
||||
|
||||
function readDomain<Schema extends z.ZodType>(
|
||||
@@ -97,6 +100,9 @@ export function readSidebarFeedDomains(
|
||||
pairedServerRows:
|
||||
unchanged('pairedServerRows')?.pairedServerRows ??
|
||||
readDomain(installed, 'pairedServerRows', entitySchemas.pairedServerRows),
|
||||
repoIcons:
|
||||
unchanged('repoIcons')?.repoIcons ??
|
||||
readDomain(installed, 'repoIcons', entitySchemas.repoIcons),
|
||||
desktop: desktop.success ? desktop.data : null,
|
||||
retention: temporal.success ? temporal.data.retention : null
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '../execution-host'
|
||||
import type { ProjectGroup } from '../project-group-types'
|
||||
import { getLogicalRepoOrderRankById } from '../project-section-order'
|
||||
import type { ProjectGroupingModel } from '../project-section-grouping'
|
||||
import type { ProjectSectionModel } from '../project-section-grouping'
|
||||
import type { Repo } from '../repo-types'
|
||||
import type { ProjectOrderBy } from '../ui-chrome-types'
|
||||
import {
|
||||
@@ -78,7 +78,7 @@ export type SidebarFeedView = {
|
||||
export type SidebarFeedCatalog = {
|
||||
repos: readonly Repo[]
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
projectGrouping: ProjectGroupingModel
|
||||
projectGrouping: ProjectSectionModel
|
||||
}
|
||||
|
||||
export type SidebarFeedRows = {
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from '../worktree-occurrence-plan'
|
||||
import { getWorktreeHostIdentity } from '../worktree/host-qualified-identity'
|
||||
import type { OrderedGroupEntry, WorktreeGroupEntry } from './worktree-group-entry'
|
||||
import type { ProjectGroupingModel } from '../project-section-grouping'
|
||||
import type { ProjectSectionModel } from '../project-section-grouping'
|
||||
import {
|
||||
appendProjectGroupSections,
|
||||
getProjectGroupOccurrencePaths
|
||||
@@ -70,7 +70,7 @@ export type SidebarRowPlanInputs = {
|
||||
importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate>
|
||||
newExternalWorktreesInboxByRepo: ReadonlyMap<string, NewExternalWorktreesInboxCandidate>
|
||||
pendingCreations: readonly PendingCreationRef[]
|
||||
projectGrouping?: ProjectGroupingModel
|
||||
projectGrouping?: ProjectSectionModel
|
||||
folderWorkspaces: readonly FolderWorkspace[]
|
||||
hostLabelById?: ReadonlyMap<string, string>
|
||||
defaultHostId: ExecutionHostId
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '../workspace-statuses'
|
||||
import { ALL_GROUP_KEY, getPRLaneKey, getProjectGroupHeaderKey } from './group-keys'
|
||||
import { buildProjectGroupingIndex, getProjectGroupingForRepo } from '../project-section-grouping'
|
||||
import type { ProjectGroupingModel } from '../project-section-grouping'
|
||||
import type { ProjectSectionModel } from '../project-section-grouping'
|
||||
import type { SidebarPRGroupReader, WorktreeGroupBy } from './row-types'
|
||||
|
||||
export function getGroupKeyForWorktree(
|
||||
@@ -18,7 +18,7 @@ export function getGroupKeyForWorktree(
|
||||
/** Null reads every worktree as having no PR, which is the in-progress lane. */
|
||||
prGroupOf: SidebarPRGroupReader | null,
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(),
|
||||
projectGrouping?: ProjectGroupingModel
|
||||
projectGrouping?: ProjectSectionModel
|
||||
): string | null {
|
||||
if (groupBy === 'none') {
|
||||
return ALL_GROUP_KEY
|
||||
@@ -43,7 +43,7 @@ export function getGroupKeysForWorktree(
|
||||
prGroupOf: SidebarPRGroupReader | null,
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(),
|
||||
projectGroups: readonly ProjectGroup[] = [],
|
||||
projectGrouping?: ProjectGroupingModel
|
||||
projectGrouping?: ProjectSectionModel
|
||||
): string[] {
|
||||
const groupKey = getGroupKeyForWorktree(
|
||||
groupBy,
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { expect } from '@stablyai/playwright-test'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { buildSidebarFeedRows } from '../../../src/shared/sidebar-list/sidebar-feed-rows'
|
||||
import {
|
||||
HostSidebarFeedReader,
|
||||
type HostSidebarFeedRead
|
||||
} from '../../../mobile/src/host-screen/host-sidebar-feed-reader'
|
||||
import {
|
||||
buildHostFeedCatalog,
|
||||
buildHostFeedView
|
||||
} from '../../../mobile/src/host-screen/host-sidebar-feed-inputs'
|
||||
import { buildHostFeedSections } from '../../../mobile/src/host-screen/host-sidebar-feed-sections'
|
||||
import { readHostSidebarFeed } from '../../../mobile/src/host-screen/use-host-sidebar-feed'
|
||||
import { readHostMethodReply } from '../../../mobile/src/host-screen/host-sidebar-feed-operations'
|
||||
import {
|
||||
hostPlatformRead,
|
||||
hostViewSettingsRead
|
||||
} from '../../../mobile/src/host-screen/host-screen-operations'
|
||||
import { readServedProjectSections } from '../../../mobile/src/worktree/project-sections-operations'
|
||||
import {
|
||||
INITIAL_MOBILE_VIEW,
|
||||
applyDesktopViewSettings,
|
||||
type WorkspaceViewSettings
|
||||
} from '../../../mobile/src/worktree/workspace-view-settings'
|
||||
import type { RpcResponse } from '../../../mobile/src/transport/types'
|
||||
import type { Section } from '../../../mobile/src/worktree/workspace-list-types'
|
||||
import type { PairedMobileClient, PairedMobileSocket } from './paired-mobile-client'
|
||||
|
||||
/** One line per row the list shows, in order: what the phone and the desktop sidebar must share. */
|
||||
export type ListOutline = string[]
|
||||
|
||||
function isChangeNotice(
|
||||
result: unknown
|
||||
): result is { type: 'sidebarInputsChanged'; generation: string; revision: number } {
|
||||
return (
|
||||
typeof result === 'object' &&
|
||||
result !== null &&
|
||||
'type' in result &&
|
||||
result.type === 'sidebarInputsChanged' &&
|
||||
'generation' in result &&
|
||||
typeof result.generation === 'string' &&
|
||||
'revision' in result &&
|
||||
typeof result.revision === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
/** The phone's one socket as the page's request port: each reply as the bridge hands it over. */
|
||||
function socketPort(socket: PairedMobileSocket) {
|
||||
let sequence = 0
|
||||
return {
|
||||
sendRequest: async (method: string, params?: unknown): Promise<RpcResponse> => {
|
||||
sequence += 1
|
||||
const id = `phone-list-${sequence}`
|
||||
socket.send(id, method, params ?? null)
|
||||
await expect
|
||||
.poll(() => socket.frames.some((frame) => frame.id === id), { timeout: 30_000 })
|
||||
.toBe(true)
|
||||
const frame = socket.frames.find((candidate) => candidate.id === id)
|
||||
return frame?.ok
|
||||
? { id, ok: true, result: frame.result }
|
||||
: { id, ok: false, error: { code: frame?.error?.code ?? 'unknown', message: '' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The phone page's workspace list over a real paired socket, built by the page's own modules: its
|
||||
* feed reader, the shared builder and its section mapping, from the reads the page makes.
|
||||
*/
|
||||
export async function openPhoneFeedList(phone: PairedMobileClient) {
|
||||
const socket = await phone.openSocket()
|
||||
socket.send('phone-list-events', 'runtime.clientEvents.subscribe', null)
|
||||
const port = socketPort(socket)
|
||||
let read: HostSidebarFeedRead = { installed: null, unavailable: false }
|
||||
const reader = new HostSidebarFeedReader(port, (next) => (read = next))
|
||||
let framesSeen = 0
|
||||
reader.refresh()
|
||||
|
||||
const forwardNotices = (): void => {
|
||||
for (const frame of socket.frames.slice(framesSeen)) {
|
||||
if (isChangeNotice(frame.result)) {
|
||||
reader.changed(frame.result)
|
||||
}
|
||||
}
|
||||
framesSeen = socket.frames.length
|
||||
}
|
||||
|
||||
/** The sections the page draws; null until the feed installs. */
|
||||
const sections = async (): Promise<Section[] | null> => {
|
||||
forwardNotices()
|
||||
const feed = readHostSidebarFeed(read)
|
||||
if (feed.kind !== 'ready') {
|
||||
return null
|
||||
}
|
||||
const [ui, served, platform] = await Promise.all(
|
||||
['ui.get', 'mobileRelay.projectSections', 'host.platform'].map((method) =>
|
||||
port.sendRequest(method)
|
||||
)
|
||||
)
|
||||
const uiRead = readHostMethodReply(ui, hostViewSettingsRead.interpret)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the page reads ui.get the same way (use-host-view-settings.ts).
|
||||
const uiValue = (uiRead.kind === 'answered' ? uiRead.value : {}) as WorkspaceViewSettings
|
||||
const view = applyDesktopViewSettings(INITIAL_MOBILE_VIEW, uiValue)
|
||||
const sections = readServedProjectSections(served)
|
||||
const platformRead = readHostMethodReply(platform, hostPlatformRead.interpret)
|
||||
const servedValue = sections.kind === 'answered' ? sections.value : null
|
||||
const built = buildSidebarFeedRows(
|
||||
feed.domains,
|
||||
buildHostFeedCatalog(servedValue, [], []),
|
||||
buildHostFeedView({
|
||||
view,
|
||||
projectOrderBy: view.projectOrderBy ?? servedValue?.projectOrderBy ?? 'manual',
|
||||
desktop: feed.domains.desktop,
|
||||
hostPlatform: platformRead.kind === 'answered' ? platformRead.value : null
|
||||
})
|
||||
)
|
||||
return buildHostFeedSections({
|
||||
rows: built.sectionRows,
|
||||
activeRowKeys: built.activeRowKeys,
|
||||
liveRows: [],
|
||||
repoHostIdByRepoId: new Map(),
|
||||
search: ''
|
||||
})
|
||||
}
|
||||
|
||||
const outline = async (): Promise<ListOutline> =>
|
||||
(await sections())?.flatMap((section) => [
|
||||
// Titles only: the desktop draws a header's count on hover, not in its text.
|
||||
`HEADER ${section.title}`,
|
||||
...section.data.map((row) => `ITEM ${row.sectionListKey}${row.isActive ? ' ACTIVE' : ''}`)
|
||||
]) ?? [readHostSidebarFeed(read).kind]
|
||||
|
||||
return {
|
||||
sections,
|
||||
outline,
|
||||
/** The page's feed reader and its replies, for asserting what the page installed. */
|
||||
feed: () => readHostSidebarFeed(read),
|
||||
request: port.sendRequest,
|
||||
close: () => {
|
||||
reader.dispose()
|
||||
socket.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The rows the desktop sidebar has rendered, in order, in the outline's terms. */
|
||||
export async function desktopSidebarOutline(page: Page): Promise<ListOutline> {
|
||||
return page.evaluate(() => {
|
||||
const prefix = 'worktree-list-option-'
|
||||
const rows = [...document.querySelectorAll<HTMLElement>('[data-worktree-virtual-row]')].sort(
|
||||
(a, b) => Number(a.dataset.index ?? 0) - Number(b.dataset.index ?? 0)
|
||||
)
|
||||
return rows.flatMap((row) =>
|
||||
// A row element can itself be the option (item rows) or hold it (headers).
|
||||
[row, ...row.querySelectorAll<HTMLElement>('[id]')]
|
||||
.filter((node) => node.id.startsWith(prefix))
|
||||
.map((option) => {
|
||||
if (option.getAttribute('role') === 'option') {
|
||||
const key = decodeURIComponent(option.id.slice(prefix.length))
|
||||
return `ITEM ${key}${option.getAttribute('aria-current') === 'page' ? ' ACTIVE' : ''}`
|
||||
}
|
||||
const text = (option.innerText || option.textContent || '').replace(/\s+/g, ' ').trim()
|
||||
return `HEADER ${text}`
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* The phone page's workspace list, built from the host's sidebar feed by the page's own modules,
|
||||
* matches the desktop sidebar row for row: headers, order, nesting, the single highlight and
|
||||
* collapse. Desktop, then a desktop that is a paired server's client.
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { z } from 'zod'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { SIDEBAR_INPUTS_BUILD } from './helpers/sidebar-inputs-build'
|
||||
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import { createDesktopMobilePairingOffer } from './helpers/paired-electron-client'
|
||||
import { pairMobileClient } from './helpers/paired-mobile-client'
|
||||
import { launchPhoneMirrorTopology } from './helpers/phone-mirror-topology'
|
||||
import {
|
||||
desktopSidebarOutline,
|
||||
openPhoneFeedList,
|
||||
type ListOutline
|
||||
} from './helpers/phone-feed-list'
|
||||
|
||||
type PhoneList = Awaited<ReturnType<typeof openPhoneFeedList>>
|
||||
|
||||
const AddedRepoSchema = z.object({ repo: z.object({ id: z.string() }) })
|
||||
|
||||
/** Both sides inside one poll: either may still be catching up with the last change. */
|
||||
async function expectPhoneMatchesDesktop(page: Page, phone: PhoneList): Promise<ListOutline> {
|
||||
let desktop: ListOutline = []
|
||||
let shown: ListOutline = []
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
desktop = await desktopSidebarOutline(page)
|
||||
shown = await phone.outline()
|
||||
return shown.join('\n') === desktop.join('\n')
|
||||
},
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
.catch(() => expect(shown).toEqual(desktop))
|
||||
expect(shown.some((line) => line.startsWith('ITEM '))).toBe(true)
|
||||
return shown
|
||||
}
|
||||
|
||||
async function runtimeCall(page: Page, method: string, params: unknown): Promise<unknown> {
|
||||
return page.evaluate(
|
||||
async ({ method, params }) => {
|
||||
const response = await window.api.runtime.call({ method, params })
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
return response.result
|
||||
},
|
||||
{ method, params }
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('switch on', () => {
|
||||
test.skip(
|
||||
!SIDEBAR_INPUTS_BUILD,
|
||||
'the phone reads the feed only in an ORCA_SIDEBAR_INPUTS=main build'
|
||||
)
|
||||
|
||||
test('the phone lists the desktop sidebar from the feed, through pins, the open workspace and collapse', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
test.setTimeout(180_000)
|
||||
await waitForSessionReady(orcaPage)
|
||||
const activeId = await waitForActiveWorktree(orcaPage)
|
||||
const repoId = activeId.split('::')[0]
|
||||
const phone = pairMobileClient(await createDesktopMobilePairingOffer(orcaPage))
|
||||
for (const name of ['feed-a', 'feed-b']) {
|
||||
await phone.call('worktree.create', {
|
||||
repo: `id:${repoId}`,
|
||||
name,
|
||||
setupDecision: 'skip'
|
||||
})
|
||||
}
|
||||
const list = await openPhoneFeedList(phone)
|
||||
try {
|
||||
const first = await expectPhoneMatchesDesktop(orcaPage, list)
|
||||
expect(first.filter((line) => line.endsWith(' ACTIVE'))).toHaveLength(1)
|
||||
|
||||
// A pin on the desktop reaches the phone through a notice, with a Pinned section.
|
||||
await runtimeCall(orcaPage, 'worktree.set', { worktree: `id:${activeId}`, isPinned: true })
|
||||
const pinned = await expectPhoneMatchesDesktop(orcaPage, list)
|
||||
expect(pinned.some((line) => line.startsWith('HEADER Pinned'))).toBe(true)
|
||||
|
||||
// Pinned rows also in their groups: the open workspace highlights at both.
|
||||
await orcaPage.evaluate(() =>
|
||||
window.__store!.getState().updateSettings({ showPinnedWorktreesInGroups: true })
|
||||
)
|
||||
const duplicated = await expectPhoneMatchesDesktop(orcaPage, list)
|
||||
expect(duplicated.filter((line) => line.endsWith(' ACTIVE'))).toHaveLength(2)
|
||||
|
||||
// The phone collapses one section with ui.setCollapsed; the desktop and the phone both fold it.
|
||||
const project = (await list.sections())?.find((section) => section.repo)
|
||||
expect(project).toBeDefined()
|
||||
const before = await orcaPage.evaluate(() => [...window.__store!.getState().collapsedGroups])
|
||||
const reply = await list.request('ui.setCollapsed', {
|
||||
key: project!.key,
|
||||
collapsed: true,
|
||||
opId: { writer: 'e2e-phone', seq: 1 }
|
||||
})
|
||||
expect(reply).toMatchObject({ ok: true })
|
||||
await expect
|
||||
.poll(() => orcaPage.evaluate(() => [...window.__store!.getState().collapsedGroups]))
|
||||
.toEqual([...before, project!.key])
|
||||
const folded = await expectPhoneMatchesDesktop(orcaPage, list)
|
||||
const foldedSection = (await list.sections())?.find((section) => section.key === project!.key)
|
||||
expect(foldedSection?.data).toEqual([])
|
||||
expect(foldedSection?.count).toBeGreaterThan(0)
|
||||
expect(folded.length).toBeLessThan(duplicated.length)
|
||||
|
||||
// Expanded again by a later op from the same writer.
|
||||
expect(
|
||||
await list.request('ui.setCollapsed', {
|
||||
key: project!.key,
|
||||
collapsed: false,
|
||||
opId: { writer: 'e2e-phone', seq: 2 }
|
||||
})
|
||||
).toMatchObject({ ok: true })
|
||||
await expectPhoneMatchesDesktop(orcaPage, list)
|
||||
const unfolded = (await list.sections())?.find((section) => section.key === project!.key)
|
||||
expect(unfolded?.data.length).toBeGreaterThan(0)
|
||||
|
||||
// Unpinned on the desktop: the Pinned section goes and one highlight is left.
|
||||
await runtimeCall(orcaPage, 'worktree.set', { worktree: `id:${activeId}`, isPinned: false })
|
||||
const unpinned = await expectPhoneMatchesDesktop(orcaPage, list)
|
||||
expect(unpinned.filter((line) => line.endsWith(' ACTIVE'))).toHaveLength(1)
|
||||
} finally {
|
||||
list.close()
|
||||
phone.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('an old open page keeps its own reads on a host that serves the feed', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
const phone = pairMobileClient(await createDesktopMobilePairingOffer(orcaPage))
|
||||
try {
|
||||
// What a page built before the feed reads, unchanged on this host.
|
||||
for (const method of ['worktree.ps', 'mobileRelay.projectSections', 'ui.get', 'repo.list']) {
|
||||
expect(await phone.request(method, {}), method).toMatchObject({ ok: true })
|
||||
}
|
||||
const served = await phone.call<{ catalog: { repos: unknown[] } | null }>(
|
||||
'mobileRelay.projectSections',
|
||||
{}
|
||||
)
|
||||
expect(served.catalog?.repos.length).toBeGreaterThan(0)
|
||||
} finally {
|
||||
phone.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('the phone lists a paired server’s workspaces as the desktop sidebar does', async (// oxlint-disable-next-line no-empty-pattern -- this test owns its topology launch.
|
||||
{}, testInfo) => {
|
||||
test.setTimeout(240_000)
|
||||
const serverRepo = testInfo.outputPath('server-repo')
|
||||
mkdirSync(serverRepo, { recursive: true })
|
||||
writeFileSync(path.join(serverRepo, 'README.md'), 'server repo\n')
|
||||
const git = (...args: string[]) => execFileSync('git', args, { cwd: serverRepo, stdio: 'pipe' })
|
||||
git('init', '-b', 'main')
|
||||
git('add', 'README.md')
|
||||
git('-c', 'user.name=E2E', '-c', 'user.email=e2e@test.local', 'commit', '-m', 'init')
|
||||
const { host, desktop, phone, dispose } = await launchPhoneMirrorTopology(
|
||||
{ phoneTo: 'desktop' },
|
||||
testInfo
|
||||
)
|
||||
const list = await openPhoneFeedList(phone)
|
||||
try {
|
||||
const added = AddedRepoSchema.parse(
|
||||
(await host.client.call('repo.add', { path: serverRepo, kind: 'git' })).result
|
||||
)
|
||||
for (const name of ['server-a', 'server-b']) {
|
||||
await host.client.call('worktree.create', {
|
||||
repo: `id:${added.repo.id}`,
|
||||
name,
|
||||
setupDecision: 'skip'
|
||||
})
|
||||
}
|
||||
const shown = await expectPhoneMatchesDesktop(desktop.page, list)
|
||||
expect(shown.some((line) => line.includes('server-a'))).toBe(true)
|
||||
} finally {
|
||||
list.close()
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user