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();