Restore project grouping across hosts (#2565)
* fix(projects): restore cross-host grouping Random host-local project IDs replaced the remote-derived value that had also served as grouping identity. Persist a separate opaque group key and let normal boot reconciliation backfill older records without a migration. * fix(projects): keep ambiguous clones separate A shared remote can identify a project across hosts, but it cannot distinguish two independent clones on one host. Treat those keys as ambiguous so host-local project IDs remain separate. * fix(projects): preserve host-local project identity * fix(projects): strengthen grouping identity * test(app): update project host shape contract * fix(projects): preserve selected host identity * fix(projects): close grouped routing gaps * fix(projects): stabilize legacy subproject grouping * fix(projects): preserve selected project identity * fix(projects): isolate legacy host identity * fix(projects): preserve symlinked bootstrap roots * fix(projects): reconcile grouped custom names * fix(projects): stabilize settings and path identity * fix(projects): normalize default remote ports * fix(projects): harden host and remote identity * fix(projects): preserve host-scoped actions * fix(projects): preserve remote path semantics * fix(projects): model SCP remote semantics * fix(projects): keep settings on routed host * fix(projects): preserve SSH URL path semantics * fix(projects): separate identity from presentation * test(projects): use grouped identity in UI specs * fix(projects): harden grouped project routing * test(projects): expect framed settings route * fix(projects): preserve exact placement identity * fix(projects): harden grouping identity and routing * fix(projects): restore project key as grouping identity Random host-local project IDs had replaced the persisted cross-host value carried by projectKey. Keep projectId for routing while reconciliation owns and persists projectKey for grouping. * fix(projects): prevent project key collisions Preserve platform path semantics and generic remote transports in cross-host keys, and compare legacy checkout roots using filesystem equivalence before materializing ownership. * test(projects): assert host-scoped project keys * fix(projects): preserve project key compatibility Resolve legacy workspace project references at the daemon boundary while keeping projectKey as the cross-host identity. Complete generic SSH normalization without collapsing distinct repositories. * fix(projects): harden project reference identity * fix(projects): retain archived reference errors * fix(projects): scope legacy paths to hosts * test(app): model persisted project keys * fix(projects): retain absolute scp users * fix(projects): resolve legacy mutation aliases * fix(projects): preserve conservative identity boundaries * fix(projects): preserve legacy placement identity * test(projects): assert legacy placement identity * test(projects): expect canonical Windows keys * fix(projects): separate bootstrap identity fields * fix(projects): scope legacy paths by host * fix(projects): stabilize opaque project identity * fix(projects): preserve remaining opaque identities * fix(projects): classify forward-slash UNC roots * fix(projects): preserve opaque selection keys * fix(projects): frame host-local project keys * fix(projects): canonicalize framed identities * test(projects): isolate repeated remote fixtures * fix(projects): keep project key as UI identity * fix(projects): reconcile hydrated project references * fix(projects): preserve archived project identity * refactor(projects): group projects from project records, not workspaces Cross-host grouping had been built by hanging a projectKey off every workspace descriptor. That scoped the key to the wrong entity: the app then had to reconstruct project identity per workspace in six modules, which forced projectKey fallbacks into RPC arguments and a server-side resolver that turned grouping keys back into project ids. A workspace has a projectId. The key belongs to the project, and grouping is a display concern, so the daemon now publishes projects over project.list plus the existing project.update channel and the app groups them in one place. Projects are addressed by host-local projectId everywhere, including the settings route. Projects persisted without a key are backfilled by ordinary boot reconciliation, with no migration. Old daemons advertise no projectList capability and simply do not group. * test(projects): seed projects alongside workspaces in fixtures Sidebar fixtures seeded workspaces without their projects, which the daemon cannot produce now that projects arrive on their own channel, so nothing rendered. Adds a seeding helper that supplies both. Also drops the provisioning test for accepting a project key where a project id belongs; that resolver was removed deliberately. * fix(projects): keep derived project identity stable Four gaps left by shrinking the key derivation: Local keys used a bare path resolve, so one directory reached by a different spelling produced a different key and reset grouped identity. Windows CI caught this with the 8.3 and long-name forms of one temp path. Canonicalize through the shared realpath-aware normalization. Subproject display names lost their selected-path input, so every subproject of a monorepo rendered under the same remote-derived name. Reconciliation only ever backfilled a missing key, so a project whose remote changed stayed grouped with hosts on the old remote. Project settings kept a disconnected host selected and rendered a dead end with no way back to an editable host. * fix(projects): read scheme remotes as URLs, not scp form An `ssh://user@host:22/org/repo` remote matched the scp-form pattern first, because `[^@]+` happily eats `ssh://git`, so the port landed in the path and the project keyed as `remote:host/22/org/repo`. Two spellings of one remote therefore never grouped. Try scp form only when there is no scheme, and drop protocol-default ports so an explicitly written `:22` matches the same remote without it.
This commit is contained in:
@@ -19,7 +19,7 @@ import {
|
||||
} from "./selectors";
|
||||
import {
|
||||
useSessionStore,
|
||||
type EmptyProjectDescriptor,
|
||||
type ProjectDescriptor,
|
||||
type WorkspaceDescriptor,
|
||||
} from "../session-store";
|
||||
|
||||
@@ -45,11 +45,33 @@ function createWorkspace(
|
||||
};
|
||||
}
|
||||
|
||||
function projectDescriptorFromTestWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor {
|
||||
return {
|
||||
projectId: workspace.projectId,
|
||||
projectKey: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
projectCustomName: workspace.projectCustomName ?? null,
|
||||
projectRootPath: workspace.projectRootPath,
|
||||
projectKind: workspace.projectKind,
|
||||
};
|
||||
}
|
||||
|
||||
function initializeWorkspaces(workspaces: WorkspaceDescriptor[]): void {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setWorkspaces(SERVER_ID, new Map(workspaces.map((workspace) => [workspace.id, workspace])));
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setProjects(
|
||||
SERVER_ID,
|
||||
new Map(
|
||||
workspaces.map((workspace) => [
|
||||
workspace.projectId,
|
||||
projectDescriptorFromTestWorkspace(workspace),
|
||||
]),
|
||||
).values(),
|
||||
);
|
||||
}
|
||||
|
||||
interface Subscribable<S> {
|
||||
@@ -125,11 +147,26 @@ describe("workspace replica authority", () => {
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setWorkspaces(SERVER_ID, new Map([[authoritativeWorkspace.id, authoritativeWorkspace]]));
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setProjects(
|
||||
SERVER_ID,
|
||||
new Map([
|
||||
[
|
||||
authoritativeWorkspace.projectId,
|
||||
projectDescriptorFromTestWorkspace(authoritativeWorkspace),
|
||||
],
|
||||
]).values(),
|
||||
);
|
||||
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
|
||||
const hydratedServerIds = selectHydratedWorkspaceServerIds(useSessionStore.getState(), [
|
||||
SERVER_ID,
|
||||
]);
|
||||
|
||||
expect(hydratedServerIds).toEqual([SERVER_ID]);
|
||||
expect(
|
||||
Array.from(useSessionStore.getState().sessions[SERVER_ID]?.projects.keys() ?? []),
|
||||
).toEqual([authoritativeWorkspace.projectId]);
|
||||
expect(
|
||||
selectWorkspaceStructureProjects(useSessionStore.getState(), hydratedServerIds).map(
|
||||
(project) => project.workspaceKeys,
|
||||
@@ -149,10 +186,16 @@ describe("workspace replica authority", () => {
|
||||
sessions: {
|
||||
[loadingServerId]: {
|
||||
hasHydratedWorkspaces: false,
|
||||
projects: new Map([
|
||||
[loadingWorkspace.projectId, projectDescriptorFromTestWorkspace(loadingWorkspace)],
|
||||
]),
|
||||
workspaces: new Map([[loadingWorkspace.id, loadingWorkspace]]),
|
||||
},
|
||||
[hydratedServerId]: {
|
||||
hasHydratedWorkspaces: true,
|
||||
projects: new Map([
|
||||
[hydratedWorkspace.projectId, projectDescriptorFromTestWorkspace(hydratedWorkspace)],
|
||||
]),
|
||||
workspaces: new Map([[hydratedWorkspace.id, hydratedWorkspace]]),
|
||||
},
|
||||
},
|
||||
@@ -295,8 +338,9 @@ describe("workspace structure composition", () => {
|
||||
projectRootPath: "/repo/a",
|
||||
workspaceDirectory: "/repo/a",
|
||||
});
|
||||
const emptyProject: EmptyProjectDescriptor = {
|
||||
const emptyProject: ProjectDescriptor = {
|
||||
projectId: "project-a",
|
||||
projectKey: "project-a",
|
||||
projectDisplayName: "Project A",
|
||||
projectCustomName: null,
|
||||
projectRootPath: "/repo/a",
|
||||
@@ -311,7 +355,7 @@ describe("workspace structure composition", () => {
|
||||
|
||||
try {
|
||||
useSessionStore.getState().removeWorkspace(SERVER_ID, workspace.id);
|
||||
useSessionStore.getState().addEmptyProject(SERVER_ID, emptyProject);
|
||||
useSessionStore.getState().upsertProject(SERVER_ID, emptyProject);
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
@@ -348,15 +392,15 @@ describe("workspace structure composition", () => {
|
||||
it("renders a project parent with zero active workspaces", () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(SERVER_ID, new Map());
|
||||
useSessionStore.getState().setEmptyProjects(SERVER_ID, [
|
||||
{
|
||||
projectId: "empty-project",
|
||||
projectDisplayName: "Empty Project",
|
||||
projectCustomName: null,
|
||||
projectRootPath: "/repo/empty",
|
||||
projectKind: "git",
|
||||
},
|
||||
]);
|
||||
const emptyProject: ProjectDescriptor = {
|
||||
projectId: "empty-project",
|
||||
projectKey: "empty-project",
|
||||
projectDisplayName: "Empty Project",
|
||||
projectCustomName: null,
|
||||
projectRootPath: "/repo/empty",
|
||||
projectKind: "git",
|
||||
};
|
||||
useSessionStore.getState().setProjects(SERVER_ID, [emptyProject]);
|
||||
|
||||
const projects = selectWorkspaceStructureProjects(useSessionStore.getState(), [SERVER_ID]);
|
||||
expect(projects).toEqual([
|
||||
@@ -368,7 +412,7 @@ describe("workspace structure composition", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("changes when a structure-relevant project identity field changes", () => {
|
||||
it("changes when a project descriptor display field changes", () => {
|
||||
const workspace = createWorkspace({
|
||||
id: "workspace-a",
|
||||
projectDisplayName: "Project 1",
|
||||
@@ -382,9 +426,10 @@ describe("workspace structure composition", () => {
|
||||
);
|
||||
const before = tracked.current;
|
||||
|
||||
useSessionStore
|
||||
.getState()
|
||||
.mergeWorkspaces(SERVER_ID, [{ ...workspace, projectDisplayName: "Project Renamed" }]);
|
||||
useSessionStore.getState().upsertProject(SERVER_ID, {
|
||||
...projectDescriptorFromTestWorkspace(workspace),
|
||||
projectDisplayName: "Project Renamed",
|
||||
});
|
||||
expect(tracked.current).not.toBe(before);
|
||||
|
||||
tracked.stop();
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "@/projects/workspace-structure";
|
||||
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "../session-store";
|
||||
import type { ProjectDescriptor, WorkspaceDescriptor } from "../session-store";
|
||||
|
||||
export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
||||
export type { WorkspaceStructure, WorkspaceStructureProject } from "@/projects/workspace-structure";
|
||||
@@ -17,7 +17,7 @@ export interface SessionsSnapshot {
|
||||
{
|
||||
hasHydratedWorkspaces?: boolean;
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
emptyProjects?: Map<string, EmptyProjectDescriptor>;
|
||||
projects?: Map<string, ProjectDescriptor>;
|
||||
}
|
||||
>;
|
||||
}
|
||||
@@ -145,20 +145,20 @@ export function selectWorkspaceStructureProjects(
|
||||
const sessions: Array<{
|
||||
serverId: string;
|
||||
workspaces: Iterable<WorkspaceDescriptor>;
|
||||
emptyProjects: Iterable<EmptyProjectDescriptor>;
|
||||
projects: Iterable<ProjectDescriptor>;
|
||||
}> = [];
|
||||
|
||||
for (const serverId of serverIds) {
|
||||
const session = state.sessions[serverId];
|
||||
const workspaces = session?.workspaces;
|
||||
const emptyProjects = session?.emptyProjects;
|
||||
if ((!workspaces || workspaces.size === 0) && (!emptyProjects || emptyProjects.size === 0)) {
|
||||
const projects = session?.projects;
|
||||
if (!projects || projects.size === 0) {
|
||||
continue;
|
||||
}
|
||||
sessions.push({
|
||||
serverId,
|
||||
workspaces: workspaces?.values() ?? [],
|
||||
emptyProjects: emptyProjects?.values() ?? [],
|
||||
projects: projects.values(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -169,6 +169,32 @@ export function selectWorkspaceStructureProjects(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ function getTestSessionReferences() {
|
||||
sessions: state.sessions,
|
||||
session,
|
||||
workspaces: session.workspaces,
|
||||
emptyProjects: session.emptyProjects,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -405,48 +404,6 @@ describe("removeWorkspace", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeEmptyProject", () => {
|
||||
it("removes an empty project by project id", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
store.setEmptyProjects("test-server", [
|
||||
{
|
||||
projectId: "project-empty",
|
||||
projectDisplayName: "Empty",
|
||||
projectCustomName: null,
|
||||
projectRootPath: "/repo/empty",
|
||||
projectKind: "git",
|
||||
},
|
||||
]);
|
||||
|
||||
store.removeEmptyProject("test-server", "project-empty");
|
||||
|
||||
expect(getTestSessionReferences().emptyProjects.has("project-empty")).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves identity when removing a missing empty project", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
store.setEmptyProjects("test-server", [
|
||||
{
|
||||
projectId: "project-empty",
|
||||
projectDisplayName: "Empty",
|
||||
projectCustomName: null,
|
||||
projectRootPath: "/repo/empty",
|
||||
projectKind: "git",
|
||||
},
|
||||
]);
|
||||
const before = getTestSessionReferences();
|
||||
|
||||
store.removeEmptyProject("test-server", "project-missing");
|
||||
const after = getTestSessionReferences();
|
||||
|
||||
expect(after.sessions).toBe(before.sessions);
|
||||
expect(after.session).toBe(before.session);
|
||||
expect(after.emptyProjects).toBe(before.emptyProjects);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchWorkspaceScripts", () => {
|
||||
it("preserves workspace entry identity when scripts are content-equal", () => {
|
||||
const script = {
|
||||
|
||||
@@ -182,19 +182,21 @@ export function normalizeWorkspaceDescriptor(
|
||||
};
|
||||
}
|
||||
|
||||
export interface EmptyProjectDescriptor {
|
||||
export interface ProjectDescriptor {
|
||||
projectId: string;
|
||||
projectKey?: string | null;
|
||||
projectDisplayName: string;
|
||||
projectCustomName: string | null;
|
||||
projectRootPath: string;
|
||||
projectKind: WorkspaceDescriptorPayload["projectKind"];
|
||||
}
|
||||
|
||||
export function normalizeEmptyProjectDescriptor(
|
||||
export function normalizeProjectDescriptor(
|
||||
payload: WorkspaceProjectDescriptorPayload,
|
||||
): EmptyProjectDescriptor {
|
||||
): ProjectDescriptor {
|
||||
return {
|
||||
projectId: payload.projectId,
|
||||
projectKey: payload.projectKey ?? null,
|
||||
projectDisplayName: payload.projectDisplayName,
|
||||
projectCustomName: payload.projectCustomName ?? null,
|
||||
projectRootPath: payload.projectRootPath,
|
||||
@@ -237,28 +239,15 @@ function preserveWorkspaceMapIdentity(
|
||||
return changed ? next : existing;
|
||||
}
|
||||
|
||||
function emptyProjectDescriptorFromWorkspace(
|
||||
workspace: WorkspaceDescriptor,
|
||||
): EmptyProjectDescriptor {
|
||||
return {
|
||||
projectId: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
projectCustomName: workspace.projectCustomName ?? null,
|
||||
projectRootPath: workspace.projectRootPath,
|
||||
projectKind: workspace.projectKind,
|
||||
};
|
||||
}
|
||||
|
||||
function hasWorkspaceInProject(
|
||||
workspaces: ReadonlyMap<string, WorkspaceDescriptor>,
|
||||
projectId: string,
|
||||
function projectMapsEqual(
|
||||
left: ReadonlyMap<string, ProjectDescriptor>,
|
||||
right: ReadonlyMap<string, ProjectDescriptor>,
|
||||
): boolean {
|
||||
for (const workspace of workspaces.values()) {
|
||||
if (workspace.projectId === projectId) {
|
||||
return true;
|
||||
}
|
||||
if (left.size !== right.size) return false;
|
||||
for (const [projectId, project] of right) {
|
||||
if (!equal(left.get(projectId), project)) return false;
|
||||
}
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export type ExplorerEntryKind = "file" | "directory";
|
||||
@@ -332,7 +321,7 @@ export interface SessionReplicaTimeline {
|
||||
export interface SessionReplica {
|
||||
agents: Map<string, Agent>;
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
||||
projects: Map<string, ProjectDescriptor>;
|
||||
timeline: SessionReplicaTimeline | null;
|
||||
}
|
||||
|
||||
@@ -383,9 +372,8 @@ export interface SessionState {
|
||||
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
|
||||
agentDetails: Map<string, Agent>;
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
// Project parents with no active workspaces, keyed by projectId. The
|
||||
// `emptyProjects` name is the existing protocol/store projection.
|
||||
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
||||
// All active project descriptors, keyed by host-local projectId.
|
||||
projects: Map<string, ProjectDescriptor>;
|
||||
// Transient restore state for archived workspaces, keyed by normalized
|
||||
// workspaceId. Cleared in mergeWorkspaces when the descriptor lands.
|
||||
restoringWorkspaces: Map<string, WorkspaceRestoreStatus>;
|
||||
@@ -512,9 +500,9 @@ interface SessionStoreActions {
|
||||
) => void;
|
||||
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => void;
|
||||
removeWorkspace: (serverId: string, workspaceId: string) => void;
|
||||
setEmptyProjects: (serverId: string, emptyProjects: Iterable<EmptyProjectDescriptor>) => void;
|
||||
addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void;
|
||||
removeEmptyProject: (serverId: string, projectId: string) => void;
|
||||
setProjects: (serverId: string, projects: Iterable<ProjectDescriptor>) => void;
|
||||
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
|
||||
removeProject: (serverId: string, projectId: string) => void;
|
||||
setWorkspaceRestoreStatus: (
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
@@ -599,7 +587,7 @@ function createInitialSessionState(
|
||||
workspaceAgentActivity: new Map(),
|
||||
agentDetails: new Map(),
|
||||
workspaces: new Map(),
|
||||
emptyProjects: new Map(),
|
||||
projects: new Map(),
|
||||
restoringWorkspaces: new Map(),
|
||||
pendingPermissions: new Map(),
|
||||
fileExplorer: new Map(),
|
||||
@@ -728,7 +716,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
agents: replica.agents,
|
||||
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(replica.agents),
|
||||
workspaces: replica.workspaces,
|
||||
emptyProjects: replica.emptyProjects,
|
||||
projects: replica.projects,
|
||||
agentStreamTail,
|
||||
agentTimelineCursor,
|
||||
agentTimelineHasOlder,
|
||||
@@ -1363,65 +1351,41 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setEmptyProjects: (serverId, emptyProjects) => {
|
||||
const next = new Map<string, EmptyProjectDescriptor>();
|
||||
for (const project of emptyProjects) {
|
||||
next.set(project.projectId, project);
|
||||
}
|
||||
setProjects: (serverId, projects) => {
|
||||
const next = new Map<string, ProjectDescriptor>();
|
||||
for (const project of projects) next.set(project.projectId, project);
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
if (session.emptyProjects.size === 0 && next.size === 0) {
|
||||
return prev;
|
||||
}
|
||||
if (!session || projectMapsEqual(session.projects, next)) return prev;
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, emptyProjects: next },
|
||||
},
|
||||
sessions: { ...prev.sessions, [serverId]: { ...session, projects: next } },
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
addEmptyProject: (serverId, emptyProject) => {
|
||||
upsertProject: (serverId, project) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
const existing = session.emptyProjects.get(emptyProject.projectId);
|
||||
if (existing && equal(existing, emptyProject)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(session.emptyProjects);
|
||||
next.set(emptyProject.projectId, emptyProject);
|
||||
if (!session || equal(session.projects.get(project.projectId), project)) return prev;
|
||||
const projects = new Map(session.projects);
|
||||
projects.set(project.projectId, project);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, emptyProjects: next },
|
||||
},
|
||||
sessions: { ...prev.sessions, [serverId]: { ...session, projects } },
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removeEmptyProject: (serverId, projectId) => {
|
||||
removeProject: (serverId, projectId) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session?.emptyProjects.has(projectId)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(session.emptyProjects);
|
||||
next.delete(projectId);
|
||||
if (!session?.projects.has(projectId)) return prev;
|
||||
const projects = new Map(session.projects);
|
||||
projects.delete(projectId);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, emptyProjects: next },
|
||||
},
|
||||
sessions: { ...prev.sessions, [serverId]: { ...session, projects } },
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -1483,17 +1447,10 @@ export const useSessionStore = create<SessionStore>()(
|
||||
}
|
||||
const next = new Map(session.workspaces);
|
||||
let changed = false;
|
||||
// A workspace landing in a project means that project is no longer
|
||||
// empty: prune any stale empty descriptor so it stops governing the
|
||||
// project's rendered metadata.
|
||||
const nextEmptyProjects = new Map(session.emptyProjects);
|
||||
// A descriptor arriving is the success signal for a pending restore:
|
||||
// clear it at the source so every entry point converges to "ready".
|
||||
let nextRestoring: Map<string, WorkspaceRestoreStatus> | null = null;
|
||||
for (const workspace of nextEntries) {
|
||||
if (nextEmptyProjects.delete(workspace.projectId)) {
|
||||
changed = true;
|
||||
}
|
||||
if (session.restoringWorkspaces.has(workspace.id)) {
|
||||
nextRestoring ??= new Map(session.restoringWorkspaces);
|
||||
nextRestoring.delete(workspace.id);
|
||||
@@ -1517,7 +1474,6 @@ export const useSessionStore = create<SessionStore>()(
|
||||
[serverId]: {
|
||||
...session,
|
||||
workspaces: next,
|
||||
emptyProjects: nextEmptyProjects,
|
||||
restoringWorkspaces: nextRestoring ?? session.restoringWorkspaces,
|
||||
},
|
||||
},
|
||||
@@ -1535,31 +1491,13 @@ export const useSessionStore = create<SessionStore>()(
|
||||
if (!session || !workspaceKey) {
|
||||
return prev;
|
||||
}
|
||||
const removedWorkspace = session.workspaces.get(workspaceKey);
|
||||
if (!removedWorkspace) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(session.workspaces);
|
||||
next.delete(workspaceKey);
|
||||
let nextEmptyProjects = session.emptyProjects;
|
||||
if (hasWorkspaceInProject(next, removedWorkspace.projectId)) {
|
||||
if (nextEmptyProjects.has(removedWorkspace.projectId)) {
|
||||
nextEmptyProjects = new Map(nextEmptyProjects);
|
||||
nextEmptyProjects.delete(removedWorkspace.projectId);
|
||||
}
|
||||
} else {
|
||||
const emptyProject = emptyProjectDescriptorFromWorkspace(removedWorkspace);
|
||||
const existing = nextEmptyProjects.get(emptyProject.projectId);
|
||||
if (!existing || !equal(existing, emptyProject)) {
|
||||
nextEmptyProjects = new Map(nextEmptyProjects);
|
||||
nextEmptyProjects.set(emptyProject.projectId, emptyProject);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, workspaces: next, emptyProjects: nextEmptyProjects },
|
||||
[serverId]: { ...session, workspaces: next },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user