fix(projects): stabilize settings and path identity

This commit is contained in:
Mohamed Boudra
2026-07-28 23:50:49 +00:00
parent 26452a4f01
commit 5928dc08d9
12 changed files with 127 additions and 18 deletions

View File

@@ -58,6 +58,7 @@ import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sec
import { useHostFeatureMap } from "@/runtime/host-features";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import { resolveProjectSettingsRouteKey } from "@/projects/project-settings-target";
import {
buildNewWorkspaceRoute,
buildProjectSettingsRoute,
@@ -523,6 +524,7 @@ function ProjectRowTrailingActions({
>
<ProjectKebabMenu
projectKey={project.projectKey}
projectSettingsKey={resolveProjectSettingsRouteKey(project)}
projectPath={project.iconWorkingDir}
onRemoveProject={onRemoveProject}
removeProjectStatus={removeProjectStatus}
@@ -550,11 +552,13 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
function ProjectKebabMenu({
projectKey,
projectSettingsKey,
projectPath,
onRemoveProject,
removeProjectStatus,
}: {
projectKey: string;
projectSettingsKey: string;
projectPath: string;
onRemoveProject: () => void;
removeProjectStatus: "idle" | "pending" | "success";
@@ -562,10 +566,10 @@ function ProjectKebabMenu({
const { t } = useTranslation();
const toast = useToast();
const handleOpenProjectSettings = useCallback(() => {
if (projectKey.trim().length === 0) return;
router.navigate(buildProjectSettingsRoute(projectKey));
}, [projectKey]);
const canOpenProjectSettings = projectKey.trim().length > 0;
if (projectSettingsKey.trim().length === 0) return;
router.navigate(buildProjectSettingsRoute(projectSettingsKey));
}, [projectSettingsKey]);
const canOpenProjectSettings = projectSettingsKey.trim().length > 0;
// Desktop-only: open a second window that lands on this project via the same
// open-project flow as a CLI launch. The project stays visible here too — no
// ownership, no move.

View File

@@ -118,4 +118,15 @@ describe("buildWorktreeSetupCalloutPolicy", () => {
testID: "worktree-setup-callout-project-1",
});
});
it("keeps the action route stable when the structural group key changes", () => {
expect(
buildWorktreeSetupCalloutPolicy({
serverId: "server-1",
projectId: "prj_local",
projectKey: "remote:github.com/acme/project",
repoRoot: "/repo/project",
}).projectSettingsRoute,
).toBe("/settings/projects/host%3Aserver-1%3Aproject%3Aprj_local");
});
});

View File

@@ -68,6 +68,10 @@ export function buildWorktreeSetupCalloutPolicy(
project: ActiveGitWorkspaceProject,
): WorktreeSetupCalloutPolicy {
const calloutKey = `worktree-setup-missing:${project.projectKey}`;
const projectSettingsKey = resolveProjectGroupKey({
serverId: project.serverId,
projectId: project.projectId,
});
return {
id: calloutKey,
@@ -76,7 +80,7 @@ export function buildWorktreeSetupCalloutPolicy(
title: i18n.t("sidebar.worktreeSetup.title"),
description: i18n.t("sidebar.worktreeSetup.description"),
actionLabel: i18n.t("sidebar.worktreeSetup.openProjectSettings"),
projectSettingsRoute: buildProjectSettingsRoute(project.projectKey),
projectSettingsRoute: buildProjectSettingsRoute(projectSettingsKey),
testID: `worktree-setup-callout-${project.projectKey}`,
};
}

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import {
findProjectSettingsTarget,
resolveProjectSettingsRouteKey,
} from "./project-settings-target";
describe("project settings target", () => {
const project = {
projectKey: "remote:github.com/acme/app",
hosts: [{ serverId: "host-a", projectId: "prj_1234" }],
};
it("builds settings routes from stable host-local identity", () => {
expect(resolveProjectSettingsRouteKey(project)).toBe("host:host-a:project:prj_1234");
});
it("keeps a host-local route valid after the structural key changes", () => {
expect(findProjectSettingsTarget([project], "host:host-a:project:prj_1234")).toBe(project);
});
});

View File

@@ -0,0 +1,32 @@
import { resolveProjectGroupKey } from "./project-group-key";
interface ProjectSettingsTarget {
projectKey: string;
hosts: ReadonlyArray<{ serverId: string; projectId?: string }>;
}
function resolveHostLocalProjectKey(host: { serverId: string; projectId?: string }): string | null {
const projectId = host.projectId?.trim();
if (!projectId) return null;
return resolveProjectGroupKey({ serverId: host.serverId, projectId });
}
export function resolveProjectSettingsRouteKey(project: ProjectSettingsTarget): string {
for (const host of project.hosts) {
const hostLocalKey = resolveHostLocalProjectKey(host);
if (hostLocalKey) return hostLocalKey;
}
return project.projectKey;
}
export function findProjectSettingsTarget<T extends ProjectSettingsTarget>(
projects: readonly T[],
routeKey: string,
): T | undefined {
return (
projects.find((project) => project.projectKey === routeKey) ??
projects.find((project) =>
project.hosts.some((host) => resolveHostLocalProjectKey(host) === routeKey),
)
);
}

View File

@@ -31,6 +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 { findProjectSettingsTarget } from "@/projects/project-settings-target";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
@@ -90,7 +91,7 @@ export interface ProjectSettingsScreenProps {
export default function ProjectSettingsScreen({ projectKey }: ProjectSettingsScreenProps) {
const { projects } = useProjects();
const project = useMemo(
() => projects.find((entry) => entry.projectKey === projectKey),
() => findProjectSettingsTarget(projects, projectKey),
[projects, projectKey],
);
const editableHosts = useMemo(() => filterEditableHosts(project), [project]);

View File

@@ -8,6 +8,10 @@ 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 {
findProjectSettingsTarget,
resolveProjectSettingsRouteKey,
} from "@/projects/project-settings-target";
import { settingsStyles } from "@/styles/settings";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
import type { ProjectSummary } from "@/utils/projects";
@@ -19,7 +23,10 @@ interface ProjectsScreenProps {
export default function ProjectsScreen({ view }: ProjectsScreenProps) {
const { t } = useTranslation();
const { projects, hostErrors, isLoading } = useProjects();
const selectedProjectKey = view.kind === "project" ? view.projectKey : null;
const selectedProjectKey =
view.kind === "project"
? (findProjectSettingsTarget(projects, view.projectKey)?.projectKey ?? null)
: null;
const iconTargets = useMemo(
() =>
projects.flatMap((project) => {
@@ -100,10 +107,11 @@ function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowPro
const { t } = useTranslation();
const { theme } = useUnistyles();
const { projectKey, projectName } = project;
const settingsRouteKey = resolveProjectSettingsRouteKey(project);
const handleNavigate = useCallback(() => {
router.navigate(buildProjectSettingsRoute(projectKey));
}, [projectKey]);
router.navigate(buildProjectSettingsRoute(settingsRouteKey));
}, [settingsRouteKey]);
const rowStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [