diff --git a/src/core/export.js b/src/core/export.js
new file mode 100644
index 0000000..18cc597
--- /dev/null
+++ b/src/core/export.js
@@ -0,0 +1,30 @@
+// Pure serializers turning result data (columns + rows) into TSV / CSV text.
+// Used by the results pane's Copy (TSV — pastes into spreadsheets) and Export
+// (CSV — opens in Excel) actions. No DOM, no globals.
+
+function cell(v) {
+ return v == null ? '' : String(v);
+}
+
+/**
+ * TabSeparated text: a header row of column names + one line per data row.
+ * Backslashes, tabs and newlines are escaped ClickHouse-TSV style so embedded
+ * whitespace can't break the column/row grid when pasted.
+ */
+export function toTSV(columns, rows) {
+ const esc = (s) => s.replace(/\\/g, '\\\\').replace(/\t/g, '\\t').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
+ const head = columns.map((c) => esc(c.name)).join('\t');
+ const body = rows.map((row) => row.map((v) => esc(cell(v))).join('\t')).join('\n');
+ return rows.length ? head + '\n' + body : head;
+}
+
+/**
+ * RFC-4180 CSV: a header row + one line per data row. A field is quoted only
+ * when it contains a comma, double-quote, or CR/LF; internal quotes are doubled.
+ */
+export function toCSV(columns, rows) {
+ const q = (s) => (/[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s);
+ const head = columns.map((c) => q(c.name)).join(',');
+ const body = rows.map((row) => row.map((v) => q(cell(v))).join(',')).join('\n');
+ return rows.length ? head + '\n' + body : head;
+}
diff --git a/src/state.js b/src/state.js
index 71b0706..7239f91 100644
--- a/src/state.js
+++ b/src/state.js
@@ -2,7 +2,7 @@
// is injected as a `save(key, value)` function (defaulting to storage.js), so
// every operation is unit-testable with a spy and no real localStorage.
-import { clamp, inferQueryName } from './core/format.js';
+import { clamp } from './core/format.js';
import { loadJSON, saveJSON, loadStr } from './core/storage.js';
export const KEYS = {
@@ -65,28 +65,59 @@ export function allocTabId(state) {
const rnd = () => Math.random().toString(36).slice(2, 6);
-/** Find a saved query whose SQL matches (trimmed). */
-export function findSavedBySql(state, sql) {
- const s = String(sql || '').trim();
- return state.savedQueries.find((q) => q.sql.trim() === s) || null;
+/** The saved query a tab is linked to (via tab.savedId), or null. */
+export function savedForTab(state, tab) {
+ return (tab && tab.savedId && state.savedQueries.find((q) => q.id === tab.savedId)) || null;
}
/**
- * Toggle the active SQL in/out of saved queries. Returns { saved } reflecting
- * the new state, or { saved: false, noop: true } for empty SQL.
+ * Save the tab's SQL under `name`. If the tab is already linked to a saved
+ * entry, update that entry in place; otherwise create a new one (newest first)
+ * and link the tab to it. The tab's name mirrors the saved name. Returns the
+ * saved entry, or null for empty SQL/name.
*/
-export function toggleSaved(state, sql, save = saveJSON, now = Date.now()) {
- const s = String(sql || '').trim();
- if (!s) return { saved: false, noop: true };
- const existing = findSavedBySql(state, s);
- if (existing) {
- state.savedQueries = state.savedQueries.filter((q) => q.id !== existing.id);
- save(KEYS.saved, state.savedQueries);
- return { saved: false };
+export function saveQuery(state, tab, name, save = saveJSON, now = Date.now()) {
+ const sql = String(tab.sql || '').trim();
+ const nm = String(name || '').trim();
+ if (!sql || !nm) return null;
+ let entry = savedForTab(state, tab);
+ if (entry) {
+ entry.name = nm;
+ entry.sql = sql;
+ } else {
+ entry = { id: 's' + now + rnd(), name: nm, sql, favorite: false };
+ state.savedQueries.unshift(entry);
+ tab.savedId = entry.id;
}
- state.savedQueries.unshift({ id: 's' + now + rnd(), name: inferQueryName(s), sql: s, starred: true });
+ tab.name = nm;
save(KEYS.saved, state.savedQueries);
- return { saved: true };
+ return entry;
+}
+
+/** Rename a saved query, keeping any linked tab's name in sync. */
+export function renameSaved(state, id, name, save = saveJSON) {
+ const nm = String(name || '').trim();
+ const entry = state.savedQueries.find((q) => q.id === id);
+ if (!entry || !nm) return;
+ entry.name = nm;
+ for (const t of state.tabs) if (t.savedId === id) t.name = nm;
+ save(KEYS.saved, state.savedQueries);
+}
+
+/** Toggle a saved query's favorite flag. */
+export function toggleFavorite(state, id, save = saveJSON) {
+ const entry = state.savedQueries.find((q) => q.id === id);
+ if (!entry) return;
+ entry.favorite = !entry.favorite;
+ save(KEYS.saved, state.savedQueries);
+}
+
+/** Saved queries with favorites first (stable within each group). */
+export function sortedSaved(state) {
+ return state.savedQueries
+ .map((q, i) => [q, i])
+ .sort((a, b) => (b[0].favorite ? 1 : 0) - (a[0].favorite ? 1 : 0) || a[1] - b[1])
+ .map(([q]) => q);
}
/** Delete a saved query by id and clear any tab pointer to it. */
diff --git a/src/styles.css b/src/styles.css
index 721c83e..ce9689e 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -248,20 +248,31 @@ body {
flex: 1; font-size: 12px; font-weight: 500; color: var(--fg);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
-.saved-row .star { color: var(--accent); display: flex; flex-shrink: 0; }
-.saved-row .star.unstar { color: var(--fg-faint); }
+.saved-row .sv-star {
+ width: 18px; height: 18px; border: none; background: transparent;
+ color: var(--fg-faint); cursor: pointer; border-radius: 3px; flex-shrink: 0;
+ display: inline-flex; align-items: center; justify-content: center; padding: 0;
+}
+.saved-row .sv-star.on { color: var(--accent); }
+.saved-row .sv-star:hover { color: var(--fg); }
+.saved-row .sv-edit {
+ flex: 1; min-width: 0; font: inherit; font-size: 12px; font-weight: 500;
+ color: var(--fg); background: var(--bg-editor);
+ border: 1px solid var(--accent); border-radius: 4px; padding: 1px 5px; outline: none;
+}
.saved-row .preview {
font-size: 10.5px; font-family: var(--mono); color: var(--fg-faint);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
padding-left: 18px;
}
-.saved-row .del {
+.sv-act {
width: 18px; height: 18px; border: none; background: transparent;
- color: var(--fg-faint); cursor: pointer; border-radius: 3px;
+ color: var(--fg-faint); cursor: pointer; border-radius: 3px; flex-shrink: 0;
display: none; align-items: center; justify-content: center; padding: 0;
}
-.saved-row:hover .del { display: inline-flex; }
-.saved-row .del:hover { color: var(--fg); background: var(--bg-hover); }
+.saved-row:hover .sv-act { display: inline-flex; }
+.sv-act:hover { color: var(--fg); background: var(--bg-hover); }
+.side-count { color: var(--fg-faint); font-weight: 400; }
.history-row {
position: relative;
padding: 8px 10px; cursor: pointer; user-select: none;
@@ -269,14 +280,8 @@ body {
display: flex; flex-direction: column; gap: 3px;
}
.history-row:hover { background: var(--bg-hover); }
-.history-row .del {
- position: absolute; top: 6px; right: 8px;
- width: 18px; height: 18px; border: none; background: transparent;
- color: var(--fg-faint); cursor: pointer; border-radius: 3px;
- display: none; align-items: center; justify-content: center; padding: 0;
-}
-.history-row:hover .del { display: inline-flex; }
-.history-row .del:hover { color: var(--fg); background: var(--bg-hover); }
+.history-row .sv-act.del { position: absolute; top: 6px; right: 8px; }
+.history-row:hover .sv-act.del { display: inline-flex; }
.history-row .sql {
font-size: 11px; font-family: var(--mono); color: var(--fg);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
@@ -286,7 +291,32 @@ body {
display: flex; gap: 10px;
font-size: 10px; color: var(--fg-faint); font-family: var(--mono);
}
-.star-btn .star-on { color: var(--accent); }
+.save-btn.saved, .save-btn.saved svg { color: var(--accent); }
+.save-popover {
+ z-index: 50; width: 240px; padding: 12px;
+ background: var(--bg-panel, var(--bg-editor)); border: 1px solid var(--border);
+ border-radius: 8px; box-shadow: 0 8px 28px rgba(0,0,0,.4);
+ display: flex; flex-direction: column; gap: 9px;
+}
+.save-popover .sp-label {
+ font-size: 10px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
+ color: var(--fg-faint);
+}
+.save-popover .sp-input {
+ font: inherit; font-size: 13px; color: var(--fg);
+ background: var(--bg-editor); border: 1px solid var(--border);
+ border-radius: 5px; padding: 6px 8px; outline: none;
+}
+.save-popover .sp-input:focus { border-color: var(--accent); }
+.save-popover .sp-actions { display: flex; justify-content: flex-end; gap: 8px; }
+.save-popover .sp-cancel, .save-popover .sp-save {
+ font: inherit; font-size: 12px; cursor: pointer;
+ padding: 5px 12px; border-radius: 5px; border: 1px solid var(--border);
+}
+.save-popover .sp-cancel { background: transparent; color: var(--fg-mute); }
+.save-popover .sp-cancel:hover { color: var(--fg); background: var(--bg-hover); }
+.save-popover .sp-save { background: var(--accent); border-color: var(--accent); color: #fff; }
+.save-popover .sp-save:hover { filter: brightness(1.08); }
/* ------------ share toast ------------ */
.share-toast {
@@ -650,6 +680,14 @@ body {
padding: 0 8px; border-right: 1px solid var(--border-faint);
}
.stat:last-of-type { border-right: none; }
+.res-act {
+ display: flex; align-items: center; gap: 5px;
+ height: 24px; padding: 0 9px;
+ border: 1px solid transparent; border-radius: 5px;
+ background: transparent; color: var(--fg-mute);
+ font-size: 11px; font-family: inherit; cursor: pointer;
+}
+.res-act:hover { background: var(--bg-hover); color: var(--fg); border-color: var(--border); }
.stat .ic { color: var(--fg-faint); display: flex; }
.stat .v { color: var(--fg); }
@@ -714,7 +752,6 @@ table.res-table.fixed { table-layout: fixed; }
table.res-table.fixed th, table.res-table.fixed td { overflow: hidden; text-overflow: ellipsis; }
table.res-table th .h-inner { display: flex; align-items: center; gap: 5px; }
table.res-table th .h-name { color: var(--fg); }
-table.res-table th .h-type { font-size: 9.5px; color: var(--fg-faint); font-weight: 400; }
table.res-table th .h-sort { color: var(--accent); display: flex; }
table.res-table td {
border-bottom: 1px solid var(--border-faint);
diff --git a/src/ui/app.js b/src/ui/app.js
index 5ad4723..15915d9 100644
--- a/src/ui/app.js
+++ b/src/ui/app.js
@@ -7,11 +7,12 @@
import { h } from './dom.js';
import { Icon } from './icons.js';
import {
- createState, activeTab, KEYS, recordHistory, findSavedBySql, toggleSaved,
+ createState, activeTab, KEYS, recordHistory, saveQuery, savedForTab,
} from '../state.js';
import { saveJSON, saveStr } from '../core/storage.js';
import { decodeJwtPayload, isTokenExpired } from '../core/jwt.js';
-import { sqlString } from '../core/format.js';
+import { sqlString, inferQueryName } from '../core/format.js';
+import { toTSV, toCSV } from '../core/export.js';
import { newResult, applyStreamLine } from '../core/stream.js';
import { encodeSqlForHash } from '../core/share.js';
import { generatePKCE, randomState } from '../core/pkce.js';
@@ -347,18 +348,111 @@ export function createApp(env = {}) {
flashToast('Link in URL — copy manually', { document: doc });
}
}
- app.updateStar = () => {
- if (!app.dom.starBtn) return;
- const saved = !!findSavedBySql(app.state, app.activeTab().sql || '');
- app.dom.starBtn.replaceChildren(Icon.star(saved));
- app.dom.starBtn.classList.toggle('star-on', saved);
- app.dom.starBtn.title = saved ? 'Remove from saved (⌘S)' : 'Save query (⌘S)';
+ // --- copy / export results --------------------------------------------
+ // A result is exportable once it has raw text or at least one row.
+ function exportableResult() {
+ const r = app.activeTab().result;
+ return r && !r.error && (r.rawText != null || r.rows.length > 0) ? r : null;
+ }
+ function copyResult() {
+ const r = exportableResult();
+ if (!r) { flashToast('Nothing to copy', { document: doc }); return; }
+ const text = r.rawText != null ? r.rawText : toTSV(r.columns, r.rows);
+ const clip = (env.navigator || win.navigator || {}).clipboard;
+ if (clip && clip.writeText) {
+ clip.writeText(text)
+ .then(() => flashToast('Copied to clipboard', { document: doc }))
+ .catch(() => flashToast('Copy failed', { document: doc }));
+ } else {
+ flashToast('Copy not supported', { document: doc });
+ }
+ }
+ function exportResult() {
+ const r = exportableResult();
+ if (!r) { flashToast('Nothing to export', { document: doc }); return; }
+ let content, ext, mime;
+ if (r.rawText != null) {
+ content = r.rawText;
+ ext = r.rawFormat === 'JSON' ? 'json' : 'tsv';
+ mime = ext === 'json' ? 'application/json' : 'text/tab-separated-values';
+ } else {
+ content = toCSV(r.columns, r.rows);
+ ext = 'csv';
+ mime = 'text/csv';
+ }
+ const base = (app.activeTab().name || 'result').replace(/[^\w.-]+/g, '_').replace(/^_+|_+$/g, '') || 'result';
+ downloadFile(base + '.' + ext, mime, content);
+ flashToast('Exported ' + base + '.' + ext, { document: doc });
+ }
+ // Trigger a browser download. Injectable via env.download for tests.
+ function downloadFile(filename, mime, content) {
+ if (env.download) { env.download(filename, mime, content); return; }
+ const url = win.URL || win.webkitURL;
+ const href = url.createObjectURL(new win.Blob([content], { type: mime }));
+ const a = doc.createElement('a');
+ a.href = href;
+ a.download = filename;
+ doc.body.appendChild(a);
+ a.click();
+ doc.body.removeChild(a);
+ url.revokeObjectURL(href);
+ }
+
+ // The toolbar Save button reads "Saved" (accent) when the active tab is linked
+ // to a saved entry and its SQL is unchanged; "Save" otherwise (incl. dirty).
+ app.updateSaveBtn = () => {
+ if (!app.dom.saveBtn) return;
+ const tab = app.activeTab();
+ const entry = savedForTab(app.state, tab);
+ const clean = !!entry && entry.sql.trim() === String(tab.sql || '').trim();
+ app.dom.saveBtn.classList.toggle('saved', clean);
+ app.dom.saveBtn.replaceChildren(Icon.bookmark(), h('span', null, clean ? 'Saved' : 'Save'));
+ app.dom.saveBtn.title = clean ? 'Saved — edit to re-save (⌘S)' : 'Save query (⌘S)';
};
- function toggleSavedActive() {
- toggleSaved(app.state, app.activeTab().sql || '', saveJSON);
- app.updateStar();
- if (app.state.sidePanel === 'saved') renderSavedHistory(app);
+ // Name popover anchored under the Save button. Prefill with the tab's name (or
+ // a name inferred from the SQL); Enter/Save → saveQuery (create or update in
+ // place) + relink the tab; Esc / click-outside cancels.
+ function openSavePopover() {
+ const tab = app.activeTab();
+ if (!String(tab.sql || '').trim()) { flashToast('Nothing to save', { document: doc }); return; }
+ if (app.dom.savePopover) return;
+ const entry = savedForTab(app.state, tab);
+ const prefill = entry ? entry.name : (tab.name && tab.name !== 'Untitled' ? tab.name : inferQueryName(tab.sql));
+ const input = h('input', { class: 'sp-input', value: prefill });
+ const close = () => {
+ doc.removeEventListener('keydown', onKey, true);
+ doc.removeEventListener('mousedown', onOutside, true);
+ if (app.dom.savePopover) { app.dom.savePopover.remove(); app.dom.savePopover = null; }
+ };
+ const commit = () => {
+ if (!input.value.trim()) return;
+ saveQuery(app.state, tab, input.value, saveJSON);
+ close();
+ app.updateSaveBtn();
+ app.actions.rerenderTabs();
+ renderSavedHistory(app);
+ flashToast('Saved', { document: doc });
+ };
+ const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } };
+ const onOutside = (e) => { if (app.dom.savePopover && !app.dom.savePopover.contains(e.target) && e.target !== app.dom.saveBtn) close(); };
+ input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } });
+ const pop = h('div', { class: 'save-popover' },
+ h('div', { class: 'sp-label' }, 'Save query as'),
+ input,
+ h('div', { class: 'sp-actions' },
+ h('button', { class: 'sp-cancel', onclick: close }, 'Cancel'),
+ h('button', { class: 'sp-save', onclick: commit }, 'Save')));
+ app.dom.savePopover = pop;
+ const r = app.dom.saveBtn.getBoundingClientRect();
+ pop.style.position = 'fixed';
+ pop.style.top = (r.bottom + 6) + 'px';
+ pop.style.right = Math.max(8, (win.innerWidth || 0) - r.right) + 'px';
+ doc.body.appendChild(pop);
+ doc.addEventListener('keydown', onKey, true);
+ doc.addEventListener('mousedown', onOutside, true);
+ setTimeout(() => { input.focus(); input.select(); });
}
+ app.openSavePopover = openSavePopover;
function toggleTheme() {
app.state.theme = app.state.theme === 'dark' ? 'light' : 'dark';
@@ -376,7 +470,9 @@ export function createApp(env = {}) {
loadIntoNewTab: (name, sql) => loadIntoNewTab(app, name, sql),
login: (idpId) => login(idpId),
share,
- toggleSaved: toggleSavedActive,
+ copyResult,
+ exportResult,
+ save: openSavePopover,
formatQuery,
insertCreate,
openShortcuts: () => openShortcuts(app),
@@ -385,7 +481,7 @@ export function createApp(env = {}) {
loadColumns,
rerenderTabs: () => renderTabs(app),
rerenderResults: () => renderResults(app),
- updateStar: () => app.updateStar(),
+ updateSaveBtn: () => app.updateSaveBtn(),
};
app.renderApp = () => renderApp(app, { toggleTheme, startDrag });
@@ -462,10 +558,11 @@ export function renderApp(app, helpers) {
h('option', { value: 'Table', selected: state.outputFormat === 'Table' }, 'Table'),
h('option', { value: 'TSV', selected: state.outputFormat === 'TSV' }, 'TSV'),
h('option', { value: 'JSON', selected: state.outputFormat === 'JSON' }, 'JSON'));
- app.dom.starBtn = h('button', { class: 'tb-btn star-btn', title: 'Save query', onclick: () => app.actions.toggleSaved() });
+ app.dom.fmtBtn = h('button', { class: 'tb-btn', title: 'Format SQL (⌘⇧↵)', onclick: () => app.actions.formatQuery() }, Icon.braces(), 'Format');
+ app.dom.saveBtn = h('button', { class: 'tb-btn save-btn', onclick: () => app.actions.save() });
app.dom.shareBtn = h('button', { class: 'tb-btn', title: 'Share query (copies link)', onclick: () => app.actions.share() }, Icon.share(), 'Share');
- const editorToolbar = h('div', { class: 'ed-toolbar' }, app.dom.runBtn, h('div', { style: { flex: '1' } }), app.dom.starBtn, app.dom.shareBtn, app.dom.fmtSelect);
+ const editorToolbar = h('div', { class: 'ed-toolbar' }, app.dom.runBtn, app.dom.fmtBtn, h('div', { style: { flex: '1' } }), app.dom.saveBtn, app.dom.shareBtn, app.dom.fmtSelect);
app.dom.editorRegion = h('div', { class: 'editor-region', style: { height: state.editorPct + '%', minHeight: '0', overflow: 'hidden', flexShrink: '0' } });
app.dom.resultsRegion = h('div', { class: 'results-region', style: { flex: '1', minHeight: '0', overflow: 'hidden' } });
app.dom.editorResultsSplit = h('div', { class: 'row-resize', onmousedown: (e) => helpers.startDrag(e, 'row', dragCtx) });
@@ -479,7 +576,7 @@ export function renderApp(app, helpers) {
renderResults(app);
renderSchema(app);
renderSavedHistory(app);
- app.updateStar();
+ app.updateSaveBtn();
app.loadVersion();
app.loadSchema();
}
diff --git a/src/ui/editor.js b/src/ui/editor.js
index 6020ff9..bf08725 100644
--- a/src/ui/editor.js
+++ b/src/ui/editor.js
@@ -61,7 +61,7 @@ export function mountEditor(app, container) {
tab.dirty = true;
paint(ta.value);
app.actions.rerenderTabs();
- app.actions.updateStar();
+ app.actions.updateSaveBtn();
});
ta.addEventListener('scroll', () => {
pre.scrollTop = ta.scrollTop;
diff --git a/src/ui/icons.js b/src/ui/icons.js
index 1ab0a6f..a3e96a2 100644
--- a/src/ui/icons.js
+++ b/src/ui/icons.js
@@ -79,5 +79,12 @@ export const Icon = {
json: () => svg('M4 1.5C2.5 1.5 2.5 3 2.5 4S2.5 5 1.5 6c1 1 1 2 1 2s0 1.5 1.5 1.5M8 1.5c1.5 0 1.5 1.5 1.5 2.5s0 1 1 2c-1 1-1 2-1 2s0 1.5-1.5 1.5', 12, 12),
table2: () => iconEl('', 12, 12),
shortcuts: () => iconEl('', 12, 12, 1.3),
+ copy: () => iconEl('', 12, 12),
+ download: () => iconEl('', 12, 12),
+ // Same glyph as the JSON view tab so the Format button's { } matches it.
+ braces: () => svg('M4 1.5C2.5 1.5 2.5 3 2.5 4S2.5 5 1.5 6c1 1 1 2 1 2s0 1.5 1.5 1.5M8 1.5c1.5 0 1.5 1.5 1.5 2.5s0 1 1 2c-1 1-1 2-1 2s0 1.5-1.5 1.5', 12, 12),
+ bookmark: () => iconEl('', 12, 12, 1.3),
+ pencil: () => iconEl('', 12, 12),
+ trash: () => iconEl('', 12, 12, 1.2),
github: () => svgFilled('M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12', 15, 15, 24, 24),
};
diff --git a/src/ui/results.js b/src/ui/results.js
index 30e1d21..7a28e5f 100644
--- a/src/ui/results.js
+++ b/src/ui/results.js
@@ -135,6 +135,16 @@ function buildToolbar(app, r) {
h('span', { class: 'v' }, (r.rawText != null ? '—' : r.rows.length) + ' rows')));
toolbar.appendChild(h('div', { class: 'stat', title: r.progress.rows + ' rows scanned' },
h('span', { class: 'ic' }, Icon.bytes()), h('span', { class: 'v' }, formatBytes(r.progress.bytes))));
+ if (!r.error) {
+ toolbar.appendChild(h('button', {
+ class: 'res-act', title: 'Copy results to clipboard',
+ onclick: () => app.actions.copyResult(),
+ }, Icon.copy(), h('span', null, 'Copy')));
+ toolbar.appendChild(h('button', {
+ class: 'res-act', title: 'Download results as a file',
+ onclick: () => app.actions.exportResult(),
+ }, Icon.download(), h('span', null, 'Export')));
+ }
}
return toolbar;
}
@@ -168,7 +178,6 @@ export function renderTable(app, r) {
},
}, h('div', { class: 'h-inner' },
h('span', { class: 'h-name' }, c.name),
- h('span', { class: 'h-type' }, c.type),
h('span', { style: { flex: '1' } }),
isSort ? h('span', { class: 'h-sort' }, dir === 'asc' ? Icon.sortAsc() : Icon.sortDesc()) : null),
// drag the right edge to resize; swallow the click so it doesn't sort.
diff --git a/src/ui/saved-history.js b/src/ui/saved-history.js
index be1d142..7060b0b 100644
--- a/src/ui/saved-history.js
+++ b/src/ui/saved-history.js
@@ -1,21 +1,24 @@
// The bottom sidebar pane: a Saved / History switcher and the two lists.
+// Saved items support favorite (star), inline rename (pencil) and delete (trash).
import { h } from './dom.js';
import { Icon } from './icons.js';
import { timeAgo } from '../core/format.js';
-import { deleteSaved, deleteHistory } from '../state.js';
+import { sortedSaved, renameSaved, toggleFavorite, deleteSaved, deleteHistory } from '../state.js';
export function renderSavedHistory(app) {
const tabsRow = app.dom.savedTabsRow;
const list = app.dom.savedList;
if (!tabsRow || !list) return;
const state = app.state;
+ const count = state.savedQueries.length;
tabsRow.replaceChildren(
h('button', {
class: 'side-tab' + (state.sidePanel === 'saved' ? ' active' : ''),
onclick: () => { state.sidePanel = 'saved'; app.savePref('sidePanel', 'saved'); renderSavedHistory(app); },
- }, Icon.star(state.sidePanel === 'saved'), h('span', null, 'Saved')),
+ }, Icon.star(state.sidePanel === 'saved'), h('span', null, 'Saved'),
+ count ? h('span', { class: 'side-count' }, '· ' + count) : null),
h('button', {
class: 'side-tab' + (state.sidePanel === 'history' ? ' active' : ''),
onclick: () => { state.sidePanel = 'history'; app.savePref('sidePanel', 'history'); renderSavedHistory(app); },
@@ -31,19 +34,55 @@ function renderSaved(app, list) {
const state = app.state;
if (state.savedQueries.length === 0) {
list.appendChild(h('div', { class: 'saved-empty' },
- 'No saved queries yet.', h('br'), 'Click ', Icon.star(true), ' next to Run to save.'));
+ 'No saved queries yet.', h('br'), 'Click ', Icon.bookmark(), ' Save next to Run.'));
return;
}
- for (const q of state.savedQueries) {
- list.appendChild(h('div', { class: 'saved-row', onclick: () => app.actions.loadIntoNewTab(q.name, q.sql) },
+ for (const q of sortedSaved(state)) {
+ const editing = app.editingSavedId === q.id;
+ const star = h('button', {
+ class: 'sv-star' + (q.favorite ? ' on' : ''), title: q.favorite ? 'Unfavorite' : 'Favorite',
+ onclick: (e) => { e.stopPropagation(); toggleFavorite(state, q.id, app.saveJSON); renderSavedHistory(app); },
+ }, Icon.star(q.favorite));
+
+ let nameEl;
+ if (editing) {
+ const input = h('input', { class: 'sv-edit', value: q.name });
+ let done = false;
+ // `commit` (Enter/blur) renames; `!commit` (Escape) cancels. The guard
+ // stops the blur fired by the re-render teardown from undoing a cancel.
+ const finish = (commit) => {
+ if (done) return;
+ done = true;
+ if (commit && input.value.trim()) { renameSaved(state, q.id, input.value, app.saveJSON); app.actions.rerenderTabs(); }
+ app.editingSavedId = null;
+ renderSavedHistory(app);
+ };
+ input.addEventListener('click', (e) => e.stopPropagation());
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') { e.preventDefault(); finish(true); }
+ else if (e.key === 'Escape') { e.preventDefault(); finish(false); }
+ });
+ input.addEventListener('blur', () => finish(true));
+ nameEl = input;
+ setTimeout(() => { input.focus(); input.select(); });
+ } else {
+ nameEl = h('span', { class: 'name' }, q.name);
+ }
+
+ const row = h('div', { class: 'saved-row', onclick: () => { if (!editing) app.actions.loadIntoNewTab(q.name, q.sql); } },
h('div', { class: 'top' },
- h('span', { class: 'star' }, Icon.star(true)),
- h('span', { class: 'name' }, q.name),
- h('button', {
- class: 'del', title: 'Delete',
- onclick: (e) => { e.stopPropagation(); deleteSaved(state, q.id, app.saveJSON); app.actions.updateStar(); renderSavedHistory(app); },
- }, Icon.close())),
- h('div', { class: 'preview' }, q.sql.split('\n')[0])));
+ star,
+ nameEl,
+ editing ? null : h('button', {
+ class: 'sv-act', title: 'Rename',
+ onclick: (e) => { e.stopPropagation(); app.editingSavedId = q.id; renderSavedHistory(app); },
+ }, Icon.pencil()),
+ editing ? null : h('button', {
+ class: 'sv-act', title: 'Delete',
+ onclick: (e) => { e.stopPropagation(); deleteSaved(state, q.id, app.saveJSON); app.updateSaveBtn(); renderSavedHistory(app); },
+ }, Icon.trash())),
+ h('div', { class: 'preview' }, q.sql.split('\n')[0]));
+ list.appendChild(row);
}
}
@@ -56,9 +95,9 @@ function renderHistory(app, list) {
for (const ent of state.history) {
list.appendChild(h('div', { class: 'history-row', onclick: () => app.actions.loadIntoNewTab('From history', ent.sql) },
h('button', {
- class: 'del', title: 'Delete',
+ class: 'sv-act del', title: 'Delete',
onclick: (e) => { e.stopPropagation(); deleteHistory(state, ent.id, app.saveJSON); renderSavedHistory(app); },
- }, Icon.close()),
+ }, Icon.trash()),
h('div', { class: 'sql' }, ent.sql),
h('div', { class: 'meta' },
h('span', null, timeAgo(ent.ts)),
diff --git a/src/ui/shortcuts.js b/src/ui/shortcuts.js
index ead391b..8ee2045 100644
--- a/src/ui/shortcuts.js
+++ b/src/ui/shortcuts.js
@@ -5,7 +5,7 @@ import { h } from './dom.js';
const SHORTCUTS = [
['Run query', '⌘↵'],
['Format query', '⌘⇧↵'],
- ['Save / unsave query', '⌘S'],
+ ['Save query', '⌘S'],
['Share query', '⌘⇧S'],
['Undo', '⌘Z'],
['Redo', '⌘⇧Z'],
@@ -77,8 +77,8 @@ export function handleKeydown(e, app) {
if (mod && e.key.toLowerCase() === 's') {
if (!signedIn) return null;
e.preventDefault();
- app.actions.toggleSaved();
- return 'toggleSaved';
+ app.actions.save();
+ return 'save';
}
if (mod && e.key.toLowerCase() === 'a') {
// When a raw result pane (TSV / JSON output) is on screen and the user isn't
diff --git a/src/ui/tabs.js b/src/ui/tabs.js
index 3c1622b..6644949 100644
--- a/src/ui/tabs.js
+++ b/src/ui/tabs.js
@@ -28,7 +28,7 @@ function refresh(app) {
renderTabs(app);
if (app.dom.editorSync) app.dom.editorSync();
app.actions.rerenderResults();
- app.actions.updateStar();
+ app.actions.updateSaveBtn();
}
/** Switch the active tab (no-op if already active). */
diff --git a/tests/helpers/fake-app.js b/tests/helpers/fake-app.js
index cdddca0..8272b80 100644
--- a/tests/helpers/fake-app.js
+++ b/tests/helpers/fake-app.js
@@ -17,7 +17,8 @@ export function makeApp(over = {}) {
email: () => 'me@example.com',
savePref: vi.fn(),
saveJSON: vi.fn(),
- updateStar: vi.fn(),
+ updateSaveBtn: vi.fn(),
+ editingSavedId: null,
showLogin: vi.fn(),
signOut: vi.fn(),
loadVersion: vi.fn(),
@@ -28,7 +29,7 @@ export function makeApp(over = {}) {
resultsRegion: document.createElement('div'),
savedTabsRow: document.createElement('div'),
savedList: document.createElement('div'),
- starBtn: document.createElement('button'),
+ saveBtn: document.createElement('button'),
},
actions: {
run: vi.fn(),
@@ -38,7 +39,9 @@ export function makeApp(over = {}) {
loadIntoNewTab: vi.fn(),
login: vi.fn(),
share: vi.fn(),
- toggleSaved: vi.fn(),
+ copyResult: vi.fn(),
+ exportResult: vi.fn(),
+ save: vi.fn(),
formatQuery: vi.fn(),
insertCreate: vi.fn(),
openShortcuts: vi.fn(),
@@ -47,7 +50,7 @@ export function makeApp(over = {}) {
loadColumns: vi.fn(),
rerenderTabs: vi.fn(),
rerenderResults: vi.fn(),
- updateStar: vi.fn(),
+ updateSaveBtn: vi.fn(),
},
...over,
};
diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js
index 69d7739..30a9019 100644
--- a/tests/unit/app.test.js
+++ b/tests/unit/app.test.js
@@ -389,13 +389,59 @@ describe('share + star + columns', () => {
app.activeTab().sql = ' ';
expect(() => app.actions.share()).not.toThrow();
});
- it('toggleSaved stars the active query and updates the button', () => {
+ it('save opens a name popover; Save commits, links the tab, and the button reads "Saved"', () => {
const app = createApp(env());
app.renderApp();
app.activeTab().sql = 'SELECT 42';
- app.actions.toggleSaved();
+ app.actions.save();
+ const pop = document.querySelector('.save-popover');
+ expect(pop).not.toBeNull();
+ expect(pop.querySelector('.sp-input').value).toBe('SELECT 42'); // inferred name
+ pop.querySelector('.sp-input').value = 'My fave';
+ pop.querySelector('.sp-save').dispatchEvent(new Event('click'));
expect(app.state.savedQueries).toHaveLength(1);
- expect(app.dom.starBtn.classList.contains('star-on')).toBe(true);
+ expect(app.state.savedQueries[0]).toMatchObject({ name: 'My fave', sql: 'SELECT 42' });
+ expect(app.activeTab().savedId).toBe(app.state.savedQueries[0].id);
+ expect(app.dom.saveBtn.classList.contains('saved')).toBe(true);
+ expect(app.dom.saveBtn.textContent).toContain('Saved');
+ expect(document.querySelector('.save-popover')).toBeNull(); // closed
+ });
+ it('save popover: re-opening is idempotent, Esc closes, dirty edit flips "Saved"→"Save"', () => {
+ const app = createApp(env());
+ app.renderApp();
+ app.activeTab().sql = 'SELECT 1';
+ app.actions.save();
+ app.actions.save(); // second call no-ops while open
+ expect(document.querySelectorAll('.save-popover')).toHaveLength(1);
+ document.querySelector('.save-popover .sp-input').value = 'Q';
+ document.querySelector('.save-popover .sp-save').dispatchEvent(new Event('click'));
+ expect(app.dom.saveBtn.textContent).toContain('Saved');
+ // edit → button reverts to "Save"
+ app.activeTab().sql = 'SELECT 2';
+ app.updateSaveBtn();
+ expect(app.dom.saveBtn.classList.contains('saved')).toBe(false);
+ expect(app.dom.saveBtn.textContent).toContain('Save');
+ // re-open then Escape closes without saving
+ app.actions.save();
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
+ expect(document.querySelector('.save-popover')).toBeNull();
+ });
+ it('save is a no-op (toast) for empty SQL', () => {
+ const app = createApp(env());
+ app.renderApp();
+ app.activeTab().sql = ' ';
+ app.actions.save();
+ expect(document.querySelector('.save-popover')).toBeNull();
+ expect(document.querySelector('.share-toast').textContent).toBe('Nothing to save');
+ });
+ it('save popover closes on click outside', () => {
+ const app = createApp(env());
+ app.renderApp();
+ app.activeTab().sql = 'SELECT 1';
+ app.actions.save();
+ expect(document.querySelector('.save-popover')).not.toBeNull();
+ document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
+ expect(document.querySelector('.save-popover')).toBeNull();
});
it('loadColumns fills the table object', async () => {
const e = env({ fetch: makeFetch([[(u, sql) => /system\.columns/.test(sql), resp({ json: { data: [{ name: 'id', type: 'UInt64', comment: '' }] } })]]) });
@@ -440,7 +486,9 @@ describe('exhaustive controller coverage', () => {
app.root.querySelector('.new-tab').dispatchEvent(new Event('click'));
app.root.querySelectorAll('.hd-btn')[0].dispatchEvent(new Event('click')); // shortcuts
app.activeTab().sql = 'SELECT 1'; // set sql on the now-active tab
- app.dom.starBtn.dispatchEvent(new Event('click')); // save
+ app.dom.saveBtn.dispatchEvent(new Event('click')); // open save popover
+ document.querySelector('.save-popover .sp-input').value = 'Q';
+ document.querySelector('.save-popover .sp-save').dispatchEvent(new Event('click')); // commit
app.dom.shareBtn.dispatchEvent(new Event('click')); // share
expect(app.state.tabs.length).toBeGreaterThan(1);
expect(app.state.savedQueries.length).toBe(1);
@@ -530,7 +578,7 @@ describe('exhaustive controller coverage', () => {
it('loaders + run guard tolerate being called before renderApp', async () => {
const app = createApp(env({ fetch: makeFetch([[() => true, resp({ json: { data: [] } })]]) }));
await app.loadVersion(); // setConn guard: no connStatus
- app.updateStar(); // guard: no starBtn
+ app.updateSaveBtn(); // guard: no starBtn
// signed-out run with non-empty SQL exercises the getToken()→onSignedOut path
const noToken = createApp(env({ sessionStorage: memSession({}) }));
@@ -558,7 +606,7 @@ describe('exhaustive controller coverage', () => {
app.actions.loadIntoNewTab('n', 'SELECT 2');
app.actions.rerenderTabs();
app.actions.rerenderResults();
- app.actions.updateStar();
+ app.actions.updateSaveBtn();
app.actions.closeTab(app.state.activeTabId);
expect(app.state.tabs.length).toBeGreaterThan(0);
});
@@ -569,7 +617,7 @@ describe('exhaustive controller coverage', () => {
app.renderApp();
app.activeTab().sql = ''; // empty
app.actions.share(); // returns at !sql (covers the `|| ''` empty branch)
- app.actions.toggleSaved(); // empty sql → no-op
+ app.actions.save(); // empty sql → toast, no popover
app.activeTab().sql = 'SELECT 1';
app.actions.share(); // no clipboard anywhere → manual toast
expect(document.querySelector('.share-toast')).not.toBeNull();
@@ -673,6 +721,69 @@ describe('exhaustive controller coverage', () => {
expect(app.email()).toBe('BorisT');
});
+ it('copyResult: TSV for structured, rawText as-is, nothing-to-copy when empty', async () => {
+ const writeText = vi.fn(async () => {});
+ const app = createApp(env({ window: fakeWin(), navigator: { clipboard: { writeText } } }));
+ app.renderApp();
+ app.activeTab().result = { error: null, rawText: null, columns: [{ name: 'a' }, { name: 'b' }], rows: [['1', 'x']] };
+ app.actions.copyResult();
+ await new Promise((r) => setTimeout(r));
+ expect(writeText).toHaveBeenCalledWith('a\tb\n1\tx');
+ expect(document.querySelector('.share-toast').textContent).toBe('Copied to clipboard');
+ app.activeTab().result = { rawText: 'raw\tdata', rows: [] };
+ app.actions.copyResult();
+ expect(writeText).toHaveBeenLastCalledWith('raw\tdata');
+ app.activeTab().result = null;
+ app.actions.copyResult();
+ expect(document.querySelector('.share-toast').textContent).toBe('Nothing to copy');
+ });
+ it('copyResult: no clipboard → not-supported; rejection → failed', async () => {
+ const app = createApp(env({ window: fakeWin(), navigator: {} }));
+ app.renderApp();
+ app.activeTab().result = { columns: [{ name: 'a' }], rows: [['1']] };
+ app.actions.copyResult();
+ expect(document.querySelector('.share-toast').textContent).toBe('Copy not supported');
+ const app2 = createApp(env({ window: fakeWin(), navigator: { clipboard: { writeText: vi.fn(async () => { throw new Error('x'); }) } } }));
+ app2.renderApp();
+ app2.activeTab().result = { columns: [{ name: 'a' }], rows: [['1']] };
+ app2.actions.copyResult();
+ await new Promise((r) => setTimeout(r));
+ expect(document.querySelector('.share-toast').textContent).toBe('Copy failed');
+ });
+ it('exportResult: CSV for structured (name sanitized), JSON for raw, nothing when empty', () => {
+ const download = vi.fn();
+ const app = createApp(env({ window: fakeWin(), download }));
+ app.renderApp();
+ app.activeTab().name = 'My Query!';
+ app.activeTab().result = { columns: [{ name: 'a' }, { name: 'b' }], rows: [['1', 'x']] };
+ app.actions.exportResult();
+ expect(download).toHaveBeenCalledWith('My_Query.csv', 'text/csv', 'a,b\n1,x');
+ app.activeTab().result = { rawText: '[{"a":1}]', rawFormat: 'JSON', rows: [] };
+ app.actions.exportResult();
+ expect(download).toHaveBeenLastCalledWith(expect.stringMatching(/\.json$/), 'application/json', '[{"a":1}]');
+ app.activeTab().result = null;
+ app.actions.exportResult();
+ expect(document.querySelector('.share-toast').textContent).toBe('Nothing to export');
+ });
+ it('exportResult: raw TSV + junk tab name falls back to result.tsv; native Blob path revokes the URL', () => {
+ const download = vi.fn();
+ const app = createApp(env({ window: fakeWin(), download }));
+ app.renderApp();
+ app.activeTab().name = '!!!';
+ app.activeTab().result = { rawText: 'a\tb', rawFormat: 'TSV', rows: [] };
+ app.actions.exportResult();
+ expect(download).toHaveBeenCalledWith('result.tsv', 'text/tab-separated-values', 'a\tb');
+ // native path (no env.download): exercises Blob + createObjectURL + revoke
+ const createObjectURL = vi.fn(() => 'blob:u');
+ const revokeObjectURL = vi.fn();
+ const app2 = createApp(env({ window: { ...fakeWin(), URL: { createObjectURL, revokeObjectURL }, Blob: class { constructor(p) { this.p = p; } } } }));
+ app2.renderApp();
+ app2.activeTab().result = { columns: [{ name: 'a' }], rows: [['1']] };
+ app2.actions.exportResult();
+ expect(createObjectURL).toHaveBeenCalled();
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:u');
+ });
+
it('shows and dismisses the auth-failure banner', () => {
const app = createApp(env());
app.renderApp();
diff --git a/tests/unit/editor.test.js b/tests/unit/editor.test.js
index 3a93878..a489da8 100644
--- a/tests/unit/editor.test.js
+++ b/tests/unit/editor.test.js
@@ -35,7 +35,7 @@ describe('mountEditor', () => {
expect(app.activeTab().dirty).toBe(true);
expect(app.dom.editorGutter.children.length).toBe(2);
expect(app.actions.rerenderTabs).toHaveBeenCalled();
- expect(app.actions.updateStar).toHaveBeenCalled();
+ expect(app.actions.updateSaveBtn).toHaveBeenCalled();
});
it('scroll syncs pre + gutter to the textarea', () => {
const { app, ta } = mount();
diff --git a/tests/unit/export.test.js b/tests/unit/export.test.js
new file mode 100644
index 0000000..8bb5221
--- /dev/null
+++ b/tests/unit/export.test.js
@@ -0,0 +1,29 @@
+import { describe, it, expect } from 'vitest';
+import { toTSV, toCSV } from '../../src/core/export.js';
+
+const cols = [{ name: 'a' }, { name: 'b' }];
+
+describe('toTSV', () => {
+ it('header + rows, null → empty cell', () => {
+ expect(toTSV(cols, [[1, 'x'], [2, null]])).toBe('a\tb\n1\tx\n2\t');
+ });
+ it('escapes backslash, tab, newline, CR ClickHouse-style', () => {
+ expect(toTSV([{ name: 'c' }], [['x\ty\nz\\w\r']])).toBe('c\nx\\ty\\nz\\\\w\\r');
+ });
+ it('header only when there are no rows', () => {
+ expect(toTSV(cols, [])).toBe('a\tb');
+ });
+});
+
+describe('toCSV', () => {
+ it('header + rows, null → empty cell, no quoting when not needed', () => {
+ expect(toCSV(cols, [[1, 'x'], [2, null]])).toBe('a,b\n1,x\n2,');
+ });
+ it('quotes fields with comma, quote, or newline; doubles internal quotes', () => {
+ expect(toCSV([{ name: 'h,1' }], [['a"b'], ['c\nd']]))
+ .toBe('"h,1"\n"a""b"\n"c\nd"');
+ });
+ it('header only when there are no rows', () => {
+ expect(toCSV(cols, [])).toBe('a,b');
+ });
+});
diff --git a/tests/unit/results.test.js b/tests/unit/results.test.js
index df82ce8..83df642 100644
--- a/tests/unit/results.test.js
+++ b/tests/unit/results.test.js
@@ -121,6 +121,31 @@ describe('renderTable', () => {
expect(el.querySelector('.h-sort')).not.toBeNull();
expect(el.querySelector('td.num')).not.toBeNull();
});
+ it('Copy and Export buttons in the footer fire their actions', () => {
+ const app = appWithResult(tableResult());
+ renderResults(app);
+ const acts = [...app.dom.resultsRegion.querySelectorAll('.res-act')];
+ expect(acts.map((b) => b.textContent)).toEqual(['Copy', 'Export']);
+ click(acts[0]);
+ expect(app.actions.copyResult).toHaveBeenCalled();
+ click(acts[1]);
+ expect(app.actions.exportResult).toHaveBeenCalled();
+ });
+ it('no Copy/Export buttons on an error result', () => {
+ const r = newResult('Table');
+ r.error = 'boom';
+ const app = appWithResult(r);
+ renderResults(app);
+ expect(app.dom.resultsRegion.querySelectorAll('.res-act')).toHaveLength(0);
+ });
+ it('header shows column names only, not types', () => {
+ const el = renderTable(appWithResult(tableResult()), tableResult());
+ const ths = el.querySelectorAll('thead th');
+ expect(ths[1].querySelector('.h-name').textContent).toBe('n');
+ expect(el.querySelector('.h-type')).toBeNull();
+ expect(ths[1].textContent).not.toContain('UInt64'); // type not rendered
+ expect(ths[2].textContent).not.toContain('String');
+ });
it('truncates very large result sets', () => {
const r = newResult('Table');
r.columns = [{ name: 'n', type: 'UInt64' }];
diff --git a/tests/unit/saved-history.test.js b/tests/unit/saved-history.test.js
index df74e83..0783429 100644
--- a/tests/unit/saved-history.test.js
+++ b/tests/unit/saved-history.test.js
@@ -18,19 +18,61 @@ describe('renderSavedHistory', () => {
expect(app.dom.savedList.textContent).toContain('No saved queries yet.');
});
- it('saved: lists rows, loads on click, deletes on close', () => {
+ const byTitle = (root, t) => [...root.querySelectorAll('.sv-act')].find((b) => b.title === t);
+
+ it('saved: lists rows, loads on click, deletes via trash + refreshes Save button', () => {
const app = makeApp();
app.state.sidePanel = 'saved';
- app.state.savedQueries = [{ id: 's1', name: 'Q1', sql: 'SELECT 1\n-- more' }];
+ app.state.savedQueries = [{ id: 's1', name: 'Q1', sql: 'SELECT 1\n-- more', favorite: false }];
renderSavedHistory(app);
const row = app.dom.savedList.querySelector('.saved-row');
expect(row.querySelector('.preview').textContent).toBe('SELECT 1');
click(row);
expect(app.actions.loadIntoNewTab).toHaveBeenCalledWith('Q1', 'SELECT 1\n-- more');
- const del = row.querySelector('.del');
- del.dispatchEvent(new Event('click', { bubbles: true }));
+ byTitle(row, 'Delete').dispatchEvent(new Event('click', { bubbles: true }));
expect(app.state.savedQueries).toHaveLength(0);
- expect(app.actions.updateStar).toHaveBeenCalled();
+ expect(app.updateSaveBtn).toHaveBeenCalled();
+ });
+
+ it('saved: live count + star toggles favorite and re-sorts favorites first', () => {
+ const app = makeApp();
+ app.state.sidePanel = 'saved';
+ app.state.savedQueries = [
+ { id: 'a', name: 'A', sql: '1', favorite: false },
+ { id: 'b', name: 'B', sql: '2', favorite: false },
+ ];
+ renderSavedHistory(app);
+ expect(app.dom.savedTabsRow.querySelector('.side-count').textContent).toContain('2');
+ const names = () => [...app.dom.savedList.querySelectorAll('.saved-row .name')].map((n) => n.textContent);
+ expect(names()).toEqual(['A', 'B']);
+ const stars = app.dom.savedList.querySelectorAll('.sv-star');
+ stars[1].dispatchEvent(new Event('click', { bubbles: true })); // favorite B
+ expect(app.state.savedQueries.find((q) => q.id === 'b').favorite).toBe(true);
+ expect(names()).toEqual(['B', 'A']);
+ });
+
+ it('saved: pencil → inline rename; Enter commits, Escape cancels', () => {
+ const app = makeApp();
+ app.state.sidePanel = 'saved';
+ app.state.savedQueries = [{ id: 's1', name: 'Old', sql: '1', favorite: false }];
+ renderSavedHistory(app);
+ byTitle(app.dom.savedList, 'Rename').dispatchEvent(new Event('click', { bubbles: true }));
+ expect(app.editingSavedId).toBe('s1');
+ let input = app.dom.savedList.querySelector('.sv-edit');
+ expect(input.value).toBe('Old');
+ input.value = 'New';
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+ expect(app.state.savedQueries[0].name).toBe('New');
+ expect(app.editingSavedId).toBeNull();
+ expect(app.actions.rerenderTabs).toHaveBeenCalled();
+ // re-open, edit, Escape → unchanged
+ byTitle(app.dom.savedList, 'Rename').dispatchEvent(new Event('click', { bubbles: true }));
+ input = app.dom.savedList.querySelector('.sv-edit');
+ input.value = 'XXX';
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ expect(app.editingSavedId).toBeNull();
+ expect(app.state.savedQueries[0].name).toBe('New');
+ // clicking the row while editing another does not load (guard) — covered by Enter path above
});
it('history: empty state', () => {
diff --git a/tests/unit/shortcuts.test.js b/tests/unit/shortcuts.test.js
index 8f6fa9c..38b0c14 100644
--- a/tests/unit/shortcuts.test.js
+++ b/tests/unit/shortcuts.test.js
@@ -81,7 +81,7 @@ describe('handleKeydown', () => {
it('⌘⇧S shares; ⌘S toggles saved', () => {
const app = makeApp();
expect(handleKeydown(ev({ metaKey: true, shiftKey: true, key: 'S' }), app)).toBe('share');
- expect(handleKeydown(ev({ metaKey: true, key: 's' }), app)).toBe('toggleSaved');
+ expect(handleKeydown(ev({ metaKey: true, key: 's' }), app)).toBe('save');
const out = makeApp({ isSignedIn: () => false });
expect(handleKeydown(ev({ metaKey: true, shiftKey: true, key: 's' }), out)).toBeNull();
expect(handleKeydown(ev({ metaKey: true, key: 's' }), out)).toBeNull();
diff --git a/tests/unit/state.test.js b/tests/unit/state.test.js
index 331c41b..8e44a4e 100644
--- a/tests/unit/state.test.js
+++ b/tests/unit/state.test.js
@@ -1,7 +1,8 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import {
KEYS, newTabObj, createState, activeTab, allocTabId,
- findSavedBySql, toggleSaved, deleteSaved, recordHistory, clearHistory, deleteHistory,
+ saveQuery, savedForTab, renameSaved, toggleFavorite, sortedSaved,
+ deleteSaved, recordHistory, clearHistory, deleteHistory,
} from '../../src/state.js';
afterEach(() => vi.unstubAllGlobals());
@@ -77,28 +78,69 @@ describe('activeTab / allocTabId', () => {
});
describe('saved queries', () => {
- it('findSavedBySql matches trimmed sql', () => {
- const s = createState(reader());
- s.savedQueries = [{ id: 's1', sql: 'SELECT 1', name: 'n' }];
- expect(findSavedBySql(s, ' SELECT 1 ')).toMatchObject({ id: 's1' });
- expect(findSavedBySql(s, 'SELECT 2')).toBeNull();
- expect(findSavedBySql(s, null)).toBeNull();
- });
- it('toggleSaved is a no-op for empty/nullish sql', () => {
+ it('saveQuery is a no-op for empty SQL or empty name', () => {
const s = createState(reader());
const save = vi.fn();
- expect(toggleSaved(s, ' ', save)).toEqual({ saved: false, noop: true });
- expect(toggleSaved(s, null, save)).toEqual({ saved: false, noop: true });
+ s.tabs[0].sql = '';
+ expect(saveQuery(s, s.tabs[0], 'name', save)).toBeNull();
+ s.tabs[0].sql = 'SELECT 1';
+ expect(saveQuery(s, s.tabs[0], ' ', save)).toBeNull();
expect(save).not.toHaveBeenCalled();
});
- it('toggleSaved adds then removes', () => {
+ it('saveQuery creates + links the tab, then updates in place on re-save', () => {
const s = createState(reader());
const save = vi.fn();
- expect(toggleSaved(s, 'SELECT 1', save, 100)).toEqual({ saved: true });
- expect(s.savedQueries[0]).toMatchObject({ sql: 'SELECT 1', starred: true });
+ const tab = s.tabs[0];
+ tab.sql = 'SELECT 1';
+ const e1 = saveQuery(s, tab, 'My query', save, 100);
+ expect(e1).toMatchObject({ name: 'My query', sql: 'SELECT 1', favorite: false });
+ expect(tab.savedId).toBe(e1.id);
+ expect(tab.name).toBe('My query');
+ expect(s.savedQueries).toHaveLength(1);
expect(save).toHaveBeenLastCalledWith(KEYS.saved, s.savedQueries);
- expect(toggleSaved(s, 'SELECT 1', save, 100)).toEqual({ saved: false });
- expect(s.savedQueries).toHaveLength(0);
+ // re-save the linked tab → updates the same entry in place
+ tab.sql = 'SELECT 2';
+ const e2 = saveQuery(s, tab, 'My query v2', save, 200);
+ expect(e2.id).toBe(e1.id);
+ expect(s.savedQueries).toHaveLength(1);
+ expect(s.savedQueries[0]).toMatchObject({ name: 'My query v2', sql: 'SELECT 2' });
+ expect(tab.name).toBe('My query v2');
+ });
+ it('savedForTab resolves the linked entry (or null)', () => {
+ const s = createState(reader());
+ s.savedQueries = [{ id: 's1', sql: 'x', name: 'n', favorite: false }];
+ s.tabs[0].savedId = 's1';
+ expect(savedForTab(s, s.tabs[0])).toMatchObject({ id: 's1' });
+ s.tabs[0].savedId = 'gone';
+ expect(savedForTab(s, s.tabs[0])).toBeNull();
+ expect(savedForTab(s, { savedId: null })).toBeNull();
+ });
+ it('renameSaved updates the entry + any linked tab name', () => {
+ const s = createState(reader());
+ s.savedQueries = [{ id: 's1', sql: 'x', name: 'old', favorite: false }];
+ s.tabs[0].savedId = 's1';
+ const save = vi.fn();
+ renameSaved(s, 's1', ' new ', save);
+ expect(s.savedQueries[0].name).toBe('new');
+ expect(s.tabs[0].name).toBe('new');
+ renameSaved(s, 's1', ' ', save); // blank ignored
+ expect(s.savedQueries[0].name).toBe('new');
+ renameSaved(s, 'missing', 'x', save); // unknown id ignored
+ expect(save).toHaveBeenCalledTimes(1);
+ });
+ it('toggleFavorite flips the flag; sortedSaved puts favorites first (stable)', () => {
+ const s = createState(reader());
+ s.savedQueries = [
+ { id: 'a', sql: '1', name: 'A', favorite: false },
+ { id: 'b', sql: '2', name: 'B', favorite: false },
+ { id: 'c', sql: '3', name: 'C', favorite: false },
+ ];
+ const save = vi.fn();
+ toggleFavorite(s, 'c', save);
+ expect(s.savedQueries.find((q) => q.id === 'c').favorite).toBe(true);
+ toggleFavorite(s, 'missing', save); // no-op
+ expect(sortedSaved(s).map((q) => q.id)).toEqual(['c', 'a', 'b']);
+ expect(save).toHaveBeenCalledTimes(1);
});
it('deleteSaved removes + clears tab pointers', () => {
const s = createState(reader());
@@ -165,10 +207,13 @@ describe('history', () => {
});
describe('default persistence', () => {
- it('toggleSaved/deleteSaved/recordHistory/clearHistory persist via storage by default', () => {
+ it('saveQuery/renameSaved/toggleFavorite/deleteSaved/recordHistory/clearHistory persist via storage by default', () => {
const s = createState(reader());
// Exercises the default saveJSON path (writes to happy-dom localStorage).
- toggleSaved(s, 'SELECT 9');
+ s.tabs[0].sql = 'SELECT 9';
+ const e = saveQuery(s, s.tabs[0], 'nine');
+ renameSaved(s, e.id, 'nine!');
+ toggleFavorite(s, e.id);
recordHistory(s, { sql: 'SELECT 9', result: { rawText: null, rows: [], progress: { elapsed_ns: 0 } } });
deleteSaved(s, 'nope');
deleteHistory(s, 'nope');
diff --git a/tests/unit/tabs.test.js b/tests/unit/tabs.test.js
index 5139cb3..f32a73c 100644
--- a/tests/unit/tabs.test.js
+++ b/tests/unit/tabs.test.js
@@ -47,7 +47,7 @@ describe('selectTab', () => {
selectTab(app, 't2');
expect(app.state.activeTabId).toBe('t2');
expect(app.actions.rerenderResults).toHaveBeenCalled();
- expect(app.actions.updateStar).toHaveBeenCalled();
+ expect(app.actions.updateSaveBtn).toHaveBeenCalled();
});
it('no-ops if already active', () => {
const app = makeApp();