* perf(app): keep large sidebars responsive Sidebar rows rescanned every agent for each session update, multiplying work with both workspace and agent count. Build workspace activity once and share the derived sidebar model so updates scale with the underlying collections. * test(app): use valid agent fixture for workspace restore * refactor(app): keep sidebar model surface focused
872 lines
28 KiB
TypeScript
872 lines
28 KiB
TypeScript
import { memo, useCallback, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
|
|
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
|
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
|
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
|
import {
|
|
useSidebarWorkspaceEntry,
|
|
type SidebarStatusWorkspacePlacement,
|
|
type SidebarWorkspaceEntry,
|
|
} from "@/hooks/use-sidebar-workspaces-list";
|
|
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
|
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
|
import { StyleSheet } from "react-native-unistyles";
|
|
import type { Theme } from "@/styles/theme";
|
|
import { withUnistyles } from "react-native-unistyles";
|
|
import {
|
|
ChevronDown,
|
|
ChevronRight,
|
|
CircleAlert,
|
|
CircleCheck,
|
|
CircleDot,
|
|
CircleX,
|
|
MoreVertical,
|
|
Copy,
|
|
Archive,
|
|
Pencil,
|
|
} from "lucide-react-native";
|
|
import { DiffStat } from "@/components/diff-stat";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { useToast } from "@/contexts/toast-context";
|
|
import { useMutation } from "@tanstack/react-query";
|
|
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
|
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
|
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
|
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
|
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
|
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
|
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
|
import * as Clipboard from "expo-clipboard";
|
|
import { Shortcut } from "@/components/ui/shortcut";
|
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
|
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
|
|
import {
|
|
SidebarWorkspaceRowFrame,
|
|
SidebarWorkspaceRowContent,
|
|
SidebarWorkspaceTrailingActionBase,
|
|
SidebarWorkspaceTrailingActionOverlay,
|
|
SidebarWorkspaceTrailingActionSlot,
|
|
} from "@/components/sidebar/sidebar-workspace-row-content";
|
|
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
|
|
|
// Themed icon wrappers
|
|
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
|
color: theme.colors.foregroundMuted,
|
|
});
|
|
const blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] });
|
|
const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
|
|
const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
|
|
const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
|
|
|
|
const ThemedChevronDown = withUnistyles(ChevronDown);
|
|
const ThemedChevronRight = withUnistyles(ChevronRight);
|
|
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
|
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
|
const ThemedCircleDot = withUnistyles(CircleDot);
|
|
const ThemedCircleX = withUnistyles(CircleX);
|
|
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
|
const ThemedCopy = withUnistyles(Copy);
|
|
const ThemedArchive = withUnistyles(Archive);
|
|
const ThemedPencil = withUnistyles(Pencil);
|
|
|
|
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
|
const markAsReadLeadingIcon = (
|
|
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
|
|
);
|
|
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
|
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
|
|
|
interface StatusWorkspaceListProps {
|
|
groups: StatusGroup[];
|
|
projectNamesByKey: Map<string, string>;
|
|
shortcutIndexByWorkspaceKey: Map<string, number>;
|
|
showShortcutBadges: boolean;
|
|
onWorkspacePress?: () => void;
|
|
hostLabelByServerId: ReadonlyMap<string, string>;
|
|
showHostLabels: boolean;
|
|
}
|
|
|
|
export function SidebarStatusWorkspaceList({
|
|
groups,
|
|
projectNamesByKey,
|
|
shortcutIndexByWorkspaceKey,
|
|
showShortcutBadges,
|
|
onWorkspacePress,
|
|
hostLabelByServerId,
|
|
showHostLabels,
|
|
}: StatusWorkspaceListProps) {
|
|
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
|
|
(state) => state.collapsedStatusGroupKeys,
|
|
);
|
|
|
|
const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : new Map();
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
{platformIsNative ? (
|
|
<NestableScrollContainer
|
|
style={styles.list}
|
|
contentContainerStyle={styles.listContent}
|
|
showsVerticalScrollIndicator={false}
|
|
testID="sidebar-status-list-scroll"
|
|
>
|
|
<StatusGroupList
|
|
groups={groups}
|
|
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
|
|
projectNamesByKey={projectNamesByKey}
|
|
shortcutIndex={statusShortcutIndex}
|
|
showShortcutBadges={showShortcutBadges}
|
|
onWorkspacePress={onWorkspacePress}
|
|
hostLabelByServerId={hostLabelByServerId}
|
|
showHostLabels={showHostLabels}
|
|
/>
|
|
</NestableScrollContainer>
|
|
) : (
|
|
<ScrollView
|
|
style={styles.list}
|
|
contentContainerStyle={styles.listContent}
|
|
showsVerticalScrollIndicator={false}
|
|
testID="sidebar-status-list-scroll"
|
|
>
|
|
<StatusGroupList
|
|
groups={groups}
|
|
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
|
|
projectNamesByKey={projectNamesByKey}
|
|
shortcutIndex={statusShortcutIndex}
|
|
showShortcutBadges={showShortcutBadges}
|
|
onWorkspacePress={onWorkspacePress}
|
|
hostLabelByServerId={hostLabelByServerId}
|
|
showHostLabels={showHostLabels}
|
|
/>
|
|
</ScrollView>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function StatusGroupList({
|
|
groups,
|
|
collapsedStatusGroupKeys,
|
|
projectNamesByKey,
|
|
shortcutIndex,
|
|
showShortcutBadges,
|
|
onWorkspacePress,
|
|
hostLabelByServerId,
|
|
showHostLabels,
|
|
}: {
|
|
groups: StatusGroup[];
|
|
collapsedStatusGroupKeys: ReadonlySet<string>;
|
|
projectNamesByKey: Map<string, string>;
|
|
shortcutIndex: Map<string, number>;
|
|
showShortcutBadges: boolean;
|
|
onWorkspacePress?: () => void;
|
|
hostLabelByServerId: ReadonlyMap<string, string>;
|
|
showHostLabels: boolean;
|
|
}) {
|
|
return (
|
|
<>
|
|
{groups.map((group) => (
|
|
<View key={group.bucket} style={styles.statusGroupBlock}>
|
|
<StatusGroupHeader group={group} collapsed={collapsedStatusGroupKeys.has(group.bucket)} />
|
|
{!collapsedStatusGroupKeys.has(group.bucket) ? (
|
|
<View
|
|
style={styles.statusWorkspaceListContainer}
|
|
testID={`sidebar-status-group-rows-${group.bucket}`}
|
|
>
|
|
{group.rows.map((workspace) => (
|
|
<StatusWorkspaceRow
|
|
key={workspace.workspaceKey}
|
|
workspace={workspace}
|
|
subtitle={buildStatusRowSubtitle({
|
|
projectName: projectNamesByKey.get(workspace.projectKey) ?? "",
|
|
hostLabel: showHostLabels
|
|
? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId)
|
|
: null,
|
|
})}
|
|
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
|
|
showShortcutBadge={showShortcutBadges}
|
|
onWorkspacePress={onWorkspacePress}
|
|
/>
|
|
))}
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Status mode breaks the project grouping, so the row needs the project name to stay
|
|
// legible; the host is appended after a middle dot once labels are active.
|
|
function buildStatusRowSubtitle({
|
|
projectName,
|
|
hostLabel,
|
|
}: {
|
|
projectName: string;
|
|
hostLabel: string | null;
|
|
}): string {
|
|
if (!hostLabel) {
|
|
return projectName;
|
|
}
|
|
return projectName ? `${projectName} · ${hostLabel}` : hostLabel;
|
|
}
|
|
|
|
function StatusGroupHeader({ group, collapsed }: { group: StatusGroup; collapsed: boolean }) {
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const toggleStatusGroupCollapsed = useSidebarCollapsedSectionsStore(
|
|
(state) => state.toggleStatusGroupCollapsed,
|
|
);
|
|
const handlePress = useCallback(() => {
|
|
toggleStatusGroupCollapsed(group.bucket);
|
|
}, [group.bucket, toggleStatusGroupCollapsed]);
|
|
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
|
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
|
const rowStyle = useCallback(
|
|
({ pressed }: PressableStateCallbackType) => [
|
|
styles.statusGroupRow,
|
|
isHovered && styles.statusGroupRowHovered,
|
|
pressed && styles.statusGroupRowPressed,
|
|
],
|
|
[isHovered],
|
|
);
|
|
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
|
|
|
|
return (
|
|
<View onPointerEnter={handleHoverIn} onPointerLeave={handleHoverOut}>
|
|
<Pressable
|
|
accessibilityRole={platformIsWeb ? undefined : "button"}
|
|
accessibilityLabel={`${group.label} status group`}
|
|
accessibilityState={accessibilityState}
|
|
style={rowStyle}
|
|
onPress={handlePress}
|
|
testID={`sidebar-status-group-${group.bucket}`}
|
|
>
|
|
<View style={styles.statusGroupRowLeft}>
|
|
<View style={styles.statusGroupLeadingVisualSlot}>
|
|
<StatusGroupLeadingVisual
|
|
bucket={group.bucket}
|
|
collapsed={collapsed}
|
|
showChevron={isHovered}
|
|
/>
|
|
</View>
|
|
<View style={styles.statusGroupTitleGroup}>
|
|
<Text style={styles.statusGroupTitle} numberOfLines={1}>
|
|
{group.label}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function StatusGroupLeadingVisual({
|
|
bucket,
|
|
collapsed,
|
|
showChevron,
|
|
}: {
|
|
bucket: StatusGroup["bucket"];
|
|
collapsed: boolean;
|
|
showChevron: boolean;
|
|
}) {
|
|
if (!showChevron) {
|
|
return <StatusGroupIcon bucket={bucket} />;
|
|
}
|
|
if (collapsed) {
|
|
return <ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />;
|
|
}
|
|
return <ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />;
|
|
}
|
|
|
|
function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) {
|
|
switch (bucket) {
|
|
case "needs_input":
|
|
return <ThemedCircleAlert size={14} uniProps={amberColorMapping} />;
|
|
case "failed":
|
|
return <ThemedCircleX size={14} uniProps={redColorMapping} />;
|
|
case "attention":
|
|
return <ThemedCircleCheck size={14} uniProps={greenColorMapping} />;
|
|
case "running":
|
|
return <ThemedCircleDot size={14} uniProps={blueColorMapping} />;
|
|
case "done":
|
|
return <ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />;
|
|
}
|
|
}
|
|
|
|
const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
|
workspace,
|
|
subtitle,
|
|
shortcutNumber,
|
|
showShortcutBadge,
|
|
onWorkspacePress,
|
|
}: {
|
|
workspace: SidebarStatusWorkspacePlacement;
|
|
subtitle: string;
|
|
shortcutNumber: number | null;
|
|
showShortcutBadge: boolean;
|
|
onWorkspacePress?: () => void;
|
|
}) {
|
|
const workspaceEntry = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
|
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
|
const selected =
|
|
activeWorkspaceSelection?.serverId === workspace.serverId &&
|
|
activeWorkspaceSelection?.workspaceId === workspace.workspaceId;
|
|
|
|
const handlePress = useCallback(() => {
|
|
if (!workspace.serverId) return;
|
|
onWorkspacePress?.();
|
|
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
|
|
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
|
|
|
if (!workspaceEntry) return null;
|
|
|
|
return (
|
|
<StatusWorkspaceRowWithMenu
|
|
workspace={workspaceEntry}
|
|
subtitle={subtitle}
|
|
selected={selected}
|
|
shortcutNumber={shortcutNumber}
|
|
showShortcutBadge={showShortcutBadge}
|
|
onPress={handlePress}
|
|
/>
|
|
);
|
|
});
|
|
|
|
function StatusWorkspaceRowWithMenu({
|
|
workspace,
|
|
subtitle,
|
|
selected,
|
|
shortcutNumber,
|
|
showShortcutBadge,
|
|
onPress,
|
|
}: {
|
|
workspace: SidebarWorkspaceEntry;
|
|
subtitle: string;
|
|
selected: boolean;
|
|
shortcutNumber: number | null;
|
|
showShortcutBadge: boolean;
|
|
onPress: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const toast = useToast();
|
|
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
|
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
|
const workspaceDirectory = resolveWorkspaceDirectory({
|
|
workspaceDirectory: workspace.workspaceDirectory,
|
|
});
|
|
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
|
|
workspaceDirectory
|
|
? state.getStatus({
|
|
serverId: workspace.serverId,
|
|
cwd: workspaceDirectory,
|
|
actionId: "archive-worktree",
|
|
})
|
|
: "idle",
|
|
);
|
|
const isWorktree = workspace.workspaceKind === "worktree";
|
|
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
|
|
|
|
const redirectAfterArchive = useCallback(() => {
|
|
redirectIfArchivingActiveWorkspace({
|
|
serverId: workspace.serverId,
|
|
workspaceId: workspace.workspaceId,
|
|
activeWorkspaceSelection: selected
|
|
? { serverId: workspace.serverId, workspaceId: workspace.workspaceId }
|
|
: null,
|
|
});
|
|
}, [selected, workspace]);
|
|
|
|
const archiveController = useWorkspaceArchive({
|
|
serverId: workspace.serverId,
|
|
workspaceId: workspace.workspaceId,
|
|
workspaceDirectory: workspace.workspaceDirectory,
|
|
workspaceKind: workspace.workspaceKind,
|
|
name: workspace.name,
|
|
...toWorktreeArchiveRisk(workspace),
|
|
onArchiveStarted: redirectAfterArchive,
|
|
onSetHiding: setIsHidingWorkspace,
|
|
});
|
|
|
|
const handleArchive = useCallback(() => {
|
|
if (isArchiving) return;
|
|
archiveController.archive();
|
|
}, [archiveController, isArchiving]);
|
|
|
|
const handleCopyPath = useCallback(() => {
|
|
let copyTargetDirectory: string;
|
|
try {
|
|
copyTargetDirectory = requireWorkspaceDirectory({
|
|
workspaceId: workspace.workspaceId,
|
|
workspaceDirectory: workspace.workspaceDirectory,
|
|
});
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : "Workspace path not available");
|
|
return;
|
|
}
|
|
void Clipboard.setStringAsync(copyTargetDirectory);
|
|
toast.copied("Path copied");
|
|
}, [toast, workspace.workspaceDirectory, workspace.workspaceId]);
|
|
|
|
const handleCopyBranchName = useCallback(() => {
|
|
void Clipboard.setStringAsync(workspace.name);
|
|
toast.copied("Branch name copied");
|
|
}, [toast, workspace.name]);
|
|
|
|
const renameMutation = useMutation({
|
|
mutationFn: async (title: string) => {
|
|
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
|
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
|
|
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
|
|
},
|
|
});
|
|
|
|
const handleOpenRename = useCallback(() => setIsRenameOpen(true), []);
|
|
const handleCloseRename = useCallback(() => setIsRenameOpen(false), []);
|
|
const handleSubmitRename = useCallback(
|
|
async (value: string) => {
|
|
await renameMutation.mutateAsync(value.trim());
|
|
},
|
|
[renameMutation],
|
|
);
|
|
|
|
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
|
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
|
serverId: workspace.serverId,
|
|
workspaceId: workspace.workspaceId,
|
|
});
|
|
const handleMarkAsRead = useCallback(() => {
|
|
void clearAttention().catch((error) => {
|
|
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read");
|
|
});
|
|
}, [clearAttention, toast]);
|
|
|
|
useKeyboardActionHandler({
|
|
handlerId: `worktree-archive-${workspace.workspaceKey}`,
|
|
actions: ["worktree.archive"],
|
|
enabled: selected && !isArchiving,
|
|
priority: 0,
|
|
handle: () => {
|
|
handleArchive();
|
|
return true;
|
|
},
|
|
});
|
|
|
|
let computedArchiveStatus: "idle" | "pending" | "success" = "idle";
|
|
if (isWorktree) {
|
|
computedArchiveStatus = worktreeArchiveStatus;
|
|
} else if (isHidingWorkspace) {
|
|
computedArchiveStatus = "pending";
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<StatusWorkspaceRowInner
|
|
workspace={workspace}
|
|
subtitle={subtitle}
|
|
selected={selected}
|
|
shortcutNumber={shortcutNumber}
|
|
showShortcutBadge={showShortcutBadge}
|
|
onPress={onPress}
|
|
isArchiving={isArchiving}
|
|
archiveLabel={t("sidebar.workspace.actions.archive")}
|
|
archiveStatus={computedArchiveStatus}
|
|
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
|
|
onArchive={handleArchive}
|
|
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
|
|
onCopyPath={handleCopyPath}
|
|
onRename={handleOpenRename}
|
|
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
|
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
|
/>
|
|
<AdaptiveRenameModal
|
|
visible={isRenameOpen}
|
|
title="Rename workspace"
|
|
initialValue={workspace.title ?? workspace.name}
|
|
placeholder={workspace.name}
|
|
submitLabel="Rename"
|
|
onClose={handleCloseRename}
|
|
onSubmit={handleSubmitRename}
|
|
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function StatusWorkspaceRowInner({
|
|
workspace,
|
|
subtitle,
|
|
selected,
|
|
shortcutNumber,
|
|
showShortcutBadge,
|
|
onPress,
|
|
isArchiving,
|
|
archiveLabel,
|
|
archiveStatus = "idle",
|
|
archivePendingLabel,
|
|
onArchive,
|
|
onCopyBranchName,
|
|
onCopyPath,
|
|
onRename,
|
|
onMarkAsRead,
|
|
archiveShortcutKeys,
|
|
}: {
|
|
workspace: SidebarWorkspaceEntry;
|
|
subtitle: string;
|
|
selected: boolean;
|
|
shortcutNumber: number | null;
|
|
showShortcutBadge: boolean;
|
|
onPress: () => void;
|
|
isArchiving: boolean;
|
|
archiveLabel?: string;
|
|
archiveStatus?: "idle" | "pending" | "success";
|
|
archivePendingLabel?: string;
|
|
onArchive?: () => void;
|
|
onCopyBranchName?: () => void;
|
|
onCopyPath?: () => void;
|
|
onRename?: () => void;
|
|
onMarkAsRead?: () => void;
|
|
archiveShortcutKeys?: ShortcutKey[][] | null;
|
|
}) {
|
|
const isTouchPlatform = platformIsNative;
|
|
|
|
const isDesktop = !isTouchPlatform;
|
|
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
|
|
const hasRunningService = workspace.scripts.some(
|
|
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
|
|
);
|
|
let scriptIconKind: "service" | "command" | null = null;
|
|
if (showScriptsIcon) {
|
|
scriptIconKind = hasRunningService ? "service" : "command";
|
|
}
|
|
|
|
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
|
|
|
return (
|
|
<SidebarWorkspaceRowFrame workspace={workspace}>
|
|
{({ isHovered, hoverHandlers }) => {
|
|
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
|
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
|
|
const showKebabInSlot = showKebab && !showShortcut;
|
|
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
|
|
const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered });
|
|
return (
|
|
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
|
|
<Pressable
|
|
disabled={isArchiving}
|
|
accessibilityRole="button"
|
|
accessibilityState={accessibilityState}
|
|
style={workspaceRowStyle}
|
|
onPress={onPress}
|
|
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
|
>
|
|
<SidebarWorkspaceRowContent
|
|
workspace={workspace}
|
|
subtitle={subtitle}
|
|
scriptIconKind={scriptIconKind}
|
|
isHovered={isHovered}
|
|
isLoading={isArchiving}
|
|
shortcutNumber={shortcutNumber}
|
|
showShortcutBadge={showShortcutBadge}
|
|
>
|
|
{shouldRenderActionSlot ? (
|
|
<StatusWorkspaceActionSlot
|
|
workspace={workspace}
|
|
showBase={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
|
|
showOverlay={showKebabInSlot}
|
|
onCopyPath={onCopyPath}
|
|
onCopyBranchName={onCopyBranchName}
|
|
onRename={onRename}
|
|
onMarkAsRead={onMarkAsRead}
|
|
onArchive={onArchive}
|
|
archiveLabel={archiveLabel}
|
|
archiveStatus={archiveStatus}
|
|
archivePendingLabel={archivePendingLabel}
|
|
archiveShortcutKeys={archiveShortcutKeys}
|
|
/>
|
|
) : null}
|
|
</SidebarWorkspaceRowContent>
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}}
|
|
</SidebarWorkspaceRowFrame>
|
|
);
|
|
}
|
|
|
|
function StatusWorkspaceActionSlot({
|
|
workspace,
|
|
showBase,
|
|
showOverlay,
|
|
onCopyPath,
|
|
onCopyBranchName,
|
|
onRename,
|
|
onMarkAsRead,
|
|
onArchive,
|
|
archiveLabel,
|
|
archiveStatus,
|
|
archivePendingLabel,
|
|
archiveShortcutKeys,
|
|
}: {
|
|
workspace: SidebarWorkspaceEntry;
|
|
showBase: boolean;
|
|
showOverlay: boolean;
|
|
onCopyPath?: () => void;
|
|
onCopyBranchName?: () => void;
|
|
onRename?: () => void;
|
|
onMarkAsRead?: () => void;
|
|
onArchive?: () => void;
|
|
archiveLabel?: string;
|
|
archiveStatus?: "idle" | "pending" | "success";
|
|
archivePendingLabel?: string;
|
|
archiveShortcutKeys?: ShortcutKey[][] | null;
|
|
}) {
|
|
return (
|
|
<SidebarWorkspaceTrailingActionSlot>
|
|
<SidebarWorkspaceTrailingActionBase visible={showBase}>
|
|
{workspace.diffStat ? (
|
|
<DiffStat
|
|
additions={workspace.diffStat.additions}
|
|
deletions={workspace.diffStat.deletions}
|
|
/>
|
|
) : null}
|
|
</SidebarWorkspaceTrailingActionBase>
|
|
<SidebarWorkspaceTrailingActionOverlay visible={showOverlay}>
|
|
{onArchive ? (
|
|
<StatusKebabMenu
|
|
workspaceKey={workspace.workspaceKey}
|
|
onCopyPath={onCopyPath}
|
|
onCopyBranchName={onCopyBranchName}
|
|
onRename={onRename}
|
|
onMarkAsRead={onMarkAsRead}
|
|
onArchive={onArchive}
|
|
archiveLabel={archiveLabel}
|
|
archiveStatus={archiveStatus}
|
|
archivePendingLabel={archivePendingLabel}
|
|
archiveShortcutKeys={archiveShortcutKeys}
|
|
/>
|
|
) : null}
|
|
</SidebarWorkspaceTrailingActionOverlay>
|
|
</SidebarWorkspaceTrailingActionSlot>
|
|
);
|
|
}
|
|
|
|
function StatusKebabMenu({
|
|
workspaceKey,
|
|
onCopyPath,
|
|
onCopyBranchName,
|
|
onRename,
|
|
onMarkAsRead,
|
|
onArchive,
|
|
archiveLabel,
|
|
archiveStatus,
|
|
archivePendingLabel,
|
|
archiveShortcutKeys,
|
|
}: {
|
|
workspaceKey: string;
|
|
onCopyPath?: () => void;
|
|
onCopyBranchName?: () => void;
|
|
onRename?: () => void;
|
|
onMarkAsRead?: () => void;
|
|
onArchive: () => void;
|
|
archiveLabel?: string;
|
|
archiveStatus?: "idle" | "pending" | "success";
|
|
archivePendingLabel?: string;
|
|
archiveShortcutKeys?: ShortcutKey[][] | null;
|
|
}) {
|
|
const archiveTrailing = useMemo(
|
|
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
|
[archiveShortcutKeys],
|
|
);
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
hitSlop={8}
|
|
style={kebabStyle}
|
|
accessibilityRole={platformIsWeb ? undefined : "button"}
|
|
accessibilityLabel="Workspace actions"
|
|
testID={`sidebar-workspace-kebab-${workspaceKey}`}
|
|
>
|
|
{({ hovered }: { hovered?: boolean }) => (
|
|
<ThemedMoreVertical
|
|
size={14}
|
|
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
|
/>
|
|
)}
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" width={260}>
|
|
{onCopyPath ? (
|
|
<DropdownMenuItem
|
|
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
|
|
leading={copyLeadingIcon}
|
|
onSelect={onCopyPath}
|
|
>
|
|
Copy path
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
{onCopyBranchName ? (
|
|
<DropdownMenuItem
|
|
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
|
|
leading={copyLeadingIcon}
|
|
onSelect={onCopyBranchName}
|
|
>
|
|
Copy branch name
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
{onRename ? (
|
|
<DropdownMenuItem
|
|
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
|
|
leading={renameLeadingIcon}
|
|
onSelect={onRename}
|
|
>
|
|
Rename workspace
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
{onMarkAsRead ? (
|
|
<DropdownMenuItem
|
|
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
|
|
leading={markAsReadLeadingIcon}
|
|
onSelect={onMarkAsRead}
|
|
>
|
|
Mark as read
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
<DropdownMenuItem
|
|
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
|
leading={archiveLeadingIcon}
|
|
trailing={archiveTrailing}
|
|
status={archiveStatus}
|
|
pendingLabel={archivePendingLabel}
|
|
onSelect={onArchive}
|
|
>
|
|
{archiveLabel ?? "Archive"}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|
|
|
|
function kebabStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
|
|
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
|
|
}
|
|
|
|
function getStatusWorkspaceRowStyle({
|
|
selected,
|
|
isHovered,
|
|
}: {
|
|
selected: boolean;
|
|
isHovered: boolean;
|
|
}) {
|
|
return [
|
|
styles.workspaceRow,
|
|
selected && styles.sidebarRowSelected,
|
|
isHovered && styles.workspaceRowHovered,
|
|
];
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
container: {
|
|
flex: 1,
|
|
},
|
|
list: {
|
|
flex: 1,
|
|
},
|
|
listContent: {
|
|
paddingHorizontal: theme.spacing[2],
|
|
paddingTop: theme.spacing[2],
|
|
paddingBottom: theme.spacing[4],
|
|
},
|
|
statusGroupBlock: {
|
|
marginBottom: theme.spacing[1],
|
|
},
|
|
statusWorkspaceListContainer: {},
|
|
statusGroupRow: {
|
|
minHeight: 36,
|
|
paddingVertical: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[2],
|
|
borderRadius: theme.borderRadius.lg,
|
|
marginBottom: theme.spacing[2],
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: theme.spacing[2],
|
|
userSelect: "none",
|
|
},
|
|
statusGroupRowHovered: {
|
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
|
},
|
|
statusGroupRowPressed: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
statusGroupRowLeft: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
flex: 1,
|
|
minWidth: 0,
|
|
},
|
|
statusGroupLeadingVisualSlot: {
|
|
position: "relative",
|
|
width: theme.iconSize.md,
|
|
height: theme.iconSize.md,
|
|
flexShrink: 0,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
statusGroupTitleGroup: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
flex: 1,
|
|
minWidth: 0,
|
|
},
|
|
statusGroupTitle: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: "400",
|
|
minWidth: 0,
|
|
flexShrink: 1,
|
|
},
|
|
workspaceRowContainer: {
|
|
position: "relative",
|
|
},
|
|
workspaceRow: {
|
|
minHeight: 36,
|
|
marginBottom: theme.spacing[1],
|
|
paddingVertical: theme.spacing[2],
|
|
paddingLeft: theme.spacing[2],
|
|
paddingRight: theme.spacing[3],
|
|
borderRadius: theme.borderRadius.lg,
|
|
flexDirection: "column",
|
|
alignItems: "stretch",
|
|
justifyContent: "flex-start",
|
|
gap: theme.spacing[1],
|
|
userSelect: "none",
|
|
},
|
|
workspaceRowHovered: {
|
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
|
},
|
|
workspaceRowPressed: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
sidebarRowSelected: {
|
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
|
},
|
|
kebabButton: {
|
|
padding: 2,
|
|
borderRadius: 4,
|
|
marginLeft: 2,
|
|
},
|
|
kebabButtonHovered: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
}));
|