mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix(projects): preserve host-scoped actions
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: [] }),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user