sqlite-hub 2.0.1 → 2.2.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 (98) hide show
  1. package/README.md +21 -9
  2. package/docs/MCP.md +8 -0
  3. package/docs/changelog.md +16 -0
  4. package/docs/guidelines/DESIGN.md +64 -0
  5. package/docs/todo.md +2 -0
  6. package/frontend/assets/mockups/backups_1_1920.webp +0 -0
  7. package/frontend/assets/mockups/backups_2_compare_drawer_1920.webp +0 -0
  8. package/frontend/assets/mockups/backups_3_create_backup_modal_1920.webp +0 -0
  9. package/frontend/assets/mockups/backups_4_edit_backup_modal_1920.webp +0 -0
  10. package/frontend/assets/mockups/backups_5_restore_backup_modal_1920.webp +0 -0
  11. package/frontend/assets/mockups/backups_6_delete_backup_modal_1920.webp +0 -0
  12. package/frontend/assets/mockups/charts_1_1920.webp +0 -0
  13. package/frontend/assets/mockups/charts_2_query_detail_1920.webp +0 -0
  14. package/frontend/assets/mockups/charts_3_create_query_chart_modal_1920.webp +0 -0
  15. package/frontend/assets/mockups/charts_4_edit_query_chart_modal_1920.webp +0 -0
  16. package/frontend/assets/mockups/charts_5_delete_query_chart_modal_1920.webp +0 -0
  17. package/frontend/assets/mockups/charts_6_copy_column_modal_1920.webp +0 -0
  18. package/frontend/assets/mockups/connections_1_1920.webp +0 -0
  19. package/frontend/assets/mockups/connections_2_create_connection_modal_1920.webp +0 -0
  20. package/frontend/assets/mockups/connections_3_open_connection_modal_1920.webp +0 -0
  21. package/frontend/assets/mockups/data_1_1920.webp +0 -0
  22. package/frontend/assets/mockups/data_2_roweditor_1920.webp +0 -0
  23. package/frontend/assets/mockups/data_3_generate_data_modal_1920.webp +0 -0
  24. package/frontend/assets/mockups/data_4_data_export_modal_1920.webp +0 -0
  25. package/frontend/assets/mockups/documents_1_1920.webp +0 -0
  26. package/frontend/assets/mockups/documents_2_document_insert_table_modal_1920.webp +0 -0
  27. package/frontend/assets/mockups/documents_3_document_insert_note_modal_1920.webp +0 -0
  28. package/frontend/assets/mockups/logs_1_1920.webp +0 -0
  29. package/frontend/assets/mockups/media_tagging_queue_1_1920.webp +0 -0
  30. package/frontend/assets/mockups/media_tagging_setup_1_1920.webp +0 -0
  31. package/frontend/assets/mockups/media_tagging_setup_2_create_media_tagging_tag_table_modal_1920.webp +0 -0
  32. package/frontend/assets/mockups/media_tagging_setup_3_create_media_tagging_mapping_table_modal_1920.webp +0 -0
  33. package/frontend/assets/mockups/overview_1_1920.webp +0 -0
  34. package/frontend/assets/mockups/settings_1_1920.webp +0 -0
  35. package/frontend/assets/mockups/sql_editor_1_1920.webp +0 -0
  36. package/frontend/assets/mockups/sql_editor_2_query_detail_1920.webp +0 -0
  37. package/frontend/assets/mockups/sql_editor_3_query_export_modal_1920.webp +0 -0
  38. package/frontend/assets/mockups/structure_1_1920.webp +0 -0
  39. package/frontend/assets/mockups/structure_2_generate_types_modal_1920.webp +0 -0
  40. package/frontend/assets/mockups/structure_3_generate_types_modal_1920.webp +0 -0
  41. package/frontend/assets/mockups/table_designer_1_1920.webp +0 -0
  42. package/frontend/assets/mockups/table_designer_2_table_designer_constraints_modal_1920.webp +0 -0
  43. package/frontend/js/api.js +18 -0
  44. package/frontend/js/app.js +567 -15
  45. package/frontend/js/components/badges.js +16 -0
  46. package/frontend/js/components/connectionCard.js +35 -0
  47. package/frontend/js/components/modal.js +268 -247
  48. package/frontend/js/components/queryHistoryDetail.js +25 -45
  49. package/frontend/js/components/rowEditorPanel.js +7 -60
  50. package/frontend/js/components/structureGraph.js +292 -13
  51. package/frontend/js/components/tableDesignerConstraintsDrawer.js +400 -0
  52. package/frontend/js/components/tableDesignerEditor.js +51 -16
  53. package/frontend/js/components/tableDesignerSidebar.js +9 -5
  54. package/frontend/js/store.js +1847 -190
  55. package/frontend/js/utils/connectionRegistry.js +76 -0
  56. package/frontend/js/utils/markdownDocuments.js +21 -4
  57. package/frontend/js/utils/tableDesigner.js +431 -62
  58. package/frontend/js/views/backups.js +2 -5
  59. package/frontend/js/views/charts.js +20 -10
  60. package/frontend/js/views/connections.js +101 -6
  61. package/frontend/js/views/data.js +52 -9
  62. package/frontend/js/views/documents.js +153 -59
  63. package/frontend/js/views/structure.js +28 -5
  64. package/frontend/js/views/tableAdvisor.js +14 -6
  65. package/frontend/js/views/tableDesigner.js +44 -9
  66. package/frontend/styles/base.css +9 -0
  67. package/frontend/styles/components.css +676 -79
  68. package/frontend/styles/structure-graph.css +49 -0
  69. package/frontend/styles/tailwind.generated.css +1 -1
  70. package/frontend/styles/views.css +12 -0
  71. package/package.json +1 -1
  72. package/server/routes/backups.js +37 -1
  73. package/server/routes/charts.js +28 -0
  74. package/server/routes/connections.js +12 -0
  75. package/server/routes/data.js +30 -2
  76. package/server/routes/documents.js +61 -5
  77. package/server/routes/settings.js +32 -0
  78. package/server/routes/tableDesigner.js +35 -2
  79. package/server/server.js +3 -3
  80. package/server/services/mcpToolService.js +30 -0
  81. package/server/services/sqlite/connectionManager.js +25 -2
  82. package/server/services/sqlite/dataBrowserService.js +21 -0
  83. package/server/services/sqlite/introspection.js +39 -1
  84. package/server/services/sqlite/overviewService.js +1 -0
  85. package/server/services/sqlite/structureService.js +66 -11
  86. package/server/services/sqlite/tableAdvisor.js +6 -0
  87. package/server/services/sqlite/tableDesigner/schemaMapping.js +7 -1
  88. package/server/services/sqlite/tableDesigner/sql.js +31 -2
  89. package/server/services/sqlite/tableDesigner/validation.js +69 -9
  90. package/server/services/sqlite/tableDesignerService.js +110 -5
  91. package/server/services/storage/appStateStore.js +590 -10
  92. package/server/utils/fileValidation.js +5 -0
  93. package/server/utils/userActionLog.js +49 -0
  94. package/frontend/assets/mockups/backups_2_create_backup_modal_1920.webp +0 -0
  95. package/frontend/assets/mockups/backups_3_edit_backup_modal_1920.webp +0 -0
  96. package/frontend/assets/mockups/backups_4_restore_backup_modal_1920.webp +0 -0
  97. package/frontend/assets/mockups/backups_5_delete_backup_modal_1920.webp +0 -0
  98. package/frontend/assets/mockups/data_3_data_export_modal_1920.webp +0 -0
