mirror of
https://github.com/getpaseo/paseo.git
synced 2026-08-14 12:23:16 +00:00
3078 lines
89 KiB
TypeScript
3078 lines
89 KiB
TypeScript
import {
|
|
View,
|
|
Text,
|
|
Pressable,
|
|
Platform,
|
|
ActivityIndicator,
|
|
StatusBar,
|
|
ScrollView,
|
|
type GestureResponderEvent,
|
|
type PressableStateCallbackType,
|
|
type ViewStyle,
|
|
} from "react-native";
|
|
import * as Haptics from "expo-haptics";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug";
|
|
import { ProjectIconView } from "@/components/project-icon-view";
|
|
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
|
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
|
import {
|
|
memo,
|
|
useCallback,
|
|
useMemo,
|
|
useState,
|
|
useEffect,
|
|
useRef,
|
|
type ReactElement,
|
|
type MutableRefObject,
|
|
type Ref,
|
|
} from "react";
|
|
import { router, usePathname, type Href } from "expo-router";
|
|
import {
|
|
navigateToWorkspace,
|
|
useActiveWorkspaceSelection,
|
|
type ActiveWorkspaceSelection,
|
|
} from "@/stores/navigation-active-workspace-store";
|
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
|
import type { Theme } from "@/styles/theme";
|
|
import { type GestureType } from "react-native-gesture-handler";
|
|
import * as Clipboard from "expo-clipboard";
|
|
import { DiffStat } from "@/components/diff-stat";
|
|
import {
|
|
Archive,
|
|
CircleAlert,
|
|
CircleCheck,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Copy,
|
|
ExternalLink,
|
|
FolderPlus,
|
|
GitPullRequest,
|
|
Settings,
|
|
MoreVertical,
|
|
Pencil,
|
|
Plus,
|
|
Trash2,
|
|
} from "lucide-react-native";
|
|
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
|
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
|
import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
|
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
|
|
import {
|
|
buildHostNewWorkspaceRoute,
|
|
buildProjectSettingsRoute,
|
|
parseHostWorkspaceRouteFromPathname,
|
|
} from "@/utils/host-routes";
|
|
import {
|
|
useSidebarWorkspaceEntry,
|
|
type SidebarProjectEntry,
|
|
type SidebarWorkspaceEntry,
|
|
} from "@/hooks/use-sidebar-workspaces-list";
|
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
|
import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges";
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuTrigger,
|
|
useContextMenu,
|
|
} from "@/components/ui/context-menu";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { SyncedLoader } from "@/components/synced-loader";
|
|
import { useToast } from "@/contexts/toast-context";
|
|
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
|
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
|
|
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
|
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
|
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
|
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
|
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
|
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
|
import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list";
|
|
import {
|
|
SidebarWorkspaceRowFrame,
|
|
SidebarWorkspaceRowContent,
|
|
SidebarWorkspaceShortcutBadge,
|
|
SidebarWorkspaceTrailingActionBase,
|
|
SidebarWorkspaceTrailingActionOverlay,
|
|
SidebarWorkspaceTrailingActionSlot,
|
|
} from "@/components/sidebar/sidebar-workspace-row-content";
|
|
import {
|
|
useProjectNamesMap,
|
|
useStatusModeWorkspaceEntries,
|
|
} from "@/hooks/use-status-mode-workspaces";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
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 type { PrHint } from "@/git/use-pr-status-query";
|
|
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
|
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
|
import { openExternalUrl } from "@/utils/open-external-url";
|
|
import {
|
|
requireWorkspaceExecutionDirectory,
|
|
resolveWorkspaceExecutionDirectory,
|
|
} from "@/utils/workspace-execution";
|
|
import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning";
|
|
import {
|
|
archiveWorkspaceOptimistically,
|
|
archiveWorkspacesOptimistically,
|
|
} from "@/workspace/workspace-archive";
|
|
import {
|
|
isWeb as platformIsWeb,
|
|
isNative as platformIsNative,
|
|
getIsElectron,
|
|
} from "@/constants/platform";
|
|
import { getDesktopHost } from "@/desktop/host";
|
|
|
|
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
|
|
|
|
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
|
|
|
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
|
const DEFAULT_STATUS_DOT_SIZE = 7;
|
|
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
|
const DEFAULT_STATUS_DOT_OFFSET = 0;
|
|
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
|
|
const ThemedExternalLink = withUnistyles(ExternalLink);
|
|
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
|
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
|
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
|
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
|
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
|
const ThemedFolderPlus = withUnistyles(FolderPlus);
|
|
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
|
const ThemedTrash2 = withUnistyles(Trash2);
|
|
const ThemedSettings = withUnistyles(Settings);
|
|
const ThemedCopy = withUnistyles(Copy);
|
|
const ThemedArchive = withUnistyles(Archive);
|
|
const ThemedPencil = withUnistyles(Pencil);
|
|
|
|
const foregroundColorMapping = (theme: Theme) => ({
|
|
color: theme.colors.foreground,
|
|
});
|
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
|
color: theme.colors.foregroundMuted,
|
|
});
|
|
const redColorMapping = (theme: Theme) => ({
|
|
color: theme.colors.palette.red[500],
|
|
});
|
|
const amberColorMapping = (theme: Theme) => ({
|
|
color: theme.colors.palette.amber[500],
|
|
});
|
|
const greenColorMapping = (theme: Theme) => ({
|
|
color: theme.colors.palette.green[500],
|
|
});
|
|
const purpleColorMapping = (theme: Theme) => ({
|
|
color: theme.colors.palette.purple[500],
|
|
});
|
|
const syncedLoaderColorMapping = (theme: Theme) => ({
|
|
color:
|
|
theme.colorScheme === "light"
|
|
? theme.colors.palette.amber[700]
|
|
: theme.colors.palette.amber[500],
|
|
});
|
|
|
|
function getPrIconUniMapping(state: PrHint["state"]) {
|
|
switch (state) {
|
|
case "merged":
|
|
return purpleColorMapping;
|
|
case "open":
|
|
return greenColorMapping;
|
|
case "closed":
|
|
return redColorMapping;
|
|
}
|
|
}
|
|
|
|
function isWorkspaceSelected(input: {
|
|
selection: ActiveWorkspaceSelection | null;
|
|
serverId: string | null;
|
|
workspaceId: string;
|
|
enabled: boolean;
|
|
}): boolean {
|
|
return (
|
|
input.enabled &&
|
|
input.selection?.serverId === input.serverId &&
|
|
input.selection.workspaceId === input.workspaceId
|
|
);
|
|
}
|
|
|
|
function isProjectSelectedByRoute(input: {
|
|
selection: ActiveWorkspaceSelection | null;
|
|
project: SidebarProjectEntry;
|
|
serverId: string | null;
|
|
enabled: boolean;
|
|
}): boolean {
|
|
return (
|
|
input.enabled &&
|
|
input.selection?.serverId === input.serverId &&
|
|
input.project.workspaces.some(
|
|
(workspace) => workspace.workspaceId === input.selection?.workspaceId,
|
|
)
|
|
);
|
|
}
|
|
|
|
function activeWorkspaceSelectionKey(selection: ActiveWorkspaceSelection | null): string {
|
|
return selection ? `${selection.serverId}:${selection.workspaceId}` : "";
|
|
}
|
|
|
|
function selectionForSelectedWorkspace(
|
|
selected: boolean,
|
|
workspace: SidebarWorkspaceEntry,
|
|
): ActiveWorkspaceSelection | null {
|
|
return selected ? { serverId: workspace.serverId, workspaceId: workspace.workspaceId } : null;
|
|
}
|
|
|
|
interface SidebarWorkspaceListProps {
|
|
projects: SidebarProjectEntry[];
|
|
serverId: string | null;
|
|
collapsedProjectKeys: ReadonlySet<string>;
|
|
onToggleProjectCollapsed: (projectKey: string) => void;
|
|
shortcutIndexByWorkspaceKey: Map<string, number>;
|
|
groupMode: "project" | "status";
|
|
isRefreshing?: boolean;
|
|
onRefresh?: () => void;
|
|
onWorkspacePress?: () => void;
|
|
onAddProject?: () => void;
|
|
listFooterComponent?: ReactElement | null;
|
|
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
|
|
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
|
}
|
|
|
|
interface ProjectHeaderRowProps {
|
|
project: SidebarProjectEntry;
|
|
displayName: string;
|
|
iconDataUri: string | null;
|
|
workspace: SidebarWorkspaceEntry | null;
|
|
selected?: boolean;
|
|
chevron: "expand" | "collapse" | null;
|
|
onPress: () => void;
|
|
serverId: string | null;
|
|
canCreateWorktree: boolean;
|
|
isProjectActive?: boolean;
|
|
onWorkspacePress?: () => void;
|
|
onWorktreeCreated?: (workspaceId: string) => void;
|
|
shortcutNumber?: number | null;
|
|
showShortcutBadge?: boolean;
|
|
drag: () => void;
|
|
isDragging: boolean;
|
|
isArchiving?: boolean;
|
|
menuController: ReturnType<typeof useContextMenu> | null;
|
|
onRemoveProject?: () => void;
|
|
removeProjectStatus?: "idle" | "pending";
|
|
dragHandleProps?: DraggableListDragHandleProps;
|
|
}
|
|
|
|
interface WorkspaceRowInnerProps {
|
|
workspace: SidebarWorkspaceEntry;
|
|
selected: boolean;
|
|
shortcutNumber: number | null;
|
|
showShortcutBadge: boolean;
|
|
onPress: () => void;
|
|
drag: () => void;
|
|
isDragging: boolean;
|
|
isArchiving: boolean;
|
|
isCreating?: boolean;
|
|
dragHandleProps?: DraggableListDragHandleProps;
|
|
menuController: ReturnType<typeof useContextMenu> | null;
|
|
archiveLabel?: string;
|
|
archiveStatus?: "idle" | "pending" | "success";
|
|
archivePendingLabel?: string;
|
|
onArchive?: () => void;
|
|
onCopyBranchName?: () => void;
|
|
onCopyPath?: () => void;
|
|
onRename?: () => void;
|
|
onMarkAsRead?: () => void;
|
|
archiveShortcutKeys?: ShortcutKey[][] | null;
|
|
}
|
|
|
|
function getWorkspaceArchiveStatus(
|
|
isWorktree: boolean,
|
|
archiveStatus: "idle" | "pending" | "success",
|
|
isArchivingWorkspace: boolean,
|
|
): "idle" | "pending" | "success" {
|
|
if (isWorktree) return archiveStatus;
|
|
if (isArchivingWorkspace) return "pending";
|
|
return "idle";
|
|
}
|
|
|
|
export function PrBadge({ hint }: { hint: PrHint }) {
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
|
|
const handlePressIn = useCallback((event: GestureResponderEvent) => {
|
|
event.stopPropagation();
|
|
}, []);
|
|
|
|
const handlePress = useCallback(
|
|
(event: GestureResponderEvent) => {
|
|
event.stopPropagation();
|
|
void openExternalUrl(hint.url);
|
|
},
|
|
[hint.url],
|
|
);
|
|
|
|
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
|
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
|
|
|
const textStyle = isHovered ? prBadgeTextHoveredCombined : prBadgeStyles.text;
|
|
const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state);
|
|
|
|
return (
|
|
<Pressable
|
|
accessibilityRole="link"
|
|
accessibilityLabel={`Pull request #${hint.number}`}
|
|
hitSlop={4}
|
|
onPressIn={handlePressIn}
|
|
onPress={handlePress}
|
|
onHoverIn={handleHoverIn}
|
|
onHoverOut={handleHoverOut}
|
|
style={prBadgePressableStyle}
|
|
>
|
|
{isHovered ? (
|
|
<ThemedExternalLink size={12} uniProps={iconUniProps} />
|
|
) : (
|
|
<ThemedGitPullRequest size={12} uniProps={iconUniProps} />
|
|
)}
|
|
<Text style={textStyle} numberOfLines={1}>
|
|
#{hint.number}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function prBadgePressableStyle({ pressed }: PressableStateCallbackType) {
|
|
return [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed];
|
|
}
|
|
|
|
function projectKebabStyle({
|
|
hovered = false,
|
|
}: PressableStateCallbackType & { hovered?: boolean }) {
|
|
return [styles.projectKebabButton, hovered && styles.projectKebabButtonHovered];
|
|
}
|
|
|
|
function workspaceKebabStyle({
|
|
hovered = false,
|
|
}: PressableStateCallbackType & { hovered?: boolean }) {
|
|
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
|
|
}
|
|
|
|
function getProjectWorkspaceRowStyle({
|
|
isDragging,
|
|
selected,
|
|
isHovered,
|
|
}: {
|
|
isDragging: boolean;
|
|
selected: boolean;
|
|
isHovered: boolean;
|
|
}) {
|
|
return [
|
|
styles.workspaceRow,
|
|
isDragging && styles.workspaceRowDragging,
|
|
selected && styles.sidebarRowSelected,
|
|
isHovered && styles.workspaceRowHovered,
|
|
];
|
|
}
|
|
|
|
function noop() {}
|
|
|
|
const prBadgeStyles = StyleSheet.create((theme) => ({
|
|
badge: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 2,
|
|
},
|
|
badgePressed: {
|
|
opacity: 0.82,
|
|
},
|
|
text: {
|
|
fontSize: theme.fontSize.xs,
|
|
fontWeight: theme.fontWeight.normal,
|
|
lineHeight: 14,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
textHovered: {
|
|
color: theme.colors.foreground,
|
|
},
|
|
}));
|
|
|
|
const prBadgeTextHoveredCombined = [prBadgeStyles.text, prBadgeStyles.textHovered];
|
|
|
|
function StatusDotOverlay({
|
|
dotColorStyle,
|
|
size,
|
|
offset,
|
|
}: {
|
|
dotColorStyle: ViewStyle;
|
|
size: number;
|
|
offset: number;
|
|
}) {
|
|
const overlayStyle = useMemo(
|
|
() => [
|
|
styles.statusDotOverlay,
|
|
dotColorStyle,
|
|
{
|
|
width: size,
|
|
height: size,
|
|
right: offset,
|
|
bottom: offset,
|
|
},
|
|
],
|
|
[dotColorStyle, size, offset],
|
|
);
|
|
return <View style={overlayStyle} />;
|
|
}
|
|
|
|
function ProjectLeadingVisual({
|
|
displayName,
|
|
iconDataUri,
|
|
workspace,
|
|
projectKey,
|
|
chevron = null,
|
|
showChevron = false,
|
|
isArchiving = false,
|
|
}: {
|
|
displayName: string;
|
|
iconDataUri: string | null;
|
|
workspace: SidebarWorkspaceEntry | null;
|
|
projectKey: string;
|
|
chevron?: "expand" | "collapse" | null;
|
|
showChevron?: boolean;
|
|
isArchiving?: boolean;
|
|
}) {
|
|
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName);
|
|
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
|
|
const activeWorkspace = workspace;
|
|
const shouldShowWorkspaceStatus =
|
|
activeWorkspace !== null && (isArchiving || activeWorkspace.statusBucket !== "done");
|
|
const shouldShowSyncedLoader = activeWorkspace
|
|
? shouldRenderSyncedStatusLoader({ bucket: activeWorkspace.statusBucket })
|
|
: false;
|
|
|
|
if (showChevron && chevron !== null) {
|
|
return (
|
|
<View style={styles.projectLeadingVisualSlot}>
|
|
<ProjectInlineChevron chevron={chevron} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (!shouldShowWorkspaceStatus || !activeWorkspace) {
|
|
return (
|
|
<View style={styles.projectLeadingVisualSlot}>
|
|
<ProjectIcon
|
|
iconDataUri={iconDataUri}
|
|
placeholderInitial={placeholderInitial}
|
|
projectKey={projectKey}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ProjectLeadingVisualStatus
|
|
iconDataUri={iconDataUri}
|
|
placeholderInitial={placeholderInitial}
|
|
projectKey={projectKey}
|
|
isArchiving={isArchiving}
|
|
shouldShowSyncedLoader={shouldShowSyncedLoader}
|
|
activeWorkspace={activeWorkspace}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ProjectRowTrailingActions({
|
|
project,
|
|
displayName,
|
|
canCreateWorktree,
|
|
isHovered,
|
|
isMobileBreakpoint,
|
|
isProjectActive,
|
|
onBeginWorkspaceSetup,
|
|
onRemoveProject,
|
|
removeProjectStatus,
|
|
}: {
|
|
project: SidebarProjectEntry;
|
|
displayName: string;
|
|
canCreateWorktree: boolean;
|
|
isHovered: boolean;
|
|
isMobileBreakpoint: boolean;
|
|
isProjectActive: boolean;
|
|
onBeginWorkspaceSetup: () => void;
|
|
onRemoveProject?: () => void;
|
|
removeProjectStatus: "idle" | "pending" | "success";
|
|
}) {
|
|
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
|
|
return (
|
|
<View style={styles.projectTrailingActions}>
|
|
{canCreateWorktree ? (
|
|
<NewWorktreeButton
|
|
displayName={displayName}
|
|
onPress={onBeginWorkspaceSetup}
|
|
visible={actionsVisible}
|
|
showShortcutHint={isProjectActive}
|
|
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
|
/>
|
|
) : null}
|
|
{onRemoveProject ? (
|
|
<View
|
|
style={!actionsVisible && styles.projectKebabButtonHidden}
|
|
pointerEvents={actionsVisible ? "auto" : "none"}
|
|
>
|
|
<ProjectKebabMenu
|
|
projectKey={project.projectKey}
|
|
projectPath={project.iconWorkingDir}
|
|
onRemoveProject={onRemoveProject}
|
|
removeProjectStatus={removeProjectStatus}
|
|
/>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const trash2LeadingIcon = <ThemedTrash2 size={14} uniProps={foregroundMutedColorMapping} />;
|
|
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
|
|
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
|
const markAsReadLeadingIcon = (
|
|
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
|
|
);
|
|
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
|
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
|
const openInNewWindowLeadingIcon = (
|
|
<ThemedExternalLink size={14} uniProps={foregroundMutedColorMapping} />
|
|
);
|
|
|
|
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
|
return (
|
|
<ThemedMoreVertical
|
|
size={14}
|
|
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ProjectKebabMenu({
|
|
projectKey,
|
|
projectPath,
|
|
onRemoveProject,
|
|
removeProjectStatus,
|
|
}: {
|
|
projectKey: string;
|
|
projectPath: string;
|
|
onRemoveProject: () => void;
|
|
removeProjectStatus: "idle" | "pending" | "success";
|
|
}) {
|
|
const toast = useToast();
|
|
const handleOpenProjectSettings = useCallback(() => {
|
|
if (projectKey.trim().length === 0) return;
|
|
router.navigate(buildProjectSettingsRoute(projectKey));
|
|
}, [projectKey]);
|
|
const canOpenProjectSettings = projectKey.trim().length > 0;
|
|
// Desktop-only: open a second window that lands on this project via the same
|
|
// open-project flow as a CLI launch. The project stays visible here too — no
|
|
// ownership, no move.
|
|
const canOpenInNewWindow = getIsElectron() && projectPath.trim().length > 0;
|
|
const handleOpenInNewWindow = useCallback(() => {
|
|
const trimmedPath = projectPath.trim();
|
|
if (trimmedPath.length === 0) return;
|
|
void getDesktopHost()
|
|
?.window?.openNew?.({ pendingOpenProjectPath: trimmedPath })
|
|
?.catch((error) => {
|
|
console.warn("[sidebar] openNew failed", error);
|
|
toast.error("Couldn't open a new window");
|
|
});
|
|
}, [projectPath, toast]);
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
hitSlop={8}
|
|
style={projectKebabStyle}
|
|
accessibilityRole={platformIsWeb ? undefined : "button"}
|
|
accessibilityLabel="Project actions"
|
|
testID={`sidebar-project-kebab-${projectKey}`}
|
|
>
|
|
{renderKebabTriggerIcon}
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" width={220}>
|
|
{canOpenProjectSettings ? (
|
|
<DropdownMenuItem
|
|
testID={`sidebar-project-menu-open-settings-${projectKey}`}
|
|
leading={settingsLeadingIcon}
|
|
onSelect={handleOpenProjectSettings}
|
|
>
|
|
Open project settings
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
{canOpenInNewWindow ? (
|
|
<DropdownMenuItem
|
|
testID={`sidebar-project-menu-open-new-window-${projectKey}`}
|
|
leading={openInNewWindowLeadingIcon}
|
|
onSelect={handleOpenInNewWindow}
|
|
>
|
|
Open in new window
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
<DropdownMenuItem
|
|
testID={`sidebar-project-menu-remove-${projectKey}`}
|
|
leading={trash2LeadingIcon}
|
|
status={removeProjectStatus}
|
|
pendingLabel="Removing..."
|
|
onSelect={onRemoveProject}
|
|
>
|
|
Remove project
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|
|
|
|
function WorkspaceRowRightGroup({
|
|
workspace,
|
|
isHovered,
|
|
isTouchPlatform,
|
|
isCreating,
|
|
showShortcutBadge,
|
|
shortcutNumber,
|
|
archiveLabel,
|
|
archiveStatus,
|
|
archivePendingLabel,
|
|
archiveShortcutKeys,
|
|
onArchive,
|
|
onMarkAsRead,
|
|
onCopyBranchName,
|
|
onCopyPath,
|
|
onRename,
|
|
}: {
|
|
workspace: SidebarWorkspaceEntry;
|
|
isHovered: boolean;
|
|
isTouchPlatform: boolean;
|
|
isCreating: boolean;
|
|
showShortcutBadge: boolean;
|
|
shortcutNumber: number | null;
|
|
archiveLabel?: string;
|
|
archiveStatus?: "idle" | "pending" | "success";
|
|
archivePendingLabel?: string;
|
|
archiveShortcutKeys?: ShortcutKey[][] | null;
|
|
onArchive?: () => void;
|
|
onMarkAsRead?: () => void;
|
|
onCopyBranchName?: () => void;
|
|
onCopyPath?: () => void;
|
|
onRename?: () => void;
|
|
}) {
|
|
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
|
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
|
|
const showKebabInSlot = showKebab && !showShortcut;
|
|
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
|
|
return (
|
|
<>
|
|
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
|
|
{shouldRenderActionSlot ? (
|
|
<SidebarWorkspaceTrailingActionSlot>
|
|
<SidebarWorkspaceTrailingActionBase
|
|
visible={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
|
|
>
|
|
{workspace.diffStat ? (
|
|
<DiffStat
|
|
additions={workspace.diffStat.additions}
|
|
deletions={workspace.diffStat.deletions}
|
|
/>
|
|
) : null}
|
|
</SidebarWorkspaceTrailingActionBase>
|
|
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
|
|
{onArchive ? (
|
|
<WorkspaceKebabMenu
|
|
workspaceKey={workspace.workspaceKey}
|
|
onCopyPath={onCopyPath}
|
|
onCopyBranchName={onCopyBranchName}
|
|
onRename={onRename}
|
|
onMarkAsRead={onMarkAsRead}
|
|
onArchive={onArchive}
|
|
archiveLabel={archiveLabel}
|
|
archiveStatus={archiveStatus}
|
|
archivePendingLabel={archivePendingLabel}
|
|
archiveShortcutKeys={archiveShortcutKeys}
|
|
/>
|
|
) : null}
|
|
</SidebarWorkspaceTrailingActionOverlay>
|
|
</SidebarWorkspaceTrailingActionSlot>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function WorkspaceKebabMenu({
|
|
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 ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
|
[archiveShortcutKeys],
|
|
);
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
hitSlop={8}
|
|
style={workspaceKebabStyle}
|
|
accessibilityRole={platformIsWeb ? undefined : "button"}
|
|
accessibilityLabel="Workspace actions"
|
|
testID={`sidebar-workspace-kebab-${workspaceKey}`}
|
|
>
|
|
{renderKebabTriggerIcon}
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" width={260}>
|
|
{onCopyPath ? (
|
|
<DropdownMenuItem
|
|
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
|
|
leading={copyLeadingIcon}
|
|
onSelect={onCopyPath}
|
|
>
|
|
Copy path
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
{onCopyBranchName ? (
|
|
<DropdownMenuItem
|
|
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
|
|
leading={copyLeadingIcon}
|
|
onSelect={onCopyBranchName}
|
|
>
|
|
Copy branch name
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
{onRename ? (
|
|
<DropdownMenuItem
|
|
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
|
|
leading={renameLeadingIcon}
|
|
onSelect={onRename}
|
|
>
|
|
Rename workspace
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
{onMarkAsRead ? (
|
|
<DropdownMenuItem
|
|
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
|
|
leading={markAsReadLeadingIcon}
|
|
onSelect={onMarkAsRead}
|
|
>
|
|
Mark as read
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
<DropdownMenuItem
|
|
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
|
leading={archiveLeadingIcon}
|
|
trailing={archiveTrailing}
|
|
status={archiveStatus}
|
|
pendingLabel={archivePendingLabel}
|
|
onSelect={onArchive}
|
|
>
|
|
{archiveLabel ?? "Archive"}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|
|
|
|
function ProjectIcon({
|
|
iconDataUri,
|
|
placeholderInitial,
|
|
projectKey,
|
|
}: {
|
|
iconDataUri: string | null;
|
|
placeholderInitial: string;
|
|
projectKey: string;
|
|
}) {
|
|
return (
|
|
<ProjectIconView
|
|
iconDataUri={iconDataUri}
|
|
initial={placeholderInitial}
|
|
projectKey={projectKey}
|
|
imageStyle={styles.projectIcon}
|
|
fallbackStyle={styles.projectIconFallback}
|
|
textStyle={styles.projectIconFallbackText}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ProjectLeadingVisualStatus({
|
|
iconDataUri,
|
|
placeholderInitial,
|
|
projectKey,
|
|
isArchiving,
|
|
shouldShowSyncedLoader,
|
|
activeWorkspace,
|
|
}: {
|
|
iconDataUri: string | null;
|
|
placeholderInitial: string;
|
|
projectKey: string;
|
|
isArchiving: boolean;
|
|
shouldShowSyncedLoader: boolean;
|
|
activeWorkspace: SidebarWorkspaceEntry;
|
|
}) {
|
|
if (isArchiving) {
|
|
return (
|
|
<View style={styles.projectLeadingVisualSlot}>
|
|
<ThemedActivityIndicator size={8} uniProps={foregroundMutedColorMapping} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (shouldShowSyncedLoader) {
|
|
return (
|
|
<View style={styles.projectLeadingVisualSlot}>
|
|
<ThemedSyncedLoader size={11} uniProps={syncedLoaderColorMapping} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (activeWorkspace.statusBucket === "needs_input") {
|
|
return (
|
|
<View style={styles.projectLeadingVisualSlot}>
|
|
<ThemedCircleAlert size={14} uniProps={amberColorMapping} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const dotColorStyle = getStatusDotColorStyle(activeWorkspace.statusBucket);
|
|
const statusDotSize = isEmphasizedStatusDotBucket(activeWorkspace.statusBucket)
|
|
? EMPHASIZED_STATUS_DOT_SIZE
|
|
: DEFAULT_STATUS_DOT_SIZE;
|
|
const statusDotOffset =
|
|
statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
|
|
? EMPHASIZED_STATUS_DOT_OFFSET
|
|
: DEFAULT_STATUS_DOT_OFFSET;
|
|
|
|
return (
|
|
<View style={styles.projectLeadingVisualSlot}>
|
|
<ProjectIcon
|
|
iconDataUri={iconDataUri}
|
|
placeholderInitial={placeholderInitial}
|
|
projectKey={projectKey}
|
|
/>
|
|
{dotColorStyle ? (
|
|
<StatusDotOverlay
|
|
dotColorStyle={dotColorStyle}
|
|
size={statusDotSize}
|
|
offset={statusDotOffset}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function ProjectInlineChevron({ chevron }: { chevron: "expand" | "collapse" | null }) {
|
|
if (chevron === null) {
|
|
return null;
|
|
}
|
|
if (chevron === "collapse") {
|
|
return <ChevronDown size={14} color="#9ca3af" />;
|
|
}
|
|
return <ChevronRight size={14} color="#9ca3af" />;
|
|
}
|
|
|
|
function NewWorktreeButton({
|
|
displayName,
|
|
onPress,
|
|
visible,
|
|
loading = false,
|
|
testID,
|
|
showShortcutHint = false,
|
|
}: {
|
|
displayName: string;
|
|
onPress: () => void;
|
|
visible: boolean;
|
|
loading?: boolean;
|
|
testID: string;
|
|
showShortcutHint?: boolean;
|
|
}) {
|
|
const newWorktreeKeys = useShortcutKeys("new-worktree");
|
|
|
|
const pressableStyle = useCallback(
|
|
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
|
styles.projectIconActionButton,
|
|
!visible && styles.projectIconActionButtonHidden,
|
|
(Boolean(hovered) || pressed) && !loading && styles.projectIconActionButtonHovered,
|
|
],
|
|
[visible, loading],
|
|
);
|
|
|
|
const handlePress = useCallback(
|
|
(event: GestureResponderEvent) => {
|
|
event.stopPropagation();
|
|
onPress();
|
|
},
|
|
[onPress],
|
|
);
|
|
|
|
return (
|
|
<View style={styles.projectTrailingControlSlot} pointerEvents={visible ? "auto" : "none"}>
|
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
|
<TooltipTrigger asChild disabled={!visible}>
|
|
<Pressable
|
|
style={pressableStyle}
|
|
onPress={handlePress}
|
|
disabled={loading}
|
|
accessibilityRole={platformIsWeb ? undefined : "button"}
|
|
accessibilityLabel={`Create a new workspace for ${displayName}`}
|
|
testID={testID}
|
|
>
|
|
{({ hovered, pressed }) =>
|
|
loading ? (
|
|
<ThemedActivityIndicator size={14} uniProps={foregroundMutedColorMapping} />
|
|
) : (
|
|
<ThemedFolderPlus
|
|
size={15}
|
|
uniProps={
|
|
hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
</Pressable>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" align="center" offset={8}>
|
|
<View style={styles.projectActionTooltipRow}>
|
|
<Text style={styles.projectActionTooltipText}>New workspace</Text>
|
|
{showShortcutHint && newWorktreeKeys ? (
|
|
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
|
|
) : null}
|
|
</View>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function useLongPressDragInteraction(input: {
|
|
drag: () => void;
|
|
menuController: ReturnType<typeof useContextMenu> | null;
|
|
}) {
|
|
const didLongPressRef = useRef(false);
|
|
const dragArmedRef = useRef(false);
|
|
const dragActivatedRef = useRef(false);
|
|
const didStartDragRef = useRef(false);
|
|
const scrollIntentRef = useRef(false);
|
|
const menuOpenedRef = useRef(false);
|
|
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
|
|
const touchCurrentRef = useRef<{ x: number; y: number } | null>(null);
|
|
const dragArmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const contextMenuTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const clearTimers = useCallback(() => {
|
|
if (dragArmTimerRef.current) {
|
|
clearTimeout(dragArmTimerRef.current);
|
|
dragArmTimerRef.current = null;
|
|
}
|
|
if (contextMenuTimerRef.current) {
|
|
clearTimeout(contextMenuTimerRef.current);
|
|
contextMenuTimerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const openContextMenuAtStartPoint = useCallback(() => {
|
|
if (!input.menuController || !touchStartRef.current) {
|
|
return;
|
|
}
|
|
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
|
input.menuController.setAnchorRect({
|
|
x: touchStartRef.current.x,
|
|
y: touchStartRef.current.y + statusBarHeight,
|
|
width: 0,
|
|
height: 0,
|
|
});
|
|
input.menuController.setOpen(true);
|
|
menuOpenedRef.current = true;
|
|
didLongPressRef.current = true;
|
|
}, [input.menuController]);
|
|
|
|
const handleLongPress = useCallback(() => {
|
|
// Manual timers own long-press behavior on mobile.
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
clearTimers();
|
|
};
|
|
}, [clearTimers]);
|
|
|
|
const armTimers = useCallback(() => {
|
|
clearTimers();
|
|
|
|
const DRAG_ARM_DELAY_MS = 180;
|
|
const DRAG_ARM_STATIONARY_SLOP_PX = 4;
|
|
const CONTEXT_MENU_DELAY_MS = 450;
|
|
const CONTEXT_MENU_STATIONARY_SLOP_PX = 6;
|
|
|
|
dragArmTimerRef.current = setTimeout(() => {
|
|
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
|
|
return;
|
|
}
|
|
const start = touchStartRef.current;
|
|
const current = touchCurrentRef.current ?? start;
|
|
if (!start || !current) {
|
|
return;
|
|
}
|
|
const dx = current.x - start.x;
|
|
const dy = current.y - start.y;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
if (distance > DRAG_ARM_STATIONARY_SLOP_PX) {
|
|
return;
|
|
}
|
|
dragArmedRef.current = true;
|
|
dragActivatedRef.current = true;
|
|
didLongPressRef.current = true;
|
|
void Haptics.selectionAsync().catch(() => {});
|
|
input.drag();
|
|
}, DRAG_ARM_DELAY_MS);
|
|
|
|
if (!input.menuController || platformIsWeb) {
|
|
return;
|
|
}
|
|
|
|
contextMenuTimerRef.current = setTimeout(() => {
|
|
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
|
|
return;
|
|
}
|
|
const start = touchStartRef.current;
|
|
const current = touchCurrentRef.current ?? start;
|
|
if (!start || !current) {
|
|
return;
|
|
}
|
|
const dx = current.x - start.x;
|
|
const dy = current.y - start.y;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
if (distance > CONTEXT_MENU_STATIONARY_SLOP_PX) {
|
|
return;
|
|
}
|
|
void Haptics.selectionAsync().catch(() => {});
|
|
openContextMenuAtStartPoint();
|
|
}, CONTEXT_MENU_DELAY_MS);
|
|
}, [clearTimers, input, openContextMenuAtStartPoint]);
|
|
|
|
const handleDragIntent = useCallback(
|
|
(_details: { dx: number; dy: number; distance: number }) => {
|
|
if (!dragActivatedRef.current) {
|
|
return;
|
|
}
|
|
didStartDragRef.current = true;
|
|
didLongPressRef.current = true;
|
|
clearTimers();
|
|
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
|
|
},
|
|
[clearTimers],
|
|
);
|
|
|
|
const handleScrollIntent = useCallback(
|
|
(_details: { dx: number; dy: number; distance: number }) => {
|
|
scrollIntentRef.current = true;
|
|
didLongPressRef.current = true;
|
|
clearTimers();
|
|
},
|
|
[clearTimers],
|
|
);
|
|
|
|
const handleSwipeIntent = useCallback(
|
|
(_details: { dx: number; dy: number; distance: number }) => {
|
|
didLongPressRef.current = true;
|
|
clearTimers();
|
|
},
|
|
[clearTimers],
|
|
);
|
|
|
|
const handlePressIn = useCallback(
|
|
(event: GestureResponderEvent) => {
|
|
didLongPressRef.current = false;
|
|
dragArmedRef.current = false;
|
|
dragActivatedRef.current = false;
|
|
didStartDragRef.current = false;
|
|
scrollIntentRef.current = false;
|
|
menuOpenedRef.current = false;
|
|
touchStartRef.current = {
|
|
x: event.nativeEvent.pageX,
|
|
y: event.nativeEvent.pageY,
|
|
};
|
|
touchCurrentRef.current = {
|
|
x: event.nativeEvent.pageX,
|
|
y: event.nativeEvent.pageY,
|
|
};
|
|
armTimers();
|
|
},
|
|
[armTimers],
|
|
);
|
|
|
|
const handleTouchMove = useCallback(
|
|
(event: GestureResponderEvent) => {
|
|
const start = touchStartRef.current;
|
|
if (!start || didStartDragRef.current || menuOpenedRef.current) {
|
|
return;
|
|
}
|
|
|
|
const touch = event?.nativeEvent?.touches?.[0] ?? event?.nativeEvent;
|
|
const x = touch?.pageX;
|
|
const y = touch?.pageY;
|
|
if (typeof x !== "number" || typeof y !== "number") {
|
|
return;
|
|
}
|
|
|
|
const current = { x, y };
|
|
touchCurrentRef.current = current;
|
|
const dx = current.x - start.x;
|
|
const dy = current.y - start.y;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
const decision = decideLongPressMove({
|
|
dragArmed: dragArmedRef.current,
|
|
didStartDrag: didStartDragRef.current,
|
|
startPoint: start,
|
|
currentPoint: current,
|
|
});
|
|
|
|
if (decision === "vertical_scroll") {
|
|
handleScrollIntent({ dx, dy, distance });
|
|
return;
|
|
}
|
|
|
|
if (decision === "horizontal_swipe" || decision === "cancel_long_press") {
|
|
handleSwipeIntent({ dx, dy, distance });
|
|
return;
|
|
}
|
|
|
|
if (decision === "start_drag") {
|
|
handleDragIntent({ dx, dy, distance });
|
|
}
|
|
},
|
|
[handleDragIntent, handleScrollIntent, handleSwipeIntent],
|
|
);
|
|
|
|
const handlePressOut = useCallback(() => {
|
|
clearTimers();
|
|
dragArmedRef.current = false;
|
|
dragActivatedRef.current = false;
|
|
touchStartRef.current = null;
|
|
touchCurrentRef.current = null;
|
|
}, [clearTimers]);
|
|
|
|
return {
|
|
didLongPressRef,
|
|
handleLongPress,
|
|
handlePressIn,
|
|
handleTouchMove,
|
|
handlePressOut,
|
|
};
|
|
}
|
|
|
|
function ProjectHeaderRow({
|
|
project,
|
|
displayName,
|
|
iconDataUri,
|
|
workspace,
|
|
selected = false,
|
|
chevron,
|
|
onPress,
|
|
serverId,
|
|
canCreateWorktree,
|
|
isProjectActive = false,
|
|
onWorkspacePress,
|
|
onWorktreeCreated: _onWorktreeCreated,
|
|
shortcutNumber = null,
|
|
showShortcutBadge = false,
|
|
drag,
|
|
isDragging,
|
|
isArchiving = false,
|
|
menuController,
|
|
onRemoveProject,
|
|
removeProjectStatus = "idle",
|
|
dragHandleProps,
|
|
}: ProjectHeaderRowProps) {
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const isMobileBreakpoint = useIsCompactFormFactor();
|
|
const handleBeginWorkspaceSetup = useCallback(() => {
|
|
if (!serverId) {
|
|
return;
|
|
}
|
|
onWorkspacePress?.();
|
|
router.navigate(
|
|
buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, {
|
|
displayName,
|
|
projectId: project.projectKey,
|
|
}) as Href,
|
|
);
|
|
}, [displayName, onWorkspacePress, project.iconWorkingDir, project.projectKey, serverId]);
|
|
const interaction = useLongPressDragInteraction({
|
|
drag,
|
|
menuController,
|
|
});
|
|
const {
|
|
role: _dragRole,
|
|
tabIndex: _dragTabIndex,
|
|
"aria-roledescription": _dragRoleDescription,
|
|
...dragAttributes
|
|
} = dragHandleProps?.attributes ?? {};
|
|
|
|
const handlePress = useCallback(() => {
|
|
if (interaction.didLongPressRef.current) {
|
|
interaction.didLongPressRef.current = false;
|
|
return;
|
|
}
|
|
onPress();
|
|
}, [interaction.didLongPressRef, onPress]);
|
|
|
|
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
|
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
|
|
|
const projectRowStyle = useCallback(
|
|
({ pressed }: PressableStateCallbackType) => [
|
|
styles.projectRow,
|
|
isDragging && styles.projectRowDragging,
|
|
selected && styles.sidebarRowSelected,
|
|
isHovered && styles.projectRowHovered,
|
|
pressed && styles.projectRowPressed,
|
|
],
|
|
[isDragging, selected, isHovered],
|
|
);
|
|
|
|
const rowChildren = (
|
|
<>
|
|
<View style={styles.projectRowLeft}>
|
|
<ProjectLeadingVisual
|
|
displayName={displayName}
|
|
iconDataUri={iconDataUri}
|
|
workspace={workspace}
|
|
projectKey={project.projectKey}
|
|
chevron={chevron}
|
|
showChevron={isHovered && chevron !== null}
|
|
isArchiving={isArchiving}
|
|
/>
|
|
|
|
<View style={styles.projectTitleGroup}>
|
|
<Text style={styles.projectTitle} numberOfLines={1}>
|
|
{displayName}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
<ProjectRowTrailingActions
|
|
project={project}
|
|
displayName={displayName}
|
|
canCreateWorktree={canCreateWorktree}
|
|
isHovered={isHovered}
|
|
isMobileBreakpoint={isMobileBreakpoint}
|
|
isProjectActive={isProjectActive}
|
|
onBeginWorkspaceSetup={handleBeginWorkspaceSetup}
|
|
onRemoveProject={onRemoveProject}
|
|
removeProjectStatus={removeProjectStatus}
|
|
/>
|
|
{showShortcutBadge && shortcutNumber !== null ? (
|
|
<View style={styles.projectShortcutBadgeOverlay} pointerEvents="none">
|
|
<SidebarWorkspaceShortcutBadge number={shortcutNumber} />
|
|
</View>
|
|
) : null}
|
|
</>
|
|
);
|
|
|
|
if (menuController) {
|
|
return (
|
|
<View
|
|
{...dragAttributes}
|
|
{...dragHandleProps?.listeners}
|
|
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
|
onPointerEnter={handlePointerEnter}
|
|
onPointerLeave={handlePointerLeave}
|
|
>
|
|
<ContextMenuTrigger
|
|
enabledOnMobile={false}
|
|
accessibilityRole="button"
|
|
style={projectRowStyle}
|
|
onPressIn={interaction.handlePressIn}
|
|
onTouchMove={interaction.handleTouchMove}
|
|
onPressOut={interaction.handlePressOut}
|
|
onPress={handlePress}
|
|
testID={`sidebar-project-row-${project.projectKey}`}
|
|
>
|
|
{rowChildren}
|
|
</ContextMenuTrigger>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View
|
|
{...dragAttributes}
|
|
{...dragHandleProps?.listeners}
|
|
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
|
onPointerEnter={handlePointerEnter}
|
|
onPointerLeave={handlePointerLeave}
|
|
>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
style={projectRowStyle}
|
|
onPressIn={interaction.handlePressIn}
|
|
onTouchMove={interaction.handleTouchMove}
|
|
onPressOut={interaction.handlePressOut}
|
|
onPress={handlePress}
|
|
testID={`sidebar-project-row-${project.projectKey}`}
|
|
>
|
|
{rowChildren}
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function WorkspaceRowInner({
|
|
workspace,
|
|
selected,
|
|
shortcutNumber,
|
|
showShortcutBadge,
|
|
onPress,
|
|
drag,
|
|
isDragging,
|
|
isArchiving,
|
|
isCreating = false,
|
|
dragHandleProps,
|
|
menuController,
|
|
archiveLabel,
|
|
archiveStatus = "idle",
|
|
archivePendingLabel,
|
|
onArchive,
|
|
onCopyBranchName,
|
|
onCopyPath,
|
|
onRename,
|
|
archiveShortcutKeys,
|
|
}: WorkspaceRowInnerProps) {
|
|
const _isCompact = useIsCompactFormFactor();
|
|
const isTouchPlatform = platformIsNative;
|
|
const interaction = useLongPressDragInteraction({
|
|
drag,
|
|
menuController,
|
|
});
|
|
const {
|
|
role: _dragRole,
|
|
tabIndex: _dragTabIndex,
|
|
"aria-roledescription": _dragRoleDescription,
|
|
...dragAttributes
|
|
} = dragHandleProps?.attributes ?? {};
|
|
|
|
const handlePress = useCallback(() => {
|
|
if (interaction.didLongPressRef.current) {
|
|
interaction.didLongPressRef.current = false;
|
|
return;
|
|
}
|
|
onPress();
|
|
}, [interaction.didLongPressRef, onPress]);
|
|
|
|
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
|
|
|
return (
|
|
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
|
|
{({ isHovered, hoverHandlers }) => {
|
|
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 workspaceRowStyle = getProjectWorkspaceRowStyle({
|
|
isDragging,
|
|
selected,
|
|
isHovered,
|
|
});
|
|
return (
|
|
<View
|
|
{...dragAttributes}
|
|
{...dragHandleProps?.listeners}
|
|
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
|
style={styles.workspaceRowContainer}
|
|
{...hoverHandlers}
|
|
>
|
|
<Pressable
|
|
disabled={isArchiving}
|
|
aria-selected={selected}
|
|
accessibilityRole="button"
|
|
accessibilityState={accessibilityState}
|
|
style={workspaceRowStyle}
|
|
onPressIn={interaction.handlePressIn}
|
|
onTouchMove={interaction.handleTouchMove}
|
|
onPressOut={interaction.handlePressOut}
|
|
onPress={handlePress}
|
|
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
|
>
|
|
<SidebarWorkspaceRowContent
|
|
workspace={workspace}
|
|
scriptIconKind={scriptIconKind}
|
|
isHovered={isHovered}
|
|
isLoading={isArchiving || isCreating}
|
|
isCreating={isCreating}
|
|
shortcutNumber={shortcutNumber}
|
|
showShortcutBadge={showShortcutBadge}
|
|
>
|
|
<WorkspaceRowRightGroup
|
|
workspace={workspace}
|
|
isHovered={isHovered}
|
|
isTouchPlatform={isTouchPlatform}
|
|
isCreating={isCreating}
|
|
showShortcutBadge={showShortcutBadge}
|
|
shortcutNumber={shortcutNumber}
|
|
archiveLabel={archiveLabel}
|
|
archiveStatus={archiveStatus}
|
|
archivePendingLabel={archivePendingLabel}
|
|
archiveShortcutKeys={archiveShortcutKeys}
|
|
onArchive={onArchive}
|
|
onCopyBranchName={onCopyBranchName}
|
|
onCopyPath={onCopyPath}
|
|
onRename={onRename}
|
|
/>
|
|
</SidebarWorkspaceRowContent>
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}}
|
|
</SidebarWorkspaceRowFrame>
|
|
);
|
|
}
|
|
|
|
function WorkspaceRowWithMenu({
|
|
workspace,
|
|
selected,
|
|
shortcutNumber,
|
|
showShortcutBadge,
|
|
onPress,
|
|
drag,
|
|
isDragging,
|
|
dragHandleProps,
|
|
canCopyBranchName,
|
|
isCreating = false,
|
|
}: {
|
|
workspace: SidebarWorkspaceEntry;
|
|
selected: boolean;
|
|
shortcutNumber: number | null;
|
|
showShortcutBadge: boolean;
|
|
onPress: () => void;
|
|
drag: () => void;
|
|
isDragging: boolean;
|
|
dragHandleProps?: DraggableListDragHandleProps;
|
|
canCopyBranchName: boolean;
|
|
isCreating?: boolean;
|
|
}) {
|
|
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: selectionForSelectedWorkspace(selected, workspace),
|
|
});
|
|
}, [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;
|
|
}
|
|
|
|
if (!archiveDirectory) {
|
|
toast.error("Workspace path not available");
|
|
return;
|
|
}
|
|
|
|
redirectAfterArchive();
|
|
|
|
void archiveWorktree({
|
|
serverId: workspace.serverId,
|
|
cwd: archiveDirectory,
|
|
worktreePath: archiveDirectory,
|
|
}).catch((error) => {
|
|
const message = error instanceof Error ? error.message : "Failed to archive worktree";
|
|
toast.error(message);
|
|
});
|
|
}, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]);
|
|
|
|
const handleArchiveWorktree = useCallback(() => {
|
|
void archiveWorktreeAfterConfirmation();
|
|
}, [archiveWorktreeAfterConfirmation]);
|
|
|
|
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("Host is not connected");
|
|
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, toast, workspace]);
|
|
|
|
const handleArchiveWorkspace = useCallback(() => {
|
|
void hideWorkspaceAfterConfirmation();
|
|
}, [hideWorkspaceAfterConfirmation]);
|
|
|
|
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("Host is not connected");
|
|
}
|
|
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 {
|
|
handleArchiveWorkspace();
|
|
}
|
|
return true;
|
|
},
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<WorkspaceRowInner
|
|
workspace={workspace}
|
|
selected={selected}
|
|
shortcutNumber={shortcutNumber}
|
|
showShortcutBadge={showShortcutBadge}
|
|
onPress={onPress}
|
|
drag={drag}
|
|
isDragging={isDragging}
|
|
isArchiving={isArchiving}
|
|
isCreating={isCreating}
|
|
dragHandleProps={dragHandleProps}
|
|
menuController={null}
|
|
archiveLabel={isWorktree ? "Archive worktree" : "Hide from sidebar"}
|
|
archiveStatus={getWorkspaceArchiveStatus(isWorktree, archiveStatus, isArchivingWorkspace)}
|
|
archivePendingLabel={isWorktree ? "Archiving..." : "Hiding..."}
|
|
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
|
|
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
|
onCopyPath={handleCopyPath}
|
|
onRename={canCopyBranchName ? handleOpenRename : undefined}
|
|
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
|
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
|
/>
|
|
<AdaptiveRenameModal
|
|
visible={isRenameOpen}
|
|
title="Rename workspace"
|
|
initialValue={workspace.name}
|
|
placeholder="branch-name"
|
|
submitLabel="Rename"
|
|
validate={validateRenameSlug}
|
|
maxLength={MAX_SLUG_LENGTH}
|
|
onClose={handleCloseRename}
|
|
onSubmit={handleSubmitRename}
|
|
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function NonGitProjectRowWithMenuContent({
|
|
project,
|
|
displayName,
|
|
iconDataUri,
|
|
workspace,
|
|
selected,
|
|
onPress,
|
|
shortcutNumber,
|
|
showShortcutBadge,
|
|
drag,
|
|
isDragging,
|
|
dragHandleProps,
|
|
}: {
|
|
project: SidebarProjectEntry;
|
|
displayName: string;
|
|
iconDataUri: string | null;
|
|
workspace: SidebarWorkspaceEntry;
|
|
selected: boolean;
|
|
onPress: () => void;
|
|
shortcutNumber: number | null;
|
|
showShortcutBadge: boolean;
|
|
drag: () => void;
|
|
isDragging: boolean;
|
|
dragHandleProps?: DraggableListDragHandleProps;
|
|
}) {
|
|
const toast = useToast();
|
|
const contextMenu = useContextMenu();
|
|
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
|
const redirectAfterArchive = useCallback(() => {
|
|
redirectIfArchivingActiveWorkspace({
|
|
serverId: workspace.serverId,
|
|
workspaceId: workspace.workspaceId,
|
|
activeWorkspaceSelection: selectionForSelectedWorkspace(selected, workspace),
|
|
});
|
|
}, [selected, workspace]);
|
|
|
|
const handleArchiveWorkspace = useCallback(() => {
|
|
if (isArchivingWorkspace) {
|
|
return;
|
|
}
|
|
|
|
void (async () => {
|
|
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("Host is not connected");
|
|
return;
|
|
}
|
|
|
|
setIsArchivingWorkspace(true);
|
|
void (async () => {
|
|
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, toast, workspace]);
|
|
|
|
return (
|
|
<>
|
|
<ProjectHeaderRow
|
|
project={project}
|
|
displayName={displayName}
|
|
iconDataUri={iconDataUri}
|
|
workspace={workspace}
|
|
selected={selected}
|
|
chevron={null}
|
|
onPress={onPress}
|
|
serverId={null}
|
|
canCreateWorktree={false}
|
|
shortcutNumber={shortcutNumber}
|
|
showShortcutBadge={showShortcutBadge}
|
|
drag={drag}
|
|
isDragging={isDragging}
|
|
isArchiving={isArchivingWorkspace}
|
|
menuController={contextMenu}
|
|
dragHandleProps={dragHandleProps}
|
|
/>
|
|
<ContextMenuContent
|
|
align="start"
|
|
width={220}
|
|
mobileMode="sheet"
|
|
testID={`sidebar-workspace-context-${workspace.workspaceKey}`}
|
|
>
|
|
<ContextMenuItem
|
|
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
|
|
status={isArchivingWorkspace ? "pending" : "idle"}
|
|
pendingLabel="Hiding..."
|
|
destructive
|
|
onSelect={handleArchiveWorkspace}
|
|
>
|
|
Hide from sidebar
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function NonGitProjectRowWithMenu(props: {
|
|
project: SidebarProjectEntry;
|
|
displayName: string;
|
|
iconDataUri: string | null;
|
|
workspace: SidebarWorkspaceEntry;
|
|
selected: boolean;
|
|
onPress: () => void;
|
|
shortcutNumber: number | null;
|
|
showShortcutBadge: boolean;
|
|
drag: () => void;
|
|
isDragging: boolean;
|
|
dragHandleProps?: DraggableListDragHandleProps;
|
|
}) {
|
|
return (
|
|
<ContextMenu>
|
|
<NonGitProjectRowWithMenuContent {...props} />
|
|
</ContextMenu>
|
|
);
|
|
}
|
|
|
|
function FlattenedProjectRow({
|
|
project,
|
|
displayName,
|
|
iconDataUri,
|
|
rowModel,
|
|
onPress,
|
|
serverId,
|
|
onWorkspacePress,
|
|
onWorktreeCreated,
|
|
shortcutNumber,
|
|
showShortcutBadge,
|
|
drag,
|
|
isDragging,
|
|
dragHandleProps,
|
|
isProjectActive = false,
|
|
onRemoveProject,
|
|
removeProjectStatus,
|
|
selectionEnabled,
|
|
activeWorkspaceSelection,
|
|
}: {
|
|
project: SidebarProjectEntry;
|
|
displayName: string;
|
|
iconDataUri: string | null;
|
|
rowModel: Extract<ReturnType<typeof buildSidebarProjectRowModel>, { kind: "workspace_link" }>;
|
|
onPress: () => void;
|
|
serverId: string | null;
|
|
onWorkspacePress?: () => void;
|
|
onWorktreeCreated?: (workspaceId: string) => void;
|
|
shortcutNumber: number | null;
|
|
showShortcutBadge: boolean;
|
|
drag: () => void;
|
|
isDragging: boolean;
|
|
dragHandleProps?: DraggableListDragHandleProps;
|
|
isProjectActive?: boolean;
|
|
onRemoveProject?: () => void;
|
|
removeProjectStatus?: "idle" | "pending";
|
|
selectionEnabled: boolean;
|
|
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
|
}) {
|
|
const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId);
|
|
const selected = isWorkspaceSelected({
|
|
selection: activeWorkspaceSelection,
|
|
serverId,
|
|
workspaceId: rowModel.workspace.workspaceId,
|
|
enabled: selectionEnabled,
|
|
});
|
|
|
|
if (!workspace) {
|
|
return null;
|
|
}
|
|
|
|
if (project.projectKind === "directory") {
|
|
return (
|
|
<NonGitProjectRowWithMenu
|
|
project={project}
|
|
displayName={displayName}
|
|
iconDataUri={iconDataUri}
|
|
workspace={workspace}
|
|
selected={selected}
|
|
onPress={onPress}
|
|
shortcutNumber={shortcutNumber}
|
|
showShortcutBadge={showShortcutBadge}
|
|
drag={drag}
|
|
isDragging={isDragging}
|
|
dragHandleProps={dragHandleProps}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ProjectHeaderRow
|
|
project={project}
|
|
displayName={displayName}
|
|
iconDataUri={iconDataUri}
|
|
workspace={workspace}
|
|
selected={selected}
|
|
chevron={rowModel.chevron}
|
|
onPress={onPress}
|
|
serverId={serverId}
|
|
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
|
|
isProjectActive={isProjectActive}
|
|
onWorkspacePress={onWorkspacePress}
|
|
onWorktreeCreated={onWorktreeCreated}
|
|
shortcutNumber={shortcutNumber}
|
|
showShortcutBadge={showShortcutBadge}
|
|
drag={drag}
|
|
isDragging={isDragging}
|
|
menuController={null}
|
|
onRemoveProject={onRemoveProject}
|
|
removeProjectStatus={removeProjectStatus}
|
|
dragHandleProps={dragHandleProps}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface WorkspaceRowItemProps {
|
|
workspace: SidebarWorkspaceEntry;
|
|
shortcutNumber: number | null;
|
|
showShortcutBadge: boolean;
|
|
canCopyBranchName: boolean;
|
|
isCreating?: boolean;
|
|
selectionEnabled: boolean;
|
|
serverId: string | null;
|
|
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
|
onWorkspacePress?: () => void;
|
|
drag?: () => void;
|
|
isDragging?: boolean;
|
|
dragHandleProps?: DraggableListDragHandleProps;
|
|
}
|
|
|
|
function WorkspaceRowItem({
|
|
workspace,
|
|
shortcutNumber,
|
|
showShortcutBadge,
|
|
canCopyBranchName,
|
|
isCreating = false,
|
|
selectionEnabled,
|
|
serverId,
|
|
activeWorkspaceSelection,
|
|
onWorkspacePress,
|
|
drag,
|
|
isDragging = false,
|
|
dragHandleProps,
|
|
}: WorkspaceRowItemProps) {
|
|
const handlePress = useCallback(() => {
|
|
if (!serverId) {
|
|
return;
|
|
}
|
|
onWorkspacePress?.();
|
|
navigateToWorkspace(serverId, workspace.workspaceId);
|
|
}, [serverId, onWorkspacePress, workspace.workspaceId]);
|
|
|
|
return (
|
|
<WorkspaceRow
|
|
workspace={workspace}
|
|
shortcutNumber={shortcutNumber}
|
|
showShortcutBadge={showShortcutBadge}
|
|
canCopyBranchName={canCopyBranchName}
|
|
isCreating={isCreating}
|
|
selected={isWorkspaceSelected({
|
|
selection: activeWorkspaceSelection,
|
|
serverId: workspace.serverId,
|
|
workspaceId: workspace.workspaceId,
|
|
enabled: selectionEnabled,
|
|
})}
|
|
onPress={handlePress}
|
|
drag={drag ?? noop}
|
|
isDragging={isDragging}
|
|
dragHandleProps={dragHandleProps}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function areWorkspaceRowItemPropsEqual(
|
|
previous: WorkspaceRowItemProps,
|
|
next: WorkspaceRowItemProps,
|
|
): boolean {
|
|
const previousSelected = isWorkspaceSelected({
|
|
selection: previous.activeWorkspaceSelection,
|
|
serverId: previous.workspace.serverId,
|
|
workspaceId: previous.workspace.workspaceId,
|
|
enabled: previous.selectionEnabled,
|
|
});
|
|
const nextSelected = isWorkspaceSelected({
|
|
selection: next.activeWorkspaceSelection,
|
|
serverId: next.workspace.serverId,
|
|
workspaceId: next.workspace.workspaceId,
|
|
enabled: next.selectionEnabled,
|
|
});
|
|
return (
|
|
previous.workspace === next.workspace &&
|
|
previous.shortcutNumber === next.shortcutNumber &&
|
|
previous.showShortcutBadge === next.showShortcutBadge &&
|
|
previous.canCopyBranchName === next.canCopyBranchName &&
|
|
previous.isCreating === next.isCreating &&
|
|
previous.serverId === next.serverId &&
|
|
previous.onWorkspacePress === next.onWorkspacePress &&
|
|
previous.drag === next.drag &&
|
|
previous.isDragging === next.isDragging &&
|
|
previous.dragHandleProps === next.dragHandleProps &&
|
|
previousSelected === nextSelected
|
|
);
|
|
}
|
|
|
|
const MemoWorkspaceRowItem = memo(WorkspaceRowItem, areWorkspaceRowItemPropsEqual);
|
|
|
|
function WorkspaceRow({
|
|
workspace,
|
|
shortcutNumber,
|
|
showShortcutBadge,
|
|
onPress,
|
|
drag,
|
|
isDragging,
|
|
dragHandleProps,
|
|
canCopyBranchName,
|
|
isCreating = false,
|
|
selected,
|
|
}: {
|
|
workspace: SidebarWorkspaceEntry;
|
|
shortcutNumber: number | null;
|
|
showShortcutBadge: boolean;
|
|
onPress: () => void;
|
|
drag: () => void;
|
|
isDragging: boolean;
|
|
dragHandleProps?: DraggableListDragHandleProps;
|
|
canCopyBranchName: boolean;
|
|
isCreating?: boolean;
|
|
selected: boolean;
|
|
}) {
|
|
const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
|
|
|
if (!hydratedWorkspace) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<WorkspaceRowWithMenu
|
|
workspace={hydratedWorkspace}
|
|
selected={selected}
|
|
shortcutNumber={shortcutNumber}
|
|
showShortcutBadge={showShortcutBadge}
|
|
onPress={onPress}
|
|
drag={drag}
|
|
isDragging={isDragging}
|
|
dragHandleProps={dragHandleProps}
|
|
canCopyBranchName={canCopyBranchName}
|
|
isCreating={isCreating}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ProjectBlock({
|
|
project,
|
|
collapsed,
|
|
displayName,
|
|
iconDataUri,
|
|
serverId,
|
|
selectionEnabled,
|
|
showShortcutBadges,
|
|
shortcutIndexByWorkspaceKey,
|
|
parentGestureRef,
|
|
onToggleCollapsed,
|
|
onWorkspacePress,
|
|
onWorkspaceReorder,
|
|
onWorktreeCreated,
|
|
drag,
|
|
isDragging,
|
|
dragHandleProps,
|
|
useNestable,
|
|
creatingWorkspaceIds,
|
|
activeWorkspaceSelection,
|
|
}: {
|
|
project: SidebarProjectEntry;
|
|
collapsed: boolean;
|
|
displayName: string;
|
|
iconDataUri: string | null;
|
|
serverId: string | null;
|
|
selectionEnabled: boolean;
|
|
showShortcutBadges: boolean;
|
|
shortcutIndexByWorkspaceKey: Map<string, number>;
|
|
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
|
onToggleCollapsed: (projectKey: string) => void;
|
|
onWorkspacePress?: () => void;
|
|
onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void;
|
|
onWorktreeCreated?: (workspaceId: string) => void;
|
|
drag: () => void;
|
|
isDragging: boolean;
|
|
dragHandleProps?: DraggableListDragHandleProps;
|
|
useNestable: boolean;
|
|
creatingWorkspaceIds: ReadonlySet<string>;
|
|
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
|
}) {
|
|
const rowModel = useMemo(
|
|
() =>
|
|
buildSidebarProjectRowModel({
|
|
project,
|
|
collapsed,
|
|
}),
|
|
[collapsed, project],
|
|
);
|
|
|
|
const active = isProjectSelectedByRoute({
|
|
selection: activeWorkspaceSelection,
|
|
serverId,
|
|
project,
|
|
enabled: selectionEnabled,
|
|
});
|
|
|
|
const renderWorkspaceRow = useCallback(
|
|
(
|
|
item: SidebarWorkspaceEntry,
|
|
input?: {
|
|
drag?: () => void;
|
|
isDragging?: boolean;
|
|
dragHandleProps?: DraggableListDragHandleProps;
|
|
},
|
|
) => {
|
|
return (
|
|
<MemoWorkspaceRowItem
|
|
workspace={item}
|
|
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
|
|
showShortcutBadge={showShortcutBadges}
|
|
canCopyBranchName={project.projectKind === "git"}
|
|
isCreating={creatingWorkspaceIds.has(item.workspaceId)}
|
|
selectionEnabled={selectionEnabled}
|
|
serverId={serverId}
|
|
activeWorkspaceSelection={activeWorkspaceSelection}
|
|
onWorkspacePress={onWorkspacePress}
|
|
drag={input?.drag}
|
|
isDragging={input?.isDragging}
|
|
dragHandleProps={input?.dragHandleProps}
|
|
/>
|
|
);
|
|
},
|
|
[
|
|
project.projectKind,
|
|
activeWorkspaceSelection,
|
|
creatingWorkspaceIds,
|
|
onWorkspacePress,
|
|
serverId,
|
|
selectionEnabled,
|
|
shortcutIndexByWorkspaceKey,
|
|
showShortcutBadges,
|
|
],
|
|
);
|
|
|
|
const renderWorkspace = useCallback(
|
|
({
|
|
item,
|
|
drag: workspaceDrag,
|
|
isActive,
|
|
dragHandleProps: workspaceDragHandleProps,
|
|
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) => {
|
|
return renderWorkspaceRow(item, {
|
|
drag: workspaceDrag,
|
|
isDragging: isActive,
|
|
dragHandleProps: workspaceDragHandleProps,
|
|
});
|
|
},
|
|
[renderWorkspaceRow],
|
|
);
|
|
|
|
const handleWorkspaceDragEnd = useCallback(
|
|
(workspaces: SidebarWorkspaceEntry[]) => {
|
|
onWorkspaceReorder(project.projectKey, workspaces);
|
|
},
|
|
[onWorkspaceReorder, project.projectKey],
|
|
);
|
|
|
|
const toast = useToast();
|
|
const [isRemovingProject, setIsRemovingProject] = useState(false);
|
|
|
|
const handleRemoveProject = useCallback(() => {
|
|
if (isRemovingProject || !serverId) {
|
|
return;
|
|
}
|
|
|
|
void (async () => {
|
|
const confirmed = await confirmDialog({
|
|
title: "Remove project?",
|
|
message: `Remove "${displayName}" from the sidebar?\n\nFiles on disk will not be changed.`,
|
|
confirmLabel: "Remove",
|
|
cancelLabel: "Cancel",
|
|
destructive: true,
|
|
});
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
|
|
const client = getHostRuntimeStore().getClient(serverId);
|
|
if (!client) {
|
|
toast.error("Host is not connected");
|
|
return;
|
|
}
|
|
|
|
setIsRemovingProject(true);
|
|
void archiveWorkspacesOptimistically({
|
|
client,
|
|
workspaces: project.workspaces,
|
|
}).then((failures) => {
|
|
if (failures.length > 0) {
|
|
toast.error("Failed to remove some workspaces");
|
|
}
|
|
setIsRemovingProject(false);
|
|
return;
|
|
});
|
|
})();
|
|
}, [isRemovingProject, serverId, displayName, toast, project.workspaces]);
|
|
|
|
const flattenedRowWorkspaceId =
|
|
rowModel.kind === "workspace_link" ? rowModel.workspace.workspaceId : null;
|
|
const handleFlattenedRowPress = useCallback(() => {
|
|
if (!serverId || !flattenedRowWorkspaceId) {
|
|
return;
|
|
}
|
|
onWorkspacePress?.();
|
|
navigateToWorkspace(serverId, flattenedRowWorkspaceId);
|
|
}, [serverId, flattenedRowWorkspaceId, onWorkspacePress]);
|
|
|
|
const handleToggleCollapsed = useCallback(() => {
|
|
onToggleCollapsed(project.projectKey);
|
|
}, [onToggleCollapsed, project.projectKey]);
|
|
|
|
return (
|
|
<View style={styles.projectBlock}>
|
|
{rowModel.kind === "workspace_link" ? (
|
|
<FlattenedProjectRow
|
|
project={project}
|
|
displayName={displayName}
|
|
iconDataUri={iconDataUri}
|
|
rowModel={rowModel}
|
|
onPress={handleFlattenedRowPress}
|
|
serverId={serverId}
|
|
onWorkspacePress={onWorkspacePress}
|
|
onWorktreeCreated={onWorktreeCreated}
|
|
shortcutNumber={shortcutIndexByWorkspaceKey.get(rowModel.workspace.workspaceKey) ?? null}
|
|
showShortcutBadge={showShortcutBadges}
|
|
drag={drag}
|
|
isDragging={isDragging}
|
|
dragHandleProps={dragHandleProps}
|
|
isProjectActive={active}
|
|
onRemoveProject={handleRemoveProject}
|
|
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
|
|
selectionEnabled={selectionEnabled}
|
|
activeWorkspaceSelection={activeWorkspaceSelection}
|
|
/>
|
|
) : (
|
|
<>
|
|
<ProjectHeaderRow
|
|
project={project}
|
|
displayName={displayName}
|
|
iconDataUri={iconDataUri}
|
|
workspace={null}
|
|
selected={false}
|
|
chevron={rowModel.chevron}
|
|
onPress={handleToggleCollapsed}
|
|
serverId={serverId}
|
|
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
|
|
isProjectActive={active}
|
|
onWorkspacePress={onWorkspacePress}
|
|
onWorktreeCreated={onWorktreeCreated}
|
|
drag={drag}
|
|
isDragging={isDragging}
|
|
isArchiving={isRemovingProject}
|
|
menuController={null}
|
|
onRemoveProject={handleRemoveProject}
|
|
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
|
|
dragHandleProps={dragHandleProps}
|
|
/>
|
|
|
|
{!collapsed ? (
|
|
<DraggableList
|
|
testID={`sidebar-workspace-list-${project.projectKey}`}
|
|
data={project.workspaces}
|
|
keyExtractor={workspaceKeyExtractor}
|
|
renderItem={renderWorkspace}
|
|
onDragEnd={handleWorkspaceDragEnd}
|
|
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
|
scrollEnabled={false}
|
|
useDragHandle
|
|
nestable={useNestable}
|
|
simultaneousGestureRef={parentGestureRef}
|
|
containerStyle={styles.workspaceListContainer}
|
|
/>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
type ProjectBlockProps = Parameters<typeof ProjectBlock>[0];
|
|
|
|
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
|
|
return (
|
|
previous.project === next.project &&
|
|
previous.collapsed === next.collapsed &&
|
|
previous.displayName === next.displayName &&
|
|
previous.iconDataUri === next.iconDataUri &&
|
|
previous.serverId === next.serverId &&
|
|
previous.selectionEnabled === next.selectionEnabled &&
|
|
previous.showShortcutBadges === next.showShortcutBadges &&
|
|
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
|
|
previous.parentGestureRef === next.parentGestureRef &&
|
|
previous.onToggleCollapsed === next.onToggleCollapsed &&
|
|
previous.onWorkspacePress === next.onWorkspacePress &&
|
|
previous.onWorkspaceReorder === next.onWorkspaceReorder &&
|
|
previous.onWorktreeCreated === next.onWorktreeCreated &&
|
|
previous.drag === next.drag &&
|
|
previous.isDragging === next.isDragging &&
|
|
previous.dragHandleProps === next.dragHandleProps &&
|
|
previous.useNestable === next.useNestable &&
|
|
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
|
|
areProjectBlockSelectionsEqual(previous, next)
|
|
);
|
|
}
|
|
|
|
function areProjectBlockSelectionsEqual(
|
|
previous: ProjectBlockProps,
|
|
next: ProjectBlockProps,
|
|
): boolean {
|
|
const previousActive = isProjectSelectedByRoute({
|
|
selection: previous.activeWorkspaceSelection,
|
|
project: previous.project,
|
|
serverId: previous.serverId,
|
|
enabled: previous.selectionEnabled,
|
|
});
|
|
const nextActive = isProjectSelectedByRoute({
|
|
selection: next.activeWorkspaceSelection,
|
|
project: next.project,
|
|
serverId: next.serverId,
|
|
enabled: next.selectionEnabled,
|
|
});
|
|
if (previousActive !== nextActive) {
|
|
return false;
|
|
}
|
|
if (!previousActive) {
|
|
return true;
|
|
}
|
|
return (
|
|
activeWorkspaceSelectionKey(previous.activeWorkspaceSelection) ===
|
|
activeWorkspaceSelectionKey(next.activeWorkspaceSelection)
|
|
);
|
|
}
|
|
|
|
const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual);
|
|
|
|
export function SidebarWorkspaceList({
|
|
projects,
|
|
serverId,
|
|
collapsedProjectKeys,
|
|
onToggleProjectCollapsed,
|
|
shortcutIndexByWorkspaceKey,
|
|
groupMode,
|
|
isRefreshing: _isRefreshing = false,
|
|
onRefresh: _onRefresh,
|
|
onWorkspacePress,
|
|
onAddProject,
|
|
listFooterComponent,
|
|
parentGestureRef,
|
|
}: SidebarWorkspaceListProps) {
|
|
const pathname = usePathname();
|
|
|
|
if (groupMode === "status") {
|
|
return (
|
|
<SidebarStatusModeWrapper
|
|
serverId={serverId}
|
|
projects={projects}
|
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
|
onWorkspacePress={onWorkspacePress}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ProjectModeList
|
|
projects={projects}
|
|
serverId={serverId}
|
|
collapsedProjectKeys={collapsedProjectKeys}
|
|
onToggleProjectCollapsed={onToggleProjectCollapsed}
|
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
|
onWorkspacePress={onWorkspacePress}
|
|
onAddProject={onAddProject}
|
|
listFooterComponent={listFooterComponent}
|
|
parentGestureRef={parentGestureRef}
|
|
pathname={pathname}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SidebarStatusModeWrapper({
|
|
serverId,
|
|
projects,
|
|
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
|
|
onWorkspacePress,
|
|
}: {
|
|
serverId: string | null;
|
|
projects: SidebarProjectEntry[];
|
|
shortcutIndexByWorkspaceKey: Map<string, number>;
|
|
onWorkspacePress?: () => void;
|
|
}) {
|
|
const hydratedWorkspaces = useStatusModeWorkspaceEntries({
|
|
serverId,
|
|
projects,
|
|
});
|
|
const projectNamesByKey = useProjectNamesMap(serverId);
|
|
const showShortcutBadges = useShowShortcutBadges();
|
|
|
|
return (
|
|
<SidebarStatusWorkspaceList
|
|
workspaces={hydratedWorkspaces}
|
|
projectNamesByKey={projectNamesByKey}
|
|
serverId={serverId}
|
|
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
|
|
showShortcutBadges={showShortcutBadges}
|
|
onWorkspacePress={onWorkspacePress}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ProjectModeList({
|
|
projects,
|
|
serverId,
|
|
collapsedProjectKeys,
|
|
onToggleProjectCollapsed,
|
|
shortcutIndexByWorkspaceKey,
|
|
onWorkspacePress,
|
|
onAddProject,
|
|
listFooterComponent,
|
|
parentGestureRef,
|
|
pathname,
|
|
}: Omit<SidebarWorkspaceListProps, "groupMode" | "isRefreshing" | "onRefresh"> & {
|
|
pathname: string;
|
|
}) {
|
|
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
|
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
|
new Map(),
|
|
);
|
|
const showShortcutBadges = useShowShortcutBadges();
|
|
|
|
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder);
|
|
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
|
|
const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder);
|
|
const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder);
|
|
|
|
const isWorkspaceRoute = useMemo(
|
|
() => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)),
|
|
[pathname],
|
|
);
|
|
const selectionEnabled = isWorkspaceRoute;
|
|
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
|
const nativeScrollGestureProps = useMemo(
|
|
() =>
|
|
parentGestureRef
|
|
? ({
|
|
// NestableScrollContainer forwards props to RNGH ScrollView. Keep
|
|
// vertical scroll and sidebar close pan simultaneous: vertical
|
|
// intent scrolls immediately, clear horizontal intent can still
|
|
// activate close from inside the list.
|
|
simultaneousHandlers: parentGestureRef,
|
|
} as object)
|
|
: undefined,
|
|
[parentGestureRef],
|
|
);
|
|
|
|
const projectIconByProjectKey = useProjectIconDataByProjectKey({
|
|
serverId,
|
|
projects,
|
|
});
|
|
|
|
useEffect(() => {
|
|
const timeouts = creatingWorkspaceTimeoutsRef.current;
|
|
return () => {
|
|
for (const timeout of timeouts.values()) {
|
|
clearTimeout(timeout);
|
|
}
|
|
timeouts.clear();
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (creatingWorkspaceIds.size === 0) {
|
|
return;
|
|
}
|
|
|
|
const visibleWorkspaceIds = new Set<string>();
|
|
for (const project of projects) {
|
|
for (const workspace of project.workspaces) {
|
|
visibleWorkspaceIds.add(workspace.workspaceId);
|
|
}
|
|
}
|
|
|
|
const removedWorkspaceIds = Array.from(creatingWorkspaceIds).filter(
|
|
(workspaceId) => !visibleWorkspaceIds.has(workspaceId),
|
|
);
|
|
if (removedWorkspaceIds.length === 0) {
|
|
return;
|
|
}
|
|
|
|
for (const workspaceId of removedWorkspaceIds) {
|
|
const timeout = creatingWorkspaceTimeoutsRef.current.get(workspaceId);
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
creatingWorkspaceTimeoutsRef.current.delete(workspaceId);
|
|
}
|
|
}
|
|
|
|
setCreatingWorkspaceIds((current) => {
|
|
const next = new Set(current);
|
|
for (const workspaceId of removedWorkspaceIds) {
|
|
next.delete(workspaceId);
|
|
}
|
|
return next;
|
|
});
|
|
}, [creatingWorkspaceIds, projects]);
|
|
|
|
const handleProjectDragEnd = useCallback(
|
|
(reorderedProjects: SidebarProjectEntry[]) => {
|
|
if (!serverId) {
|
|
return;
|
|
}
|
|
|
|
const reorderedProjectKeys = reorderedProjects.map((project) => project.projectKey);
|
|
const currentProjectOrder = getProjectOrder(serverId);
|
|
if (
|
|
!hasVisibleOrderChanged({
|
|
currentOrder: currentProjectOrder,
|
|
reorderedVisibleKeys: reorderedProjectKeys,
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
|
|
setProjectOrder(
|
|
serverId,
|
|
mergeWithRemainder({
|
|
currentOrder: currentProjectOrder,
|
|
reorderedVisibleKeys: reorderedProjectKeys,
|
|
}),
|
|
);
|
|
},
|
|
[getProjectOrder, serverId, setProjectOrder],
|
|
);
|
|
|
|
const handleWorkspaceReorder = useCallback(
|
|
(projectKey: string, reorderedWorkspaces: SidebarWorkspaceEntry[]) => {
|
|
if (!serverId) {
|
|
return;
|
|
}
|
|
|
|
const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey);
|
|
const currentWorkspaceOrder = getWorkspaceOrder(serverId, projectKey);
|
|
if (
|
|
!hasVisibleOrderChanged({
|
|
currentOrder: currentWorkspaceOrder,
|
|
reorderedVisibleKeys: reorderedWorkspaceKeys,
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
|
|
setWorkspaceOrder(
|
|
serverId,
|
|
projectKey,
|
|
mergeWithRemainder({
|
|
currentOrder: currentWorkspaceOrder,
|
|
reorderedVisibleKeys: reorderedWorkspaceKeys,
|
|
}),
|
|
);
|
|
},
|
|
[getWorkspaceOrder, serverId, setWorkspaceOrder],
|
|
);
|
|
|
|
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),
|
|
);
|
|
}, []);
|
|
|
|
const renderProject = useCallback(
|
|
({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) => {
|
|
return (
|
|
<MemoProjectBlock
|
|
project={item}
|
|
collapsed={collapsedProjectKeys.has(item.projectKey)}
|
|
displayName={item.projectName}
|
|
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
|
|
serverId={serverId}
|
|
selectionEnabled={selectionEnabled}
|
|
showShortcutBadges={showShortcutBadges}
|
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
|
parentGestureRef={parentGestureRef}
|
|
onToggleCollapsed={onToggleProjectCollapsed}
|
|
onWorkspacePress={onWorkspacePress}
|
|
onWorkspaceReorder={handleWorkspaceReorder}
|
|
onWorktreeCreated={handleWorktreeCreated}
|
|
drag={drag}
|
|
isDragging={isActive}
|
|
dragHandleProps={dragHandleProps}
|
|
useNestable={platformIsNative}
|
|
creatingWorkspaceIds={creatingWorkspaceIds}
|
|
activeWorkspaceSelection={activeWorkspaceSelection}
|
|
/>
|
|
);
|
|
},
|
|
[
|
|
collapsedProjectKeys,
|
|
activeWorkspaceSelection,
|
|
handleWorktreeCreated,
|
|
handleWorkspaceReorder,
|
|
onWorkspacePress,
|
|
onToggleProjectCollapsed,
|
|
parentGestureRef,
|
|
projectIconByProjectKey,
|
|
selectionEnabled,
|
|
serverId,
|
|
shortcutIndexByWorkspaceKey,
|
|
showShortcutBadges,
|
|
creatingWorkspaceIds,
|
|
],
|
|
);
|
|
|
|
const content = (
|
|
<>
|
|
{projects.length === 0 ? (
|
|
<View style={styles.emptyContainer}>
|
|
<Text style={styles.emptyTitle}>No projects yet</Text>
|
|
<Text style={styles.emptyText}>Add a project to get started</Text>
|
|
<Button variant="ghost" size="sm" leftIcon={Plus} onPress={onAddProject}>
|
|
Add project
|
|
</Button>
|
|
</View>
|
|
) : (
|
|
<DraggableList
|
|
testID="sidebar-project-list"
|
|
data={projects}
|
|
keyExtractor={projectKeyExtractor}
|
|
renderItem={renderProject}
|
|
onDragEnd={handleProjectDragEnd}
|
|
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
|
scrollEnabled={false}
|
|
useDragHandle
|
|
nestable={platformIsNative}
|
|
simultaneousGestureRef={parentGestureRef}
|
|
containerStyle={styles.projectListContainer}
|
|
/>
|
|
)}
|
|
{listFooterComponent}
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
{platformIsNative ? (
|
|
<NestableScrollContainer
|
|
{...nativeScrollGestureProps}
|
|
style={styles.list}
|
|
contentContainerStyle={styles.listContent}
|
|
showsVerticalScrollIndicator={false}
|
|
testID="sidebar-project-workspace-list-scroll"
|
|
>
|
|
{content}
|
|
</NestableScrollContainer>
|
|
) : (
|
|
<ScrollView
|
|
style={styles.list}
|
|
contentContainerStyle={styles.listContent}
|
|
showsVerticalScrollIndicator={false}
|
|
testID="sidebar-project-workspace-list-scroll"
|
|
>
|
|
{content}
|
|
</ScrollView>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
container: {
|
|
flex: 1,
|
|
},
|
|
list: {
|
|
flex: 1,
|
|
},
|
|
listContent: {
|
|
paddingHorizontal: theme.spacing[2],
|
|
paddingTop: theme.spacing[2],
|
|
paddingBottom: theme.spacing[4],
|
|
},
|
|
projectListContainer: {
|
|
width: "100%",
|
|
},
|
|
projectBlock: {
|
|
marginBottom: theme.spacing[1],
|
|
},
|
|
workspaceListContainer: {},
|
|
emptyContainer: {
|
|
marginHorizontal: theme.spacing[2],
|
|
marginTop: theme.spacing[4],
|
|
paddingTop: theme.spacing[6],
|
|
paddingBottom: theme.spacing[4],
|
|
paddingHorizontal: theme.spacing[4],
|
|
borderRadius: theme.borderRadius.lg,
|
|
backgroundColor: theme.colors.surface0,
|
|
alignItems: "center",
|
|
gap: theme.spacing[3],
|
|
},
|
|
emptyTitle: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.medium,
|
|
textAlign: "center",
|
|
},
|
|
emptyText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
textAlign: "center",
|
|
},
|
|
projectRow: {
|
|
position: "relative",
|
|
minHeight: 36,
|
|
paddingVertical: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[2],
|
|
borderRadius: theme.borderRadius.lg,
|
|
marginBottom: theme.spacing[1],
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: theme.spacing[2],
|
|
userSelect: "none",
|
|
},
|
|
projectRowHovered: {
|
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
|
},
|
|
projectRowPressed: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
projectRowDragging: {
|
|
backgroundColor: theme.colors.surface2,
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.border,
|
|
transform: [{ scale: 1.02 }],
|
|
zIndex: 3,
|
|
...theme.shadow.md,
|
|
},
|
|
projectRowLeft: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
flex: 1,
|
|
minWidth: 0,
|
|
},
|
|
projectTitleGroup: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
flex: 1,
|
|
minWidth: 0,
|
|
},
|
|
projectIcon: {
|
|
width: "100%",
|
|
height: "100%",
|
|
borderRadius: theme.borderRadius.sm,
|
|
},
|
|
projectLeadingVisualSlot: {
|
|
position: "relative",
|
|
width: theme.iconSize.md,
|
|
height: theme.iconSize.md,
|
|
flexShrink: 0,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
projectIconFallback: {
|
|
width: "100%",
|
|
height: "100%",
|
|
borderRadius: theme.borderRadius.sm,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
projectIconFallbackText: {
|
|
fontSize: 9,
|
|
},
|
|
projectTitle: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: "400",
|
|
minWidth: 0,
|
|
flexShrink: 1,
|
|
},
|
|
projectActionButton: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
paddingHorizontal: theme.spacing[2],
|
|
paddingVertical: theme.spacing[1],
|
|
borderRadius: theme.borderRadius.md,
|
|
flexShrink: 0,
|
|
},
|
|
projectActionButtonHovered: {
|
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
|
},
|
|
projectActionButtonText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.xs,
|
|
},
|
|
projectIconActionButton: {
|
|
width: 24,
|
|
height: 24,
|
|
borderRadius: theme.borderRadius.md,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
flexShrink: 0,
|
|
},
|
|
projectIconActionButtonHovered: {
|
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
|
},
|
|
projectIconActionButtonHidden: {
|
|
opacity: 0,
|
|
},
|
|
projectTrailingActions: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 2,
|
|
flexShrink: 0,
|
|
},
|
|
projectKebabButton: {
|
|
width: 24,
|
|
height: 24,
|
|
borderRadius: theme.borderRadius.md,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
flexShrink: 0,
|
|
},
|
|
projectKebabButtonHidden: {
|
|
opacity: 0,
|
|
},
|
|
projectKebabButtonHovered: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
projectTrailingControlSlot: {
|
|
width: 24,
|
|
height: 24,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
flexShrink: 0,
|
|
},
|
|
projectActionTooltipRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
projectActionTooltipText: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
projectActionTooltipShortcut: {},
|
|
projectShortcutBadgeOverlay: {
|
|
position: "absolute",
|
|
top: theme.spacing[2] + 1,
|
|
right: theme.spacing[2],
|
|
},
|
|
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: "center",
|
|
gap: theme.spacing[1],
|
|
userSelect: "none",
|
|
},
|
|
workspaceRowMain: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: theme.spacing[2],
|
|
width: "100%",
|
|
},
|
|
workspaceRowLeft: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
flex: 1,
|
|
minWidth: 0,
|
|
},
|
|
workspaceRowRight: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
flexShrink: 0,
|
|
},
|
|
workspaceRowHovered: {
|
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
|
},
|
|
workspaceRowPressed: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
workspaceRowDragging: {
|
|
backgroundColor: theme.colors.surface2,
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.border,
|
|
transform: [{ scale: 1.02 }],
|
|
zIndex: 3,
|
|
...theme.shadow.md,
|
|
},
|
|
sidebarRowSelected: {
|
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
|
},
|
|
workspaceRowContainer: {
|
|
position: "relative",
|
|
},
|
|
workspaceStatusDot: {
|
|
position: "relative",
|
|
width: WORKSPACE_STATUS_DOT_WIDTH,
|
|
height: 16,
|
|
borderRadius: theme.borderRadius.full,
|
|
flexShrink: 0,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
statusDotOverlay: {
|
|
position: "absolute",
|
|
right: DEFAULT_STATUS_DOT_OFFSET,
|
|
bottom: DEFAULT_STATUS_DOT_OFFSET,
|
|
width: DEFAULT_STATUS_DOT_SIZE,
|
|
height: DEFAULT_STATUS_DOT_SIZE,
|
|
borderRadius: theme.borderRadius.full,
|
|
borderWidth: 1,
|
|
},
|
|
workspaceArchivingOverlay: {
|
|
...StyleSheet.absoluteFillObject,
|
|
borderRadius: theme.borderRadius.lg,
|
|
backgroundColor: `${theme.colors.surface0}cc`,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
flexDirection: "row",
|
|
gap: theme.spacing[2],
|
|
zIndex: 1,
|
|
},
|
|
workspaceArchivingText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.xs,
|
|
fontWeight: "600",
|
|
},
|
|
workspaceBranchText: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: "400",
|
|
lineHeight: 20,
|
|
opacity: 0.76,
|
|
flex: 1,
|
|
minWidth: 0,
|
|
},
|
|
workspaceBranchTextCreating: {
|
|
opacity: 0.92,
|
|
},
|
|
workspaceBranchTextHovered: {
|
|
opacity: 1,
|
|
},
|
|
workspacePrBadgeRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
paddingLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2],
|
|
},
|
|
workspaceCreatingText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.xs,
|
|
flexShrink: 0,
|
|
},
|
|
kebabButton: {
|
|
padding: 2,
|
|
borderRadius: 4,
|
|
marginLeft: 2,
|
|
},
|
|
kebabButtonHovered: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
statusDotNeedsInput: {
|
|
backgroundColor: theme.colors.palette.amber[500],
|
|
borderColor: theme.colors.surface0,
|
|
},
|
|
statusDotFailed: {
|
|
backgroundColor: theme.colors.palette.red[500],
|
|
borderColor: theme.colors.surface0,
|
|
},
|
|
statusDotRunning: {
|
|
backgroundColor: theme.colors.palette.blue[500],
|
|
borderColor: theme.colors.surface0,
|
|
},
|
|
statusDotAttention: {
|
|
backgroundColor: theme.colors.palette.green[500],
|
|
borderColor: theme.colors.surface0,
|
|
},
|
|
}));
|
|
|
|
function getStatusDotColorStyle(bucket: SidebarStateBucket): ViewStyle | null {
|
|
switch (bucket) {
|
|
case "needs_input":
|
|
return styles.statusDotNeedsInput;
|
|
case "failed":
|
|
return styles.statusDotFailed;
|
|
case "running":
|
|
return styles.statusDotRunning;
|
|
case "attention":
|
|
return styles.statusDotAttention;
|
|
case "done":
|
|
return null;
|
|
}
|
|
}
|