fix(projects): preserve host-scoped actions

This commit is contained in:
Mohamed Boudra
2026-07-29 00:28:36 +00:00
parent f154531825
commit a3188a7699
10 changed files with 150 additions and 27 deletions

View File

@@ -116,6 +116,7 @@ import type { PrHint } from "@/git/use-pr-status-query";
import {
buildSidebarProjectRowModel,
resolveSidebarProjectIconTarget,
resolveSidebarProjectLocalPath,
type SidebarProjectHostTarget,
} from "@/utils/sidebar-project-row-model";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
@@ -133,6 +134,7 @@ import {
} from "@/constants/platform";
import { getDesktopHost } from "@/desktop/host";
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
@@ -506,6 +508,8 @@ function ProjectRowTrailingActions({
removeProjectStatus: "idle" | "pending" | "success";
}) {
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
const localDaemonServerId = useLocalDaemonServerId();
const localProjectPath = resolveSidebarProjectLocalPath(project, localDaemonServerId);
return (
<View style={styles.projectTrailingActions}>
{worktreeTarget ? (
@@ -525,7 +529,7 @@ function ProjectRowTrailingActions({
<ProjectKebabMenu
projectKey={project.projectKey}
projectSettingsKey={resolveProjectSettingsRouteKey(project)}
projectPath={project.iconWorkingDir}
projectPath={localProjectPath}
onRemoveProject={onRemoveProject}
removeProjectStatus={removeProjectStatus}
/>

View File

@@ -114,7 +114,7 @@ describe("buildWorktreeSetupCalloutPolicy", () => {
description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
actionLabel: "Open project settings",
projectSettingsRoute: "/settings/projects/project-1",
projectSettingsRoute: "/settings/projects/host%3Aserver-1%3Aproject%3Aproject-1",
testID: "worktree-setup-callout-project-1",
});
});
@@ -129,4 +129,15 @@ describe("buildWorktreeSetupCalloutPolicy", () => {
}).projectSettingsRoute,
).toBe("/settings/projects/host%3Aserver-1%3Aproject%3Aprj_local");
});
it("scopes retained legacy project IDs to the active host", () => {
expect(
buildWorktreeSetupCalloutPolicy({
serverId: "server-2",
projectId: "remote:github.com/acme/project",
projectKey: "remote:github.com/acme/project-fork",
repoRoot: "/repo/project",
}).projectSettingsRoute,
).toBe("/settings/projects/host%3Aserver-2%3Aproject%3Aremote%3Agithub.com%2Facme%2Fproject");
});
});

View File

@@ -1,6 +1,7 @@
import type { PaseoConfigRaw } from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
import { resolveProjectGroupKey } from "@/projects/project-group-key";
import { resolveHostProjectSettingsRouteKey } from "@/projects/project-settings-target";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
export interface WorktreeSetupWorkspaceInput {
@@ -68,7 +69,7 @@ export function buildWorktreeSetupCalloutPolicy(
project: ActiveGitWorkspaceProject,
): WorktreeSetupCalloutPolicy {
const calloutKey = `worktree-setup-missing:${project.projectKey}`;
const projectSettingsKey = resolveProjectGroupKey({
const projectSettingsKey = resolveHostProjectSettingsRouteKey({
serverId: project.serverId,
projectId: project.projectId,
});
@@ -80,7 +81,7 @@ export function buildWorktreeSetupCalloutPolicy(
title: i18n.t("sidebar.worktreeSetup.title"),
description: i18n.t("sidebar.worktreeSetup.description"),
actionLabel: i18n.t("sidebar.worktreeSetup.openProjectSettings"),
projectSettingsRoute: buildProjectSettingsRoute(projectSettingsKey),
projectSettingsRoute: buildProjectSettingsRoute(projectSettingsKey ?? project.projectKey),
testID: `worktree-setup-callout-${project.projectKey}`,
};
}

View File

@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
findProjectSettingsRouteTarget,
findProjectSettingsTarget,
resolveHostProjectSettingsRouteKey,
resolveProjectSettingsRouteKey,
} from "./project-settings-target";
@@ -35,4 +37,21 @@ describe("project settings target", () => {
changedProject,
);
});
it("preserves the host identified by a grouped settings route", () => {
const groupedProject = {
projectKey: "remote:github.com/acme/app",
hosts: [
{ serverId: "host-a", projectId: "project-a" },
{ serverId: "host-b", projectId: "project-b" },
],
};
const routeKey = resolveHostProjectSettingsRouteKey(groupedProject.hosts[1]);
expect(routeKey).not.toBeNull();
expect(findProjectSettingsRouteTarget([groupedProject], routeKey ?? "")).toEqual({
project: groupedProject,
serverId: "host-b",
});
});
});

