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