Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,7 @@ export type GitHubDirectory = {
name: string;
depth: number;
hasChildren: boolean;
framework: Framework | null;
};

export type GitHubStatus = {
Expand Down Expand Up @@ -730,9 +731,13 @@ export const api = {
githubRepos: (query = "") => request<{ repos: GitHubRepo[] }>(`/api/github/repos?q=${encodeURIComponent(query)}`),
githubBranches: (repoFullName: string) => request<{ branches: string[] }>(`/api/github/branches?repo=${encodeURIComponent(repoFullName)}`),
githubDirectories: (repoFullName: string, branch: string, path = "") =>
request<{ directories: GitHubDirectory[] }>(
request<{ directories: GitHubDirectory[]; rootFramework: Framework | null }>(
`/api/github/directories?repo=${encodeURIComponent(repoFullName)}&branch=${encodeURIComponent(branch)}&path=${encodeURIComponent(path)}`
),
githubDirectoryFramework: (repoFullName: string, branch: string, path = "") =>
request<{ framework: Framework | null }>(
`/api/github/directories/framework?repo=${encodeURIComponent(repoFullName)}&branch=${encodeURIComponent(branch)}&path=${encodeURIComponent(path)}`
),
projects: () => request<{ projects: ProjectCard[] }>("/api/projects"),
project: (slug: string) => request<{ project: ProjectDetail }>(`/api/projects/${slug}`),
createProject: (body: unknown) => request<{ project: ProjectDetail }>("/api/projects", { method: "POST", body: JSON.stringify(body) }),
Expand Down
97 changes: 87 additions & 10 deletions src/client/components/modals/create-service-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ import {
WorkflowSquare07Icon,
CloudServerIcon
} from "@hugeicons/core-free-icons";
import { FormEvent, ReactNode, startTransition, useCallback, useEffect, useMemo, useState } from "react";
import { FormEvent, ReactNode, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
api,
type DatabaseVariableSuggestion,
type EnvExampleVariableSuggestion,
type Framework,
type GitHubDirectory,
type GitHubRepo,
type GitHubStatus,
Expand All @@ -41,7 +42,7 @@ import {
} from "../ui/primitives";
import { Dropdown } from "../ui/dropdown";
import { formatRelativeTime, formatTime, shortSha } from "../../lib/format";
import { githubBranchesCache, githubDirectoriesCache, githubReposCache } from "../../lib/github-cache";
import { githubBranchesCache, githubDirectoriesCache, githubDirectoryFrameworkCache, githubReposCache } from "../../lib/github-cache";
import { compareReposByLastPush, repoLastPushedAt } from "../../lib/github-repos";
import { DirectoryPickerModal } from "./directory-picker";
import { DirectoryTree } from "./directory-tree";
Expand Down Expand Up @@ -150,6 +151,9 @@ export function CreateServiceModal({
const [loadingDirectoryPaths, setLoadingDirectoryPaths] = useState<Set<string>>(new Set());
const [directoryNodes, setDirectoryNodes] = useState<Record<string, GitHubDirectory[]>>({});
const [expandedDirectories, setExpandedDirectories] = useState<Set<string>>(new Set());
const [preciseFrameworks, setPreciseFrameworks] = useState<Record<string, Framework | null>>({});
const directoryFrameworkRequests = useRef(new Map<string, Promise<void>>());
const repoSelectionRef = useRef(0);
const [buildOpen, setBuildOpen] = useState(false);
const [envOpen, setEnvOpen] = useState(false);
const [envSuggestionsOpen, setEnvSuggestionsOpen] = useState(false);
Expand Down Expand Up @@ -178,6 +182,18 @@ export function CreateServiceModal({

const selectedRepo = useMemo(() => repos.find((repo) => repo.fullName === form.repoFullName) ?? null, [repos, form.repoFullName]);

const directoryNodesWithPreciseFrameworks = useMemo(() => {
if (Object.keys(preciseFrameworks).length === 0) return directoryNodes;

const merged: Record<string, GitHubDirectory[]> = {};
for (const [parentPath, rows] of Object.entries(directoryNodes)) {
merged[parentPath] = rows.map((row) => (row.path in preciseFrameworks ? { ...row, framework: preciseFrameworks[row.path] } : row));
}
return merged;
}, [directoryNodes, preciseFrameworks]);

const rootFramework = preciseFrameworks[""] ?? null;

useEffect(() => {
if (!open) {
setStep("type");
Expand Down Expand Up @@ -206,6 +222,8 @@ export function CreateServiceModal({
setLoadingRepos(false);
setLoadingDirectories(false);
setLoadingDirectoryPaths(new Set());
setPreciseFrameworks({});
repoSelectionRef.current += 1;
setRepoError("");
setDirectoryError("");
setDirectoryNodes({});
Expand Down Expand Up @@ -392,6 +410,8 @@ export function CreateServiceModal({
}));
setDirectoryNodes({});
setExpandedDirectories(new Set());
setPreciseFrameworks({});
repoSelectionRef.current += 1;
setDirectoryError("");
setStep("directory");
}
Expand All @@ -400,10 +420,14 @@ export function CreateServiceModal({
if (!form.repoFullName || !form.branch) return;

const cacheKey = `${form.repoFullName}:${form.branch}:${path}`;
const cachedDirectories = githubDirectoriesCache.get(cacheKey);
if (cachedDirectories) {
const cached = githubDirectoriesCache.get(cacheKey);
if (cached) {
startTransition(() => {
setDirectoryNodes((current) => ({ ...current, [path]: cachedDirectories }));
setDirectoryNodes((current) => ({ ...current, [path]: cached.directories }));
// Root's badge is already precise as of the listing response — seeding it here
// means clicking the root row afterward hits the "already resolved" guard in
// resolveDirectoryFramework instead of firing a redundant fetch.
if (path === "") setPreciseFrameworks((current) => ({ ...current, "": cached.rootFramework }));
});
return;
}
Expand All @@ -412,10 +436,11 @@ export function CreateServiceModal({
setLoadingDirectoryPaths((current) => new Set(current).add(path));
setDirectoryError("");
try {
const nextDirectories = (await api.githubDirectories(form.repoFullName, form.branch, path)).directories;
githubDirectoriesCache.set(cacheKey, nextDirectories);
const response = await api.githubDirectories(form.repoFullName, form.branch, path);
githubDirectoriesCache.set(cacheKey, response);
startTransition(() => {
setDirectoryNodes((current) => ({ ...current, [path]: nextDirectories }));
setDirectoryNodes((current) => ({ ...current, [path]: response.directories }));
if (path === "") setPreciseFrameworks((current) => ({ ...current, "": response.rootFramework }));
});
} catch (issue) {
startTransition(() => {
Expand Down Expand Up @@ -445,11 +470,57 @@ export function CreateServiceModal({
}

await loadDirectoryLevel(path);
void resolveDirectoryFramework(path);
startTransition(() => {
setExpandedDirectories((current) => new Set(current).add(path));
});
}

// Coarse badges come free with the directory listing; the precise one (Next.js vs
// plain React, Fiber vs Go) is only worth the extra request once a folder is actually
// opened or picked as the root.
async function resolveDirectoryFramework(path: string) {
if (!form.repoFullName || !form.branch || path in preciseFrameworks) return;

const cacheKey = `${form.repoFullName}:${form.branch}:${path}`;
const cached = githubDirectoryFrameworkCache.get(cacheKey);
if (cached !== undefined) {
setPreciseFrameworks((current) => ({ ...current, [path]: cached }));
return;
}

// Toggling/selecting the same folder again before the first lookup lands (or a
// stray double-click firing both the toggle and select handlers) would otherwise
// issue a duplicate request — reuse the in-flight one instead.
const existing = directoryFrameworkRequests.current.get(cacheKey);
if (existing) return existing;

const repoFullName = form.repoFullName;
const branch = form.branch;
const selectionAtRequest = repoSelectionRef.current;

const promise = (async () => {
try {
const { framework } = await api.githubDirectoryFramework(repoFullName, branch, path);
githubDirectoryFrameworkCache.set(cacheKey, framework);
// Discard if the user switched repos while this was in flight — otherwise a
// late-arriving result can label a folder in the newly selected repo with a
// framework detected in the previous one.
if (repoSelectionRef.current !== selectionAtRequest) return;
startTransition(() => {
setPreciseFrameworks((current) => ({ ...current, [path]: framework }));
});
} catch {
// Best-effort upgrade; the coarse badge from the directory listing stays as-is.
} finally {
directoryFrameworkRequests.current.delete(cacheKey);
}
})();

directoryFrameworkRequests.current.set(cacheKey, promise);
return promise;
}

async function submit(event: FormEvent) {
event.preventDefault();
setBusy(true);
Expand Down Expand Up @@ -819,6 +890,8 @@ export function CreateServiceModal({
setForm((current) => ({ ...current, repoFullName: "", rootDir: undefined }));
setDirectoryNodes({});
setExpandedDirectories(new Set());
setPreciseFrameworks({});
repoSelectionRef.current += 1;
setDirectoryError("");
}}
>
Expand Down Expand Up @@ -1035,14 +1108,18 @@ export function CreateServiceModal({
<DirectoryTree
repoLabel={selectedRepo?.name ?? form.repoFullName ?? ""}
selectedPath={currentDirectory}
directoriesByPath={directoryNodes}
directoriesByPath={directoryNodesWithPreciseFrameworks}
expandedPaths={expandedDirectories}
loadingPaths={loadingDirectoryPaths}
errorMessage={directoryError}
footerMessage={loadingDirectories ? "Loading folders..." : "Choose the folder that contains the app you want to deploy."}
rootLabel={`${selectedRepo?.name ?? "Repository"} (root)`}
rootFramework={rootFramework}
onToggle={toggleDirectory}
onSelect={(path) => setForm((current) => ({ ...current, rootDir: path || undefined }))}
onSelect={(path) => {
setForm((current) => ({ ...current, rootDir: path || undefined }));
void resolveDirectoryFramework(path);
}}
/>
</div>
<div className="mt-5 flex items-center justify-between gap-3 border-t border-zinc-800 pt-4">
Expand Down
5 changes: 4 additions & 1 deletion src/client/components/modals/directory-picker.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { FolderOpenIcon } from "@hugeicons/core-free-icons";
import type { GitHubDirectory } from "../../api";
import type { Framework, GitHubDirectory } from "../../api";
import { ModalShell } from "./modal-shell";
import { shellButton } from "../ui/primitives";
import { DirectoryTree } from "./directory-tree";
Expand All @@ -12,6 +12,7 @@ type DirectoryPickerModalProps = {
expandedPaths: Set<string>;
loadingPaths: Set<string>;
errorMessage: string;
rootFramework?: Framework | null;
onClose: () => void;
onToggle: (path: string) => void | Promise<void>;
onSelect: (path: string) => void;
Expand All @@ -25,6 +26,7 @@ export function DirectoryPickerModal({
expandedPaths,
loadingPaths,
errorMessage,
rootFramework,
onClose,
onToggle,
onSelect
Expand All @@ -50,6 +52,7 @@ export function DirectoryPickerModal({
loadingPaths={loadingPaths}
errorMessage={errorMessage}
footerMessage="Choose the folder that contains this service."
rootFramework={rootFramework}
onToggle={onToggle}
onSelect={onSelect}
/>
Expand Down
16 changes: 12 additions & 4 deletions src/client/components/modals/directory-tree.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ArrowLeft01Icon } from "@hugeicons/core-free-icons";
import type { ReactNode } from "react";
import type { GitHubDirectory } from "../../api";
import { AppIcon } from "../ui/primitives";
import type { Framework, GitHubDirectory } from "../../api";
import { AppIcon, FrameworkMark } from "../ui/primitives";

type DirectoryTreeProps = {
repoLabel: string;
Expand All @@ -12,6 +12,7 @@ type DirectoryTreeProps = {
errorMessage?: string;
footerMessage: string;
rootLabel?: string;
rootFramework?: Framework | null;
onToggle: (path: string) => void | Promise<void>;
onSelect: (path: string) => void;
};
Expand All @@ -25,6 +26,7 @@ export function DirectoryTree({
errorMessage,
footerMessage,
rootLabel = "Repository root",
rootFramework = null,
onToggle,
onSelect
}: DirectoryTreeProps) {
Expand Down Expand Up @@ -60,7 +62,10 @@ export function DirectoryTree({
onClick={() => onSelect(directory.path)}
/>
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => onSelect(directory.path)}>
<div className="truncate text-base font-medium text-zinc-100">{directory.name}</div>
<div className="flex items-center gap-2">
{directory.framework ? <FrameworkMark framework={directory.framework} size={16} fallback={null} /> : null}
<div className="truncate text-base font-medium text-zinc-100">{directory.name}</div>
</div>
<div className="font-mono text-[11px] uppercase tracking-[0.16em] text-zinc-500">{directory.path}</div>
</button>
</div>
Expand All @@ -82,7 +87,10 @@ export function DirectoryTree({
onClick={() => onSelect("")}
/>
<button type="button" className="flex-1 text-left" onClick={() => onSelect("")}>
<div className="text-base font-medium text-zinc-100">{rootLabel}</div>
<div className="flex items-center gap-2">
{rootFramework ? <FrameworkMark framework={rootFramework} size={16} fallback={null} /> : null}
<div className="text-base font-medium text-zinc-100">{rootLabel}</div>
</div>
</button>
</div>
{renderRows("", 0)}
Expand Down
1 change: 0 additions & 1 deletion src/client/components/ui/framework-icon-colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ const WHITE_ON_DARK_ICON_SLUGS = new Set([
"expo",
"flask",
"golang",
"nextjs",
"rust"
]);

Expand Down
19 changes: 13 additions & 6 deletions src/client/features/services/service-page-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { FormEvent, startTransition, useCallback, useEffect, useMemo, useRef, us
import {
api,
type DeploymentLog,
type Framework,
type GitHubDirectory,
type GitHubRepo,
type RuntimeLog,
Expand Down Expand Up @@ -191,6 +192,7 @@ export function ServicePageShell({
const [sourceError, setSourceError] = useState("");
const [directoryPickerOpen, setDirectoryPickerOpen] = useState(false);
const [settingsDirectoryNodes, setSettingsDirectoryNodes] = useState<Record<string, GitHubDirectory[]>>({});
const [settingsRootFramework, setSettingsRootFramework] = useState<Framework | null>(null);
const [settingsExpandedDirectories, setSettingsExpandedDirectories] = useState<Set<string>>(new Set());
const [settingsDirectoryError, setSettingsDirectoryError] = useState("");
const [settingsDirectoryLoadingPaths, setSettingsDirectoryLoadingPaths] = useState<Set<string>>(new Set());
Expand Down Expand Up @@ -419,21 +421,23 @@ export function ServicePageShell({
if (!settings.repoFullName || !settings.branch) return;

const cacheKey = `${settings.repoFullName}:${settings.branch}:${path}`;
const cachedDirectories = githubDirectoriesCache.get(cacheKey);
if (cachedDirectories) {
const cached = githubDirectoriesCache.get(cacheKey);
if (cached) {
startTransition(() => {
setSettingsDirectoryNodes((current) => ({ ...current, [path]: cachedDirectories }));
setSettingsDirectoryNodes((current) => ({ ...current, [path]: cached.directories }));
if (path === "") setSettingsRootFramework(cached.rootFramework);
});
return;
}

setSettingsDirectoryLoadingPaths((current) => new Set(current).add(path));
setSettingsDirectoryError("");
try {
const nextDirectories = (await api.githubDirectories(settings.repoFullName, settings.branch, path)).directories;
githubDirectoriesCache.set(cacheKey, nextDirectories);
const response = await api.githubDirectories(settings.repoFullName, settings.branch, path);
githubDirectoriesCache.set(cacheKey, response);
startTransition(() => {
setSettingsDirectoryNodes((current) => ({ ...current, [path]: nextDirectories }));
setSettingsDirectoryNodes((current) => ({ ...current, [path]: response.directories }));
if (path === "") setSettingsRootFramework(response.rootFramework);
});
} catch (issue) {
startTransition(() => {
Expand Down Expand Up @@ -848,6 +852,7 @@ export function ServicePageShell({
setSettings((current) => ({ ...current, branch }));
setBranchMenuOpen(false);
setSettingsDirectoryNodes({});
setSettingsRootFramework(null);
setSettingsExpandedDirectories(new Set());
}}
>
Expand Down Expand Up @@ -1024,6 +1029,7 @@ export function ServicePageShell({
setSourceQuery("");
setSourceRepos([]);
setSettingsDirectoryNodes({});
setSettingsRootFramework(null);
setSettingsExpandedDirectories(new Set());
setSettingsDirectoryError("");
}}
Expand All @@ -1037,6 +1043,7 @@ export function ServicePageShell({
expandedPaths={settingsExpandedDirectories}
loadingPaths={settingsDirectoryLoadingPaths}
errorMessage={settingsDirectoryError}
rootFramework={settingsRootFramework}
onClose={() => setDirectoryPickerOpen(false)}
onToggle={toggleSettingsDirectory}
onSelect={(path) => setSettings((current) => ({ ...current, rootDir: path }))}
Expand Down
5 changes: 3 additions & 2 deletions src/client/lib/github-cache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { GitHubDirectory, GitHubRepo } from "../api";
import type { Framework, GitHubDirectory, GitHubRepo } from "../api";

export const githubReposCache = new Map<string, GitHubRepo[]>();
export const githubBranchesCache = new Map<string, string[]>();
export const githubDirectoriesCache = new Map<string, GitHubDirectory[]>();
export const githubDirectoriesCache = new Map<string, { directories: GitHubDirectory[]; rootFramework: Framework | null }>();
export const githubDirectoryFrameworkCache = new Map<string, Framework | null>();
Loading