sqlite-hub 0.5.0 → 0.7.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 (50) hide show
  1. package/README.md +109 -2
  2. package/bin/sqlite-hub.js +285 -4
  3. package/changelog.md +15 -0
  4. package/frontend/assets/mockups/connections.png +0 -0
  5. package/frontend/assets/mockups/data.png +0 -0
  6. package/frontend/assets/mockups/data_row_editor.png +0 -0
  7. package/frontend/assets/mockups/home.png +0 -0
  8. package/frontend/assets/mockups/sql_editor.png +0 -0
  9. package/frontend/assets/mockups/sql_editor_croped.png +0 -0
  10. package/frontend/assets/mockups/sql_editor_querydetail.png +0 -0
  11. package/frontend/assets/mockups/structure.png +0 -0
  12. package/frontend/assets/mockups/structure_inspector.png +0 -0
  13. package/frontend/index.html +1 -0
  14. package/frontend/js/api.js +49 -0
  15. package/frontend/js/app.js +377 -9
  16. package/frontend/js/components/modal.js +314 -1
  17. package/frontend/js/components/queryChartRenderer.js +125 -0
  18. package/frontend/js/components/queryEditor.js +37 -8
  19. package/frontend/js/components/queryHistoryPanel.js +16 -5
  20. package/frontend/js/components/sidebar.js +2 -0
  21. package/frontend/js/components/tableDesignerEditor.js +356 -0
  22. package/frontend/js/components/tableDesignerSidebar.js +129 -0
  23. package/frontend/js/components/tableDesignerSqlPreview.js +40 -0
  24. package/frontend/js/lib/queryChartOptions.js +283 -0
  25. package/frontend/js/lib/queryCharts.js +560 -0
  26. package/frontend/js/router.js +18 -0
  27. package/frontend/js/store.js +914 -1
  28. package/frontend/js/utils/tableDesigner.js +1192 -0
  29. package/frontend/js/views/charts.js +471 -0
  30. package/frontend/js/views/data.js +281 -277
  31. package/frontend/js/views/editor.js +19 -13
  32. package/frontend/js/views/structure.js +81 -39
  33. package/frontend/js/views/tableDesigner.js +37 -0
  34. package/frontend/styles/base.css +87 -73
  35. package/frontend/styles/components.css +790 -251
  36. package/frontend/styles/views.css +316 -46
  37. package/package.json +2 -1
  38. package/server/routes/charts.js +153 -0
  39. package/server/routes/tableDesigner.js +60 -0
  40. package/server/server.js +10 -0
  41. package/server/services/sqlite/tableDesigner/changeAnalysis.js +295 -0
  42. package/server/services/sqlite/tableDesigner/schemaMapping.js +233 -0
  43. package/server/services/sqlite/tableDesigner/sql.js +63 -0
  44. package/server/services/sqlite/tableDesigner/validation.js +245 -0
  45. package/server/services/sqlite/tableDesignerService.js +181 -0
  46. package/server/services/storage/appStateStore.js +400 -1
  47. package/server/services/storage/queryHistoryChartUtils.js +145 -0
  48. package/frontend/assets/mockups/data_edit.png +0 -0
  49. package/frontend/assets/mockups/graph_visualize.png +0 -0
  50. package/frontend/assets/mockups/overview.png +0 -0
@@ -1,29 +1,23 @@
1
- import { renderDataGrid } from "../components/dataGrid.js";
2
- import { renderRowEditorPanel } from "../components/rowEditorPanel.js";
3
- import {
4
- escapeHtml,
5
- formatCellValue,
6
- formatNumber,
7
- isBlobPreview,
8
- truncateMiddle,
9
- } from "../utils/format.js";
1
+ import { renderDataGrid } from '../components/dataGrid.js';
2
+ import { renderRowEditorPanel } from '../components/rowEditorPanel.js';
3
+ import { escapeHtml, formatCellValue, formatNumber, isBlobPreview, truncateMiddle } from '../utils/format.js';
10
4
 
11
5
  function getSelectedRow(state) {
12
- const rowIndex = state.dataBrowser.selectedRowIndex;
6
+ const rowIndex = state.dataBrowser.selectedRowIndex;
13
7
 
14
- if (typeof rowIndex !== "number") {
15
- return null;
16
- }
8
+ if (typeof rowIndex !== 'number') {
9
+ return null;
10
+ }
17
11
 
18
- return state.dataBrowser.table?.rows?.[rowIndex] ?? null;
12
+ return state.dataBrowser.table?.rows?.[rowIndex] ?? null;
19
13
  }
20
14
 
