- Rename native Rust crates: gsd-engine → forge-engine, gsd-ast → forge-ast, gsd-grep → forge-grep - Update all crate dependencies (Cargo.toml, .rs source) and N-API artifacts - Mass rename log prefix [gsd] → [forge] across 81 files (scripts, src/, extensions, tests) - Rename log prefix "gsd-db:" → "forge-db:" in template literals - Update nix flake: add sf-run-native devShell with Rust toolchain for native addon builds - Update CI workflow artifact names (build-native.yml) - Verify only packages/native/* touched (no upstream pi-* packages renamed) Rationale: Complete gsd-2 → singularity-forge rebrand (2026-04-15). Native addon is sf-run-specific; all gsd-prefixed logging and crate names must align with new identity. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
470 lines
18 KiB
TypeScript
470 lines
18 KiB
TypeScript
"use client"
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react"
|
|
import { motion } from "motion/react"
|
|
import {
|
|
ArrowRight,
|
|
FolderOpen,
|
|
GitBranch,
|
|
Layers,
|
|
Loader2,
|
|
Plus,
|
|
Sparkles,
|
|
Zap,
|
|
} from "lucide-react"
|
|
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { useProjectStoreManager } from "@/lib/project-store-manager"
|
|
import { cn } from "@/lib/utils"
|
|
import { authFetch } from "@/lib/auth"
|
|
|
|
// ─── Types ──────────────────────────────────────────────────────────
|
|
|
|
type ProjectDetectionKind = "active-gsd" | "empty-gsd" | "v1-legacy" | "brownfield" | "blank"
|
|
|
|
interface ProjectDetectionSignals {
|
|
hasGsdFolder: 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-gsd": { label: "Active", color: "text-success", icon: Layers },
|
|
"empty-gsd": { 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-gsd first, then by name
|
|
const sortedProjects = [...projects].sort((a, b) => {
|
|
const kindOrder: Record<ProjectDetectionKind, number> = { "active-gsd": 0, "empty-gsd": 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-gsd" ? "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-gsd projects) */}
|
|
{progress && (
|
|
<div className="mt-1.5 text-[11px] text-muted-foreground">
|
|
{progress}
|
|
</div>
|
|
)}
|
|
|
|
{/* Row 4: milestone bar (for active-gsd 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>
|
|
)
|
|
}
|