Merge sidebar workspaces across all connected hosts (#1538)

* Merge workspaces across all hosts in the sidebar

Replace per-host sidebar sections with a single merged project list.
Projects that exist on multiple hosts (matched by projectKey) are
collapsed into one row. Host identity is surfaced via:

- A status-only footer pill showing online/offline host counts
- Hover cards with a mandatory host row on every workspace entry
- Subtitles showing the host name when a project spans >1 host
- A host filter in the grouping dropdown (not a sidebar switcher)

Order and group-mode stores migrated from per-server to global keys
with zustand persist migrations. New-workspace screen now shows a
merged project list with a host selector combobox.

Key files:
- workspace-structure.ts: merge algorithm by projectKey
- sidebar-view-store.ts / sidebar-order-store.ts: global state
- sidebar-workspace-list.tsx: context-based subtitle injection
- new-workspace-screen.tsx: host selector in footer row

* Add host filtering to the merged sidebar

The sidebar now merges workspaces from every connected host, so a single
global "active host" no longer fits. Host selection becomes explicit per
action via a host chooser, and agent history pages across all hosts at
once. Removes the active-host / active-server-id model and the
all-agents-list hook that fed it.

* Fix rebase fallout: translate archive host error, update stale workspace-key test

archiveWorkspacesOptimistically used a raw "Host is not connected" string
that the i18n completeness test forbids; route it through i18n.t with the
existing hostDisconnected key. The selectors memoization test asserted
un-prefixed workspace keys, but merged-sidebar keys are serverId-prefixed.

* Fix sidebar review fallout

* Address sidebar review followups

* Fix sidebar workspace identity migration

* Address sidebar review followups

* Handle partial host history failures

* Fix merged sidebar project removal and E2E drift

* Restore history routes and project removal shape

* Remove sidebar host context plumbing

* Tighten sidebar status and host filter state

* Keep new workspace project selection host-compatible

* Migrate sidebar view storage

* Streamline host selection flows

* Update all-host history on archive

* Fix empty project sidebar action

Code regression: the main merge dropped the empty-project New workspace child row while keeping the empty project persistence contract. Restore the row using the project host target so the sidebar stays aligned with the no-host-selection model.

* Fix Japanese shortcut translation key

Stale-base integration: main added the Japanese locale after this branch added cycleAgentMode to the English shortcut help map. Merging main exposed the missing ja key in CI typecheck and resources.test.

* Fix Playwright speech teardown race
This commit is contained in:
Mohamed Boudra
2026-06-26 12:16:49 +08:00
committed by GitHub
parent 1109e453bc
commit a49c658d1f
103 changed files with 6856 additions and 2857 deletions
@@ -5,14 +5,16 @@ import { Settings2 } from "lucide-react-native";
import type { Theme } from "@/styles/theme";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { useAppSettings, type WorkspaceTitleSource } from "@/hooks/use-settings";
import { useHosts, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { formatConnectionStatus } from "@/utils/daemons";
const ThemedSettings2 = withUnistyles(Settings2);
const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -32,22 +34,29 @@ interface DisplayPreferenceOption<Value extends string> {
label: string;
}
export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string | null }) {
const groupMode = useSidebarViewStore((state) =>
serverId ? state.getGroupMode(serverId) : "project",
);
export function SidebarDisplayPreferencesMenu() {
const groupMode = useSidebarViewStore((state) => state.groupMode);
const hostFilter = useSidebarViewStore((state) => state.hostFilter);
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
const setHostFilter = useSidebarViewStore((state) => state.setHostFilter);
const hosts = useHosts();
const {
settings: { workspaceTitleSource },
updateSettings,
} = useAppSettings();
const handleSelect = useCallback(
const handleSelectMode = useCallback(
(mode: SidebarGroupMode) => {
if (!serverId) return;
setGroupMode(serverId, mode);
setGroupMode(mode);
},
[serverId, setGroupMode],
[setGroupMode],
);
const handleSelectHost = useCallback(
(serverId: string | null) => {
setHostFilter(serverId);
},
[setHostFilter],
);
const handleWorkspaceTitleSourceSelect = useCallback(
@@ -65,6 +74,8 @@ export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string |
[],
);
const showHostFilter = hosts.length > 1;
return (
<DropdownMenu>
<DropdownMenuTrigger
@@ -75,7 +86,7 @@ export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string |
>
<ThemedSettings2 size={14} uniProps={filterColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={180} testID="sidebar-display-preferences-content">
<DropdownMenuContent align="end" width={220} testID="sidebar-display-preferences-content">
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Group by</Text>
</View>
@@ -85,9 +96,33 @@ export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string |
item={item}
isSelected={groupMode === item.value}
testIDPrefix="sidebar-grouping"
onSelect={handleSelect}
onSelect={handleSelectMode}
/>
))}
{showHostFilter ? (
<>
<DropdownMenuSeparator />
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Filter</Text>
</View>
<HostFilterItem
label="All hosts"
value={null}
hostFilter={hostFilter}
onSelect={handleSelectHost}
/>
{hosts.map((host) => (
<HostFilterItem
key={host.serverId}
label={host.label?.trim() || host.serverId}
serverId={host.serverId}
value={host.serverId}
hostFilter={hostFilter}
onSelect={handleSelectHost}
/>
))}
</>
) : null}
<DropdownMenuSeparator />
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Workspace title</Text>
@@ -129,6 +164,33 @@ function DisplayPreferenceMenuItem<Value extends string>({
);
}
function HostFilterItem({
label,
serverId,
value,
hostFilter,
onSelect,
}: {
label: string;
serverId?: string;
value: string | null;
hostFilter: string | null;
onSelect: (serverId: string | null) => void;
}) {
const isSelected = hostFilter === value;
const handleSelect = useCallback(() => onSelect(value), [value, onSelect]);
const status = useHostRuntimeSnapshot(serverId ?? "");
const subtitle = serverId
? formatConnectionStatus(status?.connectionStatus ?? "idle")
: undefined;
return (
<DropdownMenuItem selected={isSelected} description={subtitle} onSelect={handleSelect}>
{label}
</DropdownMenuItem>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
width: 28,
@@ -1,9 +1,14 @@
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 type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import {
useSidebarWorkspaceEntry,
type SidebarStatusWorkspacePlacement,
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import {
buildStatusGroups,
buildStatusShortcutIndex,
@@ -20,12 +25,44 @@ import {
CircleCheck,
CircleDot,
CircleX,
MoreVertical,
Copy,
Archive,
Pencil,
} from "lucide-react-native";
import { useSidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { SidebarWorkspaceRow } from "@/components/sidebar/sidebar-workspace-row";
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,
});
@@ -40,11 +77,21 @@ 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 {
workspaces: SidebarWorkspaceEntry[];
workspaces: SidebarStatusWorkspacePlacement[];
projectNamesByKey: Map<string, string>;
serverId: string | null;
shortcutIndexByWorkspaceKey: Map<string, number>;
showShortcutBadges: boolean;
onWorkspacePress?: () => void;
@@ -53,7 +100,6 @@ interface StatusWorkspaceListProps {
export function SidebarStatusWorkspaceList({
workspaces,
projectNamesByKey,
serverId,
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
showShortcutBadges,
onWorkspacePress,
@@ -89,7 +135,6 @@ export function SidebarStatusWorkspaceList({
groups={groups}
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
projectNamesByKey={projectNamesByKey}
serverId={serverId}
shortcutIndex={statusShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
@@ -106,7 +151,6 @@ export function SidebarStatusWorkspaceList({
groups={groups}
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
projectNamesByKey={projectNamesByKey}
serverId={serverId}
shortcutIndex={statusShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
@@ -121,7 +165,6 @@ function StatusGroupList({
groups,
collapsedStatusGroupKeys,
projectNamesByKey,
serverId,
shortcutIndex,
showShortcutBadges,
onWorkspacePress,
@@ -129,7 +172,6 @@ function StatusGroupList({
groups: StatusGroup[];
collapsedStatusGroupKeys: ReadonlySet<string>;
projectNamesByKey: Map<string, string>;
serverId: string | null;
shortcutIndex: Map<string, number>;
showShortcutBadges: boolean;
onWorkspacePress?: () => void;
@@ -149,7 +191,6 @@ function StatusGroupList({
key={workspace.workspaceKey}
workspace={workspace}
projectName={projectNamesByKey.get(workspace.projectKey) ?? ""}
serverId={serverId}
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
@@ -248,45 +289,473 @@ function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) {
const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
workspace,
projectName,
serverId,
shortcutNumber,
showShortcutBadge,
onWorkspacePress,
}: {
workspace: SidebarWorkspaceEntry;
workspace: SidebarStatusWorkspacePlacement;
projectName: string;
serverId: string | null;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onWorkspacePress?: () => void;
}) {
const hydratedWorkspace = useSidebarWorkspaceEntry(serverId, workspace.workspaceId);
const workspaceEntry = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const selected =
activeWorkspaceSelection?.serverId === workspace.serverId &&
activeWorkspaceSelection?.workspaceId === workspace.workspaceId;
const handlePress = useCallback(() => {
if (!serverId) return;
if (!workspace.serverId) return;
onWorkspacePress?.();
navigateToWorkspace(serverId, workspace.workspaceId);
}, [serverId, onWorkspacePress, workspace.workspaceId]);
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
if (!hydratedWorkspace) return null;
if (!workspaceEntry) return null;
return (
<SidebarWorkspaceRow
workspace={hydratedWorkspace}
<StatusWorkspaceRowWithMenu
workspace={workspaceEntry}
projectName={projectName}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
canCopyBranchName={hydratedWorkspace.projectKind === "git"}
onPress={handlePress}
subtitle={projectName}
/>
);
});
function StatusWorkspaceRowWithMenu({
workspace,
projectName,
selected,
shortcutNumber,
showShortcutBadge,
onPress,
}: {
workspace: SidebarWorkspaceEntry;
projectName: 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}
projectName={projectName}
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,
projectName,
selected,
shortcutNumber,
showShortcutBadge,
onPress,
isArchiving,
archiveLabel,
archiveStatus = "idle",
archivePendingLabel,
onArchive,
onCopyBranchName,
onCopyPath,
onRename,
onMarkAsRead,
archiveShortcutKeys,
}: {
workspace: SidebarWorkspaceEntry;
projectName: 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={projectName}
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,
@@ -350,4 +819,37 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0,
flexShrink: 1,
},
workspaceRowContainer: {
position: "relative",
},
workspaceRow: {
minHeight: 36,
marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[3] + theme.spacing[3],
paddingRight: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
flexDirection: "column",
alignItems: "stretch",
justifyContent: "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,
},
}));