View File

@@ -3,7 +3,10 @@ interface ProjectSettingsTarget {
hosts: ReadonlyArray<{ serverId: string; projectId?: string }>;
}
function resolveHostLocalProjectKey(host: { serverId: string; projectId?: string }): string | null {
export function resolveHostProjectSettingsRouteKey(host: {
serverId: string;
projectId?: string;
}): string | null {
const projectId = host.projectId?.trim();
if (!projectId) return null;
return `host:${host.serverId}:project:${projectId}`;
@@ -11,7 +14,7 @@ function resolveHostLocalProjectKey(host: { serverId: string; projectId?: string
export function resolveProjectSettingsRouteKey(project: ProjectSettingsTarget): string {
for (const host of project.hosts) {
const hostLocalKey = resolveHostLocalProjectKey(host);
const hostLocalKey = resolveHostProjectSettingsRouteKey(host);
if (hostLocalKey) return hostLocalKey;
}
return project.projectKey;
@@ -21,10 +24,21 @@ 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),
)
);
return findProjectSettingsRouteTarget(projects, routeKey)?.project;
}
export function findProjectSettingsRouteTarget<T extends ProjectSettingsTarget>(
projects: readonly T[],
routeKey: string,
): { project: T; serverId: string | null } | undefined {
const exactProject = projects.find((project) => project.projectKey === routeKey);
if (exactProject) return { project: exactProject, serverId: null };
for (const project of projects) {
const host = project.hosts.find(
(candidate) => resolveHostProjectSettingsRouteKey(candidate) === routeKey,
);
if (host) return { project, serverId: host.serverId };
}
return undefined;
}

View File

@@ -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 { findProjectSettingsTarget } from "@/projects/project-settings-target";
import { findProjectSettingsRouteTarget } 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,23 +90,24 @@ export interface ProjectSettingsScreenProps {
export default function ProjectSettingsScreen({ projectKey }: ProjectSettingsScreenProps) {
const { projects } = useProjects();
const project = useMemo(
() => findProjectSettingsTarget(projects, projectKey),
const routeTarget = useMemo(
() => findProjectSettingsRouteTarget(projects, projectKey),
[projects, projectKey],
);
const project = routeTarget?.project;
const editableHosts = useMemo(() => filterEditableHosts(project), [project]);
const [selectedServerId, setSelectedServerId] = useState<string>(
() => editableHosts[0]?.serverId ?? "",
const [hostSelection, setHostSelection] = useState({ routeKey: "", serverId: "" });
const selectedServerId = resolveSelectedSettingsServerId({
projectKey,
editableHosts,
routedServerId: routeTarget?.serverId ?? null,
hostSelection,
});
const setSelectedServerId = useCallback(
(serverId: string) => setHostSelection({ routeKey: projectKey, serverId }),
[projectKey],
);
useEffect(() => {
const stillValid = editableHosts.some((host) => host.serverId === selectedServerId);
if (!stillValid) {
setSelectedServerId(editableHosts[0]?.serverId ?? "");
}
}, [editableHosts, selectedServerId]);
const selectedSnapshot = useHostRuntimeSnapshot(selectedServerId);
const isHostGone =
Boolean(selectedServerId) &&
@@ -132,6 +133,25 @@ export default function ProjectSettingsScreen({ projectKey }: ProjectSettingsScr
);
}
function resolveSelectedSettingsServerId(input: {
projectKey: string;
editableHosts: ProjectHostEntry[];
routedServerId: string | null;
hostSelection: { routeKey: string; serverId: string };
}): string {
const availableServerIds = new Set(input.editableHosts.map((host) => host.serverId));
if (
input.hostSelection.routeKey === input.projectKey &&
availableServerIds.has(input.hostSelection.serverId)
) {
return input.hostSelection.serverId;
}
if (input.routedServerId && availableServerIds.has(input.routedServerId)) {
return input.routedServerId;
}
return input.editableHosts[0]?.serverId ?? "";
}
function filterEditableHosts(project: ProjectSummary | undefined): ProjectHostEntry[] {
if (!project) return [];
return project.hosts.filter(

View File

@@ -6,6 +6,7 @@ import type {
import {
buildSidebarProjectRowModel,
resolveSidebarProjectIconTarget,
resolveSidebarProjectLocalPath,
} from "./sidebar-project-row-model";
function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorkspaceEntry {
@@ -196,6 +197,19 @@ describe("buildSidebarProjectRowModel", () => {
expect(iconTarget).toEqual({ serverId: "host-b", iconWorkingDir: "/repo/b" });
});
it("resolves desktop file actions from the local project placement", () => {
const groupedProject = project({
iconWorkingDir: "/remote/repo",
hosts: [
{ serverId: "remote", iconWorkingDir: "/remote/repo", canCreateWorktree: true },
{ serverId: "local", iconWorkingDir: "/local/repo", canCreateWorktree: true },
],
});
expect(resolveSidebarProjectLocalPath(groupedProject, "local")).toBe("/local/repo");
expect(resolveSidebarProjectLocalPath(groupedProject, "missing")).toBe("");
});
it("renders an empty project as an expandable section", () => {
const result = buildSidebarProjectRowModel({
project: project({ projectKind: "git", workspaces: [] }),

View File

@@ -44,6 +44,14 @@ export function resolveSidebarProjectIconTarget(
return null;
}
export function resolveSidebarProjectLocalPath(
project: SidebarProjectEntry,
localServerId: string | null,
): string {
if (!localServerId) return "";
return project.hosts.find((host) => host.serverId === localServerId)?.iconWorkingDir.trim() ?? "";
}
// A project can host a brand-new workspace on a host when that host can create a
// git worktree (git projects) OR the host supports running multiple independent
// workspaces per directory (`workspaceMultiplicity`), which is what lets non-git

View File

@@ -84,6 +84,24 @@ describe("deriveProjectGroupKey", () => {
).toBe("remote:github.com/getpaseo/paseo#subdir:packages/app");
});
test("keeps a selected path distinct from remote path syntax", () => {
const worktreeRoot = path.resolve("repo");
const selectedKey = deriveProjectGroupKey({
rootPath: path.join(worktreeRoot, "packages", "app"),
remoteUrl: "example.com:acme/repo.git",
worktreeRoot,
mainRepoRoot: null,
});
const remoteSyntaxKey = deriveProjectGroupKey({
rootPath: worktreeRoot,
remoteUrl: "example.com:acme/repo#subdir:packages/app.git",
worktreeRoot,
mainRepoRoot: null,
});
expect(selectedKey).not.toBe(remoteSyntaxKey);
});
test("keeps repository-root keys stable", () => {
const rootPath = path.resolve("repo");

View File

@@ -55,12 +55,26 @@ function deriveRemoteProjectGroupKey(remoteUrl: string | null): string | null {
}
if (!host || !remotePath) return null;
let cleanedPath = remotePath.trim().replace(/^\/+/, "").replace(/\/+$/, "");
if (cleanedPath.endsWith(".git")) cleanedPath = cleanedPath.slice(0, -4);
const cleanedPath = normalizeRemotePath(remotePath);
if (!cleanedPath) return null;
return `remote:${host.toLowerCase()}/${cleanedPath}`;
}
function normalizeRemotePath(remotePath: string): string {
const segments = remotePath.trim().replace(/^\/+/, "").replace(/\/+$/, "").split("/");
const decodedSegments = segments.map((segment) => {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
});
const lastIndex = decodedSegments.length - 1;
const lastSegment = decodedSegments[lastIndex];
if (lastSegment?.endsWith(".git")) decodedSegments[lastIndex] = lastSegment.slice(0, -4);
return decodedSegments.map(encodeURIComponent).join("/");
}
function deriveRemoteHost(remoteUrl: URL): string | null {
const defaultPorts: Partial<Record<string, string>> = {
"git:": "9418",