sqlite-hub 0.17.0 → 1.0.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 (83) hide show
  1. package/README.md +101 -19
  2. package/bin/sqlite-hub.js +83 -401
  3. package/examples/api/queries.js +31 -0
  4. package/examples/api/rows.js +27 -0
  5. package/frontend/assets/mockups/charts_1_bars_1200.webp +0 -0
  6. package/frontend/assets/mockups/charts_2_pie_1200.webp +0 -0
  7. package/frontend/assets/mockups/charts_3_scatter_plot_1200.webp +0 -0
  8. package/frontend/assets/mockups/connections_1200.webp +0 -0
  9. package/frontend/assets/mockups/data_1_1200.webp +0 -0
  10. package/frontend/assets/mockups/data_2_row_editor_1200.webp +0 -0
  11. package/frontend/assets/mockups/documents_1200.webp +0 -0
  12. package/frontend/assets/mockups/media_tagging_1_setup_1200.webp +0 -0
  13. package/frontend/assets/mockups/media_tagging_2_tagging_queue_1200.webp +0 -0
  14. package/frontend/assets/mockups/media_tagging_3_media_viewer_1200.webp +0 -0
  15. package/frontend/assets/mockups/overview_1200.webp +0 -0
  16. package/frontend/assets/mockups/settings_1200.webp +0 -0
  17. package/frontend/assets/mockups/sql_editor_1_1200.webp +0 -0
  18. package/frontend/assets/mockups/sql_editor_2_query_details_1200.webp +0 -0
  19. package/frontend/assets/mockups/sql_editor_3_export_column_1200.webp +0 -0
  20. package/frontend/assets/mockups/sql_editor_4_export_column_as_markdown_1200.webp +0 -0
  21. package/frontend/assets/mockups/sql_editor_5_export_query_result_1200.webp +0 -0
  22. package/frontend/assets/mockups/structure_1_1200.webp +0 -0
  23. package/frontend/assets/mockups/structure_2_inspector_1200.webp +0 -0
  24. package/frontend/assets/mockups/table_designer_1_1200.webp +0 -0
  25. package/frontend/assets/mockups/table_designer_2_checks_1200.webp +0 -0
  26. package/frontend/js/api.js +25 -0
  27. package/frontend/js/app.js +183 -26
  28. package/frontend/js/components/formControls.js +38 -0
  29. package/frontend/js/components/modal.js +79 -19
  30. package/frontend/js/components/queryResults.js +18 -1
  31. package/frontend/js/components/rowEditorPanel.js +42 -34
  32. package/frontend/js/store.js +129 -2
  33. package/frontend/js/utils/jsonPreview.js +31 -0
  34. package/frontend/js/utils/rowEditorValues.js +41 -0
  35. package/frontend/js/utils/tableScrollState.js +39 -0
  36. package/frontend/js/views/charts.js +8 -0
  37. package/frontend/js/views/data.js +4 -1
  38. package/frontend/js/views/editor.js +2 -0
  39. package/frontend/js/views/settings.js +141 -5
  40. package/frontend/styles/components.css +6 -0
  41. package/frontend/styles/tailwind.generated.css +2 -2
  42. package/package.json +6 -6
  43. package/server/middleware/apiTokenAuth.js +30 -0
  44. package/server/middleware/localRequestSecurity.js +84 -0
  45. package/server/routes/connections.js +35 -1
  46. package/server/routes/externalApi.js +222 -0
  47. package/server/routes/settings.js +64 -4
  48. package/server/server.js +34 -2
  49. package/server/services/apiTokenService.js +101 -0
  50. package/server/services/databaseCommandService.js +399 -0
  51. package/server/services/nativeFileDialogService.js +260 -0
  52. package/server/services/sqlite/dataBrowserService.js +0 -4
  53. package/server/services/sqlite/exportService.js +5 -1
  54. package/server/services/sqlite/sqlExecutor.js +51 -2
  55. package/server/services/storage/appStateStore.js +113 -0
  56. package/server/utils/errors.js +7 -0
  57. package/server/utils/sqliteTypes.js +17 -8
  58. package/tests/api-token-auth.test.js +127 -0
  59. package/tests/cli-service-delegation.test.js +43 -0
  60. package/tests/connections-file-dialog-route.test.js +89 -0
  61. package/tests/copy-column-modal.test.js +48 -0
  62. package/tests/database-command-service.test.js +102 -0
  63. package/tests/export-blob.test.js +46 -0
  64. package/tests/form-controls.test.js +34 -0
  65. package/tests/json-preview.test.js +49 -0
  66. package/tests/local-request-security.test.js +85 -0
  67. package/tests/native-file-dialog.test.js +105 -0
  68. package/tests/query-results-truncation.test.js +20 -0
  69. package/tests/row-editor-null-values.test.js +131 -0
  70. package/tests/settings-api-tokens-route.test.js +76 -0
  71. package/tests/settings-view.test.js +47 -0
  72. package/tests/sql-identifier-safety.test.js +9 -3
  73. package/tests/sql-result-limit.test.js +66 -0
  74. package/tests/table-scroll-state.test.js +70 -0
  75. package/frontend/assets/mockups/connections.png +0 -0
  76. package/frontend/assets/mockups/data.png +0 -0
  77. package/frontend/assets/mockups/data_row_editor.png +0 -0
  78. package/frontend/assets/mockups/home.png +0 -0
  79. package/frontend/assets/mockups/sql_editor.png +0 -0
  80. package/frontend/assets/mockups/sql_editor_croped.png +0 -0
  81. package/frontend/assets/mockups/sql_editor_querydetail.png +0 -0
  82. package/frontend/assets/mockups/structure.png +0 -0
  83. package/frontend/assets/mockups/structure_inspector.png +0 -0
