sqlite-hub 0.17.0 → 0.17.2

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 (34) hide show
  1. package/frontend/js/api.js +6 -0
  2. package/frontend/js/app.js +119 -26
  3. package/frontend/js/components/modal.js +26 -6
  4. package/frontend/js/components/queryResults.js +18 -1
  5. package/frontend/js/components/rowEditorPanel.js +42 -34
  6. package/frontend/js/store.js +14 -2
  7. package/frontend/js/utils/jsonPreview.js +31 -0
  8. package/frontend/js/utils/rowEditorValues.js +41 -0
  9. package/frontend/js/utils/tableScrollState.js +39 -0
  10. package/frontend/js/views/charts.js +8 -0
  11. package/frontend/js/views/data.js +4 -1
  12. package/frontend/js/views/editor.js +2 -0
  13. package/frontend/styles/components.css +6 -0
  14. package/frontend/styles/tailwind.generated.css +1 -1
  15. package/package.json +1 -1
  16. package/server/middleware/localRequestSecurity.js +84 -0
  17. package/server/routes/connections.js +18 -1
  18. package/server/server.js +12 -1
  19. package/server/services/nativeFileDialogService.js +168 -0
  20. package/server/services/sqlite/dataBrowserService.js +0 -4
  21. package/server/services/sqlite/exportService.js +5 -1
  22. package/server/services/sqlite/sqlExecutor.js +51 -2
  23. package/server/utils/sqliteTypes.js +17 -8
  24. package/tests/connections-file-dialog-route.test.js +46 -0
  25. package/tests/copy-column-modal.test.js +14 -0
  26. package/tests/export-blob.test.js +46 -0
  27. package/tests/json-preview.test.js +49 -0
  28. package/tests/local-request-security.test.js +85 -0
  29. package/tests/native-file-dialog.test.js +78 -0
  30. package/tests/query-results-truncation.test.js +20 -0
  31. package/tests/row-editor-null-values.test.js +131 -0
  32. package/tests/sql-identifier-safety.test.js +9 -3
  33. package/tests/sql-result-limit.test.js +66 -0
  34. package/tests/table-scroll-state.test.js +70 -0
@@ -86,6 +86,12 @@ export function createConnection(payload) {
86
86
  });
87
87
  }
88
88
 
