Show config file paths instead of project cards on /projects for RDEs (#1676)

This commit is contained in:
Konsti Wohlwend 2026-06-26 17:30:14 -07:00 committed by GitHub
parent e70e95a3af
commit 4a77dd98ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 192 additions and 2 deletions

View File

@ -1,12 +1,13 @@
'use client';
import { Link } from "@/components/link";
import { ProjectCard } from "@/components/project-card";
import { useRouter } from "@/components/router";
import { SearchBar } from "@/components/search-bar";
import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, Skeleton, Typography, toast } from "@/components/ui";
import { getPublicEnvVar } from "@/lib/env";
import { hexclaveAppInternalsSymbol } from "@/lib/hexclave-app-internals";
import { GearIcon } from "@phosphor-icons/react";
import { FileCode, GearIcon } from "@phosphor-icons/react";
import { AdminOwnedProject, Team, useStackApp, useUser } from "@hexclave/next";
import { isPaidPlan } from "@hexclave/shared/dist/plans";
import { projectOnboardingStatusValues, strictEmailSchema, yupObject, type ProjectOnboardingStatus } from "@hexclave/shared/dist/schema-fields";
@ -15,6 +16,7 @@ import { captureError, throwErr } from "@hexclave/shared/dist/utils/errors";
import { runAsynchronously, runAsynchronouslyWithAlert, wait } from "@hexclave/shared/dist/utils/promises";
import { useQueryState } from "@hexclave/shared/dist/utils/react";
import { stringCompare } from "@hexclave/shared/dist/utils/strings";
import { urlString } from "@hexclave/shared/dist/utils/urls";
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import * as yup from "yup";
import { inviteUser, listInvitations, revokeInvitation } from "./actions";
@ -58,11 +60,12 @@ function isProjectOnboardingStatus(value: unknown): value is ProjectOnboardingSt
export default function PageClient() {
const isPreview = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_PREVIEW") === "true";
const isRemoteDevelopmentEnvironment = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true";
return (
<>
<DottedBackground />
{isPreview ? <PreviewProjectRedirect /> : <ProjectsListPage />}
{isPreview ? <PreviewProjectRedirect /> : isRemoteDevelopmentEnvironment ? <RdeProjectsListPage /> : <ProjectsListPage />}
<Footer />
</>
);
@ -82,6 +85,159 @@ function DottedBackground() {
);
}
function RdeProjectsListPage() {
const user = useUser({
or: "anonymous-if-exists[deprecated]",
projectIdMustMatch: "internal",
}) ?? throwErr("RDE projects page expected a user because useUser was called with an explicit required user mode.");
const rawProjects = user.useOwnedProjects();
const [projectConfigPaths, setProjectConfigPaths] = useState<Map<string, string>>(new Map());
const [loadingConfigPaths, setLoadingConfigPaths] = useState(true);
const [projectStatuses, setProjectStatuses] = useState<Map<string, ProjectOnboardingStatus>>(new Map());
const [loadingProjectStatuses, setLoadingProjectStatuses] = useState(true);
const app = useStackApp();
const appInternals = useMemo(() => getStackAppInternals(app), [app]);
const [search, setSearch] = useState("");
useEffect(() => {
let cancelled = false;
runAsynchronously(async () => {
try {
const response = await fetch("/api/development-environment/projects");
if (!response.ok) {
throw new Error(`Failed to load project config paths: ${response.status}`);
}
const body = await response.json() as { project_config_paths?: unknown };
if (body.project_config_paths == null || typeof body.project_config_paths !== "object" || Array.isArray(body.project_config_paths)) {
throw new Error("Invalid project config paths response.");
}
if (!cancelled) {
const paths = new Map<string, string>();
for (const [projectId, configPath] of Object.entries(body.project_config_paths)) {
if (typeof configPath === "string") {
paths.set(projectId, configPath);
}
}
setProjectConfigPaths(paths);
}
} catch (error) {
captureError("rde-projects-page-load-config-paths", error);
} finally {
if (!cancelled) {
setLoadingConfigPaths(false);
}
}
});
return () => {
cancelled = true;
};
}, [rawProjects.length]);
useEffect(() => {
let cancelled = false;
runAsynchronouslyWithAlert(async () => {
setLoadingProjectStatuses(true);
try {
const response = await appInternals.sendRequest("/internal/projects", {}, "client");
if (!response.ok) {
throw new Error(`Failed to load projects: ${response.status} ${await response.text()}`);
}
const body = await response.json();
if (body == null || typeof body !== "object" || !("items" in body) || !Array.isArray(body.items)) {
throw new Error("Project list endpoint returned an invalid response.");
}
const statusMap = new Map<string, ProjectOnboardingStatus>();
for (const item of body.items) {
if (item == null || typeof item !== "object" || !("id" in item) || typeof item.id !== "string") {
continue;
}
const onboardingStatus = "onboarding_status" in item ? item.onboarding_status : undefined;
if (!isProjectOnboardingStatus(onboardingStatus)) {
throw new Error(`Project ${item.id} returned an invalid onboarding status.`);
}
statusMap.set(item.id, onboardingStatus);
}
if (!cancelled) {
setProjectStatuses(statusMap);
}
} finally {
if (!cancelled) {
setLoadingProjectStatuses(false);
}
}
});
return () => {
cancelled = true;
};
}, [appInternals, rawProjects.length]);
const sortedProjects = useMemo(() => {
let projects = [...rawProjects];
if (search) {
projects = projects.filter((project) => {
const configPath = projectConfigPaths.get(project.id);
const searchTarget = configPath ?? project.id;
return searchTarget.toLowerCase().includes(search.toLowerCase());
});
}
return projects.sort((a, b) => a.createdAt > b.createdAt ? -1 : 1);
}, [rawProjects, search, projectConfigPaths]);
const loading = loadingConfigPaths || loadingProjectStatuses;
return (
<div className="flex-grow p-4">
<div className="mb-4">
<SearchBar
placeholder="Search config file path"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{loading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-xl" />
))}
</div>
) : sortedProjects.length === 0 ? (
<Typography variant="secondary" className="py-8 text-center">
{search ? "No projects match your search." : "No projects connected yet. Run `stack dev` to connect a project."}
</Typography>
) : (
<div className="space-y-1">
{sortedProjects.map((project) => {
const configPath = projectConfigPaths.get(project.id);
const onboardingStatus = projectStatuses.get(project.id);
const projectHref = onboardingStatus === "completed"
? urlString`/projects/${project.id}`
: urlString`/new-project?project_id=${project.id}`;
return (
<Link key={project.id} href={projectHref}>
<div className="flex items-center gap-3 rounded-xl px-3 py-2.5 transition-colors duration-150 hover:transition-none hover:bg-foreground/[0.04] group">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-foreground/[0.06] ring-1 ring-black/[0.04] dark:ring-white/[0.04]">
<FileCode className="h-4 w-4 text-muted-foreground" weight="duotone" />
</div>
<span className="min-w-0 flex-1 truncate font-mono text-sm text-foreground">
{configPath ?? project.id}
</span>
{onboardingStatus != null && onboardingStatus !== "completed" && (
<span className="shrink-0 rounded-full bg-orange-100 px-2 py-0.5 text-[10px] font-medium text-orange-700 dark:bg-orange-900/30 dark:text-orange-400">
Setup incomplete
</span>
)}
</div>
</Link>
);
})}
</div>
)}
</div>
);
}
function ProjectsListPage() {
const app = useStackApp();
const appInternals = useMemo(() => getStackAppInternals(app), [app]);

View File

@ -0,0 +1,23 @@
import { getPublicEnvVar } from "@/lib/env";
import { assertRemoteDevelopmentEnvironmentBrowserRequest } from "@/lib/remote-development-environment/security";
import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs";
export async function GET(req: NextRequest) {
const isRemoteDevelopmentEnvironment = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true";
if (!isRemoteDevelopmentEnvironment) {
return NextResponse.json({ error: "This endpoint is only available in remote development environments." }, { status: 404 });
}
const securityResponse = assertRemoteDevelopmentEnvironmentBrowserRequest(req);
if (securityResponse != null) return securityResponse;
const { getRemoteDevelopmentEnvironmentProjectConfigPaths } = await import("@/lib/remote-development-environment/manager");
const configPaths = getRemoteDevelopmentEnvironmentProjectConfigPaths();
const projectConfigPaths: Record<string, string> = {};
for (const [projectId, configFilePath] of configPaths) {
projectConfigPaths[projectId] = configFilePath;
}
return NextResponse.json({ project_config_paths: projectConfigPaths });
}

View File

@ -1004,6 +1004,17 @@ export function closeRemoteDevelopmentEnvironmentSession(sessionId: string): voi
});
}
export function getRemoteDevelopmentEnvironmentProjectConfigPaths(): Map<string, string> {
assertRemoteDevelopmentEnvironmentEnabled();
const state = readRemoteDevelopmentEnvironmentState();
const result = new Map<string, string>();
for (const [configFilePath, project] of Object.entries(state.projectsByConfigPath)) {
if (project == null) continue;
result.set(project.projectId, configFilePath);
}
return result;
}
export function getRemoteDevelopmentEnvironmentHealth(): {
healthy: boolean,
configFilePath?: string,