mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
* feat(app): modernize compact headers and sidebar affordances Use a shared island header with quick tab creation and keep sheet footers inside their visible layout. Opt sidebar rows into touch sizing without changing global appearance settings. * test(app): match darker light-theme workspace selection * test(app): align plugin journeys with compact header
2798 lines
90 KiB
TypeScript
2798 lines
90 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 {
|
|
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 { density, densityFontSize, densityIconBox } from "@/styles/density";
|
|
import type { Theme } from "@/styles/theme";
|
|
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
|
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
|
|
import { type GestureType } from "react-native-gesture-handler";
|
|
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
|
|
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
|
|
import { ExternalLink, 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, type SidebarGroupMode } 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 { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels";
|
|
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 {
|
|
SidebarFilterEmptyState,
|
|
SidebarProjectEmptyState,
|
|
} from "@/components/sidebar/empty-states";
|
|
import { selectWorkspaceServiceSummary } from "@/components/sidebar/workspace-meta-row";
|
|
import {
|
|
SidebarWorkspaceTrailingContent,
|
|
useSidebarWorkspaceTrailing,
|
|
} from "@/components/sidebar/workspace-trailing";
|
|
import { DensityIcon } from "@/components/ui/density-icon";
|
|
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 { useWorkspaceReadState } from "@/hooks/use-workspace-read-state";
|
|
import type { PrHint } from "@/git/use-pr-status-query";
|
|
import {
|
|
buildSidebarProjectRowModel,
|
|
resolveSidebarProjectLocalPath,
|
|
type SidebarProjectHostTarget,
|
|
type SidebarProjectIconTarget,
|
|
} from "@/utils/sidebar-project-row-model";
|
|
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
|
import { openExternalUrl } from "@/utils/open-external-url";
|
|
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";
|
|
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
|
|
|
|
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
|
|
|
|
const projectViewKeyExtractor = (project: SidebarProjectEntry) => project.viewKey;
|
|
|
|
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
|
const ThemedExternalLink = withUnistyles(ExternalLink);
|
|
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,
|
|
});
|
|
|
|
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 {
|
|
workspaceGroups: SidebarWorkspaceGroup[];
|
|
/** What `useProjectIcons` is asked for, straight from the projection. See `SidebarProjection`. */
|
|
projectIconTargets: SidebarProjectIconTarget[];
|
|
pinnedGroups: PinnedSidebarGroups;
|
|
projects: SidebarProjectEntry[];
|
|
hasProjectsBeforeFilter: boolean;
|
|
/** Whether a project filter is actually being applied — the resolved list, not the stored one. */
|
|
hasActiveProjectFilter: boolean;
|
|
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
|
collapsedProjectKeys: ReadonlySet<string>;
|
|
onToggleProjectCollapsed: (projectViewKey: string) => void;
|
|
shortcutIndexByWorkspaceKey: Map<string, number>;
|
|
groupMode: SidebarGroupMode;
|
|
isRefreshing?: boolean;
|
|
onRefresh?: () => void;
|
|
onWorkspacePress?: () => void;
|
|
onAddProject?: () => void;
|
|
onImportSession?: () => 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>;
|
|
dragGestureHostActive?: 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;
|
|
onMarkAsUnread?: () => 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 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={foregroundColorMapping} />
|
|
) : (
|
|
<PullRequestStateIcon state={hint.state} size={12} />
|
|
)}
|
|
<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,
|
|
isPressed,
|
|
selected,
|
|
isHovered,
|
|
}: {
|
|
isDragging: boolean;
|
|
isPressed: boolean;
|
|
selected: boolean;
|
|
isHovered: boolean;
|
|
}) {
|
|
return [
|
|
styles.workspaceRow,
|
|
isHovered && styles.workspaceRowHovered,
|
|
selected && styles.sidebarRowSelected,
|
|
isDragging && styles.workspaceRowDragging,
|
|
isPressed && styles.workspaceRowPressed,
|
|
];
|
|
}
|
|
|
|
function noop() {}
|
|
|
|
const prBadgeStyles = StyleSheet.create((theme) => ({
|
|
badge: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 2,
|
|
},
|
|
badgePressed: {
|
|
opacity: 0.82,
|
|
},
|
|
text: {
|
|
fontSize: theme.fontSize.sm,
|
|
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,
|
|
backdrop,
|
|
isHovered,
|
|
isTouchPlatform,
|
|
isCreating,
|
|
showShortcutBadge,
|
|
shortcutNumber,
|
|
archiveLabel,
|
|
archiveStatus,
|
|
archivePendingLabel,
|
|
archiveShortcutKeys,
|
|
onArchive,
|
|
onMarkAsRead,
|
|
onMarkAsUnread,
|
|
onCopyBranchName,
|
|
onCopyPath,
|
|
onRename,
|
|
isPinned,
|
|
onTogglePin,
|
|
}: {
|
|
workspace: SidebarWorkspaceEntry;
|
|
backdrop: SidebarSurfaceBackdrop;
|
|
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;
|
|
onMarkAsUnread?: () => 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 {
|
|
trailingPresentation,
|
|
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 presentation={trailingPresentation}>
|
|
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
|
|
</SidebarWorkspaceTrailingActionBase>
|
|
<SidebarWorkspaceTrailingActionOverlay
|
|
visible={kebab.showKebab}
|
|
scrimBackdrop={showScrim ? backdrop : undefined}
|
|
>
|
|
{onArchive ? (
|
|
<SidebarWorkspaceMenu
|
|
{...kebab.menuProps}
|
|
workspaceKey={workspace.workspaceKey}
|
|
serverId={workspace.serverId}
|
|
workspaceId={workspace.workspaceId}
|
|
workspaceLabels={workspace.labels}
|
|
onCopyPath={onCopyPath}
|
|
onCopyBranchName={onCopyBranchName}
|
|
onRename={onRename}
|
|
onMarkAsRead={onMarkAsRead}
|
|
onMarkAsUnread={onMarkAsUnread}
|
|
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}>
|
|
<DensityIcon
|
|
icon={Plus}
|
|
size="sm"
|
|
color={hovered || pressed ? "foreground" : "foregroundMuted"}
|
|
/>
|
|
</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 [isPressed, setIsPressed] = 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 handleProjectPressIn = useCallback(
|
|
(event: GestureResponderEvent) => {
|
|
setIsPressed(true);
|
|
interaction.handlePressIn(event);
|
|
},
|
|
[interaction],
|
|
);
|
|
const handleProjectPressOut = useCallback(() => {
|
|
setIsPressed(false);
|
|
interaction.handlePressOut();
|
|
}, [interaction]);
|
|
|
|
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, isPressed, 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.projectRowPressed}
|
|
onPressIn={handleProjectPressIn}
|
|
onTouchMove={interaction.handleTouchMove}
|
|
onPressOut={handleProjectPressOut}
|
|
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.projectRowPressed}
|
|
onPressIn={handleProjectPressIn}
|
|
onTouchMove={interaction.handleTouchMove}
|
|
onPressOut={handleProjectPressOut}
|
|
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,
|
|
onMarkAsRead,
|
|
onMarkAsUnread,
|
|
archiveShortcutKeys,
|
|
isPinned,
|
|
onTogglePin,
|
|
reserveIdleStatusIndicatorSpace = true,
|
|
}: WorkspaceRowInnerProps) {
|
|
const isCompact = useIsCompactFormFactor();
|
|
const [isPressed, setIsPressed] = useState(false);
|
|
const isTouchPlatform = platformIsNative || isCompact;
|
|
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 handleWorkspacePressIn = useCallback(
|
|
(event: GestureResponderEvent) => {
|
|
setIsPressed(true);
|
|
interaction.handlePressIn(event);
|
|
},
|
|
[interaction],
|
|
);
|
|
const handleWorkspacePressOut = useCallback(() => {
|
|
setIsPressed(false);
|
|
interaction.handlePressOut();
|
|
}, [interaction]);
|
|
|
|
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,
|
|
isPressed,
|
|
selected,
|
|
isHovered,
|
|
});
|
|
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, 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}
|
|
onMarkAsRead={onMarkAsRead}
|
|
onMarkAsUnread={onMarkAsUnread}
|
|
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.workspaceRowPressed}
|
|
onPressIn={handleWorkspacePressIn}
|
|
onTouchMove={interaction.handleTouchMove}
|
|
onPressOut={handleWorkspacePressOut}
|
|
onPress={handlePress}
|
|
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
|
>
|
|
<SidebarWorkspaceRowContent
|
|
workspace={workspace}
|
|
hostBadge={hostBadge}
|
|
leadingProjectName={leadingProjectName}
|
|
leadingProjectIconDataUri={leadingProjectIconDataUri}
|
|
serviceSummary={serviceSummary}
|
|
backdrop={backdrop}
|
|
isHovered={isHovered}
|
|
isLoading={isArchiving || isCreating}
|
|
isCreating={isCreating}
|
|
shortcutNumber={shortcutNumber}
|
|
showShortcutBadge={showShortcutBadge}
|
|
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
|
>
|
|
<WorkspaceRowRightGroup
|
|
workspace={workspace}
|
|
backdrop={backdrop}
|
|
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}
|
|
onMarkAsRead={onMarkAsRead}
|
|
onMarkAsUnread={onMarkAsUnread}
|
|
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 clipboard = useWorkspaceClipboardActions();
|
|
const handleCopyPath = useCallback(() => {
|
|
clipboard.copyPath(workspace);
|
|
}, [clipboard, workspace]);
|
|
|
|
const handleCopyBranchName = useCallback(() => {
|
|
clipboard.copyBranchName(workspace);
|
|
}, [clipboard, workspace]);
|
|
|
|
const handleOpenRename = useCallback(() => {
|
|
setIsRenameOpen(true);
|
|
}, []);
|
|
|
|
const handleCloseRename = useCallback(() => {
|
|
setIsRenameOpen(false);
|
|
}, []);
|
|
|
|
const isPinned = workspace.pinnedAt != null;
|
|
const handleTogglePin = useCallback(() => {
|
|
onToggleWorkspacePin(workspace);
|
|
}, [onToggleWorkspacePin, workspace]);
|
|
const onTogglePin = canPin ? handleTogglePin : undefined;
|
|
|
|
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
|
const { hasClearableAttention, canMarkUnread, clearAttention, markUnread } =
|
|
useWorkspaceReadState({
|
|
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]);
|
|
const handleMarkAsUnread = useCallback(() => {
|
|
void markUnread().catch((error) => {
|
|
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as unread");
|
|
});
|
|
}, [markUnread, 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}
|
|
onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined}
|
|
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
|
isPinned={isPinned}
|
|
onTogglePin={onTogglePin}
|
|
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
|
/>
|
|
<WorkspaceRenameModal
|
|
visible={isRenameOpen}
|
|
workspace={workspace}
|
|
onClose={handleCloseRename}
|
|
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,
|
|
dragGestureHostActive,
|
|
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;
|
|
dragGestureHostActive?: 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={dragGestureHostActive}
|
|
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.dragGestureHostActive === next.dragGestureHostActive &&
|
|
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({
|
|
workspaceGroups,
|
|
projectIconTargets,
|
|
pinnedGroups,
|
|
projects,
|
|
hasProjectsBeforeFilter,
|
|
hasActiveProjectFilter,
|
|
workspaceEntriesByKey,
|
|
collapsedProjectKeys,
|
|
onToggleProjectCollapsed,
|
|
shortcutIndexByWorkspaceKey,
|
|
groupMode,
|
|
isRefreshing: _isRefreshing = false,
|
|
onRefresh: _onRefresh,
|
|
onWorkspacePress,
|
|
onAddProject,
|
|
onImportSession,
|
|
listFooterComponent,
|
|
listHeaderComponent,
|
|
parentGestureRef,
|
|
dragGestureHostActive,
|
|
}: 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();
|
|
const getPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.getPinnedWorkspaceOrder);
|
|
const setPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.setPinnedWorkspaceOrder);
|
|
const handlePinnedWorkspaceReorder = useCallback(
|
|
(reorderedWorkspaces: SidebarWorkspacePlacement[]) => {
|
|
const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey);
|
|
const currentOrder = getPinnedWorkspaceOrder();
|
|
if (
|
|
!hasVisibleOrderChanged({
|
|
currentOrder,
|
|
reorderedVisibleKeys: reorderedWorkspaceKeys,
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
|
|
setPinnedWorkspaceOrder(
|
|
mergeWithRemainder({
|
|
currentOrder,
|
|
reorderedVisibleKeys: reorderedWorkspaceKeys,
|
|
}),
|
|
);
|
|
},
|
|
[getPinnedWorkspaceOrder, setPinnedWorkspaceOrder],
|
|
);
|
|
// One fetch, one map, every mode — project mode paints icons on its headers and status mode
|
|
// paints them on each row, all keyed by `projectViewKey`. The targets come from the projection
|
|
// that produced the rows, so the question "what is on screen" is answered once.
|
|
const projectIconByProjectViewKey = useProjectIcons({ projects: projectIconTargets });
|
|
|
|
// A filter that matches nothing swaps the list's body and nothing above it. It used to replace
|
|
// this whole subtree, which unmounted the header — and the header is where the display menu's
|
|
// trigger lives, so filtering the last row away closed the menu you were filtering from.
|
|
//
|
|
// Background visibility can also hide the last project. Keep the header mounted
|
|
// whenever a local filter empties an otherwise populated directory, so users can
|
|
// reach the display menu to reveal their work again.
|
|
const sidebarFilterEmpty = hasProjectsBeforeFilter && projects.length === 0;
|
|
|
|
// Project mode is the one that keeps its project headers; every other grouping mode is a flat
|
|
// list of grouped rows, so a new mode lands in the grouped branch rather than silently in this
|
|
// one's `else`.
|
|
const content =
|
|
groupMode !== "project" ? (
|
|
<SidebarGroupedModeList
|
|
workspaceGroups={workspaceGroups}
|
|
pinnedGroups={pinnedGroups}
|
|
workspaceEntriesByKey={workspaceEntriesByKey}
|
|
projectIconByProjectViewKey={projectIconByProjectViewKey}
|
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
|
onWorkspacePress={onWorkspacePress}
|
|
hostBadgeByServerId={hostBadgeByServerId}
|
|
supportsPinningByServerId={supportsPinningByServerId}
|
|
onToggleWorkspacePin={onToggleWorkspacePin}
|
|
onPinnedWorkspaceReorder={handlePinnedWorkspaceReorder}
|
|
listHeaderComponent={listHeaderComponent}
|
|
sidebarFilterEmpty={sidebarFilterEmpty}
|
|
parentGestureRef={parentGestureRef}
|
|
dragGestureHostActive={dragGestureHostActive}
|
|
/>
|
|
) : (
|
|
<ProjectModeList
|
|
projects={projects}
|
|
pinnedGroups={pinnedGroups}
|
|
workspaceEntriesByKey={workspaceEntriesByKey}
|
|
projectIconByProjectViewKey={projectIconByProjectViewKey}
|
|
collapsedProjectKeys={collapsedProjectKeys}
|
|
onToggleProjectCollapsed={onToggleProjectCollapsed}
|
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
|
onWorkspacePress={onWorkspacePress}
|
|
onAddProject={onAddProject}
|
|
onImportSession={onImportSession}
|
|
listFooterComponent={listFooterComponent}
|
|
listHeaderComponent={listHeaderComponent}
|
|
sidebarFilterEmpty={sidebarFilterEmpty}
|
|
hasActiveProjectFilter={hasActiveProjectFilter}
|
|
parentGestureRef={parentGestureRef}
|
|
dragGestureHostActive={dragGestureHostActive}
|
|
pathname={pathname}
|
|
hostBadgeByServerId={hostBadgeByServerId}
|
|
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
|
supportsPinningByServerId={supportsPinningByServerId}
|
|
onToggleWorkspacePin={onToggleWorkspacePin}
|
|
onPinnedWorkspaceReorder={handlePinnedWorkspaceReorder}
|
|
/>
|
|
);
|
|
|
|
return content;
|
|
}
|
|
|
|
/**
|
|
* Every grouping mode except project: the rows are grouped by something that is not a project, so
|
|
* each row carries its own project icon. Named for what it does rather than for the first mode
|
|
* that needed it — `SidebarStatusModeWrapper` is what made a label-mode reader believe the data
|
|
* above it was status-only.
|
|
*/
|
|
function SidebarGroupedModeList({
|
|
workspaceGroups,
|
|
pinnedGroups,
|
|
workspaceEntriesByKey,
|
|
projectIconByProjectViewKey,
|
|
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
|
|
onWorkspacePress,
|
|
hostBadgeByServerId,
|
|
supportsPinningByServerId,
|
|
onToggleWorkspacePin,
|
|
onPinnedWorkspaceReorder,
|
|
listHeaderComponent,
|
|
sidebarFilterEmpty,
|
|
parentGestureRef,
|
|
dragGestureHostActive,
|
|
}: {
|
|
workspaceGroups: SidebarWorkspaceGroup[];
|
|
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;
|
|
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void;
|
|
listHeaderComponent?: ReactElement | null;
|
|
sidebarFilterEmpty: boolean;
|
|
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
|
dragGestureHostActive?: boolean;
|
|
}) {
|
|
const showShortcutBadges = useShowShortcutBadges();
|
|
const pinnedWorkspaces = useMemo(
|
|
() =>
|
|
pinnedGroups.pinnedChats.flatMap((workspace) => {
|
|
const entry = workspaceEntriesByKey.get(workspace.workspaceKey);
|
|
return entry ? [entry] : [];
|
|
}),
|
|
[pinnedGroups.pinnedChats, workspaceEntriesByKey],
|
|
);
|
|
|
|
return (
|
|
<SidebarStatusWorkspaceList
|
|
groups={workspaceGroups}
|
|
pinnedWorkspaces={pinnedWorkspaces}
|
|
projectIconByProjectViewKey={projectIconByProjectViewKey}
|
|
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
|
|
showShortcutBadges={showShortcutBadges}
|
|
onWorkspacePress={onWorkspacePress}
|
|
hostBadgeByServerId={hostBadgeByServerId}
|
|
supportsPinningByServerId={supportsPinningByServerId}
|
|
onToggleWorkspacePin={onToggleWorkspacePin}
|
|
onPinnedWorkspaceReorder={onPinnedWorkspaceReorder}
|
|
listHeaderComponent={listHeaderComponent}
|
|
sidebarFilterEmpty={sidebarFilterEmpty}
|
|
parentGestureRef={parentGestureRef}
|
|
dragGestureHostActive={dragGestureHostActive}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ProjectModeList({
|
|
projects,
|
|
pinnedGroups,
|
|
workspaceEntriesByKey,
|
|
projectIconByProjectViewKey,
|
|
collapsedProjectKeys,
|
|
onToggleProjectCollapsed,
|
|
shortcutIndexByWorkspaceKey,
|
|
onWorkspacePress,
|
|
onAddProject,
|
|
onImportSession,
|
|
listFooterComponent,
|
|
listHeaderComponent,
|
|
sidebarFilterEmpty,
|
|
hasActiveProjectFilter,
|
|
parentGestureRef,
|
|
dragGestureHostActive,
|
|
pathname,
|
|
hostBadgeByServerId,
|
|
supportsMultiplicityByServerId,
|
|
supportsPinningByServerId,
|
|
onToggleWorkspacePin,
|
|
onPinnedWorkspaceReorder,
|
|
}: Omit<
|
|
SidebarWorkspaceListProps,
|
|
| "workspaceGroups"
|
|
| "projectIconTargets"
|
|
| "groupMode"
|
|
| "hasProjectsBeforeFilter"
|
|
| "isRefreshing"
|
|
| "onRefresh"
|
|
> & {
|
|
/** Swaps the list body for the label filter's empty state. Never the header above it. */
|
|
sidebarFilterEmpty: boolean;
|
|
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
|
|
pathname: string;
|
|
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
|
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
|
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
|
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
|
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void;
|
|
}) {
|
|
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
|
|
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 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],
|
|
);
|
|
|
|
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}
|
|
dragGestureHostActive={dragGestureHostActive}
|
|
creatingWorkspaceIds={creatingWorkspaceIds}
|
|
activeWorkspaceSelection={activeWorkspaceSelection}
|
|
hostBadgeByServerId={hostBadgeByServerId}
|
|
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
|
supportsPinningByServerId={supportsPinningByServerId}
|
|
onToggleWorkspacePin={onToggleWorkspacePin}
|
|
/>
|
|
);
|
|
},
|
|
[
|
|
collapsedProjectKeys,
|
|
activeWorkspaceSelection,
|
|
handleWorktreeCreated,
|
|
handleWorkspaceReorder,
|
|
hostBadgeByServerId,
|
|
supportsMultiplicityByServerId,
|
|
supportsPinningByServerId,
|
|
onToggleWorkspacePin,
|
|
onWorkspacePress,
|
|
onToggleProjectCollapsed,
|
|
parentGestureRef,
|
|
dragGestureHostActive,
|
|
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(
|
|
({
|
|
item: workspace,
|
|
drag,
|
|
isActive,
|
|
dragHandleProps,
|
|
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
|
|
return (
|
|
<MemoWorkspaceRowItem
|
|
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}
|
|
drag={drag}
|
|
isDragging={isActive}
|
|
dragHandleProps={dragHandleProps}
|
|
/>
|
|
);
|
|
},
|
|
[
|
|
activeWorkspaceSelection,
|
|
creatingWorkspaceIds,
|
|
hostBadgeByServerId,
|
|
onWorkspacePress,
|
|
selectionEnabled,
|
|
shortcutIndexByWorkspaceKey,
|
|
showShortcutBadges,
|
|
supportsPinningByServerId,
|
|
onToggleWorkspacePin,
|
|
projectIconByProjectViewKey,
|
|
workspaceEntriesByKey,
|
|
],
|
|
);
|
|
|
|
const projectBody =
|
|
projects.length === 0 ? (
|
|
<SidebarProjectEmptyState onAddProject={onAddProject} onImportSession={onImportSession} />
|
|
) : (
|
|
<DraggableList
|
|
testID="sidebar-project-list"
|
|
data={unpinnedProjects}
|
|
keyExtractor={projectViewKeyExtractor}
|
|
renderItem={renderProject}
|
|
onDragEnd={handleProjectDragEnd}
|
|
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
|
scrollEnabled={false}
|
|
useDragHandle
|
|
nestable={platformIsNative}
|
|
simultaneousGestureRef={parentGestureRef}
|
|
gestureHostPresented={dragGestureHostActive}
|
|
containerStyle={styles.projectListContainer}
|
|
/>
|
|
);
|
|
|
|
const content = (
|
|
<>
|
|
{pinnedChats.length > 0 ? (
|
|
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
|
|
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
|
|
{pinnedCollapsed ? null : (
|
|
<>
|
|
<DraggableList
|
|
testID="sidebar-pinned-list"
|
|
data={visiblePinnedChats}
|
|
keyExtractor={workspaceKeyExtractor}
|
|
renderItem={renderPinnedChat}
|
|
onDragEnd={onPinnedWorkspaceReorder}
|
|
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
|
scrollEnabled={false}
|
|
useDragHandle
|
|
nestable={platformIsNative}
|
|
simultaneousGestureRef={parentGestureRef}
|
|
gestureHostPresented={dragGestureHostActive}
|
|
containerStyle={styles.workspaceListContainer}
|
|
/>
|
|
{canTogglePinnedChats ? (
|
|
<SidebarGroupToggleRow
|
|
expanded={pinnedChatsExpanded}
|
|
onPress={togglePinnedChatsExpanded}
|
|
testID="sidebar-pinned-show-more"
|
|
/>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</View>
|
|
) : null}
|
|
{/* The header carries the display menu, which is the only way back out of a filter, so it
|
|
stays for as long as a filter is what emptied the list. It is absent only when the
|
|
sidebar is genuinely empty, where a section heading would sit over nothing.
|
|
Every filter that can empty this branch needs a term here: a project filter pinned to a
|
|
project whose chats are all pinned leaves `unpinnedProjects` empty, and without its term
|
|
the header would go with it, taking the only route back to the filter page. */}
|
|
{unpinnedProjects.length > 0 ||
|
|
hasActiveHostFilter ||
|
|
hasActiveProjectFilter ||
|
|
sidebarFilterEmpty
|
|
? listHeaderComponent
|
|
: null}
|
|
{sidebarFilterEmpty ? <SidebarFilterEmptyState /> : projectBody}
|
|
{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: density.rowHeight,
|
|
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: densityIconBox("md"),
|
|
height: densityIconBox("md"),
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
flexShrink: 0,
|
|
},
|
|
newWorkspaceGhostText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: densityFontSize(theme, "base"),
|
|
minWidth: 0,
|
|
flexShrink: 1,
|
|
},
|
|
newWorkspaceGhostTextHovered: {
|
|
fontSize: densityFontSize(theme, "base"),
|
|
minWidth: 0,
|
|
flexShrink: 1,
|
|
color: theme.colors.foreground,
|
|
},
|
|
projectRow: {
|
|
position: "relative",
|
|
minHeight: density.rowHeight,
|
|
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: densityFontSize(theme, "base"),
|
|
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.sm,
|
|
},
|
|
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.base,
|
|
},
|
|
projectActionTooltipShortcut: {},
|
|
projectShortcutBadgeOverlay: {
|
|
position: "absolute",
|
|
top: theme.spacing[2] + 1,
|
|
right: theme.spacing[2],
|
|
},
|
|
workspaceRow: {
|
|
minHeight: density.rowHeight,
|
|
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.surfaceSidebarSelected,
|
|
},
|
|
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.sm,
|
|
fontWeight: "600",
|
|
},
|
|
workspaceBranchText: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.base,
|
|
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.sm,
|
|
flexShrink: 0,
|
|
},
|
|
kebabButton: {
|
|
padding: 2,
|
|
borderRadius: 4,
|
|
marginLeft: 2,
|
|
},
|
|
kebabButtonHovered: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
}));
|