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
+81 -21
View File
@@ -3,6 +3,7 @@ import type {
WorkspaceStructureHostPlacement,
WorkspaceStructureProject,
} from "@/projects/workspace-structure";
import { createProjectViewKey } from "@/projects/workspace-structure";
export type HostProjectListItem = WorkspaceStructureProject;
@@ -31,7 +32,8 @@ export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjec
return null;
}
return {
projectKey: projectId,
viewKey: createProjectViewKey({ kind: "placement", serverId: route.serverId, projectId }),
projectKey: null,
projectName: trimOptional(route.displayName) || projectId,
projectKind: "git",
iconWorkingDir,
@@ -64,7 +66,12 @@ export function hostProjectFromWorkspace(input: {
}
const canCreate = canCreateWorktreeForProjectKind(input.workspace.projectKind);
return {
projectKey: projectId,
viewKey: createProjectViewKey({
kind: "placement",
serverId: input.serverId,
projectId,
}),
projectKey: null,
projectName: input.workspace.projectDisplayName || projectId,
projectKind: input.workspace.projectKind,
iconWorkingDir,
@@ -131,6 +138,67 @@ export function filterWorkspaceProjectsForHost(input: {
);
}
function compareHostProjectsForServer(
left: HostProjectListItem,
right: HostProjectListItem,
serverId: string,
): number {
return (
left.projectName.localeCompare(right.projectName, undefined, {
numeric: true,
sensitivity: "base",
}) ||
(getHostProjectId(left, serverId) ?? "").localeCompare(
getHostProjectId(right, serverId) ?? "",
) ||
left.viewKey.localeCompare(right.viewKey)
);
}
export function resolveExactHostProjectCandidate(input: {
candidate: HostProjectListItem;
projects: readonly HostProjectListItem[];
serverId: string;
}): HostProjectListItem | null {
const candidatePlacement = getHostProjectId(input.candidate, input.serverId);
if (candidatePlacement) {
const exactPlacement = input.projects.find(
(project) => getHostProjectId(project, input.serverId) === candidatePlacement,
);
if (exactPlacement) return exactPlacement;
}
const exactView = input.projects.find((project) => project.viewKey === input.candidate.viewKey);
if (input.candidate.projectKey !== null && exactView?.projectKey === input.candidate.projectKey) {
return exactView;
}
return null;
}
export function resolveEquivalentHostProjectCandidate(input: {
candidate: HostProjectListItem;
projects: readonly HostProjectListItem[];
serverId: string;
}): HostProjectListItem | null {
if (input.candidate.projectKey === null) return null;
const equivalents = input.projects.filter(
(project) => project.projectKey === input.candidate.projectKey,
);
if (equivalents.length === 0) return null;
return equivalents.toSorted((left, right) =>
compareHostProjectsForServer(left, right, input.serverId),
)[0]!;
}
export function resolveHostProjectCandidate(input: {
candidate: HostProjectListItem;
projects: readonly HostProjectListItem[];
serverId: string;
}): HostProjectListItem | null {
return resolveExactHostProjectCandidate(input) ?? resolveEquivalentHostProjectCandidate(input);
}
export function resolveInitialWorkspaceProject(input: {
routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null;
@@ -143,20 +211,12 @@ export function resolveInitialWorkspaceProject(input: {
if (!candidate) {
continue;
}
const candidatePlacement = candidate.hosts.find(
(host) => host.serverId === input.serverId && host.projectId,
);
const hydratedProject =
(candidatePlacement
? input.projects.find((project) =>
project.hosts.some(
(host) =>
host.serverId === input.serverId && host.projectId === candidatePlacement.projectId,
),
)
: undefined) ??
input.projects.find((project) => project.projectKey === candidate.projectKey) ??
candidate;
resolveHostProjectCandidate({
candidate,
projects: input.projects,
serverId: input.serverId,
}) ?? candidate;
if (
canCreateWorkspaceForHostProject({
project: hydratedProject,
@@ -186,19 +246,19 @@ export function resolveInitialWorktreeProject(input: {
}
export function resolveSelectedHostProject(input: {
selectedProjectKey: string | null;
selectedViewKey: string | null;
projects: readonly HostProjectListItem[];
routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null;
}): HostProjectListItem | null {
const selectedProjectKey = input.selectedProjectKey?.trim() ?? "";
if (!selectedProjectKey) {
const selectedViewKey = input.selectedViewKey?.trim() ?? "";
if (!selectedViewKey) {
return null;
}
return (
input.projects.find((project) => project.projectKey === selectedProjectKey) ??
(input.routeProject?.projectKey === selectedProjectKey ? input.routeProject : null) ??
(input.lastActiveProject?.projectKey === selectedProjectKey ? input.lastActiveProject : null)
input.projects.find((project) => project.viewKey === selectedViewKey) ??
(input.routeProject?.viewKey === selectedViewKey ? input.routeProject : null) ??
(input.lastActiveProject?.viewKey === selectedViewKey ? input.lastActiveProject : null)
);
}
@@ -9,6 +9,7 @@ import {
function project(): HostProjectListItem {
return {
viewKey: "view:acme/app",
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
projectKind: "git",
@@ -57,7 +58,7 @@ describe("host project lookups", () => {
sourceDirectory: "/repo/a",
}),
).toMatchObject({
projectKey: "prj_a",
projectKey: null,
hosts: [{ serverId: "host-a", projectId: "prj_a" }],
});
});
@@ -9,6 +9,9 @@ export {
getHostProjectId,
hostProjectFromRoute,
hostProjectFromWorkspace,
resolveHostProjectCandidate,
resolveExactHostProjectCandidate,
resolveEquivalentHostProjectCandidate,
resolveInitialWorkspaceProject,
resolveInitialWorktreeProject,
resolveSelectedHostProject,
+4 -4
View File
@@ -5,7 +5,7 @@ import { projectIconQueryKey, projectIconToDataUri } from "@/hooks/use-project-i
export interface ProjectIconRequestTarget {
serverId: string;
projectKey: string;
projectViewKey: string;
iconWorkingDir: string;
}
@@ -20,7 +20,7 @@ function useStableProjectIconData(
return stableRef.current.data;
}
export function useProjectIconDataByProjectKey(input: {
export function useProjectIconDataByProjectViewKey(input: {
projects: readonly ProjectIconRequestTarget[];
}): Map<string, string | null> {
const projectIconRequests = useMemo(() => {
@@ -80,11 +80,11 @@ export function useProjectIconDataByProjectKey(input: {
for (const project of input.projects) {
const cwd = project.iconWorkingDir.trim();
if (!cwd) {
byProject.set(project.projectKey, null);
byProject.set(project.projectViewKey, null);
continue;
}
byProject.set(
project.projectKey,
project.projectViewKey,
iconByServerAndCwd.get(`${project.serverId}:${cwd}`) ?? null,
);
}
@@ -6,7 +6,6 @@ import {
} from "./project-remove";
const project: ProjectRemoveProject = {
projectKey: "remote:github.com/acme/app",
hosts: [
{ serverId: "host-a", projectId: "prj_host_a" },
{ serverId: "host-b", projectId: "prj_host_b" },
@@ -8,7 +8,6 @@ interface ProjectRemoveHost {
}
export interface ProjectRemoveProject {
projectKey: string;
hosts: readonly ProjectRemoveHost[];
}
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { buildWorkspaceStructureProjects } from "./workspace-structure";
import { buildWorkspaceStructureProjects, createProjectViewKey } from "./workspace-structure";
function project(input: {
id: string;
@@ -57,6 +57,7 @@ describe("buildWorkspaceStructureProjects", () => {
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
viewKey: key,
projectKey: key,
hosts: [
{ serverId: "host-a", projectId: "prj_a" },
@@ -85,6 +86,7 @@ describe("buildWorkspaceStructureProjects", () => {
});
expect(result).toHaveLength(2);
expect(result.map((item) => item.projectKey)).toEqual([key, key]);
expect(result.map((item) => item.hosts[0]?.projectId).sort()).toEqual(["prj_one", "prj_two"]);
expect(result.map((item) => item.workspaceKeys[0]).sort()).toEqual([
"host-a:ws-one",
@@ -112,7 +114,7 @@ describe("buildWorkspaceStructureProjects", () => {
const identitiesByProjectId = (projects: ReturnType<typeof build>) => {
const identities: Record<string, string> = {};
for (const group of projects) {
for (const host of group.hosts) identities[host.projectId] = group.projectKey;
for (const host of group.hosts) identities[host.projectId] = group.viewKey;
}
return identities;
};
@@ -142,6 +144,33 @@ describe("buildWorkspaceStructureProjects", () => {
});
expect(result).toHaveLength(2);
expect(new Set(result.map((item) => item.projectKey)).size).toBe(2);
expect(result.map((item) => item.projectKey)).toEqual([null, null]);
expect(new Set(result.map((item) => item.viewKey)).size).toBe(2);
});
test("keeps opaque project keys separate from placement view keys", () => {
const placementShapedKey = createProjectViewKey({
kind: "placement",
serverId: "host-a",
projectId: "prj_b",
});
const result = buildWorkspaceStructureProjects({
sessions: [
{
serverId: "host-a",
projects: [
project({ id: "prj_a", key: placementShapedKey, root: "/repos/a" }),
project({ id: "prj_b", key: null, root: "/repos/b" }),
],
workspaces: [],
},
],
});
expect(result).toHaveLength(2);
expect(new Set(result.map((item) => item.viewKey)).size).toBe(2);
expect(result.find((item) => item.projectKey === placementShapedKey)?.viewKey).toBe(
placementShapedKey,
);
});
});
@@ -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 {