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:
@@ -14,7 +14,7 @@ function ws(
|
||||
return {
|
||||
serverId: input.serverId ?? "srv",
|
||||
workspaceId: input.workspaceId ?? input.workspaceKey.split(":")[1] ?? "ws",
|
||||
projectKey: input.projectKey ?? "proj",
|
||||
projectViewKey: input.projectViewKey ?? "proj",
|
||||
projectName: input.projectName ?? "Project",
|
||||
projectRootPath: input.projectRootPath,
|
||||
workspaceDirectory: input.workspaceDirectory,
|
||||
@@ -124,9 +124,24 @@ describe("buildStatusGroups", () => {
|
||||
]);
|
||||
|
||||
const workspaces = [
|
||||
ws({ workspaceKey: "srv:1", statusBucket: "done", projectKey: "proj-b", name: "zebra" }),
|
||||
ws({ workspaceKey: "srv:2", statusBucket: "done", projectKey: "proj-a", name: "alpha" }),
|
||||
ws({ workspaceKey: "srv:3", statusBucket: "done", projectKey: "proj-a", name: "alpha" }),
|
||||
ws({
|
||||
workspaceKey: "srv:1",
|
||||
statusBucket: "done",
|
||||
projectViewKey: "proj-b",
|
||||
name: "zebra",
|
||||
}),
|
||||
ws({
|
||||
workspaceKey: "srv:2",
|
||||
statusBucket: "done",
|
||||
projectViewKey: "proj-a",
|
||||
name: "alpha",
|
||||
}),
|
||||
ws({
|
||||
workspaceKey: "srv:3",
|
||||
statusBucket: "done",
|
||||
projectViewKey: "proj-a",
|
||||
name: "alpha",
|
||||
}),
|
||||
];
|
||||
|
||||
const groups = buildStatusGroups(workspaces, projectNames);
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface StatusGroup {
|
||||
|
||||
export function buildStatusGroups(
|
||||
workspaces: SidebarWorkspaceEntry[],
|
||||
projectNamesByKey: Map<string, string>,
|
||||
projectNamesByViewKey: Map<string, string>,
|
||||
): StatusGroup[] {
|
||||
const bucketRows = new Map<StatusBucket, SidebarWorkspaceEntry[]>();
|
||||
|
||||
@@ -46,7 +46,7 @@ export function buildStatusGroups(
|
||||
const rows = bucketRows.get(bucket);
|
||||
if (!rows || rows.length === 0) continue;
|
||||
|
||||
rows.sort((a, b) => compareStatusRows(a, b, projectNamesByKey));
|
||||
rows.sort((a, b) => compareStatusRows(a, b, projectNamesByViewKey));
|
||||
groups.push({ bucket, label: STATUS_BUCKET_LABELS[bucket], rows });
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export function buildStatusGroups(
|
||||
function compareStatusRows(
|
||||
a: SidebarWorkspaceEntry,
|
||||
b: SidebarWorkspaceEntry,
|
||||
projectNamesByKey: Map<string, string>,
|
||||
projectNamesByViewKey: Map<string, string>,
|
||||
): number {
|
||||
const aTime = a.statusEnteredAt?.getTime() ?? null;
|
||||
const bTime = b.statusEnteredAt?.getTime() ?? null;
|
||||
@@ -69,8 +69,8 @@ function compareStatusRows(
|
||||
return 1;
|
||||
}
|
||||
|
||||
const aProject = projectNamesByKey.get(a.projectKey) ?? "";
|
||||
const bProject = projectNamesByKey.get(b.projectKey) ?? "";
|
||||
const aProject = projectNamesByViewKey.get(a.projectViewKey) ?? "";
|
||||
const bProject = projectNamesByViewKey.get(b.projectViewKey) ?? "";
|
||||
const projectCmp = aProject.localeCompare(bProject);
|
||||
if (projectCmp !== 0) return projectCmp;
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ function project(input: {
|
||||
>;
|
||||
}): WorkspaceStructureProject {
|
||||
return {
|
||||
viewKey: input.projectKey,
|
||||
projectKey: input.projectKey,
|
||||
projectName: input.projectName ?? input.projectKey,
|
||||
projectKind: input.projectKind ?? "git",
|
||||
@@ -227,7 +228,7 @@ describe("buildSidebarProjectsFromStructure", () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(projects.map((entry) => entry.projectKey)).toEqual(["project-b", "project-a"]);
|
||||
expect(projects.map((entry) => entry.viewKey)).toEqual(["project-b", "project-a"]);
|
||||
});
|
||||
|
||||
it("preserves the structure hook workspace order", () => {
|
||||
@@ -327,7 +328,7 @@ describe("shared sidebar workspace model", () => {
|
||||
]);
|
||||
expect(model.projects).toEqual([
|
||||
expect.objectContaining({
|
||||
projectKey: "getpaseo/paseo",
|
||||
viewKey: "getpaseo/paseo",
|
||||
hosts: [
|
||||
{
|
||||
serverId: "host-a",
|
||||
@@ -366,7 +367,7 @@ describe("shared sidebar workspace model", () => {
|
||||
["host-a:main", "done", "main"],
|
||||
["host-b:feature", "running", "feature/status-flow"],
|
||||
]);
|
||||
expect(model.projectNamesByKey).toEqual(new Map([["getpaseo/paseo", "getpaseo/paseo"]]));
|
||||
expect(model.projectNamesByViewKey).toEqual(new Map([["getpaseo/paseo", "getpaseo/paseo"]]));
|
||||
});
|
||||
|
||||
it("preserves unchanged row identities when another workspace updates", () => {
|
||||
@@ -443,7 +444,7 @@ describe("shared sidebar workspace model", () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(entries.get("srv:clone-a")?.projectKey).toBe(projectKey);
|
||||
expect(entries.get("srv:clone-a")?.projectViewKey).toBe(projectKey);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -529,8 +530,8 @@ describe("computeSidebarOrderUpdates", () => {
|
||||
|
||||
expect(updates.projectOrder).toEqual(["project-a", "project-b"]);
|
||||
expect(updates.workspaceOrders).toEqual([
|
||||
{ projectKey: "project-a", order: ["srv:ws-2", "srv:ws-1"] },
|
||||
{ projectKey: "project-b", order: ["srv:ws-3"] },
|
||||
{ projectViewKey: "project-a", order: ["srv:ws-2", "srv:ws-1"] },
|
||||
{ projectViewKey: "project-b", order: ["srv:ws-3"] },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -550,7 +551,7 @@ describe("computeSidebarOrderUpdates", () => {
|
||||
|
||||
expect(updates.workspaceOrders).toEqual([
|
||||
{
|
||||
projectKey: "project-a",
|
||||
projectViewKey: "project-a",
|
||||
order: ["srv:newest", "srv:newer", "srv:old-b", "srv:old-a"],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface SidebarWorkspacePlacement {
|
||||
workspaceKey: string;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
projectKey: string;
|
||||
projectViewKey: string;
|
||||
projectName: string;
|
||||
projectRootPath?: string;
|
||||
workspaceDirectory?: string;
|
||||
@@ -50,7 +50,7 @@ export interface SidebarWorkspaceEntry extends SidebarStatusWorkspacePlacement {
|
||||
}
|
||||
|
||||
export interface SidebarProjectEntry {
|
||||
projectKey: string;
|
||||
viewKey: string;
|
||||
projectName: string;
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
iconWorkingDir: string;
|
||||
@@ -61,7 +61,7 @@ export interface SidebarProjectEntry {
|
||||
export interface SidebarWorkspacePlacementModel {
|
||||
workspaces: SidebarWorkspacePlacement[];
|
||||
projects: SidebarProjectEntry[];
|
||||
projectNamesByKey: Map<string, string>;
|
||||
projectNamesByViewKey: Map<string, string>;
|
||||
}
|
||||
|
||||
export interface SidebarWorkspaceSession {
|
||||
@@ -122,11 +122,11 @@ interface EffectiveWorkspaceStatus {
|
||||
enteredAt: Date | null;
|
||||
}
|
||||
|
||||
function projectNameForWorkspace(workspace: WorkspaceDescriptor, projectKey: string): string {
|
||||
function projectNameForWorkspace(workspace: WorkspaceDescriptor): string {
|
||||
return (
|
||||
workspace.projectCustomName ??
|
||||
workspace.projectDisplayName ??
|
||||
projectDisplayNameFromProjectId(projectKey)
|
||||
projectDisplayNameFromProjectId(workspace.projectId)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,18 +141,18 @@ function normalizeCurrentBranch(currentBranch: string | null | undefined): strin
|
||||
export function createSidebarWorkspaceEntry(input: {
|
||||
serverId: string;
|
||||
workspace: WorkspaceDescriptor;
|
||||
projectKey?: string;
|
||||
projectViewKey?: string;
|
||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
|
||||
}): SidebarWorkspaceEntry {
|
||||
const projectKey = input.projectKey ?? input.workspace.projectId;
|
||||
const projectViewKey = input.projectViewKey ?? input.workspace.projectId;
|
||||
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
|
||||
return {
|
||||
workspaceKey: `${input.serverId}:${input.workspace.id}`,
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspace.id,
|
||||
projectKey,
|
||||
projectName: projectNameForWorkspace(input.workspace, projectKey),
|
||||
projectViewKey,
|
||||
projectName: projectNameForWorkspace(input.workspace),
|
||||
projectRootPath: input.workspace.projectRootPath,
|
||||
workspaceDirectory: input.workspace.workspaceDirectory,
|
||||
projectKind: input.workspace.projectKind,
|
||||
@@ -228,8 +228,8 @@ export function buildSidebarWorkspacePlacementModel(input: {
|
||||
return {
|
||||
projects,
|
||||
workspaces: projects.flatMap((project) => project.workspaces),
|
||||
projectNamesByKey: new Map(
|
||||
projects.map((project) => [project.projectKey, project.projectName]),
|
||||
projectNamesByViewKey: new Map(
|
||||
projects.map((project) => [project.viewKey, project.projectName]),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -247,7 +247,7 @@ function createStructuralWorkspaceEntry(input: {
|
||||
workspaceKey: identity.workspaceKey,
|
||||
serverId: identity.serverId,
|
||||
workspaceId: identity.workspaceId,
|
||||
projectKey: input.project.projectKey,
|
||||
projectViewKey: input.project.viewKey,
|
||||
projectName: input.project.projectName,
|
||||
projectRootPath: input.project.iconWorkingDir,
|
||||
workspaceDirectory: undefined,
|
||||
@@ -324,7 +324,7 @@ export function buildSidebarWorkspaceEntries(input: {
|
||||
const entry = createSidebarWorkspaceEntry({
|
||||
serverId: placement.serverId,
|
||||
workspace,
|
||||
projectKey: placement.projectKey,
|
||||
projectViewKey: placement.projectViewKey,
|
||||
pendingCreateAttempts: input.pendingCreateAttempts,
|
||||
workspaceAgentActivity: session.workspaceAgentActivity,
|
||||
});
|
||||
@@ -369,6 +369,7 @@ export function buildSidebarProjectsFromStructure(input: {
|
||||
}): SidebarProjectEntry[] {
|
||||
return buildSidebarProjectsFromHostProjects({
|
||||
projects: input.projects.map((project) => ({
|
||||
viewKey: project.viewKey,
|
||||
projectKey: project.projectKey,
|
||||
projectName: project.projectName,
|
||||
projectKind: project.projectKind,
|
||||
@@ -387,7 +388,7 @@ export function buildSidebarProjectsFromHostProjects(input: {
|
||||
}
|
||||
|
||||
return input.projects.map((project) => ({
|
||||
projectKey: project.projectKey,
|
||||
viewKey: project.viewKey,
|
||||
projectName: project.projectName,
|
||||
projectKind: project.projectKind,
|
||||
iconWorkingDir: project.iconWorkingDir,
|
||||
@@ -498,13 +499,13 @@ export function prependMissingOrderKeys(input: {
|
||||
|
||||
export interface SidebarOrderUpdates {
|
||||
projectOrder: string[] | null;
|
||||
workspaceOrders: Array<{ projectKey: string; order: string[] }>;
|
||||
workspaceOrders: Array<{ projectViewKey: string; order: string[] }>;
|
||||
}
|
||||
|
||||
export function computeSidebarOrderUpdates(input: {
|
||||
projects: SidebarProjectEntry[];
|
||||
persistedProjectOrder: string[];
|
||||
getWorkspaceOrder: (projectKey: string) => string[];
|
||||
getWorkspaceOrder: (projectViewKey: string) => string[];
|
||||
}): SidebarOrderUpdates {
|
||||
if (input.projects.length === 0) {
|
||||
return { projectOrder: null, workspaceOrders: [] };
|
||||
@@ -512,19 +513,19 @@ export function computeSidebarOrderUpdates(input: {
|
||||
|
||||
const nextProjectOrder = appendMissingOrderKeys({
|
||||
currentOrder: input.persistedProjectOrder,
|
||||
visibleKeys: input.projects.map((project) => project.projectKey),
|
||||
visibleKeys: input.projects.map((project) => project.viewKey),
|
||||
});
|
||||
const projectOrder = nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder;
|
||||
|
||||
const workspaceOrders: Array<{ projectKey: string; order: string[] }> = [];
|
||||
const workspaceOrders: Array<{ projectViewKey: string; order: string[] }> = [];
|
||||
for (const project of input.projects) {
|
||||
const persistedWorkspaceOrder = input.getWorkspaceOrder(project.projectKey);
|
||||
const persistedWorkspaceOrder = input.getWorkspaceOrder(project.viewKey);
|
||||
const nextWorkspaceOrder = prependMissingOrderKeys({
|
||||
currentOrder: persistedWorkspaceOrder,
|
||||
visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey),
|
||||
});
|
||||
if (nextWorkspaceOrder !== persistedWorkspaceOrder) {
|
||||
workspaceOrders.push({ projectKey: project.projectKey, order: nextWorkspaceOrder });
|
||||
workspaceOrders.push({ projectViewKey: project.viewKey, order: nextWorkspaceOrder });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ function placement(workspaceKey: string): SidebarWorkspacePlacement {
|
||||
workspaceKey,
|
||||
serverId: "s1",
|
||||
workspaceId: workspaceKey,
|
||||
projectKey: "p1",
|
||||
projectViewKey: "p1",
|
||||
projectName: "Project 1",
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
@@ -20,7 +20,7 @@ function placement(workspaceKey: string): SidebarWorkspacePlacement {
|
||||
|
||||
function project(projectKey: string, workspaces: SidebarWorkspacePlacement[]): SidebarProjectEntry {
|
||||
return {
|
||||
projectKey,
|
||||
viewKey: projectKey,
|
||||
projectName: projectKey,
|
||||
projectKind: "git",
|
||||
iconWorkingDir: "",
|
||||
|
||||
@@ -17,7 +17,7 @@ function workspace(projectKey: string, workspaceId: string): SidebarWorkspaceEnt
|
||||
workspaceKey: `srv:${workspaceId}`,
|
||||
serverId: "srv",
|
||||
workspaceId,
|
||||
projectKey,
|
||||
projectViewKey: projectKey,
|
||||
projectName: projectKey,
|
||||
projectRootPath: `/repo/${projectKey}`,
|
||||
workspaceDirectory: `/repo/${projectKey}/${workspaceId}`,
|
||||
@@ -40,7 +40,7 @@ function workspace(projectKey: string, workspaceId: string): SidebarWorkspaceEnt
|
||||
|
||||
function project(projectKey: string): SidebarProjectEntry {
|
||||
return {
|
||||
projectKey,
|
||||
viewKey: projectKey,
|
||||
projectName: projectKey,
|
||||
projectKind: "git",
|
||||
iconWorkingDir: `/repo/${projectKey}`,
|
||||
|
||||
@@ -41,7 +41,7 @@ const EMPTY_PROJECT_NAMES = new Map<string, string>();
|
||||
export interface SidebarWorkspacesListResult {
|
||||
workspacePlacements: SidebarWorkspacePlacement[];
|
||||
projects: SidebarProjectEntry[];
|
||||
projectNamesByKey: Map<string, string>;
|
||||
projectNamesByViewKey: Map<string, string>;
|
||||
isLoading: boolean;
|
||||
isInitialLoad: boolean;
|
||||
isRevalidating: boolean;
|
||||
@@ -100,23 +100,25 @@ export function useSidebarWorkspacesList(options?: {
|
||||
const projects = sidebarModel.projects.length > 0 ? sidebarModel.projects : EMPTY_PROJECTS;
|
||||
const workspacePlacements =
|
||||
sidebarModel.workspaces.length > 0 ? sidebarModel.workspaces : EMPTY_WORKSPACES;
|
||||
const projectNamesByKey =
|
||||
sidebarModel.projectNamesByKey.size > 0 ? sidebarModel.projectNamesByKey : EMPTY_PROJECT_NAMES;
|
||||
const projectNamesByViewKey =
|
||||
sidebarModel.projectNamesByViewKey.size > 0
|
||||
? sidebarModel.projectNamesByViewKey
|
||||
: EMPTY_PROJECT_NAMES;
|
||||
|
||||
useEffect(() => {
|
||||
const orderStore = useSidebarOrderStore.getState();
|
||||
const updates = computeSidebarOrderUpdates({
|
||||
projects,
|
||||
persistedProjectOrder,
|
||||
getWorkspaceOrder: (projectKey) =>
|
||||
orderStore.workspaceOrderByProject[projectKey] ?? EMPTY_ORDER,
|
||||
getWorkspaceOrder: (projectViewKey) =>
|
||||
orderStore.workspaceOrderByProject[projectViewKey] ?? EMPTY_ORDER,
|
||||
});
|
||||
|
||||
if (updates.projectOrder) {
|
||||
orderStore.setProjectOrder(updates.projectOrder);
|
||||
}
|
||||
for (const { projectKey, order } of updates.workspaceOrders) {
|
||||
orderStore.setWorkspaceOrder(projectKey, order);
|
||||
for (const { projectViewKey, order } of updates.workspaceOrders) {
|
||||
orderStore.setWorkspaceOrder(projectViewKey, order);
|
||||
}
|
||||
}, [persistedProjectOrder, projects]);
|
||||
|
||||
@@ -144,7 +146,7 @@ export function useSidebarWorkspacesList(options?: {
|
||||
return {
|
||||
workspacePlacements,
|
||||
projects,
|
||||
projectNamesByKey,
|
||||
projectNamesByViewKey,
|
||||
...loadingState,
|
||||
refreshAll,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user