@rozenite/sqlite-plugin 1.7.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/LICENSE +20 -0
  3. package/README.md +102 -0
  4. package/dist/devtools/assets/panel-B3paLkwG.js +82 -0
  5. package/dist/devtools/assets/panel-CIU0JBOs.css +1 -0
  6. package/dist/devtools/panel.html +31 -0
  7. package/dist/react-native/chunks/bridge-values.cjs +5 -0
  8. package/dist/react-native/chunks/bridge-values.js +258 -0
  9. package/dist/react-native/chunks/index.require.cjs +1 -0
  10. package/dist/react-native/chunks/index.require.js +118 -0
  11. package/dist/react-native/chunks/useRozeniteSqlitePlugin.require.cjs +1 -0
  12. package/dist/react-native/chunks/useRozeniteSqlitePlugin.require.js +189 -0
  13. package/dist/react-native/index.cjs +1 -0
  14. package/dist/react-native/index.d.ts +178 -0
  15. package/dist/react-native/index.js +16 -0
  16. package/dist/rozenite.json +1 -0
  17. package/package.json +83 -0
  18. package/postcss.config.js +6 -0
  19. package/react-native.ts +55 -0
  20. package/rozenite.config.ts +8 -0
  21. package/src/react-native/adapters/__tests__/expo-sqlite.test.ts +94 -0
  22. package/src/react-native/adapters/expo-sqlite.ts +230 -0
  23. package/src/react-native/adapters/generic.ts +88 -0
  24. package/src/react-native/adapters/index.ts +9 -0
  25. package/src/react-native/sqlite-view.ts +24 -0
  26. package/src/react-native/useRozeniteSqlitePlugin.ts +262 -0
  27. package/src/shared/__tests__/bridge-values.test.ts +34 -0
  28. package/src/shared/__tests__/sql.test.ts +55 -0
  29. package/src/shared/bridge-values.ts +170 -0
  30. package/src/shared/protocol.ts +41 -0
  31. package/src/shared/sql.ts +420 -0
  32. package/src/shared/types.ts +81 -0
  33. package/src/ui/__tests__/sql-editor-utils.test.ts +135 -0
  34. package/src/ui/__tests__/sqlite-row-edit-value.test.ts +22 -0
  35. package/src/ui/__tests__/sqlite-row-mutations.test.ts +310 -0
  36. package/src/ui/__tests__/sqlite-table-column-order.test.ts +83 -0
  37. package/src/ui/__tests__/value-utils.test.tsx +12 -0
  38. package/src/ui/cell-detail-drawer.tsx +65 -0
  39. package/src/ui/globals.css +1415 -0
  40. package/src/ui/panel.tsx +2815 -0
  41. package/src/ui/query-result-table.tsx +199 -0
  42. package/src/ui/sql-editor-utils.ts +352 -0
  43. package/src/ui/sql-editor.tsx +509 -0
  44. package/src/ui/sqlite-data-table.tsx +296 -0
  45. package/src/ui/sqlite-introspection.ts +189 -0
  46. package/src/ui/sqlite-modal-controls.tsx +32 -0
  47. package/src/ui/sqlite-row-delete-modal.tsx +130 -0
  48. package/src/ui/sqlite-row-edit-modal.tsx +487 -0
  49. package/src/ui/sqlite-row-edit-value.ts +53 -0
  50. package/src/ui/sqlite-row-mutations.ts +246 -0
  51. package/src/ui/sqlite-table-column-order.ts +154 -0
  52. package/src/ui/use-sqlite-requests.ts +205 -0
  53. package/src/ui/utils.ts +107 -0
  54. package/src/ui/value-utils.tsx +162 -0
  55. package/tsconfig.json +36 -0
  56. package/vite.config.ts +20 -0
