Files
paseo/packages/app/src/stores/sidebar-view-store.ts
T
Mohamed BoudraandGitHub e9431517a0 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.
2026-06-30 09:40:24 +02:00

128 lines
4.4 KiB
TypeScript

import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist, type StateStorage } from "zustand/middleware";
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 = 2;
interface SidebarViewStoreState {
groupMode: SidebarGroupMode;
// Empty means "all hosts". A non-empty list pins the sidebar to those hosts.
hostFilters: string[];
setGroupMode: (mode: SidebarGroupMode) => void;
toggleHostFilter: (serverId: string) => void;
clearHostFilters: () => void;
reconcileHostFilters: (serverIds: readonly string[]) => void;
}
interface SidebarViewPersistedState {
groupMode: SidebarGroupMode;
hostFilters: string[];
}
function isSidebarGroupMode(value: unknown): value is SidebarGroupMode {
return value === "project" || value === "status";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readLegacyGroupMode(persistedState: Record<string, unknown>): SidebarGroupMode | null {
const groupModeByServerId = persistedState.groupModeByServerId;
if (!isRecord(groupModeByServerId)) {
return null;
}
const modes = Object.values(groupModeByServerId).filter(isSidebarGroupMode);
if (modes.length === 0) return null;
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", hostFilters: [] };
}
const legacyGroupMode = readLegacyGroupMode(persistedState);
if (legacyGroupMode) {
return { groupMode: legacyGroupMode, hostFilters: [] };
}
return {
groupMode: isSidebarGroupMode(persistedState.groupMode) ? persistedState.groupMode : "project",
hostFilters: readHostFilters(persistedState),
};
}
export function createSidebarViewStorage(
backingStorage: StateStorage = AsyncStorage,
): StateStorage {
return {
getItem: async (name) => {
const value = await backingStorage.getItem(name);
if (value !== null || name !== SIDEBAR_VIEW_STORAGE_KEY) {
return value;
}
return backingStorage.getItem(LEGACY_SIDEBAR_GROUP_MODE_STORAGE_KEY);
},
setItem: (name, value) => backingStorage.setItem(name, value),
removeItem: (name) => backingStorage.removeItem(name),
};
}
export const useSidebarViewStore = create<SidebarViewStoreState>()(
persist(
(set) => ({
groupMode: "project",
hostFilters: [],
setGroupMode: (mode) => set({ groupMode: mode }),
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.hostFilters.length === 0) {
return state;
}
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 };
}),
}),
{
name: SIDEBAR_VIEW_STORAGE_KEY,
version: SIDEBAR_VIEW_STORE_VERSION,
storage: createJSONStorage(createSidebarViewStorage),
partialize: (state) => ({
groupMode: state.groupMode,
hostFilters: state.hostFilters,
}),
migrate: migrateSidebarViewState,
},
),
);