@@ -12,37 +12,14 @@ import {
12
12
  formatTextCellCharacterCount,
13
13
  getTextCellCharacterCount,
14
14
  } from "../utils/textCellStats.js";
15
+ import { formatJsonPreview } from "../utils/jsonPreview.js";
16
+ import {
17
+ getRowEditorValueState,
18
+ getRowEditorValueStateLabel,
19
+ } from "../utils/rowEditorValues.js";
15
20
 
16
21
  const URL_PATTERN = /^https?:\/\/[^\s<>"']+$/i;
17
22
 
18
- function getJsonPreview(value) {
19
- if (value === null || value === undefined) {
20
- return null;
21
- }
22
-
23
- if (typeof value === "object") {
24
- return JSON.stringify(value, null, 2);
25
- }
26
-
27
- const text = String(value).trim();
28
-
29
- if (!text || !["{", "["].includes(text[0])) {
30
- return null;
31
- }
32
-
33
- try {
34
- const parsed = JSON.parse(text);
35
-
36
- if (!parsed || typeof parsed !== "object") {
37
- return null;
38
- }
39
-
40
- return JSON.stringify(parsed, null, 2);
41
- } catch (error) {
42
- return null;
43
- }
44
- }
45
-
46
23
  function getUrlValue(value) {
47
24
  const text = String(value ?? "").trim();
48
25
 
@@ -119,7 +96,7 @@ function renderOpenUrlButton(url) {
119
96
  }
120
97
 
121
98
  return `
122
- <div class="mt-2">
99
+ <div class="mt-2" data-row-editor-url-action>
123
100
  <button
124
101
  class="standard-button"
125
102
  data-action="open-row-editor-url"
@@ -141,7 +118,7 @@ function renderAllowedValuesSelect(field, allowedValues) {
141
118
  currentValue !== "" && !hasCurrentAllowedValue;
142
119
  const options = [
143
120
  shouldRenderEmptyOption
144
- ? `<option value="" ${currentValue === "" ? "selected" : ""}>NULL / empty</option>`
121
+ ? `<option value="" ${currentValue === "" ? "selected" : ""}>Empty string</option>`
145
122
  : "",
146
123
  shouldRenderCurrentOption
147
124
  ? `<option value="${escapeHtml(currentValue)}" selected>${escapeHtml(currentValue)}</option>`
@@ -160,6 +137,7 @@ function renderAllowedValuesSelect(field, allowedValues) {
160
137
  data-row-editor-initial-value="${escapeHtml(currentValue)}"
161
138
  data-row-editor-text-source
162
139
  data-row-editor-timestamp-source
140
+ data-row-editor-value-source
163
141
  name="field:${escapeHtml(field.name)}"
164
142
  >
165
143
  ${options}
@@ -284,7 +262,7 @@ function renderJsonViewer(prettyJson, title = "JSON Viewer") {
284
262
  <div class="text-[10px] font-mono uppercase tracking-[0.18em] text-primary-container/75">
285
263
  ${escapeHtml(title)}
286
264
  </div>
287
- <pre class="custom-scrollbar mt-3 max-h-[18rem] overflow-auto whitespace-pre-wrap break-words border border-outline-variant/10 bg-surface-container-lowest px-4 py-4 font-mono text-xs leading-6 text-on-surface">${escapeHtml(
265
+ <pre class="row-editor-json-preview custom-scrollbar mt-3 max-h-[18rem] overflow-auto border border-outline-variant/10 bg-surface-container-lowest px-4 py-4 font-mono text-xs leading-6 text-on-surface">${escapeHtml(
288
266
  prettyJson
289
267
  )}</pre>
290
268
  </div>
@@ -301,7 +279,8 @@ function renderReadonlyField(field = {}, tableMeta = {}) {
301
279
  const url = getUrlValue(value);
302
280
  const badges = withFilePathBadge(withUrlBadge(Array.isArray(label?.badges) ? label.badges : [], url), filePathPreview);
303
281
  const displayLabel = typeof label === "object" ? label.label : label;
304
- const jsonPreview = getJsonPreview(value);
282
+ const jsonPreview = formatJsonPreview(value);
283
+ const valueState = getRowEditorValueState(rawValue);
305
284
 
306
285
  return `
307
286
  <div
@@ -315,6 +294,7 @@ function renderReadonlyField(field = {}, tableMeta = {}) {
315
294
  <div class="flex flex-wrap items-center gap-2 text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">
316
295
  <span>${escapeHtml(displayLabel)}</span>
317
296
  ${renderFieldBadgesWithCharacterCount(badges, rawValue)}
297
+ ${renderValueStateBadge(valueState)}
318
298
  </div>
319
299
  ${
320
300
  jsonPreview
@@ -328,6 +308,28 @@ function renderReadonlyField(field = {}, tableMeta = {}) {
328
308
  `;
329
309
  }
330
310
 
311
+ function getValueStateBadgeClassName(state) {
312
+ if (state === "null") {
313
+ return "border-primary-container/35 bg-primary-container/15 text-primary-container";
314
+ }
315
+
316
+ if (state === "empty") {
317
+ return "border-outline-variant/35 bg-surface-container-high text-on-surface-variant";
318
+ }
319
+
320
+ return "border-outline-variant/20 bg-surface-container text-on-surface-variant";
321
+ }
322
+
323
+ function renderValueStateBadge(state) {
324
+ return `
325
+ <span
326
+ class="border px-2 py-1 text-[9px] ${getValueStateBadgeClassName(state)}"
327
+ data-row-editor-value-state
328
+ data-value-state="${escapeHtml(state)}"
329
+ >${escapeHtml(getRowEditorValueStateLabel(state))}</span>
330
+ `;
331
+ }
332
+
331
333
  function renderEditableField(field, tableMeta = {}) {
332
334
  const columnName = getFieldColumnName(field);
333
335
  const protectedKeyColumn = isProtectedKeyColumn(columnName, tableMeta);
@@ -336,25 +338,28 @@ function renderEditableField(field, tableMeta = {}) {
336
338
  const filePathPreview = getFieldFilePathPreview(field, tableMeta);
337
339
  const baseBadges = withCheckBadge(Array.isArray(field.badges) ? field.badges : [], allowedValues);
338
340
  const badges = withFilePathBadge(withUrlBadge(baseBadges, url), filePathPreview);
339
- const jsonPreview = getJsonPreview(field.value);
341
+ const jsonPreview = formatJsonPreview(field.value);
340
342
  const inputType = field.inputType === "number" ? "number" : "text";
341
343
  const numberStep = field.numberStep === "1" ? "1" : "any";
344
+ const valueState = getRowEditorValueState(getFieldRawValue(field));
342
345
 
343
346
  return `
344
347
  <div
345
348
  class="block space-y-2"
346
349
  data-row-editor-column-name="${escapeHtml(columnName)}"
347
350
  data-row-editor-field
351
+ data-row-editor-initial-state="${escapeHtml(valueState)}"
348
352
  data-row-editor-protected-key="${protectedKeyColumn ? "true" : "false"}"
349
353
  ${url ? "data-row-editor-url-field" : ""}
350
354
  >
351
355
  <span class="flex flex-wrap items-center gap-2 text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">
352
356
  <span>${escapeHtml(field.label ?? field.name)}</span>
353
357
  ${renderFieldBadgesWithCharacterCount(badges, inputType === "number" ? null : field.value ?? "")}
358
+ ${renderValueStateBadge(valueState)}
354
359
  </span>
355
360
  ${
356
361
  jsonPreview
357
- ? renderJsonViewer(jsonPreview, "JSON Preview")
362
+ ? `<div data-row-editor-json-preview-container>${renderJsonViewer(jsonPreview, "JSON Preview")}</div>`
358
363
  : ""
359
364
  }
360
365
  ${
@@ -366,6 +371,7 @@ function renderEditableField(field, tableMeta = {}) {
366
371
  class="w-full border border-outline-variant/20 bg-surface-container-lowest px-4 py-3 text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
367
372
  data-row-editor-initial-value="${escapeHtml(field.value ?? "")}"
368
373
  data-row-editor-timestamp-source
374
+ data-row-editor-value-source
369
375
  name="field:${escapeHtml(field.name)}"
370
376
  step="${escapeHtml(numberStep)}"
371
377
  type="number"
@@ -379,6 +385,7 @@ function renderEditableField(field, tableMeta = {}) {
379
385
  data-row-editor-initial-value="${escapeHtml(field.value ?? "")}"
380
386
  data-row-editor-text-source
381
387
  data-row-editor-timestamp-source
388
+ data-row-editor-value-source
382
389
  name="field:${escapeHtml(field.name)}"
383
390
  data-row-editor-url-input
384
391
  spellcheck="false"
@@ -395,6 +402,7 @@ function renderEditableField(field, tableMeta = {}) {
395
402
  data-row-editor-initial-value="${escapeHtml(field.value ?? "")}"
396
403
  data-row-editor-text-source
397
404
  data-row-editor-timestamp-source
405
+ data-row-editor-value-source
398
406
  name="field:${escapeHtml(field.name)}"
399
407
  spellcheck="false"
400
408
  >${escapeHtml(field.value ?? "")}</textarea>
@@ -253,6 +253,11 @@ const state = {
253
253
  },
254
254
  settings: {
255
255
  data: { ...DEFAULT_SETTINGS },
256
+ section: 'information',
257
+ apiTokens: [],
258
+ tokenDatabase: null,
259
+ createdApiToken: null,
260
+ tokenSaving: false,
256
261
  loading: false,
257
262
  error: null,
258
263
  appVersion: null,
@@ -1310,6 +1315,9 @@ async function refreshSettingsState() {
1310
1315
  };
1311
1316
  state.settings.appVersion = response.metadata?.appVersion ?? null;
1312
1317
  state.settings.sqliteVersion = response.metadata?.sqliteVersion ?? null;
1318
+ state.settings.apiTokens = response.metadata?.apiTokens ?? [];
1319
+ state.settings.tokenDatabase = response.metadata?.activeDatabase ?? null;
1320
+ state.settings.createdApiToken = null;
1313
1321
  } catch (error) {
1314
1322
  state.settings.error = normalizeError(error);
1315
1323
  } finally {
@@ -1318,6 +1326,103 @@ async function refreshSettingsState() {
1318
1326
  }
1319
1327
  }
1320
1328
 
1329
+ export async function createSettingsApiToken(name) {
1330
+ state.settings.tokenSaving = true;
1331
+ state.settings.error = null;
1332
+ emitChange();
1333
+
1334
+ try {
1335
+ const response = await api.createApiToken(name);
1336
+ state.settings.apiTokens = response.metadata?.apiTokens ?? state.settings.apiTokens;
1337
+ state.settings.tokenDatabase = response.metadata?.activeDatabase ?? state.settings.tokenDatabase;
1338
+ state.settings.createdApiToken = response.data ?? null;
1339
+ pushToast('API token created. Store it now; it will not be shown again.', 'success');
1340
+ return response.data ?? null;
1341
+ } catch (error) {
1342
+ state.settings.error = normalizeError(error);
1343
+ return null;
1344
+ } finally {
1345
+ state.settings.tokenSaving = false;
1346
+ emitChange();
1347
+ }
1348
+ }
1349
+
1350
+ export function setSettingsSection(section) {
1351
+ const normalizedSection = section === 'api-tokens' ? 'api-tokens' : 'information';
1352
+
1353
+ if (state.settings.section === normalizedSection) {
1354
+ return;
1355
+ }
1356
+
1357
+ state.settings.section = normalizedSection;
1358
+ emitChange();
1359
+ }
1360
+
1361
+ export async function deleteSettingsApiToken(tokenId) {
1362
+ state.settings.tokenSaving = true;
1363
+ state.settings.error = null;
1364
+ emitChange();
1365
+
1366
+ try {
1367
+ const response = await api.deleteApiToken(tokenId);
1368
+ state.settings.apiTokens = response.metadata?.apiTokens ?? [];
1369
+ state.settings.tokenDatabase = response.metadata?.activeDatabase ?? state.settings.tokenDatabase;
1370
+
1371
+ if (state.settings.createdApiToken?.id === tokenId) {
1372
+ state.settings.createdApiToken = null;
1373
+ }
1374
+
1375
+ pushToast('API token deleted.', 'success');
1376
+ return true;
1377
+ } catch (error) {
1378
+ if (state.modal?.kind === 'delete-api-token') {
1379
+ withModalError(error);
1380
+ } else {
1381
+ state.settings.error = normalizeError(error);
1382
+ }
1383
+ return false;
1384
+ } finally {
1385
+ state.settings.tokenSaving = false;
1386
+ emitChange();
1387
+ }
1388
+ }
1389
+
1390
+ export function openDeleteSettingsApiTokenModal(tokenId) {
1391
+ const token = state.settings.apiTokens.find(candidate => candidate.id === tokenId);
1392
+
1393
+ if (!token) {
1394
+ pushToast('The selected API token could not be loaded.', 'alert');
1395
+ return;
1396
+ }
1397
+
1398
+ state.modal = {
1399
+ kind: 'delete-api-token',
1400
+ tokenId: token.id,
1401
+ tokenName: token.name,
1402
+ tokenPrefix: token.tokenPrefix,
1403
+ databaseLabel: state.settings.tokenDatabase?.label ?? 'active database',
1404
+ error: null,
1405
+ submitting: false,
1406
+ };
1407
+ emitChange();
1408
+ }
1409
+
1410
+ export async function submitDeleteSettingsApiTokenConfirmation() {
1411
+ if (state.modal?.kind !== 'delete-api-token') {
1412
+ return false;
1413
+ }
1414
+
1415
+ const tokenId = state.modal.tokenId;
1416
+ startModalSubmission();
1417
+ const deleted = await deleteSettingsApiToken(tokenId);
1418
+
1419
+ if (deleted) {
1420
+ closeModalInternal();
1421
+ }
1422
+
1423
+ return deleted;
1424
+ }
1425
+
1321
1426
  async function loadQueryHistoryDetail(historyId, options = {}) {
1322
1427
  const normalizedId = String(historyId ?? '').trim();
1323
1428
  const numericId = Number(normalizedId);
@@ -3490,6 +3595,26 @@ export async function submitCreateConnection(payload) {
3490
3595
  }
3491
3596
  }
3492
3597
 
3598
+ export async function chooseOpenDatabasePath() {
3599
+ try {
3600
+ const response = await api.chooseOpenDatabasePath();
3601
+ return response.data?.path ?? null;
3602
+ } catch (error) {
3603
+ pushToast(normalizeError(error).message || 'Native file dialog could not be opened.', 'alert');
3604
+ return null;
3605
+ }
3606
+ }
3607
+
3608
+ export async function chooseCreateDatabasePath() {
3609
+ try {
3610
+ const response = await api.chooseCreateDatabasePath();
3611
+ return response.data?.path ?? null;
3612
+ } catch (error) {
3613
+ pushToast(normalizeError(error).message || 'Native file dialog could not be opened.', 'alert');
3614
+ return null;
3615
+ }
3616
+ }
3617
+
3493
3618
  export async function submitImportSql(payload) {
3494
3619
  startModalSubmission();
3495
3620
 
@@ -5303,11 +5428,13 @@ export function getQueryMessages(snapshot = state) {
5303
5428
 
5304
5429
  return [
5305
5430
  ...snapshot.editor.result.statements.map(statement => ({
5306
- tone: statement.kind === 'resultSet' ? 'success' : inferStatusTone(statement.keyword),
5431
+ tone: statement.kind === 'resultSet' && statement.truncated ? 'warning' : statement.kind === 'resultSet' ? 'success' : inferStatusTone(statement.keyword),
5307
5432
  label: `${statement.keyword} #${statement.index + 1}`,
5308
5433
  value:
5309
5434
  statement.kind === 'resultSet'
5310
- ? `${statement.rowCount} row(s) returned.`
5435
+ ? statement.truncated
5436
+ ? `Showing the first ${statement.rowCount} row(s); the result was limited to ${statement.rowLimit}.`
5437
+ : `${statement.rowCount} row(s) returned.`
5311
5438
  : `${statement.changes} row(s) affected.`,
5312
5439
  })),
5313
5440
  ...queryMessages,
@@ -0,0 +1,31 @@
1
+ export function formatJsonPreview(value) {
2
+ if (value === null || value === undefined) {
3
+ return null;
4
+ }
5
+
6
+ let parsed = value;
7
+
8
+ if (typeof value !== "object") {
9
+ const text = String(value).trim();
10
+
11
+ if (!text || !["{", "["].includes(text[0])) {
12
+ return null;
13
+ }
14
+
15
+ try {
16
+ parsed = JSON.parse(text);
17
+ } catch (error) {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ if (!parsed || typeof parsed !== "object") {
23
+ return null;
24
+ }
25
+
26
+ try {
27
+ return JSON.stringify(parsed, null, 2);
28
+ } catch (error) {
29
+ return null;
30
+ }
31
+ }
@@ -0,0 +1,41 @@
1
+ export function getRowEditorValueState(value) {
2
+ if (value === null || value === undefined) {
3
+ return "null";
4
+ }
5
+
6
+ return String(value) === "" ? "empty" : "value";
7
+ }
8
+
9
+ export function getRowEditorValueStateLabel(state) {
10
+ if (state === "null") {
11
+ return "NULL";
12
+ }
13
+
14
+ if (state === "empty") {
15
+ return "EMPTY STRING";
16
+ }
17
+
18
+ return "VALUE";
19
+ }
20
+
21
+ export function buildRowEditorSubmittedValues(formData, fieldMetadata = {}) {
22
+ const fieldNames = new Set();
23
+
24
+ for (const [key] of formData.entries()) {
25
+ if (key.startsWith("field:")) {
26
+ fieldNames.add(key.slice("field:".length));
27
+ }
28
+ }
29
+
30
+ return Object.fromEntries(
31
+ [...fieldNames].map((fieldName) => {
32
+ const valueKey = `field:${fieldName}`;
33
+ const value = String(formData.get(valueKey) ?? "");
34
+ const metadata = fieldMetadata[fieldName] ?? {};
35
+ const keepInitialNull =
36
+ metadata.initialState === "null" && metadata.dirty !== true && value === "";
37
+
38
+ return [fieldName, keepInitialNull ? null : value];
39
+ })
40
+ );
41
+ }
@@ -0,0 +1,39 @@
1
+ export function captureTableHorizontalScrollState({ routeName = "", scrollNodes = [] } = {}) {
2
+ return {
3
+ routeName: String(routeName ?? ""),
4
+ positions: Array.from(scrollNodes)
5
+ .map((scrollNode) => ({
6
+ key: String(scrollNode?.dataset?.tableScrollKey ?? ""),
7
+ scrollLeft: Number(scrollNode?.scrollLeft) || 0,
8
+ }))
9
+ .filter((position) => position.key),
10
+ };
11
+ }
12
+
13
+ export function restoreTableHorizontalScrollState({
14
+ snapshot,
15
+ routeName = "",
16
+ scrollNodes = [],
17
+ } = {}) {
18
+ if (!snapshot || snapshot.routeName !== String(routeName ?? "")) {
19
+ return false;
20
+ }
21
+
22
+ const candidates = Array.from(scrollNodes);
23
+ let restored = false;
24
+
25
+ for (const position of snapshot.positions ?? []) {
26
+ const scrollNode = candidates.find(
27
+ (candidate) => String(candidate?.dataset?.tableScrollKey ?? "") === position.key
28
+ );
29
+
30
+ if (!scrollNode) {
31
+ continue;
32
+ }
33
+
34
+ scrollNode.scrollLeft = position.scrollLeft;
35
+ restored = true;
36
+ }
37
+
38
+ return restored;
39
+ }
@@ -220,6 +220,14 @@ function renderQueryResultState(state, result) {
220
220
  `;
221
221
  }
222
222
 
223
+ if (result.truncated) {
224
+ return `
225
+ <div class="border-b border-primary-container/20 bg-primary-container/10 px-4 py-3 text-sm text-on-surface">
226
+ Charts use the first ${escapeHtml(String(result.rowLimit ?? result.rows?.length ?? 0))} rows. Refine the query for a complete visualization.
227
+ </div>
228
+ `;
229
+ }
230
+
223
231
  return '';
224
232
  }
225
233
 
@@ -461,7 +461,9 @@ function renderTableSurface(state) {
461
461
  return [
462
462
  '<div class="flex flex-1 min-h-0 flex-col bg-surface-container-lowest">',
463
463
  renderTableFilterBar(table, state, activeColumn),
464
- '<div class="custom-scrollbar flex-1 overflow-auto">',
464
+ '<div class="custom-scrollbar flex-1 overflow-auto" data-table-horizontal-scroll data-table-scroll-key="data:',
465
+ escapeHtml(table.name),
466
+ '">',
465
467
  gridMarkup,
466
468
  emptyMarkup,
467
469
  '</div>',
@@ -603,6 +605,7 @@ export function renderDataRowEditorPanel(state) {
603
605
  ...getColumnNumberInputMeta(column),
604
606
  allowedValues: column.allowedValues ?? [],
605
607
  notNull: Boolean(column.notNull),
608
+ rawValue: value,
606
609
  value: value === null || value === undefined ? '' : String(value),
607
610
  };
608
611
  }),
@@ -234,6 +234,7 @@ function renderEditorRowPanel(state) {
234
234
  badges: getEditorColumnBadges(column),
235
235
  allowedValues: column.allowedValues ?? [],
236
236
  notNull: Boolean(column.notNull),
237
+ rawValue: value,
237
238
  value: value === null || value === undefined ? '' : String(value),
238
239
  };
239
240
  }),
@@ -280,6 +281,7 @@ function renderResultsSurface(state, isResultsRoute) {
280
281
  sortDirection: state.editor.resultSortDirection,
281
282
  resultScope: 'editor',
282
283
  sortAction: 'sort-editor-results-column',
284
+ scrollKey: `editor:${state.editor.result?.historyId ?? 'current'}`,
283
285
  })
284
286
  : renderMissingDatabase();
285
287
  }
@@ -1,6 +1,35 @@
1
1
  import { renderPageHeader } from '../components/pageHeader.js';
2
+ import { renderTextInput } from '../components/formControls.js';
2
3
  import { escapeHtml } from '../utils/format.js';
3
4
 
5
+ function renderSettingsNavigation(activeSection) {
6
+ const items = [
7
+ { id: 'information', icon: 'info', label: 'Information' },
8
+ { id: 'api-tokens', icon: 'key', label: 'API Tokens' },
9
+ ];
10
+
11
+ return `
12
+ <nav class="flex flex-wrap gap-2 border-b border-outline-variant/10 pb-4" aria-label="Settings sections">
13
+ ${items
14
+ .map(
15
+ item => `
16
+ <button
17
+ class="query-history-tab ${activeSection === item.id ? 'is-active' : ''}"
18
+ aria-current="${activeSection === item.id ? 'page' : 'false'}"
19
+ data-action="set-settings-section"
20
+ data-section="${item.id}"
21
+ type="button"
22
+ >
23
+ <span class="material-symbols-outlined text-sm mr-[5px]">${item.icon}</span>
24
+ ${item.label}
25
+ </button>
26
+ `,
27
+ )
28
+ .join('')}
29
+ </nav>
30
+ `;
31
+ }
32
+
4
33
  function renderSettingsContent(state) {
5
34
  if (state.settings.loading && !state.settings.appVersion) {
6
35
  return `
@@ -24,11 +53,97 @@ function renderSettingsContent(state) {
24
53
  `;
25
54
  }
26
55
 
56
+ const activeSection = state.settings.section === 'api-tokens' ? 'api-tokens' : 'information';
27
57
  const appVersion = escapeHtml(state.settings.appVersion ?? '0.0.0');
28
58
  const sqliteVersion = escapeHtml(state.settings.sqliteVersion ?? 'unknown');
59
+ const tokenDatabase = state.settings.tokenDatabase;
60
+ const apiTokens = state.settings.apiTokens ?? [];
61
+ const createdApiToken = state.settings.createdApiToken;
62
+ const tokenSaving = Boolean(state.settings.tokenSaving);
63
+ const tokenItems = apiTokens.length
64
+ ? apiTokens
65
+ .map(
66
+ token => `
67
+ <div class="flex flex-col gap-3 border border-outline-variant/10 bg-surface-container-high px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
68
+ <div class="min-w-0">
69
+ <div class="truncate text-sm font-semibold text-on-surface">${escapeHtml(token.name)}</div>
70
+ <div class="mt-1 font-mono text-[10px] text-on-surface-variant/60">
71
+ ${escapeHtml(token.tokenPrefix)}... · Created ${escapeHtml(token.createdAt ?? 'unknown')}
72
+ </div>
73
+ </div>
74
+ <button
75
+ class="delete-button"
76
+ data-action="open-delete-api-token-modal"
77
+ data-token-id="${escapeHtml(token.id)}"
78
+ type="button"
79
+ ${tokenSaving ? 'disabled' : ''}
80
+ >
81
+ Delete
82
+ </button>
83
+ </div>
84
+ `,
85
+ )
86
+ .join('')
87
+ : '<div class="text-sm text-on-surface-variant">No API tokens exist for this database.</div>';
88
+ const tokenSection = tokenDatabase
89
+ ? `
90
+ <div class="space-y-5">
91
+ <div class="space-y-3">
92
+ <div class="text-sm font-semibold text-on-surface">${escapeHtml(tokenDatabase.label)}</div>
93
+ <div>
94
+ <div class="text-[10px] font-mono uppercase tracking-[0.2em] text-on-surface-variant/60">
95
+ Database ID
96
+ </div>
97
+ <div class="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_9rem]">
98
+ <div
99
+ class="min-w-0 flex-1 break-all border border-outline-variant/20 bg-surface-container-lowest px-4 py-3 font-mono text-sm text-primary-container"
100
+ data-database-id
101
+ >${escapeHtml(tokenDatabase.id)}</div>
102
+ <button class="standard-button w-full self-center justify-center" data-action="copy-database-id" type="button">
103
+ <span class="material-symbols-outlined text-sm">content_copy</span>
104
+ Copy ID
105
+ </button>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ <form class="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_9rem]" data-form="create-api-token">
110
+ ${renderTextInput({
111
+ className: 'min-w-0 flex-1',
112
+ dataAttributes: { apiTokenName: true },
113
+ maxlength: 80,
114
+ name: 'name',
115
+ placeholder: 'Token name',
116
+ })}
117
+ <button class="standard-button w-full self-center justify-center" type="submit" ${tokenSaving ? 'disabled' : ''}>
118
+ Create Token
119
+ </button>
120
+ </form>
121
+ ${
122
+ createdApiToken?.token
123
+ ? `
124
+ <div class="border border-primary-container/20 bg-primary-container/5 p-4">
125
+ <div class="text-[10px] font-bold uppercase tracking-[0.2em] text-primary-container">Shown once</div>
126
+ <div class="mt-2 text-sm text-on-surface-variant">Store this token now. SQLite Hub only keeps its hash.</div>
127
+ <div class="mt-3 flex flex-col gap-2 sm:flex-row">
128
+ ${renderTextInput({
129
+ className: 'min-w-0 flex-1 font-mono',
130
+ dataAttributes: { createdApiToken: true },
131
+ readonly: true,
132
+ value: createdApiToken.token,
133
+ })}
134
+ <button class="standard-button" data-action="copy-created-api-token" type="button">Copy</button>
135
+ </div>
136
+ </div>
137
+ `
138
+ : ''
139
+ }
140
+ <div class="space-y-2">${tokenItems}</div>
141
+ </div>
142
+ `
143
+ : '<div class="text-sm leading-6 text-on-surface-variant">Select a database before creating database-specific API tokens.</div>';
29
144
 
30
- return `
31
- <div class="grid grid-cols-1 gap-6 xl:grid-cols-2">
145
+ const informationSection = `
146
+ <div class="grid grid-cols-1 gap-6 xl:grid-cols-2">
32
147
  <section class="shell-section overflow-hidden">
33
148
  <div class="flex items-center justify-between border-b border-outline-variant/10 bg-surface-container-highest px-4 py-2">
34
149
  <span class="text-[10px] font-bold uppercase tracking-[0.25em]">Application</span>
@@ -120,8 +235,26 @@ function renderSettingsContent(state) {
120
235
  </a>
121
236
  </div>
122
237
  </section>
123
- </div>
124
- `;
238
+ </div>
239
+ `;
240
+ const apiTokensSection = `
241
+ <section class="shell-section overflow-hidden">
242
+ <div class="flex items-center justify-between border-b border-outline-variant/10 bg-surface-container-highest px-4 py-2">
243
+ <span class="text-[10px] font-bold uppercase tracking-[0.25em] mr-px">API Tokens</span>
244
+ <span class="material-symbols-outlined text-xs text-primary-container">key</span>
245
+ </div>
246
+ <div class="p-6">
247
+ ${tokenSection}
248
+ </div>
249
+ </section>
250
+ `;
251
+
252
+ return `
253
+ <div class="space-y-6">
254
+ ${renderSettingsNavigation(activeSection)}
255
+ ${activeSection === 'api-tokens' ? apiTokensSection : informationSection}
256
+ </div>
257
+ `;
125
258
  }
126
259
 
127
260
  export function renderSettingsView(state) {
@@ -131,7 +264,10 @@ export function renderSettingsView(state) {
131
264
  <div class="view-frame mx-auto max-w-6xl space-y-8">
132
265
  ${renderPageHeader({
133
266
  title: 'Settings',
134
- subtitle: 'Application // Build + Credits',
267
+ subtitle:
268
+ state.settings.section === 'api-tokens'
269
+ ? 'Security // Database API access'
270
+ : 'Application // Build + Credits',
135
271
  })}
136
272
  ${renderSettingsContent(state)}
137
273
  </div>