@@ -687,14 +687,11 @@ export function renderBackupDiffDrawer(state) {
687
687
  <div class="flex items-center justify-between gap-3">
688
688
  <div class="min-w-0">
689
689
  <div class="font-mono text-[10px] uppercase tracking-[0.18em] text-primary-container/70">
690
- Backup Compare
690
+ Backup Compare // ${escapeHtml(currentLabel)}
691
691
  </div>
692
692
  <h2 class="mt-1 truncate font-body text-lg font-black uppercase tracking-tight text-on-surface">
693
693
  ${escapeHtml(title)}
694
694
  </h2>
695
- <div class="mt-1 truncate text-xs text-on-surface-variant/60">
696
- Current // ${escapeHtml(currentLabel)}
697
- </div>
698
695
  </div>
699
696
  <button
700
697
  class="query-history-icon-button"
@@ -708,7 +705,7 @@ export function renderBackupDiffDrawer(state) {
708
705
  <div class="mt-4 flex flex-wrap gap-2">
709
706
  ${renderStatusBadge('read only', 'muted')}
710
707
  ${diffState.loading ? renderStatusBadge('loading', 'muted') : ''}
711
- ${diffState.error ? renderStatusBadge('error', 'alert') : ''}
708
+ ${diffState.error ? renderStatusBadge('error', 'muted') : ''}
712
709
  </div>
713
710
  </div>
714
711
  ${renderBackupDiffDrawerBody(diffState)}
@@ -339,9 +339,14 @@ export function renderChartsDetailDrawer(state) {
339
339
  <section class="flex h-full min-h-0 flex-col bg-surface-low">
340
340
  <div class="border-b border-outline-variant/10 px-5 py-4">
341
341
  <div class="flex items-center justify-between gap-3">
342
- <span class="font-body text-sm font-black uppercase tracking-[0.18em] text-primary-container">
343
- Chart Detail
344
- </span>
342
+ <div class="min-w-0">
343
+ <div class="font-mono text-[10px] uppercase tracking-[0.18em] text-primary-container/70">
344
+ Charts // Query Detail
345
+ </div>
346
+ <h2 class="mt-1 truncate font-body text-lg font-black uppercase tracking-tight text-on-surface">
347
+ Loading
348
+ </h2>
349
+ </div>
345
350
  <button class="query-history-icon-button" data-action="close-charts-query-detail" type="button">
346
351
  <span class="material-symbols-outlined text-[18px]">close</span>
347
352
  </button>
@@ -359,9 +364,14 @@ export function renderChartsDetailDrawer(state) {
359
364
  <section class="flex h-full min-h-0 flex-col bg-surface-low">
360
365
  <div class="border-b border-outline-variant/10 px-5 py-4">
361
366
  <div class="flex items-center justify-between gap-3">
362
- <span class="font-body text-sm font-black uppercase tracking-[0.18em] text-primary-container">
363
- Chart Detail
364
- </span>
367
+ <div class="min-w-0">
368
+ <div class="font-mono text-[10px] uppercase tracking-[0.18em] text-primary-container/70">
369
+ Charts // Query Detail
370
+ </div>
371
+ <h2 class="mt-1 truncate font-body text-lg font-black uppercase tracking-tight text-on-surface">
372
+ Error
373
+ </h2>
374
+ </div>
365
375
  <button class="query-history-icon-button" data-action="close-charts-query-detail" type="button">
366
376
  <span class="material-symbols-outlined text-[18px]">close</span>
367
377
  </button>
@@ -383,7 +393,7 @@ export function renderChartsDetailDrawer(state) {
383
393
  const charts = detail.charts ?? [];
384
394
  const result = state.charts.result;
385
395
  const chartTypeBadges = charts.length
386
- ? charts.map(chart => renderStatusBadge(getQueryChartTypeLabel(chart.chartType), 'primary')).join('')
396
+ ? charts.map(chart => renderStatusBadge(getQueryChartTypeLabel(chart.chartType), 'muted')).join('')
387
397
  : renderStatusBadge('no charts', 'muted');
388
398
  const savedBadgeMarkup = `
389
399
  <span
@@ -391,12 +401,12 @@ export function renderChartsDetailDrawer(state) {
391
401
  data-history-id="${escapeHtml(detail.item.id)}"
392
402
  ${detail.item.isSaved ? '' : 'hidden'}
393
403
  >
394
- ${renderStatusBadge('saved', 'primary')}
404
+ ${renderStatusBadge('saved', 'muted')}
395
405
  </span>
396
406
  `;
397
407
  const statusMarkup = [
398
408
  savedBadgeMarkup,
399
- state.charts.resultError ? renderStatusBadge('result error', 'alert') : '',
409
+ state.charts.resultError ? renderStatusBadge('result error', 'muted') : '',
400
410
  state.charts.resultLoading ? renderStatusBadge('loading result', 'muted') : '',
401
411
  chartTypeBadges,
402
412
  ].join('');
@@ -413,7 +423,7 @@ export function renderChartsDetailDrawer(state) {
413
423
  <div class="flex items-center justify-between gap-3">
414
424
  <div class="min-w-0">
415
425
  <div class="text-[10px] font-mono uppercase tracking-[0.18em] text-primary-container/70">
416
- Chart Detail
426
+ Charts // Query Detail
417
427
  </div>
418
428
  <h2 class="mt-1 truncate font-body text-lg font-black uppercase tracking-tight text-on-surface">
419
429
  ${escapeHtml(detail.item.displayTitle)}
@@ -1,6 +1,7 @@
1
1
  import { renderConnectionCard } from '../components/connectionCard.js';
2
2
  import { renderPageHeader } from '../components/pageHeader.js';
3
3
  import { escapeHtml } from '../utils/format.js';
4
+ import { filterConnections, getConnectionTagCounts } from '../utils/connectionRegistry.js';
4
5
 
5
6
  function renderConnectionsActionButton({
6
7
  label,
@@ -28,7 +29,88 @@ function renderConnectionsActionButton({
28
29
  `;
29
30
  }
30
31
 
31
- function renderConnectionsBody(state) {
32
+ function renderConnectionsToolbar(state, visibleCount) {
33
+ if (!state.connections.recent.length) {
34
+ return '';
35
+ }
36
+
37
+ const searchQuery = state.connections.searchQuery ?? '';
38
+ const selectedTagIds = new Set((state.connections.selectedTagIds ?? []).map(tagId => String(tagId)));
39
+ const selectedTags = (state.connections.tags ?? []).filter(tag => selectedTagIds.has(String(tag.id)));
40
+ const tagCounts = getConnectionTagCounts(state.connections.recent);
41
+ const filterLabel =
42
+ selectedTags.length === 0
43
+ ? 'TAGS: ALL'
44
+ : selectedTags.length === 1
45
+ ? `TAGS: ${selectedTags[0].name}`
46
+ : `TAGS: ${selectedTags.length}`;
47
+ const tagRows = (state.connections.tags ?? [])
48
+ .map(tag => {
49
+ const tagId = String(tag.id);
50
+ const isSelected = selectedTagIds.has(tagId);
51
+
52
+ return `
53
+ <label class="connection-tag-filter-row">
54
+ <input
55
+ ${isSelected ? 'checked' : ''}
56
+ data-bind="connection-tag-filter"
57
+ data-tag-id="${escapeHtml(tagId)}"
58
+ type="checkbox"
59
+ />
60
+ <span class="connection-tag-filter-row__name">${escapeHtml(tag.name)}</span>
61
+ <span class="connection-tag-filter-row__count">${escapeHtml(String(tagCounts.get(tagId) ?? tag.connectionCount ?? 0))}</span>
62
+ </label>
63
+ `;
64
+ })
65
+ .join('');
66
+
67
+ return `
68
+ <div class="connection-registry-toolbar" data-connection-registry-toolbar>
69
+ <label class="connection-registry-search">
70
+ <span class="sr-only">Search connections</span>
71
+ <span class="material-symbols-outlined connection-registry-search__icon" aria-hidden="true">search</span>
72
+ <input
73
+ autocomplete="off"
74
+ class="connection-registry-search__input"
75
+ data-bind="connections-search"
76
+ data-connections-search-input
77
+ placeholder="SEARCH_CONNECTIONS..."
78
+ spellcheck="false"
79
+ type="search"
80
+ value="${escapeHtml(searchQuery)}"
81
+ />
82
+ <span class="connection-registry-search__shortcut" aria-hidden="true">/</span>
83
+ </label>
84
+ <details class="dropdown-button dropdown-button--align-right connection-tag-filter" data-dropdown-button>
85
+ <summary class="standard-button dropdown-button__toggle connection-tag-filter__toggle" title="${escapeHtml(filterLabel)}">
86
+ <span class="connection-tag-filter__toggle-label">${escapeHtml(filterLabel)}</span>
87
+ <span class="material-symbols-outlined dropdown-button__chevron" aria-hidden="true">expand_more</span>
88
+ </summary>
89
+ <div class="dropdown-button__panel connection-tag-filter__panel">
90
+ <div class="connection-tag-filter__header">
91
+ <span>FILTER_BY_TAG</span>
92
+ <span>${escapeHtml(String(visibleCount))}</span>
93
+ </div>
94
+ ${
95
+ tagRows
96
+ ? `<div class="connection-tag-filter__list custom-scrollbar">${tagRows}</div>`
97
+ : '<div class="connection-tag-filter__empty">NO_TAGS_AVAILABLE</div>'
98
+ }
99
+ <button
100
+ class="standard-button connection-tag-filter__reset"
101
+ data-action="clear-connection-tag-filters"
102
+ type="button"
103
+ ${selectedTagIds.size ? '' : 'disabled'}
104
+ >
105
+ RESET_FILTER
106
+ </button>
107
+ </div>
108
+ </details>
109
+ </div>
110
+ `;
111
+ }
112
+
113
+ function renderConnectionsBody(state, visibleConnections) {
32
114
  if (state.connections.loading && !state.connections.recent.length) {
33
115
  return `
34
116
  <div class="flex min-h-[280px] items-center justify-center border border-outline-variant/10 bg-surface-container-low">
@@ -78,21 +160,33 @@ function renderConnectionsBody(state) {
78
160
  `;
79
161
  }
80
162
 
163
+ if (!visibleConnections.length) {
164
+ return `
165
+ <div class="connection-filter-empty">
166
+ <span class="material-symbols-outlined connection-filter-empty__icon">search_off</span>
167
+ <p class="connection-filter-empty__title">NO_CONNECTIONS_FOUND</p>
168
+ <p class="connection-filter-empty__hint">TRY_ANOTHER_SEARCH_OR_FILTER</p>
169
+ </div>
170
+ `;
171
+ }
172
+
81
173
  return `
82
174
  <div class="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
83
- ${state.connections.recent
84
- .map(connection => renderConnectionCard(connection, state.connections.active?.id))
85
- .join('')}
175
+ ${visibleConnections.map(connection => renderConnectionCard(connection, state.connections.active?.id)).join('')}
86
176
  </div>
87
177
  `;
88
178
  }
89
179
 
90
180
  export function renderConnectionsView(state) {
181
+ const visibleConnections = filterConnections(state.connections.recent, {
182
+ searchQuery: state.connections.searchQuery,
183
+ selectedTagIds: state.connections.selectedTagIds,
184
+ });
91
185
  const actions = `
92
186
  ${
93
187
  state.connections.active
94
188
  ? renderConnectionsActionButton({
95
- label: state.connections.backupLoading ? 'Creating Backup...' : 'Create Backup',
189
+ label: state.connections.backupLoading ? 'Create Backup...' : 'CreateBackup',
96
190
  icon: 'inventory_2',
97
191
  action: 'create-backup',
98
192
  disabled: state.connections.backupLoading,
@@ -122,7 +216,8 @@ export function renderConnectionsView(state) {
122
216
  subtitle: `Registry // Recent_Targets: ${String(state.connections.recent.length).padStart(2, '0')}`,
123
217
  actions,
124
218
  })}
125
- ${renderConnectionsBody(state)}
219
+ ${renderConnectionsToolbar(state, visibleConnections.length)}
220
+ ${renderConnectionsBody(state, visibleConnections)}
126
221
  </div>
127
222
  </section>
128
223
  `,
@@ -1,5 +1,6 @@
1
1
  import { renderDataGrid } from '../components/dataGrid.js';
2
2
  import { renderRowEditorPanel } from '../components/rowEditorPanel.js';
3
+ import { renderVirtualTableBadge } from '../components/badges.js';
3
4
  import { renderWorkspaceOpenDropdown } from '../components/workspaceOpenDropdown.js';
4
5
  import { escapeHtml, formatCellValue, formatNumber, isBlobPreview, truncateMiddle } from '../utils/format.js';
5
6
  import { detectEmailValue } from '../utils/emailPreview.js';
@@ -31,6 +32,33 @@ function getFilteredTables(tables, searchQuery) {
31
32
  return tables.filter(table => table.name.toLowerCase().includes(normalizedSearch));
32
33
  }
33
34
 
35
+ export function buildDataSidebarSignature(state) {
36
+ if (state?.route?.name !== 'data') {
37
+ return '';
38
+ }
39
+
40
+ const dataBrowser = state.dataBrowser ?? {};
41
+ const tablesVisible = dataBrowser.tablesVisible !== false;
42
+
43
+ if (!tablesVisible) {
44
+ return 'data-sidebar:hidden';
45
+ }
46
+
47
+ const tables = dataBrowser.tables ?? [];
48
+
49
+ return JSON.stringify({
50
+ loadingEmpty: Boolean(dataBrowser.loading && !tables.length),
51
+ tableSearchQuery: dataBrowser.tableSearchQuery ?? '',
52
+ tables: tables.map(table => [
53
+ table.name,
54
+ table.columnCount ?? 0,
55
+ Boolean(table.isVirtual),
56
+ Boolean(table.isShadow),
57
+ table.tableKind ?? '',
58
+ ]),
59
+ });
60
+ }
61
+
34
62
  function renderDataTableSearch(state) {
35
63
  return `
36
64
  <label class="table-designer-sidebar__search">
@@ -91,11 +119,18 @@ function renderTableList(state) {
91
119
  : 'border-outline-variant/10 bg-surface-container-lowest hover:bg-surface-container-high'
92
120
  }"
93
121
  data-action="navigate"
122
+ data-data-table-item
123
+ data-data-table-name="${escapeHtml(table.name)}"
94
124
  data-to="/data/${encodeURIComponent(table.name)}"
95
125
  type="button"
96
126
  >
97
- <div class="table-designer-sidebar__item-name ${table.name === activeName ? 'is-active' : ''}">
98
- ${escapeHtml(table.name)}
127
+ <div class="flex min-w-0 items-center gap-2">
128
+ <div class="table-designer-sidebar__item-name min-w-0 flex-1 ${
129
+ table.name === activeName ? 'is-active' : ''
130
+ }" data-data-table-name-label>
131
+ ${escapeHtml(table.name)}
132
+ </div>
133
+ ${renderVirtualTableBadge(table)}
99
134
  </div>
100
135
  <div class="mt-1 truncate text-[10px] uppercase tracking-[0.16em] text-on-surface-variant/45">
101
136
  ${escapeHtml(formatNumber(table.columnCount ?? 0))} column${
@@ -113,6 +148,12 @@ function renderTableList(state) {
113
148
  function renderWorkspaceHeader(state) {
114
149
  const table = state.dataBrowser.table;
115
150
  const tablesVisible = state.dataBrowser.tablesVisible !== false;
151
+ const generateDisabled = !table || table.isShadow;
152
+ const generateTitle = !table
153
+ ? 'Select a table before generating rows'
154
+ : table.isShadow
155
+ ? 'Shadow tables are read-only in Data'
156
+ : 'Generate synthetic test rows for this table';
116
157
 
117
158
  return `
118
159
  <header class="workspace-header">
@@ -140,9 +181,9 @@ function renderWorkspaceHeader(state) {
140
181
  <button
141
182
  class="standard-button"
142
183
  data-action="open-generate-data-modal"
143
- title="${table ? 'Generate synthetic test rows for this table' : 'Select a table before generating rows'}"
184
+ title="${escapeHtml(generateTitle)}"
144
185
  type="button"
145
- ${table ? '' : 'disabled aria-disabled="true"'}
186
+ ${generateDisabled ? 'disabled aria-disabled="true"' : ''}
146
187
  >
147
188
  <span class="material-symbols-outlined text-sm">auto_awesome</span>
148
189
  Generate
@@ -628,6 +669,8 @@ export function renderDataRowEditorPanel(state) {
628
669
  : [{ name: 'rowIdentity', value: JSON.stringify(row.__identity ?? null) }],
629
670
  disabledMessage: state.connections.active?.readOnly
630
671
  ? 'The active database is opened read-only, so row editing is disabled.'
672
+ : table.isShadow
673
+ ? 'Shadow tables are read-only in Data.'
631
674
  : table.notSafelyUpdatable
632
675
  ? 'This table has no stable identity column, so SQLite Hub cannot safely update rows.'
633
676
  : '',
@@ -663,7 +706,7 @@ export function renderDataRowEditorPanel(state) {
663
706
  deleting: state.dataBrowser.deleting,
664
707
  deleteAction: 'delete-data-row',
665
708
  deleteRowIndex: isIndexedRow ? rowIndex : null,
666
- deleteEnabled: Boolean(row.__identity),
709
+ deleteEnabled: Boolean(row.__identity) && !table.isShadow,
667
710
  reloadAction: 'reload-data-route',
668
711
  jsonActionsEnabled: true,
669
712
  });
@@ -674,12 +717,12 @@ export function renderDataView(state) {
674
717
 
675
718
  return {
676
719
  main: `
677
- <section class="view-surface min-h-full bg-surface-container flex h-full min-h-0 flex-col overflow-hidden">
678
- <div class="data-view-grid ${tablesVisible ? 'data-view-grid--with-subnavi' : ''}">
720
+ <section class="view-surface min-h-full bg-surface-container flex h-full min-h-0 flex-col overflow-hidden" data-data-view>
721
+ <div class="data-view-grid ${tablesVisible ? 'data-view-grid--with-subnavi' : ''}" data-data-view-grid>
679
722
  ${
680
723
  tablesVisible
681
724
  ? `
682
- <aside class="data-view__sidebar subnavi-panel">
725
+ <aside class="data-view__sidebar subnavi-panel" data-data-sidebar>
683
726
  <div class="subnavi-header">
684
727
  <div>
685
728
  <div class="subnavi-header-title">Tables</div>
@@ -694,7 +737,7 @@ export function renderDataView(state) {
694
737
  `
695
738
  : ''
696
739
  }
697
- <section class="flex min-h-0 flex-col overflow-hidden">
740
+ <section class="flex min-h-0 flex-col overflow-hidden" data-data-workspace>
698
741
  ${renderWorkspaceHeader(state)}
699
742
  ${renderWorkspaceError(state)}
700
743
  ${renderTableSurface(state)}
@@ -35,7 +35,7 @@ function renderDocumentListItem(item, selectedId) {
35
35
  <span class="documents-list-item__title" title="${escapeHtml(item.filename)}">
36
36
  ${escapeHtml(item.filename)}
37
37
  </span>
38
- <span class="documents-list-item__meta">
38
+ <span class="documents-list-item__meta" data-document-list-meta>
39
39
  ${escapeHtml(formatCompactDateTime(item.updatedAt))} // ${formatNumber(item.contentLength ?? 0)} chars
40
40
  </span>
41
41
  </span>
@@ -77,7 +77,45 @@ function renderDocumentsSearch(documents) {
77
77
 
78
78
  function renderDocumentsSidebar(documents) {
79
79
  const items = documents.items ?? [];
80
+ const folders = documents.folders ?? [];
80
81
  const filteredItems = getFilteredDocuments(items, documents.searchQuery);
82
+ const searchActive = Boolean(String(documents.searchQuery ?? '').trim());
83
+ const rootItems = filteredItems.filter(item => !item.folderId);
84
+ const renderFolderGroup = (label, groupItems, options = {}) => {
85
+ if (searchActive && !groupItems.length) {
86
+ return '';
87
+ }
88
+
89
+ return `
90
+ <div class="documents-folder-group">
91
+ <div class="px-3 pb-1 pt-3 text-[10px] font-mono uppercase tracking-[0.2em] text-on-surface-variant/55">
92
+ ${escapeHtml(label)}
93
+ </div>
94
+ ${
95
+ groupItems.length
96
+ ? groupItems.map(item => renderDocumentListItem(item, documents.selectedId)).join('')
97
+ : `<div class="documents-list-empty">${escapeHtml(options.emptyText ?? 'No documents')}</div>`
98
+ }
99
+ </div>
100
+ `;
101
+ };
102
+ const folderGroups = folders
103
+ .map(folder =>
104
+ renderFolderGroup(
105
+ folder.name,
106
+ filteredItems.filter(item => String(item.folderId ?? '') === String(folder.id)),
107
+ { emptyText: 'Empty folder' },
108
+ ),
109
+ )
110
+ .join('');
111
+ const rootGroup = renderFolderGroup('No Folder', rootItems, {
112
+ emptyText: folders.length ? 'No root documents' : 'No documents yet',
113
+ });
114
+ const listMarkup = rootGroup || folderGroups
115
+ ? `${rootGroup}${folderGroups}`
116
+ : `<div class="documents-list-empty">${
117
+ items.length ? 'No documents match the current search.' : 'No documents yet'
118
+ }</div>`;
81
119
 
82
120
  return `
83
121
  <aside class="documents-view__sidebar subnavi-panel">
@@ -89,13 +127,7 @@ function renderDocumentsSidebar(documents) {
89
127
  </div>
90
128
  ${renderDocumentsSearch(documents)}
91
129
  <div class="documents-view__sidebar-body subnavi-list custom-scrollbar">
92
- ${
93
- filteredItems.length
94
- ? filteredItems.map(item => renderDocumentListItem(item, documents.selectedId)).join('')
95
- : `<div class="documents-list-empty">${
96
- items.length ? 'No documents match the current search.' : 'No documents yet'
97
- }</div>`
98
- }
130
+ ${listMarkup}
99
131
  </div>
100
132
  </aside>
101
133
  `;
@@ -118,18 +150,18 @@ function renderNewDocumentDropdown(documents, className = '') {
118
150
  ${renderDropdownButton({
119
151
  disabled: documents.saving,
120
152
  icon: 'add',
121
- label: 'New Document',
122
- title: 'New document',
153
+ label: 'New',
154
+ title: 'New',
123
155
  items: [
124
156
  {
125
157
  action: 'create-document',
126
158
  icon: 'draft',
127
- label: 'Blank Page',
159
+ label: 'New Document',
128
160
  },
129
161
  {
130
- action: 'import-document-markdown',
131
- icon: 'upload_file',
132
- label: 'Import .md',
162
+ action: 'open-create-document-folder-modal',
163
+ icon: 'create_new_folder',
164
+ label: 'New Folder',
133
165
  },
134
166
  ],
135
167
  })}
@@ -137,40 +169,86 @@ function renderNewDocumentDropdown(documents, className = '') {
137
169
  `;
138
170
  }
139
171
 
140
- function renderDocumentsPanelToggle(documents) {
141
- const visible = documents.documentsVisible !== false;
172
+ function renderDocumentsViewDropdown(documents) {
173
+ const documentsVisible = documents.documentsVisible !== false;
174
+ const editorVisible = documents.editorVisible !== false;
175
+ const previewVisible = documents.previewVisible !== false;
142
176
 
143
- return `
144
- <button
145
- class="standard-button panel-toggle-button ${visible ? '' : 'is-active'}"
146
- aria-pressed="${visible ? 'false' : 'true'}"
147
- data-action="toggle-documents-panel"
148
- type="button"
149
- >
150
- <span class="material-symbols-outlined">${visible ? 'visibility_off' : 'visibility'}</span>
151
- ${visible ? 'Hide Documents' : 'Show Documents'}
152
- </button>
153
- `;
177
+ return renderDropdownButton({
178
+ icon: 'visibility',
179
+ label: 'View',
180
+ title: 'View options',
181
+ items: [
182
+ {
183
+ action: 'toggle-document-pane',
184
+ dataAttributes: { pane: 'editor' },
185
+ icon: editorVisible ? 'visibility_off' : 'edit_note',
186
+ label: `${editorVisible ? 'Hide' : 'Show'} Editor`,
187
+ },
188
+ {
189
+ action: 'toggle-document-pane',
190
+ dataAttributes: { pane: 'preview' },
191
+ icon: previewVisible ? 'visibility_off' : 'visibility',
192
+ label: `${previewVisible ? 'Hide' : 'Show'} Preview`,
193
+ },
194
+ {
195
+ action: 'toggle-documents-panel',
196
+ icon: documentsVisible ? 'visibility_off' : 'description',
197
+ label: `${documentsVisible ? 'Hide' : 'Show'} Documents`,
198
+ },
199
+ ],
200
+ });
154
201
  }
155
202
 
156
- function renderDocumentPaneToggle(documents, pane) {
157
- const isEditor = pane === 'editor';
158
- const visible = isEditor ? documents.editorVisible : documents.previewVisible;
159
- const label = `${visible ? 'Hide' : 'Show'} ${isEditor ? 'Editor' : 'Preview'}`;
160
- const icon = visible ? 'visibility_off' : isEditor ? 'edit_note' : 'visibility';
203
+ function renderDocumentsFileDropdown(documents) {
204
+ const exportDisabled = documents.saving || documents.detailLoading || !documents.selectedId;
161
205
 
162
- return `
163
- <button
164
- class="standard-button panel-toggle-button documents-pane__toggle ${visible ? '' : 'is-active'}"
165
- aria-pressed="${visible ? 'false' : 'true'}"
166
- data-action="toggle-document-pane"
167
- data-pane="${escapeHtml(pane)}"
168
- type="button"
169
- >
170
- <span class="material-symbols-outlined">${icon}</span>
171
- ${label}
172
- </button>
173
- `;
206
+ return renderDropdownButton({
207
+ disabled: documents.saving,
208
+ icon: 'folder_open',
209
+ label: 'File',
210
+ title: 'File actions',
211
+ items: [
212
+ {
213
+ action: 'import-document-markdown',
214
+ icon: 'upload_file',
215
+ label: 'Import MD',
216
+ },
217
+ {
218
+ action: 'export-document-markdown',
219
+ disabled: exportDisabled,
220
+ icon: 'download',
221
+ label: 'Export MD',
222
+ },
223
+ ],
224
+ });
225
+ }
226
+
227
+ function renderDocumentsMoveDropdown(documents) {
228
+ const folders = documents.folders ?? [];
229
+ const selectedFolderId = String(documents.selected?.folderId ?? '');
230
+
231
+ return renderDropdownButton({
232
+ disabled: documents.saving || documents.detailLoading || !documents.selectedId,
233
+ icon: 'drive_file_move',
234
+ label: 'Move to',
235
+ title: 'Move document to folder',
236
+ items: [
237
+ {
238
+ action: 'move-document-to-folder',
239
+ disabled: !selectedFolderId,
240
+ icon: 'folder_off',
241
+ label: 'No Folder',
242
+ },
243
+ ...folders.map(folder => ({
244
+ action: 'move-document-to-folder',
245
+ dataAttributes: { folderId: folder.id },
246
+ disabled: selectedFolderId === String(folder.id),
247
+ icon: 'folder',
248
+ label: folder.name,
249
+ })),
250
+ ],
251
+ });
174
252
  }
175
253
 
176
254
  function renderDocumentsTitlebar(documents, options = {}) {
@@ -179,7 +257,6 @@ function renderDocumentsTitlebar(documents, options = {}) {
179
257
 
180
258
  return `
181
259
  <div class="documents-titlebar">
182
- ${renderDocumentsPanelToggle(documents)}
183
260
  ${
184
261
  showFilename
185
262
  ? `
@@ -216,19 +293,33 @@ function renderDocumentsTitlebar(documents, options = {}) {
216
293
  {
217
294
  action: 'open-document-insert-note-modal',
218
295
  icon: 'note_add',
219
- label: 'Insert Note',
296
+ label: 'Insert Query Note',
297
+ },
298
+ {
299
+ action: 'open-document-insert-table-definition-modal',
300
+ icon: 'schema',
301
+ label: 'Insert Table Definition',
302
+ },
303
+ {
304
+ action: 'insert-document-saved-queries',
305
+ icon: 'bookmark',
306
+ label: 'Insert Saved Queries',
307
+ },
308
+ {
309
+ action: 'insert-document-time-metadata',
310
+ icon: 'schedule',
311
+ label: 'Insert Time Metadata',
312
+ },
313
+ {
314
+ action: 'insert-document-database-info',
315
+ icon: 'database',
316
+ label: 'Insert Database Info',
220
317
  },
221
318
  ],
222
319
  })}
223
- <button
224
- class="standard-button"
225
- data-action="export-document-markdown"
226
- type="button"
227
- ${disabled ? 'disabled aria-disabled="true"' : ''}
228
- >
229
- <span class="material-symbols-outlined">download</span>
230
- Export .md
231
- </button>
320
+ ${renderDocumentsViewDropdown(documents)}
321
+ ${renderDocumentsFileDropdown(documents)}
322
+ ${renderDocumentsMoveDropdown(documents)}
232
323
  <button
233
324
  class="delete-button"
234
325
  data-action="delete-document"
@@ -239,7 +330,10 @@ function renderDocumentsTitlebar(documents, options = {}) {
239
330
  Delete
240
331
  </button>
241
332
  `
242
- : ''
333
+ : `
334
+ ${renderDocumentsViewDropdown(documents)}
335
+ ${renderDocumentsFileDropdown(documents)}
336
+ `
243
337
  }
244
338
  </div>
245
339
  `;
@@ -249,7 +343,6 @@ function renderDocumentEditor(documents) {
249
343
  return `
250
344
  <section class="documents-pane documents-pane--editor ${documents.editorVisible ? '' : 'documents-pane--collapsed'}">
251
345
  <div class="documents-pane__header">
252
- ${renderDocumentPaneToggle(documents, 'editor')}
253
346
  <span class="documents-pane__meta">${formatNumber(documents.draftContent.length)} chars</span>
254
347
  </div>
255
348
  ${
@@ -271,11 +364,12 @@ function renderDocumentEditor(documents) {
271
364
  }
272
365
 
273
366
  function renderDocumentPreview(documents) {
367
+ const saveState = documents.dirty ? 'unsaved' : 'saved';
368
+
274
369
  return `
275
370
  <section class="documents-pane documents-pane--preview ${documents.previewVisible ? '' : 'documents-pane--collapsed'}">
276
371
  <div class="documents-pane__header">
277
- ${renderDocumentPaneToggle(documents, 'preview')}
278
- <span class="documents-pane__meta">${escapeHtml(documents.dirty ? 'unsaved' : 'saved')}</span>
372
+ <span class="documents-pane__meta" data-document-save-state="${saveState}">${escapeHtml(saveState)}</span>
279
373
  </div>
280
374
  ${
281
375
  documents.previewVisible