refactor(app): reshape session store reads around narrow hook surface
Eliminate sidebar re-render cascades by fixing both sides of the subscription contract: - Session-store writes preserve identity: setWorkspaces, mergeWorkspaces, removeWorkspace, and patchWorkspaceScripts return the previous Map/entry reference when content is unchanged. - HostRuntimeController.updateSnapshot only notifies when a patched field actually changed; idle probe ticks no longer replace the snapshot. - New canonical hook surface in stores/session-store-hooks.ts (useWorkspace, useWorkspaceFields, useWorkspaceStructure, useWorkspaceKeys, useResolveWorkspaceIdByCwd, useWorkspaceStatusesForBadges, useWorkspaceExecutionAuthority, useRecommendedProjectPaths, useHasWorkspaces). These are the only place to subscribe to workspaces. - 15 greedy subscribers migrated to the hook surface. The sidebar is now driven by useWorkspaceStructure for identity/ordering; each row hydrates its own descriptor via useWorkspaceFields, so a workspace update re-renders only its row. Archive-redirect callbacks switched to event-time useSessionStore.getState(). - Deleted dead code: use-session-directory.ts, workspace-fetch-debug.ts, and the unused SidebarProjectEntry aggregates (activeCount, totalWorkspaces, aggregated statusBucket). No user-visible behavior change. Targeted tests green (79 tests across host-runtime, session-store, session-store-hooks, sidebar list, sidebar row model, sidebar shortcuts, workspace source of truth).
This commit is contained in:
@@ -1,13 +1,9 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { getIsElectronRuntimeMac } from "@/constants/layout";
|
||||
import { useAggregatedAgents } from "./use-aggregated-agents";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
deriveMacDockBadgeCountFromWorkspaceStatuses,
|
||||
type DesktopBadgeWorkspaceStatus,
|
||||
} from "@/utils/desktop-badge-state";
|
||||
import { useWorkspaceStatusesForBadges } from "@/stores/session-store-hooks";
|
||||
import { deriveMacDockBadgeCountFromWorkspaceStatuses } from "@/utils/desktop-badge-state";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
type FaviconStatus = "none" | "running" | "attention";
|
||||
@@ -99,17 +95,7 @@ async function updateMacDockBadge(count?: number) {
|
||||
|
||||
export function useFaviconStatus() {
|
||||
const { agents } = useAggregatedAgents();
|
||||
const workspaceStatuses = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const statuses: DesktopBadgeWorkspaceStatus[] = [];
|
||||
for (const session of Object.values(state.sessions)) {
|
||||
for (const workspace of session.workspaces.values()) {
|
||||
statuses.push(workspace.status);
|
||||
}
|
||||
}
|
||||
return statuses;
|
||||
}),
|
||||
);
|
||||
const workspaceStatuses = useWorkspaceStatusesForBadges();
|
||||
const [colorScheme, setColorScheme] = useState<ColorScheme>(getSystemColorScheme);
|
||||
const lastDockBadgeCountRef = useRef<number | undefined>(undefined);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AgentProvider, ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionForServer } from "./use-session-directory";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { queryClient as singletonQueryClient } from "@/query/query-client";
|
||||
|
||||
function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null {
|
||||
@@ -39,9 +39,8 @@ export function useProvidersSnapshot(
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const normalizedCwd = cwd?.trim() || undefined;
|
||||
const normalizedCwdKey = normalizeProvidersSnapshotCwdKey(normalizedCwd);
|
||||
const supportsSnapshot = useSessionForServer(
|
||||
serverId,
|
||||
(session) => session?.serverInfo?.features?.providersSnapshot === true,
|
||||
const supportsSnapshot = useSessionStore(
|
||||
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providersSnapshot === true,
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useSessionStore, type SessionState } from "@/stores/session-store";
|
||||
|
||||
export function useSessionDirectory(): Map<string, SessionState> {
|
||||
const sessions = useSessionStore((state) => state.sessions);
|
||||
|
||||
return useMemo(() => {
|
||||
return new Map<string, SessionState>(Object.entries(sessions));
|
||||
}, [sessions]);
|
||||
}
|
||||
|
||||
type SessionSelector<T> = (session: SessionState | null) => T;
|
||||
type EqualityFn<T> = ((left: T, right: T) => boolean) | undefined;
|
||||
|
||||
export function useSessionForServer(serverId: string | null): SessionState | null;
|
||||
export function useSessionForServer<T>(
|
||||
serverId: string | null,
|
||||
selector: SessionSelector<T>,
|
||||
equalityFn?: EqualityFn<T>,
|
||||
): T;
|
||||
export function useSessionForServer<T>(
|
||||
serverId: string | null,
|
||||
selector?: SessionSelector<T>,
|
||||
equalityFn?: EqualityFn<T>,
|
||||
): SessionState | null | T {
|
||||
const storeSelector = useCallback(
|
||||
(state: ReturnType<typeof useSessionStore.getState>): SessionState | null | T => {
|
||||
const session = serverId ? (state.sessions[serverId] ?? null) : null;
|
||||
return selector ? selector(session) : session;
|
||||
},
|
||||
[selector, serverId],
|
||||
);
|
||||
|
||||
return useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
storeSelector,
|
||||
equalityFn as EqualityFn<SessionState | null | T>,
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceScriptPayload } from "@server/shared/messages";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||
});
|
||||
|
||||
import {
|
||||
appendMissingOrderKeys,
|
||||
applyStoredOrdering,
|
||||
buildSidebarProjectsFromWorkspaces,
|
||||
buildSidebarProjectsFromStructure,
|
||||
} from "./use-sidebar-workspaces-list";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { WorkspaceStructureProject } from "@/stores/session-store-hooks";
|
||||
|
||||
interface OrderedItem {
|
||||
key: string;
|
||||
@@ -15,57 +19,22 @@ function item(key: string): OrderedItem {
|
||||
return { key };
|
||||
}
|
||||
|
||||
function workspace(
|
||||
input: Pick<WorkspaceDescriptor, "id" | "projectId" | "name" | "status"> &
|
||||
Partial<
|
||||
Pick<
|
||||
WorkspaceDescriptor,
|
||||
| "projectDisplayName"
|
||||
| "projectRootPath"
|
||||
| "workspaceDirectory"
|
||||
| "projectKind"
|
||||
| "workspaceKind"
|
||||
| "scripts"
|
||||
>
|
||||
>,
|
||||
): WorkspaceDescriptor {
|
||||
function project(input: {
|
||||
projectKey: string;
|
||||
projectName?: string;
|
||||
projectKind?: WorkspaceStructureProject["projectKind"];
|
||||
iconWorkingDir?: string;
|
||||
workspaceKeys: string[];
|
||||
}): WorkspaceStructureProject {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectId,
|
||||
projectDisplayName: input.projectDisplayName ?? input.projectId,
|
||||
projectRootPath: input.projectRootPath ?? input.id,
|
||||
workspaceDirectory: input.workspaceDirectory ?? input.projectRootPath ?? input.id,
|
||||
projectKey: input.projectKey,
|
||||
projectName: input.projectName ?? input.projectKey,
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "checkout",
|
||||
name: input.name,
|
||||
status: input.status,
|
||||
diffStat: null,
|
||||
scripts: input.scripts ?? [],
|
||||
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
|
||||
workspaceKeys: input.workspaceKeys,
|
||||
};
|
||||
}
|
||||
|
||||
const runningScript: WorkspaceScriptPayload = {
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "web.paseo.localhost",
|
||||
port: 3000,
|
||||
proxyUrl: "http://web.paseo.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
};
|
||||
|
||||
const stoppedScript: WorkspaceScriptPayload = {
|
||||
scriptName: "api",
|
||||
type: "service",
|
||||
hostname: "api.paseo.localhost",
|
||||
port: 3001,
|
||||
proxyUrl: "http://api.paseo.localhost:6767",
|
||||
lifecycle: "stopped",
|
||||
health: null,
|
||||
exitCode: null,
|
||||
};
|
||||
|
||||
describe("applyStoredOrdering", () => {
|
||||
it("keeps unknown items on the baseline while applying stored order", () => {
|
||||
const result = applyStoredOrdering({
|
||||
@@ -121,187 +90,52 @@ describe("appendMissingOrderKeys", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSidebarProjectsFromWorkspaces", () => {
|
||||
it("uses workspace descriptor name and status directly", () => {
|
||||
const workspaces: WorkspaceDescriptor[] = [
|
||||
workspace({
|
||||
id: "/repo/main",
|
||||
projectId: "project-1",
|
||||
name: "feat/hard-cut",
|
||||
status: "failed",
|
||||
}),
|
||||
];
|
||||
|
||||
const projects = buildSidebarProjectsFromWorkspaces({
|
||||
describe("buildSidebarProjectsFromStructure", () => {
|
||||
it("creates structural workspace rows from ordered workspace keys", () => {
|
||||
const projects = buildSidebarProjectsFromStructure({
|
||||
serverId: "srv",
|
||||
workspaces,
|
||||
projectOrder: [],
|
||||
workspaceOrderByScope: {},
|
||||
projects: [
|
||||
project({
|
||||
projectKey: "project-1",
|
||||
projectName: "Project 1",
|
||||
iconWorkingDir: "/repo/main",
|
||||
workspaceKeys: ["ws-main"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(projects).toHaveLength(1);
|
||||
expect(projects[0]?.statusBucket).toBe("failed");
|
||||
expect(projects[0]?.workspaces[0]?.name).toBe("feat/hard-cut");
|
||||
expect(projects[0]?.workspaces[0]?.statusBucket).toBe("failed");
|
||||
expect(projects[0]?.projectName).toBe("Project 1");
|
||||
expect(projects[0]?.workspaces[0]).toMatchObject({
|
||||
workspaceKey: "srv:ws-main",
|
||||
serverId: "srv",
|
||||
workspaceId: "ws-main",
|
||||
projectRootPath: "/repo/main",
|
||||
projectKind: "git",
|
||||
});
|
||||
});
|
||||
|
||||
it("threads scripts into workspace rows and derives hasRunningScripts", () => {
|
||||
const projects = buildSidebarProjectsFromWorkspaces({
|
||||
it("preserves the structure hook project order", () => {
|
||||
const projects = buildSidebarProjectsFromStructure({
|
||||
serverId: "srv",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "/repo/main",
|
||||
projectId: "project-1",
|
||||
name: "main",
|
||||
status: "running",
|
||||
scripts: [runningScript, stoppedScript],
|
||||
}),
|
||||
projects: [
|
||||
project({ projectKey: "project-b", workspaceKeys: ["ws-b"] }),
|
||||
project({ projectKey: "project-a", workspaceKeys: ["ws-a"] }),
|
||||
],
|
||||
projectOrder: [],
|
||||
workspaceOrderByScope: {},
|
||||
});
|
||||
|
||||
expect(projects[0]?.workspaces[0]?.scripts).toEqual([runningScript, stoppedScript]);
|
||||
expect(projects[0]?.workspaces[0]?.hasRunningScripts).toBe(true);
|
||||
expect(projects.map((project) => project.projectKey)).toEqual(["project-b", "project-a"]);
|
||||
});
|
||||
|
||||
it("preserves stored project order even when input order differs", () => {
|
||||
const initialWorkspaces: WorkspaceDescriptor[] = [
|
||||
workspace({
|
||||
id: "/repo/b",
|
||||
projectId: "project-b",
|
||||
name: "feat/b",
|
||||
status: "running",
|
||||
}),
|
||||
workspace({
|
||||
id: "/repo/a",
|
||||
projectId: "project-a",
|
||||
name: "feat/a",
|
||||
status: "running",
|
||||
}),
|
||||
];
|
||||
|
||||
const seededOrder = appendMissingOrderKeys({
|
||||
currentOrder: [],
|
||||
visibleKeys: buildSidebarProjectsFromWorkspaces({
|
||||
serverId: "srv",
|
||||
workspaces: initialWorkspaces,
|
||||
projectOrder: [],
|
||||
workspaceOrderByScope: {},
|
||||
}).map((project) => project.projectKey),
|
||||
});
|
||||
|
||||
const updatedProjects = buildSidebarProjectsFromWorkspaces({
|
||||
it("preserves the structure hook workspace order", () => {
|
||||
const projects = buildSidebarProjectsFromStructure({
|
||||
serverId: "srv",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "/repo/a",
|
||||
projectId: "project-a",
|
||||
name: "feat/a",
|
||||
status: "running",
|
||||
}),
|
||||
workspace({
|
||||
id: "/repo/b",
|
||||
projectId: "project-b",
|
||||
name: "feat/b",
|
||||
status: "running",
|
||||
}),
|
||||
],
|
||||
projectOrder: seededOrder,
|
||||
workspaceOrderByScope: {},
|
||||
});
|
||||
|
||||
expect(updatedProjects.map((project) => project.projectKey)).toEqual([
|
||||
"project-a",
|
||||
"project-b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("appends new projects after the stored project order", () => {
|
||||
const projects = buildSidebarProjectsFromWorkspaces({
|
||||
serverId: "srv",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "/repo/c",
|
||||
projectId: "project-c",
|
||||
name: "feat/c",
|
||||
status: "running",
|
||||
}),
|
||||
workspace({
|
||||
id: "/repo/b",
|
||||
projectId: "project-b",
|
||||
name: "feat/b",
|
||||
status: "running",
|
||||
}),
|
||||
workspace({
|
||||
id: "/repo/a",
|
||||
projectId: "project-a",
|
||||
name: "feat/a",
|
||||
status: "running",
|
||||
}),
|
||||
],
|
||||
projectOrder: ["project-b", "project-a", "project-c"],
|
||||
workspaceOrderByScope: {},
|
||||
});
|
||||
|
||||
expect(projects.map((project) => project.projectKey)).toEqual([
|
||||
"project-b",
|
||||
"project-a",
|
||||
"project-c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves stored workspace order when workspace activity changes", () => {
|
||||
const initialProjects = buildSidebarProjectsFromWorkspaces({
|
||||
serverId: "srv",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "/repo/main",
|
||||
projectId: "project-1",
|
||||
name: "main",
|
||||
status: "running",
|
||||
}),
|
||||
workspace({
|
||||
id: "/repo/feature",
|
||||
projectId: "project-1",
|
||||
name: "feature",
|
||||
status: "running",
|
||||
}),
|
||||
],
|
||||
projectOrder: ["project-1"],
|
||||
workspaceOrderByScope: {},
|
||||
});
|
||||
|
||||
const seededWorkspaceOrder = appendMissingOrderKeys({
|
||||
currentOrder: [],
|
||||
visibleKeys: initialProjects[0]?.workspaces.map((workspace) => workspace.workspaceKey) ?? [],
|
||||
});
|
||||
|
||||
const projects = buildSidebarProjectsFromWorkspaces({
|
||||
serverId: "srv",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "/repo/main",
|
||||
projectId: "project-1",
|
||||
name: "main",
|
||||
status: "running",
|
||||
}),
|
||||
workspace({
|
||||
id: "/repo/feature",
|
||||
projectId: "project-1",
|
||||
name: "feature",
|
||||
status: "running",
|
||||
}),
|
||||
],
|
||||
projectOrder: ["project-1"],
|
||||
workspaceOrderByScope: {
|
||||
"srv::project-1": seededWorkspaceOrder,
|
||||
},
|
||||
projects: [project({ projectKey: "project-1", workspaceKeys: ["feature", "main"] })],
|
||||
});
|
||||
|
||||
expect(projects[0]?.workspaces.map((workspace) => workspace.workspaceId)).toEqual([
|
||||
"/repo/feature",
|
||||
"/repo/main",
|
||||
"feature",
|
||||
"main",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,13 +6,15 @@ import {
|
||||
useSessionStore,
|
||||
type WorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import {
|
||||
useWorkspaceStructure,
|
||||
type WorkspaceStructureProject,
|
||||
} from "@/stores/session-store-hooks";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
||||
|
||||
const EMPTY_ORDER: string[] = [];
|
||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
||||
const EMPTY_WORKSPACE_ORDER_SCOPE: Record<string, string[]> = {};
|
||||
|
||||
export type SidebarStateBucket = WorkspaceDescriptor["status"];
|
||||
|
||||
@@ -36,9 +38,6 @@ export interface SidebarProjectEntry {
|
||||
projectName: string;
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
iconWorkingDir: string;
|
||||
statusBucket: SidebarStateBucket;
|
||||
activeCount: number;
|
||||
totalWorkspaces: number;
|
||||
workspaces: SidebarWorkspaceEntry[];
|
||||
}
|
||||
|
||||
@@ -50,119 +49,68 @@ export interface SidebarWorkspacesListResult {
|
||||
refreshAll: () => void;
|
||||
}
|
||||
|
||||
const SIDEBAR_BUCKET_PRIORITY: Record<SidebarStateBucket, number> = {
|
||||
done: 0,
|
||||
attention: 1,
|
||||
running: 2,
|
||||
failed: 3,
|
||||
needs_input: 4,
|
||||
};
|
||||
|
||||
function aggregateBucket(
|
||||
current: SidebarStateBucket,
|
||||
candidate: SidebarStateBucket,
|
||||
): SidebarStateBucket {
|
||||
return SIDEBAR_BUCKET_PRIORITY[candidate] > SIDEBAR_BUCKET_PRIORITY[current]
|
||||
? candidate
|
||||
: current;
|
||||
}
|
||||
|
||||
function compareWorkspaceBaseline(
|
||||
left: SidebarWorkspaceEntry,
|
||||
right: SidebarWorkspaceEntry,
|
||||
): number {
|
||||
const nameDelta = left.name.localeCompare(right.name, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
if (nameDelta !== 0) {
|
||||
return nameDelta;
|
||||
}
|
||||
|
||||
return left.workspaceId.localeCompare(right.workspaceId, undefined, {
|
||||
sensitivity: "base",
|
||||
});
|
||||
}
|
||||
|
||||
function compareProjectBaseline(left: SidebarProjectEntry, right: SidebarProjectEntry): number {
|
||||
return left.projectName.localeCompare(right.projectName, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
}
|
||||
|
||||
export function buildSidebarProjectsFromWorkspaces(input: {
|
||||
function createStructuralWorkspaceEntry(input: {
|
||||
serverId: string;
|
||||
workspaces: Iterable<WorkspaceDescriptor>;
|
||||
projectOrder: string[];
|
||||
workspaceOrderByScope: Record<string, string[]>;
|
||||
project: WorkspaceStructureProject;
|
||||
workspaceId: string;
|
||||
}): SidebarWorkspaceEntry {
|
||||
return {
|
||||
workspaceKey: `${input.serverId}:${input.workspaceId}`,
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
projectRootPath: input.project.iconWorkingDir,
|
||||
workspaceDirectory: undefined,
|
||||
projectKind: input.project.projectKind,
|
||||
workspaceKind: "checkout",
|
||||
name: input.workspaceId,
|
||||
statusBucket: "done",
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
hasRunningScripts: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function createSidebarWorkspaceEntry(input: {
|
||||
serverId: string;
|
||||
workspace: WorkspaceDescriptor;
|
||||
}): SidebarWorkspaceEntry {
|
||||
return {
|
||||
workspaceKey: `${input.serverId}:${input.workspace.id}`,
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspace.id,
|
||||
projectRootPath: input.workspace.projectRootPath,
|
||||
workspaceDirectory: input.workspace.workspaceDirectory,
|
||||
projectKind: input.workspace.projectKind,
|
||||
workspaceKind: input.workspace.workspaceKind,
|
||||
name: input.workspace.name,
|
||||
statusBucket: input.workspace.status,
|
||||
diffStat: input.workspace.diffStat,
|
||||
scripts: input.workspace.scripts,
|
||||
hasRunningScripts: input.workspace.scripts.some((script) => script.lifecycle === "running"),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSidebarProjectsFromStructure(input: {
|
||||
serverId: string;
|
||||
projects: WorkspaceStructureProject[];
|
||||
}): SidebarProjectEntry[] {
|
||||
const byProject = new Map<string, SidebarProjectEntry>();
|
||||
|
||||
for (const workspace of input.workspaces) {
|
||||
const project =
|
||||
byProject.get(workspace.projectId) ??
|
||||
({
|
||||
projectKey: workspace.projectId,
|
||||
projectName:
|
||||
workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId),
|
||||
projectKind: workspace.projectKind,
|
||||
iconWorkingDir: workspace.projectRootPath,
|
||||
statusBucket: "done",
|
||||
activeCount: 0,
|
||||
totalWorkspaces: 0,
|
||||
workspaces: [],
|
||||
} satisfies SidebarProjectEntry);
|
||||
|
||||
const row: SidebarWorkspaceEntry = {
|
||||
workspaceKey: `${input.serverId}:${workspace.id}`,
|
||||
serverId: input.serverId,
|
||||
workspaceId: workspace.id,
|
||||
projectRootPath: workspace.projectRootPath,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
projectKind: workspace.projectKind,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
name: workspace.name,
|
||||
statusBucket: workspace.status,
|
||||
diffStat: workspace.diffStat,
|
||||
scripts: workspace.scripts,
|
||||
hasRunningScripts: workspace.scripts.some((script) => script.lifecycle === "running"),
|
||||
};
|
||||
|
||||
project.workspaces.push(row);
|
||||
project.totalWorkspaces += 1;
|
||||
if (workspace.status !== "done") {
|
||||
project.activeCount += 1;
|
||||
}
|
||||
project.statusBucket = aggregateBucket(project.statusBucket, workspace.status);
|
||||
|
||||
byProject.set(workspace.projectId, project);
|
||||
if (input.projects.length === 0) {
|
||||
return EMPTY_PROJECTS;
|
||||
}
|
||||
|
||||
const baselineProjects = Array.from(byProject.values()).map((project) => {
|
||||
const baselineWorkspaces = [...project.workspaces];
|
||||
baselineWorkspaces.sort(compareWorkspaceBaseline);
|
||||
|
||||
const workspaceOrderScopeKey = getWorkspaceOrderScopeKey(input.serverId, project.projectKey);
|
||||
const orderedWorkspaces = applyStoredOrdering({
|
||||
items: baselineWorkspaces,
|
||||
storedOrder: input.workspaceOrderByScope[workspaceOrderScopeKey] ?? EMPTY_ORDER,
|
||||
getKey: (workspace) => workspace.workspaceKey,
|
||||
});
|
||||
|
||||
return {
|
||||
...project,
|
||||
workspaces: orderedWorkspaces,
|
||||
};
|
||||
});
|
||||
|
||||
baselineProjects.sort(compareProjectBaseline);
|
||||
|
||||
return applyStoredOrdering({
|
||||
items: baselineProjects,
|
||||
storedOrder: input.projectOrder,
|
||||
getKey: (project) => project.projectKey,
|
||||
});
|
||||
return input.projects.map((project) => ({
|
||||
projectKey: project.projectKey,
|
||||
projectName: project.projectName,
|
||||
projectKind: project.projectKind,
|
||||
iconWorkingDir: project.iconWorkingDir,
|
||||
workspaces: project.workspaceKeys.map((workspaceId) =>
|
||||
createStructuralWorkspaceEntry({
|
||||
serverId: input.serverId,
|
||||
project,
|
||||
workspaceId,
|
||||
}),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
export function applyStoredOrdering<T>(input: {
|
||||
@@ -229,10 +177,6 @@ export function appendMissingOrderKeys(input: {
|
||||
return [...input.currentOrder, ...missingKeys];
|
||||
}
|
||||
|
||||
function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string {
|
||||
return `${serverId.trim()}::${projectKey.trim()}`;
|
||||
}
|
||||
|
||||
function toWorkspaceDescriptor(payload: WorkspaceDescriptorPayload): WorkspaceDescriptor {
|
||||
return normalizeWorkspaceDescriptor(payload);
|
||||
}
|
||||
@@ -250,17 +194,12 @@ export function useSidebarWorkspacesList(options?: {
|
||||
const persistedProjectOrder = useSidebarOrderStore((state) =>
|
||||
serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER,
|
||||
);
|
||||
const persistedWorkspaceOrderByScope = useSidebarOrderStore((state) =>
|
||||
serverId ? state.workspaceOrderByServerAndProject : EMPTY_WORKSPACE_ORDER_SCOPE,
|
||||
);
|
||||
|
||||
const isActive = Boolean(serverId);
|
||||
const sessionWorkspaces = useSessionStore((state) =>
|
||||
isActive && serverId ? (state.sessions[serverId]?.workspaces ?? null) : null,
|
||||
);
|
||||
const hasHydratedWorkspaces = useSessionStore((state) =>
|
||||
isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
||||
);
|
||||
const workspaceStructure = useWorkspaceStructure(isActive ? serverId : null);
|
||||
|
||||
const connectionStatus = useSyncExternalStore(
|
||||
(onStoreChange) =>
|
||||
@@ -282,22 +221,20 @@ export function useSidebarWorkspacesList(options?: {
|
||||
);
|
||||
|
||||
const projects = useMemo(() => {
|
||||
if (!sessionWorkspaces || sessionWorkspaces.size === 0 || !serverId) {
|
||||
if (!serverId || workspaceStructure.projects.length === 0) {
|
||||
return EMPTY_PROJECTS;
|
||||
}
|
||||
return buildSidebarProjectsFromWorkspaces({
|
||||
return buildSidebarProjectsFromStructure({
|
||||
serverId,
|
||||
workspaces: sessionWorkspaces.values(),
|
||||
projectOrder: persistedProjectOrder,
|
||||
workspaceOrderByScope: persistedWorkspaceOrderByScope,
|
||||
projects: workspaceStructure.projects,
|
||||
});
|
||||
}, [persistedProjectOrder, persistedWorkspaceOrderByScope, serverId, sessionWorkspaces]);
|
||||
}, [serverId, workspaceStructure]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
}, [connectionStatus, hasHydratedWorkspaces, projects, serverId, sessionWorkspaces]);
|
||||
}, [connectionStatus, hasHydratedWorkspaces, projects, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId || projects.length === 0) {
|
||||
@@ -313,9 +250,9 @@ export function useSidebarWorkspacesList(options?: {
|
||||
}
|
||||
|
||||
for (const project of projects) {
|
||||
const workspaceOrderScopeKey = getWorkspaceOrderScopeKey(serverId, project.projectKey);
|
||||
const persistedWorkspaceOrder =
|
||||
persistedWorkspaceOrderByScope[workspaceOrderScopeKey] ?? EMPTY_ORDER;
|
||||
const persistedWorkspaceOrder = useSidebarOrderStore
|
||||
.getState()
|
||||
.getWorkspaceOrder(serverId, project.projectKey);
|
||||
const nextWorkspaceOrder = appendMissingOrderKeys({
|
||||
currentOrder: persistedWorkspaceOrder,
|
||||
visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey),
|
||||
@@ -326,7 +263,7 @@ export function useSidebarWorkspacesList(options?: {
|
||||
.setWorkspaceOrder(serverId, project.projectKey, nextWorkspaceOrder);
|
||||
}
|
||||
}
|
||||
}, [persistedProjectOrder, persistedWorkspaceOrderByScope, projects, serverId]);
|
||||
}, [persistedProjectOrder, projects, serverId]);
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
if (!isActive || !serverId || connectionStatus !== "online") {
|
||||
|
||||
Reference in New Issue
Block a user