Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified src/core/saved-io.js
Binary file not shown.
26 changes: 19 additions & 7 deletions src/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,17 @@ export function savedForTab(state, tab) {
}

/**
* 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.
* Save the tab's SQL under `name` (+ an optional free-text `description`). 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 saveQuery(state, tab, name, save = saveJSON, now = Date.now()) {
export function saveQuery(state, tab, name, description, save = saveJSON, now = Date.now()) {
const sql = String(tab.sql || '').trim();
const nm = String(name || '').trim();
if (!sql || !nm) return null;
const desc = String(description || '').trim();
const chart = tabChart(tab);
// Remember the current result view (Table/JSON/Chart) so a restore reopens the
// same data representation; the transient raw view isn't persisted.
Expand All @@ -101,10 +103,12 @@ export function saveQuery(state, tab, name, save = saveJSON, now = Date.now()) {
if (entry) {
entry.name = nm;
entry.sql = sql;
if (desc) entry.description = desc; else delete entry.description;
if (chart) entry.chart = chart; else delete entry.chart;
if (view) entry.view = view; else delete entry.view;
} else {
entry = { id: makeId('s', now), name: nm, sql, favorite: false };
if (desc) entry.description = desc;
if (chart) entry.chart = chart;
if (view) entry.view = view;
state.savedQueries.unshift(entry);
Expand All @@ -115,12 +119,20 @@ export function saveQuery(state, tab, name, save = saveJSON, now = Date.now()) {
return entry;
}

/** Rename a saved query, keeping any linked tab's name in sync. */
export function renameSaved(state, id, name, save = saveJSON) {
/**
* Rename a saved query, keeping any linked tab's name in sync. When
* `description` is provided (not undefined) it is set/cleared too; pass
* undefined to leave the existing description untouched (name-only rename).
*/
export function renameSaved(state, id, name, description, save = saveJSON) {
const nm = String(name || '').trim();
const entry = state.savedQueries.find((q) => q.id === id);
if (!entry || !nm) return;
entry.name = nm;
if (description !== undefined) {
const desc = String(description || '').trim(); // match saveQuery: null/non-string → '' → cleared
if (desc) entry.description = desc; else delete entry.description;
}
for (const t of tabsForSaved(state, id)) t.name = nm;
save(KEYS.saved, state.savedQueries);
}
Expand Down
43 changes: 38 additions & 5 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -349,11 +349,37 @@ body {
}
.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 .desc {
font-size: 11px; color: var(--fg-mute); line-height: 1.4; padding-left: 18px;
overflow: hidden; text-overflow: ellipsis;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
}
/* Expanded "edit name & description" form, shown in place of a saved row. */
.saved-edit {
padding: 9px 10px; background: var(--bg-hover);
border-bottom: 1px solid var(--border-faint); border-left: 2px solid var(--accent);
display: flex; flex-direction: column; gap: 5px;
}
.saved-edit .sv-field {
font-size: 9.5px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase;
color: var(--fg-faint); margin-top: 3px;
}
.saved-edit .sv-edit-name, .saved-edit .sv-edit-desc {
font: inherit; color: var(--fg); background: var(--bg-input, var(--bg-editor));
border: 1px solid var(--border); border-radius: 5px; padding: 4px 7px; outline: none;
box-sizing: border-box; width: 100%;
}
.saved-edit .sv-edit-name { font-size: 12px; font-weight: 500; border-color: var(--accent); }
.saved-edit .sv-edit-desc { font-size: 11.5px; line-height: 1.45; resize: vertical; min-height: 48px; }
.saved-edit .sv-edit-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 3px; }
.saved-edit .sv-edit-cancel, .saved-edit .sv-edit-save {
font: inherit; font-size: 11px; font-weight: 500; cursor: pointer;
padding: 4px 11px; border-radius: 5px; border: 1px solid var(--border);
}
.saved-edit .sv-edit-cancel { background: transparent; color: var(--fg); }
.saved-edit .sv-edit-cancel:hover { background: var(--bg-hover); }
.saved-edit .sv-edit-save { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
.saved-edit .sv-edit-save:hover { filter: brightness(1.08); }
.saved-row .preview {
font-size: 10.5px; font-family: var(--mono); color: var(--fg-faint);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
Expand Down Expand Up @@ -417,6 +443,13 @@ body {
border-radius: 5px; padding: 6px 8px; outline: none;
}
.save-popover .sp-input:focus { border-color: var(--accent); }
.save-popover .sp-opt { text-transform: none; letter-spacing: 0; font-weight: 400; color: var(--fg-faint); }
.save-popover .sp-desc {
font: inherit; font-size: 12px; line-height: 1.45; color: var(--fg);
background: var(--bg-editor); border: 1px solid var(--border);
border-radius: 5px; padding: 6px 8px; outline: none; resize: vertical; min-height: 52px;
}
.save-popover .sp-desc: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;
Expand Down
8 changes: 7 additions & 1 deletion src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -587,20 +587,26 @@ export function createApp(env = {}) {
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 descInput = h('textarea', { class: 'sp-desc', rows: '3', placeholder: 'What this query does — included in Markdown export' });
if (entry && entry.description) descInput.value = entry.description;
let close;
const commit = () => {
if (!input.value.trim()) return;
saveQuery(app.state, tab, input.value, saveJSON);
saveQuery(app.state, tab, input.value, descInput.value, saveJSON);
close();
app.updateSaveBtn();
app.actions.rerenderTabs();
renderSavedHistory(app);
flashToast('Saved', { document: doc });
};
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } });
// In the multiline description, plain Enter inserts a newline; ⌘/Ctrl+Enter commits.
descInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); commit(); } });
const pop = h('div', { class: 'save-popover' },
h('div', { class: 'sp-label' }, 'Save query as'),
input,
h('div', { class: 'sp-label' }, 'Description', h('span', { class: 'sp-opt' }, ' — optional')),
descInput,
h('div', { class: 'sp-actions' },
h('button', { class: 'sp-cancel', onclick: () => close() }, 'Cancel'),
h('button', { class: 'sp-save', onclick: commit }, 'Save')));
Expand Down
80 changes: 49 additions & 31 deletions src/ui/saved-history.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,55 +37,73 @@ function renderSaved(app, list) {
'No saved queries yet.', h('br'), 'Click ', Icon.bookmark(), ' Save next to Run.'));
}
for (const q of sortedSaved(state)) {
const editing = app.editingSavedId === q.id;
if (app.editingSavedId === q.id) { list.appendChild(savedEditForm(app, q)); continue; }
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, q.id, q.chart); app.actions.run({ view: q.view }); } } },
const row = h('div', { class: 'saved-row', onclick: () => { app.actions.loadIntoNewTab(q.name, q.sql, q.id, q.chart); app.actions.run({ view: q.view }); } },
h('div', { class: 'top' },
star,
nameEl,
editing ? null : h('button', {
class: 'sv-act', title: 'Rename',
h('span', { class: 'name' }, q.name),
h('button', {
class: 'sv-act', title: 'Edit name & description',
onclick: (e) => { e.stopPropagation(); app.editingSavedId = q.id; renderSavedHistory(app); },
}, Icon.pencil()),
editing ? null : h('button', {
h('button', {
class: 'sv-act', title: 'Delete',
onclick: (e) => { e.stopPropagation(); deleteSaved(state, q.id, app.saveJSON); app.updateSaveBtn(); renderSavedHistory(app); },
}, Icon.trash())),
q.description ? h('div', { class: 'desc' }, q.description) : null,
h('div', { class: 'preview' }, q.sql.split('\n')[0]));
list.appendChild(row);
}
list.appendChild(savedActions(app));
}

/**
* The expanded "edit name & description" form shown in place of a saved row
* while `app.editingSavedId === q.id`. The Name field commits on Enter, the
* Description field on ⌘/Ctrl+Enter (plain Enter inserts a newline); Escape or
* Cancel reverts. Clicks inside the form don't load the query. A `done` guard
* keeps the re-render teardown from double-firing the commit.
*/
function savedEditForm(app, q) {
const state = app.state;
const nameInput = h('input', { class: 'sv-edit-name', value: q.name, placeholder: 'Query name' });
const descInput = h('textarea', { class: 'sv-edit-desc', rows: '3', placeholder: 'What this query does (shown in Markdown export)' });
descInput.value = q.description || '';
let done = false;
const finish = (commit) => {
if (done) return;
done = true;
if (commit && nameInput.value.trim()) {
renameSaved(state, q.id, nameInput.value, descInput.value, app.saveJSON);
app.actions.rerenderTabs();
}
app.editingSavedId = null;
renderSavedHistory(app);
};
nameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); finish(true); }
else if (e.key === 'Escape') { e.preventDefault(); finish(false); }
});
descInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); finish(true); }
else if (e.key === 'Escape') { e.preventDefault(); finish(false); }
});
setTimeout(() => { nameInput.focus(); nameInput.select(); });
return h('div', { class: 'saved-edit', onclick: (e) => e.stopPropagation() },
h('div', { class: 'sv-field' }, 'Name'),
nameInput,
h('div', { class: 'sv-field' }, 'Description'),
descInput,
h('div', { class: 'sv-edit-actions' },
h('button', { class: 'sv-edit-cancel', onclick: () => finish(false) }, 'Cancel'),
h('button', { class: 'sv-edit-save', onclick: () => finish(true) }, 'Save')));
}

