sqlite-hub 0.9.1 → 0.9.4

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 (56) hide show
  1. package/.github/workflows/ci.yml +36 -0
  2. package/README.md +2 -2
  3. package/bin/sqlite-hub.js +1 -1
  4. package/frontend/index.html +3 -158
  5. package/frontend/js/app.js +231 -5
  6. package/frontend/js/components/connectionCard.js +62 -87
  7. package/frontend/js/components/emptyState.js +20 -23
  8. package/frontend/js/components/modal.js +158 -199
  9. package/frontend/js/components/pageHeader.js +1 -1
  10. package/frontend/js/components/queryEditor.js +16 -30
  11. package/frontend/js/components/queryHistoryDetail.js +93 -164
  12. package/frontend/js/components/queryHistoryPanel.js +90 -100
  13. package/frontend/js/components/queryResults.js +3 -1
  14. package/frontend/js/components/rowEditorPanel.js +76 -56
  15. package/frontend/js/components/tableDesignerEditor.js +91 -116
  16. package/frontend/js/lib/queryChartOptions.js +5 -1
  17. package/frontend/js/lib/queryCharts.js +50 -2
  18. package/frontend/js/store.js +161 -23
  19. package/frontend/js/utils/tableDesigner.js +8 -2
  20. package/frontend/js/views/charts.js +126 -73
  21. package/frontend/js/views/data.js +147 -133
  22. package/frontend/js/views/editor.js +1 -0
  23. package/frontend/js/views/mediaTagging.js +131 -164
  24. package/frontend/js/views/structure.js +52 -48
  25. package/frontend/styles/base.css +57 -13
  26. package/frontend/styles/components.css +166 -96
  27. package/frontend/styles/layout.css +1 -1
  28. package/frontend/styles/structure-graph.css +11 -11
  29. package/frontend/styles/tailwind.css +81 -0
  30. package/frontend/styles/tailwind.generated.css +1 -0
  31. package/frontend/styles/tokens.css +50 -7
  32. package/frontend/styles/utilities.css +21 -0
  33. package/frontend/styles/views.css +94 -86
  34. package/package.json +16 -3
  35. package/server/routes/mediaTagging.js +2 -10
  36. package/server/routes/sql.js +35 -10
  37. package/server/server.js +24 -0
  38. package/server/services/sqlite/dataBrowserService.js +25 -5
  39. package/server/services/sqlite/exportService.js +4 -2
  40. package/server/services/sqlite/introspection.js +2 -2
  41. package/server/services/sqlite/mediaTaggingService.js +166 -53
  42. package/server/services/sqlite/structureService.js +2 -2
  43. package/server/services/sqlite/tableDesigner/sql.js +19 -3
  44. package/server/services/storage/appStateStore.js +227 -87
  45. package/server/services/storage/queryHistoryChartUtils.js +19 -2
  46. package/server/utils/appPaths.js +55 -19
  47. package/server/utils/fileValidation.js +94 -8
  48. package/tailwind.config.cjs +73 -0
  49. package/tests/security-paths.test.js +84 -0
  50. package/tests/sql-identifier-safety.test.js +66 -0
  51. package/.npmingnore +0 -4
  52. package/changelog.md +0 -77
  53. package/docs/DESIGN_GUIDELINES.md +0 -36
  54. package/scripts/publish_brew.sh +0 -466
  55. package/scripts/publish_npm.sh +0 -241
  56. package/shortkeys.md +0 -5
@@ -29,7 +29,11 @@ const DEFAULT_SETTINGS = {
29
29
  maxPageSize: 200,
30
30
  csvDelimiter: ',',
31
31
  };
32
+ const DEFAULT_DATA_PAGE_SIZE = 50;
32
33
  const DATA_PAGE_SIZES = [25, 50, 100];
34
+ const DATA_ROW_SIZE_STORAGE_KEY = 'data_row_size';
35
+ const CHARTS_HISTORY_TAB_STORAGE_KEY = 'charts_history_tab';
36
+ const QUERY_HISTORY_TAB_STORAGE_KEY = 'query_history_tab';
33
37
  const QUERY_HISTORY_PAGE_SIZE = 30;
34
38
  const QUERY_HISTORY_RUN_LIMIT = 8;
