diff --git a/packages/app/e2e/helpers/workspace.ts b/packages/app/e2e/helpers/workspace.ts index 467ecd300..b64f3589f 100644 --- a/packages/app/e2e/helpers/workspace.ts +++ b/packages/app/e2e/helpers/workspace.ts @@ -9,10 +9,35 @@ interface TempRepo { cleanup: () => Promise; } +async function configureRemote(input: { + repoPath: string; + withRemote: boolean; + originUrl: string | undefined; +}): Promise { + const { repoPath, withRemote, originUrl } = input; + if (withRemote) { + // Deterministic local remote to avoid relying on external auth/network in e2e. + const remoteDir = path.join(repoPath, "remote.git"); + await mkdir(remoteDir, { recursive: true }); + execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" }); + execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" }); + execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" }); + return; + } + if (originUrl) { + // Daemon reads origin for project grouping; no fetch occurs, so a synthetic URL is fine. + execSync(`git remote add origin ${JSON.stringify(originUrl)}`, { + cwd: repoPath, + stdio: "ignore", + }); + } +} + export const createTempGitRepo = async ( prefix = "paseo-e2e-", options?: { withRemote?: boolean; + originUrl?: string; paseoConfig?: Record; files?: Array<{ path: string; content: string }>; branches?: string[]; @@ -74,14 +99,7 @@ export const createTempGitRepo = async ( execSync("git checkout main", { cwd: repoPath, stdio: "ignore" }); } - if (withRemote) { - // Deterministic local remote to avoid relying on external auth/network in e2e. - const remoteDir = path.join(repoPath, "remote.git"); - await mkdir(remoteDir, { recursive: true }); - execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" }); - execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" }); - execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" }); - } + await configureRemote({ repoPath, withRemote, originUrl: options?.originUrl }); return { path: repoPath, diff --git a/packages/app/e2e/projects-settings.spec.ts b/packages/app/e2e/projects-settings.spec.ts index ce13de15f..77ebc5edd 100644 --- a/packages/app/e2e/projects-settings.spec.ts +++ b/packages/app/e2e/projects-settings.spec.ts @@ -14,6 +14,7 @@ interface ProjectsSettingsProject { interface ProjectsSettingsFixtures { editableProject: ProjectsSettingsProject; + gitlabRemoteProject: ProjectsSettingsProject; } const initialPaseoConfig = { @@ -46,6 +47,22 @@ const test = base.extend({ path: repo.path, }); + await client.close(); + await repo.cleanup(); + }, + gitlabRemoteProject: async ({ page: _page }, provide) => { + const client = await connectNewWorkspaceDaemonClient(); + const repo = await createTempGitRepo("projects-settings-gitlab-", { + paseoConfig: initialPaseoConfig, + originUrl: "https://gitlab.com/acme/app.git", + }); + const openedProject = await openProjectViaDaemon(client, repo.path); + + await provide({ + name: openedProject.projectDisplayName, + path: repo.path, + }); + await client.close(); await repo.cleanup(); }, @@ -119,4 +136,16 @@ test.describe("Projects settings", () => { await saveProjectConfig(page); await expectProjectConfigSaved(editableProject); }); + + test("user edits worktree setup on a non-GitHub remote project", async ({ + page, + gitlabRemoteProject, + }) => { + expect(gitlabRemoteProject.name).toBe("acme/app"); + await openProjects(page); + await openProjectSettings(page, gitlabRemoteProject.name); + await editWorktreeSetup(page, updatedSetup); + await saveProjectConfig(page); + await expectProjectConfigSaved(gitlabRemoteProject); + }); }); diff --git a/packages/app/src/hooks/use-projects.test.ts b/packages/app/src/hooks/use-projects.test.ts index 6b07a4a1b..86b42550f 100644 --- a/packages/app/src/hooks/use-projects.test.ts +++ b/packages/app/src/hooks/use-projects.test.ts @@ -252,7 +252,6 @@ describe("useProjects", () => { }); expect(Object.keys(result.current.projects[0] ?? {}).sort()).toEqual([ "githubUrl", - "hiddenUnsupportedRemoteCount", "hostCount", "hosts", "onlineHostCount", diff --git a/packages/app/src/hooks/use-projects.ts b/packages/app/src/hooks/use-projects.ts index 2550cecba..8b23dbf74 100644 --- a/packages/app/src/hooks/use-projects.ts +++ b/packages/app/src/hooks/use-projects.ts @@ -15,7 +15,6 @@ export interface ProjectHostError { export interface UseProjectsResult { projects: ProjectSummary[]; - hiddenUnsupportedRemoteCount: number; hostErrors: ProjectHostError[]; isLoading: boolean; isFetching: boolean; @@ -127,7 +126,6 @@ export function useProjects(): UseProjectsResult { return { projects: projectsQuery.data?.projects ?? [], - hiddenUnsupportedRemoteCount: projectsQuery.data?.hiddenUnsupportedRemoteCount ?? 0, hostErrors: projectsQuery.data?.hostErrors ?? [], isLoading: projectsQuery.isLoading, isFetching: projectsQuery.isFetching, diff --git a/packages/app/src/screens/project-settings-screen.test.tsx b/packages/app/src/screens/project-settings-screen.test.tsx index 3af776c53..56b71b8c0 100644 --- a/packages/app/src/screens/project-settings-screen.test.tsx +++ b/packages/app/src/screens/project-settings-screen.test.tsx @@ -66,7 +66,6 @@ const { theme, projectsState, hostState, navigate, confirmDialogMock, client } = current: { projects: [], hostErrors: [] as ProjectHostError[], - hiddenUnsupportedRemoteCount: 0, isLoading: false, isFetching: false, refetch: vi.fn(), @@ -442,7 +441,6 @@ function project(overrides: Partial = {}): ProjectSummary { hostCount: hosts.length, onlineHostCount: hosts.filter((host) => host.isOnline).length, githubUrl: "https://github.com/acme/app", - hiddenUnsupportedRemoteCount: 0, ...overrides, }; } @@ -451,7 +449,6 @@ function setProjectsState(overrides: Partial) { projectsState.current = { projects: [], hostErrors: [], - hiddenUnsupportedRemoteCount: 0, isLoading: false, isFetching: false, refetch: vi.fn(), diff --git a/packages/app/src/screens/projects-screen.test.tsx b/packages/app/src/screens/projects-screen.test.tsx index 4b3a40afb..30d3daef1 100644 --- a/packages/app/src/screens/projects-screen.test.tsx +++ b/packages/app/src/screens/projects-screen.test.tsx @@ -32,7 +32,6 @@ const { theme, projectsState, navigate } = vi.hoisted(() => ({ current: { projects: [], hostErrors: [], - hiddenUnsupportedRemoteCount: 0, isLoading: false, isFetching: false, refetch: vi.fn(), @@ -231,7 +230,6 @@ function project(overrides: Partial = {}): ProjectSummary { hostCount: hosts.length, onlineHostCount, githubUrl: "https://github.com/acme/app", - hiddenUnsupportedRemoteCount: 0, ...overrides, }; } @@ -240,7 +238,6 @@ function setProjectsState(overrides: Partial) { projectsState.current = { projects: [], hostErrors: [], - hiddenUnsupportedRemoteCount: 0, isLoading: false, isFetching: false, refetch: vi.fn(), @@ -343,19 +340,12 @@ describe("ProjectsScreen", () => { }); it("renders the empty state when there are no projects", () => { - setProjectsState({ projects: [], hiddenUnsupportedRemoteCount: 0 }); + setProjectsState({ projects: [] }); render({ kind: "projects" }); expect(container?.textContent).toContain("No projects yet"); - }); - - it("renders the unsupported empty state when only non-GitHub remotes were filtered", () => { - setProjectsState({ projects: [], hiddenUnsupportedRemoteCount: 3 }); - - render({ kind: "projects" }); - - expect(container?.textContent).toContain("Non-GitHub remote projects aren't supported yet"); + expect(container?.textContent).not.toContain("Non-GitHub remote projects aren't supported yet"); }); it("renders a partial-host-failure banner above the list, naming each failed host", () => { diff --git a/packages/app/src/screens/projects-screen.tsx b/packages/app/src/screens/projects-screen.tsx index bda34c562..0e9816d8f 100644 --- a/packages/app/src/screens/projects-screen.tsx +++ b/packages/app/src/screens/projects-screen.tsx @@ -15,7 +15,7 @@ interface ProjectsScreenProps { } export default function ProjectsScreen({ view }: ProjectsScreenProps) { - const { projects, hostErrors, hiddenUnsupportedRemoteCount, isLoading } = useProjects(); + const { projects, hostErrors, isLoading } = useProjects(); const selectedProjectKey = view.kind === "project" ? view.projectKey : null; if (isLoading && projects.length === 0) { @@ -27,13 +27,9 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) { } if (projects.length === 0) { - const message = - hiddenUnsupportedRemoteCount > 0 - ? "Non-GitHub remote projects aren't supported yet" - : "No projects yet"; return ( - {message} + No projects yet ); } diff --git a/packages/app/src/utils/projects.test.ts b/packages/app/src/utils/projects.test.ts index fff1cde08..eabb00fb8 100644 --- a/packages/app/src/utils/projects.test.ts +++ b/packages/app/src/utils/projects.test.ts @@ -356,7 +356,7 @@ describe("buildProjects", () => { ]); }); - it("filters non-GitHub remote projects while keeping GitHub and local projects", () => { + it("includes GitHub, GitLab, Bitbucket and local projects together, sorted by name", () => { const result = buildProjects({ hosts: [ { @@ -368,20 +368,30 @@ describe("buildProjects", () => { id: "github", repoRoot: "/repo/github", project: placement({ - projectKey: "remote:github.com/acme/app", - projectName: "acme/app", + projectKey: "remote:github.com/acme/web", + projectName: "acme/web", cwd: "/repo/github", - remoteUrl: "https://github.com/acme/app.git", + remoteUrl: "https://github.com/acme/web.git", }), }), workspace({ id: "gitlab", repoRoot: "/repo/gitlab", project: placement({ - projectKey: "remote:gitlab.com/acme/app", - projectName: "app", + projectKey: "remote:gitlab.com/acme/api", + projectName: "acme/api", cwd: "/repo/gitlab", - remoteUrl: "https://gitlab.com/acme/app.git", + remoteUrl: "https://gitlab.com/acme/api.git", + }), + }), + workspace({ + id: "bitbucket", + repoRoot: "/repo/bitbucket", + project: placement({ + projectKey: "remote:bitbucket.org/acme/cli", + projectName: "acme/cli", + cwd: "/repo/bitbucket", + remoteUrl: "https://bitbucket.org/acme/cli.git", }), }), workspace({ @@ -399,14 +409,15 @@ describe("buildProjects", () => { ], }); - expect(result.hiddenUnsupportedRemoteCount).toBe(1); expect(result.projects.map((project) => project.projectKey)).toEqual([ - "remote:github.com/acme/app", + "remote:gitlab.com/acme/api", + "remote:bitbucket.org/acme/cli", + "remote:github.com/acme/web", "/repo/local", ]); }); - it("produces the unsupported empty-state signal when only non-GitHub remote projects are present", () => { + it("renders a non-GitHub remote project on its own when no other projects are present", () => { const result = buildProjects({ hosts: [ { @@ -419,7 +430,7 @@ describe("buildProjects", () => { repoRoot: "/repo/gitlab", project: placement({ projectKey: "remote:gitlab.com/acme/app", - projectName: "app", + projectName: "acme/app", cwd: "/repo/gitlab", remoteUrl: "https://gitlab.com/acme/app.git", }), @@ -429,8 +440,48 @@ describe("buildProjects", () => { ], }); - expect(result.projects).toEqual([]); - expect(result.hiddenUnsupportedRemoteCount).toBe(1); + expect(result.projects).toHaveLength(1); + expect(result.projects[0]?.projectKey).toBe("remote:gitlab.com/acme/app"); + expect(result.projects[0]?.projectName).toBe("acme/app"); + expect(result.projects[0]?.githubUrl).toBeUndefined(); + }); + + it("groups two workspaces sharing a non-GitHub remote into one project with workspaceCount two", () => { + const result = buildProjects({ + hosts: [ + { + serverId: "local", + serverName: "Local", + isOnline: true, + workspaces: [ + workspace({ + id: "main", + repoRoot: "/repo/gitlab", + project: placement({ + projectKey: "remote:gitlab.com/acme/app", + projectName: "acme/app", + cwd: "/repo/gitlab", + remoteUrl: "https://gitlab.com/acme/app.git", + }), + }), + workspace({ + id: "feature", + repoRoot: "/repo/gitlab", + project: placement({ + projectKey: "remote:gitlab.com/acme/app", + projectName: "acme/app", + cwd: "/repo/gitlab/feature", + remoteUrl: "https://gitlab.com/acme/app.git", + }), + }), + ], + }, + ], + }); + + expect(result.projects).toHaveLength(1); + expect(result.projects[0]?.hosts).toHaveLength(1); + expect(result.projects[0]?.hosts[0]?.workspaceCount).toBe(2); }); it("falls back conservatively for mixed-version daemons whose descriptors lack project", () => { @@ -461,6 +512,5 @@ describe("buildProjects", () => { expect(summary?.hosts).toHaveLength(1); expect(summary?.hosts[0]?.repoRoot).toBe("/repo/legacy"); expect(summary?.hosts[0]?.workspaceCount).toBe(1); - expect(result.hiddenUnsupportedRemoteCount).toBe(0); }); }); diff --git a/packages/app/src/utils/projects.ts b/packages/app/src/utils/projects.ts index 7cd9bde3d..5df2b3403 100644 --- a/packages/app/src/utils/projects.ts +++ b/packages/app/src/utils/projects.ts @@ -27,7 +27,6 @@ export interface ProjectSummary { hostCount: number; onlineHostCount: number; githubUrl?: string; - hiddenUnsupportedRemoteCount: number; } export interface ProjectHost { @@ -43,7 +42,6 @@ export interface BuildProjectsInput { export interface BuildProjectsResult { projects: ProjectSummary[]; - hiddenUnsupportedRemoteCount: number; } const GITHUB_PROJECT_KEY_PATTERN = /^remote:github\.com\/([^/]+)\/([^/]+)$/; @@ -61,10 +59,6 @@ interface ProjectGroup { hostsByServerId: Map; } -function isSupportedProjectKey(projectKey: string): boolean { - return !projectKey.startsWith("remote:") || projectKey.startsWith("remote:github.com/"); -} - function deriveGithubUrl(projectKey: string): string | undefined { const match = projectKey.match(GITHUB_PROJECT_KEY_PATTERN); if (!match) { @@ -118,38 +112,27 @@ function compareHosts(left: ProjectHostEntry, right: ProjectHostEntry): number { return left.serverId.localeCompare(right.serverId); } -function toProjectSummary(input: { - draft: ProjectGroup; - hiddenUnsupportedRemoteCount: number; -}): ProjectSummary { - const hosts = Array.from(input.draft.hostsByServerId.values()) - .map(toHostEntry) - .sort(compareHosts); +function toProjectSummary(draft: ProjectGroup): ProjectSummary { + const hosts = Array.from(draft.hostsByServerId.values()).map(toHostEntry).sort(compareHosts); const totalWorkspaceCount = hosts.reduce((sum, host) => sum + host.workspaceCount, 0); const onlineHostCount = hosts.filter((host) => host.isOnline).length; return { - projectKey: input.draft.projectKey, - projectName: input.draft.projectName, + projectKey: draft.projectKey, + projectName: draft.projectName, hosts, totalWorkspaceCount, hostCount: hosts.length, onlineHostCount, - githubUrl: deriveGithubUrl(input.draft.projectKey), - hiddenUnsupportedRemoteCount: input.hiddenUnsupportedRemoteCount, + githubUrl: deriveGithubUrl(draft.projectKey), }; } export function buildProjects(input: BuildProjectsInput): BuildProjectsResult { const groups = new Map(); - let hiddenUnsupportedRemoteCount = 0; for (const host of input.hosts) { for (const workspace of host.workspaces) { const projectKey = workspace.projectId; - if (!isSupportedProjectKey(projectKey)) { - hiddenUnsupportedRemoteCount += 1; - continue; - } let group = groups.get(projectKey); if (!group) { @@ -175,9 +158,7 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult { } } - const projects = Array.from(groups.values()).map((draft) => - toProjectSummary({ draft, hiddenUnsupportedRemoteCount }), - ); + const projects = Array.from(groups.values()).map(toProjectSummary); projects.sort((left, right) => { const name = left.projectName.localeCompare(right.projectName); if (name !== 0) { @@ -186,8 +167,5 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult { return left.projectKey.localeCompare(right.projectKey); }); - return { - projects, - hiddenUnsupportedRemoteCount, - }; + return { projects }; } diff --git a/packages/server/src/server/workspace-registry-model.test.ts b/packages/server/src/server/workspace-registry-model.test.ts index 1533ee5ee..fb5390fe9 100644 --- a/packages/server/src/server/workspace-registry-model.test.ts +++ b/packages/server/src/server/workspace-registry-model.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test, vi } from "vitest"; import { classifyDirectoryForProjectMembership, + deriveProjectGroupingName, deriveProjectRootPath, deriveWorkspaceKind, deriveWorkspaceId, @@ -22,6 +23,36 @@ function createWorkspaceRecord(workspaceId: string) { }); } +describe("deriveProjectGroupingName", () => { + test("returns owner/repo for a github remote project key", () => { + expect(deriveProjectGroupingName("remote:github.com/acme/app")).toBe("acme/app"); + }); + + test("returns owner/repo for a gitlab remote project key", () => { + expect(deriveProjectGroupingName("remote:gitlab.com/acme/app")).toBe("acme/app"); + }); + + test("returns last two segments for a self-hosted remote project key", () => { + expect(deriveProjectGroupingName("remote:git.acme.internal/platform/api")).toBe("platform/api"); + }); + + test("returns last two segments for a deeply-nested remote project key", () => { + expect(deriveProjectGroupingName("remote:gitlab.com/group/sub/app")).toBe("sub/app"); + }); + + test("returns the lone path segment when only one segment follows the host", () => { + expect(deriveProjectGroupingName("remote:github.com/solo")).toBe("solo"); + }); + + test("returns the trailing path segment for a non-remote project key", () => { + expect(deriveProjectGroupingName("/repo/local")).toBe("local"); + }); + + test("returns the project key itself when no segments are present", () => { + expect(deriveProjectGroupingName("")).toBe(""); + }); +}); + describe("detectStaleWorkspaces", () => { test("returns workspace ids whose directories no longer exist", async () => { const checkDirectoryExists = vi.fn(async (cwd: string) => cwd !== "/tmp/missing"); diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index 051ffb979..daf655c77 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -103,9 +103,16 @@ export function deriveProjectGroupingKey(options: { } export function deriveProjectGroupingName(projectKey: string): string { - const githubRemotePrefix = "remote:github.com/"; - if (projectKey.startsWith(githubRemotePrefix)) { - return projectKey.slice(githubRemotePrefix.length) || projectKey; + if (projectKey.startsWith("remote:")) { + const remainder = projectKey.slice("remote:".length); + const pathSegments = remainder.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);