| @@ -3,8 +3,11 @@ · 2 unmodified lines |
| 3 | import { useEffect, useMemo, useState } from "react"; | 3 | import { useEffect, useMemo, useState } from "react"; |
| 4 | import { useAction } from "convex/react"; | 4 | import { useAction } from "convex/react"; |
| 5 | import { | 5 | import { |
| | 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"; |
| | 20 | import { JsonView } from "react-json-view-lite"; |
| 16 | import { Badge } from "@/components/ui/badge"; | 21 | import { Badge } from "@/components/ui/badge"; |
| 17 | import { Button } from "@/components/ui/button"; | 22 | import { Button } from "@/components/ui/button"; |
| 18 | import { Input } from "@/components/ui/input"; | 23 | import { Input } from "@/components/ui/input"; |
| 19 | import { Skeleton } from "@/components/ui/skeleton"; | 24 | import { Skeleton } from "@/components/ui/skeleton"; |
| | 25 | import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; |
| 20 | import type { StorageDurability, StorageInstanceRecord, StorageScopeType } from "@/lib/types"; | 26 | import type { StorageDurability, StorageInstanceRecord, StorageScopeType } from "@/lib/types"; |
| 21 | import { convexApi } from "@/lib/convex-api"; | 27 | import { convexApi } from "@/lib/convex-api"; |
| 22 | import { cn } from "@/lib/utils"; | 28 | import { cn } from "@/lib/utils"; |
| 23 | | 29 | |
| | 30 | // ── Types ────────────────────────────────────────────────────────────────── |
| | 31 | |
| 24 | type CreateStorageArgs = { | 32 | type 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 | |
| 50 | const USER_TABLES_QUERY = [ | 60 | const 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 | |
| | 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 | |
| 74 | function prettyBytes(value?: number): string { | 113 | function 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 | |
| | 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 | |
| 97 | function joinStoragePath(basePath: string, name: string): string { | 150 | function 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 | |
| | 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 | |
| 162 | export function StoragePanel({ | 260 | export 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 = rows | 469 | 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 | Scope | 566 | <div className="space-y-2"> |
| 463 | <select | 567 | <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 | Durability | 586 | Durability |
| 483 | <select | 587 | <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 | <Input | 597 | <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 | <Input | 604 | <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 | Create | 614 | </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 | <div | 643 | <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 | <button | 653 | <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 | <Button | 691 | <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 | <Button | 701 | <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 | <Button | 730 | <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" /> FS | 736 | {selectedInstance.durability} · {selectedInstance.status} · last used {asLocalDate(selectedInstance.lastSeenAt)} |
| 611 | </Button> | 737 | </p> |
| 612 | <Button | 738 | </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 | <Input | 763 | 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 | <Button | 788 | <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 | Refresh | 795 | 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 | Open | 890 | 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 | Read | 894 | 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 | <Input | 929 | <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 | <Input | 935 | <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 | <Button | 941 | <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 | Refresh | 948 | 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 | <Button | 1034 | <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 | <Button | 1042 | <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 | <textarea | 1063 | <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 | <Button | 1080 | <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" /> Run | 1086 | <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 tables | 1090 | 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 objects | 1093 | 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 schema | 1096 | 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 data | 1099 | 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 entries | 1102 | 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 | <Button | 1130 | ) : 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 | Table | 1136 | {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 | <Button | 1138 | </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 | JSON | 1144 | 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 | <th | 1189 | <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> |