Files
zerobyte/app/client/components/dev-panel.tsx

230 lines
6.7 KiB
TypeScript

import { useRef, useState, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { Play, Loader2, Trash2, Terminal } from "lucide-react";
import { Button } from "./ui/button";
import { cn } from "~/client/lib/utils";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "./ui/sheet";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { Label } from "./ui/label";
import { Textarea } from "./ui/textarea";
import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { devPanelExec } from "~/client/api-client/sdk.gen";
import { parseError } from "../lib/errors";
type DevPanelProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
type SseOutputEvent = {
type: "stdout" | "stderr";
line: string;
};
type SseDoneEvent = {
type: "done";
exitCode: number;
};
type SseErrorEvent = {
type: "error";
message: string;
};
type SseEvent = SseOutputEvent | SseDoneEvent | SseErrorEvent;
export function DevPanel({ open, onOpenChange }: DevPanelProps) {
const { data: repositories = [] } = useQuery({
...listRepositoriesOptions(),
enabled: open,
});
const [selectedRepoId, setSelectedRepoId] = useState<string>("");
const [commandLine, setCommandLine] = useState("snapshots");
const [output, setOutput] = useState<string[]>([]);
const [isRunning, setIsRunning] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const outputRef = useRef<HTMLDivElement>(null);
const scrollToBottom = useCallback(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
}, []);
const appendOutput = useCallback(
(line: string) => {
setOutput((prev) => {
const newOutput = [...prev, line];
setTimeout(scrollToBottom, 0);
return newOutput;
});
},
[scrollToBottom],
);
const handleRun = async () => {
if (!selectedRepoId || !commandLine.trim()) {
return;
}
setOutput([]);
setIsRunning(true);
appendOutput(`$ restic ${commandLine}`.trim());
appendOutput("---");
abortControllerRef.current = new AbortController();
const trimmedLine = commandLine.trim();
const parts = trimmedLine.split(/\s+/);
const command = parts[0];
const argsArray = parts.slice(1);
try {
const result = await devPanelExec({
path: { shortId: selectedRepoId },
body: { command, args: argsArray.length > 0 ? argsArray : undefined },
signal: abortControllerRef.current.signal,
});
for await (const event of result.stream) {
if (abortControllerRef.current.signal.aborted) {
break;
}
const data = event as unknown as SseEvent;
if (!data || typeof data !== "object") {
continue;
}
if (data.type === "stdout" || data.type === "stderr") {
appendOutput(data.line);
} else if (data.type === "done") {
appendOutput(`---`);
appendOutput(`Command finished with exit code: ${data.exitCode}`);
} else if (data.type === "error") {
appendOutput(`Error: ${data.message}`);
}
}
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
appendOutput("---");
appendOutput("Command cancelled");
} else {
appendOutput(`Error: ${parseError(err)?.message}`);
}
} finally {
setIsRunning(false);
abortControllerRef.current = null;
}
};
const handleCancel = () => {
abortControllerRef.current?.abort();
};
const handleClear = () => {
setOutput([]);
};
const handleClose = () => {
if (isRunning) {
handleCancel();
}
onOpenChange(false);
};
return (
<Sheet open={open} onOpenChange={handleClose}>
<SheetContent side="right" className="w-full sm:max-w-xl flex flex-col px-4">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<Terminal className="h-5 w-5" />
Dev Panel
</SheetTitle>
<SheetDescription>Execute restic commands against a repository</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-4 flex-1 min-h-0 mt-4">
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="repository">Repository</Label>
<Select value={selectedRepoId} onValueChange={setSelectedRepoId}>
<SelectTrigger id="repository">
<SelectValue placeholder="Select a repository" />
</SelectTrigger>
<SelectContent>
{repositories.map((repo) => (
<SelectItem key={repo.id} value={repo.shortId}>
{repo.name} ({repo.type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="command">Command</Label>
<Textarea
id="command"
placeholder="e.g., snapshots, check --dry-run, forget --keep-last 10"
value={commandLine}
onChange={(e) => setCommandLine(e.target.value)}
className="font-mono text-sm"
rows={2}
/>
</div>
<div className="flex gap-2">
<Button onClick={handleRun} disabled={isRunning || !selectedRepoId || !commandLine.trim()}>
<Loader2 className={cn("h-4 w-4 animate-spin mr-2", { hidden: !isRunning })} />
<Play className={cn("h-4 w-4 mr-2", { hidden: isRunning })} />
<span className={cn({ hidden: !isRunning })}>Running...</span>
<span className={cn({ hidden: isRunning })}>Run</span>
</Button>
<Button variant="destructive" onClick={handleCancel} className={cn({ hidden: !isRunning })}>
Cancel
</Button>
<Button variant="outline" onClick={handleClear} disabled={isRunning}>
<Trash2 className="h-4 w-4 mr-2" />
Clear
</Button>
</div>
</div>
<div className="flex-1 min-h-0 border rounded-md bg-muted/30">
<div ref={outputRef} className="h-full overflow-auto p-3 font-mono text-xs">
<div className={cn("text-muted-foreground", { hidden: output.length > 0 })}>
Output will appear here...
</div>
<div className={cn("space-y-0.5", { hidden: output.length === 0 })}>
{output.map((line, i) => {
let displayLine = line;
let isJson = false;
try {
const parsed = JSON.parse(line);
displayLine = JSON.stringify(parsed, null, 2);
isJson = true;
} catch {
// Not valid JSON, display as-is
}
return (
<pre
key={`${i}-${line.slice(0, 20)}`}
className={cn("whitespace-pre-wrap break-all text-xs", {
"wrap-break-word text-[10px] leading-tight": isJson,
})}
>
{displayLine}
</pre>
);
})}
</div>
</div>
</div>
</div>
</SheetContent>
</Sheet>
);
}