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.
This commit is contained in:
Mohamed Boudra
2026-03-21 22:26:17 +07:00
parent 7b8a2fbcef
commit eb618638d2
2 changed files with 117 additions and 102 deletions
+1 -7
View File
@@ -77,7 +77,6 @@ interface SidebarSharedProps {
collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"]; collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"];
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"]; shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"]; toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
setProjectCollapsed: SidebarShortcutModel["setProjectCollapsed"];
handleRefresh: () => void; handleRefresh: () => void;
handleHostSelect: (nextServerId: string) => void; handleHostSelect: (nextServerId: string) => void;
handleOpenProject: () => void; handleOpenProject: () => void;
@@ -184,7 +183,6 @@ export const LeftSidebar = memo(function LeftSidebar({
collapsedProjectKeys, collapsedProjectKeys,
shortcutIndexByWorkspaceKey, shortcutIndexByWorkspaceKey,
toggleProjectCollapsed, toggleProjectCollapsed,
setProjectCollapsed,
} = useSidebarShortcutModel(projects); } = useSidebarShortcutModel(projects);
const [isManualRefresh, setIsManualRefresh] = useState(false); const [isManualRefresh, setIsManualRefresh] = useState(false);
@@ -261,7 +259,6 @@ export const LeftSidebar = memo(function LeftSidebar({
collapsedProjectKeys, collapsedProjectKeys,
shortcutIndexByWorkspaceKey, shortcutIndexByWorkspaceKey,
toggleProjectCollapsed, toggleProjectCollapsed,
setProjectCollapsed,
handleRefresh, handleRefresh,
handleHostSelect, handleHostSelect,
}; };
@@ -346,7 +343,6 @@ function MobileSidebar({
collapsedProjectKeys, collapsedProjectKeys,
shortcutIndexByWorkspaceKey, shortcutIndexByWorkspaceKey,
toggleProjectCollapsed, toggleProjectCollapsed,
setProjectCollapsed,
handleRefresh, handleRefresh,
handleHostSelect, handleHostSelect,
handleOpenProject, handleOpenProject,
@@ -515,7 +511,7 @@ function MobileSidebar({
serverId={activeServerId} serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys} collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed} onToggleProjectCollapsed={toggleProjectCollapsed}
onSetProjectCollapsed={setProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
projects={projects} projects={projects}
isRefreshing={isManualRefresh && isRevalidating} isRefreshing={isManualRefresh && isRevalidating}
@@ -623,7 +619,6 @@ function DesktopSidebar({
collapsedProjectKeys, collapsedProjectKeys,
shortcutIndexByWorkspaceKey, shortcutIndexByWorkspaceKey,
toggleProjectCollapsed, toggleProjectCollapsed,
setProjectCollapsed,
handleRefresh, handleRefresh,
handleHostSelect, handleHostSelect,
handleOpenProject, handleOpenProject,
@@ -660,7 +655,6 @@ function DesktopSidebar({
serverId={activeServerId} serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys} collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed} onToggleProjectCollapsed={toggleProjectCollapsed}
onSetProjectCollapsed={setProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
projects={projects} projects={projects}
isRefreshing={isManualRefresh && isRevalidating} isRefreshing={isManualRefresh && isRevalidating}
@@ -10,7 +10,7 @@ import {
type GestureResponderEvent, type GestureResponderEvent,
} from "react-native"; } from "react-native";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import { useQueries } from "@tanstack/react-query"; import { useMutation, useQueries } from "@tanstack/react-query";
import { import {
useCallback, useCallback,
useMemo, useMemo,
@@ -97,7 +97,6 @@ interface SidebarWorkspaceListProps {
serverId: string | null; serverId: string | null;
collapsedProjectKeys: ReadonlySet<string>; collapsedProjectKeys: ReadonlySet<string>;
onToggleProjectCollapsed: (projectKey: string) => void; onToggleProjectCollapsed: (projectKey: string) => void;
onSetProjectCollapsed: (projectKey: string, collapsed: boolean) => void;
shortcutIndexByWorkspaceKey: Map<string, number>; shortcutIndexByWorkspaceKey: Map<string, number>;
isRefreshing?: boolean; isRefreshing?: boolean;
onRefresh?: () => void; onRefresh?: () => void;
@@ -115,7 +114,10 @@ interface ProjectHeaderRowProps {
selected?: boolean; selected?: boolean;
chevron: "expand" | "collapse" | null; chevron: "expand" | "collapse" | null;
onPress: () => void; onPress: () => void;
onCreateWorktree?: () => void; serverId: string | null;
canCreateWorktree: boolean;
onWorkspacePress?: () => void;
onWorktreeCreated?: (workspaceId: string) => void;
shortcutNumber?: number | null; shortcutNumber?: number | null;
showShortcutBadge?: boolean; showShortcutBadge?: boolean;
drag: () => void; drag: () => void;
@@ -263,11 +265,13 @@ function NewWorktreeButton({
displayName, displayName,
onPress, onPress,
visible, visible,
loading = false,
testID, testID,
}: { }: {
displayName: string; displayName: string;
onPress: () => void; onPress: () => void;
visible: boolean; visible: boolean;
loading?: boolean;
testID: string; testID: string;
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -280,22 +284,29 @@ function NewWorktreeButton({
style={({ hovered, pressed }) => [ style={({ hovered, pressed }) => [
styles.projectIconActionButton, styles.projectIconActionButton,
!visible && styles.projectIconActionButtonHidden, !visible && styles.projectIconActionButtonHidden,
(hovered || pressed) && styles.projectIconActionButtonHovered, (hovered || pressed) && !loading && styles.projectIconActionButtonHovered,
]} ]}
onPress={(event) => { onPress={(event) => {
event.stopPropagation(); event.stopPropagation();
onPress(); onPress();
}} }}
disabled={loading}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Create a new worktree for ${displayName}`} accessibilityLabel={`Create a new worktree for ${displayName}`}
testID={testID} testID={testID}
> >
{({ hovered, pressed }) => ( {({ hovered, pressed }) =>
<Plus loading ? (
size={14} <ActivityIndicator size={14} color={theme.colors.foregroundMuted} />
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted} ) : (
/> <Plus
)} size={14}
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
)
}
</Pressable> </Pressable>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}> <TooltipContent side="bottom" align="end" offset={8}>
@@ -531,7 +542,10 @@ function ProjectHeaderRow({
selected = false, selected = false,
chevron, chevron,
onPress, onPress,
onCreateWorktree, serverId,
canCreateWorktree,
onWorkspacePress,
onWorktreeCreated,
shortcutNumber = null, shortcutNumber = null,
showShortcutBadge = false, showShortcutBadge = false,
drag, drag,
@@ -541,6 +555,45 @@ function ProjectHeaderRow({
dragHandleProps, dragHandleProps,
}: ProjectHeaderRowProps) { }: ProjectHeaderRowProps) {
const [isHovered, setIsHovered] = useState(false); 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({ const interaction = useLongPressDragInteraction({
drag, drag,
menuController, menuController,
@@ -577,11 +630,12 @@ function ProjectHeaderRow({
</Text> </Text>
</View> </View>
</View> </View>
{onCreateWorktree ? ( {canCreateWorktree ? (
<NewWorktreeButton <NewWorktreeButton
displayName={displayName} displayName={displayName}
onPress={onCreateWorktree} onPress={() => createWorktreeMutation.mutate()}
visible={isHovered} visible={isHovered || isMobileBreakpoint}
loading={createWorktreeMutation.isPending}
testID={`sidebar-project-new-worktree-${project.projectKey}`} testID={`sidebar-project-new-worktree-${project.projectKey}`}
/> />
) : null} ) : null}
@@ -1064,6 +1118,8 @@ function NonGitProjectRowWithMenuContent({
selected={selected} selected={selected}
chevron={null} chevron={null}
onPress={onPress} onPress={onPress}
serverId={null}
canCreateWorktree={false}
shortcutNumber={shortcutNumber} shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge} showShortcutBadge={showShortcutBadge}
drag={drag} drag={drag}
@@ -1118,7 +1174,9 @@ function FlattenedProjectRow({
iconDataUri, iconDataUri,
rowModel, rowModel,
onPress, onPress,
onCreateWorktree, serverId,
onWorkspacePress,
onWorktreeCreated,
shortcutNumber, shortcutNumber,
showShortcutBadge, showShortcutBadge,
drag, drag,
@@ -1130,7 +1188,9 @@ function FlattenedProjectRow({
iconDataUri: string | null; iconDataUri: string | null;
rowModel: Extract<ReturnType<typeof buildSidebarProjectRowModel>, { kind: "workspace_link" }>; rowModel: Extract<ReturnType<typeof buildSidebarProjectRowModel>, { kind: "workspace_link" }>;
onPress: () => void; onPress: () => void;
onCreateWorktree?: () => void; serverId: string | null;
onWorkspacePress?: () => void;
onWorktreeCreated?: (workspaceId: string) => void;
shortcutNumber: number | null; shortcutNumber: number | null;
showShortcutBadge: boolean; showShortcutBadge: boolean;
drag: () => void; drag: () => void;
@@ -1164,7 +1224,10 @@ function FlattenedProjectRow({
selected={rowModel.selected} selected={rowModel.selected}
chevron={rowModel.chevron} chevron={rowModel.chevron}
onPress={onPress} onPress={onPress}
onCreateWorktree={rowModel.trailingAction === "new_worktree" ? onCreateWorktree : undefined} serverId={serverId}
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
shortcutNumber={shortcutNumber} shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge} showShortcutBadge={showShortcutBadge}
drag={drag} drag={drag}
@@ -1227,7 +1290,7 @@ function ProjectBlock({
onToggleCollapsed, onToggleCollapsed,
onWorkspacePress, onWorkspacePress,
onWorkspaceReorder, onWorkspaceReorder,
onCreateWorktree, onWorktreeCreated,
drag, drag,
isDragging, isDragging,
dragHandleProps, dragHandleProps,
@@ -1246,7 +1309,7 @@ function ProjectBlock({
onToggleCollapsed: () => void; onToggleCollapsed: () => void;
onWorkspacePress?: () => void; onWorkspacePress?: () => void;
onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void; onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void;
onCreateWorktree?: (project: SidebarProjectEntry) => void; onWorktreeCreated?: (workspaceId: string) => void;
drag: () => void; drag: () => void;
isDragging: boolean; isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps; dragHandleProps?: DraggableListDragHandleProps;
@@ -1348,11 +1411,9 @@ function ProjectBlock({
onWorkspacePress?.(); onWorkspacePress?.();
navigateToWorkspace(serverId, rowModel.workspace.workspaceId); navigateToWorkspace(serverId, rowModel.workspace.workspaceId);
}} }}
onCreateWorktree={ serverId={serverId}
rowModel.trailingAction === "new_worktree" && onCreateWorktree onWorkspacePress={onWorkspacePress}
? () => onCreateWorktree(project) onWorktreeCreated={onWorktreeCreated}
: undefined
}
shortcutNumber={shortcutIndexByWorkspaceKey.get(rowModel.workspace.workspaceKey) ?? null} shortcutNumber={shortcutIndexByWorkspaceKey.get(rowModel.workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges} showShortcutBadge={showShortcutBadges}
drag={drag} drag={drag}
@@ -1369,11 +1430,10 @@ function ProjectBlock({
selected={false} selected={false}
chevron={rowModel.chevron} chevron={rowModel.chevron}
onPress={onToggleCollapsed} onPress={onToggleCollapsed}
onCreateWorktree={ serverId={serverId}
rowModel.trailingAction === "new_worktree" && onCreateWorktree canCreateWorktree={rowModel.trailingAction === "new_worktree"}
? () => onCreateWorktree(project) onWorkspacePress={onWorkspacePress}
: undefined onWorktreeCreated={onWorktreeCreated}
}
drag={drag} drag={drag}
isDragging={isDragging} isDragging={isDragging}
menuController={null} menuController={null}
@@ -1405,7 +1465,6 @@ export function SidebarWorkspaceList({
serverId, serverId,
collapsedProjectKeys, collapsedProjectKeys,
onToggleProjectCollapsed, onToggleProjectCollapsed,
onSetProjectCollapsed,
shortcutIndexByWorkspaceKey, shortcutIndexByWorkspaceKey,
isRefreshing = false, isRefreshing = false,
onRefresh, onRefresh,
@@ -1417,9 +1476,6 @@ export function SidebarWorkspaceList({
const isNative = Platform.OS !== "web"; const isNative = Platform.OS !== "web";
const pathname = usePathname(); const pathname = usePathname();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection(); const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
const toast = useToast();
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const [creatingProjectKey, setCreatingProjectKey] = useState<string | null>(null);
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set()); const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>( const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map(), new Map(),
@@ -1608,68 +1664,33 @@ export function SidebarWorkspaceList({
[getWorkspaceOrder, serverId, setWorkspaceOrder], [getWorkspaceOrder, serverId, setWorkspaceOrder],
); );
const handleCreateWorktree = useCallback( const handleWorktreeCreated = useCallback(
async (project: SidebarProjectEntry) => { (workspaceId: string) => {
if (!serverId || project.projectKind !== "git") { setCreatingWorkspaceIds((current) => {
return; const next = new Set(current);
} next.add(workspaceId);
if (creatingProjectKey) { return next;
return; });
} const existingTimeout = creatingWorkspaceTimeoutsRef.current.get(workspaceId);
onSetProjectCollapsed(project.projectKey, false); if (existingTimeout) {
setCreatingProjectKey(project.projectKey); clearTimeout(existingTimeout);
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));
} }
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( const renderProject = useCallback(
@@ -1688,7 +1709,7 @@ export function SidebarWorkspaceList({
onToggleCollapsed={() => onToggleProjectCollapsed(item.projectKey)} onToggleCollapsed={() => onToggleProjectCollapsed(item.projectKey)}
onWorkspacePress={onWorkspacePress} onWorkspacePress={onWorkspacePress}
onWorkspaceReorder={handleWorkspaceReorder} onWorkspaceReorder={handleWorkspaceReorder}
onCreateWorktree={handleCreateWorktree} onWorktreeCreated={handleWorktreeCreated}
drag={drag} drag={drag}
isDragging={isActive} isDragging={isActive}
dragHandleProps={dragHandleProps} dragHandleProps={dragHandleProps}
@@ -1700,7 +1721,7 @@ export function SidebarWorkspaceList({
[ [
collapsedProjectKeys, collapsedProjectKeys,
effectiveActiveWorkspaceSelection, effectiveActiveWorkspaceSelection,
handleCreateWorktree, handleWorktreeCreated,
handleWorkspaceReorder, handleWorkspaceReorder,
onWorkspacePress, onWorkspacePress,
onToggleProjectCollapsed, onToggleProjectCollapsed,