Merge sidebar workspaces across all connected hosts (#1538)
* Merge workspaces across all hosts in the sidebar Replace per-host sidebar sections with a single merged project list. Projects that exist on multiple hosts (matched by projectKey) are collapsed into one row. Host identity is surfaced via: - A status-only footer pill showing online/offline host counts - Hover cards with a mandatory host row on every workspace entry - Subtitles showing the host name when a project spans >1 host - A host filter in the grouping dropdown (not a sidebar switcher) Order and group-mode stores migrated from per-server to global keys with zustand persist migrations. New-workspace screen now shows a merged project list with a host selector combobox. Key files: - workspace-structure.ts: merge algorithm by projectKey - sidebar-view-store.ts / sidebar-order-store.ts: global state - sidebar-workspace-list.tsx: context-based subtitle injection - new-workspace-screen.tsx: host selector in footer row * Add host filtering to the merged sidebar The sidebar now merges workspaces from every connected host, so a single global "active host" no longer fits. Host selection becomes explicit per action via a host chooser, and agent history pages across all hosts at once. Removes the active-host / active-server-id model and the all-agents-list hook that fed it. * Fix rebase fallout: translate archive host error, update stale workspace-key test archiveWorkspacesOptimistically used a raw "Host is not connected" string that the i18n completeness test forbids; route it through i18n.t with the existing hostDisconnected key. The selectors memoization test asserted un-prefixed workspace keys, but merged-sidebar keys are serverId-prefixed. * Fix sidebar review fallout * Address sidebar review followups * Fix sidebar workspace identity migration * Address sidebar review followups * Handle partial host history failures * Fix merged sidebar project removal and E2E drift * Restore history routes and project removal shape * Remove sidebar host context plumbing * Tighten sidebar status and host filter state * Keep new workspace project selection host-compatible * Migrate sidebar view storage * Streamline host selection flows * Update all-host history on archive * Fix empty project sidebar action Code regression: the main merge dropped the empty-project New workspace child row while keeping the empty project persistence contract. Restore the row using the project host target so the sidebar stays aligned with the no-host-selection model. * Fix Japanese shortcut translation key Stale-base integration: main added the Japanese locale after this branch added cycleAgentMode to the English shortcut help map. Merging main exposed the missing ja key in CI typecheck and resources.test. * Fix Playwright speech teardown race
This commit is contained in:
@@ -33,6 +33,7 @@ interface AgentListProps {
|
||||
onAgentSelect?: () => void;
|
||||
listFooterComponent?: ReactElement | null;
|
||||
showAttentionIndicator?: boolean;
|
||||
showHostColumn?: boolean;
|
||||
}
|
||||
|
||||
type DateSectionKey = "today" | "yesterday" | "thisWeek" | "thisMonth" | "older";
|
||||
@@ -152,11 +153,62 @@ function WorkspaceTitlePrefix({
|
||||
);
|
||||
}
|
||||
|
||||
function SessionRowBadges({
|
||||
agent,
|
||||
archivedIcon,
|
||||
pendingPermissionCount,
|
||||
showDesktopAttention,
|
||||
}: {
|
||||
agent: AggregatedAgent;
|
||||
archivedIcon: ReactElement;
|
||||
pendingPermissionCount: number;
|
||||
showDesktopAttention: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{agent.archivedAt ? (
|
||||
<SessionBadge label={t("agentList.badges.archived")} icon={archivedIcon} />
|
||||
) : null}
|
||||
{pendingPermissionCount > 0 ? (
|
||||
<SessionBadge
|
||||
label={t("agentList.badges.pending", { count: pendingPermissionCount })}
|
||||
tone="warning"
|
||||
/>
|
||||
) : null}
|
||||
{showDesktopAttention ? (
|
||||
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionRowTrailingAttention({
|
||||
isMobile,
|
||||
showAttentionIndicator,
|
||||
requiresAttention,
|
||||
}: {
|
||||
isMobile: boolean;
|
||||
showAttentionIndicator: boolean;
|
||||
requiresAttention: boolean | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!isMobile || !showAttentionIndicator || !requiresAttention) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View style={styles.rowTrailing}>
|
||||
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionRow({
|
||||
agent,
|
||||
isMobile,
|
||||
selectedAgentId,
|
||||
showAttentionIndicator,
|
||||
showHostColumn,
|
||||
onPress,
|
||||
onLongPress,
|
||||
}: {
|
||||
@@ -164,6 +216,7 @@ function SessionRow({
|
||||
isMobile: boolean;
|
||||
selectedAgentId?: string;
|
||||
showAttentionIndicator: boolean;
|
||||
showHostColumn: boolean;
|
||||
onPress: (agent: AggregatedAgent) => void;
|
||||
onLongPress: (agent: AggregatedAgent) => void;
|
||||
}) {
|
||||
@@ -200,6 +253,8 @@ function SessionRow({
|
||||
() => <Archive size={theme.fontSize.xs} color={theme.colors.foregroundMuted} />,
|
||||
[theme.fontSize.xs, theme.colors.foregroundMuted],
|
||||
);
|
||||
const showDesktopAttention =
|
||||
!isMobile && showAttentionIndicator && Boolean(agent.requiresAttention);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -223,20 +278,14 @@ function SessionRow({
|
||||
<Text style={sessionTitleStyle} numberOfLines={1}>
|
||||
{agent.title || t("agentList.fallbackTitle")}
|
||||
</Text>
|
||||
{agent.archivedAt ? (
|
||||
<SessionBadge label={t("agentList.badges.archived")} icon={archivedIcon} />
|
||||
) : null}
|
||||
{pendingPermissionCount > 0 ? (
|
||||
<SessionBadge
|
||||
label={t("agentList.badges.pending", { count: pendingPermissionCount })}
|
||||
tone="warning"
|
||||
/>
|
||||
) : null}
|
||||
{!isMobile && showAttentionIndicator && agent.requiresAttention ? (
|
||||
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
|
||||
) : null}
|
||||
<SessionRowBadges
|
||||
agent={agent}
|
||||
archivedIcon={archivedIcon}
|
||||
pendingPermissionCount={pendingPermissionCount}
|
||||
showDesktopAttention={showDesktopAttention}
|
||||
/>
|
||||
</View>
|
||||
{isMobile && (
|
||||
{isMobile ? (
|
||||
<View style={styles.rowMetaRow}>
|
||||
<Text
|
||||
style={styles.sessionMetaText}
|
||||
@@ -263,10 +312,18 @@ function SessionRow({
|
||||
</Text>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text style={styles.sessionMetaText}>{timeAgo}</Text>
|
||||
{showHostColumn && agent.serverLabel ? (
|
||||
<>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text style={styles.sessionMetaText} numberOfLines={1}>
|
||||
{agent.serverLabel}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
{!isMobile && (
|
||||
{!isMobile ? (
|
||||
<View style={styles.rowColumns}>
|
||||
<Text
|
||||
style={styles.columnMeta}
|
||||
@@ -275,6 +332,11 @@ function SessionRow({
|
||||
>
|
||||
{projectName}
|
||||
</Text>
|
||||
{showHostColumn ? (
|
||||
<Text style={styles.columnMetaHost} numberOfLines={1}>
|
||||
{agent.serverLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
style={styles.columnMeta}
|
||||
numberOfLines={1}
|
||||
@@ -286,12 +348,12 @@ function SessionRow({
|
||||
{timeAgo}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{isMobile && showAttentionIndicator && agent.requiresAttention ? (
|
||||
<View style={styles.rowTrailing}>
|
||||
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
|
||||
</View>
|
||||
) : null}
|
||||
<SessionRowTrailingAttention
|
||||
isMobile={isMobile}
|
||||
showAttentionIndicator={showAttentionIndicator}
|
||||
requiresAttention={agent.requiresAttention}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -304,6 +366,7 @@ export function AgentList({
|
||||
onAgentSelect,
|
||||
listFooterComponent,
|
||||
showAttentionIndicator = true,
|
||||
showHostColumn = false,
|
||||
}: AgentListProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
@@ -428,12 +491,21 @@ export function AgentList({
|
||||
isMobile={isMobile}
|
||||
selectedAgentId={selectedAgentId}
|
||||
showAttentionIndicator={showAttentionIndicator}
|
||||
showHostColumn={showHostColumn}
|
||||
onPress={handleAgentPress}
|
||||
onLongPress={handleAgentLongPress}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator, t],
|
||||
[
|
||||
handleAgentLongPress,
|
||||
handleAgentPress,
|
||||
isMobile,
|
||||
selectedAgentId,
|
||||
showAttentionIndicator,
|
||||
showHostColumn,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: FlatListItem) => item.key, []);
|
||||
@@ -640,6 +712,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
width: 72,
|
||||
textAlign: "right" as const,
|
||||
},
|
||||
columnMetaHost: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 0,
|
||||
width: 120,
|
||||
marginLeft: theme.spacing[4],
|
||||
textAlign: "right" as const,
|
||||
},
|
||||
badge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
type HostRuntimeConnectionStatus,
|
||||
useHostRuntimeConnectionStatus,
|
||||
} from "@/runtime/host-runtime";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
function hostStatusDotColor(status: HostRuntimeConnectionStatus, theme: Theme) {
|
||||
if (status === "online") return theme.colors.palette.green[400];
|
||||
if (status === "connecting") return theme.colors.palette.amber[500];
|
||||
return theme.colors.palette.red[500];
|
||||
}
|
||||
|
||||
export function HostStatusDot({ serverId }: { serverId: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
const status = useHostRuntimeConnectionStatus(serverId);
|
||||
const backgroundColor = hostStatusDotColor(status, theme);
|
||||
const dotStyle = useMemo(() => [styles.dot, { backgroundColor }], [backgroundColor]);
|
||||
|
||||
return <View style={dotStyle} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,19 @@
|
||||
export const ADD_HOST_OPTION_ID = "__add_host__";
|
||||
export const ALL_HOSTS_OPTION_ID = "__all_hosts__";
|
||||
|
||||
export function getHostPickerLabel(
|
||||
hosts: Array<{ label: string; serverId: string }>,
|
||||
value: string,
|
||||
config?: { includeAllHost?: boolean; includeAddHost?: boolean },
|
||||
): string {
|
||||
if (config?.includeAllHost && value === ALL_HOSTS_OPTION_ID) {
|
||||
return "All hosts";
|
||||
}
|
||||
if (config?.includeAddHost && value === ADD_HOST_OPTION_ID) {
|
||||
return "Add host";
|
||||
}
|
||||
return (
|
||||
hosts.find((host) => host.serverId === value)?.label ??
|
||||
(config?.includeAllHost ? "All hosts" : "Host")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
ADD_HOST_OPTION_ID,
|
||||
ALL_HOSTS_OPTION_ID,
|
||||
getHostPickerLabel,
|
||||
} from "./host-picker-constants";
|
||||
|
||||
const hosts = [{ serverId: "host-a", label: "Host A" }];
|
||||
|
||||
it.each([
|
||||
["host-a", undefined, "Host A"],
|
||||
["missing", undefined, "Host"],
|
||||
[ALL_HOSTS_OPTION_ID, { includeAllHost: true }, "All hosts"],
|
||||
["missing", { includeAllHost: true }, "All hosts"],
|
||||
[ALL_HOSTS_OPTION_ID, undefined, "Host"],
|
||||
[ADD_HOST_OPTION_ID, { includeAddHost: true }, "Add host"],
|
||||
])("resolves %s", (value, config, expected) => {
|
||||
expect(getHostPickerLabel(hosts, value, config)).toBe(expected);
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import type { GestureResponderEvent } from "react-native";
|
||||
import { Plus, Server, Settings } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { HostStatusDot } from "@/components/host-status-dot";
|
||||
import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/combobox";
|
||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||
import { orderHostsLocalFirst } from "@/types/host-connection";
|
||||
import {
|
||||
ADD_HOST_OPTION_ID,
|
||||
ALL_HOSTS_OPTION_ID,
|
||||
getHostPickerLabel,
|
||||
} from "./host-picker-constants";
|
||||
|
||||
export { ADD_HOST_OPTION_ID, ALL_HOSTS_OPTION_ID, getHostPickerLabel };
|
||||
|
||||
const SEARCHABLE_THRESHOLD = 10;
|
||||
type RenderHostOption = NonNullable<ComboboxProps["renderOption"]>;
|
||||
interface HostPickerHost {
|
||||
serverId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function HostStatusDotSlot({ serverId }: { serverId: string }): ReactElement {
|
||||
return (
|
||||
<View style={styles.statusDotSlot}>
|
||||
<HostStatusDot serverId={serverId} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export interface HostPickerOptionProps {
|
||||
serverId: string;
|
||||
label: string;
|
||||
isLocal: boolean;
|
||||
selected?: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
onOpenHostSettings?: (serverId: string) => void;
|
||||
localMarkerTestID?: string;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
export function HostPickerOption({
|
||||
serverId,
|
||||
label,
|
||||
isLocal,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
onOpenHostSettings,
|
||||
localMarkerTestID,
|
||||
testID,
|
||||
}: HostPickerOptionProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const leadingSlot = useMemo(() => <HostStatusDotSlot serverId={serverId} />, [serverId]);
|
||||
const handleSettingsPress = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
onOpenHostSettings?.(serverId);
|
||||
},
|
||||
[onOpenHostSettings, serverId],
|
||||
);
|
||||
const trailingSlot = useMemo(() => {
|
||||
if (!isLocal && !onOpenHostSettings) return undefined;
|
||||
return (
|
||||
<>
|
||||
{isLocal ? (
|
||||
<Text style={styles.localMarker} testID={localMarkerTestID}>
|
||||
Local
|
||||
</Text>
|
||||
) : null}
|
||||
{onOpenHostSettings ? (
|
||||
<Pressable
|
||||
onPress={handleSettingsPress}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open ${label} settings`}
|
||||
>
|
||||
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}, [
|
||||
handleSettingsPress,
|
||||
isLocal,
|
||||
label,
|
||||
localMarkerTestID,
|
||||
onOpenHostSettings,
|
||||
theme.colors.foregroundMuted,
|
||||
theme.iconSize.sm,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ComboboxItem
|
||||
label={label}
|
||||
leadingSlot={leadingSlot}
|
||||
trailingSlot={trailingSlot}
|
||||
selected={selected}
|
||||
active={active}
|
||||
interactiveFeedback={false}
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemHostPickerOption({
|
||||
active,
|
||||
selected,
|
||||
onPress,
|
||||
kind,
|
||||
testID,
|
||||
}: {
|
||||
active: boolean;
|
||||
selected?: boolean;
|
||||
onPress: () => void;
|
||||
kind: "add" | "all";
|
||||
testID?: string;
|
||||
}): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const Icon = kind === "add" ? Plus : Server;
|
||||
const label = kind === "add" ? "Add host" : "All hosts";
|
||||
const leadingSlot = useMemo(
|
||||
() => <Icon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||
[Icon, theme.colors.foregroundMuted, theme.iconSize.sm],
|
||||
);
|
||||
|
||||
return (
|
||||
<ComboboxItem
|
||||
label={label}
|
||||
leadingSlot={leadingSlot}
|
||||
selected={selected}
|
||||
active={active}
|
||||
interactiveFeedback={false}
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface HostPickerProps {
|
||||
hosts: HostPickerHost[];
|
||||
value: string;
|
||||
onSelect: (id: string) => void;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
anchorRef: React.RefObject<View | null>;
|
||||
includeAllHost?: boolean;
|
||||
includeAddHost?: boolean;
|
||||
onAddHost?: () => void;
|
||||
showLocalMarker?: boolean;
|
||||
onOpenHostSettings?: (serverId: string) => void;
|
||||
searchable?: boolean;
|
||||
title?: string;
|
||||
desktopPlacement?: "top-start" | "bottom-start";
|
||||
desktopMinWidth?: number;
|
||||
addHostTestID?: string;
|
||||
hostOptionTestID?: (serverId: string) => string;
|
||||
hostLocalMarkerTestID?: (serverId: string) => string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function HostPicker({
|
||||
hosts,
|
||||
value,
|
||||
onSelect,
|
||||
open,
|
||||
onOpenChange,
|
||||
anchorRef,
|
||||
includeAllHost,
|
||||
includeAddHost,
|
||||
onAddHost,
|
||||
showLocalMarker,
|
||||
onOpenHostSettings,
|
||||
searchable,
|
||||
title,
|
||||
desktopPlacement = "top-start",
|
||||
desktopMinWidth,
|
||||
addHostTestID,
|
||||
hostOptionTestID,
|
||||
hostLocalMarkerTestID,
|
||||
children,
|
||||
}: HostPickerProps): ReactElement {
|
||||
const localServerId = useLocalDaemonServerId();
|
||||
const orderedHosts = useMemo(
|
||||
() => orderHostsLocalFirst(hosts, localServerId),
|
||||
[hosts, localServerId],
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const hostOptions = orderedHosts.map((host) => ({ id: host.serverId, label: host.label }));
|
||||
if (includeAllHost) hostOptions.unshift({ id: ALL_HOSTS_OPTION_ID, label: "All hosts" });
|
||||
if (includeAddHost) hostOptions.push({ id: ADD_HOST_OPTION_ID, label: "Add host" });
|
||||
return hostOptions;
|
||||
}, [orderedHosts, includeAllHost, includeAddHost]);
|
||||
|
||||
const isSearchable = searchable === true && orderedHosts.length > SEARCHABLE_THRESHOLD;
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
if (id === ADD_HOST_OPTION_ID) {
|
||||
onAddHost?.();
|
||||
} else {
|
||||
onSelect(id);
|
||||
}
|
||||
onOpenChange(false);
|
||||
},
|
||||
[onAddHost, onOpenChange, onSelect],
|
||||
);
|
||||
|
||||
const handleOpenHostSettings = useCallback(
|
||||
(serverId: string) => {
|
||||
onOpenHostSettings?.(serverId);
|
||||
onOpenChange(false);
|
||||
},
|
||||
[onOpenHostSettings, onOpenChange],
|
||||
);
|
||||
|
||||
const renderOption = useCallback<RenderHostOption>(
|
||||
({ option, selected, active, onPress }) => {
|
||||
if (option.id === ADD_HOST_OPTION_ID) {
|
||||
return (
|
||||
<SystemHostPickerOption
|
||||
kind="add"
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
testID={addHostTestID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (option.id === ALL_HOSTS_OPTION_ID) {
|
||||
return (
|
||||
<SystemHostPickerOption
|
||||
kind="all"
|
||||
active={active}
|
||||
selected={selected}
|
||||
onPress={onPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<HostPickerOption
|
||||
serverId={option.id}
|
||||
label={option.label}
|
||||
isLocal={showLocalMarker === true && localServerId === option.id}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
onOpenHostSettings={onOpenHostSettings ? handleOpenHostSettings : undefined}
|
||||
localMarkerTestID={hostLocalMarkerTestID?.(option.id)}
|
||||
testID={hostOptionTestID?.(option.id)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[
|
||||
addHostTestID,
|
||||
hostLocalMarkerTestID,
|
||||
hostOptionTestID,
|
||||
localServerId,
|
||||
onOpenHostSettings,
|
||||
showLocalMarker,
|
||||
handleOpenHostSettings,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<Combobox
|
||||
options={options}
|
||||
value={value}
|
||||
onSelect={handleSelect}
|
||||
renderOption={renderOption}
|
||||
searchable={isSearchable}
|
||||
searchPlaceholder="Search hosts"
|
||||
title={title ?? "Host"}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
anchorRef={anchorRef}
|
||||
desktopPlacement={desktopPlacement}
|
||||
desktopMinWidth={desktopMinWidth}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
statusDotSlot: {
|
||||
width: theme.iconSize.sm,
|
||||
height: theme.iconSize.sm,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
localMarker: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
}));
|
||||
@@ -1,18 +1,7 @@
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { FolderPlus, History, Home, Plus, Search, Settings, X } from "lucide-react-native";
|
||||
import { FolderPlus, History, Home, Plus, Search, Server, Settings, X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
type Dispatch,
|
||||
memo,
|
||||
type ReactElement,
|
||||
type RefObject,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
StyleSheet as RNStyleSheet,
|
||||
@@ -20,8 +9,6 @@ import {
|
||||
useWindowDimensions,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
@@ -34,40 +21,42 @@ import Animated, {
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { HostPicker } from "@/components/hosts/host-picker";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/sidebar-display-preferences-menu";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { useHostChooser } from "@/hosts/host-chooser";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
||||
import {
|
||||
type SidebarProjectEntry,
|
||||
type SidebarStatusWorkspacePlacement,
|
||||
useSidebarWorkspacesList,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
|
||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import {
|
||||
MAX_SIDEBAR_WIDTH,
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
selectIsAgentListOpen,
|
||||
usePanelStore,
|
||||
} from "@/stores/panel-store";
|
||||
import { resolveActiveHost } from "@/utils/active-host";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||
import {
|
||||
buildHostOpenProjectRoute,
|
||||
buildOpenProjectRoute,
|
||||
buildHostNewWorkspaceRoute,
|
||||
buildHostSessionsRoute,
|
||||
buildSessionsRoute,
|
||||
buildSettingsAddHostRoute,
|
||||
buildSettingsHostSectionRoute,
|
||||
buildSettingsRoute,
|
||||
mapPathnameToServer,
|
||||
} from "@/utils/host-routes";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
@@ -85,14 +74,9 @@ interface LeftSidebarProps {
|
||||
|
||||
interface SidebarSharedProps {
|
||||
theme: SidebarTheme;
|
||||
activeServerId: string | null;
|
||||
activeHostLabel: string;
|
||||
activeHostStatusColor: string;
|
||||
hostOptions: ComboboxOption[];
|
||||
hostTriggerRef: RefObject<View | null>;
|
||||
isHostPickerOpen: boolean;
|
||||
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
|
||||
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
|
||||
projects: SidebarProjectEntry[];
|
||||
projectNamesByKey: Map<string, string>;
|
||||
isInitialLoad: boolean;
|
||||
isRevalidating: boolean;
|
||||
isManualRefresh: boolean;
|
||||
@@ -101,19 +85,14 @@ interface SidebarSharedProps {
|
||||
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
|
||||
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
|
||||
handleRefresh: () => void;
|
||||
handleHostSelect: (nextServerId: string) => void;
|
||||
handleNewWorkspaceNavigate: () => void;
|
||||
handleOpenProject: () => void;
|
||||
handleHome: () => void;
|
||||
handleSettings: () => void;
|
||||
labels: SidebarLabels;
|
||||
renderHostOption: (input: {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) => ReactElement;
|
||||
newWorkspaceKeys: ShortcutKey[][] | null;
|
||||
handleAddHost: () => void;
|
||||
handleOpenHostSettings: (serverId: string) => void;
|
||||
}
|
||||
|
||||
interface SidebarLabels {
|
||||
@@ -154,70 +133,24 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
||||
);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const pathname = usePathname();
|
||||
const daemons = useHosts();
|
||||
const activeDaemon = useMemo(
|
||||
() => resolveActiveHost({ hosts: daemons, pathname }),
|
||||
[daemons, pathname],
|
||||
);
|
||||
const activeServerId = activeDaemon?.serverId ?? null;
|
||||
const activeHostLabel = useMemo(() => {
|
||||
if (!activeDaemon) return t("sidebar.host.noHost");
|
||||
const trimmed = activeDaemon.label?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId;
|
||||
}, [activeDaemon, t]);
|
||||
const activeHostSnapshot = useHostRuntimeSnapshot(activeServerId ?? "");
|
||||
const activeHostStatus = activeServerId
|
||||
? (activeHostSnapshot?.connectionStatus ?? "connecting")
|
||||
: "idle";
|
||||
let activeHostStatusColor: string;
|
||||
if (activeHostStatus === "online") activeHostStatusColor = theme.colors.palette.green[400];
|
||||
else if (activeHostStatus === "connecting")
|
||||
activeHostStatusColor = theme.colors.palette.amber[500];
|
||||
else activeHostStatusColor = theme.colors.palette.red[500];
|
||||
const hostOptions = useMemo(
|
||||
() =>
|
||||
daemons.map((daemon) => ({
|
||||
id: daemon.serverId,
|
||||
label: daemon.label?.trim() || daemon.serverId,
|
||||
})),
|
||||
[daemons],
|
||||
);
|
||||
const renderHostOption = useCallback(
|
||||
({
|
||||
option,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
}: {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) => (
|
||||
<HostSwitchOption
|
||||
serverId={option.id}
|
||||
label={option.label}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
const hostTriggerRef = useRef<View | null>(null);
|
||||
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
||||
|
||||
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
||||
serverId: activeServerId,
|
||||
const {
|
||||
workspacePlacements,
|
||||
projects,
|
||||
projectNamesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
refreshAll,
|
||||
} = useSidebarWorkspacesList({
|
||||
enabled: isCompactLayout || isOpen,
|
||||
});
|
||||
const statusWorkspacePlacements = useStatusModeWorkspacePlacements({
|
||||
placements: workspacePlacements,
|
||||
});
|
||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
||||
useSidebarShortcutModel({ projects });
|
||||
|
||||
const groupMode = useSidebarViewStore((state) =>
|
||||
activeServerId ? state.getGroupMode(activeServerId) : "project",
|
||||
);
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
|
||||
@@ -232,7 +165,8 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
}
|
||||
}, [isRevalidating, isManualRefresh]);
|
||||
|
||||
const openProjectPicker = useOpenProjectPicker(activeServerId);
|
||||
const openProjectPicker = useOpenProjectPicker();
|
||||
const chooseHost = useHostChooser();
|
||||
|
||||
const handleOpenProjectMobile = useCallback(() => {
|
||||
showMobileAgent();
|
||||
@@ -244,9 +178,13 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
}, [openProjectPicker]);
|
||||
|
||||
const handleNewWorkspaceNavigate = useCallback(() => {
|
||||
if (!activeServerId) return;
|
||||
router.navigate(buildHostNewWorkspaceRoute(activeServerId));
|
||||
}, [activeServerId]);
|
||||
chooseHost({
|
||||
title: "Choose host",
|
||||
onChooseHost: (serverId) => {
|
||||
router.push(buildHostNewWorkspaceRoute(serverId));
|
||||
},
|
||||
});
|
||||
}, [chooseHost]);
|
||||
|
||||
const handleSettingsMobile = useCallback(() => {
|
||||
showMobileAgent();
|
||||
@@ -257,38 +195,41 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
router.push(buildSettingsRoute());
|
||||
}, []);
|
||||
|
||||
const handleHomeMobile = useCallback(() => {
|
||||
if (!activeServerId) return;
|
||||
const handleAddHostMobile = useCallback(() => {
|
||||
showMobileAgent();
|
||||
router.push(buildHostOpenProjectRoute(activeServerId));
|
||||
}, [activeServerId, showMobileAgent]);
|
||||
router.push(buildSettingsAddHostRoute(Date.now()));
|
||||
}, [showMobileAgent]);
|
||||
|
||||
const handleHomeDesktop = useCallback(() => {
|
||||
if (!activeServerId) return;
|
||||
router.push(buildHostOpenProjectRoute(activeServerId));
|
||||
}, [activeServerId]);
|
||||
const handleAddHostDesktop = useCallback(() => {
|
||||
router.push(buildSettingsAddHostRoute(Date.now()));
|
||||
}, []);
|
||||
|
||||
const handleViewMoreNavigate = useCallback(() => {
|
||||
if (!activeServerId) {
|
||||
return;
|
||||
}
|
||||
router.push(buildHostSessionsRoute(activeServerId));
|
||||
}, [activeServerId]);
|
||||
|
||||
const handleHostSelect = useCallback(
|
||||
(nextServerId: string) => {
|
||||
if (!nextServerId) {
|
||||
return;
|
||||
}
|
||||
const nextPath = mapPathnameToServer(pathname, nextServerId);
|
||||
setIsHostPickerOpen(false);
|
||||
router.push(nextPath);
|
||||
const handleOpenHostSettingsMobile = useCallback(
|
||||
(serverId: string) => {
|
||||
showMobileAgent();
|
||||
router.push(buildSettingsHostSectionRoute(serverId, "connections"));
|
||||
},
|
||||
[pathname],
|
||||
[showMobileAgent],
|
||||
);
|
||||
|
||||
const newWorkspaceKeys = useShortcutKeys("new-workspace");
|
||||
const handleOpenHostSettingsDesktop = useCallback((serverId: string) => {
|
||||
router.push(buildSettingsHostSectionRoute(serverId, "connections"));
|
||||
}, []);
|
||||
|
||||
const handleHomeMobile = useCallback(() => {
|
||||
showMobileAgent();
|
||||
router.push(buildOpenProjectRoute());
|
||||
}, [showMobileAgent]);
|
||||
|
||||
const handleHomeDesktop = useCallback(() => {
|
||||
router.push(buildOpenProjectRoute());
|
||||
}, []);
|
||||
|
||||
const handleViewMoreNavigate = useCallback(() => {
|
||||
router.push(buildSessionsRoute());
|
||||
}, []);
|
||||
|
||||
const newWorkspaceKeys = useShortcutKeys("new-workspace");
|
||||
const labels = useMemo(
|
||||
(): SidebarLabels => ({
|
||||
addProject: t("sidebar.actions.addProject"),
|
||||
@@ -305,14 +246,9 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
|
||||
const sharedProps = {
|
||||
theme,
|
||||
activeServerId,
|
||||
activeHostLabel,
|
||||
activeHostStatusColor,
|
||||
hostOptions,
|
||||
hostTriggerRef,
|
||||
isHostPickerOpen,
|
||||
setIsHostPickerOpen,
|
||||
statusWorkspacePlacements,
|
||||
projects,
|
||||
projectNamesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
isManualRefresh,
|
||||
@@ -321,8 +257,6 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
shortcutIndexByWorkspaceKey,
|
||||
toggleProjectCollapsed,
|
||||
handleRefresh,
|
||||
handleHostSelect,
|
||||
renderHostOption,
|
||||
labels,
|
||||
newWorkspaceKeys,
|
||||
};
|
||||
@@ -339,6 +273,8 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
handleOpenProject={handleOpenProjectMobile}
|
||||
handleHome={handleHomeMobile}
|
||||
handleSettings={handleSettingsMobile}
|
||||
handleAddHost={handleAddHostMobile}
|
||||
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
||||
handleViewMoreNavigate={handleViewMoreNavigate}
|
||||
/>
|
||||
);
|
||||
@@ -353,91 +289,41 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
handleOpenProject={handleOpenProjectDesktop}
|
||||
handleHome={handleHomeDesktop}
|
||||
handleSettings={handleSettingsDesktop}
|
||||
handleAddHost={handleAddHostDesktop}
|
||||
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
||||
handleViewMore={handleViewMoreNavigate}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
interface HostPickerTriggerProps {
|
||||
triggerRef: React.Ref<View>;
|
||||
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
|
||||
hostOptionsEmpty: boolean;
|
||||
hostStatusDotStyle: StyleProp<ViewStyle>;
|
||||
activeHostLabel: string;
|
||||
function sidebarHostOptionTestID(serverId: string): string {
|
||||
return `sidebar-host-row-${serverId}`;
|
||||
}
|
||||
|
||||
function HostPickerTrigger({
|
||||
triggerRef,
|
||||
setIsHostPickerOpen,
|
||||
hostOptionsEmpty,
|
||||
hostStatusDotStyle,
|
||||
activeHostLabel,
|
||||
}: HostPickerTriggerProps) {
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.hostTrigger,
|
||||
hovered && styles.hostTriggerHovered,
|
||||
],
|
||||
[],
|
||||
);
|
||||
const handlePress = useCallback(() => setIsHostPickerOpen(true), [setIsHostPickerOpen]);
|
||||
return (
|
||||
<Pressable
|
||||
ref={triggerRef}
|
||||
style={pressableStyle}
|
||||
onPress={handlePress}
|
||||
disabled={hostOptionsEmpty}
|
||||
>
|
||||
<View style={hostStatusDotStyle} />
|
||||
<Text style={styles.hostTriggerText} numberOfLines={1}>
|
||||
{activeHostLabel}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function HostSwitchOption({
|
||||
serverId,
|
||||
label,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
}: {
|
||||
serverId: string;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const snapshot = useHostRuntimeSnapshot(serverId);
|
||||
const connectionStatus = snapshot?.connectionStatus ?? "connecting";
|
||||
|
||||
return (
|
||||
<ComboboxItem
|
||||
label={label}
|
||||
description={formatConnectionStatus(connectionStatus)}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
/>
|
||||
);
|
||||
function sidebarHostLocalMarkerTestID(serverId: string): string {
|
||||
return `sidebar-host-local-marker-${serverId}`;
|
||||
}
|
||||
|
||||
function FooterIconButton({
|
||||
buttonRef,
|
||||
onPress,
|
||||
testID,
|
||||
accessibilityLabel,
|
||||
icon: Icon,
|
||||
iconSize,
|
||||
theme,
|
||||
}: {
|
||||
onPress: () => void;
|
||||
testID: string;
|
||||
accessibilityLabel: string;
|
||||
icon: typeof FolderPlus;
|
||||
iconSize?: number;
|
||||
theme: SidebarTheme;
|
||||
buttonRef?: RefObject<View | null>;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
ref={buttonRef}
|
||||
style={styles.footerIconButton}
|
||||
testID={testID}
|
||||
nativeID={testID}
|
||||
@@ -449,7 +335,7 @@ function FooterIconButton({
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Icon
|
||||
size={theme.iconSize.md}
|
||||
size={iconSize ?? theme.iconSize.md}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
@@ -457,6 +343,59 @@ function FooterIconButton({
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarHostPicker({
|
||||
theme,
|
||||
onAddHost,
|
||||
onOpenHostSettings,
|
||||
}: {
|
||||
theme: SidebarTheme;
|
||||
onAddHost: () => void;
|
||||
onOpenHostSettings: (serverId: string) => void;
|
||||
}) {
|
||||
const hosts = useHosts();
|
||||
const triggerRef = useRef<View | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
onOpenHostSettings(id);
|
||||
},
|
||||
[onOpenHostSettings],
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||
|
||||
return (
|
||||
<HostPicker
|
||||
hosts={hosts}
|
||||
value=""
|
||||
onSelect={handleSelect}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
anchorRef={triggerRef}
|
||||
includeAddHost
|
||||
onAddHost={onAddHost}
|
||||
showLocalMarker
|
||||
onOpenHostSettings={onOpenHostSettings}
|
||||
searchable
|
||||
desktopMinWidth={240}
|
||||
addHostTestID="sidebar-host-add"
|
||||
hostOptionTestID={sidebarHostOptionTestID}
|
||||
hostLocalMarkerTestID={sidebarHostLocalMarkerTestID}
|
||||
>
|
||||
<FooterIconButton
|
||||
buttonRef={triggerRef}
|
||||
onPress={handleOpen}
|
||||
testID="sidebar-hosts-trigger"
|
||||
accessibilityLabel="Hosts"
|
||||
icon={Server}
|
||||
iconSize={theme.iconSize.sm}
|
||||
theme={theme}
|
||||
/>
|
||||
</HostPicker>
|
||||
);
|
||||
}
|
||||
|
||||
function AddProjectTooltipContent({
|
||||
newAgentKeys,
|
||||
label,
|
||||
@@ -489,30 +428,14 @@ function HeaderIconTooltipContent({
|
||||
|
||||
function SidebarFooter({
|
||||
theme,
|
||||
activeServerId,
|
||||
activeHostLabel,
|
||||
hostStatusDotStyle,
|
||||
hostOptions,
|
||||
hostTriggerRef,
|
||||
isHostPickerOpen,
|
||||
setIsHostPickerOpen,
|
||||
handleHostSelect,
|
||||
renderHostOption,
|
||||
handleOpenProject,
|
||||
handleHome,
|
||||
handleSettings,
|
||||
labels,
|
||||
handleAddHost,
|
||||
handleOpenHostSettings,
|
||||
}: {
|
||||
theme: SidebarTheme;
|
||||
activeServerId: string | null;
|
||||
activeHostLabel: string;
|
||||
hostStatusDotStyle: StyleProp<ViewStyle>;
|
||||
hostOptions: ComboboxOption[];
|
||||
hostTriggerRef: RefObject<View | null>;
|
||||
isHostPickerOpen: boolean;
|
||||
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
|
||||
handleHostSelect: (nextServerId: string) => void;
|
||||
renderHostOption: SidebarSharedProps["renderHostOption"];
|
||||
handleOpenProject: () => void;
|
||||
handleHome: () => void;
|
||||
handleSettings: () => void;
|
||||
@@ -523,20 +446,19 @@ function SidebarFooter({
|
||||
switchHost: string;
|
||||
searchHosts: string;
|
||||
};
|
||||
handleAddHost: () => void;
|
||||
handleOpenHostSettings: (serverId: string) => void;
|
||||
}) {
|
||||
const newAgentKeys = useShortcutKeys("new-agent");
|
||||
|
||||
return (
|
||||
<View style={styles.sidebarFooter}>
|
||||
<View style={styles.footerHostSlot}>
|
||||
<HostPickerTrigger
|
||||
triggerRef={hostTriggerRef}
|
||||
setIsHostPickerOpen={setIsHostPickerOpen}
|
||||
hostOptionsEmpty={hostOptions.length === 0}
|
||||
hostStatusDotStyle={hostStatusDotStyle}
|
||||
activeHostLabel={activeHostLabel}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.footerIconRow}>
|
||||
<SidebarHostPicker
|
||||
theme={theme}
|
||||
onAddHost={handleAddHost}
|
||||
onOpenHostSettings={handleOpenHostSettings}
|
||||
/>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<FooterIconButton
|
||||
@@ -566,33 +488,15 @@ function SidebarFooter({
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
<Combobox
|
||||
options={hostOptions}
|
||||
value={activeServerId ?? ""}
|
||||
onSelect={handleHostSelect}
|
||||
renderOption={renderHostOption}
|
||||
searchable={false}
|
||||
title={labels.switchHost}
|
||||
searchPlaceholder={labels.searchHosts}
|
||||
desktopMinWidth={280}
|
||||
open={isHostPickerOpen}
|
||||
onOpenChange={setIsHostPickerOpen}
|
||||
anchorRef={hostTriggerRef}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileSidebar({
|
||||
theme,
|
||||
activeServerId,
|
||||
activeHostLabel,
|
||||
activeHostStatusColor,
|
||||
hostOptions,
|
||||
hostTriggerRef,
|
||||
isHostPickerOpen,
|
||||
setIsHostPickerOpen,
|
||||
statusWorkspacePlacements,
|
||||
projects,
|
||||
projectNamesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
isManualRefresh,
|
||||
@@ -601,14 +505,14 @@ function MobileSidebar({
|
||||
shortcutIndexByWorkspaceKey,
|
||||
toggleProjectCollapsed,
|
||||
handleRefresh,
|
||||
handleHostSelect,
|
||||
renderHostOption,
|
||||
newWorkspaceKeys,
|
||||
handleNewWorkspaceNavigate,
|
||||
handleOpenProject,
|
||||
handleHome,
|
||||
handleSettings,
|
||||
labels,
|
||||
handleAddHost,
|
||||
handleOpenHostSettings,
|
||||
insetsTop,
|
||||
insetsBottom,
|
||||
isOpen,
|
||||
@@ -638,21 +542,11 @@ function MobileSidebar({
|
||||
}, [closeSidebar, gestureAnimatingRef]);
|
||||
|
||||
const handleViewMore = useCallback(() => {
|
||||
if (!activeServerId) {
|
||||
return;
|
||||
}
|
||||
translateX.value = -windowWidth;
|
||||
backdropOpacity.value = 0;
|
||||
closeSidebar();
|
||||
handleViewMoreNavigate();
|
||||
}, [
|
||||
activeServerId,
|
||||
backdropOpacity,
|
||||
closeSidebar,
|
||||
handleViewMoreNavigate,
|
||||
translateX,
|
||||
windowWidth,
|
||||
]);
|
||||
}, [backdropOpacity, closeSidebar, handleViewMoreNavigate, translateX, windowWidth]);
|
||||
|
||||
const handleWorkspacePress = useCallback(() => {
|
||||
closeSidebar();
|
||||
@@ -752,11 +646,6 @@ function MobileSidebar({
|
||||
[windowWidth, insetsTop, insetsBottom],
|
||||
);
|
||||
|
||||
const hostStatusDotStyle = useMemo(
|
||||
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
|
||||
[activeHostStatusColor],
|
||||
);
|
||||
|
||||
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: translateX.value }],
|
||||
}));
|
||||
@@ -826,7 +715,7 @@ function MobileSidebar({
|
||||
variant="compact"
|
||||
/>
|
||||
</View>
|
||||
<WorkspacesSectionHeader serverId={activeServerId} />
|
||||
<WorkspacesSectionHeader />
|
||||
<Pressable
|
||||
style={styles.mobileCloseButton}
|
||||
onPress={closeSidebar}
|
||||
@@ -851,12 +740,13 @@ function MobileSidebar({
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
serverId={activeServerId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
statusWorkspacePlacements={statusWorkspacePlacements}
|
||||
projects={projects}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onWorkspacePress={handleWorkspacePress}
|
||||
@@ -867,19 +757,12 @@ function MobileSidebar({
|
||||
|
||||
<SidebarFooter
|
||||
theme={theme}
|
||||
activeServerId={activeServerId}
|
||||
activeHostLabel={activeHostLabel}
|
||||
hostStatusDotStyle={hostStatusDotStyle}
|
||||
hostOptions={hostOptions}
|
||||
hostTriggerRef={hostTriggerRef}
|
||||
isHostPickerOpen={isHostPickerOpen}
|
||||
setIsHostPickerOpen={setIsHostPickerOpen}
|
||||
handleHostSelect={handleHostSelect}
|
||||
renderHostOption={renderHostOption}
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleHome={handleHome}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
handleAddHost={handleAddHost}
|
||||
handleOpenHostSettings={handleOpenHostSettings}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
@@ -890,14 +773,9 @@ function MobileSidebar({
|
||||
|
||||
function DesktopSidebar({
|
||||
theme,
|
||||
activeServerId,
|
||||
activeHostLabel,
|
||||
activeHostStatusColor,
|
||||
hostOptions,
|
||||
hostTriggerRef,
|
||||
isHostPickerOpen,
|
||||
setIsHostPickerOpen,
|
||||
statusWorkspacePlacements,
|
||||
projects,
|
||||
projectNamesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
isManualRefresh,
|
||||
@@ -906,14 +784,14 @@ function DesktopSidebar({
|
||||
shortcutIndexByWorkspaceKey,
|
||||
toggleProjectCollapsed,
|
||||
handleRefresh,
|
||||
handleHostSelect,
|
||||
renderHostOption,
|
||||
newWorkspaceKeys,
|
||||
handleNewWorkspaceNavigate,
|
||||
handleOpenProject,
|
||||
handleHome,
|
||||
handleSettings,
|
||||
labels,
|
||||
handleAddHost,
|
||||
handleOpenHostSettings,
|
||||
insetsTop,
|
||||
isOpen,
|
||||
handleViewMore,
|
||||
@@ -924,10 +802,6 @@ function DesktopSidebar({
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const hostStatusDotStyle = useMemo(
|
||||
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
|
||||
[activeHostStatusColor],
|
||||
);
|
||||
|
||||
const startWidthRef = useRef(sidebarWidth);
|
||||
const resizeWidth = useSharedValue(sidebarWidth);
|
||||
@@ -1007,18 +881,19 @@ function DesktopSidebar({
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<WorkspacesSectionHeader serverId={activeServerId} />
|
||||
<WorkspacesSectionHeader />
|
||||
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
serverId={activeServerId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
statusWorkspacePlacements={statusWorkspacePlacements}
|
||||
projects={projects}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onAddProject={handleOpenProject}
|
||||
@@ -1029,19 +904,12 @@ function DesktopSidebar({
|
||||
|
||||
<SidebarFooter
|
||||
theme={theme}
|
||||
activeServerId={activeServerId}
|
||||
activeHostLabel={activeHostLabel}
|
||||
hostStatusDotStyle={hostStatusDotStyle}
|
||||
hostOptions={hostOptions}
|
||||
hostTriggerRef={hostTriggerRef}
|
||||
isHostPickerOpen={isHostPickerOpen}
|
||||
setIsHostPickerOpen={setIsHostPickerOpen}
|
||||
handleHostSelect={handleHostSelect}
|
||||
renderHostOption={renderHostOption}
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleHome={handleHome}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
handleAddHost={handleAddHost}
|
||||
handleOpenHostSettings={handleOpenHostSettings}
|
||||
/>
|
||||
|
||||
{/* Resize handle - absolutely positioned over right border */}
|
||||
@@ -1053,7 +921,7 @@ function DesktopSidebar({
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) {
|
||||
function WorkspacesSectionHeader() {
|
||||
const { theme } = useUnistyles();
|
||||
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
|
||||
const commandCenterKeys = useShortcutKeys("toggle-command-center");
|
||||
@@ -1096,7 +964,7 @@ function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) {
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<View>
|
||||
<SidebarDisplayPreferencesMenu serverId={serverId} />
|
||||
<SidebarDisplayPreferencesMenu />
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
@@ -1208,45 +1076,15 @@ const styles = StyleSheet.create((theme) => ({
|
||||
sidebarDragArea: {
|
||||
position: "relative",
|
||||
},
|
||||
hostTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
gap: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
hostTriggerHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
hostStatusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
hostTriggerText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
sidebarFooter: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
justifyContent: "flex-start",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
footerHostSlot: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
marginRight: theme.spacing[2],
|
||||
},
|
||||
footerIconRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -1261,34 +1099,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
},
|
||||
hostPickerList: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
hostPickerOption: {
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
hostPickerOptionText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
hostPickerCancel: {
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
alignItems: "center",
|
||||
},
|
||||
hostPickerCancelText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -8,57 +8,29 @@ import {
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { Folder, FolderPlus } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { Folder } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { getOpenProjectFailureReason, type OpenProjectFailureReason } from "@/hooks/open-project";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import type { OpenProjectFailure, OpenProjectResult } from "@/hooks/open-project";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useProjectPickerStore } from "@/stores/project-picker-store";
|
||||
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
||||
import {
|
||||
buildProjectPickerOptions,
|
||||
isOpenableProjectPath,
|
||||
type ProjectPickerOption,
|
||||
} from "./project-picker-options";
|
||||
import { buildProjectPickerOptions, type ProjectPickerOption } from "./project-picker-options";
|
||||
|
||||
interface PathRowProps {
|
||||
option: ProjectPickerOption;
|
||||
active: boolean;
|
||||
openPathLabel: string;
|
||||
onSelect: (path: string) => void;
|
||||
}
|
||||
|
||||
type ProjectPickerErrorCode = NonNullable<OpenProjectFailure["errorCode"]>;
|
||||
|
||||
function getProjectPickerErrorMessage(
|
||||
result: OpenProjectResult | undefined,
|
||||
thrownError: Error | null,
|
||||
translateErrorCode: (errorCode: ProjectPickerErrorCode) => string,
|
||||
translateOpenFailed: () => string,
|
||||
): string | null {
|
||||
if (result?.ok === false) {
|
||||
if (result.errorCode) {
|
||||
return translateErrorCode(result.errorCode);
|
||||
}
|
||||
return result.error;
|
||||
}
|
||||
if (thrownError) {
|
||||
return thrownError.message || translateOpenFailed();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function PathRow({ option, active, openPathLabel, onSelect }: PathRowProps) {
|
||||
function PathRow({ option, active, onSelect }: PathRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const Icon = option.kind === "path" ? FolderPlus : Folder;
|
||||
const { t } = useTranslation();
|
||||
const path = option.path;
|
||||
const displayPath = shortenPath(path);
|
||||
const label = option.kind === "path" ? `${openPathLabel}: ${displayPath}` : displayPath;
|
||||
const handlePress = useCallback(() => {
|
||||
onSelect(path);
|
||||
}, [onSelect, path]);
|
||||
@@ -75,15 +47,22 @@ function PathRow({ option, active, openPathLabel, onSelect }: PathRowProps) {
|
||||
() => [styles.rowText, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
);
|
||||
const rowActionTextStyle = useMemo(
|
||||
() => [styles.rowActionText, { color: theme.colors.foregroundMuted }],
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
return (
|
||||
<Pressable style={pressableStyle} onPress={handlePress}>
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.iconSlot}>
|
||||
<Icon size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />
|
||||
<Folder size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
<Text style={rowTextStyle} numberOfLines={1}>
|
||||
{label}
|
||||
{shortenPath(path)}
|
||||
</Text>
|
||||
{option.kind === "path" ? (
|
||||
<Text style={rowActionTextStyle}>{t("projectPicker.openPath")}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -92,10 +71,10 @@ function PathRow({ option, active, openPathLabel, onSelect }: PathRowProps) {
|
||||
export function ProjectPickerModal() {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const serverId = useActiveServerId();
|
||||
|
||||
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
|
||||
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
|
||||
const request = useProjectPickerStore((state) => state.request);
|
||||
const close = useProjectPickerStore((state) => state.close);
|
||||
const serverId = request?.serverId ?? null;
|
||||
const open = request !== null;
|
||||
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
@@ -104,25 +83,10 @@ export function ProjectPickerModal() {
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [openErrorReason, setOpenErrorReason] = useState<OpenProjectFailureReason | null>(null);
|
||||
const openProject = useOpenProject(serverId);
|
||||
|
||||
const openProjectMutation = useMutation({
|
||||
mutationFn: (path: string) => openProject(path),
|
||||
onSuccess: (result) => {
|
||||
if (result.ok) {
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
const { mutate: submitPath, reset: resetSubmit, isPending: isSubmitting } = openProjectMutation;
|
||||
const submitResult = openProjectMutation.data;
|
||||
const errorMessage = getProjectPickerErrorMessage(
|
||||
submitResult,
|
||||
openProjectMutation.error,
|
||||
(errorCode) => t(`projectPicker.errors.${errorCode}`),
|
||||
() => t("projectPicker.errors.open_failed"),
|
||||
);
|
||||
|
||||
const directorySuggestionsQuery = useQuery({
|
||||
queryKey: ["project-picker-directory-suggestions", serverId, query],
|
||||
queryFn: async () => {
|
||||
@@ -142,56 +106,72 @@ export function ProjectPickerModal() {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const options = useMemo(() => {
|
||||
return buildProjectPickerOptions({
|
||||
recommendedPaths,
|
||||
serverPaths: directorySuggestionsQuery.data ?? [],
|
||||
query,
|
||||
});
|
||||
}, [query, directorySuggestionsQuery.data, recommendedPaths]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, [setOpen]);
|
||||
|
||||
const handleSelectPath = useCallback(
|
||||
(path: string) => {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed || !client || !serverId) return;
|
||||
submitPath(trimmed);
|
||||
},
|
||||
[client, serverId, submitPath],
|
||||
const options = useMemo(
|
||||
() =>
|
||||
buildProjectPickerOptions({
|
||||
recommendedPaths,
|
||||
serverPaths: directorySuggestionsQuery.data ?? [],
|
||||
query,
|
||||
}),
|
||||
[directorySuggestionsQuery.data, query, recommendedPaths],
|
||||
);
|
||||
|
||||
const handleSubmitCustom = useCallback(() => {
|
||||
const trimmed = query.trim();
|
||||
if (!isOpenableProjectPath(trimmed)) return;
|
||||
handleSelectPath(trimmed);
|
||||
}, [handleSelectPath, query]);
|
||||
|
||||
const handleChangeQuery = useCallback(
|
||||
(text: string) => {
|
||||
setQuery(text);
|
||||
setActiveIndex(0);
|
||||
resetSubmit();
|
||||
},
|
||||
[resetSubmit],
|
||||
);
|
||||
|
||||
// Reset state when opening/closing
|
||||
useEffect(() => {
|
||||
resetSubmit();
|
||||
if (!open) {
|
||||
return;
|
||||
const openErrorMessage = useMemo(() => {
|
||||
if (!openErrorReason) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setQuery("");
|
||||
return t(`projectPicker.errors.${openErrorReason}`);
|
||||
}, [openErrorReason, t]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
close();
|
||||
}, [close]);
|
||||
|
||||
const handleSelectPath = useCallback(
|
||||
async (path: string) => {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed || !client || !serverId) return;
|
||||
|
||||
setOpenErrorReason(null);
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await openProject(trimmed);
|
||||
if (result.ok) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
setOpenErrorReason(getOpenProjectFailureReason(result));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[client, close, openProject, serverId],
|
||||
);
|
||||
|
||||
const submitActiveOption = useCallback(() => {
|
||||
const option = options[activeIndex];
|
||||
if (!option) return;
|
||||
void handleSelectPath(option.path);
|
||||
}, [activeIndex, handleSelectPath, options]);
|
||||
|
||||
const handleChangeQuery = useCallback((text: string) => {
|
||||
setQuery(text);
|
||||
setActiveIndex(0);
|
||||
const id = setTimeout(() => inputRef.current?.focus(), 0);
|
||||
return () => clearTimeout(id);
|
||||
}, [open, resetSubmit]);
|
||||
setOpenErrorReason(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery("");
|
||||
setActiveIndex(0);
|
||||
setOpenErrorReason(null);
|
||||
const id = setTimeout(() => inputRef.current?.focus(), 0);
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Clamp active index
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (activeIndex >= options.length) {
|
||||
@@ -199,7 +179,6 @@ export function ProjectPickerModal() {
|
||||
}
|
||||
}, [activeIndex, options.length, open]);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
if (!open || isNative) return;
|
||||
|
||||
@@ -209,17 +188,13 @@ export function ProjectPickerModal() {
|
||||
|
||||
if (key === "Escape") {
|
||||
event.preventDefault();
|
||||
handleClose();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (options.length > 0 && activeIndex < options.length) {
|
||||
handleSelectPath(options[activeIndex].path);
|
||||
} else if (query.trim()) {
|
||||
handleSubmitCustom();
|
||||
}
|
||||
submitActiveOption();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -238,7 +213,7 @@ export function ProjectPickerModal() {
|
||||
|
||||
window.addEventListener("keydown", handler, true);
|
||||
return () => window.removeEventListener("keydown", handler, true);
|
||||
}, [activeIndex, handleClose, handleSelectPath, handleSubmitCustom, open, options, query]);
|
||||
}, [close, open, options.length, submitActiveOption]);
|
||||
|
||||
const panelStyle = useMemo(
|
||||
() => [
|
||||
@@ -258,14 +233,14 @@ export function ProjectPickerModal() {
|
||||
() => [styles.input, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
);
|
||||
const errorTextStyle = useMemo(
|
||||
() => [styles.errorText, { color: theme.colors.destructive }],
|
||||
[theme.colors.destructive],
|
||||
);
|
||||
const emptyTextStyle = useMemo(
|
||||
() => [styles.emptyText, { color: theme.colors.foregroundMuted }],
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
const errorTextStyle = useMemo(
|
||||
() => [styles.emptyText, { color: theme.colors.destructive }],
|
||||
[theme.colors.destructive],
|
||||
);
|
||||
|
||||
if (!serverId) return null;
|
||||
|
||||
@@ -288,9 +263,8 @@ export function ProjectPickerModal() {
|
||||
autoFocus
|
||||
editable={!isSubmitting}
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={handleSubmitCustom}
|
||||
onSubmitEditing={submitActiveOption}
|
||||
/>
|
||||
{errorMessage ? <Text style={errorTextStyle}>{errorMessage}</Text> : null}
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
@@ -300,17 +274,19 @@ export function ProjectPickerModal() {
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{isSubmitting ? <Text style={emptyTextStyle}>{t("projectPicker.opening")}</Text> : null}
|
||||
{!isSubmitting && options.length === 0 ? (
|
||||
{!isSubmitting && openErrorMessage ? (
|
||||
<Text style={errorTextStyle}>{openErrorMessage}</Text>
|
||||
) : null}
|
||||
{!isSubmitting && options.length === 0 && !query.trim() ? (
|
||||
<Text style={emptyTextStyle}>{t("projectPicker.empty")}</Text>
|
||||
) : null}
|
||||
{!isSubmitting && options.length > 0 ? (
|
||||
{!isSubmitting && !(options.length === 0 && !query.trim()) ? (
|
||||
<>
|
||||
{options.map((option, index) => (
|
||||
<PathRow
|
||||
key={`${option.kind}:${option.path}`}
|
||||
option={option}
|
||||
active={index === activeIndex}
|
||||
openPathLabel={t("projectPicker.openPath")}
|
||||
onSelect={handleSelectPath}
|
||||
/>
|
||||
))}
|
||||
@@ -353,11 +329,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[1],
|
||||
outlineStyle: "none",
|
||||
} as object,
|
||||
errorText: {
|
||||
marginTop: theme.spacing[2],
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 18,
|
||||
},
|
||||
results: {
|
||||
flexGrow: 0,
|
||||
},
|
||||
@@ -383,8 +354,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: "400",
|
||||
lineHeight: 20,
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
},
|
||||
rowActionText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: "500",
|
||||
},
|
||||
emptyText: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[4],
|
||||
|
||||
@@ -152,29 +152,27 @@ function makeHost(): HostProfile {
|
||||
};
|
||||
}
|
||||
|
||||
function setHostProfiles(hosts: HostProfile[]): void {
|
||||
(
|
||||
getHostRuntimeStore() as unknown as {
|
||||
setHostsAndSync: (hosts: HostProfile[]) => void;
|
||||
}
|
||||
).setHostsAndSync(hosts);
|
||||
}
|
||||
|
||||
function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void {
|
||||
act(() => {
|
||||
getHostRuntimeStore().syncHosts([makeHost()]);
|
||||
setHostProfiles([makeHost()]);
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
|
||||
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrderByServerId: {
|
||||
[SERVER_ID]: ["project-a", "project-b"],
|
||||
},
|
||||
workspaceOrderByServerAndProject: {
|
||||
[`${SERVER_ID}::project-a`]: [
|
||||
`${SERVER_ID}:a-main`,
|
||||
`${SERVER_ID}:a-one`,
|
||||
`${SERVER_ID}:a-two`,
|
||||
],
|
||||
[`${SERVER_ID}::project-b`]: [
|
||||
`${SERVER_ID}:b-main`,
|
||||
`${SERVER_ID}:b-one`,
|
||||
`${SERVER_ID}:b-two`,
|
||||
],
|
||||
projectOrder: ["project-a", "project-b"],
|
||||
workspaceOrderByProject: {
|
||||
["project-a"]: [`${SERVER_ID}:a-main`, `${SERVER_ID}:a-one`, `${SERVER_ID}:a-two`],
|
||||
["project-b"]: [`${SERVER_ID}:b-main`, `${SERVER_ID}:b-one`, `${SERVER_ID}:b-two`],
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -258,7 +256,7 @@ function WorkspaceSelectionProbe({
|
||||
|
||||
function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
|
||||
counts.frame += 1;
|
||||
const { projects } = useSidebarWorkspacesList({ serverId: SERVER_ID });
|
||||
const { projects } = useSidebarWorkspacesList({ hostFilter: SERVER_ID });
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -345,11 +343,11 @@ describe("sidebar workspace render isolation", () => {
|
||||
container = null;
|
||||
act(() => {
|
||||
pathnameState.value = "/";
|
||||
getHostRuntimeStore().syncHosts([]);
|
||||
setHostProfiles([]);
|
||||
useSessionStore.getState().clearSession(SERVER_ID);
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrderByServerId: {},
|
||||
workspaceOrderByServerAndProject: {},
|
||||
projectOrder: [],
|
||||
workspaceOrderByProject: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,14 +5,16 @@ import { Settings2 } from "lucide-react-native";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { useAppSettings, type WorkspaceTitleSource } from "@/hooks/use-settings";
|
||||
import { useHosts, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
|
||||
const ThemedSettings2 = withUnistyles(Settings2);
|
||||
const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
@@ -32,22 +34,29 @@ interface DisplayPreferenceOption<Value extends string> {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string | null }) {
|
||||
const groupMode = useSidebarViewStore((state) =>
|
||||
serverId ? state.getGroupMode(serverId) : "project",
|
||||
);
|
||||
export function SidebarDisplayPreferencesMenu() {
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
const hostFilter = useSidebarViewStore((state) => state.hostFilter);
|
||||
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
|
||||
const setHostFilter = useSidebarViewStore((state) => state.setHostFilter);
|
||||
const hosts = useHosts();
|
||||
const {
|
||||
settings: { workspaceTitleSource },
|
||||
updateSettings,
|
||||
} = useAppSettings();
|
||||
|
||||
const handleSelect = useCallback(
|
||||
const handleSelectMode = useCallback(
|
||||
(mode: SidebarGroupMode) => {
|
||||
if (!serverId) return;
|
||||
setGroupMode(serverId, mode);
|
||||
setGroupMode(mode);
|
||||
},
|
||||
[serverId, setGroupMode],
|
||||
[setGroupMode],
|
||||
);
|
||||
|
||||
const handleSelectHost = useCallback(
|
||||
(serverId: string | null) => {
|
||||
setHostFilter(serverId);
|
||||
},
|
||||
[setHostFilter],
|
||||
);
|
||||
|
||||
const handleWorkspaceTitleSourceSelect = useCallback(
|
||||
@@ -65,6 +74,8 @@ export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string |
|
||||
[],
|
||||
);
|
||||
|
||||
const showHostFilter = hosts.length > 1;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
@@ -75,7 +86,7 @@ export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string |
|
||||
>
|
||||
<ThemedSettings2 size={14} uniProps={filterColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={180} testID="sidebar-display-preferences-content">
|
||||
<DropdownMenuContent align="end" width={220} testID="sidebar-display-preferences-content">
|
||||
<View style={styles.menuHeader}>
|
||||
<Text style={styles.menuHeaderLabel}>Group by</Text>
|
||||
</View>
|
||||
@@ -85,9 +96,33 @@ export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string |
|
||||
item={item}
|
||||
isSelected={groupMode === item.value}
|
||||
testIDPrefix="sidebar-grouping"
|
||||
onSelect={handleSelect}
|
||||
onSelect={handleSelectMode}
|
||||
/>
|
||||
))}
|
||||
{showHostFilter ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<View style={styles.menuHeader}>
|
||||
<Text style={styles.menuHeaderLabel}>Filter</Text>
|
||||
</View>
|
||||
<HostFilterItem
|
||||
label="All hosts"
|
||||
value={null}
|
||||
hostFilter={hostFilter}
|
||||
onSelect={handleSelectHost}
|
||||
/>
|
||||
{hosts.map((host) => (
|
||||
<HostFilterItem
|
||||
key={host.serverId}
|
||||
label={host.label?.trim() || host.serverId}
|
||||
serverId={host.serverId}
|
||||
value={host.serverId}
|
||||
hostFilter={hostFilter}
|
||||
onSelect={handleSelectHost}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<View style={styles.menuHeader}>
|
||||
<Text style={styles.menuHeaderLabel}>Workspace title</Text>
|
||||
@@ -129,6 +164,33 @@ function DisplayPreferenceMenuItem<Value extends string>({
|
||||
);
|
||||
}
|
||||
|
||||
function HostFilterItem({
|
||||
label,
|
||||
serverId,
|
||||
value,
|
||||
hostFilter,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string;
|
||||
serverId?: string;
|
||||
value: string | null;
|
||||
hostFilter: string | null;
|
||||
onSelect: (serverId: string | null) => void;
|
||||
}) {
|
||||
const isSelected = hostFilter === value;
|
||||
const handleSelect = useCallback(() => onSelect(value), [value, onSelect]);
|
||||
const status = useHostRuntimeSnapshot(serverId ?? "");
|
||||
const subtitle = serverId
|
||||
? formatConnectionStatus(status?.connectionStatus ?? "idle")
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem selected={isSelected} description={subtitle} onSelect={handleSelect}>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
width: 28,
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import {
|
||||
useSidebarWorkspaceEntry,
|
||||
type SidebarStatusWorkspacePlacement,
|
||||
type SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import {
|
||||
buildStatusGroups,
|
||||
buildStatusShortcutIndex,
|
||||
@@ -20,12 +25,44 @@ import {
|
||||
CircleCheck,
|
||||
CircleDot,
|
||||
CircleX,
|
||||
MoreVertical,
|
||||
Copy,
|
||||
Archive,
|
||||
Pencil,
|
||||
} from "lucide-react-native";
|
||||
import { useSidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { SidebarWorkspaceRow } from "@/components/sidebar/sidebar-workspace-row";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
|
||||
import {
|
||||
SidebarWorkspaceRowFrame,
|
||||
SidebarWorkspaceRowContent,
|
||||
SidebarWorkspaceTrailingActionBase,
|
||||
SidebarWorkspaceTrailingActionOverlay,
|
||||
SidebarWorkspaceTrailingActionSlot,
|
||||
} from "@/components/sidebar/sidebar-workspace-row-content";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
|
||||
// Themed icon wrappers
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
@@ -40,11 +77,21 @@ const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
const ThemedCircleDot = withUnistyles(CircleDot);
|
||||
const ThemedCircleX = withUnistyles(CircleX);
|
||||
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||
const ThemedCopy = withUnistyles(Copy);
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
|
||||
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const markAsReadLeadingIcon = (
|
||||
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
|
||||
interface StatusWorkspaceListProps {
|
||||
workspaces: SidebarWorkspaceEntry[];
|
||||
workspaces: SidebarStatusWorkspacePlacement[];
|
||||
projectNamesByKey: Map<string, string>;
|
||||
serverId: string | null;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
showShortcutBadges: boolean;
|
||||
onWorkspacePress?: () => void;
|
||||
@@ -53,7 +100,6 @@ interface StatusWorkspaceListProps {
|
||||
export function SidebarStatusWorkspaceList({
|
||||
workspaces,
|
||||
projectNamesByKey,
|
||||
serverId,
|
||||
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
|
||||
showShortcutBadges,
|
||||
onWorkspacePress,
|
||||
@@ -89,7 +135,6 @@ export function SidebarStatusWorkspaceList({
|
||||
groups={groups}
|
||||
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
serverId={serverId}
|
||||
shortcutIndex={statusShortcutIndex}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
@@ -106,7 +151,6 @@ export function SidebarStatusWorkspaceList({
|
||||
groups={groups}
|
||||
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
serverId={serverId}
|
||||
shortcutIndex={statusShortcutIndex}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
@@ -121,7 +165,6 @@ function StatusGroupList({
|
||||
groups,
|
||||
collapsedStatusGroupKeys,
|
||||
projectNamesByKey,
|
||||
serverId,
|
||||
shortcutIndex,
|
||||
showShortcutBadges,
|
||||
onWorkspacePress,
|
||||
@@ -129,7 +172,6 @@ function StatusGroupList({
|
||||
groups: StatusGroup[];
|
||||
collapsedStatusGroupKeys: ReadonlySet<string>;
|
||||
projectNamesByKey: Map<string, string>;
|
||||
serverId: string | null;
|
||||
shortcutIndex: Map<string, number>;
|
||||
showShortcutBadges: boolean;
|
||||
onWorkspacePress?: () => void;
|
||||
@@ -149,7 +191,6 @@ function StatusGroupList({
|
||||
key={workspace.workspaceKey}
|
||||
workspace={workspace}
|
||||
projectName={projectNamesByKey.get(workspace.projectKey) ?? ""}
|
||||
serverId={serverId}
|
||||
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
@@ -248,45 +289,473 @@ function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) {
|
||||
const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
workspace,
|
||||
projectName,
|
||||
serverId,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
onWorkspacePress,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
workspace: SidebarStatusWorkspacePlacement;
|
||||
projectName: string;
|
||||
serverId: string | null;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
onWorkspacePress?: () => void;
|
||||
}) {
|
||||
const hydratedWorkspace = useSidebarWorkspaceEntry(serverId, workspace.workspaceId);
|
||||
const workspaceEntry = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const selected =
|
||||
activeWorkspaceSelection?.serverId === workspace.serverId &&
|
||||
activeWorkspaceSelection?.workspaceId === workspace.workspaceId;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (!serverId) return;
|
||||
if (!workspace.serverId) return;
|
||||
onWorkspacePress?.();
|
||||
navigateToWorkspace(serverId, workspace.workspaceId);
|
||||
}, [serverId, onWorkspacePress, workspace.workspaceId]);
|
||||
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
|
||||
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
if (!hydratedWorkspace) return null;
|
||||
if (!workspaceEntry) return null;
|
||||
|
||||
return (
|
||||
<SidebarWorkspaceRow
|
||||
workspace={hydratedWorkspace}
|
||||
<StatusWorkspaceRowWithMenu
|
||||
workspace={workspaceEntry}
|
||||
projectName={projectName}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
canCopyBranchName={hydratedWorkspace.projectKind === "git"}
|
||||
onPress={handlePress}
|
||||
subtitle={projectName}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function StatusWorkspaceRowWithMenu({
|
||||
workspace,
|
||||
projectName,
|
||||
selected,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
onPress,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
projectName: string;
|
||||
selected: boolean;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const workspaceDirectory = resolveWorkspaceDirectory({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
workspaceDirectory
|
||||
? state.getStatus({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspaceDirectory,
|
||||
actionId: "archive-worktree",
|
||||
})
|
||||
: "idle",
|
||||
);
|
||||
const isWorktree = workspace.workspaceKind === "worktree";
|
||||
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
|
||||
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
activeWorkspaceSelection: selected
|
||||
? { serverId: workspace.serverId, workspaceId: workspace.workspaceId }
|
||||
: null,
|
||||
});
|
||||
}, [selected, workspace]);
|
||||
|
||||
const archiveController = useWorkspaceArchive({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
name: workspace.name,
|
||||
...toWorktreeArchiveRisk(workspace),
|
||||
onArchiveStarted: redirectAfterArchive,
|
||||
onSetHiding: setIsHidingWorkspace,
|
||||
});
|
||||
|
||||
const handleArchive = useCallback(() => {
|
||||
if (isArchiving) return;
|
||||
archiveController.archive();
|
||||
}, [archiveController, isArchiving]);
|
||||
|
||||
const handleCopyPath = useCallback(() => {
|
||||
let copyTargetDirectory: string;
|
||||
try {
|
||||
copyTargetDirectory = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Workspace path not available");
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(copyTargetDirectory);
|
||||
toast.copied("Path copied");
|
||||
}, [toast, workspace.workspaceDirectory, workspace.workspaceId]);
|
||||
|
||||
const handleCopyBranchName = useCallback(() => {
|
||||
void Clipboard.setStringAsync(workspace.name);
|
||||
toast.copied("Branch name copied");
|
||||
}, [toast, workspace.name]);
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: async (title: string) => {
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenRename = useCallback(() => setIsRenameOpen(true), []);
|
||||
const handleCloseRename = useCallback(() => setIsRenameOpen(false), []);
|
||||
const handleSubmitRename = useCallback(
|
||||
async (value: string) => {
|
||||
await renameMutation.mutateAsync(value.trim());
|
||||
},
|
||||
[renameMutation],
|
||||
);
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const handleMarkAsRead = useCallback(() => {
|
||||
void clearAttention().catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read");
|
||||
});
|
||||
}, [clearAttention, toast]);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `worktree-archive-${workspace.workspaceKey}`,
|
||||
actions: ["worktree.archive"],
|
||||
enabled: selected && !isArchiving,
|
||||
priority: 0,
|
||||
handle: () => {
|
||||
handleArchive();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
let computedArchiveStatus: "idle" | "pending" | "success" = "idle";
|
||||
if (isWorktree) {
|
||||
computedArchiveStatus = worktreeArchiveStatus;
|
||||
} else if (isHidingWorkspace) {
|
||||
computedArchiveStatus = "pending";
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusWorkspaceRowInner
|
||||
workspace={workspace}
|
||||
projectName={projectName}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
isArchiving={isArchiving}
|
||||
archiveLabel={t("sidebar.workspace.actions.archive")}
|
||||
archiveStatus={computedArchiveStatus}
|
||||
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
|
||||
onArchive={handleArchive}
|
||||
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
|
||||
onCopyPath={handleCopyPath}
|
||||
onRename={handleOpenRename}
|
||||
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
/>
|
||||
<AdaptiveRenameModal
|
||||
visible={isRenameOpen}
|
||||
title="Rename workspace"
|
||||
initialValue={workspace.title ?? workspace.name}
|
||||
placeholder={workspace.name}
|
||||
submitLabel="Rename"
|
||||
onClose={handleCloseRename}
|
||||
onSubmit={handleSubmitRename}
|
||||
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusWorkspaceRowInner({
|
||||
workspace,
|
||||
projectName,
|
||||
selected,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
onPress,
|
||||
isArchiving,
|
||||
archiveLabel,
|
||||
archiveStatus = "idle",
|
||||
archivePendingLabel,
|
||||
onArchive,
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
archiveShortcutKeys,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
projectName: string;
|
||||
selected: boolean;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
onPress: () => void;
|
||||
isArchiving: boolean;
|
||||
archiveLabel?: string;
|
||||
archiveStatus?: "idle" | "pending" | "success";
|
||||
archivePendingLabel?: string;
|
||||
onArchive?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}) {
|
||||
const isTouchPlatform = platformIsNative;
|
||||
|
||||
const isDesktop = !isTouchPlatform;
|
||||
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
|
||||
const hasRunningService = workspace.scripts.some(
|
||||
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
|
||||
);
|
||||
let scriptIconKind: "service" | "command" | null = null;
|
||||
if (showScriptsIcon) {
|
||||
scriptIconKind = hasRunningService ? "service" : "command";
|
||||
}
|
||||
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
|
||||
return (
|
||||
<SidebarWorkspaceRowFrame workspace={workspace}>
|
||||
{({ isHovered, hoverHandlers }) => {
|
||||
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
||||
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
|
||||
const showKebabInSlot = showKebab && !showShortcut;
|
||||
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
|
||||
const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered });
|
||||
return (
|
||||
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
|
||||
<Pressable
|
||||
disabled={isArchiving}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
onPress={onPress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
<SidebarWorkspaceRowContent
|
||||
workspace={workspace}
|
||||
subtitle={projectName}
|
||||
scriptIconKind={scriptIconKind}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
>
|
||||
{shouldRenderActionSlot ? (
|
||||
<StatusWorkspaceActionSlot
|
||||
workspace={workspace}
|
||||
showBase={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
|
||||
showOverlay={showKebabInSlot}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceRowContent>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
</SidebarWorkspaceRowFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusWorkspaceActionSlot({
|
||||
workspace,
|
||||
showBase,
|
||||
showOverlay,
|
||||
onCopyPath,
|
||||
onCopyBranchName,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
onArchive,
|
||||
archiveLabel,
|
||||
archiveStatus,
|
||||
archivePendingLabel,
|
||||
archiveShortcutKeys,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
showBase: boolean;
|
||||
showOverlay: boolean;
|
||||
onCopyPath?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onRename?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onArchive?: () => void;
|
||||
archiveLabel?: string;
|
||||
archiveStatus?: "idle" | "pending" | "success";
|
||||
archivePendingLabel?: string;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}) {
|
||||
return (
|
||||
<SidebarWorkspaceTrailingActionSlot>
|
||||
<SidebarWorkspaceTrailingActionBase visible={showBase}>
|
||||
{workspace.diffStat ? (
|
||||
<DiffStat
|
||||
additions={workspace.diffStat.additions}
|
||||
deletions={workspace.diffStat.deletions}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={showOverlay}>
|
||||
{onArchive ? (
|
||||
<StatusKebabMenu
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceTrailingActionOverlay>
|
||||
</SidebarWorkspaceTrailingActionSlot>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusKebabMenu({
|
||||
workspaceKey,
|
||||
onCopyPath,
|
||||
onCopyBranchName,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
onArchive,
|
||||
archiveLabel,
|
||||
archiveStatus,
|
||||
archivePendingLabel,
|
||||
archiveShortcutKeys,
|
||||
}: {
|
||||
workspaceKey: string;
|
||||
onCopyPath?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onRename?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onArchive: () => void;
|
||||
archiveLabel?: string;
|
||||
archiveStatus?: "idle" | "pending" | "success";
|
||||
archivePendingLabel?: string;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}) {
|
||||
const archiveTrailing = useMemo(
|
||||
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
||||
[archiveShortcutKeys],
|
||||
);
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={kebabStyle}
|
||||
accessibilityRole={platformIsWeb ? undefined : "button"}
|
||||
accessibilityLabel="Workspace actions"
|
||||
testID={`sidebar-workspace-kebab-${workspaceKey}`}
|
||||
>
|
||||
{({ hovered }: { hovered?: boolean }) => (
|
||||
<ThemedMoreVertical
|
||||
size={14}
|
||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={260}>
|
||||
{onCopyPath ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
|
||||
leading={copyLeadingIcon}
|
||||
onSelect={onCopyPath}
|
||||
>
|
||||
Copy path
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onCopyBranchName ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
|
||||
leading={copyLeadingIcon}
|
||||
onSelect={onCopyBranchName}
|
||||
>
|
||||
Copy branch name
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onRename ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
|
||||
leading={renameLeadingIcon}
|
||||
onSelect={onRename}
|
||||
>
|
||||
Rename workspace
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onMarkAsRead ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
|
||||
leading={markAsReadLeadingIcon}
|
||||
onSelect={onMarkAsRead}
|
||||
>
|
||||
Mark as read
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
||||
leading={archiveLeadingIcon}
|
||||
trailing={archiveTrailing}
|
||||
status={archiveStatus}
|
||||
pendingLabel={archivePendingLabel}
|
||||
onSelect={onArchive}
|
||||
>
|
||||
{archiveLabel ?? "Archive"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function kebabStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
|
||||
}
|
||||
|
||||
function getStatusWorkspaceRowStyle({
|
||||
selected,
|
||||
isHovered,
|
||||
}: {
|
||||
selected: boolean;
|
||||
isHovered: boolean;
|
||||
}) {
|
||||
return [
|
||||
styles.workspaceRow,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isHovered && styles.workspaceRowHovered,
|
||||
];
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
@@ -350,4 +819,37 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
workspaceRowContainer: {
|
||||
position: "relative",
|
||||
},
|
||||
workspaceRow: {
|
||||
minHeight: 36,
|
||||
marginBottom: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[3] + theme.spacing[3],
|
||||
paddingRight: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
flexDirection: "column",
|
||||
alignItems: "stretch",
|
||||
justifyContent: "flex-start",
|
||||
gap: theme.spacing[1],
|
||||
userSelect: "none",
|
||||
},
|
||||
workspaceRowHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
workspaceRowPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
sidebarRowSelected: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
kebabButton: {
|
||||
padding: 2,
|
||||
borderRadius: 4,
|
||||
marginLeft: 2,
|
||||
},
|
||||
kebabButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -230,6 +230,7 @@ export interface ComboboxItemProps {
|
||||
disabled?: boolean;
|
||||
/** When true, bumps hover/pressed colors up one surface level (for items on elevated backgrounds). */
|
||||
elevated?: boolean;
|
||||
interactiveFeedback?: boolean;
|
||||
onPress: () => void;
|
||||
testID?: string;
|
||||
}
|
||||
@@ -244,6 +245,7 @@ export function ComboboxItem({
|
||||
active,
|
||||
disabled,
|
||||
elevated,
|
||||
interactiveFeedback = true,
|
||||
onPress,
|
||||
testID,
|
||||
}: ComboboxItemProps): ReactElement {
|
||||
@@ -269,12 +271,16 @@ export function ComboboxItem({
|
||||
const itemPressableStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.comboboxItem,
|
||||
hovered && (elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
|
||||
pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
|
||||
active && styles.comboboxItemActive,
|
||||
interactiveFeedback &&
|
||||
hovered &&
|
||||
(elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
|
||||
interactiveFeedback &&
|
||||
pressed &&
|
||||
(elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
|
||||
interactiveFeedback && active && styles.comboboxItemActive,
|
||||
disabled && styles.comboboxItemDisabled,
|
||||
],
|
||||
[elevated, active, disabled],
|
||||
[elevated, active, disabled, interactiveFeedback],
|
||||
);
|
||||
|
||||
const itemContentStyle = useMemo(
|
||||
|
||||
@@ -992,5 +992,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
itemContent: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -12,7 +12,7 @@ import { PairLinkModal } from "./pair-link-modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { buildOpenProjectRoute } from "@/utils/host-routes";
|
||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
@@ -170,15 +170,12 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!anyOnlineServerId) return;
|
||||
router.replace(buildHostRootRoute(anyOnlineServerId));
|
||||
router.replace(buildOpenProjectRoute());
|
||||
}, [anyOnlineServerId, router]);
|
||||
|
||||
const finishOnboarding = useCallback(
|
||||
(serverId: string) => {
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
},
|
||||
[router],
|
||||
);
|
||||
const finishOnboarding = useCallback(() => {
|
||||
router.replace(buildOpenProjectRoute());
|
||||
}, [router]);
|
||||
|
||||
const handleOpenPaseoSite = useCallback(() => {
|
||||
void openExternalUrl("https://paseo.sh");
|
||||
@@ -197,9 +194,9 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
}, [router]);
|
||||
|
||||
const handleHostSaved = useCallback(
|
||||
({ profile, serverId }: { profile: HostProfile; serverId: string }) => {
|
||||
({ profile }: { profile: HostProfile; serverId: string }) => {
|
||||
onHostAdded?.(profile);
|
||||
finishOnboarding(serverId);
|
||||
finishOnboarding();
|
||||
},
|
||||
[onHostAdded, finishOnboarding],
|
||||
);
|
||||
|
||||
@@ -39,6 +39,8 @@ import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { FloatingSurface } from "@/components/ui/floating";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useHosts, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
@@ -284,6 +286,7 @@ function WorkspaceHoverCardContent({
|
||||
{workspace.name}
|
||||
</Text>
|
||||
</View>
|
||||
<HostRow serverId={workspace.serverId} />
|
||||
{workspace.currentBranch ? (
|
||||
<CopyableInfoRow
|
||||
icon={ThemedGitBranch}
|
||||
@@ -327,6 +330,22 @@ function WorkspaceHoverCardContent({
|
||||
|
||||
const ThemedGitBranch = withUnistyles(GitBranch);
|
||||
const ThemedFolder = withUnistyles(Folder);
|
||||
|
||||
function HostRow({ serverId }: { serverId: string }): ReactElement | null {
|
||||
const hosts = useHosts();
|
||||
const host = hosts.find((h) => h.serverId === serverId);
|
||||
const snapshot = useHostRuntimeSnapshot(serverId);
|
||||
const label = host?.label?.trim() || serverId;
|
||||
const status = formatConnectionStatus(snapshot?.connectionStatus ?? "idle");
|
||||
|
||||
return (
|
||||
<View style={styles.hostRow}>
|
||||
<Text style={styles.hostRowLabel}>{label}</Text>
|
||||
<Text style={styles.hostRowStatus}>{status}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
@@ -548,6 +567,22 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
hostRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
hostRowLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
hostRowStatus: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
cardMetaRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -6,11 +6,13 @@ import { act } from "@testing-library/react";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { WorkspaceShortcutTargetsSubscriber } from "./workspace-shortcut-targets-subscriber";
|
||||
|
||||
vi.hoisted(() => {
|
||||
@@ -42,6 +44,27 @@ function workspaceDescriptor(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function hostProfile(serverId = "srv"): HostProfile {
|
||||
const now = "2026-04-19T00:00:00.000Z";
|
||||
return {
|
||||
serverId,
|
||||
label: "Shortcut Host",
|
||||
lifecycle: {},
|
||||
connections: [],
|
||||
preferredConnectionId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function setHostProfiles(hosts: HostProfile[]): void {
|
||||
(
|
||||
getHostRuntimeStore() as unknown as {
|
||||
setHostsAndSync: (hosts: HostProfile[]) => void;
|
||||
}
|
||||
).setHostsAndSync(hosts);
|
||||
}
|
||||
|
||||
describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
@@ -58,14 +81,16 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
collapsedProjectKeys: new Set(),
|
||||
});
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrderByServerId: {},
|
||||
workspaceOrderByServerAndProject: {},
|
||||
projectOrder: [],
|
||||
workspaceOrderByProject: {},
|
||||
});
|
||||
useSidebarViewStore.setState({
|
||||
groupModeByServerId: {},
|
||||
groupMode: "project",
|
||||
hostFilter: null,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
setHostProfiles([hostProfile()]);
|
||||
useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(
|
||||
"srv",
|
||||
@@ -88,13 +113,16 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
container?.remove();
|
||||
container = null;
|
||||
act(() => {
|
||||
setHostProfiles([]);
|
||||
useSessionStore.getState().clearSession("srv");
|
||||
useSessionStore.getState().clearSession("host-a");
|
||||
useSessionStore.getState().clearSession("host-b");
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes workspace shortcut targets without rendering the sidebar", async () => {
|
||||
await act(async () => {
|
||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
|
||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
|
||||
});
|
||||
|
||||
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
|
||||
@@ -105,7 +133,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
|
||||
it("publishes status-mode shortcut targets in visual status order", async () => {
|
||||
act(() => {
|
||||
useSidebarViewStore.getState().setGroupMode("srv", "status");
|
||||
useSidebarViewStore.getState().setGroupMode("status");
|
||||
useSessionStore.getState().setWorkspaces(
|
||||
"srv",
|
||||
new Map([
|
||||
@@ -158,7 +186,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
|
||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
|
||||
});
|
||||
|
||||
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
|
||||
@@ -169,13 +197,52 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears targets when disabled", async () => {
|
||||
await act(async () => {
|
||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
|
||||
it("publishes shortcut targets from the visible host filter in project and status modes", async () => {
|
||||
act(() => {
|
||||
setHostProfiles([hostProfile("host-a"), hostProfile("host-b")]);
|
||||
useSessionStore.getState().initializeSession("host-a", null as unknown as DaemonClient);
|
||||
useSessionStore.getState().initializeSession("host-b", null as unknown as DaemonClient);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setWorkspaces(
|
||||
"host-a",
|
||||
new Map([["a-1", workspaceDescriptor({ id: "a-1", name: "Host A" })]]),
|
||||
);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setWorkspaces(
|
||||
"host-b",
|
||||
new Map([["b-1", workspaceDescriptor({ id: "b-1", name: "Host B" })]]),
|
||||
);
|
||||
useSessionStore.getState().setHasHydratedWorkspaces("host-a", true);
|
||||
useSessionStore.getState().setHasHydratedWorkspaces("host-b", true);
|
||||
useSidebarViewStore.getState().setHostFilter("host-b");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={false} serverId="srv" />);
|
||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
|
||||
});
|
||||
|
||||
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
|
||||
{ serverId: "host-b", workspaceId: "b-1" },
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
useSidebarViewStore.getState().setGroupMode("status");
|
||||
});
|
||||
|
||||
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
|
||||
{ serverId: "host-b", workspaceId: "b-1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears targets when disabled", async () => {
|
||||
await act(async () => {
|
||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={false} />);
|
||||
});
|
||||
|
||||
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([]);
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import {
|
||||
useProjectNamesMap,
|
||||
useStatusModeWorkspaceEntries,
|
||||
} from "@/hooks/use-status-mode-workspaces";
|
||||
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
@@ -12,28 +9,16 @@ import {
|
||||
buildStatusSidebarShortcutModel,
|
||||
} from "@/utils/sidebar-shortcuts";
|
||||
|
||||
export function WorkspaceShortcutTargetsSubscriber({
|
||||
enabled,
|
||||
serverId,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
serverId: string | null;
|
||||
}) {
|
||||
const { projects } = useSidebarWorkspacesList({ serverId, enabled });
|
||||
// groupMode must be resolved before gating the status-mode subscriptions below.
|
||||
const groupMode = useSidebarViewStore((state) =>
|
||||
enabled && serverId ? state.getGroupMode(serverId) : "project",
|
||||
);
|
||||
// Only subscribe to agents/workspaces when the status-group view is actually active.
|
||||
// In project mode (the default), these subscriptions would fire on every agent update
|
||||
// (agents Map identity is replaced on every status transition) with no effect on
|
||||
// the shortcut targets, causing ~15-46 wasted re-renders per agent switch.
|
||||
export function WorkspaceShortcutTargetsSubscriber({ enabled }: { enabled: boolean }) {
|
||||
const { workspacePlacements, projects, projectNamesByKey } = useSidebarWorkspacesList({
|
||||
enabled,
|
||||
});
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
const isStatusMode = enabled && groupMode === "status";
|
||||
const statusWorkspaces = useStatusModeWorkspaceEntries({
|
||||
serverId: isStatusMode ? serverId : null,
|
||||
projects,
|
||||
const statusWorkspacePlacements = useStatusModeWorkspacePlacements({
|
||||
placements: workspacePlacements,
|
||||
enabled: isStatusMode,
|
||||
});
|
||||
const projectNamesByKey = useProjectNamesMap(isStatusMode ? serverId : null);
|
||||
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedProjectKeys,
|
||||
);
|
||||
@@ -47,7 +32,7 @@ export function WorkspaceShortcutTargetsSubscriber({
|
||||
const shortcutModel = useMemo(() => {
|
||||
if (groupMode === "status") {
|
||||
return buildStatusSidebarShortcutModel({
|
||||
workspaces: statusWorkspaces,
|
||||
workspaces: statusWorkspacePlacements,
|
||||
projectNamesByKey,
|
||||
collapsedStatusGroupKeys,
|
||||
});
|
||||
@@ -63,17 +48,17 @@ export function WorkspaceShortcutTargetsSubscriber({
|
||||
groupMode,
|
||||
projectNamesByKey,
|
||||
projects,
|
||||
statusWorkspaces,
|
||||
statusWorkspacePlacements,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !serverId) {
|
||||
if (!enabled) {
|
||||
setSidebarShortcutWorkspaceTargets([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSidebarShortcutWorkspaceTargets(shortcutModel.shortcutTargets);
|
||||
}, [enabled, serverId, setSidebarShortcutWorkspaceTargets, shortcutModel.shortcutTargets]);
|
||||
}, [enabled, setSidebarShortcutWorkspaceTargets, shortcutModel.shortcutTargets]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
Reference in New Issue
Block a user