import { memo, useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native"; import { NestableScrollContainer } from "react-native-draggable-flatlist"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import { buildStatusGroups, buildStatusShortcutIndex, type StatusGroup, } from "@/hooks/sidebar-status-view-model"; import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform"; import { StyleSheet } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; import { withUnistyles } from "react-native-unistyles"; import { ChevronDown, ChevronRight, CircleAlert, CircleCheck, CircleDot, CircleX, MoreVertical, Copy, Archive, Pencil, } from "lucide-react-native"; import { DiffStat } from "@/components/diff-stat"; import { useSidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, } from "@/components/ui/dropdown-menu"; import { useToast } from "@/contexts/toast-context"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug"; import { AdaptiveRenameModal } from "@/components/rename-modal"; import { requireWorkspaceExecutionDirectory, resolveWorkspaceExecutionDirectory, } from "@/utils/workspace-execution"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning"; import { confirmDialog } from "@/utils/confirm-dialog"; import * as Clipboard from "expo-clipboard"; import { Shortcut } from "@/components/ui/shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention"; import { SidebarWorkspaceRowFrame, SidebarWorkspaceRowContent, SidebarWorkspaceTrailingActionBase, SidebarWorkspaceTrailingActionOverlay, SidebarWorkspaceTrailingActionSlot, } from "@/components/sidebar/sidebar-workspace-row-content"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; // Themed icon wrappers const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); const blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] }); const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] }); const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] }); const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] }); const ThemedChevronDown = withUnistyles(ChevronDown); const ThemedChevronRight = withUnistyles(ChevronRight); const ThemedCircleAlert = withUnistyles(CircleAlert); const ThemedCircleCheck = withUnistyles(CircleCheck); const ThemedCircleDot = withUnistyles(CircleDot); const ThemedCircleX = withUnistyles(CircleX); const ThemedMoreVertical = withUnistyles(MoreVertical); const ThemedCopy = withUnistyles(Copy); const ThemedArchive = withUnistyles(Archive); const ThemedPencil = withUnistyles(Pencil); const copyLeadingIcon = ; const markAsReadLeadingIcon = ( ); const archiveLeadingIcon = ; const renameLeadingIcon = ; interface StatusWorkspaceListProps { workspaces: SidebarWorkspaceEntry[]; projectNamesByKey: Map; serverId: string | null; shortcutIndexByWorkspaceKey: Map; showShortcutBadges: boolean; onWorkspacePress?: () => void; } export function SidebarStatusWorkspaceList({ workspaces, projectNamesByKey, serverId, shortcutIndexByWorkspaceKey: _projectShortcutIndex, showShortcutBadges, onWorkspacePress, }: StatusWorkspaceListProps) { const groups = useMemo( () => buildStatusGroups(workspaces, projectNamesByKey), [workspaces, projectNamesByKey], ); const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore( (state) => state.collapsedStatusGroupKeys, ); const statusShortcutIndex = useMemo( () => showShortcutBadges ? buildStatusShortcutIndex( groups.filter((group) => !collapsedStatusGroupKeys.has(group.bucket)), ) : new Map(), [collapsedStatusGroupKeys, groups, showShortcutBadges], ); return ( {platformIsNative ? ( ) : ( )} ); } function StatusGroupList({ groups, collapsedStatusGroupKeys, projectNamesByKey, serverId, shortcutIndex, showShortcutBadges, onWorkspacePress, }: { groups: StatusGroup[]; collapsedStatusGroupKeys: ReadonlySet; projectNamesByKey: Map; serverId: string | null; shortcutIndex: Map; showShortcutBadges: boolean; onWorkspacePress?: () => void; }) { return ( <> {groups.map((group) => ( {!collapsedStatusGroupKeys.has(group.bucket) ? ( {group.rows.map((workspace) => ( ))} ) : null} ))} ); } function StatusGroupHeader({ group, collapsed }: { group: StatusGroup; collapsed: boolean }) { const [isHovered, setIsHovered] = useState(false); const toggleStatusGroupCollapsed = useSidebarCollapsedSectionsStore( (state) => state.toggleStatusGroupCollapsed, ); const handlePress = useCallback(() => { toggleStatusGroupCollapsed(group.bucket); }, [group.bucket, toggleStatusGroupCollapsed]); const handleHoverIn = useCallback(() => setIsHovered(true), []); const handleHoverOut = useCallback(() => setIsHovered(false), []); const rowStyle = useCallback( ({ pressed }: PressableStateCallbackType) => [ styles.statusGroupRow, isHovered && styles.statusGroupRowHovered, pressed && styles.statusGroupRowPressed, ], [isHovered], ); const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]); return ( {group.label} ); } function StatusGroupLeadingVisual({ bucket, collapsed, showChevron, }: { bucket: StatusGroup["bucket"]; collapsed: boolean; showChevron: boolean; }) { if (!showChevron) { return ; } if (collapsed) { return ; } return ; } function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) { switch (bucket) { case "needs_input": return ; case "failed": return ; case "attention": return ; case "running": return ; case "done": return ; } } const StatusWorkspaceRow = memo(function StatusWorkspaceRow({ workspace, projectName, serverId, shortcutNumber, showShortcutBadge, onWorkspacePress, }: { workspace: SidebarWorkspaceEntry; projectName: string; serverId: string | null; shortcutNumber: number | null; showShortcutBadge: boolean; onWorkspacePress?: () => void; }) { const hydratedWorkspace = useSidebarWorkspaceEntry(serverId, workspace.workspaceId); const activeWorkspaceSelection = useActiveWorkspaceSelection(); const selected = activeWorkspaceSelection?.serverId === workspace.serverId && activeWorkspaceSelection?.workspaceId === workspace.workspaceId; const handlePress = useCallback(() => { if (!serverId) return; onWorkspacePress?.(); navigateToWorkspace(serverId, workspace.workspaceId); }, [serverId, onWorkspacePress, workspace.workspaceId]); if (!hydratedWorkspace) return null; return ( ); }); function StatusWorkspaceRowWithMenu({ workspace, projectName, selected, shortcutNumber, showShortcutBadge, onPress, }: { workspace: SidebarWorkspaceEntry; projectName: string; selected: boolean; shortcutNumber: number | null; showShortcutBadge: boolean; onPress: () => void; }) { const { t } = useTranslation(); const toast = useToast(); const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); const queryClient = useQueryClient(); const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); const [isRenameOpen, setIsRenameOpen] = useState(false); const workspaceDirectory = resolveWorkspaceExecutionDirectory({ workspaceDirectory: workspace.workspaceDirectory, }); const archiveStatus = useCheckoutGitActionsStore((state) => workspaceDirectory ? state.getStatus({ serverId: workspace.serverId, cwd: workspaceDirectory, actionId: "archive-worktree", }) : "idle", ); const isWorktree = workspace.workspaceKind === "worktree"; const isArchiving = isWorktree ? workspace.archivingAt !== null : isArchivingWorkspace; const redirectAfterArchive = useCallback(() => { redirectIfArchivingActiveWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId, activeWorkspaceSelection: selected ? { serverId: workspace.serverId, workspaceId: workspace.workspaceId } : null, }); }, [selected, workspace]); const archiveWorktreeAfterConfirmation = useCallback(async () => { if (isArchiving) return; const confirmed = await confirmRiskyWorktreeArchive({ worktreeName: workspace.name, isDirty: workspace.archiveHasUncommittedChanges, aheadOfOrigin: workspace.archiveUnpushedCommitCount, diffStat: workspace.diffStat, }); if (!confirmed) return; let archiveDirectory: string; try { archiveDirectory = requireWorkspaceExecutionDirectory({ workspaceId: workspace.workspaceId, workspaceDirectory: workspace.workspaceDirectory, }); } catch (error) { toast.error(error instanceof Error ? error.message : "Workspace path not available"); return; } redirectAfterArchive(); void archiveWorktree({ serverId: workspace.serverId, cwd: archiveDirectory, worktreePath: archiveDirectory, }).catch((error) => { toast.error(error instanceof Error ? error.message : "Failed to archive worktree"); }); }, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]); const hideWorkspaceAfterConfirmation = useCallback(async () => { if (isArchivingWorkspace) return; const confirmed = await confirmDialog({ title: "Hide workspace?", message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`, confirmLabel: "Hide", cancelLabel: "Cancel", destructive: true, }); if (!confirmed) return; const client = getHostRuntimeStore().getClient(workspace.serverId); if (!client) { toast.error(t("workspace.terminal.hostDisconnected")); return; } setIsArchivingWorkspace(true); try { await archiveWorkspaceOptimistically({ client, workspace, afterHide: redirectAfterArchive, }); } catch (error) { toast.error(error instanceof Error ? error.message : "Failed to hide workspace"); } finally { setIsArchivingWorkspace(false); } }, [isArchivingWorkspace, redirectAfterArchive, t, toast, workspace]); const handleCopyPath = useCallback(() => { let copyTargetDirectory: string; try { copyTargetDirectory = requireWorkspaceExecutionDirectory({ workspaceId: workspace.workspaceId, workspaceDirectory: workspace.workspaceDirectory, }); } catch (error) { toast.error(error instanceof Error ? error.message : "Workspace path not available"); return; } void Clipboard.setStringAsync(copyTargetDirectory); toast.copied("Path copied"); }, [toast, workspace.workspaceDirectory, workspace.workspaceId]); const handleCopyBranchName = useCallback(() => { void Clipboard.setStringAsync(workspace.name); toast.copied("Branch name copied"); }, [toast, workspace.name]); const renameMutation = useMutation({ mutationFn: async (branch: string) => { const client = getHostRuntimeStore().getClient(workspace.serverId); if (!client) throw new Error(t("workspace.terminal.hostDisconnected")); const targetCwd = requireWorkspaceExecutionDirectory({ workspaceId: workspace.workspaceId, workspaceDirectory: workspace.workspaceDirectory, }); const payload = await client.renameBranch({ cwd: targetCwd, branch }); if (!payload.success || payload.error) { throw new Error(payload.error?.message ?? "Failed to rename branch"); } return { targetCwd }; }, onSuccess: async ({ targetCwd }) => { await invalidateCheckoutGitQueriesForClient(queryClient, { serverId: workspace.serverId, cwd: targetCwd, }); }, }); const handleOpenRename = useCallback(() => setIsRenameOpen(true), []); const handleCloseRename = useCallback(() => setIsRenameOpen(false), []); const handleSubmitRename = useCallback( async (value: string) => { await renameMutation.mutateAsync(slugify(value)); }, [renameMutation], ); const validateRenameSlug = useCallback((value: string): string | null => { const result = validateBranchSlug(slugify(value)); if (result.valid) return null; return result.error ?? "Invalid branch name"; }, []); const archiveShortcutKeys = useShortcutKeys("archive-worktree"); const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({ serverId: workspace.serverId, workspaceId: workspace.workspaceId, }); const handleMarkAsRead = useCallback(() => { void clearAttention().catch((error) => { toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read"); }); }, [clearAttention, toast]); useKeyboardActionHandler({ handlerId: `worktree-archive-${workspace.workspaceKey}`, actions: ["worktree.archive"], enabled: selected && !isArchiving, priority: 0, handle: () => { if (isWorktree) { void archiveWorktreeAfterConfirmation(); } else { void hideWorkspaceAfterConfirmation(); } return true; }, }); let computedArchiveStatus: "idle" | "pending" | "success" = "idle"; if (isWorktree) { computedArchiveStatus = archiveStatus; } else if (isArchivingWorkspace) { computedArchiveStatus = "pending"; } return ( <> ); } function StatusWorkspaceRowInner({ workspace, projectName, selected, shortcutNumber, showShortcutBadge, onPress, isArchiving, archiveLabel, archiveStatus = "idle", archivePendingLabel, onArchive, onCopyBranchName, onCopyPath, onRename, onMarkAsRead, archiveShortcutKeys, }: { workspace: SidebarWorkspaceEntry; projectName: string; selected: boolean; shortcutNumber: number | null; showShortcutBadge: boolean; onPress: () => void; isArchiving: boolean; archiveLabel?: string; archiveStatus?: "idle" | "pending" | "success"; archivePendingLabel?: string; onArchive?: () => void; onCopyBranchName?: () => void; onCopyPath?: () => void; onRename?: () => void; onMarkAsRead?: () => void; archiveShortcutKeys?: ShortcutKey[][] | null; }) { const isTouchPlatform = platformIsNative; const isDesktop = !isTouchPlatform; const showScriptsIcon = isDesktop && workspace.hasRunningScripts; const hasRunningService = workspace.scripts.some( (s) => s.lifecycle === "running" && (s.type ?? "service") === "service", ); let scriptIconKind: "service" | "command" | null = null; if (showScriptsIcon) { scriptIconKind = hasRunningService ? "service" : "command"; } const accessibilityState = useMemo(() => ({ selected }), [selected]); return ( {({ isHovered, hoverHandlers }) => { const showShortcut = showShortcutBadge && shortcutNumber !== null; const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform)); const showKebabInSlot = showKebab && !showShortcut; const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat); const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered }); return ( {shouldRenderActionSlot ? ( ) : null} ); }} ); } function StatusWorkspaceActionSlot({ workspace, showBase, showOverlay, onCopyPath, onCopyBranchName, onRename, onMarkAsRead, onArchive, archiveLabel, archiveStatus, archivePendingLabel, archiveShortcutKeys, }: { workspace: SidebarWorkspaceEntry; showBase: boolean; showOverlay: boolean; onCopyPath?: () => void; onCopyBranchName?: () => void; onRename?: () => void; onMarkAsRead?: () => void; onArchive?: () => void; archiveLabel?: string; archiveStatus?: "idle" | "pending" | "success"; archivePendingLabel?: string; archiveShortcutKeys?: ShortcutKey[][] | null; }) { return ( {workspace.diffStat ? ( ) : null} {onArchive ? ( ) : null} ); } function StatusKebabMenu({ workspaceKey, onCopyPath, onCopyBranchName, onRename, onMarkAsRead, onArchive, archiveLabel, archiveStatus, archivePendingLabel, archiveShortcutKeys, }: { workspaceKey: string; onCopyPath?: () => void; onCopyBranchName?: () => void; onRename?: () => void; onMarkAsRead?: () => void; onArchive: () => void; archiveLabel?: string; archiveStatus?: "idle" | "pending" | "success"; archivePendingLabel?: string; archiveShortcutKeys?: ShortcutKey[][] | null; }) { const archiveTrailing = useMemo( () => (archiveShortcutKeys ? : null), [archiveShortcutKeys], ); return ( {({ hovered }: { hovered?: boolean }) => ( )} {onCopyPath ? ( Copy path ) : null} {onCopyBranchName ? ( Copy branch name ) : null} {onRename ? ( Rename workspace ) : null} {onMarkAsRead ? ( Mark as read ) : null} {archiveLabel ?? "Archive"} ); } function kebabStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) { return [styles.kebabButton, hovered && styles.kebabButtonHovered]; } function getStatusWorkspaceRowStyle({ selected, isHovered, }: { selected: boolean; isHovered: boolean; }) { return [ styles.workspaceRow, selected && styles.sidebarRowSelected, isHovered && styles.workspaceRowHovered, ]; } const styles = StyleSheet.create((theme) => ({ container: { flex: 1, }, list: { flex: 1, }, listContent: { paddingHorizontal: theme.spacing[2], paddingTop: theme.spacing[2], paddingBottom: theme.spacing[4], }, statusGroupBlock: { marginBottom: theme.spacing[1], }, statusWorkspaceListContainer: {}, statusGroupRow: { minHeight: 36, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, marginBottom: theme.spacing[2], flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: theme.spacing[2], userSelect: "none", }, statusGroupRowHovered: { backgroundColor: theme.colors.surfaceSidebarHover, }, statusGroupRowPressed: { backgroundColor: theme.colors.surface2, }, statusGroupRowLeft: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], flex: 1, minWidth: 0, }, statusGroupLeadingVisualSlot: { position: "relative", width: theme.iconSize.md, height: theme.iconSize.md, flexShrink: 0, alignItems: "center", justifyContent: "center", }, statusGroupTitleGroup: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], flex: 1, minWidth: 0, }, statusGroupTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: "400", minWidth: 0, flexShrink: 1, }, workspaceRowContainer: { position: "relative", }, workspaceRow: { minHeight: 36, marginBottom: theme.spacing[1], paddingVertical: theme.spacing[2], paddingLeft: theme.spacing[3] + theme.spacing[3], paddingRight: theme.spacing[3], borderRadius: theme.borderRadius.lg, flexDirection: "column", alignItems: "stretch", justifyContent: "flex-start", gap: theme.spacing[1], userSelect: "none", }, workspaceRowHovered: { backgroundColor: theme.colors.surfaceSidebarHover, }, workspaceRowPressed: { backgroundColor: theme.colors.surface2, }, sidebarRowSelected: { backgroundColor: theme.colors.surfaceSidebarHover, }, kebabButton: { padding: 2, borderRadius: 4, marginLeft: 2, }, kebabButtonHovered: { backgroundColor: theme.colors.surface2, }, }));