mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 00:03:59 +00:00
Split mobile host screen
This commit is contained in:
@@ -66,7 +66,6 @@ inline src/renderer/src/store/slices/ui.ts
|
||||
inline src/shared/keybindings.ts
|
||||
inline src/shared/telemetry-events.ts
|
||||
mobile-config app/h/*/files/*.tsx
|
||||
mobile-config app/h/*/index.tsx
|
||||
mobile-config app/h/*/session/*.tsx
|
||||
mobile-config app/h/*/source-control/*.tsx
|
||||
mobile-config app/h/*/tasks.tsx
|
||||
|
||||
@@ -45,12 +45,6 @@
|
||||
"max-lines": ["error", { "max": 2152, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["app/h/*/index.tsx"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 1603, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["app/index.tsx"],
|
||||
"rules": {
|
||||
|
||||
+8
-1408
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,337 @@
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import {
|
||||
ChevronLeft,
|
||||
Filter,
|
||||
Layers,
|
||||
List,
|
||||
PanelLeftClose,
|
||||
Plus,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
SquareTerminal,
|
||||
UserCircle,
|
||||
X
|
||||
} from 'lucide-react-native'
|
||||
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'
|
||||
|
||||
function isErrorVerdict(v: ConnectionVerdict): boolean {
|
||||
return v.kind === 'warning' || v.kind === 'unreachable' || v.kind === 'auth-failed'
|
||||
}
|
||||
|
||||
export function HostScreenHeader({ controller }: { controller: HostScreenController }) {
|
||||
const {
|
||||
actions,
|
||||
connState,
|
||||
embedded,
|
||||
floatingWorkspaceEnabled,
|
||||
forceReconnectHost,
|
||||
hostId,
|
||||
lastConnectedAt,
|
||||
onHideSidebar,
|
||||
reconnectAttempts,
|
||||
relayRecovery,
|
||||
settings,
|
||||
state
|
||||
} = controller
|
||||
|
||||
return (
|
||||
<View style={styles.topChrome}>
|
||||
<View style={styles.statusBar}>
|
||||
<Pressable
|
||||
style={styles.backButton}
|
||||
onPress={actions.leaveHost}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Back to hosts"
|
||||
hitSlop={8}
|
||||
>
|
||||
<ChevronLeft size={22} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
{(() => {
|
||||
const headerVerdict = classifyConnection({
|
||||
state: connState,
|
||||
reconnectAttempts,
|
||||
lastConnectedAt,
|
||||
...relayRecovery
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<View style={styles.hostIdentity}>
|
||||
<StatusDot state={connState} verdict={headerVerdict} />
|
||||
<Text style={styles.hostNameText} numberOfLines={1}>
|
||||
{state.hostName || 'Host'}
|
||||
</Text>
|
||||
</View>
|
||||
{connState !== 'connected' &&
|
||||
(() => {
|
||||
// Why: auth-failed has its own banner, so suppress the Reconnect button for that verdict.
|
||||
const verdict = headerVerdict
|
||||
const isError = isErrorVerdict(verdict)
|
||||
const showReconnectButton = isError && hostId && verdict.kind !== 'auth-failed'
|
||||
if (!showReconnectButton) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.reconnectButton}
|
||||
onPress={() => void forceReconnectHost(hostId!)}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Text style={styles.reconnectButtonText}>Reconnect</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})()}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
{!embedded && floatingWorkspaceEnabled ? (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.floatingWorkspaceHeaderButton,
|
||||
connState !== 'connected' && styles.toolbarIconDisabled
|
||||
]}
|
||||
onPress={actions.openFloatingWorkspace}
|
||||
disabled={connState !== 'connected'}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Floating Workspace"
|
||||
hitSlop={8}
|
||||
>
|
||||
<SquareTerminal
|
||||
size={18}
|
||||
color={connState === 'connected' ? colors.textPrimary : colors.textMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{embedded && onHideSidebar ? (
|
||||
<Pressable
|
||||
style={styles.sidebarCollapseButton}
|
||||
onPress={onHideSidebar}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Hide sidebar"
|
||||
hitSlop={8}
|
||||
>
|
||||
<PanelLeftClose size={14} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{/* Filter/sort/group toolbar */}
|
||||
{embedded ? (
|
||||
<View style={styles.embeddedToolbar}>
|
||||
<View style={styles.embeddedToolbarRow}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.filterChip,
|
||||
styles.embeddedFilterChip,
|
||||
settings.activeFilterCount > 0 && styles.filterChipActive
|
||||
]}
|
||||
onPress={() => state.setShowFilterModal(true)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Filter workspaces${settings.activeFilterCount > 0 ? `, ${settings.activeFilterCount} active` : ''}`}
|
||||
>
|
||||
<Filter
|
||||
size={12}
|
||||
color={settings.activeFilterCount > 0 ? colors.textPrimary : colors.textSecondary}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.filterChipText,
|
||||
settings.activeFilterCount > 0 && styles.filterChipTextActive
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
Filter{settings.activeFilterCount > 0 ? ` ${settings.activeFilterCount}` : ''}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={[styles.modeButton, styles.embeddedModeButton]}
|
||||
onPress={() => state.setShowSortPicker(true)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Sort by ${settings.selectedSortLabel}`}
|
||||
>
|
||||
<SlidersHorizontal size={14} color={colors.textSecondary} />
|
||||
<Text style={styles.sortLabel} numberOfLines={1}>
|
||||
{settings.selectedSortLabel}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={[styles.modeButton, styles.embeddedModeButton]}
|
||||
onPress={() => state.setShowGroupPicker(true)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Group workspaces"
|
||||
>
|
||||
<Layers size={14} color={colors.textSecondary} />
|
||||
<Text style={styles.sortLabel} numberOfLines={1}>
|
||||
{state.groupMode === 'none'
|
||||
? 'Group'
|
||||
: state.groupMode === 'workspaceStatus'
|
||||
? 'Status'
|
||||
: state.groupMode === 'repo'
|
||||
? 'Repo'
|
||||
: 'PR'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.embeddedToolbarRow}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.embeddedToolbarIconButton,
|
||||
connState !== 'connected' && styles.toolbarIconDisabled
|
||||
]}
|
||||
onPress={() => actions.navigateFromHostList(`/h/${hostId}/accounts`)}
|
||||
disabled={connState !== 'connected'}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Accounts"
|
||||
>
|
||||
<UserCircle
|
||||
size={16}
|
||||
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={[
|
||||
styles.embeddedToolbarIconButton,
|
||||
connState !== 'connected' && styles.toolbarIconDisabled
|
||||
]}
|
||||
onPress={() => actions.navigateFromHostList(`/h/${hostId}/tasks`)}
|
||||
disabled={connState !== 'connected'}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Tasks"
|
||||
>
|
||||
<List
|
||||
size={16}
|
||||
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{floatingWorkspaceEnabled ? (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.embeddedToolbarIconButton,
|
||||
connState !== 'connected' && styles.toolbarIconDisabled
|
||||
]}
|
||||
onPress={actions.openFloatingWorkspace}
|
||||
disabled={connState !== 'connected'}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Floating Workspace"
|
||||
>
|
||||
<SquareTerminal
|
||||
size={18}
|
||||
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
style={[
|
||||
styles.embeddedToolbarIconButton,
|
||||
connState !== 'connected' && styles.toolbarIconDisabled
|
||||
]}
|
||||
onPress={actions.openNewWorktreeModal}
|
||||
disabled={connState !== 'connected'}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="New workspace"
|
||||
>
|
||||
<Plus
|
||||
size={16}
|
||||
color={connState === 'connected' ? colors.textPrimary : colors.textMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={styles.embeddedToolbarIconButton}
|
||||
onPress={() => state.setShowSearch((s) => !s)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={state.showSearch ? 'Close search' : 'Search workspaces'}
|
||||
>
|
||||
{state.showSearch ? (
|
||||
<X size={16} color={colors.textSecondary} />
|
||||
) : (
|
||||
<Search size={16} color={colors.textSecondary} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.toolbar}>
|
||||
<Pressable
|
||||
style={[styles.filterChip, settings.activeFilterCount > 0 && styles.filterChipActive]}
|
||||
onPress={() => state.setShowFilterModal(true)}
|
||||
>
|
||||
<Filter
|
||||
size={12}
|
||||
color={settings.activeFilterCount > 0 ? colors.textPrimary : colors.textSecondary}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.filterChipText,
|
||||
settings.activeFilterCount > 0 && styles.filterChipTextActive
|
||||
]}
|
||||
>
|
||||
Filter{settings.activeFilterCount > 0 ? ` (${settings.activeFilterCount})` : ''}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable style={styles.modeButton} onPress={() => state.setShowSortPicker(true)}>
|
||||
<SlidersHorizontal size={14} color={colors.textSecondary} />
|
||||
<Text style={styles.sortLabel} numberOfLines={1}>
|
||||
{settings.selectedSortLabel}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable style={styles.modeButton} onPress={() => state.setShowGroupPicker(true)}>
|
||||
<Layers size={14} color={colors.textSecondary} />
|
||||
<Text style={styles.sortLabel} numberOfLines={1}>
|
||||
{state.groupMode === 'none'
|
||||
? 'Group'
|
||||
: state.groupMode === 'workspaceStatus'
|
||||
? 'Status'
|
||||
: state.groupMode === 'repo'
|
||||
? 'Repo'
|
||||
: 'PR'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.toolbarSpacer} />
|
||||
|
||||
<Pressable
|
||||
style={styles.searchToggle}
|
||||
onPress={() => actions.navigateFromHostList(`/h/${hostId}/accounts`)}
|
||||
disabled={connState !== 'connected'}
|
||||
>
|
||||
<UserCircle
|
||||
size={16}
|
||||
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={styles.searchToggle}
|
||||
onPress={() => actions.navigateFromHostList(`/h/${hostId}/tasks`)}
|
||||
disabled={connState !== 'connected'}
|
||||
>
|
||||
<List
|
||||
size={16}
|
||||
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Pressable style={styles.searchToggle} onPress={() => state.setShowSearch((s) => !s)}>
|
||||
{state.showSearch ? (
|
||||
<X size={16} color={colors.textSecondary} />
|
||||
) : (
|
||||
<Search size={16} color={colors.textSecondary} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import { Check, Moon } from 'lucide-react-native'
|
||||
import { buildWorktreeNavigationActions } from '../agent-history/worktree-navigation-actions'
|
||||
import { ActionSheetContent } from '../components/ActionSheetModal'
|
||||
import { BottomDrawer } from '../components/BottomDrawer'
|
||||
import { ConfirmModal } from '../components/ConfirmModal'
|
||||
import { NewWorktreeModalController } from '../components/NewWorktreeModalController'
|
||||
import { PickerModal } from '../components/PickerModal'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { hostNewWorktreeSessionRoute } from '../host-route-action-state'
|
||||
import { getWorktreeRowIdentity } from '../worktree/worktree-host-row-identity'
|
||||
import {
|
||||
WORKSPACE_GROUP_OPTIONS as GROUP_OPTIONS,
|
||||
WORKSPACE_SORT_OPTIONS as SORT_OPTIONS
|
||||
} 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'
|
||||
|
||||
export function HostScreenOverlays({ controller }: { controller: HostScreenController }) {
|
||||
const {
|
||||
actions,
|
||||
catalog,
|
||||
client,
|
||||
existingWorktreePaths,
|
||||
hostCapabilities,
|
||||
hostId,
|
||||
settings,
|
||||
showNewWorktree,
|
||||
state
|
||||
} = controller
|
||||
const actionTarget = state.actionTarget
|
||||
|
||||
return (
|
||||
<>
|
||||
<PickerModal
|
||||
visible={state.showSortPicker}
|
||||
title="Sort By"
|
||||
options={SORT_OPTIONS}
|
||||
selected={state.sortMode}
|
||||
onSelect={settings.handleSortChange}
|
||||
onClose={() => state.setShowSortPicker(false)}
|
||||
/>
|
||||
|
||||
<PickerModal
|
||||
visible={state.showGroupPicker}
|
||||
title="Group By"
|
||||
options={GROUP_OPTIONS}
|
||||
selected={state.groupMode}
|
||||
onSelect={settings.handleGroupChange}
|
||||
onClose={() => state.setShowGroupPicker(false)}
|
||||
/>
|
||||
|
||||
<BottomDrawer visible={state.showFilterModal} onClose={() => state.setShowFilterModal(false)}>
|
||||
<View style={styles.filterModalHeader}>
|
||||
<Text style={styles.filterModalTitle}>Filter</Text>
|
||||
{settings.activeFilterCount > 0 && (
|
||||
<Pressable onPress={settings.clearFilters}>
|
||||
<Text style={styles.clearFiltersText}>Clear filters</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text style={styles.filterSectionLabel}>Workspaces</Text>
|
||||
<View style={styles.filterGroup}>
|
||||
<Pressable style={styles.filterRow} onPress={settings.toggleHideSleeping}>
|
||||
<Text style={styles.filterRowText}>Hide sleeping</Text>
|
||||
{state.filters.hideSleeping && <Check size={14} color={colors.textPrimary} />}
|
||||
</Pressable>
|
||||
<View style={styles.filterSeparator} />
|
||||
<Pressable style={styles.filterRow} onPress={settings.toggleHideDefaultBranch}>
|
||||
<Text style={styles.filterRowText}>Hide default branch</Text>
|
||||
{state.filters.hideDefaultBranch && <Check size={14} color={colors.textPrimary} />}
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{controller.sectionsResult.uniqueRepos.length > 1 && (
|
||||
<>
|
||||
<Text style={styles.filterSectionLabel}>Repositories</Text>
|
||||
<View style={styles.filterGroup}>
|
||||
{controller.sectionsResult.uniqueRepos.map((repo, i) => (
|
||||
<View key={repo.id}>
|
||||
{i > 0 && <View style={styles.filterSeparator} />}
|
||||
<Pressable
|
||||
style={styles.filterRow}
|
||||
onPress={() => settings.toggleRepoFilter(repo.id)}
|
||||
>
|
||||
<View style={[styles.filterRepoDot, { backgroundColor: repo.color }]} />
|
||||
<Text style={styles.filterRowText} numberOfLines={1}>
|
||||
{repo.name}
|
||||
</Text>
|
||||
{state.filters.filterRepoIds.has(repo.id) && (
|
||||
<Check size={14} color={colors.textPrimary} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</BottomDrawer>
|
||||
|
||||
{/* Worktree long-press action sheet (inline confirm to avoid double-Modal lag) */}
|
||||
<BottomDrawer
|
||||
visible={actionTarget != null}
|
||||
onClose={() => {
|
||||
state.setConfirmDelete(null)
|
||||
state.setActionTarget(null)
|
||||
}}
|
||||
>
|
||||
{state.confirmDelete ? (
|
||||
<View>
|
||||
<View style={styles.confirmContent}>
|
||||
<Text style={styles.confirmTitle}>Delete Worktree</Text>
|
||||
<Text style={styles.confirmMessage}>
|
||||
Delete "{state.confirmDelete.displayName || state.confirmDelete.repo}" (
|
||||
{state.confirmDelete.branch})?
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.confirmButtons}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.confirmBtn,
|
||||
styles.confirmBtnCancel,
|
||||
pressed && styles.confirmBtnPressed
|
||||
]}
|
||||
onPress={() => state.setConfirmDelete(null)}
|
||||
>
|
||||
<Text style={styles.confirmBtnCancelText}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.confirmBtn,
|
||||
styles.confirmBtnDestructive,
|
||||
pressed && styles.confirmBtnPressed
|
||||
]}
|
||||
onPress={() => {
|
||||
if (state.confirmDelete) {
|
||||
void actions.handleDeleteWorktree(state.confirmDelete)
|
||||
}
|
||||
state.setConfirmDelete(null)
|
||||
state.setActionTarget(null)
|
||||
}}
|
||||
>
|
||||
<Text style={styles.confirmBtnDestructiveText}>Delete</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<ActionSheetContent
|
||||
title={actionTarget ? actionTarget.displayName || actionTarget.repo : undefined}
|
||||
message={actionTarget?.branch}
|
||||
actions={
|
||||
actionTarget
|
||||
? [
|
||||
...buildWorktreeNavigationActions({
|
||||
hostId,
|
||||
worktreeId: actionTarget.worktreeId,
|
||||
worktreeName: actionTarget.displayName || actionTarget.repo,
|
||||
hostCapabilities,
|
||||
navigate: actions.navigateFromHostList,
|
||||
onDone: () => state.setActionTarget(null)
|
||||
}),
|
||||
{
|
||||
label: 'Sleep',
|
||||
icon: Moon,
|
||||
onPress: () => {
|
||||
if (client) {
|
||||
state.setSleptIds((prev) =>
|
||||
new Set(prev).add(getWorktreeRowIdentity(actionTarget))
|
||||
)
|
||||
void client
|
||||
.sendRequest('worktree.sleep', {
|
||||
worktree: `id:${actionTarget.worktreeId}`
|
||||
})
|
||||
.catch(() => null)
|
||||
}
|
||||
state.setActionTarget(null)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: isWorktreePinned(actionTarget, state.pinnedIds) ? 'Unpin' : 'Pin',
|
||||
onPress: () => {
|
||||
actions.togglePin(actionTarget.worktreeId)
|
||||
state.setActionTarget(null)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
destructive: true,
|
||||
onPress: () => state.setConfirmDelete(actionTarget)
|
||||
}
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</BottomDrawer>
|
||||
|
||||
{/* Host remove confirmation */}
|
||||
<ConfirmModal
|
||||
visible={state.confirmRemoveHost}
|
||||
title="Remove Host"
|
||||
message={`Remove "${state.hostName}"? You can re-pair later.`}
|
||||
confirmLabel="Remove"
|
||||
destructive
|
||||
onConfirm={() => void actions.handleRemoveHost()}
|
||||
onCancel={() => state.setConfirmRemoveHost(false)}
|
||||
/>
|
||||
|
||||
<NewWorktreeModalController
|
||||
ref={state.newWorktreeModalRef}
|
||||
routeVisible={showNewWorktree}
|
||||
client={client}
|
||||
hostId={hostId}
|
||||
existingWorktreePaths={existingWorktreePaths}
|
||||
existingWorktrees={state.worktrees}
|
||||
onVisibleChange={(visible) => {
|
||||
state.newWorktreeModalVisibleRef.current = visible
|
||||
}}
|
||||
onCreated={(worktreeId, worktreeName) => {
|
||||
void catalog.fetchWorktrees({ allowDuringModal: true })
|
||||
actions.navigateFromHostList(
|
||||
hostNewWorktreeSessionRoute(hostId, worktreeId, worktreeName)
|
||||
)
|
||||
}}
|
||||
onRouteVisibleChange={actions.setShowNewWorktreeVisible}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Text, View } from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
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'
|
||||
|
||||
export function HostScreenView({ controller }: { controller: HostScreenController }) {
|
||||
if (controller.state.error) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.errorText}>{controller.state.error}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<HostScreenHeader controller={controller} />
|
||||
<HostWorkspaceList controller={controller} />
|
||||
<HostScreenOverlays controller={controller} />
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { Pressable, RefreshControl, SectionList, Text, View } from 'react-native'
|
||||
import { ChevronDown, ChevronRight, Pin } from 'lucide-react-native'
|
||||
import { AuthFailedBanner } from '../components/AuthFailedBanner'
|
||||
import { HostDiagnosticsLink } from '../components/HostDiagnosticsLink'
|
||||
import { HostRouteNoticeBanner } from '../components/HostRouteNoticeBanner'
|
||||
import { MobileRepoIcon } from '../components/MobileRepoIcon'
|
||||
import { MobileSearchField } from '../components/MobileSearchField'
|
||||
import { NewWorkspaceFab, FAB_SIZE } from '../components/NewWorkspaceFab'
|
||||
import { WorktreeListRow } from '../components/WorktreeListRow'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
import { getWorktreeRowIdentity } from '../worktree/worktree-host-row-identity'
|
||||
import { HostWorkspaceListStates } from '../worktree/host-workspace-list-states'
|
||||
import { 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'
|
||||
|
||||
export function HostWorkspaceList({ controller }: { controller: HostScreenController }) {
|
||||
const {
|
||||
actions,
|
||||
activeWorktreeScroll,
|
||||
catalog,
|
||||
connState,
|
||||
contentMaxWidth,
|
||||
displayWorktrees,
|
||||
embedded,
|
||||
forceReconnectHost,
|
||||
hostId,
|
||||
insets,
|
||||
isReadOnly,
|
||||
isWideLayout,
|
||||
noticeParam,
|
||||
now,
|
||||
reconnectAttempts,
|
||||
relayRecovery,
|
||||
routeNotice,
|
||||
router,
|
||||
sectionsResult,
|
||||
setDismissedNotice,
|
||||
settings,
|
||||
state
|
||||
} = controller
|
||||
const { rawSections, sections, uniqueRepoColors } = sectionsResult
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Auth failed: a latched relay rejection must reach the same re-pair affordance. */}
|
||||
{(connState === 'auth-failed' || relayRecovery.pairingRejected) && (
|
||||
<AuthFailedBanner
|
||||
canRetry={!!hostId}
|
||||
onRetry={() => hostId && void forceReconnectHost(hostId)}
|
||||
onRepair={() => router.push('/pair-scan')}
|
||||
onRemove={() => state.setConfirmRemoveHost(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{connState !== 'connected' &&
|
||||
!relayRecovery.pairingRejected &&
|
||||
reconnectAttempts >= 3 &&
|
||||
hostId ? (
|
||||
<HostDiagnosticsLink
|
||||
onPress={() =>
|
||||
router.push({ pathname: '/connection-log', params: { hostId: String(hostId) } })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Why a bounced route landed here (e.g. the workspace was deleted on the desktop). */}
|
||||
{routeNotice && (
|
||||
<HostRouteNoticeBanner
|
||||
message={routeNotice}
|
||||
onDismiss={() => setDismissedNotice(noticeParam ?? null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Search bar */}
|
||||
{state.showSearch && (
|
||||
<View style={styles.searchBar}>
|
||||
<MobileSearchField
|
||||
value={state.search}
|
||||
onChangeText={state.setSearch}
|
||||
placeholder="Search worktrees…"
|
||||
autoFocus
|
||||
// Why: new key per open remounts the focus effect across rapid toggles so the keyboard reappears.
|
||||
focusKey={state.showSearch}
|
||||
accessibilityLabel="Search worktrees"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<HostWorkspaceListStates
|
||||
connState={connState}
|
||||
worktreesLoaded={state.worktreesLoaded}
|
||||
displayCount={displayWorktrees.length}
|
||||
sectionCount={sections.length}
|
||||
catalogError={state.catalogError}
|
||||
search={state.search}
|
||||
activeFilterCount={settings.activeFilterCount}
|
||||
/>
|
||||
|
||||
{sections.length > 0 && (
|
||||
<SectionList
|
||||
ref={activeWorktreeScroll.sectionListRef}
|
||||
sections={sections}
|
||||
keyExtractor={(w) => w.sectionListKey ?? getWorktreeRowIdentity(w)}
|
||||
stickySectionHeadersEnabled={false}
|
||||
// Why: keep the search IME up while tapping clear / scrolling results.
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
onScrollToIndexFailed={activeWorktreeScroll.onScrollToIndexFailed}
|
||||
// Why: edge-to-edge under the system nav bar; insets.bottom keeps the last row above it.
|
||||
contentContainerStyle={[
|
||||
styles.list,
|
||||
// Reserve room so the last row stays tappable above the phone's floating "+" (embedded uses the toolbar +).
|
||||
{ paddingBottom: (embedded ? spacing.lg : FAB_SIZE + spacing.xl) + insets.bottom },
|
||||
isWideLayout &&
|
||||
!embedded && { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' }
|
||||
]}
|
||||
renderSectionHeader={({ section }) => {
|
||||
if (!section.title) {
|
||||
return null
|
||||
}
|
||||
const isCollapsed = state.collapsedGroups.has(section.key)
|
||||
const rawSection = rawSections.find((s) => s.key === section.key)
|
||||
const count = rawSection?.data.length ?? 0
|
||||
const repoSectionColor =
|
||||
state.groupMode === 'repo' ? uniqueRepoColors.get(section.title) : null
|
||||
const repoSectionIcon =
|
||||
state.groupMode === 'repo' ? state.repoIconsByName.get(section.title) : null
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.sectionHeader}
|
||||
onPress={() => settings.toggleCollapsed(section.key)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight size={12} color={colors.textMuted} style={styles.sectionIcon} />
|
||||
) : (
|
||||
<ChevronDown size={12} color={colors.textMuted} style={styles.sectionIcon} />
|
||||
)}
|
||||
{section.icon === 'pin' && (
|
||||
<Pin size={12} color={colors.textMuted} style={styles.sectionIcon} />
|
||||
)}
|
||||
{state.groupMode === 'repo' ? (
|
||||
<View style={styles.sectionRepoIcon}>
|
||||
<MobileRepoIcon
|
||||
repoIcon={repoSectionIcon}
|
||||
size={14}
|
||||
color={repoSectionColor ?? colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||
<Text style={styles.sectionCount}>{count}</Text>
|
||||
</Pressable>
|
||||
)
|
||||
}}
|
||||
ItemSeparatorComponent={ListSeparator}
|
||||
// Why (#8498): manual pull-to-refresh forces a fresh snapshot after a stale-cache reconnect.
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={catalog.refreshing}
|
||||
onRefresh={catalog.onRefresh}
|
||||
tintColor={colors.textSecondary}
|
||||
colors={[colors.textSecondary]}
|
||||
/>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<WorktreeListRow
|
||||
item={item}
|
||||
isReadOnly={isReadOnly}
|
||||
now={now}
|
||||
status={getWorktreeStatus(item)}
|
||||
repoColor={uniqueRepoColors.get(item.repo) ?? repoColor(item.repo)}
|
||||
repoIcon={state.repoIconsByName.get(item.repo) ?? null}
|
||||
hideRepo={state.groupMode === 'repo'}
|
||||
onPress={actions.openWorktreeSession}
|
||||
onLongPress={
|
||||
item.workspaceKind === 'folder-workspace' ? undefined : state.setActionTarget
|
||||
}
|
||||
onToggleLineage={settings.toggleWorktreeLineage}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Floating "new workspace" button — phone only; embedded sidebars keep the toolbar +. */}
|
||||
{!embedded && (
|
||||
<NewWorkspaceFab
|
||||
onPress={actions.openNewWorktreeModal}
|
||||
disabled={connState !== 'connected'}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ListSeparator() {
|
||||
return <View style={styles.separator} />
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useCallback } from 'react'
|
||||
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 type { HostScreenState } from './use-host-screen-state'
|
||||
|
||||
const REPO_METADATA_REFRESH_MS = 60_000
|
||||
|
||||
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,
|
||||
setRepoColorsByName,
|
||||
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])))
|
||||
} 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>
|
||||
@@ -0,0 +1,158 @@
|
||||
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 { 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'
|
||||
|
||||
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 applyWorktreeRowDisplayState(
|
||||
base,
|
||||
state.sleptIds,
|
||||
state.optimisticActiveWorktreeIdentity
|
||||
)
|
||||
}, [
|
||||
connState,
|
||||
state.worktrees,
|
||||
state.lastKnownWorktrees,
|
||||
state.sleptIds,
|
||||
state.optimisticActiveWorktreeIdentity
|
||||
])
|
||||
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
|
||||
})
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
export type HostScreenController = ReturnType<typeof useHostScreenController>
|
||||
@@ -0,0 +1,90 @@
|
||||
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,
|
||||
setHostName,
|
||||
setLastKnownWorktrees,
|
||||
setPinnedIds,
|
||||
setRepoColorsByName,
|
||||
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())
|
||||
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])
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useRef, useState } from 'react'
|
||||
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())
|
||||
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,
|
||||
hostName,
|
||||
lastKnownWorktrees,
|
||||
newWorktreeModalRef,
|
||||
newWorktreeModalVisibleRef,
|
||||
optimisticActiveWorktreeIdentity,
|
||||
pinnedIds,
|
||||
repoColorsByName,
|
||||
repoIconsByName,
|
||||
repoIdsByName,
|
||||
repoMetadataFetchedAtRef,
|
||||
routeActionState,
|
||||
search,
|
||||
setActionTarget,
|
||||
setCatalogError,
|
||||
setCollapsedGroups,
|
||||
setConfirmDelete,
|
||||
setConfirmRemoveHost,
|
||||
setError,
|
||||
setFilters,
|
||||
setGroupMode,
|
||||
setHostName,
|
||||
setLastKnownWorktrees,
|
||||
setOptimisticActiveWorktreeIdentity,
|
||||
setPinnedIds,
|
||||
setRepoColorsByName,
|
||||
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>
|
||||
@@ -0,0 +1,194 @@
|
||||
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>
|
||||
@@ -0,0 +1,212 @@
|
||||
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 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 {
|
||||
await removeHostAndCloseClient(hostId, 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>
|
||||
@@ -0,0 +1,174 @@
|
||||
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 { 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({ 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({
|
||||
client,
|
||||
connState,
|
||||
fetchWorktrees,
|
||||
fetchRepoMetadata
|
||||
})
|
||||
|
||||
return { fetchWorktrees, onRefresh, refreshing }
|
||||
}
|
||||
|
||||
export type HostWorktreeCatalog = ReturnType<typeof useHostWorktreeCatalog>
|
||||
@@ -1,7 +1,10 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(new URL('../../app/h/[hostId]/index.tsx', import.meta.url), 'utf8')
|
||||
const source = readFileSync(
|
||||
new URL('../host-screen/use-host-worktree-actions.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function sliceBetween(startPattern: string, endPattern: string): string {
|
||||
const start = source.indexOf(startPattern)
|
||||
@@ -15,7 +18,7 @@ describe('mobile worktree activation', () => {
|
||||
it('opens mobile sessions without foregrounding other paired clients', () => {
|
||||
const openSession = sliceBetween(
|
||||
'const openWorktreeSession = useCallback(',
|
||||
'const handleSortChange = useCallback'
|
||||
'const openFloatingWorkspace = useCallback'
|
||||
)
|
||||
|
||||
expect(openSession).toContain("sendRequest('worktree.activate'")
|
||||
|
||||
Reference in New Issue
Block a user