History

Redesign storage UI: cleaner layout, JSON viewer, no triple sidebar

- Replace overwhelming storage panel with cleaner two-panel layout (instances sidebar + full-width inspector with proper tabs) - Add react-json-view-lite for interactive JSON tree rendering in file preview, KV values, and SQL JSON mode with themed colors - Replace SQL tables third sidebar with inline horizontal chips - Fix page-level scroll: app shell uses h-screen for compact routes so only content areas scroll, not the nav sidebar - Collapsible create form, breadcrumb path nav, expandable KV rows, Cmd+Enter to run SQL, hover row highlights, null styling

Rhys Sullivan committed 8 months ago

Commit 8f1ee8f1 parent b43eea05 files · +760 −350Browse files

Unified · Split

5 files
  1. bun.lock +3 −0
  2. executor/apps/web/package.json +1 −0
  3. executor/apps/web/src/app/globals.css +100 −0
  4. executor/apps/web/src/components/app-shell.tsx +2 −2
  5. executor/apps/web/src/components/tools/storage.tsx +654 −348
bun.lock+3−0
@@ -111,6 +111,7 @@ · 110 unmodified lines
111111 "react-dom": "19.2.3",
112112 "react-hook-form": "^7.71.1",
113113 "react-infinite-scroll-component": "^6.1.1",
114+ "react-json-view-lite": "^2.5.0",
114115 "shiki": "^3.22.0",
115116 "sonner": "^2.0.7",
116117 "streamdown": "^2.1.0",
@@ -2301,6 +2302,8 @@ · 2184 unmodified lines
23012302
23022303 "react-infinite-scroll-component": ["react-infinite-scroll-component@6.1.1", "", { "dependencies": { "throttle-debounce": "^2.1.0" }, "peerDependencies": { "react": ">=16.0.0" } }, "sha512-R8YoOyiNDynSWmfVme5LHslsKrP+/xcRUWR2ies8UgUab9dtyw5ECnMCVPPmnmjjF4MWQmfVdRwRWcWaDgeyMA=="],
23032304
2305+ "react-json-view-lite": ["react-json-view-lite@2.5.0", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-tk7o7QG9oYyELWHL8xiMQ8x4WzjCzbWNyig3uexmkLb54r8jO0yH3WCWx8UZS0c49eSA4QUmG5caiRJ8fAn58g=="],
2306+
23042307 "react-reconciler": ["react-reconciler@0.31.0", "", { "dependencies": { "scheduler": "^0.25.0" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ=="],
23052308
23062309 "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
executor/apps/web/package.json+1−0
@@ -36,6 +36,7 @@ · 35 unmodified lines
3636 "react-dom": "19.2.3",
3737 "react-hook-form": "^7.71.1",
3838 "react-infinite-scroll-component": "^6.1.1",
39+ "react-json-view-lite": "^2.5.0",
3940 "shiki": "^3.22.0",
4041 "sonner": "^2.0.7",
4142 "streamdown": "^2.1.0",
executor/apps/web/src/app/globals.css+100−0
@@ -394,3 +394,103 @@ · 393 unmodified lines
394394 border-radius: 0.25rem;
395395 }
396396
397+/* ── JSON Viewer (react-json-view-lite custom theme) ───────────────────────── */
398+.json-viewer-root {
399+ font-family: var(--font-geist-mono), ui-monospace, monospace;
400+ font-size: 0.6875rem;
401+ line-height: 1.65;
402+}
403+
404+.json-viewer-container {
405+ line-height: 1.65;
406+ white-space: pre-wrap;
407+ word-wrap: break-word;
408+}
409+
410+.json-viewer-child {
411+ margin-right: 0;
412+}
413+
414+.json-viewer-label {
415+ font-weight: 500;
416+ margin-right: 6px;
417+ color: var(--foreground);
418+}
419+
420+.json-viewer-clickable-label {
421+ cursor: pointer;
422+}
423+
424+.json-viewer-clickable-label:hover {
425+ color: var(--primary);
426+}
427+
428+.json-viewer-null {
429+ color: var(--muted-foreground);
430+ font-style: italic;
431+}
432+
433+.json-viewer-undefined {
434+ color: var(--muted-foreground);
435+ font-style: italic;
436+}
437+
438+.json-viewer-number {
439+ color: var(--terminal-cyan);
440+}
441+
442+.json-viewer-string {
443+ color: var(--terminal-green);
444+}
445+
446+.json-viewer-boolean {
447+ color: var(--terminal-amber);
448+}
449+
450+.json-viewer-other {
451+ color: var(--terminal-red);
452+}
453+
454+.json-viewer-punctuation {
455+ color: var(--muted-foreground);
456+}
457+
458+.json-viewer-expand,
459+.json-viewer-collapse {
460+ font-size: 0.85em;
461+ margin-right: 4px;
462+ user-select: none;
463+ cursor: pointer;
464+ opacity: 0.5;
465+ transition: opacity 0.15s;
466+}
467+
468+.json-viewer-expand:hover,
469+.json-viewer-collapse:hover {
470+ opacity: 1;
471+}
472+
473+.json-viewer-expand::after {
474+ content: '\25B8';
475+}
476+
477+.json-viewer-collapse::after {
478+ content: '\25BE';
479+}
480+
481+.json-viewer-collapsed {
482+ margin-right: 4px;
483+ color: var(--muted-foreground);
484+ opacity: 0.5;
485+}
486+
487+.json-viewer-collapsed::after {
488+ content: '...';
489+ font-size: 0.8em;
490+}
491+
492+.json-viewer-fields {
493+ margin: 0;
494+ padding: 0 0 0 1rem;
495+}
496+
executor/apps/web/src/components/app-shell.tsx+2−2
@@ -251,13 +251,13 @@ export function AppShell({ children }: { children: React.ReactNode }) { · 250 unmodified lines
251251 }
252252
253253 return (
254− <div className="flex min-h-screen">
254+ <div className={cn("flex", useCompactChrome ? "h-screen overflow-hidden" : "min-h-screen")}>
255255 <ApprovalNotifier />
256256 <NoOrganizationModal enabled />
257257 <Sidebar />
258258 <div className="flex-1 flex flex-col min-w-0">
259259 <MobileHeader />
260− <main className={cn("flex-1 min-h-0", useCompactChrome ? "p-0" : "p-4 md:p-6 lg:p-8")}>{children}</main>
260+ <main className={cn("flex-1 min-h-0", useCompactChrome ? "p-0 overflow-hidden" : "p-4 md:p-6 lg:p-8")}>{children}</main>
261261 </div>
262262 </div>
263263 );
executor/apps/web/src/components/tools/storage.tsx+654−348
@@ -3,8 +3,11 @@ · 2 unmodified lines
33 import { useEffect, useMemo, useState } from "react";
44 import { useAction } from "convex/react";
55 import {
6+ ChevronDown,
7+ ChevronRight,
68 Database,
7− FolderOpen,
9+ File,
10+ Folder,
811 HardDrive,
912 KeyRound,
1013 Play,
@@ -12,15 +15,20 @@ import { · 1 unmodified line
1215 Power,
1316 Table,
1417 Trash2,
18+ X,
1519 } from "lucide-react";
20+import { JsonView } from "react-json-view-lite";
1621 import { Badge } from "@/components/ui/badge";
1722 import { Button } from "@/components/ui/button";
1823 import { Input } from "@/components/ui/input";
1924 import { Skeleton } from "@/components/ui/skeleton";
25+import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
2026 import type { StorageDurability, StorageInstanceRecord, StorageScopeType } from "@/lib/types";
2127 import { convexApi } from "@/lib/convex-api";
2228 import { cn } from "@/lib/utils";
2329
30+// ── Types ──────────────────────────────────────────────────────────────────
31+
2432 type CreateStorageArgs = {
2533 scopeType: StorageScopeType;
2634 durability: StorageDurability;
@@ -47,6 +55,8 @@ type StorageSqlObject = { · 20 unmodified lines
4755 type: "table" | "view" | "unknown";
4856 };
4957
58+// ── Constants ──────────────────────────────────────────────────────────────
59+
5060 const USER_TABLES_QUERY = [
5161 "SELECT name",
5262 "FROM sqlite_master",
@@ -71,6 +81,35 @@ const INTERNAL_SQL_OBJECT_NAMES = new Set([ · 18 unmodified lines
7181 "sqlite_sequence",
7282 ]);
7383
84+// ── Custom JSON viewer styles ──────────────────────────────────────────────
85+
86+const jsonViewerStyles = {
87+ container: "json-viewer-container",
88+ basicChildStyle: "json-viewer-child",
89+ label: "json-viewer-label",
90+ clickableLabel: "json-viewer-clickable-label",
91+ nullValue: "json-viewer-null",
92+ undefinedValue: "json-viewer-undefined",
93+ numberValue: "json-viewer-number",
94+ stringValue: "json-viewer-string",
95+ booleanValue: "json-viewer-boolean",
96+ otherValue: "json-viewer-other",
97+ punctuation: "json-viewer-punctuation",
98+ expandIcon: "json-viewer-expand",
99+ collapseIcon: "json-viewer-collapse",
100+ collapsedContent: "json-viewer-collapsed",
101+ childFieldsContainer: "json-viewer-fields",
102+ noQuotesForStringValues: false,
103+ quotesForFieldNames: true,
104+ stringifyStringValues: true,
105+ ariaLables: {
106+ collapseJson: "Collapse",
107+ expandJson: "Expand",
108+ },
109+} as const;
110+
111+// ── Helpers ────────────────────────────────────────────────────────────────
112+
74113 function prettyBytes(value?: number): string {
75114 if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
76115 return "-";
@@ -94,6 +133,20 @@ function asLocalDate(value?: number): string { · 17 unmodified lines
94133 return new Date(value).toLocaleString();
95134 }
96135
136+function relativeTime(value?: number): string {
137+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
138+ return "-";
139+ }
140+ const seconds = Math.floor((Date.now() - value) / 1000);
141+ if (seconds < 60) return "just now";
142+ const minutes = Math.floor(seconds / 60);
143+ if (minutes < 60) return `${minutes}m ago`;
144+ const hours = Math.floor(minutes / 60);
145+ if (hours < 24) return `${hours}h ago`;
146+ const days = Math.floor(hours / 24);
147+ return `${days}d ago`;
148+}
149+
97150 function joinStoragePath(basePath: string, name: string): string {
98151 const base = (basePath.trim() || "/").replace(/\/+$/, "");
99152 if (!base || base === "/") {
@@ -159,6 +212,51 @@ function escapeSqlIdentifier(value: string): string { · 59 unmodified lines
159212 return value.replaceAll('"', '""');
160213 }
161214
215+function isJsonLike(content: string): boolean {
216+ const trimmed = content.trimStart();
217+ return trimmed.startsWith("{") || trimmed.startsWith("[");
218+}
219+
220+function tryParseJson(content: string): unknown | null {
221+ try {
222+ return JSON.parse(content);
223+ } catch {
224+ return null;
225+ }
226+}
227+
228+function shouldExpandJsonNode(level: number): boolean {
229+ return level < 2;
230+}
231+
232+// ── Scope badge color ──────────────────────────────────────────────────────
233+
234+function scopeColor(scope: string): string {
235+ switch (scope) {
236+ case "scratch": return "text-muted-foreground";
237+ case "account": return "text-terminal-cyan";
238+ case "workspace": return "text-terminal-green";
239+ case "organization": return "text-terminal-amber";
240+ default: return "text-muted-foreground";
241+ }
242+}
243+
244+// ── Inline JSON Viewer ─────────────────────────────────────────────────────
245+
246+function JsonPreview({ data, className }: { data: unknown; className?: string }) {
247+ if (data === null || data === undefined || typeof data !== "object") {
248+ return <pre className={cn("font-mono text-xs leading-relaxed whitespace-pre-wrap break-words", className)}>{String(data)}</pre>;
249+ }
250+
251+ return (
252+ <div className={cn("json-viewer-root font-mono text-xs", className)}>
253+ <JsonView data={data as object} style={jsonViewerStyles} shouldExpandNode={shouldExpandJsonNode} />
254+ </div>
255+ );
256+}
257+
258+// ── Main Component ─────────────────────────────────────────────────────────
259+
162260 export function StoragePanel({
163261 workspaceId,
164262 sessionId,
@@ -180,13 +278,18 @@ export function StoragePanel({ · 15 unmodified lines
180278 onClose: (instanceId: string) => Promise<void>;
181279 onDelete: (instanceId: string) => Promise<void>;
182280 }) {
281+ // ── Create form state ──
282+ const [showCreateForm, setShowCreateForm] = useState(false);
183283 const [scopeType, setScopeType] = useState<StorageScopeType>("scratch");
184284 const [durability, setDurability] = useState<StorageDurability>("ephemeral");
185285 const [purpose, setPurpose] = useState("");
186286 const [ttlHours, setTtlHours] = useState("24");
287+
288+ // ── Selection state ──
187289 const [selectedInstanceId, setSelectedInstanceId] = useState<string | undefined>(undefined);
188290 const [activeInspectorTab, setActiveInspectorTab] = useState<"fs" | "kv" | "sql">("fs");
189291
292+ // ── FS state ──
190293 const [fsPath, setFsPath] = useState("/");
191294 const [fsEntries, setFsEntries] = useState<StorageDirectoryEntry[]>([]);
192295 const [fsLoading, setFsLoading] = useState(false);
@@ -196,12 +299,15 @@ export function StoragePanel({ · 3 unmodified lines
196299 const [filePreviewBytes, setFilePreviewBytes] = useState<number | null>(null);
197300 const [filePreviewLoading, setFilePreviewLoading] = useState(false);
198301
302+ // ── KV state ──
199303 const [kvPrefix, setKvPrefix] = useState("");
200304 const [kvLimit, setKvLimit] = useState("100");
201305 const [kvItems, setKvItems] = useState<Array<{ key: string; value: unknown }>>([]);
202306 const [kvLoading, setKvLoading] = useState(false);
203307 const [kvError, setKvError] = useState<string | null>(null);
308+ const [expandedKvKey, setExpandedKvKey] = useState<string | null>(null);
204309
310+ // ── SQL state ──
205311 const [sqlText, setSqlText] = useState(USER_TABLES_QUERY);
206312 const [sqlMaxRows, setSqlMaxRows] = useState("200");
207313 const [sqlViewMode, setSqlViewMode] = useState<"table" | "json">("table");
@@ -214,11 +320,13 @@ export function StoragePanel({ · 6 unmodified lines
214320 const [sqlShowInternalObjects, setSqlShowInternalObjects] = useState(false);
215321 const [selectedSqlObjectName, setSelectedSqlObjectName] = useState<string | null>(null);
216322
323+ // ── Actions ──
217324 const listDirectory = useAction(convexApi.executorNode.storageListDirectory);
218325 const readFileAction = useAction(convexApi.executorNode.storageReadFile);
219326 const listKv = useAction(convexApi.executorNode.storageListKv);
220327 const querySql = useAction(convexApi.executorNode.storageQuerySql);
221328
329+ // ── Derived ──
222330 const visibleInstances = useMemo(
223331 () => [...instances].sort((a, b) => b.updatedAt - a.updatedAt),
224332 [instances],
@@ -251,11 +359,10 @@ export function StoragePanel({ · 26 unmodified lines
251359 [sqlObjects, sqlShowInternalObjects],
252360 );
253361
254− const refreshDirectory = async (nextPath?: string) => {
255− if (!workspaceId || !selectedInstance) {
256− return;
257− }
362+ // ── Data fetchers ──
258363
364+ const refreshDirectory = async (nextPath?: string) => {
365+ if (!workspaceId || !selectedInstance) return;
259366 const path = (nextPath ?? fsPath).trim() || "/";
260367 setFsLoading(true);
261368 setFsError(null);
@@ -277,10 +384,7 @@ export function StoragePanel({ · 15 unmodified lines
277384 };
278385
279386 const readFilePreview = async (path: string) => {
280− if (!workspaceId || !selectedInstance) {
281− return;
282− }
283−
387+ if (!workspaceId || !selectedInstance) return;
284388 setFilePreviewLoading(true);
285389 try {
286390 const result = await readFileAction({
@@ -303,10 +407,7 @@ export function StoragePanel({ · 16 unmodified lines
303407 };
304408
305409 const refreshKv = async () => {
306− if (!workspaceId || !selectedInstance) {
307− return;
308− }
309−
410+ if (!workspaceId || !selectedInstance) return;
310411 const parsedLimit = Number.parseInt(kvLimit, 10);
311412 setKvLoading(true);
312413 setKvError(null);
@@ -328,15 +429,9 @@ export function StoragePanel({ · 15 unmodified lines
328429 };
329430
330431 const runSql = async (queryOverride?: string) => {
331− if (!workspaceId || !selectedInstance) {
332− return;
333− }
334−
432+ if (!workspaceId || !selectedInstance) return;
335433 const sql = (queryOverride ?? sqlText).trim();
336− if (!sql) {
337− return;
338− }
339−
434+ if (!sql) return;
340435 const parsedMaxRows = Number.parseInt(sqlMaxRows, 10);
341436 setSqlLoading(true);
342437 setSqlError(null);
@@ -348,9 +443,7 @@ export function StoragePanel({ · 5 unmodified lines
348443 sql,
349444 maxRows: Number.isFinite(parsedMaxRows) ? parsedMaxRows : 200,
350445 });
351− if (queryOverride) {
352− setSqlText(sql);
353− }
446+ if (queryOverride) setSqlText(sql);
354447 setSqlResult(result as StorageSqlResult);
355448 } catch (error) {
356449 setSqlError(error instanceof Error ? error.message : "Failed to query SQLite");
@@ -361,10 +454,7 @@ export function StoragePanel({ · 4 unmodified lines
361454 };
362455
363456 const refreshSqlObjects = async () => {
364− if (!workspaceId || !selectedInstance) {
365− return [] as StorageSqlObject[];
366− }
367−
457+ if (!workspaceId || !selectedInstance) return [] as StorageSqlObject[];
368458 setSqlObjectsLoading(true);
369459 setSqlObjectsError(null);
370460 try {
@@ -375,7 +465,6 @@ export function StoragePanel({ · 4 unmodified lines
375465 sql: SQL_OBJECTS_QUERY,
376466 maxRows: 1000,
377467 });
378−
379468 const rows = ((result as StorageSqlResult).rows ?? []) as Array<Record<string, unknown>>;
380469 const objects = rows
381470 .map((row) => ({
@@ -383,7 +472,6 @@ export function StoragePanel({ · 1 unmodified line
383472 type: sqlObjectType(row.type),
384473 }))
385474 .filter((entry) => entry.name.length > 0);
386−
387475 setSqlObjects(objects);
388476 return objects;
389477 } catch (error) {
@@ -403,6 +491,8 @@ export function StoragePanel({ · 13 unmodified lines
403491 await runSql(`SELECT * FROM "${escapeSqlIdentifier(objectName)}" LIMIT ${limit}`);
404492 };
405493
494+ // ── Effects ──
495+
406496 useEffect(() => {
407497 if (!canInspect) {
408498 setFsEntries([]);
@@ -439,101 +529,113 @@ export function StoragePanel({ · 30 unmodified lines
439529 ...(durability === "ephemeral" && Number.isFinite(parsedTtl) ? { ttlHours: parsedTtl } : {}),
440530 });
441531 setPurpose("");
532+ setShowCreateForm(false);
442533 };
443534
535+ // path breadcrumbs
536+ const pathSegments = fsPath.split("/").filter(Boolean);
537+
444538 return (
445− <section className="flex h-full min-h-0 w-full overflow-hidden rounded-none border border-border/50 bg-card/30">
446− <aside className="flex h-full w-72 shrink-0 flex-col border-r border-border/40 lg:w-80 xl:w-[22rem]">
447− <div className="flex items-center justify-between border-b border-border/40 px-3 py-2.5">
539+ <section className="flex h-full min-h-0 w-full overflow-hidden border-t border-border/40 bg-background">
540+ {/* ── Left sidebar: instances ── */}
541+ <aside className="flex h-full w-64 shrink-0 flex-col border-r border-border/40 bg-card/30 lg:w-72">
542+ {/* Header */}
543+ <div className="flex items-center justify-between border-b border-border/40 px-3 py-2">
448544 <div className="flex items-center gap-2">
449− <div className="flex h-7 w-7 items-center justify-center border border-border/60 bg-background/80">
450− <Database className="h-4 w-4 text-muted-foreground" />
451− </div>
452− <h2 className="text-sm font-medium">Storage</h2>
545+ <Database className="h-3.5 w-3.5 text-muted-foreground" />
546+ <span className="text-xs font-medium text-foreground">Instances</span>
547+ {visibleInstances.length > 0 && (
548+ <Badge variant="outline" className="h-4 px-1.5 text-[9px] font-mono text-muted-foreground">
549+ {visibleInstances.length}
550+ </Badge>
551+ )}
453552 </div>
454− <Badge variant="outline" className="h-5 px-2 text-[10px] font-mono uppercase tracking-wide text-muted-foreground">
455− {visibleInstances.length}
456− </Badge>
553+ <Button
554+ variant={showCreateForm ? "default" : "outline"}
555+ size="sm"
556+ className="h-6 rounded-md px-2 text-[10px]"
557+ onClick={() => setShowCreateForm(!showCreateForm)}
558+ >
559+ {showCreateForm ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
560+ </Button>
457561 </div>
458562
459− <div className="shrink-0 border-b border-border/40 bg-background/40 px-3 py-3">
460− <div className="space-y-2">
461− <label className="flex flex-col gap-1 text-[11px] text-muted-foreground">
462− Scope
463− <select
464− className="h-8 rounded-none border border-border/60 bg-background px-2 text-xs text-foreground"
465− value={scopeType}
466− onChange={(event) => {
467− const next = event.target.value as StorageScopeType;
468− setScopeType(next);
469− if (next !== "scratch") {
470− setDurability("durable");
471− }
472− }}
473− >
474− <option value="scratch">scratch</option>
475− <option value="account">account</option>
476− <option value="workspace">workspace</option>
477− <option value="organization">organization</option>
478− </select>
479− </label>
480−
481− <label className="flex flex-col gap-1 text-[11px] text-muted-foreground">
482− Durability
483− <select
484− className="h-8 rounded-none border border-border/60 bg-background px-2 text-xs text-foreground"
485− value={durability}
486− onChange={(event) => setDurability(event.target.value as StorageDurability)}
487− >
488− <option value="ephemeral">ephemeral</option>
489− <option value="durable">durable</option>
490− </select>
491− </label>
492−
493− <label className="flex flex-col gap-1 text-[11px] text-muted-foreground">
494− Purpose
563+ {/* Create form (collapsible) */}
564+ {showCreateForm && (
565+ <div className="border-b border-border/40 bg-muted/30 px-3 py-3">
566+ <div className="space-y-2">
567+ <div className="grid grid-cols-2 gap-2">
568+ <label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
569+ Scope
570+ <select
571+ className="h-7 rounded-md border border-border/60 bg-background px-2 text-[11px] text-foreground"
572+ value={scopeType}
573+ onChange={(event) => {
574+ const next = event.target.value as StorageScopeType;
575+ setScopeType(next);
576+ if (next !== "scratch") setDurability("durable");
577+ }}
578+ >
579+ <option value="scratch">scratch</option>
580+ <option value="account">account</option>
581+ <option value="workspace">workspace</option>
582+ <option value="organization">organization</option>
583+ </select>
584+ </label>
585+ <label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
586+ Durability
587+ <select
588+ className="h-7 rounded-md border border-border/60 bg-background px-2 text-[11px] text-foreground"
589+ value={durability}
590+ onChange={(event) => setDurability(event.target.value as StorageDurability)}
591+ >
592+ <option value="ephemeral">ephemeral</option>
593+ <option value="durable">durable</option>
594+ </select>
595+ </label>
596+ </div>
495597 <Input
496598 value={purpose}
497599 onChange={(event) => setPurpose(event.target.value)}
498− placeholder="scratch for repo indexing"
499− className="h-8 rounded-none text-xs"
600+ placeholder="Purpose (optional)"
601+ className="h-7 rounded-md text-[11px]"
500602 />
501− </label>
502−
503− <div className="flex items-end gap-2">
504− <label className="flex flex-1 flex-col gap-1 text-[11px] text-muted-foreground">
505− TTL (hours)
603+ <div className="flex items-end gap-2">
506604 <Input
507605 value={ttlHours}
508606 onChange={(event) => setTtlHours(event.target.value)}
509607 disabled={durability !== "ephemeral"}
510− className="h-8 rounded-none text-xs"
608+ placeholder="TTL (hours)"
609+ className="h-7 flex-1 rounded-md text-[11px]"
511610 />
512− </label>
513− <Button size="sm" className="h-8 rounded-none text-xs" disabled={creating} onClick={() => void submitCreate()}>
514− <Plus className="mr-1.5 h-3.5 w-3.5" />
515− Create
516− </Button>
611+ <Button size="sm" className="h-7 rounded-md px-3 text-[10px]" disabled={creating} onClick={() => void submitCreate()}>
612+ Create
613+ </Button>
614+ </div>
517615 </div>
518616 </div>
519− </div>
617+ )}
520618
521− <div className="min-h-0 flex-1 overflow-y-auto p-2">
619+ {/* Instance list */}
620+ <div className="min-h-0 flex-1 overflow-y-auto">
522621 {loading ? (
523− <div className="space-y-2">
622+ <div className="space-y-1 p-2">
524623 {Array.from({ length: 4 }).map((_, i) => (
525− <Skeleton key={i} className="h-16 rounded-none" />
624+ <Skeleton key={i} className="h-14 rounded-md" />
526625 ))}
527626 </div>
528627 ) : visibleInstances.length === 0 ? (
529− <div className="flex h-full min-h-[220px] flex-col items-center justify-center gap-2 border border-dashed border-border/50 bg-background/50 p-3">
530− <div className="flex h-10 w-10 items-center justify-center border border-border/60 bg-muted/40">
531− <FolderOpen className="h-5 w-5 text-muted-foreground/50" />
628+ <div className="flex h-full flex-col items-center justify-center gap-3 px-4 py-8">
629+ <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted/50">
630+ <Database className="h-5 w-5 text-muted-foreground/40" />
631+ </div>
632+ <div className="text-center">
633+ <p className="text-xs font-medium text-muted-foreground">No instances</p>
634+ <p className="mt-0.5 text-[10px] text-muted-foreground/60">Create one to get started</p>
532635 </div>
533− <p className="text-sm text-muted-foreground">No storage instances yet</p>
534636 </div>
535637 ) : (
536− <div className="space-y-2">
638+ <div className="p-1.5 space-y-0.5">
537639 {visibleInstances.map((instance) => {
538640 const busy = busyInstanceId === instance.id;
539641 const active = selectedInstance?.id === instance.id;
@@ -541,47 +643,70 @@ export function StoragePanel({ · 1 unmodified line
541643 <div
542644 key={instance.id}
543645 className={cn(
544− "group flex items-center gap-2 border px-2 py-2 transition-colors",
545− active ? "border-primary/40 bg-primary/5" : "border-border/50 bg-background/70",
546− busy ? "opacity-60" : "hover:border-border hover:bg-accent/20",
646+ "group relative rounded-md transition-all duration-150",
647+ active
648+ ? "bg-primary/8 ring-1 ring-primary/20"
649+ : "hover:bg-accent/50",
650+ busy && "opacity-50 pointer-events-none",
547651 )}
548652 >
549653 <button
550654 type="button"
551− className="flex min-w-0 flex-1 items-center gap-2 text-left"
655+ className="flex w-full items-start gap-2.5 px-2.5 py-2 text-left"
552656 onClick={() => setSelectedInstanceId(instance.id)}
553657 >
554− <div className="flex h-7 w-7 shrink-0 items-center justify-center border border-border/60 bg-muted/50">
555− <Database className="h-3.5 w-3.5 text-muted-foreground" />
658+ <div className={cn(
659+ "mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md",
660+ active ? "bg-primary/15 text-primary" : "bg-muted/60 text-muted-foreground",
661+ )}>
662+ <Database className="h-3 w-3" />
556663 </div>
557664 <div className="min-w-0 flex-1">
558− <p className="truncate text-[12px] font-medium">{instance.purpose || instance.id}</p>
559− <p className="text-[10px] text-muted-foreground">
560− {instance.scopeType} · {instance.durability} · {instance.status}
561− </p>
562− <p className="text-[10px] text-muted-foreground">
563− {prettyBytes(instance.sizeBytes)} · {instance.fileCount ?? "-"} inode{instance.fileCount === 1 ? "" : "s"}
665+ <p className={cn(
666+ "truncate text-[11px] font-medium",
667+ active ? "text-foreground" : "text-foreground/80",
668+ )}>
669+ {instance.purpose || instance.id.slice(0, 12)}
564670 </p>
671+ <div className="mt-0.5 flex items-center gap-1.5">
672+ <span className={cn("text-[9px] font-medium uppercase tracking-wider", scopeColor(instance.scopeType))}>
673+ {instance.scopeType}
674+ </span>
675+ <span className="text-[9px] text-muted-foreground/40">/</span>
676+ <span className="text-[9px] text-muted-foreground">
677+ {prettyBytes(instance.sizeBytes)}
678+ </span>
679+ <span className="text-[9px] text-muted-foreground/40">/</span>
680+ <span className="text-[9px] text-muted-foreground">
681+ {relativeTime(instance.lastSeenAt)}
682+ </span>
683+ </div>
565684 </div>
566685 </button>
567− <div className="flex items-center gap-1">
686+ {/* Action buttons - visible on hover/active */}
687+ <div className={cn(
688+ "absolute right-1.5 top-1.5 flex items-center gap-0.5 transition-opacity",
689+ active ? "opacity-100" : "opacity-0 group-hover:opacity-100",
690+ )}>
568691 <Button
569− variant="outline"
692+ variant="ghost"
570693 size="sm"
571− className="h-7 rounded-none px-2 text-[11px]"
694+ className="h-5 w-5 rounded p-0 text-muted-foreground hover:text-foreground"
572695 disabled={busy}
573− onClick={() => void onClose(instance.id)}
696+ onClick={(e) => { e.stopPropagation(); void onClose(instance.id); }}
697+ title="Close instance"
574698 >
575− <Power className="h-3 w-3" />
699+ <Power className="h-2.5 w-2.5" />
576700 </Button>
577701 <Button
578− variant="outline"
702+ variant="ghost"
579703 size="sm"
580− className="h-7 rounded-none border-destructive/40 px-2 text-[11px] text-destructive hover:bg-destructive/10"
704+ className="h-5 w-5 rounded p-0 text-muted-foreground hover:text-destructive"
581705 disabled={busy}
582− onClick={() => void onDelete(instance.id)}
706+ onClick={(e) => { e.stopPropagation(); void onDelete(instance.id); }}
707+ title="Delete instance"
583708 >
584− <Trash2 className="h-3 w-3" />
709+ <Trash2 className="h-2.5 w-2.5" />
585710 </Button>
586711 </div>
587712 </div>
@@ -592,172 +717,332 @@ export function StoragePanel({ · 4 unmodified lines
592717 </div>
593718 </aside>
594719
595− <div className="flex min-h-0 flex-1 min-w-0 flex-col bg-background/50">
720+ {/* ── Right panel: inspector ── */}
721+ <div className="flex min-h-0 flex-1 min-w-0 flex-col">
596722 {selectedInstance ? (
597− <>
598− <div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/40 px-4 py-2.5">
599− <div>
600− <p className="text-xs font-medium">Inspector</p>
601− <p className="text-[11px] text-muted-foreground">{selectedInstance.id} · last used {asLocalDate(selectedInstance.lastSeenAt)}</p>
602− </div>
603− <div className="flex items-center gap-1">
604− <Button
605− variant={activeInspectorTab === "fs" ? "default" : "outline"}
606− size="sm"
607− className="h-7 rounded-none px-2 text-[11px]"
608− onClick={() => setActiveInspectorTab("fs")}
609− >
610− <HardDrive className="mr-1 h-3 w-3" /> FS
611− </Button>
612− <Button
613− variant={activeInspectorTab === "kv" ? "default" : "outline"}
614− size="sm"
615− className="h-7 rounded-none px-2 text-[11px]"
616− onClick={() => setActiveInspectorTab("kv")}
617− >
618− <KeyRound className="mr-1 h-3 w-3" /> KV
619− </Button>
620− <Button
621− variant={activeInspectorTab === "sql" ? "default" : "outline"}
622− size="sm"
623− className="h-7 rounded-none px-2 text-[11px]"
624− onClick={() => setActiveInspectorTab("sql")}
625− >
626− <Table className="mr-1 h-3 w-3" /> SQLite
627− </Button>
723+ <Tabs
724+ value={activeInspectorTab}
725+ onValueChange={(value) => setActiveInspectorTab(value as "fs" | "kv" | "sql")}
726+ className="flex h-full min-h-0 flex-col"
727+ >
728+ {/* Inspector header */}
729+ <div className="flex items-center justify-between border-b border-border/40 px-4 py-1.5 bg-card/20">
730+ <div className="flex items-center gap-3 min-w-0">
731+ <div className="min-w-0">
732+ <p className="truncate text-[11px] font-medium text-foreground">
733+ {selectedInstance.purpose || selectedInstance.id.slice(0, 20)}
734+ </p>
735+ <p className="text-[9px] text-muted-foreground">
736+ {selectedInstance.durability} · {selectedInstance.status} · last used {asLocalDate(selectedInstance.lastSeenAt)}
737+ </p>
738+ </div>
628739 </div>
740+ <TabsList variant="line" className="h-8">
741+ <TabsTrigger value="fs" className="gap-1.5 px-3 text-[11px]">
742+ <HardDrive className="h-3 w-3" />
743+ Files
744+ </TabsTrigger>
745+ <TabsTrigger value="kv" className="gap-1.5 px-3 text-[11px]">
746+ <KeyRound className="h-3 w-3" />
747+ KV
748+ </TabsTrigger>
749+ <TabsTrigger value="sql" className="gap-1.5 px-3 text-[11px]">
750+ <Table className="h-3 w-3" />
751+ SQLite
752+ </TabsTrigger>
753+ </TabsList>
629754 </div>
630755
631− {!canInspect ? (
632− <div className="p-4 text-xs text-muted-foreground">Select a signed-in workspace session to inspect contents.</div>
633− ) : null}
634−
635− {activeInspectorTab === "fs" ? (
636− <div className="space-y-2 p-4">
637− <div className="flex items-center gap-2">
638− <Input
639− value={fsPath}
640− onChange={(event) => setFsPath(event.target.value)}
641− className="h-8 rounded-none text-xs"
642− placeholder="/"
643− />
756+ {/* ── FS Tab ── */}
757+ <TabsContent value="fs" className="flex-1 min-h-0 overflow-hidden">
758+ <div className="flex h-full flex-col">
759+ {/* Path bar */}
760+ <div className="flex items-center gap-2 border-b border-border/30 px-4 py-2 bg-muted/20">
761+ <div className="flex items-center gap-1 min-w-0 flex-1 text-[11px]">
762+ <button
763+ type="button"
764+ className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
765+ onClick={() => void refreshDirectory("/")}
766+ >
767+ /
768+ </button>
769+ {pathSegments.map((segment, i) => {
770+ const segmentPath = "/" + pathSegments.slice(0, i + 1).join("/");
771+ return (
772+ <span key={segmentPath} className="flex items-center gap-1">
773+ <span className="text-muted-foreground/40">/</span>
774+ <button
775+ type="button"
776+ className={cn(
777+ "hover:text-foreground transition-colors truncate",
778+ i === pathSegments.length - 1 ? "text-foreground font-medium" : "text-muted-foreground",
779+ )}
780+ onClick={() => void refreshDirectory(segmentPath)}
781+ >
782+ {segment}
783+ </button>
784+ </span>
785+ );
786+ })}
787+ </div>
644788 <Button
789+ variant="ghost"
645790 size="sm"
646− className="h-8 rounded-none text-xs"
791+ className="h-6 shrink-0 rounded-md px-2 text-[10px] text-muted-foreground"
647792 disabled={fsLoading || !canInspect}
648793 onClick={() => void refreshDirectory()}
649794 >
650795 Refresh
651796 </Button>
652797 </div>
653− {fsError ? <p className="text-[11px] text-destructive">{fsError}</p> : null}
654798
655− <div className="max-h-56 overflow-auto border border-border/40">
799+ {fsError && (
800+ <div className="border-b border-destructive/20 bg-destructive/5 px-4 py-1.5 text-[11px] text-destructive">
801+ {fsError}
802+ </div>
803+ )}
804+
805+ {/* File list + preview */}
806+ <div className="flex-1 min-h-0 overflow-y-auto">
656807 {fsLoading ? (
657− <div className="p-2 text-[11px] text-muted-foreground">Loading...</div>
658− ) : fsEntries.length === 0 ? (
659− <div className="p-2 text-[11px] text-muted-foreground">No entries</div>
808+ <div className="p-4 space-y-1">
809+ {Array.from({ length: 5 }).map((_, i) => (
810+ <Skeleton key={i} className="h-8 rounded-md" />
811+ ))}
812+ </div>
813+ ) : fsEntries.length === 0 && !filePreviewPath ? (
814+ <div className="flex h-full items-center justify-center">
815+ <p className="text-xs text-muted-foreground/60">Empty directory</p>
816+ </div>
660817 ) : (
661− <div className="divide-y divide-border/30">
662− {fsEntries.map((entry) => {
663− const nextPath = joinStoragePath(fsPath, entry.name);
664− return (
665− <div key={`${entry.type}:${entry.name}`} className="flex items-center justify-between gap-2 px-2 py-1.5 text-[11px]">
666− <div className="min-w-0">
667− <p className="truncate font-medium">{entry.name}</p>
668− <p className="text-muted-foreground">{entry.type} {typeof entry.size === "number" ? `- ${prettyBytes(entry.size)}` : ""}</p>
818+ <div>
819+ {/* Directory listing */}
820+ {fsEntries.length > 0 && (
821+ <div className="divide-y divide-border/20">
822+ {/* Parent directory link */}
823+ {fsPath !== "/" && (
824+ <button
825+ type="button"
826+ className="flex w-full items-center gap-3 px-4 py-2 text-left hover:bg-accent/30 transition-colors"
827+ onClick={() => {
828+ const parent = fsPath.split("/").slice(0, -1).join("/") || "/";
829+ void refreshDirectory(parent);
830+ }}
831+ >
832+ <Folder className="h-3.5 w-3.5 text-muted-foreground/50" />
833+ <span className="text-[11px] text-muted-foreground">..</span>
834+ </button>
835+ )}
836+ {fsEntries.map((entry) => {
837+ const nextPath = joinStoragePath(fsPath, entry.name);
838+ const isDir = entry.type === "directory";
839+ return (
840+ <button
841+ key={`${entry.type}:${entry.name}`}
842+ type="button"
843+ className={cn(
844+ "flex w-full items-center gap-3 px-4 py-2 text-left transition-colors",
845+ "hover:bg-accent/30",
846+ filePreviewPath === nextPath && "bg-primary/5",
847+ )}
848+ onClick={() => {
849+ if (isDir) {
850+ void refreshDirectory(nextPath);
851+ } else if (entry.type === "file") {
852+ void readFilePreview(nextPath);
853+ }
854+ }}
855+ >
856+ {isDir ? (
857+ <Folder className="h-3.5 w-3.5 shrink-0 text-terminal-amber/70" />
858+ ) : (
859+ <File className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
860+ )}
861+ <span className="flex-1 min-w-0 truncate text-[11px] font-medium text-foreground/90">
862+ {entry.name}
863+ </span>
864+ {typeof entry.size === "number" && (
865+ <span className="shrink-0 text-[10px] text-muted-foreground/60">{prettyBytes(entry.size)}</span>
866+ )}
867+ </button>
868+ );
869+ })}
870+ </div>
871+ )}
872+
873+ {/* File preview */}
874+ {filePreviewPath && (
875+ <div className="border-t border-border/30">
876+ <div className="flex items-center justify-between border-b border-border/20 bg-muted/20 px-4 py-1.5">
877+ <div className="flex items-center gap-2 min-w-0">
878+ <File className="h-3 w-3 shrink-0 text-muted-foreground" />
879+ <span className="truncate text-[11px] font-medium text-foreground/80">
880+ {filePreviewPath.split("/").pop()}
881+ </span>
882+ {filePreviewBytes !== null && (
883+ <span className="shrink-0 text-[10px] text-muted-foreground/60">
884+ {prettyBytes(filePreviewBytes)}
885+ </span>
886+ )}
669887 </div>
670− {entry.type === "directory" ? (
671− <Button variant="outline" size="sm" className="h-6 rounded-none px-2 text-[10px]" onClick={() => void refreshDirectory(nextPath)}>
672− Open
673− </Button>
674− ) : entry.type === "file" ? (
675− <Button variant="outline" size="sm" className="h-6 rounded-none px-2 text-[10px]" onClick={() => void readFilePreview(nextPath)}>
676− Read
677− </Button>
678− ) : null}
888+ <Button
889+ variant="ghost"
890+ size="sm"
891+ className="h-5 w-5 shrink-0 rounded p-0 text-muted-foreground hover:text-foreground"
892+ onClick={() => {
893+ setFilePreviewPath(null);
894+ setFilePreviewContent("");
895+ setFilePreviewBytes(null);
896+ }}
897+ >
898+ <X className="h-3 w-3" />
899+ </Button>
679900 </div>
680− );
681− })}
901+ <div className="max-h-[60vh] overflow-auto p-4">
902+ {filePreviewLoading ? (
903+ <div className="space-y-1">
904+ {Array.from({ length: 8 }).map((_, i) => (
905+ <Skeleton key={i} className="h-4 rounded" style={{ width: `${40 + Math.random() * 50}%` }} />
906+ ))}
907+ </div>
908+ ) : isJsonLike(filePreviewContent) && tryParseJson(filePreviewContent) !== null ? (
909+ <JsonPreview data={tryParseJson(filePreviewContent)} />
910+ ) : (
911+ <pre className="font-mono text-xs leading-relaxed text-foreground/80 whitespace-pre-wrap break-words">
912+ {filePreviewContent}
913+ </pre>
914+ )}
915+ </div>
916+ </div>
917+ )}
682918 </div>
683919 )}
684920 </div>
685−
686− {filePreviewPath ? (
687− <div className="border border-border/40">
688− <div className="flex items-center justify-between border-b border-border/30 px-2 py-1 text-[11px]">
689− <span className="truncate">{filePreviewPath}</span>
690− <span className="text-muted-foreground">{filePreviewBytes !== null ? prettyBytes(filePreviewBytes) : "-"}</span>
691− </div>
692− <pre className="max-h-[28rem] overflow-auto p-2 text-[11px] leading-5">{filePreviewLoading ? "Loading..." : filePreviewContent}</pre>
693− </div>
694− ) : null}
695921 </div>
696− ) : null}
922+ </TabsContent>
697923
698− {activeInspectorTab === "kv" ? (
699− <div className="space-y-2 p-4">
700− <div className="flex items-center gap-2">
924+ {/* ── KV Tab ── */}
925+ <TabsContent value="kv" className="flex-1 min-h-0 overflow-hidden">
926+ <div className="flex h-full flex-col">
927+ {/* Search bar */}
928+ <div className="flex items-center gap-2 border-b border-border/30 px-4 py-2 bg-muted/20">
701929 <Input
702930 value={kvPrefix}
703931 onChange={(event) => setKvPrefix(event.target.value)}
704− className="h-8 rounded-none text-xs"
705− placeholder="prefix"
932+ className="h-7 flex-1 rounded-md text-[11px]"
933+ placeholder="Filter by key prefix..."
706934 />
707935 <Input
708936 value={kvLimit}
709937 onChange={(event) => setKvLimit(event.target.value)}
710− className="h-8 w-24 rounded-none text-xs"
938+ className="h-7 w-16 rounded-md text-[11px] text-center"
711939 placeholder="100"
712940 />
713941 <Button
942+ variant="ghost"
714943 size="sm"
715− className="h-8 rounded-none text-xs"
944+ className="h-7 shrink-0 rounded-md px-2 text-[10px] text-muted-foreground"
716945 disabled={kvLoading || !canInspect}
717946 onClick={() => void refreshKv()}
718947 >
719948 Refresh
720949 </Button>
721950 </div>
722− {kvError ? <p className="text-[11px] text-destructive">{kvError}</p> : null}
723951
724− <div className="max-h-[36rem] overflow-auto border border-border/40">
952+ {kvError && (
953+ <div className="border-b border-destructive/20 bg-destructive/5 px-4 py-1.5 text-[11px] text-destructive">
954+ {kvError}
955+ </div>
956+ )}
957+
958+ {/* KV list */}
959+ <div className="flex-1 min-h-0 overflow-y-auto">
725960 {kvLoading ? (
726− <div className="p-2 text-[11px] text-muted-foreground">Loading...</div>
961+ <div className="p-4 space-y-2">
962+ {Array.from({ length: 6 }).map((_, i) => (
963+ <Skeleton key={i} className="h-12 rounded-md" />
964+ ))}
965+ </div>
727966 ) : kvItems.length === 0 ? (
728− <div className="p-2 text-[11px] text-muted-foreground">No key-value entries</div>
967+ <div className="flex h-full items-center justify-center">
968+ <p className="text-xs text-muted-foreground/60">No key-value entries</p>
969+ </div>
729970 ) : (
730− <div className="divide-y divide-border/30">
731− {kvItems.map((item) => (
732− <div key={item.key} className="px-2 py-1.5 text-[11px]">
733− <p className="font-medium">{item.key}</p>
734− <pre className="overflow-auto text-muted-foreground">{truncateText(previewJson(item.value), 4000)}</pre>
735− </div>
736− ))}
971+ <div className="divide-y divide-border/20">
972+ {kvItems.map((item) => {
973+ const isExpanded = expandedKvKey === item.key;
974+ return (
975+ <div key={item.key}>
976+ <button
977+ type="button"
978+ className="flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-accent/30 transition-colors"
979+ onClick={() => setExpandedKvKey(isExpanded ? null : item.key)}
980+ >
981+ {isExpanded ? (
982+ <ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
983+ ) : (
984+ <ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
985+ )}
986+ <span className="flex-1 min-w-0 truncate font-mono text-[11px] font-medium text-foreground/90">
987+ {item.key}
988+ </span>
989+ <span className="shrink-0 max-w-[40%] truncate font-mono text-[10px] text-muted-foreground/60">
990+ {typeof item.value === "string"
991+ ? item.value.length > 60 ? item.value.slice(0, 60) + "..." : item.value
992+ : typeof item.value === "object"
993+ ? Array.isArray(item.value) ? `Array(${item.value.length})` : "Object"
994+ : String(item.value)}
995+ </span>
996+ </button>
997+ {isExpanded && (
998+ <div className="border-t border-border/10 bg-muted/20 px-4 py-3 pl-10">
999+ <JsonPreview data={item.value} />
1000+ </div>
1001+ )}
1002+ </div>
1003+ );
1004+ })}
7371005 </div>
7381006 )}
7391007 </div>
7401008 </div>
741− ) : null}
742−
743− {activeInspectorTab === "sql" ? (
744− <div className="grid h-full min-h-0 gap-3 p-4 lg:grid-cols-[15rem_minmax(0,1fr)]">
745− <div className="flex min-h-0 flex-col border border-border/40">
746− <div className="flex items-center justify-between border-b border-border/30 px-2 py-1 text-[11px] text-muted-foreground">
747− <span>Tables</span>
748− <div className="flex items-center gap-1">
1009+ </TabsContent>
1010+
1011+ {/* ── SQL Tab ── */}
1012+ <TabsContent value="sql" className="flex-1 min-h-0 overflow-hidden">
1013+ <div className="flex h-full min-h-0 flex-col">
1014+ {/* Table chips — inline horizontal list, no sidebar */}
1015+ {visibleSqlObjects.length > 0 && (
1016+ <div className="flex items-center gap-1.5 border-b border-border/30 bg-muted/15 px-4 py-1.5 overflow-x-auto shrink-0">
1017+ <span className="shrink-0 text-[9px] font-medium text-muted-foreground/60 uppercase tracking-wider mr-0.5">Tables</span>
1018+ {visibleSqlObjects.map((entry) => (
1019+ <button
1020+ key={entry.name}
1021+ type="button"
1022+ className={cn(
1023+ "shrink-0 rounded-md px-2 py-0.5 text-[10px] font-medium transition-colors",
1024+ selectedSqlObjectName === entry.name
1025+ ? "bg-primary/15 text-primary ring-1 ring-primary/20"
1026+ : "text-foreground/60 hover:bg-accent/50 hover:text-foreground",
1027+ )}
1028+ onClick={() => void openSqlObject(entry.name)}
1029+ >
1030+ {entry.name}
1031+ </button>
1032+ ))}
1033+ <div className="ml-auto flex items-center gap-1 shrink-0">
7491034 <Button
750− variant="outline"
1035+ variant="ghost"
7511036 size="sm"
752− className="h-6 rounded-none px-2 text-[10px]"
753− onClick={() => setSqlShowInternalObjects((current) => !current)}
1037+ className="h-5 rounded px-1.5 text-[9px] text-muted-foreground/50"
1038+ onClick={() => setSqlShowInternalObjects((c) => !c)}
7541039 >
755− {sqlShowInternalObjects ? "Hide internal" : "Show internal"}
1040+ {sqlShowInternalObjects ? "Hide sys" : "Show sys"}
7561041 </Button>
7571042 <Button
758− variant="outline"
1043+ variant="ghost"
7591044 size="sm"
760− className="h-6 rounded-none px-2 text-[10px]"
1045+ className="h-5 rounded px-1.5 text-[9px] text-muted-foreground/50"
7611046 onClick={() => void refreshSqlObjects()}
7621047 disabled={sqlObjectsLoading}
7631048 >
@@ -765,163 +1050,184 @@ export function StoragePanel({ · 1 unmodified line
7651050 </Button>
7661051 </div>
7671052 </div>
1053+ )}
7681054
769− {sqlObjectsError ? <p className="px-2 py-1 text-[11px] text-destructive">{sqlObjectsError}</p> : null}
770−
771− <div className="min-h-0 flex-1 overflow-y-auto p-1">
772− {sqlObjectsLoading ? (
773− <p className="px-2 py-1 text-[11px] text-muted-foreground">Loading...</p>
774− ) : visibleSqlObjects.length === 0 ? (
775− <p className="px-2 py-1 text-[11px] text-muted-foreground">No tables</p>
776− ) : (
777− <div className="space-y-1">
778− {visibleSqlObjects.map((entry) => (
779− <Button
780− key={entry.name}
781− variant={selectedSqlObjectName === entry.name ? "default" : "outline"}
782− size="sm"
783− className="h-7 w-full justify-start rounded-none px-2 text-[11px]"
784− onClick={() => void openSqlObject(entry.name)}
785− >
786− <span className="truncate">{entry.name}</span>
787− </Button>
788− ))}
789− </div>
790− )}
1055+ {sqlObjectsError && (
1056+ <div className="border-b border-destructive/20 bg-destructive/5 px-4 py-1 text-[10px] text-destructive">
1057+ {sqlObjectsError}
7911058 </div>
792− </div>
1059+ )}
7931060
794− <div className="flex min-h-0 flex-col gap-2">
1061+ {/* Query editor */}
1062+ <div className="shrink-0 border-b border-border/30 bg-muted/20 p-3">
7951063 <textarea
7961064 value={sqlText}
7971065 onChange={(event) => {
7981066 setSqlText(event.target.value);
7991067 setSelectedSqlObjectName(null);
8001068 }}
801− className="h-24 w-full resize-y rounded-none border border-border/60 bg-background px-2 py-1.5 font-mono text-xs text-foreground"
802− placeholder="SELECT * FROM sqlite_master"
1069+ onKeyDown={(event) => {
1070+ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
1071+ event.preventDefault();
1072+ setSelectedSqlObjectName(null);
1073+ void runSql();
1074+ }
1075+ }}
1076+ className="min-h-[3.5rem] max-h-32 w-full resize-y rounded-md border border-border/40 bg-background px-3 py-2 font-mono text-[11px] leading-relaxed text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:ring-1 focus:ring-primary/30"
1077+ placeholder="SELECT * FROM ..."
8031078 />
804− <div className="flex flex-wrap items-center gap-2">
805− <Input
806− value={sqlMaxRows}
807− onChange={(event) => setSqlMaxRows(event.target.value)}
808− className="h-8 w-24 rounded-none text-xs"
809− placeholder="200"
810− />
1079+ <div className="mt-2 flex flex-wrap items-center gap-1.5">
8111080 <Button
8121081 size="sm"
813− className="h-8 rounded-none text-xs"
1082+ className="h-6 rounded-md px-2.5 text-[10px]"
8141083 disabled={sqlLoading || !canInspect}
815− onClick={() => {
816− setSelectedSqlObjectName(null);
817− void runSql();
818− }}
1084+ onClick={() => { setSelectedSqlObjectName(null); void runSql(); }}
8191085 >
820− <Play className="mr-1 h-3 w-3" /> Run
1086+ <Play className="mr-1 h-2.5 w-2.5" /> Run
8211087 </Button>
822− <Button variant="outline" size="sm" className="h-8 rounded-none px-2 text-[11px]" onClick={() => { setSelectedSqlObjectName(null); void runSql(USER_TABLES_QUERY); }}>
1088+ <div className="h-4 w-px bg-border/40 mx-0.5" />
1089+ <Button variant="ghost" size="sm" className="h-6 rounded-md px-2 text-[10px] text-muted-foreground" onClick={() => { setSelectedSqlObjectName(null); void runSql(USER_TABLES_QUERY); }}>
8231090 User tables
8241091 </Button>
825− <Button variant="outline" size="sm" className="h-8 rounded-none px-2 text-[11px]" onClick={() => { setSelectedSqlObjectName(null); void runSql(ALL_OBJECTS_QUERY); }}>
1092+ <Button variant="ghost" size="sm" className="h-6 rounded-md px-2 text-[10px] text-muted-foreground" onClick={() => { setSelectedSqlObjectName(null); void runSql(ALL_OBJECTS_QUERY); }}>
8261093 All objects
8271094 </Button>
828− <Button variant="outline" size="sm" className="h-8 rounded-none px-2 text-[11px]" onClick={() => { setSelectedSqlObjectName(null); void runSql("PRAGMA table_info('kv_store')"); }}>
1095+ <Button variant="ghost" size="sm" className="h-6 rounded-md px-2 text-[10px] text-muted-foreground" onClick={() => { setSelectedSqlObjectName(null); void runSql("PRAGMA table_info('kv_store')"); }}>
8291096 KV schema
8301097 </Button>
831− <Button variant="outline" size="sm" className="h-8 rounded-none px-2 text-[11px]" onClick={() => { setSelectedSqlObjectName("kv_store"); void runSql(KV_DATA_QUERY); }}>
1098+ <Button variant="ghost" size="sm" className="h-6 rounded-md px-2 text-[10px] text-muted-foreground" onClick={() => { setSelectedSqlObjectName("kv_store"); void runSql(KV_DATA_QUERY); }}>
8321099 KV data
8331100 </Button>
834− <Button variant="outline" size="sm" className="h-8 rounded-none px-2 text-[11px]" onClick={() => { setSelectedSqlObjectName("fs_dentry"); void runSql(FS_ENTRIES_QUERY); }}>
1101+ <Button variant="ghost" size="sm" className="h-6 rounded-md px-2 text-[10px] text-muted-foreground" onClick={() => { setSelectedSqlObjectName("fs_dentry"); void runSql(FS_ENTRIES_QUERY); }}>
8351102 FS entries
8361103 </Button>
1104+ <div className="ml-auto flex items-center gap-1.5">
1105+ <span className="text-[9px] text-muted-foreground/50">Limit</span>
1106+ <Input
1107+ value={sqlMaxRows}
1108+ onChange={(event) => setSqlMaxRows(event.target.value)}
1109+ className="h-6 w-14 rounded-md text-center text-[10px]"
1110+ />
1111+ </div>
8371112 </div>
838− <p className="text-[11px] text-muted-foreground">
839− Tip: click a table on the left to load rows instantly (no manual SQL needed).
1113+ <p className="mt-1.5 text-[9px] text-muted-foreground/40">
1114+ Press <kbd className="rounded border border-border/40 bg-muted/50 px-1 py-0.5 text-[8px] font-mono">Cmd+Enter</kbd> to run
8401115 </p>
841− {sqlError ? <p className="text-[11px] text-destructive">{sqlError}</p> : null}
842−
843− <div className="min-h-0 flex-1 border border-border/40">
844− {sqlLoading ? (
845− <div className="p-2 text-[11px] text-muted-foreground">Running query...</div>
846− ) : sqlResult ? (
847− <div className="h-full min-h-0">
848− <div className="flex items-center justify-between gap-2 border-b border-border/30 px-2 py-1 text-[11px] text-muted-foreground">
849− <span>
850− rows: {sqlResult.rowCount}
851− {typeof sqlResult.changes === "number" ? ` - changes: ${sqlResult.changes}` : ""}
852− {sqlColumns.length > 0 ? ` - columns: ${sqlColumns.length}` : ""}
853− </span>
854− <div className="flex items-center gap-1">
855− <Button
856− variant={sqlViewMode === "table" ? "default" : "outline"}
857− size="sm"
858− className="h-6 rounded-none px-2 text-[10px]"
859− onClick={() => setSqlViewMode("table")}
860− >
861− Table
862− </Button>
863− <Button
864− variant={sqlViewMode === "json" ? "default" : "outline"}
865− size="sm"
866− className="h-6 rounded-none px-2 text-[10px]"
867− onClick={() => setSqlViewMode("json")}
868− >
869− JSON
870− </Button>
871− </div>
1116+ </div>
1117+
1118+ {sqlError && (
1119+ <div className="border-b border-destructive/20 bg-destructive/5 px-4 py-1.5 text-[11px] text-destructive">
1120+ {sqlError}
1121+ </div>
1122+ )}
1123+
1124+ {/* Results */}
1125+ <div className="min-h-0 flex-1 flex flex-col">
1126+ {sqlLoading ? (
1127+ <div className="flex-1 flex items-center justify-center">
1128+ <p className="text-xs text-muted-foreground/60">Running query...</p>
1129+ </div>
1130+ ) : sqlResult ? (
1131+ <>
1132+ {/* Results header */}
1133+ <div className="flex items-center justify-between border-b border-border/20 px-4 py-1.5 bg-muted/10">
1134+ <span className="text-[10px] text-muted-foreground">
1135+ {sqlResult.rowCount} row{sqlResult.rowCount === 1 ? "" : "s"}
1136+ {typeof sqlResult.changes === "number" ? ` · ${sqlResult.changes} change${sqlResult.changes === 1 ? "" : "s"}` : ""}
1137+ {sqlColumns.length > 0 ? ` · ${sqlColumns.length} col${sqlColumns.length === 1 ? "" : "s"}` : ""}
1138+ </span>
1139+ <div className="flex items-center gap-0.5">
1140+ <Button
1141+ variant={sqlViewMode === "table" ? "default" : "ghost"}
1142+ size="sm"
1143+ className="h-5 rounded-md px-2 text-[9px]"
1144+ onClick={() => setSqlViewMode("table")}
1145+ >
1146+ Table
1147+ </Button>
1148+ <Button
1149+ variant={sqlViewMode === "json" ? "default" : "ghost"}
1150+ size="sm"
1151+ className="h-5 rounded-md px-2 text-[9px]"
1152+ onClick={() => setSqlViewMode("json")}
1153+ >
1154+ JSON
1155+ </Button>
8721156 </div>
1157+ </div>
8731158
1159+ {/* Results body */}
1160+ <div className="flex-1 min-h-0 overflow-auto">
8741161 {sqlViewMode === "json" ? (
875− <pre className="h-[calc(100%-2rem)] overflow-auto p-2 text-[11px] leading-5">{previewJson(sqlRows)}</pre>
1162+ <div className="p-4">
1163+ <JsonPreview data={sqlRows} />
1164+ </div>
8761165 ) : sqlRows.length === 0 ? (
877− <div className="p-2 text-[11px] text-muted-foreground">Query returned no rows.</div>
1166+ <div className="flex h-full items-center justify-center">
1167+ <p className="text-xs text-muted-foreground/60">Query returned no rows</p>
1168+ </div>
8781169 ) : (
879− <div className="h-[calc(100%-2rem)] overflow-auto">
880− <table className="min-w-full border-collapse text-[11px]">
881− <thead className="sticky top-0 z-10 bg-muted/50">
882− <tr>
883− <th className="border-b border-r border-border/40 px-2 py-1 text-left font-medium text-muted-foreground">#</th>
1170+ <table className="min-w-full border-collapse text-[11px]">
1171+ <thead className="sticky top-0 z-10 bg-muted/80 backdrop-blur-sm">
1172+ <tr>
1173+ <th className="border-b border-r border-border/30 px-3 py-1.5 text-left text-[10px] font-medium text-muted-foreground w-10">#</th>
1174+ {sqlColumns.map((column) => (
1175+ <th
1176+ key={column}
1177+ className="border-b border-r border-border/30 px-3 py-1.5 text-left text-[10px] font-medium text-muted-foreground last:border-r-0"
1178+ >
1179+ {column}
1180+ </th>
1181+ ))}
1182+ </tr>
1183+ </thead>
1184+ <tbody>
1185+ {sqlRows.map((row, rowIndex) => (
1186+ <tr key={`row-${rowIndex}`} className="hover:bg-accent/20 transition-colors">
1187+ <td className="border-b border-r border-border/15 px-3 py-1.5 align-top text-muted-foreground/50 tabular-nums">{rowIndex + 1}</td>
8841188 {sqlColumns.map((column) => (
885− <th
886− key={column}
887− className="border-b border-r border-border/40 px-2 py-1 text-left font-medium text-muted-foreground last:border-r-0"
1189+ <td
1190+ key={`row-${rowIndex}-${column}`}
1191+ className="max-w-[28rem] border-b border-r border-border/15 px-3 py-1.5 align-top last:border-r-0"
8881192 >
889− {column}
890− </th>
1193+ <div className="max-h-24 overflow-auto whitespace-pre-wrap break-words font-mono text-foreground/80">
1194+ {row[column] === null ? (
1195+ <span className="text-muted-foreground/40 italic">null</span>
1196+ ) : (
1197+ truncateText(sqlCellText(row[column]), 2000)
1198+ )}
1199+ </div>
1200+ </td>
8911201 ))}
8921202 </tr>
893− </thead>
894− <tbody>
895− {sqlRows.map((row, rowIndex) => (
896− <tr key={`row-${rowIndex}`} className="odd:bg-background even:bg-muted/10">
897− <td className="border-b border-r border-border/30 px-2 py-1 align-top text-muted-foreground">{rowIndex + 1}</td>
898− {sqlColumns.map((column) => (
899− <td
900− key={`row-${rowIndex}-${column}`}
901− className="max-w-[420px] border-b border-r border-border/30 px-2 py-1 align-top last:border-r-0"
902− >
903− <div className="max-h-28 overflow-auto whitespace-pre-wrap break-words">
904− {truncateText(sqlCellText(row[column]), 2000)}
905− </div>
906− </td>
907− ))}
908− </tr>
909− ))}
910− </tbody>
911− </table>
912− </div>
1203+ ))}
1204+ </tbody>
1205+ </table>
9131206 )}
9141207 </div>
915− ) : (
916− <div className="p-2 text-[11px] text-muted-foreground">Select a table from the left or run a query.</div>
917− )}
918− </div>
1208+ </>
1209+ ) : (
1210+ <div className="flex-1 flex items-center justify-center">
1211+ <p className="text-xs text-muted-foreground/60">Select a table or run a query</p>
1212+ </div>
1213+ )}
9191214 </div>
9201215 </div>
921− ) : null}
922− </>
1216+ </TabsContent>
1217+ </Tabs>
9231218 ) : (
924− <div className="p-4 text-[11px] text-muted-foreground">Select a storage instance to inspect.</div>
1219+ <div className="flex h-full items-center justify-center">
1220+ <div className="flex flex-col items-center gap-3">
1221+ <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-muted/40">
1222+ <Database className="h-6 w-6 text-muted-foreground/30" />
1223+ </div>
1224+ <p className="text-xs text-muted-foreground/60">
1225+ {visibleInstances.length === 0
1226+ ? "Create a storage instance to get started"
1227+ : "Select an instance to inspect"}
1228+ </p>
1229+ </div>
1230+ </div>
9251231 )}
9261232 </div>
9271233 </section>