* 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>
137 lines
3.8 KiB
TypeScript
137 lines
3.8 KiB
TypeScript
import { useMemo } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import type { DaemonClient } from "@server/client/daemon-client";
|
|
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
|
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
|
|
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
|
|
|
|
export const projectsQueryKey = ["projects"] as const;
|
|
|
|
export interface ProjectHostError {
|
|
serverId: string;
|
|
serverName: string;
|
|
message: string;
|
|
}
|
|
|
|
export interface UseProjectsResult {
|
|
projects: ProjectSummary[];
|
|
hostErrors: ProjectHostError[];
|
|
isLoading: boolean;
|
|
isFetching: boolean;
|
|
refetch: () => void;
|
|
}
|
|
|
|
interface FetchAllWorkspaceDescriptorsInput {
|
|
client: Pick<DaemonClient, "fetchWorkspaces">;
|
|
}
|
|
|
|
interface HostWorkspacesResult {
|
|
host: ProjectHost;
|
|
error: ProjectHostError | null;
|
|
}
|
|
|
|
function toErrorMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
async function fetchAllWorkspaceDescriptors(
|
|
input: FetchAllWorkspaceDescriptorsInput,
|
|
): Promise<WorkspaceDescriptor[]> {
|
|
const entries: WorkspaceDescriptor[] = [];
|
|
let cursor: string | null = null;
|
|
|
|
while (true) {
|
|
const payload = await input.client.fetchWorkspaces({
|
|
sort: [{ key: "name", direction: "asc" }],
|
|
page: cursor ? { limit: 200, cursor } : { limit: 200 },
|
|
});
|
|
entries.push(...payload.entries.map((entry) => normalizeWorkspaceDescriptor(entry)));
|
|
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
|
break;
|
|
}
|
|
cursor = payload.pageInfo.nextCursor;
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
export function useProjects(): UseProjectsResult {
|
|
const hosts = useHosts();
|
|
const runtime = getHostRuntimeStore();
|
|
const hostInputs = useMemo(
|
|
() =>
|
|
hosts.map((host) => ({
|
|
serverId: host.serverId,
|
|
serverName: host.label,
|
|
})),
|
|
[hosts],
|
|
);
|
|
|
|
const projectsQuery = useQuery({
|
|
queryKey: projectsQueryKey,
|
|
queryFn: async () => {
|
|
const results = await Promise.all(
|
|
hostInputs.map(async (host): Promise<HostWorkspacesResult> => {
|
|
const snapshot = runtime.getSnapshot(host.serverId);
|
|
const isOnline = snapshot?.connectionStatus === "online";
|
|
const client = runtime.getClient(host.serverId);
|
|
|
|
if (!client || !isOnline) {
|
|
return {
|
|
host: {
|
|
serverId: host.serverId,
|
|
serverName: host.serverName,
|
|
isOnline,
|
|
workspaces: [],
|
|
},
|
|
error: null,
|
|
};
|
|
}
|
|
|
|
try {
|
|
return {
|
|
host: {
|
|
serverId: host.serverId,
|
|
serverName: host.serverName,
|
|
isOnline,
|
|
workspaces: await fetchAllWorkspaceDescriptors({ client }),
|
|
},
|
|
error: null,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
host: {
|
|
serverId: host.serverId,
|
|
serverName: host.serverName,
|
|
isOnline,
|
|
workspaces: [],
|
|
},
|
|
error: {
|
|
serverId: host.serverId,
|
|
serverName: host.serverName,
|
|
message: toErrorMessage(error),
|
|
},
|
|
};
|
|
}
|
|
}),
|
|
);
|
|
|
|
const hostErrors = results.flatMap((result) => (result.error ? [result.error] : []));
|
|
return {
|
|
...buildProjects({ hosts: results.map((result) => result.host) }),
|
|
hostErrors,
|
|
};
|
|
},
|
|
});
|
|
|
|
return {
|
|
projects: projectsQuery.data?.projects ?? [],
|
|
hostErrors: projectsQuery.data?.hostErrors ?? [],
|
|
isLoading: projectsQuery.isLoading,
|
|
isFetching: projectsQuery.isFetching,
|
|
refetch: () => {
|
|
void projectsQuery.refetch();
|
|
},
|
|
};
|
|
}
|