Files
paseo/packages/app/src/stores/session-store-hooks/selectors.ts
T
Mohamed BoudraandGitHub 99eca19a76 Keep selected projects when switching hosts (#2700)
* fix(app): preserve project selection across hosts

Rendered project identity and persisted equivalence were conflated after duplicate-project handling. Keep them separate so host changes resolve selectable equivalents deterministically without resetting legacy sidebar identity.

* fix(app): restore selected clone after host round trip

Keep manual origin and current destination placements separately so switching back restores the chosen checkout while archive gaps preserve the active host selection.

* fix(app): key status icons by project view

* fix(app): preserve origin during archive gaps
2026-07-31 15:42:08 +02:00

276 lines
7.7 KiB
TypeScript

import equal from "fast-deep-equal";
import {
buildWorkspaceStructureProjects,
type WorkspaceStructure,
type WorkspaceStructureProject,
} from "@/projects/workspace-structure";
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
import type { ProjectDescriptor, WorkspaceDescriptor } from "../session-store";
export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
export type { WorkspaceStructure, WorkspaceStructureProject } from "@/projects/workspace-structure";
export interface SessionsSnapshot {
sessions: Record<
string,
{
hasHydratedWorkspaces?: boolean;
workspaces: Map<string, WorkspaceDescriptor>;
projects?: Map<string, ProjectDescriptor>;
}
>;
}
export interface SidebarOrderSnapshot {
projectOrder: string[];
workspaceOrderByProject: Record<string, string[]>;
}
const EMPTY_WORKSPACE_KEYS: string[] = [];
const EMPTY_WORKSPACE_STRUCTURE: WorkspaceStructure = { projects: [] };
export const workspaceEqualityFns = {
identity: Object.is as (a: unknown, b: unknown) => boolean,
deep: equal as (a: unknown, b: unknown) => boolean,
};
function applyStoredOrdering<T>(input: {
items: T[];
storedOrder: readonly string[];
getKey: (item: T) => string;
}): T[] {
if (input.items.length <= 1 || input.storedOrder.length === 0) {
return input.items;
}
const itemByKey = new Map<string, T>();
for (const item of input.items) {
itemByKey.set(input.getKey(item), item);
}
const prunedOrder: string[] = [];
const seen = new Set<string>();
for (const key of input.storedOrder) {
if (!itemByKey.has(key) || seen.has(key)) {
continue;
}
seen.add(key);
prunedOrder.push(key);
}
if (prunedOrder.length === 0) {
return input.items;
}
const orderedSet = new Set(prunedOrder);
const ordered: T[] = [];
let orderedIndex = 0;
for (const item of input.items) {
const key = input.getKey(item);
if (!orderedSet.has(key)) {
ordered.push(item);
continue;
}
const targetKey = prunedOrder[orderedIndex] ?? key;
orderedIndex += 1;
ordered.push(itemByKey.get(targetKey) ?? item);
}
return ordered;
}
export function selectWorkspace(
state: SessionsSnapshot,
serverId: string | null,
workspaceId: string | null,
): WorkspaceDescriptor | null {
if (!serverId || !workspaceId) {
return null;
}
const workspaces = state.sessions[serverId]?.workspaces;
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
workspaces,
workspaceId,
});
return workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null;
}
export function selectWorkspaceFields<T>(
state: SessionsSnapshot,
serverId: string | null,
workspaceId: string | null,
project: (w: WorkspaceDescriptor) => T,
): T | null {
const workspace = selectWorkspace(state, serverId, workspaceId);
return workspace ? project(workspace) : null;
}
export function selectWorkspaceDirectory(
state: SessionsSnapshot,
serverId: string | null,
workspaceId: string | null,
): string | null {
return selectWorkspace(state, serverId, workspaceId)?.workspaceDirectory || null;
}
export function selectWorkspaceExists(
state: SessionsSnapshot,
serverId: string | null,
workspaceId: string | null,
): boolean {
return selectWorkspace(state, serverId, workspaceId) !== null;
}
export function selectHasHydratedWorkspaces(
state: SessionsSnapshot,
serverId: string | null,
): boolean {
return serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false;
}
export function selectHydratedWorkspaceServerIds(
state: SessionsSnapshot,
serverIds: readonly string[],
): string[] {
return serverIds.filter((serverId) => state.sessions[serverId]?.hasHydratedWorkspaces === true);
}
export function selectWorkspaceStructureProjects(
state: SessionsSnapshot,
serverIds: readonly string[],
): WorkspaceStructureProject[] {
const sessions: Array<{
serverId: string;
workspaces: Iterable<WorkspaceDescriptor>;
projects: Iterable<ProjectDescriptor>;
}> = [];
for (const serverId of serverIds) {
const session = state.sessions[serverId];
const workspaces = session?.workspaces;
const projects = session?.projects;
if (!projects || projects.size === 0) {
continue;
}
sessions.push({
serverId,
workspaces: workspaces?.values() ?? [],
projects: projects.values(),
});
}
if (sessions.length === 0) {
return EMPTY_WORKSPACE_STRUCTURE.projects;
}
return buildWorkspaceStructureProjects({ sessions });
}
export function selectProject(
state: SessionsSnapshot,
serverId: string | null,
projectId: string | null,
): ProjectDescriptor | null {
if (!serverId || !projectId) return null;
return state.sessions[serverId]?.projects?.get(projectId) ?? null;
}
export function selectProjectIdForServer(
state: SessionsSnapshot,
input: {
sourceServerId: string;
projectId: string;
targetServerId: string;
},
): string | null {
if (input.sourceServerId === input.targetServerId) return input.projectId;
const source = selectProject(state, input.sourceServerId, input.projectId);
if (!source?.projectKey) return null;
for (const project of state.sessions[input.targetServerId]?.projects?.values() ?? []) {
if (project.projectKey === source.projectKey) return project.projectId;
}
return null;
}
export function selectProjectOrder(state: SidebarOrderSnapshot): string[] {
return state.projectOrder ?? EMPTY_WORKSPACE_KEYS;
}
export function selectWorkspaceOrderByScope(state: SidebarOrderSnapshot): Record<string, string[]> {
return state.workspaceOrderByProject ?? {};
}
export function composeWorkspaceStructure(input: {
projects: WorkspaceStructureProject[];
projectOrder: readonly string[];
workspaceOrderByScope: Record<string, readonly string[]>;
}): WorkspaceStructure {
if (input.projects.length === 0) {
return EMPTY_WORKSPACE_STRUCTURE;
}
const orderedProjects = applyStoredOrdering({
items: input.projects.map((project) => {
const workspaceOrder = input.workspaceOrderByScope[project.viewKey] ?? EMPTY_WORKSPACE_KEYS;
return {
...project,
workspaceKeys: applyStoredOrdering({
items: project.workspaceKeys,
storedOrder: workspaceOrder,
getKey: (workspaceKey) => workspaceKey,
}),
};
}),
storedOrder: input.projectOrder,
getKey: (project) => project.viewKey,
});
return { projects: orderedProjects };
}
export function selectWorkspaceKeys(state: SessionsSnapshot, serverId: string | null): string[] {
if (!serverId) {
return EMPTY_WORKSPACE_KEYS;
}
const workspaces = state.sessions[serverId]?.workspaces;
return workspaces ? Array.from(workspaces.keys()) : EMPTY_WORKSPACE_KEYS;
}
export function selectRecommendedProjectPaths(
state: SessionsSnapshot,
serverId: string | null,
): string[] {
if (!serverId) {
return EMPTY_WORKSPACE_KEYS;
}
const workspaces = state.sessions[serverId]?.workspaces;
if (!workspaces) {
return EMPTY_WORKSPACE_KEYS;
}
return Array.from(workspaces.values())
.map((workspace) => workspace.projectRootPath)
.filter((path) => path.length > 0);
}
export function selectHasWorkspaces(state: SessionsSnapshot, serverId: string | null): boolean {
if (!serverId) {
return false;
}
return (state.sessions[serverId]?.workspaces?.size ?? 0) > 0;
}
export function selectWorkspaceStatusesForBadges(
state: SessionsSnapshot,
): DesktopBadgeWorkspaceStatus[] {
const statuses: DesktopBadgeWorkspaceStatus[] = [];
for (const session of Object.values(state.sessions)) {
for (const workspace of session.workspaces.values()) {
statuses.push(workspace.status);
}
}
return statuses;
}