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
65 changes: 48 additions & 17 deletions src/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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. */
Expand Down
60 changes: 45 additions & 15 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -248,35 +248,40 @@ 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;
border-bottom: 1px solid var(--border-faint);
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;
Expand All @@ -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 {
Expand Down
78 changes: 61 additions & 17 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand All @@ -429,7 +472,7 @@ export function createApp(env = {}) {
share,
copyResult,
exportResult,
toggleSaved: toggleSavedActive,
save: openSavePopover,
formatQuery,
insertCreate,
openShortcuts: () => openShortcuts(app),
Expand All @@ -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 });
Expand Down Expand Up @@ -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) });
Expand All @@ -532,7 +576,7 @@ export function renderApp(app, helpers) {
renderResults(app);
renderSchema(app);
renderSavedHistory(app);
app.updateStar();
app.updateSaveBtn();
app.loadVersion();
app.loadSchema();
}
2 changes: 1 addition & 1 deletion src/ui/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src/ui/icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,10 @@ export const Icon = {
shortcuts: () => iconEl('<rect x="1.5" y="3" width="9" height="6" rx="1"/><path d="M3.5 5h.01M6 5h.01M8.5 5h.01M3.5 7h5"/>', 12, 12, 1.3),
copy: () => iconEl('<rect x="3.5" y="3.5" width="7" height="7" rx="1"/><path d="M2 8.5V2.5a1 1 0 0 1 1-1h6"/>', 12, 12),
download: () => iconEl('<path d="M6 1.5v6.5M3.5 5.5L6 8l2.5-2.5"/><path d="M2 10h8"/>', 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('<path d="M3.5 1.8h5a.6.6 0 0 1 .6.6v8.2l-3.1-2-3.1 2V2.4a.6.6 0 0 1 .6-.6z"/>', 12, 12, 1.3),
pencil: () => iconEl('<path d="M2 10l.6-2.5 5-5 1.9 1.9-5 5z"/><path d="M7 3.1l1.9 1.9"/>', 12, 12),
trash: () => iconEl('<path d="M2.5 3.5h7"/><path d="M4 3.5V2.4h3v1.1"/><path d="M3.4 3.5l.45 6.6a.6.6 0 0 0 .6.5h2.9a.6.6 0 0 0 .6-.5l.45-6.6"/>', 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),
};
Loading
Loading