From eb618638d24a4a25ee412cb6d5463cd09cf79025 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 21 Mar 2026 22:26:17 +0700 Subject: [PATCH] Use useMutation for worktree creation with loading spinner on mobile Move worktree creation into a useMutation in ProjectHeaderRow so each row owns its own pending state. Show ActivityIndicator on the + button while the request is in flight, and always show the + button on mobile breakpoints where hover isn't available. --- packages/app/src/components/left-sidebar.tsx | 8 +- .../src/components/sidebar-workspace-list.tsx | 211 ++++++++++-------- 2 files changed, 117 insertions(+), 102 deletions(-) diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 49bc40606..a80448bc0 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -77,7 +77,6 @@ interface SidebarSharedProps { collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"]; shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"]; toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"]; - setProjectCollapsed: SidebarShortcutModel["setProjectCollapsed"]; handleRefresh: () => void; handleHostSelect: (nextServerId: string) => void; handleOpenProject: () => void; @@ -184,7 +183,6 @@ export const LeftSidebar = memo(function LeftSidebar({ collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, - setProjectCollapsed, } = useSidebarShortcutModel(projects); const [isManualRefresh, setIsManualRefresh] = useState(false); @@ -261,7 +259,6 @@ export const LeftSidebar = memo(function LeftSidebar({ collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, - setProjectCollapsed, handleRefresh, handleHostSelect, }; @@ -346,7 +343,6 @@ function MobileSidebar({ collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, - setProjectCollapsed, handleRefresh, handleHostSelect, handleOpenProject, @@ -515,7 +511,7 @@ function MobileSidebar({ serverId={activeServerId} collapsedProjectKeys={collapsedProjectKeys} onToggleProjectCollapsed={toggleProjectCollapsed} - onSetProjectCollapsed={setProjectCollapsed} + shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} projects={projects} isRefreshing={isManualRefresh && isRevalidating} @@ -623,7 +619,6 @@ function DesktopSidebar({ collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, - setProjectCollapsed, handleRefresh, handleHostSelect, handleOpenProject, @@ -660,7 +655,6 @@ function DesktopSidebar({ serverId={activeServerId} collapsedProjectKeys={collapsedProjectKeys} onToggleProjectCollapsed={toggleProjectCollapsed} - onSetProjectCollapsed={setProjectCollapsed} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} projects={projects} isRefreshing={isManualRefresh && isRevalidating} diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index dab204305..a1a6cb262 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -10,7 +10,7 @@ import { type GestureResponderEvent, } from "react-native"; import * as Haptics from "expo-haptics"; -import { useQueries } from "@tanstack/react-query"; +import { useMutation, useQueries } from "@tanstack/react-query"; import { useCallback, useMemo, @@ -97,7 +97,6 @@ interface SidebarWorkspaceListProps { serverId: string | null; collapsedProjectKeys: ReadonlySet; onToggleProjectCollapsed: (projectKey: string) => void; - onSetProjectCollapsed: (projectKey: string, collapsed: boolean) => void; shortcutIndexByWorkspaceKey: Map; isRefreshing?: boolean; onRefresh?: () => void; @@ -115,7 +114,10 @@ interface ProjectHeaderRowProps { selected?: boolean; chevron: "expand" | "collapse" | null; onPress: () => void; - onCreateWorktree?: () => void; + serverId: string | null; + canCreateWorktree: boolean; + onWorkspacePress?: () => void; + onWorktreeCreated?: (workspaceId: string) => void; shortcutNumber?: number | null; showShortcutBadge?: boolean; drag: () => void; @@ -263,11 +265,13 @@ function NewWorktreeButton({ displayName, onPress, visible, + loading = false, testID, }: { displayName: string; onPress: () => void; visible: boolean; + loading?: boolean; testID: string; }) { const { theme } = useUnistyles(); @@ -280,22 +284,29 @@ function NewWorktreeButton({ style={({ hovered, pressed }) => [ styles.projectIconActionButton, !visible && styles.projectIconActionButtonHidden, - (hovered || pressed) && styles.projectIconActionButtonHovered, + (hovered || pressed) && !loading && styles.projectIconActionButtonHovered, ]} onPress={(event) => { event.stopPropagation(); onPress(); }} + disabled={loading} accessibilityRole="button" accessibilityLabel={`Create a new worktree for ${displayName}`} testID={testID} > - {({ hovered, pressed }) => ( - - )} + {({ hovered, pressed }) => + loading ? ( + + ) : ( + + ) + } @@ -531,7 +542,10 @@ function ProjectHeaderRow({ selected = false, chevron, onPress, - onCreateWorktree, + serverId, + canCreateWorktree, + onWorkspacePress, + onWorktreeCreated, shortcutNumber = null, showShortcutBadge = false, drag, @@ -541,6 +555,45 @@ function ProjectHeaderRow({ dragHandleProps, }: ProjectHeaderRowProps) { const [isHovered, setIsHovered] = useState(false); + const isMobileBreakpoint = + UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); + const toast = useToast(); + + const createWorktreeMutation = useMutation({ + mutationFn: async () => { + if (!serverId) { + throw new Error("No server"); + } + const client = getHostRuntimeStore().getClient(serverId); + if (!client || !isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(serverId))) { + throw new Error("Host is not connected"); + } + const payload = await client.createPaseoWorktree({ + cwd: project.iconWorkingDir, + worktreeSlug: createNameId(), + }); + if (payload.error || !payload.workspace) { + throw new Error(payload.error ?? "Failed to create worktree"); + } + return payload.workspace; + }, + onSuccess: (workspace) => { + mergeWorkspaces(serverId!, [normalizeWorkspaceDescriptor(workspace)]); + onWorktreeCreated?.(workspace.id); + onWorkspacePress?.(); + router.navigate( + prepareWorkspaceTab({ + serverId: serverId!, + workspaceId: workspace.id, + target: { kind: "draft", draftId: "new" }, + }) as any, + ); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : String(error)); + }, + }); const interaction = useLongPressDragInteraction({ drag, menuController, @@ -577,11 +630,12 @@ function ProjectHeaderRow({ - {onCreateWorktree ? ( + {canCreateWorktree ? ( createWorktreeMutation.mutate()} + visible={isHovered || isMobileBreakpoint} + loading={createWorktreeMutation.isPending} testID={`sidebar-project-new-worktree-${project.projectKey}`} /> ) : null} @@ -1064,6 +1118,8 @@ function NonGitProjectRowWithMenuContent({ selected={selected} chevron={null} onPress={onPress} + serverId={null} + canCreateWorktree={false} shortcutNumber={shortcutNumber} showShortcutBadge={showShortcutBadge} drag={drag} @@ -1118,7 +1174,9 @@ function FlattenedProjectRow({ iconDataUri, rowModel, onPress, - onCreateWorktree, + serverId, + onWorkspacePress, + onWorktreeCreated, shortcutNumber, showShortcutBadge, drag, @@ -1130,7 +1188,9 @@ function FlattenedProjectRow({ iconDataUri: string | null; rowModel: Extract, { kind: "workspace_link" }>; onPress: () => void; - onCreateWorktree?: () => void; + serverId: string | null; + onWorkspacePress?: () => void; + onWorktreeCreated?: (workspaceId: string) => void; shortcutNumber: number | null; showShortcutBadge: boolean; drag: () => void; @@ -1164,7 +1224,10 @@ function FlattenedProjectRow({ selected={rowModel.selected} chevron={rowModel.chevron} onPress={onPress} - onCreateWorktree={rowModel.trailingAction === "new_worktree" ? onCreateWorktree : undefined} + serverId={serverId} + canCreateWorktree={rowModel.trailingAction === "new_worktree"} + onWorkspacePress={onWorkspacePress} + onWorktreeCreated={onWorktreeCreated} shortcutNumber={shortcutNumber} showShortcutBadge={showShortcutBadge} drag={drag} @@ -1227,7 +1290,7 @@ function ProjectBlock({ onToggleCollapsed, onWorkspacePress, onWorkspaceReorder, - onCreateWorktree, + onWorktreeCreated, drag, isDragging, dragHandleProps, @@ -1246,7 +1309,7 @@ function ProjectBlock({ onToggleCollapsed: () => void; onWorkspacePress?: () => void; onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void; - onCreateWorktree?: (project: SidebarProjectEntry) => void; + onWorktreeCreated?: (workspaceId: string) => void; drag: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; @@ -1348,11 +1411,9 @@ function ProjectBlock({ onWorkspacePress?.(); navigateToWorkspace(serverId, rowModel.workspace.workspaceId); }} - onCreateWorktree={ - rowModel.trailingAction === "new_worktree" && onCreateWorktree - ? () => onCreateWorktree(project) - : undefined - } + serverId={serverId} + onWorkspacePress={onWorkspacePress} + onWorktreeCreated={onWorktreeCreated} shortcutNumber={shortcutIndexByWorkspaceKey.get(rowModel.workspace.workspaceKey) ?? null} showShortcutBadge={showShortcutBadges} drag={drag} @@ -1369,11 +1430,10 @@ function ProjectBlock({ selected={false} chevron={rowModel.chevron} onPress={onToggleCollapsed} - onCreateWorktree={ - rowModel.trailingAction === "new_worktree" && onCreateWorktree - ? () => onCreateWorktree(project) - : undefined - } + serverId={serverId} + canCreateWorktree={rowModel.trailingAction === "new_worktree"} + onWorkspacePress={onWorkspacePress} + onWorktreeCreated={onWorktreeCreated} drag={drag} isDragging={isDragging} menuController={null} @@ -1405,7 +1465,6 @@ export function SidebarWorkspaceList({ serverId, collapsedProjectKeys, onToggleProjectCollapsed, - onSetProjectCollapsed, shortcutIndexByWorkspaceKey, isRefreshing = false, onRefresh, @@ -1417,9 +1476,6 @@ export function SidebarWorkspaceList({ const isNative = Platform.OS !== "web"; const pathname = usePathname(); const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection(); - const toast = useToast(); - const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); - const [creatingProjectKey, setCreatingProjectKey] = useState(null); const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState>(() => new Set()); const creatingWorkspaceTimeoutsRef = useRef>>( new Map(), @@ -1608,68 +1664,33 @@ export function SidebarWorkspaceList({ [getWorkspaceOrder, serverId, setWorkspaceOrder], ); - const handleCreateWorktree = useCallback( - async (project: SidebarProjectEntry) => { - if (!serverId || project.projectKind !== "git") { - return; - } - if (creatingProjectKey) { - return; - } - onSetProjectCollapsed(project.projectKey, false); - setCreatingProjectKey(project.projectKey); - try { - const client = getHostRuntimeStore().getClient(serverId); - if (!client || !isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(serverId))) { - throw new Error("Host is not connected"); - } - const payload = await client.createPaseoWorktree({ - cwd: project.iconWorkingDir, - worktreeSlug: createNameId(), - }); - if (payload.error || !payload.workspace) { - throw new Error(payload.error ?? "Failed to create worktree"); - } - const workspace = payload.workspace; - mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(workspace)]); - setCreatingWorkspaceIds((current) => { - const next = new Set(current); - next.add(workspace.id); - return next; - }); - const existingTimeout = creatingWorkspaceTimeoutsRef.current.get(workspace.id); - if (existingTimeout) { - clearTimeout(existingTimeout); - } - creatingWorkspaceTimeoutsRef.current.set( - workspace.id, - setTimeout(() => { - creatingWorkspaceTimeoutsRef.current.delete(workspace.id); - setCreatingWorkspaceIds((current) => { - if (!current.has(workspace.id)) { - return current; - } - const next = new Set(current); - next.delete(workspace.id); - return next; - }); - }, 3000), - ); - onWorkspacePress?.(); - router.navigate( - prepareWorkspaceTab({ - serverId, - workspaceId: workspace.id, - target: { kind: "draft", draftId: "new" }, - }) as any, - ); - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)); - } finally { - setCreatingProjectKey((current) => (current === project.projectKey ? null : current)); + const handleWorktreeCreated = useCallback( + (workspaceId: string) => { + setCreatingWorkspaceIds((current) => { + const next = new Set(current); + next.add(workspaceId); + return next; + }); + const existingTimeout = creatingWorkspaceTimeoutsRef.current.get(workspaceId); + if (existingTimeout) { + clearTimeout(existingTimeout); } + creatingWorkspaceTimeoutsRef.current.set( + workspaceId, + setTimeout(() => { + creatingWorkspaceTimeoutsRef.current.delete(workspaceId); + setCreatingWorkspaceIds((current) => { + if (!current.has(workspaceId)) { + return current; + } + const next = new Set(current); + next.delete(workspaceId); + return next; + }); + }, 3000), + ); }, - [creatingProjectKey, mergeWorkspaces, onSetProjectCollapsed, onWorkspacePress, serverId, toast], + [], ); const renderProject = useCallback( @@ -1688,7 +1709,7 @@ export function SidebarWorkspaceList({ onToggleCollapsed={() => onToggleProjectCollapsed(item.projectKey)} onWorkspacePress={onWorkspacePress} onWorkspaceReorder={handleWorkspaceReorder} - onCreateWorktree={handleCreateWorktree} + onWorktreeCreated={handleWorktreeCreated} drag={drag} isDragging={isActive} dragHandleProps={dragHandleProps} @@ -1700,7 +1721,7 @@ export function SidebarWorkspaceList({ [ collapsedProjectKeys, effectiveActiveWorkspaceSelection, - handleCreateWorktree, + handleWorktreeCreated, handleWorkspaceReorder, onWorkspacePress, onToggleProjectCollapsed,