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:
@@ -45,7 +45,7 @@ function projectOptionId(projectId: string): string {
|
||||
function computeProjectOptionData(projects: readonly HostProjectListItem[]) {
|
||||
const projectByOptionId = new Map<string, HostProjectListItem>();
|
||||
const options = projects.map((project) => {
|
||||
const id = projectOptionId(project.projectKey);
|
||||
const id = projectOptionId(project.viewKey);
|
||||
projectByOptionId.set(id, project);
|
||||
return { id, label: project.projectName };
|
||||
});
|
||||
@@ -104,15 +104,14 @@ export function useNewWorkspaceProjectPicker({
|
||||
[allowAllProjects, lastActiveProject, routeProject, selectableProjects, selectedServerId],
|
||||
);
|
||||
|
||||
const routeProjectKey = routeProject?.projectKey ?? null;
|
||||
const routeProjectViewKey = routeProject?.viewKey ?? null;
|
||||
const selectionContextKey = createProjectSelectionContextKey({
|
||||
selectedServerId,
|
||||
routeProjectKey,
|
||||
routeProjectViewKey,
|
||||
allowAllProjects,
|
||||
});
|
||||
const manualSelectionContextKey = createManualProjectSelectionContextKey({
|
||||
selectedServerId,
|
||||
routeProjectKey,
|
||||
routeProjectViewKey,
|
||||
});
|
||||
const shouldPreserveMissingProject = useCallback(
|
||||
(project: HostProjectListItem) =>
|
||||
@@ -126,6 +125,7 @@ export function useNewWorkspaceProjectPicker({
|
||||
() => ({
|
||||
contextKey: selectionContextKey,
|
||||
manualContextKey: manualSelectionContextKey,
|
||||
selectedServerId,
|
||||
initialProject,
|
||||
initialProjectSource: resolveInitialProjectSelectionSource({
|
||||
initialProject,
|
||||
@@ -143,6 +143,7 @@ export function useNewWorkspaceProjectPicker({
|
||||
manualSelectionContextKey,
|
||||
routeProject,
|
||||
selectableProjects,
|
||||
selectedServerId,
|
||||
selectionContextKey,
|
||||
shouldPreserveMissingProject,
|
||||
],
|
||||
@@ -168,8 +169,8 @@ export function useNewWorkspaceProjectPicker({
|
||||
if (!allowAllProjects && !project.hosts.some((host) => host.canCreateWorktree)) return;
|
||||
setProjectSelection({
|
||||
contextKey: manualSelectionContextKey,
|
||||
projectKey: project.projectKey,
|
||||
project,
|
||||
originProject: project,
|
||||
source: "manual",
|
||||
});
|
||||
},
|
||||
@@ -183,7 +184,7 @@ export function useNewWorkspaceProjectPicker({
|
||||
: null,
|
||||
projectPickerOptions,
|
||||
projectByOptionId,
|
||||
selectedProjectOptionId: selectedProject ? projectOptionId(selectedProject.projectKey) : "",
|
||||
selectedProjectOptionId: selectedProject ? projectOptionId(selectedProject.viewKey) : "",
|
||||
projectTriggerLabel: selectedProject?.projectName ?? "Choose project",
|
||||
handleSelectProjectOption,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HostProjectListItem } from "@/projects/host-projects";
|
||||
import { filterWorkspaceProjectsForHost, type HostProjectListItem } from "@/projects/host-projects";
|
||||
import {
|
||||
createManualProjectSelectionContextKey,
|
||||
createProjectSelectionContextKey,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
|
||||
function project(projectKey: string, serverId = "host"): HostProjectListItem {
|
||||
return {
|
||||
viewKey: `view:${serverId}:${projectKey}`,
|
||||
projectKey,
|
||||
projectName: projectKey,
|
||||
projectKind: "git",
|
||||
@@ -40,7 +41,8 @@ function context(
|
||||
const lastActiveProject = input.lastActiveProject ?? null;
|
||||
return {
|
||||
contextKey,
|
||||
manualContextKey: input.manualContextKey ?? contextKey,
|
||||
manualContextKey: input.manualContextKey ?? "",
|
||||
selectedServerId: input.selectedServerId ?? "host",
|
||||
routeProject,
|
||||
lastActiveProject,
|
||||
initialProjectSource:
|
||||
@@ -71,7 +73,6 @@ describe("reconcileProjectSelection", () => {
|
||||
|
||||
expect(reconciled).toEqual({
|
||||
contextKey: "host:",
|
||||
projectKey: remembered.projectKey,
|
||||
project: remembered,
|
||||
source: "initial",
|
||||
});
|
||||
@@ -80,15 +81,16 @@ describe("reconcileProjectSelection", () => {
|
||||
|
||||
it("resets stale selection when the route project context changes", () => {
|
||||
const manual = project("manual");
|
||||
const routeProject = project("route-project");
|
||||
const routeProject = { ...project("route-project"), projectKey: null };
|
||||
const current: ProjectSelection = {
|
||||
contextKey: "host:previous-route",
|
||||
projectKey: manual.projectKey,
|
||||
contextKey: "previous-route",
|
||||
project: manual,
|
||||
originProject: manual,
|
||||
source: "manual",
|
||||
};
|
||||
const nextContext = context({
|
||||
contextKey: "host:route-project",
|
||||
manualContextKey: routeProject.viewKey,
|
||||
initialProject: routeProject,
|
||||
projects: [manual, routeProject],
|
||||
routeProject,
|
||||
@@ -96,7 +98,6 @@ describe("reconcileProjectSelection", () => {
|
||||
|
||||
expect(reconcileProjectSelection(current, nextContext)).toEqual({
|
||||
contextKey: "host:route-project",
|
||||
projectKey: routeProject.projectKey,
|
||||
project: routeProject,
|
||||
source: "initial",
|
||||
});
|
||||
@@ -112,7 +113,6 @@ describe("reconcileProjectSelection", () => {
|
||||
|
||||
expect(reconcileProjectSelection(current, hydratedContext)).toEqual({
|
||||
contextKey: "host:",
|
||||
projectKey: initialProject.projectKey,
|
||||
project: initialProject,
|
||||
source: "initial",
|
||||
});
|
||||
@@ -121,6 +121,7 @@ describe("reconcileProjectSelection", () => {
|
||||
it("adopts a routed project's hydrated cross-host key", () => {
|
||||
const routedProject = {
|
||||
...project("local-project-id"),
|
||||
projectKey: null,
|
||||
hosts: [
|
||||
{
|
||||
serverId: "host",
|
||||
@@ -152,7 +153,6 @@ describe("reconcileProjectSelection", () => {
|
||||
|
||||
expect(reconcileProjectSelection(current, hydratedContext)).toEqual({
|
||||
contextKey: "host:",
|
||||
projectKey: hydratedProject.projectKey,
|
||||
project: hydratedProject,
|
||||
source: "initial",
|
||||
});
|
||||
@@ -177,7 +177,6 @@ describe("reconcileProjectSelection", () => {
|
||||
|
||||
expect(hydratedSelection).toEqual({
|
||||
contextKey: "host:",
|
||||
projectKey: hydratedProject.projectKey,
|
||||
project: hydratedProject,
|
||||
source: "initial",
|
||||
});
|
||||
@@ -207,7 +206,6 @@ describe("reconcileProjectSelection", () => {
|
||||
|
||||
expect(reconcileProjectSelection(current, afterRememberedHydration)).toEqual({
|
||||
contextKey: "host:",
|
||||
projectKey: remembered.projectKey,
|
||||
project: remembered,
|
||||
source: "initial",
|
||||
});
|
||||
@@ -217,9 +215,9 @@ describe("reconcileProjectSelection", () => {
|
||||
const manual = project("manual");
|
||||
const remembered = project("remembered");
|
||||
const current: ProjectSelection = {
|
||||
contextKey: "host:",
|
||||
projectKey: manual.projectKey,
|
||||
contextKey: "",
|
||||
project: manual,
|
||||
originProject: manual,
|
||||
source: "manual",
|
||||
};
|
||||
const afterRememberedHydration = context({
|
||||
@@ -231,6 +229,295 @@ describe("reconcileProjectSelection", () => {
|
||||
expect(reconcileProjectSelection(current, afterRememberedHydration)).toEqual(current);
|
||||
});
|
||||
|
||||
it("resolves a manual selection on another host while retaining its origin snapshot", () => {
|
||||
const selected = project("shared", "host-a");
|
||||
const equivalent = project("shared", "host-b");
|
||||
const fallback = project("fallback", "host-b");
|
||||
const current: ProjectSelection = {
|
||||
contextKey: "",
|
||||
project: selected,
|
||||
originProject: selected,
|
||||
source: "manual",
|
||||
};
|
||||
const nextHost = context({
|
||||
selectedServerId: "host-b",
|
||||
initialProject: fallback,
|
||||
projects: [fallback, equivalent],
|
||||
});
|
||||
|
||||
const reconciled = reconcileProjectSelection(current, nextHost);
|
||||
|
||||
expect(reconciled).toEqual({
|
||||
contextKey: "",
|
||||
project: equivalent,
|
||||
originProject: selected,
|
||||
source: "manual",
|
||||
});
|
||||
expect(resolveProjectSelection(reconciled, nextHost)).toBe(equivalent);
|
||||
});
|
||||
|
||||
it("ignores a stale placement view collision and selects the persisted equivalent", () => {
|
||||
const stalePlacementViewKey = JSON.stringify(["host-b", "opaque-project"]);
|
||||
const selected = {
|
||||
...project("shared", "host-a"),
|
||||
viewKey: stalePlacementViewKey,
|
||||
};
|
||||
const unrelated = {
|
||||
...project(stalePlacementViewKey, "host-b"),
|
||||
viewKey: stalePlacementViewKey,
|
||||
};
|
||||
const equivalent = project("shared", "host-b");
|
||||
const current: ProjectSelection = {
|
||||
contextKey: "",
|
||||
project: selected,
|
||||
originProject: selected,
|
||||
source: "manual",
|
||||
};
|
||||
const nextHost = context({
|
||||
selectedServerId: "host-b",
|
||||
initialProject: unrelated,
|
||||
projects: [unrelated, equivalent],
|
||||
});
|
||||
|
||||
const reconciled = reconcileProjectSelection(current, nextHost);
|
||||
|
||||
expect(reconciled).toEqual({
|
||||
contextKey: "",
|
||||
project: equivalent,
|
||||
originProject: selected,
|
||||
source: "manual",
|
||||
});
|
||||
expect(resolveProjectSelection(reconciled, nextHost)).toBe(equivalent);
|
||||
});
|
||||
|
||||
it("chooses the same equivalent clone regardless of input order", () => {
|
||||
const selected = project("shared", "host-a");
|
||||
const cloneA = {
|
||||
...project("shared", "host-b"),
|
||||
viewKey: "view:host-b:clone-a",
|
||||
projectName: "Shared",
|
||||
hosts: [
|
||||
{
|
||||
serverId: "host-b",
|
||||
projectId: "prj_a",
|
||||
iconWorkingDir: "/work/a",
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
const cloneB = {
|
||||
...cloneA,
|
||||
viewKey: "view:host-b:clone-b",
|
||||
hosts: [{ ...cloneA.hosts[0]!, projectId: "prj_b", iconWorkingDir: "/work/b" }],
|
||||
};
|
||||
const current: ProjectSelection = {
|
||||
contextKey: "",
|
||||
project: selected,
|
||||
originProject: selected,
|
||||
source: "manual",
|
||||
};
|
||||
const resolve = (projects: HostProjectListItem[]) => {
|
||||
const nextHost = context({
|
||||
selectedServerId: "host-b",
|
||||
initialProject: cloneB,
|
||||
projects,
|
||||
});
|
||||
return resolveProjectSelection(reconcileProjectSelection(current, nextHost), nextHost);
|
||||
};
|
||||
|
||||
expect(resolve([cloneB, cloneA])).toBe(cloneA);
|
||||
expect(resolve([cloneA, cloneB])).toBe(cloneA);
|
||||
});
|
||||
|
||||
it("restores the exact manually selected clone after switching away and back", () => {
|
||||
const selected = {
|
||||
...project("shared", "host-a"),
|
||||
viewKey: "view:host-a:selected",
|
||||
hosts: [
|
||||
{
|
||||
serverId: "host-a",
|
||||
projectId: "prj_b",
|
||||
iconWorkingDir: "/work/selected",
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
const sibling = {
|
||||
...selected,
|
||||
viewKey: "view:host-a:sibling",
|
||||
hosts: [{ ...selected.hosts[0]!, projectId: "prj_a", iconWorkingDir: "/work/sibling" }],
|
||||
};
|
||||
const destination = project("shared", "host-b");
|
||||
const current: ProjectSelection = {
|
||||
contextKey: "",
|
||||
project: selected,
|
||||
originProject: selected,
|
||||
source: "manual",
|
||||
};
|
||||
const otherHost = context({
|
||||
selectedServerId: "host-b",
|
||||
initialProject: destination,
|
||||
projects: [destination],
|
||||
});
|
||||
|
||||
const retained = reconcileProjectSelection(current, otherHost);
|
||||
expect(retained).toEqual({
|
||||
contextKey: "",
|
||||
project: destination,
|
||||
originProject: selected,
|
||||
source: "manual",
|
||||
});
|
||||
expect(resolveProjectSelection(retained, otherHost)).toBe(destination);
|
||||
|
||||
const originHost = context({
|
||||
selectedServerId: "host-a",
|
||||
initialProject: sibling,
|
||||
projects: [sibling, selected],
|
||||
});
|
||||
const restored = reconcileProjectSelection(retained, originHost);
|
||||
|
||||
expect(restored.project).toBe(selected);
|
||||
expect(restored.source === "manual" ? restored.originProject : null).toBe(selected);
|
||||
expect(resolveProjectSelection(restored, originHost)).toBe(selected);
|
||||
});
|
||||
|
||||
it("preserves the current destination clone during its pending archive gap", () => {
|
||||
const selected = project("shared", "host-a");
|
||||
const destination = {
|
||||
...project("shared", "host-b"),
|
||||
workspaceKeys: ["host-b:workspace"],
|
||||
};
|
||||
const fallback = project("fallback", "host-b");
|
||||
const current: ProjectSelection = {
|
||||
contextKey: "",
|
||||
project: selected,
|
||||
originProject: selected,
|
||||
source: "manual",
|
||||
};
|
||||
const destinationHost = context({
|
||||
selectedServerId: "host-b",
|
||||
initialProject: fallback,
|
||||
projects: [fallback, destination],
|
||||
});
|
||||
const resolvedDestination = reconcileProjectSelection(current, destinationHost);
|
||||
const archiveGap = context({
|
||||
selectedServerId: "host-b",
|
||||
initialProject: fallback,
|
||||
projects: [fallback],
|
||||
shouldPreserveMissingProject: (candidate) => candidate === destination,
|
||||
});
|
||||
|
||||
expect(reconcileProjectSelection(resolvedDestination, archiveGap)).toBe(resolvedDestination);
|
||||
expect(resolveProjectSelection(resolvedDestination, archiveGap)).toBe(destination);
|
||||
});
|
||||
|
||||
it("preserves the exact origin clone during its pending archive gap", () => {
|
||||
const selected = {
|
||||
...project("shared", "host-a"),
|
||||
viewKey: "view:host-a:selected",
|
||||
workspaceKeys: ["host-a:workspace"],
|
||||
hosts: [
|
||||
{
|
||||
serverId: "host-a",
|
||||
projectId: "prj_b",
|
||||
iconWorkingDir: "/work/selected",
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
const sibling = {
|
||||
...selected,
|
||||
viewKey: "view:host-a:sibling",
|
||||
workspaceKeys: [],
|
||||
hosts: [{ ...selected.hosts[0]!, projectId: "prj_a", iconWorkingDir: "/work/sibling" }],
|
||||
};
|
||||
const destination = project("shared", "host-b");
|
||||
const current: ProjectSelection = {
|
||||
contextKey: "",
|
||||
project: selected,
|
||||
originProject: selected,
|
||||
source: "manual",
|
||||
};
|
||||
const destinationHost = context({
|
||||
selectedServerId: "host-b",
|
||||
initialProject: destination,
|
||||
projects: [destination],
|
||||
});
|
||||
const resolvedDestination = reconcileProjectSelection(current, destinationHost);
|
||||
const archiveGap = context({
|
||||
selectedServerId: "host-a",
|
||||
initialProject: sibling,
|
||||
projects: [sibling],
|
||||
shouldPreserveMissingProject: (candidate) => candidate === selected,
|
||||
});
|
||||
|
||||
const preservedOrigin = reconcileProjectSelection(resolvedDestination, archiveGap);
|
||||
|
||||
expect(preservedOrigin.project).toBe(selected);
|
||||
expect(resolveProjectSelection(preservedOrigin, archiveGap)).toBe(selected);
|
||||
});
|
||||
|
||||
it("falls back when the only equivalent project is not selectable", () => {
|
||||
const selected = project("shared", "host-a");
|
||||
const unselectableEquivalent = {
|
||||
...project("shared", "host-b"),
|
||||
hosts: [
|
||||
{
|
||||
serverId: "host-b",
|
||||
projectId: "shared",
|
||||
iconWorkingDir: "/work/shared",
|
||||
canCreateWorktree: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
const fallback = project("fallback", "host-b");
|
||||
const current: ProjectSelection = {
|
||||
contextKey: "",
|
||||
project: selected,
|
||||
originProject: selected,
|
||||
source: "manual",
|
||||
};
|
||||
const selectableProjects = filterWorkspaceProjectsForHost({
|
||||
projects: [unselectableEquivalent, fallback],
|
||||
serverId: "host-b",
|
||||
allowAllProjects: false,
|
||||
});
|
||||
const selectableContext = context({
|
||||
selectedServerId: "host-b",
|
||||
initialProject: fallback,
|
||||
projects: selectableProjects,
|
||||
});
|
||||
|
||||
expect(reconcileProjectSelection(current, selectableContext).project).toBe(fallback);
|
||||
});
|
||||
|
||||
it("does not equate projects without a persisted project key", () => {
|
||||
const selected = { ...project("selected", "host-a"), projectKey: null };
|
||||
const sameName = {
|
||||
...project("same-name", "host-b"),
|
||||
projectKey: null,
|
||||
projectName: selected.projectName,
|
||||
};
|
||||
const fallback = project("fallback", "host-b");
|
||||
const current: ProjectSelection = {
|
||||
contextKey: "",
|
||||
project: selected,
|
||||
originProject: selected,
|
||||
source: "manual",
|
||||
};
|
||||
|
||||
expect(
|
||||
reconcileProjectSelection(
|
||||
current,
|
||||
context({
|
||||
selectedServerId: "host-b",
|
||||
initialProject: fallback,
|
||||
projects: [sameName, fallback],
|
||||
}),
|
||||
).project,
|
||||
).toBe(fallback);
|
||||
});
|
||||
|
||||
it("preserves an opaque project key ending in whitespace", () => {
|
||||
const selected = project("host:project ");
|
||||
const current = createProjectSelection(
|
||||
@@ -249,7 +536,7 @@ describe("reconcileProjectSelection", () => {
|
||||
context({
|
||||
contextKey: createProjectSelectionContextKey({
|
||||
selectedServerId: "host",
|
||||
routeProjectKey: null,
|
||||
routeProjectViewKey: null,
|
||||
allowAllProjects: false,
|
||||
}),
|
||||
initialProject: fallback,
|
||||
@@ -259,7 +546,7 @@ describe("reconcileProjectSelection", () => {
|
||||
const afterCapabilityHydration = context({
|
||||
contextKey: createProjectSelectionContextKey({
|
||||
selectedServerId: "host",
|
||||
routeProjectKey: null,
|
||||
routeProjectViewKey: null,
|
||||
allowAllProjects: true,
|
||||
}),
|
||||
initialProject: remembered,
|
||||
@@ -268,7 +555,6 @@ describe("reconcileProjectSelection", () => {
|
||||
|
||||
expect(reconcileProjectSelection(current, afterCapabilityHydration)).toEqual({
|
||||
contextKey: "host:all-projects:",
|
||||
projectKey: remembered.projectKey,
|
||||
project: remembered,
|
||||
source: "initial",
|
||||
});
|
||||
@@ -280,22 +566,20 @@ describe("reconcileProjectSelection", () => {
|
||||
const remembered = project("remembered-directory");
|
||||
const current: ProjectSelection = {
|
||||
contextKey: createManualProjectSelectionContextKey({
|
||||
selectedServerId: "host",
|
||||
routeProjectKey: null,
|
||||
routeProjectViewKey: null,
|
||||
}),
|
||||
projectKey: manual.projectKey,
|
||||
project: manual,
|
||||
originProject: manual,
|
||||
source: "manual",
|
||||
};
|
||||
const afterCapabilityHydration = context({
|
||||
contextKey: createProjectSelectionContextKey({
|
||||
selectedServerId: "host",
|
||||
routeProjectKey: null,
|
||||
routeProjectViewKey: null,
|
||||
allowAllProjects: true,
|
||||
}),
|
||||
manualContextKey: createManualProjectSelectionContextKey({
|
||||
selectedServerId: "host",
|
||||
routeProjectKey: null,
|
||||
routeProjectViewKey: null,
|
||||
}),
|
||||
initialProject: remembered,
|
||||
projects: [fallback, manual, remembered],
|
||||
@@ -325,6 +609,34 @@ describe("reconcileProjectSelection", () => {
|
||||
expect(resolveProjectSelection(reconciled, withoutRemembered)).toEqual(remembered);
|
||||
});
|
||||
|
||||
it("keeps a pending archived clone instead of jumping to a same-host equivalent", () => {
|
||||
const remembered = project("shared");
|
||||
const sibling = {
|
||||
...project("shared"),
|
||||
viewKey: "view:host:sibling",
|
||||
hosts: [
|
||||
{
|
||||
serverId: "host",
|
||||
projectId: "sibling",
|
||||
iconWorkingDir: "/work/sibling",
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
const fallback = project("fallback");
|
||||
const current = createProjectSelection(
|
||||
context({ initialProject: remembered, projects: [remembered, sibling] }),
|
||||
);
|
||||
const archiveGap = context({
|
||||
initialProject: fallback,
|
||||
projects: [sibling, fallback],
|
||||
shouldPreserveMissingProject: (candidate) => candidate.viewKey === remembered.viewKey,
|
||||
});
|
||||
|
||||
expect(reconcileProjectSelection(current, archiveGap)).toEqual(current);
|
||||
expect(resolveProjectSelection(current, archiveGap)).toEqual(remembered);
|
||||
});
|
||||
|
||||
it("falls back when the selected project disappears without a pending archive", () => {
|
||||
const remembered = project("remembered");
|
||||
const fallback = project("fallback");
|
||||
@@ -338,7 +650,6 @@ describe("reconcileProjectSelection", () => {
|
||||
|
||||
expect(reconcileProjectSelection(current, withoutRemembered)).toEqual({
|
||||
contextKey: "host:",
|
||||
projectKey: fallback.projectKey,
|
||||
project: fallback,
|
||||
source: "initial",
|
||||
});
|
||||
@@ -349,13 +660,14 @@ describe("reconcileProjectSelection", () => {
|
||||
const routeProject = project("route-project");
|
||||
const remembered = project("remembered");
|
||||
const current: ProjectSelection = {
|
||||
contextKey: "host:route-project",
|
||||
projectKey: manual.projectKey,
|
||||
contextKey: routeProject.viewKey,
|
||||
project: manual,
|
||||
originProject: manual,
|
||||
source: "manual",
|
||||
};
|
||||
const selectionContext = context({
|
||||
contextKey: "host:route-project",
|
||||
manualContextKey: routeProject.viewKey,
|
||||
initialProject: routeProject,
|
||||
projects: [manual],
|
||||
routeProject,
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
import type { HostProjectListItem } from "@/projects/host-projects";
|
||||
import {
|
||||
getHostProjectId,
|
||||
resolveEquivalentHostProjectCandidate,
|
||||
resolveExactHostProjectCandidate,
|
||||
type HostProjectListItem,
|
||||
} from "@/projects/host-projects";
|
||||
|
||||
export type ProjectSelectionSource = "initial" | "manual";
|
||||
export type InitialProjectSelectionSource = "route" | "lastActive" | "fallback" | null;
|
||||
|
||||
export interface ProjectSelection {
|
||||
interface InitialProjectSelection {
|
||||
contextKey: string;
|
||||
projectKey: string | null;
|
||||
project: HostProjectListItem | null;
|
||||
source: ProjectSelectionSource;
|
||||
source: "initial";
|
||||
}
|
||||
|
||||
interface ManualProjectSelection {
|
||||
contextKey: string;
|
||||
project: HostProjectListItem;
|
||||
originProject: HostProjectListItem;
|
||||
source: "manual";
|
||||
}
|
||||
|
||||
export type ProjectSelection = InitialProjectSelection | ManualProjectSelection;
|
||||
|
||||
export interface ProjectSelectionContext {
|
||||
contextKey: string;
|
||||
manualContextKey: string;
|
||||
selectedServerId: string;
|
||||
initialProject: HostProjectListItem | null;
|
||||
initialProjectSource: InitialProjectSelectionSource;
|
||||
projects: HostProjectListItem[];
|
||||
@@ -23,18 +37,17 @@ export interface ProjectSelectionContext {
|
||||
|
||||
export function createProjectSelectionContextKey(input: {
|
||||
selectedServerId: string;
|
||||
routeProjectKey: string | null;
|
||||
routeProjectViewKey: string | null;
|
||||
allowAllProjects: boolean;
|
||||
}): string {
|
||||
const projectScope = input.allowAllProjects ? "all-projects" : "worktree-projects";
|
||||
return `${input.selectedServerId}:${projectScope}:${input.routeProjectKey ?? ""}`;
|
||||
return `${input.selectedServerId}:${projectScope}:${input.routeProjectViewKey ?? ""}`;
|
||||
}
|
||||
|
||||
export function createManualProjectSelectionContextKey(input: {
|
||||
selectedServerId: string;
|
||||
routeProjectKey: string | null;
|
||||
routeProjectViewKey: string | null;
|
||||
}): string {
|
||||
return `${input.selectedServerId}:${input.routeProjectKey ?? ""}`;
|
||||
return input.routeProjectViewKey ?? "";
|
||||
}
|
||||
|
||||
export function createProjectSelection({
|
||||
@@ -43,7 +56,6 @@ export function createProjectSelection({
|
||||
}: ProjectSelectionContext): ProjectSelection {
|
||||
return {
|
||||
contextKey,
|
||||
projectKey: initialProject?.projectKey ?? null,
|
||||
project: initialProject,
|
||||
source: "initial",
|
||||
};
|
||||
@@ -58,7 +70,7 @@ export function resolveInitialProjectSelectionSource(input: {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
input.routeProject?.projectKey === input.initialProject.projectKey ||
|
||||
input.routeProject?.viewKey === input.initialProject.viewKey ||
|
||||
input.routeProject?.hosts.some((routeHost) =>
|
||||
input.initialProject?.hosts.some(
|
||||
(host) =>
|
||||
@@ -70,24 +82,24 @@ export function resolveInitialProjectSelectionSource(input: {
|
||||
) {
|
||||
return "route";
|
||||
}
|
||||
if (input.lastActiveProject?.projectKey === input.initialProject.projectKey) {
|
||||
if (
|
||||
input.lastActiveProject?.viewKey === input.initialProject.viewKey ||
|
||||
(input.lastActiveProject?.projectKey !== null &&
|
||||
input.lastActiveProject?.projectKey !== undefined &&
|
||||
input.lastActiveProject.projectKey === input.initialProject.projectKey)
|
||||
) {
|
||||
return "lastActive";
|
||||
}
|
||||
return "fallback";
|
||||
}
|
||||
|
||||
function resolveProjectSelectionKey(selection: ProjectSelection): string | null {
|
||||
const projectKey = selection.projectKey ?? "";
|
||||
return projectKey.trim() ? projectKey : null;
|
||||
}
|
||||
|
||||
function resolveSelectedProjectFromInitialInputs(
|
||||
projectKey: string,
|
||||
project: HostProjectListItem,
|
||||
context: ProjectSelectionContext,
|
||||
): HostProjectListItem | null {
|
||||
return (
|
||||
(context.routeProject?.projectKey === projectKey ? context.routeProject : null) ??
|
||||
(context.lastActiveProject?.projectKey === projectKey ? context.lastActiveProject : null)
|
||||
(context.routeProject?.viewKey === project.viewKey ? context.routeProject : null) ??
|
||||
(context.lastActiveProject?.viewKey === project.viewKey ? context.lastActiveProject : null)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,16 +107,40 @@ function refreshSelectionProject(
|
||||
selection: ProjectSelection,
|
||||
project: HostProjectListItem,
|
||||
): ProjectSelection {
|
||||
if (selection.projectKey === project.projectKey && selection.project === project) {
|
||||
if (selection.project === project) {
|
||||
return selection;
|
||||
}
|
||||
return {
|
||||
...selection,
|
||||
projectKey: project.projectKey,
|
||||
project,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveManualOriginProject(
|
||||
selection: ProjectSelection,
|
||||
context: ProjectSelectionContext,
|
||||
): HostProjectListItem | null {
|
||||
if (selection.source !== "manual") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const exactOriginProject = resolveExactHostProjectCandidate({
|
||||
candidate: selection.originProject,
|
||||
projects: context.projects,
|
||||
serverId: context.selectedServerId,
|
||||
});
|
||||
if (exactOriginProject) {
|
||||
return exactOriginProject;
|
||||
}
|
||||
|
||||
const originBelongsToSelectedHost =
|
||||
getHostProjectId(selection.originProject, context.selectedServerId) !== null;
|
||||
return originBelongsToSelectedHost &&
|
||||
context.shouldPreserveMissingProject(selection.originProject)
|
||||
? selection.originProject
|
||||
: null;
|
||||
}
|
||||
|
||||
function shouldResetHydratedInitialSelection(
|
||||
selection: ProjectSelection,
|
||||
context: ProjectSelectionContext,
|
||||
@@ -117,32 +153,48 @@ function shouldResetHydratedInitialSelection(
|
||||
return false;
|
||||
}
|
||||
|
||||
return selection.projectKey !== context.initialProject.projectKey;
|
||||
return selection.project?.viewKey !== context.initialProject.viewKey;
|
||||
}
|
||||
|
||||
export function resolveProjectSelection(
|
||||
selection: ProjectSelection,
|
||||
context: ProjectSelectionContext,
|
||||
): HostProjectListItem | null {
|
||||
const projectKey = resolveProjectSelectionKey(selection);
|
||||
if (!projectKey) {
|
||||
if (!selection.project) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectableProject = context.projects.find((project) => project.projectKey === projectKey);
|
||||
if (selectableProject) {
|
||||
return selectableProject;
|
||||
if (selection.source === "manual") {
|
||||
const originProject = resolveManualOriginProject(selection, context);
|
||||
if (originProject) {
|
||||
return originProject;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
selection.project?.projectKey === projectKey &&
|
||||
context.shouldPreserveMissingProject(selection.project)
|
||||
) {
|
||||
const exactProject = resolveExactHostProjectCandidate({
|
||||
candidate: selection.project,
|
||||
projects: context.projects,
|
||||
serverId: context.selectedServerId,
|
||||
});
|
||||
if (exactProject) {
|
||||
return exactProject;
|
||||
}
|
||||
|
||||
if (context.shouldPreserveMissingProject(selection.project)) {
|
||||
return selection.project;
|
||||
}
|
||||
|
||||
const equivalentProject = resolveEquivalentHostProjectCandidate({
|
||||
candidate: selection.project,
|
||||
projects: context.projects,
|
||||
serverId: context.selectedServerId,
|
||||
});
|
||||
if (equivalentProject) {
|
||||
return equivalentProject;
|
||||
}
|
||||
|
||||
if (selection.source !== "manual") {
|
||||
return resolveSelectedProjectFromInitialInputs(projectKey, context);
|
||||
return resolveSelectedProjectFromInitialInputs(selection.project, context);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -163,6 +215,34 @@ export function reconcileProjectSelection(
|
||||
return initialSelection;
|
||||
}
|
||||
|
||||
if (!current.project) {
|
||||
return initialSelection;
|
||||
}
|
||||
|
||||
if (current.source === "manual") {
|
||||
const originProject = resolveManualOriginProject(current, context);
|
||||
if (originProject) {
|
||||
return {
|
||||
...current,
|
||||
project: originProject,
|
||||
originProject,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const exactProject = resolveExactHostProjectCandidate({
|
||||
candidate: current.project,
|
||||
projects: context.projects,
|
||||
serverId: context.selectedServerId,
|
||||
});
|
||||
if (exactProject) {
|
||||
return refreshSelectionProject(current, exactProject);
|
||||
}
|
||||
|
||||
if (context.shouldPreserveMissingProject(current.project)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const resolvedProject = resolveProjectSelection(current, context);
|
||||
if (resolvedProject) {
|
||||
return refreshSelectionProject(current, resolvedProject);
|
||||
|
||||
Reference in New Issue
Block a user