Files
paseo/packages/app/src/components/sidebar-workspace-list.tsx
T
Mohamed Boudra 01f9bc8291 Modernize compact headers and sidebar affordances (#6431)
* 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
2026-10-10 20:53:09 +01:00

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,
},
}));