From 8140d8508e750f5ddf7f8fdc7bbf22a87b79cb58 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 29 Jul 2026 01:40:55 +0000 Subject: [PATCH] fix(projects): separate identity from presentation --- .../src/components/sidebar-workspace-list.tsx | 25 ++++++- .../utils/sidebar-project-row-model.test.ts | 40 +++++++++++ .../src/utils/sidebar-project-row-model.ts | 17 ++++- .../src/server/project-group-key.test.ts | 49 +++++++++++-- .../server/src/server/project-group-key.ts | 70 ++++++++++++++++--- .../workspace-registry-bootstrap-legacy.ts | 23 ++---- 6 files changed, 191 insertions(+), 33 deletions(-) diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 4ae005e61..5fac3cc13 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -48,7 +48,11 @@ import { import { NestableScrollContainer } from "react-native-draggable-flatlist"; import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list"; import type { DraggableListDragHandleProps } from "./draggable-list.types"; -import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; +import { + getHostRuntimeStore, + useHostRuntimeConnectionStatuses, + useHosts, +} from "@/runtime/host-runtime"; import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins"; import { useSidebarWorkspacePinController, @@ -1610,6 +1614,7 @@ function ProjectBlock({ hostLabelByServerId, showHostLabels, supportsMultiplicityByServerId, + onlineServerIds, supportsPinningByServerId, onToggleWorkspacePin, }: { @@ -1635,6 +1640,7 @@ function ProjectBlock({ hostLabelByServerId: ReadonlyMap; showHostLabels: boolean; supportsMultiplicityByServerId: ReadonlyMap; + onlineServerIds: ReadonlySet; supportsPinningByServerId: ReadonlyMap; onToggleWorkspacePin: ToggleSidebarWorkspacePin; }) { @@ -1650,8 +1656,9 @@ function ProjectBlock({ project, collapsed, supportsMultiplicityByServerId, + onlineServerIds, }), - [collapsed, project, supportsMultiplicityByServerId], + [collapsed, onlineServerIds, project, supportsMultiplicityByServerId], ); const active = isProjectSelectedByRoute({ @@ -1876,6 +1883,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo previous.hostLabelByServerId === next.hostLabelByServerId && previous.showHostLabels === next.showHostLabels && previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId && + previous.onlineServerIds === next.onlineServerIds && previous.supportsPinningByServerId === next.supportsPinningByServerId && previous.onToggleWorkspacePin === next.onToggleWorkspacePin && previous.parentGestureRef === next.parentGestureRef && @@ -1949,6 +1957,14 @@ export function SidebarWorkspaceList({ }, [hosts]); const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity"); + const connectionStatusByServerId = useHostRuntimeConnectionStatuses(serverIds); + const onlineServerIds = useMemo( + () => + new Set( + serverIds.filter((serverId) => connectionStatusByServerId.get(serverId) === "online"), + ), + [connectionStatusByServerId, serverIds], + ); const supportsPinningByServerId = useHostFeatureMap(serverIds, "workspacePinning"); const onToggleWorkspacePin = useSidebarWorkspacePinController(); const showHostLabels = useMemo(() => shouldShowSidebarHostLabels(projects), [projects]); @@ -1985,6 +2001,7 @@ export function SidebarWorkspaceList({ hostLabelByServerId={hostLabelByServerId} showHostLabels={showHostLabels} supportsMultiplicityByServerId={supportsMultiplicityByServerId} + onlineServerIds={onlineServerIds} supportsPinningByServerId={supportsPinningByServerId} onToggleWorkspacePin={onToggleWorkspacePin} /> @@ -2056,6 +2073,7 @@ function ProjectModeList({ hostLabelByServerId, showHostLabels, supportsMultiplicityByServerId, + onlineServerIds, supportsPinningByServerId, onToggleWorkspacePin, }: Omit< @@ -2066,6 +2084,7 @@ function ProjectModeList({ hostLabelByServerId: ReadonlyMap; showHostLabels: boolean; supportsMultiplicityByServerId: ReadonlyMap; + onlineServerIds: ReadonlySet; supportsPinningByServerId: ReadonlyMap; onToggleWorkspacePin: ToggleSidebarWorkspacePin; }) { @@ -2278,6 +2297,7 @@ function ProjectModeList({ hostLabelByServerId={hostLabelByServerId} showHostLabels={showHostLabels} supportsMultiplicityByServerId={supportsMultiplicityByServerId} + onlineServerIds={onlineServerIds} supportsPinningByServerId={supportsPinningByServerId} onToggleWorkspacePin={onToggleWorkspacePin} /> @@ -2291,6 +2311,7 @@ function ProjectModeList({ hostLabelByServerId, showHostLabels, supportsMultiplicityByServerId, + onlineServerIds, supportsPinningByServerId, onToggleWorkspacePin, onWorkspacePress, 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 3194508d2..1998771a9 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -141,6 +141,46 @@ describe("buildSidebarProjectRowModel", () => { }); }); + it("prefers an online host when a grouped project's first host is offline", () => { + const result = buildSidebarProjectRowModel({ + project: project({ + hosts: [ + { serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }, + { serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true }, + ], + }), + collapsed: false, + onlineServerIds: new Set(["host-b"]), + }); + + expect(result).toMatchObject({ + trailingAction: { + kind: "new_workspace", + target: { serverId: "host-b", iconWorkingDir: "/repo/b" }, + }, + }); + }); + + it("falls back to the first capable host when every grouped host is offline", () => { + const result = buildSidebarProjectRowModel({ + project: project({ + hosts: [ + { serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }, + { serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true }, + ], + }), + collapsed: false, + onlineServerIds: new Set(), + }); + + expect(result).toMatchObject({ + trailingAction: { + kind: "new_workspace", + target: { serverId: "host-a", iconWorkingDir: "/repo/a" }, + }, + }); + }); + it("targets the first multiplicity-capable host for a non-git project", () => { const result = buildSidebarProjectRowModel({ project: project({ diff --git a/packages/app/src/utils/sidebar-project-row-model.ts b/packages/app/src/utils/sidebar-project-row-model.ts index 67ef6f6be..6e745bc00 100644 --- a/packages/app/src/utils/sidebar-project-row-model.ts +++ b/packages/app/src/utils/sidebar-project-row-model.ts @@ -19,6 +19,7 @@ export interface SidebarProjectSectionRowModel { export type SidebarProjectRowModel = SidebarProjectSectionRowModel; const EMPTY_MULTIPLICITY_MAP: ReadonlyMap = new Map(); +const EMPTY_SERVER_SET: ReadonlySet = new Set(); function hostTarget(input: { serverId: string; @@ -61,24 +62,32 @@ export function resolveSidebarProjectLocalPath( function resolveNewWorkspaceTarget( project: SidebarProjectEntry, supportsMultiplicityByServerId: ReadonlyMap, + onlineServerIds: ReadonlySet, ): SidebarProjectHostTarget | null { + let firstEligibleTarget: SidebarProjectHostTarget | null = null; for (const host of project.hosts) { if (!host.canCreateWorktree && !supportsMultiplicityByServerId.get(host.serverId)) { continue; } const target = hostTarget(host); - if (target) { + if (target && onlineServerIds.has(host.serverId)) { return target; } + firstEligibleTarget ??= target; } - return null; + return firstEligibleTarget; } function projectTrailingAction( project: SidebarProjectEntry, supportsMultiplicityByServerId: ReadonlyMap, + onlineServerIds: ReadonlySet, ): SidebarProjectTrailingAction { - const target = resolveNewWorkspaceTarget(project, supportsMultiplicityByServerId); + const target = resolveNewWorkspaceTarget( + project, + supportsMultiplicityByServerId, + onlineServerIds, + ); return target ? { kind: "new_workspace", target } : { kind: "none" }; } @@ -86,6 +95,7 @@ export function buildSidebarProjectRowModel(input: { project: SidebarProjectEntry; collapsed: boolean; supportsMultiplicityByServerId?: ReadonlyMap; + onlineServerIds?: ReadonlySet; }): SidebarProjectRowModel { return { kind: "project_section", @@ -93,6 +103,7 @@ export function buildSidebarProjectRowModel(input: { trailingAction: projectTrailingAction( input.project, input.supportsMultiplicityByServerId ?? EMPTY_MULTIPLICITY_MAP, + input.onlineServerIds ?? EMPTY_SERVER_SET, ), }; } diff --git a/packages/server/src/server/project-group-key.test.ts b/packages/server/src/server/project-group-key.test.ts index ea955124c..b27d619b4 100644 --- a/packages/server/src/server/project-group-key.test.ts +++ b/packages/server/src/server/project-group-key.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, test } from "vitest"; -import { deriveProjectGroupKey } from "./project-group-key.js"; +import { deriveProjectGroupKey, deriveProjectGroupingDisplayName } from "./project-group-key.js"; describe("deriveProjectGroupKey", () => { test("preserves an explicit remote port", () => { @@ -15,7 +15,7 @@ describe("deriveProjectGroupKey", () => { worktreeRoot: rootPath, mainRepoRoot: null, }), - ).toBe("remote:git.example.com:8443/acme/app"); + ).toBe("remote:git.example.com:8443/acme/app.git"); }); test("normalizes the default SSH port", () => { @@ -43,7 +43,33 @@ describe("deriveProjectGroupKey", () => { worktreeRoot: rootPath, mainRepoRoot: null, }), - ).toBe("remote:git.example.com/repo"); + ).toBe("remote:git.example.com/repo.git"); + }); + + test("preserves meaningful dot-git suffixes on unknown Git servers", () => { + const rootPath = path.resolve("repo"); + const derive = (remoteUrl: string) => + deriveProjectGroupKey({ + rootPath, + remoteUrl, + worktreeRoot: rootPath, + mainRepoRoot: null, + }); + + expect(derive("git@example.com:repos/foo.git")).not.toBe(derive("git@example.com:repos/foo")); + }); + + test("normalizes dot-git suffixes on known cloud forges", () => { + const rootPath = path.resolve("repo"); + const derive = (remoteUrl: string) => + deriveProjectGroupKey({ + rootPath, + remoteUrl, + worktreeRoot: rootPath, + mainRepoRoot: null, + }); + + expect(derive("git@github.com:acme/foo.git")).toBe(derive("https://github.com/acme/foo")); }); test("accepts an SCP-style remote without a username", () => { @@ -155,7 +181,7 @@ describe("deriveProjectGroupKey", () => { worktreeRoot: rootPath, mainRepoRoot: null, }), - ).toBe("remote:[2001:db8::1]/getpaseo/paseo"); + ).toBe("remote:[2001:db8::1]/getpaseo/paseo.git"); }); test("includes the selected path within a repository", () => { @@ -253,3 +279,18 @@ describe("deriveProjectGroupKey", () => { }, ); }); + +describe("deriveProjectGroupingDisplayName", () => { + test.each([ + ["https://example.com/acme/my%20repo.git", "acme/my repo"], + ["git@example.com:acme/my repo.git", "acme/my repo"], + ["git@example.com:acme/my#repo.git", "acme/my#repo"], + ["git@example.com:acme/my%repo.git", "acme/my%repo"], + ])("derives a decoded display name from %s", (remoteUrl, expected) => { + const rootPath = path.resolve("repo"); + + expect(deriveProjectGroupingDisplayName({ rootPath, remoteUrl, worktreeRoot: rootPath })).toBe( + expected, + ); + }); +}); diff --git a/packages/server/src/server/project-group-key.ts b/packages/server/src/server/project-group-key.ts index f3603f8bb..a4b6d3447 100644 --- a/packages/server/src/server/project-group-key.ts +++ b/packages/server/src/server/project-group-key.ts @@ -1,6 +1,17 @@ import { resolve } from "node:path"; +import { FORGE_DEFINITIONS } from "@getpaseo/protocol/forge-manifest"; import { getRealpathAwareRelativePath } from "../utils/path.js"; +const CLOUD_FORGE_HOSTS = new Set( + FORGE_DEFINITIONS.flatMap((forge) => forge.cloudHosts ?? []).map((host) => host.toLowerCase()), +); +const DEFAULT_REMOTE_PORTS: Partial> = { + "git:": "9418", + "git+ssh:": "22", + "ssh:": "22", + "ssh+git:": "22", +}; + /** * Derives the persisted, opaque equivalence key used to group projects across hosts. * A Git remote is today's strongest shared fact, but callers treat the result as opaque. @@ -40,6 +51,7 @@ function deriveRemoteProjectGroupKey(remoteUrl: string | null): string | null { remote.path, remote.preserveLeadingSlash, remote.decodePercentEncoding, + remote.stripDotGitSuffix, ); if (!cleanedPath) return null; const userPrefix = remote.relativePathUser @@ -54,6 +66,7 @@ interface RemoteLocation { relativePathUser: string | null; preserveLeadingSlash: boolean; decodePercentEncoding: boolean; + stripDotGitSuffix: boolean; } function parseRemoteLocation(remoteUrl: string): RemoteLocation | null { @@ -75,6 +88,7 @@ function parseScpRemote(remoteUrl: string): RemoteLocation | null { relativePathUser: user && user !== "git" && !preserveLeadingSlash ? user : null, preserveLeadingSlash, decodePercentEncoding: false, + stripDotGitSuffix: CLOUD_FORGE_HOSTS.has(host.toLowerCase()), }; } @@ -92,6 +106,9 @@ function parseUrlRemote(remoteUrl: string): RemoteLocation | null { relativePathUser: null, preserveLeadingSlash, decodePercentEncoding: true, + stripDotGitSuffix: + (!parsed.port || parsed.port === DEFAULT_REMOTE_PORTS[parsed.protocol]) && + CLOUD_FORGE_HOSTS.has(parsed.hostname.toLowerCase()), }; } catch { return null; @@ -102,6 +119,7 @@ function normalizeRemotePath( remotePath: string, preserveLeadingSlash: boolean, decodePercentEncoding: boolean, + stripDotGitSuffix: boolean, ): string { const trimmedPath = remotePath.trim().replace(/\/+$/, ""); const pathForEncoding = preserveLeadingSlash ? trimmedPath : trimmedPath.replace(/^\/+/, ""); @@ -116,17 +134,53 @@ function normalizeRemotePath( }); const lastIndex = decodedSegments.length - 1; const lastSegment = decodedSegments[lastIndex]; - if (lastSegment?.endsWith(".git")) decodedSegments[lastIndex] = lastSegment.slice(0, -4); + if (stripDotGitSuffix && lastSegment?.endsWith(".git")) { + decodedSegments[lastIndex] = lastSegment.slice(0, -4); + } return decodedSegments.map(encodeURIComponent).join("/"); } +export function deriveProjectGroupingDisplayName(input: { + rootPath: string; + remoteUrl: string | null; + worktreeRoot: string | null; +}): string { + if (deriveSelectedPath(input.rootPath, input.worktreeRoot)) { + return lastPathSegment(input.rootPath); + } + + const trimmed = input.remoteUrl?.trim(); + const remote = trimmed ? parseRemoteLocation(trimmed) : null; + if (!remote) { + return lastPathSegment(input.rootPath); + } + + const segments = remote.path + .trim() + .replace(/^\/+|\/+$/gu, "") + .split("/") + .filter(Boolean) + .map((segment) => { + if (!remote.decodePercentEncoding) return segment; + try { + return decodeURIComponent(segment); + } catch { + return segment; + } + }); + const lastIndex = segments.length - 1; + const lastSegment = segments[lastIndex]; + if (lastSegment?.endsWith(".git")) segments[lastIndex] = lastSegment.slice(0, -4); + return segments.slice(-2).join("/") || input.rootPath; +} + +function lastPathSegment(inputPath: string): string { + const segments = inputPath.split(/[\\/]/u).filter(Boolean); + return segments[segments.length - 1] ?? inputPath; +} + function deriveRemoteHost(remoteUrl: URL): string | null { - const defaultPorts: Partial> = { - "git:": "9418", - "git+ssh:": "22", - "ssh:": "22", - "ssh+git:": "22", - }; - if (remoteUrl.port === defaultPorts[remoteUrl.protocol]) return remoteUrl.hostname || null; + if (remoteUrl.port === DEFAULT_REMOTE_PORTS[remoteUrl.protocol]) + return remoteUrl.hostname || null; return remoteUrl.host || null; } diff --git a/packages/server/src/server/workspace-registry-bootstrap-legacy.ts b/packages/server/src/server/workspace-registry-bootstrap-legacy.ts index 252a0b313..2ed18f5de 100644 --- a/packages/server/src/server/workspace-registry-bootstrap-legacy.ts +++ b/packages/server/src/server/workspace-registry-bootstrap-legacy.ts @@ -1,10 +1,10 @@ -import { basename, resolve } from "node:path"; +import { resolve } from "node:path"; import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages"; import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js"; import { getRealpathAwareRelativePath } from "../utils/path.js"; -import { deriveProjectGroupKey } from "./project-group-key.js"; +import { deriveProjectGroupKey, deriveProjectGroupingDisplayName } from "./project-group-key.js"; import { deriveProjectKind, deriveWorkspaceDisplayName, @@ -47,7 +47,11 @@ export function classifyDirectoryForProjectMembership(input: { workspaceKind: deriveWorkspaceKind(checkout), workspaceDisplayName: deriveWorkspaceDisplayName({ cwd, checkout }), projectKey, - projectName: deriveProjectGroupingName(projectKey, cwd), + projectName: deriveProjectGroupingDisplayName({ + rootPath: cwd, + remoteUrl: checkout.remoteUrl, + worktreeRoot: checkout.worktreeRoot, + }), projectRootPath: deriveProjectRootPath({ cwd, checkout }), projectKind: deriveProjectKind(checkout), }; @@ -59,19 +63,6 @@ function deriveWorkspaceDirectoryKey(cwd: string, checkout: ProjectCheckoutLiteP return worktreeRoot && resolve(worktreeRoot) === selectedRoot ? worktreeRoot : selectedRoot; } -function deriveProjectGroupingName(projectKey: string, selectedRoot: string): string { - if (projectKey.includes("#subdir:")) return basename(selectedRoot); - if (projectKey.startsWith("remote:")) { - const pathSegments = projectKey.slice("remote:".length).split("/").filter(Boolean).slice(1); - if (pathSegments.length >= 2) return pathSegments.slice(-2).join("/"); - if (pathSegments.length === 1) return pathSegments[0]; - return projectKey; - } - - const segments = projectKey.split(/[\\/]/).filter(Boolean); - return segments[segments.length - 1] || projectKey; -} - function deriveProjectRootPath(input: { cwd: string; checkout: ProjectCheckoutLitePayload;