mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 09:57:48 +00:00
Show folder workspace Source Control on the phone (#39999)
* feat(mobile): show folder workspace Source Control on the phone A folder workspace now has Source Control on the phone: one row per attached worktree (repo, name, branch, ahead/behind, change count), read from the host list's own lineage projection and each worktree's git.status. Tapping a row opens that worktree's existing Source Control in place; Back returns to the list. * fix(mobile): folder Source Control routing, back, hidden reads and failure states - A desktop worktree under a server's folder is listed and opens with desktop routing and gates. - A child opened in the dock gets Back to the folder list beside Close. - Rows and the catalog stop reading while the screen is covered, as the single-worktree screen does. - Offline, failed and missing-folder states; rows whose status can't be read say Unavailable. - Server rows poll like the host list; branch shown without refs/heads/. - Perf contract: git.status reads per signal at N and 10N rows. * perf(mobile): folder Source Control reads summaries and rebuilds only on lineage changes - Rows read a status summary without per-file line stats; relayed servers' rows open no desktop event stream. - Membership resolves over a projection of lineage and label fields, so agent and terminal churn rebuilds nothing. - A row whose re-read failed or whose host reported readError shows Unavailable. - A connection still coming up shows loading, not offline. - Perf contracts at N/10N rows and H/10H catalog rows. * perf(mobile): folder membership keys on lineage only; child status reads update the folder list Membership resolves again only when a row's lineage fields change, keying only rows the catalog replaced; labels come from the current rows. A child Source Control's newer status read is handed to the folder list's summary, so Back shows what an action did without another read. * fix(mobile): folder summary takes child reads as they land and ignores server health Membership depends only on each relayed server's installation, so health and relay flips no longer resolve the folder again. A child Source Control hands each status read to the folder summary as it is accepted, so a summary read answering just after can't hide it, and an older read never replaces a newer summary failure. * fix(mobile): folder summary skips restored and cached reads that could undo newer ones Persisted status is marked restored before anyone hears of it, so a child screen never hands an earlier launch's status to the folder list. A cached read caught up on opening writes only an idle summary and never cancels one in flight. Perf contracts count real lineage serializations, committed row renders and summary publications. * fix(mobile): clear the restored mark on any answer but the restore's own A live answer timed before the stored record, as after a clock correction, is still this launch's, so its reads and the folder summary treat it as confirmed. * fix(mobile): the restore's own write skips clearing its mark only once A listener replacing restored data as it hears of it writes this launch's answer, so the mark clears for it too. * test(mobile): type folder Source Control test fixtures without assertions * test(mobile): name mocked modules with type-only namespace imports * feat(mobile): show folder worktrees as cards on the sheet surface Each attached worktree is its own grouped card on the grey sheet surface, the look the phone's option sheets use, so rows read apart from the page. * fix(mobile): keep folder Source Control's states on the card surface Loading, empty, error and the list now share the sheet surface, so the screen no longer changes background between states.
This commit is contained in:
@@ -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 (
|
||||
<MobileSourceControlPanel
|
||||
<MobileSourceControlRoutePanel
|
||||
hostId={hostId}
|
||||
worktreeId={worktreeId}
|
||||
name={firstParam(params.name)}
|
||||
|
||||
@@ -3585,6 +3585,12 @@
|
||||
"unavailableTitle": "Source Control Unavailable",
|
||||
"loading": "Loading changes…"
|
||||
},
|
||||
"folder": {
|
||||
"emptyTitle": "No attached worktrees yet",
|
||||
"emptyCopy": "Worktrees created from this workspace will show up here.",
|
||||
"statusUnavailable": "Unavailable",
|
||||
"backToFolder": "Back to folder"
|
||||
},
|
||||
"hostedReviewCopy": {
|
||||
"updatingBranch": "Updating branch...",
|
||||
"stagingChanges": "Staging changes...",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useContext, type ReactNode } from 'react'
|
||||
import { useLocalSearchParams } from 'expo-router'
|
||||
import { normalizeExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import { normalizeExecutionHostId, type ExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import { firstParam } from './route-param-reader'
|
||||
import { WorkspaceExecutionHostContext } from './workspace-execution-host'
|
||||
import {
|
||||
@@ -22,14 +22,36 @@ export function WorkspaceRoute({ children }: { children: ReactNode }) {
|
||||
hostId?: string | string[]
|
||||
executionHost?: string | string[]
|
||||
}>()
|
||||
const hostId = firstParam(params.hostId)
|
||||
const executionHost = normalizeExecutionHostId(firstParam(params.executionHost)) ?? undefined
|
||||
return (
|
||||
<WorkspaceExecutionScope
|
||||
hostId={firstParam(params.hostId)}
|
||||
executionHost={normalizeExecutionHostId(firstParam(params.executionHost)) ?? undefined}
|
||||
>
|
||||
{children}
|
||||
</WorkspaceExecutionScope>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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}
|
||||
</WorkspaceExecutionHostContext.Provider>
|
||||
)
|
||||
return server ? (
|
||||
<WorkspaceServerContext.Provider value={server}>
|
||||
<HostStatusGatesContext.Provider value={server.gates}>
|
||||
if (server) {
|
||||
return (
|
||||
<WorkspaceServerContext.Provider value={server}>
|
||||
<HostStatusGatesContext.Provider value={server.gates}>
|
||||
{screens}
|
||||
</HostStatusGatesContext.Provider>
|
||||
</WorkspaceServerContext.Provider>
|
||||
)
|
||||
}
|
||||
// A desktop workspace shown inside a server's scope goes back to desktop routing and gates.
|
||||
return outerServer ? (
|
||||
<WorkspaceServerContext.Provider value={undefined}>
|
||||
<HostStatusGatesContext.Provider value={desktopGates}>
|
||||
{screens}
|
||||
</HostStatusGatesContext.Provider>
|
||||
</WorkspaceServerContext.Provider>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -136,6 +136,8 @@ export function createHostQueryPersistence(
|
||||
const missedAtBoot = new Set<string>()
|
||||
const triedOnMiss = new WeakSet<Query>()
|
||||
const restored = new Set<string>()
|
||||
// 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<unknown>) {
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 ? (
|
||||
<MobileSessionTabStrip
|
||||
controller={controller}
|
||||
|
||||
@@ -27,7 +27,6 @@ export function MobileSessionHeaderMoreMenu({ controller, anchorRef, actionsColl
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const {
|
||||
isFolderWorkspaceRoute,
|
||||
isFloatingWorkspaceRoute,
|
||||
showHeaderMoreActions,
|
||||
setShowHeaderMoreActions,
|
||||
@@ -56,7 +55,7 @@ export function MobileSessionHeaderMoreMenu({ controller, anchorRef, actionsColl
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(actionsCollapsed && !isFolderWorkspaceRoute && !isFloatingWorkspaceRoute
|
||||
...(actionsCollapsed && !isFloatingWorkspaceRoute
|
||||
? [
|
||||
{
|
||||
id: 'sourceControl',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo } from 'react'
|
||||
import { View, StyleSheet } from 'react-native'
|
||||
import { MobileSourceControlPanel } from '../source-control/MobileSourceControlPanel'
|
||||
import { MobileSourceControlRoutePanel } from '../source-control/MobileSourceControlRoutePanel'
|
||||
import { MobileFileExplorerPanel } from '../files/MobileFileExplorerPanel'
|
||||
import { mobilePrSidebarStyles } from '../components/pr-sidebar/mobile-pr-sidebar-styles'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
@@ -56,7 +56,7 @@ const DockPanelContent = memo(function DockPanelContent({
|
||||
// Reuse the source-control hub so dock icon switches retain commit draft, visited tabs and scroll.
|
||||
if (activePanel === 'sourceControl' || activePanel === 'pr') {
|
||||
return (
|
||||
<MobileSourceControlPanel
|
||||
<MobileSourceControlRoutePanel
|
||||
hostId={hostId}
|
||||
worktreeId={worktreeId}
|
||||
name={name}
|
||||
|
||||
@@ -15,6 +15,8 @@ type Props = {
|
||||
/** Shown rows are being re-read; the line waits out quick reads. */
|
||||
refreshing: boolean
|
||||
onBack: () => 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 = (
|
||||
<NavBarButton
|
||||
icon={X}
|
||||
onPress={onClose}
|
||||
accessibilityLabel={t('sourceControl.header.close', 'Close source control')}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<NavBar
|
||||
// Dock dismissal and route Back have distinct semantics.
|
||||
leading={
|
||||
embedded ? (
|
||||
backInPlace ? (
|
||||
<NavBarButton
|
||||
icon={X}
|
||||
onPress={onClose}
|
||||
accessibilityLabel={t('sourceControl.header.close', 'Close source control')}
|
||||
icon={ChevronLeft}
|
||||
onPress={backInPlace.onPress}
|
||||
accessibilityLabel={backInPlace.label}
|
||||
/>
|
||||
) : embedded ? (
|
||||
closeButton
|
||||
) : (
|
||||
<NavBarButton
|
||||
icon={ChevronLeft}
|
||||
@@ -96,16 +108,19 @@ export function MobileSourceControlHeader({
|
||||
accessory={accessory}
|
||||
refreshing={showRefreshLine}
|
||||
trailing={
|
||||
<NavBarButton
|
||||
ref={moreRef}
|
||||
icon={MoreHorizontal}
|
||||
onPress={() => setMenuOpen(true)}
|
||||
active={menuOpen}
|
||||
accessibilityLabel={t(
|
||||
'sourceControl.header.moreActions',
|
||||
'More source control actions'
|
||||
)}
|
||||
/>
|
||||
<>
|
||||
{backInPlace && embedded ? closeButton : null}
|
||||
<NavBarButton
|
||||
ref={moreRef}
|
||||
icon={MoreHorizontal}
|
||||
onPress={() => setMenuOpen(true)}
|
||||
active={menuOpen}
|
||||
accessibilityLabel={t(
|
||||
'sourceControl.header.moreActions',
|
||||
'More source control actions'
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<PullDownMenu
|
||||
|
||||
@@ -51,6 +51,8 @@ export type MobileSourceControlPanelProps = {
|
||||
name?: string
|
||||
embedded?: boolean
|
||||
initialTab?: SourceControlHubRouteTab
|
||||
/** Back for a hub shown over another screen in place; a route's Back pops it. */
|
||||
backInPlace?: { onPress: () => 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={
|
||||
|
||||
@@ -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 (
|
||||
<MobileFolderSourceControlPanel
|
||||
hostId={props.hostId}
|
||||
folderWorktreeId={props.worktreeId}
|
||||
name={props.name ?? ''}
|
||||
embedded={props.embedded ?? false}
|
||||
onRequestClose={props.onRequestClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <MobileSourceControlPanel {...props} />
|
||||
}
|
||||
@@ -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<string>()
|
||||
const [opened, setOpened] = useState<FolderListRow | null>(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 = (
|
||||
<MobileSourceControlHeader
|
||||
embedded={embedded}
|
||||
worktreeLabel={folder?.displayName || name}
|
||||
ioBusy={false}
|
||||
refreshing={false}
|
||||
onBack={() => 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') ? (
|
||||
<MobileOfflineState onReconnect={retry} />
|
||||
) : folder === undefined && loadError ? (
|
||||
<MobileLoadErrorState error={loadError} onRetry={retry} />
|
||||
) : folder === undefined ? (
|
||||
<MobileStateView loading title={t('sourceControl.panel.loading', 'Loading changes…')} />
|
||||
) : folder === null ? (
|
||||
<MobileStateView
|
||||
icon={GitBranch}
|
||||
title={t('sourceControl.panel.unavailableTitle', 'Source Control Unavailable')}
|
||||
/>
|
||||
) : worktrees.length === 0 ? (
|
||||
<MobileStateView
|
||||
icon={GitBranch}
|
||||
title={t('sourceControl.folder.emptyTitle', 'No attached worktrees yet')}
|
||||
message={t(
|
||||
'sourceControl.folder.emptyCopy',
|
||||
'Worktrees created from this workspace will show up here.'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
// One card per attached worktree, as desktop shows them.
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.list, { paddingBottom: insets.bottom + spacing.lg }]}
|
||||
>
|
||||
{worktrees.map((worktree) => (
|
||||
<ListGroup key={getWorktreeRowIdentity(worktree)}>
|
||||
<MobileFolderWorktreeRow
|
||||
hostId={hostId}
|
||||
desktopCapabilities={desktopCapabilities}
|
||||
worktree={worktree}
|
||||
unreachable={
|
||||
serverHosts.find((server) => server.hostId === worktree.hostId)?.relay ===
|
||||
'update-needed'
|
||||
}
|
||||
subscribed={observing}
|
||||
refreshers={refreshers}
|
||||
onOpen={open}
|
||||
/>
|
||||
</ListGroup>
|
||||
))}
|
||||
</ScrollView>
|
||||
)
|
||||
const main = (
|
||||
<View style={sourceControlStyles.container}>
|
||||
{embedded ? (
|
||||
<View style={sourceControlStyles.header}>{header}</View>
|
||||
) : (
|
||||
<SafeAreaView style={sourceControlStyles.header} edges={['top']}>
|
||||
{header}
|
||||
</SafeAreaView>
|
||||
)}
|
||||
{/* Every state shares the sheet surface the cards sit on, as the phone's option sheets do. */}
|
||||
<View style={styles.body}>{body}</View>
|
||||
</View>
|
||||
)
|
||||
return (
|
||||
<OptionPager
|
||||
fill
|
||||
pager={pager}
|
||||
main={main}
|
||||
page={
|
||||
pageOpen && opened ? (
|
||||
// The worktree's own Source Control, on its own server when the desktop relays one.
|
||||
<WorkspaceExecutionScope
|
||||
hostId={hostId}
|
||||
executionHost={opened.hostId?.startsWith('runtime:') ? opened.hostId : undefined}
|
||||
>
|
||||
<MobileSourceControlPanel
|
||||
key={getWorktreeRowIdentity(opened)}
|
||||
hostId={hostId}
|
||||
worktreeId={opened.worktreeId}
|
||||
name={opened.displayName || opened.repo}
|
||||
embedded={embedded}
|
||||
backInPlace={{
|
||||
onPress: pager.close,
|
||||
label: t('sourceControl.folder.backToFolder', 'Back to folder')
|
||||
}}
|
||||
onRequestClose={onRequestClose}
|
||||
/>
|
||||
</WorkspaceExecutionScope>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = themedStyleSheet(() => ({
|
||||
body: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.sheetSurface
|
||||
},
|
||||
list: {
|
||||
padding: spacing.lg,
|
||||
gap: spacing.sm
|
||||
}
|
||||
}))
|
||||
@@ -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 (
|
||||
<ListRow
|
||||
title={worktree.repo}
|
||||
subtitle={subtitle}
|
||||
value={
|
||||
!openable || unread
|
||||
? t('sourceControl.folder.statusUnavailable', 'Unavailable')
|
||||
: changeCount > 0
|
||||
? String(changeCount)
|
||||
: undefined
|
||||
}
|
||||
navigates={openable}
|
||||
disabled={!openable}
|
||||
onPress={() => onOpen(worktree)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
])
|
||||
}
|
||||
@@ -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>): 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)
|
||||
})
|
||||
})
|
||||
@@ -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<Worktree, 'createdAt'>
|
||||
|
||||
// Keyed by row object: the catalog query keeps an unchanged row's identity, so only changed rows are read again.
|
||||
const lineageKeys = new WeakMap<Worktree, string>()
|
||||
const listRows = new WeakMap<Worktree, FolderListRow>()
|
||||
|
||||
/** 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<Row extends FolderLineageRow>(
|
||||
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<Row extends FolderLineageRow>(
|
||||
rows: readonly Row[],
|
||||
folder: Row,
|
||||
stores?: MobileLineageStores
|
||||
): Row[] {
|
||||
const childrenByParent = getMobileWorkspaceLineageChildren(rows, stores)
|
||||
const members = new Map<string, Row>()
|
||||
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))
|
||||
)
|
||||
}
|
||||
@@ -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<string, { data: GitStatusRead | undefined; updatedAt: number }>()
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<typeof MobileWorkspaceLineage>()
|
||||
return {
|
||||
...actual,
|
||||
getMobileWorkspaceLineageChildren: (
|
||||
...args: Parameters<typeof actual.getMobileWorkspaceLineageChildren>
|
||||
) => {
|
||||
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<typeof FolderLineageSerialization>()
|
||||
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> = {}): 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<typeof catalogHost>, 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<MobileRelayHost>) => ({
|
||||
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<MobileRelayHost>) => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<FolderLineage | null>(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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<typeof gitStatusEntrySchema>
|
||||
|
||||
@@ -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<GitStatusRead> {
|
||||
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<string, never>,
|
||||
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<void> {
|
||||
// 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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, number>()
|
||||
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<typeof useMobileSourceControlLoaders> | 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<string, string>()
|
||||
let readGate: Promise<void> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<GitStatusRead>(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.
|
||||
|
||||
@@ -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<MobileRelayHost, 'hostId' | 'installationId'>[]
|
||||
}
|
||||
|
||||
type ResolutionRow = {
|
||||
worktree: Worktree
|
||||
type ResolutionRow<Row extends MobileLineageRow = MobileLineageRow> = {
|
||||
worktree: Row
|
||||
id: string
|
||||
hostId: ExecutionHostId
|
||||
instanceId: string | undefined
|
||||
isArchived: boolean
|
||||
}
|
||||
|
||||
function getResolutionRows(worktrees: readonly Worktree[]): Map<string, ResolutionRow> {
|
||||
function getResolutionRows<Row extends MobileLineageRow>(
|
||||
worktrees: readonly Row[]
|
||||
): Map<string, ResolutionRow<Row>> {
|
||||
return new Map(
|
||||
worktrees.map((worktree) => [
|
||||
getWorktreeRowIdentity(worktree),
|
||||
@@ -75,7 +93,7 @@ function getResolutionRows(worktrees: readonly Worktree[]): Map<string, Resoluti
|
||||
}
|
||||
|
||||
// Unchecked wire data: a newer host may send a shape this build cannot read.
|
||||
function readFolderEdge(worktree: Worktree): WorkspaceLineageEdge | undefined {
|
||||
function readFolderEdge(worktree: MobileLineageRow): WorkspaceLineageEdge | undefined {
|
||||
const edge = worktree.workspaceLineage
|
||||
return edge && typeof edge.parentWorkspaceKey === 'string' ? edge : undefined
|
||||
}
|
||||
@@ -109,11 +127,11 @@ function getCrossStoreView(
|
||||
* machine; lineage loops nest nothing.
|
||||
* Edges resolve over every row, then `filterRepoIds` keeps those with both ends shown: a hidden
|
||||
* twin must still make a parent ambiguous. */
|
||||
export function getMobileWorkspaceLineageChildren(
|
||||
listed: readonly Worktree[],
|
||||
export function getMobileWorkspaceLineageChildren<Row extends MobileLineageRow>(
|
||||
listed: readonly Row[],
|
||||
stores?: MobileLineageStores,
|
||||
filterRepoIds: readonly string[] = []
|
||||
): Map<string, Worktree[]> {
|
||||
): Map<string, Row[]> {
|
||||
const rows = getResolutionRows(listed)
|
||||
// The shared policy: worktree parent, then folder workspace, then another store. The phone trusts
|
||||
// an older host's edge without instance ids and keeps its loops, as it always has.
|
||||
@@ -132,9 +150,9 @@ export function getMobileWorkspaceLineageChildren(
|
||||
keepSameStoreCycles: true
|
||||
})
|
||||
const parentByChild = new Map([...resolved].map(([child, parent]) => [child, parent.worktree]))
|
||||
const shown = (row: Worktree): boolean =>
|
||||
const shown = (row: Row): boolean =>
|
||||
filterRepoIds.length === 0 || filterRepoIds.includes(row.repoId)
|
||||
const childrenByParentId = new Map<string, Worktree[]>()
|
||||
const childrenByParentId = new Map<string, Row[]>()
|
||||
for (const worktree of listed) {
|
||||
const parent = parentByChild.get(getWorktreeRowIdentity(worktree))
|
||||
// A folder workspace spans projects, so the project filter never hides it as a parent.
|
||||
|
||||
@@ -2,12 +2,15 @@ import { createContext, useContext } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc/rpc-client-contract'
|
||||
import type { ConnectionState } from '../transport/connection/connection-state'
|
||||
import type { ServerHostDesktopReach } from './server-workspaces'
|
||||
import type { HostStatusGates } from '../transport/connection/host-status-gates'
|
||||
|
||||
// WorkspaceRoute supplies server context; null means unreachable, absent means desktop routing.
|
||||
export type WorkspaceServer = {
|
||||
client: RpcClient | null
|
||||
state: ConnectionState
|
||||
desktopCapabilities: readonly string[]
|
||||
/** The paired desktop's own gates, which a server's scope replaces in context; null before they load. */
|
||||
desktopGates?: HostStatusGates | null
|
||||
name?: string | null
|
||||
desktopReach?: ServerHostDesktopReach
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ export function useWorkspaceServer(args: {
|
||||
client: RpcClient | null
|
||||
connState: ConnectionState
|
||||
desktopCapabilities: readonly string[]
|
||||
/** The paired desktop's own gates, carried for screens nested in this server's scope. */
|
||||
desktopGates?: HostStatusGates | null
|
||||
executionHost: ExecutionHostId | undefined
|
||||
}): {
|
||||
routeHost: ExecutionHostId | undefined | null
|
||||
@@ -25,9 +27,10 @@ export function useWorkspaceServer(args: {
|
||||
desktopReach: ServerHostDesktopReach | undefined
|
||||
gates: HostStatusGates
|
||||
desktopCapabilities: readonly string[]
|
||||
desktopGates: HostStatusGates | null
|
||||
} | null
|
||||
} {
|
||||
const { client, connState, desktopCapabilities, executionHost } = args
|
||||
const { client, connState, desktopCapabilities, desktopGates = null, executionHost } = args
|
||||
const routeHost = workspaceRouteExecutionHost(client, desktopCapabilities, executionHost)
|
||||
const serverClient = client && routeHost ? scopeRpcClientToExecutionHost(client, routeHost) : null
|
||||
// The same feed the host list reads, so the desktop is not polled twice.
|
||||
@@ -48,7 +51,8 @@ export function useWorkspaceServer(args: {
|
||||
name: selectedHost?.label ?? null,
|
||||
desktopReach: selectedHost ? serverHostDesktopReach(selectedHost) : undefined,
|
||||
gates,
|
||||
desktopCapabilities
|
||||
desktopCapabilities,
|
||||
desktopGates
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user