21
15
  function renderTableList(state) {
22
- const tables = state.dataBrowser.tables ?? [];
23
- const activeName = state.dataBrowser.selectedTable;
16
+ const tables = state.dataBrowser.tables ?? [];
17
+ const activeName = state.dataBrowser.selectedTable;
24
18
 
25
- if (state.dataBrowser.loading && !tables.length) {
26
- return `
19
+ if (state.dataBrowser.loading && !tables.length) {
20
+ return `
27
21
  <div class="flex flex-1 items-center justify-center px-6">
28
22
  <div class="text-center text-on-surface-variant/40">
29
23
  <span class="material-symbols-outlined mb-3 text-4xl">progress_activity</span>
@@ -31,82 +25,88 @@ function renderTableList(state) {
31
25
  </div>
32
26
  </div>
33
27
  `;
34
- }
28
+ }
35
29
 
36
- if (!tables.length) {
37
- return `
30
+ if (!tables.length) {
31
+ return `
38
32
  <div class="px-6 py-6 text-sm text-on-surface-variant/55">
39
33
  No tables found in the active SQLite database.
40
34
  </div>
41
35
  `;
42
- }
36
+ }
43
37
 
44
- return `
38
+ return `
45
39
  <div class="custom-scrollbar flex-1 overflow-auto px-4 py-4">
46
40
  <div class="space-y-2">
47
41
  ${tables
48
- .map(
49
- (table) => `
42
+ .map(
43
+ table => `
50
44
  <button
51
45
  class="w-full border px-4 py-3 text-left transition-colors ${
52
- table.name === activeName
53
- ? "border-primary-container/30 bg-surface-container-high"
54
- : "border-outline-variant/10 bg-surface-container-lowest hover:bg-surface-container-high"
46
+ table.name === activeName
47
+ ? 'border-primary-container/30 bg-surface-container-high'
48
+ : 'border-outline-variant/10 bg-surface-container-lowest hover:bg-surface-container-high'
55
49
  }"
56
50
  data-action="navigate"
57
51
  data-to="/data/${encodeURIComponent(table.name)}"
58
52
  type="button"
59
53
  >
60
54
  <div class="truncate font-mono text-xs ${
61
- table.name === activeName ? "text-primary-container" : "text-on-surface"
55
+ table.name === activeName ? 'text-primary-container' : 'text-on-surface'
62
56
  }">
63
57
  ${escapeHtml(table.name)}
64
58
  </div>
65
59
  </button>
66
- `
67
- )
68
- .join("")}
60
+ `,
61
+ )
62
+ .join('')}
69
63
  </div>
70
64
  </div>
71
65
  `;
72
66
  }
73
67
 
74
68
  function renderWorkspaceHeader(state) {
75
- const table = state.dataBrowser.table;
69
+ const table = state.dataBrowser.table;
70
+ const tablesVisible = state.dataBrowser.tablesVisible !== false;
76
71
 
77
- return `
72
+ return `
78
73
  <header class="border-b border-outline-variant/10 bg-surface-container px-6 py-5">
79
74
  <div class="flex flex-wrap items-end justify-between gap-4">
80
- <div>
75
+ <div class="data-headline-container">
81
76
  <div class="text-[10px] font-bold uppercase tracking-[0.2em] text-primary-container">
82
77
  Data Browser
83
78
  </div>
84
79
  <h1 class="mt-2 font-headline text-4xl font-black uppercase tracking-tight text-primary-container">
85
- ${escapeHtml(table?.name ?? "Table Data")}
80
+ ${escapeHtml(table?.name ?? 'Table Data')}
86
81
  </h1>
87
82
  <div class="mt-2 text-[10px] font-mono uppercase tracking-[0.16em] text-on-surface-variant/55">
88
83
  ${
89
- table
90
- ? `rows ${escapeHtml(formatNumber(table.rowCount ?? 0))} // columns ${escapeHtml(
91
- formatNumber(table.columns?.length ?? 0)
92
- )}`
93
- : `tables ${escapeHtml(formatNumber(state.dataBrowser.tables.length))}`
84
+ table
85
+ ? `rows ${escapeHtml(formatNumber(table.rowCount ?? 0))} // columns ${escapeHtml(
86
+ formatNumber(table.columns?.length ?? 0),
87
+ )}`
88
+ : `tables ${escapeHtml(formatNumber(state.dataBrowser.tables.length))}`
94
89
  }
95
90
  </div>
96
91
  </div>
97
92
  <div class="flex items-center gap-3">
98
93
  ${
99
- table
100
- ? `
94
+ table
95
+ ? `<button
96
+ class="border border-outline-variant/20 px-4 py-3 text-[10px] font-bold uppercase tracking-[0.16em] text-on-surface hover:bg-surface-container-highest"
97
+ data-action="toggle-data-tables"
98
+ type="button"
99
+ >
100
+ ${tablesVisible ? '<span class="material-symbols-outlined text-sm">visibility_off</span> Hide Tables' : 'Show Tables'}
101
+ </button>
101
102
  <button
102
103
  class="border border-outline-variant/20 px-4 py-3 text-[10px] font-bold uppercase tracking-[0.16em] text-on-surface hover:bg-surface-container-highest"
103
104
  data-action="export-data-csv"
104
105
  type="button"
105
106
  >
106
- ${state.dataBrowser.exportLoading ? "Exporting..." : "Export CSV"}
107
- </button>
108
- `
109
- : ""
107
+ ${state.dataBrowser.exportLoading ? 'Exporting...' : 'Export CSV'}
108
+ </button>`
109
+ : ''
110
110
  }
111
111
  <button
112
112
  class="border border-outline-variant/20 px-4 py-3 text-[10px] font-bold uppercase tracking-[0.16em] text-on-surface hover:bg-surface-container-highest"
@@ -116,8 +116,8 @@ function renderWorkspaceHeader(state) {
116
116
  Reload Data
117
117
  </button>
118
118
  ${
119
- table
120
- ? `
119
+ table
120
+ ? `
121
121
  <button
122
122
  class="border border-outline-variant/20 px-4 py-3 text-[10px] font-bold uppercase tracking-[0.16em] text-on-surface hover:bg-surface-container-highest"
123
123
  data-action="navigate"
@@ -127,7 +127,7 @@ function renderWorkspaceHeader(state) {
127
127
  Open Structure
128
128
  </button>
129
129
  `
