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
@@ -8,6 +8,7 @@ import {
function projectFor(serverId: string, key = "project"): HostProjectListItem {
return {
viewKey: `view:${serverId}:${key}`,
projectKey: key,
projectName: key,
projectKind: "git",
@@ -1,5 +1,7 @@
import {
canCreateWorkspaceForHostProject,
filterWorkspaceProjectsForHost,
resolveHostProjectCandidate,
type HostProjectListItem,
} from "@/projects/host-projects";
import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime";
@@ -25,35 +27,31 @@ function supportsAllProjects(
return workspaceMultiplicityByServerId.get(serverId) === true;
}
function getProjectForServer(input: {
candidate: HostProjectListItem;
projects: readonly HostProjectListItem[];
serverId: string;
}) {
return (
input.projects.find(
(project) =>
project.projectKey === input.candidate.projectKey &&
project.hosts.some((host) => host.serverId === input.serverId),
) ?? input.candidate
);
}
function canUseProjectForServer(input: {
project: HostProjectListItem;
projects: readonly HostProjectListItem[];
serverId: string;
workspaceMultiplicityByServerId: ReadonlyMap<string, boolean>;
}) {
const project = getProjectForServer({
candidate: input.project,
const allowAllProjects = supportsAllProjects(
input.workspaceMultiplicityByServerId,
input.serverId,
);
const selectableProjects = filterWorkspaceProjectsForHost({
projects: input.projects,
serverId: input.serverId,
allowAllProjects,
});
const project =
resolveHostProjectCandidate({
candidate: input.project,
projects: selectableProjects,
serverId: input.serverId,
}) ?? input.project;
return canCreateWorkspaceForHostProject({
project,
serverId: input.serverId,
allowAllProjects: supportsAllProjects(input.workspaceMultiplicityByServerId, input.serverId),
allowAllProjects,
});
}
@@ -72,7 +72,7 @@ import {
useHostProjects,
type HostProjectListItem,
} from "@/projects/host-projects";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import { useProjectIconDataByProjectViewKey } from "@/projects/project-icons";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { ComposerAttachment } from "@/attachments/types";
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
@@ -273,7 +273,7 @@ function ProjectPickerTrigger({
disabled,
badgePressableStyle,
label,
projectKey,
projectViewKey,
iconDataUri,
iconColor,
iconSize,
@@ -283,7 +283,7 @@ function ProjectPickerTrigger({
disabled: boolean;
badgePressableStyle: React.ComponentProps<typeof Pressable>["style"];
label: string;
projectKey: string | null;
projectViewKey: string | null;
iconDataUri: string | null;
iconColor: string;
iconSize: number;
@@ -303,11 +303,11 @@ function ProjectPickerTrigger({
accessibilityLabel="Workspace project"
>
<View style={styles.badgeIconBox}>
{projectKey ? (
{projectViewKey ? (
<ProjectIconView
iconDataUri={iconDataUri}
initial={placeholderInitial}
projectKey={projectKey}
projectViewKey={projectViewKey}
imageStyle={styles.projectIcon}
fallbackStyle={styles.projectIconFallback}
textStyle={styles.projectIconFallbackText}
@@ -423,7 +423,7 @@ function IsolationOptionItem({
function ProjectOptionItem({
testID,
projectKey,
projectViewKey,
iconDataUri,
label,
description,
@@ -433,7 +433,7 @@ function ProjectOptionItem({
onPress,
}: {
testID: string;
projectKey: string;
projectViewKey: string;
iconDataUri: string | null;
label: string;
description: string | undefined;
@@ -450,14 +450,14 @@ function ProjectOptionItem({
<ProjectIconView
iconDataUri={iconDataUri}
initial={placeholderInitial}
projectKey={projectKey}
projectViewKey={projectViewKey}
imageStyle={styles.projectIcon}
fallbackStyle={styles.projectIconFallback}
textStyle={styles.projectIconFallbackText}
/>
</View>
),
[iconDataUri, placeholderInitial, projectKey],
[iconDataUri, placeholderInitial, projectViewKey],
);
return (
@@ -533,7 +533,7 @@ function NewWorkspaceProjectPickerOption({
active,
onPress,
projectByOptionId,
projectIconDataByProjectKey,
projectIconDataByProjectViewKey,
selectedServerId,
isPending,
supportsWorkspaceMultiplicity,
@@ -543,7 +543,7 @@ function NewWorkspaceProjectPickerOption({
active: boolean;
onPress: () => void;
projectByOptionId: Map<string, HostProjectListItem>;
projectIconDataByProjectKey: Map<string, string | null>;
projectIconDataByProjectViewKey: Map<string, string | null>;
selectedServerId: string;
isPending: boolean;
supportsWorkspaceMultiplicity: boolean;
@@ -555,9 +555,9 @@ function NewWorkspaceProjectPickerOption({
return (
<ProjectOptionItem
testID={`new-workspace-project-picker-option-${project.projectKey}`}
projectKey={project.projectKey}
iconDataUri={projectIconDataByProjectKey.get(project.projectKey) ?? null}
testID={`new-workspace-project-picker-option-${project.viewKey}`}
projectViewKey={project.viewKey}
iconDataUri={projectIconDataByProjectViewKey.get(project.viewKey) ?? null}
label={project.projectName}
description={sourceDirectory}
selected={selected}
@@ -1305,7 +1305,7 @@ interface NewWorkspaceFormStackInput {
options: ComboboxOptionType[];
triggerLabel: string;
selectedProject: HostProjectListItem | null;
iconDataByProjectKey: Map<string, string | null>;
iconDataByProjectViewKey: Map<string, string | null>;
selectedOptionId: string;
onSelect: (id: string) => void;
onAddProject: () => void;
@@ -1369,10 +1369,10 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
disabled={isPending}
badgePressableStyle={badgePressableStyle}
label={project.triggerLabel}
projectKey={project.selectedProject?.projectKey ?? null}
projectViewKey={project.selectedProject?.viewKey ?? null}
iconDataUri={
project.selectedProject
? (project.iconDataByProjectKey.get(project.selectedProject.projectKey) ?? null)
? (project.iconDataByProjectViewKey.get(project.selectedProject.viewKey) ?? null)
: null
}
iconColor={theme.colors.foregroundMuted}
@@ -1592,12 +1592,12 @@ export function NewWorkspaceScreen({
if (!iconWorkingDir) {
return [];
}
return [{ projectKey: project.projectKey, serverId: selectedServerId, iconWorkingDir }];
return [{ projectViewKey: project.viewKey, serverId: selectedServerId, iconWorkingDir }];
}),
[projects, selectedServerId],
);
const projectIconDataByProjectKey = useProjectIconDataByProjectKey({
const projectIconDataByProjectViewKey = useProjectIconDataByProjectViewKey({
projects: projectIconTargets,
});
const draftKey = buildNewWorkspaceDraftKey(draftId);
@@ -2039,7 +2039,7 @@ export function NewWorkspaceScreen({
<NewWorkspaceProjectPickerOption
{...props}
projectByOptionId={projectByOptionId}
projectIconDataByProjectKey={projectIconDataByProjectKey}
projectIconDataByProjectViewKey={projectIconDataByProjectViewKey}
selectedServerId={selectedServerId}
isPending={isPending}
supportsWorkspaceMultiplicity={supportsWorkspaceMultiplicity}
@@ -2048,7 +2048,7 @@ export function NewWorkspaceScreen({
[
isPending,
projectByOptionId,
projectIconDataByProjectKey,
projectIconDataByProjectViewKey,
selectedServerId,
supportsWorkspaceMultiplicity,
],
@@ -2093,7 +2093,7 @@ export function NewWorkspaceScreen({
options: projectPickerOptions,
triggerLabel: projectTriggerLabel,
selectedProject,
iconDataByProjectKey: projectIconDataByProjectKey,
iconDataByProjectViewKey: projectIconDataByProjectViewKey,
selectedOptionId: selectedProjectOptionId,
onSelect: handleSelectProjectOption,
onAddProject: handleAddProject,
@@ -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);
@@ -31,7 +31,7 @@ import { SettingsGroup } from "@/screens/settings/settings-group";
import { SettingsSection } from "@/screens/settings/settings-section";
import { settingsStyles } from "@/styles/settings";
import { useProjects } from "@/hooks/use-projects";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import { useProjectIconDataByProjectViewKey } from "@/projects/project-icons";
import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import { confirmDialog } from "@/utils/confirm-dialog";
@@ -233,16 +233,16 @@ function ProjectSettingsBody({
() => [
{
serverId: selectedHost.serverId,
projectKey: project.projectKey,
projectViewKey: project.viewKey,
iconWorkingDir: selectedHost.repoRoot,
},
],
[project.projectKey, selectedHost.repoRoot, selectedHost.serverId],
[project.viewKey, selectedHost.repoRoot, selectedHost.serverId],
);
const projectIconDataByKey = useProjectIconDataByProjectKey({
const projectIconDataByKey = useProjectIconDataByProjectViewKey({
projects: projectIconTargets,
});
const projectIconDataUri = projectIconDataByKey.get(project.projectKey) ?? null;
const projectIconDataUri = projectIconDataByKey.get(project.viewKey) ?? null;
const loadedConfig: PaseoConfigRaw | null = data?.ok ? (data.config ?? {}) : null;
const loadedRevision: PaseoConfigRevision | null = data?.ok ? data.revision : null;
const readError: ProjectConfigRpcError | null = data && !data.ok ? data.error : null;
@@ -262,7 +262,7 @@ function ProjectSettingsBody({
<ProjectTitleIcon
iconDataUri={projectIconDataUri}
projectName={project.projectName}
projectKey={project.projectKey}
projectViewKey={project.viewKey}
/>
<ProjectNameEditor
key={`${selectedHost.serverId}:${selectedHost.projectId}`}
@@ -959,18 +959,18 @@ function ProjectNameEditor({
function ProjectTitleIcon({
iconDataUri,
projectName,
projectKey,
projectViewKey,
}: {
iconDataUri: string | null;
projectName: string;
projectKey: string;
projectViewKey: string;
}) {
const initial = projectName.trim().charAt(0).toUpperCase() || "?";
return (
<ProjectIconView
iconDataUri={iconDataUri}
initial={initial}
projectKey={projectKey}
projectViewKey={projectViewKey}
imageStyle={styles.titleIcon}
fallbackStyle={styles.titleIconFallback}
textStyle={styles.titleIconFallbackText}
@@ -202,7 +202,7 @@ vi.mock("@/hooks/use-projects", () => ({
}));
vi.mock("@/projects/project-icons", () => ({
useProjectIconDataByProjectKey: () => new Map(),
useProjectIconDataByProjectViewKey: () => new Map(),
}));
import ProjectsScreen from "./projects-screen";
@@ -239,7 +239,7 @@ function project(overrides: Partial<ProjectSummary> = {}): ProjectSummary {
overrides.totalWorkspaceCount ?? hosts.reduce((sum, host) => sum + host.workspaceCount, 0);
const onlineHostCount = overrides.onlineHostCount ?? hosts.filter((h) => h.isOnline).length;
return {
projectKey: "remote:github.com/acme/app",
viewKey: "remote:github.com/acme/app",
projectName: "acme/app",
hosts,
totalWorkspaceCount,
@@ -323,7 +323,7 @@ describe("ProjectsScreen", () => {
it("navigates to the project detail route when the row is pressed", () => {
setProjectsState({
projects: [project({ projectKey: "remote:github.com/acme/app" })],
projects: [project({ viewKey: "remote:github.com/acme/app" })],
});
render({ kind: "projects" });
@@ -339,7 +339,7 @@ describe("ProjectsScreen", () => {
it("does not render a kebab menu on the row", () => {
setProjectsState({
projects: [project({ projectKey: "remote:github.com/acme/app" })],
projects: [project({ viewKey: "remote:github.com/acme/app" })],
});
render({ kind: "projects" });
@@ -388,9 +388,9 @@ describe("ProjectsScreen", () => {
it("highlights the selected row when the active view targets a project", () => {
setProjectsState({
projects: [
project({ projectKey: "remote:github.com/acme/app" }),
project({ viewKey: "remote:github.com/acme/app" }),
project({
projectKey: "remote:github.com/acme/other",
viewKey: "remote:github.com/acme/other",
projectName: "acme/other",
githubUrl: "https://github.com/acme/other",
}),
+14 -15
View File
@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
import { ProjectIconView } from "@/components/project-icon-view";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useProjects, type ProjectHostError } from "@/hooks/use-projects";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import { useProjectIconDataByProjectViewKey } from "@/projects/project-icons";
import { settingsStyles } from "@/styles/settings";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
import { getProjectSummaryForHostProject, type ProjectSummary } from "@/utils/projects";
@@ -19,10 +19,9 @@ interface ProjectsScreenProps {
export default function ProjectsScreen({ view }: ProjectsScreenProps) {
const { t } = useTranslation();
const { projects, hostErrors, isLoading } = useProjects();
const selectedProjectKey =
const selectedProjectViewKey =
view.kind === "project"
? (getProjectSummaryForHostProject(projects, view.serverId, view.projectId)?.projectKey ??
null)
? (getProjectSummaryForHostProject(projects, view.serverId, view.projectId)?.viewKey ?? null)
: null;
const iconTargets = useMemo(
() =>
@@ -32,14 +31,14 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) {
return [
{
serverId: host.serverId,
projectKey: project.projectKey,
projectViewKey: project.viewKey,
iconWorkingDir: host.repoRoot,
},
];
}),
[projects],
);
const iconDataByProjectKey = useProjectIconDataByProjectKey({
const iconDataByProjectViewKey = useProjectIconDataByProjectViewKey({
projects: iconTargets,
});
@@ -65,11 +64,11 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) {
<View style={settingsStyles.card}>
{projects.map((project, index) => (
<ProjectRow
key={project.projectKey}
key={project.viewKey}
project={project}
isFirst={index === 0}
isSelected={selectedProjectKey === project.projectKey}
iconDataUri={iconDataByProjectKey.get(project.projectKey) ?? null}
isSelected={selectedProjectViewKey === project.viewKey}
iconDataUri={iconDataByProjectViewKey.get(project.viewKey) ?? null}
/>
))}
</View>
@@ -103,7 +102,7 @@ interface ProjectRowProps {
function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const { projectKey, projectName } = project;
const { viewKey, projectName } = project;
const target = project.hosts[0];
const handleNavigate = useCallback(() => {
if (target) router.navigate(buildProjectSettingsRoute(target.serverId, target.projectId));
@@ -127,7 +126,7 @@ function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowPro
onPress={handleNavigate}
accessibilityRole="button"
accessibilityLabel={t("settings.projectList.editProject", { projectName })}
testID={`project-row-${projectKey}`}
testID={`project-row-${viewKey}`}
data-selected={isSelected ? "true" : "false"}
>
<View style={styles.rowMain}>
@@ -135,7 +134,7 @@ function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowPro
<ProjectRowIcon
iconDataUri={iconDataUri}
projectName={projectName}
projectKey={projectKey}
projectViewKey={viewKey}
/>
</View>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
@@ -150,18 +149,18 @@ function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowPro
function ProjectRowIcon({
iconDataUri,
projectName,
projectKey,
projectViewKey,
}: {
iconDataUri: string | null;
projectName: string;
projectKey: string;
projectViewKey: string;
}) {
const initial = projectName.trim().charAt(0).toUpperCase() || "?";
return (
<ProjectIconView
iconDataUri={iconDataUri}
initial={initial}
projectKey={projectKey}
projectViewKey={projectViewKey}
imageStyle={styles.iconImage}
fallbackStyle={styles.iconFallback}
textStyle={styles.iconFallbackText}