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
This commit is contained in:
Mohamed Boudra
2026-07-31 15:42:08 +02:00
committed by GitHub
parent 87ef631ac4
commit 99eca19a76
56 changed files with 1234 additions and 428 deletions
@@ -9,7 +9,8 @@ export interface WorkspaceStructureHostPlacement {
}
export interface WorkspaceStructureProject {
projectKey: string;
viewKey: string;
projectKey: string | null;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
@@ -28,7 +29,8 @@ interface WorkspaceStructureSession {
}
interface ProjectDraft {
projectKey: string;
viewKey: string;
projectKey: string | null;
projectName: string;
hasCustomName: boolean;
projectKind: WorkspaceDescriptor["projectKind"];
@@ -57,19 +59,29 @@ export function buildWorkspaceStructureProjects(input: {
}
}
const allocatedViewKeys = new Set(
projectEntries.flatMap(({ project }) => (project.projectKey ? [project.projectKey] : [])),
);
for (const { serverId, project } of projectEntries) {
const projectKey = addProjectToView({ byProject, keyCountsByServer, serverId, project });
const viewKey = addProjectToView({
byProject,
keyCountsByServer,
allocatedViewKeys,
serverId,
project,
});
getOrCreate(viewKeyByServerProjectId, serverId, () => new Map()).set(
project.projectId,
projectKey,
viewKey,
);
}
for (const session of input.sessions) {
for (const workspace of session.workspaces) {
const projectKey = viewKeyByServerProjectId.get(session.serverId)?.get(workspace.projectId);
if (!projectKey) continue;
byProject.get(projectKey)?.workspaces.push({
const viewKey = viewKeyByServerProjectId.get(session.serverId)?.get(workspace.projectId);
if (!viewKey) continue;
byProject.get(viewKey)?.workspaces.push({
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceKey: `${session.serverId}:${workspace.id}`,
@@ -79,6 +91,7 @@ export function buildWorkspaceStructureProjects(input: {
return Array.from(byProject.values())
.map((draft) => ({
viewKey: draft.viewKey,
projectKey: draft.projectKey,
projectName: draft.projectName,
projectKind: draft.projectKind,
@@ -88,17 +101,48 @@ export function buildWorkspaceStructureProjects(input: {
.sort(compareWorkspaceStructureItems)
.map((workspace) => workspace.workspaceKey),
}))
.sort((left, right) =>
left.projectName.localeCompare(right.projectName, undefined, {
numeric: true,
sensitivity: "base",
}),
.sort(
(left, right) =>
left.projectName.localeCompare(right.projectName, undefined, {
numeric: true,
sensitivity: "base",
}) || left.viewKey.localeCompare(right.viewKey),
);
}
export function createProjectViewKey(
identity:
| { kind: "equivalence"; projectKey: string }
| { kind: "placement"; serverId: string; projectId: string },
): string {
return identity.kind === "equivalence"
? identity.projectKey
: JSON.stringify([identity.serverId, identity.projectId]);
}
function allocatePlacementViewKey(
allocatedViewKeys: Set<string>,
serverId: string,
projectId: string,
): string {
const legacyKey = createProjectViewKey({ kind: "placement", serverId, projectId });
if (!allocatedViewKeys.has(legacyKey)) {
allocatedViewKeys.add(legacyKey);
return legacyKey;
}
for (let suffix = 0; ; suffix += 1) {
const collisionKey = JSON.stringify(["placement", serverId, projectId, suffix]);
if (allocatedViewKeys.has(collisionKey)) continue;
allocatedViewKeys.add(collisionKey);
return collisionKey;
}
}
function addProjectToView(input: {
byProject: Map<string, ProjectDraft>;
keyCountsByServer: Map<string, Map<string, number>>;
allocatedViewKeys: Set<string>;
serverId: string;
project: ProjectDescriptor;
}): string {
@@ -106,17 +150,20 @@ function addProjectToView(input: {
const sharedKey = project.projectKey ?? null;
const canUseSharedKey =
sharedKey !== null && keyCountsByServer.get(serverId)?.get(sharedKey) === 1;
const projectKey = canUseSharedKey ? sharedKey : JSON.stringify([serverId, project.projectId]);
const viewKey = canUseSharedKey
? createProjectViewKey({ kind: "equivalence", projectKey: sharedKey })
: allocatePlacementViewKey(input.allocatedViewKeys, serverId, project.projectId);
const placement: WorkspaceStructureHostPlacement = {
serverId,
projectId: project.projectId,
iconWorkingDir: project.projectRootPath,
canCreateWorktree: project.projectKind === "git",
};
const draft = byProject.get(projectKey);
const draft = byProject.get(viewKey);
if (!draft) {
byProject.set(projectKey, {
projectKey,
byProject.set(viewKey, {
viewKey,
projectKey: sharedKey,
projectName:
project.projectCustomName ??
project.projectDisplayName ??
@@ -134,7 +181,7 @@ function addProjectToView(input: {
}
draft.hosts.set(serverId, placement);
}
return projectKey;
return viewKey;
}
function getOrCreate<K, V>(map: Map<K, V>, key: K, create: () => V): V {