130
- : ""
130
+ : ''
131
131
  }
132
132
  </div>
133
133
  </div>
@@ -136,11 +136,11 @@ function renderWorkspaceHeader(state) {
136
136
  }
137
137
 
138
138
  function renderWorkspaceError(state) {
139
- if (!state.dataBrowser.error) {
140
- return "";
141
- }
139
+ if (!state.dataBrowser.error) {
140
+ return '';
141
+ }
142
142
 
143
- return `
143
+ return `
144
144
  <div class="border-b border-error/20 bg-error-container/10 px-6 py-4 text-sm text-on-surface">
145
145
  <div class="font-headline text-xs font-bold uppercase tracking-[0.18em] text-error">
146
146
  ${escapeHtml(state.dataBrowser.error.code)}
@@ -151,38 +151,38 @@ function renderWorkspaceError(state) {
151
151
  }
152
152
 
153
153
  function getCellWidthClass(columnName) {
154
- const normalized = String(columnName ?? "").toLowerCase();
154
+ const normalized = String(columnName ?? '').toLowerCase();
155
155
 
156
- if (/(path|url|hash|sql|query|content|description|message|title|name)/.test(normalized)) {
157
- return "max-w-[18rem]";
158
- }
156
+ if (/(path|url|hash|sql|query|content|description|message|title|name)/.test(normalized)) {
157
+ return 'max-w-[18rem]';
158
+ }
159
159
 
160
- if (/(date|time|modified|created|updated|timestamp)/.test(normalized)) {
161
- return "max-w-[11rem]";
162
- }
160
+ if (/(date|time|modified|created|updated|timestamp)/.test(normalized)) {
161
+ return 'max-w-[11rem]';
162
+ }
163
163
 
164
- if (/(id|uuid|token|key)/.test(normalized)) {
165
- return "max-w-[10rem]";
166
- }
164
+ if (/(id|uuid|token|key)/.test(normalized)) {
165
+ return 'max-w-[10rem]';
166
+ }
167
167
 
168
- return "max-w-[12rem]";
168
+ return 'max-w-[12rem]';
169
169
  }
170
170
 
171
171
  function getSortIcon(columnName, sortColumn, sortDirection) {
172
- if (columnName !== sortColumn) {
173
- return "unfold_more";
174
- }
172
+ if (columnName !== sortColumn) {
173
+ return 'unfold_more';
174
+ }
175
175
 
176
- return sortDirection === "desc" ? "south" : "north";
176
+ return sortDirection === 'desc' ? 'south' : 'north';
177
177
  }
178
178
 
179
179
  function renderSortableHeader(columnName, sortColumn, sortDirection, action) {
180
- const isActive = columnName === sortColumn;
180
+ const isActive = columnName === sortColumn;
181
181
 
182
- return `
182
+ return `
183
183
  <button
184
184
  class="flex w-full items-center justify-between gap-2 text-left transition-colors ${
185
- isActive ? "text-primary-container" : "text-on-surface-variant hover:text-primary-container"
185
+ isActive ? 'text-primary-container' : 'text-on-surface-variant hover:text-primary-container'
186
186
  }"
187
187
  data-action="${action}"
188
188
  data-column-name="${escapeHtml(columnName)}"
@@ -190,52 +190,54 @@ function renderSortableHeader(columnName, sortColumn, sortDirection, action) {
190
190
  >
191
191
  <span class="truncate">${escapeHtml(columnName)}</span>
192
192
  <span class="material-symbols-outlined text-sm leading-none">${getSortIcon(
193
- columnName,
194
- sortColumn,
195
- sortDirection
193
+ columnName,
194
+ sortColumn,
195
+ sortDirection,
196
196
  )}</span>
197
197
  </button>
198
198
  `;
199
199
  }
200
200
 
201
201
  function getFilteredTableRows(table, state) {
202
- const allRows = table?.rows ?? [];
203
- const availableColumns = table?.columns ?? [];
204
- const searchQuery = String(state.dataBrowser.searchQuery ?? "").trim().toLowerCase();
205
- const activeColumn = availableColumns.includes(state.dataBrowser.searchColumn)
206
- ? state.dataBrowser.searchColumn
207
- : (availableColumns[0] ?? "");
208
-
209
- const indexedRows = allRows.map((row, index) => ({
210
- row,
211
- index,
212
- }));
213
-
214
- if (!searchQuery || !activeColumn) {
202
+ const allRows = table?.rows ?? [];
203
+ const availableColumns = table?.columns ?? [];
204
+ const searchQuery = String(state.dataBrowser.searchQuery ?? '')
205
+ .trim()
206
+ .toLowerCase();
207
+ const activeColumn = availableColumns.includes(state.dataBrowser.searchColumn)
208
+ ? state.dataBrowser.searchColumn
209
+ : (availableColumns[0] ?? '');
210
+
211
+ const indexedRows = allRows.map((row, index) => ({
212
+ row,
213
+ index,
214
+ }));
215
+
216
+ if (!searchQuery || !activeColumn) {
217
+ return {
218
+ activeColumn,
219
+ filteredRows: indexedRows,
220
+ searchQuery,
221
+ };
222
+ }
223
+
215
224
  return {
216
- activeColumn,
217
- filteredRows: indexedRows,
218
- searchQuery,
225
+ activeColumn,
226
+ searchQuery,
227
+ filteredRows: indexedRows.filter(({ row }) =>
228
+ formatCellValue(row[activeColumn]).toLowerCase().includes(searchQuery),
229
+ ),
219
230
  };
220
- }
221
-
222
- return {
223
- activeColumn,
224
- searchQuery,
225
- filteredRows: indexedRows.filter(({ row }) =>
226
- formatCellValue(row[activeColumn]).toLowerCase().includes(searchQuery)
227
- ),
228
- };
229
231
  }
230
232
 
231
233
  function renderTableSearchBar(table, state, activeColumn, filteredRowCount) {
232
- const columns = table?.columns ?? [];
234
+ const columns = table?.columns ?? [];
233
235
 
234
- if (!table || !columns.length) {
235
- return "";
236
- }
236
+ if (!table || !columns.length) {
237
+ return '';
238
+ }
237
239
 
238
- return `
240
+ return `
239
241
  <div class="flex flex-wrap items-center gap-3 border-b border-outline-variant/10 bg-surface-container-low px-6 py-4">
240
242
  <label class="flex min-w-[18rem] flex-1 items-center gap-3 border border-outline-variant/20 bg-surface-container-lowest px-4 py-3">
241
243
  <span class="material-symbols-outlined text-base text-on-surface-variant/55">search</span>
@@ -244,7 +246,7 @@ function renderTableSearchBar(table, state, activeColumn, filteredRowCount) {
244
246
  data-bind="data-search-query"
245
247
  placeholder="Filter current page..."
246
248
  type="search"
247
- value="${escapeHtml(state.dataBrowser.searchQuery ?? "")}"
249
+ value="${escapeHtml(state.dataBrowser.searchQuery ?? '')}"
248
250
  />
249
251
  </label>
250
252
  <select
@@ -252,31 +254,27 @@ function renderTableSearchBar(table, state, activeColumn, filteredRowCount) {
252
254
  data-bind="data-search-column"
253
255
  >
254
256
  ${columns
255
- .map(
256
- (columnName) => `
257
- <option value="${escapeHtml(columnName)}" ${
258
- columnName === activeColumn ? "selected" : ""
259
- }>
257
+ .map(
258
+ columnName => `
259
+ <option value="${escapeHtml(columnName)}" ${columnName === activeColumn ? 'selected' : ''}>
260
260
  ${escapeHtml(columnName)}
261
261
  </option>
262
- `
263
- )
264
- .join("")}
262
+ `,
263
+ )
264
+ .join('')}
265
265
  </select>
266
266
  <div class="text-[10px] font-mono tracking-[0.14em] text-on-surface-variant/55">
267
- ${escapeHtml(formatNumber(filteredRowCount))} match${
268
- filteredRowCount === 1 ? "" : "es"
269
- } on this page
267
+ ${escapeHtml(formatNumber(filteredRowCount))} match${filteredRowCount === 1 ? '' : 'es'} on this page
270
268
  </div>
271
269
  </div>
272
270
  `;
273
271
  }
