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:
@@ -10,7 +10,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
|
||||
export type AssistantForkTarget = "tab" | "workspace";
|
||||
|
||||
@@ -69,7 +69,10 @@ export const AssistantForkMenu = memo(function AssistantForkMenu({
|
||||
[t],
|
||||
);
|
||||
|
||||
const forkIcon = useMemo(() => <ThemedSplit size={16} uniProps={foregroundColorMapping} />, []);
|
||||
const forkIcon = useMemo(
|
||||
() => <ThemedSplit size={ICON_SIZE.sm} uniProps={foregroundColorMapping} />,
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
|
||||
@@ -85,7 +88,7 @@ export const AssistantForkMenu = memo(function AssistantForkMenu({
|
||||
>
|
||||
{({ hovered, open }) => (
|
||||
<ThemedSplit
|
||||
size={16}
|
||||
size={ICON_SIZE.sm}
|
||||
uniProps={hovered || open ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -36,11 +36,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
additions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.diffAddition,
|
||||
color: theme.colors.diffStatAddition,
|
||||
},
|
||||
deletions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.diffDeletion,
|
||||
color: theme.colors.diffStatDeletion,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -78,7 +78,6 @@ interface SidebarSharedProps {
|
||||
pinnedGroups: PinnedSidebarGroups;
|
||||
projects: SidebarProjectEntry[];
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
projectNamesByViewKey: Map<string, string>;
|
||||
isInitialLoad: boolean;
|
||||
isRevalidating: boolean;
|
||||
isManualRefresh: boolean;
|
||||
@@ -133,7 +132,6 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
const {
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByViewKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
refreshAll,
|
||||
@@ -239,7 +237,6 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
pinnedGroups,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByViewKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
isManualRefresh,
|
||||
@@ -593,7 +590,6 @@ function MobileSidebar({
|
||||
pinnedGroups,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByViewKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
isManualRefresh,
|
||||
@@ -710,7 +706,6 @@ function MobileSidebar({
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
projectNamesByViewKey={projectNamesByViewKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onWorkspacePress={handleWorkspacePress}
|
||||
@@ -741,7 +736,6 @@ function DesktopSidebar({
|
||||
pinnedGroups,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByViewKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
isManualRefresh,
|
||||
@@ -878,7 +872,6 @@ function DesktopSidebar({
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
projectNamesByViewKey={projectNamesByViewKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onAddProject={handleOpenProject}
|
||||
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
FileSymlink,
|
||||
} from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -373,6 +373,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
alignSelf: "flex-end",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
height: 24,
|
||||
gap: theme.spacing[2],
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
@@ -1034,9 +1035,9 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
||||
? turnCopyButtonStylesheet.iconHoveredColor.color
|
||||
: turnCopyButtonStylesheet.iconColor.color;
|
||||
return copied ? (
|
||||
<Check size={16} color={iconColor} />
|
||||
<Check size={ICON_SIZE.sm} color={iconColor} />
|
||||
) : (
|
||||
<Copy size={16} color={iconColor} />
|
||||
<Copy size={ICON_SIZE.sm} color={iconColor} />
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { deriveProjectIconColor } from "@/utils/project-icon-color";
|
||||
import { deriveIdentityColorName, identityColor } from "@/styles/identity-colors";
|
||||
|
||||
const WHITE_TEXT = { color: "#ffffff" } as const;
|
||||
|
||||
@@ -29,7 +29,10 @@ export function ProjectIconView({
|
||||
}) {
|
||||
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
|
||||
const fallbackStyles = useMemo(
|
||||
() => [fallbackStyle, { backgroundColor: deriveProjectIconColor(projectViewKey) }],
|
||||
() => [
|
||||
fallbackStyle,
|
||||
{ backgroundColor: identityColor(deriveIdentityColorName(projectViewKey)) },
|
||||
],
|
||||
[fallbackStyle, projectViewKey],
|
||||
);
|
||||
const textStyles = useMemo(() => [textStyle, WHITE_TEXT], [textStyle]);
|
||||
|
||||
@@ -43,6 +43,7 @@ import { seedSessionWorkspaces } from "@/test/seed-session";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { defaultHostAppearance } from "@/hosts/appearance";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
@@ -146,6 +147,7 @@ function makeHost(): HostProfile {
|
||||
return {
|
||||
serverId: SERVER_ID,
|
||||
label: "Render Count Host",
|
||||
appearance: defaultHostAppearance(),
|
||||
lifecycle: {},
|
||||
connections: [],
|
||||
preferredConnectionId: null,
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Server } from "lucide-react-native";
|
||||
import { HOST_COLORS, type HostColor } from "@/hosts/appearance";
|
||||
import { identityColor } from "@/styles/identity-colors";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const ThemedServer = withUnistyles(Server);
|
||||
const MAX_NAMED_BADGE_WIDTH = 96;
|
||||
|
||||
// The identity table is theme-independent, so its icon mapping is fixed for the life of the
|
||||
// module. It must not be rebuilt per render: a fresh `uniProps` identity makes withUnistyles
|
||||
// re-subscribe every pass.
|
||||
function buildIconMappings(): Record<HostColor, (theme: Theme) => { color: string }> {
|
||||
const byColor = {} as Record<HostColor, (theme: Theme) => { color: string }>;
|
||||
for (const color of HOST_COLORS) {
|
||||
byColor[color] =
|
||||
color === "none"
|
||||
? (theme: Theme) => ({ color: theme.colors.foregroundMuted })
|
||||
: () => ({ color: identityColor(color) });
|
||||
}
|
||||
return byColor;
|
||||
}
|
||||
|
||||
const ICON_MAPPINGS = buildIconMappings();
|
||||
|
||||
/**
|
||||
* Which host a workspace lives on, as a pill after the title. Only rendered in multi-host
|
||||
* setups — see selectHostBadges. It's a badge rather than a second line of text because the
|
||||
* row has to stay scannable at one line per workspace.
|
||||
*
|
||||
*/
|
||||
export function HostBadge({
|
||||
serverId,
|
||||
label,
|
||||
color,
|
||||
showLabel,
|
||||
}: {
|
||||
serverId: string;
|
||||
label: string;
|
||||
color: HostColor;
|
||||
showLabel: boolean;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={[hostBadgeStyles.badge, showLabel ? null : hostBadgeStyles.badgeIconOnly]}
|
||||
testID={`sidebar-host-badge-${serverId}`}
|
||||
>
|
||||
<ThemedServer size={9} uniProps={ICON_MAPPINGS[color]} />
|
||||
{showLabel ? (
|
||||
<Text style={hostBadgeStyles.text} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const hostBadgeStyles = StyleSheet.create((theme) => ({
|
||||
// Sized off the title's 20pt line box so the pill never grows the row. A named badge must
|
||||
// leave room for the workspace title and trailing actions, even when a host has an arbitrary
|
||||
// custom label. The full host name remains in the row's accessible label and hover card.
|
||||
badge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
height: 16,
|
||||
paddingLeft: 4,
|
||||
paddingRight: 6,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
maxWidth: MAX_NAMED_BADGE_WIDTH,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
// Icon-only drops the label, so the asymmetric padding that made room for text leaves a
|
||||
// lopsided pill. Symmetric padding recenters the icon.
|
||||
badgeIconOnly: {
|
||||
paddingLeft: 4,
|
||||
paddingRight: 4,
|
||||
flexShrink: 0,
|
||||
},
|
||||
text: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 14,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
}));
|
||||
@@ -1,21 +1,27 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ActivityIndicator, View, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
|
||||
import { ProjectIconView } from "@/components/project-icon-view";
|
||||
import { SyncedLoader } from "@/components/synced-loader";
|
||||
import { PulsingStatusDot } from "@/components/sidebar/pulsing-status-dot";
|
||||
import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { getProjectStatusBadgeContent } from "@/utils/project-status-badge-content";
|
||||
import {
|
||||
getProjectStatusBadgeContent,
|
||||
type ProjectStatusBadgeContent,
|
||||
type ProjectStatusBadgeDotBucket,
|
||||
} from "@/utils/project-status-badge-content";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
|
||||
// Every surfaced status shares one badge shell, so the ring never changes size or position
|
||||
// Every surfaced status shares one badge shell, so the badge never changes size or position
|
||||
// between states. Only the thing inside it changes.
|
||||
const STATUS_BADGE_SIZE = 14;
|
||||
const STATUS_BADGE_BORDER_WIDTH = 1;
|
||||
// Centers the badge on the icon's bottom-right corner: half in, half out.
|
||||
const STATUS_BADGE_OFFSET = -(STATUS_BADGE_SIZE / 2);
|
||||
// How far the badge overhangs the icon's bottom-right corner. Well short of half the badge:
|
||||
// centered on the corner it reads as hanging off the icon rather than sitting on it, and the
|
||||
// sidebar row has no padding there to absorb the overhang.
|
||||
const STATUS_BADGE_OFFSET = -5;
|
||||
// Both glyphs must be EVEN. The shell's interior is 12pt (14 minus the 1pt ring on each side),
|
||||
// so a centered glyph of size N sits at a (12 - N) / 2 offset — fractional for odd N, which the
|
||||
// browser snaps to a device-pixel boundary and renders visibly off-center (an odd size measured
|
||||
@@ -26,17 +32,12 @@ const STATUS_BADGE_OFFSET = -(STATUS_BADGE_SIZE / 2);
|
||||
// against the 8pt dot — the two states read as the same-diameter disc.
|
||||
const STATUS_BADGE_DOT_SIZE = 8;
|
||||
const STATUS_BADGE_ALERT_SIZE = 10;
|
||||
// SyncedLoader clamps its dot size at 2px, so sizes 5-10 all render the same 5x8 dot grid.
|
||||
const STATUS_BADGE_LOADER_SIZE = 7;
|
||||
// The dot-comet's 6 divs are geometrically centered, but the snake reads a hair right inside
|
||||
// the round circle. This optical nudge (pt, leftward) lands its visual center on the circle
|
||||
// center — the play-button-triangle trick. Dialed in visually: -0.67pt ≈ 2 device px left at
|
||||
// 3x (a full -1pt / 3px read a touch too far left).
|
||||
const STATUS_BADGE_LOADER_NUDGE_X = -0.67;
|
||||
// Matches the workspace title's lineHeight (sidebar-workspace-row-content's
|
||||
// workspaceBranchText) so the icon centers on the title rather than floating above it.
|
||||
const LEADING_SLOT_HEIGHT = 20;
|
||||
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -44,12 +45,6 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
const amberColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.palette.amber[500],
|
||||
});
|
||||
const syncedLoaderColorMapping = (theme: Theme) => ({
|
||||
color:
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.palette.amber[700]
|
||||
: theme.colors.palette.amber[500],
|
||||
});
|
||||
|
||||
/**
|
||||
* Leading slot of a sidebar project row: chevron on hover, archive spinner while removing,
|
||||
@@ -73,9 +68,6 @@ export function ProjectLeadingVisual({
|
||||
showChevron?: boolean;
|
||||
isArchiving?: boolean;
|
||||
}) {
|
||||
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName);
|
||||
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
|
||||
|
||||
if (showChevron && chevron !== null) {
|
||||
return (
|
||||
<View style={styles.projectLeadingVisualSlot}>
|
||||
@@ -95,7 +87,7 @@ export function ProjectLeadingVisual({
|
||||
return (
|
||||
<ProjectStatusIndicator
|
||||
iconDataUri={iconDataUri}
|
||||
placeholderInitial={placeholderInitial}
|
||||
displayName={displayName}
|
||||
projectViewKey={projectViewKey}
|
||||
statusBucket={statusBucket}
|
||||
/>
|
||||
@@ -103,46 +95,65 @@ export function ProjectLeadingVisual({
|
||||
}
|
||||
|
||||
// The project icon (the lettered box) is what marks a row as a *project* rather than a
|
||||
// workspace, so it always stays. Status rides along as a corner badge instead of replacing
|
||||
// the icon: "working" and "needs input" keep the same amber loader/alert glyph a workspace
|
||||
// row uses, the quieter buckets get a dot, and "done" shows nothing. Every one of those sits
|
||||
// in the identical ringed badge, so the badge reads as one fixed shell across all states.
|
||||
// workspace, so it always stays and status annotates it instead of replacing it. Every
|
||||
// surfaced bucket lands in the identical corner badge — an amber alert glyph for needs_input,
|
||||
// a colored disc for the rest, nothing for done — so the badge reads as one fixed shell and
|
||||
// only its contents change.
|
||||
export function ProjectStatusIndicator({
|
||||
iconDataUri,
|
||||
placeholderInitial,
|
||||
displayName,
|
||||
projectViewKey,
|
||||
statusBucket,
|
||||
loading = false,
|
||||
testID,
|
||||
}: {
|
||||
iconDataUri: string | null;
|
||||
placeholderInitial: string;
|
||||
displayName: string;
|
||||
projectViewKey: string;
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
loading?: boolean;
|
||||
testID?: string;
|
||||
}) {
|
||||
const placeholderInitial = projectIconPlaceholderLabelFromDisplayName(displayName)
|
||||
.charAt(0)
|
||||
.toUpperCase();
|
||||
// A row that's still starting up and a row that's working are both "busy", and at 8pt
|
||||
// there's no room to draw the difference — so `loading` just resolves to the running bucket
|
||||
// and they share one badge.
|
||||
const badgeBucket = loading ? "running" : statusBucket;
|
||||
const badgeContent = getProjectStatusBadgeContent(badgeBucket);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.projectLeadingVisualSlot}
|
||||
testID={
|
||||
statusBucket && statusBucket !== "done"
|
||||
testID ??
|
||||
(statusBucket && statusBucket !== "done"
|
||||
? `project-status-indicator-${statusBucket}`
|
||||
: "project-icon-only"
|
||||
: "project-icon-only")
|
||||
}
|
||||
>
|
||||
<ProjectIcon
|
||||
iconDataUri={iconDataUri}
|
||||
placeholderInitial={placeholderInitial}
|
||||
projectViewKey={projectViewKey}
|
||||
/>
|
||||
<ProjectStatusBadge statusBucket={statusBucket} />
|
||||
<View style={styles.projectIconBox}>
|
||||
<ProjectIcon
|
||||
iconDataUri={iconDataUri}
|
||||
placeholderInitial={placeholderInitial}
|
||||
projectViewKey={projectViewKey}
|
||||
/>
|
||||
{badgeContent === null || badgeBucket === null ? null : (
|
||||
<ProjectStatusBadge content={badgeContent} statusBucket={badgeBucket} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectStatusBadge({ statusBucket }: { statusBucket: SidebarStateBucket | null }) {
|
||||
const content = getProjectStatusBadgeContent(statusBucket);
|
||||
if (content === null || statusBucket === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function ProjectStatusBadge({
|
||||
content,
|
||||
statusBucket,
|
||||
}: {
|
||||
content: ProjectStatusBadgeContent;
|
||||
statusBucket: SidebarStateBucket;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
role="status"
|
||||
@@ -150,28 +161,20 @@ function ProjectStatusBadge({ statusBucket }: { statusBucket: SidebarStateBucket
|
||||
style={styles.statusBadge}
|
||||
testID="project-status-badge"
|
||||
>
|
||||
{renderProjectStatusBadgeContent(content)}
|
||||
{content.kind === "alert" ? (
|
||||
<ThemedCircleAlert size={STATUS_BADGE_ALERT_SIZE} uniProps={amberColorMapping} />
|
||||
) : (
|
||||
<ProjectStatusDot bucket={content.bucket} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function renderProjectStatusBadgeContent(
|
||||
content: NonNullable<ReturnType<typeof getProjectStatusBadgeContent>>,
|
||||
): ReactNode {
|
||||
switch (content.kind) {
|
||||
case "loader":
|
||||
// Nudged left a hair so the comet's visual center sits on the circle center (see the
|
||||
// nudge constant). The wrapper carries the transform; the loader itself is untouched.
|
||||
return (
|
||||
<View style={styles.runningLoaderNudge}>
|
||||
<ThemedSyncedLoader size={STATUS_BADGE_LOADER_SIZE} uniProps={syncedLoaderColorMapping} />
|
||||
</View>
|
||||
);
|
||||
case "alert":
|
||||
return <ThemedCircleAlert size={STATUS_BADGE_ALERT_SIZE} uniProps={amberColorMapping} />;
|
||||
case "dot":
|
||||
return <View testID="project-status-dot" style={getStatusDotColorStyle(content.bucket)} />;
|
||||
function ProjectStatusDot({ bucket }: { bucket: ProjectStatusBadgeDotBucket }) {
|
||||
if (bucket !== "running") {
|
||||
return <View testID="project-status-dot" style={getStatusDotColorStyle(bucket)} />;
|
||||
}
|
||||
return <PulsingStatusDot testID="project-status-dot" style={styles.statusDotRunning} />;
|
||||
}
|
||||
|
||||
function ProjectIcon({
|
||||
@@ -205,29 +208,43 @@ function ProjectInlineChevron({ chevron }: { chevron: "expand" | "collapse" | nu
|
||||
return <ChevronRight size={14} color="#9ca3af" />;
|
||||
}
|
||||
|
||||
function getStatusDotColorStyle(bucket: "failed" | "attention"): ViewStyle {
|
||||
function getStatusDotColorStyle(
|
||||
bucket: Exclude<ProjectStatusBadgeDotBucket, "running">,
|
||||
): ViewStyle {
|
||||
return bucket === "failed" ? styles.statusDotFailed : styles.statusDotAttention;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => {
|
||||
// Dot statuses sit *inside* the shell rather than replacing it, so "ready to review" and
|
||||
// "failed" carry the same ring as the loader and alert. The geometry is shared here and
|
||||
// baked into each colored variant so the style prop stays a single stable object.
|
||||
const statusDotBase = {
|
||||
width: STATUS_BADGE_DOT_SIZE,
|
||||
height: STATUS_BADGE_DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
} as const;
|
||||
// Dot statuses sit *inside* the shell rather than replacing it, so they carry the same ring
|
||||
// the alert does. The geometry is shared here and baked into each colored variant so the
|
||||
// style prop stays a single stable object; the colors come from the one bucket-to-color map
|
||||
// so this badge can't drift from the status dots everywhere else.
|
||||
const statusDot = (bucket: ProjectStatusBadgeDotBucket) =>
|
||||
({
|
||||
width: STATUS_BADGE_DOT_SIZE,
|
||||
height: STATUS_BADGE_DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: getStatusDotColor({ theme, bucket }) ?? undefined,
|
||||
}) as const;
|
||||
|
||||
return {
|
||||
// The slot is as tall as the title's line box, not as tall as the icon, and centers the
|
||||
// icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a
|
||||
// 16pt slot next to a 20pt line box puts the icon 2pt above the title and the kebab —
|
||||
// which is why the workspace status indicator is also 20 tall. Keep the two in step.
|
||||
projectLeadingVisualSlot: {
|
||||
position: "relative",
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
height: LEADING_SLOT_HEIGHT,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
// Anchors the corner badge to the icon rather than to the taller slot.
|
||||
projectIconBox: {
|
||||
position: "relative",
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
},
|
||||
projectIcon: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
@@ -243,9 +260,9 @@ const styles = StyleSheet.create((theme) => {
|
||||
projectIconFallbackText: {
|
||||
fontSize: 9,
|
||||
},
|
||||
// The one shell every status shares. It straddles the icon's bottom-right corner (half in,
|
||||
// half out) so the lettered project box stays readable, and its ring is sidebar-colored so
|
||||
// the badge separates from the icon underneath rather than blending into it.
|
||||
// The shell the alert and dot statuses share. It straddles the icon's bottom-right corner
|
||||
// (half in, half out) so the lettered project box stays readable, and its ring is
|
||||
// sidebar-colored so the badge separates from the icon underneath rather than blending in.
|
||||
statusBadge: {
|
||||
position: "absolute",
|
||||
right: STATUS_BADGE_OFFSET,
|
||||
@@ -260,17 +277,8 @@ const styles = StyleSheet.create((theme) => {
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
},
|
||||
// Optical-centering nudge for the running dot-comet (see the nudge constant).
|
||||
runningLoaderNudge: {
|
||||
transform: [{ translateX: STATUS_BADGE_LOADER_NUDGE_X }],
|
||||
},
|
||||
statusDotFailed: {
|
||||
...statusDotBase,
|
||||
backgroundColor: theme.colors.palette.red[500],
|
||||
},
|
||||
statusDotAttention: {
|
||||
...statusDotBase,
|
||||
backgroundColor: theme.colors.palette.green[500],
|
||||
},
|
||||
statusDotRunning: statusDot("running"),
|
||||
statusDotFailed: statusDot("failed"),
|
||||
statusDotAttention: statusDot("attention"),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect } from "react";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
makeMutable,
|
||||
useAnimatedStyle,
|
||||
useReducedMotion,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
|
||||
// The busy affordance for a sidebar row. Every status in the sidebar is a dot; busy is the
|
||||
// one that moves. Callers own the dot's geometry and color and pass it in as a style — this
|
||||
// module owns nothing but the pulse.
|
||||
//
|
||||
// Opacity only, and shallow: the dot sits in a column of static dots in neighboring rows, so
|
||||
// anything that changes its size or drops it near invisible reads as jitter down the list.
|
||||
// One full cycle is out-and-back, so the visible period is twice the half period.
|
||||
const PULSE_HALF_PERIOD_MS = 900;
|
||||
const PULSE_MIN_OPACITY = 0.65;
|
||||
|
||||
// One clock for every pulsing dot in the app. Dots that each start their own animation drift
|
||||
// apart as rows mount at different times, and a column of them fading out of step is noise —
|
||||
// the same reason SyncedLoader drives all its instances from a single step. The value *is*
|
||||
// the opacity, so subscribers read it directly and there is nothing per-instance to fall out
|
||||
// of phase.
|
||||
//
|
||||
// Refcounted so the animation only runs while at least one dot is mounted: an unattended
|
||||
// withRepeat keeps the UI thread's frame loop awake forever.
|
||||
const pulseClock = makeMutable(1);
|
||||
let pulseClockSubscribers = 0;
|
||||
|
||||
function acquirePulseClock(): void {
|
||||
pulseClockSubscribers += 1;
|
||||
if (pulseClockSubscribers > 1) {
|
||||
return;
|
||||
}
|
||||
pulseClock.value = withRepeat(
|
||||
withTiming(PULSE_MIN_OPACITY, {
|
||||
duration: PULSE_HALF_PERIOD_MS,
|
||||
easing: Easing.inOut(Easing.quad),
|
||||
}),
|
||||
-1,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
function releasePulseClock(): void {
|
||||
pulseClockSubscribers -= 1;
|
||||
if (pulseClockSubscribers > 0) {
|
||||
return;
|
||||
}
|
||||
cancelAnimation(pulseClock);
|
||||
pulseClock.value = 1;
|
||||
}
|
||||
|
||||
/** A status dot, breathing in step with every other one on screen. */
|
||||
export function PulsingStatusDot({
|
||||
style,
|
||||
testID,
|
||||
}: {
|
||||
style: StyleProp<ViewStyle>;
|
||||
testID?: string;
|
||||
}) {
|
||||
const active = useRetainedPanelActive();
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || reduceMotion) {
|
||||
return;
|
||||
}
|
||||
acquirePulseClock();
|
||||
return releasePulseClock;
|
||||
}, [active, reduceMotion]);
|
||||
|
||||
const pulseStyle = useAnimatedStyle(() => ({ opacity: pulseClock.value }));
|
||||
|
||||
// Reduced motion leaves the dot fully opaque. It still carries the running color, and the
|
||||
// status is spelled out in the surrounding row's accessible label either way.
|
||||
return <Animated.View testID={testID} style={[style, reduceMotion ? null : pulseStyle]} />;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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 type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import type { HostBadgeModel } from "@/hosts/appearance";
|
||||
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -35,12 +36,16 @@ import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attentio
|
||||
import {
|
||||
SidebarWorkspaceRowFrame,
|
||||
SidebarWorkspaceRowContent,
|
||||
resolveTrailingActionVisibility,
|
||||
SidebarWorkspaceTrailingActionBase,
|
||||
SidebarWorkspaceTrailingActionOverlay,
|
||||
SidebarWorkspaceTrailingActionSlot,
|
||||
} from "@/components/sidebar/sidebar-workspace-row-content";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
||||
import {
|
||||
SidebarWorkspaceContextMenu,
|
||||
SidebarWorkspaceMenu,
|
||||
} from "@/components/sidebar/sidebar-workspace-menu";
|
||||
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
|
||||
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
|
||||
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
||||
@@ -50,7 +55,6 @@ import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pi
|
||||
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] });
|
||||
@@ -64,13 +68,11 @@ const ThemedCircleX = withUnistyles(CircleX);
|
||||
interface StatusWorkspaceListProps {
|
||||
groups: StatusGroup[];
|
||||
pinnedWorkspaces: SidebarWorkspaceEntry[];
|
||||
projectNamesByViewKey: Map<string, string>;
|
||||
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
showShortcutBadges: boolean;
|
||||
onWorkspacePress?: () => void;
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
showHostLabels: boolean;
|
||||
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
listHeaderComponent?: ReactNode;
|
||||
@@ -79,13 +81,11 @@ interface StatusWorkspaceListProps {
|
||||
export function SidebarStatusWorkspaceList({
|
||||
groups,
|
||||
pinnedWorkspaces,
|
||||
projectNamesByViewKey,
|
||||
projectIconByProjectViewKey,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
showShortcutBadges,
|
||||
onWorkspacePress,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
hostBadgeByServerId,
|
||||
supportsPinningByServerId,
|
||||
onToggleWorkspacePin,
|
||||
listHeaderComponent,
|
||||
@@ -118,10 +118,8 @@ export function SidebarStatusWorkspaceList({
|
||||
workspace={workspace}
|
||||
{...buildStatusRowProjectPresentation({
|
||||
workspace,
|
||||
projectNamesByViewKey,
|
||||
projectIconByProjectViewKey,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
hostBadgeByServerId,
|
||||
})}
|
||||
shortcutNumber={statusShortcutIndex.get(workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
@@ -145,13 +143,11 @@ export function SidebarStatusWorkspaceList({
|
||||
<StatusGroupList
|
||||
groups={groups}
|
||||
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
|
||||
projectNamesByViewKey={projectNamesByViewKey}
|
||||
projectIconByProjectViewKey={projectIconByProjectViewKey}
|
||||
shortcutIndex={statusShortcutIndex}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
hostBadgeByServerId={hostBadgeByServerId}
|
||||
supportsPinningByServerId={supportsPinningByServerId}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
/>
|
||||
@@ -186,25 +182,21 @@ export function SidebarStatusWorkspaceList({
|
||||
function StatusGroupList({
|
||||
groups,
|
||||
collapsedStatusGroupKeys,
|
||||
projectNamesByViewKey,
|
||||
projectIconByProjectViewKey,
|
||||
shortcutIndex,
|
||||
showShortcutBadges,
|
||||
onWorkspacePress,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
hostBadgeByServerId,
|
||||
supportsPinningByServerId,
|
||||
onToggleWorkspacePin,
|
||||
}: {
|
||||
groups: StatusGroup[];
|
||||
collapsedStatusGroupKeys: ReadonlySet<string>;
|
||||
projectNamesByViewKey: Map<string, string>;
|
||||
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
|
||||
shortcutIndex: Map<string, number>;
|
||||
showShortcutBadges: boolean;
|
||||
onWorkspacePress?: () => void;
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
showHostLabels: boolean;
|
||||
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
}) {
|
||||
@@ -215,13 +207,11 @@ function StatusGroupList({
|
||||
key={group.bucket}
|
||||
group={group}
|
||||
collapsed={collapsedStatusGroupKeys.has(group.bucket)}
|
||||
projectNamesByViewKey={projectNamesByViewKey}
|
||||
projectIconByProjectViewKey={projectIconByProjectViewKey}
|
||||
shortcutIndex={shortcutIndex}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
hostBadgeByServerId={hostBadgeByServerId}
|
||||
supportsPinningByServerId={supportsPinningByServerId}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
/>
|
||||
@@ -233,25 +223,21 @@ function StatusGroupList({
|
||||
function StatusGroupRows({
|
||||
group,
|
||||
collapsed,
|
||||
projectNamesByViewKey,
|
||||
projectIconByProjectViewKey,
|
||||
shortcutIndex,
|
||||
showShortcutBadges,
|
||||
onWorkspacePress,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
hostBadgeByServerId,
|
||||
supportsPinningByServerId,
|
||||
onToggleWorkspacePin,
|
||||
}: {
|
||||
group: StatusGroup;
|
||||
collapsed: boolean;
|
||||
projectNamesByViewKey: Map<string, string>;
|
||||
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
|
||||
shortcutIndex: Map<string, number>;
|
||||
showShortcutBadges: boolean;
|
||||
onWorkspacePress?: () => void;
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
showHostLabels: boolean;
|
||||
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
}) {
|
||||
@@ -276,10 +262,8 @@ function StatusGroupRows({
|
||||
workspace={workspace}
|
||||
{...buildStatusRowProjectPresentation({
|
||||
workspace,
|
||||
projectNamesByViewKey,
|
||||
projectIconByProjectViewKey,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
hostBadgeByServerId,
|
||||
})}
|
||||
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
@@ -301,47 +285,24 @@ function StatusGroupRows({
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
interface StatusRowProjectPresentation {
|
||||
subtitle: string;
|
||||
hostBadge: HostBadgeModel | null;
|
||||
projectName: string;
|
||||
projectIconDataUri: string | null;
|
||||
}
|
||||
|
||||
function buildStatusRowProjectPresentation({
|
||||
workspace,
|
||||
projectNamesByViewKey,
|
||||
projectIconByProjectViewKey,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
hostBadgeByServerId,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
projectNamesByViewKey: Map<string, string>;
|
||||
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
showHostLabels: boolean;
|
||||
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
||||
}): StatusRowProjectPresentation {
|
||||
const projectName = projectNamesByViewKey.get(workspace.projectViewKey) ?? "";
|
||||
const hostLabel = showHostLabels
|
||||
? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId)
|
||||
: null;
|
||||
return {
|
||||
subtitle: buildStatusRowSubtitle({ projectName, hostLabel }),
|
||||
projectName,
|
||||
hostBadge: hostBadgeByServerId.get(workspace.serverId) ?? null,
|
||||
projectName: workspace.projectName,
|
||||
projectIconDataUri: projectIconByProjectViewKey.get(workspace.projectViewKey) ?? null,
|
||||
};
|
||||
}
|
||||
@@ -422,7 +383,7 @@ function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) {
|
||||
case "attention":
|
||||
return <ThemedCircleCheck size={14} uniProps={greenColorMapping} />;
|
||||
case "running":
|
||||
return <ThemedCircleDot size={14} uniProps={blueColorMapping} />;
|
||||
return <ThemedCircleDot size={14} uniProps={amberColorMapping} />;
|
||||
case "done":
|
||||
return <ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
}
|
||||
@@ -430,7 +391,7 @@ function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) {
|
||||
|
||||
const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
workspace,
|
||||
subtitle,
|
||||
hostBadge,
|
||||
projectName,
|
||||
projectIconDataUri,
|
||||
shortcutNumber,
|
||||
@@ -441,7 +402,7 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
onWorkspacePress,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
subtitle: string;
|
||||
hostBadge: HostBadgeModel | null;
|
||||
projectName: string;
|
||||
projectIconDataUri: string | null;
|
||||
shortcutNumber: number | null;
|
||||
@@ -465,7 +426,7 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
return (
|
||||
<StatusWorkspaceRowWithMenu
|
||||
workspace={workspace}
|
||||
subtitle={subtitle}
|
||||
hostBadge={hostBadge}
|
||||
projectName={projectName}
|
||||
projectIconDataUri={projectIconDataUri}
|
||||
selected={selected}
|
||||
@@ -481,7 +442,7 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
|
||||
function StatusWorkspaceRowWithMenu({
|
||||
workspace,
|
||||
subtitle,
|
||||
hostBadge,
|
||||
projectName,
|
||||
projectIconDataUri,
|
||||
selected,
|
||||
@@ -493,7 +454,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
onPress,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
subtitle: string;
|
||||
hostBadge: HostBadgeModel | null;
|
||||
projectName: string;
|
||||
projectIconDataUri: string | null;
|
||||
selected: boolean;
|
||||
@@ -603,7 +564,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
<>
|
||||
<StatusWorkspaceRowInner
|
||||
workspace={workspace}
|
||||
subtitle={subtitle}
|
||||
hostBadge={hostBadge}
|
||||
projectName={projectName}
|
||||
projectIconDataUri={projectIconDataUri}
|
||||
selected={selected}
|
||||
@@ -640,7 +601,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
|
||||
function StatusWorkspaceRowInner({
|
||||
workspace,
|
||||
subtitle,
|
||||
hostBadge,
|
||||
projectName,
|
||||
projectIconDataUri,
|
||||
selected,
|
||||
@@ -662,7 +623,7 @@ function StatusWorkspaceRowInner({
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
subtitle: string;
|
||||
hostBadge: HostBadgeModel | null;
|
||||
projectName: string;
|
||||
projectIconDataUri: string | null;
|
||||
selected: boolean;
|
||||
@@ -699,15 +660,43 @@ function StatusWorkspaceRowInner({
|
||||
|
||||
return (
|
||||
<SidebarWorkspaceRowFrame workspace={workspace}>
|
||||
{({ isHovered, hoverHandlers }) => {
|
||||
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
|
||||
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);
|
||||
const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered });
|
||||
return (
|
||||
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
|
||||
<Pressable
|
||||
<SidebarWorkspaceContextMenu
|
||||
contextMenuOpen={contextMenuOpen}
|
||||
onContextMenuOpenChange={onContextMenuOpenChange}
|
||||
workspace={workspace}
|
||||
leadingProjectName={projectName}
|
||||
hostBadgeLabel={hostBadge?.label}
|
||||
scriptIconKind={scriptIconKind}
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
openInFileManagerPath={workspace.workspaceDirectory}
|
||||
disabled={isArchiving}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
@@ -717,9 +706,9 @@ function StatusWorkspaceRowInner({
|
||||
>
|
||||
<SidebarWorkspaceRowContent
|
||||
workspace={workspace}
|
||||
subtitle={subtitle}
|
||||
subtitleProjectName={projectName}
|
||||
subtitleProjectIconDataUri={projectIconDataUri}
|
||||
hostBadge={hostBadge}
|
||||
leadingProjectName={projectName}
|
||||
leadingProjectIconDataUri={projectIconDataUri}
|
||||
scriptIconKind={scriptIconKind}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving}
|
||||
@@ -730,8 +719,9 @@ function StatusWorkspaceRowInner({
|
||||
{shouldRenderActionSlot ? (
|
||||
<StatusWorkspaceActionSlot
|
||||
workspace={workspace}
|
||||
showBase={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
|
||||
showBase={showDiffStat}
|
||||
showKebab={showKebabInSlot}
|
||||
showScrim={showScrim}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
onCopyPath={onCopyPath}
|
||||
@@ -746,7 +736,7 @@ function StatusWorkspaceRowInner({
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceRowContent>
|
||||
</Pressable>
|
||||
</SidebarWorkspaceContextMenu>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
@@ -758,6 +748,7 @@ function StatusWorkspaceActionSlot({
|
||||
workspace,
|
||||
showBase,
|
||||
showKebab,
|
||||
showScrim,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
onCopyPath,
|
||||
@@ -773,6 +764,7 @@ function StatusWorkspaceActionSlot({
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
showBase: boolean;
|
||||
showKebab: boolean;
|
||||
showScrim: boolean;
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
@@ -795,7 +787,7 @@ function StatusWorkspaceActionSlot({
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={showKebab}>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={showKebab} scrim={showScrim}>
|
||||
{showKebab && onArchive ? (
|
||||
<SidebarWorkspaceMenu
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ProjectIconView } from "@/components/project-icon-view";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
|
||||
/**
|
||||
* Decorative project marker for a sidebar row's subtitle line. Status mode breaks
|
||||
* the project grouping, so the icon is what makes two same-named branches from
|
||||
* different projects tell apart at a glance — the project name next to it carries
|
||||
* the meaning, so the icon itself stays out of the accessibility tree.
|
||||
*/
|
||||
export function SidebarSubtitleProjectIcon({
|
||||
projectViewKey,
|
||||
projectName,
|
||||
iconDataUri,
|
||||
testID,
|
||||
}: {
|
||||
projectViewKey: string;
|
||||
projectName: string;
|
||||
iconDataUri: string | null;
|
||||
testID?: string;
|
||||
}) {
|
||||
const placeholderInitial = projectIconPlaceholderLabelFromDisplayName(projectName)
|
||||
.charAt(0)
|
||||
.toUpperCase();
|
||||
if (!iconDataUri && !placeholderInitial) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.slot}
|
||||
testID={testID}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
<ProjectIconView
|
||||
iconDataUri={iconDataUri}
|
||||
initial={placeholderInitial}
|
||||
projectViewKey={projectViewKey}
|
||||
imageStyle={styles.icon}
|
||||
fallbackStyle={styles.fallback}
|
||||
textStyle={styles.fallbackText}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
slot: {
|
||||
width: 12,
|
||||
// Match the 14px subtitle line so the icon does not change row density.
|
||||
height: 14,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
icon: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
fallback: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
fallbackText: {
|
||||
fontSize: 8,
|
||||
lineHeight: 12,
|
||||
},
|
||||
}));
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, type ComponentProps, type PropsWithChildren, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Archive, CircleCheck, Copy, MoreVertical, Pencil, Pin, PinOff } from "lucide-react-native";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { getForgePresentation, normalizeForge } from "@/git/forge";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import {
|
||||
@@ -12,8 +15,15 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
|
||||
import { resolveSidebarWorkspaceAccessibilityLabel } from "@/components/sidebar/sidebar-workspace-title";
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
@@ -46,7 +56,7 @@ function renderTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarWorkspaceMenuProps {
|
||||
export interface SidebarWorkspaceMenuProps {
|
||||
workspaceKey: string;
|
||||
onCopyPath?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
@@ -62,6 +72,121 @@ interface SidebarWorkspaceMenuProps {
|
||||
openInFileManagerPath?: string | null;
|
||||
}
|
||||
|
||||
interface SidebarWorkspaceMenuItemsProps extends Omit<SidebarWorkspaceMenuProps, "onArchive"> {
|
||||
onArchive?: () => void;
|
||||
}
|
||||
|
||||
type MenuSurface = "context" | "dropdown";
|
||||
|
||||
function WorkspaceMenuItem({
|
||||
surface,
|
||||
children,
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
Omit<ComponentProps<typeof DropdownMenuItem>, "children"> & { surface: MenuSurface }
|
||||
>) {
|
||||
if (surface === "context") {
|
||||
return <ContextMenuItem {...props}>{children}</ContextMenuItem>;
|
||||
}
|
||||
return <DropdownMenuItem {...props}>{children}</DropdownMenuItem>;
|
||||
}
|
||||
|
||||
function SidebarWorkspaceMenuItems({
|
||||
surface,
|
||||
workspaceKey,
|
||||
onCopyPath,
|
||||
onCopyBranchName,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
onArchive,
|
||||
archiveLabel,
|
||||
archiveStatus,
|
||||
archivePendingLabel,
|
||||
archiveShortcutKeys,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
openInFileManagerPath,
|
||||
}: SidebarWorkspaceMenuItemsProps & { surface: MenuSurface }): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
const archiveTrailing = useMemo(
|
||||
() => (archiveShortcutKeys && !isNative ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
||||
[archiveShortcutKeys],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{onCopyPath ? (
|
||||
<WorkspaceMenuItem
|
||||
surface={surface}
|
||||
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
|
||||
leading={copyLeadingIcon}
|
||||
onSelect={onCopyPath}
|
||||
>
|
||||
{t("sidebar.workspace.actions.copyPath")}
|
||||
</WorkspaceMenuItem>
|
||||
) : null}
|
||||
{onCopyBranchName ? (
|
||||
<WorkspaceMenuItem
|
||||
surface={surface}
|
||||
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
|
||||
leading={copyLeadingIcon}
|
||||
onSelect={onCopyBranchName}
|
||||
>
|
||||
{t("sidebar.workspace.actions.copyBranchName")}
|
||||
</WorkspaceMenuItem>
|
||||
) : null}
|
||||
{onRename ? (
|
||||
<WorkspaceMenuItem
|
||||
surface={surface}
|
||||
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
|
||||
leading={renameLeadingIcon}
|
||||
onSelect={onRename}
|
||||
>
|
||||
{t("sidebar.workspace.actions.rename")}
|
||||
</WorkspaceMenuItem>
|
||||
) : null}
|
||||
{onMarkAsRead ? (
|
||||
<WorkspaceMenuItem
|
||||
surface={surface}
|
||||
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
|
||||
leading={markAsReadLeadingIcon}
|
||||
onSelect={onMarkAsRead}
|
||||
>
|
||||
Mark as read
|
||||
</WorkspaceMenuItem>
|
||||
) : null}
|
||||
{onTogglePin ? (
|
||||
<WorkspaceMenuItem
|
||||
surface={surface}
|
||||
testID={`sidebar-workspace-menu-pin-${workspaceKey}`}
|
||||
leading={isPinned ? unpinLeadingIcon : pinLeadingIcon}
|
||||
onSelect={onTogglePin}
|
||||
>
|
||||
{isPinned ? t("sidebar.workspace.actions.unpin") : t("sidebar.workspace.actions.pin")}
|
||||
</WorkspaceMenuItem>
|
||||
) : null}
|
||||
<OpenInFileManagerMenuItem
|
||||
surface={surface}
|
||||
path={openInFileManagerPath}
|
||||
testID={`sidebar-workspace-menu-open-folder-${workspaceKey}`}
|
||||
/>
|
||||
{onArchive ? (
|
||||
<WorkspaceMenuItem
|
||||
surface={surface}
|
||||
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
||||
leading={archiveLeadingIcon}
|
||||
trailing={archiveTrailing}
|
||||
status={archiveStatus}
|
||||
pendingLabel={archivePendingLabel}
|
||||
onSelect={onArchive}
|
||||
>
|
||||
{archiveLabel ?? t("sidebar.workspace.actions.archive")}
|
||||
</WorkspaceMenuItem>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarWorkspaceMenu({
|
||||
workspaceKey,
|
||||
onCopyPath,
|
||||
@@ -78,11 +203,6 @@ export function SidebarWorkspaceMenu({
|
||||
openInFileManagerPath,
|
||||
}: SidebarWorkspaceMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const archiveTrailing = useMemo(
|
||||
() => (archiveShortcutKeys && !isNative ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
||||
[archiveShortcutKeys],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
@@ -95,70 +215,120 @@ export function SidebarWorkspaceMenu({
|
||||
{renderTriggerIcon}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={260}>
|
||||
{onCopyPath ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
|
||||
leading={copyLeadingIcon}
|
||||
onSelect={onCopyPath}
|
||||
>
|
||||
{t("sidebar.workspace.actions.copyPath")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onCopyBranchName ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
|
||||
leading={copyLeadingIcon}
|
||||
onSelect={onCopyBranchName}
|
||||
>
|
||||
{t("sidebar.workspace.actions.copyBranchName")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onRename ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
|
||||
leading={renameLeadingIcon}
|
||||
onSelect={onRename}
|
||||
>
|
||||
{t("sidebar.workspace.actions.rename")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onMarkAsRead ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
|
||||
leading={markAsReadLeadingIcon}
|
||||
onSelect={onMarkAsRead}
|
||||
>
|
||||
Mark as read
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onTogglePin ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-pin-${workspaceKey}`}
|
||||
leading={isPinned ? unpinLeadingIcon : pinLeadingIcon}
|
||||
onSelect={onTogglePin}
|
||||
>
|
||||
{isPinned ? t("sidebar.workspace.actions.unpin") : t("sidebar.workspace.actions.pin")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<OpenInFileManagerMenuItem
|
||||
path={openInFileManagerPath}
|
||||
testID={`sidebar-workspace-menu-open-folder-${workspaceKey}`}
|
||||
<SidebarWorkspaceMenuItems
|
||||
surface="dropdown"
|
||||
workspaceKey={workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
openInFileManagerPath={openInFileManagerPath}
|
||||
/>
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
||||
leading={archiveLeadingIcon}
|
||||
trailing={archiveTrailing}
|
||||
status={archiveStatus}
|
||||
pendingLabel={archivePendingLabel}
|
||||
onSelect={onArchive}
|
||||
>
|
||||
{archiveLabel ?? t("sidebar.workspace.actions.archive")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
type ContextTriggerProps = Omit<
|
||||
ComponentProps<typeof ContextMenuTrigger>,
|
||||
"children" | "enabledOnMobile"
|
||||
>;
|
||||
|
||||
export function SidebarWorkspaceContextMenu({
|
||||
children,
|
||||
contextMenuOpen,
|
||||
onContextMenuOpenChange,
|
||||
workspace,
|
||||
leadingProjectName,
|
||||
hostBadgeLabel,
|
||||
scriptIconKind,
|
||||
workspaceKey,
|
||||
onCopyPath,
|
||||
onCopyBranchName,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
onArchive,
|
||||
archiveLabel,
|
||||
archiveStatus,
|
||||
archivePendingLabel,
|
||||
archiveShortcutKeys,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
openInFileManagerPath,
|
||||
accessibilityLabel,
|
||||
...triggerProps
|
||||
}: PropsWithChildren<
|
||||
SidebarWorkspaceMenuItemsProps &
|
||||
ContextTriggerProps & {
|
||||
contextMenuOpen: boolean;
|
||||
onContextMenuOpenChange: (open: boolean) => void;
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
leadingProjectName?: string | null;
|
||||
hostBadgeLabel?: string | null;
|
||||
scriptIconKind?: "service" | "command" | null;
|
||||
}
|
||||
>) {
|
||||
const {
|
||||
settings: { workspaceTitleSource },
|
||||
} = useAppSettings();
|
||||
const { t } = useTranslation();
|
||||
const pullRequestLabel = workspace.prHint
|
||||
? t("workspace.git.pr.accessibility.pullRequest", {
|
||||
number: workspace.prHint.number,
|
||||
context: getForgePresentation(normalizeForge(workspace.prHint.forge)).changeRequestContext,
|
||||
})
|
||||
: null;
|
||||
const rowAccessibilityLabel = resolveSidebarWorkspaceAccessibilityLabel({
|
||||
workspace,
|
||||
workspaceTitleSource,
|
||||
leadingProjectName,
|
||||
hostBadgeLabel,
|
||||
pullRequestLabel,
|
||||
scriptLabel: scriptIconKind ? t("workspace.status.scriptsAvailable") : null,
|
||||
});
|
||||
|
||||
return (
|
||||
<ContextMenu open={contextMenuOpen} onOpenChange={onContextMenuOpenChange}>
|
||||
<ContextMenuTrigger
|
||||
{...triggerProps}
|
||||
enabledOnMobile={false}
|
||||
accessibilityLabel={accessibilityLabel ?? rowAccessibilityLabel}
|
||||
>
|
||||
{children}
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent
|
||||
align="start"
|
||||
width={260}
|
||||
testID={`sidebar-workspace-context-menu-${workspaceKey}`}
|
||||
>
|
||||
<SidebarWorkspaceMenuItems
|
||||
surface="context"
|
||||
workspaceKey={workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
openInFileManagerPath={openInFileManagerPath}
|
||||
/>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.trigger, hovered && styles.triggerHovered];
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { memo, useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { memo, useCallback, useId, useMemo, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View, type GestureResponderEvent, type ViewStyle } from "react-native";
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||
import {
|
||||
CircleAlert,
|
||||
ExternalLink,
|
||||
Folder,
|
||||
FolderGit2,
|
||||
GitPullRequest,
|
||||
@@ -13,53 +19,82 @@ import {
|
||||
Monitor,
|
||||
SquareTerminal,
|
||||
} from "lucide-react-native";
|
||||
import { SidebarSubtitleProjectIcon } from "@/components/sidebar/sidebar-subtitle-project-icon";
|
||||
import { HostBadge } from "@/components/sidebar/host-badge";
|
||||
import { ProjectStatusIndicator } from "@/components/sidebar/project-leading-visual";
|
||||
import { PulsingStatusDot } from "@/components/sidebar/pulsing-status-dot";
|
||||
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
|
||||
import { SyncedLoader } from "@/components/synced-loader";
|
||||
import type { HostBadgeModel } from "@/hosts/appearance";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { PrHint } from "@/git/use-pr-status-query";
|
||||
import { getForgePresentation, normalizeForge } from "@/git/forge";
|
||||
import { ForgeBrandIcon } from "@/git/forge-icon";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
|
||||
|
||||
// The scrim spans more than the kebab so the fade starts left of the diff stat. Solid from
|
||||
// SCRIM_SOLID_OFFSET rightward, which keeps the kebab itself off the gradient entirely.
|
||||
const SCRIM_WIDTH = 48;
|
||||
const SCRIM_SOLID_OFFSET = "55%";
|
||||
|
||||
const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||
const DEFAULT_STATUS_DOT_OFFSET = 0;
|
||||
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
|
||||
const syncedLoaderColorMapping = (theme: Theme) => ({
|
||||
color:
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.palette.amber[700]
|
||||
: theme.colors.palette.amber[500],
|
||||
});
|
||||
const blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] });
|
||||
const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
|
||||
const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
|
||||
const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500] });
|
||||
|
||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||
const ThemedMonitor = withUnistyles(Monitor);
|
||||
const ThemedFolder = withUnistyles(Folder);
|
||||
const ThemedFolderGit2 = withUnistyles(FolderGit2);
|
||||
const ThemedGlobe = withUnistyles(Globe);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
|
||||
function renderChecksBadgeForgeIcon(icon: string) {
|
||||
return <ForgeBrandIcon iconKind={icon} size={10} uniProps={redColorMapping} />;
|
||||
/**
|
||||
* react-native-svg's extractGradient reads stopColor off the child elements structurally,
|
||||
* without rendering them, so wrapping Stop itself in withUnistyles hides the color from it and
|
||||
* the native gradient silently falls back to black. Theme the whole SVG instead and keep real
|
||||
* Stop elements as direct children of the gradient.
|
||||
*/
|
||||
function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; color: string }) {
|
||||
return (
|
||||
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
||||
<Defs>
|
||||
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
{/* Same color at both ends, varying only stopOpacity. Interpolating a hex toward
|
||||
`transparent` goes through black in some engines and leaves a grey fringe. */}
|
||||
<Stop offset="0%" stopColor={color} stopOpacity={0} />
|
||||
<Stop offset={SCRIM_SOLID_OFFSET} stopColor={color} stopOpacity={1} />
|
||||
<Stop offset="100%" stopColor={color} stopOpacity={1} />
|
||||
</SvgLinearGradient>
|
||||
</Defs>
|
||||
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedTrailingActionScrimSvg = withUnistyles(TrailingActionScrimSvg);
|
||||
|
||||
const scrimColorMapping = (theme: Theme) => ({ color: theme.colors.surfaceSidebarHover });
|
||||
|
||||
function pullRequestIconStyle({
|
||||
hovered,
|
||||
pressed,
|
||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [
|
||||
pullRequestIconStyles.pressable,
|
||||
(Boolean(hovered) || pressed) && pullRequestIconStyles.pressableActive,
|
||||
];
|
||||
}
|
||||
|
||||
type SidebarWorkspaceScriptIconKind = "service" | "command";
|
||||
@@ -73,29 +108,48 @@ export function SidebarWorkspaceRowFrame({
|
||||
isDragging?: boolean;
|
||||
children: (input: {
|
||||
isHovered: boolean;
|
||||
contextMenuOpen: boolean;
|
||||
onContextMenuOpenChange: (open: boolean) => void;
|
||||
hoverHandlers: { onPointerEnter: () => void; onPointerLeave: () => void };
|
||||
}) => ReactNode;
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||
const [contextMenuOpen, setContextMenuOpen] = useState(false);
|
||||
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 hoverHandlers = useMemo(
|
||||
() => ({ onPointerEnter: handlePointerEnter, onPointerLeave: handlePointerLeave }),
|
||||
[handlePointerEnter, handlePointerLeave],
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceHoverCard workspace={workspace} prHint={workspace.prHint} isDragging={isDragging}>
|
||||
{children({ isHovered, hoverHandlers })}
|
||||
<WorkspaceHoverCard
|
||||
workspace={workspace}
|
||||
prHint={workspace.prHint}
|
||||
isDragging={isDragging}
|
||||
disabled={contextMenuOpen}
|
||||
>
|
||||
{children({
|
||||
isHovered: isHovered && !contextMenuOpen,
|
||||
contextMenuOpen,
|
||||
onContextMenuOpenChange: handleContextMenuOpenChange,
|
||||
hoverHandlers,
|
||||
})}
|
||||
</WorkspaceHoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowContent({
|
||||
workspace,
|
||||
subtitle,
|
||||
subtitleProjectName = null,
|
||||
subtitleProjectIconDataUri = null,
|
||||
hostBadge,
|
||||
leadingProjectName = null,
|
||||
leadingProjectIconDataUri = null,
|
||||
scriptIconKind = null,
|
||||
isHovered,
|
||||
isLoading,
|
||||
@@ -106,10 +160,10 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
children,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
subtitle?: string | null;
|
||||
/** Project named by the subtitle. Set it to lead the subtitle line with that project's icon. */
|
||||
subtitleProjectName?: string | null;
|
||||
subtitleProjectIconDataUri?: string | null;
|
||||
hostBadge?: HostBadgeModel | null;
|
||||
/** Hoisted rows use their project icon as the leading visual because no project row contains them. */
|
||||
leadingProjectName?: string | null;
|
||||
leadingProjectIconDataUri?: string | null;
|
||||
scriptIconKind?: SidebarWorkspaceScriptIconKind | null;
|
||||
isHovered: boolean;
|
||||
isLoading: boolean;
|
||||
@@ -124,28 +178,51 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
settings: { workspaceTitleSource },
|
||||
} = useAppSettings();
|
||||
const workspaceLabel = resolveSidebarWorkspacePrimaryLabel({ workspace, workspaceTitleSource });
|
||||
const hasTitleAccessory = Boolean(scriptIconKind || workspace.prHint || hostBadge);
|
||||
const workspaceBranchTextStyle = useMemo(
|
||||
() => [
|
||||
styles.workspaceBranchText,
|
||||
scriptIconKind ? styles.workspaceBranchTextWithAccessory : styles.workspaceBranchTextFlexible,
|
||||
hasTitleAccessory
|
||||
? styles.workspaceBranchTextWithAccessory
|
||||
: styles.workspaceBranchTextFlexible,
|
||||
isHovered && styles.workspaceBranchTextHovered,
|
||||
isCreating && styles.workspaceBranchTextCreating,
|
||||
],
|
||||
[scriptIconKind, isHovered, isCreating],
|
||||
[hasTitleAccessory, isHovered, isCreating],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.workspaceRowContent}>
|
||||
<View style={styles.workspaceRowMain}>
|
||||
<WorkspaceStatusIndicator
|
||||
bucket={workspace.statusBucket}
|
||||
workspaceKind={workspace.workspaceKind}
|
||||
loading={isLoading}
|
||||
reserveIdleSpace={reserveIdleStatusIndicatorSpace}
|
||||
/>
|
||||
{leadingProjectName ? (
|
||||
<ProjectStatusIndicator
|
||||
iconDataUri={leadingProjectIconDataUri}
|
||||
displayName={leadingProjectName}
|
||||
projectViewKey={workspace.projectViewKey}
|
||||
statusBucket={workspace.statusBucket}
|
||||
loading={isLoading}
|
||||
testID={`sidebar-row-project-icon-${workspace.workspaceKey}`}
|
||||
/>
|
||||
) : (
|
||||
<WorkspaceStatusIndicator
|
||||
bucket={workspace.statusBucket}
|
||||
workspaceKind={workspace.workspaceKind}
|
||||
loading={isLoading}
|
||||
reserveIdleSpace={reserveIdleStatusIndicatorSpace}
|
||||
/>
|
||||
)}
|
||||
<View style={styles.workspaceContentColumn}>
|
||||
<View style={styles.workspaceTitleRow}>
|
||||
<View style={styles.workspaceTitleLeft}>
|
||||
{workspace.prHint ? <PullRequestIcon hint={workspace.prHint} /> : null}
|
||||
{hostBadge ? (
|
||||
<HostBadge
|
||||
serverId={hostBadge.serverId}
|
||||
label={hostBadge.label}
|
||||
color={hostBadge.color}
|
||||
showLabel={hostBadge.showLabel}
|
||||
/>
|
||||
) : null}
|
||||
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
|
||||
{workspaceLabel}
|
||||
</Text>
|
||||
@@ -153,27 +230,6 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
</View>
|
||||
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
|
||||
</View>
|
||||
{subtitle ? (
|
||||
<View style={styles.workspaceSubtitleRow}>
|
||||
{subtitleProjectName ? (
|
||||
<SidebarSubtitleProjectIcon
|
||||
projectViewKey={workspace.projectViewKey}
|
||||
projectName={subtitleProjectName}
|
||||
iconDataUri={subtitleProjectIconDataUri}
|
||||
testID={`sidebar-row-project-icon-${workspace.workspaceKey}`}
|
||||
/>
|
||||
) : null}
|
||||
<Text style={styles.workspaceSubtitle} numberOfLines={1}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{workspace.prHint ? (
|
||||
<View style={styles.workspacePrBadgeRow}>
|
||||
<PrBadge hint={workspace.prHint} />
|
||||
<ChecksBadge checks={workspace.prHint.checks} forge={workspace.prHint.forge} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
{showShortcutBadge && shortcutNumber !== null ? (
|
||||
@@ -212,20 +268,22 @@ function WorkspaceStatusIndicator({
|
||||
loading?: boolean;
|
||||
reserveIdleSpace?: boolean;
|
||||
}) {
|
||||
const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket });
|
||||
|
||||
// Busy is a pulsing dot here for the same reason it is on a project icon: every status in
|
||||
// the sidebar is a dot, and a row with a project icon simply moves that dot onto the icon.
|
||||
// A row starting up and a row working are both busy, so they share the dot and differ only
|
||||
// in testID.
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-loading">
|
||||
<ThemedLoadingSpinner size={8} uniProps={foregroundMutedColorMapping} />
|
||||
<PulsingStatusDot style={styles.standaloneRunningDot} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldShowSyncedLoader) {
|
||||
if (shouldRenderSyncedStatusLoader({ bucket })) {
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-running">
|
||||
<ThemedSyncedLoader size={11} uniProps={syncedLoaderColorMapping} />
|
||||
<PulsingStatusDot style={styles.standaloneRunningDot} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -247,8 +305,14 @@ function WorkspaceStatusIndicator({
|
||||
}
|
||||
|
||||
if (bucket === "done") {
|
||||
// An idle row still gets a dot rather than an empty slot. Nested rows are marked as
|
||||
// workspaces by indentation alone, and with nothing in the leading slot the rail has no
|
||||
// edge to read against — a workspace carrying its own glyph starts looking like a project
|
||||
// header. The dot is muted to half opacity so it holds the rail without reporting status.
|
||||
return reserveIdleSpace ? (
|
||||
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-done" />
|
||||
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-done">
|
||||
<View style={styles.idleStatusDot} />
|
||||
</View>
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -304,9 +368,8 @@ function StatusDotOverlay({
|
||||
return <View style={overlayStyle} />;
|
||||
}
|
||||
|
||||
function PrBadge({ hint }: { hint: PrHint }) {
|
||||
function PullRequestIcon({ hint }: { hint: PrHint }) {
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const handlePress = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
@@ -314,21 +377,9 @@ function PrBadge({ hint }: { hint: PrHint }) {
|
||||
},
|
||||
[hint.url],
|
||||
);
|
||||
const textStyle = useMemo(
|
||||
() => (isHovered ? [prBadgeStyles.text, prBadgeStyles.textHovered] : prBadgeStyles.text),
|
||||
[isHovered],
|
||||
);
|
||||
const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state);
|
||||
const presentation = getForgePresentation(normalizeForge(hint.forge));
|
||||
|
||||
const handlePressIn = useCallback((event: GestureResponderEvent) => event.stopPropagation(), []);
|
||||
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
||||
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
||||
|
||||
const pressableStyle = useMemo(
|
||||
() => [prBadgeStyles.badge, isHovered && prBadgeStyles.badgePressed],
|
||||
[isHovered],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -340,36 +391,13 @@ function PrBadge({ hint }: { hint: PrHint }) {
|
||||
hitSlop={4}
|
||||
onPressIn={handlePressIn}
|
||||
onPress={handlePress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
style={pressableStyle}
|
||||
style={pullRequestIconStyle}
|
||||
>
|
||||
{isHovered ? (
|
||||
<ThemedExternalLink size={12} uniProps={iconUniProps} />
|
||||
) : (
|
||||
<ThemedGitPullRequest size={12} uniProps={iconUniProps} />
|
||||
)}
|
||||
<Text style={textStyle} numberOfLines={1}>
|
||||
{presentation.numberPrefix}
|
||||
{hint.number}
|
||||
</Text>
|
||||
<ThemedGitPullRequest size={12} uniProps={getPrIconUniMapping(hint.state)} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function ChecksBadge({ checks, forge }: { checks: PrHint["checks"]; forge: PrHint["forge"] }) {
|
||||
if (!checks || checks.length === 0) return null;
|
||||
const failed = checks.filter((check) => check.status === "failure").length;
|
||||
if (failed === 0) return null;
|
||||
const icon = getForgePresentation(normalizeForge(forge)).icon;
|
||||
return (
|
||||
<View style={checksBadgeStyles.badge}>
|
||||
{renderChecksBadgeForgeIcon(icon)}
|
||||
<Text style={checksBadgeStyles.text}>{failed} failed</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function getPrIconUniMapping(state: PrHint["state"]) {
|
||||
switch (state) {
|
||||
case "merged":
|
||||
@@ -396,38 +424,16 @@ function getStatusDotColorStyle(bucket: SidebarStateBucket) {
|
||||
}
|
||||
}
|
||||
|
||||
const prBadgeStyles = StyleSheet.create((theme) => ({
|
||||
badge: {
|
||||
flexDirection: "row",
|
||||
const pullRequestIconStyles = StyleSheet.create(() => ({
|
||||
pressable: {
|
||||
height: 20,
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
badgePressed: {
|
||||
pressableActive: {
|
||||
opacity: 0.82,
|
||||
},
|
||||
text: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 14,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
textHovered: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
|
||||
const checksBadgeStyles = StyleSheet.create((theme) => ({
|
||||
badge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
},
|
||||
text: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 14,
|
||||
color: theme.colors.palette.red[500],
|
||||
},
|
||||
}));
|
||||
|
||||
export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
|
||||
@@ -469,6 +475,13 @@ export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
|
||||
top: 0,
|
||||
right: 0,
|
||||
},
|
||||
trailingActionScrim: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: SCRIM_WIDTH,
|
||||
},
|
||||
}));
|
||||
|
||||
export function SidebarWorkspaceShortcutBadge({ number }: { number: number }) {
|
||||
@@ -479,6 +492,34 @@ export function SidebarWorkspaceShortcutBadge({ number }: { number: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the trailing slot shows for a row. Derived in one place because three row renderers
|
||||
* share it: the two project-mode rows and the status-mode row. The rule used to be copied
|
||||
* into each of them and immediately drifted — one call site kept hiding the diff after the
|
||||
* others stopped.
|
||||
*
|
||||
* The diff survives the kebab on hover and fades under the scrim instead of blinking out.
|
||||
* Touch has no hover, so its permanent kebab still hides the diff outright rather than
|
||||
* scrimming an unhovered row whose background doesn't match the gradient.
|
||||
*/
|
||||
export function resolveTrailingActionVisibility({
|
||||
hasDiffStat,
|
||||
hasArchiveAction,
|
||||
isHovered,
|
||||
isTouchPlatform,
|
||||
showShortcut,
|
||||
}: {
|
||||
hasDiffStat: boolean;
|
||||
hasArchiveAction: boolean;
|
||||
isHovered: boolean;
|
||||
isTouchPlatform: boolean;
|
||||
showShortcut: boolean;
|
||||
}): { showDiffStat: boolean; showKebab: boolean; showScrim: boolean } {
|
||||
const showKebab = Boolean(hasArchiveAction && (isHovered || isTouchPlatform)) && !showShortcut;
|
||||
const showDiffStat = hasDiffStat && !showShortcut && (isHovered || !showKebab);
|
||||
return { showDiffStat, showKebab, showScrim: showDiffStat && showKebab };
|
||||
}
|
||||
|
||||
export function SidebarWorkspaceTrailingActionSlot({ children }: { children: ReactNode }) {
|
||||
return <View style={sidebarWorkspaceRowStyles.trailingActionSlot}>{children}</View>;
|
||||
}
|
||||
@@ -496,13 +537,42 @@ export function SidebarWorkspaceTrailingActionBase({
|
||||
|
||||
export function SidebarWorkspaceTrailingActionOverlay({
|
||||
visible,
|
||||
scrim = false,
|
||||
children,
|
||||
}: {
|
||||
visible: boolean;
|
||||
/** Fade the row into the kebab when something (the diff stat) is still rendered behind it. */
|
||||
scrim?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (!visible || !children) return null;
|
||||
return <View style={sidebarWorkspaceRowStyles.trailingActionOverlay}>{children}</View>;
|
||||
return (
|
||||
<>
|
||||
{scrim ? <TrailingActionScrim /> : null}
|
||||
<View style={sidebarWorkspaceRowStyles.trailingActionOverlay}>{children}</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The row's own background, faded in from the right, sitting between the diff stat and the
|
||||
* kebab. The kebab lands on fully opaque background while the diff dissolves underneath it
|
||||
* rather than blinking out — hiding the diff outright was the old behavior and it cost a
|
||||
* visible flicker on every hover.
|
||||
*
|
||||
* Anchored to the trailing slot, which is position:relative. Wider than the slot on purpose:
|
||||
* the fade has to start before the diff stat does or the diff's left edge cuts off hard.
|
||||
*/
|
||||
function TrailingActionScrim() {
|
||||
// useId's output contains characters that are not legal inside url(#...) — React 19 wraps
|
||||
// ids in guillemets, React 18 in colons — and an unresolvable fill paints nothing at all.
|
||||
// Keep the per-instance uniqueness, drop everything a fragment reference can't carry.
|
||||
const gradientId = `sidebar-scrim-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
||||
return (
|
||||
<View style={sidebarWorkspaceRowStyles.trailingActionScrim} pointerEvents="none">
|
||||
<ThemedTrailingActionScrimSvg gradientId={gradientId} uniProps={scrimColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -528,7 +598,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
workspaceTitleLeft: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
gap: theme.spacing[1],
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
@@ -557,6 +627,19 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.palette.green[500],
|
||||
},
|
||||
standaloneRunningDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: getStatusDotColor({ theme, bucket: "running" }) ?? undefined,
|
||||
},
|
||||
idleStatusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.foregroundExtraMuted,
|
||||
opacity: 0.3,
|
||||
},
|
||||
workspaceBranchText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
@@ -583,25 +666,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
workspaceBranchTextHovered: {
|
||||
opacity: 1,
|
||||
},
|
||||
workspaceSubtitleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
minWidth: 0,
|
||||
},
|
||||
workspaceSubtitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: 14,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
workspacePrBadgeRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
statusDotNeedsInput: {
|
||||
backgroundColor: theme.colors.palette.amber[500],
|
||||
borderColor: theme.colors.surface0,
|
||||
@@ -611,7 +675,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.surface0,
|
||||
},
|
||||
statusDotRunning: {
|
||||
backgroundColor: theme.colors.palette.blue[500],
|
||||
backgroundColor: getStatusDotColor({ theme, bucket: "running" }) ?? undefined,
|
||||
borderColor: theme.colors.surface0,
|
||||
},
|
||||
statusDotAttention: {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { memo, useCallback, useMemo, useState, type Ref } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { View, Text } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import type { HostBadgeModel } from "@/hosts/appearance";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
@@ -20,10 +21,14 @@ import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-ar
|
||||
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { isNative as platformIsNative } from "@/constants/platform";
|
||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
||||
import {
|
||||
SidebarWorkspaceContextMenu,
|
||||
SidebarWorkspaceMenu,
|
||||
} from "@/components/sidebar/sidebar-workspace-menu";
|
||||
import {
|
||||
SidebarWorkspaceRowFrame,
|
||||
SidebarWorkspaceRowContent,
|
||||
resolveTrailingActionVisibility,
|
||||
SidebarWorkspaceTrailingActionBase,
|
||||
SidebarWorkspaceTrailingActionOverlay,
|
||||
SidebarWorkspaceTrailingActionSlot,
|
||||
@@ -38,8 +43,8 @@ interface SidebarWorkspaceRowProps {
|
||||
showShortcutBadge: boolean;
|
||||
canCopyBranchName: boolean;
|
||||
onPress: () => void;
|
||||
/** Secondary line under the name (status grouping shows the project name). */
|
||||
subtitle?: string | null;
|
||||
/** The host pill after the title. Absent → the sidebar spans one host, or this one is hidden. */
|
||||
hostBadge?: HostBadgeModel | null;
|
||||
/** Project grouping only: shows a transient "creating" affordance. */
|
||||
isCreating?: boolean;
|
||||
/** Project grouping only: drag-to-reorder wiring. Absent → not draggable. */
|
||||
@@ -55,7 +60,7 @@ export function SidebarWorkspaceRow({
|
||||
showShortcutBadge,
|
||||
canCopyBranchName,
|
||||
onPress,
|
||||
subtitle,
|
||||
hostBadge,
|
||||
isCreating = false,
|
||||
drag,
|
||||
isDragging = false,
|
||||
@@ -175,7 +180,7 @@ export function SidebarWorkspaceRow({
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
subtitle={subtitle}
|
||||
hostBadge={hostBadge}
|
||||
isCreating={isCreating}
|
||||
isArchiving={isArchiving}
|
||||
onPress={onPress}
|
||||
@@ -211,7 +216,7 @@ interface WorkspaceRowBodyProps {
|
||||
selected: boolean;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
subtitle?: string | null;
|
||||
hostBadge?: HostBadgeModel | null;
|
||||
isCreating: boolean;
|
||||
isArchiving: boolean;
|
||||
onPress: () => void;
|
||||
@@ -234,7 +239,7 @@ function WorkspaceRowBody({
|
||||
selected,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
subtitle,
|
||||
hostBadge,
|
||||
isCreating,
|
||||
isArchiving,
|
||||
onPress,
|
||||
@@ -276,7 +281,7 @@ function WorkspaceRowBody({
|
||||
|
||||
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(
|
||||
@@ -297,7 +302,23 @@ function WorkspaceRowBody({
|
||||
style={styles.workspaceRowContainer}
|
||||
{...hoverHandlers}
|
||||
>
|
||||
<Pressable
|
||||
<SidebarWorkspaceContextMenu
|
||||
contextMenuOpen={contextMenuOpen}
|
||||
onContextMenuOpenChange={onContextMenuOpenChange}
|
||||
workspace={workspace}
|
||||
hostBadgeLabel={hostBadge?.label}
|
||||
scriptIconKind={scriptIconKind}
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
openInFileManagerPath={workspace.workspaceDirectory}
|
||||
disabled={isArchiving}
|
||||
aria-selected={selected}
|
||||
accessibilityRole="button"
|
||||
@@ -311,7 +332,7 @@ function WorkspaceRowBody({
|
||||
>
|
||||
<SidebarWorkspaceRowContent
|
||||
workspace={workspace}
|
||||
subtitle={subtitle}
|
||||
hostBadge={hostBadge}
|
||||
scriptIconKind={scriptIconKind}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving || isCreating}
|
||||
@@ -337,7 +358,7 @@ function WorkspaceRowBody({
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
/>
|
||||
</SidebarWorkspaceRowContent>
|
||||
</Pressable>
|
||||
</SidebarWorkspaceContextMenu>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
@@ -380,8 +401,17 @@ function WorkspaceRowTrailingActions({
|
||||
}) {
|
||||
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 (
|
||||
@@ -391,9 +421,7 @@ function WorkspaceRowTrailingActions({
|
||||
) : null}
|
||||
{shouldRenderActionSlot ? (
|
||||
<SidebarWorkspaceTrailingActionSlot>
|
||||
<SidebarWorkspaceTrailingActionBase
|
||||
visible={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
|
||||
>
|
||||
<SidebarWorkspaceTrailingActionBase visible={showDiffStat}>
|
||||
{workspace.diffStat ? (
|
||||
<DiffStat
|
||||
additions={workspace.diffStat.additions}
|
||||
@@ -401,7 +429,7 @@ function WorkspaceRowTrailingActions({
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot} scrim={showScrim}>
|
||||
{onArchive ? (
|
||||
<SidebarWorkspaceMenu
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
|
||||
import {
|
||||
resolveSidebarWorkspaceAccessibilityLabel,
|
||||
resolveSidebarWorkspacePrimaryLabel,
|
||||
} from "@/components/sidebar/sidebar-workspace-title";
|
||||
|
||||
describe("resolveSidebarWorkspacePrimaryLabel", () => {
|
||||
it("uses the workspace name in title mode", () => {
|
||||
@@ -29,3 +32,45 @@ describe("resolveSidebarWorkspacePrimaryLabel", () => {
|
||||
expect(label).toBe("Local folder");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSidebarWorkspaceAccessibilityLabel", () => {
|
||||
it("includes the visible host badge with the workspace title", () => {
|
||||
const label = resolveSidebarWorkspaceAccessibilityLabel({
|
||||
workspace: { name: "Investigate search", currentBranch: "fix/search", statusBucket: "done" },
|
||||
workspaceTitleSource: "title",
|
||||
hostBadgeLabel: "Build host",
|
||||
});
|
||||
|
||||
expect(label).toBe("Investigate search, Build host");
|
||||
});
|
||||
|
||||
it("owns every visual row contributor in one accessible label", () => {
|
||||
const label = resolveSidebarWorkspaceAccessibilityLabel({
|
||||
workspace: {
|
||||
name: "Investigate search",
|
||||
currentBranch: "fix/search",
|
||||
statusBucket: "running",
|
||||
},
|
||||
workspaceTitleSource: "branch",
|
||||
leadingProjectName: "Search project",
|
||||
hostBadgeLabel: "Build host",
|
||||
pullRequestLabel: "Pull request 42",
|
||||
scriptLabel: "Scripts available",
|
||||
});
|
||||
|
||||
expect(label).toBe(
|
||||
"Search project, fix/search, Build host, Pull request 42, Scripts available, Working",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the idle status from the workspace label", () => {
|
||||
const label = resolveSidebarWorkspaceAccessibilityLabel({
|
||||
workspace: { name: "Investigate search", currentBranch: "fix/search", statusBucket: "done" },
|
||||
workspaceTitleSource: "title",
|
||||
leadingProjectName: "Search project",
|
||||
hostBadgeLabel: "Build host",
|
||||
});
|
||||
|
||||
expect(label).toBe("Search project, Investigate search, Build host");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { WorkspaceTitleSource } from "@/hooks/use-settings";
|
||||
import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model";
|
||||
|
||||
export function resolveSidebarWorkspacePrimaryLabel(input: {
|
||||
workspace: Pick<SidebarWorkspaceEntry, "name" | "currentBranch">;
|
||||
@@ -10,3 +11,25 @@ export function resolveSidebarWorkspacePrimaryLabel(input: {
|
||||
}
|
||||
return input.workspace.name;
|
||||
}
|
||||
|
||||
export function resolveSidebarWorkspaceAccessibilityLabel(input: {
|
||||
workspace: Pick<SidebarWorkspaceEntry, "name" | "currentBranch" | "statusBucket">;
|
||||
workspaceTitleSource: WorkspaceTitleSource;
|
||||
leadingProjectName?: string | null;
|
||||
hostBadgeLabel?: string | null;
|
||||
pullRequestLabel?: string | null;
|
||||
scriptLabel?: string | null;
|
||||
}): string {
|
||||
return [
|
||||
input.leadingProjectName,
|
||||
resolveSidebarWorkspacePrimaryLabel(input),
|
||||
input.hostBadgeLabel,
|
||||
input.pullRequestLabel,
|
||||
input.scriptLabel,
|
||||
input.workspace.statusBucket === "done"
|
||||
? null
|
||||
: STATUS_BUCKET_LABELS[input.workspace.statusBucket],
|
||||
]
|
||||
.filter((label): label is string => Boolean(label))
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
CircleX,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FileDiff,
|
||||
Folder,
|
||||
GitBranch,
|
||||
Server,
|
||||
@@ -94,12 +95,14 @@ interface WorkspaceHoverCardProps {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
prHint: PrHint | null;
|
||||
isDragging: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function WorkspaceHoverCard({
|
||||
workspace,
|
||||
prHint,
|
||||
isDragging,
|
||||
disabled = false,
|
||||
children,
|
||||
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactNode {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
@@ -109,7 +112,12 @@ export function WorkspaceHoverCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceHoverCardDesktop workspace={workspace} prHint={prHint} isDragging={isDragging}>
|
||||
<WorkspaceHoverCardDesktop
|
||||
workspace={workspace}
|
||||
prHint={prHint}
|
||||
isDragging={isDragging}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</WorkspaceHoverCardDesktop>
|
||||
);
|
||||
@@ -119,6 +127,7 @@ function WorkspaceHoverCardDesktop({
|
||||
workspace,
|
||||
prHint,
|
||||
isDragging,
|
||||
disabled = false,
|
||||
children,
|
||||
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactElement {
|
||||
const triggerRef = useRef<View>(null);
|
||||
@@ -145,10 +154,10 @@ function WorkspaceHoverCardDesktop({
|
||||
const handleTriggerEnter = useCallback(() => {
|
||||
triggerHoveredRef.current = true;
|
||||
clearGraceTimer();
|
||||
if (!isDragging) {
|
||||
if (!isDragging && !disabled) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [clearGraceTimer, isDragging]);
|
||||
}, [clearGraceTimer, disabled, isDragging]);
|
||||
|
||||
const handleTriggerLeave = useCallback(() => {
|
||||
triggerHoveredRef.current = false;
|
||||
@@ -166,13 +175,13 @@ function WorkspaceHoverCardDesktop({
|
||||
onLeaveSafeZone: scheduleClose,
|
||||
});
|
||||
|
||||
// Close when drag starts
|
||||
// Close while another row interaction owns attention.
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
if (isDragging || disabled) {
|
||||
clearGraceTimer();
|
||||
setOpen(false);
|
||||
}
|
||||
}, [isDragging, clearGraceTimer]);
|
||||
}, [clearGraceTimer, disabled, isDragging]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
@@ -285,6 +294,16 @@ function WorkspaceHoverCardContent({
|
||||
{workspace.name}
|
||||
</Text>
|
||||
</View>
|
||||
{prHint ? <PrBadge hint={prHint} style={styles.cardInfoRow} /> : null}
|
||||
{workspace.diffStat ? (
|
||||
<View style={styles.cardInfoRow}>
|
||||
<ThemedFileDiff size={12} uniProps={foregroundMutedColorMapping} />
|
||||
<DiffStat
|
||||
additions={workspace.diffStat.additions}
|
||||
deletions={workspace.diffStat.deletions}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
<HostRow serverId={workspace.serverId} />
|
||||
{workspace.currentBranch ? (
|
||||
<CopyableInfoRow
|
||||
@@ -304,17 +323,6 @@ function WorkspaceHoverCardContent({
|
||||
testID="hover-card-workspace-cwd"
|
||||
/>
|
||||
) : null}
|
||||
{prHint || workspace.diffStat ? (
|
||||
<View style={styles.cardMetaRow}>
|
||||
{workspace.diffStat ? (
|
||||
<DiffStat
|
||||
additions={workspace.diffStat.additions}
|
||||
deletions={workspace.diffStat.deletions}
|
||||
/>
|
||||
) : null}
|
||||
{prHint ? <PrBadge hint={prHint} /> : null}
|
||||
</View>
|
||||
) : null}
|
||||
{prHint?.checks && prHint.checks.length > 0 ? (
|
||||
<>
|
||||
<View style={styles.separator} />
|
||||
@@ -334,6 +342,7 @@ function WorkspaceHoverCardContent({
|
||||
const ThemedGitBranch = withUnistyles(GitBranch);
|
||||
const ThemedFolder = withUnistyles(Folder);
|
||||
const ThemedServer = withUnistyles(Server);
|
||||
const ThemedFileDiff = withUnistyles(FileDiff);
|
||||
|
||||
type CardInfoIcon = React.ComponentType<React.ComponentProps<typeof ThemedGitBranch>>;
|
||||
|
||||
@@ -596,13 +605,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
cardMetaRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
cardInfoRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { WorkspaceShortcutTargetsSubscriber } from "./workspace-shortcut-targets-subscriber";
|
||||
import { SidebarModelProvider } from "./sidebar/sidebar-model";
|
||||
import { defaultHostAppearance } from "@/hosts/appearance";
|
||||
|
||||
vi.hoisted(() => {
|
||||
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||
@@ -51,6 +52,7 @@ function hostProfile(serverId = "srv"): HostProfile {
|
||||
return {
|
||||
serverId,
|
||||
label: "Shortcut Host",
|
||||
appearance: defaultHostAppearance(),
|
||||
lifecycle: {},
|
||||
connections: [],
|
||||
preferredConnectionId: null,
|
||||
|
||||
Reference in New Issue
Block a user