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
@@ -86,6 +86,18 @@ export function createConnection(payload) {
86
86
  });
87
87
  }
88
88
 
89
+ export function chooseOpenDatabasePath() {
90
+ return request("/api/connections/choose-open-path", {
91
+ method: "POST",
92
+ });
93
+ }
94
+
95
+ export function chooseCreateDatabasePath() {
96
+ return request("/api/connections/choose-create-path", {
97
+ method: "POST",
98
+ });
99
+ }
100
+
89
101
  export function importSql(payload) {
90
102
  return request("/api/connections/import-sql", {
91
103
  method: "POST",
@@ -419,6 +431,19 @@ export function patchSettings(settings) {
419
431
  });
420
432
  }
421
433
 
434
+ export function createApiToken(name) {
435
+ return request("/api/settings/api-tokens", {
436
+ method: "POST",
437
+ body: { name },
438
+ });
439
+ }
440
+
441
+ export function deleteApiToken(tokenId) {
442
+ return request(`/api/settings/api-tokens/${encodeURIComponent(tokenId)}`, {
443
+ method: "DELETE",
444
+ });
445
+ }
446
+
422
447
  const TEXT_EXPORT_EXTENSIONS = {
423
448
  csv: "csv",
424
449
  tsv: "tsv",
@@ -24,6 +24,9 @@ import { renderTopNav } from './components/topNav.js';
24
24
  import { createRouter } from './router.js';
25
25
  import {
26
26
  createActiveConnectionBackup,
27
+ createSettingsApiToken,
28
+ chooseOpenDatabasePath,
29
+ chooseCreateDatabasePath,
27
30
  createDocument,
28
31
  createDocumentFromMarkdownExport,
29
32
  clearCurrentQuery,
@@ -48,6 +51,7 @@ import {
48
51
  openDeleteDataRowModal,
49
52
  openDeleteEditorRowModal,
50
53
  openDeleteQueryHistoryModal,
54
+ openDeleteSettingsApiTokenModal,
51
55
  openDeleteQueryChartModal,
52
56
  openDataExportModal,
53
57
  openDeleteDocumentModal,
@@ -102,6 +106,7 @@ import {
102
106
  submitCreateMediaTaggingTagTable,
103
107
  submitCreateMediaTaggingMappingTable,
104
108
  submitDeleteQueryHistoryConfirmation,
109
+ submitDeleteSettingsApiTokenConfirmation,
105
110
  submitRowUpdatePreviewConfirmation,
106
111
  setQueryHistoryPanelVisibility,
107
112
  sortDataTableByColumn,
@@ -112,6 +117,7 @@ import {
112
117
  setCopyColumnModalEditedText,
113
118
  setCopyColumnModalSubmitting,
114
119
  setRoute,
120
+ setSettingsSection,
115
121
  saveQueryHistoryNotes,
116
122
  saveQueryHistoryTitle,
117
123
  saveCurrentTableDesignerDraft,
@@ -178,10 +184,19 @@ import {
178
184
  stringifyRowEditorJson,
179
185
  } from './utils/rowEditorJson.js';
180
186
  import { getTimestampPreviewForField } from './utils/timestampPreview.js';
187
+ import {
188
+ buildRowEditorSubmittedValues,
189
+ getRowEditorValueState,
190
+ getRowEditorValueStateLabel,
191
+ } from './utils/rowEditorValues.js';
181
192
  import {
182
193
  formatTextCellCharacterCount,
183
194
  getTextCellCharacterCount,
184
195
  } from './utils/textCellStats.js';
196
+ import {
197
+ captureTableHorizontalScrollState,
198
+ restoreTableHorizontalScrollState,
199
+ } from './utils/tableScrollState.js';
185
200
 
186
201
  const appRoot = document.querySelector('#app');
187
202
 
@@ -1257,6 +1272,10 @@ function renderApp(state) {
1257
1272
 
1258
1273
  const focusedInput = captureFocusedInputState();
1259
1274
  const queryHistoryScrollState = captureQueryHistoryScrollState();
1275
+ const tableHorizontalScrollState = captureTableHorizontalScrollState({
1276
+ routeName: lastRenderedRouteName,
1277
+ scrollNodes: shellRefs.view.querySelectorAll('[data-table-horizontal-scroll]'),
1278
+ });
1260
1279
  const isEnteringNewTableDesignerRoute =
1261
1280
  state.route.name === 'tableDesigner' && state.route.params?.isNew && previousRoutePath !== state.route.path;
1262
1281
 
@@ -1329,6 +1348,14 @@ function renderApp(state) {
1329
1348
  restoreQueryHistoryScrollState(queryHistoryScrollState);
1330
1349
  }
1331
1350
 
1351
+ if (mainChanged && !mainPatched) {
1352
+ restoreTableHorizontalScrollState({
1353
+ snapshot: tableHorizontalScrollState,
1354
+ routeName: state.route.name,
1355
+ scrollNodes: shellRefs.view.querySelectorAll('[data-table-horizontal-scroll]'),
1356
+ });
1357
+ }
1358
+
1332
1359
  if (pendingQueryEditorFocus && (state.route.name === 'editor' || state.route.name === 'editorResults')) {
1333
1360
  if (focusQueryEditorInput()) {
1334
1361
  pendingQueryEditorFocus = false;
@@ -1572,6 +1599,53 @@ function syncRowEditorFilePathPreview(inputNode) {
1572
1599
  previewNode.hidden = false;
1573
1600
  }
1574
1601
 
1602
+ function getRowEditorValueStateClassName(state) {
1603
+ if (state === 'null') {
1604
+ return 'border px-2 py-1 text-[9px] border-primary-container/35 bg-primary-container/15 text-primary-container';
1605
+ }
1606
+
1607
+ if (state === 'empty') {
1608
+ return 'border px-2 py-1 text-[9px] border-outline-variant/35 bg-surface-container-high text-on-surface-variant';
1609
+ }
1610
+
1611
+ return 'border px-2 py-1 text-[9px] border-outline-variant/20 bg-surface-container text-on-surface-variant';
1612
+ }
1613
+
1614
+ function syncRowEditorValueState(controlNode) {
1615
+ const fieldNode = controlNode.closest('[data-row-editor-field]');
1616
+ const valueInput = fieldNode?.querySelector('[data-row-editor-value-source]');
1617
+ const stateBadge = fieldNode?.querySelector('[data-row-editor-value-state]');
1618
+
1619
+ if (!fieldNode || !valueInput || !stateBadge) {
1620
+ return;
1621
+ }
1622
+
1623
+ const valueState = getRowEditorValueState(valueInput.value);
1624
+
1625
+ valueInput.dataset.rowEditorDirty = 'true';
1626
+ stateBadge.dataset.valueState = valueState;
1627
+ stateBadge.className = getRowEditorValueStateClassName(valueState);
1628
+ stateBadge.textContent = getRowEditorValueStateLabel(valueState);
1629
+
1630
+ syncRowEditorTimestampPreview(valueInput);
1631
+ syncRowEditorFilePathPreview(valueInput);
1632
+ syncRowEditorCharacterCount(valueInput);
1633
+ }
1634
+
1635
+ function getRowEditorFieldMetadata(form) {
1636
+ return Object.fromEntries(
1637
+ Array.from(form.elements)
1638
+ .filter(element => element.name?.startsWith('field:'))
1639
+ .map(element => [
1640
+ element.name.slice('field:'.length),
1641
+ {
1642
+ initialState: element.closest('[data-row-editor-field]')?.dataset?.rowEditorInitialState,
1643
+ dirty: element.dataset.rowEditorDirty === 'true',
1644
+ },
1645
+ ]),
1646
+ );
1647
+ }
1648
+
1575
1649
  function closeCopyColumnMenus(exceptMenu = null) {
1576
1650
  document.querySelectorAll('[data-copy-column-menu][open]').forEach(menu => {
1577
1651
  if (menu !== exceptMenu && menu instanceof HTMLDetailsElement) {
@@ -1732,21 +1806,24 @@ function applyChangedRowEditorFormValues(rowObject) {
1732
1806
 
1733
1807
  const nextRowObject = { ...rowObject };
1734
1808
  const formData = new FormData(form);
1809
+ const submittedValues = buildRowEditorSubmittedValues(formData, getRowEditorFieldMetadata(form));
1735
1810
 
1736
- for (const [key, value] of formData.entries()) {
1737
- if (!key.startsWith('field:')) {
1738
- continue;
1739
- }
1740
-
1811
+ for (const [fieldName, value] of Object.entries(submittedValues)) {
1812
+ const key = `field:${fieldName}`;
1741
1813
  const control = Array.from(form.elements).find(element => element.name === key);
1742
1814
  const initialValue = control?.dataset?.rowEditorInitialValue;
1743
- const currentValue = String(value ?? '');
1815
+ const initialState = control?.closest('[data-row-editor-field]')?.dataset?.rowEditorInitialState;
1816
+ const currentState = getRowEditorValueState(value);
1817
+ const currentValue = value === null ? null : String(value);
1744
1818
 
1745
- if (initialValue !== undefined && currentValue === initialValue) {
1819
+ if (
1820
+ initialState === currentState &&
1821
+ (currentState === 'null' || (initialValue !== undefined && currentValue === initialValue))
1822
+ ) {
1746
1823
  continue;
1747
1824
  }
1748
1825
 
1749
- nextRowObject[key.slice('field:'.length)] = currentValue;
1826
+ nextRowObject[fieldName] = currentValue;
1750
1827
  }
1751
1828
 
1752
1829
  return nextRowObject;
@@ -2019,6 +2096,32 @@ async function handleAction(actionNode) {
2019
2096
  case 'refresh-view':
2020
2097
  await refreshCurrentRoute();
2021
2098
  return;
2099
+ case 'set-settings-section':
2100
+ setSettingsSection(actionNode.dataset.section);
2101
+ return;
2102
+ case 'open-delete-api-token-modal':
2103
+ openDeleteSettingsApiTokenModal(actionNode.dataset.tokenId);
2104
+ return;
2105
+ case 'copy-created-api-token': {
2106
+ const tokenInput = document.querySelector('[data-created-api-token]');
2107
+ const token = tokenInput instanceof HTMLInputElement ? tokenInput.value : '';
2108
+
2109
+ if (token && navigator.clipboard?.writeText) {
2110
+ await navigator.clipboard.writeText(token);
2111
+ showToast('API token copied.', 'success');
2112
+ }
2113
+ return;
2114
+ }
2115
+ case 'copy-database-id': {
2116
+ const databaseIdNode = document.querySelector('[data-database-id]');
2117
+ const databaseId = databaseIdNode?.textContent?.trim() ?? '';
2118
+
2119
+ if (databaseId && navigator.clipboard?.writeText) {
2120
+ await navigator.clipboard.writeText(databaseId);
2121
+ showToast('Database ID copied.', 'success');
2122
+ }
2123
+ return;
2124
+ }
2022
2125
  case 'open-row-editor-url':
2023
2126
  openRowEditorUrl(actionNode);
2024
2127
  return;
@@ -2109,6 +2212,60 @@ async function handleAction(actionNode) {
2109
2212
  case 'edit-connection':
2110
2213
  openEditConnectionModal(actionNode.dataset.connectionId);
2111
2214
  return;
2215
+ case 'choose-open-database-path': {
2216
+ const labelNode = actionNode.querySelector('[data-open-database-path-button-label]');
2217
+
2218
+ actionNode.setAttribute('disabled', '');
2219
+ if (labelNode) {
2220
+ labelNode.textContent = 'Choosing...';
2221
+ }
2222
+
2223
+ const selectedPath = await chooseOpenDatabasePath();
2224
+ const pathInput = document.querySelector(
2225
+ '[data-form="open-connection"] [data-open-database-path]',
2226
+ );
2227
+
2228
+ if (selectedPath && pathInput instanceof HTMLInputElement) {
2229
+ pathInput.value = selectedPath;
2230
+ pathInput.focus({ preventScroll: true });
2231
+ pathInput.setSelectionRange(pathInput.value.length, pathInput.value.length);
2232
+ }
2233
+
2234
+ if (actionNode.isConnected) {
2235
+ actionNode.removeAttribute('disabled');
2236
+ if (labelNode) {
2237
+ labelNode.textContent = 'Browse...';
2238
+ }
2239
+ }
2240
+ return;
2241
+ }
2242
+ case 'choose-create-database-path': {
2243
+ const labelNode = actionNode.querySelector('[data-create-database-path-button-label]');
2244
+
2245
+ actionNode.setAttribute('disabled', '');
2246
+ if (labelNode) {
2247
+ labelNode.textContent = 'Choosing...';
2248
+ }
2249
+
2250
+ const selectedPath = await chooseCreateDatabasePath();
2251
+ const pathInput = document.querySelector(
2252
+ '[data-form="create-connection"] [data-create-database-path]',
2253
+ );
2254
+
2255
+ if (selectedPath && pathInput instanceof HTMLInputElement) {
2256
+ pathInput.value = selectedPath;
2257
+ pathInput.focus({ preventScroll: true });
2258
+ pathInput.setSelectionRange(pathInput.value.length, pathInput.value.length);
2259
+ }
2260
+
2261
+ if (actionNode.isConnected) {
2262
+ actionNode.removeAttribute('disabled');
2263
+ if (labelNode) {
2264
+ labelNode.textContent = 'Browse...';
2265
+ }
2266
+ }
2267
+ return;
2268
+ }
2112
2269
  case 'close-modal':
2113
2270
  closeModal();
2114
2271
  return;
@@ -2689,6 +2846,7 @@ document.addEventListener('keydown', event => {
2689
2846
 
2690
2847
  document.addEventListener('input', event => {
2691
2848
  const target = event.target instanceof Element ? event.target : null;
2849
+ const valueInput = target?.closest('[data-row-editor-value-source]');
2692
2850
  const timestampInput = target?.closest('[data-row-editor-timestamp-source]');
2693
2851
  const textCellInput = target?.closest('[data-row-editor-text-source]');
2694
2852
 
@@ -2701,6 +2859,10 @@ document.addEventListener('input', event => {
2701
2859
  syncRowEditorCharacterCount(textCellInput);
2702
2860
  }
2703
2861
 
2862
+ if (valueInput) {
2863
+ syncRowEditorValueState(valueInput);
2864
+ }
2865
+
2704
2866
  const bindNode = event.target.closest('[data-bind]');
2705
2867
 
2706
2868
  if (!bindNode) {
@@ -2845,6 +3007,7 @@ document.addEventListener(
2845
3007
 
2846
3008
  document.addEventListener('change', event => {
2847
3009
  const target = event.target instanceof Element ? event.target : null;
3010
+ const valueControl = target?.closest('[data-row-editor-value-source]');
2848
3011
  const timestampInput = target?.closest('[data-row-editor-timestamp-source]');
2849
3012
  const textCellInput = target?.closest('[data-row-editor-text-source]');
2850
3013
 
@@ -2857,6 +3020,10 @@ document.addEventListener('change', event => {
2857
3020
  syncRowEditorCharacterCount(textCellInput);
2858
3021
  }
2859
3022
 
3023
+ if (valueControl) {
3024
+ syncRowEditorValueState(valueControl);
3025
+ }
3026
+
2860
3027
  const bindNode = event.target.closest('[data-bind]');
2861
3028
 
2862
3029
  if (!bindNode) {
@@ -3007,6 +3174,9 @@ document.addEventListener('submit', async event => {
3007
3174
  const formData = submitter ? new FormData(form, submitter) : new FormData(form);
3008
3175
 
3009
3176
  switch (form.dataset.form) {
3177
+ case 'create-api-token':
3178
+ await createSettingsApiToken(String(formData.get('name') ?? ''));
3179
+ return;
3010
3180
  case 'open-connection': {
3011
3181
  resetStructureGraphForDatabaseChange();
3012
3182
  const connection = await submitOpenConnection({
@@ -3097,6 +3267,9 @@ document.addEventListener('submit', async event => {
3097
3267
  }
3098
3268
  return;
3099
3269
  }
3270
+ case 'delete-api-token-confirm':
3271
+ await submitDeleteSettingsApiTokenConfirmation();
3272
+ return;
3100
3273
  case 'document-insert-table': {
3101
3274
  const inserted = await submitDocumentInsertTable();
3102
3275
 
@@ -3126,15 +3299,7 @@ document.addEventListener('submit', async event => {
3126
3299
  await submitCopyColumnModal(formData);
3127
3300
  return;
3128
3301
  case 'save-data-row': {
3129
- const values = {};
3130
-
3131
- for (const [key, value] of formData.entries()) {
3132
- if (!key.startsWith('field:')) {
3133
- continue;
3134
- }
3135
-
3136
- values[key.slice('field:'.length)] = String(value ?? '');
3137
- }
3302
+ const values = buildRowEditorSubmittedValues(formData, getRowEditorFieldMetadata(form));
3138
3303
 
3139
3304
  let rowIdentity = null;
3140
3305
  const rawRowIdentity = String(formData.get('rowIdentity') ?? '').trim();
@@ -3151,15 +3316,7 @@ document.addEventListener('submit', async event => {
3151
3316
  return;
3152
3317
  }
3153
3318
  case 'save-editor-row': {
3154
- const values = {};
3155
-
3156
- for (const [key, value] of formData.entries()) {
3157
- if (!key.startsWith('field:')) {
3158
- continue;
3159
- }
3160
-
3161
- values[key.slice('field:'.length)] = String(value ?? '');
3162
- }
3319
+ const values = buildRowEditorSubmittedValues(formData, getRowEditorFieldMetadata(form));
3163
3320
 
3164
3321
  await openEditorRowUpdatePreview(String(formData.get('rowIndex') ?? ''), values);
3165
3322
  return;
@@ -0,0 +1,38 @@
1
+ import { escapeHtml } from '../utils/format.js';
2
+
3
+ export const STANDARD_TEXT_INPUT_CLASS =
4
+ 'control-input w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors placeholder:text-on-surface-variant/35 focus:border-primary-container';
5
+
6
+ export function renderTextInput({
7
+ className = '',
8
+ dataAttributes = {},
9
+ disabled = false,
10
+ maxlength = null,
11
+ name = '',
12
+ placeholder = '',
13
+ readonly = false,
14
+ spellcheck = null,
15
+ type = 'text',
16
+ value = '',
17
+ } = {}) {
18
+ const attributes = [
19
+ `class="${escapeHtml([STANDARD_TEXT_INPUT_CLASS, className].filter(Boolean).join(' '))}"`,
20
+ name ? `name="${escapeHtml(name)}"` : '',
21
+ placeholder ? `placeholder="${escapeHtml(placeholder)}"` : '',
22
+ `type="${escapeHtml(type)}"`,
23
+ `value="${escapeHtml(value)}"`,
24
+ Number.isInteger(maxlength) ? `maxlength="${maxlength}"` : '',
25
+ disabled ? 'disabled' : '',
26
+ readonly ? 'readonly' : '',
27
+ typeof spellcheck === 'boolean' ? `spellcheck="${spellcheck}"` : '',
28
+ ...Object.entries(dataAttributes).map(([name, attributeValue]) => {
29
+ const attributeName = `data-${String(name).replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)}`;
30
+
31
+ return attributeValue === true
32
+ ? attributeName
33
+ : `${attributeName}="${escapeHtml(attributeValue)}"`;
34
+ }),
35
+ ].filter(Boolean);
36
+
37
+ return `<input ${attributes.join(' ')} />`;
38
+ }
@@ -7,6 +7,7 @@ import {
7
7
  isMarkdownTodoCopyColumnMode,
8
8
  } from "../utils/copyColumnExport.js";
9
9
  import { renderConnectionLogo } from "./connectionLogo.js";
10
+ import { renderTextInput } from "./formControls.js";
10
11
  import {
11
12
  analyzeQueryChartResult,
12
13
  getQueryChartTypeLabel,
@@ -27,13 +28,7 @@ function renderField({ label, name, type = "text", placeholder = "", value = ""
27
28
  <span class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
28
29
  ${escapeHtml(label)}
29
30
  </span>
30
- <input
31
- class="control-input w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
32
- name="${escapeHtml(name)}"
33
- placeholder="${escapeHtml(placeholder)}"
34
- type="${escapeHtml(type)}"
35
- value="${escapeHtml(value)}"
36
- />
31
+ ${renderTextInput({ name, placeholder, type, value })}
37
32
  </label>
38
33
  `;
39
34
  }
@@ -152,14 +147,33 @@ function renderError(error) {
152
147
  `;
153
148
  }
154
149
 
155
- function renderOpenConnectionForm(modal) {
150
+ export function renderOpenConnectionForm(modal) {
156
151
  return `
157
152
  <form class="space-y-5" data-form="open-connection">
158
- ${renderField({
159
- label: "SQLite File Path",
160
- name: "path",
161
- placeholder: "/absolute/path/to/database.sqlite",
162
- })}
153
+ <label class="block space-y-2">
154
+ <span class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
155
+ SQLite File Path
156
+ </span>
157
+ <span class="flex items-stretch gap-2">
158
+ ${renderTextInput({
159
+ className: "min-w-0 flex-1",
160
+ dataAttributes: { openDatabasePath: true },
161
+ name: "path",
162
+ placeholder: "/absolute/path/to/database.sqlite",
163
+ })}
164
+ <button
165
+ class="standard-button flex-none"
166
+ data-action="choose-open-database-path"
167
+ type="button"
168
+ >
169
+ <span class="material-symbols-outlined text-sm">folder_open</span>
170
+ <span data-open-database-path-button-label>Browse...</span>
171
+ </button>
172
+ </span>
173
+ <span class="block text-[11px] leading-5 text-on-surface-variant/60">
174
+ Choose an existing SQLite file, or enter an absolute path manually.
175
+ </span>
176
+ </label>
163
177
  ${renderField({
164
178
  label: "Label",
165
179
  name: "label",
@@ -264,14 +278,33 @@ function renderEditConnectionForm(modal) {
264
278
  `;
265
279
  }
266
280
 
267
- function renderCreateDatabaseForm(modal) {
281
+ export function renderCreateDatabaseForm(modal) {
268
282
  return `
269
283
  <form class="space-y-5" data-form="create-connection">
270
- ${renderField({
271
- label: "New SQLite File Path",
272
- name: "path",
273
- placeholder: "/absolute/path/to/new-database.sqlite",
274
- })}
284
+ <label class="block space-y-2">
285
+ <span class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
286
+ New SQLite File Path
287
+ </span>
288
+ <span class="flex items-stretch gap-2">
289
+ ${renderTextInput({
290
+ className: "min-w-0 flex-1",
291
+ dataAttributes: { createDatabasePath: true },
292
+ name: "path",
293
+ placeholder: "/absolute/path/to/new-database.sqlite",
294
+ })}
295
+ <button
296
+ class="standard-button flex-none"
297
+ data-action="choose-create-database-path"
298
+ type="button"
299
+ >
300
+ <span class="material-symbols-outlined text-sm">folder_open</span>
301
+ <span data-create-database-path-button-label>Browse...</span>
302
+ </button>
303
+ </span>
304
+ <span class="block text-[11px] leading-5 text-on-surface-variant/60">
305
+ Choose a folder and filename, or enter an absolute path manually.
306
+ </span>
307
+ </label>
275
308
  ${renderField({
276
309
  label: "Label",
277
310
  name: "label",
@@ -827,6 +860,28 @@ function renderDeleteDocumentForm(modal) {
827
860
  ].join("");
828
861
  }
829
862
 
863
+ export function renderDeleteApiTokenForm(modal) {
864
+ return [
865
+ '<form class="space-y-5" data-form="delete-api-token-confirm"><div class="space-y-3">',
866
+ '<p class="text-sm leading-7 text-on-surface">Delete API token <span class="font-bold text-primary-container">',
867
+ escapeHtml(modal.tokenName ?? "API token"),
868
+ "</span>?</p>",
869
+ '<p class="text-sm leading-7 text-on-surface-variant/65">Requests using this token will immediately lose access to <span class="font-semibold text-on-surface">',
870
+ escapeHtml(modal.databaseLabel ?? "the active database"),
871
+ ".</span></p>",
872
+ '<div class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-3 font-mono text-[10px] text-on-surface-variant/55">',
873
+ escapeHtml(modal.tokenPrefix ?? ""),
874
+ "...</div>",
875
+ "</div>",
876
+ renderError(modal.error),
877
+ '<div class="flex items-center justify-between gap-3 pt-2">',
878
+ '<button class="standard-button" data-action="close-modal" type="button">Cancel</button>',
879
+ '<button class="delete-button" type="submit">',
880
+ modal.submitting ? "Deleting..." : "Delete Token",
881
+ "</button></div></form>",
882
+ ].join("");
883
+ }
884
+
830
885
  function getDocumentInsertQueryTitle(query) {
831
886
  return query?.displayTitle || query?.title || query?.previewSql || query?.rawSql || "Saved query";
832
887
  }
@@ -1667,6 +1722,11 @@ export function renderModal(state) {
1667
1722
  title: "Delete Document",
1668
1723
  body: renderDeleteDocumentForm(modal),
1669
1724
  },
1725
+ "delete-api-token": {
1726
+ eyebrow: "Settings // Confirm token deletion",
1727
+ title: "Delete API Token",
1728
+ body: renderDeleteApiTokenForm(modal),
1729
+ },
1670
1730
  "document-insert-table": {
1671
1731
  eyebrow: "Documents // Saved query output",
1672
1732
  title: "Insert Table",
@@ -95,6 +95,7 @@ export function renderQueryResultsPane(
95
95
  sortDirection = null,
96
96
  resultScope = "editor",
97
97
  sortAction = "sort-editor-results-column",
98
+ scrollKey = "",
98
99
  } = {}
99
100
  ) {
100
101
  if (!result) {
@@ -124,7 +125,23 @@ export function renderQueryResultsPane(
124
125
 
125
126
  return `
126
127
  <div class="relative flex h-full min-h-0 flex-col overflow-hidden bg-surface-container">
127
- <div class="custom-scrollbar min-h-0 flex-1 overflow-auto bg-surface-container-lowest">
128
+ ${
129
+ result.truncated
130
+ ? `
131
+ <div class="border-b border-primary-container/20 bg-primary-container/10 px-4 py-2 text-xs text-on-surface">
132
+ Showing the first ${escapeHtml(String(result.rowLimit ?? result.rows?.length ?? 0))} rows. Refine the query or export it to process the complete result set.
133
+ </div>
134
+ `
135
+ : ""
136
+ }
137
+ <div
138
+ class="custom-scrollbar min-h-0 flex-1 overflow-auto bg-surface-container-lowest"
139
+ ${
140
+ scrollKey
141
+ ? `data-table-horizontal-scroll data-table-scroll-key="${escapeHtml(scrollKey)}"`
142
+ : ""
143
+ }
144
+ >
128
145
  ${
129
146
  result.columns?.length
130
147
  ? renderDataGrid({