mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
Merge remote-tracking branch 'origin/feat/mobile-remote-tab-actions' into feat/mobile-server-ssh-repos
# Conflicts: # mobile/src/components/NewWorktreeModal.test.tsx
This commit is contained in:
@@ -11,6 +11,7 @@ const asyncStorage = vi.hoisted(() => ({
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
AppState: { currentState: 'active' },
|
||||
FlatList: 'FlatList',
|
||||
Image: 'Image',
|
||||
Keyboard: { dismiss: vi.fn() },
|
||||
@@ -45,8 +46,12 @@ vi.mock('./TaskProviderLogo', () => ({ TaskProviderLogo: 'TaskProviderLogo' }))
|
||||
|
||||
import { setCachedRepos } from '../cache/repo-cache'
|
||||
import { recordHostDescriptor } from '../transport/host-descriptor-store'
|
||||
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
|
||||
import { getLocalExecutionHostLabel } from '../../../src/shared/execution-host'
|
||||
import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
|
||||
import { MOBILE_RELAY_HOSTS_LIST_METHOD } from '../../../src/shared/mobile-relay-hosts-contract'
|
||||
import { NewWorktreeModal } from './NewWorktreeModal'
|
||||
import { NewWorktreeModalController } from './NewWorktreeModalController'
|
||||
|
||||
const LOCAL_HOST_LABEL = getLocalExecutionHostLabel('darwin')
|
||||
|
||||
@@ -548,6 +553,58 @@ describe('NewWorktreeModal project targets', () => {
|
||||
expect(server).not.toHaveBeenCalledWith('worktree.create', expect.anything(), expect.anything())
|
||||
})
|
||||
|
||||
it("offers the desktop's servers, by name, in a sheet opened before the list has them", async () => {
|
||||
const serverRepo = { ...repos[0]!, id: 'server-repo', path: '/srv/orca' }
|
||||
// One desktop: its own repos, its server list, and a server's repos relayed through it.
|
||||
const desktop = new FakeSession('connected')
|
||||
desktop.sendRequest.mockImplementation(async (method: string, ...rest: unknown[]) => {
|
||||
const options = rest[1]
|
||||
const relayed =
|
||||
typeof options === 'object' && options !== null && 'executionHost' in options
|
||||
? options.executionHost
|
||||
: undefined
|
||||
const result =
|
||||
method === MOBILE_RELAY_HOSTS_LIST_METHOD
|
||||
? {
|
||||
hosts: [
|
||||
{ hostId: 'runtime:vm', label: 'scratch-vm', health: 'available', relay: 'ready' }
|
||||
]
|
||||
}
|
||||
: method === 'repo.list'
|
||||
? { repos: relayed === 'runtime:vm' ? [serverRepo] : repos }
|
||||
: {}
|
||||
return { id: 'reply', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
|
||||
})
|
||||
const listCalls = () =>
|
||||
desktop.sendRequest.mock.calls.filter(([method]) => method === MOBILE_RELAY_HOSTS_LIST_METHOD)
|
||||
// The host list's own names: no server rows have loaded behind a sheet opened from Home.
|
||||
const sheet = (routeVisible: boolean) =>
|
||||
createElement(NewWorktreeModalController, {
|
||||
routeVisible,
|
||||
client: desktop,
|
||||
hostCapabilities: [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY],
|
||||
hostId: 'host-1',
|
||||
hostLabelById: new Map(),
|
||||
openExternalUrl: () => {},
|
||||
onCreated: () => {},
|
||||
onRouteVisibleChange: () => {}
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(sheet(false))
|
||||
})
|
||||
await flushUpdates()
|
||||
// Closed, it asks nothing of the desktop.
|
||||
expect(listCalls()).toHaveLength(0)
|
||||
|
||||
await act(async () => renderer.update(sheet(true)))
|
||||
await flushUpdates()
|
||||
expect(pickerItems(renderer, 'Run on').map((item) => [item.label, item.detail])).toEqual([
|
||||
[expect.any(String), '/src/orca'],
|
||||
['scratch-vm', '/srv/orca']
|
||||
])
|
||||
})
|
||||
|
||||
it("lists the desktop's repos at once while a server has not answered", async () => {
|
||||
const desktop = vi.fn((method: string) =>
|
||||
method === 'repo.list'
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from 'react'
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'
|
||||
|
||||
import type { ExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import { reachableServerClients } from '../transport/execution-host-scoped-rpc-client'
|
||||
import { useRelayHosts } from '../worktree/relay-hosts-feed'
|
||||
import { withServerHosts } from '../worktree/server-workspaces'
|
||||
import { NewWorktreeModal } from './NewWorktreeModal'
|
||||
import type { NewWorktreeModalProps } from './new-worktree-modal-types'
|
||||
|
||||
@@ -7,7 +11,14 @@ export type NewWorktreeModalControllerHandle = {
|
||||
open: () => void
|
||||
}
|
||||
|
||||
type Props = Omit<NewWorktreeModalProps, 'visible' | 'onClose'> & {
|
||||
type Props = Omit<
|
||||
NewWorktreeModalProps,
|
||||
'visible' | 'onClose' | 'serverClients' | 'hostLabelById'
|
||||
> & {
|
||||
/** The desktop's capabilities, which say whether its servers are reachable through it. */
|
||||
hostCapabilities: readonly string[]
|
||||
/** The desktop's own host names; the sheet adds its servers' from the feed. */
|
||||
hostLabelById: ReadonlyMap<ExecutionHostId, string>
|
||||
routeVisible: boolean
|
||||
onVisibleChange?: (visible: boolean) => void
|
||||
onRouteVisibleChange: (visible: boolean) => void
|
||||
@@ -18,7 +29,7 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
|
||||
{
|
||||
routeVisible,
|
||||
client,
|
||||
serverClients,
|
||||
hostCapabilities,
|
||||
hostId,
|
||||
hostLabelById,
|
||||
existingWorktreePaths,
|
||||
@@ -32,6 +43,16 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
|
||||
) {
|
||||
const [manualVisible, setManualVisible] = useState(false)
|
||||
const visible = routeVisible || manualVisible
|
||||
// Why the feed, not the list's server rows: the list stops refreshing behind the sheet.
|
||||
const servers = useRelayHosts(visible ? client : null)
|
||||
const serverClients = useMemo(
|
||||
() => reachableServerClients(client, hostCapabilities, servers ?? []),
|
||||
[client, hostCapabilities, servers]
|
||||
)
|
||||
const serverLabelById = useMemo(
|
||||
() => withServerHosts(hostLabelById, servers ?? [], (host) => host.label),
|
||||
[hostLabelById, servers]
|
||||
)
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
@@ -58,7 +79,7 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
|
||||
client={client}
|
||||
serverClients={serverClients}
|
||||
hostId={hostId}
|
||||
hostLabelById={hostLabelById}
|
||||
hostLabelById={serverLabelById}
|
||||
existingWorktreePaths={existingWorktreePaths}
|
||||
existingWorktrees={existingWorktrees}
|
||||
openExternalUrl={openExternalUrl}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
AppState: { currentState: 'active' },
|
||||
Linking: { openURL: vi.fn() },
|
||||
Platform: { OS: 'ios', select: (options: { ios?: unknown }) => options.ios },
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: { create: <T,>(styles: T) => styles },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
|
||||
vi.mock('lucide-react-native', () => ({ Check: 'Check', Moon: 'Moon' }))
|
||||
vi.mock('../agent-history/worktree-navigation-actions', () => ({
|
||||
buildWorktreeNavigationActions: () => []
|
||||
}))
|
||||
vi.mock('../components/ActionSheetModal', () => ({ ActionSheetContent: 'ActionSheetContent' }))
|
||||
vi.mock('../components/BottomDrawer', () => ({ BottomDrawer: 'BottomDrawer' }))
|
||||
vi.mock('../components/ConfirmModal', () => ({ ConfirmModal: 'ConfirmModal' }))
|
||||
vi.mock('../components/NewWorktreeModalController', () => ({
|
||||
NewWorktreeModalController: 'NewWorktreeModalController'
|
||||
}))
|
||||
vi.mock('../components/PickerModal', () => ({ PickerModal: 'PickerModal' }))
|
||||
|
||||
import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
|
||||
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
|
||||
import type { Worktree } from '../worktree/workspace-list-types'
|
||||
import { HostScreenOverlays } from './host-screen-overlays'
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
/** The long-press sheet's action labels for `row`. */
|
||||
async function actionLabels(row: Partial<Worktree>): Promise<string[]> {
|
||||
const controller = {
|
||||
actions: {},
|
||||
catalog: {},
|
||||
client: new FakeSession('connected'),
|
||||
connState: 'connected',
|
||||
existingWorktreePaths: [],
|
||||
hostCapabilities: [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY],
|
||||
hostId: 'host-1',
|
||||
sectionsResult: { uniqueRepos: [] },
|
||||
settings: { activeFilterCount: 0 },
|
||||
showNewWorktree: false,
|
||||
state: {
|
||||
actionTarget: { worktreeId: 'wt', repo: 'orca', branch: 'main', ...row },
|
||||
confirmDelete: null,
|
||||
filters: { filterRepoIds: new Set() },
|
||||
hostLabelById: new Map(),
|
||||
pinnedIds: new Set()
|
||||
}
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(HostScreenOverlays, {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the action sheet reads only these members; the drawers and pickers that read the rest are mocked.
|
||||
controller: controller as unknown as Parameters<typeof HostScreenOverlays>[0]['controller']
|
||||
})
|
||||
)
|
||||
})
|
||||
const sheet = renderer!.root.find((node) => String(node.type) === 'ActionSheetContent')
|
||||
return sheet.props.actions.map((action: { label: string }) => action.label)
|
||||
}
|
||||
|
||||
describe('the workspace long-press sheet', () => {
|
||||
it("offers Sleep for the desktop's own workspace", async () => {
|
||||
expect(await actionLabels({})).toContain('Sleep')
|
||||
})
|
||||
|
||||
it("does not offer Sleep for a server's workspace, which the desktop would sleep by its own id", async () => {
|
||||
expect(await actionLabels({ worktreeId: 'runtime:vm-wt', hostId: 'runtime:vm' })).toEqual([
|
||||
'Pin',
|
||||
'Delete'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useState } from 'react'
|
||||
import { openExternalLink } from '../platform/external-link'
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import { Check, Moon } from 'lucide-react-native'
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
} from '../worktree/workspace-list-picker-options'
|
||||
import { isWorktreePinned } from '../worktree/workspace-list-sections'
|
||||
import { hostScreenStyles as styles } from './host-screen-styles'
|
||||
import { useNewWorkspaceServers } from './use-host-display-worktrees'
|
||||
import type { HostScreenController } from './use-host-screen-controller'
|
||||
|
||||
export function HostScreenOverlays({ controller }: { controller: HostScreenController }) {
|
||||
@@ -31,7 +29,6 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
|
||||
existingWorktreePaths,
|
||||
hostCapabilities,
|
||||
hostId,
|
||||
hostLabels,
|
||||
settings,
|
||||
showNewWorktree,
|
||||
state
|
||||
@@ -44,13 +41,6 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
|
||||
desktopCapabilities: hostCapabilities,
|
||||
executionHost: actionTarget?.hostId
|
||||
})
|
||||
const [newWorktreeVisible, setNewWorktreeVisible] = useState(false)
|
||||
const newWorkspaceServers = useNewWorkspaceServers({
|
||||
client,
|
||||
hostCapabilities,
|
||||
hostLabelById: hostLabels.hostLabelById,
|
||||
open: newWorktreeVisible
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -189,14 +179,19 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
|
||||
navigate: actions.navigateFromHostList,
|
||||
onDone: () => state.setActionTarget(null)
|
||||
}),
|
||||
{
|
||||
label: 'Sleep',
|
||||
icon: Moon,
|
||||
onPress: () => {
|
||||
actions.putWorktreeToSleep(actionTarget)
|
||||
state.setActionTarget(null)
|
||||
}
|
||||
},
|
||||
// Server sleep is not offered yet; the desktop would sleep its own id.
|
||||
...(actionServer.server
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: 'Sleep',
|
||||
icon: Moon,
|
||||
onPress: () => {
|
||||
actions.putWorktreeToSleep(actionTarget)
|
||||
state.setActionTarget(null)
|
||||
}
|
||||
}
|
||||
]),
|
||||
{
|
||||
label: isWorktreePinned(actionTarget, state.pinnedIds) ? 'Unpin' : 'Pin',
|
||||
onPress: () => {
|
||||
@@ -231,9 +226,9 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
|
||||
ref={state.newWorktreeModalRef}
|
||||
routeVisible={showNewWorktree}
|
||||
client={client}
|
||||
serverClients={newWorkspaceServers.serverClients}
|
||||
hostCapabilities={hostCapabilities}
|
||||
hostId={hostId}
|
||||
hostLabelById={newWorkspaceServers.hostLabelById}
|
||||
hostLabelById={state.hostLabelById}
|
||||
existingWorktreePaths={existingWorktreePaths}
|
||||
existingWorktrees={state.worktrees}
|
||||
// The seam, not react-native's `Linking`: this screen is in the tasks page closure, and
|
||||
@@ -241,7 +236,6 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
|
||||
openExternalUrl={openExternalLink}
|
||||
onVisibleChange={(visible) => {
|
||||
state.newWorktreeModalVisibleRef.current = visible
|
||||
setNewWorktreeVisible(visible)
|
||||
}}
|
||||
onCreated={(worktreeId, worktreeName, warning, executionHost) => {
|
||||
void catalog.fetchWorktrees({ allowDuringModal: true })
|
||||
|
||||
@@ -26,7 +26,7 @@ import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import { readVisibleHostIds } from '../worktree/visible-host-rows'
|
||||
import type { Worktree } from '../worktree/workspace-list-types'
|
||||
import { useHostDisplayWorktrees, useHostLabelSources } from './use-host-display-worktrees'
|
||||
import { useHostDisplayWorktrees } from './use-host-display-worktrees'
|
||||
import { useHostScreenState } from './use-host-screen-state'
|
||||
import { useHostWorktreeActions } from './use-host-worktree-actions'
|
||||
import { useHostWorktreeCatalog } from './use-host-worktree-catalog'
|
||||
@@ -149,7 +149,7 @@ async function mountScreen(first: FakeSession, hostCapabilities: string[]): Prom
|
||||
})
|
||||
held.state = state
|
||||
held.fetch = catalog.fetchWorktrees
|
||||
held.rows = useHostDisplayWorktrees('connected', state, useHostLabelSources('host-1', state))
|
||||
held.rows = useHostDisplayWorktrees('connected', state, 'host-1')
|
||||
return null
|
||||
}
|
||||
await act(async () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ vi.mock('../cache/repo-cache', () => ({ setCachedRepos: () => {} }))
|
||||
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import type { Worktree } from '../worktree/workspace-list-types'
|
||||
import { useHostDisplayWorktrees, useHostLabelSources } from './use-host-display-worktrees'
|
||||
import { useHostDisplayWorktrees } from './use-host-display-worktrees'
|
||||
import { useHostRepoMetadata } from './use-host-repo-metadata'
|
||||
import { useHostScreenState } from './use-host-screen-state'
|
||||
|
||||
@@ -70,7 +70,7 @@ async function displayedRows(args: {
|
||||
state.clientRef.current = client
|
||||
setWorktrees = state.setWorktrees
|
||||
seen.refresh = useHostRepoMetadata({ client, connState: 'connected', hostId: 'host-1', state })
|
||||
seen.rows = useHostDisplayWorktrees('connected', state, useHostLabelSources('host-1', state))
|
||||
seen.rows = useHostDisplayWorktrees('connected', state, 'host-1')
|
||||
return null
|
||||
}
|
||||
await act(async () => {
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-native', () => ({ AppState: { currentState: 'active' } }))
|
||||
vi.mock('../transport/host-descriptor-store', () => ({ useHostDescriptor: () => null }))
|
||||
|
||||
import type { ExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
|
||||
import { MOBILE_RELAY_HOSTS_LIST_METHOD } from '../../../src/shared/mobile-relay-hosts-contract'
|
||||
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
|
||||
import { useNewWorkspaceServers } from './use-host-display-worktrees'
|
||||
|
||||
const RELAYS = [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY]
|
||||
// The host list's own labels: what a sheet opened from Home sees before the list loads a server.
|
||||
const DESKTOP_LABELS: ReadonlyMap<ExecutionHostId, string> = new Map()
|
||||
|
||||
/** A desktop that lists one ready server. */
|
||||
function desktop(): FakeSession {
|
||||
const session = new FakeSession('connected')
|
||||
session.sendRequest.mockImplementation(async (method: string) => ({
|
||||
id: 'reply',
|
||||
ok: true,
|
||||
result:
|
||||
method === MOBILE_RELAY_HOSTS_LIST_METHOD
|
||||
? { hosts: [{ hostId: 'runtime:vm', label: 'vm', health: 'available', relay: 'ready' }] }
|
||||
: {},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}))
|
||||
return session
|
||||
}
|
||||
|
||||
function listCalls(session: FakeSession): number {
|
||||
return session.sendRequest.mock.calls.filter(
|
||||
([method]) => method === MOBILE_RELAY_HOSTS_LIST_METHOD
|
||||
).length
|
||||
}
|
||||
|
||||
describe('the servers a new workspace can run on', () => {
|
||||
it('are offered, by name, when the sheet opens before the host list has loaded them', async () => {
|
||||
const client = desktop()
|
||||
const seen: { value: ReturnType<typeof useNewWorkspaceServers> | null } = { value: null }
|
||||
function Sheet({ open }: { open: boolean }): null {
|
||||
seen.value = useNewWorkspaceServers({
|
||||
client,
|
||||
hostCapabilities: RELAYS,
|
||||
hostLabelById: DESKTOP_LABELS,
|
||||
open
|
||||
})
|
||||
return null
|
||||
}
|
||||
const rendered: { tree: ReturnType<typeof create> | null } = { tree: null }
|
||||
await act(async () => {
|
||||
rendered.tree = create(createElement(Sheet, { open: false }))
|
||||
})
|
||||
// Closed, it asks nothing of the desktop.
|
||||
expect(listCalls(client)).toBe(0)
|
||||
expect([...(seen.value?.serverClients.keys() ?? [])]).toEqual([])
|
||||
await act(async () => {
|
||||
rendered.tree?.update(createElement(Sheet, { open: true }))
|
||||
})
|
||||
expect([...(seen.value?.serverClients.keys() ?? [])]).toEqual(['runtime:vm'])
|
||||
expect(seen.value?.hostLabelById.get('runtime:vm')).toBe('vm')
|
||||
})
|
||||
})
|
||||
@@ -1,24 +1,18 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { ExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import { reachableServerClients } from '../transport/execution-host-scoped-rpc-client'
|
||||
import { useHostDescriptor } from '../transport/host-descriptor-store'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import { useRelayHosts } from '../worktree/relay-hosts-feed'
|
||||
import { serverHostHealth } from '../worktree/server-workspaces'
|
||||
import { serverHostHealth, withServerHosts } from '../worktree/server-workspaces'
|
||||
import { filterVisibleHostRows } from '../worktree/visible-host-rows'
|
||||
import {
|
||||
applyWorktreeHostContextLabels,
|
||||
type HostLabelSources
|
||||
} from '../worktree/worktree-host-context-labels'
|
||||
import { applyWorktreeHostContextLabels } from '../worktree/worktree-host-context-labels'
|
||||
import { applyWorktreeRowDisplayState } from '../worktree/worktree-host-row-identity'
|
||||
import type { HostScreenState } from './use-host-screen-state'
|
||||
|
||||
/** What names each host on this screen: the row badges and the new-workspace run targets. */
|
||||
export function useHostLabelSources(
|
||||
hostId: string | undefined,
|
||||
state: HostScreenState
|
||||
): HostLabelSources {
|
||||
/** The rows the host list renders: the live or last-known catalog with display state and host badges. */
|
||||
export function useHostDisplayWorktrees(
|
||||
connState: ConnectionState,
|
||||
state: HostScreenState,
|
||||
hostId: string | undefined
|
||||
) {
|
||||
const { serverWorkspaces } = state
|
||||
// Why: a server's label and health come with its rows, alongside the desktop's SSH ones.
|
||||
const hostLabelById = useMemo(
|
||||
@@ -32,84 +26,35 @@ export function useHostLabelSources(
|
||||
const descriptorPlatform = useHostDescriptor(hostId)?.platform ?? null
|
||||
// Why: metadata reads the platform only for a mixed desktop catalog; server rows mix it too.
|
||||
const hostPlatform = state.hostPlatform ?? descriptorPlatform
|
||||
return useMemo(
|
||||
() => ({
|
||||
repoHostIdByRepoId: state.repoHostIdByRepoId,
|
||||
hostLabelById,
|
||||
hostPlatform,
|
||||
hostHealthById
|
||||
}),
|
||||
[state.repoHostIdByRepoId, hostLabelById, hostPlatform, hostHealthById]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The servers the new-workspace sheet offers, and their names, read from the desktop's server-list
|
||||
* feed while it is open: the list's own server rows stop refreshing behind the sheet.
|
||||
*/
|
||||
export function useNewWorkspaceServers(args: {
|
||||
client: RpcClient | null
|
||||
hostCapabilities: readonly string[]
|
||||
hostLabelById: ReadonlyMap<ExecutionHostId, string>
|
||||
open: boolean
|
||||
}): {
|
||||
serverClients: ReadonlyMap<ExecutionHostId, RpcClient>
|
||||
hostLabelById: ReadonlyMap<ExecutionHostId, string>
|
||||
} {
|
||||
const { client, hostCapabilities, hostLabelById, open } = args
|
||||
const hosts = useRelayHosts(open ? client : null)
|
||||
return useMemo(() => {
|
||||
const listed = hosts ?? []
|
||||
return {
|
||||
serverClients: reachableServerClients(client, hostCapabilities, listed),
|
||||
hostLabelById: withServerHosts(hostLabelById, listed, (host) => host.label)
|
||||
}
|
||||
}, [client, hostCapabilities, hostLabelById, hosts])
|
||||
}
|
||||
|
||||
/** The rows the host list renders: the live or last-known catalog with display state and host badges. */
|
||||
export function useHostDisplayWorktrees(
|
||||
connState: ConnectionState,
|
||||
state: HostScreenState,
|
||||
labels: HostLabelSources
|
||||
) {
|
||||
return useMemo(() => {
|
||||
// Why: live `worktrees` is authoritative only while connected; under the amber
|
||||
// mount default, connecting/handshaking must keep the pre-reconnect list too.
|
||||
const base = connState === 'connected' ? state.worktrees : state.lastKnownWorktrees
|
||||
const rows = filterVisibleHostRows(
|
||||
[...base, ...state.serverWorkspaces.worktrees],
|
||||
[...base, ...serverWorkspaces.worktrees],
|
||||
state.visibleHostIds,
|
||||
state.repoHostIdByRepoId
|
||||
)
|
||||
return applyWorktreeHostContextLabels(
|
||||
applyWorktreeRowDisplayState(rows, state.sleptIds, state.optimisticActiveWorktreeIdentity),
|
||||
labels
|
||||
{
|
||||
repoHostIdByRepoId: state.repoHostIdByRepoId,
|
||||
hostLabelById,
|
||||
hostPlatform,
|
||||
hostHealthById
|
||||
}
|
||||
)
|
||||
}, [
|
||||
connState,
|
||||
state.worktrees,
|
||||
state.lastKnownWorktrees,
|
||||
state.serverWorkspaces.worktrees,
|
||||
serverWorkspaces.worktrees,
|
||||
state.visibleHostIds,
|
||||
state.sleptIds,
|
||||
state.optimisticActiveWorktreeIdentity,
|
||||
state.repoHostIdByRepoId,
|
||||
labels
|
||||
hostLabelById,
|
||||
hostPlatform,
|
||||
hostHealthById
|
||||
])
|
||||
}
|
||||
|
||||
function withServerHosts<Value>(
|
||||
desktop: ReadonlyMap<ExecutionHostId, Value>,
|
||||
hosts: HostScreenState['serverWorkspaces']['hosts'],
|
||||
read: (host: HostScreenState['serverWorkspaces']['hosts'][number]) => Value
|
||||
): ReadonlyMap<ExecutionHostId, Value> {
|
||||
if (hosts.length === 0) {
|
||||
return desktop
|
||||
}
|
||||
const merged = new Map(desktop)
|
||||
for (const host of hosts) {
|
||||
merged.set(host.hostId, read(host))
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
useRelayRecoveryStatus
|
||||
} from '../transport/client-context-connection-metrics'
|
||||
import { useWorkspaceSections } from '../worktree/use-workspace-sections'
|
||||
import { useHostDisplayWorktrees, useHostLabelSources } from './use-host-display-worktrees'
|
||||
import { useHostDisplayWorktrees } from './use-host-display-worktrees'
|
||||
import { useHostRepoMetadata } from './use-host-repo-metadata'
|
||||
import { useHostScreenIdentity } from './use-host-screen-identity'
|
||||
import { useHostScreenState } from './use-host-screen-state'
|
||||
@@ -103,8 +103,7 @@ export function useHostScreenController({
|
||||
}
|
||||
const showNewWorktree = resolvedRouteActionState.showNewWorktree
|
||||
|
||||
const hostLabels = useHostLabelSources(hostId, state)
|
||||
const displayWorktrees = useHostDisplayWorktrees(connState, state, hostLabels)
|
||||
const displayWorktrees = useHostDisplayWorktrees(connState, state, hostId)
|
||||
const sectionsResult = useWorkspaceSections({
|
||||
displayWorktrees,
|
||||
sortMode: state.sortMode,
|
||||
@@ -136,7 +135,6 @@ export function useHostScreenController({
|
||||
hostCapabilities,
|
||||
hostDisplay,
|
||||
hostId,
|
||||
hostLabels,
|
||||
insets,
|
||||
isReadOnly: connState === 'auth-failed',
|
||||
isWideLayout,
|
||||
|
||||
@@ -19,6 +19,22 @@ export function serverHostHealth(host: MobileRelayHost): ExecutionHostHealth {
|
||||
return host.relay === 'update-needed' ? 'blocked' : host.health
|
||||
}
|
||||
|
||||
/** `desktop`'s per-host values with each server's read from its feed entry. */
|
||||
export function withServerHosts<Value>(
|
||||
desktop: ReadonlyMap<ExecutionHostId, Value>,
|
||||
hosts: readonly MobileRelayHost[],
|
||||
read: (host: MobileRelayHost) => Value
|
||||
): ReadonlyMap<ExecutionHostId, Value> {
|
||||
if (hosts.length === 0) {
|
||||
return desktop
|
||||
}
|
||||
const merged = new Map(desktop)
|
||||
for (const host of hosts) {
|
||||
merged.set(host.hostId, read(host))
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
function serverRowSchema(hostId: ExecutionHostId) {
|
||||
return z.custom<Worktree>(
|
||||
(row) =>
|
||||
|
||||
Reference in New Issue
Block a user