feat: add worktree archiving with context menu and improved logging

This commit is contained in:
Mohamed Boudra
2026-03-03 17:35:48 +07:00
parent f418f3ab02
commit 888af29485
11 changed files with 529 additions and 24 deletions
+3 -1
View File
@@ -98,6 +98,7 @@ function AgentListRow({
const timeAgo = formatTimeAgo(agent.lastActivityAt);
const agentKey = `${agent.serverId}:${agent.id}`;
const isSelected = selectedAgentId === agentKey;
const archivedLabel = agent.archivedAt ? "Archived" : null;
const checkoutQuery = useCheckoutStatusCacheOnly({
serverId: agent.serverId,
cwd: agent.cwd,
@@ -137,7 +138,8 @@ function AgentListRow({
<Text style={styles.secondaryRow} numberOfLines={1}>
{shortenPath(projectPath)}
{branchLabel ? ` · ${branchLabel}` : ""} · {timeAgo}
{branchLabel ? ` · ${branchLabel}` : ""}
{archivedLabel ? ` · ${archivedLabel}` : ""} · {timeAgo}
</Text>
</View>
)}
@@ -1,4 +1,4 @@
import { View, Text, Pressable, Image, Platform } from 'react-native'
import { View, Text, Pressable, Image, Platform, Alert, ActivityIndicator, StatusBar } from 'react-native'
import { useQueries } from '@tanstack/react-query'
import {
useCallback,
@@ -11,8 +11,8 @@ import {
} from 'react'
import { router, usePathname, useSegments } from 'expo-router'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { type GestureType } from 'react-native-gesture-handler'
import { ChevronDown, ChevronRight } from 'lucide-react-native'
import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'
import { Archive, ChevronDown, ChevronRight } from 'lucide-react-native'
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
@@ -27,8 +27,17 @@ import {
type SidebarWorkspaceEntry,
} from '@/hooks/use-sidebar-agents-list'
import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store'
import { formatTimeAgo } from '@/utils/time'
import type { SidebarStateBucket } from '@/utils/sidebar-agent-state'
import { confirmDialog } from '@/utils/confirm-dialog'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
useContextMenu,
} from '@/components/ui/context-menu'
type SidebarTreeRow =
| {
@@ -76,7 +85,7 @@ interface WorkspaceRowProps {
workspace: SidebarWorkspaceEntry
selected: boolean
onPress: () => void
onLongPress: () => void
drag: () => void
}
function deriveProjectDisplayName(input: { projectKey: string; projectName: string }): string {
@@ -135,10 +144,25 @@ function resolveStatusDotColor(input: { theme: ReturnType<typeof useUnistyles>['
: theme.colors.border
}
function WorkspaceStatusDot({ bucket }: { bucket: SidebarWorkspaceEntry['statusBucket'] }) {
function WorkspaceStatusIndicator({
bucket,
loading = false,
}: {
bucket: SidebarWorkspaceEntry['statusBucket']
loading?: boolean
}) {
const { theme } = useUnistyles()
const color = resolveStatusDotColor({ theme, bucket })
return <View style={[styles.workspaceStatusDot, { backgroundColor: color }]} />
return (
<View style={styles.workspaceStatusDot}>
{loading ? (
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
) : (
<View style={[styles.workspaceStatusDotFill, { backgroundColor: color }]} />
)}
</View>
)
}
function ProjectRow({
@@ -201,10 +225,71 @@ function ProjectRow({
)
}
function WorkspaceRow({ workspace, selected, onPress, onLongPress }: WorkspaceRowProps) {
const didLongPressRef = useRef(false)
function WorkspaceRow({ workspace, selected, onPress, drag }: WorkspaceRowProps) {
const { theme } = useUnistyles()
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
const { setAnchorRect, setOpen } = useContextMenu()
const didLongPressRef = useRef(false)
const didLongPressCleanupTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const longPressArmedRef = useRef(false)
const longPressCancelledRef = useRef(false)
const didStartDragRef = useRef(false)
const touchStartRef = useRef<{ x: number; y: number } | null>(null)
const archiveStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({
serverId: workspace.serverId,
cwd: workspace.cwd,
actionId: 'archive-worktree',
})
)
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree)
const isArchiving = archiveStatus === 'pending'
const openContextMenuAtTouchStart = useCallback(() => {
const point = touchStartRef.current
if (!point) {
return
}
const statusBarHeight = Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) : 0
setAnchorRect({
x: point.x,
y: point.y + statusBarHeight,
width: 0,
height: 0,
})
setOpen(true)
}, [setAnchorRect, setOpen])
const handleArchiveWorktree = useCallback(() => {
if (!workspace.isPaseoOwnedWorktree) {
return
}
void confirmDialog({
title: 'Archive worktree?',
message: `Archive this worktree?\n\n${workspace.cwd}`,
confirmLabel: 'Archive',
cancelLabel: 'Cancel',
destructive: true,
})
.then((confirmed) => {
if (!confirmed) {
return
}
return runArchiveWorktree({
serverId: workspace.serverId,
cwd: workspace.cwd,
worktreePath: workspace.cwd,
})
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to archive worktree'
Alert.alert('Error', message)
})
}, [runArchiveWorktree, workspace.cwd, workspace.isPaseoOwnedWorktree, workspace.serverId])
const handlePress = useCallback(() => {
if (didLongPressRef.current) {
didLongPressRef.current = false
@@ -214,25 +299,115 @@ function WorkspaceRow({ workspace, selected, onPress, onLongPress }: WorkspaceRo
}, [onPress])
const handleLongPress = useCallback(() => {
if (longPressCancelledRef.current) {
return
}
didLongPressRef.current = true
onLongPress()
}, [onLongPress])
longPressArmedRef.current = true
}, [])
return (
<Pressable
const moveMonitorGesture = useMemo(() => {
if (Platform.OS === 'web') {
return null
}
const CANCEL_SLOP_PX = 10
const DRAG_SLOP_PX = 8
return Gesture.Pan()
.manualActivation(true)
.runOnJS(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0]
if (!touch) {
return
}
touchStartRef.current = { x: touch.absoluteX, y: touch.absoluteY }
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0]
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail()
return
}
const start = touchStartRef.current
if (!start) {
touchStartRef.current = { x: touch.absoluteX, y: touch.absoluteY }
return
}
const dx = touch.absoluteX - start.x
const dy = touch.absoluteY - start.y
const distance = Math.sqrt(dx * dx + dy * dy)
if (!longPressArmedRef.current) {
if (distance > CANCEL_SLOP_PX) {
longPressCancelledRef.current = true
stateManager.fail()
}
return
}
if (didStartDragRef.current) {
return
}
if (distance > DRAG_SLOP_PX) {
didStartDragRef.current = true
drag()
stateManager.fail()
}
})
}, [drag])
const trigger = (
<ContextMenuTrigger
enabledOnMobile={false}
style={({ pressed, hovered = false }) => [
styles.workspaceRow,
selected && styles.workspaceRowSelected,
hovered && styles.workspaceRowHovered,
pressed && styles.workspaceRowPressed,
]}
onPressIn={(event) => {
if (didLongPressCleanupTimerRef.current) {
clearTimeout(didLongPressCleanupTimerRef.current)
didLongPressCleanupTimerRef.current = null
}
longPressCancelledRef.current = false
longPressArmedRef.current = false
didStartDragRef.current = false
touchStartRef.current = { x: event.nativeEvent.pageX, y: event.nativeEvent.pageY }
}}
onPressOut={() => {
if (Platform.OS === 'web') {
return
}
if (!longPressArmedRef.current || didStartDragRef.current) {
longPressCancelledRef.current = false
longPressArmedRef.current = false
didStartDragRef.current = false
touchStartRef.current = null
return
}
openContextMenuAtTouchStart()
didLongPressCleanupTimerRef.current = setTimeout(() => {
didLongPressRef.current = false
didLongPressCleanupTimerRef.current = null
}, 0)
longPressCancelledRef.current = false
longPressArmedRef.current = false
didStartDragRef.current = false
touchStartRef.current = null
}}
onPress={handlePress}
onLongPress={handleLongPress}
delayLongPress={200}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<View style={styles.workspaceRowLeft}>
<WorkspaceStatusDot bucket={workspace.statusBucket} />
<WorkspaceStatusIndicator bucket={workspace.statusBucket} loading={isArchiving} />
<Text style={styles.workspaceBranchText} numberOfLines={1}>
{resolveWorkspaceBranchLabel(workspace)}
</Text>
@@ -242,7 +417,54 @@ function WorkspaceRow({ workspace, selected, onPress, onLongPress }: WorkspaceRo
{createdAtLabel}
</Text>
) : null}
</Pressable>
</ContextMenuTrigger>
)
return (
<>
{moveMonitorGesture ? (
<GestureDetector gesture={moveMonitorGesture}>{trigger}</GestureDetector>
) : (
trigger
)}
<ContextMenuContent
align="start"
width={220}
testID={`sidebar-workspace-context-${workspace.workspaceKey}`}
>
<ContextMenuItem
leading={<Archive size={16} color={theme.colors.foregroundMuted} />}
destructive
disabled={!workspace.isPaseoOwnedWorktree}
status={workspace.isPaseoOwnedWorktree ? archiveStatus : 'idle'}
pendingLabel="Archiving…"
successLabel="Archived"
onSelect={handleArchiveWorktree}
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
>
Archive worktree
</ContextMenuItem>
</ContextMenuContent>
</>
)
}
function WorkspaceRowWithMenu({
workspace,
selected,
onPress,
drag,
}: {
workspace: SidebarWorkspaceEntry
selected: boolean
onPress: () => void
drag: () => void
}) {
return (
<ContextMenu>
<WorkspaceRow workspace={workspace} selected={selected} onPress={onPress} drag={drag} />
</ContextMenu>
)
}
@@ -452,7 +674,7 @@ export function SidebarAgentList({
activeWorkspaceSelection.workspaceId === item.workspace.cwd
return (
<WorkspaceRow
<WorkspaceRowWithMenu
workspace={item.workspace}
selected={isSelected}
onPress={() => {
@@ -462,7 +684,7 @@ export function SidebarAgentList({
onWorkspacePress?.()
navigate(workspaceRoute as any)
}}
onLongPress={drag}
drag={drag}
/>
)
},
@@ -671,6 +893,14 @@ const styles = StyleSheet.create((theme) => ({
height: 8,
borderRadius: theme.borderRadius.full,
flexShrink: 0,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
workspaceStatusDotFill: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
},
workspaceBranchText: {
color: theme.colors.foreground,
@@ -58,6 +58,10 @@ function useContextMenuContext(componentName: string): ContextMenuContextValue {
return ctx;
}
export function useContextMenu(): ContextMenuContextValue {
return useContextMenuContext("useContextMenu");
}
function useControllableOpenState({
open,
defaultOpen,
@@ -55,6 +55,9 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
}
const serverLabel = serverLabelById.get(serverId) ?? serverId;
for (const agent of agents.values()) {
if (agent.archivedAt) {
continue;
}
const nextAgent: AggregatedAgent = {
id: agent.id,
serverId,
@@ -74,9 +74,6 @@ export function useAllAgentsList(options?: {
serverId,
serverLabel,
});
if (aggregated.archivedAt) {
continue;
}
if (aggregated.labels.ui !== "true") {
continue;
}
+1 -1
View File
@@ -1113,7 +1113,7 @@ export class HostRuntimeStore {
controller.markAgentDirectorySyncLoading();
try {
const payload = await client.fetchAgents({
filter: input.filter ?? { labels: { ui: "true" } },
filter: input.filter ?? { labels: { ui: "true" }, includeArchived: true },
...(input.subscribe ? { subscribe: input.subscribe } : {}),
...(input.page ? { page: input.page } : {}),
});
@@ -86,6 +86,49 @@ function invalidateWorktreeList() {
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList",
});
void queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === "sidebarPaseoWorktreeList",
});
}
function removeWorktreeFromCachedLists(input: {
serverId: string;
worktreePath: string;
}): void {
const serverId = input.serverId.trim();
const worktreePath = input.worktreePath.trim();
if (!serverId || !worktreePath) {
return;
}
const removeFromList = (current: unknown) => {
if (!Array.isArray(current)) {
return current;
}
const filtered = current.filter((entry) => entry?.worktreePath !== worktreePath);
return filtered.length === current.length ? current : filtered;
};
queryClient.setQueriesData(
{
predicate: (query) =>
Array.isArray(query.queryKey) &&
query.queryKey[0] === "paseoWorktreeList" &&
query.queryKey[1] === serverId,
},
removeFromList
);
queryClient.setQueriesData(
{
predicate: (query) =>
Array.isArray(query.queryKey) &&
query.queryKey[0] === "sidebarPaseoWorktreeList" &&
query.queryKey[1] === serverId,
},
removeFromList
);
}
const successTimers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -267,6 +310,7 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
if (payload.error) {
throw new Error(payload.error.message);
}
removeWorktreeFromCachedLists({ serverId, worktreePath });
invalidateWorktreeList();
},
});