Feat/open projects config to any (#681)

* feat(server): derive owner/repo display name for any remote host

Generalize deriveProjectGroupingName so any remote:<host>/<segments...>
project key returns the last two path segments (owner/repo) instead of
just the trailing segment. Path-fallback for non-remote keys is unchanged.

Brings GitLab, Gitea, Bitbucket, and self-hosted remotes to parity with
the prior github.com-only behavior — no separate special-case needed.

* feat(app): show projects from any git remote in Projects settings

Remove the isSupportedProjectKey filter so workspaces with non-GitHub
remotes (GitLab, Gitea, Bitbucket, self-hosted, ssh-style) appear in the
Projects list and route to the project settings screen. The daemon RPCs,
config schema, and registry were already host-agnostic; this lifts a
client-side filter that hid them.

Also remove the now-vestigial hiddenUnsupportedRemoteCount field from
ProjectSummary, BuildProjectsResult, and UseProjectsResult — once the
filter is gone, the count is always zero and the field is dead state.
This is an internal app-package type, not a wire schema, so deletion is
safe.

* chore(app): simplify Projects empty state to "No projects yet"

Drop the "Non-GitHub remote projects aren't supported yet" empty-state
branch — it can no longer fire now that any git remote is shown. The
empty state is unconditional now.

* test(app): cover non-GitHub remote in projects-settings e2e

Add a fixture that creates a temp git repo with origin pointing at a
gitlab.com URL and exercises the same paseo.json read/edit/save flow
already covered for local repos. Verifies the project surfaces with the
"acme/app" display name and that the round-trip persists correctly.

Extracted the remote-setup branch in createTempGitRepo into a small
configureRemote helper to keep the main function under the cyclomatic
complexity limit.

---------

Co-authored-by: Mathias Kurz <mkurz@stamus-networks.com>
This commit is contained in:
Mathias Kurz
2026-05-05 19:56:51 +07:00
committed by Mohamed Boudra
co-authored by Mathias Kurz
parent 2b6d7d4ec2
commit 44d13919d7
11 changed files with 171 additions and 78 deletions
+26 -8
View File
@@ -9,10 +9,35 @@ interface TempRepo {
cleanup: () => Promise<void>;
}
async function configureRemote(input: {
repoPath: string;
withRemote: boolean;
originUrl: string | undefined;
}): Promise<void> {
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<string, unknown>;
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,
@@ -14,6 +14,7 @@ interface ProjectsSettingsProject {
interface ProjectsSettingsFixtures {
editableProject: ProjectsSettingsProject;
gitlabRemoteProject: ProjectsSettingsProject;
}
const initialPaseoConfig = {
@@ -46,6 +47,22 @@ const test = base.extend<ProjectsSettingsFixtures>({
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);
});
});
@@ -252,7 +252,6 @@ describe("useProjects", () => {
});
expect(Object.keys(result.current.projects[0] ?? {}).sort()).toEqual([
"githubUrl",
"hiddenUnsupportedRemoteCount",
"hostCount",
"hosts",
"onlineHostCount",
-2
View File
@@ -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,
@@ -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> = {}): 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<UseProjectsResult>) {
projectsState.current = {
projects: [],
hostErrors: [],
hiddenUnsupportedRemoteCount: 0,
isLoading: false,
isFetching: false,
refetch: vi.fn(),
@@ -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> = {}): ProjectSummary {
hostCount: hosts.length,
onlineHostCount,
githubUrl: "https://github.com/acme/app",
hiddenUnsupportedRemoteCount: 0,
...overrides,
};
}
@@ -240,7 +238,6 @@ function setProjectsState(overrides: Partial<UseProjectsResult>) {
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", () => {
+2 -6
View File
@@ -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 (
<View style={styles.centered} testID="projects-list">
<Text style={styles.emptyText}>{message}</Text>
<Text style={styles.emptyText}>No projects yet</Text>
</View>
);
}
+64 -14
View File
@@ -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);
});
});
+7 -29
View File
@@ -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<string, HostGroup>;
}
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<string, ProjectGroup>();
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 };
}
@@ -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");
@@ -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);