/** Export / Import row pinned at the bottom of the Saved panel. */
function savedActions(app) {
const empty = app.state.savedQueries.length === 0;
Expand Down
13 changes: 13 additions & 0 deletions tests/unit/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,19 @@ describe('share + star + columns', () => {
expect(app.dom.saveBtn.classList.contains('saved')).toBe(true);
expect(app.dom.saveBtn.textContent).toContain('Saved');
});
it('save popover: prefills the linked query description; ⌘Enter on the textarea commits', () => {
const app = createApp(env());
app.renderApp();
app.state.savedQueries = [{ id: 's9', name: 'Fav', sql: 'SELECT 9', favorite: false, description: 'why' }];
app.actions.loadIntoNewTab('Fav', 'SELECT 9', 's9');
app.actions.save();
const desc = document.querySelector('.save-popover .sp-desc');
expect(desc.value).toBe('why'); // prefilled from the linked entry
desc.value = 'updated reason';
desc.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', metaKey: true }));
expect(document.querySelector('.save-popover')).toBeNull(); // committed + closed
expect(app.state.savedQueries[0].description).toBe('updated reason');
});
const fakeReader = (content, fail) => class {
readAsText() { this.result = content; if (fail) this.onerror && this.onerror(); else this.onload && this.onload(); }
};
Expand Down
82 changes: 69 additions & 13 deletions tests/unit/saved-history.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,28 +54,84 @@ describe('renderSavedHistory', () => {
expect(names()).toEqual(['B', 'A']);
});

it('saved: pencil → inline rename; Enter commits, Escape cancels', () => {
it('saved: pencil opens the edit form; Name(Enter)+Description commit via renameSaved; double-fire is guarded', () => {
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 }));
byTitle(app.dom.savedList, 'Edit name & description').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');
const nameInput = app.dom.savedList.querySelector('.sv-edit-name');
const descInput = app.dom.savedList.querySelector('.sv-edit-desc');
expect(nameInput.value).toBe('Old');
expect(descInput.value).toBe(''); // no description yet
nameInput.value = 'New';
descInput.value = 'a description';
nameInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(app.state.savedQueries[0]).toMatchObject({ name: 'New', description: 'a description' });
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 }));
// a second commit on the now-detached field is a no-op (the `done` guard)
nameInput.value = 'AGAIN';
nameInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(app.state.savedQueries[0].name).toBe('New');
// re-open and press Escape on the name field → cancels without saving
byTitle(app.dom.savedList, 'Edit name & description').dispatchEvent(new Event('click', { bubbles: true }));
const reName = app.dom.savedList.querySelector('.sv-edit-name');
reName.value = 'XYZ';
reName.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('saved: edit form — description prefilled; ⌘/Ctrl+Enter + Save commit, Escape/Cancel + empty name revert', () => {
const app = makeApp();
app.state.sidePanel = 'saved';
app.state.savedQueries = [{ id: 's1', name: 'Old', sql: '1', favorite: false, description: 'd0' }];
renderSavedHistory(app);
const open = () => byTitle(app.dom.savedList, 'Edit name & description').dispatchEvent(new Event('click', { bubbles: true }));
// ⌘Enter on the description commits (and prefills the existing description)
open();
let descInput = app.dom.savedList.querySelector('.sv-edit-desc');
expect(descInput.value).toBe('d0');
descInput.value = 'd1';
descInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', metaKey: true, bubbles: true }));
expect(app.state.savedQueries[0].description).toBe('d1');
// Ctrl+Enter also commits
open();
descInput = app.dom.savedList.querySelector('.sv-edit-desc');
descInput.value = 'd2';
descInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true, bubbles: true }));
expect(app.state.savedQueries[0].description).toBe('d2');
// Escape on the description cancels without saving
open();
descInput = app.dom.savedList.querySelector('.sv-edit-desc');
descInput.value = 'nope';
descInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(app.state.savedQueries[0].description).toBe('d2');
expect(app.editingSavedId).toBeNull();
// Save button with a blank name does not rename (commit guard)
open();
app.dom.savedList.querySelector('.sv-edit-name').value = ' ';
app.dom.savedList.querySelector('.sv-edit-save').dispatchEvent(new Event('click', { bubbles: true }));
expect(app.state.savedQueries[0].name).toBe('Old');
expect(app.editingSavedId).toBeNull();
// Cancel button reverts an edited name
open();
app.dom.savedList.querySelector('.sv-edit-name').value = 'ZZZ';
app.dom.savedList.querySelector('.sv-edit-cancel').dispatchEvent(new Event('click', { bubbles: true }));
expect(app.state.savedQueries[0].name).toBe('Old');
});
it('saved: renders a 2-line description preview when present, omits it otherwise', () => {
const app = makeApp();
app.state.sidePanel = 'saved';
app.state.savedQueries = [
{ id: 's1', name: 'A', sql: '1', favorite: false, description: 'explains A' },
{ id: 's2', name: 'B', sql: '2', favorite: false },
];
renderSavedHistory(app);
const rows = app.dom.savedList.querySelectorAll('.saved-row');
expect(rows[0].querySelector('.desc').textContent).toBe('explains A');
expect(rows[1].querySelector('.desc')).toBeNull();
});

it('saved: Export/Import row — Export disabled when empty, enabled with queries, wired', () => {
Expand Down
Loading
Loading