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
+2 -1
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from "vitest";
import { createProjectViewKey } from "@/projects/workspace-structure";
import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { buildProjects, getProjectSummaryForHostProject } from "./projects";
@@ -56,7 +57,7 @@ describe("buildProjects", () => {
expect(result.projects).toEqual([
expect.objectContaining({
projectKey: key,
viewKey: createProjectViewKey({ kind: "equivalence", projectKey: key }),
hostCount: 2,
onlineHostCount: 1,
totalWorkspaceCount: 2,
+12 -12
View File
@@ -27,7 +27,7 @@ export interface ProjectHostEntry {
}
export interface ProjectSummary {
projectKey: string;
viewKey: string;
projectName: string;
projectCustomName?: string | null;
hosts: ProjectHostEntry[];
@@ -91,7 +91,7 @@ interface HostGroup {
}
interface ProjectGroup {
projectKey: string;
viewKey: string;
projectName: string;
projectCustomName: string | null;
hostsByServerId: Map<string, HostGroup>;
@@ -99,10 +99,10 @@ interface ProjectGroup {
function findProjectMetadata(
host: ProjectHost,
projectKey: string,
projectId: string,
): { customName: string | null; displayName: string } | null {
for (const project of host.projects) {
if (project.projectId === projectKey) {
if (project.projectId === projectId) {
return {
customName: project.projectCustomName ?? null,
displayName: project.projectDisplayName,
@@ -172,7 +172,7 @@ function toProjectSummary(draft: ProjectGroup): ProjectSummary {
const totalWorkspaceCount = hosts.reduce((sum, host) => sum + host.workspaceCount, 0);
const onlineHostCount = hosts.filter((host) => host.isOnline).length;
return {
projectKey: draft.projectKey,
viewKey: draft.viewKey,
projectName: draft.projectName,
projectCustomName: draft.projectCustomName,
hosts,
@@ -196,15 +196,15 @@ function addHostProjects(
if (!placement) continue;
const projectId = placement.projectId;
const customName = findProjectMetadata(host, projectId);
let group = groups.get(hostProject.projectKey);
let group = groups.get(hostProject.viewKey);
if (!group) {
group = {
projectKey: hostProject.projectKey,
viewKey: hostProject.viewKey,
projectName: customName?.displayName ?? hostProject.projectName,
projectCustomName: customName?.customName ?? null,
hostsByServerId: new Map(),
};
groups.set(hostProject.projectKey, group);
groups.set(hostProject.viewKey, group);
} else if (customName?.customName && !group.projectCustomName) {
group.projectCustomName = customName.customName;
group.projectName = customName.displayName;
@@ -230,16 +230,16 @@ function attachHostWorkspaces(
host: ProjectHost,
hostProjects: HostProjectListItem[],
): void {
const projectKeyByProjectId = new Map(
const projectViewKeyByProjectId = new Map(
hostProjects.flatMap((project) =>
project.hosts
.filter((placement) => placement.serverId === host.serverId && placement.projectId)
.map((placement) => [placement.projectId!, project.projectKey] as const),
.map((placement) => [placement.projectId!, project.viewKey] as const),
),
);
for (const workspace of host.workspaces) {
const key = projectKeyByProjectId.get(workspace.projectId);
const key = projectViewKeyByProjectId.get(workspace.projectId);
if (key) groups.get(key)?.hostsByServerId.get(host.serverId)?.workspaces.push(workspace);
}
}
@@ -262,7 +262,7 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
if (name !== 0) {
return name;
}
return left.projectKey.localeCompare(right.projectKey);
return left.viewKey.localeCompare(right.viewKey);
});
return { projects };
@@ -6,6 +6,7 @@ import type {
import {
buildSidebarProjectRowModel,
resolveSidebarProjectIconTarget,
resolveSidebarProjectIconTargets,
resolveSidebarProjectLocalPath,
} from "./sidebar-project-row-model";
@@ -14,7 +15,7 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
workspaceKey: "srv:ws-root",
serverId: "srv",
workspaceId: "ws-root",
projectKey: "project-1",
projectViewKey: "project-1",
projectName: "paseo",
workspaceDirectory: "/repo",
projectKind: "git",
@@ -48,7 +49,7 @@ function project(overrides: ProjectOverrides = {}): SidebarProjectEntry {
(host) => Object.assign({}, host, { projectId: host.projectId ?? `project-${host.serverId}` }),
);
return {
projectKey: "project-1",
viewKey: "project-1",
projectName: "paseo",
projectKind,
iconWorkingDir: "/repo",
@@ -209,6 +210,22 @@ describe("buildSidebarProjectRowModel", () => {
});
});
it("keys project icon results by the rendered project view", () => {
const [iconTarget] = resolveSidebarProjectIconTargets([
project({
viewKey: '["placement","host-b","project-b"]',
hosts: [{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true }],
}),
]);
expect(iconTarget).toEqual({
projectViewKey: '["placement","host-b","project-b"]',
serverId: "host-b",
projectId: "project-host-b",
iconWorkingDir: "/repo/b",
});
});
it("resolves desktop file actions from the local project placement", () => {
const groupedProject = project({
iconWorkingDir: "/remote/repo",
@@ -44,7 +44,7 @@ export function resolveSidebarProjectIconTarget(
}
export interface SidebarProjectIconTarget extends SidebarProjectHostTarget {
projectKey: string;
projectViewKey: string;
}
export function resolveSidebarProjectIconTargets(
@@ -52,7 +52,7 @@ export function resolveSidebarProjectIconTargets(
): SidebarProjectIconTarget[] {
return projects.flatMap((project) => {
const target = resolveSidebarProjectIconTarget(project);
return target ? [{ projectKey: project.projectKey, ...target }] : [];
return target ? [{ projectViewKey: project.viewKey, ...target }] : [];
});
}
@@ -24,7 +24,7 @@ function workspace(input: {
workspaceKey: `${input.serverId}:${input.workspaceId}`,
serverId: input.serverId,
workspaceId: input.workspaceId,
projectKey: input.projectKey ?? "project-default",
projectViewKey: input.projectKey ?? "project-default",
projectName: input.projectKey ?? "Project",
workspaceDirectory: input.workspaceDirectory,
projectKind: "git",
@@ -46,7 +46,7 @@ function workspace(input: {
function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): SidebarProjectEntry {
return {
projectKey,
viewKey: projectKey,
projectName: projectKey,
projectKind: "git",
iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "",
+1 -1
View File
@@ -36,7 +36,7 @@ export function buildSidebarShortcutModel(input: {
return buildSidebarShortcutSections({
sections: input.projects.map((project) => ({
workspaces: project.workspaces,
collapsed: input.collapsedProjectKeys.has(project.projectKey),
collapsed: input.collapsedProjectKeys.has(project.viewKey),
})),
shortcutLimit: input.shortcutLimit,
});