sqlite-hub 0.12.0 → 0.17.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 (64) hide show
  1. package/README.md +118 -23
  2. package/bin/sqlite-hub.js +1041 -224
  3. package/frontend/index.html +1 -0
  4. package/frontend/js/api.js +32 -2
  5. package/frontend/js/app.js +989 -13
  6. package/frontend/js/components/modal.js +644 -15
  7. package/frontend/js/components/pageHeader.js +14 -16
  8. package/frontend/js/components/queryEditor.js +9 -1
  9. package/frontend/js/components/queryHistoryPanel.js +126 -131
  10. package/frontend/js/components/queryResults.js +79 -17
  11. package/frontend/js/components/rowEditorPanel.js +204 -10
  12. package/frontend/js/components/sidebar.js +102 -18
  13. package/frontend/js/components/tableDesignerEditor.js +69 -11
  14. package/frontend/js/router.js +8 -0
  15. package/frontend/js/store.js +868 -9
  16. package/frontend/js/utils/copyColumnExport.js +117 -0
  17. package/frontend/js/utils/exportFilenames.js +32 -0
  18. package/frontend/js/utils/filePathPreview.js +315 -0
  19. package/frontend/js/utils/format.js +1 -1
  20. package/frontend/js/utils/markdownDocuments.js +248 -0
  21. package/frontend/js/utils/rowEditorJson.js +65 -0
  22. package/frontend/js/utils/sqlFormatter.js +691 -0
  23. package/frontend/js/utils/tableDesigner.js +178 -6
  24. package/frontend/js/utils/textCellStats.js +20 -0
  25. package/frontend/js/utils/timestampPreview.js +264 -0
  26. package/frontend/js/views/charts.js +3 -1
  27. package/frontend/js/views/data.js +34 -2
  28. package/frontend/js/views/documents.js +300 -0
  29. package/frontend/js/views/editor.js +48 -1
  30. package/frontend/js/views/settings.js +39 -6
  31. package/frontend/js/views/structure.js +154 -212
  32. package/frontend/styles/base.css +6 -0
  33. package/frontend/styles/components.css +476 -2
  34. package/frontend/styles/structure-graph.css +0 -3
  35. package/frontend/styles/tailwind.generated.css +1 -1
  36. package/frontend/styles/tokens.css +1 -1
  37. package/frontend/styles/views.css +422 -0
  38. package/package.json +3 -3
  39. package/server/routes/documents.js +163 -0
  40. package/server/routes/settings.js +22 -6
  41. package/server/server.js +6 -0
  42. package/server/services/sqlite/introspection.js +10 -0
  43. package/server/services/sqlite/sqlExecutor.js +29 -0
  44. package/server/services/sqlite/tableDesigner/changeAnalysis.js +25 -1
  45. package/server/services/sqlite/tableDesigner/schemaMapping.js +105 -10
  46. package/server/services/sqlite/tableDesigner/validation.js +60 -2
  47. package/server/services/sqlite/tableDesignerService.js +1 -1
  48. package/server/services/storage/appStateStore.js +313 -0
  49. package/tests/check-constraint-options.test.js +14 -0
  50. package/tests/cli-args.test.js +100 -0
  51. package/tests/copy-column-modal.test.js +83 -0
  52. package/tests/database-documents.test.js +85 -0
  53. package/tests/export-filenames.test.js +34 -0
  54. package/tests/file-path-preview.test.js +165 -0
  55. package/tests/markdown-documents.test.js +79 -0
  56. package/tests/row-editor-json.test.js +82 -0
  57. package/tests/row-editor-timestamp-preview.test.js +192 -0
  58. package/tests/settings-metadata.test.js +16 -0
  59. package/tests/settings-view.test.js +32 -0
  60. package/tests/sql-formatter.test.js +173 -0
  61. package/tests/sql-highlight.test.js +38 -0
  62. package/tests/table-designer-v2-unique-constraints.test.js +78 -0
  63. package/tests/text-cell-stats.test.js +38 -0
  64. package/fill.js +0 -526
@@ -1,4 +1,17 @@
1
1
  import { escapeHtml } from "../utils/format.js";
