singularity-forge/web/components/sf/onboarding/step-project.tsx
2026-05-05 14:46:18 +02:00

557 lines
17 KiB
TypeScript

"use client";
import {
ArrowRight,
FolderOpen,
GitBranch,
Layers,
Loader2,
Plus,
Sparkles,
Zap,
} from "lucide-react";
import { motion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { authFetch } from "@/lib/auth";
import { useProjectStoreManager } from "@/lib/project-store-manager";
import { cn } from "@/lib/utils";
// ─── Types ──────────────────────────────────────────────────────────
type ProjectDetectionKind =
| "active-sf"
| "empty-sf"
| "v1-legacy"
| "brownfield"
| "blank";
interface ProjectDetectionSignals {
hasSfFolder: boolean;
hasPlanningFolder: boolean;
hasGitRepo: boolean;
hasPackageJson: boolean;
fileCount: number;
hasMilestones?: boolean;
hasCargo?: boolean;
hasGoMod?: boolean;
hasPyproject?: boolean;
isMonorepo?: boolean;
}
interface ProjectProgressInfo {
activeMilestone: string | null;
activeSlice: string | null;
phase: string | null;
milestonesCompleted: number;
milestonesTotal: number;
}
interface ProjectMetadata {
name: string;
path: string;
kind: ProjectDetectionKind;
signals: ProjectDetectionSignals;
lastModified: number;
progress?: ProjectProgressInfo | null;
}
// ─── Helpers ────────────────────────────────────────────────────────
const KIND_STYLE: Record<
ProjectDetectionKind,
{ label: string; color: string; icon: typeof Layers }
> = {
"active-sf": { label: "Active", color: "text-success", icon: Layers },
"empty-sf": { label: "Initialized", color: "text-info", icon: FolderOpen },
brownfield: { label: "Existing", color: "text-warning", icon: GitBranch },
"v1-legacy": { label: "Legacy", color: "text-warning", icon: GitBranch },
blank: { label: "New", color: "text-muted-foreground", icon: Sparkles },
};
function techStack(signals: ProjectDetectionSignals): string[] {
const tags: string[] = [];
if (signals.isMonorepo) tags.push("Monorepo");
if (signals.hasGitRepo) tags.push("Git");
if (signals.hasPackageJson) tags.push("Node.js");
if (signals.hasCargo) tags.push("Rust");
if (signals.hasGoMod) tags.push("Go");
if (signals.hasPyproject) tags.push("Python");
return tags;
}
function progressLabel(p: ProjectProgressInfo): string | null {
if (p.milestonesTotal === 0) return null;
const parts: string[] = [];
if (p.activeMilestone) parts.push(p.activeMilestone);
if (p.activeSlice) parts.push(p.activeSlice);
if (p.phase) parts.push(p.phase);
return parts.join(" · ") || null;
}
function _shortenPath(p: string): string {
const _home = typeof window !== "undefined" ? "" : "";
// Show last 2-3 segments
const parts = p.split("/").filter(Boolean);
if (parts.length <= 3) return p;
return "…/" + parts.slice(-2).join("/");
}
// ─── Component ──────────────────────────────────────────────────────
interface StepProjectProps {
onFinish: (projectPath: string) => void;
onBack: () => void;
/** Called immediately before a project switch starts — use to disarm gates. */
onBeforeSwitch?: () => void;
}
export function StepProject({
onFinish,
onBack,
onBeforeSwitch,
}: StepProjectProps) {
const manager = useProjectStoreManager();
const [devRoot, setDevRoot] = useState<string | null>(null);
const [projects, setProjects] = useState<ProjectMetadata[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState("");
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const createInputRef = useRef<HTMLInputElement>(null);
const [switchingTo, setSwitchingTo] = useState<string | null>(null);
const switchPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError(null);
try {
const prefsRes = await authFetch("/api/preferences");
if (!prefsRes.ok) throw new Error("Failed to load preferences");
const prefs = await prefsRes.json();
if (!prefs.devRoot) {
setDevRoot(null);
setProjects([]);
setLoading(false);
return;
}
setDevRoot(prefs.devRoot);
const projRes = await authFetch(
`/api/projects?root=${encodeURIComponent(prefs.devRoot)}&detail=true`,
);
if (!projRes.ok) throw new Error("Failed to discover projects");
const discovered = (await projRes.json()) as ProjectMetadata[];
if (!cancelled) setProjects(discovered);
} catch (err) {
if (!cancelled)
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
return () => {
if (switchPollRef.current) clearInterval(switchPollRef.current);
};
}, []);
useEffect(() => {
if (showCreate) {
const t = setTimeout(() => createInputRef.current?.focus(), 50);
return () => clearTimeout(t);
}
}, [showCreate]);
const existingNames = projects.map((p) => p.name);
const nameValid = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(newName);
const nameConflict = existingNames.includes(newName);
const canCreate =
newName.length > 0 && nameValid && !nameConflict && !creating;
const handleSelectProject = useCallback(
(project: ProjectMetadata) => {
onBeforeSwitch?.();
setSwitchingTo(project.path);
const store = manager.switchProject(project.path);
if (switchPollRef.current) clearInterval(switchPollRef.current);
const startTime = Date.now();
switchPollRef.current = setInterval(() => {
const state = store.getSnapshot();
const elapsed = Date.now() - startTime;
if (
state.bootStatus === "ready" ||
state.bootStatus === "error" ||
elapsed > 30000
) {
if (switchPollRef.current) clearInterval(switchPollRef.current);
switchPollRef.current = null;
setSwitchingTo(null);
onFinish(project.path);
}
}, 150);
},
[manager, onFinish, onBeforeSwitch],
);
const handleCreate = useCallback(async () => {
if (!canCreate || !devRoot) return;
setCreating(true);
setCreateError(null);
try {
const res = await authFetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ devRoot, name: newName }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(
(body as { error?: string }).error ?? `Failed (${res.status})`,
);
}
const project = (await res.json()) as ProjectMetadata;
setProjects((prev) =>
[...prev, project].sort((a, b) => a.name.localeCompare(b.name)),
);
setNewName("");
setShowCreate(false);
handleSelectProject(project);
} catch (err) {
setCreateError(
err instanceof Error ? err.message : "Failed to create project",
);
setCreating(false);
}
}, [canCreate, devRoot, newName, handleSelectProject]);
const noDevRoot = !loading && !devRoot;
// Sort: active-sf first, then by name
const sortedProjects = [...projects].sort((a, b) => {
const kindOrder: Record<ProjectDetectionKind, number> = {
"active-sf": 0,
"empty-sf": 1,
brownfield: 2,
"v1-legacy": 3,
blank: 4,
};
const ka = kindOrder[a.kind] ?? 5;
const kb = kindOrder[b.kind] ?? 5;
if (ka !== kb) return ka - kb;
return a.name.localeCompare(b.name);
});
return (
<div className="flex flex-col items-center">
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="text-center"
>
<h2 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
Open a project
</h2>
<p className="mt-2 max-w-sm text-sm leading-relaxed text-muted-foreground">
{noDevRoot
? "Set a dev root first to discover your projects."
: "Pick a project to start working in, or create a new one."}
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.08, duration: 0.45 }}
className="mt-8 w-full max-w-lg space-y-2"
>
{loading && (
<div className="flex items-center justify-center gap-2 py-10 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Discovering projects
</div>
)}
{error && (
<div className="rounded-xl border border-destructive/20 bg-destructive/[0.06] px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
{noDevRoot && (
<div className="rounded-xl border border-border/50 bg-card/50 px-4 py-6 text-center text-sm text-muted-foreground">
No dev root configured. Go back and set one, or finish setup to
configure later.
</div>
)}
{/* Project cards */}
{!loading && sortedProjects.length > 0 && (
<div className="space-y-2">
{sortedProjects.map((project) => {
const isSwitching = switchingTo === project.path;
const style = KIND_STYLE[project.kind];
const KindIcon = style.icon;
const stack = techStack(project.signals);
const progress = project.progress
? progressLabel(project.progress)
: null;
const milestoneCount = project.progress
? `${project.progress.milestonesCompleted}/${project.progress.milestonesTotal}`
: null;
return (
<button
key={project.path}
type="button"
onClick={() => handleSelectProject(project)}
disabled={!!switchingTo}
className={cn(
"group flex w-full items-start gap-3.5 rounded-xl border px-4 py-3.5 text-left transition-all duration-200",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"active:scale-[0.98]",
isSwitching
? "border-foreground/30 bg-foreground/[0.06]"
: "border-border/50 bg-card/50 hover:border-foreground/15 hover:bg-card/50",
switchingTo &&
!isSwitching &&
"opacity-40 pointer-events-none",
)}
>
{/* Icon */}
<div
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg mt-0.5",
project.kind === "active-sf"
? "bg-success/10"
: "bg-foreground/[0.04]",
)}
>
{isSwitching ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<KindIcon className={cn("h-4 w-4", style.color)} />
)}
</div>
{/* Content */}
<div className="min-w-0 flex-1">
{/* Row 1: name + kind badge */}
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground truncate">
{project.name}
</span>
<span
className={cn(
"text-[10px] font-medium shrink-0",
style.color,
)}
>
{style.label}
</span>
</div>
{/* Row 2: tech stack tags */}
{stack.length > 0 && (
<div className="mt-1 flex items-center gap-1.5">
{stack.map((tag) => (
<span
key={tag}
className="rounded bg-foreground/[0.04] px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
{tag}
</span>
))}
</div>
)}
{/* Row 3: progress info (for active-sf projects) */}
{progress && (
<div className="mt-1.5 text-[11px] text-muted-foreground">
{progress}
</div>
)}
{/* Row 4: milestone bar (for active-sf with milestones) */}
{project.progress &&
project.progress.milestonesTotal > 0 && (
<div className="mt-2 flex items-center gap-2">
<div className="h-1 flex-1 overflow-hidden rounded-full bg-foreground/[0.06]">
<div
className="h-full rounded-full bg-success/60 transition-all"
style={{
width: `${Math.round((project.progress.milestonesCompleted / project.progress.milestonesTotal) * 100)}%`,
}}
/>
</div>
<span className="text-[10px] tabular-nums text-muted-foreground">
{milestoneCount}
</span>
</div>
)}
</div>
{/* Arrow */}
<ArrowRight className="mt-1 h-4 w-4 shrink-0 text-muted-foreground/50 transition-all group-hover:text-muted-foreground group-hover:translate-x-0.5" />
</button>
);
})}
</div>
)}
{!loading && devRoot && projects.length === 0 && !error && (
<div className="rounded-xl border border-border/50 bg-card/50 px-4 py-6 text-center text-sm text-muted-foreground">
No projects found in {devRoot}
</div>
)}
{/* Create new project */}
{!loading &&
devRoot &&
(!showCreate ? (
<button
type="button"
onClick={() => setShowCreate(true)}
disabled={!!switchingTo}
className={cn(
"flex w-full items-center gap-3.5 rounded-xl border border-dashed px-4 py-3.5 text-left transition-all duration-200",
"border-border/50 text-muted-foreground hover:border-foreground/15 hover:text-foreground",
"active:scale-[0.98]",
switchingTo && "opacity-40 pointer-events-none",
)}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-foreground/[0.04]">
<Plus className="h-4 w-4" />
</div>
<div>
<span className="text-sm font-medium">Create new project</span>
<p className="mt-0.5 text-[11px] text-muted-foreground">
Initialize a new directory with Git
</p>
</div>
</button>
) : (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
transition={{ duration: 0.2 }}
className="rounded-xl border border-border/50 bg-card/50 p-4 space-y-3"
>
<div className="text-sm font-medium text-foreground">
New project
</div>
<form
onSubmit={(e) => {
e.preventDefault();
void handleCreate();
}}
className="space-y-2"
>
<Input
ref={createInputRef}
value={newName}
onChange={(e) => {
setNewName(e.target.value);
setCreateError(null);
}}
placeholder="my-project"
autoComplete="off"
className="text-sm"
disabled={creating}
/>
{newName && !nameValid && (
<p className="text-xs text-destructive">
Letters, numbers, hyphens, underscores, dots. Must start
with a letter or number.
</p>
)}
{nameConflict && (
<p className="text-xs text-destructive">
A project with this name already exists
</p>
)}
{createError && (
<p className="text-xs text-destructive">{createError}</p>
)}
{newName && nameValid && !nameConflict && (
<p className="font-mono text-xs text-muted-foreground">
{devRoot}/{newName}
</p>
)}
<div className="flex items-center gap-2 pt-1">
<Button
type="submit"
size="sm"
disabled={!canCreate}
className="gap-1.5 transition-transform active:scale-[0.96]"
>
{creating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="h-3.5 w-3.5" />
)}
Create & open
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setShowCreate(false);
setNewName("");
setCreateError(null);
}}
disabled={creating}
className="text-muted-foreground"
>
Cancel
</Button>
</div>
</form>
</motion.div>
))}
</motion.div>
{/* Navigation */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.15, duration: 0.3 }}
className="mt-8 flex w-full max-w-lg items-center justify-between"
>
<Button
variant="ghost"
onClick={onBack}
className="text-muted-foreground transition-transform active:scale-[0.96]"
>
Back
</Button>
<Button
onClick={() => {
onBeforeSwitch?.();
onFinish("");
}}
className="group gap-2 transition-transform active:scale-[0.96]"
>
Finish setup
<Zap className="h-4 w-4 transition-transform group-hover:scale-110" />
</Button>
</motion.div>
</div>
);
}