Improve sidebar workspace readability and host identity (#2790)
* fix(app): align turn footer actions Keep running-turn actions in the same position as completed-turn actions and use the shared small icon size for both copy and fork controls. * feat(app): improve sidebar workspace identity Make dense sidebar workspace rows easier to scan and let each device control how connected hosts are identified. * fix(app): keep host badges readable Use a compact display menu and keep identity color on the host icon so translated controls fit and badge text retains standard contrast. * fix(app): announce selected sidebar badge mode * fix(app): retain icon-only host names for screen readers * fix(app): name hoisted workspace rows * fix(app): retain host identity in workspace labels * fix(app): retain workspace status in row labels * fix(app): defer host appearance until identity resolves * fix(app): serialize host appearance writes * fix(app): serialize host registry writes * fix(app): bound sidebar host badge labels * test(app): match workspace status labels * fix(app): consolidate sidebar identity behavior * test(app): assert status grouping behavior * fix(app): keep sidebar row actions accessible * fix(app): stabilize optimistic message layout
This commit is contained in:
@@ -6,6 +6,8 @@ import {
|
||||
ScrollView,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
@@ -19,6 +21,8 @@ import {
|
||||
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";
|
||||
@@ -68,7 +72,13 @@ import {
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges";
|
||||
import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
useContextMenu,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
@@ -84,7 +94,10 @@ import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list";
|
||||
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
||||
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";
|
||||
@@ -93,6 +106,7 @@ import {
|
||||
SidebarWorkspaceRowFrame,
|
||||
SidebarWorkspaceRowContent,
|
||||
SidebarWorkspaceShortcutBadge,
|
||||
resolveTrailingActionVisibility,
|
||||
SidebarWorkspaceTrailingActionBase,
|
||||
SidebarWorkspaceTrailingActionOverlay,
|
||||
SidebarWorkspaceTrailingActionSlot,
|
||||
@@ -126,7 +140,8 @@ import {
|
||||
} 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 { useLocalDaemonServerId, useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
|
||||
import { selectHostBadges, type HostBadgeModel } from "@/hosts/appearance";
|
||||
|
||||
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
|
||||
|
||||
@@ -212,7 +227,6 @@ interface SidebarWorkspaceListProps {
|
||||
pinnedGroups: PinnedSidebarGroups;
|
||||
projects: SidebarProjectEntry[];
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
projectNamesByViewKey: Map<string, string>;
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
onToggleProjectCollapsed: (projectViewKey: string) => void;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
@@ -255,7 +269,9 @@ interface ProjectHeaderRowProps {
|
||||
|
||||
interface WorkspaceRowInnerProps {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
subtitle?: string | null;
|
||||
hostBadge?: HostBadgeModel | null;
|
||||
leadingProjectName?: string | null;
|
||||
leadingProjectIconDataUri?: string | null;
|
||||
selected: boolean;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
@@ -280,7 +296,7 @@ interface WorkspaceRowInnerProps {
|
||||
reserveIdleStatusIndicatorSpace?: boolean;
|
||||
}
|
||||
|
||||
export function PrBadge({ hint }: { hint: PrHint }) {
|
||||
export function PrBadge({ hint, style }: { hint: PrHint; style?: StyleProp<ViewStyle> }) {
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
@@ -299,6 +315,18 @@ export function PrBadge({ hint }: { hint: PrHint }) {
|
||||
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;
|
||||
@@ -317,7 +345,7 @@ export function PrBadge({ hint }: { hint: PrHint }) {
|
||||
onPress={handlePress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
style={prBadgePressableStyle}
|
||||
style={pressableStyle}
|
||||
>
|
||||
{isHovered ? (
|
||||
<ThemedExternalLink size={12} uniProps={iconUniProps} />
|
||||
@@ -332,10 +360,6 @@ export function PrBadge({ hint }: { hint: PrHint }) {
|
||||
);
|
||||
}
|
||||
|
||||
function prBadgePressableStyle({ pressed }: PressableStateCallbackType) {
|
||||
return [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed];
|
||||
}
|
||||
|
||||
function projectKebabStyle({
|
||||
hovered = false,
|
||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
@@ -382,9 +406,11 @@ const prBadgeStyles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
function ProjectRowTrailingActions({
|
||||
project,
|
||||
projectViewKey,
|
||||
displayName,
|
||||
worktreeTarget,
|
||||
settingsTarget,
|
||||
projectPath,
|
||||
isHovered,
|
||||
isMobileBreakpoint,
|
||||
isProjectActive,
|
||||
@@ -392,9 +418,11 @@ function ProjectRowTrailingActions({
|
||||
onRemoveProject,
|
||||
removeProjectStatus,
|
||||
}: {
|
||||
project: SidebarProjectEntry;
|
||||
projectViewKey: string;
|
||||
displayName: string;
|
||||
worktreeTarget: SidebarProjectHostTarget | null;
|
||||
settingsTarget: { serverId: string; projectId: string } | null;
|
||||
projectPath: string;
|
||||
isHovered: boolean;
|
||||
isMobileBreakpoint: boolean;
|
||||
isProjectActive: boolean;
|
||||
@@ -403,9 +431,6 @@ function ProjectRowTrailingActions({
|
||||
removeProjectStatus: "idle" | "pending" | "success";
|
||||
}) {
|
||||
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
|
||||
const localDaemonServerId = useLocalDaemonServerId();
|
||||
const localProjectPath = resolveSidebarProjectLocalPath(project, localDaemonServerId);
|
||||
const settingsTarget = project.hosts[0] ?? null;
|
||||
return (
|
||||
<View style={styles.projectTrailingActions}>
|
||||
{worktreeTarget ? (
|
||||
@@ -414,7 +439,7 @@ function ProjectRowTrailingActions({
|
||||
onPress={onBeginWorkspaceSetup}
|
||||
visible={actionsVisible}
|
||||
showShortcutHint={isProjectActive}
|
||||
testID={`sidebar-project-new-worktree-${project.viewKey}`}
|
||||
testID={`sidebar-project-new-worktree-${projectViewKey}`}
|
||||
/>
|
||||
) : null}
|
||||
{onRemoveProject ? (
|
||||
@@ -423,9 +448,9 @@ function ProjectRowTrailingActions({
|
||||
pointerEvents={actionsVisible ? "auto" : "none"}
|
||||
>
|
||||
<ProjectKebabMenu
|
||||
projectViewKey={project.viewKey}
|
||||
projectViewKey={projectViewKey}
|
||||
settingsTarget={settingsTarget}
|
||||
projectPath={localProjectPath}
|
||||
projectPath={projectPath}
|
||||
onRemoveProject={onRemoveProject}
|
||||
removeProjectStatus={removeProjectStatus}
|
||||
/>
|
||||
@@ -464,26 +489,6 @@ function ProjectKebabMenu({
|
||||
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 canOpenProjectSettings = settingsTarget !== null;
|
||||
// Desktop-only: open a second window that lands on this project via the same
|
||||
// open-project flow as a CLI launch. The project stays visible here too — no
|
||||
// ownership, no move.
|
||||
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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
@@ -496,42 +501,108 @@ function ProjectKebabMenu({
|
||||
{renderKebabTriggerIcon}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220}>
|
||||
{canOpenProjectSettings ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-project-menu-open-settings-${projectViewKey}`}
|
||||
leading={settingsLeadingIcon}
|
||||
onSelect={handleOpenProjectSettings}
|
||||
>
|
||||
{t("sidebar.project.actions.openSettings")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{canOpenInNewWindow ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-project-menu-open-new-window-${projectViewKey}`}
|
||||
leading={openInNewWindowLeadingIcon}
|
||||
onSelect={handleOpenInNewWindow}
|
||||
>
|
||||
{t("sidebar.project.actions.openNewWindow")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<OpenInFileManagerMenuItem
|
||||
path={projectPath}
|
||||
testID={`sidebar-project-menu-open-folder-${projectViewKey}`}
|
||||
<ProjectMenuItems
|
||||
surface="dropdown"
|
||||
projectViewKey={projectViewKey}
|
||||
settingsTarget={settingsTarget}
|
||||
projectPath={projectPath}
|
||||
onRemoveProject={onRemoveProject}
|
||||
removeProjectStatus={removeProjectStatus}
|
||||
/>
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-project-menu-remove-${projectViewKey}`}
|
||||
leading={trash2LeadingIcon}
|
||||
status={removeProjectStatus}
|
||||
pendingLabel={t("sidebar.project.actions.removing")}
|
||||
onSelect={onRemoveProject}
|
||||
>
|
||||
{t("sidebar.project.actions.remove")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
type ProjectMenuSurface = "context" | "dropdown";
|
||||
|
||||
function ProjectMenuItem({
|
||||
surface,
|
||||
children,
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
Omit<ComponentProps<typeof DropdownMenuItem>, "children"> & { surface: ProjectMenuSurface }
|
||||
>) {
|
||||
if (surface === "context") {
|
||||
return <ContextMenuItem {...props}>{children}</ContextMenuItem>;
|
||||
}
|
||||
return <DropdownMenuItem {...props}>{children}</DropdownMenuItem>;
|
||||
}
|
||||
|
||||
function ProjectMenuItems({
|
||||
surface,
|
||||
projectViewKey,
|
||||
settingsTarget,
|
||||
projectPath,
|
||||
onRemoveProject,
|
||||
removeProjectStatus,
|
||||
}: {
|
||||
surface: ProjectMenuSurface;
|
||||
projectViewKey: string;
|
||||
settingsTarget: { serverId: string; projectId: string } | null;
|
||||
projectPath: string;
|
||||
onRemoveProject: () => void;
|
||||
removeProjectStatus: "idle" | "pending" | "success";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const handleOpenProjectSettings = useCallback(() => {
|
||||
if (!settingsTarget) return;
|
||||
router.navigate(buildProjectSettingsRoute(settingsTarget.serverId, settingsTarget.projectId));
|
||||
}, [settingsTarget]);
|
||||
const canOpenInNewWindow = getIsElectron() && projectPath.trim().length > 0;
|
||||
const handleOpenInNewWindow = useCallback(() => {
|
||||
const trimmedPath = projectPath.trim();
|
||||
if (trimmedPath.length === 0) return;
|
||||
void getDesktopHost()
|
||||
?.window?.openNew?.({ pendingOpenProjectPath: trimmedPath })
|
||||
?.catch((error) => {
|
||||
console.warn("[sidebar] openNew failed", error);
|
||||
toast.error(t("sidebar.project.actions.openNewWindowFailed"));
|
||||
});
|
||||
}, [projectPath, t, toast]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{settingsTarget ? (
|
||||
<ProjectMenuItem
|
||||
surface={surface}
|
||||
testID={`sidebar-project-menu-open-settings-${projectViewKey}`}
|
||||
leading={settingsLeadingIcon}
|
||||
onSelect={handleOpenProjectSettings}
|
||||
>
|
||||
{t("sidebar.project.actions.openSettings")}
|
||||
</ProjectMenuItem>
|
||||
) : null}
|
||||
{canOpenInNewWindow ? (
|
||||
<ProjectMenuItem
|
||||
surface={surface}
|
||||
testID={`sidebar-project-menu-open-new-window-${projectViewKey}`}
|
||||
leading={openInNewWindowLeadingIcon}
|
||||
onSelect={handleOpenInNewWindow}
|
||||
>
|
||||
{t("sidebar.project.actions.openNewWindow")}
|
||||
</ProjectMenuItem>
|
||||
) : null}
|
||||
<OpenInFileManagerMenuItem
|
||||
surface={surface}
|
||||
path={projectPath}
|
||||
testID={`sidebar-project-menu-open-folder-${projectViewKey}`}
|
||||
/>
|
||||
<ProjectMenuItem
|
||||
surface={surface}
|
||||
testID={`sidebar-project-menu-remove-${projectViewKey}`}
|
||||
leading={trash2LeadingIcon}
|
||||
status={removeProjectStatus}
|
||||
pendingLabel={t("sidebar.project.actions.removing")}
|
||||
onSelect={onRemoveProject}
|
||||
>
|
||||
{t("sidebar.project.actions.remove")}
|
||||
</ProjectMenuItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceRowRightGroup({
|
||||
workspace,
|
||||
isHovered,
|
||||
@@ -572,8 +643,17 @@ function WorkspaceRowRightGroup({
|
||||
const workspacePath = workspace.workspaceDirectory ?? workspace.projectRootPath;
|
||||
const { t } = useTranslation();
|
||||
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
||||
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
|
||||
const showKebabInSlot = showKebab && !showShortcut;
|
||||
const {
|
||||
showDiffStat,
|
||||
showKebab: showKebabInSlot,
|
||||
showScrim,
|
||||
} = resolveTrailingActionVisibility({
|
||||
hasDiffStat: Boolean(workspace.diffStat),
|
||||
hasArchiveAction: Boolean(onArchive),
|
||||
isHovered,
|
||||
isTouchPlatform,
|
||||
showShortcut,
|
||||
});
|
||||
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
|
||||
|
||||
return (
|
||||
@@ -583,9 +663,7 @@ function WorkspaceRowRightGroup({
|
||||
) : null}
|
||||
{shouldRenderActionSlot ? (
|
||||
<SidebarWorkspaceTrailingActionSlot>
|
||||
<SidebarWorkspaceTrailingActionBase
|
||||
visible={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
|
||||
>
|
||||
<SidebarWorkspaceTrailingActionBase visible={showDiffStat}>
|
||||
{workspace.diffStat ? (
|
||||
<DiffStat
|
||||
additions={workspace.diffStat.additions}
|
||||
@@ -593,7 +671,7 @@ function WorkspaceRowRightGroup({
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot} scrim={showScrim}>
|
||||
{onArchive ? (
|
||||
<SidebarWorkspaceMenu
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
@@ -784,7 +862,11 @@ function ProjectHeaderRow({
|
||||
dragHandleProps,
|
||||
}: ProjectHeaderRowProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [contextMenuOpen, setContextMenuOpen] = useState(false);
|
||||
const isMobileBreakpoint = useIsCompactFormFactor();
|
||||
const localDaemonServerId = useLocalDaemonServerId();
|
||||
const projectPath = resolveSidebarProjectLocalPath(project, localDaemonServerId);
|
||||
const settingsTarget = project.hosts[0] ?? null;
|
||||
const handleBeginWorkspaceSetup = useCallback(() => {
|
||||
if (!worktreeTarget) {
|
||||
return;
|
||||
@@ -818,8 +900,14 @@ function ProjectHeaderRow({
|
||||
onPress();
|
||||
}, [interaction.didLongPressRef, onPress]);
|
||||
|
||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||
const handlePointerEnter = useCallback(() => {
|
||||
if (!contextMenuOpen) setIsHovered(true);
|
||||
}, [contextMenuOpen]);
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
const handleContextMenuOpenChange = useCallback((open: boolean) => {
|
||||
setContextMenuOpen(open);
|
||||
if (open) setIsHovered(false);
|
||||
}, []);
|
||||
|
||||
const projectRowStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
@@ -852,9 +940,11 @@ function ProjectHeaderRow({
|
||||
</View>
|
||||
</View>
|
||||
<ProjectRowTrailingActions
|
||||
project={project}
|
||||
projectViewKey={project.viewKey}
|
||||
displayName={displayName}
|
||||
worktreeTarget={worktreeTarget}
|
||||
settingsTarget={settingsTarget}
|
||||
projectPath={projectPath}
|
||||
isHovered={isHovered}
|
||||
isMobileBreakpoint={isMobileBreakpoint}
|
||||
isProjectActive={isProjectActive}
|
||||
@@ -870,8 +960,32 @@ function ProjectHeaderRow({
|
||||
</>
|
||||
);
|
||||
|
||||
if (menuController) {
|
||||
if (!onRemoveProject) {
|
||||
return (
|
||||
<View
|
||||
{...dragAttributes}
|
||||
{...dragHandleProps?.listeners}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
style={projectRowStyle}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-project-row-${project.viewKey}`}
|
||||
>
|
||||
{rowChildren}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu open={contextMenuOpen} onOpenChange={handleContextMenuOpenChange}>
|
||||
<View
|
||||
{...dragAttributes}
|
||||
{...dragHandleProps?.listeners}
|
||||
@@ -892,35 +1006,29 @@ function ProjectHeaderRow({
|
||||
{rowChildren}
|
||||
</ContextMenuTrigger>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
{...dragAttributes}
|
||||
{...dragHandleProps?.listeners}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
style={projectRowStyle}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-project-row-${project.viewKey}`}
|
||||
<ContextMenuContent
|
||||
align="start"
|
||||
width={220}
|
||||
testID={`sidebar-project-context-menu-${project.viewKey}`}
|
||||
>
|
||||
{rowChildren}
|
||||
</Pressable>
|
||||
</View>
|
||||
<ProjectMenuItems
|
||||
surface="context"
|
||||
projectViewKey={project.viewKey}
|
||||
settingsTarget={settingsTarget}
|
||||
projectPath={projectPath}
|
||||
onRemoveProject={onRemoveProject}
|
||||
removeProjectStatus={removeProjectStatus}
|
||||
/>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceRowInner({
|
||||
workspace,
|
||||
subtitle,
|
||||
hostBadge,
|
||||
leadingProjectName,
|
||||
leadingProjectIconDataUri,
|
||||
selected,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
@@ -968,7 +1076,7 @@ function WorkspaceRowInner({
|
||||
|
||||
return (
|
||||
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
|
||||
{({ isHovered, hoverHandlers }) => {
|
||||
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
|
||||
const isDesktop = !isTouchPlatform;
|
||||
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
|
||||
const hasRunningService = workspace.scripts.some(
|
||||
@@ -991,7 +1099,24 @@ function WorkspaceRowInner({
|
||||
style={styles.workspaceRowContainer}
|
||||
{...hoverHandlers}
|
||||
>
|
||||
<Pressable
|
||||
<SidebarWorkspaceContextMenu
|
||||
contextMenuOpen={contextMenuOpen}
|
||||
onContextMenuOpenChange={onContextMenuOpenChange}
|
||||
workspace={workspace}
|
||||
leadingProjectName={leadingProjectName}
|
||||
hostBadgeLabel={hostBadge?.label}
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
openInFileManagerPath={workspace.workspaceDirectory}
|
||||
disabled={isArchiving}
|
||||
aria-selected={selected}
|
||||
accessibilityRole="button"
|
||||
@@ -1005,7 +1130,9 @@ function WorkspaceRowInner({
|
||||
>
|
||||
<SidebarWorkspaceRowContent
|
||||
workspace={workspace}
|
||||
subtitle={subtitle}
|
||||
hostBadge={hostBadge}
|
||||
leadingProjectName={leadingProjectName}
|
||||
leadingProjectIconDataUri={leadingProjectIconDataUri}
|
||||
scriptIconKind={scriptIconKind}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving || isCreating}
|
||||
@@ -1033,7 +1160,7 @@ function WorkspaceRowInner({
|
||||
onTogglePin={onTogglePin}
|
||||
/>
|
||||
</SidebarWorkspaceRowContent>
|
||||
</Pressable>
|
||||
</SidebarWorkspaceContextMenu>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
@@ -1043,7 +1170,9 @@ function WorkspaceRowInner({
|
||||
|
||||
function WorkspaceRowWithMenu({
|
||||
workspace,
|
||||
subtitle,
|
||||
hostBadge,
|
||||
leadingProjectName,
|
||||
leadingProjectIconDataUri,
|
||||
selected,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
@@ -1058,7 +1187,9 @@ function WorkspaceRowWithMenu({
|
||||
isCreating = false,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
subtitle?: string | null;
|
||||
hostBadge?: HostBadgeModel | null;
|
||||
leadingProjectName?: string | null;
|
||||
leadingProjectIconDataUri?: string | null;
|
||||
selected: boolean;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
@@ -1186,7 +1317,9 @@ function WorkspaceRowWithMenu({
|
||||
<>
|
||||
<WorkspaceRowInner
|
||||
workspace={workspace}
|
||||
subtitle={subtitle}
|
||||
hostBadge={hostBadge}
|
||||
leadingProjectName={leadingProjectName}
|
||||
leadingProjectIconDataUri={leadingProjectIconDataUri}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
@@ -1227,7 +1360,9 @@ function WorkspaceRowWithMenu({
|
||||
interface WorkspaceRowItemProps {
|
||||
workspace: SidebarWorkspacePlacement;
|
||||
workspaceEntry: SidebarWorkspaceEntry | null;
|
||||
subtitle?: string | null;
|
||||
hostBadge?: HostBadgeModel | null;
|
||||
leadingProjectName?: string | null;
|
||||
leadingProjectIconDataUri?: string | null;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
canCopyBranchName: boolean;
|
||||
@@ -1246,7 +1381,9 @@ interface WorkspaceRowItemProps {
|
||||
function WorkspaceRowItem({
|
||||
workspace,
|
||||
workspaceEntry,
|
||||
subtitle,
|
||||
hostBadge,
|
||||
leadingProjectName,
|
||||
leadingProjectIconDataUri,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
canCopyBranchName,
|
||||
@@ -1272,7 +1409,9 @@ function WorkspaceRowItem({
|
||||
return (
|
||||
<WorkspaceRow
|
||||
workspaceEntry={workspaceEntry}
|
||||
subtitle={subtitle}
|
||||
hostBadge={hostBadge}
|
||||
leadingProjectName={leadingProjectName}
|
||||
leadingProjectIconDataUri={leadingProjectIconDataUri}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
canCopyBranchName={canCopyBranchName}
|
||||
@@ -1313,7 +1452,9 @@ function areWorkspaceRowItemPropsEqual(
|
||||
return (
|
||||
previous.workspace === next.workspace &&
|
||||
previous.workspaceEntry === next.workspaceEntry &&
|
||||
previous.subtitle === next.subtitle &&
|
||||
previous.hostBadge === next.hostBadge &&
|
||||
previous.leadingProjectName === next.leadingProjectName &&
|
||||
previous.leadingProjectIconDataUri === next.leadingProjectIconDataUri &&
|
||||
previous.shortcutNumber === next.shortcutNumber &&
|
||||
previous.showShortcutBadge === next.showShortcutBadge &&
|
||||
previous.canCopyBranchName === next.canCopyBranchName &&
|
||||
@@ -1333,7 +1474,9 @@ const MemoWorkspaceRowItem = memo(WorkspaceRowItem, areWorkspaceRowItemPropsEqua
|
||||
|
||||
function WorkspaceRow({
|
||||
workspaceEntry,
|
||||
subtitle,
|
||||
hostBadge,
|
||||
leadingProjectName,
|
||||
leadingProjectIconDataUri,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
onPress,
|
||||
@@ -1348,7 +1491,9 @@ function WorkspaceRow({
|
||||
selected,
|
||||
}: {
|
||||
workspaceEntry: SidebarWorkspaceEntry | null;
|
||||
subtitle?: string | null;
|
||||
hostBadge?: HostBadgeModel | null;
|
||||
leadingProjectName?: string | null;
|
||||
leadingProjectIconDataUri?: string | null;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
onPress: () => void;
|
||||
@@ -1369,7 +1514,9 @@ function WorkspaceRow({
|
||||
return (
|
||||
<WorkspaceRowWithMenu
|
||||
workspace={workspaceEntry}
|
||||
subtitle={subtitle}
|
||||
hostBadge={hostBadge}
|
||||
leadingProjectName={leadingProjectName}
|
||||
leadingProjectIconDataUri={leadingProjectIconDataUri}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
@@ -1407,8 +1554,7 @@ function ProjectBlock({
|
||||
dragGestureHostPresented,
|
||||
creatingWorkspaceIds,
|
||||
activeWorkspaceSelection,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
hostBadgeByServerId,
|
||||
supportsMultiplicityByServerId,
|
||||
supportsPinningByServerId,
|
||||
onToggleWorkspacePin,
|
||||
@@ -1433,8 +1579,7 @@ function ProjectBlock({
|
||||
dragGestureHostPresented?: boolean;
|
||||
creatingWorkspaceIds: ReadonlySet<string>;
|
||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
showHostLabels: boolean;
|
||||
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
||||
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
@@ -1481,9 +1626,7 @@ function ProjectBlock({
|
||||
<MemoWorkspaceRowItem
|
||||
workspace={item}
|
||||
workspaceEntry={workspaceEntriesByKey.get(item.workspaceKey) ?? null}
|
||||
subtitle={
|
||||
showHostLabels ? (hostLabelByServerId.get(item.serverId) ?? item.serverId) : null
|
||||
}
|
||||
hostBadge={hostBadgeByServerId.get(item.serverId) ?? null}
|
||||
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canCopyBranchName={project.projectKind === "git"}
|
||||
@@ -1503,10 +1646,9 @@ function ProjectBlock({
|
||||
project.projectKind,
|
||||
onToggleWorkspacePin,
|
||||
supportsPinningByServerId,
|
||||
showHostLabels,
|
||||
activeWorkspaceSelection,
|
||||
creatingWorkspaceIds,
|
||||
hostLabelByServerId,
|
||||
hostBadgeByServerId,
|
||||
onWorkspacePress,
|
||||
selectionEnabled,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
@@ -1681,8 +1823,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
|
||||
previous.selectionEnabled === next.selectionEnabled &&
|
||||
previous.showShortcutBadges === next.showShortcutBadges &&
|
||||
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
|
||||
previous.hostLabelByServerId === next.hostLabelByServerId &&
|
||||
previous.showHostLabels === next.showHostLabels &&
|
||||
previous.hostBadgeByServerId === next.hostBadgeByServerId &&
|
||||
previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId &&
|
||||
previous.supportsPinningByServerId === next.supportsPinningByServerId &&
|
||||
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
|
||||
@@ -1734,7 +1875,6 @@ export function SidebarWorkspaceList({
|
||||
pinnedGroups,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByViewKey,
|
||||
collapsedProjectKeys,
|
||||
onToggleProjectCollapsed,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
@@ -1750,18 +1890,22 @@ export function SidebarWorkspaceList({
|
||||
}: SidebarWorkspaceListProps) {
|
||||
const pathname = usePathname();
|
||||
const hosts = useHosts();
|
||||
const hostLabelByServerId = useMemo(() => {
|
||||
const labels = new Map<string, string>();
|
||||
for (const host of hosts) {
|
||||
labels.set(host.serverId, host.label?.trim() || host.serverId);
|
||||
}
|
||||
return labels;
|
||||
}, [hosts]);
|
||||
const localServerId = useLocalDaemonServerId();
|
||||
const localDaemon = useLocalDaemonServerIdState();
|
||||
const hostBadgeByServerId = useMemo(
|
||||
() =>
|
||||
selectHostBadges({
|
||||
hosts,
|
||||
localServerId,
|
||||
localHostResolutionPending: localDaemon.status !== "resolved",
|
||||
enabled: shouldShowSidebarHostLabels(projects),
|
||||
}),
|
||||
[hosts, localDaemon.status, localServerId, projects],
|
||||
);
|
||||
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
||||
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
|
||||
const supportsPinningByServerId = useHostFeatureMap(serverIds, "workspacePinning");
|
||||
const onToggleWorkspacePin = useSidebarWorkspacePinController();
|
||||
const showHostLabels = useMemo(() => shouldShowSidebarHostLabels(projects), [projects]);
|
||||
// Status mode drops the project grouping, so its rows carry their own project
|
||||
// icon. Project mode fetches the same icons inside ProjectModeList for its
|
||||
// project headers, so only the active mode requests them.
|
||||
@@ -1779,12 +1923,10 @@ export function SidebarWorkspaceList({
|
||||
statusGroups={statusGroups}
|
||||
pinnedGroups={pinnedGroups}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
projectNamesByViewKey={projectNamesByViewKey}
|
||||
projectIconByProjectViewKey={statusProjectIconByProjectViewKey}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
hostBadgeByServerId={hostBadgeByServerId}
|
||||
supportsPinningByServerId={supportsPinningByServerId}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
@@ -1804,8 +1946,7 @@ export function SidebarWorkspaceList({
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
pathname={pathname}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
hostBadgeByServerId={hostBadgeByServerId}
|
||||
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
||||
supportsPinningByServerId={supportsPinningByServerId}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
@@ -1819,12 +1960,10 @@ function SidebarStatusModeWrapper({
|
||||
statusGroups,
|
||||
pinnedGroups,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByViewKey,
|
||||
projectIconByProjectViewKey,
|
||||
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
|
||||
onWorkspacePress,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
hostBadgeByServerId,
|
||||
supportsPinningByServerId,
|
||||
onToggleWorkspacePin,
|
||||
listHeaderComponent,
|
||||
@@ -1832,12 +1971,10 @@ function SidebarStatusModeWrapper({
|
||||
statusGroups: StatusGroup[];
|
||||
pinnedGroups: PinnedSidebarGroups;
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
projectNamesByViewKey: Map<string, string>;
|
||||
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
onWorkspacePress?: () => void;
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
showHostLabels: boolean;
|
||||
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
listHeaderComponent?: ReactElement | null;
|
||||
@@ -1851,13 +1988,11 @@ function SidebarStatusModeWrapper({
|
||||
const entry = workspaceEntriesByKey.get(workspace.workspaceKey);
|
||||
return entry ? [entry] : [];
|
||||
})}
|
||||
projectNamesByViewKey={projectNamesByViewKey}
|
||||
projectIconByProjectViewKey={projectIconByProjectViewKey}
|
||||
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
hostBadgeByServerId={hostBadgeByServerId}
|
||||
supportsPinningByServerId={supportsPinningByServerId}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
@@ -1879,18 +2014,13 @@ function ProjectModeList({
|
||||
parentGestureRef,
|
||||
dragGestureHostPresented,
|
||||
pathname,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
hostBadgeByServerId,
|
||||
supportsMultiplicityByServerId,
|
||||
supportsPinningByServerId,
|
||||
onToggleWorkspacePin,
|
||||
}: Omit<
|
||||
SidebarWorkspaceListProps,
|
||||
"statusGroups" | "projectNamesByViewKey" | "groupMode" | "isRefreshing" | "onRefresh"
|
||||
> & {
|
||||
}: Omit<SidebarWorkspaceListProps, "statusGroups" | "groupMode" | "isRefreshing" | "onRefresh"> & {
|
||||
pathname: string;
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
showHostLabels: boolean;
|
||||
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
||||
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
@@ -2095,8 +2225,7 @@ function ProjectModeList({
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
creatingWorkspaceIds={creatingWorkspaceIds}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
hostBadgeByServerId={hostBadgeByServerId}
|
||||
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
||||
supportsPinningByServerId={supportsPinningByServerId}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
@@ -2108,8 +2237,7 @@ function ProjectModeList({
|
||||
activeWorkspaceSelection,
|
||||
handleWorktreeCreated,
|
||||
handleWorkspaceReorder,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
hostBadgeByServerId,
|
||||
supportsMultiplicityByServerId,
|
||||
supportsPinningByServerId,
|
||||
onToggleWorkspacePin,
|
||||
@@ -2134,17 +2262,16 @@ function ProjectModeList({
|
||||
|
||||
const renderPinnedChat = useCallback(
|
||||
(workspace: SidebarWorkspacePlacement) => {
|
||||
// A hoisted chat loses its project context, so surface the project name (plus
|
||||
// host when the sidebar spans multiple hosts) as the subtitle.
|
||||
const hostLabel = showHostLabels
|
||||
? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId)
|
||||
: null;
|
||||
return (
|
||||
<MemoWorkspaceRowItem
|
||||
key={workspace.workspaceKey}
|
||||
workspace={workspace}
|
||||
workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null}
|
||||
subtitle={hostLabel ? `${workspace.projectName} · ${hostLabel}` : workspace.projectName}
|
||||
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"}
|
||||
@@ -2160,14 +2287,14 @@ function ProjectModeList({
|
||||
[
|
||||
activeWorkspaceSelection,
|
||||
creatingWorkspaceIds,
|
||||
hostLabelByServerId,
|
||||
hostBadgeByServerId,
|
||||
onWorkspacePress,
|
||||
selectionEnabled,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
showHostLabels,
|
||||
showShortcutBadges,
|
||||
supportsPinningByServerId,
|
||||
onToggleWorkspacePin,
|
||||
projectIconByProjectViewKey,
|
||||
workspaceEntriesByKey,
|
||||
],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user