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 6ff5cbc..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 {
diff --git a/src/ui/app.js b/src/ui/app.js
index 08a2141..15915d9 100644
--- a/src/ui/app.js
+++ b/src/ui/app.js
@@ -7,11 +7,11 @@
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';
@@ -398,18 +398,61 @@ export function createApp(env = {}) {
url.revokeObjectURL(href);
}
- 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)';
+ // 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';
@@ -429,7 +472,7 @@ export function createApp(env = {}) {
share,
copyResult,
exportResult,
- toggleSaved: toggleSavedActive,
+ save: openSavePopover,
formatQuery,
insertCreate,
openShortcuts: () => openShortcuts(app),
@@ -438,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 });
@@ -515,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) });
@@ -532,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 0787044..a3e96a2 100644
--- a/src/ui/icons.js
+++ b/src/ui/icons.js
@@ -81,5 +81,10 @@ export const Icon = {
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/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 babba51..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(),
@@ -40,7 +41,7 @@ export function makeApp(over = {}) {
share: vi.fn(),
copyResult: vi.fn(),
exportResult: vi.fn(),
- toggleSaved: vi.fn(),
+ save: vi.fn(),
formatQuery: vi.fn(),
insertCreate: vi.fn(),
openShortcuts: vi.fn(),
@@ -49,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 12f7176..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();
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/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();