dashboard (#1034)
* dashboard * fix: re-apply download behavior on recording context (#1019) * fix: re-apply download behavior on recording context record start creates a new browser context via Target.createBrowserContext. Browser.setDownloadBehavior called at launch only applies to the default context, so downloads in the recording context are silently dropped. Fix: 1. Store download_path on BrowserManager (from LaunchOptions) 2. After creating the recording context, call Browser.setDownloadBehavior with the new browserContextId This ensures downloads work during recording. Fixes #1018 * fix: add download_path to third BrowserManager constructor (auto_connect_cdp) * fix: reap zombie Chrome process and fast-detect crash for auto-restart (#1023) When Chrome crashes (e.g. SIGTRAP from CHECK() assertion), the daemon now: 1. Reaps the zombie immediately via a SIGCHLD handler in the event loop that calls waitpid(-1, WNOHANG) 2. Detects the crash instantly on the next command via a non-blocking try_wait() check (has_process_exited), avoiding the 3-second CDP timeout that is_connection_alive() would incur 3. Auto-relaunches Chrome transparently for the caller Fixes #1017 Co-authored-by: ctate <366502+ctate@users.noreply.github.com> * fix: route keyboard type through text input (#1014) * fix: handle --clear flag in console command (#1015) The console and errors commands parsed --clear from CLI args but the action handlers silently ignored the flag. The handlers did not accept the cmd parameter so they had no way to read the clear field. Changes: - Add clear_console() method to EventTracker in network.rs - Update handle_console to accept cmd, read the clear field, and clear the buffer when --clear is passed (returns {cleared: true}) - Update call site in execute_command to pass cmd Co-authored-by: xuyongliang <yongliang.xyl@alibaba-inc.com> * chore: patch release - ### Bug Fixes - **Re-apply download behavior on r... (#1025) * Add runtime stream enable/disable/status commands (#951) * Add runtime stream management commands * Run rustfmt and satisfy clippy * Fix stream disable cleanup semantics * Format stream disable regression tests * fix: retain radio/checkbox elements in compact snapshot tree (#1008) compact_tree() checked for "[ref=" to identify lines worth keeping, but radio and checkbox elements render as e.g. [checked=false, ref=e1] where the "[" opens before "checked=", not "ref=". Dropping the leading bracket so the check is just "ref=" fixes the match for all elements with refs. Fixes #1006 Co-authored-by: ctate <366502+ctate@users.noreply.github.com> * chore: version packages (#1027) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * fixes * dashboard * fixes * remove observe * fmt * fixes * fixes * jotai * fmt * upload dashboard --------- Co-authored-by: Stefan Smiljkovic <stefan@vanila.io> Co-authored-by: ctate <366502+ctate@users.noreply.github.com> Co-authored-by: zhanba <c5e1856@gmail.com> Co-authored-by: xuyongliang <478439790@qq.com> Co-authored-by: xuyongliang <yongliang.xyl@alibaba-inc.com> Co-authored-by: Thomas Kosiewski <thoma471@googlemail.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
ctate
github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Stefan Smiljkovic
zhanba
xuyongliang
xuyongliang
Thomas Kosiewski
parent
63f03b8e06
commit
f9174513c2
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const config: NextConfig = {
|
||||
output: "export",
|
||||
images: { unoptimized: true },
|
||||
devIndicators: false,
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "dashboard",
|
||||
"version": "0.22.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jotai": "^2.19.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"next": "16.1.1",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-resizable-panels": "^4.7.6",
|
||||
"shadcn": "^4.1.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.3",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"tailwindcss": "^4.1.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.3 KiB |
@@ -0,0 +1,188 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--success: #22c55e;
|
||||
--warning: #eab308;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #e5e5e5;
|
||||
--card: #141414;
|
||||
--card-foreground: #e5e5e5;
|
||||
--popover: #141414;
|
||||
--popover-foreground: #e5e5e5;
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: #1a1a1a;
|
||||
--secondary-foreground: #e5e5e5;
|
||||
--muted: #1a1a1a;
|
||||
--muted-foreground: #737373;
|
||||
--accent: #1a1a1a;
|
||||
--accent-foreground: #e5e5e5;
|
||||
--destructive: #ef4444;
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: #141414;
|
||||
--sidebar-foreground: #e5e5e5;
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: #1a1a1a;
|
||||
--sidebar-accent-foreground: #e5e5e5;
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--success: #22c55e;
|
||||
--warning: #eab308;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--color-success: var(--success);
|
||||
--color-warning: var(--warning);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.json-key {
|
||||
color: #d6409f;
|
||||
}
|
||||
.json-string {
|
||||
color: #067a6e;
|
||||
}
|
||||
.json-number,
|
||||
.json-bool,
|
||||
.json-null {
|
||||
color: #0070c0;
|
||||
}
|
||||
.json-punct {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
:is(.dark *).json-key {
|
||||
color: #ff4d8d;
|
||||
}
|
||||
:is(.dark *).json-string {
|
||||
color: #00ca50;
|
||||
}
|
||||
:is(.dark *).json-number,
|
||||
:is(.dark *).json-bool,
|
||||
:is(.dark *).json-null {
|
||||
color: #47a8ff;
|
||||
}
|
||||
:is(.dark *).json-punct {
|
||||
color: #a1a1a1;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { Geist } from "next/font/google";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { JotaiProvider } from "@/store/provider";
|
||||
|
||||
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "agent-browser",
|
||||
description: "Observability dashboard for agent-browser",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className={cn("dark font-sans antialiased", geist.variable)}>
|
||||
<body>
|
||||
<JotaiProvider>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</JotaiProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai/react";
|
||||
import { activePortAtom } from "@/store/sessions";
|
||||
import { useSessionsSync } from "@/store/sessions";
|
||||
import { useStreamSync, hasConsoleErrorsAtom, consoleLogsAtom } from "@/store/stream";
|
||||
import { useActivitySync } from "@/store/activity";
|
||||
import { activeExtensionsAtom } from "@/store/sessions";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { Viewport } from "@/components/viewport";
|
||||
import { ActivityFeed } from "@/components/activity-feed";
|
||||
import { ConsolePanel } from "@/components/console-panel";
|
||||
import { StoragePanel } from "@/components/storage-panel";
|
||||
import { ExtensionsPanel } from "@/components/extensions-panel";
|
||||
import { NetworkPanel } from "@/components/network-panel";
|
||||
import { SessionTree } from "@/components/session-tree";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const activePort = useAtomValue(activePortAtom);
|
||||
useStreamSync(activePort);
|
||||
useSessionsSync();
|
||||
useActivitySync();
|
||||
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
const hasConsoleErrors = useAtomValue(hasConsoleErrorsAtom);
|
||||
const activeExtensions = useAtomValue(activeExtensionsAtom);
|
||||
|
||||
const sidePanel = (
|
||||
<Tabs defaultValue="activity" className="flex h-full flex-col">
|
||||
<div className="shrink-0 px-2 pt-1">
|
||||
<TabsList variant="line" className="h-7 w-full">
|
||||
<TabsTrigger value="activity" className="text-[11px]">Activity</TabsTrigger>
|
||||
<TabsTrigger value="console" className="text-[11px]">
|
||||
Console
|
||||
{hasConsoleErrors && (
|
||||
<span className="ml-1 inline-flex size-1.5 rounded-full bg-destructive" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="network" className="text-[11px]">Network</TabsTrigger>
|
||||
<TabsTrigger value="storage" className="text-[11px]">Storage</TabsTrigger>
|
||||
<TabsTrigger value="extensions" className="text-[11px]">
|
||||
Extensions
|
||||
{activeExtensions.length > 0 && (
|
||||
<span className="ml-1 text-[9px] tabular-nums text-muted-foreground">{activeExtensions.length}</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="activity" className="min-h-0 flex-1 overflow-hidden">
|
||||
<ActivityFeed />
|
||||
</TabsContent>
|
||||
<TabsContent value="console" className="min-h-0 flex-1 overflow-hidden">
|
||||
<ConsolePanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="network" className="min-h-0 flex-1 overflow-hidden">
|
||||
<NetworkPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="storage" className="min-h-0 flex-1 overflow-hidden">
|
||||
<StoragePanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="extensions" className="min-h-0 flex-1 overflow-hidden">
|
||||
<ExtensionsPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background">
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
className="min-h-0 flex-1"
|
||||
>
|
||||
<ResizablePanel id="sessions" defaultSize="15%" minSize="10%" maxSize="30%">
|
||||
<SessionTree />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel id="viewport" defaultSize="55%" minSize="30%">
|
||||
<Viewport />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel id="activity" defaultSize="30%" minSize="15%" maxSize="50%">
|
||||
{sidePanel}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background">
|
||||
<Tabs defaultValue="viewport" className="min-h-0 flex-1">
|
||||
<div className="shrink-0 px-2 pt-2">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="sessions">Sessions</TabsTrigger>
|
||||
<TabsTrigger value="viewport">Viewport</TabsTrigger>
|
||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="sessions" className="min-h-0 overflow-hidden">
|
||||
<SessionTree />
|
||||
</TabsContent>
|
||||
<TabsContent value="viewport" className="min-h-0 overflow-hidden">
|
||||
<Viewport />
|
||||
</TabsContent>
|
||||
<TabsContent value="activity" className="min-h-0 overflow-hidden">
|
||||
{sidePanel}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAtomValue, useSetAtom } from "jotai/react";
|
||||
import type { ActivityEvent } from "@/types";
|
||||
import { combinedEventsAtom, persistActivityAtom, togglePersistAtom, clearActivityAtom } from "@/store/activity";
|
||||
import { Bookmark, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { JsonSyntax } from "@/components/json-syntax";
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function highlightRefs(text: string): React.ReactNode {
|
||||
const parts = text.split(/(@e\d+)/g);
|
||||
return parts.map((part, i) =>
|
||||
part.match(/^@e\d+$/) ? (
|
||||
<span key={i} className="font-mono font-semibold text-accent-foreground">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
part
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function CommandEntry({
|
||||
event,
|
||||
}: {
|
||||
event: ActivityEvent & { type: "command" };
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const label = event.action;
|
||||
const hasParams =
|
||||
event.params &&
|
||||
Object.keys(event.params).filter((k) => k !== "action" && k !== "id")
|
||||
.length > 0;
|
||||
|
||||
return (
|
||||
<Collapsible open={expanded} onOpenChange={setExpanded}>
|
||||
<div className="py-1.5 px-3">
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-2 text-left text-xs">
|
||||
<span className="shrink-0 font-mono text-muted-foreground">
|
||||
{formatTime(event.timestamp)}
|
||||
</span>
|
||||
<span className="truncate font-mono font-semibold">
|
||||
{highlightRefs(label)}
|
||||
</span>
|
||||
{hasParams && (
|
||||
<span className="ml-auto shrink-0 text-muted-foreground">
|
||||
{expanded ? "-" : "+"}
|
||||
</span>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{hasParams && (
|
||||
<pre className="mt-1 max-h-32 overflow-x-auto overflow-y-auto text-[10px]">
|
||||
<JsonSyntax
|
||||
value={Object.fromEntries(
|
||||
Object.entries(event.params).filter(
|
||||
([k]) => k !== "action" && k !== "id",
|
||||
),
|
||||
)}
|
||||
/>
|
||||
</pre>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
<Separator />
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultEntry({
|
||||
event,
|
||||
}: {
|
||||
event: ActivityEvent & { type: "result" };
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<Collapsible open={expanded} onOpenChange={setExpanded}>
|
||||
<div className="py-1.5 px-3">
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-2 text-left text-xs">
|
||||
<span className="shrink-0 font-mono text-muted-foreground">
|
||||
{formatTime(event.timestamp)}
|
||||
</span>
|
||||
<span className="truncate font-mono">
|
||||
{event.action}
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
{event.duration_ms}ms
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 text-muted-foreground">
|
||||
{expanded ? "-" : "+"}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{event.data != null && (
|
||||
<pre className="mt-1 max-h-48 overflow-x-auto overflow-y-auto text-[10px]">
|
||||
{typeof event.data === "string"
|
||||
? event.data
|
||||
: <JsonSyntax value={event.data} />}
|
||||
</pre>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
<Separator />
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
const LEVEL_STYLES: Record<string, string> = {
|
||||
error: "text-destructive",
|
||||
warn: "text-warning",
|
||||
warning: "text-warning",
|
||||
info: "text-accent-foreground",
|
||||
log: "text-muted-foreground",
|
||||
};
|
||||
|
||||
function ConsoleEntry({
|
||||
event,
|
||||
}: {
|
||||
event: ActivityEvent & { type: "console" };
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start gap-2 py-1.5 px-3 text-xs">
|
||||
<span className="shrink-0 font-mono text-muted-foreground">
|
||||
{formatTime(event.timestamp)}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-4 px-1 text-[10px]",
|
||||
LEVEL_STYLES[event.level] ?? "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{event.level}
|
||||
</Badge>
|
||||
<span className="truncate font-mono">{event.text}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActivityFeed() {
|
||||
const events = useAtomValue(combinedEventsAtom);
|
||||
const persist = useAtomValue(persistActivityAtom);
|
||||
const togglePersist = useSetAtom(togglePersistAtom);
|
||||
const clearActivity = useSetAtom(clearActivityAtom);
|
||||
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScrollRef.current) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [events.length]);
|
||||
|
||||
const handleScroll = () => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
autoScrollRef.current = atBottom;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex shrink-0 items-center gap-2 px-3 py-2">
|
||||
<span className="text-xs text-muted-foreground">Activity</span>
|
||||
<Badge variant="secondary" className="ml-auto h-4 px-1.5 text-[10px]">
|
||||
{events.length}
|
||||
</Badge>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => togglePersist()}
|
||||
className={cn(
|
||||
"flex h-5 items-center gap-1 rounded border px-1.5 text-[10px] transition-colors",
|
||||
persist
|
||||
? "border-accent-foreground/30 bg-accent text-accent-foreground"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title={persist ? "Activity is persisted across reloads. Click to disable." : "Persist activity across reloads"}
|
||||
>
|
||||
<Bookmark className="size-3" />
|
||||
{persist ? "On" : "Off"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => clearActivity()}
|
||||
className="flex h-5 items-center gap-1 rounded border border-border px-1.5 text-[10px] text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Clear activity"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
{events.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
Waiting for events...
|
||||
</div>
|
||||
) : (
|
||||
events.map((event, i) => {
|
||||
const key = event.type === "console"
|
||||
? `console-${event.timestamp}-${i}`
|
||||
: `${event.type}-${event.id}`;
|
||||
switch (event.type) {
|
||||
case "command":
|
||||
return <CommandEntry key={key} event={event} />;
|
||||
case "result":
|
||||
return <ResultEntry key={key} event={event} />;
|
||||
case "console":
|
||||
return <ConsoleEntry key={key} event={event} />;
|
||||
}
|
||||
})
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useAtomValue, useSetAtom } from "jotai/react";
|
||||
import type { ConsoleEntry } from "@/types";
|
||||
import { consoleLogsAtom, clearConsoleLogsAtom } from "@/store/stream";
|
||||
import { activeSessionNameAtom } from "@/store/sessions";
|
||||
import { execCommand, sessionArgs } from "@/lib/exec";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { CornerDownLeft, Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
type FilterLevel = "all" | "errors" | "warnings" | "info" | "log";
|
||||
|
||||
const FILTER_MATCH: Record<FilterLevel, (e: ConsoleEntry) => boolean> = {
|
||||
all: () => true,
|
||||
errors: (e) => e.type === "page_error" || (e.type === "console" && e.level === "error"),
|
||||
warnings: (e) => e.type === "console" && (e.level === "warn" || e.level === "warning"),
|
||||
info: (e) => e.type === "console" && e.level === "info",
|
||||
log: (e) => e.type === "console" && (e.level === "log" || e.level === "debug"),
|
||||
};
|
||||
|
||||
const LEVEL_COLORS: Record<string, string> = {
|
||||
error: "text-destructive",
|
||||
page_error: "text-destructive",
|
||||
warn: "text-warning",
|
||||
warning: "text-warning",
|
||||
info: "text-blue-400",
|
||||
log: "text-muted-foreground",
|
||||
debug: "text-muted-foreground/60",
|
||||
};
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function entryLevel(e: ConsoleEntry): string {
|
||||
return e.type === "page_error" ? "error" : e.level;
|
||||
}
|
||||
|
||||
function entryText(e: ConsoleEntry): string {
|
||||
if (e.type === "page_error") {
|
||||
let text = e.text;
|
||||
if (e.line != null) {
|
||||
text += ` (${e.line}`;
|
||||
if (e.column != null) text += `:${e.column}`;
|
||||
text += ")";
|
||||
}
|
||||
return text;
|
||||
}
|
||||
return e.text;
|
||||
}
|
||||
|
||||
interface EvalEntry {
|
||||
id: number;
|
||||
expression: string;
|
||||
result?: string;
|
||||
error?: string;
|
||||
pending: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
let evalIdCounter = 0;
|
||||
|
||||
export function ConsolePanel() {
|
||||
const entries = useAtomValue(consoleLogsAtom);
|
||||
const clearConsoleLogs = useSetAtom(clearConsoleLogsAtom);
|
||||
const sessionName = useAtomValue(activeSessionNameAtom);
|
||||
|
||||
const [filter, setFilter] = useState<FilterLevel>("all");
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
const [evalInput, setEvalInput] = useState("");
|
||||
const [evalEntries, setEvalEntries] = useState<EvalEntry[]>([]);
|
||||
const [evaluating, setEvaluating] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const filtered = entries.filter(FILTER_MATCH[filter]);
|
||||
|
||||
const errorCount = entries.filter(FILTER_MATCH.errors).length;
|
||||
const warnCount = entries.filter(FILTER_MATCH.warnings).length;
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScrollRef.current) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [filtered.length, evalEntries.length]);
|
||||
|
||||
const handleScroll = () => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
autoScrollRef.current = atBottom;
|
||||
};
|
||||
|
||||
const handleEval = useCallback(async () => {
|
||||
const expr = evalInput.trim();
|
||||
if (!expr || !sessionName || evaluating) return;
|
||||
|
||||
const id = ++evalIdCounter;
|
||||
const entry: EvalEntry = { id, expression: expr, pending: true, timestamp: Date.now() };
|
||||
setEvalEntries((prev) => [...prev, entry]);
|
||||
setEvalInput("");
|
||||
setEvaluating(true);
|
||||
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await execCommand(sessionArgs(sessionName, "eval", expr));
|
||||
setEvalEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
e.id === id
|
||||
? { ...e, pending: false, result: res.stdout.trim(), error: res.stderr.trim() || undefined }
|
||||
: e,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
setEvalEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
e.id === id ? { ...e, pending: false, error: "Failed to execute" } : e,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setEvaluating(false);
|
||||
}
|
||||
}, [evalInput, sessionName, evaluating]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleEval();
|
||||
}
|
||||
},
|
||||
[handleEval],
|
||||
);
|
||||
|
||||
const filters: { key: FilterLevel; label: string; count?: number }[] = [
|
||||
{ key: "all", label: "All" },
|
||||
{ key: "errors", label: "Errors", count: errorCount },
|
||||
{ key: "warnings", label: "Warnings", count: warnCount },
|
||||
{ key: "info", label: "Info" },
|
||||
{ key: "log", label: "Log" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex shrink-0 items-center gap-1.5 px-3 py-2">
|
||||
{filters.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
onClick={() => setFilter(f.key)}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] transition-colors",
|
||||
filter === f.key
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
{f.count != null && f.count > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"h-3.5 min-w-4 px-1 text-[9px] tabular-nums",
|
||||
f.key === "errors" && "bg-destructive/20 text-destructive",
|
||||
f.key === "warnings" && "bg-warning/20 text-warning",
|
||||
)}
|
||||
>
|
||||
{f.count}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => clearConsoleLogs()}
|
||||
className="ml-auto flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Clear console"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
className="min-h-0 flex-1 overflow-y-auto font-mono"
|
||||
>
|
||||
{filtered.length === 0 && evalEntries.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
No console output
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filtered.map((entry, i) => {
|
||||
const level = entryLevel(entry);
|
||||
const color = LEVEL_COLORS[level] ?? "text-muted-foreground";
|
||||
return (
|
||||
<div
|
||||
key={`c-${i}`}
|
||||
className={cn(
|
||||
"flex items-start gap-2 border-b border-border/50 px-3 py-1 text-[11px]",
|
||||
level === "error" || entry.type === "page_error"
|
||||
? "bg-destructive/5"
|
||||
: level === "warn" || level === "warning"
|
||||
? "bg-warning/5"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground/60">
|
||||
{formatTime(entry.timestamp)}
|
||||
</span>
|
||||
<span className={cn("shrink-0 w-10 uppercase", color)}>
|
||||
{entry.type === "page_error" ? "error" : entry.level}
|
||||
</span>
|
||||
<span className={cn("min-w-0 flex-1 break-all whitespace-pre-wrap", color)}>
|
||||
{entryText(entry)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{evalEntries.map((entry) => (
|
||||
<div key={`e-${entry.id}`} className="border-b border-border/50 text-[11px]">
|
||||
<div className="flex items-start gap-2 bg-muted/30 px-3 py-1">
|
||||
<span className="shrink-0 text-muted-foreground/60">
|
||||
{formatTime(entry.timestamp)}
|
||||
</span>
|
||||
<span className="shrink-0 w-10 text-violet-400">></span>
|
||||
<span className="min-w-0 flex-1 whitespace-pre-wrap break-all text-violet-400">
|
||||
{entry.expression}
|
||||
</span>
|
||||
</div>
|
||||
{entry.pending ? (
|
||||
<div className="flex items-center gap-2 px-3 py-1">
|
||||
<Loader2 className="size-3 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : entry.error ? (
|
||||
<div className="bg-destructive/5 px-3 py-1 pl-[76px]">
|
||||
<span className="whitespace-pre-wrap break-all text-destructive">
|
||||
{entry.error}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{entry.result && (
|
||||
<div className="px-3 py-1 pl-[76px]">
|
||||
<span className="whitespace-pre-wrap break-all text-emerald-400">
|
||||
{entry.result}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<div className="shrink-0 flex items-end gap-1.5 px-3 py-2">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={evalInput}
|
||||
onChange={(e) => {
|
||||
setEvalInput(e.target.value);
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height = `${Math.min(e.target.scrollHeight, 120)}px`;
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={sessionName ? "Evaluate JavaScript..." : "No active session"}
|
||||
disabled={!sessionName}
|
||||
rows={1}
|
||||
className={cn(
|
||||
"min-h-[28px] max-h-[120px] flex-1 resize-none rounded border border-border bg-background px-2 py-1.5 font-mono text-[11px] text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-ring",
|
||||
!sessionName && "opacity-50",
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEval}
|
||||
disabled={!sessionName || !evalInput.trim() || evaluating}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:pointer-events-none"
|
||||
title="Run (Enter)"
|
||||
>
|
||||
{evaluating ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<CornerDownLeft className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai/react";
|
||||
import { activeExtensionsAtom, activeSessionNameAtom } from "@/store/sessions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Puzzle } from "lucide-react";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useState } from "react";
|
||||
|
||||
export function ExtensionsPanel() {
|
||||
const extensions = useAtomValue(activeExtensionsAtom);
|
||||
const sessionName = useAtomValue(activeSessionNameAtom);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
if (!sessionName) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<Header count={0} />
|
||||
<Separator />
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
No active session
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<Header count={extensions.length} />
|
||||
<Separator />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{extensions.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
No extensions loaded
|
||||
</div>
|
||||
) : (
|
||||
extensions.map((ext) => {
|
||||
const isExpanded = expanded === ext.path;
|
||||
return (
|
||||
<div key={ext.path} className="border-b border-border/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(isExpanded ? null : ext.path)}
|
||||
className="flex w-full items-start gap-2.5 px-3 py-2 text-left text-xs hover:bg-muted/50"
|
||||
>
|
||||
<Puzzle className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-foreground">
|
||||
{ext.name}
|
||||
</span>
|
||||
{ext.version && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 px-1.5 text-[10px] tabular-nums"
|
||||
>
|
||||
v{ext.version}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{ext.description && !isExpanded && (
|
||||
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">
|
||||
{ext.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="space-y-1 bg-muted/30 px-3 py-2 text-[11px]">
|
||||
{ext.description && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Description: </span>
|
||||
<span className="text-foreground">{ext.description}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-muted-foreground">Path: </span>
|
||||
<span className={cn("break-all font-mono text-foreground")}>
|
||||
{ext.path}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({ count }: { count: number }) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1.5 px-3 py-2">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Chrome Extensions
|
||||
</span>
|
||||
{count > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-auto h-4 px-1.5 text-[10px] tabular-nums"
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
function renderValue(value: JsonValue, indent: number): ReactNode {
|
||||
if (value === null) {
|
||||
return <span className="json-null">null</span>;
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return <span className="json-bool">{String(value)}</span>;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return <span className="json-number">{String(value)}</span>;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return <span className="json-string">"{value}"</span>;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return <span className="json-punct">[]</span>;
|
||||
const pad = " ".repeat(indent + 1);
|
||||
const closePad = " ".repeat(indent);
|
||||
return (
|
||||
<>
|
||||
<span className="json-punct">[</span>
|
||||
{"\n"}
|
||||
{value.map((item, i) => (
|
||||
<span key={i}>
|
||||
{pad}
|
||||
{renderValue(item, indent + 1)}
|
||||
{i < value.length - 1 && <span className="json-punct">,</span>}
|
||||
{"\n"}
|
||||
</span>
|
||||
))}
|
||||
{closePad}
|
||||
<span className="json-punct">]</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 0) return <span className="json-punct">{"{}"}</span>;
|
||||
const pad = " ".repeat(indent + 1);
|
||||
const closePad = " ".repeat(indent);
|
||||
return (
|
||||
<>
|
||||
<span className="json-punct">{"{"}</span>
|
||||
{"\n"}
|
||||
{entries.map(([k, v], i) => (
|
||||
<span key={k}>
|
||||
{pad}
|
||||
<span className="json-key">"{k}"</span>
|
||||
<span className="json-punct">: </span>
|
||||
{renderValue(v, indent + 1)}
|
||||
{i < entries.length - 1 && <span className="json-punct">,</span>}
|
||||
{"\n"}
|
||||
</span>
|
||||
))}
|
||||
{closePad}
|
||||
<span className="json-punct">{"}"}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function JsonSyntax({ value }: { value: unknown }) {
|
||||
return <code>{renderValue(value as JsonValue, 0)}</code>;
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useAtomValue } from "jotai/react";
|
||||
import { activeSessionNameAtom } from "@/store/sessions";
|
||||
import { execCommand, sessionArgs } from "@/lib/exec";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Circle, Loader2, RefreshCw, Square, Trash2 } from "lucide-react";
|
||||
|
||||
interface NetworkRequest {
|
||||
url: string;
|
||||
method: string;
|
||||
status?: number;
|
||||
resourceType: string;
|
||||
requestId: string;
|
||||
mimeType?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
type TypeFilter = "all" | "xhr" | "doc" | "css" | "js" | "img" | "font" | "other";
|
||||
|
||||
const TYPE_FILTERS: { key: TypeFilter; label: string; cliType?: string }[] = [
|
||||
{ key: "all", label: "All" },
|
||||
{ key: "xhr", label: "XHR", cliType: "xhr,fetch" },
|
||||
{ key: "doc", label: "Doc", cliType: "document" },
|
||||
{ key: "css", label: "CSS", cliType: "stylesheet" },
|
||||
{ key: "js", label: "JS", cliType: "script" },
|
||||
{ key: "img", label: "Img", cliType: "image" },
|
||||
{ key: "font", label: "Font", cliType: "font" },
|
||||
{ key: "other", label: "Other", cliType: "other,websocket,media,manifest,texttrack,eventsource,signedexchange,ping,cspviolationreport,preflight" },
|
||||
];
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
"2": "text-emerald-500",
|
||||
"3": "text-blue-400",
|
||||
"4": "text-warning",
|
||||
"5": "text-destructive",
|
||||
};
|
||||
|
||||
function statusColor(status?: number): string {
|
||||
if (status == null) return "text-muted-foreground";
|
||||
const prefix = String(status)[0];
|
||||
return STATUS_COLOR[prefix] ?? "text-muted-foreground";
|
||||
}
|
||||
|
||||
function truncateUrl(url: string, max: number): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const path = u.pathname + u.search;
|
||||
return path.length > max ? path.slice(0, max) + "..." : path;
|
||||
} catch {
|
||||
return url.length > max ? url.slice(0, max) + "..." : url;
|
||||
}
|
||||
}
|
||||
|
||||
function urlHost(url: string): string {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function NetworkPanel() {
|
||||
const sessionName = useAtomValue(activeSessionNameAtom);
|
||||
|
||||
const [requests, setRequests] = useState<NetworkRequest[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [harRecording, setHarRecording] = useState(false);
|
||||
const [harDialogOpen, setHarDialogOpen] = useState(false);
|
||||
const [harPath, setHarPath] = useState("capture.har");
|
||||
const harInputRef = useRef<HTMLInputElement>(null);
|
||||
const lastSessionRef = useRef(sessionName);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const doFetch = useCallback(async (showSpinner: boolean) => {
|
||||
if (!sessionName) return;
|
||||
if (showSpinner) setLoading(true);
|
||||
try {
|
||||
const args = sessionArgs(sessionName, "network", "requests");
|
||||
if (typeFilter !== "all") {
|
||||
const filter = TYPE_FILTERS.find((f) => f.key === typeFilter);
|
||||
if (filter?.cliType) {
|
||||
args.push("--type", filter.cliType);
|
||||
}
|
||||
}
|
||||
const res = await execCommand(args);
|
||||
if (res.success && res.stdout) {
|
||||
try {
|
||||
const parsed = JSON.parse(res.stdout);
|
||||
const data = parsed.data ?? parsed;
|
||||
setRequests(data.requests ?? []);
|
||||
} catch {
|
||||
setRequests([]);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (showSpinner) setLoading(false);
|
||||
}
|
||||
}, [sessionName, typeFilter]);
|
||||
|
||||
const fetchRequests = useCallback(() => doFetch(true), [doFetch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionName && sessionName !== lastSessionRef.current) {
|
||||
lastSessionRef.current = sessionName;
|
||||
setRequests([]);
|
||||
setExpanded(null);
|
||||
setDetail(null);
|
||||
setHarRecording(false);
|
||||
}
|
||||
doFetch(true);
|
||||
}, [sessionName, typeFilter, doFetch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionName) return;
|
||||
pollRef.current = setInterval(() => {
|
||||
if (document.visibilityState === "visible") doFetch(false);
|
||||
}, 5000);
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
};
|
||||
}, [sessionName, doFetch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (harDialogOpen) {
|
||||
requestAnimationFrame(() => harInputRef.current?.select());
|
||||
}
|
||||
}, [harDialogOpen]);
|
||||
|
||||
const handleClear = useCallback(async () => {
|
||||
if (!sessionName) return;
|
||||
await execCommand(sessionArgs(sessionName, "network", "requests", "--clear"));
|
||||
setRequests([]);
|
||||
setExpanded(null);
|
||||
setDetail(null);
|
||||
}, [sessionName]);
|
||||
|
||||
const handleExpand = useCallback(async (requestId: string) => {
|
||||
if (expanded === requestId) {
|
||||
setExpanded(null);
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
setExpanded(requestId);
|
||||
setDetail(null);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const res = await execCommand(sessionArgs(sessionName, "network", "request", requestId));
|
||||
if (res.success && res.stdout) {
|
||||
try {
|
||||
const parsed = JSON.parse(res.stdout);
|
||||
setDetail(parsed.data ?? parsed);
|
||||
} catch {
|
||||
setDetail(null);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}, [expanded, sessionName]);
|
||||
|
||||
const handleHarStart = useCallback(async () => {
|
||||
if (!sessionName) return;
|
||||
await execCommand(sessionArgs(sessionName, "network", "har", "start"));
|
||||
setHarRecording(true);
|
||||
}, [sessionName]);
|
||||
|
||||
const handleHarStop = useCallback(async () => {
|
||||
if (!sessionName) return;
|
||||
const path = harPath.trim() || "capture.har";
|
||||
setHarDialogOpen(false);
|
||||
await execCommand(sessionArgs(sessionName, "network", "har", "stop", path));
|
||||
setHarRecording(false);
|
||||
}, [sessionName, harPath]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex shrink-0 items-center gap-1.5 px-3 py-2">
|
||||
{TYPE_FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTypeFilter(f.key);
|
||||
setExpanded(null);
|
||||
setDetail(null);
|
||||
}}
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 text-[10px] transition-colors",
|
||||
typeFilter === f.key
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={harRecording ? () => setHarDialogOpen(true) : handleHarStart}
|
||||
disabled={!sessionName}
|
||||
className={cn(
|
||||
"flex size-5 items-center justify-center rounded transition-colors disabled:opacity-40",
|
||||
harRecording
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title={harRecording ? "Stop HAR recording" : "Start HAR recording"}
|
||||
>
|
||||
{harRecording ? (
|
||||
<Square className="size-2.5 fill-current" />
|
||||
) : (
|
||||
<Circle className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
{harRecording && (
|
||||
<Badge variant="secondary" className="h-3.5 px-1 text-[9px] text-destructive">
|
||||
HAR
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
disabled={!sessionName}
|
||||
className="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
|
||||
title="Clear requests"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchRequests}
|
||||
disabled={loading || !sessionName}
|
||||
className="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
|
||||
title="Refresh"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto font-mono">
|
||||
{!sessionName ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
No active session
|
||||
</div>
|
||||
) : requests.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
{loading ? (
|
||||
<Loader2 className="mx-auto size-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
"No requests captured"
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
requests.map((r) => {
|
||||
const isExpanded = expanded === r.requestId;
|
||||
return (
|
||||
<div key={r.requestId} className="border-b border-border/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleExpand(r.requestId)}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] hover:bg-muted/50"
|
||||
>
|
||||
<span className={cn("w-7 shrink-0 text-right tabular-nums", statusColor(r.status))}>
|
||||
{r.status ?? "..."}
|
||||
</span>
|
||||
<span className="w-8 shrink-0 text-muted-foreground">{r.method}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-foreground" title={r.url}>
|
||||
{truncateUrl(r.url, 80)}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground/60">
|
||||
{r.resourceType}
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="space-y-1.5 bg-muted/30 px-3 py-2 text-[10px]">
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<Loader2 className="size-3 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : detail ? (
|
||||
<RequestDetail detail={detail} url={r.url} />
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
URL: {r.url}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={harDialogOpen} onOpenChange={setHarDialogOpen}>
|
||||
<DialogContent className="max-w-xs">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save HAR</DialogTitle>
|
||||
</DialogHeader>
|
||||
<input
|
||||
ref={harInputRef}
|
||||
type="text"
|
||||
value={harPath}
|
||||
onChange={(e) => setHarPath(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleHarStop();
|
||||
}
|
||||
}}
|
||||
placeholder="capture.har"
|
||||
className="h-9 w-full rounded-md border border-input bg-transparent px-3 font-mono text-sm outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setHarDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleHarStop} disabled={!harPath.trim()}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestDetail({ detail, url }: { detail: Record<string, unknown>; url: string }) {
|
||||
const host = urlHost(url);
|
||||
const headers = detail.headers as Record<string, string> | undefined;
|
||||
const responseHeaders = detail.responseHeaders as Record<string, string> | undefined;
|
||||
const body = detail.body as string | undefined;
|
||||
const postData = detail.postData as string | undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DetailRow label="URL" value={url} wrap />
|
||||
{host && <DetailRow label="Host" value={host} />}
|
||||
{detail.method && <DetailRow label="Method" value={String(detail.method)} />}
|
||||
{detail.status != null && <DetailRow label="Status" value={String(detail.status)} />}
|
||||
{detail.mimeType && <DetailRow label="Type" value={String(detail.mimeType)} />}
|
||||
|
||||
{headers && Object.keys(headers).length > 0 && (
|
||||
<HeadersSection title="Request Headers" headers={headers} />
|
||||
)}
|
||||
{postData && (
|
||||
<div className="mt-1">
|
||||
<span className="text-muted-foreground">Request Body</span>
|
||||
<pre className="mt-0.5 max-h-32 overflow-auto whitespace-pre-wrap break-all text-foreground">
|
||||
{formatBody(postData)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{responseHeaders && Object.keys(responseHeaders).length > 0 && (
|
||||
<HeadersSection title="Response Headers" headers={responseHeaders} />
|
||||
)}
|
||||
{body && (
|
||||
<div className="mt-1">
|
||||
<span className="text-muted-foreground">Response Body</span>
|
||||
<pre className="mt-0.5 max-h-48 overflow-auto whitespace-pre-wrap break-all text-foreground">
|
||||
{formatBody(body)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HeadersSection({ title, headers }: { title: string; headers: Record<string, string> }) {
|
||||
return (
|
||||
<div className="mt-1">
|
||||
<span className="text-muted-foreground">{title}</span>
|
||||
<div className="mt-0.5 space-y-px">
|
||||
{Object.entries(headers).map(([k, v]) => (
|
||||
<div key={k} className="flex gap-2">
|
||||
<span className="shrink-0 text-muted-foreground">{k}:</span>
|
||||
<span className="min-w-0 flex-1 break-all text-foreground">{String(v)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value, wrap }: { label: string; value: string; wrap?: boolean }) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<span className="w-12 shrink-0 text-muted-foreground">{label}</span>
|
||||
<span className={cn("min-w-0 flex-1 text-foreground", wrap ? "break-all whitespace-pre-wrap" : "truncate")}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBody(raw: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState, type SyntheticEvent } from "react";
|
||||
import { useAtomValue, useSetAtom } from "jotai/react";
|
||||
import type { SessionInfo, TabInfo } from "@/types";
|
||||
import {
|
||||
sessionsAtom,
|
||||
activePortAtom,
|
||||
createSessionAtom,
|
||||
closeSessionAtom,
|
||||
killSessionAtom,
|
||||
closeAllSessionsAtom,
|
||||
closeTabAtom,
|
||||
addTabAtom,
|
||||
switchTabAtom,
|
||||
} from "@/store/sessions";
|
||||
import { tabsForPortAtom, engineForPortAtom } from "@/store/tabs";
|
||||
import { ChevronRight, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
|
||||
const ENGINE_LOGOS: Record<string, string> = {
|
||||
chrome: "https://svgl.app/library/chrome.svg",
|
||||
firefox: "https://svgl.app/library/firefox.svg",
|
||||
safari: "https://svgl.app/library/safari.svg",
|
||||
lightpanda: "/lightpanda.svg",
|
||||
};
|
||||
|
||||
const SUPPORTED_ENGINES = ["chrome", "lightpanda"] as const;
|
||||
|
||||
function EngineLogo({ engine }: { engine: string }) {
|
||||
const src = ENGINE_LOGOS[engine];
|
||||
if (!src) {
|
||||
if (!engine) {
|
||||
return <span className="size-4 shrink-0" />;
|
||||
}
|
||||
return (
|
||||
<span className="flex size-4 shrink-0 items-center justify-center rounded bg-muted text-[8px] font-bold text-muted-foreground uppercase">
|
||||
{engine.charAt(0)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={engine}
|
||||
width={16}
|
||||
height={16}
|
||||
className="size-4 shrink-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getFaviconUrl(url: string): string | null {
|
||||
try {
|
||||
const { hostname } = new URL(url);
|
||||
if (!hostname || hostname === "localhost") return null;
|
||||
return `https://www.google.com/s2/favicons?domain=${hostname}&sz=32`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function TabFavicon({ url }: { url: string }) {
|
||||
const src = getFaviconUrl(url);
|
||||
if (!src) {
|
||||
return <span className="flex size-3.5 shrink-0 items-center justify-center rounded-sm bg-muted text-[8px] text-muted-foreground">●</span>;
|
||||
}
|
||||
const handleError = (e: SyntheticEvent<HTMLImageElement>) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
};
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
width={14}
|
||||
height={14}
|
||||
className="size-3.5 shrink-0 rounded-sm"
|
||||
onError={handleError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabNode({ tab, isViewed, isSessionActive, onClose, onSwitch, onSelectSession }: { tab: TabInfo; isViewed: boolean; isSessionActive: boolean; onClose: () => void; onSwitch: () => void; onSelectSession: () => void }) {
|
||||
const handleClick = () => {
|
||||
if (!isSessionActive) {
|
||||
onSelectSession();
|
||||
}
|
||||
if (!tab.active) {
|
||||
onSwitch();
|
||||
}
|
||||
};
|
||||
const isClickable = !isViewed;
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<button
|
||||
onClick={isClickable ? handleClick : undefined}
|
||||
className={cn(
|
||||
"flex w-full min-w-0 items-center gap-1.5 py-1 pr-1 pl-7 text-left text-xs",
|
||||
isViewed
|
||||
? "bg-card text-foreground"
|
||||
: "text-muted-foreground cursor-pointer hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<TabFavicon url={tab.url} />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{tab.title || tab.url || `Tab ${tab.index}`}
|
||||
</span>
|
||||
{tab.active && (
|
||||
<span className="shrink-0 rounded border border-border px-1 py-px text-[9px] leading-none text-muted-foreground">
|
||||
active
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={onClose}>Close tab</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionNode({
|
||||
session,
|
||||
isActive,
|
||||
tabs,
|
||||
engine,
|
||||
expanded,
|
||||
onSelect,
|
||||
onToggle,
|
||||
onCloseTab,
|
||||
onAddTab,
|
||||
onSwitchTab,
|
||||
onClose,
|
||||
onKill,
|
||||
}: {
|
||||
session: SessionInfo;
|
||||
isActive: boolean;
|
||||
tabs: TabInfo[];
|
||||
engine: string;
|
||||
expanded: boolean;
|
||||
onSelect: () => void;
|
||||
onToggle: () => void;
|
||||
onCloseTab: (tabIndex: number) => void;
|
||||
onAddTab: () => void;
|
||||
onSwitchTab: (tabIndex: number) => void;
|
||||
onClose: () => void;
|
||||
onKill: () => void;
|
||||
}) {
|
||||
const [confirmClose, setConfirmClose] = useState(false);
|
||||
const [confirmKill, setConfirmKill] = useState(false);
|
||||
|
||||
if (session.pending || session.closing) {
|
||||
return (
|
||||
<div className="flex w-full items-center text-xs text-muted-foreground">
|
||||
<span className="flex size-6 shrink-0 items-center justify-center">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
</span>
|
||||
<span className="flex flex-1 min-w-0 items-center gap-2 py-1.5 pr-3 pl-1">
|
||||
<EngineLogo engine={session.engine ?? engine} />
|
||||
<span className="truncate font-mono font-semibold">
|
||||
{session.session}
|
||||
</span>
|
||||
<span className="ml-auto text-[10px]">
|
||||
{session.closing ? "Closing..." : "Starting..."}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible open={expanded} onOpenChange={() => onToggle()}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center text-xs transition-colors",
|
||||
isActive
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center">
|
||||
<ChevronRight className={cn("size-3 transition-transform", expanded && "rotate-90")} />
|
||||
</span>
|
||||
<span className="flex flex-1 min-w-0 items-center gap-2 py-1.5 pr-3 pl-1 text-left">
|
||||
<EngineLogo engine={engine} />
|
||||
<span className="truncate font-mono font-semibold">
|
||||
{session.session}
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-auto h-4 px-1.5 text-[10px] tabular-nums"
|
||||
>
|
||||
{tabs.length}
|
||||
</Badge>
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => setConfirmClose(true)}>Close session</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive focus:text-destructive" onClick={() => setConfirmKill(true)}>Kill session</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<Dialog open={confirmClose} onOpenChange={setConfirmClose}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Close session</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Close <span className="font-mono font-semibold text-foreground">{session.session}</span> and its browser? This action cannot be undone.
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={() => setConfirmClose(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setConfirmClose(false);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={confirmKill} onOpenChange={setConfirmKill}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Kill session</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Force-kill <span className="font-mono font-semibold text-foreground">{session.session}</span>? This immediately terminates the process without cleanup.
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={() => setConfirmKill(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setConfirmKill(false);
|
||||
onKill();
|
||||
}}
|
||||
>
|
||||
Kill
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CollapsibleContent>
|
||||
<div className="overflow-hidden pb-1">
|
||||
{tabs.map((tab) => (
|
||||
<TabNode key={tab.index} tab={tab} isViewed={isActive && tab.active} isSessionActive={isActive} onClose={() => onCloseTab(tab.index)} onSwitch={() => onSwitchTab(tab.index)} onSelectSession={onSelect} />
|
||||
))}
|
||||
<button
|
||||
onClick={onAddTab}
|
||||
className="flex w-full items-center gap-1.5 py-1 pr-1 pl-7 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add tab
|
||||
</button>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionTree() {
|
||||
const sessions = useAtomValue(sessionsAtom);
|
||||
const activePort = useAtomValue(activePortAtom);
|
||||
const setActivePort = useSetAtom(activePortAtom);
|
||||
const getTabsForSession = useAtomValue(tabsForPortAtom);
|
||||
const getEngineForSession = useAtomValue(engineForPortAtom);
|
||||
const dispatchCreateSession = useSetAtom(createSessionAtom);
|
||||
const dispatchCloseSession = useSetAtom(closeSessionAtom);
|
||||
const dispatchKillSession = useSetAtom(killSessionAtom);
|
||||
const dispatchCloseAllSessions = useSetAtom(closeAllSessionsAtom);
|
||||
const dispatchCloseTab = useSetAtom(closeTabAtom);
|
||||
const dispatchAddTab = useSetAtom(addTabAtom);
|
||||
const dispatchSwitchTab = useSetAtom(switchTabAtom);
|
||||
|
||||
const [expandedMap, setExpandedMap] = useState<Record<number, boolean>>({});
|
||||
const [newSessionOpen, setNewSessionOpen] = useState(false);
|
||||
const [closeAllOpen, setCloseAllOpen] = useState(false);
|
||||
const [newSessionName, setNewSessionName] = useState("");
|
||||
const [newSessionEngine, setNewSessionEngine] = useState("chrome");
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isExpanded = useCallback(
|
||||
(port: number) => expandedMap[port] ?? true,
|
||||
[expandedMap],
|
||||
);
|
||||
|
||||
const toggleExpanded = useCallback((port: number) => {
|
||||
setExpandedMap((prev) => ({ ...prev, [port]: !(prev[port] ?? true) }));
|
||||
}, []);
|
||||
|
||||
const handleCreateSubmit = useCallback(() => {
|
||||
const name = newSessionName.trim();
|
||||
if (name) {
|
||||
dispatchCreateSession({ name, engine: newSessionEngine });
|
||||
setNewSessionName("");
|
||||
setNewSessionOpen(false);
|
||||
}
|
||||
}, [newSessionName, newSessionEngine, dispatchCreateSession]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex shrink-0 items-center px-3 py-2">
|
||||
<span className="text-xs text-muted-foreground">Sessions</span>
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
{sessions.some((s) => !s.pending) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCloseAllOpen(true)}
|
||||
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="Close all sessions"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewSessionOpen(true)}
|
||||
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="New session"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="w-full py-1">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="py-4 text-center text-xs text-muted-foreground">
|
||||
No sessions
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((s) => (
|
||||
<SessionNode
|
||||
key={s.pending ? `pending-${s.session}` : s.port}
|
||||
session={s}
|
||||
isActive={s.port === activePort}
|
||||
tabs={getTabsForSession(s.port)}
|
||||
engine={getEngineForSession(s.port)}
|
||||
expanded={isExpanded(s.port)}
|
||||
onSelect={() => setActivePort(s.port)}
|
||||
onToggle={() => toggleExpanded(s.port)}
|
||||
onCloseTab={(tabIndex) => dispatchCloseTab({ port: s.port, tabIndex })}
|
||||
onAddTab={() => dispatchAddTab(s.port)}
|
||||
onSwitchTab={(tabIndex) => dispatchSwitchTab({ port: s.port, tabIndex })}
|
||||
onClose={() => dispatchCloseSession(s.port)}
|
||||
onKill={() => dispatchKillSession(s.port)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<Dialog open={newSessionOpen} onOpenChange={setNewSessionOpen}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New session</DialogTitle>
|
||||
</DialogHeader>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
value={newSessionName}
|
||||
onChange={(e) => setNewSessionName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleCreateSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="Session name"
|
||||
autoFocus
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
{SUPPORTED_ENGINES.map((eng) => (
|
||||
<button
|
||||
key={eng}
|
||||
type="button"
|
||||
onClick={() => setNewSessionEngine(eng)}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors",
|
||||
newSessionEngine === eng
|
||||
? "border-ring bg-muted text-foreground"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<EngineLogo engine={eng} />
|
||||
{eng}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setNewSessionOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCreateSubmit}
|
||||
disabled={!newSessionName.trim()}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={closeAllOpen} onOpenChange={setCloseAllOpen}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Close all sessions</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will close {sessions.filter((s) => !s.pending).length} active {sessions.filter((s) => !s.pending).length === 1 ? "session" : "sessions"} and their browsers. This action cannot be undone.
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCloseAllOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCloseAllOpen(false);
|
||||
dispatchCloseAllSessions();
|
||||
}}
|
||||
>
|
||||
Close all
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useAtomValue } from "jotai/react";
|
||||
import { activeSessionNameAtom } from "@/store/sessions";
|
||||
import { execCommand, sessionArgs } from "@/lib/exec";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
|
||||
type StorageTab = "cookies" | "localStorage" | "sessionStorage";
|
||||
|
||||
interface CookieEntry {
|
||||
name: string;
|
||||
value: string;
|
||||
domain?: string;
|
||||
path?: string;
|
||||
expires?: number;
|
||||
httpOnly?: boolean;
|
||||
secure?: boolean;
|
||||
sameSite?: string;
|
||||
}
|
||||
|
||||
interface StorageEntry {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const TABS: { key: StorageTab; label: string }[] = [
|
||||
{ key: "cookies", label: "Cookies" },
|
||||
{ key: "localStorage", label: "Local" },
|
||||
{ key: "sessionStorage", label: "Session" },
|
||||
];
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length > max ? s.slice(0, max) + "..." : s;
|
||||
}
|
||||
|
||||
function formatExpiry(expires: number | undefined): string {
|
||||
if (expires == null || expires <= 0) return "Session";
|
||||
const d = new Date(expires * 1000);
|
||||
return d.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function StoragePanel() {
|
||||
const sessionName = useAtomValue(activeSessionNameAtom);
|
||||
|
||||
const [tab, setTab] = useState<StorageTab>("cookies");
|
||||
const [cookies, setCookies] = useState<CookieEntry[]>([]);
|
||||
const [local, setLocal] = useState<StorageEntry[]>([]);
|
||||
const [session, setSession] = useState<StorageEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const lastSessionRef = useRef(sessionName);
|
||||
|
||||
const fetchData = useCallback(
|
||||
async (which: StorageTab) => {
|
||||
if (!sessionName) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
if (which === "cookies") {
|
||||
const res = await execCommand(sessionArgs(sessionName, "cookies"));
|
||||
if (res.success && res.stdout) {
|
||||
try {
|
||||
const parsed = JSON.parse(res.stdout);
|
||||
setCookies(parsed.cookies ?? []);
|
||||
} catch {
|
||||
setCookies([]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const storageType = which === "localStorage" ? "local" : "session";
|
||||
const res = await execCommand(
|
||||
sessionArgs(sessionName, "storage", storageType),
|
||||
);
|
||||
if (res.success && res.stdout) {
|
||||
try {
|
||||
const parsed = JSON.parse(res.stdout);
|
||||
const entries: StorageEntry[] = [];
|
||||
if (parsed.entries && typeof parsed.entries === "object") {
|
||||
for (const [k, v] of Object.entries(parsed.entries)) {
|
||||
entries.push({ key: k, value: String(v) });
|
||||
}
|
||||
} else if (typeof parsed === "object") {
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
if (k !== "length") {
|
||||
entries.push({ key: k, value: String(v) });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (which === "localStorage") setLocal(entries);
|
||||
else setSession(entries);
|
||||
} catch {
|
||||
if (which === "localStorage") setLocal([]);
|
||||
else setSession([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[sessionName],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionName && sessionName !== lastSessionRef.current) {
|
||||
lastSessionRef.current = sessionName;
|
||||
setCookies([]);
|
||||
setLocal([]);
|
||||
setSession([]);
|
||||
}
|
||||
fetchData(tab);
|
||||
}, [tab, sessionName, fetchData]);
|
||||
|
||||
const handleRefresh = () => fetchData(tab);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex shrink-0 items-center gap-1.5 px-3 py-2">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTab(t.key);
|
||||
setExpanded(null);
|
||||
}}
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 text-[10px] transition-colors",
|
||||
tab === t.key
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading || !sessionName}
|
||||
className="ml-auto flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
|
||||
title="Refresh"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto font-mono">
|
||||
{!sessionName ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
No active session
|
||||
</div>
|
||||
) : tab === "cookies" ? (
|
||||
<CookiesView
|
||||
cookies={cookies}
|
||||
loading={loading}
|
||||
expanded={expanded}
|
||||
onExpand={setExpanded}
|
||||
/>
|
||||
) : (
|
||||
<KeyValueView
|
||||
entries={tab === "localStorage" ? local : session}
|
||||
loading={loading}
|
||||
expanded={expanded}
|
||||
onExpand={setExpanded}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CookiesView({
|
||||
cookies,
|
||||
loading,
|
||||
expanded,
|
||||
onExpand,
|
||||
}: {
|
||||
cookies: CookieEntry[];
|
||||
loading: boolean;
|
||||
expanded: string | null;
|
||||
onExpand: (key: string | null) => void;
|
||||
}) {
|
||||
if (loading && cookies.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (cookies.length === 0) {
|
||||
return (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
No cookies
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{cookies.map((c) => {
|
||||
const id = `${c.domain ?? ""}::${c.name}`;
|
||||
const isExpanded = expanded === id;
|
||||
return (
|
||||
<div key={id} className="border-b border-border/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExpand(isExpanded ? null : id)}
|
||||
className="flex w-full items-start gap-2 px-3 py-1.5 text-left text-[11px] hover:bg-muted/50"
|
||||
>
|
||||
<span className="shrink-0 font-semibold text-foreground">
|
||||
{c.name}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
||||
{truncate(c.value, 60)}
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="space-y-0.5 bg-muted/30 px-3 py-1.5 text-[10px]">
|
||||
<DetailRow label="Value" value={c.value} wrap />
|
||||
{c.domain && <DetailRow label="Domain" value={c.domain} />}
|
||||
{c.path && <DetailRow label="Path" value={c.path} />}
|
||||
<DetailRow label="Expires" value={formatExpiry(c.expires)} />
|
||||
<DetailRow
|
||||
label="Flags"
|
||||
value={[
|
||||
c.httpOnly && "HttpOnly",
|
||||
c.secure && "Secure",
|
||||
c.sameSite && `SameSite=${c.sameSite}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ") || "None"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyValueView({
|
||||
entries,
|
||||
loading,
|
||||
expanded,
|
||||
onExpand,
|
||||
}: {
|
||||
entries: StorageEntry[];
|
||||
loading: boolean;
|
||||
expanded: string | null;
|
||||
onExpand: (key: string | null) => void;
|
||||
}) {
|
||||
if (loading && entries.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
No entries
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{entries.map((e) => {
|
||||
const isExpanded = expanded === e.key;
|
||||
return (
|
||||
<div key={e.key} className="border-b border-border/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExpand(isExpanded ? null : e.key)}
|
||||
className="flex w-full items-start gap-2 px-3 py-1.5 text-left text-[11px] hover:bg-muted/50"
|
||||
>
|
||||
<span className="shrink-0 font-semibold text-foreground">
|
||||
{e.key}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
||||
{truncate(e.value, 80)}
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="bg-muted/30 px-3 py-1.5 text-[10px]">
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all text-foreground">
|
||||
{formatValue(e.value)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
wrap,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
wrap?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<span className="w-14 shrink-0 text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 flex-1 text-foreground",
|
||||
wrap ? "break-all whitespace-pre-wrap" : "truncate",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatValue(raw: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,67 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
@@ -0,0 +1,263 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function ContextMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger
|
||||
data-slot="context-menu-trigger"
|
||||
className={cn("select-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
data-slot="context-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn("z-50 max-h-(--radix-context-menu-content-available-height) min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn("z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
align = "start",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.GroupProps) {
|
||||
return (
|
||||
<ResizablePrimitive.Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full aria-[orientation=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.SeparatorProps & {
|
||||
withHandle?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border" />
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
)
|
||||
}
|
||||
|
||||
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 [&>div]:!block"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
@@ -0,0 +1,724 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useAtomValue, useSetAtom } from "jotai/react";
|
||||
import { ArrowLeft, ArrowRight, Camera, Circle, FileCode, Maximize, Moon, RotateCw, Smartphone, Square, Sun, Wifi, WifiOff } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { execCommand, sessionArgs } from "@/lib/exec";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
currentFrameAtom,
|
||||
viewportWidthAtom,
|
||||
viewportHeightAtom,
|
||||
browserConnectedAtom,
|
||||
screencastingAtom,
|
||||
recordingAtom,
|
||||
streamEngineAtom,
|
||||
activeUrlAtom,
|
||||
sendInputAtom,
|
||||
} from "@/store/stream";
|
||||
import { activeSessionNameAtom, activePortAtom } from "@/store/sessions";
|
||||
|
||||
const SCREENCAST_ENGINES = new Set(["chrome"]);
|
||||
|
||||
function cdpModifiers(e: React.MouseEvent | React.WheelEvent): number {
|
||||
let m = 0;
|
||||
if (e.altKey) m |= 1;
|
||||
if (e.ctrlKey) m |= 2;
|
||||
if (e.metaKey) m |= 4;
|
||||
if (e.shiftKey) m |= 8;
|
||||
return m;
|
||||
}
|
||||
|
||||
const KEY_INFO: Record<string, { text?: string; keyCode: number }> = {
|
||||
Enter: { text: "\r", keyCode: 13 },
|
||||
Tab: { text: "\t", keyCode: 9 },
|
||||
Backspace: { text: "\b", keyCode: 8 },
|
||||
Escape: { keyCode: 27 },
|
||||
ArrowLeft: { keyCode: 37 },
|
||||
ArrowUp: { keyCode: 38 },
|
||||
ArrowRight: { keyCode: 39 },
|
||||
ArrowDown: { keyCode: 40 },
|
||||
Delete: { keyCode: 46 },
|
||||
Home: { keyCode: 36 },
|
||||
End: { keyCode: 35 },
|
||||
PageUp: { keyCode: 33 },
|
||||
PageDown: { keyCode: 34 },
|
||||
};
|
||||
|
||||
function cdpButton(btn: number): string {
|
||||
switch (btn) {
|
||||
case 0: return "left";
|
||||
case 1: return "middle";
|
||||
case 2: return "right";
|
||||
default: return "none";
|
||||
}
|
||||
}
|
||||
|
||||
const DIMENSION_PRESETS: { label: string; ratio?: [number, number] }[] = [
|
||||
{ label: "1:1", ratio: [1, 1] },
|
||||
{ label: "4:3", ratio: [4, 3] },
|
||||
{ label: "16:9", ratio: [16, 9] },
|
||||
{ label: "9:16", ratio: [9, 16] },
|
||||
{ label: "21:9", ratio: [21, 9] },
|
||||
];
|
||||
|
||||
const DEVICE_PRESETS = [
|
||||
{ label: "iPhone 15", value: "iPhone 15" },
|
||||
{ label: "iPhone 16", value: "iPhone 16" },
|
||||
{ label: "iPhone 16 Pro", value: "iPhone 16 Pro" },
|
||||
{ label: "iPhone 17", value: "iPhone 17" },
|
||||
{ label: "iPad", value: "iPad" },
|
||||
{ label: "iPad Pro", value: "iPad Pro" },
|
||||
{ label: "Pixel 9", value: "Pixel 9" },
|
||||
{ label: "Galaxy S25", value: "Galaxy S25" },
|
||||
];
|
||||
|
||||
type ColorScheme = "light" | "dark" | "no-preference";
|
||||
|
||||
function computePresetSize(
|
||||
ratio: [number, number],
|
||||
availableWidth: number,
|
||||
availableHeight: number,
|
||||
): { w: number; h: number } {
|
||||
const [rw, rh] = ratio;
|
||||
let w = availableWidth;
|
||||
let h = Math.round(w * rh / rw);
|
||||
if (h > availableHeight) {
|
||||
h = availableHeight;
|
||||
w = Math.round(h * rw / rh);
|
||||
}
|
||||
return { w: Math.max(w, 1), h: Math.max(h, 1) };
|
||||
}
|
||||
|
||||
function normalizeUrl(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
return `https://${trimmed}`;
|
||||
}
|
||||
|
||||
export function Viewport() {
|
||||
const frame = useAtomValue(currentFrameAtom);
|
||||
const viewportWidth = useAtomValue(viewportWidthAtom);
|
||||
const viewportHeight = useAtomValue(viewportHeightAtom);
|
||||
const browserConnected = useAtomValue(browserConnectedAtom);
|
||||
const screencasting = useAtomValue(screencastingAtom);
|
||||
const recording = useAtomValue(recordingAtom);
|
||||
const engine = useAtomValue(streamEngineAtom);
|
||||
const url = useAtomValue(activeUrlAtom);
|
||||
const sessionName = useAtomValue(activeSessionNameAtom);
|
||||
const streamPort = useAtomValue(activePortAtom);
|
||||
const sendInput = useSetAtom(sendInputAtom);
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasAreaRef = useRef<HTMLDivElement>(null);
|
||||
const addressRef = useRef<HTMLInputElement>(null);
|
||||
const [addressValue, setAddressValue] = useState(url);
|
||||
const [navigating, setNavigating] = useState(false);
|
||||
const [canvasArea, setCanvasArea] = useState({ width: 0, height: 0 });
|
||||
const [customDialogOpen, setCustomDialogOpen] = useState(false);
|
||||
const [customValue, setCustomValue] = useState("");
|
||||
const customInputRef = useRef<HTMLInputElement>(null);
|
||||
const [recordDialogOpen, setRecordDialogOpen] = useState(false);
|
||||
const [recordPath, setRecordPath] = useState("recording.webm");
|
||||
const recordInputRef = useRef<HTMLInputElement>(null);
|
||||
const [activeDevice, setActiveDevice] = useState<string | null>(null);
|
||||
const [colorScheme, setColorScheme] = useState<ColorScheme>("no-preference");
|
||||
const [offline, setOffline] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (customDialogOpen) {
|
||||
requestAnimationFrame(() => customInputRef.current?.select());
|
||||
}
|
||||
}, [customDialogOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (recordDialogOpen) {
|
||||
requestAnimationFrame(() => recordInputRef.current?.select());
|
||||
}
|
||||
}, [recordDialogOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
setAddressValue(url);
|
||||
}, [url]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveDevice(null);
|
||||
setColorScheme("no-preference");
|
||||
setOffline(false);
|
||||
}, [sessionName]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = canvasAreaRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
const { width, height } = entry.contentRect;
|
||||
setCanvasArea({ width: Math.floor(width), height: Math.floor(height) });
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const runCmd = useCallback(
|
||||
(...args: string[]) => execCommand(sessionArgs(sessionName, ...args)),
|
||||
[sessionName],
|
||||
);
|
||||
|
||||
const handleNavigate = useCallback(async () => {
|
||||
if (!addressValue.trim() || navigating) return;
|
||||
|
||||
addressRef.current?.blur();
|
||||
|
||||
const target = normalizeUrl(addressValue);
|
||||
setAddressValue(target);
|
||||
setNavigating(true);
|
||||
try {
|
||||
await runCmd("navigate", target);
|
||||
} finally {
|
||||
setNavigating(false);
|
||||
}
|
||||
}, [addressValue, navigating, runCmd]);
|
||||
|
||||
const drawFrame = useCallback((base64: string) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const bin = atob(base64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
|
||||
createImageBitmap(new Blob([bytes], { type: "image/jpeg" })).then((bmp) => {
|
||||
canvas.width = bmp.width;
|
||||
canvas.height = bmp.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) ctx.drawImage(bmp, 0, 0);
|
||||
bmp.close();
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (frame) {
|
||||
drawFrame(frame);
|
||||
}
|
||||
}, [frame, drawFrame]);
|
||||
|
||||
const isFit =
|
||||
canvasArea.width > 0 &&
|
||||
viewportWidth === canvasArea.width &&
|
||||
viewportHeight === canvasArea.height;
|
||||
|
||||
const handleFit = useCallback(() => {
|
||||
if (canvasArea.width <= 0) return;
|
||||
const w = canvasArea.width;
|
||||
const h = canvasArea.height;
|
||||
if (w > 0 && h > 0) {
|
||||
runCmd("set", "viewport", String(w), String(h));
|
||||
}
|
||||
}, [canvasArea, runCmd]);
|
||||
|
||||
const handlePreset = useCallback(
|
||||
(ratio: [number, number]) => {
|
||||
if (canvasArea.width <= 0) return;
|
||||
const avail = { w: canvasArea.width, h: canvasArea.height };
|
||||
const { w, h } = computePresetSize(ratio, avail.w, avail.h);
|
||||
runCmd("set", "viewport", String(w), String(h));
|
||||
},
|
||||
[canvasArea, runCmd],
|
||||
);
|
||||
|
||||
const submitCustomDimensions = useCallback(() => {
|
||||
setCustomDialogOpen(false);
|
||||
const match = customValue.trim().match(/^(\d+)\s*[x,\s]\s*(\d+)$/);
|
||||
if (!match) return;
|
||||
const w = parseInt(match[1], 10);
|
||||
const h = parseInt(match[2], 10);
|
||||
if (w > 0 && h > 0) {
|
||||
runCmd("set", "viewport", String(w), String(h));
|
||||
}
|
||||
}, [customValue, runCmd]);
|
||||
|
||||
const handleRecordStart = useCallback(async () => {
|
||||
const path = recordPath.trim();
|
||||
if (!path) return;
|
||||
setRecordDialogOpen(false);
|
||||
await execCommand(sessionArgs(sessionName, "record", "start", path));
|
||||
}, [recordPath, sessionName]);
|
||||
|
||||
const handleRecordStop = useCallback(async () => {
|
||||
await execCommand(sessionArgs(sessionName, "record", "stop"));
|
||||
}, [sessionName]);
|
||||
|
||||
const handleSetDevice = useCallback(async (device: string) => {
|
||||
setActiveDevice(device);
|
||||
await execCommand(sessionArgs(sessionName, "set", "device", device));
|
||||
}, [sessionName]);
|
||||
|
||||
const handleResetDevice = useCallback(async () => {
|
||||
setActiveDevice(null);
|
||||
if (canvasArea.width > 0 && canvasArea.height > 0) {
|
||||
await runCmd("set", "viewport", String(canvasArea.width), String(canvasArea.height));
|
||||
}
|
||||
}, [canvasArea.width, canvasArea.height, runCmd]);
|
||||
|
||||
const handleSetColorScheme = useCallback(async (scheme: ColorScheme) => {
|
||||
setColorScheme(scheme);
|
||||
const args = sessionArgs(sessionName, "set", "media");
|
||||
if (scheme !== "no-preference") args.push(scheme);
|
||||
await execCommand(args);
|
||||
}, [sessionName]);
|
||||
|
||||
const handleToggleOffline = useCallback(async () => {
|
||||
const next = !offline;
|
||||
setOffline(next);
|
||||
const args = sessionArgs(sessionName, "set", "offline");
|
||||
if (!next) args.push("off");
|
||||
await execCommand(args);
|
||||
}, [offline, sessionName]);
|
||||
|
||||
const toViewport = useCallback(
|
||||
(e: React.MouseEvent): { x: number; y: number } | null => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = viewportWidth / rect.width;
|
||||
const scaleY = viewportHeight / rect.height;
|
||||
return {
|
||||
x: Math.round((e.clientX - rect.left) * scaleX),
|
||||
y: Math.round((e.clientY - rect.top) * scaleY),
|
||||
};
|
||||
},
|
||||
[viewportWidth, viewportHeight],
|
||||
);
|
||||
|
||||
const handleMouseEvent = useCallback(
|
||||
(e: React.MouseEvent, eventType: string) => {
|
||||
const pos = toViewport(e);
|
||||
if (!pos) return;
|
||||
sendInput({
|
||||
type: "input_mouse",
|
||||
eventType,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
button: cdpButton(e.button),
|
||||
clickCount: eventType === "mousePressed" ? 1 : 0,
|
||||
modifiers: cdpModifiers(e),
|
||||
});
|
||||
},
|
||||
[toViewport, sendInput],
|
||||
);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: React.WheelEvent) => {
|
||||
const pos = toViewport(e);
|
||||
if (!pos) return;
|
||||
sendInput({
|
||||
type: "input_mouse",
|
||||
eventType: "mouseWheel",
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
button: "none",
|
||||
clickCount: 0,
|
||||
deltaX: e.deltaX,
|
||||
deltaY: e.deltaY,
|
||||
modifiers: cdpModifiers(e),
|
||||
});
|
||||
},
|
||||
[toViewport, sendInput],
|
||||
);
|
||||
|
||||
const dispatchKey = useCallback(
|
||||
(e: KeyboardEvent, eventType: string) => {
|
||||
const info = KEY_INFO[e.key];
|
||||
const text = eventType === "keyDown"
|
||||
? (info?.text ?? (e.key.length === 1 ? e.key : undefined))
|
||||
: undefined;
|
||||
const keyCode = info?.keyCode ?? (e.key.length === 1 ? e.key.charCodeAt(0) : 0);
|
||||
let m = 0;
|
||||
if (e.altKey) m |= 1;
|
||||
if (e.ctrlKey) m |= 2;
|
||||
if (e.metaKey) m |= 4;
|
||||
if (e.shiftKey) m |= 8;
|
||||
sendInput({
|
||||
type: "input_keyboard",
|
||||
eventType,
|
||||
key: e.key,
|
||||
code: e.code,
|
||||
text,
|
||||
windowsVirtualKeyCode: keyCode,
|
||||
modifiers: m,
|
||||
});
|
||||
},
|
||||
[sendInput],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (document.activeElement !== canvasRef.current) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dispatchKey(e, e.type === "keydown" ? "keyDown" : "keyUp");
|
||||
};
|
||||
window.addEventListener("keydown", handler, true);
|
||||
window.addEventListener("keyup", handler, true);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handler, true);
|
||||
window.removeEventListener("keyup", handler, true);
|
||||
};
|
||||
}, [dispatchKey]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex h-full flex-col">
|
||||
{browserConnected && (
|
||||
<>
|
||||
<div className="flex shrink-0 items-center gap-1.5 px-2 py-1.5">
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runCmd("back")}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom"><p>Back</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runCmd("forward")}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<ArrowRight className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom"><p>Forward</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runCmd("reload")}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<RotateCw className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom"><p>Refresh</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div className="flex min-w-0 flex-1 items-center rounded-md bg-muted px-2.5 py-1">
|
||||
<input
|
||||
ref={addressRef}
|
||||
type="text"
|
||||
value={addressValue}
|
||||
onChange={(e) => setAddressValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleNavigate();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-full bg-transparent font-mono text-xs text-muted-foreground outline-none placeholder:text-muted-foreground/50",
|
||||
navigating && "opacity-50",
|
||||
)}
|
||||
placeholder="Enter URL..."
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runCmd("snapshot")}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<FileCode className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom"><p>Snapshot</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runCmd("screenshot")}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Camera className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom"><p>Screenshot</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={recording ? handleRecordStop : () => setRecordDialogOpen(true)}
|
||||
className={cn(
|
||||
"shrink-0 rounded p-1 transition-colors",
|
||||
recording
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{recording ? <Square className="size-3.5" /> : <Circle className="size-4" />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom"><p>{recording ? "Stop recording" : "Record"}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div ref={canvasAreaRef} className="flex min-h-0 flex-1 items-center justify-center">
|
||||
{frame ? (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
tabIndex={0}
|
||||
className="max-h-full max-w-full object-contain outline-none"
|
||||
onMouseMove={(e) => handleMouseEvent(e, "mouseMoved")}
|
||||
onMouseDown={(e) => {
|
||||
canvasRef.current?.focus();
|
||||
handleMouseEvent(e, "mousePressed");
|
||||
}}
|
||||
onMouseUp={(e) => handleMouseEvent(e, "mouseReleased")}
|
||||
onWheel={handleWheel}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
{browserConnected
|
||||
? SCREENCAST_ENGINES.has(engine)
|
||||
? "Waiting for frames..."
|
||||
: `Screencast not available for ${engine}`
|
||||
: "No browser connected"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<div className="flex shrink-0 items-center gap-2 px-3 py-2">
|
||||
<div
|
||||
className={cn(
|
||||
"size-2 rounded-full",
|
||||
browserConnected ? "bg-success" : "bg-destructive",
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{browserConnected
|
||||
? screencasting
|
||||
? "Live"
|
||||
: "Connected"
|
||||
: "Disconnected"}
|
||||
</span>
|
||||
{browserConnected && (
|
||||
<span className="text-xs text-muted-foreground/60 font-mono">
|
||||
ws://localhost:{streamPort}
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{browserConnected && (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex h-4 cursor-pointer items-center gap-1 px-1.5 text-[10px] hover:bg-muted"
|
||||
>
|
||||
{colorScheme === "dark" ? (
|
||||
<Moon className="size-2.5" />
|
||||
) : (
|
||||
<Sun className="size-2.5" />
|
||||
)}
|
||||
{colorScheme === "dark" ? "Dark" : colorScheme === "light" ? "Light" : "System"}
|
||||
</Badge>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="top">
|
||||
<DropdownMenuLabel className="text-xs">Color Scheme</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleSetColorScheme("no-preference")}
|
||||
className={cn("text-xs", colorScheme === "no-preference" && "font-semibold")}
|
||||
>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleSetColorScheme("light")}
|
||||
className={cn("text-xs", colorScheme === "light" && "font-semibold")}
|
||||
>
|
||||
<Sun className="mr-1.5 size-3" />
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleSetColorScheme("dark")}
|
||||
className={cn("text-xs", colorScheme === "dark" && "font-semibold")}
|
||||
>
|
||||
<Moon className="mr-1.5 size-3" />
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Badge
|
||||
variant="outline"
|
||||
onClick={handleToggleOffline}
|
||||
className={cn(
|
||||
"flex h-4 cursor-pointer items-center gap-1 px-1.5 text-[10px] hover:bg-muted",
|
||||
offline && "border-destructive/50 bg-destructive/10 text-destructive hover:bg-destructive/20",
|
||||
)}
|
||||
>
|
||||
{offline ? <WifiOff className="size-2.5" /> : <Wifi className="size-2.5" />}
|
||||
{offline ? "Offline" : "Online"}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex h-4 cursor-pointer items-center gap-1 px-1.5 text-[10px] tabular-nums hover:bg-muted"
|
||||
>
|
||||
{activeDevice && <Smartphone className="size-2.5" />}
|
||||
{activeDevice ?? `${viewportWidth} x ${viewportHeight}`}
|
||||
</Badge>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="top">
|
||||
<DropdownMenuLabel className="text-xs">Aspect Ratio</DropdownMenuLabel>
|
||||
{DIMENSION_PRESETS.map((p) => (
|
||||
<DropdownMenuItem
|
||||
key={p.label}
|
||||
onClick={() => handlePreset(p.ratio!)}
|
||||
className="text-xs"
|
||||
>
|
||||
{p.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs">Devices</DropdownMenuLabel>
|
||||
{DEVICE_PRESETS.map((d) => (
|
||||
<DropdownMenuItem
|
||||
key={d.value}
|
||||
onClick={() => handleSetDevice(d.value)}
|
||||
className={cn("text-xs", activeDevice === d.value && "font-semibold")}
|
||||
>
|
||||
<Smartphone className="mr-1.5 size-3" />
|
||||
{d.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{!isFit && (
|
||||
<DropdownMenuItem onClick={handleFit} className="text-xs">
|
||||
<Maximize className="mr-1.5 size-3" />
|
||||
Fit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setCustomValue(`${viewportWidth} x ${viewportHeight}`);
|
||||
setCustomDialogOpen(true);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
Custom...
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={customDialogOpen} onOpenChange={setCustomDialogOpen}>
|
||||
<DialogContent className="max-w-xs">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Custom Dimensions</DialogTitle>
|
||||
</DialogHeader>
|
||||
<input
|
||||
ref={customInputRef}
|
||||
type="text"
|
||||
value={customValue}
|
||||
onChange={(e) => setCustomValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submitCustomDimensions();
|
||||
}
|
||||
}}
|
||||
placeholder="1280 x 720"
|
||||
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setCustomDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={submitCustomDimensions}>
|
||||
Apply
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={recordDialogOpen} onOpenChange={setRecordDialogOpen}>
|
||||
<DialogContent className="max-w-xs">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Start Recording</DialogTitle>
|
||||
</DialogHeader>
|
||||
<input
|
||||
ref={recordInputRef}
|
||||
type="text"
|
||||
value={recordPath}
|
||||
onChange={(e) => setRecordPath(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleRecordStart();
|
||||
}
|
||||
}}
|
||||
placeholder="recording.webm"
|
||||
className="h-9 w-full rounded-md border border-input bg-transparent px-3 font-mono text-sm outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Output path for the WebM video file.
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setRecordDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleRecordStart} disabled={!recordPath.trim()}>
|
||||
Record
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(query);
|
||||
setMatches(mql.matches);
|
||||
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||
mql.addEventListener("change", handler);
|
||||
return () => mql.removeEventListener("change", handler);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
const DASHBOARD_PORT = 4848;
|
||||
|
||||
export interface ExecResult {
|
||||
success: boolean;
|
||||
exit_code: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export async function execCommand(args: string[]): Promise<ExecResult> {
|
||||
const resp = await fetch(`http://localhost:${DASHBOARD_PORT}/api/exec`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ args }),
|
||||
});
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export function sessionArgs(session: string, ...args: string[]): string[] {
|
||||
return ["--session", session, ...args];
|
||||
}
|
||||
|
||||
export async function killSession(session: string): Promise<{ success: boolean; killed_pid?: number }> {
|
||||
const resp = await fetch(`http://localhost:${DASHBOARD_PORT}/api/kill`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session }),
|
||||
});
|
||||
return resp.json();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { atom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { useAtomValue, useSetAtom } from "jotai/react";
|
||||
import type { ActivityEvent } from "@/types";
|
||||
import { streamEventsAtom } from "@/store/stream";
|
||||
import { activeSessionNameAtom } from "@/store/sessions";
|
||||
|
||||
const PERSIST_KEY = "ab-persist-activity";
|
||||
const MAX_PERSISTED = 500;
|
||||
|
||||
function activityStorageKey(session: string) {
|
||||
return `ab-activity-${session}`;
|
||||
}
|
||||
|
||||
function loadPersistedEvents(session: string): ActivityEvent[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(activityStorageKey(session));
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function savePersistedEvents(session: string, events: ActivityEvent[]) {
|
||||
try {
|
||||
const capped = events.slice(-MAX_PERSISTED);
|
||||
localStorage.setItem(activityStorageKey(session), JSON.stringify(capped));
|
||||
} catch {
|
||||
// Storage full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function clearPersistedEvents(session: string) {
|
||||
try {
|
||||
localStorage.removeItem(activityStorageKey(session));
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primitive atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const persistActivityAtom = atom(
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem(PERSIST_KEY) === "true"
|
||||
: false,
|
||||
);
|
||||
|
||||
export const restoredEventsAtom = atom<ActivityEvent[]>([]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const combinedEventsAtom = atom((get) => {
|
||||
const persist = get(persistActivityAtom);
|
||||
const restored = get(restoredEventsAtom);
|
||||
const streamEvents = get(streamEventsAtom);
|
||||
|
||||
if (persist && restored.length > 0) {
|
||||
return [...restored, ...streamEvents].slice(-MAX_PERSISTED);
|
||||
}
|
||||
return streamEvents;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const togglePersistAtom = atom(null, (get, set) => {
|
||||
const next = !get(persistActivityAtom);
|
||||
set(persistActivityAtom, next);
|
||||
localStorage.setItem(PERSIST_KEY, String(next));
|
||||
|
||||
if (!next) {
|
||||
const session = get(activeSessionNameAtom);
|
||||
if (session) clearPersistedEvents(session);
|
||||
set(restoredEventsAtom, []);
|
||||
}
|
||||
});
|
||||
|
||||
export const clearActivityAtom = atom(null, (get, set) => {
|
||||
set(streamEventsAtom, []);
|
||||
set(restoredEventsAtom, []);
|
||||
const session = get(activeSessionNameAtom);
|
||||
if (session) clearPersistedEvents(session);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync hook -- call once to keep localStorage in sync with atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useActivitySync() {
|
||||
const persist = useAtomValue(persistActivityAtom);
|
||||
const session = useAtomValue(activeSessionNameAtom);
|
||||
const combinedEvents = useAtomValue(combinedEventsAtom);
|
||||
const setRestored = useSetAtom(restoredEventsAtom);
|
||||
|
||||
// Load persisted events when session changes
|
||||
useEffect(() => {
|
||||
if (persist && session) {
|
||||
setRestored(loadPersistedEvents(session));
|
||||
} else {
|
||||
setRestored([]);
|
||||
}
|
||||
}, [persist, session, setRestored]);
|
||||
|
||||
// Save combined events to localStorage when persist is on
|
||||
useEffect(() => {
|
||||
if (persist && session && combinedEvents.length > 0) {
|
||||
savePersistedEvents(session, combinedEvents);
|
||||
}
|
||||
}, [persist, session, combinedEvents]);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Provider } from "jotai";
|
||||
|
||||
export function JotaiProvider({ children }: { children: React.ReactNode }) {
|
||||
return <Provider>{children}</Provider>;
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import { atom } from "jotai";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useAtomCallback } from "jotai/utils";
|
||||
import type { SessionInfo } from "@/types";
|
||||
import { execCommand, killSession, sessionArgs } from "@/lib/exec";
|
||||
import { tabCacheAtom, engineCacheAtom } from "@/store/tabs";
|
||||
import { streamTabsAtom, streamEngineAtom } from "@/store/stream";
|
||||
|
||||
function getPort(): number {
|
||||
if (typeof window === "undefined") return 9223;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const p = params.get("port");
|
||||
return p ? parseInt(p, 10) || 9223 : 9223;
|
||||
}
|
||||
|
||||
const DASHBOARD_PORT = 4848;
|
||||
|
||||
function getSessionsUrl(): string {
|
||||
if (typeof window !== "undefined") {
|
||||
const origin = window.location.origin;
|
||||
if (origin.includes(`:${DASHBOARD_PORT}`)) {
|
||||
return "/api/sessions";
|
||||
}
|
||||
}
|
||||
return `http://localhost:${DASHBOARD_PORT}/api/sessions`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primitive atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const activePortAtom = atom(getPort());
|
||||
|
||||
export const polledSessionsAtom = atom<SessionInfo[]>([]);
|
||||
|
||||
export const pendingSessionsAtom = atom<{ session: string; engine: string }[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
export const closingSessionsAtom = atom<Set<string>>(new Set<string>());
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const sessionsAtom = atom((get) => {
|
||||
const polled = get(polledSessionsAtom);
|
||||
const pending = get(pendingSessionsAtom);
|
||||
const closing = get(closingSessionsAtom);
|
||||
|
||||
const polledNames = new Set(polled.map((s) => s.session));
|
||||
const pendingEntries = pending
|
||||
.filter((p) => !polledNames.has(p.session))
|
||||
.map((p) => ({
|
||||
session: p.session,
|
||||
port: 0,
|
||||
engine: p.engine,
|
||||
pending: true as const,
|
||||
}));
|
||||
const merged = polled.map((s) =>
|
||||
closing.has(s.session) ? { ...s, closing: true as const } : s,
|
||||
);
|
||||
return [...merged, ...pendingEntries];
|
||||
});
|
||||
|
||||
export const activeSessionInfoAtom = atom((get) => {
|
||||
const sessions = get(sessionsAtom);
|
||||
const port = get(activePortAtom);
|
||||
return sessions.find((s) => s.port === port);
|
||||
});
|
||||
|
||||
export const activeSessionNameAtom = atom(
|
||||
(get) => get(activeSessionInfoAtom)?.session ?? "",
|
||||
);
|
||||
|
||||
export const activeExtensionsAtom = atom((get) => {
|
||||
const info = get(activeSessionInfoAtom);
|
||||
return (
|
||||
(info && "extensions" in info ? info.extensions : undefined) ?? []
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const createSessionAtom = atom(
|
||||
null,
|
||||
(
|
||||
_get,
|
||||
set,
|
||||
{ name, engine }: { name: string; engine: string },
|
||||
) => {
|
||||
set(pendingSessionsAtom, (prev) => [...prev, { session: name, engine }]);
|
||||
execCommand(["--session", name, "--engine", engine, "open", "about:blank"]);
|
||||
},
|
||||
);
|
||||
|
||||
export const closeSessionAtom = atom(null, (get, set, port: number) => {
|
||||
const sessions = get(sessionsAtom);
|
||||
const s = sessions.find((x) => x.port === port)?.session;
|
||||
if (s) {
|
||||
set(closingSessionsAtom, (prev) => new Set(prev).add(s));
|
||||
execCommand(sessionArgs(s, "close"));
|
||||
}
|
||||
});
|
||||
|
||||
export const killSessionAtom = atom(null, (get, set, port: number) => {
|
||||
const sessions = get(sessionsAtom);
|
||||
const s = sessions.find((x) => x.port === port)?.session;
|
||||
if (s) {
|
||||
set(closingSessionsAtom, (prev) => new Set(prev).add(s));
|
||||
killSession(s);
|
||||
}
|
||||
});
|
||||
|
||||
export const closeAllSessionsAtom = atom(null, (get, set) => {
|
||||
const sessions = get(sessionsAtom);
|
||||
for (const s of sessions) {
|
||||
if (!s.pending && !s.closing) {
|
||||
set(closingSessionsAtom, (prev) => new Set(prev).add(s.session));
|
||||
execCommand(sessionArgs(s.session, "close"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const closeTabAtom = atom(
|
||||
null,
|
||||
(get, _set, { port, tabIndex }: { port: number; tabIndex: number }) => {
|
||||
const sessions = get(sessionsAtom);
|
||||
const s = sessions.find((x) => x.port === port)?.session;
|
||||
if (s) execCommand(sessionArgs(s, "tab", "close", String(tabIndex)));
|
||||
},
|
||||
);
|
||||
|
||||
export const addTabAtom = atom(null, (get, _set, port: number) => {
|
||||
const sessions = get(sessionsAtom);
|
||||
const s = sessions.find((x) => x.port === port)?.session;
|
||||
if (s) execCommand(sessionArgs(s, "tab", "new"));
|
||||
});
|
||||
|
||||
export const switchTabAtom = atom(
|
||||
null,
|
||||
(get, _set, { port, tabIndex }: { port: number; tabIndex: number }) => {
|
||||
const sessions = get(sessionsAtom);
|
||||
const s = sessions.find((x) => x.port === port)?.session;
|
||||
if (s) execCommand(sessionArgs(s, "tab", String(tabIndex)));
|
||||
},
|
||||
);
|
||||
|
||||
/** Prune pending/closing once the polled list confirms them */
|
||||
const reconcileSessionsAtom = atom(
|
||||
null,
|
||||
(get, set) => {
|
||||
const polled = get(polledSessionsAtom);
|
||||
const polledNames = new Set(polled.map((s) => s.session));
|
||||
|
||||
set(pendingSessionsAtom, (prev) => {
|
||||
const next = prev.filter((p) => !polledNames.has(p.session));
|
||||
return next.length === prev.length ? prev : next;
|
||||
});
|
||||
|
||||
set(closingSessionsAtom, (prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const name of prev) {
|
||||
if (!polledNames.has(name)) next.delete(name);
|
||||
}
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useSessionsSync(pollInterval = 5000) {
|
||||
const failCountRef = useRef(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const reconcile = useAtomCallback(
|
||||
useCallback((_get, set) => {
|
||||
set(reconcileSessionsAtom);
|
||||
}, []),
|
||||
);
|
||||
|
||||
const fetchSessions = useAtomCallback(
|
||||
useCallback(
|
||||
async (get, set) => {
|
||||
try {
|
||||
const resp = await fetch(getSessionsUrl());
|
||||
if (resp.ok) {
|
||||
failCountRef.current = 0;
|
||||
const data: SessionInfo[] = await resp.json();
|
||||
data.sort((a, b) => a.session.localeCompare(b.session));
|
||||
set(polledSessionsAtom, data);
|
||||
|
||||
// Reconcile pending/closing
|
||||
reconcile();
|
||||
|
||||
// Seed engine cache from session list
|
||||
const engineCache = get(engineCacheAtom);
|
||||
const nextEngine = { ...engineCache };
|
||||
let engineChanged = false;
|
||||
for (const s of data) {
|
||||
if (s.engine && !nextEngine[s.port]) {
|
||||
nextEngine[s.port] = s.engine;
|
||||
engineChanged = true;
|
||||
}
|
||||
}
|
||||
if (engineChanged) set(engineCacheAtom, nextEngine);
|
||||
|
||||
// Auto-select first session if current port is not in list
|
||||
const activePort = get(activePortAtom);
|
||||
const sessions = get(sessionsAtom);
|
||||
if (sessions.length > 0 && !sessions.some((s) => s.port === activePort)) {
|
||||
set(activePortAtom, sessions[0].port);
|
||||
}
|
||||
|
||||
// Poll tabs for all sessions
|
||||
for (const s of data) {
|
||||
try {
|
||||
const tabsResp = await fetch(
|
||||
`http://localhost:${s.port}/api/tabs`,
|
||||
).catch(() => null);
|
||||
if (tabsResp?.ok) {
|
||||
const tabs = await tabsResp.json();
|
||||
if (tabs.length > 0) {
|
||||
set(tabCacheAtom, (prev) => ({ ...prev, [s.port]: tabs }));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Session unreachable
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Server unreachable
|
||||
}
|
||||
failCountRef.current++;
|
||||
if (failCountRef.current >= 2) set(polledSessionsAtom, []);
|
||||
},
|
||||
[reconcile],
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions();
|
||||
timerRef.current = setInterval(fetchSessions, pollInterval);
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, [fetchSessions, pollInterval]);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { atom } from "jotai";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useSetAtom } from "jotai/react";
|
||||
import type {
|
||||
ActivityEvent,
|
||||
ConsoleEntry,
|
||||
StreamMessage,
|
||||
TabInfo,
|
||||
} from "@/types";
|
||||
import { activePortAtom } from "@/store/sessions";
|
||||
import { tabCacheAtom, engineCacheAtom } from "@/store/tabs";
|
||||
|
||||
const MAX_EVENTS = 500;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primitive atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const streamConnectedAtom = atom(false);
|
||||
export const browserConnectedAtom = atom(false);
|
||||
export const screencastingAtom = atom(false);
|
||||
export const recordingAtom = atom(false);
|
||||
export const viewportWidthAtom = atom(1280);
|
||||
export const viewportHeightAtom = atom(720);
|
||||
export const currentFrameAtom = atom<string | null>(null);
|
||||
export const streamEventsAtom = atom<ActivityEvent[]>([]);
|
||||
export const consoleLogsAtom = atom<ConsoleEntry[]>([]);
|
||||
export const streamTabsAtom = atom<TabInfo[]>([]);
|
||||
export const streamEngineAtom = atom("");
|
||||
export const wsRefAtom = atom<WebSocket | null>(null);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const activeUrlAtom = atom(
|
||||
(get) => get(streamTabsAtom).find((t) => t.active)?.url ?? "",
|
||||
);
|
||||
|
||||
export const hasConsoleErrorsAtom = atom((get) =>
|
||||
get(consoleLogsAtom).some(
|
||||
(e) =>
|
||||
e.type === "page_error" ||
|
||||
(e.type === "console" && e.level === "error"),
|
||||
),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const sendInputAtom = atom(
|
||||
null,
|
||||
(get, _set, msg: Record<string, unknown>) => {
|
||||
const ws = get(wsRefAtom);
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const clearEventsAtom = atom(null, (_get, set) => {
|
||||
set(streamEventsAtom, []);
|
||||
});
|
||||
|
||||
export const clearConsoleLogsAtom = atom(null, (_get, set) => {
|
||||
set(consoleLogsAtom, []);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useStreamSync(port: number) {
|
||||
const setConnected = useSetAtom(streamConnectedAtom);
|
||||
const setBrowserConnected = useSetAtom(browserConnectedAtom);
|
||||
const setScreencasting = useSetAtom(screencastingAtom);
|
||||
const setRecording = useSetAtom(recordingAtom);
|
||||
const setVpWidth = useSetAtom(viewportWidthAtom);
|
||||
const setVpHeight = useSetAtom(viewportHeightAtom);
|
||||
const setFrame = useSetAtom(currentFrameAtom);
|
||||
const setEvents = useSetAtom(streamEventsAtom);
|
||||
const setConsoleLogs = useSetAtom(consoleLogsAtom);
|
||||
const setTabs = useSetAtom(streamTabsAtom);
|
||||
const setEngine = useSetAtom(streamEngineAtom);
|
||||
const setWsRef = useSetAtom(wsRefAtom);
|
||||
const setTabCache = useSetAtom(tabCacheAtom);
|
||||
const setEngineCache = useSetAtom(engineCacheAtom);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const retryCountRef = useRef(0);
|
||||
const eventsRef = useRef<ActivityEvent[]>([]);
|
||||
const consoleRef = useRef<ConsoleEntry[]>([]);
|
||||
const portRef = useRef(port);
|
||||
|
||||
// Reset all stream state when port changes
|
||||
useEffect(() => {
|
||||
if (portRef.current !== port) {
|
||||
portRef.current = port;
|
||||
eventsRef.current = [];
|
||||
consoleRef.current = [];
|
||||
setConnected(false);
|
||||
setBrowserConnected(false);
|
||||
setScreencasting(false);
|
||||
setRecording(false);
|
||||
setVpWidth(1280);
|
||||
setVpHeight(720);
|
||||
setFrame(null);
|
||||
setEvents([]);
|
||||
setConsoleLogs([]);
|
||||
setTabs([]);
|
||||
setEngine("");
|
||||
}
|
||||
}, [port, setConnected, setBrowserConnected, setScreencasting, setRecording, setVpWidth, setVpHeight, setFrame, setEvents, setConsoleLogs, setTabs, setEngine]);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
const ws = new WebSocket(`ws://localhost:${port}`);
|
||||
wsRef.current = ws;
|
||||
setWsRef(ws);
|
||||
|
||||
ws.onopen = () => {
|
||||
retryCountRef.current = 0;
|
||||
setConnected(true);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
const delay = Math.min(2000 * 2 ** retryCountRef.current, 30000);
|
||||
retryCountRef.current++;
|
||||
reconnectTimerRef.current = setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
let msg: StreamMessage;
|
||||
try {
|
||||
msg = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case "frame":
|
||||
setFrame(msg.data);
|
||||
break;
|
||||
|
||||
case "status":
|
||||
setBrowserConnected(msg.connected);
|
||||
setScreencasting(msg.screencasting);
|
||||
if (msg.recording != null) setRecording(msg.recording);
|
||||
setVpWidth(msg.viewportWidth);
|
||||
setVpHeight(msg.viewportHeight);
|
||||
if (msg.engine) {
|
||||
setEngine(msg.engine);
|
||||
setEngineCache((prev) => ({ ...prev, [port]: msg.engine! }));
|
||||
}
|
||||
break;
|
||||
|
||||
case "command": {
|
||||
const updated = [...eventsRef.current, msg].slice(-MAX_EVENTS);
|
||||
eventsRef.current = updated;
|
||||
setEvents(updated);
|
||||
break;
|
||||
}
|
||||
|
||||
case "console": {
|
||||
const conUpdated = [...consoleRef.current, msg].slice(-MAX_EVENTS);
|
||||
consoleRef.current = conUpdated;
|
||||
setConsoleLogs(conUpdated);
|
||||
break;
|
||||
}
|
||||
|
||||
case "page_error": {
|
||||
const conUpdated = [...consoleRef.current, msg].slice(-MAX_EVENTS);
|
||||
consoleRef.current = conUpdated;
|
||||
setConsoleLogs(conUpdated);
|
||||
break;
|
||||
}
|
||||
|
||||
case "result": {
|
||||
const cmdIdx = eventsRef.current.findIndex(
|
||||
(e) => e.type === "command" && e.id === msg.id,
|
||||
);
|
||||
const base =
|
||||
cmdIdx >= 0
|
||||
? [
|
||||
...eventsRef.current.slice(0, cmdIdx),
|
||||
...eventsRef.current.slice(cmdIdx + 1),
|
||||
]
|
||||
: eventsRef.current;
|
||||
const updated = [...base, msg].slice(-MAX_EVENTS);
|
||||
eventsRef.current = updated;
|
||||
setEvents(updated);
|
||||
break;
|
||||
}
|
||||
|
||||
case "tabs":
|
||||
setTabs(msg.tabs);
|
||||
setTabCache((prev) => ({ ...prev, [port]: msg.tabs }));
|
||||
break;
|
||||
|
||||
case "url":
|
||||
setTabs((prev) =>
|
||||
prev.map((t) => (t.active ? { ...t, url: msg.url } : t)),
|
||||
);
|
||||
break;
|
||||
|
||||
case "error":
|
||||
break;
|
||||
}
|
||||
};
|
||||
}, [port, setWsRef, setConnected, setBrowserConnected, setScreencasting, setRecording, setVpWidth, setVpHeight, setFrame, setEvents, setConsoleLogs, setTabs, setEngine, setTabCache, setEngineCache]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
return () => {
|
||||
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
|
||||
wsRef.current?.close();
|
||||
setWsRef(null);
|
||||
};
|
||||
}, [connect, setWsRef]);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { atom } from "jotai";
|
||||
import type { TabInfo } from "@/types";
|
||||
import { activePortAtom } from "@/store/sessions";
|
||||
import { streamTabsAtom, streamEngineAtom } from "@/store/stream";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primitive atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const tabCacheAtom = atom<Record<number, TabInfo[]>>({});
|
||||
export const engineCacheAtom = atom<Record<number, string>>({});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived atoms (used by SessionTree to get tabs/engine for any port)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const tabsForPortAtom = atom((get) => {
|
||||
const activePort = get(activePortAtom);
|
||||
const streamTabs = get(streamTabsAtom);
|
||||
const cache = get(tabCacheAtom);
|
||||
|
||||
return (port: number): TabInfo[] => {
|
||||
if (port === activePort && streamTabs.length > 0) return streamTabs;
|
||||
return cache[port] ?? [];
|
||||
};
|
||||
});
|
||||
|
||||
export const engineForPortAtom = atom((get) => {
|
||||
const activePort = get(activePortAtom);
|
||||
const streamEngine = get(streamEngineAtom);
|
||||
const cache = get(engineCacheAtom);
|
||||
|
||||
return (port: number): string => {
|
||||
if (cache[port]) return cache[port];
|
||||
if (port === activePort && streamEngine) return streamEngine;
|
||||
return "";
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
export interface FrameMessage {
|
||||
type: "frame";
|
||||
data: string;
|
||||
metadata: {
|
||||
offsetTop: number;
|
||||
pageScaleFactor: number;
|
||||
deviceWidth: number;
|
||||
deviceHeight: number;
|
||||
scrollOffsetX: number;
|
||||
scrollOffsetY: number;
|
||||
timestamp: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StatusMessage {
|
||||
type: "status";
|
||||
connected: boolean;
|
||||
screencasting: boolean;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
engine?: string;
|
||||
recording?: boolean;
|
||||
}
|
||||
|
||||
export interface CommandMessage {
|
||||
type: "command";
|
||||
action: string;
|
||||
id: string;
|
||||
params: Record<string, unknown>;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface ResultMessage {
|
||||
type: "result";
|
||||
id: string;
|
||||
action: string;
|
||||
success: boolean;
|
||||
data: unknown;
|
||||
duration_ms: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface ConsoleMessage {
|
||||
type: "console";
|
||||
level: string;
|
||||
text: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface UrlMessage {
|
||||
type: "url";
|
||||
url: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface PageErrorMessage {
|
||||
type: "page_error";
|
||||
text: string;
|
||||
line: number | null;
|
||||
column: number | null;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface ErrorMessage {
|
||||
type: "error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TabInfo {
|
||||
index: number;
|
||||
title: string;
|
||||
url: string;
|
||||
type: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface TabsMessage {
|
||||
type: "tabs";
|
||||
tabs: TabInfo[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export type StreamMessage =
|
||||
| FrameMessage
|
||||
| StatusMessage
|
||||
| CommandMessage
|
||||
| ResultMessage
|
||||
| ConsoleMessage
|
||||
| PageErrorMessage
|
||||
| ErrorMessage
|
||||
| UrlMessage
|
||||
| TabsMessage;
|
||||
|
||||
export type ActivityEvent = CommandMessage | ResultMessage | ConsoleMessage;
|
||||
export type ConsoleEntry = ConsoleMessage | PageErrorMessage;
|
||||
|
||||
export interface ExtensionInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
session: string;
|
||||
port: number;
|
||||
engine?: string;
|
||||
extensions?: ExtensionInfo[];
|
||||
pending?: boolean;
|
||||
closing?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user