274
272
 
275
273
  function renderTableSurface(state) {
276
- const table = state.dataBrowser.table;
274
+ const table = state.dataBrowser.table;
277
275
 
278
- if (state.dataBrowser.tableLoading && !table) {
279
- return `
276
+ if (state.dataBrowser.tableLoading && !table) {
277
+ return `
280
278
  <div class="flex flex-1 items-center justify-center">
281
279
  <div class="text-center text-on-surface-variant/40">
282
280
  <span class="material-symbols-outlined mb-3 text-4xl">progress_activity</span>
@@ -284,10 +282,10 @@ function renderTableSurface(state) {
284
282
  </div>
285
283
  </div>
286
284
  `;
287
- }
285
+ }
288
286
 
289
- if (!table) {
290
- return `
287
+ if (!table) {
288
+ return `
291
289
  <div class="flex flex-1 items-center justify-center px-8 text-center">
292
290
  <div>
293
291
  <div class="text-[10px] font-bold uppercase tracking-[0.2em] text-primary-container">
@@ -299,89 +297,88 @@ function renderTableSurface(state) {
299
297
  </div>
300
298
  </div>
301
299
  `;
302
- }
303
-
304
- const { activeColumn, filteredRows, searchQuery } = getFilteredTableRows(table, state);
305
- const sortColumn = state.dataBrowser.sortColumn;
306
- const sortDirection = state.dataBrowser.sortDirection;
307
- const columns = (table.columns ?? []).map((columnName) => ({
308
- headerClassName:
309
- "border-b border-primary-container/20 px-4 py-3 text-[10px] font-bold tracking-[0.08em] text-primary-container",
310
- renderHeader: () =>
311
- renderSortableHeader(columnName, sortColumn, sortDirection, "sort-data-column"),
312
- cellClassName: "px-4 py-2 align-top text-[11px] text-on-surface",
313
- render: (row) => {
314
- const value = formatCellValue(row[columnName]);
315
- const isNull = value === "NULL";
316
- const widthClass = getCellWidthClass(columnName);
317
- const displayValue = isNull ? value : truncateMiddle(value, 48);
318
-
319
- return `<span class="block ${widthClass} overflow-hidden text-ellipsis whitespace-nowrap ${
320
- isNull ? "text-on-surface-variant/45" : "text-on-surface"
321
- }" title="${escapeHtml(value)}">${escapeHtml(displayValue)}</span>`;
322
- },
323
- }));
324
- const totalRows = table.rowCount ?? 0;
325
- const page = table.page ?? state.dataBrowser.page ?? 1;
326
- const pageCount = table.pageCount ?? Math.max(1, Math.ceil(totalRows / (table.limit ?? 50)));
327
- const fromRow = totalRows === 0 ? 0 : (table.offset ?? 0) + 1;
328
- const toRow = totalRows === 0 ? 0 : Math.min((table.offset ?? 0) + (table.rows?.length ?? 0), totalRows);
329
- const pageSizes = [25, 50, 100];
330
- const filteredRowCount = filteredRows.length;
331
- const hasActiveSearch = Boolean(searchQuery);
332
-
333
- return `
300
+ }
301
+
302
+ const { activeColumn, filteredRows, searchQuery } = getFilteredTableRows(table, state);
303
+ const sortColumn = state.dataBrowser.sortColumn;
304
+ const sortDirection = state.dataBrowser.sortDirection;
305
+ const columns = (table.columns ?? []).map(columnName => ({
306
+ headerClassName:
307
+ 'border-b border-primary-container/20 px-4 py-3 text-[10px] font-bold tracking-[0.08em] text-primary-container',
308
+ renderHeader: () => renderSortableHeader(columnName, sortColumn, sortDirection, 'sort-data-column'),
309
+ cellClassName: 'px-4 py-2 align-top text-[11px] text-on-surface',
310
+ render: row => {
311
+ const value = formatCellValue(row[columnName]);
312
+ const isNull = value === 'NULL';
313
+ const widthClass = getCellWidthClass(columnName);
314
+ const displayValue = isNull ? value : truncateMiddle(value, 48);
315
+
316
+ return `<span class="block ${widthClass} overflow-hidden text-ellipsis whitespace-nowrap ${
317
+ isNull ? 'text-on-surface-variant/45' : 'text-on-surface'
318
+ }" title="${escapeHtml(value)}">${escapeHtml(displayValue)}</span>`;
319
+ },
320
+ }));
321
+ const totalRows = table.rowCount ?? 0;
322
+ const page = table.page ?? state.dataBrowser.page ?? 1;
323
+ const pageCount = table.pageCount ?? Math.max(1, Math.ceil(totalRows / (table.limit ?? 50)));
324
+ const fromRow = totalRows === 0 ? 0 : (table.offset ?? 0) + 1;
325
+ const toRow = totalRows === 0 ? 0 : Math.min((table.offset ?? 0) + (table.rows?.length ?? 0), totalRows);
326
+ const pageSizes = [25, 50, 100];
327
+ const filteredRowCount = filteredRows.length;
328
+ const hasActiveSearch = Boolean(searchQuery);
329
+
330
+ return `
334
331
  <div class="flex flex-1 min-h-0 flex-col bg-surface-container-lowest">
335
332
  ${renderTableSearchBar(table, state, activeColumn, filteredRowCount)}
336
333
  <div class="custom-scrollbar flex-1 overflow-auto">
337
334
  ${renderDataGrid({
338
- columns,
339
- rows: filteredRows.map(({ row }) => row),
340
- tableClass: "min-w-full border-collapse text-left font-mono text-xs",
341
- theadClass: "sticky top-0 z-10 bg-surface-container-highest",
342
- tbodyClass: "divide-y divide-outline-variant/5",
343
- getRowClass: (_, filteredIndex) => {
344
- const rowIndex = filteredRows[filteredIndex]?.index ?? filteredIndex;
345
-
346
- return `${
347
- state.dataBrowser.selectedRowIndex === rowIndex
348
- ? "bg-surface-bright"
349
- : filteredIndex % 2 === 0
350
- ? "bg-surface-container-low"
351
- : "bg-surface-container-lowest"
352
- } cursor-pointer transition-colors hover:bg-surface-container-high`;
353
- },
354
- getRowAttrs: (_, filteredIndex) => {
355
- const rowIndex = filteredRows[filteredIndex]?.index ?? filteredIndex;
356
-
357
- return `data-action="select-data-row" data-row-index="${rowIndex}"`;
358
- },
335
+ columns,
336
+ rows: filteredRows.map(({ row }) => row),
337
+ tableClass: 'min-w-full border-collapse text-left font-mono text-xs',
338
+ theadClass: 'sticky top-0 z-10 bg-surface-container-highest',
339
+ tbodyClass: 'divide-y divide-outline-variant/5',
340
+ getRowClass: (_, filteredIndex) => {
341
+ const rowIndex = filteredRows[filteredIndex]?.index ?? filteredIndex;
342
+
343
+ return `${
344
+ state.dataBrowser.selectedRowIndex === rowIndex
345
+ ? 'bg-surface-bright'
346
+ : filteredIndex % 2 === 0
347
+ ? 'bg-surface-container-low'
348
+ : 'bg-surface-container-lowest'
349
+ } cursor-pointer transition-colors hover:bg-surface-container-high`;
350
+ },
351
+ getRowAttrs: (_, filteredIndex) => {
352
+ const rowIndex = filteredRows[filteredIndex]?.index ?? filteredIndex;
353
+
354
+ return `data-action="select-data-row" data-row-index="${rowIndex}"`;
355
+ },
359
356
  })}
360
357
  ${
361
- !table.rows?.length
362
- ? `
358
+ !table.rows?.length
359
+ ? `
363
360
  <div class="flex min-h-[180px] items-center justify-center border-t border-outline-variant/10">
364
361
  <p class="font-mono text-[10px] uppercase tracking-[0.22em] text-on-surface-variant/40">
365
362
  TABLE_IS_EMPTY
366
363
  </p>
367
364
  </div>
368
365
  `