89
+ export function chooseCreateDatabasePath() {
90
+ return request("/api/connections/choose-create-path", {
91
+ method: "POST",
92
+ });
93
+ }
94
+
89
95
  export function importSql(payload) {
90
96
  return request("/api/connections/import-sql", {
91
97
  method: "POST",
@@ -24,6 +24,7 @@ import { renderTopNav } from './components/topNav.js';
24
24
  import { createRouter } from './router.js';
25
25
  import {
26
26
  createActiveConnectionBackup,
27
+ chooseCreateDatabasePath,
27
28
  createDocument,
28
29
  createDocumentFromMarkdownExport,
29
30
  clearCurrentQuery,
@@ -178,10 +179,19 @@ import {
178
179
  stringifyRowEditorJson,
179
180
  } from './utils/rowEditorJson.js';
180
181
  import { getTimestampPreviewForField } from './utils/timestampPreview.js';
182
+ import {
183
+ buildRowEditorSubmittedValues,
184
+ getRowEditorValueState,
185
+ getRowEditorValueStateLabel,
186
+ } from './utils/rowEditorValues.js';
181
187
  import {
182
188
  formatTextCellCharacterCount,
183
189
  getTextCellCharacterCount,
184
190
  } from './utils/textCellStats.js';
191
+ import {
192
+ captureTableHorizontalScrollState,
193
+ restoreTableHorizontalScrollState,
194
+ } from './utils/tableScrollState.js';
185
195
 
186
196
  const appRoot = document.querySelector('#app');
187
197
 
@@ -1257,6 +1267,10 @@ function renderApp(state) {
1257
1267
 
1258
1268
  const focusedInput = captureFocusedInputState();
1259
1269
  const queryHistoryScrollState = captureQueryHistoryScrollState();
1270
+ const tableHorizontalScrollState = captureTableHorizontalScrollState({
1271
+ routeName: lastRenderedRouteName,
1272
+ scrollNodes: shellRefs.view.querySelectorAll('[data-table-horizontal-scroll]'),
1273
+ });
1260
1274
  const isEnteringNewTableDesignerRoute =
1261
1275
  state.route.name === 'tableDesigner' && state.route.params?.isNew && previousRoutePath !== state.route.path;
1262
1276
 
@@ -1329,6 +1343,14 @@ function renderApp(state) {
1329
1343
  restoreQueryHistoryScrollState(queryHistoryScrollState);
1330
1344
  }
1331
1345
 
1346
+ if (mainChanged && !mainPatched) {
1347
+ restoreTableHorizontalScrollState({
1348
+ snapshot: tableHorizontalScrollState,
1349
+ routeName: state.route.name,
1350
+ scrollNodes: shellRefs.view.querySelectorAll('[data-table-horizontal-scroll]'),
1351
+ });
1352
+ }
1353
+
1332
1354
  if (pendingQueryEditorFocus && (state.route.name === 'editor' || state.route.name === 'editorResults')) {
1333
1355
  if (focusQueryEditorInput()) {
1334
1356
  pendingQueryEditorFocus = false;
@@ -1572,6 +1594,53 @@ function syncRowEditorFilePathPreview(inputNode) {
1572
1594
  previewNode.hidden = false;
1573
1595
  }
1574
1596
 
1597
+ function getRowEditorValueStateClassName(state) {
1598
+ if (state === 'null') {
1599
+ return 'border px-2 py-1 text-[9px] border-primary-container/35 bg-primary-container/15 text-primary-container';
1600
+ }
1601
+
1602
+ if (state === 'empty') {
1603
+ return 'border px-2 py-1 text-[9px] border-outline-variant/35 bg-surface-container-high text-on-surface-variant';
1604
+ }
1605
+
1606
+ return 'border px-2 py-1 text-[9px] border-outline-variant/20 bg-surface-container text-on-surface-variant';
1607
+ }
1608
+
1609
+ function syncRowEditorValueState(controlNode) {
1610
+ const fieldNode = controlNode.closest('[data-row-editor-field]');
1611
+ const valueInput = fieldNode?.querySelector('[data-row-editor-value-source]');
1612
+ const stateBadge = fieldNode?.querySelector('[data-row-editor-value-state]');
1613
+
1614
+ if (!fieldNode || !valueInput || !stateBadge) {
1615
+ return;
1616
+ }
1617
+
1618
+ const valueState = getRowEditorValueState(valueInput.value);
1619
+
1620
+ valueInput.dataset.rowEditorDirty = 'true';
1621
+ stateBadge.dataset.valueState = valueState;
1622
+ stateBadge.className = getRowEditorValueStateClassName(valueState);
1623
+ stateBadge.textContent = getRowEditorValueStateLabel(valueState);
1624
+
1625
+ syncRowEditorTimestampPreview(valueInput);
1626
+ syncRowEditorFilePathPreview(valueInput);
1627
+ syncRowEditorCharacterCount(valueInput);
1628
+ }
1629
+
1630
+ function getRowEditorFieldMetadata(form) {
1631
+ return Object.fromEntries(
1632
+ Array.from(form.elements)
1633
+ .filter(element => element.name?.startsWith('field:'))
1634
+ .map(element => [
1635
+ element.name.slice('field:'.length),
1636
+ {
1637
+ initialState: element.closest('[data-row-editor-field]')?.dataset?.rowEditorInitialState,
1638
+ dirty: element.dataset.rowEditorDirty === 'true',
1639
+ },
1640
+ ]),
1641
+ );
1642
+ }
1643
+
1575
1644
  function closeCopyColumnMenus(exceptMenu = null) {
1576
1645
  document.querySelectorAll('[data-copy-column-menu][open]').forEach(menu => {
1577
1646
  if (menu !== exceptMenu && menu instanceof HTMLDetailsElement) {
@@ -1732,21 +1801,24 @@ function applyChangedRowEditorFormValues(rowObject) {
1732
1801
 
1733
1802
  const nextRowObject = { ...rowObject };
1734
1803
  const formData = new FormData(form);
1804
+ const submittedValues = buildRowEditorSubmittedValues(formData, getRowEditorFieldMetadata(form));
1735
1805
 
1736
- for (const [key, value] of formData.entries()) {
1737
- if (!key.startsWith('field:')) {
1738
- continue;
1739
- }
1740
-
1806
+ for (const [fieldName, value] of Object.entries(submittedValues)) {
1807
+ const key = `field:${fieldName}`;
1741
1808
  const control = Array.from(form.elements).find(element => element.name === key);
1742
1809
  const initialValue = control?.dataset?.rowEditorInitialValue;
1743
- const currentValue = String(value ?? '');
1810
+ const initialState = control?.closest('[data-row-editor-field]')?.dataset?.rowEditorInitialState;
1811
+ const currentState = getRowEditorValueState(value);
1812
+ const currentValue = value === null ? null : String(value);
1744
1813
 
1745
- if (initialValue !== undefined && currentValue === initialValue) {
1814
+ if (
1815
+ initialState === currentState &&
1816
+ (currentState === 'null' || (initialValue !== undefined && currentValue === initialValue))
1817
+ ) {
1746
1818
  continue;
1747
1819
  }
1748
1820
 
1749
- nextRowObject[key.slice('field:'.length)] = currentValue;
1821
+ nextRowObject[fieldName] = currentValue;
1750
1822
  }
1751
1823
 
1752
1824
  return nextRowObject;
@@ -2109,6 +2181,33 @@ async function handleAction(actionNode) {
2109
2181
  case 'edit-connection':
2110
2182
  openEditConnectionModal(actionNode.dataset.connectionId);
2111
2183
  return;
2184
+ case 'choose-create-database-path': {
2185
+ const labelNode = actionNode.querySelector('[data-create-database-path-button-label]');
2186
+
2187
+ actionNode.setAttribute('disabled', '');
2188
+ if (labelNode) {
2189
+ labelNode.textContent = 'Choosing...';
2190
+ }
2191
+
2192
+ const selectedPath = await chooseCreateDatabasePath();
2193
+ const pathInput = document.querySelector(
2194
+ '[data-form="create-connection"] [data-create-database-path]',
2195
+ );
2196
+
2197
+ if (selectedPath && pathInput instanceof HTMLInputElement) {
2198
+ pathInput.value = selectedPath;
2199
+ pathInput.focus({ preventScroll: true });
2200
+ pathInput.setSelectionRange(pathInput.value.length, pathInput.value.length);
2201
+ }
2202
+
2203
+ if (actionNode.isConnected) {
2204
+ actionNode.removeAttribute('disabled');
2205
+ if (labelNode) {
2206
+ labelNode.textContent = 'Browse...';
2207
+ }
2208
+ }
2209
+ return;
2210
+ }
2112
2211
  case 'close-modal':
2113
2212
  closeModal();
2114
2213
  return;
@@ -2689,6 +2788,7 @@ document.addEventListener('keydown', event => {
2689
2788
 
2690
2789
  document.addEventListener('input', event => {
2691
2790
  const target = event.target instanceof Element ? event.target : null;
2791
+ const valueInput = target?.closest('[data-row-editor-value-source]');
2692
2792
  const timestampInput = target?.closest('[data-row-editor-timestamp-source]');
2693
2793
  const textCellInput = target?.closest('[data-row-editor-text-source]');
2694
2794
 
@@ -2701,6 +2801,10 @@ document.addEventListener('input', event => {
2701
2801
  syncRowEditorCharacterCount(textCellInput);
2702
2802
  }
2703
2803
 
2804
+ if (valueInput) {
2805
+ syncRowEditorValueState(valueInput);
2806
+ }
2807
+
2704
2808
  const bindNode = event.target.closest('[data-bind]');
2705
2809
 
2706
2810
  if (!bindNode) {
@@ -2845,6 +2949,7 @@ document.addEventListener(
2845
2949
 
2846
2950
  document.addEventListener('change', event => {
2847
2951
  const target = event.target instanceof Element ? event.target : null;
2952
+ const valueControl = target?.closest('[data-row-editor-value-source]');
2848
2953
  const timestampInput = target?.closest('[data-row-editor-timestamp-source]');
2849
2954
  const textCellInput = target?.closest('[data-row-editor-text-source]');
2850
2955
 
@@ -2857,6 +2962,10 @@ document.addEventListener('change', event => {
2857
2962
  syncRowEditorCharacterCount(textCellInput);
2858
2963
  }
2859
2964
 
2965
+ if (valueControl) {
2966
+ syncRowEditorValueState(valueControl);
2967
+ }
2968
+
2860
2969
  const bindNode = event.target.closest('[data-bind]');
2861
2970
 
2862
2971
  if (!bindNode) {
@@ -3126,15 +3235,7 @@ document.addEventListener('submit', async event => {
3126
3235
  await submitCopyColumnModal(formData);
3127
3236
  return;
3128
3237
  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
- }
3238
+ const values = buildRowEditorSubmittedValues(formData, getRowEditorFieldMetadata(form));
3138
3239
 
3139
3240
  let rowIdentity = null;
3140
3241
  const rawRowIdentity = String(formData.get('rowIdentity') ?? '').trim();
@@ -3151,15 +3252,7 @@ document.addEventListener('submit', async event => {
3151
3252
  return;
3152
3253
  }
3153
3254
  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
- }
3255
+ const values = buildRowEditorSubmittedValues(formData, getRowEditorFieldMetadata(form));
3163
3256
 
3164
3257
  await openEditorRowUpdatePreview(String(formData.get('rowIndex') ?? ''), values);
3165
3258
  return;
@@ -264,14 +264,34 @@ function renderEditConnectionForm(modal) {
264
264
  `;
265
265
  }
266
266
 
267
- function renderCreateDatabaseForm(modal) {
267
+ export function renderCreateDatabaseForm(modal) {
268
268
  return `
269
269
  <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
- })}
270
+ <label class="block space-y-2">
271
+ <span class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
272
+ New SQLite File Path
273
+ </span>
274
+ <span class="flex items-stretch gap-2">
275
+ <input
276
+ class="control-input min-w-0 flex-1 border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
277
+ data-create-database-path
278
+ name="path"
279
+ placeholder="/absolute/path/to/new-database.sqlite"
280
+ type="text"
281
+ />
282
+ <button
283
+ class="standard-button flex-none"
284
+ data-action="choose-create-database-path"
285
+ type="button"
286
+ >
287
+ <span class="material-symbols-outlined text-sm">folder_open</span>
288
+ <span data-create-database-path-button-label>Browse...</span>
289
+ </button>
290
+ </span>
291
+ <span class="block text-[11px] leading-5 text-on-surface-variant/60">
292
+ Choose a folder and filename, or enter an absolute path manually.
293
+ </span>
294
+ </label>
275
295
  ${renderField({
276
296
  label: "Label",
277
297
  name: "label",
@@ -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({
@@ -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>
@@ -3490,6 +3490,16 @@ export async function submitCreateConnection(payload) {
3490
3490
  }
3491
3491
  }
3492
3492
 
3493
+ export async function chooseCreateDatabasePath() {
3494
+ try {
3495
+ const response = await api.chooseCreateDatabasePath();
3496
+ return response.data?.path ?? null;
3497
+ } catch (error) {
3498
+ pushToast(normalizeError(error).message || 'Native file dialog could not be opened.', 'alert');
3499
+ return null;
3500
+ }
3501
+ }
3502
+
3493
3503
  export async function submitImportSql(payload) {
3494
3504
  startModalSubmission();
3495
3505
 
@@ -5303,11 +5313,13 @@ export function getQueryMessages(snapshot = state) {
5303
5313
 
5304
5314
  return [
5305
5315
  ...snapshot.editor.result.statements.map(statement => ({
5306
- tone: statement.kind === 'resultSet' ? 'success' : inferStatusTone(statement.keyword),
5316
+ tone: statement.kind === 'resultSet' && statement.truncated ? 'warning' : statement.kind === 'resultSet' ? 'success' : inferStatusTone(statement.keyword),
5307
5317
  label: `${statement.keyword} #${statement.index + 1}`,
5308
5318
  value:
5309
5319
  statement.kind === 'resultSet'
5310
- ? `${statement.rowCount} row(s) returned.`
5320
+ ? statement.truncated
5321
+ ? `Showing the first ${statement.rowCount} row(s); the result was limited to ${statement.rowLimit}.`
5322
+ : `${statement.rowCount} row(s) returned.`
5311
5323
  : `${statement.changes} row(s) affected.`,
5312
5324
  })),
5313
5325
  ...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