- 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>
389 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|