369
- : !filteredRowCount
370
- ? `
366
+ : !filteredRowCount
367
+ ? `
371
368
  <div class="flex min-h-[180px] items-center justify-center border-t border-outline-variant/10">
372
369
  <p class="font-mono text-[10px] tracking-[0.18em] text-on-surface-variant/40">
373
- ${hasActiveSearch ? "No matching rows on this page." : "No rows available."}
370
+ ${hasActiveSearch ? 'No matching rows on this page.' : 'No rows available.'}
374
371
  </p>
375
372
  </div>
376
373
  `
377
- : ""
374
+ : ''
378
375
  }
379
376
  </div>
380
377
  <footer class="flex flex-wrap items-center justify-between gap-4 border-t border-outline-variant/10 bg-surface-container px-6 py-4">
381
378
  <div class="text-[10px] font-mono uppercase tracking-[0.16em] text-on-surface-variant/55">
382
379
  showing ${escapeHtml(formatNumber(fromRow))}-${escapeHtml(formatNumber(toRow))} of ${escapeHtml(
383
- formatNumber(totalRows)
384
- )} rows${hasActiveSearch ? ` // ${escapeHtml(formatNumber(filteredRowCount))} visible on this page` : ""}
380
+ formatNumber(totalRows),
381
+ )} rows${hasActiveSearch ? ` // ${escapeHtml(formatNumber(filteredRowCount))} visible on this page` : ''}
385
382
  </div>
