mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
refactor(mobile): drop the orphaned non-hybrid host-screen controller chain
The hybrid WebView migration routes h/[hostId] through useHybridHostScreenController, leaving the seven pre-hybrid hooks with zero call sites. The four view components typed their prop against the dead controller's ReturnType, so they now take HybridHostScreenController directly. That retype surfaced a live mismatch: the hybrid forceReconnectHost takes no arguments, but the header and workspace list still passed hostId through the old loose type. Repoints the STA-5781 cross-client source pin at use-hybrid-host-screen-settings.ts, which carries the same patch-only builder call; verified the assertion still fails when that call is mutated. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -16,13 +16,13 @@ import { StatusDot } from '../components/StatusDot'
|
||||
import { classifyConnection, type ConnectionVerdict } from '../transport/connection-health'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { hostScreenStyles as styles } from './host-screen-styles'
|
||||
import type { HostScreenController } from './use-host-screen-controller'
|
||||
import type { HybridHostScreenController } from './use-hybrid-host-screen-controller'
|
||||
|
||||
function isErrorVerdict(v: ConnectionVerdict): boolean {
|
||||
return v.kind === 'warning' || v.kind === 'unreachable' || v.kind === 'auth-failed'
|
||||
}
|
||||
|
||||
export function HostScreenHeader({ controller }: { controller: HostScreenController }) {
|
||||
export function HostScreenHeader({ controller }: { controller: HybridHostScreenController }) {
|
||||
const {
|
||||
actions,
|
||||
connState,
|
||||
@@ -77,7 +77,7 @@ export function HostScreenHeader({ controller }: { controller: HostScreenControl
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.reconnectButton}
|
||||
onPress={() => void forceReconnectHost(hostId!)}
|
||||
onPress={() => void forceReconnectHost()}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Text style={styles.reconnectButtonText}>Reconnect</Text>
|
||||
|
||||
@@ -18,9 +18,9 @@ import {
|
||||
} from '../worktree/workspace-list-picker-options'
|
||||
import { isWorktreePinned } from '../worktree/workspace-list-sections'
|
||||
import { hostScreenStyles as styles } from './host-screen-styles'
|
||||
import type { HostScreenController } from './use-host-screen-controller'
|
||||
import type { HybridHostScreenController } from './use-hybrid-host-screen-controller'
|
||||
|
||||
export function HostScreenOverlays({ controller }: { controller: HostScreenController }) {
|
||||
export function HostScreenOverlays({ controller }: { controller: HybridHostScreenController }) {
|
||||
const {
|
||||
actions,
|
||||
catalog,
|
||||
|
||||
@@ -4,9 +4,9 @@ import { hostScreenStyles as styles } from './host-screen-styles'
|
||||
import { HostScreenHeader } from './host-screen-header'
|
||||
import { HostScreenOverlays } from './host-screen-overlays'
|
||||
import { HostWorkspaceList } from './host-workspace-list'
|
||||
import type { HostScreenController } from './use-host-screen-controller'
|
||||
import type { HybridHostScreenController } from './use-hybrid-host-screen-controller'
|
||||
|
||||
export function HostScreenView({ controller }: { controller: HostScreenController }) {
|
||||
export function HostScreenView({ controller }: { controller: HybridHostScreenController }) {
|
||||
if (controller.state.error) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
|
||||
@@ -14,9 +14,9 @@ import { HostWorkspaceListStates } from '../worktree/host-workspace-list-states'
|
||||
import { getWorktreeStatus } from '../worktree/workspace-list-sections'
|
||||
import { repoColor } from '../worktree/repo-color'
|
||||
import { hostScreenStyles as styles } from './host-screen-styles'
|
||||
import type { HostScreenController } from './use-host-screen-controller'
|
||||
import type { HybridHostScreenController } from './use-hybrid-host-screen-controller'
|
||||
|
||||
export function HostWorkspaceList({ controller }: { controller: HostScreenController }) {
|
||||
export function HostWorkspaceList({ controller }: { controller: HybridHostScreenController }) {
|
||||
const {
|
||||
actions,
|
||||
activeWorktreeScroll,
|
||||
@@ -51,7 +51,7 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
|
||||
{(connState === 'auth-failed' || relayRecovery.pairingRejected) && (
|
||||
<AuthFailedBanner
|
||||
canRetry={!!hostId}
|
||||
onRetry={() => hostId && void forceReconnectHost(hostId)}
|
||||
onRetry={() => hostId && void forceReconnectHost()}
|
||||
onRepair={() => shellOperations.repairPairing()}
|
||||
onRemove={() => state.setConfirmRemoveHost(true)}
|
||||
/>
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import { useCallback } from 'react'
|
||||
import { getRepoExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import { setCachedRepos } from '../cache/repo-cache'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState, RpcSuccess } from '../transport/types'
|
||||
import type { RepoSummary } from '../worktree/host-worktree-rpc-types'
|
||||
import { repoColor } from '../worktree/repo-color'
|
||||
import {
|
||||
buildHostLabelById,
|
||||
buildRepoHostIdByRepoId
|
||||
} from '../worktree/worktree-host-context-labels'
|
||||
import type { HostScreenState } from './use-host-screen-state'
|
||||
|
||||
const REPO_METADATA_REFRESH_MS = 60_000
|
||||
|
||||
type SshTargetSummaryRow = { id: string; label: string }
|
||||
|
||||
async function requestResult(client: RpcClient, method: string): Promise<unknown> {
|
||||
try {
|
||||
const response = await client.sendRequest(method)
|
||||
return response.ok ? (response as RpcSuccess).result : null
|
||||
} catch {
|
||||
// Best-effort: hosts that predate a method still list repos; labels degrade to host ids.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readSshTargets(result: unknown): SshTargetSummaryRow[] {
|
||||
const targets = (result as { targets?: unknown } | null)?.targets
|
||||
if (!Array.isArray(targets)) {
|
||||
return []
|
||||
}
|
||||
return targets.filter(
|
||||
(target): target is SshTargetSummaryRow =>
|
||||
typeof target === 'object' &&
|
||||
target !== null &&
|
||||
typeof (target as SshTargetSummaryRow).id === 'string' &&
|
||||
typeof (target as SshTargetSummaryRow).label === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function readHostPlatform(result: unknown): NodeJS.Platform | null {
|
||||
const platform = (result as { platform?: unknown } | null)?.platform
|
||||
return typeof platform === 'string' && platform ? (platform as NodeJS.Platform) : null
|
||||
}
|
||||
|
||||
function readHostSettingOverrides(result: unknown): unknown {
|
||||
return (result as { settings?: { hostSettingOverrides?: unknown } } | null)?.settings
|
||||
?.hostSettingOverrides
|
||||
}
|
||||
|
||||
export function useHostRepoMetadata(args: {
|
||||
client: RpcClient | null
|
||||
connState: ConnectionState
|
||||
hostId: string | undefined
|
||||
state: HostScreenState
|
||||
}) {
|
||||
const { client, connState, hostId, state } = args
|
||||
const {
|
||||
clientRef,
|
||||
fetchRepoMetadataInFlightRef,
|
||||
fetchRepoMetadataPendingRef,
|
||||
repoMetadataFetchedAtRef,
|
||||
setHostLabelById,
|
||||
setHostPlatform,
|
||||
setRepoColorsByName,
|
||||
setRepoHostIdByRepoId,
|
||||
setRepoIconsByName,
|
||||
setRepoIdsByName
|
||||
} = state
|
||||
|
||||
const fetchRepoMetadata = useCallback(
|
||||
async (options: { force?: boolean; queueIfInFlight?: boolean } = {}) => {
|
||||
if (!client || connState !== 'connected' || !hostId) {
|
||||
return
|
||||
}
|
||||
if (fetchRepoMetadataInFlightRef.current.has(client)) {
|
||||
if (options.queueIfInFlight) {
|
||||
fetchRepoMetadataPendingRef.current.add(client)
|
||||
}
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
if (!options.force && now - repoMetadataFetchedAtRef.current < REPO_METADATA_REFRESH_MS) {
|
||||
return
|
||||
}
|
||||
fetchRepoMetadataInFlightRef.current.add(client)
|
||||
const requestClient = client,
|
||||
requestHostId = hostId
|
||||
try {
|
||||
do {
|
||||
fetchRepoMetadataPendingRef.current.delete(requestClient)
|
||||
const repoResponse = await requestClient.sendRequest('repo.list')
|
||||
if (clientRef.current !== requestClient || hostId !== requestHostId || !repoResponse.ok) {
|
||||
return
|
||||
}
|
||||
const repoResult = (repoResponse as RpcSuccess).result as { repos: RepoSummary[] }
|
||||
repoMetadataFetchedAtRef.current = Date.now()
|
||||
setCachedRepos(requestHostId, repoResult.repos)
|
||||
setRepoColorsByName(
|
||||
new Map(
|
||||
repoResult.repos.map((repo) => [
|
||||
repo.displayName,
|
||||
repo.badgeColor || repoColor(repo.displayName)
|
||||
])
|
||||
)
|
||||
)
|
||||
setRepoIconsByName(
|
||||
new Map(
|
||||
repoResult.repos.flatMap((repo) =>
|
||||
repo.repoIcon ? [[repo.displayName, repo.repoIcon] as const] : []
|
||||
)
|
||||
)
|
||||
)
|
||||
setRepoIdsByName(new Map(repoResult.repos.map((repo) => [repo.displayName, repo.id])))
|
||||
setRepoHostIdByRepoId(buildRepoHostIdByRepoId(repoResult.repos))
|
||||
// Why: rows only name their host when the list spans hosts, so a single-host
|
||||
// catalog never pays for the label lookups. Counted over repos, not the id-keyed
|
||||
// map: one repo id registered on two hosts is two hosts.
|
||||
const hostIds = new Set(repoResult.repos.map((repo) => getRepoExecutionHostId(repo)))
|
||||
if (hostIds.size > 1) {
|
||||
const [sshTargets, hostSettings, hostPlatform] = await Promise.all([
|
||||
requestResult(requestClient, 'ssh.listTargetSummaries'),
|
||||
requestResult(requestClient, 'settings.get'),
|
||||
requestResult(requestClient, 'host.platform')
|
||||
])
|
||||
if (clientRef.current !== requestClient || hostId !== requestHostId) {
|
||||
return
|
||||
}
|
||||
setHostLabelById(
|
||||
buildHostLabelById({
|
||||
sshTargets: readSshTargets(sshTargets),
|
||||
hostSettingOverrides: readHostSettingOverrides(hostSettings)
|
||||
})
|
||||
)
|
||||
setHostPlatform(readHostPlatform(hostPlatform))
|
||||
}
|
||||
} while (fetchRepoMetadataPendingRef.current.has(requestClient))
|
||||
} catch {
|
||||
// Repo metadata is decorative; the next refresh can retry.
|
||||
} finally {
|
||||
fetchRepoMetadataInFlightRef.current.delete(requestClient)
|
||||
}
|
||||
},
|
||||
[client, connState, hostId]
|
||||
)
|
||||
|
||||
return fetchRepoMetadata
|
||||
}
|
||||
|
||||
export type FetchHostRepoMetadata = ReturnType<typeof useHostRepoMetadata>
|
||||
@@ -1,191 +0,0 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useLocalSearchParams, usePathname, useRouter } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useHostProtocolGates } from '../components/HostProtocolGate'
|
||||
import { visibleHostRouteNotice } from '../host-route-notice'
|
||||
import { resolveHostRouteActionState } from '../host-route-action-state'
|
||||
import { useActiveWorktreeScroll } from '../hooks/use-active-worktree-scroll'
|
||||
import { useNow } from '../hooks/use-now'
|
||||
import { useResponsiveLayout } from '../layout/responsive-layout'
|
||||
import { useForgetHostClient, useForceReconnect, useHostClient } from '../transport/client-context'
|
||||
import {
|
||||
useLastConnectedAt,
|
||||
useReconnectAttempt,
|
||||
useRelayRecoveryStatus
|
||||
} from '../transport/client-context-connection-metrics'
|
||||
import { applyWorktreeRowDisplayState } from '../worktree/worktree-host-row-identity'
|
||||
import { applyWorktreeHostContextLabels } from '../worktree/worktree-host-context-labels'
|
||||
import { useWorkspaceSections } from '../worktree/use-workspace-sections'
|
||||
import { useHostRepoMetadata } from './use-host-repo-metadata'
|
||||
import { useHostScreenIdentity } from './use-host-screen-identity'
|
||||
import { useHostScreenState } from './use-host-screen-state'
|
||||
import { useHostViewSettings } from './use-host-view-settings'
|
||||
import { useHostWorktreeActions } from './use-host-worktree-actions'
|
||||
import { useHostWorktreeCatalog } from './use-host-worktree-catalog'
|
||||
import type { HostWorkspaceCreationOperations } from '../worktree/host-workspace-creation-operations'
|
||||
import type { HostScreenShellOperations } from '../worktree/host-screen-shell-operations'
|
||||
|
||||
export type HostScreenProps = {
|
||||
// When true, rendered as the persistent tablet sidebar by the host layout, not as its own routed screen.
|
||||
embedded?: boolean
|
||||
// Route params aren't in scope when rendered from the layout, so the caller passes these explicitly.
|
||||
hostId?: string
|
||||
action?: string
|
||||
onHideSidebar?: () => void
|
||||
}
|
||||
|
||||
export function useHostScreenController({
|
||||
embedded = false,
|
||||
hostId: hostIdProp,
|
||||
action: actionProp,
|
||||
onHideSidebar
|
||||
}: HostScreenProps = {}) {
|
||||
const params = useLocalSearchParams<{ hostId: string; action?: string; notice?: string }>()
|
||||
const hostId = hostIdProp ?? params.hostId
|
||||
const action = actionProp ?? params.action
|
||||
const [dismissedNotice, setDismissedNotice] = useState<string | null>(null)
|
||||
const noticeParam = params.notice?.trim()
|
||||
const routeNotice = visibleHostRouteNotice(embedded, noticeParam, dismissedNotice)
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const insets = useSafeAreaInsets()
|
||||
// Why: cap and center the list on wide/tablet canvases; on phones isWideLayout is false so it stays edge-to-edge.
|
||||
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
|
||||
// Shared client per host owned by RpcClientProvider. See docs/mobile-shared-client-per-host.md.
|
||||
const { client, state: connState } = useHostClient(hostId)
|
||||
const reconnectAttempts = useReconnectAttempt(hostId)
|
||||
const lastConnectedAt = useLastConnectedAt(hostId)
|
||||
const relayRecovery = useRelayRecoveryStatus(hostId)
|
||||
const forgetHostClient = useForgetHostClient()
|
||||
const forceReconnectHost = useForceReconnect()
|
||||
// One tick drives every visible agent row's relative timestamp.
|
||||
const now = useNow(30_000)
|
||||
const { hostCapabilities, floatingWorkspaceEnabled } = useHostProtocolGates()
|
||||
const state = useHostScreenState(hostId, action)
|
||||
const settings = useHostViewSettings({ client, connState, hostId, state })
|
||||
|
||||
useHostScreenIdentity({ client, hostId, state })
|
||||
const fetchRepoMetadata = useHostRepoMetadata({ client, connState, hostId, state })
|
||||
const catalog = useHostWorktreeCatalog({
|
||||
client,
|
||||
connState,
|
||||
embedded,
|
||||
fetchRepoMetadata,
|
||||
hostId,
|
||||
state,
|
||||
syncViewSettingsFromDesktop: settings.syncViewSettingsFromDesktop
|
||||
})
|
||||
const actions = useHostWorktreeActions({
|
||||
client,
|
||||
connState,
|
||||
embedded,
|
||||
fetchWorktrees: catalog.fetchWorktrees,
|
||||
forgetHostClient,
|
||||
hostId,
|
||||
pathname,
|
||||
router,
|
||||
state
|
||||
})
|
||||
|
||||
const resolvedRouteActionState = resolveHostRouteActionState(state.routeActionState, action)
|
||||
// Why: resolve `action=newWorktree` before commit, but don't reopen after the user closes while the URL persists.
|
||||
if (resolvedRouteActionState !== state.routeActionState) {
|
||||
state.setRouteActionState(resolvedRouteActionState)
|
||||
}
|
||||
const showNewWorktree = resolvedRouteActionState.showNewWorktree
|
||||
|
||||
const displayWorktrees = 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
|
||||
return applyWorktreeHostContextLabels(
|
||||
applyWorktreeRowDisplayState(base, state.sleptIds, state.optimisticActiveWorktreeIdentity),
|
||||
{
|
||||
repoHostIdByRepoId: state.repoHostIdByRepoId,
|
||||
hostLabelById: state.hostLabelById,
|
||||
hostPlatform: state.hostPlatform
|
||||
}
|
||||
)
|
||||
}, [
|
||||
connState,
|
||||
state.worktrees,
|
||||
state.lastKnownWorktrees,
|
||||
state.sleptIds,
|
||||
state.optimisticActiveWorktreeIdentity,
|
||||
state.repoHostIdByRepoId,
|
||||
state.hostLabelById,
|
||||
state.hostPlatform
|
||||
])
|
||||
const sectionsResult = useWorkspaceSections({
|
||||
displayWorktrees,
|
||||
sortMode: state.sortMode,
|
||||
filters: state.filters,
|
||||
search: state.search,
|
||||
groupMode: state.groupMode,
|
||||
pinnedIds: state.pinnedIds,
|
||||
repoIdsByName: state.repoIdsByName,
|
||||
repoColorsByName: state.repoColorsByName,
|
||||
collapsedGroups: state.collapsedGroups,
|
||||
workspaceStatuses: state.workspaceStatuses,
|
||||
worktreesLoaded: state.worktreesLoaded
|
||||
})
|
||||
const existingWorktreePaths = useMemo(() => state.worktrees.map((w) => w.path), [state.worktrees])
|
||||
const activeWorktreeScroll = useActiveWorktreeScroll(sectionsResult.sections)
|
||||
|
||||
return {
|
||||
actions,
|
||||
activeWorktreeScroll,
|
||||
catalog,
|
||||
client,
|
||||
connState,
|
||||
contentMaxWidth,
|
||||
displayWorktrees,
|
||||
embedded,
|
||||
existingWorktreePaths,
|
||||
floatingWorkspaceEnabled,
|
||||
forceReconnectHost,
|
||||
hostCapabilities,
|
||||
hostId,
|
||||
insets,
|
||||
isReadOnly: connState === 'auth-failed',
|
||||
isWideLayout,
|
||||
lastConnectedAt,
|
||||
noticeParam,
|
||||
now,
|
||||
onHideSidebar,
|
||||
reconnectAttempts,
|
||||
relayRecovery,
|
||||
routeNotice,
|
||||
router,
|
||||
sectionsResult,
|
||||
setDismissedNotice,
|
||||
settings,
|
||||
showNewWorktree,
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
type HostScreenRouter = {
|
||||
push: (target: string | { pathname: string; params?: Record<string, string> }) => void
|
||||
}
|
||||
type HostScreenState = ReturnType<typeof useHostScreenController>['state']
|
||||
type HostScreenViewState = Omit<
|
||||
HostScreenState,
|
||||
| 'clientRef'
|
||||
| 'fetchRepoMetadataInFlightRef'
|
||||
| 'fetchRepoMetadataPendingRef'
|
||||
| 'worktreeCatalogRef'
|
||||
>
|
||||
|
||||
export type HostScreenController = Omit<
|
||||
ReturnType<typeof useHostScreenController>,
|
||||
'router' | 'state'
|
||||
> & {
|
||||
router: HostScreenRouter
|
||||
state: HostScreenViewState
|
||||
creationOperations?: HostWorkspaceCreationOperations | null
|
||||
shellOperations?: HostScreenShellOperations
|
||||
actions: ReturnType<typeof useHostScreenController>['actions'] & {
|
||||
sleepWorktree?: (worktreeId: string) => Promise<void>
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { getCachedWorktrees } from '../cache/worktree-cache'
|
||||
import { loadPinnedIds } from '../storage/preferences'
|
||||
import { loadHosts, updateLastConnected } from '../transport/host-store'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { Worktree } from '../worktree/workspace-list-sections'
|
||||
import type { HostScreenState } from './use-host-screen-state'
|
||||
|
||||
export function useHostScreenIdentity(args: {
|
||||
client: RpcClient | null
|
||||
hostId: string | undefined
|
||||
state: HostScreenState
|
||||
}): void {
|
||||
const { client, hostId, state } = args
|
||||
const {
|
||||
clientRef,
|
||||
repoMetadataFetchedAtRef,
|
||||
setCatalogError,
|
||||
setError,
|
||||
setHostLabelById,
|
||||
setHostName,
|
||||
setHostPlatform,
|
||||
setLastKnownWorktrees,
|
||||
setPinnedIds,
|
||||
setRepoColorsByName,
|
||||
setRepoHostIdByRepoId,
|
||||
setRepoIconsByName,
|
||||
setWorktrees,
|
||||
setWorktreesLoaded
|
||||
} = state
|
||||
|
||||
// Load persisted pins from local cache; view settings are no longer local (they sync via ui.get).
|
||||
useEffect(() => {
|
||||
if (!hostId) {
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
void (async () => {
|
||||
const pins = await loadPinnedIds(hostId)
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
setPinnedIds(pins)
|
||||
})()
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [hostId])
|
||||
|
||||
// Why: mirror client into a ref so imperative call sites read it without re-subscribing.
|
||||
useEffect(() => {
|
||||
clientRef.current = client
|
||||
}, [client])
|
||||
|
||||
useEffect(() => {
|
||||
setHostName('')
|
||||
setError('')
|
||||
setRepoColorsByName(new Map())
|
||||
setRepoIconsByName(new Map())
|
||||
setRepoHostIdByRepoId(new Map())
|
||||
setHostLabelById(new Map())
|
||||
setHostPlatform(null)
|
||||
repoMetadataFetchedAtRef.current = 0
|
||||
// Why: useState initializer runs only on first mount, so re-seed the cache when Expo Router reuses this screen for a new hostId.
|
||||
const freshCache = hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null
|
||||
setCatalogError(null)
|
||||
if (freshCache) {
|
||||
setWorktrees(freshCache)
|
||||
setLastKnownWorktrees(freshCache)
|
||||
setWorktreesLoaded(true)
|
||||
} else {
|
||||
setWorktreesLoaded(false)
|
||||
setWorktrees([])
|
||||
setLastKnownWorktrees([])
|
||||
}
|
||||
if (!hostId) {
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
void loadHosts().then((hosts) => {
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
const host = hosts.find((h) => h.id === hostId)
|
||||
if (!host) {
|
||||
setError('Host not found')
|
||||
return
|
||||
}
|
||||
setHostName(host.name)
|
||||
void updateLastConnected(host.id)
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [hostId])
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import type { ExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import type { RepoIcon } from '../../../src/shared/repo-icon'
|
||||
import type { WorkspaceStatusDefinition } from '../../../src/shared/worktree/types'
|
||||
import { getCachedWorktrees } from '../cache/worktree-cache'
|
||||
import { createInitialHostRouteActionState } from '../host-route-action-state'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from '../worktree/mobile-workspace-statuses'
|
||||
import { WorktreeCatalogSnapshotClient } from '../worktree/worktree-catalog-snapshot-client'
|
||||
import type {
|
||||
MobileGroupMode,
|
||||
MobileSortMode,
|
||||
MobileViewState
|
||||
} from '../worktree/workspace-view-settings'
|
||||
import type { FilterState, Worktree } from '../worktree/workspace-list-sections'
|
||||
|
||||
export function useHostScreenState(hostId: string | undefined, action: string | undefined) {
|
||||
const [initialCache] = useState(() =>
|
||||
hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null
|
||||
)
|
||||
const clientRef = useRef<RpcClient | null>(null)
|
||||
const fetchWorktreesInFlightRef = useRef(false)
|
||||
// Why: useRef, not useMemo — React may discard memoized values, which would silently
|
||||
// reset the snapshot token this object exists to own.
|
||||
const worktreeCatalogRef = useRef(new WorktreeCatalogSnapshotClient())
|
||||
const fetchRepoMetadataInFlightRef = useRef(new WeakSet<RpcClient>())
|
||||
const fetchRepoMetadataPendingRef = useRef(new WeakSet<RpcClient>())
|
||||
const repoMetadataFetchedAtRef = useRef(0)
|
||||
const newWorktreeModalRef = useRef<{ open: () => void }>(null)
|
||||
const newWorktreeModalVisibleRef = useRef(false)
|
||||
const [worktrees, setWorktrees] = useState<Worktree[]>(initialCache ?? [])
|
||||
const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null)
|
||||
// Why (STA-3123): error code of the last failed worktree.ps, so a broken catalog
|
||||
// path renders as a failure instead of an empty host. Cleared on the next success.
|
||||
const [catalogError, setCatalogError] = useState<string | null>(null)
|
||||
// Why: track the locally-opened worktree so the active-row highlight moves instantly instead of waiting for the next poll.
|
||||
const [optimisticActiveWorktreeIdentity, setOptimisticActiveWorktreeIdentity] = useState<
|
||||
string | null
|
||||
>(null)
|
||||
const [repoColorsByName, setRepoColorsByName] = useState<Map<string, string>>(new Map())
|
||||
const [repoIconsByName, setRepoIconsByName] = useState<Map<string, RepoIcon>>(new Map())
|
||||
const [hostName, setHostName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [lastKnownWorktrees, setLastKnownWorktrees] = useState<Worktree[]>(initialCache ?? [])
|
||||
const [search, setSearch] = useState('')
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const [sortMode, setSortMode] = useState<MobileSortMode>('recent')
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
filterRepoIds: new Set(),
|
||||
hideSleeping: false,
|
||||
hideDefaultBranch: false,
|
||||
alwaysShowDefaultBranch: true
|
||||
})
|
||||
const [groupMode, setGroupMode] = useState<MobileGroupMode>('repo')
|
||||
const [workspaceStatuses, setWorkspaceStatuses] = useState<readonly WorkspaceStatusDefinition[]>(
|
||||
DEFAULT_MOBILE_WORKSPACE_STATUSES
|
||||
)
|
||||
// displayName → repo id: filters key on repo id, but section headers/rows key on displayName, so bridge the two.
|
||||
const [repoIdsByName, setRepoIdsByName] = useState<Map<string, string>>(new Map())
|
||||
// Host-label inputs for rows: repo → host, SSH/override labels, and the host's own platform.
|
||||
const [repoHostIdByRepoId, setRepoHostIdByRepoId] = useState<Map<string, ExecutionHostId>>(
|
||||
new Map()
|
||||
)
|
||||
const [hostLabelById, setHostLabelById] = useState<Map<ExecutionHostId, string>>(new Map())
|
||||
const [hostPlatform, setHostPlatform] = useState<NodeJS.Platform | null>(null)
|
||||
const [showSortPicker, setShowSortPicker] = useState(false)
|
||||
const [showGroupPicker, setShowGroupPicker] = useState(false)
|
||||
const [showFilterModal, setShowFilterModal] = useState(false)
|
||||
const [actionTarget, setActionTarget] = useState<Worktree | null>(null)
|
||||
const [confirmDelete, setConfirmDelete] = useState<Worktree | null>(null)
|
||||
const [confirmRemoveHost, setConfirmRemoveHost] = useState(false)
|
||||
const [routeActionState, setRouteActionState] = useState(() =>
|
||||
createInitialHostRouteActionState(action)
|
||||
)
|
||||
const [sleptIds, setSleptIds] = useState<Set<string>>(new Set())
|
||||
const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set())
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
// Why: ref so the ui.get merge and ui.set writes read the latest values without re-creating callbacks on every state change.
|
||||
const viewStateRef = useRef<MobileViewState>({
|
||||
groupMode: 'repo',
|
||||
sortMode: 'recent',
|
||||
hideSleeping: false,
|
||||
hideDefaultBranch: false,
|
||||
alwaysShowDefaultBranch: true,
|
||||
filterRepoIds: [],
|
||||
collapsedGroups: [],
|
||||
workspaceStatuses: DEFAULT_MOBILE_WORKSPACE_STATUSES
|
||||
})
|
||||
|
||||
return {
|
||||
actionTarget,
|
||||
catalogError,
|
||||
clientRef,
|
||||
collapsedGroups,
|
||||
confirmDelete,
|
||||
confirmRemoveHost,
|
||||
error,
|
||||
fetchRepoMetadataInFlightRef,
|
||||
fetchRepoMetadataPendingRef,
|
||||
fetchWorktreesInFlightRef,
|
||||
filters,
|
||||
groupMode,
|
||||
hostLabelById,
|
||||
hostName,
|
||||
hostPlatform,
|
||||
lastKnownWorktrees,
|
||||
newWorktreeModalRef,
|
||||
newWorktreeModalVisibleRef,
|
||||
optimisticActiveWorktreeIdentity,
|
||||
pinnedIds,
|
||||
repoColorsByName,
|
||||
repoHostIdByRepoId,
|
||||
repoIconsByName,
|
||||
repoIdsByName,
|
||||
repoMetadataFetchedAtRef,
|
||||
routeActionState,
|
||||
search,
|
||||
setActionTarget,
|
||||
setCatalogError,
|
||||
setCollapsedGroups,
|
||||
setConfirmDelete,
|
||||
setConfirmRemoveHost,
|
||||
setError,
|
||||
setFilters,
|
||||
setGroupMode,
|
||||
setHostLabelById,
|
||||
setHostName,
|
||||
setHostPlatform,
|
||||
setLastKnownWorktrees,
|
||||
setOptimisticActiveWorktreeIdentity,
|
||||
setPinnedIds,
|
||||
setRepoColorsByName,
|
||||
setRepoHostIdByRepoId,
|
||||
setRepoIconsByName,
|
||||
setRepoIdsByName,
|
||||
setRouteActionState,
|
||||
setSearch,
|
||||
setShowFilterModal,
|
||||
setShowGroupPicker,
|
||||
setShowSearch,
|
||||
setShowSortPicker,
|
||||
setSleptIds,
|
||||
setSortMode,
|
||||
setWorkspaceStatuses,
|
||||
setWorktrees,
|
||||
setWorktreesLoaded,
|
||||
showFilterModal,
|
||||
showGroupPicker,
|
||||
showSearch,
|
||||
showSortPicker,
|
||||
sleptIds,
|
||||
sortMode,
|
||||
viewStateRef,
|
||||
workspaceStatuses,
|
||||
worktreeCatalogRef,
|
||||
worktrees,
|
||||
worktreesLoaded
|
||||
}
|
||||
}
|
||||
|
||||
export type HostScreenState = ReturnType<typeof useHostScreenState>
|
||||
@@ -1,194 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState, RpcSuccess } from '../transport/types'
|
||||
import { getMobileWorkspaceLineageGroupKey } from '../worktree/mobile-workspace-lineage'
|
||||
import { WORKSPACE_SORT_OPTIONS as SORT_OPTIONS } from '../worktree/workspace-list-picker-options'
|
||||
import {
|
||||
applyDesktopViewSettings,
|
||||
buildWorkspaceViewSettingsUpdate,
|
||||
type MobileGroupMode,
|
||||
type MobileSortMode,
|
||||
type MobileViewState,
|
||||
type WorkspaceViewSettings
|
||||
} from '../worktree/workspace-view-settings'
|
||||
import type { Worktree } from '../worktree/workspace-list-sections'
|
||||
import type { HostScreenState } from './use-host-screen-state'
|
||||
|
||||
export function useHostViewSettings(args: {
|
||||
client: RpcClient | null
|
||||
connState: ConnectionState
|
||||
hostId: string | undefined
|
||||
state: HostScreenState
|
||||
}) {
|
||||
const { client, connState, hostId, state } = args
|
||||
const {
|
||||
clientRef,
|
||||
collapsedGroups,
|
||||
filters,
|
||||
groupMode,
|
||||
setCollapsedGroups,
|
||||
setFilters,
|
||||
setGroupMode,
|
||||
setSortMode,
|
||||
setWorkspaceStatuses,
|
||||
sortMode,
|
||||
viewStateRef,
|
||||
workspaceStatuses
|
||||
} = state
|
||||
|
||||
useEffect(() => {
|
||||
viewStateRef.current = {
|
||||
groupMode,
|
||||
sortMode,
|
||||
hideSleeping: filters.hideSleeping,
|
||||
hideDefaultBranch: filters.hideDefaultBranch,
|
||||
alwaysShowDefaultBranch: filters.alwaysShowDefaultBranch !== false,
|
||||
filterRepoIds: [...filters.filterRepoIds],
|
||||
collapsedGroups: [...collapsedGroups],
|
||||
workspaceStatuses
|
||||
}
|
||||
}, [groupMode, sortMode, filters, collapsedGroups, workspaceStatuses])
|
||||
|
||||
// Apply a MobileViewState onto the individual states and the snapshot ref in one shot.
|
||||
const applyViewState = useCallback((next: MobileViewState) => {
|
||||
viewStateRef.current = next
|
||||
setGroupMode(next.groupMode)
|
||||
setSortMode(next.sortMode)
|
||||
setWorkspaceStatuses(next.workspaceStatuses)
|
||||
setCollapsedGroups(new Set(next.collapsedGroups))
|
||||
setFilters({
|
||||
filterRepoIds: new Set(next.filterRepoIds),
|
||||
hideSleeping: next.hideSleeping,
|
||||
hideDefaultBranch: next.hideDefaultBranch,
|
||||
alwaysShowDefaultBranch: next.alwaysShowDefaultBranch
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Apply the change locally, then patch the desktop's shared store (ui.set) so both apps stay in sync.
|
||||
const persistViewSettings = useCallback(
|
||||
(patch: Partial<MobileViewState>) => {
|
||||
const next: MobileViewState = { ...viewStateRef.current, ...patch }
|
||||
applyViewState(next)
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
// Send only the touched fields: the host merges partial updates, so a stale
|
||||
// mirror can no longer revert sibling settings another client just changed
|
||||
// (STA-5781; supersedes the #8873 whole-payload special case).
|
||||
const payload: WorkspaceViewSettings = buildWorkspaceViewSettingsUpdate(patch, next)
|
||||
if (Object.keys(payload).length === 0) {
|
||||
return
|
||||
}
|
||||
void client.sendRequest('ui.set', payload).catch(() => {
|
||||
// Best-effort: view settings are a convenience preference.
|
||||
})
|
||||
},
|
||||
[client, applyViewState]
|
||||
)
|
||||
|
||||
// Merge the desktop's shared view settings (PersistedUIState) onto local state so desktop changes appear here.
|
||||
const syncViewSettingsFromDesktop = useCallback(async () => {
|
||||
if (!client || connState !== 'connected') {
|
||||
return
|
||||
}
|
||||
const requestClient = client
|
||||
const requestHostId = hostId
|
||||
try {
|
||||
const response = await requestClient.sendRequest('ui.get')
|
||||
if (clientRef.current !== requestClient || hostId !== requestHostId || !response.ok) {
|
||||
return
|
||||
}
|
||||
const ui = ((response as RpcSuccess).result as { ui?: WorkspaceViewSettings }).ui
|
||||
if (!ui) {
|
||||
return
|
||||
}
|
||||
applyViewState(applyDesktopViewSettings(viewStateRef.current, ui))
|
||||
} catch {
|
||||
// Transient transport failure; retry on the next focus/connect.
|
||||
}
|
||||
}, [client, connState, hostId, applyViewState])
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(value: MobileSortMode) => {
|
||||
persistViewSettings({ sortMode: value })
|
||||
},
|
||||
[persistViewSettings]
|
||||
)
|
||||
|
||||
const toggleHideSleeping = useCallback(() => {
|
||||
persistViewSettings({ hideSleeping: !viewStateRef.current.hideSleeping })
|
||||
}, [persistViewSettings])
|
||||
|
||||
const toggleHideDefaultBranch = useCallback(() => {
|
||||
persistViewSettings({ hideDefaultBranch: !viewStateRef.current.hideDefaultBranch })
|
||||
}, [persistViewSettings])
|
||||
|
||||
const toggleRepoFilter = useCallback(
|
||||
(repoId: string) => {
|
||||
const next = new Set(viewStateRef.current.filterRepoIds)
|
||||
if (next.has(repoId)) {
|
||||
next.delete(repoId)
|
||||
} else {
|
||||
next.add(repoId)
|
||||
}
|
||||
persistViewSettings({ filterRepoIds: [...next] })
|
||||
},
|
||||
[persistViewSettings]
|
||||
)
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
persistViewSettings({ hideSleeping: false, hideDefaultBranch: false, filterRepoIds: [] })
|
||||
}, [persistViewSettings])
|
||||
|
||||
const activeFilterCount = useMemo(() => {
|
||||
let count = 0
|
||||
if (filters.hideSleeping) {
|
||||
count++
|
||||
}
|
||||
if (filters.hideDefaultBranch) {
|
||||
count++
|
||||
}
|
||||
count += filters.filterRepoIds.size
|
||||
return count
|
||||
}, [filters])
|
||||
const selectedSortLabel =
|
||||
SORT_OPTIONS.find((option) => option.value === sortMode)?.label ?? 'Recent'
|
||||
|
||||
const handleGroupChange = useCallback(
|
||||
(value: MobileGroupMode) => {
|
||||
persistViewSettings({ groupMode: value })
|
||||
},
|
||||
[persistViewSettings]
|
||||
)
|
||||
|
||||
const toggleCollapsed = useCallback(
|
||||
(key: string) => {
|
||||
const next = new Set(viewStateRef.current.collapsedGroups)
|
||||
if (!next.delete(key)) {
|
||||
next.add(key)
|
||||
}
|
||||
persistViewSettings({ collapsedGroups: [...next] })
|
||||
},
|
||||
[persistViewSettings]
|
||||
)
|
||||
const toggleWorktreeLineage = useCallback(
|
||||
(item: Worktree) => toggleCollapsed(getMobileWorkspaceLineageGroupKey(item)),
|
||||
[toggleCollapsed]
|
||||
)
|
||||
|
||||
return {
|
||||
activeFilterCount,
|
||||
clearFilters,
|
||||
handleGroupChange,
|
||||
handleSortChange,
|
||||
selectedSortLabel,
|
||||
syncViewSettingsFromDesktop,
|
||||
toggleCollapsed,
|
||||
toggleHideDefaultBranch,
|
||||
toggleHideSleeping,
|
||||
toggleRepoFilter,
|
||||
toggleWorktreeLineage
|
||||
}
|
||||
}
|
||||
|
||||
export type HostViewSettings = ReturnType<typeof useHostViewSettings>
|
||||
@@ -1,217 +0,0 @@
|
||||
import { useCallback } from 'react'
|
||||
import { Alert } from 'react-native'
|
||||
import type { useRouter } from 'expo-router'
|
||||
import { floatingWorkspaceSessionPath } from '../session/floating-workspace'
|
||||
import { savePinnedIds } from '../storage/preferences'
|
||||
import type { useForgetHostClient } from '../transport/client-context'
|
||||
import { removeHostAndCloseClient } from '../transport/host-removal-lifecycle'
|
||||
import { loadHosts } from '../transport/host-store'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import { setHostRouteNewWorktreeVisible } from '../host-route-action-state'
|
||||
import { leaveHostRoute } from '../host-route-exit'
|
||||
import { getWorktreeRowIdentity, removeWorktreeRow } from '../worktree/worktree-host-row-identity'
|
||||
import { isWorktreePinned, type Worktree } from '../worktree/workspace-list-sections'
|
||||
import type { HostScreenState } from './use-host-screen-state'
|
||||
|
||||
export function useHostWorktreeActions(args: {
|
||||
client: RpcClient | null
|
||||
connState: ConnectionState
|
||||
embedded: boolean
|
||||
fetchWorktrees: (options?: { allowDuringModal?: boolean }) => Promise<void>
|
||||
forgetHostClient: ReturnType<typeof useForgetHostClient>
|
||||
hostId: string | undefined
|
||||
pathname: string
|
||||
router: ReturnType<typeof useRouter>
|
||||
state: HostScreenState
|
||||
}) {
|
||||
const {
|
||||
client,
|
||||
connState,
|
||||
embedded,
|
||||
fetchWorktrees,
|
||||
forgetHostClient,
|
||||
hostId,
|
||||
pathname,
|
||||
router,
|
||||
state
|
||||
} = args
|
||||
const {
|
||||
newWorktreeModalRef,
|
||||
newWorktreeModalVisibleRef,
|
||||
pinnedIds,
|
||||
setConfirmRemoveHost,
|
||||
setLastKnownWorktrees,
|
||||
setOptimisticActiveWorktreeIdentity,
|
||||
setPinnedIds,
|
||||
setRouteActionState,
|
||||
setWorktrees,
|
||||
worktrees
|
||||
} = state
|
||||
|
||||
const leaveHost = useCallback(() => {
|
||||
leaveHostRoute(router)
|
||||
}, [router])
|
||||
|
||||
const openNewWorktreeModal = useCallback(() => {
|
||||
const modal = newWorktreeModalRef.current
|
||||
if (!modal) {
|
||||
return
|
||||
}
|
||||
newWorktreeModalVisibleRef.current = true
|
||||
modal.open()
|
||||
}, [])
|
||||
|
||||
const setShowNewWorktreeVisible = useCallback((visible: boolean) => {
|
||||
setRouteActionState((current) => setHostRouteNewWorktreeVisible(current, visible))
|
||||
}, [])
|
||||
|
||||
const updateLocalPins = useCallback(
|
||||
(worktreeId: string, pinned: boolean) => {
|
||||
setPinnedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (pinned) {
|
||||
next.add(worktreeId)
|
||||
} else {
|
||||
next.delete(worktreeId)
|
||||
}
|
||||
if (hostId) {
|
||||
void savePinnedIds(hostId, next)
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[hostId]
|
||||
)
|
||||
|
||||
const togglePin = useCallback(
|
||||
(worktreeId: string) => {
|
||||
const worktree = worktrees.find((w) => w.worktreeId === worktreeId)
|
||||
const currentlyPinned = worktree
|
||||
? isWorktreePinned(worktree, pinnedIds)
|
||||
: pinnedIds.has(worktreeId)
|
||||
const newPinned = !currentlyPinned
|
||||
|
||||
setWorktrees((prev) =>
|
||||
prev.map((w) => (w.worktreeId === worktreeId ? { ...w, isPinned: newPinned } : w))
|
||||
)
|
||||
setLastKnownWorktrees((prev) =>
|
||||
prev.map((w) => (w.worktreeId === worktreeId ? { ...w, isPinned: newPinned } : w))
|
||||
)
|
||||
|
||||
updateLocalPins(worktreeId, newPinned)
|
||||
|
||||
if (client) {
|
||||
client
|
||||
.sendRequest('worktree.set', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
isPinned: newPinned
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
},
|
||||
[client, worktrees, pinnedIds, updateLocalPins]
|
||||
)
|
||||
|
||||
const handleDeleteWorktree = useCallback(
|
||||
async (item: Worktree) => {
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const removeFromList = (list: Worktree[]) => removeWorktreeRow(list, item)
|
||||
setWorktrees(removeFromList)
|
||||
setLastKnownWorktrees(removeFromList)
|
||||
|
||||
try {
|
||||
const response = await client.sendRequest('worktree.rm', {
|
||||
worktree: `id:${item.worktreeId}`,
|
||||
force: true
|
||||
})
|
||||
if (!response.ok) {
|
||||
setWorktrees((prev) => [...prev, item])
|
||||
setLastKnownWorktrees((prev) => [...prev, item])
|
||||
}
|
||||
void fetchWorktrees()
|
||||
} catch {
|
||||
setWorktrees((prev) => [...prev, item])
|
||||
setLastKnownWorktrees((prev) => [...prev, item])
|
||||
}
|
||||
},
|
||||
[client, fetchWorktrees]
|
||||
)
|
||||
|
||||
const handleRemoveHost = useCallback(async () => {
|
||||
if (!hostId) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const host = (await loadHosts()).find((candidate) => candidate.id === hostId)
|
||||
if (!host) {
|
||||
throw new Error('Host identity unavailable')
|
||||
}
|
||||
await removeHostAndCloseClient(hostId, host.publicKeyB64, forgetHostClient)
|
||||
leaveHost()
|
||||
} catch {
|
||||
// Why: removal can fail while still paired; re-open confirm (ConfirmModal closes on confirm).
|
||||
setConfirmRemoveHost(true)
|
||||
Alert.alert('Could not remove host', 'Please try again.')
|
||||
}
|
||||
}, [hostId, leaveHost, forgetHostClient])
|
||||
|
||||
const navigateFromHostList = useCallback(
|
||||
(target: string) => {
|
||||
if (!embedded) {
|
||||
router.push(target)
|
||||
return
|
||||
}
|
||||
if (pathname === (target.split('?')[0] ?? target)) {
|
||||
return
|
||||
}
|
||||
if (pathname === `/h/${hostId}`) {
|
||||
router.push(target)
|
||||
return
|
||||
}
|
||||
router.replace(target)
|
||||
},
|
||||
[embedded, hostId, pathname, router]
|
||||
)
|
||||
|
||||
const openWorktreeSession = useCallback(
|
||||
(item: Worktree) => {
|
||||
setOptimisticActiveWorktreeIdentity(getWorktreeRowIdentity(item))
|
||||
if (client && connState === 'connected') {
|
||||
void client
|
||||
.sendRequest('worktree.activate', {
|
||||
worktree: `id:${item.worktreeId}`,
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
})
|
||||
.catch(() => null)
|
||||
}
|
||||
const target = `/h/${hostId}/session/${encodeURIComponent(item.worktreeId)}?name=${encodeURIComponent(item.displayName || item.repo)}`
|
||||
navigateFromHostList(target)
|
||||
},
|
||||
[client, connState, hostId, navigateFromHostList]
|
||||
)
|
||||
|
||||
const openFloatingWorkspace = useCallback(() => {
|
||||
// Why: no worktree.activate here — the floating sentinel has no worktree
|
||||
// record; session.tabs.list hydrates its host-owned tabs on open.
|
||||
navigateFromHostList(floatingWorkspaceSessionPath(hostId))
|
||||
}, [hostId, navigateFromHostList])
|
||||
|
||||
return {
|
||||
handleDeleteWorktree,
|
||||
handleRemoveHost,
|
||||
leaveHost,
|
||||
navigateFromHostList,
|
||||
openFloatingWorkspace,
|
||||
openNewWorktreeModal,
|
||||
openWorktreeSession,
|
||||
setShowNewWorktreeVisible,
|
||||
togglePin
|
||||
}
|
||||
}
|
||||
|
||||
export type HostWorktreeActions = ReturnType<typeof useHostWorktreeActions>
|
||||
@@ -1,179 +0,0 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useFocusEffect } from 'expo-router'
|
||||
import { setCachedWorktrees } from '../cache/worktree-cache'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import { useWorktreeResync } from '../transport/use-worktree-resync'
|
||||
import { startHostWorktreeRefresh } from '../worktree/host-worktree-refresh'
|
||||
import { defaultHostWorkspaceOperations } from '../worktree/default-host-workspace-operations'
|
||||
import { areWorktreeListsEqual } from '../worktree/worktree-list-snapshot'
|
||||
import {
|
||||
clearConfirmedActiveWorktreeIdentity,
|
||||
retainLiveSleptWorktreeIdentities
|
||||
} from '../worktree/worktree-host-row-identity'
|
||||
import { savePinnedIds } from '../storage/preferences'
|
||||
import type { FetchHostRepoMetadata } from './use-host-repo-metadata'
|
||||
import type { HostScreenState } from './use-host-screen-state'
|
||||
|
||||
export function useHostWorktreeCatalog(args: {
|
||||
client: RpcClient | null
|
||||
connState: ConnectionState
|
||||
embedded: boolean
|
||||
fetchRepoMetadata: FetchHostRepoMetadata
|
||||
hostId: string | undefined
|
||||
state: HostScreenState
|
||||
syncViewSettingsFromDesktop: () => Promise<void>
|
||||
}) {
|
||||
const {
|
||||
client,
|
||||
connState,
|
||||
embedded,
|
||||
fetchRepoMetadata,
|
||||
hostId,
|
||||
state,
|
||||
syncViewSettingsFromDesktop
|
||||
} = args
|
||||
const {
|
||||
clientRef,
|
||||
fetchWorktreesInFlightRef,
|
||||
newWorktreeModalVisibleRef,
|
||||
setCatalogError,
|
||||
setLastKnownWorktrees,
|
||||
setOptimisticActiveWorktreeIdentity,
|
||||
setPinnedIds,
|
||||
setSleptIds,
|
||||
setWorktrees,
|
||||
setWorktreesLoaded,
|
||||
worktreeCatalogRef
|
||||
} = state
|
||||
|
||||
const fetchWorktrees = useCallback(
|
||||
async (options: { allowDuringModal?: boolean } = {}) => {
|
||||
if (!client || connState !== 'connected' || !hostId) {
|
||||
return
|
||||
}
|
||||
if (!options.allowDuringModal && newWorktreeModalVisibleRef.current) {
|
||||
return
|
||||
}
|
||||
// Why: prevent slow remote hosts from stacking overlapping worktree.ps requests during polling.
|
||||
if (fetchWorktreesInFlightRef.current) {
|
||||
return
|
||||
}
|
||||
fetchWorktreesInFlightRef.current = true
|
||||
const requestClient = client
|
||||
const requestHostId = hostId
|
||||
|
||||
try {
|
||||
const fetched = await worktreeCatalogRef.current.fetch(requestClient, requestHostId)
|
||||
if (clientRef.current !== requestClient || hostId !== requestHostId) {
|
||||
return
|
||||
}
|
||||
if (!options.allowDuringModal && newWorktreeModalVisibleRef.current) {
|
||||
return
|
||||
}
|
||||
// Why (STA-3123): a failed catalog request must not pass for "0 worktrees";
|
||||
// surface it so a broken remote host is diagnosable instead of looking empty.
|
||||
if (fetched.kind === 'request_failed') {
|
||||
setCatalogError(fetched.code)
|
||||
return
|
||||
}
|
||||
if (fetched.pending.admission.kind === 'invalid') {
|
||||
setCatalogError('invalid_response')
|
||||
}
|
||||
// Why: unchanged responses still yield the confirmed rows, so every poll reasserts
|
||||
// host truth over optimistic local edits regardless of payload size.
|
||||
const confirmed = worktreeCatalogRef.current.admit(fetched.pending)
|
||||
if (confirmed) {
|
||||
setCatalogError(null)
|
||||
// Why: reuse the existing array on identical snapshots to keep SectionList/sort rebuilds off the tap path.
|
||||
setWorktrees((current) =>
|
||||
areWorktreeListsEqual(current, confirmed) ? current : confirmed
|
||||
)
|
||||
setLastKnownWorktrees((current) =>
|
||||
areWorktreeListsEqual(current, confirmed) ? current : confirmed
|
||||
)
|
||||
setWorktreesLoaded(true)
|
||||
// Why (#8498): overwrite the home-written cache with the confirmed snapshot so a reconnect/remount can't serve a stale list.
|
||||
if (hostId) {
|
||||
setCachedWorktrees(hostId, confirmed, { proven: true })
|
||||
}
|
||||
// Drop the optimistic active override once the host reports it active, so later desktop changes win.
|
||||
setOptimisticActiveWorktreeIdentity((pending) =>
|
||||
clearConfirmedActiveWorktreeIdentity(pending, confirmed)
|
||||
)
|
||||
|
||||
// Clear optimistic sleep overrides once the server confirms inactive (liveTerminalCount === 0).
|
||||
setSleptIds((prev) => retainLiveSleptWorktreeIdentities(prev, confirmed))
|
||||
|
||||
// Sync pin state from server so desktop-initiated pins reflect without relying on stale AsyncStorage.
|
||||
const serverPinned = new Set(confirmed.filter((w) => w.isPinned).map((w) => w.worktreeId))
|
||||
setPinnedIds((prev) => {
|
||||
if (serverPinned.size === prev.size && [...serverPinned].every((id) => prev.has(id))) {
|
||||
return prev
|
||||
}
|
||||
if (hostId) {
|
||||
void savePinnedIds(hostId, serverPinned)
|
||||
}
|
||||
return serverPinned
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Will retry on reconnect
|
||||
if (clientRef.current === requestClient && hostId === requestHostId) {
|
||||
setCatalogError('network_error')
|
||||
}
|
||||
} finally {
|
||||
fetchWorktreesInFlightRef.current = false
|
||||
}
|
||||
},
|
||||
[client, connState, hostId]
|
||||
)
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
// Why: focus nudges reconnect and probes a possibly half-open socket; empty deps fire per focus, not per state flip (which defeats backoff).
|
||||
// 'focus' keeps a healthy relay green — probe, never suspend (S2 grey blink).
|
||||
clientRef.current?.notifyForeground('focus')
|
||||
}, [])
|
||||
)
|
||||
|
||||
const startWorktreeRefresh = useCallback(() => {
|
||||
if (!client || connState !== 'connected') {
|
||||
return
|
||||
}
|
||||
void syncViewSettingsFromDesktop()
|
||||
return startHostWorktreeRefresh({
|
||||
operations: defaultHostWorkspaceOperations(client),
|
||||
fetchWorktrees,
|
||||
fetchRepoMetadata
|
||||
})
|
||||
}, [client, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
// The embedded sidebar isn't a routed screen (focus never fires); it refreshes via the mount effect below.
|
||||
if (!embedded) {
|
||||
return startWorktreeRefresh()
|
||||
}
|
||||
}, [embedded, startWorktreeRefresh])
|
||||
)
|
||||
|
||||
// Why: the embedded sidebar is never the focused route, so wire its refresh lifecycle from a mount effect.
|
||||
useEffect(() => {
|
||||
if (embedded) {
|
||||
return startWorktreeRefresh()
|
||||
}
|
||||
}, [embedded, startWorktreeRefresh])
|
||||
|
||||
// Why (#8498): steady-state polls miss the transition INTO 'connected' after background/sleep, when the cache is stalest.
|
||||
const { refreshing, onRefresh } = useWorktreeResync({
|
||||
available: client !== null,
|
||||
connState,
|
||||
fetchWorktrees,
|
||||
fetchRepoMetadata
|
||||
})
|
||||
|
||||
return { fetchWorktrees, onRefresh, refreshing }
|
||||
}
|
||||
|
||||
export type HostWorktreeCatalog = ReturnType<typeof useHostWorktreeCatalog>
|
||||
@@ -181,7 +181,7 @@ function createMobileClient(authority: Authority) {
|
||||
|
||||
function readMobileViewSettingsHookSource(): string {
|
||||
return readFileSync(
|
||||
join(__dirname, '../../../../mobile/src/host-screen/use-host-view-settings.ts'),
|
||||
join(__dirname, '../../../../mobile/src/host-screen/use-hybrid-host-screen-settings.ts'),
|
||||
'utf-8'
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user