2
+ import {
3
+ compactPathForDisplay,
4
+ detectFilePathValue,
5
+ getPathTypeLabel,
6
+ } from "../utils/filePathPreview.js";
7
+ import {
8
+ getTimestampPreviewForField,
9
+ isProtectedKeyColumn,
10
+ } from "../utils/timestampPreview.js";
11
+ import {
12
+ formatTextCellCharacterCount,
13
+ getTextCellCharacterCount,
14
+ } from "../utils/textCellStats.js";
2
15
 
3
16
  const URL_PATTERN = /^https?:\/\/[^\s<>"']+$/i;
4
17
 
@@ -87,6 +100,19 @@ function withCheckBadge(badges = [], allowedValues = []) {
87
100
  return hasCheckBadge ? badges : [...badges, { label: "CHECK", tone: "check" }];
88
101
  }
89
102
 
103
+ function withFilePathBadge(badges = [], filePathPreview) {
104
+ if (!filePathPreview) {
105
+ return badges;
106
+ }
107
+
108
+ const hasPathBadge = badges.some((badge) => {
109
+ const label = typeof badge === "object" ? badge.label : badge;
110
+ return String(label ?? "").toUpperCase() === "PATH";
111
+ });
112
+
113
+ return hasPathBadge ? badges : [...badges, { label: "PATH", tone: "filepath" }];
114
+ }
115
+
90
116
  function renderOpenUrlButton(url) {
91
117
  if (!url) {
92
118
  return "";
@@ -131,6 +157,9 @@ function renderAllowedValuesSelect(field, allowedValues) {
131
157
  return `
132
158
  <select
133
159
  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"
160
+ data-row-editor-initial-value="${escapeHtml(currentValue)}"
161
+ data-row-editor-text-source
162
+ data-row-editor-timestamp-source
134
163
  name="field:${escapeHtml(field.name)}"
135
164
  >
136
165
  ${options}
@@ -138,6 +167,117 @@ function renderAllowedValuesSelect(field, allowedValues) {
138
167
  `;
139
168
  }
140
169
 
170
+ function getFieldColumnName(field = {}) {
171
+ return String(field.sourceColumn ?? field.name ?? "");
172
+ }
173
+
174
+ function getFieldRawValue(field = {}) {
175
+ return field.rawValue === undefined ? field.value : field.rawValue;
176
+ }
177
+
178
+ function getFieldTimestampPreview(field = {}, tableMeta = {}) {
179
+ return getTimestampPreviewForField({
180
+ columnName: getFieldColumnName(field),
181
+ value: getFieldRawValue(field),
182
+ tableMeta,
183
+ });
184
+ }
185
+
186
+ function renderTimestampPreview(field = {}, tableMeta = {}) {
187
+ const preview = getFieldTimestampPreview(field, tableMeta);
188
+
189
+ if (preview.kind === "protected-key") {
190
+ return `
191
+ <div class="text-[11px] text-on-surface-variant/55">
192
+ Key column - shown as raw value
193
+ </div>
194
+ `;
195
+ }
196
+
197
+ return `
198
+ <div
199
+ class="text-[11px] text-on-surface-variant/70"
200
+ data-row-editor-timestamp-preview
201
+ ${preview.kind === "timestamp" ? "" : "hidden"}
202
+ >
203
+ ${
204
+ preview.kind === "timestamp"
205
+ ? `Interpretiert als Datum: ${escapeHtml(preview.formatted)}`
206
+ : ""
207
+ }
208
+ </div>
209
+ `;
210
+ }
211
+
212
+ function renderTextCharacterCountBadge(value) {
213
+ const count = getTextCellCharacterCount(value);
214
+
215
+ return `
216
+ <span
217
+ class="border px-2 py-1 text-[9px] ${getFieldBadgeClassName("char-count")}"
218
+ data-row-editor-char-count
219
+ ${count === null ? "hidden" : ""}
220
+ >
221
+ ${count === null ? "" : escapeHtml(formatTextCellCharacterCount(count))}
222
+ </span>
223
+ `;
224
+ }
225
+
226
+ function renderFieldBadgesWithCharacterCount(badges = [], value) {
227
+ const [typeBadge, ...remainingBadges] = badges;
228
+
229
+ return [
230
+ typeBadge ? renderFieldBadge(typeBadge) : "",
231
+ renderTextCharacterCountBadge(value),
232
+ ...remainingBadges.map((badge) => renderFieldBadge(badge)),
233
+ ].join("");
234
+ }
235
+
236
+ function getFieldFilePathPreview(field = {}, tableMeta = {}) {
237
+ return detectFilePathValue(getFieldRawValue(field), getFieldColumnName(field), tableMeta);
238
+ }
239
+
240
+ function renderFilePathPreview(field = {}, tableMeta = {}) {
241
+ if (isProtectedKeyColumn(getFieldColumnName(field), tableMeta)) {
242
+ return "";
243
+ }
244
+
245
+ const preview = getFieldFilePathPreview(field, tableMeta);
246
+
247
+ return `
248
+ <div
249
+ class="border border-outline-variant/10 bg-surface-container px-4 py-4 text-xs text-on-surface"
250
+ data-row-editor-filepath-preview
251
+ ${preview ? "" : "hidden"}
252
+ >
253
+ <div class="flex items-center gap-2 text-[10px] font-mono uppercase tracking-[0.18em] text-primary-container/75">
254
+ <span class="material-symbols-outlined text-sm">folder</span>
255
+ Interpretiert als Filepath
256
+ </div>
257
+ <div class="mt-3 grid gap-2 font-mono text-[11px] leading-5">
258
+ <div>
259
+ <span class="text-on-surface-variant/55">Filename:</span>
260
+ <span data-row-editor-filepath-filename>${escapeHtml(preview?.fileName ?? "N/A")}</span>
261
+ </div>
262
+ <div data-row-editor-filepath-directory-row ${preview?.directory ? "" : "hidden"}>
263
+ <span class="text-on-surface-variant/55">Directory:</span>
264
+ <span data-row-editor-filepath-directory title="${escapeHtml(preview?.directory ?? "")}">
265
+ ${escapeHtml(preview?.directory ? compactPathForDisplay(preview.directory, 72) : "")}
266
+ </span>
267
+ </div>
268
+ <div>
269
+ <span class="text-on-surface-variant/55">Extension:</span>
270
+ <span data-row-editor-filepath-extension>${escapeHtml(preview?.extension ?? "N/A")}</span>
271
+ </div>
272
+ <div>
273
+ <span class="text-on-surface-variant/55">Type:</span>
274
+ <span data-row-editor-filepath-type>${escapeHtml(preview ? getPathTypeLabel(preview.pathType) : "N/A")}</span>
275
+ </div>
276
+ </div>
277
+ </div>
278
+ `;
279
+ }
280
+
141
281
  function renderJsonViewer(prettyJson, title = "JSON Viewer") {
142
282
  return `
143
283
  <div class="border border-outline-variant/10 bg-surface-container px-4 py-4">
@@ -151,44 +291,66 @@ function renderJsonViewer(prettyJson, title = "JSON Viewer") {
151
291
  `;
152
292
  }
153
293
 
154
- function renderReadonlyField(label, value) {
294
+ function renderReadonlyField(field = {}, tableMeta = {}) {
295
+ const label = field.label ?? field.name;
296
+ const value = field.value;
297
+ const rawValue = getFieldRawValue(field);
298
+ const columnName = getFieldColumnName(field);
299
+ const protectedKeyColumn = isProtectedKeyColumn(columnName, tableMeta);
300
+ const filePathPreview = getFieldFilePathPreview({ ...field, rawValue }, tableMeta);
155
301
  const url = getUrlValue(value);
156
- const badges = withUrlBadge(Array.isArray(label?.badges) ? label.badges : [], url);
302
+ const badges = withFilePathBadge(withUrlBadge(Array.isArray(label?.badges) ? label.badges : [], url), filePathPreview);
157
303
  const displayLabel = typeof label === "object" ? label.label : label;
158
304
  const jsonPreview = getJsonPreview(value);
159
305
 
160
306
  return `
161
- <div class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-3" ${
307
+ <div
308
+ class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-3"
309
+ data-row-editor-column-name="${escapeHtml(columnName)}"
310
+ data-row-editor-field
311
+ data-row-editor-protected-key="${protectedKeyColumn ? "true" : "false"}"
312
+ ${
162
313
  url ? "data-row-editor-url-field" : ""
163
314
  }>
164
315
  <div class="flex flex-wrap items-center gap-2 text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">
165
316
  <span>${escapeHtml(displayLabel)}</span>
166
- ${badges.map((badge) => renderFieldBadge(badge)).join("")}
317
+ ${renderFieldBadgesWithCharacterCount(badges, rawValue)}
167
318
  </div>
168
319
  ${
169
320
  jsonPreview
170
321
  ? `<div class="mt-2">${renderJsonViewer(jsonPreview)}</div>`
171
322
  : `<div class="mt-2 text-sm text-on-surface">${escapeHtml(value)}</div>`
172
323
  }
324
+ ${renderTimestampPreview({ ...field, rawValue }, tableMeta)}
325
+ ${renderFilePathPreview({ ...field, rawValue }, tableMeta)}
173
326
  ${renderOpenUrlButton(url)}
174
327
  </div>
175
328
  `;
176
329
  }
177
330
 
178
- function renderEditableField(field) {
331
+ function renderEditableField(field, tableMeta = {}) {
332
+ const columnName = getFieldColumnName(field);
333
+ const protectedKeyColumn = isProtectedKeyColumn(columnName, tableMeta);
179
334
  const url = getUrlValue(field.value);
180
335
  const allowedValues = getAllowedValues(field);
336
+ const filePathPreview = getFieldFilePathPreview(field, tableMeta);
181
337
  const baseBadges = withCheckBadge(Array.isArray(field.badges) ? field.badges : [], allowedValues);
182
- const badges = withUrlBadge(baseBadges, url);
338
+ const badges = withFilePathBadge(withUrlBadge(baseBadges, url), filePathPreview);
183
339
  const jsonPreview = getJsonPreview(field.value);
184
340
  const inputType = field.inputType === "number" ? "number" : "text";
185
341
  const numberStep = field.numberStep === "1" ? "1" : "any";
186
342
 
187
343
  return `
188
- <div class="block space-y-2" ${url ? "data-row-editor-url-field" : ""}>
344
+ <div
345
+ class="block space-y-2"
346
+ data-row-editor-column-name="${escapeHtml(columnName)}"
347
+ data-row-editor-field
348
+ data-row-editor-protected-key="${protectedKeyColumn ? "true" : "false"}"
349
+ ${url ? "data-row-editor-url-field" : ""}
350
+ >
189
351
  <span class="flex flex-wrap items-center gap-2 text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">
190
352
  <span>${escapeHtml(field.label ?? field.name)}</span>
191
- ${badges.map((badge) => renderFieldBadge(badge)).join("")}
353
+ ${renderFieldBadgesWithCharacterCount(badges, inputType === "number" ? null : field.value ?? "")}
192
354
  </span>
193
355
  ${
194
356
  jsonPreview
@@ -202,6 +364,8 @@ function renderEditableField(field) {
202
364
  ? `
203
365
  <input
204
366
  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
+ data-row-editor-initial-value="${escapeHtml(field.value ?? "")}"
368
+ data-row-editor-timestamp-source
205
369
  name="field:${escapeHtml(field.name)}"
206
370
  step="${escapeHtml(numberStep)}"
207
371
  type="number"
@@ -212,6 +376,9 @@ function renderEditableField(field) {
212
376
  ? `
213
377
  <input
214
378
  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"
379
+ data-row-editor-initial-value="${escapeHtml(field.value ?? "")}"
380
+ data-row-editor-text-source
381
+ data-row-editor-timestamp-source
215
382
  name="field:${escapeHtml(field.name)}"
216
383
  data-row-editor-url-input
217
384
  spellcheck="false"
@@ -225,11 +392,16 @@ function renderEditableField(field) {
225
392
  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 ${
226
393
  jsonPreview ? "min-h-[14rem] font-mono leading-6" : "min-h-[56px]"
227
394
  }"
395
+ data-row-editor-initial-value="${escapeHtml(field.value ?? "")}"
396
+ data-row-editor-text-source
397
+ data-row-editor-timestamp-source
228
398
  name="field:${escapeHtml(field.name)}"
229
399
  spellcheck="false"
230
400
  >${escapeHtml(field.value ?? "")}</textarea>
231
401
  `
232
402
  }
403
+ ${renderTimestampPreview(field, tableMeta)}
404
+ ${renderFilePathPreview(field, tableMeta)}
233
405
  </div>
234
406
  `;
235
407
  }
@@ -251,6 +423,14 @@ function getFieldBadgeClassName(tone) {
251
423
  return "border-tertiary-fixed-dim/35 bg-tertiary-fixed-dim/15 text-tertiary-fixed-dim";
252
424
  }
253
425
 
426
+ if (tone === "filepath") {
427
+ return "border-outline-variant/30 bg-surface-container-high text-on-surface";
428
+ }
429
+
430
+ if (tone === "char-count") {
431
+ return "border-outline-variant/25 bg-surface-container text-on-surface-variant";
432
+ }
433
+
254
434
  return "border-outline-variant/20 bg-surface-container text-on-surface-variant";
255
435
  }
256
436
 
@@ -274,11 +454,13 @@ export function renderRowEditorPanel({
274
454
  hiddenFields = [],
275
455
  editableFields = [],
276
456
  readonlyFields = [],
457
+ tableMeta = {},
277
458
  disabledMessage = "",
278
459
  saveError = null,
279
460
  saving = false,
280
461
  deleting = false,
281
462
  reloadAction = "",
463
+ jsonActionsEnabled = false,
282
464
  submitLabel = "Save",
283
465
  deleteAction = "",
284
466
  deleteRowIndex = null,
@@ -297,6 +479,18 @@ export function renderRowEditorPanel({
297
479
  '" type="button">Reload</button>',
298
480
  ].join("")
299
481
  : "",
482
+ jsonActionsEnabled
483
+ ? [
484
+ '<button class="standard-button" data-action="copy-row-editor-json" type="button">',
485
+ '<span class="material-symbols-outlined text-sm">content_copy</span>',
486
+ "Copy as JSON",
487
+ "</button>",
488
+ '<button class="standard-button" data-action="export-row-editor-json" type="button">',
489
+ '<span class="material-symbols-outlined text-sm">download</span>',
490
+ "Export as JSON",
491
+ "</button>",
492
+ ].join("")
493
+ : "",
300
494
  canSubmit
301
495
  ? [
302
496
  '<button class="standard-button" form="',
@@ -393,7 +587,7 @@ export function renderRowEditorPanel({
393
587
  ? `
394
588
  <div class="space-y-4">
395
589
  ${editableFields
396
- .map((field) => renderEditableField(field))
590
+ .map((field) => renderEditableField(field, tableMeta))
397
591
  .join("")}
398
592
  </div>
399
593
  `
@@ -425,7 +619,7 @@ export function renderRowEditorPanel({
425
619
  </div>
426
620
  <div class="space-y-3">
427
621
  ${readonlyFields
428
- .map((field) => renderReadonlyField(field.label ?? field.name, field.value))
622
+ .map((field) => renderReadonlyField(field, tableMeta))
429
623
  .join("")}
430
624
  </div>
431
625
  </div>
@@ -1,4 +1,4 @@
1
- import { escapeHtml } from '../utils/format.js';
1
+ import { escapeHtml, formatCompactDateTime, truncateMiddle } from '../utils/format.js';
2
2
  import { renderConnectionLogo } from './connectionLogo.js';
3
3
 
4
4
  const sidebarItems = [
@@ -8,6 +8,7 @@ const sidebarItems = [
8
8
  { label: 'Structure', href: '#/structure', key: 'structure', icon: 'account_tree' },
9
9
  { label: 'SQL_Editor', href: '#/editor', key: 'editor', icon: 'terminal' },
10
10
  { label: 'Charts', href: '#/charts', key: 'charts', icon: 'bar_chart' },
11
+ { label: 'Documents', href: '#/documents', key: 'documents', icon: 'description' },
11
12
  { label: 'Table_Designer', href: '#/table-designer', key: 'tableDesigner', icon: 'table_chart' },
12
13
  {
13
14
  label: 'MEDIA_TAGGING',
@@ -37,9 +38,107 @@ function getActiveSidebarKey(routeName) {
37
38
  return routeName;
38
39
  }
39
40
 
41
+ function getConnectionTimeValue(connection) {
42
+ const value = connection?.lastOpenedAt ?? connection?.lastModifiedAt ?? 0;
43
+ const time = new Date(value).getTime();
44
+ return Number.isFinite(time) ? time : 0;
45
+ }
46
+
47
+ function getQuickPickConnections(state) {
48
+ const byId = new Map();
49
+
50
+ [state.connections.active, ...(state.connections.recent ?? [])].forEach(connection => {
51
+ if (!connection?.id || byId.has(connection.id)) {
52
+ return;
53
+ }
54
+
55
+ byId.set(connection.id, connection);
56
+ });
57
+
58
+ return [...byId.values()]
59
+ .sort((left, right) => getConnectionTimeValue(right) - getConnectionTimeValue(left))
60
+ .slice(0, 5);
61
+ }
62
+
63
+ function renderQuickPickCard(connection, activeConnectionId) {
64
+ const isActive = connection.id === activeConnectionId;
65
+ const label = connection.label || 'Untitled database';
66
+ const path = connection.path || '';
67
+
68
+ return `
69
+ <button
70
+ class="sidebar-db-picker-card ${isActive ? 'is-active' : ''}"
71
+ data-action="select-connection"
72
+ data-connection-id="${escapeHtml(connection.id)}"
73
+ type="button"
74
+ >
75
+ ${renderConnectionLogo(connection, {
76
+ containerClass: 'sidebar-db-picker-card__mark overflow-hidden',
77
+ imageClassName: 'h-full w-full object-cover',
78
+ iconClassName: 'text-[15px]',
79
+ icon: 'database',
80
+ })}
81
+ <span class="sidebar-db-picker-card__body">
82
+ <span class="sidebar-db-picker-card__label" title="${escapeHtml(label)}">
83
+ ${escapeHtml(label)}
84
+ </span>
85
+ <span class="sidebar-db-picker-card__path" title="${escapeHtml(path)}">
86
+ ${escapeHtml(path ? truncateMiddle(path, 34) : 'path:n/a')}
87
+ </span>
88
+ <span class="sidebar-db-picker-card__meta">
89
+ ${isActive ? 'ACTIVE' : escapeHtml(formatCompactDateTime(connection.lastOpenedAt))}
90
+ ${connection.readOnly ? ' // READ_ONLY' : ''}
91
+ </span>
92
+ </span>
93
+ </button>
94
+ `;
95
+ }
96
+
97
+ function renderConnectionQuickPicker(state) {
98
+ const activeConnection = state.connections.active;
99
+ const quickPicks = getQuickPickConnections(state);
100
+ const hasQuickPicks = quickPicks.length > 0;
101
+
102
+ return `
103
+ <details class="sidebar-db-picker">
104
+ <summary class="sidebar-footer-card" title="Switch active database">
105
+ ${renderConnectionLogo(activeConnection, {
106
+ containerClass: 'sidebar-footer-mark overflow-hidden bg-primary-container text-on-primary',
107
+ imageClassName: 'h-full w-full object-cover',
108
+ iconClassName: 'text-[15px]',
109
+ icon: 'memory',
110
+ })}
111
+ <span class="min-w-0 flex-1">
112
+ <span class="block truncate text-[10px] font-bold text-on-surface">
113
+ ${escapeHtml(activeConnection?.label ?? 'NO_ACTIVE_DATABASE')}
114
+ </span>
115
+ <span class="block text-[8px] text-on-surface-variant/60">
116
+ ${activeConnection?.readOnly ? 'READ_ONLY' : activeConnection ? 'READ_WRITE' : 'SELECT_DATABASE'}
117
+ </span>
118
+ </span>
119
+ <span class="material-symbols-outlined sidebar-db-picker__chevron" aria-hidden="true">expand_less</span>
120
+ </summary>
121
+ <div class="sidebar-db-picker__panel">
122
+ <div class="sidebar-db-picker__header">
123
+ <span>Quick Picks</span>
124
+ <span>${escapeHtml(String(quickPicks.length))}/5</span>
125
+ </div>
126
+ ${
127
+ hasQuickPicks
128
+ ? `<div class="sidebar-db-picker__list">
129
+ ${quickPicks
130
+ .map(connection => renderQuickPickCard(connection, activeConnection?.id))
131
+ .join('')}
132
+ </div>`
133
+ : `<div class="sidebar-db-picker__empty">No recent databases</div>`
134
+ }
135
+ </div>
136
+ </details>
137
+ `;
138
+ }
139
+
40
140
  export function renderSidebar(state) {
41
141
  const activeKey = getActiveSidebarKey(state.route.name);
42
- const activeConnection = state.connections.active;
43
142
  const expandedKey = activeKey === 'mediaTagging' ? 'mediaTagging' : null;
44
143
 
45
144
  return `
@@ -86,22 +185,7 @@ export function renderSidebar(state) {
86
185
  .join('')}
87
186
  </nav>
88
187
  <div class="sidebar-footer">
89
- <div class="sidebar-footer-card">
90
- ${renderConnectionLogo(activeConnection, {
91
- containerClass: 'sidebar-footer-mark overflow-hidden bg-primary-container text-on-primary',
92
- imageClassName: 'h-full w-full object-cover',
93
- iconClassName: 'text-[15px]',
94
- icon: 'memory',
95
- })}
96
- <div class="min-w-0">
97
- <p class="truncate text-[10px] font-bold text-on-surface">
98
- ${escapeHtml(activeConnection?.label ?? 'NO_ACTIVE_DATABASE')}
99
- </p>
100
- <p class="text-[8px] text-on-surface-variant/60">
101
- ${activeConnection?.readOnly ? 'READ_ONLY' : 'READ_WRITE'}
102
- </p>
103
- </div>
104
- </div>
188
+ ${renderConnectionQuickPicker(state)}
105
189
  </div>
106
190
  `;
107
191
  }
@@ -81,6 +81,54 @@ export function renderTableDesignerReferenceColumnOptions(
81
81
  ].join("");
82
82
  }
83
83
 
84
+ function normalizeConstraintColumnName(name) {
85
+ return String(name ?? "").trim().toLowerCase();
86
+ }
87
+
88
+ function constraintIncludesColumn(constraint, columnName) {
89
+ const normalizedColumn = normalizeConstraintColumnName(columnName);
90
+
91
+ if (!normalizedColumn) {
92
+ return false;
93
+ }
94
+
95
+ return (constraint.columns ?? []).some(
96
+ (constraintColumn) => normalizeConstraintColumnName(constraintColumn.name) === normalizedColumn
97
+ );
98
+ }
99
+
100
+ function countColumnCheckConstraints(column, draft) {
101
+ if (draft.mode !== "edit") {
102
+ return 0;
103
+ }
104
+
105
+ return (draft.checkConstraints ?? []).filter((constraint) =>
106
+ constraintIncludesColumn(constraint, column.name)
107
+ ).length;
108
+ }
109
+
110
+ function renderColumnCheckAction(column, draft) {
111
+ const checkCount = countColumnCheckConstraints(column, draft);
112
+ const columnName = column.name || "Unnamed column";
113
+
114
+ return `
115
+ <button
116
+ aria-label="Checks for ${escapeHtml(columnName)}"
117
+ class="standard-button table-designer-row-check-button"
118
+ data-action="open-modal"
119
+ data-column-id="${escapeHtml(column.id)}"
120
+ data-column-name="${escapeHtml(column.name)}"
121
+ data-modal="table-designer-constraints"
122
+ title="Checks for ${escapeHtml(columnName)}"
123
+ type="button"
124
+ >
125
+ <span class="material-symbols-outlined text-base">fact_check</span>
126
+ <span>Checks</span>
127
+ ${checkCount ? `<span class="status-badge status-badge--muted">${escapeHtml(String(checkCount))}</span>` : ""}
128
+ </button>
129
+ `;
130
+ }
131
+
84
132
  function renderColumnRow(column, draft, catalogTables) {
85
133
  const typeOptions = draft.supportedTypes
86
134
  .map((type) =>
@@ -162,10 +210,13 @@ function renderColumnRow(column, draft, catalogTables) {
162
210
  '" data-field="referencesColumn">',
163
211
  referenceColumnOptions,
164
212
  "</select>",
213
+ '<div class="table-designer-row-actions">',
214
+ renderColumnCheckAction(column, draft),
165
215
  '<button class="delete-button" data-action="remove-table-designer-column" data-column-id="',
166
216
  columnId,
167
217
  '" type="button"><span class="material-symbols-outlined text-base">delete</span><span>Remove</span></button>',
168
218
  "</div>",
219
+ "</div>",
169
220
  ].join("");
170
221
  }
171
222
 
@@ -183,7 +234,7 @@ function renderColumnGrid(draft, catalogTables) {
183
234
  <div>Default</div>
184
235
  <div>FK Table</div>
185
236
  <div>FK Column</div>
186
- <div></div>
237
+ <div>Actions</div>
187
238
  </div>
188
239
  ${visibleColumns.map((column) => renderColumnRow(column, draft, catalogTables)).join("")}
189
240
  </div>
@@ -225,6 +276,10 @@ export function renderTableDesignerFeedback(draft, saveError) {
225
276
  title: message,
226
277
  }));
227
278
  const warningItems = draft?.warnings ?? [];
279
+ const schemaNotes = warningItems.filter(
280
+ (item) => item.tone === "muted" && item.code !== "COMPLEX_UNIQUE_CONSTRAINTS_PRESENT"
281
+ );
282
+ const alertWarnings = warningItems.filter((item) => item.tone !== "muted");
228
283
 
229
284
  return [
230
285
  saveError
@@ -233,10 +288,11 @@ export function renderTableDesignerFeedback(draft, saveError) {
233
288
  <div class="table-designer-main__error-code">${escapeHtml(saveError.code)}</div>
234
289
  <div class="table-designer-main__error-text">${escapeHtml(saveError.message)}</div>
235
290
  </div>
236
- `
291
+ `
237
292
  : "",
238
293
  renderWarningList(validationItems, "table-designer-banner is-validation", "Validation", "alert"),
239
- renderWarningList(warningItems, "table-designer-banner is-warning", "Warnings", "alert"),
294
+ renderWarningList(alertWarnings, "table-designer-banner is-warning", "Warnings", "alert"),
295
+ renderWarningList(schemaNotes, "table-designer-banner is-note", "Schema Notes", "muted"),
240
296
  ].join("");
241
297
  }
242
298
 
@@ -305,7 +361,7 @@ export function renderTableDesignerEditor(state) {
305
361
  <div class="table-designer-main__subtitle">
306
362
  ${escapeHtml(String(visibleColumns.length))} visible column${
307
363
  visibleColumns.length === 1 ? "" : "s"
308
- } // SQLite-safe operations only
364
+ } // Table Designer v2 // SQLite-safe operations only
309
365
  </div>
310
366
  </div>
311
367
  <div class="table-designer-main__actions">
@@ -338,13 +394,15 @@ export function renderTableDesignerEditor(state) {
338
394
  <div>
339
395
  <div class="table-designer-main__section-title">Columns</div>
340
396
  </div>
341
- <button
342
- class="standard-button"
343
- data-action="add-table-designer-column"
344
- type="button"
345
- >
346
- + Add Column
347
- </button>
397
+ <div class="table-designer-main__section-actions">
398
+ <button
399
+ class="standard-button"
400
+ data-action="add-table-designer-column"
401
+ type="button"
402
+ >
403
+ + Add Column
404
+ </button>
405
+ </div>
348
406
  </div>
349
407
  ${renderColumnGrid(draft, catalogTables)}
350
408
  </section>
@@ -21,6 +21,14 @@ export function parseHash(hash = window.location.hash) {
21
21
  historyId: segments[1] ? decodeURIComponent(segments[1]) : null,
22
22
  },
23
23
  };
24
+ case 'documents':
25
+ return {
26
+ name: 'documents',
27
+ path: cleanPath,
28
+ params: {
29
+ documentId: segments[1] ? decodeURIComponent(segments[1]) : null,
30
+ },
31
+ };
24
32
  case 'editor':
25
33
  if (segments[1] === 'results') {
26
34
  return { name: 'editorResults', path: '/editor/results', params: {} };