386
383
  <div class="flex flex-wrap items-center gap-4">
387
384
  <div class="flex items-center gap-2">
@@ -390,13 +387,13 @@ function renderTableSurface(state) {
390
387
  </span>
391
388
  <div class="flex items-center gap-2">
392
389
  ${pageSizes
393
- .map(
394
- (pageSize) => `
390
+ .map(
391
+ pageSize => `
395
392
  <button
396
393
  class="border px-3 py-2 text-[10px] font-bold uppercase tracking-[0.16em] transition-colors ${
397
- pageSize === (table.limit ?? state.dataBrowser.pageSize)
398
- ? "border-primary-container/30 bg-surface-container-high text-primary-container"
399
- : "border-outline-variant/20 text-on-surface hover:bg-surface-container-highest"
394
+ pageSize === (table.limit ?? state.dataBrowser.pageSize)
395
+ ? 'border-primary-container/30 bg-surface-container-high text-primary-container'
396
+ : 'border-outline-variant/20 text-on-surface hover:bg-surface-container-highest'
400
397
  }"
401
398
  data-action="set-data-page-size"
402
399
  data-page-size="${pageSize}"
@@ -404,9 +401,9 @@ function renderTableSurface(state) {
404
401
  >
405
402
  ${pageSize}
406
403
  </button>
407
- `
408
- )
409
- .join("")}
404
+ `,
405
+ )
406
+ .join('')}
410
407
  </div>
411
408
  </div>
412
409
  <div class="flex items-center gap-2">
@@ -415,7 +412,7 @@ function renderTableSurface(state) {
415
412
  data-action="set-data-page"
416
413
  data-page="${page - 1}"
417
414
  type="button"
418
- ${page <= 1 ? "disabled" : ""}
415
+ ${page <= 1 ? 'disabled' : ''}
419
416
  >
420
417
  Prev
421
418
  </button>
@@ -427,7 +424,7 @@ function renderTableSurface(state) {
427
424
  data-action="set-data-page"
428
425
  data-page="${page + 1}"
429
426
  type="button"
430
- ${page >= pageCount ? "disabled" : ""}
427
+ ${page >= pageCount ? 'disabled' : ''}
431
428
  >
432
429
  Next
433
430
  </button>
@@ -439,97 +436,104 @@ function renderTableSurface(state) {
439
436
  }
440
437
 
441
438
  function renderDataRowEditorPanel(state) {
442
- const table = state.dataBrowser.table;
443
- const rowIndex = state.dataBrowser.selectedRowIndex;
444
- const row = getSelectedRow(state);
445
-
446
- if (!table || !row || typeof rowIndex !== "number") {
447
- return "";
448
- }
449
-
450
- const identityColumns =
451
- table.identityStrategy?.type === "primaryKey" ? table.identityStrategy.columns ?? [] : [];
452
- const editableColumns = (table.columnMeta ?? []).filter((column) => {
453
- if (!column.visible || column.generated) {
454
- return false;
455
- }
439
+ const table = state.dataBrowser.table;
440
+ const rowIndex = state.dataBrowser.selectedRowIndex;
441
+ const row = getSelectedRow(state);
456
442
 
457
- if (identityColumns.includes(column.name)) {
458
- return false;
443
+ if (!table || !row || typeof rowIndex !== 'number') {
444
+ return '';
459
445
  }
460
446
 
461
- const value = row[column.name];
462
- if (isBlobPreview(value) || (value && typeof value === "object")) {
463
- return false;
464
- }
447
+ const identityColumns = table.identityStrategy?.type === 'primaryKey' ? (table.identityStrategy.columns ?? []) : [];
448
+ const editableColumns = (table.columnMeta ?? []).filter(column => {
449
+ if (!column.visible || column.generated) {
450
+ return false;
451
+ }
465
452
 
466
- return true;
467
- });
468
- const readonlyColumns = (table.columnMeta ?? []).filter((column) => {
469
- if (!column.visible) {
470
- return false;
471
- }
453
+ if (identityColumns.includes(column.name)) {
454
+ return false;
455
+ }
472
456
 
473
- if (identityColumns.includes(column.name) || column.generated) {
474
- return true;
475
- }
457
+ const value = row[column.name];
458
+ if (isBlobPreview(value) || (value && typeof value === 'object')) {
459
+ return false;
460
+ }
461
+
462
+ return true;
463
+ });
464
+ const readonlyColumns = (table.columnMeta ?? []).filter(column => {
465
+ if (!column.visible) {
466
+ return false;
467
+ }
476
468
 
477
- const value = row[column.name];
478
- return isBlobPreview(value) || (value && typeof value === "object");
479
- });
480
-
481
- return renderRowEditorPanel({
482
- title: table.name,
483
- sectionLabel: "Row Editor",
484
- subtitle: `row ${rowIndex + 1}`,
485
- closeAction: "clear-data-row-selection",
486
- formName: "save-data-row",
487
- hiddenFields: [{ name: "rowIndex", value: String(rowIndex) }],
488
- disabledMessage: state.connections.active?.readOnly
489
- ? "The active database is opened read-only, so row editing is disabled."
490
- : table.notSafelyUpdatable
491
- ? "This table has no stable identity column, so SQLite Hub cannot safely update rows."
492
- : "",
493
- editableFields: editableColumns.map((column) => {
494
- const value = row[column.name];
495
-
496
- return {
497
- name: column.name,
498
- label: column.name,
499
- value: value === null || value === undefined ? "" : String(value),
500
- };
501
- }),
502
- readonlyFields: readonlyColumns.map((column) => ({
503
- name: column.name,
504
- label: column.name,
505
- value: formatCellValue(row[column.name]),
506
- })),
507
- saveError: state.dataBrowser.saveError,
508
- saving: state.dataBrowser.saving,
509
- deleting: state.dataBrowser.deleting,
510
- deleteAction: "delete-data-row",
511
- deleteRowIndex: rowIndex,
512
- deleteEnabled: Boolean(row.__identity),
513
- reloadAction: "reload-data-route",
514
- });
469
+ if (identityColumns.includes(column.name) || column.generated) {
470
+ return true;
471
+ }
472
+
473
+ const value = row[column.name];
474
+ return isBlobPreview(value) || (value && typeof value === 'object');
475
+ });
476
+
477
+ return renderRowEditorPanel({
478
+ title: table.name,
479
+ sectionLabel: 'Row Editor',
480
+ subtitle: `row ${rowIndex + 1}`,
481
+ closeAction: 'clear-data-row-selection',
482
+ formName: 'save-data-row',
483
+ hiddenFields: [{ name: 'rowIndex', value: String(rowIndex) }],
484
+ disabledMessage: state.connections.active?.readOnly
485
+ ? 'The active database is opened read-only, so row editing is disabled.'
486
+ : table.notSafelyUpdatable
487
+ ? 'This table has no stable identity column, so SQLite Hub cannot safely update rows.'
488
+ : '',
489
+ editableFields: editableColumns.map(column => {
490
+ const value = row[column.name];
491
+
492
+ return {
493
+ name: column.name,
494
+ label: column.name,
495
+ value: value === null || value === undefined ? '' : String(value),
496
+ };
497
+ }),
498
+ readonlyFields: readonlyColumns.map(column => ({
499
+ name: column.name,
500
+ label: column.name,
501
+ value: formatCellValue(row[column.name]),
502
+ })),
503
+ saveError: state.dataBrowser.saveError,
504
+ saving: state.dataBrowser.saving,
505
+ deleting: state.dataBrowser.deleting,
506
+ deleteAction: 'delete-data-row',
507
+ deleteRowIndex: rowIndex,
508
+ deleteEnabled: Boolean(row.__identity),
509
+ reloadAction: 'reload-data-route',
510
+ });
515
511
  }
516
512
 
517
513
  export function renderDataView(state) {
518
- return {
519
- main: `
514
+ const tablesVisible = state.dataBrowser.tablesVisible !== false;
515
+
516
+ return {
517
+ main: `
520
518
  <section class="view-surface min-h-full bg-surface-container flex h-full min-h-0 flex-col overflow-hidden">
521
- <div class="grid h-full min-h-0 grid-cols-1 md:grid-cols-[18rem_minmax(0,1fr)]">
522
- <aside class="flex min-h-0 flex-col border-r border-outline-variant/10 bg-surface-low">
523
- <div class="border-b border-outline-variant/10 px-6 py-5">
524
- <div class="text-[10px] font-bold uppercase tracking-[0.2em] text-primary-container">
525
- Tables
526
- </div>
527
- <div class="mt-2 text-[10px] font-mono uppercase tracking-[0.16em] text-on-surface-variant/55">
528
- total ${escapeHtml(formatNumber(state.dataBrowser.tables.length))}
529
- </div>
530
- </div>
531
- ${renderTableList(state)}
532
- </aside>
519
+ <div class="grid h-full min-h-0 grid-cols-1 ${tablesVisible ? 'md:grid-cols-[18rem_minmax(0,1fr)]' : ''}">
520
+ ${
521
+ tablesVisible
522
+ ? `
523
+ <aside class="flex min-h-0 flex-col border-r border-outline-variant/10 bg-surface-low">
524
+ <div class="border-b border-outline-variant/10 px-6 py-5">
525
+ <div class="text-[10px] font-bold uppercase tracking-[0.2em] text-primary-container">
526
+ Tables
527
+ </div>
528
+ <div class="mt-2 text-[10px] font-mono uppercase tracking-[0.16em] text-on-surface-variant/55">
529
+ total ${escapeHtml(formatNumber(state.dataBrowser.tables.length))}
530
+ </div>
531
+ </div>
532
+ ${renderTableList(state)}
533
+ </aside>
534
+ `
535
+ : ''
536
+ }
533
537
  <section class="flex min-h-0 flex-col overflow-hidden">
534
538
  ${renderWorkspaceHeader(state)}
535
539
  ${renderWorkspaceError(state)}
@@ -538,6 +542,6 @@ export function renderDataView(state) {
538
542
  </div>
539
543
  </section>
540
544
  `,
541
- panel: renderDataRowEditorPanel(state),
542
- };
545
+ panel: renderDataRowEditorPanel(state),
546
+ };
543
547
  }