sqlite-hub 1.2.0 → 1.5.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 (169) hide show
  1. package/README.md +113 -71
  2. package/bin/sqlite-hub.js +723 -116
  3. package/docs/API.md +152 -0
  4. package/docs/CLI.md +249 -0
  5. package/docs/CLI_API_PARITY.md +61 -0
  6. package/docs/changelog.md +149 -0
  7. package/docs/guidelines/AGENTS.md +32 -0
  8. package/docs/guidelines/DESIGN.md +55 -0
  9. package/docs/shortkeys.md +27 -0
  10. package/docs/todo.md +4 -0
  11. package/examples/api/generate-types.js +52 -0
  12. package/frontend/assets/mockups/backups_1_1920.webp +0 -0
  13. package/frontend/assets/mockups/backups_2_create_backup_modal_1920.webp +0 -0
  14. package/frontend/assets/mockups/backups_3_edit_backup_modal_1920.webp +0 -0
  15. package/frontend/assets/mockups/backups_4_restore_backup_modal_1920.webp +0 -0
  16. package/frontend/assets/mockups/backups_5_delete_backup_modal_1920.webp +0 -0
  17. package/frontend/assets/mockups/charts_1_1920.webp +0 -0
  18. package/frontend/assets/mockups/charts_2_query_detail_1920.webp +0 -0
  19. package/frontend/assets/mockups/charts_3_create_query_chart_modal_1920.webp +0 -0
  20. package/frontend/assets/mockups/charts_4_edit_query_chart_modal_1920.webp +0 -0
  21. package/frontend/assets/mockups/charts_5_delete_query_chart_modal_1920.webp +0 -0
  22. package/frontend/assets/mockups/charts_6_copy_column_modal_1920.webp +0 -0
  23. package/frontend/assets/mockups/connections_1_1920.webp +0 -0
  24. package/frontend/assets/mockups/connections_2_create_connection_modal_1920.webp +0 -0
  25. package/frontend/assets/mockups/connections_3_open_connection_modal_1920.webp +0 -0
  26. package/frontend/assets/mockups/data_1_1920.webp +0 -0
  27. package/frontend/assets/mockups/data_2_roweditor_1920.webp +0 -0
  28. package/frontend/assets/mockups/data_3_data_export_modal_1920.webp +0 -0
  29. package/frontend/assets/mockups/documents_1_1920.webp +0 -0
  30. package/frontend/assets/mockups/documents_2_document_insert_table_modal_1920.webp +0 -0
  31. package/frontend/assets/mockups/documents_3_document_insert_note_modal_1920.webp +0 -0
  32. package/frontend/assets/mockups/media_tagging_queue_1_1920.webp +0 -0
  33. package/frontend/assets/mockups/media_tagging_setup_1_1920.webp +0 -0
  34. package/frontend/assets/mockups/media_tagging_setup_2_create_media_tagging_tag_table_modal_1920.webp +0 -0
  35. package/frontend/assets/mockups/media_tagging_setup_3_create_media_tagging_mapping_table_modal_1920.webp +0 -0
  36. package/frontend/assets/mockups/overview_1_1920.webp +0 -0
  37. package/frontend/assets/mockups/settings_1_1920.webp +0 -0
  38. package/frontend/assets/mockups/sql_editor_1_1920.webp +0 -0
  39. package/frontend/assets/mockups/sql_editor_2_query_detail_1920.webp +0 -0
  40. package/frontend/assets/mockups/sql_editor_3_query_export_modal_1920.webp +0 -0
  41. package/frontend/assets/mockups/structure_1_1920.webp +0 -0
  42. package/frontend/assets/mockups/structure_2_generate_types_modal_1920.webp +0 -0
  43. package/frontend/assets/mockups/structure_3_generate_types_modal_1920.webp +0 -0
  44. package/frontend/assets/mockups/table_designer_1_1920.webp +0 -0
  45. package/frontend/assets/mockups/table_designer_2_table_designer_constraints_modal_1920.webp +0 -0
  46. package/frontend/js/api.js +63 -0
  47. package/frontend/js/app.js +310 -10
  48. package/frontend/js/components/connectionCard.js +4 -2
  49. package/frontend/js/components/emptyState.js +4 -4
  50. package/frontend/js/components/generateTypesDropdown.js +33 -0
  51. package/frontend/js/components/metricCard.js +1 -1
  52. package/frontend/js/components/modal.js +555 -21
  53. package/frontend/js/components/pageHeader.js +1 -1
  54. package/frontend/js/components/queryHistoryDetail.js +3 -3
  55. package/frontend/js/components/queryHistoryHeader.js +1 -1
  56. package/frontend/js/components/queryHistoryPanel.js +1 -1
  57. package/frontend/js/components/rowEditorPanel.js +13 -3
  58. package/frontend/js/components/sidebar.js +41 -7
  59. package/frontend/js/components/topNav.js +2 -14
  60. package/frontend/js/router.js +38 -6
  61. package/frontend/js/store.js +870 -8
  62. package/frontend/js/utils/inputClear.js +36 -0
  63. package/frontend/js/utils/syntheticData.js +240 -0
  64. package/frontend/js/views/backups.js +581 -37
  65. package/frontend/js/views/charts.js +9 -9
  66. package/frontend/js/views/connections.js +2 -2
  67. package/frontend/js/views/data.js +17 -1
  68. package/frontend/js/views/documents.js +3 -3
  69. package/frontend/js/views/editor.js +6 -6
  70. package/frontend/js/views/logs.js +339 -0
  71. package/frontend/js/views/mediaTagging.js +7 -5
  72. package/frontend/js/views/overview.js +3 -3
  73. package/frontend/js/views/settings.js +80 -30
  74. package/frontend/js/views/structure.js +12 -1
  75. package/frontend/js/views/tableAdvisor.js +385 -0
  76. package/frontend/js/views/tableDesigner.js +6 -0
  77. package/frontend/styles/base.css +1 -40
  78. package/frontend/styles/components.css +258 -232
  79. package/frontend/styles/structure-graph.css +32 -68
  80. package/frontend/styles/tailwind.css +8 -4
  81. package/frontend/styles/tailwind.generated.css +1 -1
  82. package/frontend/styles/tokens.css +28 -21
  83. package/frontend/styles/views.css +102 -261
  84. package/package.json +18 -2
  85. package/server/routes/backups.js +18 -2
  86. package/server/routes/data.js +45 -0
  87. package/server/routes/externalApi.js +321 -2
  88. package/server/routes/logs.js +127 -0
  89. package/server/routes/structure.js +22 -0
  90. package/server/server.js +3 -0
  91. package/server/services/databaseCommandService.js +59 -0
  92. package/server/services/sqlite/backupDiff.js +914 -0
  93. package/server/services/sqlite/backupService.js +77 -1
  94. package/server/services/sqlite/dataBrowserService.js +36 -0
  95. package/server/services/sqlite/introspection.js +226 -22
  96. package/server/services/sqlite/structureService.js +7 -0
  97. package/server/services/sqlite/syntheticDataGenerator.js +728 -0
  98. package/server/services/sqlite/tableAdvisor.js +790 -0
  99. package/server/services/storage/appStateStore.js +773 -169
  100. package/server/services/typeGenerationService.js +663 -0
  101. package/tailwind.config.cjs +0 -1
  102. package/.github/funding.yml +0 -2
  103. package/.github/workflows/ci.yml +0 -36
  104. package/database.sqlite +0 -0
  105. package/frontend/assets/mockups/charts_1_bars_1200.webp +0 -0
  106. package/frontend/assets/mockups/charts_2_pie_1200.webp +0 -0
  107. package/frontend/assets/mockups/charts_3_scatter_plot_1200.webp +0 -0
  108. package/frontend/assets/mockups/connections_1200.webp +0 -0
  109. package/frontend/assets/mockups/data_1_1200.webp +0 -0
  110. package/frontend/assets/mockups/data_2_row_editor_1200.webp +0 -0
  111. package/frontend/assets/mockups/documents_1200.webp +0 -0
  112. package/frontend/assets/mockups/media_tagging_1_setup_1200.webp +0 -0
  113. package/frontend/assets/mockups/media_tagging_2_tagging_queue_1200.webp +0 -0
  114. package/frontend/assets/mockups/media_tagging_3_media_viewer_1200.webp +0 -0
  115. package/frontend/assets/mockups/overview_1200.webp +0 -0
  116. package/frontend/assets/mockups/settings_1200.webp +0 -0
  117. package/frontend/assets/mockups/sql_editor_1_1200.webp +0 -0
  118. package/frontend/assets/mockups/sql_editor_2_query_details_1200.webp +0 -0
  119. package/frontend/assets/mockups/sql_editor_3_export_column_1200.webp +0 -0
  120. package/frontend/assets/mockups/sql_editor_4_export_column_as_markdown_1200.webp +0 -0
  121. package/frontend/assets/mockups/sql_editor_5_export_query_result_1200.webp +0 -0
  122. package/frontend/assets/mockups/structure_1_1200.webp +0 -0
  123. package/frontend/assets/mockups/structure_2_inspector_1200.webp +0 -0
  124. package/frontend/assets/mockups/table_designer_1_1200.webp +0 -0
  125. package/frontend/assets/mockups/table_designer_2_checks_1200.webp +0 -0
  126. package/tests/api-token-auth.test.js +0 -279
  127. package/tests/backup-manager.test.js +0 -140
  128. package/tests/backups-view.test.js +0 -70
  129. package/tests/charts-height-preset-storage.test.js +0 -60
  130. package/tests/charts-route-state.test.js +0 -144
  131. package/tests/check-constraint-options.test.js +0 -90
  132. package/tests/cli-args.test.js +0 -113
  133. package/tests/cli-service-delegation.test.js +0 -140
  134. package/tests/connection-removal.test.js +0 -52
  135. package/tests/connections-file-dialog-route.test.js +0 -89
  136. package/tests/copy-column-modal.test.js +0 -131
  137. package/tests/database-command-service.test.js +0 -174
  138. package/tests/database-documents.test.js +0 -85
  139. package/tests/documents-view.test.js +0 -132
  140. package/tests/dropdown-button.test.js +0 -101
  141. package/tests/email-preview.test.js +0 -89
  142. package/tests/export-blob.test.js +0 -99
  143. package/tests/export-filenames.test.js +0 -38
  144. package/tests/file-path-preview.test.js +0 -165
  145. package/tests/form-controls.test.js +0 -34
  146. package/tests/json-preview.test.js +0 -49
  147. package/tests/local-request-security.test.js +0 -85
  148. package/tests/markdown-documents.test.js +0 -103
  149. package/tests/native-file-dialog.test.js +0 -105
  150. package/tests/query-editor.test.js +0 -28
  151. package/tests/query-history-detail.test.js +0 -37
  152. package/tests/query-history-header.test.js +0 -30
  153. package/tests/query-results-truncation.test.js +0 -20
  154. package/tests/risky-sql.test.js +0 -30
  155. package/tests/row-editor-json.test.js +0 -82
  156. package/tests/row-editor-null-values.test.js +0 -184
  157. package/tests/row-editor-timestamp-preview.test.js +0 -192
  158. package/tests/security-paths.test.js +0 -84
  159. package/tests/settings-api-tokens-route.test.js +0 -97
  160. package/tests/settings-metadata.test.js +0 -114
  161. package/tests/settings-view.test.js +0 -108
  162. package/tests/sql-formatter.test.js +0 -173
  163. package/tests/sql-highlight.test.js +0 -38
  164. package/tests/sql-identifier-safety.test.js +0 -171
  165. package/tests/sql-result-limit.test.js +0 -66
  166. package/tests/structure-view.test.js +0 -60
  167. package/tests/table-designer-v2-unique-constraints.test.js +0 -78
  168. package/tests/table-scroll-state.test.js +0 -70
  169. package/tests/text-cell-stats.test.js +0 -38
