From a3188a7699df1c29e5c5c13091b9506e83e04c12 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 29 Jul 2026 00:28:36 +0000 Subject: [PATCH] fix(projects): preserve host-scoped actions --- .../src/components/sidebar-workspace-list.tsx | 6 ++- .../worktree-setup-callout-policy.test.ts | 13 +++++- .../worktree-setup-callout-policy.ts | 5 +- .../projects/project-settings-target.test.ts | 19 ++++++++ .../src/projects/project-settings-target.ts | 30 ++++++++---- .../src/screens/project-settings-screen.tsx | 46 +++++++++++++------ .../utils/sidebar-project-row-model.test.ts | 14 ++++++ .../src/utils/sidebar-project-row-model.ts | 8 ++++ .../src/server/project-group-key.test.ts | 18 ++++++++ .../server/src/server/project-group-key.ts | 18 +++++++- 10 files changed, 150 insertions(+), 27 deletions(-) diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 8f851893d..4ae005e61 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -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 ( {worktreeTarget ? ( @@ -525,7 +529,7 @@ function ProjectRowTrailingActions({ diff --git a/packages/app/src/components/worktree-setup-callout-policy.test.ts b/packages/app/src/components/worktree-setup-callout-policy.test.ts index 79a14318c..7e9c0514d 100644 --- a/packages/app/src/components/worktree-setup-callout-policy.test.ts +++ b/packages/app/src/components/worktree-setup-callout-policy.test.ts @@ -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"); + }); }); diff --git a/packages/app/src/components/worktree-setup-callout-policy.ts b/packages/app/src/components/worktree-setup-callout-policy.ts index 9cccc6065..14864b250 100644 --- a/packages/app/src/components/worktree-setup-callout-policy.ts +++ b/packages/app/src/components/worktree-setup-callout-policy.ts @@ -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}`, }; } diff --git a/packages/app/src/projects/project-settings-target.test.ts b/packages/app/src/projects/project-settings-target.test.ts index 610ec94ce..1c4af0591 100644 --- a/packages/app/src/projects/project-settings-target.test.ts +++ b/packages/app/src/projects/project-settings-target.test.ts @@ -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", + }); + }); }); diff --git a/packages/app/src/projects/project-settings-target.ts b/packages/app/src/projects/project-settings-target.ts index 6dc995691..e1de9d64e 100644 --- a/packages/app/src/projects/project-settings-target.ts +++ b/packages/app/src/projects/project-settings-target.ts @@ -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( 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( + 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; } diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx index 6cf86376b..1d92be4cf 100644 --- a/packages/app/src/screens/project-settings-screen.tsx +++ b/packages/app/src/screens/project-settings-screen.tsx @@ -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( - () => 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( diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index 37548071e..3194508d2 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -6,6 +6,7 @@ import type { import { buildSidebarProjectRowModel, resolveSidebarProjectIconTarget, + resolveSidebarProjectLocalPath, } from "./sidebar-project-row-model"; function workspace(overrides: Partial = {}): 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: [] }), diff --git a/packages/app/src/utils/sidebar-project-row-model.ts b/packages/app/src/utils/sidebar-project-row-model.ts index 3a6431378..67ef6f6be 100644 --- a/packages/app/src/utils/sidebar-project-row-model.ts +++ b/packages/app/src/utils/sidebar-project-row-model.ts @@ -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 diff --git a/packages/server/src/server/project-group-key.test.ts b/packages/server/src/server/project-group-key.test.ts index ae91fe164..f352f74ec 100644 --- a/packages/server/src/server/project-group-key.test.ts +++ b/packages/server/src/server/project-group-key.test.ts @@ -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"); diff --git a/packages/server/src/server/project-group-key.ts b/packages/server/src/server/project-group-key.ts index 93144e069..0acb2566b 100644 --- a/packages/server/src/server/project-group-key.ts +++ b/packages/server/src/server/project-group-key.ts @@ -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> = { "git:": "9418",