diff --git a/mobile/app/h/[hostId]/source-control/[worktreeId].web.tsx b/mobile/app/h/[hostId]/source-control/[worktreeId].web.tsx index cef6989884d..18c7ccc3813 100644 --- a/mobile/app/h/[hostId]/source-control/[worktreeId].web.tsx +++ b/mobile/app/h/[hostId]/source-control/[worktreeId].web.tsx @@ -1,6 +1,6 @@ import { withWorkspaceRoute } from '../../../../src/navigation/workspace-route' import { useLocalSearchParams } from 'expo-router' -import { MobileSourceControlPanel } from '../../../../src/source-control/MobileSourceControlPanel' +import { MobileSourceControlRoutePanel } from '../../../../src/source-control/MobileSourceControlRoutePanel' import { firstParam } from '../../../../src/navigation/route-param-reader' import { parseSourceControlHubTab } from '../../../../src/source-control/mobile-source-control-hub-tab' @@ -16,7 +16,7 @@ export default withWorkspaceRoute(function MobileSourceControlScreen() { const hostId = firstParam(params.hostId) const worktreeId = firstParam(params.worktreeId) return ( - () - const hostId = firstParam(params.hostId) - const executionHost = normalizeExecutionHostId(firstParam(params.executionHost)) ?? undefined + return ( + + {children} + + ) +} + +/** A route's execution scope for one workspace; screens that show another workspace in place nest their own. */ +export function WorkspaceExecutionScope({ + hostId, + executionHost, + children +}: { + hostId: string + executionHost: ExecutionHostId | undefined + children: ReactNode +}) { const { client, state } = useHostClient(hostId) - const desktopGates = useOptionalHostProtocolGates() + const outerServer = useContext(WorkspaceServerContext) + const contextGates = useOptionalHostProtocolGates() + // Nested in a server's scope, the gates in context are that server's, not the desktop's. + const desktopGates = outerServer ? (outerServer.desktopGates ?? null) : contextGates const { routeHost, server } = useWorkspaceServer({ client, connState: state, desktopCapabilities: desktopGates?.hostCapabilities ?? NO_CAPABILITIES, + desktopGates, executionHost }) // Settled desktop status cannot become reachable by keeping this spinner open. @@ -41,9 +63,19 @@ export function WorkspaceRoute({ children }: { children: ReactNode }) { {children} ) - return server ? ( - - + if (server) { + return ( + + + {screens} + + + ) + } + // A desktop workspace shown inside a server's scope goes back to desktop routing and gates. + return outerServer ? ( + + {screens} diff --git a/mobile/src/query/host-query-persistence.test.ts b/mobile/src/query/host-query-persistence.test.ts index 2095297f0c4..4439806d94c 100644 --- a/mobile/src/query/host-query-persistence.test.ts +++ b/mobile/src/query/host-query-persistence.test.ts @@ -1,8 +1,12 @@ -import { QueryClient } from '@tanstack/react-query' +import { hashKey, QueryClient } from '@tanstack/react-query' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { OrcaQueryStoreBackend } from '../device-store/query-storage' import { hostWorktreeCatalogKey, hostWorktreeCatalogQuery } from '../host-screen/host-list-queries' -import { createHostQueryPersistence, persistedHostQueryKey } from './host-query-persistence' +import { + createHostQueryPersistence, + holdsRestoredHostQueryData, + persistedHostQueryKey +} from './host-query-persistence' vi.mock('react-native', () => ({ AppState: {} })) @@ -119,4 +123,44 @@ describe('createHostQueryPersistence', () => { expect(relaunched.getQueryData(key)).toBe(live) }) + + it.each([ + { replaceOnHearing: false, expected: [true, false] }, + { replaceOnHearing: true, expected: [true, false, false] } + ])( + "marks restored data as an earlier launch's before anyone hears of it, until this launch's own answer (a listener's replacement: $replaceOnHearing)", + async ({ replaceOnHearing, expected }) => { + const store = memoryStore('host-a') + await storeFromEarlierLaunch(store, [liveRow]) + + const relaunched = new QueryClient() + const persistence = createHostQueryPersistence(relaunched, store.backend) + relaunched.getQueryCache().build(relaunched, { + queryKey: key, + meta: { persist: hostWorktreeCatalogQuery.persist } + }) + const heard: boolean[] = [] + const replacement = { snapshotId: 'snap-8', worktrees: [liveRow] } + relaunched.getQueryCache().subscribe((event) => { + if (event.type === 'updated' && event.action.type === 'success') { + heard.push(holdsRestoredHostQueryData(relaunched, hashKey(key))) + // A listener replacing the restored rows as it hears of them. + if (replaceOnHearing && heard.length === 1) { + relaunched.setQueryData(key, replacement, { updatedAt: SAVED_AT - 2 }) + } + } + }) + const restoring = persistence.restore(firstPaint) + await vi.runAllTimersAsync() + await restoring + // A live answer timed before the stored one, as after a clock correction, is still this launch's. + relaunched.setQueryData( + key, + { snapshotId: 'snap-8', worktrees: [liveRow] }, + { updatedAt: SAVED_AT - 1 } + ) + + expect(heard).toEqual(expected) + } + ) }) diff --git a/mobile/src/query/host-query-persistence.ts b/mobile/src/query/host-query-persistence.ts index 9edf0f556df..ccca23f3207 100644 --- a/mobile/src/query/host-query-persistence.ts +++ b/mobile/src/query/host-query-persistence.ts @@ -136,6 +136,8 @@ export function createHostQueryPersistence( const missedAtBoot = new Set() const triedOnMiss = new WeakSet() const restored = new Set() + // The key a restore is writing, so its own success leaves the mark. + let applyingRestore: string | null = null restoredKeys.set(queryClient, restored) function storedData(queryHash: string, data: unknown, persist: HostQueryPersist) { @@ -204,8 +206,14 @@ export function createHostQueryPersistence( if (queryClient.getQueryData(queryKey) === undefined) { const { data, dataUpdatedAt } = record.state settledAt.set(queryHash, dataUpdatedAt) - queryClient.setQueryData(queryKey, data, { updatedAt: dataUpdatedAt }) + // Marked first: listeners told of this write must already see it as an earlier launch's. restored.add(queryHash) + applyingRestore = queryHash + try { + queryClient.setQueryData(queryKey, data, { updatedAt: dataUpdatedAt }) + } finally { + applyingRestore = null + } } settle() }) @@ -252,7 +260,12 @@ export function createHostQueryPersistence( if (event.type !== 'updated' || event.action.type !== 'success') { return } - restored.delete(query.queryHash) + // Any write but the restore's own is this launch's, whatever its clock says; a listener's write during it included. + if (query.queryHash === applyingRestore) { + applyingRestore = null + } else { + restored.delete(query.queryHash) + } const family = familyOf(query.queryKey) const at = query.state.dataUpdatedAt if (family === null || at <= (settledAt.get(query.queryHash) ?? 0)) { diff --git a/mobile/src/session/MobileSessionHeader.tsx b/mobile/src/session/MobileSessionHeader.tsx index a37b0d2499d..c7593258e42 100644 --- a/mobile/src/session/MobileSessionHeader.tsx +++ b/mobile/src/session/MobileSessionHeader.tsx @@ -30,7 +30,6 @@ export function MobileSessionHeader({ const t = useMobileTranslation() const { hostId, - isFolderWorkspaceRoute, isFloatingWorkspaceRoute, presentedConnState, forceReconnectHost, @@ -61,8 +60,7 @@ export function MobileSessionHeader({ const tabsInTopBar = (layout.isLandscapePhone || sideRail) && hasTabs // Bar buttons otherwise leave less than two readable tabs on a narrow header. const actionsCollapsed = tabsInTopBar && (headerWidth ?? layout.width) < 600 - const sourceControlInBar = - !isFolderWorkspaceRoute && !isFloatingWorkspaceRoute && !actionsCollapsed + const sourceControlInBar = !isFloatingWorkspaceRoute && !actionsCollapsed const tabStrip = hasTabs ? ( void + /** Back to the screen this hub was opened over in place, e.g. a folder's list; drawn beside dock Close. */ + backInPlace?: { onPress: () => void; label: string } // Dock dismissal does not pop a route; keep it separate from Back. onClose: () => void onRefresh: () => void @@ -33,6 +35,7 @@ export function MobileSourceControlHeader({ ioBusy, refreshing, onBack, + backInPlace, onClose, onRefresh, onOpenPrWeb, @@ -72,17 +75,26 @@ export function MobileSourceControlHeader({ ] : []) ] + const closeButton = ( + + ) return ( <> + ) : embedded ? ( + closeButton ) : ( setMenuOpen(true)} - active={menuOpen} - accessibilityLabel={t( - 'sourceControl.header.moreActions', - 'More source control actions' - )} - /> + <> + {backInPlace && embedded ? closeButton : null} + setMenuOpen(true)} + active={menuOpen} + accessibilityLabel={t( + 'sourceControl.header.moreActions', + 'More source control actions' + )} + /> + } /> void; label: string } onRequestClose?: () => void } @@ -60,6 +62,7 @@ export function MobileSourceControlPanel({ name = '', embedded = false, initialTab = 'changes', + backInPlace, onRequestClose }: MobileSourceControlPanelProps) { useMobileColorScheme() @@ -251,6 +254,7 @@ export function MobileSourceControlPanel({ // Only Changes reads through the query layer so far; the PR tab keeps its own loading UI. refreshing={activeTab === 'changes' && state.refreshing} onBack={onBack} + backInPlace={backInPlace} onClose={onClose} onRefresh={onRefresh} menuGroups={ diff --git a/mobile/src/source-control/MobileSourceControlRoutePanel.tsx b/mobile/src/source-control/MobileSourceControlRoutePanel.tsx new file mode 100644 index 00000000000..f82fd2915e9 --- /dev/null +++ b/mobile/src/source-control/MobileSourceControlRoutePanel.tsx @@ -0,0 +1,22 @@ +import { parseWorkspaceKey } from '../../../src/shared/workspace-scope' +import { MobileFolderSourceControlPanel } from './folder/MobileFolderSourceControlPanel' +import { + MobileSourceControlPanel, + type MobileSourceControlPanelProps +} from './MobileSourceControlPanel' + +/** Source Control for the workspace a route or dock names: a folder workspace lists its attached worktrees. */ +export function MobileSourceControlRoutePanel(props: MobileSourceControlPanelProps) { + if (parseWorkspaceKey(props.worktreeId)?.type === 'folder') { + return ( + + ) + } + return +} diff --git a/mobile/src/source-control/folder/MobileFolderSourceControlPanel.tsx b/mobile/src/source-control/folder/MobileFolderSourceControlPanel.tsx new file mode 100644 index 00000000000..f27ba094d7b --- /dev/null +++ b/mobile/src/source-control/folder/MobileFolderSourceControlPanel.tsx @@ -0,0 +1,205 @@ +import { useCallback, useState } from 'react' +import { useFocusEffect } from 'expo-router' +import { ScrollView, View } from 'react-native' +import { GitBranch } from 'lucide-react-native' +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' +import { + MobileLoadErrorState, + MobileOfflineState, + MobileStateView +} from '../../components/MobileStateView' +import { connectionRetryAction } from '../../transport/connection/connection-retry-action' +import { useForceReconnect } from '../../transport/connection/host-client-hooks' +import { OptionPager, useOptionPager } from '../../components/bottom-drawer/BottomDrawer' +import { ListGroup } from '../../components/list/ListGroup' +import { useMobileTranslation } from '../../i18n/use-mobile-translation' +import { useBackClaim } from '../../navigation/use-back-claim' +import { useWorkspaceRouteHandoff } from '../../navigation/workspace-route-handoff' +import { WorkspaceExecutionScope } from '../../navigation/workspace-route' +import { colors, spacing } from '../../theme/mobile-theme' +import { themedStyleSheet } from '../../theme/themed-styles' +import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' +import type { FolderListRow } from './mobile-folder-attached-worktrees' +import { getWorktreeRowIdentity } from '../../worktree/worktree-host-row-identity' +import { MobileSourceControlHeader } from '../MobileSourceControlHeader' +import { MobileSourceControlPanel } from '../MobileSourceControlPanel' +import { styles as sourceControlStyles } from '../mobile-source-control-styles' +import { MobileFolderWorktreeRow } from './MobileFolderWorktreeRow' +import { useMobileFolderAttachedWorktrees } from './use-mobile-folder-attached-worktrees' + +type Props = { + hostId: string + folderWorktreeId: string + name: string + embedded: boolean + onRequestClose?: () => void +} + +/** A folder workspace's Source Control: one row per attached worktree, each opening that worktree's own Source Control in place. */ +export function MobileFolderSourceControlPanel({ + hostId, + folderWorktreeId, + name, + embedded, + onRequestClose +}: Props) { + useMobileColorScheme() + const t = useMobileTranslation() + const router = useWorkspaceRouteHandoff() + const insets = useSafeAreaInsets() + const pager = useOptionPager() + const [opened, setOpened] = useState(null) + const pageOpen = pager.page !== null + // A covered screen stops observing, as the single-worktree screen does, and so does the list under an open child. + const [focused, setFocused] = useState(true) + useFocusEffect( + useCallback(() => { + setFocused(true) + return () => setFocused(false) + }, []) + ) + const observing = focused && !pageOpen + const { folder, worktrees, desktopCapabilities, serverHosts, connState, loadError, refetch } = + useMobileFolderAttachedWorktrees(hostId, folderWorktreeId, observing) + const forceReconnect = useForceReconnect() + const [refreshers] = useState(() => new Set<() => void>()) + const refresh = () => { + refetch() + for (const refreshNow of refreshers) { + refreshNow() + } + } + useBackClaim( + pageOpen + ? () => { + pager.close() + return true + } + : null + ) + const openPage = pager.open + const open = useCallback( + (worktree: FolderListRow) => { + setOpened(worktree) + openPage(getWorktreeRowIdentity(worktree)) + }, + [openPage] + ) + const onClose = onRequestClose ?? (() => router.back()) + + const header = ( + router.back()} + onClose={onClose} + onRefresh={refresh} + /> + ) + const retry = connectionRetryAction({ + hostId, + needsReconnect: connState !== 'connected', + forceReconnect, + reload: refetch + }) + const body = + // A connection still coming up shows loading; offline is for one that dropped or was refused. + folder === undefined && (connState === 'disconnected' || connState === 'auth-failed') ? ( + + ) : folder === undefined && loadError ? ( + + ) : folder === undefined ? ( + + ) : folder === null ? ( + + ) : worktrees.length === 0 ? ( + + ) : ( + // One card per attached worktree, as desktop shows them. + + {worktrees.map((worktree) => ( + + server.hostId === worktree.hostId)?.relay === + 'update-needed' + } + subscribed={observing} + refreshers={refreshers} + onOpen={open} + /> + + ))} + + ) + const main = ( + + {embedded ? ( + {header} + ) : ( + + {header} + + )} + {/* Every state shares the sheet surface the cards sit on, as the phone's option sheets do. */} + {body} + + ) + return ( + + + + ) : null + } + /> + ) +} + +const styles = themedStyleSheet(() => ({ + body: { + flex: 1, + backgroundColor: colors.sheetSurface + }, + list: { + padding: spacing.lg, + gap: spacing.sm + } +})) diff --git a/mobile/src/source-control/folder/MobileFolderWorktreeRow.tsx b/mobile/src/source-control/folder/MobileFolderWorktreeRow.tsx new file mode 100644 index 00000000000..d28e6b0c362 --- /dev/null +++ b/mobile/src/source-control/folder/MobileFolderWorktreeRow.tsx @@ -0,0 +1,82 @@ +import { memo } from 'react' +import { useMobileTranslation } from '../../i18n/use-mobile-translation' +import { ListRow } from '../../components/list/ListGroup' +import { useHostClient } from '../../transport/connection/host-client-hooks' +import { workspaceRouteExecutionHost } from '../../worktree/execution-host-scoped-rpc-client' +import type { FolderListRow } from './mobile-folder-attached-worktrees' +import { formatBranchLabel } from '../mobile-source-control-screen-state' +import { useFolderWorktreeStatus } from './use-folder-worktree-status' + +type Props = { + hostId: string + /** The paired desktop's, which decide whether it relays to the row's server. */ + desktopCapabilities: readonly string[] + worktree: FolderListRow + /** The desktop relays to the row's server but can't reach it (e.g. it needs an update). */ + unreachable: boolean + subscribed: boolean + refreshers: Set<() => void> + onOpen: (worktree: FolderListRow) => void +} + +/** One attached worktree: desktop's card header (repo, name, branch, ahead/behind, change count); tapping opens its Source Control. */ +// Memoized: the host catalog changes often, and a row only re-renders when its own inputs do. +export const MobileFolderWorktreeRow = memo(function MobileFolderWorktreeRow({ + hostId, + desktopCapabilities, + worktree, + unreachable, + subscribed, + refreshers, + onOpen +}: Props) { + const t = useMobileTranslation() + const { client, state } = useHostClient(hostId) + // A relayed server's row reads through the desktop, as its own Source Control does. + const routeHost = workspaceRouteExecutionHost(client, desktopCapabilities, worktree.hostId) + const openable = routeHost !== null && !unreachable + const status = useFolderWorktreeStatus({ + hostId, + client: state === 'connected' && openable ? client : null, + routeHost: routeHost ?? undefined, + worktreeId: worktree.worktreeId, + subscribed, + refreshers + }) + + const read = status.data?.kind === 'ready' ? status.data.status : undefined + // A failed, partial or unsupported read must not look like a clean worktree, even over an older good read. + const unread = + status.data?.kind === 'unavailable' || status.error !== null || read?.readError !== undefined + const branch = read + ? formatBranchLabel(read.branch, read.head) + : worktree.branch.replace(/^refs\/heads\//, '') + const ahead = read?.upstreamStatus?.ahead ?? 0 + const behind = read?.upstreamStatus?.behind ?? 0 + const changeCount = read?.entries.length ?? 0 + const subtitle = [ + // Same-repo worktrees are told apart by name, as desktop does. + worktree.displayName && worktree.displayName !== branch ? worktree.displayName : null, + branch || null, + ahead > 0 ? `↑${ahead}` : null, + behind > 0 ? `↓${behind}` : null + ] + .filter(Boolean) + .join(' · ') + return ( + 0 + ? String(changeCount) + : undefined + } + navigates={openable} + disabled={!openable} + onPress={() => onOpen(worktree)} + /> + ) +}) diff --git a/mobile/src/source-control/folder/folder-lineage-serialization.ts b/mobile/src/source-control/folder/folder-lineage-serialization.ts new file mode 100644 index 00000000000..fc4a7f686bb --- /dev/null +++ b/mobile/src/source-control/folder/folder-lineage-serialization.ts @@ -0,0 +1,19 @@ +import type { Worktree } from '../../worktree/workspace-list-types' + +/** The fields folder membership and order depend on; labels and agent state are left out so their churn rebuilds nothing. */ +export function serializeFolderLineage(row: Worktree): string { + return JSON.stringify([ + row.worktreeId, + row.hostId, + row.repoId, + row.workspaceKind, + row.isArchived, + row.parentWorktreeId, + row.worktreeInstanceId, + row.lineageWorktreeInstanceId, + row.parentWorktreeInstanceId, + row.crossStoreParent, + row.workspaceLineage, + row.createdAt + ]) +} diff --git a/mobile/src/source-control/folder/mobile-folder-attached-worktrees.test.ts b/mobile/src/source-control/folder/mobile-folder-attached-worktrees.test.ts new file mode 100644 index 00000000000..b6f91d3d2a0 --- /dev/null +++ b/mobile/src/source-control/folder/mobile-folder-attached-worktrees.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import type { Worktree } from '../../worktree/workspace-list-types' +import { + findFolderWorkspaceRow, + getFolderAttachedWorktrees +} from './mobile-folder-attached-worktrees' + +function worktree(overrides: Partial): Worktree { + return { + workspaceKind: 'git', + worktreeId: 'worktree', + repoId: 'repo-1', + repo: 'api', + branch: 'feature', + displayName: 'worktree', + path: '/tmp/worktree', + liveTerminalCount: 0, + hasAttachedPty: false, + unread: false, + isPinned: false, + linkedPR: null, + ...overrides + } +} + +const folder = worktree({ + workspaceKind: 'folder-workspace', + worktreeId: 'folder:f-1', + repoId: 'folder-project:group-1' +}) +const attachedTo = (worktreeId: `folder:${string}`) => ({ + workspaceLineage: { parentWorkspaceKey: worktreeId, parentInstanceId: 'f-1' } +}) + +describe('getFolderAttachedWorktrees', () => { + it("lists the folder's children and their worktree descendants, oldest first", () => { + const web = worktree({ worktreeId: 'web', createdAt: 20, ...attachedTo('folder:f-1') }) + const api = worktree({ worktreeId: 'api', createdAt: 10, ...attachedTo('folder:f-1') }) + const apiChild = worktree({ worktreeId: 'api-child', createdAt: 30, parentWorktreeId: 'api' }) + const archived = worktree({ + worktreeId: 'archived', + createdAt: 5, + isArchived: true, + ...attachedTo('folder:f-1') + }) + const other = worktree({ worktreeId: 'other', ...attachedTo('folder:f-2') }) + const unrelated = worktree({ worktreeId: 'unrelated' }) + + const rows = [folder, web, api, apiChild, archived, other, unrelated] + expect(getFolderAttachedWorktrees(rows, folder).map((row) => row.worktreeId)).toEqual([ + 'api', + 'web', + 'api-child' + ]) + }) +}) + +describe('findFolderWorkspaceRow', () => { + it("finds the paired desktop's folder, or a relayed server's when the route names it", () => { + const serverFolder = { ...folder, hostId: 'runtime:env-1' as const } + const rows = [serverFolder, folder] + expect(findFolderWorkspaceRow(rows, 'folder:f-1', undefined)).toBe(folder) + expect(findFolderWorkspaceRow(rows, 'folder:f-1', 'runtime:env-1')).toBe(serverFolder) + }) +}) diff --git a/mobile/src/source-control/folder/mobile-folder-attached-worktrees.ts b/mobile/src/source-control/folder/mobile-folder-attached-worktrees.ts new file mode 100644 index 00000000000..5fbae1707b5 --- /dev/null +++ b/mobile/src/source-control/folder/mobile-folder-attached-worktrees.ts @@ -0,0 +1,87 @@ +import type { ExecutionHostId } from '../../../../src/shared/execution-host' +import type { Worktree } from '../../worktree/workspace-list-types' +import { + getMobileWorkspaceLineageChildren, + type MobileLineageRow, + type MobileLineageStores +} from '../../worktree/mobile-workspace-lineage' +import { getWorktreeRowIdentity } from '../../worktree/worktree-host-row-identity' +import { serializeFolderLineage } from './folder-lineage-serialization' + +/** What a folder list row shows of a host row. */ +export type FolderListRow = Pick< + Worktree, + 'worktreeId' | 'hostId' | 'repo' | 'displayName' | 'branch' +> + +type FolderLineageRow = MobileLineageRow & Pick + +// Keyed by row object: the catalog query keeps an unchanged row's identity, so only changed rows are read again. +const lineageKeys = new WeakMap() +const listRows = new WeakMap() + +/** A row's lineage key, serialized once per row object. */ +export function folderLineageKey(row: Worktree): string { + let key = lineageKeys.get(row) + if (key === undefined) { + key = serializeFolderLineage(row) + lineageKeys.set(row, key) + } + return key +} + +export function toFolderListRow(row: Worktree): FolderListRow { + let listRow = listRows.get(row) + if (listRow === undefined) { + listRow = { + worktreeId: row.worktreeId, + hostId: row.hostId, + repo: row.repo, + displayName: row.displayName, + branch: row.branch + } + listRows.set(row, listRow) + } + return listRow +} + +/** The folder workspace row a Source Control route names; a route without a server means the paired desktop's own. */ +export function findFolderWorkspaceRow( + rows: readonly Row[], + folderWorktreeId: string, + executionHost: ExecutionHostId | undefined +): Row | undefined { + return rows.find( + (row) => + row.worktreeId === folderWorktreeId && + row.workspaceKind === 'folder-workspace' && + (executionHost ? row.hostId === executionHost : !row.hostId?.startsWith('runtime:')) + ) +} + +/** Worktrees attached to the folder, as the host list nests them, with their worktree descendants; oldest first, like desktop. */ +export function getFolderAttachedWorktrees( + rows: readonly Row[], + folder: Row, + stores?: MobileLineageStores +): Row[] { + const childrenByParent = getMobileWorkspaceLineageChildren(rows, stores) + const members = new Map() + const queue = [folder] + for (let i = 0; i < queue.length; i++) { + for (const child of childrenByParent.get(getWorktreeRowIdentity(queue[i])) ?? []) { + const identity = getWorktreeRowIdentity(child) + if (child.isArchived || members.has(identity)) { + continue + } + members.set(identity, child) + queue.push(child) + } + } + // Rows carry no attach time; a child is attached when it is created from the folder. + return [...members.values()].sort( + (left, right) => + (left.createdAt ?? 0) - (right.createdAt ?? 0) || + getWorktreeRowIdentity(left).localeCompare(getWorktreeRowIdentity(right)) + ) +} diff --git a/mobile/src/source-control/folder/use-folder-worktree-status.perf.test.ts b/mobile/src/source-control/folder/use-folder-worktree-status.perf.test.ts new file mode 100644 index 00000000000..5cadbf87217 --- /dev/null +++ b/mobile/src/source-control/folder/use-folder-worktree-status.perf.test.ts @@ -0,0 +1,257 @@ +import { createElement, Fragment } from 'react' +import { useQueryClient, type QueryClient } from '@tanstack/react-query' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionHostId } from '../../../../src/shared/execution-host' +import type { RuntimeClientEventStreamMessage } from '../../../../src/shared/runtime-client-events' +import { stubRpcClient } from '../../transport/rpc/rpc-client-test-stub' +import type { RpcClient } from '../../transport/rpc/rpc-client-contract' +import { + documentQueryClient, + settleQueryNotifications +} from '../../test-support/rpc-recording/hook-mount' +import { hostQueryKey } from '../../query/host-query-key' +import { + publishGitStatusSummary, + serverGitStatusSummaryQuery, + type GitStatusRead +} from '../mobile-git-status-query' +import { useFolderWorktreeStatus } from './use-folder-worktree-status' + +const STATUS = { branch: 'feature', entries: [] } + +type StatusRead = { worktree: string; includeLineStats?: boolean } + +function isStatusRead(params: unknown): params is StatusRead { + return typeof params === 'object' && params !== null && 'worktree' in params +} + +const runtimeHost = (environmentId: string): ExecutionHostId => `runtime:${environmentId}` +const READY: RuntimeClientEventStreamMessage = { type: 'ready', subscriptionId: 'sub-1' } + +function countingClient() { + const reads: StatusRead[] = [] + let eventStreams = 0 + let emit: (event: RuntimeClientEventStreamMessage) => void = () => {} + const client: RpcClient = stubRpcClient({ + getLastConnectedAt: () => 1, + sendRequest: async (method, params) => { + if (method === 'git.status') { + if (isStatusRead(params)) { + reads.push(params) + } + } + return { id: 'call', ok: true, result: STATUS, _meta: { runtimeId: 'runtime-1' } } + }, + subscribe: (method, _params, onEvent) => { + if (method === 'runtime.clientEvents.subscribe') { + eventStreams += 1 + emit = (event) => onEvent(event) + } + return () => {} + } + }) + return { + client, + reads, + eventStreams: () => eventStreams, + emit: (event: RuntimeClientEventStreamMessage) => emit(event) + } +} + +let renderer: ReactTestRenderer | null = null +const queries = documentQueryClient() + +afterEach(async () => { + act(() => renderer?.unmount()) + renderer = null + await queries.end() + vi.useRealTimers() +}) + +const REFRESHERS = new Set<() => void>() +const shown = new Map() +let queryClient: QueryClient | null = null +const rowKey = (row: RowSpec) => `${row.routeHost ?? 'local'}|${row.worktreeId}` + +function Row(props: { + client: RpcClient + worktreeId: string + routeHost: ExecutionHostId | undefined + subscribed: boolean +}): null { + queryClient = useQueryClient() + const status = useFolderWorktreeStatus({ + hostId: 'host-1', + client: props.client, + routeHost: props.routeHost, + worktreeId: props.worktreeId, + subscribed: props.subscribed, + refreshers: REFRESHERS + }) + shown.set(rowKey(props), { data: status.data, updatedAt: status.dataUpdatedAt }) + return null +} + +type RowSpec = { worktreeId: string; routeHost: ExecutionHostId | undefined } + +/** One folder's attached worktrees: N in repo-a and N in repo-b on the desktop, N spread over S relayed servers. */ +function folderRows(n: number, servers: number): RowSpec[] { + const local = ['repo-a', 'repo-b'].flatMap((repo) => + Array.from({ length: n }, (_, i) => ({ worktreeId: `${repo}::/w${i}`, routeHost: undefined })) + ) + const relayed = Array.from({ length: n }, (_, i) => ({ + worktreeId: `repo-s::/w${i}`, + routeHost: runtimeHost(`env-${i % servers}`) + })) + return [...local, ...relayed] +} + +async function mountFolder(rows: RowSpec[], client: RpcClient) { + const render = (subscribed: boolean) => + queries.wrap( + createElement( + Fragment, + null, + rows.map((row) => createElement(Row, { key: rowKey(row), client, ...row, subscribed })) + ) + ) + await act(async () => { + renderer = create(render(true)) + }) + await settleQueryNotifications() + return async (subscribed: boolean) => { + await act(async () => renderer?.update(render(subscribed))) + await settleQueryNotifications() + } +} + +// Past every read's one-second signal floor, so each signal reads at once. +function passSignalFloor(): void { + vi.setSystemTime(Date.now() + 5_000) +} + +describe.each([ + { n: 10, servers: 3 }, + { n: 100, servers: 30 } +])('folder Source Control rows at N=$n over $servers servers', ({ n, servers }) => { + it('read each status once without line stats, on one desktop event stream', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + const host = countingClient() + await mountFolder(folderRows(n, servers), host.client) + host.emit(READY) + await settleQueryNotifications() + + expect(host.reads).toHaveLength(3 * n) + expect(host.reads.every((read) => read.includeLineStats === false)).toBe(true) + // Relayed servers' statuses get no desktop signal, so they open no event stream of their own. + expect(host.eventStreams()).toBe(1) + }) + + it('read once per worktree a signal names, and not at all while hidden', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + const host = countingClient() + const setSubscribed = await mountFolder(folderRows(n, servers), host.client) + host.emit(READY) + await settleQueryNotifications() + + host.reads.length = 0 + passSignalFloor() + await act(async () => host.emit({ type: 'gitStatusChanged', repoId: 'repo-a' })) + await settleQueryNotifications() + expect(host.reads).toHaveLength(n) + + host.reads.length = 0 + passSignalFloor() + await act(async () => + host.emit({ type: 'gitStatusChanged', repoId: 'repo-a', worktreeIds: ['repo-a::/w0'] }) + ) + await settleQueryNotifications() + expect(host.reads.map((read) => read.worktree)).toEqual(['id:repo-a::/w0']) + + await setSubscribed(false) + host.reads.length = 0 + passSignalFloor() + await act(async () => host.emit({ type: 'gitStatusChanged', repoId: 'repo-a' })) + await settleQueryNotifications() + expect(host.reads).toHaveLength(0) + }) + + it("shows a child's detailed read on Back with no read of its own", async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + const host = countingClient() + // The same worktree id on another server and on the desktop must keep their own summaries. + const opened: RowSpec = { worktreeId: 'repo-s::/w0', routeHost: 'runtime:env-0' } + const rows = [ + ...folderRows(n, servers), + { worktreeId: 'repo-s::/w0', routeHost: runtimeHost('env-x') }, + { worktreeId: 'repo-s::/w0', routeHost: undefined } + ] + const setSubscribed = await mountFolder(rows, host.client) + const before = new Map(shown) + const listed = before.get(rowKey(opened))! + if (listed.data?.kind !== 'ready') { + throw new Error('summary not read') + } + + // The child opens over the list, commits, and its forced reread lands. + await setSubscribed(false) + host.reads.length = 0 + vi.setSystemTime(Date.now() + 1_000) + const after: GitStatusRead = { + kind: 'ready', + status: { ...listed.data.status, branch: 'after' } + } + const target = { exec: opened.routeHost!, worktree: opened.worktreeId } + await act(() => + publishGitStatusSummary(queryClient!, 'host-1', target, after, Date.now(), true) + ) + // A detailed read older than the summary changes nothing. + await act(() => + publishGitStatusSummary( + queryClient!, + 'host-1', + target, + listed.data!, + listed.updatedAt - 1, + true + ) + ) + + // Back within three seconds, then well past them with no focus or signal. + vi.setSystemTime(Date.now() + 1_000) + await setSubscribed(true) + vi.setSystemTime(Date.now() + 10_000) + await setSubscribed(true) + expect(host.reads).toHaveLength(0) + expect(shown.get(rowKey(opened))?.data).toEqual(after) + const changed = rows.filter( + (row) => shown.get(rowKey(row))?.data !== before.get(rowKey(row))?.data + ) + expect(changed).toEqual([opened]) + }) + + it("keeps a summary's newer failure over an older detailed read", async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + const host = countingClient() + const opened: RowSpec = { worktreeId: 'repo-s::/w0', routeHost: 'runtime:env-0' } + await mountFolder(folderRows(n, servers), host.client) + const listed = shown.get(rowKey(opened))! + const target = { exec: opened.routeHost!, worktree: opened.worktreeId } + const key = hostQueryKey('host-1', serverGitStatusSummaryQuery.family, target, {}) + const cachedDetailAt = Date.now() + 1_000 + vi.setSystemTime(cachedDetailAt + 1_000) + const failure = new Error('status read failed') + await act(async () => + queryClient! + .getQueryCache() + .find({ queryKey: key, exact: true })! + .setState({ status: 'error', error: failure, errorUpdatedAt: Date.now() }) + ) + + await act(() => + publishGitStatusSummary(queryClient!, 'host-1', target, listed.data!, cachedDetailAt, true) + ) + expect(queryClient!.getQueryState(key)?.error).toBe(failure) + }) +}) diff --git a/mobile/src/source-control/folder/use-folder-worktree-status.ts b/mobile/src/source-control/folder/use-folder-worktree-status.ts new file mode 100644 index 00000000000..c8c8f19673d --- /dev/null +++ b/mobile/src/source-control/folder/use-folder-worktree-status.ts @@ -0,0 +1,38 @@ +import { useEffect } from 'react' +import type { ExecutionHostId } from '../../../../src/shared/execution-host' +import { useHostQuery } from '../../query/host-query' +import type { RpcClient } from '../../transport/rpc/rpc-client-contract' +import { scopeRpcClientToExecutionHost } from '../../worktree/execution-host-scoped-rpc-client' +import { gitStatusSummaryQuery, serverGitStatusSummaryQuery } from '../mobile-git-status-query' + +const NO_INPUT = {} + +/** One attached worktree's status summary, read on its own server when relayed. */ +export function useFolderWorktreeStatus(args: { + hostId: string + /** The paired desktop's client while connected and the row can be read, else null. */ + client: RpcClient | null + routeHost: ExecutionHostId | undefined + worktreeId: string + subscribed: boolean + refreshers: Set<() => void> +}) { + const { hostId, client, routeHost, worktreeId, subscribed, refreshers } = args + const rowClient = client && routeHost ? scopeRpcClientToExecutionHost(client, routeHost) : client + const status = useHostQuery( + routeHost ? serverGitStatusSummaryQuery : gitStatusSummaryQuery, + { hostId, client: rowClient }, + { exec: routeHost ?? 'local', worktree: worktreeId }, + NO_INPUT, + { subscribed } + ) + const refetch = status.refetch + useEffect(() => { + const refresh = () => void refetch() + refreshers.add(refresh) + return () => { + refreshers.delete(refresh) + } + }, [refetch, refreshers]) + return status +} diff --git a/mobile/src/source-control/folder/use-mobile-folder-attached-worktrees.perf.test.ts b/mobile/src/source-control/folder/use-mobile-folder-attached-worktrees.perf.test.ts new file mode 100644 index 00000000000..b24b3c0908a --- /dev/null +++ b/mobile/src/source-control/folder/use-mobile-folder-attached-worktrees.perf.test.ts @@ -0,0 +1,298 @@ +import { createElement, Fragment } from 'react' +import { useQueryClient, type QueryClient } from '@tanstack/react-query' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeClientEventStreamMessage } from '../../../../src/shared/runtime-client-events' +import { stubRpcClient } from '../../transport/rpc/rpc-client-test-stub' +import type { RpcClient } from '../../transport/rpc/rpc-client-contract' +import type { Worktree } from '../../worktree/workspace-list-types' +import type * as MobileWorkspaceLineage from '../../worktree/mobile-workspace-lineage' +import type * as FolderLineageSerialization from './folder-lineage-serialization' +import type { MobileRelayHost } from '../../../../src/shared/remote-runtime/relay/mobile-relay-hosts-contract' +import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../../src/shared/mobile-desktop-relay-contract' +import { HOST_LIST_TARGET, hostServerWorkspacesQuery } from '../../host-screen/host-list-queries' +import { hostQueryKey } from '../../query/host-query-key' +import { + documentQueryClient, + settleQueryNotifications +} from '../../test-support/rpc-recording/hook-mount' +import { + useMobileFolderAttachedWorktrees, + type MobileFolderAttachedWorktrees +} from './use-mobile-folder-attached-worktrees' +import { MobileFolderWorktreeRow } from './MobileFolderWorktreeRow' + +vi.mock('react-native', () => ({ AppState: {} })) +const gates = vi.hoisted(() => { + const hostCapabilities: string[] = [] + return { hostCapabilities } +}) +vi.mock('../../components/host-protocol-gates-context', () => ({ + useOptionalHostProtocolGates: () => gates +})) +const host = vi.hoisted(() => { + const state: { client: unknown } = { client: null } + return state +}) +vi.mock('../../transport/connection/host-client-hooks', () => ({ + useHostClient: () => ({ client: host.client, clientId: 'client-1', state: 'connected' }) +})) +const lineage = vi.hoisted(() => ({ resolutions: 0, serialized: 0, rowCommits: 0 })) +vi.mock('../../worktree/mobile-workspace-lineage', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getMobileWorkspaceLineageChildren: ( + ...args: Parameters + ) => { + lineage.resolutions += 1 + return actual.getMobileWorkspaceLineageChildren(...args) + } + } +}) +// Runs behind the production key cache, so each call is a row serialized. +vi.mock('./folder-lineage-serialization', async (importOriginal) => { + const actual = await importOriginal() + return { + serializeFolderLineage: (row: Worktree) => { + lineage.serialized += 1 + return actual.serializeFolderLineage(row) + } + } +}) +// Inside the production row's memo boundary: each call is a committed row render. +vi.mock('../../components/list/ListGroup', () => ({ + ListRow: () => { + lineage.rowCommits += 1 + return null + } +})) +vi.mock('./use-folder-worktree-status', () => ({ + useFolderWorktreeStatus: () => NO_STATUS +})) +vi.mock('../../i18n/use-mobile-translation', () => ({ + useMobileTranslation: () => (_key: string, fallback: string) => fallback +})) +const NO_STATUS = vi.hoisted(() => ({ data: undefined, error: null })) +const REFRESHERS = new Set<() => void>() +const NOOP = (): void => {} + +const READY: RuntimeClientEventStreamMessage = { type: 'ready', subscriptionId: 'sub-1' } + +function row(worktreeId: string, overrides: Partial = {}): Worktree { + return { + worktreeId, + repoId: worktreeId.split('::')[0], + repo: 'api', + branch: 'feature', + displayName: worktreeId, + path: `/src/${worktreeId}`, + liveTerminalCount: 0, + hasAttachedPty: false, + unread: false, + isPinned: false, + linkedPR: null, + ...overrides + } +} + +/** H host rows: the folder, N attached worktrees, and unrelated ones. */ +function catalog(h: number, n: number, edit: (rows: Worktree[]) => void = () => {}): Worktree[] { + const rows = [row('folder:f-1', { workspaceKind: 'folder-workspace' })] + for (let i = 0; i < n; i++) { + rows.push( + row(`repo-a::/w${i}`, { + workspaceLineage: { parentWorkspaceKey: 'folder:f-1', parentInstanceId: null } + }) + ) + } + for (let i = rows.length; i < h; i++) { + rows.push(row(`repo-b::/other${i}`)) + } + edit(rows) + return rows +} + +function catalogHost(initial: Worktree[]) { + let rows = initial + let emit: (event: RuntimeClientEventStreamMessage) => void = () => {} + const client: RpcClient = stubRpcClient({ + getLastConnectedAt: () => 1, + sendRequest: async (method) => ({ + id: 'call', + ok: true, + result: method === 'worktree.ps' ? { worktrees: rows } : {}, + _meta: { runtimeId: 'runtime-1' } + }), + subscribe: (method, _params, onEvent) => { + if (method === 'runtime.clientEvents.subscribe') { + emit = (event) => onEvent(event) + } + return () => {} + } + }) + return { + client, + setRows: (next: Worktree[]) => { + rows = next + }, + emit: (event: RuntimeClientEventStreamMessage) => emit(event) + } +} + +let renderer: ReactTestRenderer | null = null +const queries = documentQueryClient() + +afterEach(async () => { + gates.hostCapabilities = [] + act(() => renderer?.unmount()) + renderer = null + await queries.end() + vi.useRealTimers() +}) + +let queryClient: QueryClient | null = null + +async function mount(read: { current: MobileFolderAttachedWorktrees | null }) { + function Harness() { + queryClient = useQueryClient() + const folder = useMobileFolderAttachedWorktrees('host-1', 'folder:f-1', true) + read.current = folder + return createElement( + Fragment, + null, + folder.worktrees.map((worktree) => + createElement(MobileFolderWorktreeRow, { + key: worktree.worktreeId, + hostId: 'host-1', + desktopCapabilities: folder.desktopCapabilities, + worktree, + unreachable: false, + subscribed: true, + refreshers: REFRESHERS, + onOpen: NOOP + }) + ) + ) + } + await act(async () => { + renderer = create(queries.wrap(createElement(Harness))) + }) + await settleQueryNotifications() +} + +async function rowsChanged(fake: ReturnType, next: Worktree[]) { + fake.setRows(next) + // Past the catalog's three-second signal floor. + vi.setSystemTime(Date.now() + 5_000) + await act(async () => fake.emit({ type: 'worktreeRowsChanged' })) + await settleQueryNotifications() +} + +function churn(rows: Worktree[]): void { + const h = rows.length + rows[1] = { ...rows[1], liveTerminalCount: 3, unread: true } + rows[h - 1] = { ...rows[h - 1], status: 'working' } + rows[h - 2] = { ...rows[h - 2], displayName: 'elsewhere', branch: 'other' } +} + +function rename(rows: Worktree[]): void { + rows[2] = { ...rows[2], displayName: 'renamed', branch: 'renamed-branch' } +} + +describe.each([ + { h: 750, n: 30 }, + { h: 7_500, n: 300 } +])('folder membership over H=$h host rows, N=$n attached', ({ h, n }) => { + it('resolves lineage only for lineage changes, serializing and committing only changed rows', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + const fake = catalogHost(catalog(h, n)) + host.client = fake.client + const read: { current: MobileFolderAttachedWorktrees | null } = { current: null } + await mount(read) + fake.emit(READY) + await settleQueryNotifications() + expect(read.current?.worktrees).toHaveLength(n) + const members = read.current!.worktrees + lineage.resolutions = 0 + lineage.serialized = 0 + lineage.rowCommits = 0 + + // Agent attention and terminal counts on a member and an unrelated row, and an unrelated rename. + await rowsChanged(fake, catalog(h, n, churn)) + expect(lineage.resolutions).toBe(0) + expect(lineage.serialized).toBe(3) + expect(lineage.rowCommits).toBe(0) + expect(read.current?.worktrees).toBe(members) + + // A member renamed: shown at once, without resolving lineage or touching the other members. + lineage.serialized = 0 + await rowsChanged( + fake, + catalog(h, n, (rows) => { + churn(rows) + rename(rows) + }) + ) + expect(lineage.resolutions).toBe(0) + expect(lineage.serialized).toBe(1) + expect(lineage.rowCommits).toBe(1) + const renamed = read.current!.worktrees + expect(renamed[1]).toMatchObject({ displayName: 'renamed', branch: 'renamed-branch' }) + expect(renamed.filter((member, index) => member !== members[index])).toHaveLength(1) + + // A worktree attached: membership follows. + await rowsChanged( + fake, + catalog(h, n, (rows) => { + churn(rows) + rename(rows) + rows[h - 1] = { + ...rows[h - 1], + workspaceLineage: { parentWorkspaceKey: 'folder:f-1', parentInstanceId: null } + } + }) + ) + expect(lineage.resolutions).toBe(1) + expect(read.current?.worktrees).toHaveLength(n + 1) + }) + + it('resolves lineage again for a server store change, not for its health', async () => { + gates.hostCapabilities = [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY] + const fake = catalogHost(catalog(h, n)) + host.client = fake.client + const read: { current: MobileFolderAttachedWorktrees | null } = { current: null } + await mount(read) + const serversKey = hostQueryKey('host-1', hostServerWorkspacesQuery.family, HOST_LIST_TARGET, { + deltas: false + }) + const servers = (overrides: Partial) => ({ + hosts: Array.from({ length: 30 }, (_, i) => ({ + hostId: `runtime:env-${i}` as const, + label: `env ${i}`, + health: 'available' as const, + relay: 'ready' as const, + installationId: `install-${i}`, + ...(i === 0 ? overrides : {}) + })), + worktrees: [] + }) + const setServers = async (overrides: Partial) => { + await act(async () => { + queryClient!.setQueryData(serversKey, servers(overrides)) + }) + await settleQueryNotifications() + } + await setServers({}) + expect(read.current?.worktrees).toHaveLength(n) + const members = read.current!.worktrees + lineage.resolutions = 0 + + await setServers({ health: 'connecting', relay: 'unavailable' }) + expect(lineage.resolutions).toBe(0) + expect(read.current?.worktrees).toBe(members) + + await setServers({ installationId: 'install-new' }) + expect(lineage.resolutions).toBe(1) + }) +}) diff --git a/mobile/src/source-control/folder/use-mobile-folder-attached-worktrees.ts b/mobile/src/source-control/folder/use-mobile-folder-attached-worktrees.ts new file mode 100644 index 00000000000..027e2836d1a --- /dev/null +++ b/mobile/src/source-control/folder/use-mobile-folder-attached-worktrees.ts @@ -0,0 +1,181 @@ +import { useContext, useMemo, useState } from 'react' +import { replaceEqualDeep } from '@tanstack/react-query' +import { MOBILE_RELAY_HOST_WORKTREE_DELTAS_RUNTIME_CAPABILITY } from '../../../../src/shared/mobile-desktop-relay-contract' +import { useOptionalHostProtocolGates } from '../../components/host-protocol-gates-context' +import { + HOST_LIST_POLL_MS, + HOST_LIST_TARGET, + hostServerWorkspacesQuery, + hostWorktreeCatalogQuery, + NO_HOST_LIST_INPUT +} from '../../host-screen/host-list-queries' +import { useWorkspaceExecutionHost } from '../../navigation/workspace-execution-host' +import { useHostQuery } from '../../query/host-query' +import { useHostClient } from '../../transport/connection/host-client-hooks' +import { relaysToServers } from '../../worktree/execution-host-scoped-rpc-client' +import type { MobileLineageStores } from '../../worktree/mobile-workspace-lineage' +import { WorkspaceServerContext } from '../../worktree/workspace-server-context' +import type { ConnectionState } from '../../transport/connection/connection-state' +import type { MobileRelayHost } from '../../../../src/shared/remote-runtime/relay/mobile-relay-hosts-contract' +import { + findFolderWorkspaceRow, + folderLineageKey, + getFolderAttachedWorktrees, + toFolderListRow, + type FolderListRow +} from './mobile-folder-attached-worktrees' +import type { Worktree } from '../../worktree/workspace-list-types' + +type FolderLineage = { + /** The catalog rows last seen. */ + source: readonly Worktree[] + /** The rows membership was resolved over; same lineage keys as `source`, in the same order. */ + rows: readonly Worktree[] + keys: readonly string[] +} + +const NO_ROWS: FolderListRow[] = [] + +// Reads each row's cached key: only rows the catalog replaced are keyed again. +function sameLineage(held: FolderLineage | null, rows: readonly Worktree[]): held is FolderLineage { + if (!held || held.keys.length !== rows.length) { + return false + } + return rows.every((row, index) => folderLineageKey(row) === held.keys[index]) +} + +const NO_CAPABILITIES: readonly string[] = [] +const NO_SERVER_HOSTS: readonly MobileRelayHost[] = [] + +export type MobileFolderAttachedWorktrees = { + /** Undefined until the host's list is read; null when the list has no such folder. */ + folder: FolderListRow | null | undefined + worktrees: readonly FolderListRow[] + desktopCapabilities: readonly string[] + /** The servers the desktop relays to, for rows it can't open. */ + serverHosts: readonly MobileRelayHost[] + connState: ConnectionState + /** The first catalog read's failure, while nothing is shown. */ + loadError: Error | null + refetch: () => void +} + +/** The folder's attached worktrees from the host list's own reads, so the list and this screen agree. */ +export function useMobileFolderAttachedWorktrees( + hostId: string, + folderWorktreeId: string, + subscribed: boolean +): MobileFolderAttachedWorktrees { + // The desktop lists every row, a relayed server's included, so read through it even on a server route. + const { client, state } = useHostClient(hostId) + const executionHost = useWorkspaceExecutionHost() + const server = useContext(WorkspaceServerContext) + const contextGates = useOptionalHostProtocolGates() + // In a server's scope the gates in context are the server's; the desktop's travel with it. + const desktopGates = server ? (server.desktopGates ?? null) : contextGates + const desktopCapabilities = desktopGates?.hostCapabilities ?? NO_CAPABILITIES + const ownInstallationId = desktopGates?.hostInstallationId + const host = { hostId, client: state === 'connected' ? client : null } + const catalog = useHostQuery( + hostWorktreeCatalogQuery, + host, + HOST_LIST_TARGET, + NO_HOST_LIST_INPUT, + { subscribed } + ) + const relays = client !== null && relaysToServers(client, desktopCapabilities) + const servers = useHostQuery( + hostServerWorkspacesQuery, + host, + HOST_LIST_TARGET, + { deltas: desktopCapabilities.includes(MOBILE_RELAY_HOST_WORKTREE_DELTAS_RUNTIME_CAPABILITY) }, + // Execution hosts' notices are not relayed, so server rows keep the host list's poll. + { enabled: relays, subscribed, refetchInterval: HOST_LIST_POLL_MS } + ) + const ownRows = catalog.data?.worktrees + const serverRows = servers.data?.worktrees + const serverHosts = servers.data?.hosts + const allRows = useMemo( + () => ownRows && (serverRows?.length ? [...ownRows, ...serverRows] : ownRows), + [ownRows, serverRows] + ) + // Agent, terminal and label updates rewrite rows constantly; membership is resolved again only when lineage moves. + const [lineage, setLineage] = useState(null) + let held = lineage + if (allRows && held?.source !== allRows) { + held = sameLineage(held, allRows) + ? { ...held, source: allRows } + : { source: allRows, rows: allRows, keys: allRows.map(folderLineageKey) } + setLineage(held) + } + const lineageRows = held?.rows + const serverRowsRead = serverRows !== undefined + // Health and relay state flip often; the resolver reads only each server's installation. + const storesKey = JSON.stringify( + serverHosts?.map((server) => [server.hostId, server.installationId]) ?? [] + ) + const [heldStores, setHeldStores] = useState<{ key: string; stores: MobileLineageStores } | null>( + null + ) + let stores = heldStores?.stores + if (heldStores?.key !== storesKey || stores?.ownInstallationId !== ownInstallationId) { + stores = { ownInstallationId, servers: serverHosts ?? NO_SERVER_HOSTS } + setHeldStores({ key: storesKey, stores }) + } + const membership = useMemo(() => { + // A server's folder is unknown, not missing, until its rows are read. + if (!lineageRows || (executionHost && relays && !serverRowsRead)) { + return undefined + } + const folder = findFolderWorkspaceRow(lineageRows, folderWorktreeId, executionHost) + if (!folder) { + return null + } + const indexOf = new Map(lineageRows.map((row, index) => [row, index])) + return { + folderIndex: indexOf.get(folder)!, + memberIndexes: getFolderAttachedWorktrees(lineageRows, folder, stores).map((row) => + indexOf.get(row)! + ) + } + }, [lineageRows, serverRowsRead, stores, folderWorktreeId, executionHost, relays]) + // Same lineage means same row order, so the current rows supply the labels shown. + const currentRows = held?.source + const shown = useMemo( + () => + membership && currentRows + ? { + folder: toFolderListRow(currentRows[membership.folderIndex]), + worktrees: membership.memberIndexes.map((index) => toFolderListRow(currentRows[index])) + } + : null, + [membership, currentRows] + ) + const [heldShown, setHeldShown] = useState({ from: shown, value: shown }) + let stableShown = heldShown.value + if (heldShown.from !== shown) { + stableShown = replaceEqualDeep(heldShown.value, shown) + setHeldShown({ from: shown, value: stableShown }) + } + const resolved = + membership === undefined + ? { folder: undefined, worktrees: NO_ROWS } + : stableShown + ? stableShown + : { folder: null, worktrees: NO_ROWS } + const refetchCatalog = catalog.refetch + const refetchServers = servers.refetch + return { + ...resolved, + desktopCapabilities, + serverHosts: serverHosts ?? NO_SERVER_HOSTS, + connState: state, + loadError: ownRows ? null : catalog.error, + refetch: () => { + void refetchCatalog() + if (relays) { + void refetchServers() + } + } + } +} diff --git a/mobile/src/source-control/git-status-reply-schema.ts b/mobile/src/source-control/git-status-reply-schema.ts index 327e9292261..fd7f0592b3a 100644 --- a/mobile/src/source-control/git-status-reply-schema.ts +++ b/mobile/src/source-control/git-status-reply-schema.ts @@ -59,7 +59,9 @@ export const gitStatusHostPayloadSchema = z.looseObject({ conflictOperation: salvagedOptional('conflictOperation', gitConflictOperationSchema), branch: salvagedOptional('branch', z.string()), head: salvagedOptional('head', z.string()), - upstreamStatus: salvagedOptional('upstreamStatus', gitUpstreamStatusSchema) + upstreamStatus: salvagedOptional('upstreamStatus', gitUpstreamStatusSchema), + // The host's own failure reading status, sent with whatever entries it got. + readError: salvagedOptional('readError', z.string()) }) export type MobileGitStatusEntry = z.output diff --git a/mobile/src/source-control/mobile-git-status-query.ts b/mobile/src/source-control/mobile-git-status-query.ts index bc88b070c7a..986a262e7f1 100644 --- a/mobile/src/source-control/mobile-git-status-query.ts +++ b/mobile/src/source-control/mobile-git-status-query.ts @@ -1,15 +1,17 @@ import * as z from 'zod' +import { hashKey, type QueryClient } from '@tanstack/react-query' import { translate } from '../i18n/mobile-i18n' import { LOCAL_EXECUTION_HOST_ID } from '../../../src/shared/execution-host' import { getRepoIdFromWorktreeId, worktreeIdsEqual } from '../../../src/shared/worktree/id' import { defineHostQuery, type HostSignalEvent } from '../query/host-query' -import type { HostQueryTarget } from '../query/host-query-key' +import { hostQueryKey, type HostQueryTarget } from '../query/host-query-key' import type { RpcClient } from '../transport/rpc/rpc-client-contract' import { gitStatusHostPayloadSchema, type MobileGitStatusHostPayload } from './git-status-reply-schema' import { gitStatusHostPayloadRead } from './mobile-git-read-operations' +import { holdsRestoredHostQueryData } from '../query/host-query-persistence' import { isMobileGitTransientRefreshError, isMobileGitUnavailableReply, @@ -28,12 +30,14 @@ export type GitStatusRead = async function readGitStatus( client: RpcClient, worktreeId: string, - revalidating: boolean + revalidating: boolean, + lineStats = true ): Promise { for (let attempt = 0; ; attempt += 1) { // Changes rows show each file's +/- counts, so ask the host for line stats. const reply = await gitStatusHostPayloadRead.request(client, { worktree: `id:${worktreeId}`, + ...(lineStats ? {} : { includeLineStats: false }), // Rows are already shown: reuse counts while HEAD and entries match, and never wake a stopped WSL distro. ...(revalidating && { reuseLineStats: true, admissionTier: 'background' as const }) }) @@ -93,3 +97,70 @@ export const gitStatusQuery = defineHostQuery({ ]) } }) + +const fetchStatusSummary = ( + client: RpcClient, + target: HostQueryTarget, + _input: Record, + previous: GitStatusRead | undefined +) => readGitStatus(client, target.worktree ?? '', previous !== undefined, false) + +/** Branch, sync and change count only, for a list that shows no per-file counts; older hosts ignore the opt-out. */ +export const gitStatusSummaryQuery = defineHostQuery({ + family: 'git.statusSummary', + staleTime: 3_000, + refetchOnWindowFocus: true, + signal: { events: ['gitStatusChanged'], floorMs: 1_000, matches: namesThisStatus }, + fetch: fetchStatusSummary +}) + +// A relayed server's statuses get no desktop signal (namesThisStatus), so its reads hold no event stream. +export const serverGitStatusSummaryQuery = defineHostQuery({ + family: 'git.statusSummary.server', + staleTime: 3_000, + refetchOnWindowFocus: true, + fetch: fetchStatusSummary +}) + +/** Hands a detailed read to the summary a folder list shows for the same worktree, so Back shows what an action just did. */ +export async function publishGitStatusSummary( + queryClient: QueryClient, + hostId: string, + target: HostQueryTarget, + read: GitStatusRead, + readAt: number, + /** A read accepted now; otherwise a cached one, which can't tell whether a summary read out started before it. */ + accepted: boolean +): Promise { + // An earlier launch's copy is unconfirmed; the summary's own read decides. + if ( + holdsRestoredHostQueryData( + queryClient, + hashKey(hostQueryKey(hostId, gitStatusQuery.family, target, {})) + ) + ) { + return + } + const summary = + target.exec === LOCAL_EXECUTION_HOST_ID ? gitStatusSummaryQuery : serverGitStatusSummaryQuery + const queryKey = hostQueryKey(hostId, summary.family, target, {}) + // No list reads this worktree, or its summary, a failed read included, is newer than this read. + const supersedes = () => { + const state = queryClient.getQueryState(queryKey) + return state !== undefined && Math.max(state.dataUpdatedAt, state.errorUpdatedAt) < readAt + } + if (!supersedes()) { + return + } + if (queryClient.getQueryState(queryKey)?.fetchStatus !== 'idle') { + // A cached read leaves a summary read out to answer for itself. + if (!accepted) { + return + } + // A summary read out started before this answer; landing later, it would undo it. + await queryClient.cancelQueries({ queryKey, exact: true }, { silent: true }) + } + if (supersedes()) { + queryClient.setQueryData(queryKey, read, { updatedAt: readAt }) + } +} diff --git a/mobile/src/source-control/use-mobile-source-control-loaders.test.ts b/mobile/src/source-control/use-mobile-source-control-loaders.test.ts index 652a7baa978..be243eb6934 100644 --- a/mobile/src/source-control/use-mobile-source-control-loaders.test.ts +++ b/mobile/src/source-control/use-mobile-source-control-loaders.test.ts @@ -1,4 +1,5 @@ import { createElement } from 'react' +import { hashKey, QueryClientProvider, type QueryClient } from '@tanstack/react-query' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -7,6 +8,18 @@ import type { RpcClient } from '../transport/rpc/rpc-client-contract' import type { RpcResponse } from '../transport/rpc/rpc-wire-types' import { useMobileSourceControlLoaders } from './use-mobile-source-control-loaders' import { stubRpcClient } from '../transport/rpc/rpc-client-test-stub' +import { prefetchHostQuery } from '../query/host-query' +import { hostQueryKey } from '../query/host-query-key' +import { + gitStatusQuery, + gitStatusSummaryQuery, + serverGitStatusSummaryQuery, + type GitStatusRead +} from './mobile-git-status-query' +import { gitStatusHostPayloadSchema } from './git-status-reply-schema' +import { createOrcaQueryClient } from '../query/orca-query-client' +import { createHostQueryPersistence } from '../query/host-query-persistence' +import type { OrcaQueryStoreBackend } from '../device-store/query-storage' import { documentQueryClient, @@ -178,3 +191,300 @@ describe('useMobileSourceControlLoaders branch compare', () => { expect(read.loaders?.branchCompareState.kind).toBe('ready') }) }) + +describe('useMobileSourceControlLoaders status summary', () => { + let renderer: ReactTestRenderer | null = null + let queryClient: QueryClient | null = null + + afterEach(async () => { + act(() => renderer?.unmount()) + renderer = null + await queryClient?.cancelQueries() + queryClient?.clear() + queryClient = null + vi.useRealTimers() + }) + + const local = { exec: 'local' as const, worktree: WORKTREE } + const summaryKey = hostQueryKey('host-1', gitStatusSummaryQuery.family, local, {}) + const detailKey = hostQueryKey('host-1', gitStatusQuery.family, local, {}) + + function statusReply(branch: string): RpcResponse { + return success({ branch, head: `${branch}-oid`, entries: [] }) + } + + function readOf(branch: string): GitStatusRead { + return { + kind: 'ready', + status: gitStatusHostPayloadSchema.parse({ branch, head: `${branch}-oid`, entries: [] }) + } + } + + // Summary reads skip line stats; the screen's own read asks for them. + function isSummaryRead(params: unknown): boolean { + return ( + typeof params === 'object' && + params !== null && + 'includeLineStats' in params && + params.includeLineStats === false + ) + } + + function takeRead(calls: PendingCall[], summary: boolean): PendingCall { + const index = calls.findIndex( + (call) => call.method === 'git.status' && isSummaryRead(call.params) === summary + ) + if (index === -1) { + throw new Error(`The schedule expected a pending ${summary ? 'summary' : 'status'} read`) + } + return calls.splice(index, 1)[0] + } + + function setup(calls: PendingCall[]) { + const client = fakeClient(calls) + const documentClient = createOrcaQueryClient() + queryClient = documentClient + // Successful writes to each summary entry: its own reads and publications. + const summaryWrites = new Map() + documentClient.getQueryCache().subscribe((event) => { + const family = event.query.queryKey[2] + if ( + event.type === 'updated' && + event.action.type === 'success' && + typeof family === 'string' && + family.startsWith('git.statusSummary') + ) { + summaryWrites.set( + event.query.queryHash, + (summaryWrites.get(event.query.queryHash) ?? 0) + 1 + ) + } + }) + const read: { loaders: ReturnType | null } = { + loaders: null + } + function Harness(): null { + read.loaders = useMobileSourceControlLoaders({ + hostId: 'host-1', + client, + connState: 'connected', + statusIdentityKey: IDENTITY, + worktreeId: WORKTREE, + setActionError: IGNORE_ACTION_ERROR + }) + return null + } + return { + client, + queryClient: documentClient, + read, + writesTo: (key: readonly unknown[]) => summaryWrites.get(hashKey(key)) ?? 0, + clearWrites: () => summaryWrites.clear(), + readSummary: () => + void prefetchHostQuery( + documentClient, + gitStatusSummaryQuery, + { hostId: 'host-1', client }, + local, + {} + ), + mount: async () => { + await act(async () => { + renderer = create( + createElement(QueryClientProvider, { client: documentClient }, createElement(Harness)) + ) + }) + } + } + } + + it('hands each accepted status read to the matching summary once, over an earlier summary read', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + const calls: PendingCall[] = [] + const child = setup(calls) + // The same worktree id on a relayed server keeps its own summary. + const elsewhereKey = hostQueryKey( + 'host-1', + serverGitStatusSummaryQuery.family, + { exec: 'runtime:env-1', worktree: WORKTREE }, + {} + ) + const elsewhereData = { kind: 'unavailable' as const } + child.queryClient.setQueryData(elsewhereKey, elsewhereData) + child.clearWrites() + await child.mount() + child.readSummary() + const stale = takeRead(calls, true) + const screenRead = takeRead(calls, false) + + // Landing just after the screen's read, the earlier summary read must not undo it. + await act(async () => { + screenRead.settle(statusReply('opened')) + for (let i = 0; i < 20; i++) { + await Promise.resolve() + } + vi.setSystemTime(Date.now() + 1) + stale.settle(statusReply('before')) + }) + await settleQueryNotifications() + expect(child.queryClient.getQueryData(summaryKey)).toMatchObject({ + status: { branch: 'opened' } + }) + expect(child.writesTo(summaryKey)).toBe(1) + + // An action's forced reread. + const reread = child.read.loaders!.loadStatus({ force: true }) + await settleQueryNotifications() + await act(async () => takeRead(calls, false).settle(statusReply('after'))) + await act(async () => { + await reread + }) + await settleQueryNotifications() + expect(child.queryClient.getQueryData(summaryKey)).toMatchObject({ + status: { branch: 'after' } + }) + expect(child.writesTo(summaryKey)).toBe(2) + expect(child.queryClient.getQueryData(elsewhereKey)).toBe(elsewhereData) + expect(child.writesTo(elsewhereKey)).toBe(0) + expect(calls.filter((call) => call.method === 'git.status')).toHaveLength(0) + }) + + // Summary read at 100, detail cached at 200, child opened at 400. + async function cachedDetailOpened(refreshAt300: boolean) { + vi.useFakeTimers({ toFake: ['Date'], now: 100 }) + const calls: PendingCall[] = [] + const child = setup(calls) + child.queryClient.setQueryData(summaryKey, readOf('first'), { updatedAt: 100 }) + vi.setSystemTime(200) + child.queryClient.setQueryData(detailKey, readOf('cached'), { updatedAt: 200 }) + vi.setSystemTime(300) + if (refreshAt300) { + await child.queryClient.invalidateQueries({ queryKey: summaryKey }, { cancelRefetch: false }) + child.readSummary() + } + child.clearWrites() + vi.setSystemTime(400) + await child.mount() + await settleQueryNotifications() + return { calls, child } + } + + it('leaves a summary read out to answer when the child opens over an older cached read', async () => { + const { calls, child } = await cachedDetailOpened(true) + expect(child.writesTo(summaryKey)).toBe(0) + await act(async () => takeRead(calls, true).settle(statusReply('refresh'))) + await settleQueryNotifications() + expect(child.queryClient.getQueryData(summaryKey)).toMatchObject({ + status: { branch: 'refresh' } + }) + }) + + it('catches an idle older summary up to the cached read once, keeping its time', async () => { + const { calls, child } = await cachedDetailOpened(false) + expect(child.writesTo(summaryKey)).toBe(1) + expect(child.queryClient.getQueryData(summaryKey)).toEqual(readOf('cached')) + expect(child.queryClient.getQueryState(summaryKey)?.dataUpdatedAt).toBe(200) + expect(calls.filter((call) => call.method === 'git.status')).toHaveLength(0) + }) + + it("never hands an earlier launch's status to the summary, nor cancels its live read", async () => { + const store = memoryStore() + const earlier = createOrcaQueryClient() + const earlierPersistence = createHostQueryPersistence(earlier, store.backend) + earlier.getQueryCache().build(earlier, { + queryKey: detailKey, + meta: { persist: gitStatusQuery.persist } + }) + earlier.setQueryData(detailKey, readOf('saved'), { updatedAt: Date.now() - 86_400_000 }) + await settleQueryNotifications() + await earlierPersistence.flush() + expect(store.rows.size).toBe(1) + earlier.clear() + + const calls: PendingCall[] = [] + const child = setup(calls) + createHostQueryPersistence(child.queryClient, store.backend) + child.readSummary() + const release = store.holdReads() + await child.mount() + release() + await settleQueryNotifications() + expect(child.queryClient.getQueryData(detailKey)).toEqual(readOf('saved')) + expect(child.writesTo(summaryKey)).toBe(0) + + await act(async () => takeRead(calls, true).settle(statusReply('live'))) + await settleQueryNotifications() + expect(child.queryClient.getQueryData(summaryKey)).toMatchObject({ status: { branch: 'live' } }) + + await act(async () => takeRead(calls, false).settle(statusReply('newer'))) + await settleQueryNotifications() + expect(child.queryClient.getQueryData(summaryKey)).toMatchObject({ + status: { branch: 'newer' } + }) + expect(child.writesTo(summaryKey)).toBe(2) + }) + + it.each([10, 100])('touches one summary of N=%i listed beside it', async (n) => { + vi.useFakeTimers({ toFake: ['Date'] }) + const calls: PendingCall[] = [] + const child = setup(calls) + const others = Array.from({ length: n }, (_, i) => [ + hostQueryKey( + 'host-1', + gitStatusSummaryQuery.family, + { exec: 'local', worktree: `repo42::/o${i}` }, + {} + ), + hostQueryKey( + 'host-1', + serverGitStatusSummaryQuery.family, + { exec: `runtime:env-${i}`, worktree: WORKTREE }, + {} + ) + ]).flat() + for (const key of [summaryKey, ...others]) { + child.queryClient.setQueryData(key, readOf('first')) + } + child.clearWrites() + vi.setSystemTime(Date.now() + 1) + await child.mount() + await act(async () => takeRead(calls, false).settle(statusReply('opened'))) + await settleQueryNotifications() + expect(child.writesTo(summaryKey)).toBe(1) + expect(others.filter((key) => child.writesTo(key) > 0)).toHaveLength(0) + expect(calls.filter((call) => call.method === 'git.status')).toHaveLength(0) + }) +}) + +function memoryStore() { + const rows = new Map() + let readGate: Promise = Promise.resolve() + const backend: OrcaQueryStoreBackend = { + hostId: 'host-1', + read: async (keys) => { + await readGate + return { status: 'ok', values: keys.map((storeKey) => rows.get(storeKey) ?? null) } + }, + write: async (ops) => { + for (const op of ops) { + if (op.value === null) { + rows.delete(op.key) + } else { + rows.set(op.key, op.value) + } + } + return { written: true } + } + } + return { + backend, + rows, + holdReads: () => { + let release = (): void => {} + readGate = new Promise((resolve) => { + release = resolve + }) + return release + } + } +} diff --git a/mobile/src/source-control/use-mobile-source-control-loaders.ts b/mobile/src/source-control/use-mobile-source-control-loaders.ts index 26dca818647..48a36a09c1b 100644 --- a/mobile/src/source-control/use-mobile-source-control-loaders.ts +++ b/mobile/src/source-control/use-mobile-source-control-loaders.ts @@ -1,5 +1,5 @@ -import { useCallback, useMemo, useRef, useState, type MutableRefObject } from 'react' -import { useQueryClient } from '@tanstack/react-query' +import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react' +import { hashKey, useQueryClient } from '@tanstack/react-query' import { useMobileTranslation } from '../i18n/use-mobile-translation' import type { MobileSourceControlSettings } from './mobile-source-control-settings-operations' import { useMobileSourceControlSettings } from './mobile-source-control-settings' @@ -16,7 +16,12 @@ import { readBranchCompareOutcome, type BranchCompareAttempt } from './mobile-branch-compare-outcome' -import { gitStatusQuery } from './mobile-git-status-query' +import { + gitStatusQuery, + publishGitStatusSummary, + type GitStatusRead +} from './mobile-git-status-query' +import { hostQueryKey } from '../query/host-query-key' import type { LoadStatusOptions, MobileBranchCompareState, @@ -103,6 +108,34 @@ export function useMobileSourceControlLoaders(params: Params): MobileSourceContr enabled: statusEnabled, subscribed: focused }) + // Published as each read is accepted: a summary read answering just after would otherwise look newer. + useEffect(() => { + const statusKey = hostQueryKey(hostId, gitStatusQuery.family, target, NO_INPUT) + const statusHash = hashKey(statusKey) + const publish = (accepted: boolean) => { + const state = queryClient.getQueryState(statusKey) + if (state?.data) { + void publishGitStatusSummary( + queryClient, + hostId, + target, + state.data, + state.dataUpdatedAt, + accepted + ) + } + } + publish(false) + return queryClient.getQueryCache().subscribe((event) => { + if ( + event.type === 'updated' && + event.action.type === 'success' && + event.query.queryHash === statusHash + ) { + publish(true) + } + }) + }, [hostId, queryClient, target]) const mountedRef = useRef(true) const setRootRef = useCallback((node: View | null): void => { // Invalidate route-detached RPC writes without waiting for passive cleanup. diff --git a/mobile/src/worktree/mobile-workspace-lineage.ts b/mobile/src/worktree/mobile-workspace-lineage.ts index 2973036ccc3..9b500882848 100644 --- a/mobile/src/worktree/mobile-workspace-lineage.ts +++ b/mobile/src/worktree/mobile-workspace-lineage.ts @@ -29,7 +29,23 @@ export function getMobileWorkspaceLineageGroupKey(worktree: WorktreeLineageIdent return `workspace-lineage:${encodeURIComponent(getWorktreeRowIdentity(worktree))}` } -function hasValidLineageParent(worktree: Worktree, parent: Worktree): boolean { +/** The row fields lineage reads; a caller may resolve over a projection of its rows. */ +export type MobileLineageRow = Pick< + Worktree, + | 'worktreeId' + | 'hostId' + | 'repoId' + | 'workspaceKind' + | 'isArchived' + | 'parentWorktreeId' + | 'worktreeInstanceId' + | 'lineageWorktreeInstanceId' + | 'parentWorktreeInstanceId' + | 'crossStoreParent' + | 'workspaceLineage' +> + +function hasValidLineageParent(worktree: MobileLineageRow, parent: MobileLineageRow): boolean { if ( worktree.lineageWorktreeInstanceId === undefined && worktree.parentWorktreeInstanceId === undefined @@ -50,15 +66,17 @@ export type MobileLineageStores = { servers: readonly Pick[] } -type ResolutionRow = { - worktree: Worktree +type ResolutionRow = { + worktree: Row id: string hostId: ExecutionHostId instanceId: string | undefined isArchived: boolean } -function getResolutionRows(worktrees: readonly Worktree[]): Map { +function getResolutionRows( + worktrees: readonly Row[] +): Map> { return new Map( worktrees.map((worktree) => [ getWorktreeRowIdentity(worktree), @@ -75,7 +93,7 @@ function getResolutionRows(worktrees: readonly Worktree[]): Map