35
39
  const CHART_HEIGHT_PRESETS = new Set(['small', 'medium', 'large']);
@@ -46,6 +50,54 @@ let chartsLoadVersion = 0;
46
50
  let chartsDetailLoadVersion = 0;
47
51
  let mediaTaggingPreviewVersion = 0;
48
52
 
53
+ function readStoredDataPageSize(fallback = DEFAULT_DATA_PAGE_SIZE) {
54
+ try {
55
+ return normalizeDataPageSize(globalThis.localStorage?.getItem(DATA_ROW_SIZE_STORAGE_KEY), fallback);
56
+ } catch {
57
+ return fallback;
58
+ }
59
+ }
60
+
61
+ function storeDataPageSize(pageSize) {
62
+ try {
63
+ globalThis.localStorage?.setItem(DATA_ROW_SIZE_STORAGE_KEY, String(pageSize));
64
+ } catch {
65
+ // Ignore unavailable browser storage; the in-memory setting still applies.
66
+ }
67
+ }
68
+
69
+ function readStoredChartsHistoryTab(fallback = 'recent') {
70
+ try {
71
+ return normalizeChartsHistoryTab(globalThis.localStorage?.getItem(CHARTS_HISTORY_TAB_STORAGE_KEY) ?? fallback);
72
+ } catch {
73
+ return normalizeChartsHistoryTab(fallback);
74
+ }
75
+ }
76
+
77
+ function storeChartsHistoryTab(tab) {
78
+ try {
79
+ globalThis.localStorage?.setItem(CHARTS_HISTORY_TAB_STORAGE_KEY, normalizeChartsHistoryTab(tab));
80
+ } catch {
81
+ // Ignore unavailable browser storage; the in-memory setting still applies.
82
+ }
83
+ }
84
+
85
+ function readStoredQueryHistoryTab(fallback = 'recent') {
86
+ try {
87
+ return normalizeQueryHistoryTab(globalThis.localStorage?.getItem(QUERY_HISTORY_TAB_STORAGE_KEY) ?? fallback);
88
+ } catch {
89
+ return normalizeQueryHistoryTab(fallback);
90
+ }
91
+ }
92
+
93
+ function storeQueryHistoryTab(tab) {
94
+ try {
95
+ globalThis.localStorage?.setItem(QUERY_HISTORY_TAB_STORAGE_KEY, normalizeQueryHistoryTab(tab));
96
+ } catch {
97
+ // Ignore unavailable browser storage; the in-memory setting still applies.
98
+ }
99
+ }
100
+
49
101
  const state = {
50
102
  ready: false,
51
103
  route: { name: 'landing', path: '/', params: {} },
@@ -79,7 +131,7 @@ const state = {
79
131
  saving: false,
80
132
  deleting: false,
81
133
  page: 1,
82
- pageSize: 50,
134
+ pageSize: readStoredDataPageSize(),
83
135
  sortColumn: null,
84
136
  sortDirection: null,
85
137
  searchQuery: '',
@@ -99,7 +151,7 @@ const state = {
99
151
  historyLoading: false,
100
152
  historyLoadingMore: false,
101
153
  historyError: null,
102
- historyTab: 'recent',
154
+ historyTab: readStoredQueryHistoryTab(),
103
155
  historySearchInput: '',
104
156
  historySearch: '',
105
157
  historyPageSize: QUERY_HISTORY_PAGE_SIZE,
@@ -124,9 +176,11 @@ const state = {
124
176
  saveError: null,
125
177
  },
126
178
  charts: {
179
+ loaded: false,
127
180
  queries: [],
128
181
  loading: false,
129
182
  error: null,
183
+ historyTab: readStoredChartsHistoryTab(),
130
184
  selectedHistoryId: null,
131
185
  chartHeightPreset: 'medium',
132
186
  sqlExpanded: false,
@@ -666,9 +720,11 @@ function resetQueryHistoryState({ preserveSearch = true } = {}) {
666
720
  function resetChartsState() {
667
721
  chartsLoadVersion += 1;
668
722
  chartsDetailLoadVersion += 1;
723
+ state.charts.loaded = false;
669
724
  state.charts.queries = [];
670
725
  state.charts.loading = false;
671
726
  state.charts.error = null;
727
+ state.charts.historyTab = readStoredChartsHistoryTab(state.charts.historyTab);
672
728
  state.charts.selectedHistoryId = null;
673
729
  state.charts.chartHeightPreset = 'medium';
674
730
  state.charts.sqlExpanded = false;
@@ -689,6 +745,10 @@ function normalizeChartsHeightPreset(value) {
689
745
  return CHART_HEIGHT_PRESETS.has(normalizedValue) ? normalizedValue : 'medium';
690
746
  }
691
747
 
748
+ function normalizeChartsHistoryTab(value) {
749
+ return ['recent', 'saved'].includes(value) ? value : 'recent';
750
+ }
751
+
692
752
  function mergeQueryHistoryItemWithChartSummary(updatedItem, fallbackItem = null) {
693
753
  if (!updatedItem) {
694
754
  return updatedItem;
@@ -714,20 +774,24 @@ function syncQueryHistoryItem(updatedItem) {
714
774
  return;
715
775
  }
716
776
 
717
- state.editor.history = state.editor.history.map(entry => (entry.id === updatedItem.id ? updatedItem : entry));
777
+ const updatedItemId = String(updatedItem.id);
778
+
779
+ state.editor.history = state.editor.history.map(entry => (String(entry.id) === updatedItemId ? updatedItem : entry));
718
780
 
719
- if (state.editor.historyDetail?.id === updatedItem.id) {
781
+ if (String(state.editor.historyDetail?.id ?? '') === updatedItemId) {
720
782
  state.editor.historyDetail = updatedItem;
721
783
  }
722
784
 
723
785
  const existingChartsEntry =
724
- state.charts.queries.find(entry => entry.id === updatedItem.id) ??
725
- (state.charts.detail?.item?.id === updatedItem.id ? state.charts.detail.item : null);
786
+ state.charts.queries.find(entry => String(entry.id) === updatedItemId) ??
787
+ (String(state.charts.detail?.item?.id ?? '') === updatedItemId ? state.charts.detail.item : null);
726
788
  const mergedChartsItem = mergeQueryHistoryItemWithChartSummary(updatedItem, existingChartsEntry);
727
789
 
728
- state.charts.queries = state.charts.queries.map(entry => (entry.id === updatedItem.id ? mergedChartsItem : entry));
790
+ state.charts.queries = state.charts.queries.map(entry =>
791
+ String(entry.id) === updatedItemId ? mergedChartsItem : entry,
792
+ );
729
793
 
730
- if (state.charts.detail?.item?.id === updatedItem.id) {
794
+ if (String(state.charts.detail?.item?.id ?? '') === updatedItemId) {
731
795
  state.charts.detail = {
732
796
  ...state.charts.detail,
733
797
  item: mergeQueryHistoryItemWithChartSummary(updatedItem, state.charts.detail.item),
@@ -1169,7 +1233,56 @@ async function loadChartsDetail(historyId) {
1169
1233
  }
1170
1234
  }
1171
1235
 
1172
- async function loadCharts(version, route) {
1236
+ function getRequestedChartsHistoryId(route) {
1237
+ const requestedHistoryId = Number(route.params?.historyId ?? null);
1238
+
1239
+ return Number.isInteger(requestedHistoryId) && requestedHistoryId > 0 ? requestedHistoryId : null;
1240
+ }
1241
+
1242
+ function resolveLoadableChartsHistoryId(route) {
1243
+ const requestedHistoryId = getRequestedChartsHistoryId(route);
1244
+
1245
+ if (!requestedHistoryId) {
1246
+ return null;
1247
+ }
1248
+
1249
+ return state.charts.queries.some(item => item.id === requestedHistoryId) ? requestedHistoryId : null;
1250
+ }
1251
+
1252
+ function hasSettledChartsDetail(historyId) {
1253
+ if (state.charts.detailLoading || state.charts.resultLoading) {
1254
+ return false;
1255
+ }
1256
+
1257
+ if (historyId === null) {
1258
+ return (
1259
+ state.charts.selectedHistoryId === null &&
1260
+ !state.charts.detail &&
1261
+ !state.charts.result &&
1262
+ !state.charts.detailError &&
1263
+ !state.charts.resultError
1264
+ );
1265
+ }
1266
+
1267
+ return (
1268
+ state.charts.selectedHistoryId === historyId &&
1269
+ (state.charts.detail?.item?.id === historyId || Boolean(state.charts.detailError)) &&
1270
+ (Boolean(state.charts.result) || Boolean(state.charts.resultError))
1271
+ );
1272
+ }
1273
+
1274
+ async function loadCharts(version, route, options = {}) {
1275
+ if (!options.force && state.charts.loaded) {
1276
+ const historyId = resolveLoadableChartsHistoryId(route);
1277
+
1278
+ if (hasSettledChartsDetail(historyId)) {
1279
+ return;
1280
+ }
1281
+
1282
+ await loadChartsDetail(historyId);
1283
+ return;
1284
+ }
1285
+
1173
1286
  state.charts.loading = true;
1174
1287
  state.charts.error = null;
1175
1288
  emitChange();
@@ -1182,6 +1295,7 @@ async function loadCharts(version, route) {
1182
1295
  }
1183
1296
 
1184
1297
  state.charts.queries = response.data ?? [];
1298
+ state.charts.loaded = true;
1185
1299
  state.charts.error = null;
1186
1300
  } catch (error) {
1187
1301
  if (version !== routeLoadVersion) {
@@ -1189,6 +1303,7 @@ async function loadCharts(version, route) {
1189
1303
  }
1190
1304
 
1191
1305
  state.charts.queries = [];
1306
+ state.charts.loaded = false;
1192
1307
  state.charts.error = normalizeError(error);
1193
1308
  } finally {
1194
1309
  if (version === routeLoadVersion) {
@@ -1201,11 +1316,7 @@ async function loadCharts(version, route) {
1201
1316
  return;
1202
1317
  }
1203
1318
 
1204
- const requestedHistoryId = Number(route.params?.historyId ?? null);
1205
- const canLoadRequestedHistory =
1206
- Number.isInteger(requestedHistoryId) && state.charts.queries.some(item => item.id === requestedHistoryId);
1207
-
1208
- await loadChartsDetail(canLoadRequestedHistory ? requestedHistoryId : null);
1319
+ await loadChartsDetail(resolveLoadableChartsHistoryId(route));
1209
1320
  }
1210
1321
 
1211
1322
  async function loadOverview(version) {
@@ -1280,7 +1391,7 @@ async function resolvePendingDataBrowserRow(version) {
1280
1391
 
1281
1392
  async function loadDataTable(version) {
1282
1393
  const tableName = state.dataBrowser.selectedTable;
1283
- const pageSize = normalizeDataPageSize(state.dataBrowser.pageSize, 50);
1394
+ const pageSize = normalizeDataPageSize(state.dataBrowser.pageSize, DEFAULT_DATA_PAGE_SIZE);
1284
1395
  const page = Math.max(1, Number(state.dataBrowser.page) || 1);
1285
1396
  const sortColumn = state.dataBrowser.sortColumn;
1286
1397
  const sortDirection = normalizeSortDirection(state.dataBrowser.sortDirection);
@@ -1799,7 +1910,7 @@ function invalidateDatabaseCaches() {
1799
1910
  state.mediaTagging.tagFormValues = {};
1800
1911
  }
1801
1912
 
1802
- async function loadRouteData(route) {
1913
+ async function loadRouteData(route, options = {}) {
1803
1914
  clearRouteSlices();
1804
1915
 
1805
1916
  if (requiresActiveDatabase(route.name) && !state.connections.active) {
@@ -1808,7 +1919,7 @@ async function loadRouteData(route) {
1808
1919
  return;
1809
1920
  }
1810
1921
 
1811
- if (isMediaTaggingRouteName(route.name) && hasLoadedMediaTaggingForActiveConnection()) {
1922
+ if (!options.force && isMediaTaggingRouteName(route.name) && hasLoadedMediaTaggingForActiveConnection()) {
1812
1923
  return;
1813
1924
  }
1814
1925
 
@@ -1827,7 +1938,7 @@ async function loadRouteData(route) {
1827
1938
  await loadData(version, route);
1828
1939
  return;
1829
1940
  case 'charts':
1830
- await loadCharts(version, route);
1941
+ await loadCharts(version, route, options);
1831
1942
  return;
1832
1943
  case 'editor':
1833
1944
  case 'editorResults':
@@ -2201,6 +2312,22 @@ export function setChartsHeightPreset(preset) {
2201
2312
  emitChange();
2202
2313
  }
2203
2314
 
2315
+ export function setChartsHistoryTab(tab) {
2316
+ const nextTab = normalizeChartsHistoryTab(
2317
+ String(tab ?? '')
2318
+ .trim()
2319
+ .toLowerCase(),
2320
+ );
2321
+
2322
+ if (state.charts.historyTab === nextTab) {
2323
+ return;
2324
+ }
2325
+
2326
+ state.charts.historyTab = nextTab;
2327
+ storeChartsHistoryTab(nextTab);
2328
+ emitChange();
2329
+ }
2330
+
2204
2331
  export function updateCurrentQueryChartDraftField(field, value) {
2205
2332
  if (state.modal?.kind !== 'chart-editor' || !state.modal.draft) {
2206
2333
  return;
@@ -2529,6 +2656,7 @@ export async function setQueryHistoryTab(tab) {
2529
2656
  }
2530
2657
 
2531
2658
  state.editor.historyTab = normalizedTab;
2659
+ storeQueryHistoryTab(normalizedTab);
2532
2660
  state.editor.historyActiveId = null;
2533
2661
  clearQueryHistoryDetailState();
2534
2662
  emitChange();
@@ -2625,16 +2753,25 @@ export async function runQueryHistoryItem(historyId) {
2625
2753
  return executeCurrentQuery();
2626
2754
  }
2627
2755
 
2628
- export async function toggleQueryHistorySavedState(historyId, nextValue) {
2756
+ export async function toggleQueryHistorySavedState(historyId, nextValue, options = {}) {
2629
2757
  try {
2630
2758
  const response = await api.toggleQueryHistorySaved(historyId, nextValue);
2631
2759
  syncQueryHistoryItem(response.data);
2632
- pushToast(response.message || 'Query save state updated.', 'muted');
2633
- await refreshQueryHistoryState();
2760
+ if (options.toast !== false) {
2761
+ pushToast(response.message || 'Query save state updated.', 'muted');
2762
+ }
2763
+ if (options.notify !== false) {
2764
+ emitChange();
2765
+ }
2766
+ if (options.refresh !== false) {
2767
+ await refreshQueryHistoryState();
2768
+ }
2634
2769
  return true;
2635
2770
  } catch (error) {
2636
2771
  state.editor.historyDetailError = normalizeError(error);
2637
- emitChange();
2772
+ if (options.notify !== false) {
2773
+ emitChange();
2774
+ }
2638
2775
  return false;
2639
2776
  }
2640
2777
  }
@@ -3340,6 +3477,7 @@ export async function setDataPageSize(pageSize) {
3340
3477
  }
3341
3478
 
3342
3479
  state.dataBrowser.pageSize = normalizedPageSize;
3480
+ storeDataPageSize(normalizedPageSize);
3343
3481
  state.dataBrowser.page = 1;
3344
3482
  clearDataBrowserRowSelectionState();
3345
3483
  state.dataBrowser.saveError = null;
@@ -3735,7 +3873,7 @@ export function sortEditorResultsByColumn(columnName) {
3735
3873
  }
3736
3874
 
3737
3875
  export async function refreshCurrentRoute() {
3738
- await loadRouteData(state.route);
3876
+ await loadRouteData(state.route, { force: true });
3739
3877
  }
3740
3878
 
3741
3879
  export function showToast(message, tone = 'muted') {
@@ -713,13 +713,19 @@ function buildImportedInsertPreviewSql(draft, maxRows = 3) {
713
713
  }
714
714
 
715
715
  const columnSql = importedColumns.map((column) => quoteIdentifier(column.name)).join(", ");
716
- const insertPrefix = `INSERT INTO ${quoteIdentifier(draft.tableName)} (${columnSql}) VALUES`;
716
+ const insertPrefix = [
717
+ "INSERT INTO",
718
+ quoteIdentifier(draft.tableName),
719
+ "(",
720
+ columnSql,
721
+ ") VALUES",
722
+ ].join(" ");
717
723
  const previewStatements = draft.importedCsvRows.slice(0, maxRows).map((row) => {
718
724
  const valueSql = importedColumns
719
725
  .map((column) => formatImportedCellValueForSql(column, row[column.importedValueIndex] ?? ""))
720
726
  .join(", ");
721
727
 
722
- return `${insertPrefix} (${valueSql});`;
728
+ return [insertPrefix, "(", valueSql, ");"].join(" ");
723
729
  });
724
730
 
725
731
  if (draft.importedCsvRows.length > maxRows) {
@@ -20,10 +20,12 @@ function renderMissingDatabase() {
20
20
  }
21
21
 
22
22
  function renderChartsList(state) {
23
- const queries = state.charts.queries ?? [];
23
+ const activeTab = ['recent', 'saved'].includes(state.charts.historyTab) ? state.charts.historyTab : 'recent';
24
+ const allQueries = state.charts.queries ?? [];
25
+ const queries = activeTab === 'saved' ? allQueries.filter(item => item.isSaved) : allQueries;
24
26
  const selectedHistoryId = Number(state.charts.selectedHistoryId);
25
27
 
26
- if (state.charts.loading && !queries.length) {
28
+ if (state.charts.loading && !allQueries.length) {
27
29
  return `
28
30
  <div class="flex h-full items-center justify-center px-6 text-center text-on-surface-variant/45">
29
31
  <div>
@@ -34,7 +36,7 @@ function renderChartsList(state) {
34
36
  `;
35
37
  }
36
38
 
37
- if (state.charts.error && !queries.length) {
39
+ if (state.charts.error && !allQueries.length) {
38
40
  return `
39
41
  <div class="p-5">
40
42
  <div class="border border-error/30 bg-error-container/20 px-4 py-4 text-sm text-error">
@@ -45,15 +47,23 @@ function renderChartsList(state) {
45
47
  }
46
48
 
47
49
  if (!queries.length) {
50
+ const isSavedTab = activeTab === 'saved';
51
+
48
52
  return `
49
53
  <div class="flex h-full items-center justify-center px-6 text-center">
50
54
  <div>
51
- <span class="material-symbols-outlined mb-3 text-4xl text-on-surface-variant/25">query_stats</span>
55
+ <span class="material-symbols-outlined mb-3 text-4xl text-on-surface-variant/25">${
56
+ isSavedTab ? 'bookmark' : 'query_stats'
57
+ }</span>
52
58
  <p class="font-headline text-lg font-black uppercase tracking-tight text-on-surface">
53
- No Chartable Queries
59
+ ${isSavedTab ? 'No Saved Charts Queries' : 'No Chartable Queries'}
54
60
  </p>
55
61
  <p class="mt-2 max-w-xs text-sm leading-6 text-on-surface-variant/60">
56
- Run SELECT queries in the SQL Editor first. They will appear here automatically.
62
+ ${
63
+ isSavedTab
64
+ ? 'Save chartable queries from this list or from the SQL Editor to keep them here.'
65
+ : 'Run SELECT queries in the SQL Editor first. They will appear here automatically.'
66
+ }
57
67
  </p>
58
68
  </div>
59
69
  </div>
@@ -66,36 +76,67 @@ function renderChartsList(state) {
66
76
  ${queries
67
77
  .map(
68
78
  item => `
69
- <button
70
- class="w-full border px-4 py-3 text-left transition-colors ${
71
- selectedHistoryId === item.id
79
+ <article
80
+ class="group w-full border transition-colors ${
81
+ selectedHistoryId === Number(item.id)
72
82
  ? 'border-primary-container/30 bg-surface-container-high'
73
83
  : 'border-outline-variant/10 bg-surface-container-lowest hover:bg-surface-container-high'
74
84
  }"
75
- data-action="navigate"
76
- data-to="/charts/${encodeURIComponent(item.id)}"
77
- type="button"
85
+ data-charts-history-item
78
86
  >
79
- <div class="flex items-start justify-between gap-3">
80
- <div class="min-w-0 flex-1 truncate font-mono text-xs ${
81
- selectedHistoryId === item.id ? 'text-primary-container' : 'text-on-surface'
82
- }">
83
- ${escapeHtml(item.displayTitle)}
87
+ <button
88
+ class="w-full px-4 py-3 text-left transition-colors group-hover:bg-surface-container-high"
89
+ data-action="navigate"
90
+ data-history-id="${escapeHtml(item.id)}"
91
+ data-to="/charts/${encodeURIComponent(item.id)}"
92
+ type="button"
93
+ >
94
+ <div class="flex items-start justify-between gap-3">
95
+ <div class="min-w-0 flex-1 truncate font-mono text-xs ${
96
+ selectedHistoryId === Number(item.id) ? 'text-primary-container' : 'text-on-surface'
97
+ }" data-charts-history-title>
98
+ ${escapeHtml(item.displayTitle)}
99
+ </div>
100
+ <div class="flex shrink-0 flex-wrap justify-end gap-1">
101
+ <span class="inline-flex" data-charts-saved-badge ${item.isSaved ? '' : 'hidden'}>
102
+ ${renderStatusBadge('saved', 'primary')}
103
+ </span>
104
+ ${
105
+ item.chartTypes?.length
106
+ ? item.chartTypes
107
+ .map(chartType => renderStatusBadge(getQueryChartTypeLabel(chartType), 'primary'))
108
+ .join('')
109
+ : renderStatusBadge('None', 'muted')
110
+ }
111
+ </div>
84
112
  </div>
85
- <div class="flex shrink-0 flex-wrap justify-end gap-1">
86
- ${
87
- item.chartTypes?.length
88
- ? item.chartTypes
89
- .map(chartType => renderStatusBadge(getQueryChartTypeLabel(chartType), 'primary'))
90
- .join('')
91
- : renderStatusBadge('None', 'muted')
92
- }
113
+ <div class="mt-1 truncate text-[10px] uppercase tracking-[0.16em] text-on-surface-variant/45">
114
+ ${escapeHtml(item.previewSql)}
93
115
  </div>
116
+ </button>
117
+ <div class="flex items-center justify-end border-t border-outline-variant/10 px-3 py-2 transition-colors group-hover:bg-surface-container-high">
118
+ <button
119
+ aria-label="Open chart query"
120
+ class="min-h-[var(--control-height)] flex-1 self-stretch"
121
+ data-action="navigate"
122
+ data-history-id="${escapeHtml(item.id)}"
123
+ data-to="/charts/${encodeURIComponent(item.id)}"
124
+ type="button"
125
+ ></button>
126
+ <button
127
+ class="query-history-icon-button ${item.isSaved ? 'is-active' : ''}"
128
+ data-action="toggle-charts-query-history-saved"
129
+ data-history-id="${escapeHtml(item.id)}"
130
+ data-next-value="${item.isSaved ? 'false' : 'true'}"
131
+ title="${item.isSaved ? 'Remove from saved' : 'Save query'}"
132
+ type="button"
133
+ >
134
+ <span class="material-symbols-outlined text-[18px]">
135
+ ${item.isSaved ? 'bookmark' : 'bookmark_add'}
136
+ </span>
137
+ </button>
94
138
  </div>
95
- <div class="mt-1 truncate text-[10px] uppercase tracking-[0.16em] text-on-surface-variant/45">
96
- ${escapeHtml(item.previewSql)}
97
- </div>
98
- </button>
139
+ </article>
99
140
  `,
100
141
  )
101
142
  .join('')}
@@ -104,6 +145,37 @@ function renderChartsList(state) {
104
145
  `;
105
146
  }
106
147
 
148
+ function renderChartsHistoryTabs(state) {
149
+ const activeTab = ['recent', 'saved'].includes(state.charts.historyTab) ? state.charts.historyTab : 'recent';
150
+ const savedCount = (state.charts.queries ?? []).filter(item => item.isSaved).length;
151
+ const tabs = [
152
+ { id: 'recent', label: 'Recent', count: state.charts.queries?.length ?? 0 },
153
+ { id: 'saved', label: 'Saved', count: savedCount },
154
+ ];
155
+
156
+ return `
157
+ <div class="mt-4 flex items-center gap-2">
158
+ ${tabs
159
+ .map(
160
+ tab => `
161
+ <button
162
+ class="query-history-tab ${activeTab === tab.id ? 'is-active' : ''}"
163
+ data-action="set-charts-history-tab"
164
+ data-tab="${escapeHtml(tab.id)}"
165
+ type="button"
166
+ >
167
+ ${escapeHtml(tab.label)}
168
+ </button>
169
+ `,
170
+ )
171
+ .join('')}
172
+ <span class="ml-auto text-[10px] font-mono uppercase tracking-[0.16em] text-on-surface-variant/50">
173
+ <span data-charts-history-count>${escapeHtml(String(tabs.find(tab => tab.id === activeTab)?.count ?? 0))}</span>
174
+ </span>
175
+ </div>
176
+ `;
177
+ }
178
+
107
179
  function renderEmptyChartDetail() {
108
180
  return `
109
181
  <div class="flex flex-1 items-center justify-center px-8 py-10 text-center">
@@ -302,52 +374,32 @@ function renderChartSurface(chart, state, analysis) {
302
374
  function renderChartCard(chart, state, analysis) {
303
375
  const sizeClass = resolveChartCardSizeClass(state);
304
376
 
305
- return `
306
- <article class="query-chart-card ${sizeClass}">
307
- <header class="query-chart-card__header">
308
- <div class="min-w-0">
309
- <div class="flex flex-wrap items-center gap-2">
310
- <h3 class="truncate font-headline text-xl font-black uppercase tracking-tight text-on-surface">
311
- ${escapeHtml(chart.name)}
312
- </h3>
313
- ${renderStatusBadge(getQueryChartTypeLabel(chart.chartType), 'primary')}
314
- </div>
315
- </div>
316
- <div class="flex flex-wrap items-center gap-2">
317
- <button
318
- class="standard-button"
319
- data-action="open-edit-query-chart-modal"
320
- data-chart-id="${escapeHtml(chart.id)}"
321
- type="button"
322
- >
323
- Edit
324
- </button>
325
- <button
326
- class="delete-button"
327
- data-action="open-delete-query-chart-modal"
328
- data-chart-id="${escapeHtml(chart.id)}"
329
- type="button"
330
- >
331
- Delete
332
- </button>
333
- <button
334
- class="standard-button"
335
- data-action="export-query-chart-png"
336
- data-chart-id="${escapeHtml(chart.id)}"
337
- type="button"
338
- >
339
- Export PNG
340
- </button>
341
- </div>
342
- </header>
343
- <div class="query-chart-card__body">
344
- ${renderChartSurface(chart, state, analysis)}
345
- </div>
346
- </article>
347
- `;
377
+ return [
378
+ '<article class="query-chart-card ',
379
+ sizeClass,
380
+ '"><header class="query-chart-card__header"><div class="min-w-0">',
381
+ '<div class="flex flex-wrap items-center gap-2">',
382
+ '<h3 class="truncate font-headline text-xl font-black uppercase tracking-tight text-on-surface">',
383
+ escapeHtml(chart.name),
384
+ "</h3>",
385
+ renderStatusBadge(getQueryChartTypeLabel(chart.chartType), 'primary'),
386
+ '</div></div><div class="flex flex-wrap items-center gap-2">',
387
+ '<button class="standard-button" data-action="open-edit-query-chart-modal" data-chart-id="',
388
+ escapeHtml(chart.id),
389
+ '" type="button">Edit</button>',
390
+ '<button class="delete-button" data-action="open-delete-query-chart-modal" data-chart-id="',
391
+ escapeHtml(chart.id),
392
+ '" type="button">Delete</button>',
393
+ '<button class="standard-button" data-action="export-query-chart-png" data-chart-id="',
394
+ escapeHtml(chart.id),
395
+ '" type="button">Export PNG</button>',
396
+ '</div></header><div class="query-chart-card__body">',
397
+ renderChartSurface(chart, state, analysis),
398
+ "</div></article>",
399
+ ].join("");
348
400
  }
349
401
 
350
- function renderChartsDetail(state) {
402
+ export function renderChartsDetail(state) {
351
403
  const detail = state.charts.detail;
352
404
  const selectedHistoryId = state.charts.selectedHistoryId;
353
405
  const historyVisible = state.editor.historyPanelVisible !== false;
@@ -485,6 +537,7 @@ export function renderChartsView(state) {
485
537
  <h2 class="mt-2 text-[10px] font-mono uppercase tracking-[0.16em] text-on-surface-variant/55">
486
538
  Charts
487
539
  </h2>
540
+ ${renderChartsHistoryTabs(state)}
488
541
  </div>
489
542
  ${renderChartsList(state)}
490
543
  </aside>