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:
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user