singularity-forge/web/components/sf/onboarding/step-dev-root.tsx
Mikael Hugo 2d34d3a386 fix(web): resolve ESLint regressions from eslint-config-next upgrade
- Escape unescaped entities (react/no-unescaped-entities) in step-remote,
  step-welcome, projects-view, settings-panels
- Add targeted eslint-disable-next-line for react-hooks/set-state-in-effect
  on established async-fetch and prop-sync patterns in useEffect bodies:
  chat-mode, file-content-viewer, files-view, step-dev-root, projects-view,
  settings-panels, update-banner, visualizer-view, carousel, use-mobile
- Add targeted eslint-disable-next-line for react-hooks/purity on Date.now()
  display timestamps in streaming chat messages (chat-mode)
- Remove now-unused eslint-disable directives (projects-view, settings-panels)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-10 12:18:58 +02:00

389 lines
11 KiB
TypeScript

"use client";
import {
ArrowRight,
ChevronRight,
CornerLeftUp,
Folder,
FolderOpen,
FolderRoot,
Loader2,
SkipForward,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { authFetch } from "@/lib/auth";
import { cn } from "@/lib/utils";
interface StepDevRootProps {
onNext: () => void;
onBack: () => void;
}
const SUGGESTED_PATHS = ["~/Projects", "~/Developer", "~/Code", "~/dev"];
// ─── Inline folder browser ──────────────────────────────────────────
interface BrowseEntry {
name: string;
path: string;
}
function InlineFolderBrowser({
onSelect,
onCancel,
}: {
onSelect: (path: string) => void;
onCancel: () => void;
}) {
const [currentPath, setCurrentPath] = useState("");
const [parentPath, setParentPath] = useState<string | null>(null);
const [entries, setEntries] = useState<BrowseEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const browse = useCallback(async (targetPath?: string) => {
setLoading(true);
setError(null);
try {
const param = targetPath ? `?path=${encodeURIComponent(targetPath)}` : "";
const res = await authFetch(`/api/browse-directories${param}`);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error((body as { error?: string }).error ?? `${res.status}`);
}
const data = (await res.json()) as {
current: string;
parent: string | null;
entries: BrowseEntry[];
};
setCurrentPath(data.current);
setParentPath(data.parent);
setEntries(data.entries);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to browse");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch, setState runs after await
void browse();
}, [browse]);
return (
<div className="rounded-xl border border-border/50 bg-card/50 overflow-hidden">
{/* Current path */}
<div className="flex items-center justify-between gap-2 border-b border-border/50 px-4 py-2.5">
<p
className="min-w-0 truncate font-mono text-xs text-muted-foreground"
title={currentPath}
>
{currentPath}
</p>
<Button
type="button"
size="sm"
onClick={() => onSelect(currentPath)}
className="shrink-0 h-7 gap-1.5 text-xs transition-transform active:scale-[0.96]"
>
Select this folder
</Button>
</div>
{/* Directory listing */}
<ScrollArea className="h-[240px]">
<div className="px-1.5 py-1">
{loading && (
<div className="flex items-center justify-center py-10">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
{error && (
<div className="px-3 py-4 text-center text-xs text-destructive">
{error}
</div>
)}
{!loading && !error && (
<>
{parentPath && (
<button
type="button"
onClick={() => void browse(parentPath)}
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent/50"
>
<CornerLeftUp className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">..</span>
</button>
)}
{entries.map((entry) => (
<button
key={entry.path}
type="button"
onClick={() => void browse(entry.path)}
className="group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent/50"
>
<Folder className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-foreground">
{entry.name}
</span>
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/50 opacity-0 transition-opacity group-hover:opacity-100" />
</button>
))}
{entries.length === 0 && !parentPath && (
<div className="px-3 py-8 text-center text-xs text-muted-foreground">
No subdirectories
</div>
)}
</>
)}
</div>
</ScrollArea>
{/* Cancel */}
<div className="border-t border-border/50 px-4 py-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onCancel}
className="h-7 text-xs text-muted-foreground"
>
Cancel
</Button>
</div>
</div>
);
}
// ─── Main step ──────────────────────────────────────────────────────
export function StepDevRoot({ onNext, onBack }: StepDevRootProps) {
const [path, setPath] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [browsing, setBrowsing] = useState(false);
const handleSuggestionClick = useCallback((suggestion: string) => {
setPath(suggestion);
setError(null);
}, []);
const handleContinue = useCallback(async () => {
const trimmed = path.trim();
if (!trimmed) {
setError("Enter a path or skip this step");
return;
}
setSaving(true);
setError(null);
try {
const res = await authFetch("/api/preferences", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ devRoot: trimmed }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(
(body as { error?: string }).error ??
`Request failed (${res.status})`,
);
}
onNext();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to save preference",
);
} finally {
setSaving(false);
}
}, [path, onNext]);
return (
<div className="flex flex-col items-center text-center">
{/* Icon */}
<motion.div
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", duration: 0.5, bounce: 0 }}
className="mb-8"
>
<div className="flex h-14 w-14 items-center justify-center rounded-xl border border-border/50 bg-card/50">
<FolderRoot
className="h-7 w-7 text-foreground/80"
strokeWidth={1.5}
/>
</div>
</motion.div>
<motion.h2
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.06, duration: 0.4 }}
className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl"
>
Dev root
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.12, duration: 0.4 }}
className="mt-2 max-w-sm text-sm leading-relaxed text-muted-foreground"
>
The folder that contains your projects. SF discovers and manages
workspaces inside it.
</motion.p>
{/* Input + browse */}
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.18, duration: 0.45 }}
className="mt-8 w-full max-w-md space-y-4"
>
<AnimatePresence mode="wait">
{browsing ? (
<motion.div
key="browser"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
>
<InlineFolderBrowser
onSelect={(selected) => {
setPath(selected);
setBrowsing(false);
setError(null);
}}
onCancel={() => setBrowsing(false)}
/>
</motion.div>
) : (
<motion.div key="input" className="space-y-4">
<div className="flex gap-2">
<Input
value={path}
onChange={(e) => {
setPath(e.target.value);
if (error) setError(null);
}}
placeholder="/Users/you/Projects"
className={cn(
"h-11 flex-1 font-mono text-sm",
error &&
"border-destructive/50 focus-visible:ring-destructive/30",
)}
data-testid="onboarding-devroot-input"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && path.trim()) {
void handleContinue();
}
}}
/>
<Button
type="button"
variant="outline"
onClick={() => setBrowsing(true)}
className="h-11 gap-2 shrink-0 transition-transform active:scale-[0.96]"
>
<FolderOpen className="h-4 w-4" />
Browse
</Button>
</div>
{error && (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
)}
{/* Suggestions */}
<div className="flex flex-wrap items-center justify-center gap-2">
{SUGGESTED_PATHS.map((suggestion) => (
<button
key={suggestion}
type="button"
onClick={() => handleSuggestionClick(suggestion)}
className={cn(
"rounded-full border px-3 py-1 font-mono text-xs transition-all duration-150",
"active:scale-[0.96]",
path === suggestion
? "border-foreground/25 bg-foreground/10 text-foreground"
: "border-border/50 text-muted-foreground hover:border-foreground/15 hover:text-foreground",
)}
>
{suggestion}
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
{/* Navigation */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.25, duration: 0.3 }}
className="mt-8 flex w-full max-w-md items-center justify-between"
>
<Button
variant="ghost"
onClick={onBack}
className="text-muted-foreground transition-transform active:scale-[0.96]"
>
Back
</Button>
<div className="flex items-center gap-2">
<Button
variant="ghost"
onClick={onNext}
className="gap-1.5 text-muted-foreground transition-transform active:scale-[0.96]"
data-testid="onboarding-devroot-skip"
>
Skip
<SkipForward className="h-3.5 w-3.5" />
</Button>
<Button
onClick={() => void handleContinue()}
className="group gap-2 transition-transform active:scale-[0.96]"
disabled={saving || browsing}
data-testid="onboarding-devroot-continue"
>
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Saving
</>
) : (
<>
Continue
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</>
)}
</Button>
</div>
</motion.div>
</div>
);
}