The row only renders under its project header, so stepping it in reads as part of that project rather than as a peer of it. Extra padding rather than margin keeps the hover and pressed fills on the same box as every other sidebar row.
2718 lines
86 KiB
TypeScript
2718 lines
86 KiB
TypeScript
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|
import {
|
|
View,
|
|
Text,
|
|
Pressable,
|
|
ScrollView,
|
|
type GestureResponderEvent,
|
|
type PressableStateCallbackType,
|
|
type StyleProp,
|
|
type ViewStyle,
|
|
} from "react-native";
|
|
import { useMutation } from "@tanstack/react-query";
|
|
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
|
import {
|
|
memo,
|
|
useCallback,
|
|
useMemo,
|
|
useState,
|
|
useEffect,
|
|
useRef,
|
|
type ReactElement,
|
|
type MutableRefObject,
|
|
type Ref,
|
|
type ComponentProps,
|
|
type PropsWithChildren,
|
|
} from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
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 { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
|
|
import { type GestureType } from "react-native-gesture-handler";
|
|
import * as Clipboard from "expo-clipboard";
|
|
import {
|
|
ExternalLink,
|
|
GitPullRequest,
|
|
Settings,
|
|
MoreVertical,
|
|
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, useHosts } from "@/runtime/host-runtime";
|
|
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
|
|
import {
|
|
useSidebarWorkspacePinController,
|
|
type ToggleSidebarWorkspacePin,
|
|
} from "@/hooks/use-sidebar-workspace-pin";
|
|
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
|
import { useHostFeatureMap } from "@/runtime/host-features";
|
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
import { useProjectIcons } from "@/projects/icons";
|
|
import {
|
|
buildNewWorkspaceRoute,
|
|
buildProjectSettingsRoute,
|
|
parseHostWorkspaceRouteFromPathname,
|
|
} from "@/utils/host-routes";
|
|
import {
|
|
shouldShowSidebarHostLabels,
|
|
useSidebarProjectStatusBucket,
|
|
type SidebarProjectEntry,
|
|
type SidebarWorkspaceEntry,
|
|
type SidebarWorkspacePlacement,
|
|
} from "@/hooks/use-sidebar-workspaces-list";
|
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
|
import { useSidebarViewStore } from "@/stores/sidebar-view-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 { ProjectLeadingVisual } from "@/components/sidebar/project-leading-visual";
|
|
import { useToast } from "@/contexts/toast-context";
|
|
import { getForgePresentation, normalizeForge } from "@/git/forge";
|
|
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
|
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
|
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
|
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
|
import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list";
|
|
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
|
import {
|
|
SidebarWorkspaceContextMenu,
|
|
SidebarWorkspaceMenu,
|
|
} from "@/components/sidebar/sidebar-workspace-menu";
|
|
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
|
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
|
|
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
|
|
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
|
import {
|
|
SidebarWorkspaceRowFrame,
|
|
SidebarWorkspaceRowContent,
|
|
SidebarWorkspaceShortcutBadge,
|
|
resolveTrailingActionVisibility,
|
|
SidebarWorkspaceTrailingActionBase,
|
|
SidebarWorkspaceTrailingActionOverlay,
|
|
SidebarWorkspaceTrailingActionSlot,
|
|
} from "@/components/sidebar/sidebar-workspace-row-content";
|
|
import { useOpenKebabMenuVisibility } from "@/components/sidebar/use-open-kebab-menu-visibility";
|
|
import { selectWorkspaceServiceSummary } from "@/components/sidebar/workspace-meta-row";
|
|
import {
|
|
SidebarWorkspaceTrailingContent,
|
|
useSidebarWorkspaceTrailing,
|
|
} from "@/components/sidebar/workspace-trailing";
|
|
import { Button } from "@/components/ui/button";
|
|
import { PressHighlight } from "@/components/ui/press-highlight";
|
|
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,
|
|
resolveSidebarProjectIconTargets,
|
|
resolveSidebarProjectLocalPath,
|
|
type SidebarProjectHostTarget,
|
|
} from "@/utils/sidebar-project-row-model";
|
|
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
|
import { openExternalUrl } from "@/utils/open-external-url";
|
|
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
|
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
|
import {
|
|
getCurrentProjectRemoveReadiness,
|
|
removeProjectFromHosts,
|
|
} from "@/projects/project-remove";
|
|
import {
|
|
isWeb as platformIsWeb,
|
|
isNative as platformIsNative,
|
|
getIsElectron,
|
|
} from "@/constants/platform";
|
|
import { getDesktopHost } from "@/desktop/host";
|
|
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
|
|
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
|
import type { HostBadgeModel } from "@/hosts/appearance";
|
|
import { useHostBadges } from "@/hosts/use-host-badges";
|
|
import { useSidebarRowItems } from "@/components/sidebar/display-preferences/model";
|
|
|
|
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
|
|
|
|
const projectViewKeyExtractor = (project: SidebarProjectEntry) => project.viewKey;
|
|
|
|
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
|
const ThemedExternalLink = withUnistyles(ExternalLink);
|
|
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
|
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
|
const ThemedPlus = withUnistyles(Plus);
|
|
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
|
const ThemedTrash2 = withUnistyles(Trash2);
|
|
const ThemedSettings = withUnistyles(Settings);
|
|
|
|
const foregroundColorMapping = (theme: Theme) => ({
|
|
color: theme.colors.foreground,
|
|
});
|
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
|
color: theme.colors.foregroundMuted,
|
|
});
|
|
const redColorMapping = (theme: Theme) => ({
|
|
color: theme.colors.statusDanger,
|
|
});
|
|
const greenColorMapping = (theme: Theme) => ({
|
|
color: theme.colors.statusSuccess,
|
|
});
|
|
const purpleColorMapping = (theme: Theme) => ({
|
|
color: theme.colors.statusMerged,
|
|
});
|
|
|
|
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;
|
|
enabled: boolean;
|
|
}): boolean {
|
|
return (
|
|
input.enabled &&
|
|
input.project.workspaces.some(
|
|
(workspace) =>
|
|
workspace.serverId === input.selection?.serverId &&
|
|
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 {
|
|
statusGroups: StatusGroup[];
|
|
pinnedGroups: PinnedSidebarGroups;
|
|
projects: SidebarProjectEntry[];
|
|
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
|
collapsedProjectKeys: ReadonlySet<string>;
|
|
onToggleProjectCollapsed: (projectViewKey: string) => void;
|
|
shortcutIndexByWorkspaceKey: Map<string, number>;
|
|
groupMode: "project" | "status";
|
|
isRefreshing?: boolean;
|
|
onRefresh?: () => void;
|
|
onWorkspacePress?: () => void;
|
|
onAddProject?: () => void;
|
|
listFooterComponent?: ReactElement | null;
|
|
// Rendered inside the scroll area, below the Pinned section and above the workspace
|
|
// list. Holds the "Workspaces" section header so pinned items sit above it.
|
|
listHeaderComponent?: ReactElement | null;
|
|
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
|
|
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
|
dragGestureHostPresented?: boolean;
|
|
}
|
|
|
|
interface ProjectHeaderRowProps {
|
|
project: SidebarProjectEntry;
|
|
displayName: string;
|
|
iconDataUri: string | null;
|
|
statusBucket: SidebarStateBucket | null;
|
|
selected?: boolean;
|
|
chevron: "expand" | "collapse" | null;
|
|
onPress: () => void;
|
|
worktreeTarget: SidebarProjectHostTarget | null;
|
|
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;
|
|
hostBadge?: HostBadgeModel | null;
|
|
leadingProjectName?: string | null;
|
|
leadingProjectIconDataUri?: string | null;
|
|
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;
|
|
isPinned?: boolean;
|
|
onTogglePin?: () => void;
|
|
reserveIdleStatusIndicatorSpace?: boolean;
|
|
}
|
|
|
|
export function PrBadge({ hint, style }: { hint: PrHint; style?: StyleProp<ViewStyle> }) {
|
|
const { t } = useTranslation();
|
|
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), []);
|
|
|
|
// Callers that place the badge in a list of icon+text rows pass that row's layout in, so the
|
|
// icon and text land on the same rails as their neighbors instead of on the badge's tighter
|
|
// inline spacing.
|
|
const pressableStyle = useCallback(
|
|
({ pressed }: PressableStateCallbackType) => [
|
|
prBadgeStyles.badge,
|
|
style,
|
|
pressed && prBadgeStyles.badgePressed,
|
|
],
|
|
[style],
|
|
);
|
|
|
|
const textStyle = isHovered
|
|
? [prBadgeStyles.text, prBadgeStyles.textHovered]
|
|
: prBadgeStyles.text;
|
|
const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state);
|
|
const presentation = getForgePresentation(normalizeForge(hint.forge));
|
|
|
|
return (
|
|
<Pressable
|
|
accessibilityRole="link"
|
|
accessibilityLabel={t("workspace.git.pr.accessibility.pullRequest", {
|
|
number: hint.number,
|
|
context: presentation.changeRequestContext,
|
|
})}
|
|
hitSlop={4}
|
|
onPressIn={handlePressIn}
|
|
onPress={handlePress}
|
|
onHoverIn={handleHoverIn}
|
|
onHoverOut={handleHoverOut}
|
|
style={pressableStyle}
|
|
>
|
|
{isHovered ? (
|
|
<ThemedExternalLink size={12} uniProps={iconUniProps} />
|
|
) : (
|
|
<ThemedGitPullRequest size={12} uniProps={iconUniProps} />
|
|
)}
|
|
<Text style={textStyle} numberOfLines={1}>
|
|
{hint.number}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function projectKebabStyle({
|
|
hovered = false,
|
|
}: PressableStateCallbackType & { hovered?: boolean }) {
|
|
return [styles.projectKebabButton, hovered && styles.projectKebabButtonHovered];
|
|
}
|
|
|
|
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,
|
|
},
|
|
}));
|
|
|
|
function ProjectRowTrailingActions({
|
|
projectViewKey,
|
|
displayName,
|
|
worktreeTarget,
|
|
settingsTarget,
|
|
projectPath,
|
|
isHovered,
|
|
isMobileBreakpoint,
|
|
isProjectActive,
|
|
onBeginWorkspaceSetup,
|
|
onRemoveProject,
|
|
removeProjectStatus,
|
|
}: {
|
|
projectViewKey: string;
|
|
displayName: string;
|
|
worktreeTarget: SidebarProjectHostTarget | null;
|
|
settingsTarget: { serverId: string; projectId: string } | null;
|
|
projectPath: string;
|
|
isHovered: boolean;
|
|
isMobileBreakpoint: boolean;
|
|
isProjectActive: boolean;
|
|
onBeginWorkspaceSetup: () => void;
|
|
onRemoveProject?: () => void;
|
|
removeProjectStatus: "idle" | "pending" | "success";
|
|
}) {
|
|
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
|
|
return (
|
|
<View style={styles.projectTrailingActions}>
|
|
{worktreeTarget ? (
|
|
<NewWorktreeButton
|
|
displayName={displayName}
|
|
onPress={onBeginWorkspaceSetup}
|
|
visible={actionsVisible}
|
|
showShortcutHint={isProjectActive}
|
|
testID={`sidebar-project-new-worktree-${projectViewKey}`}
|
|
/>
|
|
) : null}
|
|
{onRemoveProject ? (
|
|
<View
|
|
style={!actionsVisible && styles.projectKebabButtonHidden}
|
|
pointerEvents={actionsVisible ? "auto" : "none"}
|
|
>
|
|
<ProjectKebabMenu
|
|
projectViewKey={projectViewKey}
|
|
settingsTarget={settingsTarget}
|
|
projectPath={projectPath}
|
|
onRemoveProject={onRemoveProject}
|
|
removeProjectStatus={removeProjectStatus}
|
|
/>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const trash2LeadingIcon = <ThemedTrash2 size={14} uniProps={foregroundMutedColorMapping} />;
|
|
const settingsLeadingIcon = <ThemedSettings 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({
|
|
projectViewKey,
|
|
settingsTarget,
|
|
projectPath,
|
|
onRemoveProject,
|
|
removeProjectStatus,
|
|
}: {
|
|
projectViewKey: string;
|
|
settingsTarget: { serverId: string; projectId: string } | null;
|
|
projectPath: string;
|
|
onRemoveProject: () => void;
|
|
removeProjectStatus: "idle" | "pending" | "success";
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<DropdownMenu compactMode="sheet">
|
|
<DropdownMenuTrigger
|
|
hitSlop={8}
|
|
style={projectKebabStyle}
|
|
accessibilityRole={platformIsWeb ? undefined : "button"}
|
|
accessibilityLabel={t("sidebar.project.actions.menu")}
|
|
testID={`sidebar-project-kebab-${projectViewKey}`}
|
|
>
|
|
{renderKebabTriggerIcon}
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" width={220} sheetTitle={t("sidebar.project.actions.menu")}>
|
|
<ProjectMenuItems
|
|
surface="dropdown"
|
|
projectViewKey={projectViewKey}
|
|
settingsTarget={settingsTarget}
|
|
projectPath={projectPath}
|
|
onRemoveProject={onRemoveProject}
|
|
removeProjectStatus={removeProjectStatus}
|
|
/>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|
|
|
|
type ProjectMenuSurface = "context" | "dropdown";
|
|
|
|
function ProjectMenuItem({
|
|
surface,
|
|
children,
|
|
...props
|
|
}: PropsWithChildren<
|
|
Omit<ComponentProps<typeof DropdownMenuItem>, "children"> & { surface: ProjectMenuSurface }
|
|
>) {
|
|
if (surface === "context") {
|
|
return <ContextMenuItem {...props}>{children}</ContextMenuItem>;
|
|
}
|
|
return <DropdownMenuItem {...props}>{children}</DropdownMenuItem>;
|
|
}
|
|
|
|
function ProjectMenuItems({
|
|
surface,
|
|
projectViewKey,
|
|
settingsTarget,
|
|
projectPath,
|
|
onRemoveProject,
|
|
removeProjectStatus,
|
|
}: {
|
|
surface: ProjectMenuSurface;
|
|
projectViewKey: string;
|
|
settingsTarget: { serverId: string; projectId: string } | null;
|
|
projectPath: string;
|
|
onRemoveProject: () => void;
|
|
removeProjectStatus: "idle" | "pending" | "success";
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const toast = useToast();
|
|
const handleOpenProjectSettings = useCallback(() => {
|
|
if (!settingsTarget) return;
|
|
router.navigate(buildProjectSettingsRoute(settingsTarget.serverId, settingsTarget.projectId));
|
|
}, [settingsTarget]);
|
|
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(t("sidebar.project.actions.openNewWindowFailed"));
|
|
});
|
|
}, [projectPath, t, toast]);
|
|
|
|
return (
|
|
<>
|
|
{settingsTarget ? (
|
|
<ProjectMenuItem
|
|
surface={surface}
|
|
testID={`sidebar-project-menu-open-settings-${projectViewKey}`}
|
|
leading={settingsLeadingIcon}
|
|
onSelect={handleOpenProjectSettings}
|
|
>
|
|
{t("sidebar.project.actions.openSettings")}
|
|
</ProjectMenuItem>
|
|
) : null}
|
|
{canOpenInNewWindow ? (
|
|
<ProjectMenuItem
|
|
surface={surface}
|
|
testID={`sidebar-project-menu-open-new-window-${projectViewKey}`}
|
|
leading={openInNewWindowLeadingIcon}
|
|
onSelect={handleOpenInNewWindow}
|
|
>
|
|
{t("sidebar.project.actions.openNewWindow")}
|
|
</ProjectMenuItem>
|
|
) : null}
|
|
<OpenInFileManagerMenuItem
|
|
surface={surface}
|
|
path={projectPath}
|
|
testID={`sidebar-project-menu-open-folder-${projectViewKey}`}
|
|
/>
|
|
<ProjectMenuItem
|
|
surface={surface}
|
|
testID={`sidebar-project-menu-remove-${projectViewKey}`}
|
|
leading={trash2LeadingIcon}
|
|
status={removeProjectStatus}
|
|
pendingLabel={t("sidebar.project.actions.removing")}
|
|
onSelect={onRemoveProject}
|
|
>
|
|
{t("sidebar.project.actions.remove")}
|
|
</ProjectMenuItem>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function WorkspaceRowRightGroup({
|
|
workspace,
|
|
isHovered,
|
|
isTouchPlatform,
|
|
isCreating,
|
|
showShortcutBadge,
|
|
shortcutNumber,
|
|
archiveLabel,
|
|
archiveStatus,
|
|
archivePendingLabel,
|
|
archiveShortcutKeys,
|
|
onArchive,
|
|
onMarkAsRead,
|
|
onCopyBranchName,
|
|
onCopyPath,
|
|
onRename,
|
|
isPinned,
|
|
onTogglePin,
|
|
}: {
|
|
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;
|
|
isPinned?: boolean;
|
|
onTogglePin?: () => void;
|
|
}) {
|
|
const workspacePath = workspace.workspaceDirectory ?? workspace.projectRootPath;
|
|
const { t } = useTranslation();
|
|
const trailing = useSidebarWorkspaceTrailing();
|
|
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
|
const {
|
|
showTrailing,
|
|
showKebab: showKebabInSlot,
|
|
showScrim,
|
|
renderSlot,
|
|
reserveSlotWidth,
|
|
} = resolveTrailingActionVisibility({
|
|
workspace,
|
|
trailing,
|
|
hasArchiveAction: Boolean(onArchive),
|
|
isHovered,
|
|
isTouchPlatform,
|
|
showShortcut,
|
|
});
|
|
const kebab = useOpenKebabMenuVisibility(showKebabInSlot);
|
|
|
|
return (
|
|
<>
|
|
{isCreating ? (
|
|
<Text style={styles.workspaceCreatingText}>{t("sidebar.workspace.status.creating")}</Text>
|
|
) : null}
|
|
{renderSlot ? (
|
|
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
|
|
<SidebarWorkspaceTrailingActionBase visible={showTrailing}>
|
|
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
|
|
</SidebarWorkspaceTrailingActionBase>
|
|
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}>
|
|
{onArchive ? (
|
|
<SidebarWorkspaceMenu
|
|
{...kebab.menuProps}
|
|
workspaceKey={workspace.workspaceKey}
|
|
onCopyPath={onCopyPath}
|
|
onCopyBranchName={onCopyBranchName}
|
|
onRename={onRename}
|
|
onMarkAsRead={onMarkAsRead}
|
|
onArchive={onArchive}
|
|
archiveLabel={archiveLabel}
|
|
archiveStatus={archiveStatus}
|
|
archivePendingLabel={archivePendingLabel}
|
|
archiveShortcutKeys={archiveShortcutKeys}
|
|
isPinned={isPinned}
|
|
onTogglePin={onTogglePin}
|
|
openInFileManagerPath={workspacePath}
|
|
/>
|
|
) : null}
|
|
</SidebarWorkspaceTrailingActionOverlay>
|
|
</SidebarWorkspaceTrailingActionSlot>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function NewWorktreeButton({
|
|
displayName,
|
|
onPress,
|
|
visible,
|
|
loading = false,
|
|
testID,
|
|
showShortcutHint = false,
|
|
}: {
|
|
displayName: string;
|
|
onPress: () => void;
|
|
visible: boolean;
|
|
loading?: boolean;
|
|
testID: string;
|
|
showShortcutHint?: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
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={t("sidebar.workspace.actions.createWorkspaceFor", {
|
|
projectName: displayName,
|
|
})}
|
|
testID={testID}
|
|
>
|
|
{({ hovered, pressed }) =>
|
|
loading ? (
|
|
<ThemedLoadingSpinner size={14} uniProps={foregroundMutedColorMapping} />
|
|
) : (
|
|
<ThemedPlus
|
|
size={15}
|
|
uniProps={
|
|
hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
</Pressable>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" align="center" offset={8}>
|
|
<View style={styles.projectActionTooltipRow}>
|
|
<Text style={styles.projectActionTooltipText}>
|
|
{t("sidebar.workspace.actions.newWorkspace")}
|
|
</Text>
|
|
{showShortcutHint && newWorktreeKeys ? (
|
|
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
|
|
) : null}
|
|
</View>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function NewWorkspaceGhostRow({
|
|
project,
|
|
displayName,
|
|
worktreeTarget,
|
|
onWorkspacePress,
|
|
}: {
|
|
project: SidebarProjectEntry;
|
|
displayName: string;
|
|
worktreeTarget: SidebarProjectHostTarget;
|
|
onWorkspacePress?: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const handlePress = useCallback(() => {
|
|
onWorkspacePress?.();
|
|
router.navigate(
|
|
buildNewWorkspaceRoute({
|
|
serverId: worktreeTarget.serverId,
|
|
sourceDirectory: worktreeTarget.iconWorkingDir,
|
|
displayName,
|
|
projectId: worktreeTarget.projectId,
|
|
}) as Href,
|
|
);
|
|
}, [displayName, onWorkspacePress, worktreeTarget]);
|
|
const rowStyle = useCallback(
|
|
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
|
styles.newWorkspaceGhostRow,
|
|
hovered && !pressed && styles.newWorkspaceGhostRowHovered,
|
|
pressed && styles.newWorkspaceGhostRowPressed,
|
|
],
|
|
[],
|
|
);
|
|
|
|
return (
|
|
<Pressable
|
|
accessibilityRole={platformIsWeb ? undefined : "button"}
|
|
accessibilityLabel={t("sidebar.workspace.actions.createWorkspaceFor", {
|
|
projectName: displayName,
|
|
})}
|
|
onPress={handlePress}
|
|
style={rowStyle}
|
|
testID={`sidebar-project-new-workspace-row-${project.viewKey}`}
|
|
>
|
|
{({ hovered, pressed }) => (
|
|
<>
|
|
<View style={styles.newWorkspaceGhostIconSlot}>
|
|
<ThemedPlus
|
|
size={14}
|
|
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
|
|
/>
|
|
</View>
|
|
<Text
|
|
style={
|
|
hovered || pressed
|
|
? styles.newWorkspaceGhostTextHovered
|
|
: styles.newWorkspaceGhostText
|
|
}
|
|
numberOfLines={1}
|
|
>
|
|
{t("sidebar.workspace.actions.newWorkspace")}
|
|
</Text>
|
|
</>
|
|
)}
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function ProjectHeaderRow({
|
|
project,
|
|
displayName,
|
|
iconDataUri,
|
|
statusBucket,
|
|
selected = false,
|
|
chevron,
|
|
onPress,
|
|
worktreeTarget,
|
|
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 [contextMenuOpen, setContextMenuOpen] = useState(false);
|
|
const isMobileBreakpoint = useIsCompactFormFactor();
|
|
const localDaemonServerId = useLocalDaemonServerId();
|
|
const projectPath = resolveSidebarProjectLocalPath(project, localDaemonServerId);
|
|
const settingsTarget = project.hosts[0] ?? null;
|
|
const handleBeginWorkspaceSetup = useCallback(() => {
|
|
if (!worktreeTarget) {
|
|
return;
|
|
}
|
|
onWorkspacePress?.();
|
|
router.navigate(
|
|
buildNewWorkspaceRoute({
|
|
serverId: worktreeTarget.serverId,
|
|
sourceDirectory: worktreeTarget.iconWorkingDir,
|
|
displayName,
|
|
projectId: worktreeTarget.projectId,
|
|
}) as Href,
|
|
);
|
|
}, [displayName, onWorkspacePress, worktreeTarget]);
|
|
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(() => {
|
|
if (!contextMenuOpen) setIsHovered(true);
|
|
}, [contextMenuOpen]);
|
|
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
|
const handleContextMenuOpenChange = useCallback((open: boolean) => {
|
|
setContextMenuOpen(open);
|
|
if (open) 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}
|
|
statusBucket={statusBucket}
|
|
projectViewKey={project.viewKey}
|
|
backdrop={getSidebarRowBackdrop({ isDragging, selected, isHovered })}
|
|
chevron={chevron}
|
|
showChevron={isHovered && chevron !== null}
|
|
isArchiving={isArchiving}
|
|
/>
|
|
|
|
<View style={styles.projectTitleGroup}>
|
|
<Text style={styles.projectTitle} numberOfLines={1}>
|
|
{displayName}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
<ProjectRowTrailingActions
|
|
projectViewKey={project.viewKey}
|
|
displayName={displayName}
|
|
worktreeTarget={worktreeTarget}
|
|
settingsTarget={settingsTarget}
|
|
projectPath={projectPath}
|
|
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 (!onRemoveProject) {
|
|
return (
|
|
<View
|
|
{...dragAttributes}
|
|
{...dragHandleProps?.listeners}
|
|
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
|
onPointerEnter={handlePointerEnter}
|
|
onPointerLeave={handlePointerLeave}
|
|
>
|
|
<PressHighlight
|
|
accessibilityRole="button"
|
|
style={projectRowStyle}
|
|
highlightStyle={styles.projectRowHovered}
|
|
onPressIn={interaction.handlePressIn}
|
|
onTouchMove={interaction.handleTouchMove}
|
|
onPressOut={interaction.handlePressOut}
|
|
onPress={handlePress}
|
|
testID={`sidebar-project-row-${project.viewKey}`}
|
|
>
|
|
{rowChildren}
|
|
</PressHighlight>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ContextMenu open={contextMenuOpen} onOpenChange={handleContextMenuOpenChange}>
|
|
<View
|
|
{...dragAttributes}
|
|
{...dragHandleProps?.listeners}
|
|
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
|
onPointerEnter={handlePointerEnter}
|
|
onPointerLeave={handlePointerLeave}
|
|
>
|
|
<ContextMenuTrigger
|
|
enabledOnMobile={false}
|
|
accessibilityRole="button"
|
|
style={projectRowStyle}
|
|
highlightStyle={styles.projectRowHovered}
|
|
onPressIn={interaction.handlePressIn}
|
|
onTouchMove={interaction.handleTouchMove}
|
|
onPressOut={interaction.handlePressOut}
|
|
onPress={handlePress}
|
|
testID={`sidebar-project-row-${project.viewKey}`}
|
|
>
|
|
{rowChildren}
|
|
</ContextMenuTrigger>
|
|
</View>
|
|
<ContextMenuContent
|
|
align="start"
|
|
width={220}
|
|
testID={`sidebar-project-context-menu-${project.viewKey}`}
|
|
>
|
|
<ProjectMenuItems
|
|
surface="context"
|
|
projectViewKey={project.viewKey}
|
|
settingsTarget={settingsTarget}
|
|
projectPath={projectPath}
|
|
onRemoveProject={onRemoveProject}
|
|
removeProjectStatus={removeProjectStatus}
|
|
/>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
);
|
|
}
|
|
|
|
function WorkspaceRowInner({
|
|
workspace,
|
|
hostBadge,
|
|
leadingProjectName,
|
|
leadingProjectIconDataUri,
|
|
selected,
|
|
shortcutNumber,
|
|
showShortcutBadge,
|
|
onPress,
|
|
drag,
|
|
isDragging,
|
|
isArchiving,
|
|
isCreating = false,
|
|
dragHandleProps,
|
|
menuController,
|
|
archiveLabel,
|
|
archiveStatus = "idle",
|
|
archivePendingLabel,
|
|
onArchive,
|
|
onCopyBranchName,
|
|
onCopyPath,
|
|
onRename,
|
|
archiveShortcutKeys,
|
|
isPinned,
|
|
onTogglePin,
|
|
reserveIdleStatusIndicatorSpace = true,
|
|
}: 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, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
|
|
const isDesktop = !isTouchPlatform;
|
|
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
|
|
const workspaceRowStyle = getProjectWorkspaceRowStyle({
|
|
isDragging,
|
|
selected,
|
|
isHovered,
|
|
});
|
|
return (
|
|
<View
|
|
{...dragAttributes}
|
|
{...dragHandleProps?.listeners}
|
|
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
|
style={styles.workspaceRowContainer}
|
|
{...hoverHandlers}
|
|
>
|
|
<SidebarWorkspaceContextMenu
|
|
contextMenuOpen={contextMenuOpen}
|
|
onContextMenuOpenChange={onContextMenuOpenChange}
|
|
workspace={workspace}
|
|
leadingProjectName={leadingProjectName}
|
|
hostBadgeLabel={hostBadge?.label}
|
|
workspaceKey={workspace.workspaceKey}
|
|
onCopyPath={onCopyPath}
|
|
onCopyBranchName={onCopyBranchName}
|
|
onRename={onRename}
|
|
onArchive={onArchive}
|
|
archiveLabel={archiveLabel}
|
|
archiveStatus={archiveStatus}
|
|
archivePendingLabel={archivePendingLabel}
|
|
archiveShortcutKeys={archiveShortcutKeys}
|
|
isPinned={isPinned}
|
|
onTogglePin={onTogglePin}
|
|
openInFileManagerPath={workspace.workspaceDirectory}
|
|
disabled={isArchiving}
|
|
aria-selected={selected}
|
|
accessibilityRole="button"
|
|
accessibilityState={accessibilityState}
|
|
style={workspaceRowStyle}
|
|
highlightStyle={styles.workspaceRowHovered}
|
|
onPressIn={interaction.handlePressIn}
|
|
onTouchMove={interaction.handleTouchMove}
|
|
onPressOut={interaction.handlePressOut}
|
|
onPress={handlePress}
|
|
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
|
>
|
|
<SidebarWorkspaceRowContent
|
|
workspace={workspace}
|
|
hostBadge={hostBadge}
|
|
leadingProjectName={leadingProjectName}
|
|
leadingProjectIconDataUri={leadingProjectIconDataUri}
|
|
serviceSummary={serviceSummary}
|
|
backdrop={getSidebarRowBackdrop({ isDragging, selected, isHovered })}
|
|
isHovered={isHovered}
|
|
isLoading={isArchiving || isCreating}
|
|
isCreating={isCreating}
|
|
shortcutNumber={shortcutNumber}
|
|
showShortcutBadge={showShortcutBadge}
|
|
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
|
>
|
|
<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}
|
|
isPinned={isPinned}
|
|
onTogglePin={onTogglePin}
|
|
/>
|
|
</SidebarWorkspaceRowContent>
|
|
</SidebarWorkspaceContextMenu>
|
|
</View>
|
|
);
|
|
}}
|
|
</SidebarWorkspaceRowFrame>
|
|
);
|
|
}
|
|
|
|
function WorkspaceRowWithMenu({
|
|
workspace,
|
|
hostBadge,
|
|
leadingProjectName,
|
|
leadingProjectIconDataUri,
|
|
selected,
|
|
shortcutNumber,
|
|
showShortcutBadge,
|
|
onPress,
|
|
drag,
|
|
isDragging,
|
|
dragHandleProps,
|
|
canCopyBranchName,
|
|
canPin,
|
|
onToggleWorkspacePin,
|
|
reserveIdleStatusIndicatorSpace = true,
|
|
isCreating = false,
|
|
}: {
|
|
workspace: SidebarWorkspaceEntry;
|
|
hostBadge?: HostBadgeModel | null;
|
|
leadingProjectName?: string | null;
|
|
leadingProjectIconDataUri?: string | null;
|
|
selected: boolean;
|
|
shortcutNumber: number | null;
|
|
showShortcutBadge: boolean;
|
|
onPress: () => void;
|
|
drag: () => void;
|
|
isDragging: boolean;
|
|
dragHandleProps?: DraggableListDragHandleProps;
|
|
canCopyBranchName: boolean;
|
|
canPin: boolean;
|
|
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
|
reserveIdleStatusIndicatorSpace?: boolean;
|
|
isCreating?: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const toast = useToast();
|
|
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
|
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
|
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
|
|
const redirectAfterArchive = useCallback(() => {
|
|
redirectIfArchivingActiveWorkspace({
|
|
serverId: workspace.serverId,
|
|
workspaceId: workspace.workspaceId,
|
|
activeWorkspaceSelection: selectionForSelectedWorkspace(selected, workspace),
|
|
});
|
|
}, [selected, workspace]);
|
|
|
|
const archiveController = useWorkspaceArchive({
|
|
serverId: workspace.serverId,
|
|
workspaceId: workspace.workspaceId,
|
|
workspaceKind: workspace.workspaceKind,
|
|
name: workspace.name,
|
|
...toWorktreeArchiveRisk(workspace),
|
|
onArchiveStarted: redirectAfterArchive,
|
|
onSetHiding: setIsHidingWorkspace,
|
|
});
|
|
|
|
const handleArchive = useCallback(() => {
|
|
if (isArchiving) {
|
|
return;
|
|
}
|
|
archiveController.archive();
|
|
}, [archiveController, isArchiving]);
|
|
|
|
const handleCopyPath = useCallback(() => {
|
|
let copyTargetDirectory: string;
|
|
try {
|
|
copyTargetDirectory = requireWorkspaceDirectory({
|
|
workspaceId: workspace.workspaceId,
|
|
workspaceDirectory: workspace.workspaceDirectory,
|
|
});
|
|
} catch (error) {
|
|
toast.error(
|
|
error instanceof Error
|
|
? error.message
|
|
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
|
|
);
|
|
return;
|
|
}
|
|
void Clipboard.setStringAsync(copyTargetDirectory);
|
|
toast.copied(t("sidebar.workspace.toasts.pathCopied"));
|
|
}, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]);
|
|
|
|
const handleCopyBranchName = useCallback(() => {
|
|
if (!workspace.currentBranch) {
|
|
return;
|
|
}
|
|
void Clipboard.setStringAsync(workspace.currentBranch);
|
|
toast.copied(t("sidebar.workspace.toasts.branchNameCopied"));
|
|
}, [t, toast, workspace.currentBranch]);
|
|
|
|
const renameMutation = useMutation({
|
|
mutationFn: async (title: string) => {
|
|
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
|
if (!client) {
|
|
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
|
|
}
|
|
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
|
|
},
|
|
});
|
|
|
|
const handleOpenRename = useCallback(() => {
|
|
setIsRenameOpen(true);
|
|
}, []);
|
|
|
|
const handleCloseRename = useCallback(() => {
|
|
setIsRenameOpen(false);
|
|
}, []);
|
|
|
|
const handleSubmitRename = useCallback(
|
|
async (value: string) => {
|
|
await renameMutation.mutateAsync(value.trim());
|
|
},
|
|
[renameMutation],
|
|
);
|
|
|
|
const isPinned = workspace.pinnedAt != null;
|
|
const handleTogglePin = useCallback(() => {
|
|
onToggleWorkspacePin(workspace);
|
|
}, [onToggleWorkspacePin, workspace]);
|
|
const onTogglePin = canPin ? handleTogglePin : undefined;
|
|
|
|
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
|
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: `workspace-archive-${workspace.workspaceKey}`,
|
|
actions: ["workspace.archive"],
|
|
enabled: selected && !isArchiving,
|
|
priority: 0,
|
|
handle: () => {
|
|
handleArchive();
|
|
return true;
|
|
},
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<WorkspaceRowInner
|
|
workspace={workspace}
|
|
hostBadge={hostBadge}
|
|
leadingProjectName={leadingProjectName}
|
|
leadingProjectIconDataUri={leadingProjectIconDataUri}
|
|
selected={selected}
|
|
shortcutNumber={shortcutNumber}
|
|
showShortcutBadge={showShortcutBadge}
|
|
onPress={onPress}
|
|
drag={drag}
|
|
isDragging={isDragging}
|
|
isArchiving={isArchiving}
|
|
isCreating={isCreating}
|
|
dragHandleProps={dragHandleProps}
|
|
menuController={null}
|
|
archiveLabel={t("sidebar.workspace.actions.archive")}
|
|
archiveStatus={isArchiving ? "pending" : "idle"}
|
|
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
|
|
onArchive={handleArchive}
|
|
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
|
onCopyPath={handleCopyPath}
|
|
onRename={handleOpenRename}
|
|
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
|
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
|
isPinned={isPinned}
|
|
onTogglePin={onTogglePin}
|
|
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
|
/>
|
|
<AdaptiveRenameModal
|
|
visible={isRenameOpen}
|
|
title={t("sidebar.workspace.rename.title")}
|
|
initialValue={workspace.title ?? workspace.name}
|
|
placeholder={workspace.name}
|
|
submitLabel={t("sidebar.workspace.rename.submit")}
|
|
onClose={handleCloseRename}
|
|
onSubmit={handleSubmitRename}
|
|
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface WorkspaceRowItemProps {
|
|
workspace: SidebarWorkspacePlacement;
|
|
workspaceEntry: SidebarWorkspaceEntry | null;
|
|
hostBadge?: HostBadgeModel | null;
|
|
leadingProjectName?: string | null;
|
|
leadingProjectIconDataUri?: string | null;
|
|
shortcutNumber: number | null;
|
|
showShortcutBadge: boolean;
|
|
canCopyBranchName: boolean;
|
|
canPin: boolean;
|
|
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
|
reserveIdleStatusIndicatorSpace?: boolean;
|
|
isCreating?: boolean;
|
|
selectionEnabled: boolean;
|
|
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
|
onWorkspacePress?: () => void;
|
|
drag?: () => void;
|
|
isDragging?: boolean;
|
|
dragHandleProps?: DraggableListDragHandleProps;
|
|
}
|
|
|
|
function WorkspaceRowItem({
|
|
workspace,
|
|
workspaceEntry,
|
|
hostBadge,
|
|
leadingProjectName,
|
|
leadingProjectIconDataUri,
|
|
shortcutNumber,
|
|
showShortcutBadge,
|
|
canCopyBranchName,
|
|
canPin,
|
|
onToggleWorkspacePin,
|
|
reserveIdleStatusIndicatorSpace = true,
|
|
isCreating = false,
|
|
selectionEnabled,
|
|
activeWorkspaceSelection,
|
|
onWorkspacePress,
|
|
drag,
|
|
isDragging = false,
|
|
dragHandleProps,
|
|
}: WorkspaceRowItemProps) {
|
|
const handlePress = useCallback(() => {
|
|
if (!workspace.serverId) {
|
|
return;
|
|
}
|
|
onWorkspacePress?.();
|
|
navigateToWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId });
|
|
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
|
|
|
return (
|
|
<WorkspaceRow
|
|
workspaceEntry={workspaceEntry}
|
|
hostBadge={hostBadge}
|
|
leadingProjectName={leadingProjectName}
|
|
leadingProjectIconDataUri={leadingProjectIconDataUri}
|
|
shortcutNumber={shortcutNumber}
|
|
showShortcutBadge={showShortcutBadge}
|
|
canCopyBranchName={canCopyBranchName}
|
|
canPin={canPin}
|
|
onToggleWorkspacePin={onToggleWorkspacePin}
|
|
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
|
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.workspaceEntry === next.workspaceEntry &&
|
|
previous.hostBadge === next.hostBadge &&
|
|
previous.leadingProjectName === next.leadingProjectName &&
|
|
previous.leadingProjectIconDataUri === next.leadingProjectIconDataUri &&
|
|
previous.shortcutNumber === next.shortcutNumber &&
|
|
previous.showShortcutBadge === next.showShortcutBadge &&
|
|
previous.canCopyBranchName === next.canCopyBranchName &&
|
|
previous.canPin === next.canPin &&
|
|
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
|
|
previous.reserveIdleStatusIndicatorSpace === next.reserveIdleStatusIndicatorSpace &&
|
|
previous.isCreating === next.isCreating &&
|
|
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({
|
|
workspaceEntry,
|
|
hostBadge,
|
|
leadingProjectName,
|
|
leadingProjectIconDataUri,
|
|
shortcutNumber,
|
|
showShortcutBadge,
|
|
onPress,
|
|
drag,
|
|
isDragging,
|
|
dragHandleProps,
|
|
canCopyBranchName,
|
|
canPin,
|
|
onToggleWorkspacePin,
|
|
reserveIdleStatusIndicatorSpace = true,
|
|
isCreating = false,
|
|
selected,
|
|
}: {
|
|
workspaceEntry: SidebarWorkspaceEntry | null;
|
|
hostBadge?: HostBadgeModel | null;
|
|
leadingProjectName?: string | null;
|
|
leadingProjectIconDataUri?: string | null;
|
|
shortcutNumber: number | null;
|
|
showShortcutBadge: boolean;
|
|
onPress: () => void;
|
|
drag: () => void;
|
|
isDragging: boolean;
|
|
dragHandleProps?: DraggableListDragHandleProps;
|
|
canCopyBranchName: boolean;
|
|
canPin: boolean;
|
|
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
|
reserveIdleStatusIndicatorSpace?: boolean;
|
|
isCreating?: boolean;
|
|
selected: boolean;
|
|
}) {
|
|
if (!workspaceEntry) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<WorkspaceRowWithMenu
|
|
workspace={workspaceEntry}
|
|
hostBadge={hostBadge}
|
|
leadingProjectName={leadingProjectName}
|
|
leadingProjectIconDataUri={leadingProjectIconDataUri}
|
|
selected={selected}
|
|
shortcutNumber={shortcutNumber}
|
|
showShortcutBadge={showShortcutBadge}
|
|
onPress={onPress}
|
|
drag={drag}
|
|
isDragging={isDragging}
|
|
dragHandleProps={dragHandleProps}
|
|
canCopyBranchName={canCopyBranchName}
|
|
canPin={canPin}
|
|
onToggleWorkspacePin={onToggleWorkspacePin}
|
|
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
|
isCreating={isCreating}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ProjectBlock({
|
|
project,
|
|
workspaceEntriesByKey,
|
|
collapsed,
|
|
displayName,
|
|
iconDataUri,
|
|
selectionEnabled,
|
|
showShortcutBadges,
|
|
shortcutIndexByWorkspaceKey,
|
|
parentGestureRef,
|
|
onToggleCollapsed,
|
|
onWorkspacePress,
|
|
onWorkspaceReorder,
|
|
onWorktreeCreated,
|
|
drag,
|
|
isDragging,
|
|
dragHandleProps,
|
|
useNestable,
|
|
dragGestureHostPresented,
|
|
creatingWorkspaceIds,
|
|
activeWorkspaceSelection,
|
|
hostBadgeByServerId,
|
|
supportsMultiplicityByServerId,
|
|
supportsPinningByServerId,
|
|
onToggleWorkspacePin,
|
|
}: {
|
|
project: SidebarProjectEntry;
|
|
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
|
collapsed: boolean;
|
|
displayName: string;
|
|
iconDataUri: string | null;
|
|
selectionEnabled: boolean;
|
|
showShortcutBadges: boolean;
|
|
shortcutIndexByWorkspaceKey: Map<string, number>;
|
|
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
|
onToggleCollapsed: (projectViewKey: string) => void;
|
|
onWorkspacePress?: () => void;
|
|
onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void;
|
|
onWorktreeCreated?: (workspaceId: string) => void;
|
|
drag: () => void;
|
|
isDragging: boolean;
|
|
dragHandleProps?: DraggableListDragHandleProps;
|
|
useNestable: boolean;
|
|
dragGestureHostPresented?: boolean;
|
|
creatingWorkspaceIds: ReadonlySet<string>;
|
|
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
|
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
|
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
|
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
|
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
|
}) {
|
|
const {
|
|
visibleItems: visibleWorkspaces,
|
|
expanded: workspacesExpanded,
|
|
canToggle: canToggleWorkspaces,
|
|
toggleExpanded: toggleWorkspacesExpanded,
|
|
} = useLimitedSidebarGroup(project.workspaces);
|
|
const rowModel = useMemo(
|
|
() =>
|
|
buildSidebarProjectRowModel({
|
|
project,
|
|
collapsed,
|
|
supportsMultiplicityByServerId,
|
|
}),
|
|
[collapsed, project, supportsMultiplicityByServerId],
|
|
);
|
|
|
|
// Collapsed rows hide their workspace rows, so the project row carries the most urgent
|
|
// status among them; expanded rows leave the signal to the child rows themselves.
|
|
const aggregateStatusBucket = useSidebarProjectStatusBucket({
|
|
workspaces: project.workspaces,
|
|
enabled: collapsed,
|
|
});
|
|
|
|
const active = isProjectSelectedByRoute({
|
|
selection: activeWorkspaceSelection,
|
|
project,
|
|
enabled: selectionEnabled,
|
|
});
|
|
|
|
const renderWorkspaceRow = useCallback(
|
|
(
|
|
item: SidebarWorkspacePlacement,
|
|
input?: {
|
|
drag?: () => void;
|
|
isDragging?: boolean;
|
|
dragHandleProps?: DraggableListDragHandleProps;
|
|
},
|
|
) => {
|
|
return (
|
|
<MemoWorkspaceRowItem
|
|
workspace={item}
|
|
workspaceEntry={workspaceEntriesByKey.get(item.workspaceKey) ?? null}
|
|
hostBadge={hostBadgeByServerId.get(item.serverId) ?? null}
|
|
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
|
|
showShortcutBadge={showShortcutBadges}
|
|
canCopyBranchName={project.projectKind === "git"}
|
|
canPin={supportsPinningByServerId.get(item.serverId) === true}
|
|
onToggleWorkspacePin={onToggleWorkspacePin}
|
|
isCreating={creatingWorkspaceIds.has(item.workspaceId)}
|
|
selectionEnabled={selectionEnabled}
|
|
activeWorkspaceSelection={activeWorkspaceSelection}
|
|
onWorkspacePress={onWorkspacePress}
|
|
drag={input?.drag}
|
|
isDragging={input?.isDragging}
|
|
dragHandleProps={input?.dragHandleProps}
|
|
/>
|
|
);
|
|
},
|
|
[
|
|
project.projectKind,
|
|
onToggleWorkspacePin,
|
|
supportsPinningByServerId,
|
|
activeWorkspaceSelection,
|
|
creatingWorkspaceIds,
|
|
hostBadgeByServerId,
|
|
onWorkspacePress,
|
|
selectionEnabled,
|
|
shortcutIndexByWorkspaceKey,
|
|
showShortcutBadges,
|
|
workspaceEntriesByKey,
|
|
],
|
|
);
|
|
|
|
const renderWorkspace = useCallback(
|
|
({
|
|
item,
|
|
drag: workspaceDrag,
|
|
isActive,
|
|
dragHandleProps: workspaceDragHandleProps,
|
|
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
|
|
return renderWorkspaceRow(item, {
|
|
drag: workspaceDrag,
|
|
isDragging: isActive,
|
|
dragHandleProps: workspaceDragHandleProps,
|
|
});
|
|
},
|
|
[renderWorkspaceRow],
|
|
);
|
|
|
|
const handleWorkspaceDragEnd = useCallback(
|
|
(workspaces: SidebarWorkspacePlacement[]) => {
|
|
onWorkspaceReorder(project.viewKey, workspaces);
|
|
},
|
|
[onWorkspaceReorder, project.viewKey],
|
|
);
|
|
|
|
const toast = useToast();
|
|
const { t } = useTranslation();
|
|
const [isRemovingProject, setIsRemovingProject] = useState(false);
|
|
|
|
const handleRemoveProject = useCallback(() => {
|
|
if (isRemovingProject) {
|
|
return;
|
|
}
|
|
|
|
void (async () => {
|
|
const confirmed = await confirmDialog({
|
|
title: t("sidebar.project.confirmations.removeTitle"),
|
|
message: t("sidebar.project.confirmations.removeMessage", { projectName: displayName }),
|
|
confirmLabel: t("sidebar.project.confirmations.removeConfirm"),
|
|
cancelLabel: t("sidebar.project.confirmations.cancel"),
|
|
destructive: true,
|
|
});
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
|
|
setIsRemovingProject(true);
|
|
const readiness = getCurrentProjectRemoveReadiness({
|
|
hosts: project.hosts,
|
|
});
|
|
if (readiness.kind === "needs_host_update") {
|
|
toast.error(t("sidebar.project.toasts.updateHostToRemove"));
|
|
setIsRemovingProject(false);
|
|
return;
|
|
}
|
|
|
|
void removeProjectFromHosts({
|
|
targets: readiness.targets,
|
|
getClient: (serverId) => getHostRuntimeStore().getClient(serverId),
|
|
})
|
|
.then((outcome) => {
|
|
if (outcome.kind === "host_disconnected") {
|
|
toast.error(t("sidebar.project.toasts.hostDisconnected"));
|
|
return null;
|
|
}
|
|
if (outcome.kind === "failed") {
|
|
toast.error(t("sidebar.project.toasts.removeFailed"));
|
|
}
|
|
return null;
|
|
})
|
|
.catch((error) => {
|
|
toast.error(
|
|
error instanceof Error ? error.message : t("sidebar.project.toasts.removeFailed"),
|
|
);
|
|
})
|
|
.finally(() => {
|
|
setIsRemovingProject(false);
|
|
});
|
|
})();
|
|
}, [isRemovingProject, displayName, t, toast, project.hosts]);
|
|
|
|
const handleToggleCollapsed = useCallback(() => {
|
|
onToggleCollapsed(project.viewKey);
|
|
}, [onToggleCollapsed, project.viewKey]);
|
|
|
|
let projectChildren = null;
|
|
if (!collapsed) {
|
|
if (project.workspaces.length > 0) {
|
|
projectChildren = (
|
|
<>
|
|
<DraggableList
|
|
testID={`sidebar-workspace-list-${project.viewKey}`}
|
|
data={visibleWorkspaces}
|
|
keyExtractor={workspaceKeyExtractor}
|
|
renderItem={renderWorkspace}
|
|
onDragEnd={handleWorkspaceDragEnd}
|
|
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
|
scrollEnabled={false}
|
|
useDragHandle
|
|
nestable={useNestable}
|
|
simultaneousGestureRef={parentGestureRef}
|
|
gestureHostPresented={dragGestureHostPresented}
|
|
containerStyle={styles.workspaceListContainer}
|
|
/>
|
|
{canToggleWorkspaces ? (
|
|
<SidebarGroupToggleRow
|
|
expanded={workspacesExpanded}
|
|
onPress={toggleWorkspacesExpanded}
|
|
testID={`sidebar-project-show-more-${project.viewKey}`}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
} else if (rowModel.trailingAction.kind === "new_workspace") {
|
|
projectChildren = (
|
|
<NewWorkspaceGhostRow
|
|
project={project}
|
|
displayName={displayName}
|
|
worktreeTarget={rowModel.trailingAction.target}
|
|
onWorkspacePress={onWorkspacePress}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<View
|
|
role="group"
|
|
accessibilityLabel={displayName}
|
|
style={projectChildren ? styles.projectBlockExpanded : undefined}
|
|
>
|
|
<ProjectHeaderRow
|
|
project={project}
|
|
displayName={displayName}
|
|
iconDataUri={iconDataUri}
|
|
statusBucket={aggregateStatusBucket}
|
|
selected={false}
|
|
chevron={rowModel.chevron}
|
|
onPress={handleToggleCollapsed}
|
|
worktreeTarget={
|
|
rowModel.trailingAction.kind === "new_workspace" ? rowModel.trailingAction.target : null
|
|
}
|
|
isProjectActive={active}
|
|
onWorkspacePress={onWorkspacePress}
|
|
onWorktreeCreated={onWorktreeCreated}
|
|
drag={drag}
|
|
isDragging={isDragging}
|
|
isArchiving={isRemovingProject}
|
|
menuController={null}
|
|
onRemoveProject={handleRemoveProject}
|
|
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
|
|
dragHandleProps={dragHandleProps}
|
|
/>
|
|
|
|
{projectChildren}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
type ProjectBlockProps = Parameters<typeof ProjectBlock>[0];
|
|
|
|
// oxlint-disable-next-line complexity
|
|
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
|
|
return (
|
|
previous.project === next.project &&
|
|
previous.workspaceEntriesByKey === next.workspaceEntriesByKey &&
|
|
previous.collapsed === next.collapsed &&
|
|
previous.displayName === next.displayName &&
|
|
previous.iconDataUri === next.iconDataUri &&
|
|
previous.selectionEnabled === next.selectionEnabled &&
|
|
previous.showShortcutBadges === next.showShortcutBadges &&
|
|
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
|
|
previous.hostBadgeByServerId === next.hostBadgeByServerId &&
|
|
previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId &&
|
|
previous.supportsPinningByServerId === next.supportsPinningByServerId &&
|
|
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
|
|
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.dragGestureHostPresented === next.dragGestureHostPresented &&
|
|
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
|
|
areProjectBlockSelectionsEqual(previous, next)
|
|
);
|
|
}
|
|
|
|
function areProjectBlockSelectionsEqual(
|
|
previous: ProjectBlockProps,
|
|
next: ProjectBlockProps,
|
|
): boolean {
|
|
const previousActive = isProjectSelectedByRoute({
|
|
selection: previous.activeWorkspaceSelection,
|
|
project: previous.project,
|
|
enabled: previous.selectionEnabled,
|
|
});
|
|
const nextActive = isProjectSelectedByRoute({
|
|
selection: next.activeWorkspaceSelection,
|
|
project: next.project,
|
|
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({
|
|
statusGroups,
|
|
pinnedGroups,
|
|
projects,
|
|
workspaceEntriesByKey,
|
|
collapsedProjectKeys,
|
|
onToggleProjectCollapsed,
|
|
shortcutIndexByWorkspaceKey,
|
|
groupMode,
|
|
isRefreshing: _isRefreshing = false,
|
|
onRefresh: _onRefresh,
|
|
onWorkspacePress,
|
|
onAddProject,
|
|
listFooterComponent,
|
|
listHeaderComponent,
|
|
parentGestureRef,
|
|
dragGestureHostPresented,
|
|
}: SidebarWorkspaceListProps) {
|
|
const pathname = usePathname();
|
|
const hosts = useHosts();
|
|
const rowItems = useSidebarRowItems();
|
|
// Host badge visibility is a lattice, not three competing switches: this gate is the global
|
|
// "off", `shouldShowSidebarHostLabels` is the automatic "there is only one host so it says
|
|
// nothing", and each host's own `badgeDisplay` decides name vs icon vs hidden. Turning the
|
|
// item off here removes the badge everywhere; leaving it on defers to the per-host setting.
|
|
const hostBadgeByServerId = useHostBadges({
|
|
enabled: rowItems.host && shouldShowSidebarHostLabels(projects),
|
|
});
|
|
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
|
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
|
|
const supportsPinningByServerId = useHostFeatureMap(serverIds, "workspacePinning");
|
|
const onToggleWorkspacePin = useSidebarWorkspacePinController();
|
|
// Status mode drops the project grouping, so its rows carry their own project
|
|
// icon. Project mode fetches the same icons inside ProjectModeList for its
|
|
// project headers, so only the active mode requests them.
|
|
const statusProjectIconTargets = useMemo(
|
|
() => (groupMode === "status" ? resolveSidebarProjectIconTargets(projects) : []),
|
|
[groupMode, projects],
|
|
);
|
|
const statusProjectIconByProjectViewKey = useProjectIcons({
|
|
projects: statusProjectIconTargets,
|
|
});
|
|
|
|
const content =
|
|
groupMode === "status" ? (
|
|
<SidebarStatusModeWrapper
|
|
statusGroups={statusGroups}
|
|
pinnedGroups={pinnedGroups}
|
|
workspaceEntriesByKey={workspaceEntriesByKey}
|
|
projectIconByProjectViewKey={statusProjectIconByProjectViewKey}
|
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
|
onWorkspacePress={onWorkspacePress}
|
|
hostBadgeByServerId={hostBadgeByServerId}
|
|
supportsPinningByServerId={supportsPinningByServerId}
|
|
onToggleWorkspacePin={onToggleWorkspacePin}
|
|
listHeaderComponent={listHeaderComponent}
|
|
/>
|
|
) : (
|
|
<ProjectModeList
|
|
projects={projects}
|
|
pinnedGroups={pinnedGroups}
|
|
workspaceEntriesByKey={workspaceEntriesByKey}
|
|
collapsedProjectKeys={collapsedProjectKeys}
|
|
onToggleProjectCollapsed={onToggleProjectCollapsed}
|
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
|
onWorkspacePress={onWorkspacePress}
|
|
onAddProject={onAddProject}
|
|
listFooterComponent={listFooterComponent}
|
|
listHeaderComponent={listHeaderComponent}
|
|
parentGestureRef={parentGestureRef}
|
|
dragGestureHostPresented={dragGestureHostPresented}
|
|
pathname={pathname}
|
|
hostBadgeByServerId={hostBadgeByServerId}
|
|
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
|
supportsPinningByServerId={supportsPinningByServerId}
|
|
onToggleWorkspacePin={onToggleWorkspacePin}
|
|
/>
|
|
);
|
|
|
|
return content;
|
|
}
|
|
|
|
function SidebarStatusModeWrapper({
|
|
statusGroups,
|
|
pinnedGroups,
|
|
workspaceEntriesByKey,
|
|
projectIconByProjectViewKey,
|
|
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
|
|
onWorkspacePress,
|
|
hostBadgeByServerId,
|
|
supportsPinningByServerId,
|
|
onToggleWorkspacePin,
|
|
listHeaderComponent,
|
|
}: {
|
|
statusGroups: StatusGroup[];
|
|
pinnedGroups: PinnedSidebarGroups;
|
|
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
|
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
|
|
shortcutIndexByWorkspaceKey: Map<string, number>;
|
|
onWorkspacePress?: () => void;
|
|
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
|
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
|
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
|
listHeaderComponent?: ReactElement | null;
|
|
}) {
|
|
const showShortcutBadges = useShowShortcutBadges();
|
|
|
|
return (
|
|
<SidebarStatusWorkspaceList
|
|
groups={statusGroups}
|
|
pinnedWorkspaces={pinnedGroups.pinnedChats.flatMap((workspace) => {
|
|
const entry = workspaceEntriesByKey.get(workspace.workspaceKey);
|
|
return entry ? [entry] : [];
|
|
})}
|
|
projectIconByProjectViewKey={projectIconByProjectViewKey}
|
|
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
|
|
showShortcutBadges={showShortcutBadges}
|
|
onWorkspacePress={onWorkspacePress}
|
|
hostBadgeByServerId={hostBadgeByServerId}
|
|
supportsPinningByServerId={supportsPinningByServerId}
|
|
onToggleWorkspacePin={onToggleWorkspacePin}
|
|
listHeaderComponent={listHeaderComponent}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ProjectModeList({
|
|
projects,
|
|
pinnedGroups,
|
|
workspaceEntriesByKey,
|
|
collapsedProjectKeys,
|
|
onToggleProjectCollapsed,
|
|
shortcutIndexByWorkspaceKey,
|
|
onWorkspacePress,
|
|
onAddProject,
|
|
listFooterComponent,
|
|
listHeaderComponent,
|
|
parentGestureRef,
|
|
dragGestureHostPresented,
|
|
pathname,
|
|
hostBadgeByServerId,
|
|
supportsMultiplicityByServerId,
|
|
supportsPinningByServerId,
|
|
onToggleWorkspacePin,
|
|
}: Omit<SidebarWorkspaceListProps, "statusGroups" | "groupMode" | "isRefreshing" | "onRefresh"> & {
|
|
pathname: string;
|
|
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
|
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
|
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
|
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
|
}) {
|
|
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
|
|
const { t } = useTranslation();
|
|
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
|
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
|
new Map(),
|
|
);
|
|
const showShortcutBadges = useShowShortcutBadges();
|
|
const pinnedCollapsed = useSidebarCollapsedSectionsStore((state) => state.collapsedPinned);
|
|
const togglePinnedCollapsed = useSidebarCollapsedSectionsStore(
|
|
(state) => state.togglePinnedCollapsed,
|
|
);
|
|
|
|
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 { pinnedChats, unpinnedProjects } = pinnedGroups;
|
|
const {
|
|
visibleItems: visiblePinnedChats,
|
|
expanded: pinnedChatsExpanded,
|
|
canToggle: canTogglePinnedChats,
|
|
toggleExpanded: togglePinnedChatsExpanded,
|
|
} = useLimitedSidebarGroup(pinnedChats);
|
|
const projectIconTargets = useMemo(() => resolveSidebarProjectIconTargets(projects), [projects]);
|
|
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 projectIconByProjectViewKey = useProjectIcons({
|
|
projects: projectIconTargets,
|
|
});
|
|
|
|
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[]) => {
|
|
const reorderedProjectKeys = reorderedProjects.map((project) => project.viewKey);
|
|
const currentProjectOrder = getProjectOrder();
|
|
if (
|
|
!hasVisibleOrderChanged({
|
|
currentOrder: currentProjectOrder,
|
|
reorderedVisibleKeys: reorderedProjectKeys,
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
|
|
setProjectOrder(
|
|
mergeWithRemainder({
|
|
currentOrder: currentProjectOrder,
|
|
reorderedVisibleKeys: reorderedProjectKeys,
|
|
}),
|
|
);
|
|
},
|
|
[getProjectOrder, setProjectOrder],
|
|
);
|
|
|
|
const handleWorkspaceReorder = useCallback(
|
|
(projectViewKey: string, reorderedWorkspaces: SidebarWorkspacePlacement[]) => {
|
|
const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey);
|
|
const currentWorkspaceOrder = getWorkspaceOrder(projectViewKey);
|
|
if (
|
|
!hasVisibleOrderChanged({
|
|
currentOrder: currentWorkspaceOrder,
|
|
reorderedVisibleKeys: reorderedWorkspaceKeys,
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
|
|
setWorkspaceOrder(
|
|
projectViewKey,
|
|
mergeWithRemainder({
|
|
currentOrder: currentWorkspaceOrder,
|
|
reorderedVisibleKeys: reorderedWorkspaceKeys,
|
|
}),
|
|
);
|
|
},
|
|
[getWorkspaceOrder, 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 renderProjectBlock = useCallback(
|
|
(
|
|
item: SidebarProjectEntry,
|
|
dragState: {
|
|
drag: () => void;
|
|
isDragging: boolean;
|
|
dragHandleProps?: DraggableRenderItemInfo<SidebarProjectEntry>["dragHandleProps"];
|
|
},
|
|
) => {
|
|
return (
|
|
<MemoProjectBlock
|
|
key={item.viewKey}
|
|
project={item}
|
|
workspaceEntriesByKey={workspaceEntriesByKey}
|
|
collapsed={collapsedProjectKeys.has(item.viewKey)}
|
|
displayName={item.projectName}
|
|
iconDataUri={projectIconByProjectViewKey.get(item.viewKey) ?? null}
|
|
selectionEnabled={selectionEnabled}
|
|
showShortcutBadges={showShortcutBadges}
|
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
|
parentGestureRef={parentGestureRef}
|
|
onToggleCollapsed={onToggleProjectCollapsed}
|
|
onWorkspacePress={onWorkspacePress}
|
|
onWorkspaceReorder={handleWorkspaceReorder}
|
|
onWorktreeCreated={handleWorktreeCreated}
|
|
drag={dragState.drag}
|
|
isDragging={dragState.isDragging}
|
|
dragHandleProps={dragState.dragHandleProps}
|
|
useNestable={platformIsNative}
|
|
dragGestureHostPresented={dragGestureHostPresented}
|
|
creatingWorkspaceIds={creatingWorkspaceIds}
|
|
activeWorkspaceSelection={activeWorkspaceSelection}
|
|
hostBadgeByServerId={hostBadgeByServerId}
|
|
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
|
supportsPinningByServerId={supportsPinningByServerId}
|
|
onToggleWorkspacePin={onToggleWorkspacePin}
|
|
/>
|
|
);
|
|
},
|
|
[
|
|
collapsedProjectKeys,
|
|
activeWorkspaceSelection,
|
|
handleWorktreeCreated,
|
|
handleWorkspaceReorder,
|
|
hostBadgeByServerId,
|
|
supportsMultiplicityByServerId,
|
|
supportsPinningByServerId,
|
|
onToggleWorkspacePin,
|
|
onWorkspacePress,
|
|
onToggleProjectCollapsed,
|
|
parentGestureRef,
|
|
dragGestureHostPresented,
|
|
projectIconByProjectViewKey,
|
|
selectionEnabled,
|
|
shortcutIndexByWorkspaceKey,
|
|
showShortcutBadges,
|
|
workspaceEntriesByKey,
|
|
creatingWorkspaceIds,
|
|
],
|
|
);
|
|
|
|
const renderProject = useCallback(
|
|
({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) =>
|
|
renderProjectBlock(item, { drag, isDragging: isActive, dragHandleProps }),
|
|
[renderProjectBlock],
|
|
);
|
|
|
|
const renderPinnedChat = useCallback(
|
|
(workspace: SidebarWorkspacePlacement) => {
|
|
return (
|
|
<MemoWorkspaceRowItem
|
|
key={workspace.workspaceKey}
|
|
workspace={workspace}
|
|
workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null}
|
|
hostBadge={hostBadgeByServerId.get(workspace.serverId) ?? null}
|
|
leadingProjectName={workspace.projectName}
|
|
leadingProjectIconDataUri={
|
|
projectIconByProjectViewKey.get(workspace.projectViewKey) ?? null
|
|
}
|
|
shortcutNumber={shortcutIndexByWorkspaceKey.get(workspace.workspaceKey) ?? null}
|
|
showShortcutBadge={showShortcutBadges}
|
|
canCopyBranchName={workspace.projectKind === "git"}
|
|
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
|
onToggleWorkspacePin={onToggleWorkspacePin}
|
|
isCreating={creatingWorkspaceIds.has(workspace.workspaceId)}
|
|
selectionEnabled={selectionEnabled}
|
|
activeWorkspaceSelection={activeWorkspaceSelection}
|
|
onWorkspacePress={onWorkspacePress}
|
|
/>
|
|
);
|
|
},
|
|
[
|
|
activeWorkspaceSelection,
|
|
creatingWorkspaceIds,
|
|
hostBadgeByServerId,
|
|
onWorkspacePress,
|
|
selectionEnabled,
|
|
shortcutIndexByWorkspaceKey,
|
|
showShortcutBadges,
|
|
supportsPinningByServerId,
|
|
onToggleWorkspacePin,
|
|
projectIconByProjectViewKey,
|
|
workspaceEntriesByKey,
|
|
],
|
|
);
|
|
|
|
const content = (
|
|
<>
|
|
{pinnedChats.length > 0 ? (
|
|
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
|
|
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
|
|
{pinnedCollapsed ? null : (
|
|
<>
|
|
{visiblePinnedChats.map(renderPinnedChat)}
|
|
{canTogglePinnedChats ? (
|
|
<SidebarGroupToggleRow
|
|
expanded={pinnedChatsExpanded}
|
|
onPress={togglePinnedChatsExpanded}
|
|
testID="sidebar-pinned-show-more"
|
|
/>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</View>
|
|
) : null}
|
|
{unpinnedProjects.length > 0 || hasActiveHostFilter ? listHeaderComponent : null}
|
|
{projects.length === 0 ? (
|
|
<View style={styles.emptyContainer}>
|
|
<Text style={styles.emptyTitle} testID="sidebar-project-empty-state">
|
|
{t("sidebar.project.empty.title")}
|
|
</Text>
|
|
<Text style={styles.emptyText}>{t("sidebar.project.empty.description")}</Text>
|
|
<Button variant="ghost" size="sm" leftIcon={Plus} onPress={onAddProject}>
|
|
{t("sidebar.actions.addProject")}
|
|
</Button>
|
|
</View>
|
|
) : (
|
|
<DraggableList
|
|
testID="sidebar-project-list"
|
|
data={unpinnedProjects}
|
|
keyExtractor={projectViewKeyExtractor}
|
|
renderItem={renderProject}
|
|
onDragEnd={handleProjectDragEnd}
|
|
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
|
scrollEnabled={false}
|
|
useDragHandle
|
|
nestable={platformIsNative}
|
|
simultaneousGestureRef={parentGestureRef}
|
|
gestureHostPresented={dragGestureHostPresented}
|
|
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],
|
|
// Optical inset: aligns the visible Pinned/Workspaces glyph edge with the
|
|
// Schedules icon across the divider; their layout boxes have different insets.
|
|
paddingTop: 2,
|
|
paddingBottom: theme.spacing[4],
|
|
},
|
|
projectListContainer: {
|
|
width: "100%",
|
|
},
|
|
pinnedSection: {
|
|
marginBottom: theme.spacing[1],
|
|
},
|
|
// Three times the gap a row keeps from its neighbour, so the break between two groups reads as
|
|
// a break rather than as one more row of pitch. Kept equal to `statusGroupBlockExpanded` — the
|
|
// two groupings are the same list under a different heading and must not breathe differently.
|
|
//
|
|
// Padding on the block rather than margin, and only while it has children: the gap belongs to
|
|
// the rows underneath the header, so a collapsed project gives it back and a column of collapsed
|
|
// headers closes up to the pitch of a list instead of staying spaced for content that is gone.
|
|
projectBlockExpanded: {
|
|
paddingBottom: theme.spacing[3],
|
|
},
|
|
workspaceListContainer: {},
|
|
// Kept in step with `workspaceRow` above. It stands in a project's list where a workspace row
|
|
// would be, so it takes that row's geometry and both of its fills.
|
|
//
|
|
// The one departure is the extra left padding: it only ever renders under its project header, so
|
|
// the step in reads as belonging to that project. Padding rather than margin, so the hover and
|
|
// pressed fills stay the same box as every other row in the sidebar.
|
|
newWorkspaceGhostRow: {
|
|
minHeight: 36,
|
|
marginBottom: theme.spacing[0.5],
|
|
paddingVertical: theme.spacing[2],
|
|
paddingLeft: theme.spacing[4],
|
|
paddingRight: theme.spacing[3],
|
|
borderRadius: theme.borderRadius.lg,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
userSelect: "none",
|
|
},
|
|
newWorkspaceGhostRowHovered: {
|
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
|
},
|
|
newWorkspaceGhostRowPressed: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
// The width of a workspace row's status slot, so the label lands on the same rail as the
|
|
// titles above it.
|
|
newWorkspaceGhostIconSlot: {
|
|
width: theme.iconSize.md,
|
|
height: theme.iconSize.md,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
flexShrink: 0,
|
|
},
|
|
newWorkspaceGhostText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
minWidth: 0,
|
|
flexShrink: 1,
|
|
},
|
|
newWorkspaceGhostTextHovered: {
|
|
fontSize: theme.fontSize.sm,
|
|
minWidth: 0,
|
|
flexShrink: 1,
|
|
color: theme.colors.foreground,
|
|
},
|
|
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,
|
|
},
|
|
projectTitle: {
|
|
color: theme.colors.foregroundMuted,
|
|
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,
|
|
// MoreVertical paints only around the center of its 14px SVG. Keep the 24px controls,
|
|
// but pull their painted edge through the unused view-box space onto the row rail.
|
|
marginRight: -6,
|
|
},
|
|
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[0.5],
|
|
paddingVertical: theme.spacing[2],
|
|
paddingLeft: theme.spacing[2],
|
|
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",
|
|
},
|
|
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,
|
|
},
|
|
}));
|