mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Show host in search results and filter sidebar by multiple hosts (#1825)
* feat(app): show host in command center and multi-select host filter Command-center agent results now show which host the workspace lives on, shown only when more than one host is connected (matching the sidebar). The sidebar display-preferences filter becomes multi-select — pick any number of hosts instead of one-or-all — and its host rows now carry a status dot on the left like the other host pickers. Persisted filter state migrates from the old single host to a host list. * test(app): extract host-filter and command-center e2e DSL helpers Move the mechanical menu/search interactions out of the new spec bodies into focused helpers so the tests read as user intent. * fix(app): drop the icon from the host filter "All hosts" item * fix(app): make multi-host e2e seeding order-independent The offline-host seed relied on Playwright running the test's init script after the fixture's registry reset — an ordering Playwright does not guarantee. Write the full registry and set the fixture's disable-once flag, then reload, so the multi-host seed survives regardless of script order. Also tag the pre-v2 hostFilter migration reader with COMPAT so the cleanup sweep can find it.
This commit is contained in:
49
packages/app/e2e/command-center-host.spec.ts
Normal file
49
packages/app/e2e/command-center-host.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { createIdleAgent } from "./helpers/archive-tab";
|
||||
import { openCommandCenter } from "./helpers/command-center";
|
||||
import { addOfflineHostAndReload } from "./helpers/hosts";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
const PRIMARY_HOST_LABEL = "Primary Host";
|
||||
const SECONDARY_HOST_ID = "host-command-center-secondary";
|
||||
|
||||
test.describe("Command center host labels", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("agent results show the host they live on when more than one host exists", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-host-" });
|
||||
const title = `cc-host-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
try {
|
||||
const agent = await createIdleAgent(seeded.client, {
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title,
|
||||
});
|
||||
|
||||
// A second (offline) host makes the view multi-host, which is when the host label earns its space.
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
label: "Secondary Host",
|
||||
primaryLabel: PRIMARY_HOST_LABEL,
|
||||
});
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
|
||||
// The shared daemon may carry agents from other specs, so target this agent by its id.
|
||||
const row = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).toContainText(title);
|
||||
await expect(row).toContainText(PRIMARY_HOST_LABEL);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
9
packages/app/e2e/helpers/command-center.ts
Normal file
9
packages/app/e2e/helpers/command-center.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
// Opens the command center / global search palette from the sidebar and returns its panel.
|
||||
export async function openCommandCenter(page: Page): Promise<Locator> {
|
||||
await page.getByTestId("sidebar-command-center-search").click();
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
return panel;
|
||||
}
|
||||
79
packages/app/e2e/helpers/hosts.ts
Normal file
79
packages/app/e2e/helpers/hosts.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildSeededHost } from "./daemon-registry";
|
||||
|
||||
const REGISTRY_KEY = "@paseo:daemon-registry";
|
||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
||||
|
||||
// The multi-host UI (the command-center host label, the sidebar host filter) only renders once
|
||||
// more than one host exists. The e2e harness runs a single real daemon, so we add an extra registry
|
||||
// entry pointing at an unreachable endpoint: it stays offline, which is enough to make the UI treat
|
||||
// the view as multi-host without standing up a second daemon.
|
||||
//
|
||||
// Must run AFTER the first navigation: the auto-seed fixture writes the registry + nonce on load,
|
||||
// and reseeds on every navigation. We write the full registry here and set the fixture's
|
||||
// disable-once flag, then reload — so the fixture skips its reset and the registry survives. This
|
||||
// avoids depending on the (unspecified) ordering of multiple Playwright init scripts. Optionally
|
||||
// relabels the seeded primary host so assertions can target a distinctive name.
|
||||
export async function addOfflineHostAndReload(
|
||||
page: Page,
|
||||
input: { serverId: string; label: string; primaryLabel?: string },
|
||||
): Promise<void> {
|
||||
const offlineHost = buildSeededHost({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
endpoint: "127.0.0.1:59999",
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await page.evaluate(
|
||||
({ host, keys, primaryLabel }) => {
|
||||
const nonce = localStorage.getItem(keys.nonce);
|
||||
if (!nonce) {
|
||||
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
|
||||
}
|
||||
const raw = localStorage.getItem(keys.registry);
|
||||
const registry: Array<{ serverId: string; label?: string }> = raw ? JSON.parse(raw) : [];
|
||||
if (primaryLabel && registry[0]) {
|
||||
registry[0].label = primaryLabel;
|
||||
}
|
||||
if (!registry.some((entry) => entry.serverId === host.serverId)) {
|
||||
registry.push(host);
|
||||
}
|
||||
localStorage.setItem(keys.registry, JSON.stringify(registry));
|
||||
localStorage.setItem(keys.disableSeedOnce, nonce);
|
||||
},
|
||||
{
|
||||
host: offlineHost,
|
||||
keys: {
|
||||
registry: REGISTRY_KEY,
|
||||
nonce: SEED_NONCE_KEY,
|
||||
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||
},
|
||||
primaryLabel: input.primaryLabel,
|
||||
},
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
// A host's filter row carries a status dot on the left next to its label.
|
||||
export async function expectHostFilterRow(page: Page, serverId: string): Promise<void> {
|
||||
await expect(page.getByTestId(`sidebar-host-filter-${serverId}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`sidebar-host-filter-status-${serverId}`)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function toggleHostFilter(page: Page, serverId: string): Promise<void> {
|
||||
await page.getByTestId(`sidebar-host-filter-${serverId}`).click();
|
||||
}
|
||||
|
||||
export async function selectAllHostsFilter(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-host-filter-all").click();
|
||||
}
|
||||
56
packages/app/e2e/sidebar-host-filter-multi.spec.ts
Normal file
56
packages/app/e2e/sidebar-host-filter-multi.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
addOfflineHostAndReload,
|
||||
expectHostFilterRow,
|
||||
openSidebarDisplayPreferences,
|
||||
selectAllHostsFilter,
|
||||
toggleHostFilter,
|
||||
} from "./helpers/hosts";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
const SECONDARY_HOST_ID = "host-filter-secondary";
|
||||
|
||||
test.describe("Sidebar host filter (multi-select)", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("pins the sidebar to multiple selected hosts at once", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "host-filter-" });
|
||||
const serverId = getServerId();
|
||||
const workspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${serverId}:${seeded.workspaceId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
// A second (offline) host is enough to surface the host filter without a second daemon.
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, { serverId: SECONDARY_HOST_ID, label: "Secondary Host" });
|
||||
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openSidebarDisplayPreferences(page);
|
||||
await expectHostFilterRow(page, serverId);
|
||||
await expectHostFilterRow(page, SECONDARY_HOST_ID);
|
||||
|
||||
// Pin the primary host — its workspace stays visible.
|
||||
await toggleHostFilter(page, serverId);
|
||||
await expect(workspaceRow).toBeVisible();
|
||||
|
||||
// Add the secondary host without clearing the primary. Under single-select this would replace
|
||||
// the primary and hide the workspace; multi-select keeps both pinned, so it stays visible.
|
||||
await toggleHostFilter(page, SECONDARY_HOST_ID);
|
||||
await expect(workspaceRow).toBeVisible();
|
||||
|
||||
// Drop the primary host — only the (empty) secondary host remains pinned, so the workspace hides.
|
||||
await toggleHostFilter(page, serverId);
|
||||
await expect(workspaceRow).toHaveCount(0, { timeout: 10_000 });
|
||||
|
||||
// Back to all hosts — the workspace returns.
|
||||
await selectAllHostsFilter(page);
|
||||
await expect(workspaceRow).toBeVisible({ timeout: 10_000 });
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { Home, Plus, Settings } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useCommandCenter } from "@/hooks/use-command-center";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
@@ -199,9 +200,10 @@ function CommandCenterAgentRow({
|
||||
|
||||
interface CommandCenterAgentRowContentProps {
|
||||
agent: AggregatedAgent;
|
||||
showHost: boolean;
|
||||
}
|
||||
|
||||
function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentProps) {
|
||||
function CommandCenterAgentRowContent({ agent, showHost }: CommandCenterAgentRowContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const titleStyle = useMemo(
|
||||
@@ -213,7 +215,7 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
return (
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
|
||||
<View style={styles.rowMain}>
|
||||
<View style={styles.iconSlot}>
|
||||
<AgentStatusDot
|
||||
@@ -226,7 +228,8 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro
|
||||
<Text style={titleStyle} numberOfLines={1}>
|
||||
{agent.title || t("shell.commandCenter.newAgent")}
|
||||
</Text>
|
||||
<Text style={subtitleStyle} numberOfLines={1}>
|
||||
<Text style={subtitleStyle} numberOfLines={1} testID="command-center-agent-subtitle">
|
||||
{showHost ? `${agent.serverLabel} · ` : ""}
|
||||
{shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -246,6 +249,7 @@ interface AgentItemsSectionProps {
|
||||
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
|
||||
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
|
||||
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
|
||||
showHost: boolean;
|
||||
}
|
||||
|
||||
function AgentItemsSection({
|
||||
@@ -257,6 +261,7 @@ function AgentItemsSection({
|
||||
onSelect,
|
||||
sectionDividerStyle,
|
||||
sectionLabelStyle,
|
||||
showHost,
|
||||
}: AgentItemsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -277,7 +282,7 @@ function AgentItemsSection({
|
||||
onLayout={onRowLayout(rowIndex)}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<CommandCenterAgentRowContent agent={agent} />
|
||||
<CommandCenterAgentRowContent agent={agent} showHost={showHost} />
|
||||
</CommandCenterAgentRow>
|
||||
);
|
||||
})}
|
||||
@@ -302,6 +307,8 @@ export function CommandCenter() {
|
||||
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const showBottomSheet = isCompact && isNative;
|
||||
// Host names only earn their space once results can span more than one host.
|
||||
const showHost = useHosts().length > 1;
|
||||
|
||||
const rowRefs = useRef<Map<number, View>>(new Map());
|
||||
const rowLayouts = useRef<Map<number, { y: number; height: number }>>(new Map());
|
||||
@@ -477,6 +484,7 @@ export function CommandCenter() {
|
||||
onSelect={handleSelectItem}
|
||||
sectionDividerStyle={sectionDividerStyle}
|
||||
sectionLabelStyle={sectionLabelStyle}
|
||||
showHost={showHost}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -256,7 +256,7 @@ function WorkspaceSelectionProbe({
|
||||
|
||||
function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
|
||||
counts.frame += 1;
|
||||
const { projects } = useSidebarWorkspacesList({ hostFilter: SERVER_ID });
|
||||
const { projects } = useSidebarWorkspacesList({ hostFilters: [SERVER_ID] });
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Settings2 } from "lucide-react-native";
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { HostStatusDot } from "@/components/host-status-dot";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { useAppSettings, type WorkspaceTitleSource } from "@/hooks/use-settings";
|
||||
import { useHosts, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import { useHosts } 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 });
|
||||
@@ -36,9 +36,10 @@ interface DisplayPreferenceOption<Value extends string> {
|
||||
|
||||
export function SidebarDisplayPreferencesMenu() {
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
const hostFilter = useSidebarViewStore((state) => state.hostFilter);
|
||||
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
|
||||
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
|
||||
const setHostFilter = useSidebarViewStore((state) => state.setHostFilter);
|
||||
const toggleHostFilter = useSidebarViewStore((state) => state.toggleHostFilter);
|
||||
const clearHostFilters = useSidebarViewStore((state) => state.clearHostFilters);
|
||||
const hosts = useHosts();
|
||||
const {
|
||||
settings: { workspaceTitleSource },
|
||||
@@ -52,13 +53,6 @@ export function SidebarDisplayPreferencesMenu() {
|
||||
[setGroupMode],
|
||||
);
|
||||
|
||||
const handleSelectHost = useCallback(
|
||||
(serverId: string | null) => {
|
||||
setHostFilter(serverId);
|
||||
},
|
||||
[setHostFilter],
|
||||
);
|
||||
|
||||
const handleWorkspaceTitleSourceSelect = useCallback(
|
||||
(source: WorkspaceTitleSource) => {
|
||||
void updateSettings({ workspaceTitleSource: source });
|
||||
@@ -75,6 +69,7 @@ export function SidebarDisplayPreferencesMenu() {
|
||||
);
|
||||
|
||||
const showHostFilter = hosts.length > 1;
|
||||
const allHostsSelected = hostFilters.length === 0;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -105,20 +100,21 @@ export function SidebarDisplayPreferencesMenu() {
|
||||
<View style={styles.menuHeader}>
|
||||
<Text style={styles.menuHeaderLabel}>Filter</Text>
|
||||
</View>
|
||||
<HostFilterItem
|
||||
label="All hosts"
|
||||
value={null}
|
||||
hostFilter={hostFilter}
|
||||
onSelect={handleSelectHost}
|
||||
/>
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-host-filter-all"
|
||||
selected={allHostsSelected}
|
||||
closeOnSelect={false}
|
||||
onSelect={clearHostFilters}
|
||||
>
|
||||
All hosts
|
||||
</DropdownMenuItem>
|
||||
{hosts.map((host) => (
|
||||
<HostFilterItem
|
||||
key={host.serverId}
|
||||
label={host.label?.trim() || host.serverId}
|
||||
serverId={host.serverId}
|
||||
value={host.serverId}
|
||||
hostFilter={hostFilter}
|
||||
onSelect={handleSelectHost}
|
||||
selected={hostFilters.includes(host.serverId)}
|
||||
onToggle={toggleHostFilter}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -167,25 +163,32 @@ function DisplayPreferenceMenuItem<Value extends string>({
|
||||
function HostFilterItem({
|
||||
label,
|
||||
serverId,
|
||||
value,
|
||||
hostFilter,
|
||||
onSelect,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
serverId?: string;
|
||||
value: string | null;
|
||||
hostFilter: string | null;
|
||||
onSelect: (serverId: string | null) => void;
|
||||
serverId: string;
|
||||
selected: boolean;
|
||||
onToggle: (serverId: string) => void;
|
||||
}) {
|
||||
const isSelected = hostFilter === value;
|
||||
const handleSelect = useCallback(() => onSelect(value), [value, onSelect]);
|
||||
const status = useHostRuntimeSnapshot(serverId ?? "");
|
||||
const subtitle = serverId
|
||||
? formatConnectionStatus(status?.connectionStatus ?? "idle")
|
||||
: undefined;
|
||||
const handleSelect = useCallback(() => onToggle(serverId), [serverId, onToggle]);
|
||||
const leading = useMemo(
|
||||
() => (
|
||||
<View testID={`sidebar-host-filter-status-${serverId}`}>
|
||||
<HostStatusDot serverId={serverId} />
|
||||
</View>
|
||||
),
|
||||
[serverId],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem selected={isSelected} description={subtitle} onSelect={handleSelect}>
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-host-filter-${serverId}`}
|
||||
selected={selected}
|
||||
closeOnSelect={false}
|
||||
leading={leading}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
@@ -86,7 +86,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
});
|
||||
useSidebarViewStore.setState({
|
||||
groupMode: "project",
|
||||
hostFilter: null,
|
||||
hostFilters: [],
|
||||
});
|
||||
|
||||
act(() => {
|
||||
@@ -216,7 +216,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
);
|
||||
useSessionStore.getState().setHasHydratedWorkspaces("host-a", true);
|
||||
useSessionStore.getState().setHasHydratedWorkspaces("host-b", true);
|
||||
useSidebarViewStore.getState().setHostFilter("host-b");
|
||||
useSidebarViewStore.getState().toggleHostFilter("host-b");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -89,7 +89,7 @@ export interface SidebarWorkspacesListResult {
|
||||
}
|
||||
|
||||
export function useSidebarWorkspacesList(options?: {
|
||||
hostFilter?: string | null;
|
||||
hostFilters?: readonly string[];
|
||||
enabled?: boolean;
|
||||
}): SidebarWorkspacesListResult {
|
||||
const runtime = getHostRuntimeStore();
|
||||
@@ -97,27 +97,31 @@ export function useSidebarWorkspacesList(options?: {
|
||||
const hostRegistryLoaded = useHostRegistryLoaded();
|
||||
const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]);
|
||||
|
||||
const storeHostFilter = useSidebarViewStore((state) => state.hostFilter);
|
||||
const hostFilter = options?.hostFilter ?? storeHostFilter;
|
||||
const reconcileHostFilter = useSidebarViewStore((state) => state.reconcileHostFilter);
|
||||
const hasHostFilterMatch = hostFilter ? allServerIds.includes(hostFilter) : false;
|
||||
const effectiveHostFilter =
|
||||
hostFilter && (!hostRegistryLoaded || hasHostFilterMatch) ? hostFilter : null;
|
||||
const storeHostFilters = useSidebarViewStore((state) => state.hostFilters);
|
||||
const hostFilters = options?.hostFilters ?? storeHostFilters;
|
||||
const reconcileHostFilters = useSidebarViewStore((state) => state.reconcileHostFilters);
|
||||
const isActive = options?.enabled !== false;
|
||||
|
||||
const serverIds = useMemo(() => {
|
||||
if (effectiveHostFilter) {
|
||||
return allServerIds.filter((id) => id === effectiveHostFilter);
|
||||
if (hostFilters.length === 0) {
|
||||
return allServerIds;
|
||||
}
|
||||
return allServerIds;
|
||||
}, [allServerIds, effectiveHostFilter]);
|
||||
const selected = new Set(hostFilters);
|
||||
const matched = allServerIds.filter((id) => selected.has(id));
|
||||
// Registry has settled but none of the pinned hosts still exist — fall back to every
|
||||
// host rather than leaving the sidebar empty.
|
||||
if (hostRegistryLoaded && matched.length === 0) {
|
||||
return allServerIds;
|
||||
}
|
||||
return matched;
|
||||
}, [allServerIds, hostFilters, hostRegistryLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRegistryLoaded) {
|
||||
return;
|
||||
}
|
||||
reconcileHostFilter(allServerIds);
|
||||
}, [allServerIds, hostRegistryLoaded, reconcileHostFilter]);
|
||||
reconcileHostFilters(allServerIds);
|
||||
}, [allServerIds, hostRegistryLoaded, reconcileHostFilters]);
|
||||
|
||||
const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER);
|
||||
|
||||
|
||||
@@ -39,24 +39,44 @@ describe("sidebar view store", () => {
|
||||
beforeEach(() => {
|
||||
useSidebarViewStore.setState({
|
||||
groupMode: "project",
|
||||
hostFilter: null,
|
||||
hostFilters: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a host filter that still points at an available host", () => {
|
||||
useSidebarViewStore.getState().setHostFilter("host-a");
|
||||
it("toggles multiple hosts into and out of the filter", () => {
|
||||
const store = useSidebarViewStore.getState();
|
||||
store.toggleHostFilter("host-a");
|
||||
store.toggleHostFilter("host-b");
|
||||
|
||||
useSidebarViewStore.getState().reconcileHostFilter(["host-a", "host-b"]);
|
||||
expect(useSidebarViewStore.getState().hostFilters).toEqual(["host-a", "host-b"]);
|
||||
|
||||
expect(useSidebarViewStore.getState().hostFilter).toBe("host-a");
|
||||
store.toggleHostFilter("host-a");
|
||||
|
||||
expect(useSidebarViewStore.getState().hostFilters).toEqual(["host-b"]);
|
||||
|
||||
store.clearHostFilters();
|
||||
|
||||
expect(useSidebarViewStore.getState().hostFilters).toEqual([]);
|
||||
});
|
||||
|
||||
it("clears a host filter after that host is removed", () => {
|
||||
useSidebarViewStore.getState().setHostFilter("removed-host");
|
||||
it("keeps host filters that still point at available hosts", () => {
|
||||
const store = useSidebarViewStore.getState();
|
||||
store.toggleHostFilter("host-a");
|
||||
store.toggleHostFilter("host-b");
|
||||
|
||||
useSidebarViewStore.getState().reconcileHostFilter(["host-a"]);
|
||||
store.reconcileHostFilters(["host-a", "host-b", "host-c"]);
|
||||
|
||||
expect(useSidebarViewStore.getState().hostFilter).toBeNull();
|
||||
expect(useSidebarViewStore.getState().hostFilters).toEqual(["host-a", "host-b"]);
|
||||
});
|
||||
|
||||
it("drops a host filter after that host is removed", () => {
|
||||
const store = useSidebarViewStore.getState();
|
||||
store.toggleHostFilter("host-a");
|
||||
store.toggleHostFilter("removed-host");
|
||||
|
||||
store.reconcileHostFilters(["host-a"]);
|
||||
|
||||
expect(useSidebarViewStore.getState().hostFilters).toEqual(["host-a"]);
|
||||
});
|
||||
|
||||
it("migrates legacy per-host group modes to the new global mode", () => {
|
||||
@@ -69,11 +89,11 @@ describe("sidebar view store", () => {
|
||||
}),
|
||||
).toEqual({
|
||||
groupMode: "status",
|
||||
hostFilter: null,
|
||||
hostFilters: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps current persisted sidebar view state during version migration", () => {
|
||||
it("migrates a pre-v2 single host filter to the multi-host list", () => {
|
||||
expect(
|
||||
migrateSidebarViewState({
|
||||
groupMode: "status",
|
||||
@@ -81,7 +101,19 @@ describe("sidebar view store", () => {
|
||||
}),
|
||||
).toEqual({
|
||||
groupMode: "status",
|
||||
hostFilter: "host-a",
|
||||
hostFilters: ["host-a"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps current persisted sidebar view state during version migration", () => {
|
||||
expect(
|
||||
migrateSidebarViewState({
|
||||
groupMode: "status",
|
||||
hostFilters: ["host-a", "host-b"],
|
||||
}),
|
||||
).toEqual({
|
||||
groupMode: "status",
|
||||
hostFilters: ["host-a", "host-b"],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,8 +140,8 @@ describe("sidebar view store", () => {
|
||||
it("uses the new storage key without reading the legacy key when current state exists", async () => {
|
||||
const storage = createMemoryStorage({
|
||||
"sidebar-view": JSON.stringify({
|
||||
state: { groupMode: "project", hostFilter: "host-a" },
|
||||
version: 1,
|
||||
state: { groupMode: "project", hostFilters: ["host-a"] },
|
||||
version: 2,
|
||||
}),
|
||||
"sidebar-group-mode": JSON.stringify({
|
||||
state: { groupModeByServerId: { "host-b": "status" } },
|
||||
@@ -121,8 +153,8 @@ describe("sidebar view store", () => {
|
||||
|
||||
expect(value).toBe(
|
||||
JSON.stringify({
|
||||
state: { groupMode: "project", hostFilter: "host-a" },
|
||||
version: 1,
|
||||
state: { groupMode: "project", hostFilters: ["host-a"] },
|
||||
version: 2,
|
||||
}),
|
||||
);
|
||||
expect(storage.reads).toEqual(["sidebar-view"]);
|
||||
|
||||
@@ -6,19 +6,21 @@ export type SidebarGroupMode = "project" | "status";
|
||||
|
||||
const SIDEBAR_VIEW_STORAGE_KEY = "sidebar-view";
|
||||
const LEGACY_SIDEBAR_GROUP_MODE_STORAGE_KEY = "sidebar-group-mode";
|
||||
const SIDEBAR_VIEW_STORE_VERSION = 1;
|
||||
const SIDEBAR_VIEW_STORE_VERSION = 2;
|
||||
|
||||
interface SidebarViewStoreState {
|
||||
groupMode: SidebarGroupMode;
|
||||
hostFilter: string | null;
|
||||
// Empty means "all hosts". A non-empty list pins the sidebar to those hosts.
|
||||
hostFilters: string[];
|
||||
setGroupMode: (mode: SidebarGroupMode) => void;
|
||||
setHostFilter: (serverId: string | null) => void;
|
||||
reconcileHostFilter: (serverIds: readonly string[]) => void;
|
||||
toggleHostFilter: (serverId: string) => void;
|
||||
clearHostFilters: () => void;
|
||||
reconcileHostFilters: (serverIds: readonly string[]) => void;
|
||||
}
|
||||
|
||||
interface SidebarViewPersistedState {
|
||||
groupMode: SidebarGroupMode;
|
||||
hostFilter: string | null;
|
||||
hostFilters: string[];
|
||||
}
|
||||
|
||||
function isSidebarGroupMode(value: unknown): value is SidebarGroupMode {
|
||||
@@ -40,19 +42,32 @@ function readLegacyGroupMode(persistedState: Record<string, unknown>): SidebarGr
|
||||
return modes.includes("status") ? "status" : "project";
|
||||
}
|
||||
|
||||
// Reads the host filter from any persisted shape: the current `hostFilters` array, or the
|
||||
// pre-v2 single `hostFilter` string (null/absent meant "all hosts").
|
||||
function readHostFilters(persistedState: Record<string, unknown>): string[] {
|
||||
const hostFilters = persistedState.hostFilters;
|
||||
if (Array.isArray(hostFilters)) {
|
||||
return hostFilters.filter((value): value is string => typeof value === "string");
|
||||
}
|
||||
// COMPAT(sidebarHostFilters): added in v0.1.102, remove after 2026-12-30 once pre-v2 persisted
|
||||
// sidebar state (a single `hostFilter` string) has aged out.
|
||||
const legacyHostFilter = persistedState.hostFilter;
|
||||
return typeof legacyHostFilter === "string" ? [legacyHostFilter] : [];
|
||||
}
|
||||
|
||||
export function migrateSidebarViewState(persistedState: unknown): SidebarViewPersistedState {
|
||||
if (!isRecord(persistedState)) {
|
||||
return { groupMode: "project", hostFilter: null };
|
||||
return { groupMode: "project", hostFilters: [] };
|
||||
}
|
||||
|
||||
const legacyGroupMode = readLegacyGroupMode(persistedState);
|
||||
if (legacyGroupMode) {
|
||||
return { groupMode: legacyGroupMode, hostFilter: null };
|
||||
return { groupMode: legacyGroupMode, hostFilters: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
groupMode: isSidebarGroupMode(persistedState.groupMode) ? persistedState.groupMode : "project",
|
||||
hostFilter: typeof persistedState.hostFilter === "string" ? persistedState.hostFilter : null,
|
||||
hostFilters: readHostFilters(persistedState),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,15 +91,26 @@ export const useSidebarViewStore = create<SidebarViewStoreState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
groupMode: "project",
|
||||
hostFilter: null,
|
||||
hostFilters: [],
|
||||
setGroupMode: (mode) => set({ groupMode: mode }),
|
||||
setHostFilter: (serverId) => set({ hostFilter: serverId }),
|
||||
reconcileHostFilter: (serverIds) =>
|
||||
toggleHostFilter: (serverId) =>
|
||||
set((state) => ({
|
||||
hostFilters: state.hostFilters.includes(serverId)
|
||||
? state.hostFilters.filter((id) => id !== serverId)
|
||||
: [...state.hostFilters, serverId],
|
||||
})),
|
||||
clearHostFilters: () => set({ hostFilters: [] }),
|
||||
reconcileHostFilters: (serverIds) =>
|
||||
set((state) => {
|
||||
if (!state.hostFilter || serverIds.includes(state.hostFilter)) {
|
||||
if (state.hostFilters.length === 0) {
|
||||
return state;
|
||||
}
|
||||
return { hostFilter: null };
|
||||
const allowed = new Set(serverIds);
|
||||
const next = state.hostFilters.filter((id) => allowed.has(id));
|
||||
if (next.length === state.hostFilters.length) {
|
||||
return state;
|
||||
}
|
||||
return { hostFilters: next };
|
||||
}),
|
||||
}),
|
||||
{
|
||||
@@ -93,7 +119,7 @@ export const useSidebarViewStore = create<SidebarViewStoreState>()(
|
||||
storage: createJSONStorage(createSidebarViewStorage),
|
||||
partialize: (state) => ({
|
||||
groupMode: state.groupMode,
|
||||
hostFilter: state.hostFilter,
|
||||
hostFilters: state.hostFilters,
|
||||
}),
|
||||
migrate: migrateSidebarViewState,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user