@@ -0,0 +1,2815 @@
1
+ import { useRozeniteDevToolsClient } from '@rozenite/plugin-bridge';
2
+ import type { CompletionSource } from '@codemirror/autocomplete';
3
+ import type { ColumnDef, Updater } from '@tanstack/react-table';
4
+ import {
5
+ useCallback,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ type ChangeEvent,
11
+ type PointerEvent as ReactPointerEvent,
12
+ } from 'react';
13
+ import {
14
+ ChevronDown,
15
+ ChevronRight,
16
+ Copy,
17
+ Database,
18
+ Download,
19
+ FileCode2,
20
+ FolderTree,
21
+ KeyRound,
22
+ Pencil,
23
+ Play,
24
+ RefreshCw,
25
+ Search,
26
+ Table2,
27
+ TerminalSquare,
28
+ Trash2,
29
+ Wand2,
30
+ X,
31
+ } from 'lucide-react';
32
+ import { PLUGIN_ID, type SqliteEventMap } from '../shared/protocol';
33
+ import {
34
+ getStatementAtCursor,
35
+ normalizeSingleStatementSql,
36
+ splitSqlStatements,
37
+ } from '../shared/sql';
38
+ import type {
39
+ SqliteDatabaseInfo,
40
+ SqliteQueryResult,
41
+ SqliteScriptResult,
42
+ SqliteScriptStatementResult,
43
+ } from '../shared/types';
44
+ import {
45
+ buildBrowseEntitySql,
46
+ buildEntityCountSql,
47
+ buildForeignKeySql,
48
+ buildIndexInfoSql,
49
+ buildIndexListSql,
50
+ buildListEntitiesSql,
51
+ buildTableXInfoSql,
52
+ LIST_SCHEMAS_SQL,
53
+ parseColumns,
54
+ parseCount,
55
+ parseEntities,
56
+ parseForeignKeys,
57
+ parseIndexColumns,
58
+ parseIndexes,
59
+ parseSchemas,
60
+ type SqliteColumnInfo,
61
+ type SqliteEntity,
62
+ type SqliteForeignKeyInfo,
63
+ type SqliteIndexInfo,
64
+ type SqliteSchema,
65
+ } from './sqlite-introspection';
66
+ import { QueryResultTable } from './query-result-table';
67
+ import { SqliteRowDeleteModal } from './sqlite-row-delete-modal';
68
+ import { SqliteDataTable } from './sqlite-data-table';
69
+ import { SqliteRowEditModal } from './sqlite-row-edit-modal';
70
+ import { SqlEditor, type SqlEditorHandle } from './sql-editor';
71
+ import {
72
+ buildSqlCompletionSchema,
73
+ createSqlColumnCompletions,
74
+ createSqlEditorColumnCache,
75
+ extractSqlEditorAliases,
76
+ formatSqlScript,
77
+ getDefaultSqlCompletionSchema,
78
+ getSqlEditorCachedColumns,
79
+ getSqlEditorColumnCompletionRequest,
80
+ resolveSqlEditorEntityReference,
81
+ setSqlEditorCachedColumns,
82
+ syncSqlEditorColumnCacheDatabase,
83
+ } from './sql-editor-utils';
84
+ import { useSqliteRequests } from './use-sqlite-requests';
85
+ import {
86
+ SQLITE_HIDDEN_ROWID_COLUMN_ID,
87
+ SQLITE_ROW_ACTIONS_COLUMN_ID,
88
+ buildRowDeleteMutation,
89
+ buildRowUpdateMutation,
90
+ getEditableColumns,
91
+ getPrimaryKeyColumns,
92
+ getRowMutationDescriptor,
93
+ type SqliteRowMutationDescriptor,
94
+ } from './sqlite-row-mutations';
95
+ import {
96
+ SQLITE_ROW_NUMBER_COLUMN_ID,
97
+ areColumnOrdersEqual,
98
+ buildEntityTableId,
99
+ buildQueryTableId,
100
+ getDefaultTableColumnOrder,
101
+ resolveTableColumnOrderUpdate,
102
+ } from './sqlite-table-column-order';
103
+ import {
104
+ copyToClipboard,
105
+ downloadTextFile,
106
+ formatNumber,
107
+ slugifyFileName,
108
+ } from './utils';
109
+ import { getResultSummary, getScriptResultSummary } from './value-utils';
110
+ import './globals.css';
111
+
112
+ type ActiveTab = 'query' | 'data' | 'structure';
113
+ type StructureSection = 'columns' | 'keys' | 'indexes';
114
+
115
+ type StructureState = {
116
+ columns: SqliteColumnInfo[];
117
+ foreignKeys: SqliteForeignKeyInfo[];
118
+ indexes: Array<SqliteIndexInfo & { columns: string[] }>;
119
+ };
120
+
121
+ type StructureColumnRow = {
122
+ name: string;
123
+ type: string;
124
+ nullable: string;
125
+ defaultValue: string;
126
+ primaryKey: string;
127
+ foreignKey: string;
128
+ extra: string;
129
+ };
130
+
131
+ type StructureIndexRow = {
132
+ indexName: string;
133
+ columns: string;
134
+ unique: string;
135
+ type: string;
136
+ };
137
+
138
+ type ExplorerState = {
139
+ schemas: SqliteSchema[];
140
+ entities: SqliteEntity[];
141
+ loading: boolean;
142
+ error: string | null;
143
+ loaded: boolean;
144
+ };
145
+
146
+ type ActiveRowMutationState = {
147
+ row: Record<string, unknown>;
148
+ rowIndex: number;
149
+ } | null;
150
+
151
+ const DEFAULT_QUERY =
152
+ 'SELECT name, type FROM sqlite_schema ORDER BY type, name';
153
+ const DEFAULT_QUERY_LIMIT = 100;
154
+ const DEFAULT_PAGE_SIZE = 50;
155
+ const MIN_EDITOR_HEIGHT = 180;
156
+ const MIN_RESULTS_HEIGHT = 120;
157
+ const MIN_SIDEBAR_WIDTH = 280;
158
+ const MAX_SIDEBAR_WIDTH = 420;
159
+ const DEFAULT_EXPLORER_STATE: ExplorerState = {
160
+ schemas: [],
161
+ entities: [],
162
+ loading: false,
163
+ error: null,
164
+ loaded: false,
165
+ };
166
+
167
+ const joinClassNames = (
168
+ ...classNames: Array<string | false | null | undefined>
169
+ ) => classNames.filter(Boolean).join(' ');
170
+
171
+ const safeError = (error: unknown) =>
172
+ error instanceof Error ? error.message : String(error);
173
+
174
+ const getEntityKey = (
175
+ databaseId: string,
176
+ schemaName: string,
177
+ entityName: string,
178
+ ) => JSON.stringify([databaseId, schemaName, entityName]);
179
+
180
+ const getSchemaKey = (databaseId: string, schemaName: string) =>
181
+ JSON.stringify([databaseId, schemaName]);
182
+
183
+ const getLineNumberAtPosition = (value: string, position: number) =>
184
+ value.slice(0, Math.max(0, position)).split('\n').length;
185
+
186
+ const buildGeneratedSelect = (
187
+ entity: SqliteEntity | null,
188
+ rowLimit: number,
189
+ ) => {
190
+ if (!entity) {
191
+ return DEFAULT_QUERY;
192
+ }
193
+
194
+ return buildBrowseEntitySql(
195
+ entity.schemaName,
196
+ entity.name,
197
+ Math.max(1, Math.floor(rowLimit)),
198
+ 0,
199
+ );
200
+ };
201
+
202
+ const buildCsv = (result: SqliteQueryResult | null) => {
203
+ if (!result || result.columns.length === 0) {
204
+ return '';
205
+ }
206
+
207
+ const escapeCell = (value: unknown) => {
208
+ const cell = value == null ? '' : String(value);
209
+ const escaped = cell.replace(/"/g, '""');
210
+ return /[",\n]/.test(escaped) ? `"${escaped}"` : escaped;
211
+ };
212
+
213
+ return [
214
+ result.columns.join(','),
215
+ ...result.rows.map((row) =>
216
+ result.columns.map((column) => escapeCell(row[column])).join(','),
217
+ ),
218
+ ].join('\n');
219
+ };
220
+
221
+ const getDefaultSelectedQueryStatementIndex = (
222
+ execution: SqliteScriptResult | null,
223
+ ) => {
224
+ if (!execution || execution.statements.length === 0) {
225
+ return null;
226
+ }
227
+
228
+ return (
229
+ execution.failedStatementIndex ??
230
+ execution.statements[execution.statements.length - 1]?.index ??
231
+ null
232
+ );
233
+ };
234
+
235
+ const getStatementQueryResult = (
236
+ statement: SqliteScriptStatementResult | null,
237
+ ) => statement?.execution?.result ?? null;
238
+
239
+ const getStatementSelectorLabel = (
240
+ statement: SqliteScriptStatementResult,
241
+ maxLength = 72,
242
+ ) => {
243
+ const normalizedSql = statement.input.sql
244
+ .replace(/\s+/g, ' ')
245
+ .replace(/;\s*$/, '')
246
+ .trim();
247
+
248
+ if (normalizedSql.length <= maxLength) {
249
+ return `${formatNumber(statement.index + 1)}. ${normalizedSql}`;
250
+ }
251
+
252
+ return `${formatNumber(statement.index + 1)}. ${normalizedSql.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
253
+ };
254
+
255
+ const isMutatingStatement = (result: SqliteQueryResult) =>
256
+ !(
257
+ result.metadata.statementType === 'select' ||
258
+ result.metadata.statementType === 'pragma' ||
259
+ result.metadata.statementType === 'with' ||
260
+ result.metadata.statementType === 'explain'
261
+ );
262
+
263
+ const hasMutatingStatements = (execution: SqliteScriptResult | null) =>
264
+ execution?.statements.some((statement) => {
265
+ const result = getStatementQueryResult(statement);
266
+ return result ? isMutatingStatement(result) : false;
267
+ }) ?? false;
268
+
269
+ const isQueryableEntity = (entity: SqliteEntity | null) => !!entity;
270
+
271
+ const buildExplorerGroups = (
272
+ schemas: SqliteSchema[],
273
+ entities: SqliteEntity[],
274
+ objectSearch: string,
275
+ ) => {
276
+ const term = objectSearch.trim().toLowerCase();
277
+
278
+ return schemas
279
+ .map((schema) => {
280
+ const schemaEntities = entities.filter(
281
+ (entity) => entity.schemaName === schema.name,
282
+ );
283
+ const filteredEntities = term
284
+ ? schemaEntities.filter((entity) =>
285
+ `${entity.name} ${entity.type} ${schema.name}`
286
+ .toLowerCase()
287
+ .includes(term),
288
+ )
289
+ : schemaEntities;
290
+ const tables = filteredEntities.filter(
291
+ (entity) => entity.type === 'table',
292
+ );
293
+ const views = filteredEntities.filter((entity) => entity.type === 'view');
294
+ const visible =
295
+ term.length === 0 ||
296
+ schema.name.toLowerCase().includes(term) ||
297
+ filteredEntities.length > 0;
298
+
299
+ return {
300
+ schema,
301
+ tables,
302
+ views,
303
+ visible,
304
+ };
305
+ })
306
+ .filter((group) => group.visible);
307
+ };
308
+
309
+ const toneButtonClassName =
310
+ 'sqlite-button inline-flex items-center justify-center gap-2 rounded-xl px-3 py-2 text-sm font-medium';
311
+
312
+ const secondaryButtonClassName = `${toneButtonClassName} sqlite-button-secondary`;
313
+ const ghostButtonClassName = `${toneButtonClassName} sqlite-button-ghost`;
314
+ const iconButtonClassName =
315
+ 'sqlite-icon-button inline-flex h-10 w-10 items-center justify-center rounded-xl';
316
+ const primaryIconButtonClassName = `${iconButtonClassName} sqlite-button-primary`;
317
+ const secondaryIconButtonClassName = `${iconButtonClassName} sqlite-button-secondary`;
318
+ const ghostIconButtonClassName = `${iconButtonClassName} sqlite-button-ghost`;
319
+
320
+ const renderEmptyState = (
321
+ title: string,
322
+ description: string,
323
+ icon: 'database' | 'table' | 'query' | 'structure',
324
+ ) => {
325
+ const Icon =
326
+ icon === 'query'
327
+ ? TerminalSquare
328
+ : icon === 'structure'
329
+ ? FolderTree
330
+ : icon === 'table'
331
+ ? Table2
332
+ : Database;
333
+
334
+ return (
335
+ <div className="sqlite-empty-state">
336
+ <div className="sqlite-empty-state-icon">
337
+ <Icon aria-hidden="true" className="h-6 w-6" />
338
+ </div>
339
+ <div className="max-w-md space-y-2 text-center">
340
+ <h2 className="text-lg font-semibold text-white">{title}</h2>
341
+ <p className="text-sm leading-6 text-slate-400">{description}</p>
342
+ </div>
343
+ </div>
344
+ );
345
+ };
346
+
347
+ export default function SqlitePanel() {
348
+ const client = useRozeniteDevToolsClient<SqliteEventMap>({
349
+ pluginId: PLUGIN_ID,
350
+ });
351
+ const { requestDatabases, requestQuery, requestScriptExecution } =
352
+ useSqliteRequests(client);
353
+
354
+ const querySplitRef = useRef<HTMLDivElement | null>(null);
355
+ const sidebarRef = useRef<HTMLDivElement | null>(null);
356
+ const editorRef = useRef<SqlEditorHandle | null>(null);
357
+ const objectSearchRef = useRef<HTMLInputElement | null>(null);
358
+ const selectedDatabaseIdRef = useRef<string | null>(null);
359
+ const selectedEntityKeyRef = useRef<string | null>(null);
360
+ const browseRequestVersionRef = useRef(0);
361
+ const structureRequestVersionRef = useRef(0);
362
+
363
+ const [activeTab, setActiveTab] = useState<ActiveTab>('query');
364
+ const [sidebarWidth, setSidebarWidth] = useState(304);
365
+ const [editorSplit, setEditorSplit] = useState(50);
366
+ const [expandedDatabaseIds, setExpandedDatabaseIds] = useState<string[]>([]);
367
+ const [expandedSchemaKeys, setExpandedSchemaKeys] = useState<string[]>([]);
368
+ const [structureSection, setStructureSection] =
369
+ useState<StructureSection>('columns');
370
+
371
+ const [databases, setDatabases] = useState<SqliteDatabaseInfo[]>([]);
372
+ const [selectedDatabaseId, setSelectedDatabaseId] = useState<string | null>(
373
+ null,
374
+ );
375
+ const [explorerStateByDatabase, setExplorerStateByDatabase] = useState<
376
+ Record<string, ExplorerState>
377
+ >({});
378
+ const [selectedEntityKey, setSelectedEntityKey] = useState<string | null>(
379
+ null,
380
+ );
381
+
382
+ const [databaseLoading, setDatabaseLoading] = useState(false);
383
+ const [browseLoading, setBrowseLoading] = useState(false);
384
+ const [queryLoading, setQueryLoading] = useState(false);
385
+ const [structureLoading, setStructureLoading] = useState(false);
386
+
387
+ const [databaseError, setDatabaseError] = useState<string | null>(null);
388
+ const [browseError, setBrowseError] = useState<string | null>(null);
389
+ const [queryError, setQueryError] = useState<string | null>(null);
390
+ const [structureError, setStructureError] = useState<string | null>(null);
391
+
392
+ const [browseOffset, setBrowseOffset] = useState(0);
393
+ const [browsePageSize, setBrowsePageSize] = useState(DEFAULT_PAGE_SIZE);
394
+ const [browseResult, setBrowseResult] = useState<SqliteQueryResult | null>(
395
+ null,
396
+ );
397
+ const [entityRowCount, setEntityRowCount] = useState<number | null>(null);
398
+ const [structureState, setStructureState] = useState<StructureState>({
399
+ columns: [],
400
+ foreignKeys: [],
401
+ indexes: [],
402
+ });
403
+
404
+ const [queryInput, setQueryInput] = useState(DEFAULT_QUERY);
405
+ const [queryExecution, setQueryExecution] =
406
+ useState<SqliteScriptResult | null>(null);
407
+ const [selectedQueryStatementIndex, setSelectedQueryStatementIndex] =
408
+ useState<number | null>(null);
409
+ const [queryRowLimit, setQueryRowLimit] = useState(DEFAULT_QUERY_LIMIT);
410
+ const [querySelection, setQuerySelection] = useState({ start: 0, end: 0 });
411
+ const [, setQueryMessage] = useState('Ready.');
412
+ const [queryErrorLine, setQueryErrorLine] = useState<number | null>(null);
413
+ const [queryColumnCache, setQueryColumnCache] = useState(() =>
414
+ createSqlEditorColumnCache(),
415
+ );
416
+ const [tableColumnOrderById, setTableColumnOrderById] = useState<
417
+ Record<string, string[]>
418
+ >({});
419
+ const [editingRow, setEditingRow] = useState<ActiveRowMutationState>(null);
420
+ const [deletingRow, setDeletingRow] = useState<ActiveRowMutationState>(null);
421
+
422
+ const [objectSearch, setObjectSearch] = useState('');
423
+ const [dataSearch, setDataSearch] = useState('');
424
+
425
+ const selectedDatabase = useMemo(
426
+ () =>
427
+ databases.find((database) => database.id === selectedDatabaseId) ?? null,
428
+ [databases, selectedDatabaseId],
429
+ );
430
+
431
+ const selectedExplorerState = useMemo(
432
+ () =>
433
+ (selectedDatabaseId
434
+ ? explorerStateByDatabase[selectedDatabaseId]
435
+ : null) ?? DEFAULT_EXPLORER_STATE,
436
+ [explorerStateByDatabase, selectedDatabaseId],
437
+ );
438
+
439
+ const schemas = selectedExplorerState.schemas;
440
+ const entities = selectedExplorerState.entities;
441
+ const entityLoading = selectedExplorerState.loading;
442
+
443
+ const selectedEntity = useMemo(
444
+ () =>
445
+ entities.find(
446
+ (entity) =>
447
+ selectedDatabaseId != null &&
448
+ getEntityKey(selectedDatabaseId, entity.schemaName, entity.name) ===
449
+ selectedEntityKey,
450
+ ) ?? null,
451
+ [entities, selectedDatabaseId, selectedEntityKey],
452
+ );
453
+
454
+ useEffect(() => {
455
+ selectedDatabaseIdRef.current = selectedDatabaseId;
456
+ }, [selectedDatabaseId]);
457
+
458
+ useEffect(() => {
459
+ selectedEntityKeyRef.current = selectedEntityKey;
460
+ }, [selectedEntityKey]);
461
+
462
+ const structureColumnMeta = useMemo(
463
+ () =>
464
+ Object.fromEntries(
465
+ structureState.columns.map((column) => [
466
+ column.name,
467
+ {
468
+ type: column.type,
469
+ isPrimaryKey: column.primaryKeyOrder > 0,
470
+ isForeignKey: structureState.foreignKeys.some(
471
+ (foreignKey) => foreignKey.from === column.name,
472
+ ),
473
+ },
474
+ ]),
475
+ ),
476
+ [structureState.columns, structureState.foreignKeys],
477
+ );
478
+
479
+ const defaultCompletionSchemaName = useMemo(
480
+ () => getDefaultSqlCompletionSchema(schemas),
481
+ [schemas],
482
+ );
483
+
484
+ const cachedSelectedEntityColumns = useMemo(() => {
485
+ if (!selectedDatabaseId || !selectedEntity) {
486
+ return [];
487
+ }
488
+
489
+ return (
490
+ getSqlEditorCachedColumns(
491
+ queryColumnCache,
492
+ selectedDatabaseId,
493
+ selectedEntity.schemaName,
494
+ selectedEntity.name,
495
+ ) ?? []
496
+ );
497
+ }, [queryColumnCache, selectedDatabaseId, selectedEntity]);
498
+
499
+ const editorCompletionSchema = useMemo(
500
+ () =>
501
+ buildSqlCompletionSchema({
502
+ columnCache: queryColumnCache,
503
+ databaseId: selectedDatabaseId,
504
+ entities,
505
+ schemas,
506
+ }),
507
+ [entities, queryColumnCache, schemas, selectedDatabaseId],
508
+ );
509
+
510
+ const filteredBrowseRows = useMemo(() => {
511
+ if (!browseResult) {
512
+ return [];
513
+ }
514
+
515
+ const term = dataSearch.trim().toLowerCase();
516
+ const searchableColumns = browseResult.columns.filter(
517
+ (column) => column !== SQLITE_HIDDEN_ROWID_COLUMN_ID,
518
+ );
519
+
520
+ if (!term) {
521
+ return browseResult.rows;
522
+ }
523
+
524
+ return browseResult.rows.filter((row) =>
525
+ searchableColumns.some((column) =>
526
+ String(row[column] ?? '')
527
+ .toLowerCase()
528
+ .includes(term),
529
+ ),
530
+ );
531
+ }, [browseResult, dataSearch]);
532
+
533
+ const filteredBrowseResult = useMemo(() => {
534
+ if (!browseResult) {
535
+ return null;
536
+ }
537
+
538
+ return {
539
+ ...browseResult,
540
+ rows: filteredBrowseRows,
541
+ metadata: {
542
+ ...browseResult.metadata,
543
+ rowCount: filteredBrowseRows.length,
544
+ },
545
+ } satisfies SqliteQueryResult;
546
+ }, [browseResult, filteredBrowseRows]);
547
+
548
+ const dataPageStart = filteredBrowseRows.length > 0 ? browseOffset + 1 : 0;
549
+ const dataPageEnd =
550
+ filteredBrowseRows.length > 0
551
+ ? browseOffset + filteredBrowseRows.length
552
+ : 0;
553
+ const canBrowseBackward = browseOffset > 0;
554
+ const canBrowseForward =
555
+ entityRowCount != null && browseOffset + browsePageSize < entityRowCount;
556
+ const currentDataPage = selectedEntity
557
+ ? Math.floor(browseOffset / browsePageSize) + 1
558
+ : 0;
559
+ const totalDataPages =
560
+ entityRowCount == null || entityRowCount === 0
561
+ ? 0
562
+ : Math.ceil(entityRowCount / browsePageSize);
563
+ const primaryKeyColumns = useMemo(
564
+ () => getPrimaryKeyColumns(structureState.columns),
565
+ [structureState.columns],
566
+ );
567
+ const editableColumns = useMemo(
568
+ () => getEditableColumns(structureState.columns),
569
+ [structureState.columns],
570
+ );
571
+ const rowMutationDescriptor = useMemo<SqliteRowMutationDescriptor | null>(
572
+ () => getRowMutationDescriptor(selectedEntity, structureState.columns),
573
+ [selectedEntity, structureState.columns],
574
+ );
575
+ const canMutateRows = rowMutationDescriptor != null;
576
+ const visibleBrowseColumnIds = useMemo(
577
+ () =>
578
+ (filteredBrowseResult?.columns ?? []).filter(
579
+ (column) => column !== SQLITE_HIDDEN_ROWID_COLUMN_ID,
580
+ ),
581
+ [filteredBrowseResult?.columns],
582
+ );
583
+
584
+ const structureColumnRows = useMemo<StructureColumnRow[]>(
585
+ () =>
586
+ structureState.columns.map((column) => ({
587
+ name: column.name,
588
+ type: column.type || '—',
589
+ nullable: column.notNull ? 'No' : 'Yes',
590
+ defaultValue: column.defaultValue ?? '—',
591
+ primaryKey:
592
+ column.primaryKeyOrder > 0 ? `PK ${column.primaryKeyOrder}` : '—',
593
+ foreignKey: structureState.foreignKeys.some(
594
+ (foreignKey) => foreignKey.from === column.name,
595
+ )
596
+ ? 'Yes'
597
+ : '—',
598
+ extra: column.hidden > 0 ? `Hidden ${column.hidden}` : '—',
599
+ })),
600
+ [structureState.columns, structureState.foreignKeys],
601
+ );
602
+
603
+ const structureIndexRows = useMemo<StructureIndexRow[]>(
604
+ () =>
605
+ structureState.indexes.map((index) => ({
606
+ indexName: index.name,
607
+ columns: index.columns.join(', ') || '—',
608
+ unique: index.unique ? 'Yes' : 'No',
609
+ type: `${index.origin.toUpperCase()}${index.partial ? ' · Partial' : ''}`,
610
+ })),
611
+ [structureState.indexes],
612
+ );
613
+
614
+ const structureColumnsTableColumns = useMemo<
615
+ ColumnDef<StructureColumnRow, unknown>[]
616
+ >(
617
+ () => [
618
+ { id: 'name', header: 'Name', accessorKey: 'name' },
619
+ { id: 'type', header: 'Type', accessorKey: 'type' },
620
+ { id: 'nullable', header: 'Nullable', accessorKey: 'nullable' },
621
+ { id: 'defaultValue', header: 'Default', accessorKey: 'defaultValue' },
622
+ { id: 'primaryKey', header: 'PK', accessorKey: 'primaryKey' },
623
+ { id: 'foreignKey', header: 'FK', accessorKey: 'foreignKey' },
624
+ { id: 'extra', header: 'Extra', accessorKey: 'extra' },
625
+ ],
626
+ [],
627
+ );
628
+
629
+ const structureIndexesTableColumns = useMemo<
630
+ ColumnDef<StructureIndexRow, unknown>[]
631
+ >(
632
+ () => [
633
+ { id: 'indexName', header: 'Index Name', accessorKey: 'indexName' },
634
+ { id: 'columns', header: 'Columns', accessorKey: 'columns' },
635
+ { id: 'unique', header: 'Unique', accessorKey: 'unique' },
636
+ { id: 'type', header: 'Type', accessorKey: 'type' },
637
+ ],
638
+ [],
639
+ );
640
+
641
+ const queryStatements = useMemo(
642
+ () => splitSqlStatements(queryInput),
643
+ [queryInput],
644
+ );
645
+
646
+ const selectedQueryStatement = useMemo(() => {
647
+ if (!queryExecution) {
648
+ return null;
649
+ }
650
+
651
+ const nextIndex =
652
+ selectedQueryStatementIndex ??
653
+ getDefaultSelectedQueryStatementIndex(queryExecution);
654
+
655
+ if (nextIndex == null) {
656
+ return null;
657
+ }
658
+
659
+ return (
660
+ queryExecution.statements.find(
661
+ (statement) => statement.index === nextIndex,
662
+ ) ?? null
663
+ );
664
+ }, [queryExecution, selectedQueryStatementIndex]);
665
+
666
+ const activeQueryResult = useMemo(
667
+ () => getStatementQueryResult(selectedQueryStatement),
668
+ [selectedQueryStatement],
669
+ );
670
+
671
+ const selectedQueryStatementValue = selectedQueryStatement?.index ?? '';
672
+
673
+ const queryTableId = useMemo(
674
+ () =>
675
+ buildQueryTableId(selectedDatabaseId, activeQueryResult?.columns ?? []),
676
+ [activeQueryResult?.columns, selectedDatabaseId],
677
+ );
678
+
679
+ const dataTableId = useMemo(
680
+ () =>
681
+ buildEntityTableId(
682
+ 'data',
683
+ selectedDatabaseId,
684
+ selectedEntity?.schemaName ?? null,
685
+ selectedEntity?.name ?? null,
686
+ ),
687
+ [selectedDatabaseId, selectedEntity?.name, selectedEntity?.schemaName],
688
+ );
689
+
690
+ const structureColumnsTableId = useMemo(
691
+ () =>
692
+ buildEntityTableId(
693
+ 'structure-columns',
694
+ selectedDatabaseId,
695
+ selectedEntity?.schemaName ?? null,
696
+ selectedEntity?.name ?? null,
697
+ ),
698
+ [selectedDatabaseId, selectedEntity?.name, selectedEntity?.schemaName],
699
+ );
700
+
701
+ const structureIndexesTableId = useMemo(
702
+ () =>
703
+ buildEntityTableId(
704
+ 'structure-indexes',
705
+ selectedDatabaseId,
706
+ selectedEntity?.schemaName ?? null,
707
+ selectedEntity?.name ?? null,
708
+ ),
709
+ [selectedDatabaseId, selectedEntity?.name, selectedEntity?.schemaName],
710
+ );
711
+
712
+ const getTableColumnOrder = useCallback(
713
+ (
714
+ tableId: string,
715
+ columnIds: string[],
716
+ fixedLeadingColumnIds: string[] = [],
717
+ ) =>
718
+ resolveTableColumnOrderUpdate({
719
+ columnIds,
720
+ fixedLeadingColumnIds,
721
+ storedColumnOrder: tableColumnOrderById[tableId],
722
+ nextColumnOrder: getDefaultTableColumnOrder(
723
+ columnIds,
724
+ fixedLeadingColumnIds,
725
+ ),
726
+ }),
727
+ [tableColumnOrderById],
728
+ );
729
+
730
+ const setTableColumnOrder = useCallback(
731
+ (
732
+ tableId: string,
733
+ columnIds: string[],
734
+ nextColumnOrder: Updater<string[]>,
735
+ fixedLeadingColumnIds: string[] = [],
736
+ ) => {
737
+ setTableColumnOrderById((current) => {
738
+ const resolvedColumnOrder = resolveTableColumnOrderUpdate({
739
+ columnIds,
740
+ fixedLeadingColumnIds,
741
+ storedColumnOrder: current[tableId],
742
+ nextColumnOrder,
743
+ });
744
+
745
+ if (areColumnOrdersEqual(current[tableId] ?? [], resolvedColumnOrder)) {
746
+ return current;
747
+ }
748
+
749
+ return {
750
+ ...current,
751
+ [tableId]: resolvedColumnOrder,
752
+ };
753
+ });
754
+ },
755
+ [],
756
+ );
757
+
758
+ const queryColumnIds = useMemo(
759
+ () => [SQLITE_ROW_NUMBER_COLUMN_ID, ...(activeQueryResult?.columns ?? [])],
760
+ [activeQueryResult?.columns],
761
+ );
762
+ const dataColumnIds = useMemo(
763
+ () => [
764
+ SQLITE_ROW_NUMBER_COLUMN_ID,
765
+ ...visibleBrowseColumnIds,
766
+ ...(canMutateRows ? [SQLITE_ROW_ACTIONS_COLUMN_ID] : []),
767
+ ],
768
+ [canMutateRows, visibleBrowseColumnIds],
769
+ );
770
+ const structureColumnsColumnIds = useMemo(
771
+ () => structureColumnsTableColumns.map((column) => column.id as string),
772
+ [structureColumnsTableColumns],
773
+ );
774
+ const structureIndexesColumnIds = useMemo(
775
+ () => structureIndexesTableColumns.map((column) => column.id as string),
776
+ [structureIndexesTableColumns],
777
+ );
778
+
779
+ const queryColumnOrder = useMemo(
780
+ () =>
781
+ getTableColumnOrder(queryTableId, queryColumnIds, [
782
+ SQLITE_ROW_NUMBER_COLUMN_ID,
783
+ ]),
784
+ [getTableColumnOrder, queryColumnIds, queryTableId],
785
+ );
786
+ const dataColumnOrder = useMemo(
787
+ () =>
788
+ getTableColumnOrder(dataTableId, dataColumnIds, [
789
+ SQLITE_ROW_NUMBER_COLUMN_ID,
790
+ ]),
791
+ [dataColumnIds, dataTableId, getTableColumnOrder],
792
+ );
793
+ const structureColumnsColumnOrder = useMemo(
794
+ () =>
795
+ getTableColumnOrder(structureColumnsTableId, structureColumnsColumnIds),
796
+ [getTableColumnOrder, structureColumnsColumnIds, structureColumnsTableId],
797
+ );
798
+ const structureIndexesColumnOrder = useMemo(
799
+ () =>
800
+ getTableColumnOrder(structureIndexesTableId, structureIndexesColumnIds),
801
+ [getTableColumnOrder, structureIndexesColumnIds, structureIndexesTableId],
802
+ );
803
+
804
+ const setEntitySelection = useCallback(
805
+ (databaseId: string, entity: SqliteEntity) => {
806
+ setSelectedDatabaseId(databaseId);
807
+ setSelectedEntityKey(
808
+ getEntityKey(databaseId, entity.schemaName, entity.name),
809
+ );
810
+ },
811
+ [],
812
+ );
813
+
814
+ const loadDatabases = useCallback(async () => {
815
+ setDatabaseLoading(true);
816
+ setDatabaseError(null);
817
+
818
+ try {
819
+ const nextDatabases = await requestDatabases();
820
+ setDatabases(nextDatabases);
821
+ setExplorerStateByDatabase((current) =>
822
+ Object.fromEntries(
823
+ nextDatabases.map((database) => [
824
+ database.id,
825
+ current[database.id] ?? DEFAULT_EXPLORER_STATE,
826
+ ]),
827
+ ),
828
+ );
829
+ setExpandedDatabaseIds((current) => {
830
+ const currentIds = current.filter((id) =>
831
+ nextDatabases.some((database) => database.id === id),
832
+ );
833
+ const missingIds = nextDatabases
834
+ .map((database) => database.id)
835
+ .filter((id) => !currentIds.includes(id));
836
+
837
+ return [...currentIds, ...missingIds];
838
+ });
839
+ setSelectedDatabaseId((current) => {
840
+ if (
841
+ current &&
842
+ nextDatabases.some((database) => database.id === current)
843
+ ) {
844
+ return current;
845
+ }
846
+
847
+ return nextDatabases[0]?.id ?? null;
848
+ });
849
+ return nextDatabases;
850
+ } catch (error) {
851
+ setDatabaseError(safeError(error));
852
+ setDatabases([]);
853
+ setExplorerStateByDatabase({});
854
+ setSelectedDatabaseId(null);
855
+ return [];
856
+ } finally {
857
+ setDatabaseLoading(false);
858
+ }
859
+ }, [requestDatabases]);
860
+
861
+ const loadExplorer = useCallback(
862
+ async (databaseId: string) => {
863
+ setExplorerStateByDatabase((current) => ({
864
+ ...current,
865
+ [databaseId]: {
866
+ ...(current[databaseId] ?? DEFAULT_EXPLORER_STATE),
867
+ loading: true,
868
+ error: null,
869
+ },
870
+ }));
871
+
872
+ try {
873
+ const schemaResult = await requestQuery({
874
+ databaseId,
875
+ sql: LIST_SCHEMAS_SQL,
876
+ });
877
+ const nextSchemas = parseSchemas(schemaResult);
878
+ const entityResults = await Promise.all(
879
+ nextSchemas.map(async (schema) => ({
880
+ schemaName: schema.name,
881
+ result: await requestQuery({
882
+ databaseId,
883
+ sql: buildListEntitiesSql(schema.name),
884
+ }),
885
+ })),
886
+ );
887
+ const nextEntities = entityResults.flatMap(({ schemaName, result }) =>
888
+ parseEntities(result, schemaName),
889
+ );
890
+
891
+ setExplorerStateByDatabase((current) => ({
892
+ ...current,
893
+ [databaseId]: {
894
+ schemas: nextSchemas,
895
+ entities: nextEntities,
896
+ loading: false,
897
+ error: null,
898
+ loaded: true,
899
+ },
900
+ }));
901
+ setExpandedSchemaKeys((current) => {
902
+ const nextKeys = nextSchemas.map((schema) =>
903
+ getSchemaKey(databaseId, schema.name),
904
+ );
905
+ return Array.from(new Set([...current, ...nextKeys]));
906
+ });
907
+ } catch (error) {
908
+ setExplorerStateByDatabase((current) => ({
909
+ ...current,
910
+ [databaseId]: {
911
+ schemas: [],
912
+ entities: [],
913
+ loading: false,
914
+ error: safeError(error),
915
+ loaded: true,
916
+ },
917
+ }));
918
+ }
919
+ },
920
+ [requestQuery],
921
+ );
922
+
923
+ const loadBrowse = useCallback(async () => {
924
+ const requestVersion = browseRequestVersionRef.current + 1;
925
+ browseRequestVersionRef.current = requestVersion;
926
+
927
+ if (!selectedDatabaseId || !selectedEntity) {
928
+ setBrowseResult(null);
929
+ setEntityRowCount(null);
930
+ return;
931
+ }
932
+
933
+ const requestEntityKey = getEntityKey(
934
+ selectedDatabaseId,
935
+ selectedEntity.schemaName,
936
+ selectedEntity.name,
937
+ );
938
+
939
+ setBrowseLoading(true);
940
+ setBrowseError(null);
941
+
942
+ try {
943
+ const [result, countResult] = await Promise.all([
944
+ requestQuery({
945
+ databaseId: selectedDatabaseId,
946
+ sql: buildBrowseEntitySql(
947
+ selectedEntity.schemaName,
948
+ selectedEntity.name,
949
+ browsePageSize,
950
+ browseOffset,
951
+ rowMutationDescriptor?.mode === 'rowid'
952
+ ? rowMutationDescriptor.rowIdIdentifier
953
+ : null,
954
+ ),
955
+ }),
956
+ requestQuery({
957
+ databaseId: selectedDatabaseId,
958
+ sql: buildEntityCountSql(
959
+ selectedEntity.schemaName,
960
+ selectedEntity.name,
961
+ ),
962
+ }),
963
+ ]);
964
+
965
+ if (
966
+ browseRequestVersionRef.current !== requestVersion ||
967
+ selectedDatabaseIdRef.current !== selectedDatabaseId ||
968
+ selectedEntityKeyRef.current !== requestEntityKey
969
+ ) {
970
+ return;
971
+ }
972
+
973
+ setBrowseResult(result);
974
+ setEntityRowCount(parseCount(countResult));
975
+ } catch (error) {
976
+ if (
977
+ browseRequestVersionRef.current !== requestVersion ||
978
+ selectedDatabaseIdRef.current !== selectedDatabaseId ||
979
+ selectedEntityKeyRef.current !== requestEntityKey
980
+ ) {
981
+ return;
982
+ }
983
+
984
+ setBrowseError(safeError(error));
985
+ setBrowseResult(null);
986
+ setEntityRowCount(null);
987
+ } finally {
988
+ if (browseRequestVersionRef.current === requestVersion) {
989
+ setBrowseLoading(false);
990
+ }
991
+ }
992
+ }, [
993
+ browseOffset,
994
+ browsePageSize,
995
+ requestQuery,
996
+ rowMutationDescriptor,
997
+ selectedDatabaseId,
998
+ selectedEntity,
999
+ ]);
1000
+
1001
+ const loadStructure = useCallback(async () => {
1002
+ const requestVersion = structureRequestVersionRef.current + 1;
1003
+ structureRequestVersionRef.current = requestVersion;
1004
+
1005
+ if (!selectedDatabaseId || !selectedEntity) {
1006
+ setStructureState({
1007
+ columns: [],
1008
+ foreignKeys: [],
1009
+ indexes: [],
1010
+ });
1011
+ return;
1012
+ }
1013
+
1014
+ const requestEntityKey = getEntityKey(
1015
+ selectedDatabaseId,
1016
+ selectedEntity.schemaName,
1017
+ selectedEntity.name,
1018
+ );
1019
+
1020
+ setStructureLoading(true);
1021
+ setStructureError(null);
1022
+
1023
+ try {
1024
+ const [columnsOutcome, foreignKeysOutcome, indexesOutcome] =
1025
+ await Promise.allSettled([
1026
+ requestQuery({
1027
+ databaseId: selectedDatabaseId,
1028
+ sql: buildTableXInfoSql(
1029
+ selectedEntity.schemaName,
1030
+ selectedEntity.name,
1031
+ ),
1032
+ }),
1033
+ requestQuery({
1034
+ databaseId: selectedDatabaseId,
1035
+ sql: buildForeignKeySql(
1036
+ selectedEntity.schemaName,
1037
+ selectedEntity.name,
1038
+ ),
1039
+ }),
1040
+ requestQuery({
1041
+ databaseId: selectedDatabaseId,
1042
+ sql: buildIndexListSql(
1043
+ selectedEntity.schemaName,
1044
+ selectedEntity.name,
1045
+ ),
1046
+ }),
1047
+ ]);
1048
+
1049
+ const columns =
1050
+ columnsOutcome.status === 'fulfilled'
1051
+ ? parseColumns(columnsOutcome.value)
1052
+ : [];
1053
+ const foreignKeys =
1054
+ foreignKeysOutcome.status === 'fulfilled'
1055
+ ? parseForeignKeys(foreignKeysOutcome.value)
1056
+ : [];
1057
+ const indexes =
1058
+ indexesOutcome.status === 'fulfilled'
1059
+ ? parseIndexes(indexesOutcome.value)
1060
+ : [];
1061
+
1062
+ const enrichedIndexes = await Promise.all(
1063
+ indexes.map(async (index) => {
1064
+ try {
1065
+ const result = await requestQuery({
1066
+ databaseId: selectedDatabaseId,
1067
+ sql: buildIndexInfoSql(selectedEntity.schemaName, index.name),
1068
+ });
1069
+
1070
+ return {
1071
+ ...index,
1072
+ columns: parseIndexColumns(result)
1073
+ .sort((left, right) => left.seqno - right.seqno)
1074
+ .map((column) => column.name),
1075
+ };
1076
+ } catch {
1077
+ return {
1078
+ ...index,
1079
+ columns: [],
1080
+ };
1081
+ }
1082
+ }),
1083
+ );
1084
+
1085
+ if (
1086
+ structureRequestVersionRef.current !== requestVersion ||
1087
+ selectedDatabaseIdRef.current !== selectedDatabaseId ||
1088
+ selectedEntityKeyRef.current !== requestEntityKey
1089
+ ) {
1090
+ return;
1091
+ }
1092
+
1093
+ setStructureState({
1094
+ columns,
1095
+ foreignKeys,
1096
+ indexes: enrichedIndexes,
1097
+ });
1098
+ } catch (error) {
1099
+ if (
1100
+ structureRequestVersionRef.current !== requestVersion ||
1101
+ selectedDatabaseIdRef.current !== selectedDatabaseId ||
1102
+ selectedEntityKeyRef.current !== requestEntityKey
1103
+ ) {
1104
+ return;
1105
+ }
1106
+
1107
+ setStructureError(safeError(error));
1108
+ setStructureState({
1109
+ columns: [],
1110
+ foreignKeys: [],
1111
+ indexes: [],
1112
+ });
1113
+ } finally {
1114
+ if (structureRequestVersionRef.current === requestVersion) {
1115
+ setStructureLoading(false);
1116
+ }
1117
+ }
1118
+ }, [requestQuery, selectedDatabaseId, selectedEntity]);
1119
+
1120
+ const refreshExplorerData = useCallback(async () => {
1121
+ const nextDatabases = await loadDatabases();
1122
+ await Promise.all(
1123
+ nextDatabases.map((database) => loadExplorer(database.id)),
1124
+ );
1125
+ }, [loadDatabases, loadExplorer]);
1126
+
1127
+ const refreshWorkspace = useCallback(async () => {
1128
+ await refreshExplorerData();
1129
+
1130
+ if (selectedEntity) {
1131
+ await Promise.all([loadBrowse(), loadStructure()]);
1132
+ }
1133
+ }, [loadBrowse, loadStructure, refreshExplorerData, selectedEntity]);
1134
+
1135
+ const handleSaveRow = useCallback(
1136
+ async (nextValues: Record<string, unknown>) => {
1137
+ if (
1138
+ !selectedDatabaseId ||
1139
+ !selectedEntity ||
1140
+ !editingRow ||
1141
+ !rowMutationDescriptor
1142
+ ) {
1143
+ throw new Error('The selected row is no longer available.');
1144
+ }
1145
+
1146
+ const mutation = buildRowUpdateMutation({
1147
+ entity: selectedEntity,
1148
+ columns: structureState.columns,
1149
+ row: editingRow.row,
1150
+ descriptor: rowMutationDescriptor,
1151
+ nextValues,
1152
+ });
1153
+
1154
+ await requestQuery({
1155
+ databaseId: selectedDatabaseId,
1156
+ sql: mutation.sql,
1157
+ params: mutation.params,
1158
+ });
1159
+ await loadBrowse();
1160
+ setEditingRow(null);
1161
+ },
1162
+ [
1163
+ editingRow,
1164
+ loadBrowse,
1165
+ requestQuery,
1166
+ rowMutationDescriptor,
1167
+ selectedDatabaseId,
1168
+ selectedEntity,
1169
+ structureState.columns,
1170
+ ],
1171
+ );
1172
+
1173
+ const handleDeleteRow = useCallback(async () => {
1174
+ if (
1175
+ !selectedDatabaseId ||
1176
+ !selectedEntity ||
1177
+ !deletingRow ||
1178
+ !rowMutationDescriptor
1179
+ ) {
1180
+ throw new Error('The selected row is no longer available.');
1181
+ }
1182
+
1183
+ const mutation = buildRowDeleteMutation({
1184
+ entity: selectedEntity,
1185
+ row: deletingRow.row,
1186
+ descriptor: rowMutationDescriptor,
1187
+ });
1188
+
1189
+ await requestQuery({
1190
+ databaseId: selectedDatabaseId,
1191
+ sql: mutation.sql,
1192
+ params: mutation.params,
1193
+ });
1194
+ await loadBrowse();
1195
+ setDeletingRow(null);
1196
+ }, [
1197
+ deletingRow,
1198
+ loadBrowse,
1199
+ requestQuery,
1200
+ rowMutationDescriptor,
1201
+ selectedDatabaseId,
1202
+ selectedEntity,
1203
+ ]);
1204
+
1205
+ const getActiveStatement = useCallback(() => {
1206
+ const cursorPosition =
1207
+ editorRef.current?.getSelection().start ?? querySelection.start;
1208
+ const currentStatement = getStatementAtCursor(queryInput, cursorPosition);
1209
+ const start = currentStatement?.start ?? 0;
1210
+ const end = currentStatement?.end ?? queryInput.length;
1211
+
1212
+ return {
1213
+ sql: normalizeSingleStatementSql(currentStatement?.text ?? queryInput),
1214
+ cursorPosition,
1215
+ start,
1216
+ end,
1217
+ };
1218
+ }, [queryInput, querySelection.start]);
1219
+
1220
+ const runSingleStatement = useCallback(
1221
+ async (
1222
+ statement: {
1223
+ sql: string;
1224
+ cursorPosition: number;
1225
+ start: number;
1226
+ end: number;
1227
+ },
1228
+ label: string,
1229
+ ) => {
1230
+ if (!selectedDatabaseId) {
1231
+ return;
1232
+ }
1233
+
1234
+ setQueryLoading(true);
1235
+ setQueryError(null);
1236
+ setQueryErrorLine(null);
1237
+ setQueryMessage(`${label}…`);
1238
+
1239
+ try {
1240
+ const result = await requestQuery({
1241
+ databaseId: selectedDatabaseId,
1242
+ sql: statement.sql,
1243
+ });
1244
+ const execution: SqliteScriptResult = {
1245
+ statements: [
1246
+ {
1247
+ index: 0,
1248
+ start: statement.start,
1249
+ end: statement.end,
1250
+ input: { sql: statement.sql },
1251
+ execution: {
1252
+ input: { sql: statement.sql },
1253
+ result,
1254
+ },
1255
+ },
1256
+ ],
1257
+ totalStatementCount: 1,
1258
+ failedStatementIndex: null,
1259
+ };
1260
+
1261
+ setQueryExecution(execution);
1262
+ setSelectedQueryStatementIndex(0);
1263
+ setQueryMessage(getResultSummary(result) ?? 'Statement completed.');
1264
+
1265
+ if (isMutatingStatement(result)) {
1266
+ await refreshWorkspace();
1267
+ }
1268
+ } catch (error) {
1269
+ const errorMessage = safeError(error);
1270
+
1271
+ setQueryExecution({
1272
+ statements: [
1273
+ {
1274
+ index: 0,
1275
+ start: statement.start,
1276
+ end: statement.end,
1277
+ input: { sql: statement.sql },
1278
+ error: errorMessage,
1279
+ },
1280
+ ],
1281
+ totalStatementCount: 1,
1282
+ failedStatementIndex: 0,
1283
+ });
1284
+ setSelectedQueryStatementIndex(0);
1285
+ setQueryError(errorMessage);
1286
+ setQueryErrorLine(getLineNumberAtPosition(queryInput, statement.start));
1287
+ setQueryMessage('Execution failed.');
1288
+ } finally {
1289
+ setQueryLoading(false);
1290
+ editorRef.current?.focus();
1291
+ }
1292
+ },
1293
+ [queryInput, refreshWorkspace, requestQuery, selectedDatabaseId],
1294
+ );
1295
+
1296
+ const runScript = useCallback(
1297
+ async (sql: string, label: string) => {
1298
+ if (!selectedDatabaseId) {
1299
+ return;
1300
+ }
1301
+
1302
+ setQueryLoading(true);
1303
+ setQueryError(null);
1304
+ setQueryErrorLine(null);
1305
+ setQueryMessage(`${label}…`);
1306
+
1307
+ try {
1308
+ const execution = await requestScriptExecution({
1309
+ databaseId: selectedDatabaseId,
1310
+ sql,
1311
+ });
1312
+ const failedStatement =
1313
+ execution.failedStatementIndex == null
1314
+ ? null
1315
+ : (execution.statements.find(
1316
+ (statement) =>
1317
+ statement.index === execution.failedStatementIndex,
1318
+ ) ?? null);
1319
+
1320
+ setQueryExecution(execution);
1321
+ setSelectedQueryStatementIndex(
1322
+ getDefaultSelectedQueryStatementIndex(execution),
1323
+ );
1324
+
1325
+ if (failedStatement?.error) {
1326
+ setQueryError(failedStatement.error);
1327
+ setQueryErrorLine(
1328
+ getLineNumberAtPosition(queryInput, failedStatement.start),
1329
+ );
1330
+ }
1331
+
1332
+ setQueryMessage(
1333
+ getScriptResultSummary(execution) ?? 'Script execution completed.',
1334
+ );
1335
+
1336
+ if (hasMutatingStatements(execution)) {
1337
+ await refreshWorkspace();
1338
+ }
1339
+ } catch (error) {
1340
+ setQueryExecution(null);
1341
+ setSelectedQueryStatementIndex(null);
1342
+ setQueryError(safeError(error));
1343
+ setQueryErrorLine(
1344
+ getLineNumberAtPosition(queryInput, querySelection.start),
1345
+ );
1346
+ setQueryMessage('Execution failed.');
1347
+ } finally {
1348
+ setQueryLoading(false);
1349
+ editorRef.current?.focus();
1350
+ }
1351
+ },
1352
+ [
1353
+ queryInput,
1354
+ querySelection.start,
1355
+ refreshWorkspace,
1356
+ requestScriptExecution,
1357
+ selectedDatabaseId,
1358
+ ],
1359
+ );
1360
+
1361
+ const handleRun = useCallback(async () => {
1362
+ await runScript(queryInput, 'Running all statements');
1363
+ }, [queryInput, runScript]);
1364
+
1365
+ const handleRunCurrentStatement = useCallback(async () => {
1366
+ try {
1367
+ await runSingleStatement(
1368
+ getActiveStatement(),
1369
+ 'Running current statement',
1370
+ );
1371
+ } catch (error) {
1372
+ setQueryError(safeError(error));
1373
+ setQueryErrorLine(
1374
+ getLineNumberAtPosition(queryInput, querySelection.start),
1375
+ );
1376
+ }
1377
+ }, [
1378
+ getActiveStatement,
1379
+ queryInput,
1380
+ querySelection.start,
1381
+ runSingleStatement,
1382
+ ]);
1383
+
1384
+ const handleSaveQuery = useCallback(() => {
1385
+ const fileName = `${slugifyFileName(selectedEntity?.name ?? 'query')}.sql`;
1386
+ downloadTextFile(fileName, queryInput);
1387
+ setQueryMessage(`Saved ${fileName}.`);
1388
+ }, [queryInput, selectedEntity?.name]);
1389
+
1390
+ const handleExportResults = useCallback(async () => {
1391
+ const csv = buildCsv(activeQueryResult);
1392
+ if (!csv) {
1393
+ return;
1394
+ }
1395
+
1396
+ const fileName = `${slugifyFileName(selectedEntity?.name ?? 'query-results')}.csv`;
1397
+ downloadTextFile(fileName, csv);
1398
+ setQueryMessage(`Exported ${fileName}.`);
1399
+ }, [activeQueryResult, selectedEntity?.name]);
1400
+
1401
+ const handleCopyResults = useCallback(async () => {
1402
+ if (!activeQueryResult) {
1403
+ return;
1404
+ }
1405
+
1406
+ await copyToClipboard(JSON.stringify(activeQueryResult.rows, null, 2));
1407
+ setQueryMessage('Copied result rows as JSON.');
1408
+ }, [activeQueryResult]);
1409
+
1410
+ const handleCopyError = useCallback(async () => {
1411
+ if (!queryError) {
1412
+ return;
1413
+ }
1414
+
1415
+ await copyToClipboard(queryError);
1416
+ setQueryMessage('Copied SQL error.');
1417
+ }, [queryError]);
1418
+
1419
+ const handleFormatQuery = useCallback(() => {
1420
+ try {
1421
+ const formatted = formatSqlScript(queryInput);
1422
+ setQueryInput(formatted);
1423
+ setQueryError(null);
1424
+ setQueryErrorLine(null);
1425
+ setQueryMessage(
1426
+ formatted ? 'Formatted query.' : 'Cleared query formatting.',
1427
+ );
1428
+ } catch (error) {
1429
+ setQueryError(safeError(error));
1430
+ setQueryErrorLine(null);
1431
+ setQueryMessage('Formatting failed.');
1432
+ }
1433
+ }, [queryInput]);
1434
+
1435
+ const ensureQueryEntityColumns = useCallback(
1436
+ async (schemaName: string, entityName: string) => {
1437
+ if (!selectedDatabaseId) {
1438
+ return [];
1439
+ }
1440
+
1441
+ const cachedColumns = getSqlEditorCachedColumns(
1442
+ queryColumnCache,
1443
+ selectedDatabaseId,
1444
+ schemaName,
1445
+ entityName,
1446
+ );
1447
+ if (cachedColumns) {
1448
+ return cachedColumns;
1449
+ }
1450
+
1451
+ const result = await requestQuery({
1452
+ databaseId: selectedDatabaseId,
1453
+ sql: buildTableXInfoSql(schemaName, entityName),
1454
+ });
1455
+ const columns = parseColumns(result);
1456
+
1457
+ setQueryColumnCache((current) =>
1458
+ setSqlEditorCachedColumns(
1459
+ current,
1460
+ selectedDatabaseId,
1461
+ schemaName,
1462
+ entityName,
1463
+ columns,
1464
+ ),
1465
+ );
1466
+
1467
+ return columns;
1468
+ },
1469
+ [queryColumnCache, requestQuery, selectedDatabaseId],
1470
+ );
1471
+
1472
+ const editorCompletionSource = useCallback<CompletionSource>(
1473
+ async (context) => {
1474
+ const request = getSqlEditorColumnCompletionRequest(
1475
+ context.state.doc.toString(),
1476
+ context.pos,
1477
+ );
1478
+ if (!request) {
1479
+ return null;
1480
+ }
1481
+
1482
+ const aliases = extractSqlEditorAliases(
1483
+ context.state.doc.sliceString(0, context.pos),
1484
+ );
1485
+ const entity = resolveSqlEditorEntityReference({
1486
+ aliases,
1487
+ entities,
1488
+ request,
1489
+ selectedSchemaName:
1490
+ selectedEntity?.schemaName ?? defaultCompletionSchemaName ?? null,
1491
+ });
1492
+ if (!entity) {
1493
+ return null;
1494
+ }
1495
+
1496
+ const columns = await ensureQueryEntityColumns(
1497
+ entity.schemaName,
1498
+ entity.name,
1499
+ );
1500
+ if (context.aborted || columns.length === 0) {
1501
+ return null;
1502
+ }
1503
+
1504
+ return {
1505
+ from: request.from,
1506
+ options: createSqlColumnCompletions(columns),
1507
+ to: request.to,
1508
+ validFor: /^[A-Za-z_][\w$]*$/,
1509
+ };
1510
+ },
1511
+ [
1512
+ defaultCompletionSchemaName,
1513
+ ensureQueryEntityColumns,
1514
+ entities,
1515
+ selectedEntity?.schemaName,
1516
+ ],
1517
+ );
1518
+
1519
+ const handleSidebarResizeStart = useCallback(
1520
+ (event: ReactPointerEvent<HTMLDivElement>) => {
1521
+ const container = sidebarRef.current;
1522
+ if (!container) {
1523
+ return;
1524
+ }
1525
+
1526
+ event.preventDefault();
1527
+
1528
+ const startX = event.clientX;
1529
+ const startWidth = sidebarWidth;
1530
+ const previousCursor = document.body.style.cursor;
1531
+ const previousUserSelect = document.body.style.userSelect;
1532
+
1533
+ document.body.style.cursor = 'col-resize';
1534
+ document.body.style.userSelect = 'none';
1535
+
1536
+ const handlePointerMove = (moveEvent: PointerEvent) => {
1537
+ const nextWidth = Math.min(
1538
+ MAX_SIDEBAR_WIDTH,
1539
+ Math.max(MIN_SIDEBAR_WIDTH, startWidth + moveEvent.clientX - startX),
1540
+ );
1541
+ setSidebarWidth(nextWidth);
1542
+ };
1543
+
1544
+ const handlePointerUp = () => {
1545
+ document.body.style.cursor = previousCursor;
1546
+ document.body.style.userSelect = previousUserSelect;
1547
+ window.removeEventListener('pointermove', handlePointerMove);
1548
+ window.removeEventListener('pointerup', handlePointerUp);
1549
+ window.removeEventListener('pointercancel', handlePointerUp);
1550
+ };
1551
+
1552
+ window.addEventListener('pointermove', handlePointerMove);
1553
+ window.addEventListener('pointerup', handlePointerUp);
1554
+ window.addEventListener('pointercancel', handlePointerUp);
1555
+ },
1556
+ [sidebarWidth],
1557
+ );
1558
+
1559
+ const handleQuerySplitResizeStart = useCallback(
1560
+ (event: ReactPointerEvent<HTMLDivElement>) => {
1561
+ const container = querySplitRef.current;
1562
+ if (!container) {
1563
+ return;
1564
+ }
1565
+
1566
+ event.preventDefault();
1567
+
1568
+ const rect = container.getBoundingClientRect();
1569
+ const totalHeight = rect.height;
1570
+ const startHeight = (editorSplit / 100) * totalHeight;
1571
+ const startY = event.clientY;
1572
+ const previousCursor = document.body.style.cursor;
1573
+ const previousUserSelect = document.body.style.userSelect;
1574
+
1575
+ document.body.style.cursor = 'row-resize';
1576
+ document.body.style.userSelect = 'none';
1577
+
1578
+ const handlePointerMove = (moveEvent: PointerEvent) => {
1579
+ const nextHeight = startHeight + moveEvent.clientY - startY;
1580
+ const boundedHeight = Math.max(
1581
+ MIN_EDITOR_HEIGHT,
1582
+ Math.min(totalHeight - MIN_RESULTS_HEIGHT, nextHeight),
1583
+ );
1584
+ setEditorSplit((boundedHeight / totalHeight) * 100);
1585
+ };
1586
+
1587
+ const handlePointerUp = () => {
1588
+ document.body.style.cursor = previousCursor;
1589
+ document.body.style.userSelect = previousUserSelect;
1590
+ window.removeEventListener('pointermove', handlePointerMove);
1591
+ window.removeEventListener('pointerup', handlePointerUp);
1592
+ window.removeEventListener('pointercancel', handlePointerUp);
1593
+ };
1594
+
1595
+ window.addEventListener('pointermove', handlePointerMove);
1596
+ window.addEventListener('pointerup', handlePointerUp);
1597
+ window.addEventListener('pointercancel', handlePointerUp);
1598
+ },
1599
+ [editorSplit],
1600
+ );
1601
+
1602
+ useEffect(() => {
1603
+ void refreshExplorerData();
1604
+ }, [refreshExplorerData]);
1605
+
1606
+ useEffect(() => {
1607
+ if (
1608
+ !selectedDatabaseId ||
1609
+ selectedExplorerState.loading ||
1610
+ !selectedExplorerState.loaded
1611
+ ) {
1612
+ return;
1613
+ }
1614
+
1615
+ setSelectedEntityKey((current) => {
1616
+ if (
1617
+ current &&
1618
+ selectedExplorerState.entities.some(
1619
+ (entity) =>
1620
+ getEntityKey(selectedDatabaseId, entity.schemaName, entity.name) ===
1621
+ current,
1622
+ )
1623
+ ) {
1624
+ return current;
1625
+ }
1626
+
1627
+ const fallbackEntity = selectedExplorerState.entities[0];
1628
+ return fallbackEntity
1629
+ ? getEntityKey(
1630
+ selectedDatabaseId,
1631
+ fallbackEntity.schemaName,
1632
+ fallbackEntity.name,
1633
+ )
1634
+ : null;
1635
+ });
1636
+ }, [
1637
+ selectedDatabaseId,
1638
+ selectedExplorerState.entities,
1639
+ selectedExplorerState.loaded,
1640
+ selectedExplorerState.loading,
1641
+ ]);
1642
+
1643
+ useEffect(() => {
1644
+ if (!selectedEntityKey) {
1645
+ setBrowseOffset(0);
1646
+ setBrowseResult(null);
1647
+ setEntityRowCount(null);
1648
+ setEditingRow(null);
1649
+ setDeletingRow(null);
1650
+ setStructureState({
1651
+ columns: [],
1652
+ foreignKeys: [],
1653
+ indexes: [],
1654
+ });
1655
+ return;
1656
+ }
1657
+
1658
+ setBrowseOffset(0);
1659
+ setEditingRow(null);
1660
+ setDeletingRow(null);
1661
+ void loadStructure();
1662
+ }, [loadStructure, selectedEntityKey]);
1663
+
1664
+ useEffect(() => {
1665
+ if (!selectedEntityKey) {
1666
+ return;
1667
+ }
1668
+
1669
+ void loadBrowse();
1670
+ }, [loadBrowse, selectedEntityKey]);
1671
+
1672
+ useEffect(() => {
1673
+ setQueryColumnCache((current) =>
1674
+ syncSqlEditorColumnCacheDatabase(current, selectedDatabaseId),
1675
+ );
1676
+ }, [selectedDatabaseId]);
1677
+
1678
+ useEffect(() => {
1679
+ if (
1680
+ !selectedDatabaseId ||
1681
+ !selectedEntity ||
1682
+ structureLoading ||
1683
+ structureError
1684
+ ) {
1685
+ return;
1686
+ }
1687
+
1688
+ setQueryColumnCache((current) =>
1689
+ setSqlEditorCachedColumns(
1690
+ current,
1691
+ selectedDatabaseId,
1692
+ selectedEntity.schemaName,
1693
+ selectedEntity.name,
1694
+ structureState.columns,
1695
+ ),
1696
+ );
1697
+ }, [
1698
+ selectedDatabaseId,
1699
+ selectedEntity,
1700
+ structureError,
1701
+ structureLoading,
1702
+ structureState.columns,
1703
+ ]);
1704
+
1705
+ useEffect(() => {
1706
+ if (!selectedEntity) {
1707
+ return;
1708
+ }
1709
+
1710
+ setQueryInput((current) =>
1711
+ current.trim() === '' || current.trim() === DEFAULT_QUERY
1712
+ ? buildGeneratedSelect(selectedEntity, queryRowLimit)
1713
+ : current,
1714
+ );
1715
+ }, [queryRowLimit, selectedEntity]);
1716
+
1717
+ useEffect(() => {
1718
+ if (!selectedDatabaseId) {
1719
+ setQueryExecution(null);
1720
+ setSelectedQueryStatementIndex(null);
1721
+ setQueryError(null);
1722
+ setQueryErrorLine(null);
1723
+ setBrowseResult(null);
1724
+ setBrowseError(null);
1725
+ setStructureError(null);
1726
+ return;
1727
+ }
1728
+ }, [selectedDatabaseId]);
1729
+
1730
+ useEffect(() => {
1731
+ const handleKeyDown = (event: globalThis.KeyboardEvent) => {
1732
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'p') {
1733
+ event.preventDefault();
1734
+ objectSearchRef.current?.focus();
1735
+ }
1736
+
1737
+ if (
1738
+ activeTab === 'query' &&
1739
+ (event.metaKey || event.ctrlKey) &&
1740
+ event.key.toLowerCase() === 's'
1741
+ ) {
1742
+ event.preventDefault();
1743
+ handleSaveQuery();
1744
+ }
1745
+ };
1746
+
1747
+ window.addEventListener('keydown', handleKeyDown);
1748
+ return () => window.removeEventListener('keydown', handleKeyDown);
1749
+ }, [activeTab, handleSaveQuery]);
1750
+
1751
+ useEffect(() => {
1752
+ if (!client) {
1753
+ return;
1754
+ }
1755
+
1756
+ const readySubscription = client.onMessage('sqlite:ready', () => {
1757
+ void refreshExplorerData();
1758
+ });
1759
+
1760
+ return () => readySubscription.remove();
1761
+ }, [client, refreshExplorerData]);
1762
+
1763
+ const queryTabHeader = (
1764
+ <div className="sqlite-subtoolbar">
1765
+ <div className="sqlite-subtoolbar-group">
1766
+ <button
1767
+ type="button"
1768
+ className={primaryIconButtonClassName}
1769
+ onClick={() => void handleRun()}
1770
+ disabled={!selectedDatabaseId || queryLoading || !queryInput.trim()}
1771
+ aria-label="Run all statements"
1772
+ title="Run all statements"
1773
+ >
1774
+ <Play aria-hidden="true" className="h-4 w-4" />
1775
+ </button>
1776
+ </div>
1777
+
1778
+ <div className="sqlite-subtoolbar-group">
1779
+ <button
1780
+ type="button"
1781
+ className={secondaryIconButtonClassName}
1782
+ onClick={handleFormatQuery}
1783
+ disabled={!queryInput.trim()}
1784
+ aria-label="Format SQL"
1785
+ title="Format SQL"
1786
+ >
1787
+ <Wand2 aria-hidden="true" className="h-4 w-4" />
1788
+ </button>
1789
+ <button
1790
+ type="button"
1791
+ className={secondaryIconButtonClassName}
1792
+ onClick={handleSaveQuery}
1793
+ disabled={!queryInput.trim()}
1794
+ aria-label="Save query"
1795
+ title="Save query"
1796
+ >
1797
+ <Download aria-hidden="true" className="h-4 w-4" />
1798
+ </button>
1799
+ <button
1800
+ type="button"
1801
+ className={ghostIconButtonClassName}
1802
+ onClick={() => {
1803
+ setQueryInput('');
1804
+ setQueryExecution(null);
1805
+ setSelectedQueryStatementIndex(null);
1806
+ setQueryError(null);
1807
+ setQueryErrorLine(null);
1808
+ setQueryMessage('Cleared the query editor.');
1809
+ queueMicrotask(() => editorRef.current?.focus());
1810
+ }}
1811
+ disabled={!queryInput && !queryExecution && !queryError}
1812
+ aria-label="Clear query"
1813
+ title="Clear query"
1814
+ >
1815
+ <X aria-hidden="true" className="h-4 w-4" />
1816
+ </button>
1817
+ </div>
1818
+ </div>
1819
+ );
1820
+
1821
+ const queryPane = !selectedDatabase ? (
1822
+ renderEmptyState(
1823
+ 'Select A Database',
1824
+ 'Choose a database in the sidebar to run SQL.',
1825
+ 'database',
1826
+ )
1827
+ ) : (
1828
+ <div ref={querySplitRef} className="sqlite-query-layout">
1829
+ <section
1830
+ className="sqlite-query-editor-pane"
1831
+ style={{ flex: `0 0 ${editorSplit}%` }}
1832
+ >
1833
+ {queryTabHeader}
1834
+ <div className="sqlite-editor-frame">
1835
+ <SqlEditor
1836
+ ref={editorRef}
1837
+ ariaLabel="SQL query editor"
1838
+ completionSchema={editorCompletionSchema}
1839
+ completionSource={editorCompletionSource}
1840
+ defaultSchema={
1841
+ selectedEntity?.schemaName ?? defaultCompletionSchemaName
1842
+ }
1843
+ defaultTable={
1844
+ cachedSelectedEntityColumns.length > 0
1845
+ ? selectedEntity?.name
1846
+ : undefined
1847
+ }
1848
+ errorLine={queryErrorLine}
1849
+ onFormat={handleFormatQuery}
1850
+ onRun={() => void handleRun()}
1851
+ onRunCurrent={() => void handleRunCurrentStatement()}
1852
+ onSave={handleSaveQuery}
1853
+ onSelectionChange={setQuerySelection}
1854
+ onValueChange={setQueryInput}
1855
+ placeholderText={
1856
+ 'Write SQL here…\nPress Cmd/Ctrl + Enter to run all statements.\nPress Shift + Cmd/Ctrl + Enter to run the current statement.\nUse autocomplete for tables and columns.'
1857
+ }
1858
+ value={queryInput}
1859
+ />
1860
+ </div>
1861
+ </section>
1862
+
1863
+ <div
1864
+ role="separator"
1865
+ aria-orientation="horizontal"
1866
+ aria-label="Resize query editor and results"
1867
+ className="sqlite-view-splitter"
1868
+ onPointerDown={handleQuerySplitResizeStart}
1869
+ />
1870
+
1871
+ <section className="sqlite-query-results-pane">
1872
+ <div className="sqlite-results-header sqlite-query-results-header">
1873
+ <div className="sqlite-toolbar-actions sqlite-query-results-header-main">
1874
+ {!queryExecution ? (
1875
+ <span className="sqlite-helper-text">
1876
+ Run SQL to inspect per-statement results.
1877
+ </span>
1878
+ ) : null}
1879
+
1880
+ {queryExecution && queryExecution.statements.length > 1 ? (
1881
+ <div className="sqlite-query-statement-switcher">
1882
+ <select
1883
+ id="sqlite-query-statement-select"
1884
+ aria-label="Selected query statement result"
1885
+ name="queryStatementResult"
1886
+ autoComplete="off"
1887
+ className="sqlite-select"
1888
+ value={selectedQueryStatementValue}
1889
+ onChange={(event: ChangeEvent<HTMLSelectElement>) => {
1890
+ setSelectedQueryStatementIndex(Number(event.target.value));
1891
+ }}
1892
+ >
1893
+ {queryExecution.statements.map((statement) => {
1894
+ return (
1895
+ <option key={statement.index} value={statement.index}>
1896
+ {getStatementSelectorLabel(statement)}
1897
+ </option>
1898
+ );
1899
+ })}
1900
+ </select>
1901
+ </div>
1902
+ ) : null}
1903
+ </div>
1904
+
1905
+ <div className="sqlite-toolbar-actions ml-auto sqlite-query-results-header-actions">
1906
+ <div className="sqlite-field sqlite-field-inline">
1907
+ <label htmlFor="sqlite-query-limit">Row Limit</label>
1908
+ <select
1909
+ id="sqlite-query-limit"
1910
+ aria-label="Default query row limit"
1911
+ name="queryLimit"
1912
+ autoComplete="off"
1913
+ className="sqlite-select"
1914
+ value={queryRowLimit}
1915
+ onChange={(event: ChangeEvent<HTMLSelectElement>) => {
1916
+ setQueryRowLimit(Number(event.target.value));
1917
+ }}
1918
+ >
1919
+ {[25, 50, 100, 250, 500].map((value) => (
1920
+ <option key={value} value={value}>
1921
+ {value}
1922
+ </option>
1923
+ ))}
1924
+ </select>
1925
+ </div>
1926
+ <button
1927
+ type="button"
1928
+ className={secondaryIconButtonClassName}
1929
+ onClick={handleCopyResults}
1930
+ disabled={!activeQueryResult}
1931
+ aria-label="Copy results"
1932
+ title="Copy results"
1933
+ >
1934
+ <Copy aria-hidden="true" className="h-4 w-4" />
1935
+ </button>
1936
+ <button
1937
+ type="button"
1938
+ className={secondaryIconButtonClassName}
1939
+ onClick={handleExportResults}
1940
+ disabled={!activeQueryResult}
1941
+ aria-label="Export results"
1942
+ title="Export results"
1943
+ >
1944
+ <Download aria-hidden="true" className="h-4 w-4" />
1945
+ </button>
1946
+ </div>
1947
+ </div>
1948
+
1949
+ {queryError ? (
1950
+ <div className="sqlite-inline-error" aria-live="polite">
1951
+ <div>
1952
+ <p className="font-medium text-rose-100">
1953
+ {(queryExecution?.totalStatementCount ??
1954
+ queryStatements.length) > 1
1955
+ ? 'Script Error'
1956
+ : 'SQL Error'}
1957
+ </p>
1958
+ <p className="mt-1 text-sm text-rose-100/90">{queryError}</p>
1959
+ {queryErrorLine ? (
1960
+ <p className="mt-1 text-xs text-rose-100/70">
1961
+ Approximate location: line {formatNumber(queryErrorLine)}
1962
+ </p>
1963
+ ) : null}
1964
+ </div>
1965
+ <button
1966
+ type="button"
1967
+ className={ghostButtonClassName}
1968
+ onClick={handleCopyError}
1969
+ >
1970
+ <Copy aria-hidden="true" className="h-4 w-4" />
1971
+ Copy Error
1972
+ </button>
1973
+ </div>
1974
+ ) : null}
1975
+
1976
+ <div className="sqlite-results-panel">
1977
+ <QueryResultTable
1978
+ tableId={queryTableId}
1979
+ result={activeQueryResult}
1980
+ columnOrder={queryColumnOrder}
1981
+ onColumnOrderChange={(nextColumnOrder) =>
1982
+ setTableColumnOrder(
1983
+ queryTableId,
1984
+ queryColumnIds,
1985
+ nextColumnOrder,
1986
+ [SQLITE_ROW_NUMBER_COLUMN_ID],
1987
+ )
1988
+ }
1989
+ loading={queryLoading}
1990
+ showMetadata={false}
1991
+ shellClassName="h-full min-h-0"
1992
+ scrollContainerClassName="min-h-0 sqlite-results-scroll-flush"
1993
+ emptyTitle={
1994
+ selectedQueryStatement?.error ? 'Statement Failed' : 'No Results'
1995
+ }
1996
+ emptyDescription={
1997
+ selectedQueryStatement?.error
1998
+ ? 'Select another statement to inspect its rows, or fix the error and run again.'
1999
+ : 'Run SQL to see rows here.'
2000
+ }
2001
+ />
2002
+ </div>
2003
+ </section>
2004
+ </div>
2005
+ );
2006
+
2007
+ const dataRowActions = canMutateRows
2008
+ ? {
2009
+ columnId: SQLITE_ROW_ACTIONS_COLUMN_ID,
2010
+ header: 'Actions',
2011
+ cell: (row: Record<string, unknown>, rowIndex: number) => {
2012
+ const rowNumber = browseOffset + rowIndex + 1;
2013
+
2014
+ return (
2015
+ <div className="flex items-center justify-end gap-2">
2016
+ <button
2017
+ type="button"
2018
+ className={secondaryIconButtonClassName}
2019
+ disabled={editableColumns.length === 0}
2020
+ aria-label={`Edit row ${rowNumber}`}
2021
+ title={
2022
+ editableColumns.length === 0
2023
+ ? 'No editable columns'
2024
+ : `Edit row ${rowNumber}`
2025
+ }
2026
+ onClick={(event) => {
2027
+ event.stopPropagation();
2028
+ setDeletingRow(null);
2029
+ setEditingRow({
2030
+ row,
2031
+ rowIndex,
2032
+ });
2033
+ }}
2034
+ >
2035
+ <Pencil aria-hidden="true" className="h-3.5 w-3.5" />
2036
+ </button>
2037
+ <button
2038
+ type="button"
2039
+ className={secondaryIconButtonClassName}
2040
+ aria-label={`Delete row ${rowNumber}`}
2041
+ title={`Delete row ${rowNumber}`}
2042
+ onClick={(event) => {
2043
+ event.stopPropagation();
2044
+ setEditingRow(null);
2045
+ setDeletingRow({
2046
+ row,
2047
+ rowIndex,
2048
+ });
2049
+ }}
2050
+ >
2051
+ <Trash2 aria-hidden="true" className="h-3.5 w-3.5" />
2052
+ </button>
2053
+ </div>
2054
+ );
2055
+ },
2056
+ }
2057
+ : undefined;
2058
+
2059
+ const dataPane = !selectedDatabase ? (
2060
+ renderEmptyState(
2061
+ 'Select A Database',
2062
+ 'Choose a database in the sidebar to browse rows.',
2063
+ 'database',
2064
+ )
2065
+ ) : !selectedEntity ? (
2066
+ renderEmptyState(
2067
+ 'Select A Table',
2068
+ 'Choose a table in the sidebar to view its rows.',
2069
+ 'table',
2070
+ )
2071
+ ) : (
2072
+ <div className="sqlite-content-stack">
2073
+ <header className="sqlite-object-header">
2074
+ <div className="sqlite-toolbar-actions sqlite-subtoolbar-group-grow">
2075
+ <div className="sqlite-field sqlite-field-grow">
2076
+ <label htmlFor="sqlite-data-search" className="sr-only">
2077
+ Search current result
2078
+ </label>
2079
+ <div className="sqlite-input-with-icon">
2080
+ <Search aria-hidden="true" className="h-4 w-4" />
2081
+ <input
2082
+ id="sqlite-data-search"
2083
+ type="text"
2084
+ name="dataSearch"
2085
+ autoComplete="off"
2086
+ spellCheck={false}
2087
+ value={dataSearch}
2088
+ onChange={(event) => setDataSearch(event.target.value)}
2089
+ placeholder="Filter visible rows…"
2090
+ className="sqlite-input"
2091
+ />
2092
+ </div>
2093
+ </div>
2094
+ {dataSearch.trim() ? (
2095
+ <button
2096
+ type="button"
2097
+ className="sqlite-chip"
2098
+ onClick={() => setDataSearch('')}
2099
+ >
2100
+ contains {dataSearch}
2101
+ <X aria-hidden="true" className="h-3.5 w-3.5" />
2102
+ </button>
2103
+ ) : null}
2104
+ </div>
2105
+
2106
+ <div className="sqlite-toolbar-actions">
2107
+ <button
2108
+ type="button"
2109
+ className={secondaryIconButtonClassName}
2110
+ onClick={() => void loadBrowse()}
2111
+ disabled={browseLoading || !isQueryableEntity(selectedEntity)}
2112
+ aria-label="Refresh data"
2113
+ title="Refresh data"
2114
+ >
2115
+ <RefreshCw
2116
+ aria-hidden="true"
2117
+ className={joinClassNames(
2118
+ 'h-4 w-4',
2119
+ browseLoading && 'animate-spin',
2120
+ )}
2121
+ />
2122
+ </button>
2123
+ </div>
2124
+ </header>
2125
+
2126
+ {browseError ? (
2127
+ <div className="sqlite-inline-error" aria-live="polite">
2128
+ <div>
2129
+ <p className="font-medium text-rose-100">Data Load Failed</p>
2130
+ <p className="mt-1 text-sm text-rose-100/90">{browseError}</p>
2131
+ </div>
2132
+ </div>
2133
+ ) : null}
2134
+
2135
+ <div className="sqlite-results-panel flex-1">
2136
+ <QueryResultTable
2137
+ tableId={dataTableId}
2138
+ result={filteredBrowseResult}
2139
+ columnOrder={dataColumnOrder}
2140
+ onColumnOrderChange={(nextColumnOrder) =>
2141
+ setTableColumnOrder(dataTableId, dataColumnIds, nextColumnOrder, [
2142
+ SQLITE_ROW_NUMBER_COLUMN_ID,
2143
+ ])
2144
+ }
2145
+ loading={browseLoading}
2146
+ showMetadata={false}
2147
+ shellClassName="h-full min-h-0"
2148
+ scrollContainerClassName="min-h-0 sqlite-results-scroll-flush"
2149
+ emptyTitle={
2150
+ selectedEntity ? 'No Rows On This Page' : 'No Table Selected'
2151
+ }
2152
+ emptyDescription={
2153
+ selectedEntity
2154
+ ? 'This page does not contain rows.'
2155
+ : 'Select a table in the sidebar to view its data.'
2156
+ }
2157
+ rowNumberOffset={browseOffset}
2158
+ columnMeta={structureColumnMeta}
2159
+ hiddenColumnIds={[SQLITE_HIDDEN_ROWID_COLUMN_ID]}
2160
+ rowActions={dataRowActions}
2161
+ />
2162
+ </div>
2163
+
2164
+ <footer className="sqlite-status-footer">
2165
+ <div className="sqlite-status-cluster sqlite-tabular">
2166
+ <span>Page {currentDataPage > 0 ? currentDataPage : '—'}</span>
2167
+ <span>
2168
+ Rows{' '}
2169
+ {dataPageStart > 0
2170
+ ? `${formatNumber(dataPageStart)}–${formatNumber(dataPageEnd)}`
2171
+ : '—'}
2172
+ </span>
2173
+ <span>Total {formatNumber(entityRowCount)}</span>
2174
+ <span>Visible {formatNumber(filteredBrowseRows.length)}</span>
2175
+ </div>
2176
+ <div className="sqlite-toolbar-actions">
2177
+ <div className="sqlite-field sqlite-field-inline">
2178
+ <label htmlFor="sqlite-data-page-size">Page Size</label>
2179
+ <select
2180
+ id="sqlite-data-page-size"
2181
+ aria-label="Data page size"
2182
+ name="dataPageSize"
2183
+ autoComplete="off"
2184
+ className="sqlite-select"
2185
+ value={browsePageSize}
2186
+ onChange={(event: ChangeEvent<HTMLSelectElement>) => {
2187
+ setBrowsePageSize(Number(event.target.value));
2188
+ setBrowseOffset(0);
2189
+ }}
2190
+ >
2191
+ {[25, 50, 100].map((value) => (
2192
+ <option key={value} value={value}>
2193
+ {value}
2194
+ </option>
2195
+ ))}
2196
+ </select>
2197
+ </div>
2198
+ <button
2199
+ type="button"
2200
+ className={secondaryButtonClassName}
2201
+ onClick={() =>
2202
+ setBrowseOffset((current) =>
2203
+ Math.max(0, current - browsePageSize),
2204
+ )
2205
+ }
2206
+ disabled={browseLoading || !canBrowseBackward}
2207
+ >
2208
+ Previous
2209
+ </button>
2210
+ <button
2211
+ type="button"
2212
+ className={secondaryButtonClassName}
2213
+ onClick={() =>
2214
+ setBrowseOffset((current) => current + browsePageSize)
2215
+ }
2216
+ disabled={browseLoading || !canBrowseForward}
2217
+ >
2218
+ Next
2219
+ </button>
2220
+ <span className="sqlite-badge sqlite-badge-neutral sqlite-tabular">
2221
+ {totalDataPages > 0
2222
+ ? `${currentDataPage}/${totalDataPages}`
2223
+ : '0/0'}
2224
+ </span>
2225
+ </div>
2226
+ </footer>
2227
+ </div>
2228
+ );
2229
+
2230
+ const structurePane = !selectedDatabase ? (
2231
+ renderEmptyState(
2232
+ 'Select A Database',
2233
+ 'Choose a database in the sidebar to inspect schema metadata.',
2234
+ 'database',
2235
+ )
2236
+ ) : !selectedEntity ? (
2237
+ renderEmptyState(
2238
+ 'Select A Table',
2239
+ 'Choose a table or view in the sidebar to inspect it.',
2240
+ 'structure',
2241
+ )
2242
+ ) : (
2243
+ <div className="sqlite-content-stack">
2244
+ <header className="sqlite-object-header">
2245
+ <div
2246
+ className="sqlite-section-tabs"
2247
+ role="tablist"
2248
+ aria-label="Structure sections"
2249
+ >
2250
+ {(
2251
+ [
2252
+ ['columns', 'Columns'],
2253
+ ['keys', 'Keys'],
2254
+ ['indexes', 'Indexes'],
2255
+ ] as Array<[StructureSection, string]>
2256
+ ).map(([key, label]) => (
2257
+ <button
2258
+ key={key}
2259
+ type="button"
2260
+ role="tab"
2261
+ aria-selected={structureSection === key}
2262
+ className={joinClassNames(
2263
+ 'sqlite-section-tab',
2264
+ structureSection === key && 'is-active',
2265
+ )}
2266
+ onClick={() => setStructureSection(key)}
2267
+ >
2268
+ {label}
2269
+ </button>
2270
+ ))}
2271
+ </div>
2272
+
2273
+ <div className="sqlite-toolbar-actions">
2274
+ <button
2275
+ type="button"
2276
+ className={secondaryIconButtonClassName}
2277
+ onClick={() => void loadStructure()}
2278
+ disabled={structureLoading}
2279
+ aria-label="Refresh structure"
2280
+ title="Refresh structure"
2281
+ >
2282
+ <RefreshCw
2283
+ aria-hidden="true"
2284
+ className={joinClassNames(
2285
+ 'h-4 w-4',
2286
+ structureLoading && 'animate-spin',
2287
+ )}
2288
+ />
2289
+ </button>
2290
+ </div>
2291
+ </header>
2292
+
2293
+ {structureError ? (
2294
+ <div className="sqlite-inline-error" aria-live="polite">
2295
+ <div>
2296
+ <p className="font-medium text-rose-100">Structure Load Failed</p>
2297
+ <p className="mt-1 text-sm text-rose-100/90">{structureError}</p>
2298
+ </div>
2299
+ </div>
2300
+ ) : null}
2301
+
2302
+ <div
2303
+ className={joinClassNames(
2304
+ 'sqlite-structure-panel',
2305
+ (structureSection === 'columns' || structureSection === 'indexes') &&
2306
+ 'sqlite-structure-panel-flush',
2307
+ )}
2308
+ >
2309
+ {structureSection === 'columns' ? (
2310
+ <SqliteDataTable
2311
+ tableId={structureColumnsTableId}
2312
+ data={structureColumnRows}
2313
+ columns={structureColumnsTableColumns}
2314
+ columnOrder={structureColumnsColumnOrder}
2315
+ onColumnOrderChange={(nextColumnOrder) =>
2316
+ setTableColumnOrder(
2317
+ structureColumnsTableId,
2318
+ structureColumnsColumnIds,
2319
+ nextColumnOrder,
2320
+ )
2321
+ }
2322
+ loading={structureLoading}
2323
+ emptyTitle="No Columns Found"
2324
+ emptyDescription="This table or view does not expose columns."
2325
+ shellClassName="sqlite-metadata-table-wrap sqlite-metadata-table-wrap-flush"
2326
+ scrollContainerClassName="p-0"
2327
+ tableClassName="sqlite-metadata-table"
2328
+ />
2329
+ ) : structureSection === 'keys' ? (
2330
+ structureLoading ? (
2331
+ <div className="sqlite-structure-skeleton" aria-live="polite">
2332
+ {Array.from({ length: 4 }, (_, index) => (
2333
+ <div key={index} className="sqlite-structure-skeleton-row" />
2334
+ ))}
2335
+ </div>
2336
+ ) : (
2337
+ <div className="sqlite-structure-grid">
2338
+ <section className="sqlite-detail-card">
2339
+ <header className="mb-3">
2340
+ <h3 className="sqlite-detail-card-title">Primary Key</h3>
2341
+ </header>
2342
+ {primaryKeyColumns.length === 0 ? (
2343
+ <p className="sqlite-helper-text">No primary key defined.</p>
2344
+ ) : (
2345
+ <div className="sqlite-chip-row">
2346
+ {primaryKeyColumns
2347
+ .sort(
2348
+ (left, right) =>
2349
+ left.primaryKeyOrder - right.primaryKeyOrder,
2350
+ )
2351
+ .map((column) => (
2352
+ <span
2353
+ key={column.name}
2354
+ className="sqlite-chip sqlite-chip-static"
2355
+ >
2356
+ <KeyRound
2357
+ aria-hidden="true"
2358
+ className="h-3.5 w-3.5"
2359
+ />
2360
+ {column.name}
2361
+ </span>
2362
+ ))}
2363
+ </div>
2364
+ )}
2365
+ </section>
2366
+
2367
+ <section className="sqlite-detail-card">
2368
+ <header className="mb-3">
2369
+ <h3 className="sqlite-detail-card-title">Foreign Keys</h3>
2370
+ </header>
2371
+ {structureState.foreignKeys.length === 0 ? (
2372
+ <p className="sqlite-helper-text">No foreign keys defined.</p>
2373
+ ) : (
2374
+ <div className="space-y-3">
2375
+ {structureState.foreignKeys.map((foreignKey) => (
2376
+ <div
2377
+ key={`${foreignKey.id}-${foreignKey.seq}`}
2378
+ className="sqlite-key-row"
2379
+ >
2380
+ <div>
2381
+ <p className="font-medium text-white">
2382
+ {foreignKey.from} → {foreignKey.table}
2383
+ {foreignKey.to ? `.${foreignKey.to}` : ''}
2384
+ </p>
2385
+ <p className="sqlite-helper-text">
2386
+ Update {foreignKey.onUpdate} · Delete{' '}
2387
+ {foreignKey.onDelete}
2388
+ </p>
2389
+ </div>
2390
+ <span className="sqlite-badge sqlite-badge-neutral">
2391
+ Match {foreignKey.match}
2392
+ </span>
2393
+ </div>
2394
+ ))}
2395
+ </div>
2396
+ )}
2397
+ </section>
2398
+ </div>
2399
+ )
2400
+ ) : (
2401
+ <SqliteDataTable
2402
+ tableId={structureIndexesTableId}
2403
+ data={structureIndexRows}
2404
+ columns={structureIndexesTableColumns}
2405
+ columnOrder={structureIndexesColumnOrder}
2406
+ onColumnOrderChange={(nextColumnOrder) =>
2407
+ setTableColumnOrder(
2408
+ structureIndexesTableId,
2409
+ structureIndexesColumnIds,
2410
+ nextColumnOrder,
2411
+ )
2412
+ }
2413
+ loading={structureLoading}
2414
+ emptyTitle="No Indexes Defined"
2415
+ emptyDescription="This table or view does not define indexes."
2416
+ shellClassName="sqlite-metadata-table-wrap sqlite-metadata-table-wrap-flush"
2417
+ scrollContainerClassName="p-0"
2418
+ tableClassName="sqlite-metadata-table"
2419
+ />
2420
+ )}
2421
+ </div>
2422
+ </div>
2423
+ );
2424
+
2425
+ return (
2426
+ <div className="sqlite-app-shell">
2427
+ <a href="#sqlite-main-content" className="sqlite-skip-link">
2428
+ Skip To Workspace
2429
+ </a>
2430
+ <div className="sqlite-app-body">
2431
+ <aside
2432
+ ref={sidebarRef}
2433
+ className="sqlite-sidebar-wrap"
2434
+ style={{ width: sidebarWidth }}
2435
+ >
2436
+ <section className="sqlite-sidebar-panel">
2437
+ <header className="sqlite-sidebar-header">
2438
+ <div className="sqlite-toolbar-actions">
2439
+ <h1 className="sqlite-section-title">Databases</h1>
2440
+ </div>
2441
+ <div className="sqlite-toolbar-actions">
2442
+ <button
2443
+ type="button"
2444
+ className={iconButtonClassName}
2445
+ aria-label="Refresh databases"
2446
+ title="Refresh databases"
2447
+ onClick={() => void refreshWorkspace()}
2448
+ disabled={databaseLoading || entityLoading}
2449
+ >
2450
+ <RefreshCw
2451
+ aria-hidden="true"
2452
+ className={joinClassNames(
2453
+ 'h-4 w-4',
2454
+ (databaseLoading || entityLoading) && 'animate-spin',
2455
+ )}
2456
+ />
2457
+ </button>
2458
+ </div>
2459
+ </header>
2460
+
2461
+ <div className="sqlite-sidebar-toolbar">
2462
+ <label htmlFor="sqlite-sidebar-filter" className="sr-only">
2463
+ Filter databases, tables, and views
2464
+ </label>
2465
+ <div className="sqlite-input-with-icon">
2466
+ <Search aria-hidden="true" className="h-4 w-4" />
2467
+ <input
2468
+ id="sqlite-sidebar-filter"
2469
+ type="text"
2470
+ name="sidebarFilter"
2471
+ autoComplete="off"
2472
+ spellCheck={false}
2473
+ value={objectSearch}
2474
+ onChange={(event) => setObjectSearch(event.target.value)}
2475
+ placeholder="Filter databases, tables, views…"
2476
+ className="sqlite-input"
2477
+ />
2478
+ </div>
2479
+ </div>
2480
+
2481
+ <div className="sqlite-sidebar-scroll">
2482
+ {databaseLoading && databases.length === 0 ? (
2483
+ <div className="sqlite-sidebar-skeleton" aria-live="polite">
2484
+ {Array.from({ length: 6 }, (_, index) => (
2485
+ <div key={index} className="sqlite-sidebar-skeleton-row" />
2486
+ ))}
2487
+ </div>
2488
+ ) : databases.length === 0 ? (
2489
+ renderEmptyState(
2490
+ databaseError
2491
+ ? 'Could Not Load Databases'
2492
+ : 'No Databases Found',
2493
+ databaseError ??
2494
+ 'Expose a SQLite adapter in your app, then refresh to inspect it here.',
2495
+ 'database',
2496
+ )
2497
+ ) : (
2498
+ <div className="sqlite-connection-list">
2499
+ {databases.map((database) => {
2500
+ const isExpanded = expandedDatabaseIds.includes(
2501
+ database.id,
2502
+ );
2503
+ const databaseExplorerState =
2504
+ explorerStateByDatabase[database.id] ??
2505
+ DEFAULT_EXPLORER_STATE;
2506
+ const databaseExplorerGroups = buildExplorerGroups(
2507
+ databaseExplorerState.schemas,
2508
+ databaseExplorerState.entities,
2509
+ objectSearch,
2510
+ );
2511
+
2512
+ return (
2513
+ <div key={database.id} className="sqlite-connection-card">
2514
+ <button
2515
+ type="button"
2516
+ className="sqlite-connection-row"
2517
+ onClick={() => {
2518
+ setSelectedDatabaseId(database.id);
2519
+ setExpandedDatabaseIds((current) =>
2520
+ current.includes(database.id)
2521
+ ? current.filter((id) => id !== database.id)
2522
+ : [...current, database.id],
2523
+ );
2524
+ }}
2525
+ onDoubleClick={() => {
2526
+ setSelectedDatabaseId(database.id);
2527
+ setActiveTab('query');
2528
+ }}
2529
+ >
2530
+ {isExpanded ? (
2531
+ <ChevronDown
2532
+ aria-hidden="true"
2533
+ className="h-4 w-4 shrink-0"
2534
+ />
2535
+ ) : (
2536
+ <ChevronRight
2537
+ aria-hidden="true"
2538
+ className="h-4 w-4 shrink-0"
2539
+ />
2540
+ )}
2541
+ <Database
2542
+ aria-hidden="true"
2543
+ className="h-4 w-4 shrink-0"
2544
+ />
2545
+ <span className="min-w-0 truncate font-medium">
2546
+ {database.name}
2547
+ </span>
2548
+ </button>
2549
+
2550
+ {isExpanded ? (
2551
+ <div className="sqlite-tree-shell">
2552
+ {databaseExplorerState.loading ||
2553
+ !databaseExplorerState.loaded ? (
2554
+ <div
2555
+ className="sqlite-sidebar-skeleton"
2556
+ aria-live="polite"
2557
+ >
2558
+ {Array.from({ length: 4 }, (_, index) => (
2559
+ <div
2560
+ key={index}
2561
+ className="sqlite-sidebar-skeleton-row"
2562
+ />
2563
+ ))}
2564
+ </div>
2565
+ ) : databaseExplorerState.error ? (
2566
+ <div
2567
+ className="sqlite-inline-error"
2568
+ aria-live="polite"
2569
+ >
2570
+ <div>
2571
+ <p className="font-medium text-rose-100">
2572
+ Explorer Load Failed
2573
+ </p>
2574
+ <p className="mt-1 text-sm text-rose-100/90">
2575
+ {databaseExplorerState.error}
2576
+ </p>
2577
+ </div>
2578
+ </div>
2579
+ ) : databaseExplorerGroups.length === 0 ? (
2580
+ <div className="sqlite-tree-empty">
2581
+ No objects match this filter.
2582
+ </div>
2583
+ ) : (
2584
+ databaseExplorerGroups.map(
2585
+ ({ schema, tables, views }) => {
2586
+ const schemaKey = getSchemaKey(
2587
+ database.id,
2588
+ schema.name,
2589
+ );
2590
+ const isSchemaExpanded =
2591
+ expandedSchemaKeys.includes(schemaKey);
2592
+
2593
+ return (
2594
+ <div
2595
+ key={`${database.id}-${schema.name}`}
2596
+ className="sqlite-schema-group"
2597
+ >
2598
+ <button
2599
+ type="button"
2600
+ className="sqlite-schema-row"
2601
+ onClick={() => {
2602
+ setExpandedSchemaKeys((current) =>
2603
+ current.includes(schemaKey)
2604
+ ? current.filter(
2605
+ (value) =>
2606
+ value !== schemaKey,
2607
+ )
2608
+ : [...current, schemaKey],
2609
+ );
2610
+ }}
2611
+ >
2612
+ {isSchemaExpanded ? (
2613
+ <ChevronDown
2614
+ aria-hidden="true"
2615
+ className="h-4 w-4"
2616
+ />
2617
+ ) : (
2618
+ <ChevronRight
2619
+ aria-hidden="true"
2620
+ className="h-4 w-4"
2621
+ />
2622
+ )}
2623
+ <FolderTree
2624
+ aria-hidden="true"
2625
+ className="h-4 w-4"
2626
+ />
2627
+ <span className="min-w-0 flex-1 truncate">
2628
+ {schema.name}
2629
+ </span>
2630
+ </button>
2631
+
2632
+ {isSchemaExpanded ? (
2633
+ <div className="sqlite-schema-content">
2634
+ {tables.length > 0 ? (
2635
+ <div className="sqlite-object-section">
2636
+ <p className="sqlite-object-section-title">
2637
+ Tables
2638
+ </p>
2639
+ <div className="sqlite-object-list">
2640
+ {tables.map((entity) => {
2641
+ const isSelected =
2642
+ getEntityKey(
2643
+ database.id,
2644
+ entity.schemaName,
2645
+ entity.name,
2646
+ ) === selectedEntityKey;
2647
+
2648
+ return (
2649
+ <button
2650
+ key={`${database.id}-${entity.schemaName}-${entity.name}`}
2651
+ type="button"
2652
+ className={joinClassNames(
2653
+ 'sqlite-object-row',
2654
+ isSelected &&
2655
+ 'is-active',
2656
+ )}
2657
+ onClick={() => {
2658
+ setEntitySelection(
2659
+ database.id,
2660
+ entity,
2661
+ );
2662
+ setActiveTab('data');
2663
+ }}
2664
+ >
2665
+ <Table2
2666
+ aria-hidden="true"
2667
+ className="h-4 w-4 shrink-0"
2668
+ />
2669
+ <span className="min-w-0 flex-1 truncate text-left">
2670
+ {entity.name}
2671
+ </span>
2672
+ </button>
2673
+ );
2674
+ })}
2675
+ </div>
2676
+ </div>
2677
+ ) : null}
2678
+
2679
+ {views.length > 0 ? (
2680
+ <div className="sqlite-object-section">
2681
+ <p className="sqlite-object-section-title">
2682
+ Views
2683
+ </p>
2684
+ <div className="sqlite-object-list">
2685
+ {views.map((entity) => {
2686
+ const isSelected =
2687
+ getEntityKey(
2688
+ database.id,
2689
+ entity.schemaName,
2690
+ entity.name,
2691
+ ) === selectedEntityKey;
2692
+
2693
+ return (
2694
+ <button
2695
+ key={`${database.id}-${entity.schemaName}-${entity.name}`}
2696
+ type="button"
2697
+ className={joinClassNames(
2698
+ 'sqlite-object-row',
2699
+ isSelected &&
2700
+ 'is-active',
2701
+ )}
2702
+ onClick={() => {
2703
+ setEntitySelection(
2704
+ database.id,
2705
+ entity,
2706
+ );
2707
+ setActiveTab(
2708
+ 'structure',
2709
+ );
2710
+ }}
2711
+ >
2712
+ <FileCode2
2713
+ aria-hidden="true"
2714
+ className="h-4 w-4 shrink-0"
2715
+ />
2716
+ <span className="min-w-0 flex-1 truncate text-left">
2717
+ {entity.name}
2718
+ </span>
2719
+ </button>
2720
+ );
2721
+ })}
2722
+ </div>
2723
+ </div>
2724
+ ) : null}
2725
+ </div>
2726
+ ) : null}
2727
+ </div>
2728
+ );
2729
+ },
2730
+ )
2731
+ )}
2732
+ </div>
2733
+ ) : null}
2734
+ </div>
2735
+ );
2736
+ })}
2737
+ </div>
2738
+ )}
2739
+ </div>
2740
+ </section>
2741
+
2742
+ <div
2743
+ role="separator"
2744
+ aria-orientation="vertical"
2745
+ aria-label="Resize database explorer"
2746
+ className="sqlite-sidebar-resizer"
2747
+ onPointerDown={handleSidebarResizeStart}
2748
+ />
2749
+ </aside>
2750
+
2751
+ <main id="sqlite-main-content" className="sqlite-workspace">
2752
+ <section className="sqlite-workspace-panel">
2753
+ <div className="sqlite-workspace-content">
2754
+ <div className="sqlite-main-stack">
2755
+ <div
2756
+ className="sqlite-workspace-tabs sqlite-main-tabs"
2757
+ role="tablist"
2758
+ aria-label="Workspace tabs"
2759
+ >
2760
+ {(
2761
+ [
2762
+ ['query', 'Query'],
2763
+ ['data', 'Data'],
2764
+ ['structure', 'Structure'],
2765
+ ] as Array<[ActiveTab, string]>
2766
+ ).map(([tab, label]) => (
2767
+ <button
2768
+ key={tab}
2769
+ type="button"
2770
+ role="tab"
2771
+ aria-selected={activeTab === tab}
2772
+ className={joinClassNames(
2773
+ 'sqlite-workspace-tab',
2774
+ activeTab === tab && 'is-active',
2775
+ )}
2776
+ onClick={() => setActiveTab(tab)}
2777
+ >
2778
+ {label}
2779
+ </button>
2780
+ ))}
2781
+ </div>
2782
+
2783
+ <div className="sqlite-main-pane">
2784
+ {activeTab === 'query'
2785
+ ? queryPane
2786
+ : activeTab === 'data'
2787
+ ? dataPane
2788
+ : structurePane}
2789
+ </div>
2790
+ </div>
2791
+ </div>
2792
+ </section>
2793
+ </main>
2794
+
2795
+ <SqliteRowEditModal
2796
+ isOpen={!!editingRow && !!selectedEntity}
2797
+ rowNumber={browseOffset + (editingRow?.rowIndex ?? 0) + 1}
2798
+ entityName={selectedEntity?.name ?? 'row'}
2799
+ row={editingRow?.row ?? null}
2800
+ columns={structureState.columns}
2801
+ onClose={() => setEditingRow(null)}
2802
+ onSave={handleSaveRow}
2803
+ />
2804
+
2805
+ <SqliteRowDeleteModal
2806
+ isOpen={!!deletingRow && !!selectedEntity}
2807
+ rowNumber={browseOffset + (deletingRow?.rowIndex ?? 0) + 1}
2808
+ entityName={selectedEntity?.name ?? 'row'}
2809
+ onClose={() => setDeletingRow(null)}
2810
+ onDelete={handleDeleteRow}
2811
+ />
2812
+ </div>
2813
+ </div>
2814
+ );
2815
+ }