mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix(projects): separate identity from presentation
This commit is contained in:
@@ -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<string, string>;
|
||||
showHostLabels: boolean;
|
||||
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
||||
onlineServerIds: ReadonlySet<string>;
|
||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||
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<string, string>;
|
||||
showHostLabels: boolean;
|
||||
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
||||
onlineServerIds: ReadonlySet<string>;
|
||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||
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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface SidebarProjectSectionRowModel {
|
||||
export type SidebarProjectRowModel = SidebarProjectSectionRowModel;
|
||||
|
||||
const EMPTY_MULTIPLICITY_MAP: ReadonlyMap<string, boolean> = new Map();
|
||||
const EMPTY_SERVER_SET: ReadonlySet<string> = new Set();
|
||||
|
||||
function hostTarget(input: {
|
||||
serverId: string;
|
||||
@@ -61,24 +62,32 @@ export function resolveSidebarProjectLocalPath(
|
||||
function resolveNewWorkspaceTarget(
|
||||
project: SidebarProjectEntry,
|
||||
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>,
|
||||
onlineServerIds: ReadonlySet<string>,
|
||||
): 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<string, boolean>,
|
||||
onlineServerIds: ReadonlySet<string>,
|
||||
): 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<string, boolean>;
|
||||
onlineServerIds?: ReadonlySet<string>;
|
||||
}): 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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Record<string, string>> = {
|
||||
"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<Record<string, string>> = {
|
||||
"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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user