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
111 "react-dom": "19.2.3",111 "react-dom": "19.2.3",
112 "react-hook-form": "^7.71.1",112 "react-hook-form": "^7.71.1",
113 "react-infinite-scroll-component": "^6.1.1",113 "react-infinite-scroll-component": "^6.1.1",
114 "react-json-view-lite": "^2.5.0",
114 "shiki": "^3.22.0",115 "shiki": "^3.22.0",
115 "sonner": "^2.0.7",116 "sonner": "^2.0.7",
116 "streamdown": "^2.1.0",117 "streamdown": "^2.1.0",
@@ -2301,6 +2302,8 @@ · 2184 unmodified lines
2301 2302
2302 "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=="],2303 "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=="],
2303 2304
2305 "react-json-view-lite": ["react-json-view-lite@2.5.0", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-tk7o7QG9oYyELWHL8xiMQ8x4WzjCzbWNyig3uexmkLb54r8jO0yH3WCWx8UZS0c49eSA4QUmG5caiRJ8fAn58g=="],
2306
2304 "react-reconciler": ["react-reconciler@0.31.0", "", { "dependencies": { "scheduler": "^0.25.0" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ=="],2307 "react-reconciler": ["react-reconciler@0.31.0", "", { "dependencies": { "scheduler": "^0.25.0" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ=="],
2305 2308
2306 "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],2309 "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
36 "react-dom": "19.2.3",36 "react-dom": "19.2.3",
37 "react-hook-form": "^7.71.1",37 "react-hook-form": "^7.71.1",
38 "react-infinite-scroll-component": "^6.1.1",38 "react-infinite-scroll-component": "^6.1.1",
39 "react-json-view-lite": "^2.5.0",
39 "shiki": "^3.22.0",40 "shiki": "^3.22.0",
40 "sonner": "^2.0.7",41 "sonner": "^2.0.7",
41 "streamdown": "^2.1.0",42 "streamdown": "^2.1.0",
executor/apps/web/src/app/globals.css+100−0
@@ -394,3 +394,103 @@ · 393 unmodified lines
394 border-radius: 0.25rem;394 border-radius: 0.25rem;
395}395}
396 396
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
251 }251 }
252 252
253 return (253 return (
254 <div className="flex min-h-screen">254 <div className={cn("flex", useCompactChrome ? "h-screen overflow-hidden" : "min-h-screen")}>
255 <ApprovalNotifier />255 <ApprovalNotifier />
256 <NoOrganizationModal enabled />256 <NoOrganizationModal enabled />
257 <Sidebar />257 <Sidebar />
258 <div className="flex-1 flex flex-col min-w-0">258 <div className="flex-1 flex flex-col min-w-0">
259 <MobileHeader />259 <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>
261 </div>261 </div>
262 </div>262 </div>
263 );263 );
executor/apps/web/src/components/tools/storage.tsx+654−348
@@ -3,8 +3,11 @@ · 2 unmodified lines
3import { useEffect, useMemo, useState } from "react";3import { useEffect, useMemo, useState } from "react";
4import { useAction } from "convex/react";4import { useAction } from "convex/react";
5import {5import {
6 ChevronDown,
7 ChevronRight,
6 Database,8 Database,
7 FolderOpen,9 File,
10 Folder,
8 HardDrive,11 HardDrive,
9 KeyRound,12 KeyRound,
10 Play,13 Play,
@@ -12,15 +15,20 @@ import { · 1 unmodified line
12 Power,15 Power,
13 Table,16 Table,
14 Trash2,17 Trash2,
18 X,
15} from "lucide-react";19} from "lucide-react";
20import { JsonView } from "react-json-view-lite";
16import { Badge } from "@/components/ui/badge";21import { Badge } from "@/components/ui/badge";
17import { Button } from "@/components/ui/button";22import { Button } from "@/components/ui/button";
18import { Input } from "@/components/ui/input";23import { Input } from "@/components/ui/input";
19import { Skeleton } from "@/components/ui/skeleton";24import { Skeleton } from "@/components/ui/skeleton";
25import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
20import type { StorageDurability, StorageInstanceRecord, StorageScopeType } from "@/lib/types";26import type { StorageDurability, StorageInstanceRecord, StorageScopeType } from "@/lib/types";
21import { convexApi } from "@/lib/convex-api";27import { convexApi } from "@/lib/convex-api";
22import { cn } from "@/lib/utils";28import { cn } from "@/lib/utils";
23 29
30// ── Types ──────────────────────────────────────────────────────────────────
31
24type CreateStorageArgs = {32type CreateStorageArgs = {
25 scopeType: StorageScopeType;33 scopeType: StorageScopeType;
26 durability: StorageDurability;34 durability: StorageDurability;
@@ -47,6 +55,8 @@ type StorageSqlObject = { · 20 unmodified lines
47 type: "table" | "view" | "unknown";55 type: "table" | "view" | "unknown";
48};56};
49 57
58// ── Constants ──────────────────────────────────────────────────────────────
59
50const USER_TABLES_QUERY = [60const USER_TABLES_QUERY = [
51 "SELECT name",61 "SELECT name",
52 "FROM sqlite_master",62 "FROM sqlite_master",
@@ -71,6 +81,35 @@ const INTERNAL_SQL_OBJECT_NAMES = new Set([ · 18 unmodified lines
71 "sqlite_sequence",81 "sqlite_sequence",
72]);82]);
73 83
84// ── Custom JSON viewer styles ──────────────────────────────────────────────
85
86const 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
74function prettyBytes(value?: number): string {113function prettyBytes(value?: number): string {
75 if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {114 if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
76 return "-";115 return "-";
@@ -94,6 +133,20 @@ function asLocalDate(value?: number): string { · 17 unmodified lines
94 return new Date(value).toLocaleString();133 return new Date(value).toLocaleString();
95}134}
96 135
136function 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
97function joinStoragePath(basePath: string, name: string): string {150function joinStoragePath(basePath: string, name: string): string {
98 const base = (basePath.trim() || "/").replace(/\/+$/, "");151 const base = (basePath.trim() || "/").replace(/\/+$/, "");
99 if (!base || base === "/") {152 if (!base || base === "/") {
@@ -159,6 +212,51 @@ function escapeSqlIdentifier(value: string): string { · 59 unmodified lines
159 return value.replaceAll('"', '""');212 return value.replaceAll('"', '""');
160}213}
161 214
215function isJsonLike(content: string): boolean {
216 const trimmed = content.trimStart();
217 return trimmed.startsWith("{") || trimmed.startsWith("[");
218}
219
220function tryParseJson(content: string): unknown | null {
221 try {
222 return JSON.parse(content);
223 } catch {
224 return null;
225 }
226}
227
228function shouldExpandJsonNode(level: number): boolean {
229 return level < 2;
230}
231
232// ── Scope badge color ──────────────────────────────────────────────────────
233
234function 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
246function 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
162export function StoragePanel({260export function StoragePanel({
163 workspaceId,261 workspaceId,
164 sessionId,262 sessionId,
@@ -180,13 +278,18 @@ export function StoragePanel({ · 15 unmodified lines
180 onClose: (instanceId: string) => Promise<void>;278 onClose: (instanceId: string) => Promise<void>;
181 onDelete: (instanceId: string) => Promise<void>;279 onDelete: (instanceId: string) => Promise<void>;
182}) {280}) {
281 // ── Create form state ──
282 const [showCreateForm, setShowCreateForm] = useState(false);
183 const [scopeType, setScopeType] = useState<StorageScopeType>("scratch");283 const [scopeType, setScopeType] = useState<StorageScopeType>("scratch");
184 const [durability, setDurability] = useState<StorageDurability>("ephemeral");284 const [durability, setDurability] = useState<StorageDurability>("ephemeral");
185 const [purpose, setPurpose] = useState("");285 const [purpose, setPurpose] = useState("");
186 const [ttlHours, setTtlHours] = useState("24");286 const [ttlHours, setTtlHours] = useState("24");
287
288 // ── Selection state ──
187 const [selectedInstanceId, setSelectedInstanceId] = useState<string | undefined>(undefined);289 const [selectedInstanceId, setSelectedInstanceId] = useState<string | undefined>(undefined);
188 const [activeInspectorTab, setActiveInspectorTab] = useState<"fs" | "kv" | "sql">("fs");290 const [activeInspectorTab, setActiveInspectorTab] = useState<"fs" | "kv" | "sql">("fs");
189 291
292 // ── FS state ──
190 const [fsPath, setFsPath] = useState("/");293 const [fsPath, setFsPath] = useState("/");
191 const [fsEntries, setFsEntries] = useState<StorageDirectoryEntry[]>([]);294 const [fsEntries, setFsEntries] = useState<StorageDirectoryEntry[]>([]);
192 const [fsLoading, setFsLoading] = useState(false);295 const [fsLoading, setFsLoading] = useState(false);
@@ -196,12 +299,15 @@ export function StoragePanel({ · 3 unmodified lines
196 const [filePreviewBytes, setFilePreviewBytes] = useState<number | null>(null);299 const [filePreviewBytes, setFilePreviewBytes] = useState<number | null>(null);
197 const [filePreviewLoading, setFilePreviewLoading] = useState(false);300 const [filePreviewLoading, setFilePreviewLoading] = useState(false);
198 301
302 // ── KV state ──
199 const [kvPrefix, setKvPrefix] = useState("");303 const [kvPrefix, setKvPrefix] = useState("");
200 const [kvLimit, setKvLimit] = useState("100");304 const [kvLimit, setKvLimit] = useState("100");
201 const [kvItems, setKvItems] = useState<Array<{ key: string; value: unknown }>>([]);305 const [kvItems, setKvItems] = useState<Array<{ key: string; value: unknown }>>([]);
202 const [kvLoading, setKvLoading] = useState(false);306 const [kvLoading, setKvLoading] = useState(false);
203 const [kvError, setKvError] = useState<string | null>(null);307 const [kvError, setKvError] = useState<string | null>(null);
308 const [expandedKvKey, setExpandedKvKey] = useState<string | null>(null);
204 309
310 // ── SQL state ──
205 const [sqlText, setSqlText] = useState(USER_TABLES_QUERY);311 const [sqlText, setSqlText] = useState(USER_TABLES_QUERY);
206 const [sqlMaxRows, setSqlMaxRows] = useState("200");312 const [sqlMaxRows, setSqlMaxRows] = useState("200");
207 const [sqlViewMode, setSqlViewMode] = useState<"table" | "json">("table");313 const [sqlViewMode, setSqlViewMode] = useState<"table" | "json">("table");
@@ -214,11 +320,13 @@ export function StoragePanel({ · 6 unmodified lines
214 const [sqlShowInternalObjects, setSqlShowInternalObjects] = useState(false);320 const [sqlShowInternalObjects, setSqlShowInternalObjects] = useState(false);
215 const [selectedSqlObjectName, setSelectedSqlObjectName] = useState<string | null>(null);321 const [selectedSqlObjectName, setSelectedSqlObjectName] = useState<string | null>(null);
216 322
323 // ── Actions ──
217 const listDirectory = useAction(convexApi.executorNode.storageListDirectory);324 const listDirectory = useAction(convexApi.executorNode.storageListDirectory);
218 const readFileAction = useAction(convexApi.executorNode.storageReadFile);325 const readFileAction = useAction(convexApi.executorNode.storageReadFile);
219 const listKv = useAction(convexApi.executorNode.storageListKv);326 const listKv = useAction(convexApi.executorNode.storageListKv);
220 const querySql = useAction(convexApi.executorNode.storageQuerySql);327 const querySql = useAction(convexApi.executorNode.storageQuerySql);
221 328
329 // ── Derived ──
222 const visibleInstances = useMemo(330 const visibleInstances = useMemo(
223 () => [...instances].sort((a, b) => b.updatedAt - a.updatedAt),331 () => [...instances].sort((a, b) => b.updatedAt - a.updatedAt),
224 [instances],332 [instances],
@@ -251,11 +359,10 @@ export function StoragePanel({ · 26 unmodified lines
251 [sqlObjects, sqlShowInternalObjects],359 [sqlObjects, sqlShowInternalObjects],
252 );360 );
253 361
254 const refreshDirectory = async (nextPath?: string) => {362 // ── Data fetchers ──
255 if (!workspaceId || !selectedInstance) {
256 return;
257 }
258 363
364 const refreshDirectory = async (nextPath?: string) => {
365 if (!workspaceId || !selectedInstance) return;
259 const path = (nextPath ?? fsPath).trim() || "/";366 const path = (nextPath ?? fsPath).trim() || "/";
260 setFsLoading(true);367 setFsLoading(true);
261 setFsError(null);368 setFsError(null);
@@ -277,10 +384,7 @@ export function StoragePanel({ · 15 unmodified lines
277 };384 };
278 385
279 const readFilePreview = async (path: string) => {386 const readFilePreview = async (path: string) => {
280 if (!workspaceId || !selectedInstance) {387 if (!workspaceId || !selectedInstance) return;
281 return;
282 }
283
284 setFilePreviewLoading(true);388 setFilePreviewLoading(true);
285 try {389 try {
286 const result = await readFileAction({390 const result = await readFileAction({
@@ -303,10 +407,7 @@ export function StoragePanel({ · 16 unmodified lines
303 };407 };
304 408
305 const refreshKv = async () => {409 const refreshKv = async () => {
306 if (!workspaceId || !selectedInstance) {410 if (!workspaceId || !selectedInstance) return;
307 return;
308 }
309
310 const parsedLimit = Number.parseInt(kvLimit, 10);411 const parsedLimit = Number.parseInt(kvLimit, 10);
311 setKvLoading(true);412 setKvLoading(true);
312 setKvError(null);413 setKvError(null);
@@ -328,15 +429,9 @@ export function StoragePanel({ · 15 unmodified lines
328 };429 };
329 430
330 const runSql = async (queryOverride?: string) => {431 const runSql = async (queryOverride?: string) => {
331 if (!workspaceId || !selectedInstance) {432 if (!workspaceId || !selectedInstance) return;
332 return;
333 }
334
335 const sql = (queryOverride ?? sqlText).trim();433 const sql = (queryOverride ?? sqlText).trim();
336 if (!sql) {434 if (!sql) return;
337 return;
338 }
339
340 const parsedMaxRows = Number.parseInt(sqlMaxRows, 10);435 const parsedMaxRows = Number.parseInt(sqlMaxRows, 10);
341 setSqlLoading(true);436 setSqlLoading(true);
342 setSqlError(null);437 setSqlError(null);
@@ -348,9 +443,7 @@ export function StoragePanel({ · 5 unmodified lines
348 sql,443 sql,
349 maxRows: Number.isFinite(parsedMaxRows) ? parsedMaxRows : 200,444 maxRows: Number.isFinite(parsedMaxRows) ? parsedMaxRows : 200,
350 });445 });
351 if (queryOverride) {446 if (queryOverride) setSqlText(sql);
352 setSqlText(sql);
353 }
354 setSqlResult(result as StorageSqlResult);447 setSqlResult(result as StorageSqlResult);
355 } catch (error) {448 } catch (error) {
356 setSqlError(error instanceof Error ? error.message : "Failed to query SQLite");449 setSqlError(error instanceof Error ? error.message : "Failed to query SQLite");
@@ -361,10 +454,7 @@ export function StoragePanel({ · 4 unmodified lines
361 };454 };
362 455
363 const refreshSqlObjects = async () => {456 const refreshSqlObjects = async () => {
364 if (!workspaceId || !selectedInstance) {457 if (!workspaceId || !selectedInstance) return [] as StorageSqlObject[];
365 return [] as StorageSqlObject[];
366 }
367
368 setSqlObjectsLoading(true);458 setSqlObjectsLoading(true);
369 setSqlObjectsError(null);459 setSqlObjectsError(null);
370 try {460 try {
@@ -375,7 +465,6 @@ export function StoragePanel({ · 4 unmodified lines
375 sql: SQL_OBJECTS_QUERY,465 sql: SQL_OBJECTS_QUERY,
376 maxRows: 1000,466 maxRows: 1000,
377 });467 });
378
379 const rows = ((result as StorageSqlResult).rows ?? []) as Array<Record<string, unknown>>;468 const rows = ((result as StorageSqlResult).rows ?? []) as Array<Record<string, unknown>>;
380 const objects = rows469 const objects = rows
381 .map((row) => ({470 .map((row) => ({
@@ -383,7 +472,6 @@ export function StoragePanel({ · 1 unmodified line
383 type: sqlObjectType(row.type),472 type: sqlObjectType(row.type),
384 }))473 }))
385 .filter((entry) => entry.name.length > 0);474 .filter((entry) => entry.name.length > 0);
386
387 setSqlObjects(objects);475 setSqlObjects(objects);
388 return objects;476 return objects;
389 } catch (error) {477 } catch (error) {
@@ -403,6 +491,8 @@ export function StoragePanel({ · 13 unmodified lines
403 await runSql(`SELECT * FROM "${escapeSqlIdentifier(objectName)}" LIMIT ${limit}`);491 await runSql(`SELECT * FROM "${escapeSqlIdentifier(objectName)}" LIMIT ${limit}`);
404 };492 };
405 493
494 // ── Effects ──
495
406 useEffect(() => {496 useEffect(() => {
407 if (!canInspect) {497 if (!canInspect) {
408 setFsEntries([]);498 setFsEntries([]);
@@ -439,101 +529,113 @@ export function StoragePanel({ · 30 unmodified lines
439 ...(durability === "ephemeral" && Number.isFinite(parsedTtl) ? { ttlHours: parsedTtl } : {}),529 ...(durability === "ephemeral" && Number.isFinite(parsedTtl) ? { ttlHours: parsedTtl } : {}),
440 });530 });
441 setPurpose("");531 setPurpose("");
532 setShowCreateForm(false);
442 };533 };
443 534
535 // path breadcrumbs
536 const pathSegments = fsPath.split("/").filter(Boolean);
537
444 return (538 return (
445 <section className="flex h-full min-h-0 w-full overflow-hidden rounded-none border border-border/50 bg-card/30">539 <section className="flex h-full min-h-0 w-full overflow-hidden border-t border-border/40 bg-background">
446 <aside className="flex h-full w-72 shrink-0 flex-col border-r border-border/40 lg:w-80 xl:w-[22rem]">540 {/* ── Left sidebar: instances ── */}
447 <div className="flex items-center justify-between border-b border-border/40 px-3 py-2.5">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">
448 <div className="flex items-center gap-2">544 <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">545 <Database className="h-3.5 w-3.5 text-muted-foreground" />
450 <Database className="h-4 w-4 text-muted-foreground" />546 <span className="text-xs font-medium text-foreground">Instances</span>
451 </div>547 {visibleInstances.length > 0 && (
452 <h2 className="text-sm font-medium">Storage</h2>548 <Badge variant="outline" className="h-4 px-1.5 text-[9px] font-mono text-muted-foreground">
549 {visibleInstances.length}
550 </Badge>
551 )}
453 </div>552 </div>
454 <Badge variant="outline" className="h-5 px-2 text-[10px] font-mono uppercase tracking-wide text-muted-foreground">553 <Button
455 {visibleInstances.length}554 variant={showCreateForm ? "default" : "outline"}
456 </Badge>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>
457 </div>561 </div>
458 562
459 <div className="shrink-0 border-b border-border/40 bg-background/40 px-3 py-3">563 {/* Create form (collapsible) */}
460 <div className="space-y-2">564 {showCreateForm && (
461 <label className="flex flex-col gap-1 text-[11px] text-muted-foreground">565 <div className="border-b border-border/40 bg-muted/30 px-3 py-3">
462 Scope566 <div className="space-y-2">
463 <select567 <div className="grid grid-cols-2 gap-2">
464 className="h-8 rounded-none border border-border/60 bg-background px-2 text-xs text-foreground"568 <label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
465 value={scopeType}569 Scope
466 onChange={(event) => {570 <select
467 const next = event.target.value as StorageScopeType;571 className="h-7 rounded-md border border-border/60 bg-background px-2 text-[11px] text-foreground"
468 setScopeType(next);572 value={scopeType}
469 if (next !== "scratch") {573 onChange={(event) => {
470 setDurability("durable");574 const next = event.target.value as StorageScopeType;
471 }575 setScopeType(next);
472 }}576 if (next !== "scratch") setDurability("durable");
473 >577 }}
474 <option value="scratch">scratch</option>578 >
475 <option value="account">account</option>579 <option value="scratch">scratch</option>
476 <option value="workspace">workspace</option>580 <option value="account">account</option>
477 <option value="organization">organization</option>581 <option value="workspace">workspace</option>
478 </select>582 <option value="organization">organization</option>
479 </label>583 </select>
480 584 </label>
481 <label className="flex flex-col gap-1 text-[11px] text-muted-foreground">585 <label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
482 Durability586 Durability
483 <select587 <select
484 className="h-8 rounded-none border border-border/60 bg-background px-2 text-xs text-foreground"588 className="h-7 rounded-md border border-border/60 bg-background px-2 text-[11px] text-foreground"
485 value={durability}589 value={durability}
486 onChange={(event) => setDurability(event.target.value as StorageDurability)}590 onChange={(event) => setDurability(event.target.value as StorageDurability)}
487 >591 >
488 <option value="ephemeral">ephemeral</option>592 <option value="ephemeral">ephemeral</option>
489 <option value="durable">durable</option>593 <option value="durable">durable</option>
490 </select>594 </select>
491 </label>595 </label>
492 596 </div>
493 <label className="flex flex-col gap-1 text-[11px] text-muted-foreground">
494 Purpose
495 <Input597 <Input
496 value={purpose}598 value={purpose}
497 onChange={(event) => setPurpose(event.target.value)}599 onChange={(event) => setPurpose(event.target.value)}
498 placeholder="scratch for repo indexing"600 placeholder="Purpose (optional)"
499 className="h-8 rounded-none text-xs"601 className="h-7 rounded-md text-[11px]"
500 />602 />
501 </label>603 <div className="flex items-end gap-2">
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)
506 <Input604 <Input
507 value={ttlHours}605 value={ttlHours}
508 onChange={(event) => setTtlHours(event.target.value)}606 onChange={(event) => setTtlHours(event.target.value)}
509 disabled={durability !== "ephemeral"}607 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]"
511 />610 />
512 </label>611 <Button size="sm" className="h-7 rounded-md px-3 text-[10px]" disabled={creating} onClick={() => void submitCreate()}>
513 <Button size="sm" className="h-8 rounded-none text-xs" disabled={creating} onClick={() => void submitCreate()}>612 Create
514 <Plus className="mr-1.5 h-3.5 w-3.5" />613 </Button>
515 Create614 </div>
516 </Button>
517 </div>615 </div>
518 </div>616 </div>
519 </div>617 )}
520 618
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">
522 {loading ? (621 {loading ? (
523 <div className="space-y-2">622 <div className="space-y-1 p-2">
524 {Array.from({ length: 4 }).map((_, i) => (623 {Array.from({ length: 4 }).map((_, i) => (
525 <Skeleton key={i} className="h-16 rounded-none" />624 <Skeleton key={i} className="h-14 rounded-md" />
526 ))}625 ))}
527 </div>626 </div>
528 ) : visibleInstances.length === 0 ? (627 ) : 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">628 <div className="flex h-full flex-col items-center justify-center gap-3 px-4 py-8">
530 <div className="flex h-10 w-10 items-center justify-center border border-border/60 bg-muted/40">629 <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted/50">
531 <FolderOpen className="h-5 w-5 text-muted-foreground/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>
532 </div>635 </div>
533 <p className="text-sm text-muted-foreground">No storage instances yet</p>
534 </div>636 </div>
535 ) : (637 ) : (
536 <div className="space-y-2">638 <div className="p-1.5 space-y-0.5">
537 {visibleInstances.map((instance) => {639 {visibleInstances.map((instance) => {
538 const busy = busyInstanceId === instance.id;640 const busy = busyInstanceId === instance.id;
539 const active = selectedInstance?.id === instance.id;641 const active = selectedInstance?.id === instance.id;
@@ -541,47 +643,70 @@ export function StoragePanel({ · 1 unmodified line
541 <div643 <div
542 key={instance.id}644 key={instance.id}
543 className={cn(645 className={cn(
544 "group flex items-center gap-2 border px-2 py-2 transition-colors",646 "group relative rounded-md transition-all duration-150",
545 active ? "border-primary/40 bg-primary/5" : "border-border/50 bg-background/70",647 active
546 busy ? "opacity-60" : "hover:border-border hover:bg-accent/20",648 ? "bg-primary/8 ring-1 ring-primary/20"
649 : "hover:bg-accent/50",
650 busy && "opacity-50 pointer-events-none",
547 )}651 )}
548 >652 >
549 <button653 <button
550 type="button"654 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"
552 onClick={() => setSelectedInstanceId(instance.id)}656 onClick={() => setSelectedInstanceId(instance.id)}
553 >657 >
554 <div className="flex h-7 w-7 shrink-0 items-center justify-center border border-border/60 bg-muted/50">658 <div className={cn(
555 <Database className="h-3.5 w-3.5 text-muted-foreground" />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" />
556 </div>663 </div>
557 <div className="min-w-0 flex-1">664 <div className="min-w-0 flex-1">
558 <p className="truncate text-[12px] font-medium">{instance.purpose || instance.id}</p>665 <p className={cn(
559 <p className="text-[10px] text-muted-foreground">666 "truncate text-[11px] font-medium",
560 {instance.scopeType} · {instance.durability} · {instance.status}667 active ? "text-foreground" : "text-foreground/80",
561 </p>668 )}>
562 <p className="text-[10px] text-muted-foreground">669 {instance.purpose || instance.id.slice(0, 12)}
563 {prettyBytes(instance.sizeBytes)} · {instance.fileCount ?? "-"} inode{instance.fileCount === 1 ? "" : "s"}
564 </p>670 </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>
565 </div>684 </div>
566 </button>685 </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 )}>
568 <Button691 <Button
569 variant="outline"692 variant="ghost"
570 size="sm"693 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"
572 disabled={busy}695 disabled={busy}
573 onClick={() => void onClose(instance.id)}696 onClick={(e) => { e.stopPropagation(); void onClose(instance.id); }}
697 title="Close instance"
574 >698 >
575 <Power className="h-3 w-3" />699 <Power className="h-2.5 w-2.5" />
576 </Button>700 </Button>
577 <Button701 <Button
578 variant="outline"702 variant="ghost"
579 size="sm"703 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"
581 disabled={busy}705 disabled={busy}
582 onClick={() => void onDelete(instance.id)}706 onClick={(e) => { e.stopPropagation(); void onDelete(instance.id); }}
707 title="Delete instance"
583 >708 >
584 <Trash2 className="h-3 w-3" />709 <Trash2 className="h-2.5 w-2.5" />
585 </Button>710 </Button>
586 </div>711 </div>
587 </div>712 </div>
@@ -592,172 +717,332 @@ export function StoragePanel({ · 4 unmodified lines
592 </div>717 </div>
593 </aside>718 </aside>
594 719
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">
596 {selectedInstance ? (722 {selectedInstance ? (
597 <>723 <Tabs
598 <div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/40 px-4 py-2.5">724 value={activeInspectorTab}
599 <div>725 onValueChange={(value) => setActiveInspectorTab(value as "fs" | "kv" | "sql")}
600 <p className="text-xs font-medium">Inspector</p>726 className="flex h-full min-h-0 flex-col"
601 <p className="text-[11px] text-muted-foreground">{selectedInstance.id} · last used {asLocalDate(selectedInstance.lastSeenAt)}</p>727 >
602 </div>728 {/* Inspector header */}
603 <div className="flex items-center gap-1">729 <div className="flex items-center justify-between border-b border-border/40 px-4 py-1.5 bg-card/20">
604 <Button730 <div className="flex items-center gap-3 min-w-0">
605 variant={activeInspectorTab === "fs" ? "default" : "outline"}731 <div className="min-w-0">
606 size="sm"732 <p className="truncate text-[11px] font-medium text-foreground">
607 className="h-7 rounded-none px-2 text-[11px]"733 {selectedInstance.purpose || selectedInstance.id.slice(0, 20)}
608 onClick={() => setActiveInspectorTab("fs")}734 </p>
609 >735 <p className="text-[9px] text-muted-foreground">
610 <HardDrive className="mr-1 h-3 w-3" /> FS736 {selectedInstance.durability} · {selectedInstance.status} · last used {asLocalDate(selectedInstance.lastSeenAt)}
611 </Button>737 </p>
612 <Button738 </div>
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>
628 </div>739 </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>
629 </div>754 </div>
630 755
631 {!canInspect ? (756 {/* ── FS Tab ── */}
632 <div className="p-4 text-xs text-muted-foreground">Select a signed-in workspace session to inspect contents.</div>757 <TabsContent value="fs" className="flex-1 min-h-0 overflow-hidden">
633 ) : null}758 <div className="flex h-full flex-col">
634 759 {/* Path bar */}
635 {activeInspectorTab === "fs" ? (760 <div className="flex items-center gap-2 border-b border-border/30 px-4 py-2 bg-muted/20">
636 <div className="space-y-2 p-4">761 <div className="flex items-center gap-1 min-w-0 flex-1 text-[11px]">
637 <div className="flex items-center gap-2">762 <button
638 <Input763 type="button"
639 value={fsPath}764 className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
640 onChange={(event) => setFsPath(event.target.value)}765 onClick={() => void refreshDirectory("/")}
641 className="h-8 rounded-none text-xs"766 >
642 placeholder="/"767 /
643 />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>
644 <Button788 <Button
789 variant="ghost"
645 size="sm"790 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"
647 disabled={fsLoading || !canInspect}792 disabled={fsLoading || !canInspect}
648 onClick={() => void refreshDirectory()}793 onClick={() => void refreshDirectory()}
649 >794 >
650 Refresh795 Refresh
651 </Button>796 </Button>
652 </div>797 </div>
653 {fsError ? <p className="text-[11px] text-destructive">{fsError}</p> : null}
654 798
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">
656 {fsLoading ? (807 {fsLoading ? (
657 <div className="p-2 text-[11px] text-muted-foreground">Loading...</div>808 <div className="p-4 space-y-1">
658 ) : fsEntries.length === 0 ? (809 {Array.from({ length: 5 }).map((_, i) => (
659 <div className="p-2 text-[11px] text-muted-foreground">No entries</div>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>
660 ) : (817 ) : (
661 <div className="divide-y divide-border/30">818 <div>
662 {fsEntries.map((entry) => {819 {/* Directory listing */}
663 const nextPath = joinStoragePath(fsPath, entry.name);820 {fsEntries.length > 0 && (
664 return (821 <div className="divide-y divide-border/20">
665 <div key={`${entry.type}:${entry.name}`} className="flex items-center justify-between gap-2 px-2 py-1.5 text-[11px]">822 {/* Parent directory link */}
666 <div className="min-w-0">823 {fsPath !== "/" && (
667 <p className="truncate font-medium">{entry.name}</p>824 <button
668 <p className="text-muted-foreground">{entry.type} {typeof entry.size === "number" ? `- ${prettyBytes(entry.size)}` : ""}</p>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 )}
669 </div>887 </div>
670 {entry.type === "directory" ? (888 <Button
671 <Button variant="outline" size="sm" className="h-6 rounded-none px-2 text-[10px]" onClick={() => void refreshDirectory(nextPath)}>889 variant="ghost"
672 Open890 size="sm"
673 </Button>891 className="h-5 w-5 shrink-0 rounded p-0 text-muted-foreground hover:text-foreground"
674 ) : entry.type === "file" ? (892 onClick={() => {
675 <Button variant="outline" size="sm" className="h-6 rounded-none px-2 text-[10px]" onClick={() => void readFilePreview(nextPath)}>893 setFilePreviewPath(null);
676 Read894 setFilePreviewContent("");
677 </Button>895 setFilePreviewBytes(null);
678 ) : null}896 }}
897 >
898 <X className="h-3 w-3" />
899 </Button>
679 </div>900 </div>
680 );901 <div className="max-h-[60vh] overflow-auto p-4">
681 })}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 )}
682 </div>918 </div>
683 )}919 )}
684 </div>920 </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}
695 </div>921 </div>
696 ) : null}922 </TabsContent>
697 923
698 {activeInspectorTab === "kv" ? (924 {/* ── KV Tab ── */}
699 <div className="space-y-2 p-4">925 <TabsContent value="kv" className="flex-1 min-h-0 overflow-hidden">
700 <div className="flex items-center gap-2">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">
701 <Input929 <Input
702 value={kvPrefix}930 value={kvPrefix}
703 onChange={(event) => setKvPrefix(event.target.value)}931 onChange={(event) => setKvPrefix(event.target.value)}
704 className="h-8 rounded-none text-xs"932 className="h-7 flex-1 rounded-md text-[11px]"
705 placeholder="prefix"933 placeholder="Filter by key prefix..."
706 />934 />
707 <Input935 <Input
708 value={kvLimit}936 value={kvLimit}
709 onChange={(event) => setKvLimit(event.target.value)}937 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"
711 placeholder="100"939 placeholder="100"
712 />940 />
713 <Button941 <Button
942 variant="ghost"
714 size="sm"943 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"
716 disabled={kvLoading || !canInspect}945 disabled={kvLoading || !canInspect}
717 onClick={() => void refreshKv()}946 onClick={() => void refreshKv()}
718 >947 >
719 Refresh948 Refresh
720 </Button>949 </Button>
721 </div>950 </div>
722 {kvError ? <p className="text-[11px] text-destructive">{kvError}</p> : null}
723 951
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">
725 {kvLoading ? (960 {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>
727 ) : kvItems.length === 0 ? (966 ) : 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>
729 ) : (970 ) : (
730 <div className="divide-y divide-border/30">971 <div className="divide-y divide-border/20">
731 {kvItems.map((item) => (972 {kvItems.map((item) => {
732 <div key={item.key} className="px-2 py-1.5 text-[11px]">973 const isExpanded = expandedKvKey === item.key;
733 <p className="font-medium">{item.key}</p>974 return (
734 <pre className="overflow-auto text-muted-foreground">{truncateText(previewJson(item.value), 4000)}</pre>975 <div key={item.key}>
735 </div>976 <button
736 ))}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 })}
737 </div>1005 </div>
738 )}1006 )}
739 </div>1007 </div>
740 </div>1008 </div>
741 ) : null}1009 </TabsContent>
742 1010
743 {activeInspectorTab === "sql" ? (1011 {/* ── SQL Tab ── */}
744 <div className="grid h-full min-h-0 gap-3 p-4 lg:grid-cols-[15rem_minmax(0,1fr)]">1012 <TabsContent value="sql" className="flex-1 min-h-0 overflow-hidden">
745 <div className="flex min-h-0 flex-col border border-border/40">1013 <div className="flex h-full min-h-0 flex-col">
746 <div className="flex items-center justify-between border-b border-border/30 px-2 py-1 text-[11px] text-muted-foreground">1014 {/* Table chips — inline horizontal list, no sidebar */}
747 <span>Tables</span>1015 {visibleSqlObjects.length > 0 && (
748 <div className="flex items-center gap-1">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">
749 <Button1034 <Button
750 variant="outline"1035 variant="ghost"
751 size="sm"1036 size="sm"
752 className="h-6 rounded-none px-2 text-[10px]"1037 className="h-5 rounded px-1.5 text-[9px] text-muted-foreground/50"
753 onClick={() => setSqlShowInternalObjects((current) => !current)}1038 onClick={() => setSqlShowInternalObjects((c) => !c)}
754 >1039 >
755 {sqlShowInternalObjects ? "Hide internal" : "Show internal"}1040 {sqlShowInternalObjects ? "Hide sys" : "Show sys"}
756 </Button>1041 </Button>
757 <Button1042 <Button
758 variant="outline"1043 variant="ghost"
759 size="sm"1044 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"
761 onClick={() => void refreshSqlObjects()}1046 onClick={() => void refreshSqlObjects()}
762 disabled={sqlObjectsLoading}1047 disabled={sqlObjectsLoading}
763 >1048 >
@@ -765,163 +1050,184 @@ export function StoragePanel({ · 1 unmodified line
765 </Button>1050 </Button>
766 </div>1051 </div>
767 </div>1052 </div>
1053 )}
768 1054
769 {sqlObjectsError ? <p className="px-2 py-1 text-[11px] text-destructive">{sqlObjectsError}</p> : null}1055 {sqlObjectsError && (
770 1056 <div className="border-b border-destructive/20 bg-destructive/5 px-4 py-1 text-[10px] text-destructive">
771 <div className="min-h-0 flex-1 overflow-y-auto p-1">1057 {sqlObjectsError}
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 )}
791 </div>1058 </div>
792 </div>1059 )}
793 1060
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">
795 <textarea1063 <textarea
796 value={sqlText}1064 value={sqlText}
797 onChange={(event) => {1065 onChange={(event) => {
798 setSqlText(event.target.value);1066 setSqlText(event.target.value);
799 setSelectedSqlObjectName(null);1067 setSelectedSqlObjectName(null);
800 }}1068 }}
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"1069 onKeyDown={(event) => {
802 placeholder="SELECT * FROM sqlite_master"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 ..."
803 />1078 />
804 <div className="flex flex-wrap items-center gap-2">1079 <div className="mt-2 flex flex-wrap items-center gap-1.5">
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 />
811 <Button1080 <Button
812 size="sm"1081 size="sm"
813 className="h-8 rounded-none text-xs"1082 className="h-6 rounded-md px-2.5 text-[10px]"
814 disabled={sqlLoading || !canInspect}1083 disabled={sqlLoading || !canInspect}
815 onClick={() => {1084 onClick={() => { setSelectedSqlObjectName(null); void runSql(); }}
816 setSelectedSqlObjectName(null);
817 void runSql();
818 }}
819 >1085 >
820 <Play className="mr-1 h-3 w-3" /> Run1086 <Play className="mr-1 h-2.5 w-2.5" /> Run
821 </Button>1087 </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); }}>
823 User tables1090 User tables
824 </Button>1091 </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); }}>
826 All objects1093 All objects
827 </Button>1094 </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')"); }}>
829 KV schema1096 KV schema
830 </Button>1097 </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); }}>
832 KV data1099 KV data
833 </Button>1100 </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); }}>
835 FS entries1102 FS entries
836 </Button>1103 </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>
837 </div>1112 </div>
838 <p className="text-[11px] text-muted-foreground">1113 <p className="mt-1.5 text-[9px] text-muted-foreground/40">
839 Tip: click a table on the left to load rows instantly (no manual SQL needed).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
840 </p>1115 </p>
841 {sqlError ? <p className="text-[11px] text-destructive">{sqlError}</p> : null}1116 </div>
842 1117
843 <div className="min-h-0 flex-1 border border-border/40">1118 {sqlError && (
844 {sqlLoading ? (1119 <div className="border-b border-destructive/20 bg-destructive/5 px-4 py-1.5 text-[11px] text-destructive">
845 <div className="p-2 text-[11px] text-muted-foreground">Running query...</div>1120 {sqlError}
846 ) : sqlResult ? (1121 </div>
847 <div className="h-full min-h-0">1122 )}
848 <div className="flex items-center justify-between gap-2 border-b border-border/30 px-2 py-1 text-[11px] text-muted-foreground">1123
849 <span>1124 {/* Results */}
850 rows: {sqlResult.rowCount}1125 <div className="min-h-0 flex-1 flex flex-col">
851 {typeof sqlResult.changes === "number" ? ` - changes: ${sqlResult.changes}` : ""}1126 {sqlLoading ? (
852 {sqlColumns.length > 0 ? ` - columns: ${sqlColumns.length}` : ""}1127 <div className="flex-1 flex items-center justify-center">
853 </span>1128 <p className="text-xs text-muted-foreground/60">Running query...</p>
854 <div className="flex items-center gap-1">1129 </div>
855 <Button1130 ) : sqlResult ? (
856 variant={sqlViewMode === "table" ? "default" : "outline"}1131 <>
857 size="sm"1132 {/* Results header */}
858 className="h-6 rounded-none px-2 text-[10px]"1133 <div className="flex items-center justify-between border-b border-border/20 px-4 py-1.5 bg-muted/10">
859 onClick={() => setSqlViewMode("table")}1134 <span className="text-[10px] text-muted-foreground">
860 >1135 {sqlResult.rowCount} row{sqlResult.rowCount === 1 ? "" : "s"}
861 Table1136 {typeof sqlResult.changes === "number" ? ` · ${sqlResult.changes} change${sqlResult.changes === 1 ? "" : "s"}` : ""}
862 </Button>1137 {sqlColumns.length > 0 ? ` · ${sqlColumns.length} col${sqlColumns.length === 1 ? "" : "s"}` : ""}
863 <Button1138 </span>
864 variant={sqlViewMode === "json" ? "default" : "outline"}1139 <div className="flex items-center gap-0.5">
865 size="sm"1140 <Button
866 className="h-6 rounded-none px-2 text-[10px]"1141 variant={sqlViewMode === "table" ? "default" : "ghost"}
867 onClick={() => setSqlViewMode("json")}1142 size="sm"
868 >1143 className="h-5 rounded-md px-2 text-[9px]"
869 JSON1144 onClick={() => setSqlViewMode("table")}
870 </Button>1145 >
871 </div>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>
872 </div>1156 </div>
1157 </div>
873 1158
1159 {/* Results body */}
1160 <div className="flex-1 min-h-0 overflow-auto">
874 {sqlViewMode === "json" ? (1161 {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>
876 ) : sqlRows.length === 0 ? (1165 ) : 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>
878 ) : (1169 ) : (
879 <div className="h-[calc(100%-2rem)] overflow-auto">1170 <table className="min-w-full border-collapse text-[11px]">
880 <table className="min-w-full border-collapse text-[11px]">1171 <thead className="sticky top-0 z-10 bg-muted/80 backdrop-blur-sm">
881 <thead className="sticky top-0 z-10 bg-muted/50">1172 <tr>
882 <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>
883 <th className="border-b border-r border-border/40 px-2 py-1 text-left font-medium text-muted-foreground">#</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>
884 {sqlColumns.map((column) => (1188 {sqlColumns.map((column) => (
885 <th1189 <td
886 key={column}1190 key={`row-${rowIndex}-${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"1191 className="max-w-[28rem] border-b border-r border-border/15 px-3 py-1.5 align-top last:border-r-0"
888 >1192 >
889 {column}1193 <div className="max-h-24 overflow-auto whitespace-pre-wrap break-words font-mono text-foreground/80">
890 </th>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>
891 ))}1201 ))}
892 </tr>1202 </tr>
893 </thead>1203 ))}
894 <tbody>1204 </tbody>
895 {sqlRows.map((row, rowIndex) => (1205 </table>
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>
913 )}1206 )}
914 </div>1207 </div>
915 ) : (1208 </>
916 <div className="p-2 text-[11px] text-muted-foreground">Select a table from the left or run a query.</div>1209 ) : (
917 )}1210 <div className="flex-1 flex items-center justify-center">
918 </div>1211 <p className="text-xs text-muted-foreground/60">Select a table or run a query</p>
1212 </div>
1213 )}
919 </div>1214 </div>
920 </div>1215 </div>
921 ) : null}1216 </TabsContent>
922 </>1217 </Tabs>
923 ) : (1218 ) : (
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>
925 )}1231 )}
926 </div>1232 </div>
927 </section>1233 </section>