e30e75b5d4
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
151 lines
4.5 KiB
TypeScript
151 lines
4.5 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useState } from "react";
|
|
import { ArrowLeft, Folder } from "lucide-react";
|
|
import type { FsListing } from "@/app/api/pi/fs/route";
|
|
import {
|
|
Dialog,
|
|
DialogClose,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/radix/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Spinner } from "@/components/ui/spinner";
|
|
|
|
/** Server-backed directory browser. Click a folder to navigate in; the footer
|
|
* commits whatever directory is currently in view. */
|
|
export function WorkspaceBrowser({
|
|
value,
|
|
onCommit,
|
|
}: {
|
|
value: string;
|
|
onCommit: (path: string) => void;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const [listing, setListing] = useState<FsListing | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
// Monotonic request token: rapid navigation fires overlapping fetches, and a
|
|
// slow earlier response must not overwrite the directory navigated to last.
|
|
const requestSeq = useRef(0);
|
|
|
|
const load = (path?: string) => {
|
|
const seq = ++requestSeq.current;
|
|
setLoading(true);
|
|
setError(null);
|
|
const qs = path ? `?path=${encodeURIComponent(path)}` : "";
|
|
fetch(`/api/pi/fs${qs}`)
|
|
.then(async (response) => {
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to list directory (HTTP ${response.status})`);
|
|
}
|
|
return (await response.json()) as FsListing;
|
|
})
|
|
.then((data) => {
|
|
if (seq === requestSeq.current) setListing(data);
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (seq === requestSeq.current) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (seq === requestSeq.current) setLoading(false);
|
|
});
|
|
};
|
|
|
|
const currentName = value.split("/").filter(Boolean).at(-1);
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(next) => {
|
|
setOpen(next);
|
|
if (next) load(value || undefined);
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{currentName && (
|
|
<span
|
|
className="text-muted-foreground max-w-72 truncate font-mono text-xs"
|
|
title={value}
|
|
>
|
|
…/{currentName}
|
|
</span>
|
|
)}
|
|
<DialogTrigger asChild>
|
|
<Button variant="outline" size="sm">
|
|
Browse…
|
|
</Button>
|
|
</DialogTrigger>
|
|
</div>
|
|
|
|
<DialogContent className="sm:max-w-lg">
|
|
<DialogHeader className="min-w-0">
|
|
<DialogTitle>Select workspace</DialogTitle>
|
|
<DialogDescription className="truncate font-mono text-xs">
|
|
{listing?.path ?? "…"}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="xs"
|
|
disabled={!listing?.parent || loading}
|
|
onClick={() => listing?.parent && load(listing.parent)}
|
|
>
|
|
<ArrowLeft /> Back
|
|
</Button>
|
|
{loading && <Spinner className="size-4" />}
|
|
</div>
|
|
|
|
<ScrollArea className="h-72 min-w-0 rounded-md border [&_[data-radix-scroll-area-viewport]>div]:!block">
|
|
{error && (
|
|
<p className="text-destructive px-3 py-2 text-xs">{error}</p>
|
|
)}
|
|
{listing?.entries.map((entry) => (
|
|
<button
|
|
key={entry.path}
|
|
type="button"
|
|
onClick={() => load(entry.path)}
|
|
className="hover:bg-accent flex w-full min-w-0 items-center gap-2 px-3 py-1.5 text-left font-mono text-xs"
|
|
>
|
|
<Folder className="size-3.5 shrink-0" />
|
|
<span className="truncate">{entry.name}</span>
|
|
</button>
|
|
))}
|
|
{listing && listing.entries.length === 0 && (
|
|
<p className="text-muted-foreground px-3 py-2 text-xs">
|
|
No subfolders
|
|
</p>
|
|
)}
|
|
</ScrollArea>
|
|
|
|
<DialogFooter>
|
|
<DialogClose asChild>
|
|
<Button variant="ghost" size="sm">
|
|
Cancel
|
|
</Button>
|
|
</DialogClose>
|
|
<Button
|
|
size="sm"
|
|
disabled={!listing}
|
|
onClick={() => {
|
|
if (listing) onCommit(listing.path);
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
Select this folder
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|