@@ -1,6 +1,6 @@
1
1
  import { renderPageHeader } from '../components/pageHeader.js';
2
2
  import { renderTextInput } from '../components/formControls.js';
3
- import { escapeHtml } from '../utils/format.js';
3
+ import { escapeHtml, formatCompactDateTime, formatNumber } from '../utils/format.js';
4
4
 
5
5
  function renderSettingsNavigation(activeSection) {
6
6
  const items = [
@@ -90,6 +90,81 @@ function renderVersionCheckStatus(settings) {
90
90
  `;
91
91
  }
92
92
 
93
+ function renderApiTokenRow(token, tokenSaving) {
94
+ const callCount = Number(token.callCount ?? 0);
95
+ const createdAt = token.createdAt ? formatCompactDateTime(token.createdAt) : 'Unknown';
96
+ const lastCallAt = token.lastCallAt ? formatCompactDateTime(token.lastCallAt) : 'Never';
97
+
98
+ return `
99
+ <div class="grid grid-cols-1 gap-4 border-t border-outline-variant/10 bg-surface-container-high px-4 py-4 xl:grid-cols-[minmax(0,1.35fr)_minmax(8rem,0.75fr)_7rem_minmax(8rem,0.85fr)_8rem] xl:items-center">
100
+ <div class="min-w-0">
101
+ <div class="truncate text-sm font-semibold text-on-surface" title="${escapeHtml(token.name)}">
102
+ ${escapeHtml(token.name)}
103
+ </div>
104
+ <div
105
+ class="mt-2 inline-flex max-w-full border border-outline-variant/10 bg-surface-container-lowest px-3 py-1 font-mono text-[11px] text-primary-container"
106
+ title="${escapeHtml(token.tokenPrefix ?? '')}"
107
+ >
108
+ <span class="truncate">${escapeHtml(token.tokenPrefix)}...</span>
109
+ </div>
110
+ </div>
111
+ <div>
112
+ <div class="text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/45 xl:hidden">Created</div>
113
+ <div class="mt-1 font-mono text-[11px] text-on-surface-variant/75 xl:mt-0" title="${escapeHtml(token.createdAt ?? 'unknown')}">
114
+ ${escapeHtml(createdAt)}
115
+ </div>
116
+ </div>
117
+ <div>
118
+ <div class="text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/45 xl:hidden">Calls</div>
119
+ <div class="mt-1 font-mono text-lg font-bold text-on-surface xl:mt-0">
120
+ ${escapeHtml(formatNumber(callCount))}
121
+ </div>
122
+ </div>
123
+ <div>
124
+ <div class="text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/45 xl:hidden">Last Call</div>
125
+ <div class="mt-1 font-mono text-[11px] text-on-surface-variant/75 xl:mt-0" title="${escapeHtml(token.lastCallAt ?? 'never')}">
126
+ ${escapeHtml(lastCallAt)}
127
+ </div>
128
+ </div>
129
+ <div class="flex xl:justify-end">
130
+ <button
131
+ class="delete-button"
132
+ data-action="open-delete-api-token-modal"
133
+ data-token-id="${escapeHtml(token.id)}"
134
+ type="button"
135
+ ${tokenSaving ? 'disabled' : ''}
136
+ >
137
+ <span class="material-symbols-outlined text-sm">delete</span>
138
+ Delete
139
+ </button>
140
+ </div>
141
+ </div>
142
+ `;
143
+ }
144
+
145
+ function renderApiTokenRows(apiTokens, tokenSaving) {
146
+ if (!apiTokens.length) {
147
+ return `
148
+ <div class="border border-outline-variant/10 bg-surface-container-high px-4 py-5 text-sm text-on-surface-variant">
149
+ No API tokens exist for this database.
150
+ </div>
151
+ `;
152
+ }
153
+
154
+ return `
155
+ <div class="overflow-hidden border border-outline-variant/10">
156
+ <div class="hidden bg-surface-container-highest px-4 py-3 font-mono text-[10px] uppercase tracking-[0.18em] text-on-surface-variant/55 xl:grid xl:grid-cols-[minmax(0,1.35fr)_minmax(8rem,0.75fr)_7rem_minmax(8rem,0.85fr)_8rem]">
157
+ <div>Token</div>
158
+ <div>Created</div>
159
+ <div>Calls</div>
160
+ <div>Last Call</div>
161
+ <div class="text-right">Actions</div>
162
+ </div>
163
+ ${apiTokens.map(token => renderApiTokenRow(token, tokenSaving)).join('')}
164
+ </div>
165
+ `;
166
+ }
167
+
93
168
  function renderSettingsContent(state) {
94
169
  if (state.settings.loading && !state.settings.appVersion) {
95
170
  return `
@@ -105,7 +180,7 @@ function renderSettingsContent(state) {
105
180
  if (state.settings.error) {
106
181
  return `
107
182
  <div class="border border-error/20 bg-error-container/10 px-6 py-5 text-sm text-on-surface">
108
- <div class="font-headline text-xs font-bold uppercase tracking-[0.18em] text-error">
183
+ <div class="font-body text-xs font-bold uppercase tracking-[0.18em] text-error">
109
184
  ${escapeHtml(state.settings.error.code)}
110
185
  </div>
111
186
  <div class="mt-2">${escapeHtml(state.settings.error.message)}</div>
@@ -121,32 +196,7 @@ function renderSettingsContent(state) {
121
196
  const apiTokens = state.settings.apiTokens ?? [];
122
197
  const createdApiToken = state.settings.createdApiToken;
123
198
  const tokenSaving = Boolean(state.settings.tokenSaving);
124
- const tokenItems = apiTokens.length
125
- ? apiTokens
126
- .map(
127
- token => `
128
- <div class="flex flex-col gap-3 border border-outline-variant/10 bg-surface-container-high px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
129
- <div class="min-w-0">
130
- <div class="truncate text-sm font-semibold text-on-surface">${escapeHtml(token.name)}</div>
131
- <div class="mt-1 font-mono text-[10px] text-on-surface-variant/60">
132
- ${escapeHtml(token.tokenPrefix)}... · Created ${escapeHtml(token.createdAt ?? 'unknown')}
133
- </div>
134
- </div>
135
- <button
136
- class="delete-button"
137
- data-action="open-delete-api-token-modal"
138
- data-token-id="${escapeHtml(token.id)}"
139
- type="button"
140
- ${tokenSaving ? 'disabled' : ''}
141
- >
142
- <span class="material-symbols-outlined text-sm">delete</span>
143
- Delete
144
- </button>
145
- </div>
146
- `,
147
- )
148
- .join('')
149
- : '<div class="text-sm text-on-surface-variant">No API tokens exist for this database.</div>';
199
+ const tokenItems = renderApiTokenRows(apiTokens, tokenSaving);
150
200
  const tokenSection = tokenDatabase
151
201
  ? `
152
202
  <div class="space-y-5">
@@ -217,7 +267,7 @@ function renderSettingsContent(state) {
217
267
  <div class="text-[10px] font-mono uppercase tracking-[0.2em] text-on-surface-variant/60">
218
268
  Current_Version
219
269
  </div>
220
- <div class="mt-2 font-headline text-4xl font-black uppercase tracking-tight text-primary-container">
270
+ <div class="mt-2 font-body text-4xl font-black uppercase tracking-tight text-primary-container">
221
271
  v${appVersion}
222
272
  </div>
223
273
  </div>
@@ -225,7 +275,7 @@ function renderSettingsContent(state) {
225
275
  <div class="text-[10px] font-mono uppercase tracking-[0.2em] text-on-surface-variant/60">
226
276
  SQLite_Runtime
227
277
  </div>
228
- <div class="mt-2 font-headline text-4xl font-black uppercase tracking-tight text-primary-container">
278
+ <div class="mt-2 font-body text-4xl font-black uppercase tracking-tight text-primary-container">
229
279
  ${sqliteVersion}
230
280
  </div>
231
281
  </div>
@@ -1,5 +1,6 @@
1
1
  import { clearInspector, renderDdlSection, renderInspector } from '../components/structureGraph.js';
2
2
  import { renderDropdownButton } from '../components/dropdownButton.js';
3
+ import { renderGenerateTypesDropdown } from '../components/generateTypesDropdown.js';
3
4
  import { renderWorkspaceOpenDropdown } from '../components/workspaceOpenDropdown.js';
4
5
  import { escapeHtml, formatNumber } from '../utils/format.js';
5
6
 
@@ -247,11 +248,21 @@ function renderGraphSurface(structure, selectedName, detail, detailLoading, tabl
247
248
  label: 'Table Designer',
248
249
  target: tableName => `/table-designer/${encodeURIComponent(tableName)}`,
249
250
  },
251
+ {
252
+ icon: 'troubleshoot',
253
+ key: 'table-advisor',
254
+ label: 'Table Advisor',
255
+ target: tableName => `/table-advisor/${encodeURIComponent(tableName)}`,
256
+ },
250
257
  {
251
258
  key: 'sql-editor',
252
259
  },
253
260
  ],
254
261
  })}
262
+ ${renderGenerateTypesDropdown({
263
+ selectedTableName: selectedGraphTable?.name ?? '',
264
+ hasTables: Boolean(graph.tables?.length),
265
+ })}
255
266
  ${renderDropdownButton({
256
267
  icon: 'format_shapes',
257
268
  label: 'Format',
@@ -394,7 +405,7 @@ export function renderStructureView(state) {
394
405
  : state.structure.error
395
406
  ? `
396
407
  <div class="min-h-0 flex-1 border-t border-error/20 bg-error-container/10 px-6 py-5 text-sm text-on-surface">
397
- <div class="font-headline text-xs font-bold uppercase tracking-[0.18em] text-error">
408
+ <div class="font-body text-xs font-bold uppercase tracking-[0.18em] text-error">
398
409
  ${escapeHtml(state.structure.error.code)}
399
410
  </div>
400
411
  <div class="mt-2">${escapeHtml(state.structure.error.message)}</div>
@@ -0,0 +1,385 @@
1
+ import {
2
+ escapeHtml,
3
+ formatDateTime,
4
+ formatNumber,
5
+ highlightSql,
6
+ truncateMiddle,
7
+ } from '../utils/format.js';
8
+ import { renderWorkspaceOpenDropdown } from '../components/workspaceOpenDropdown.js';
9
+
10
+ const CATEGORY_LABELS = {
11
+ schema: 'Schema',
12
+ constraints: 'Constraints',
13
+ performance: 'Performance',
14
+ 'data-quality': 'Data Quality',
15
+ documentation: 'Documentation',
16
+ };
17
+
18
+ const CATEGORY_ORDER = ['schema', 'constraints', 'performance', 'data-quality', 'documentation'];
19
+
20
+ function getScoreClass(score) {
21
+ if (score >= 80) {
22
+ return 'text-primary-container';
23
+ }
24
+
25
+ if (score >= 60) {
26
+ return 'text-on-surface';
27
+ }
28
+
29
+ return 'text-error';
30
+ }
31
+
32
+ function renderError(error) {
33
+ if (!error) {
34
+ return '';
35
+ }
36
+
37
+ return `
38
+ <div class="border border-error/20 bg-error-container/10 px-6 py-5 text-sm text-on-surface">
39
+ <div class="font-body text-xs font-bold uppercase tracking-[0.18em] text-error">
40
+ ${escapeHtml(error.code ?? 'REQUEST_FAILED')}
41
+ </div>
42
+ <div class="mt-2">${escapeHtml(error.message ?? 'Request failed.')}</div>
43
+ </div>
44
+ `;
45
+ }
46
+
47
+ function renderSeverityBadge(severity = 'info') {
48
+ const normalized = String(severity ?? 'info').toLowerCase();
49
+ const className =
50
+ normalized === 'critical'
51
+ ? 'border-error/35 bg-error-container/25 text-error'
52
+ : normalized === 'warning'
53
+ ? 'border-primary-container/35 bg-primary-container/15 text-primary-container'
54
+ : 'border-outline-variant/20 bg-surface-container-lowest text-on-surface-variant';
55
+
56
+ return `<span class="inline-flex h-6 items-center border px-2 font-mono text-[10px] font-bold uppercase tracking-[0.16em] ${className}">${escapeHtml(
57
+ normalized,
58
+ )}</span>`;
59
+ }
60
+
61
+ function renderRiskBadge(risk = 'low') {
62
+ const normalized = String(risk ?? 'low').toLowerCase();
63
+ const className =
64
+ normalized === 'high'
65
+ ? 'border-error/35 text-error'
66
+ : normalized === 'medium'
67
+ ? 'border-primary-container/35 text-primary-container'
68
+ : 'border-outline-variant/20 text-on-surface-variant/70';
69
+
70
+ return `<span class="inline-flex h-6 items-center border bg-surface-container-lowest px-2 font-mono text-[10px] font-bold uppercase tracking-[0.16em] ${className}">Risk ${escapeHtml(
71
+ normalized,
72
+ )}</span>`;
73
+ }
74
+
75
+ function renderTableList(state) {
76
+ const { tables = [], selectedTableName } = state.tableAdvisor;
77
+
78
+ if (state.tableAdvisor.loading && !tables.length) {
79
+ return `
80
+ <div class="px-4 py-6 text-sm text-on-surface-variant/45">
81
+ Loading tables...
82
+ </div>
83
+ `;
84
+ }
85
+
86
+ if (!tables.length) {
87
+ return `
88
+ <div class="px-4 py-6 text-sm text-on-surface-variant/45">
89
+ No tables found.
90
+ </div>
91
+ `;
92
+ }
93
+
94
+ return `
95
+ <div class="subnavi-list custom-scrollbar">
96
+ ${tables
97
+ .map(table => {
98
+ const isActive = table.name === selectedTableName;
99
+
100
+ return `
101
+ <a
102
+ class="table-designer-sidebar__item subnavi-item block border px-4 py-3 text-left transition-colors ${
103
+ isActive
104
+ ? 'is-active border-primary-container/30 bg-surface-container-high'
105
+ : 'border-outline-variant/10 bg-surface-container-lowest hover:bg-surface-container-high'
106
+ }"
107
+ href="#/table-advisor/${encodeURIComponent(table.name)}"
108
+ title="${escapeHtml(table.name)}"
109
+ >
110
+ <div class="table-designer-sidebar__item-name ${isActive ? 'is-active' : ''}">
111
+ ${escapeHtml(table.name)}
112
+ </div>
113
+ <div class="table-designer-sidebar__item-meta">
114
+ ${escapeHtml(formatNumber(table.columnCount ?? 0))} columns
115
+ </div>
116
+ </a>
117
+ `;
118
+ })
119
+ .join('')}
120
+ </div>
121
+ `;
122
+ }
123
+
124
+ function renderSummary(result = {}) {
125
+ const score = Number(result.score ?? 0);
126
+
127
+ return `
128
+ <div class="grid grid-cols-1 gap-3 md:grid-cols-4">
129
+ <div class="border border-outline-variant/10 bg-surface-container-low px-4 py-3">
130
+ <div class="font-mono text-[10px] uppercase tracking-[0.2em] text-on-surface-variant/55">Table</div>
131
+ <div class="mt-2 min-w-0 truncate text-lg font-black text-on-surface" title="${escapeHtml(
132
+ result.tableName ?? '',
133
+ )}">${escapeHtml(result.tableName ?? 'Unknown')}</div>
134
+ </div>
135
+ <div class="border border-outline-variant/10 bg-surface-container-low px-4 py-3">
136
+ <div class="font-mono text-[10px] uppercase tracking-[0.2em] text-on-surface-variant/55">Score</div>
137
+ <div class="mt-2 text-3xl font-black ${getScoreClass(score)}">${escapeHtml(formatNumber(score))}</div>
138
+ </div>
139
+ <div class="border border-outline-variant/10 bg-surface-container-low px-4 py-3">
140
+ <div class="font-mono text-[10px] uppercase tracking-[0.2em] text-on-surface-variant/55">Issues</div>
141
+ <div class="mt-2 text-3xl font-black text-on-surface">${escapeHtml(formatNumber(result.issueCount ?? 0))}</div>
142
+ </div>
143
+ <div class="border border-outline-variant/10 bg-surface-container-low px-4 py-3">
144
+ <div class="font-mono text-[10px] uppercase tracking-[0.2em] text-on-surface-variant/55">Rows</div>
145
+ <div class="mt-2 text-3xl font-black text-on-surface">${escapeHtml(formatNumber(result.rowCount ?? 0))}</div>
146
+ <div class="mt-1 truncate font-mono text-[10px] uppercase tracking-[0.14em] text-on-surface-variant/45">
147
+ ${escapeHtml(formatDateTime(result.analyzedAt))}
148
+ </div>
149
+ </div>
150
+ </div>
151
+ `;
152
+ }
153
+
154
+ function renderIssue(issue = {}) {
155
+ const sql = String(issue.sql ?? '').trim();
156
+
157
+ return `
158
+ <article class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-4">
159
+ <div class="flex flex-wrap items-start justify-between gap-3">
160
+ <div class="min-w-0">
161
+ <div class="flex flex-wrap items-center gap-2">
162
+ ${renderSeverityBadge(issue.severity)}
163
+ ${renderRiskBadge(issue.risk)}
164
+ </div>
165
+ <h4 class="mt-3 text-lg font-black text-on-surface">${escapeHtml(issue.title ?? 'Advisor issue')}</h4>
166
+ </div>
167
+ <div class="font-mono text-[10px] uppercase tracking-[0.16em] text-on-surface-variant/45" title="${escapeHtml(
168
+ issue.id ?? '',
169
+ )}">
170
+ ${escapeHtml(truncateMiddle(issue.id ?? '', 42))}
171
+ </div>
172
+ </div>
173
+ <div class="mt-3 grid grid-cols-1 gap-3 text-sm leading-6 text-on-surface-variant/75 lg:grid-cols-3">
174
+ <div>
175
+ <div class="font-mono text-[10px] uppercase tracking-[0.18em] text-on-surface-variant/45">Explanation</div>
176
+ <p class="mt-1">${escapeHtml(issue.explanation ?? '')}</p>
177
+ </div>
178
+ <div>
179
+ <div class="font-mono text-[10px] uppercase tracking-[0.18em] text-on-surface-variant/45">Evidence</div>
180
+ <p class="mt-1">${escapeHtml(issue.evidence ?? '')}</p>
181
+ </div>
182
+ <div>
183
+ <div class="font-mono text-[10px] uppercase tracking-[0.18em] text-on-surface-variant/45">Recommendation</div>
184
+ <p class="mt-1">${escapeHtml(issue.recommendation ?? '')}</p>
185
+ </div>
186
+ </div>
187
+ ${
188
+ issue.risk === 'high'
189
+ ? `<div class="mt-3 border border-error/20 bg-error-container/15 px-3 py-2 text-xs leading-5 text-error">
190
+ This change may require rebuilding the table. Create a backup before applying it.
191
+ </div>`
192
+ : ''
193
+ }
194
+ ${
195
+ sql
196
+ ? `<div class="mt-4 border border-outline-variant/10 bg-surface-container-low">
197
+ <div class="flex items-center justify-between gap-3 border-b border-outline-variant/10 px-3 py-2">
198
+ <div class="font-mono text-[10px] uppercase tracking-[0.18em] text-primary-container">SQL</div>
199
+ <button
200
+ class="standard-button min-h-8 px-3 py-1 text-[10px]"
201
+ data-action="copy-table-advisor-sql"
202
+ data-issue-id="${escapeHtml(issue.id ?? '')}"
203
+ type="button"
204
+ >
205
+ <span class="material-symbols-outlined text-sm">content_copy</span>
206
+ Copy SQL
207
+ </button>
208
+ </div>
209
+ <pre class="custom-scrollbar max-h-48 overflow-auto whitespace-pre-wrap px-3 py-3 text-xs leading-6 text-on-surface"><code>${highlightSql(
210
+ sql,
211
+ )}</code></pre>
212
+ </div>`
213
+ : ''
214
+ }
215
+ </article>
216
+ `;
217
+ }
218
+
219
+ function renderIssues(result = {}) {
220
+ const issues = result.issues ?? [];
221
+
222
+ if (!issues.length) {
223
+ return `
224
+ <div class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-6 text-sm leading-6 text-on-surface-variant/70">
225
+ No table advisor issues found for this table.
226
+ </div>
227
+ `;
228
+ }
229
+
230
+ return CATEGORY_ORDER.map(category => {
231
+ const categoryIssues = issues.filter(issue => issue.category === category);
232
+
233
+ if (!categoryIssues.length) {
234
+ return '';
235
+ }
236
+
237
+ return `
238
+ <section class="space-y-3">
239
+ <div class="flex items-center justify-between gap-3 border-b border-outline-variant/10 pb-2">
240
+ <h3 class="font-mono text-[11px] uppercase tracking-[0.22em] text-primary-container">
241
+ ${escapeHtml(CATEGORY_LABELS[category] ?? category)}
242
+ </h3>
243
+ <div class="font-mono text-[10px] uppercase tracking-[0.16em] text-on-surface-variant/45">
244
+ ${escapeHtml(formatNumber(categoryIssues.length))}
245
+ </div>
246
+ </div>
247
+ ${categoryIssues.map(issue => renderIssue(issue)).join('')}
248
+ </section>
249
+ `;
250
+ }).join('');
251
+ }
252
+
253
+ function renderAdvisorBody(state) {
254
+ const advisor = state.tableAdvisor;
255
+
256
+ if (advisor.error) {
257
+ return renderError(advisor.error);
258
+ }
259
+
260
+ if (advisor.loading && !advisor.result) {
261
+ return `
262
+ <div class="flex min-h-[280px] items-center justify-center border border-outline-variant/10 bg-surface-container-low">
263
+ <div class="text-center text-on-surface-variant/40">
264
+ <span class="material-symbols-outlined mb-3 text-4xl">progress_activity</span>
265
+ <p class="font-mono text-[10px] uppercase tracking-[0.22em]">LOADING_TABLES</p>
266
+ </div>
267
+ </div>
268
+ `;
269
+ }
270
+
271
+ if (!advisor.selectedTableName) {
272
+ return `
273
+ <div class="border border-dashed border-outline-variant/20 bg-surface-container-low px-8 py-10 text-center">
274
+ <span class="material-symbols-outlined mb-3 text-5xl text-on-surface-variant/25">troubleshoot</span>
275
+ <p class="font-body text-xl font-black uppercase tracking-tight text-primary-container">
276
+ No table selected
277
+ </p>
278
+ </div>
279
+ `;
280
+ }
281
+
282
+ if (advisor.analysisLoading) {
283
+ return `
284
+ <div class="flex min-h-[280px] items-center justify-center border border-outline-variant/10 bg-surface-container-low">
285
+ <div class="text-center text-on-surface-variant/40">
286
+ <span class="material-symbols-outlined mb-3 text-4xl">progress_activity</span>
287
+ <p class="font-mono text-[10px] uppercase tracking-[0.22em]">ANALYZING_TABLE</p>
288
+ </div>
289
+ </div>
290
+ `;
291
+ }
292
+
293
+ if (advisor.analysisError) {
294
+ return renderError(advisor.analysisError);
295
+ }
296
+
297
+ if (!advisor.result) {
298
+ return '';
299
+ }
300
+
301
+ return `
302
+ <div class="space-y-6">
303
+ ${renderSummary(advisor.result)}
304
+ ${renderIssues(advisor.result)}
305
+ </div>
306
+ `;
307
+ }
308
+
309
+ function renderToolbar(state) {
310
+ const tableName = state.tableAdvisor.selectedTableName;
311
+
312
+ return `
313
+ <header class="workspace-header">
314
+ <div class="min-w-0">
315
+ <div class="font-mono text-[10px] uppercase tracking-[0.26em] text-primary-container/70">
316
+ Local analysis
317
+ </div>
318
+ <h1 class="mt-1 truncate font-body text-3xl font-black uppercase tracking-tight text-primary-container">
319
+ Table Advisor
320
+ </h1>
321
+ </div>
322
+ <div class="ml-auto flex flex-wrap items-center justify-end gap-3">
323
+ ${renderWorkspaceOpenDropdown({
324
+ tableName: tableName ?? '',
325
+ destinations: [
326
+ {
327
+ icon: 'table_rows',
328
+ key: 'data',
329
+ label: 'Data',
330
+ target: name => `/data/${encodeURIComponent(name)}`,
331
+ },
332
+ {
333
+ icon: 'account_tree',
334
+ key: 'structure',
335
+ label: 'Structure',
336
+ target: name => `/structure/${encodeURIComponent(name)}`,
337
+ },
338
+ {
339
+ icon: 'table_chart',
340
+ key: 'table-designer',
341
+ label: 'Table Designer',
342
+ target: name => `/table-designer/${encodeURIComponent(name)}`,
343
+ },
344
+ {
345
+ key: 'sql-editor',
346
+ },
347
+ ],
348
+ })}
349
+ <button class="standard-button" data-action="refresh-view" type="button">
350
+ <span class="material-symbols-outlined text-sm">refresh</span>
351
+ Refresh
352
+ </button>
353
+ </div>
354
+ </header>
355
+ `;
356
+ }
357
+
358
+ export function renderTableAdvisorView(state) {
359
+ return {
360
+ main: `
361
+ <section class="view-surface flex h-full min-h-full min-h-0 flex-col overflow-hidden bg-surface-container">
362
+ <div class="data-view-grid data-view-grid--with-subnavi">
363
+ <aside class="subnavi-panel border-r border-outline-variant/10 bg-surface-low">
364
+ <div class="subnavi-header">
365
+ <div>
366
+ <div class="subnavi-header-title">Tables</div>
367
+ <div class="subnavi-header-details">
368
+ total ${escapeHtml(formatNumber(state.tableAdvisor.tables.length))}
369
+ </div>
370
+ </div>
371
+ </div>
372
+ ${renderTableList(state)}
373
+ </aside>
374
+ <section class="flex min-h-0 flex-col overflow-hidden">
375
+ ${renderToolbar(state)}
376
+ <div class="custom-scrollbar min-h-0 flex-1 overflow-auto p-6">
377
+ ${renderAdvisorBody(state)}
378
+ </div>
379
+ </section>
380
+ </div>
381
+ </section>
382
+ `,
383
+ panel: '',
384
+ };
385
+ }
@@ -51,6 +51,12 @@ function renderWorkspaceToolbar(state) {
51
51
  label: "Structure",
52
52
  target: name => `/structure/${encodeURIComponent(name)}`,
53
53
  },
54
+ {
55
+ icon: "troubleshoot",
56
+ key: "table-advisor",
57
+ label: "Table Advisor",
58
+ target: name => `/table-advisor/${encodeURIComponent(name)}`,
59
+ },
54
60
  {
55
61
  key: "sql-editor",
56
62
  },
@@ -129,11 +129,6 @@ img {
129
129
  display: none !important;
130
130
  }
131
131
 
132
- .font-headline {
133
- font-family: var(--font-family-headline);
134
- word-break: break-all;
135
- }
136
-
137
132
  .font-body {
138
133
  font-family: var(--font-family-body);
139
134
  }
@@ -159,17 +154,6 @@ img {
159
154
  vertical-align: middle;
160
155
  }
161
156
 
162
- .no-scrollbar {
163
- -ms-overflow-style: none;
164
- scrollbar-width: none;
165
- }
166
-
167
- .no-scrollbar::-webkit-scrollbar {
168
- display: none;
169
- height: 0;
170
- width: 0;
171
- }
172
-
173
157
  .custom-scrollbar {
174
158
  scrollbar-color: var(--color-surface-highest) var(--color-surface-lowest);
175
159
  scrollbar-width: thin;
@@ -193,10 +177,7 @@ img {
193
177
  }
194
178
 
195
179
  .clipped-btn,
196
- .clipped-corner,
197
- .custom-clip,
198
- .clip-button,
199
- .clip-corner {
180
+ .clipped-corner {
200
181
  clip-path: var(--clip-path);
201
182
  }
202
183
 
@@ -274,28 +255,12 @@ img {
274
255
  color: transparent;
275
256
  }
276
257
 
277
- .cursor-blink {
278
- animation: cursor-blink var(--duration-cursor-blink) step-end infinite;
279
- border-right: 2px solid var(--color-primary-container);
280
- }
281
-
282
258
  .text-xs {
283
259
  white-space: nowrap;
284
260
  overflow: hidden;
285
261
  text-overflow: ellipsis;
286
262
  }
287
263
 
288
- @keyframes cursor-blink {
289
- from,
290
- to {
291
- border-color: transparent;
292
- }
293
-
294
- 50% {
295
- border-color: var(--color-primary-container);
296
- }
297
- }
298
-
299
264
  @media (prefers-reduced-motion: reduce) {
300
265
  *,
301
266
  *::before,
@@ -306,8 +271,4 @@ img {
306
271
  transition-duration: 0.001ms;
307
272
  }
308
273
 
309
- .cursor-blink {
310
- animation: none;
311
- border-right-color: var(--color-primary-container);
312
- }
313
274
  }