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
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,38 @@ of scope for a textarea and tracked separately (CodeMirror, issue #21).
> work. The `.jsx` files there are React prototypes; production is the vanilla
> ES-module code under `src/`.

## Saved queries & the Library

Queries you save (★ **Save** next to Run, or `⌘S`) land in the sidebar **★ Library**
panel. Each carries a name, an optional **description**, and — when set — its
remembered result view and chart config. Saving or editing a query opens a small
form with both a name and a description field; the description shows under the
row and is included in Markdown/SQL exports.

The whole collection is treated as a **document — the Library** — with a name and
an unsaved-changes dot, managed from the header **File ▾** menu:

- **New Library** — clears to an empty, default-named library (confirms first
when non-empty). Open editor tabs are unaffected.
- **Save JSON** (`.json`) — downloads the whole Library in the versioned
`altinity-sql-browser/saved-queries` envelope (lossless: keeps id, name,
description, sql, favorite, chart, view). The filename derives from the Library
name; saving clears the unsaved-changes dot.
- **Replace… / Append…** — load a `.json` file: Replace swaps the Library and
adopts the file's base name (confirms when the current Library is non-empty);
Append merges via the existing dedupe and reports `Added N · updated N ·
skipped N`. **JSON is the only importable format**, and imported SQL is never
run automatically.
- **Share / publish** — **Download Markdown** (`.md`, a `### heading` + fenced
` ```sql ` cookbook) and **Download SQL** (`.sql`, `/* name + description */`
comment blocks, `;`-delimited). Both are **one-way** — lossy by design (no ids,
chart, or view), so JSON stays the canonical round-trip format.

The Library name is editable inline (click it in the header) and is persisted
separately from the queries. The **•** dot appears after any change that hasn't
been written to a file yet (save/rename/delete/favorite/append/rename) and clears
on Save JSON / Replace / New.

## Quick start (development)

```bash
Expand Down
Binary file modified src/core/saved-io.js
Binary file not shown.
84 changes: 83 additions & 1 deletion src/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { clamp } from './core/format.js';
import { mergeSaved } from './core/saved-io.js';
import { cloneChartCfg } from './core/chart-data.js';
import { loadJSON, saveJSON, loadStr } from './core/storage.js';
import { loadJSON, saveJSON, loadStr, saveStr } from './core/storage.js';

/** A tab's chart state as a persistable payload `{ cfg, key }`, or null. */
export function tabChart(tab) {
Expand All @@ -24,8 +24,12 @@ export const KEYS = {
sidePanel: 'asb:sidePanel',
saved: 'asb:saved',
history: 'asb:history',
libraryName: 'asb:libraryName',
};

/** Default name for a fresh / unnamed saved-query library. */
export const DEFAULT_LIBRARY_NAME = 'SQL Library';

/** A blank query tab. `chartCfg`/`chartKey` hold the per-tab chart config and
* the schema signature it was derived for (re-derived when the schema changes). */
export function newTabObj(id) {
Expand Down Expand Up @@ -60,6 +64,11 @@ export function createState(read = { loadJSON, loadStr }) {
sidePanel: read.loadStr(KEYS.sidePanel, 'saved'),
savedQueries: read.loadJSON(KEYS.saved, []),
history: read.loadJSON(KEYS.history, []),
// The saved-query collection treated as a named document ("the Library").
// `libraryName` is persisted; `libraryDirty` (unsaved changes since the last
// file Save/Replace/New) is session-only and resets on reload.
libraryName: read.loadStr(KEYS.libraryName, DEFAULT_LIBRARY_NAME),
libraryDirty: false,
shortcutsOpen: false,
};
}
Expand Down Expand Up @@ -115,6 +124,7 @@ export function saveQuery(state, tab, name, description, save = saveJSON, now =
tab.savedId = entry.id;
}
tab.name = nm;
state.libraryDirty = true;
save(KEYS.saved, state.savedQueries);
return entry;
}
Expand All @@ -134,6 +144,7 @@ export function renameSaved(state, id, name, description, save = saveJSON) {
if (desc) entry.description = desc; else delete entry.description;
}
for (const t of tabsForSaved(state, id)) t.name = nm;
state.libraryDirty = true;
save(KEYS.saved, state.savedQueries);
}

Expand All @@ -142,6 +153,7 @@ export function toggleFavorite(state, id, save = saveJSON) {
const entry = state.savedQueries.find((q) => q.id === id);
if (!entry) return;
entry.favorite = !entry.favorite;
state.libraryDirty = true;
save(KEYS.saved, state.savedQueries);
}

Expand All @@ -160,6 +172,7 @@ export function sortedSaved(state) {
export function importSaved(state, queries, save = saveJSON, genId = () => makeId('s', Date.now())) {
const { merged, added, updated, skipped } = mergeSaved(state.savedQueries, queries, genId);
state.savedQueries = merged;
state.libraryDirty = true;
save(KEYS.saved, state.savedQueries);
return { added, updated, skipped };
}
Expand All @@ -168,7 +181,76 @@ export function importSaved(state, queries, save = saveJSON, genId = () => makeI
export function deleteSaved(state, id, save = saveJSON) {
state.savedQueries = state.savedQueries.filter((q) => q.id !== id);
for (const t of tabsForSaved(state, id)) t.savedId = null;
state.libraryDirty = true;
save(KEYS.saved, state.savedQueries);
}

// ── Library document ops ────────────────────────────────────────────────────
// The saved-query collection is a named, savable document. These ops back the
// header File menu (New / Save / Replace / Append) and the editable library
// name + unsaved-changes dot.

/** Clear tab→saved links whose entry no longer exists (after New/Replace), so a
* kept tab doesn't show "Saved" against a query that's gone. */
function pruneTabLinks(state) {
const ids = new Set(state.savedQueries.map((q) => q.id));
for (const t of state.tabs) if (t.savedId && !ids.has(t.savedId)) t.savedId = null;
}

/** Rename the library (blank → the default name). Marks dirty; persists name. */
export function renameLibrary(state, name, saveName = saveStr) {
state.libraryName = String(name || '').trim() || DEFAULT_LIBRARY_NAME;
state.libraryDirty = true;
saveName(KEYS.libraryName, state.libraryName);
}

/** Start an empty, default-named library. Clears dirty; open tabs are kept
* (their now-dangling saved links are pruned). */
export function newLibrary(state, save = saveJSON, saveName = saveStr) {
state.savedQueries = [];
pruneTabLinks(state);
state.libraryName = DEFAULT_LIBRARY_NAME;
state.libraryDirty = false;
save(KEYS.saved, state.savedQueries);
saveName(KEYS.libraryName, state.libraryName);
}

/** Replace the library with `queries`, adopting the loaded file's base name.
* Unique ids are kept (lossless round-trip); missing OR duplicate ids get a fresh id.
* Clears dirty; open tabs are kept (dangling links pruned). */
export function replaceLibrary(state, queries, fileName, save = saveJSON, saveName = saveStr, genId = () => makeId('s', Date.now())) {
const seen = new Set();
state.savedQueries = queries.map((q) => {
// Mint a fresh id for a missing OR already-seen id so every saved row has a
// unique id. The sidebar addresses rows by id (find/filter), so a duplicate
// id would let one delete remove several rows and rename/favorite hit the
// wrong one. (mergeSaved-based import already collapsed dup ids; keep parity.)
let id = q.id;
if (!id || seen.has(id)) { do { id = genId(); } while (seen.has(id)); }
seen.add(id);
return {
id, name: q.name, sql: q.sql, favorite: !!q.favorite,
...(q.description ? { description: q.description } : {}),
...(q.chart ? { chart: q.chart } : {}), ...(q.view ? { view: q.view } : {}),
};
});
pruneTabLinks(state);
const base = String(fileName || '').replace(/\.[^.]+$/, '').trim();
state.libraryName = base || DEFAULT_LIBRARY_NAME;
state.libraryDirty = false;
save(KEYS.saved, state.savedQueries);
saveName(KEYS.libraryName, state.libraryName);
}

/** Append `queries` into the library via the standard merge dedupe (sets dirty
* through importSaved). Returns { added, updated, skipped }. */
export function appendLibrary(state, queries, save = saveJSON, genId = () => makeId('s', Date.now())) {
return importSaved(state, queries, save, genId);
}

/** Mark the library as saved to a file (clears the unsaved-changes dot). */
export function markLibrarySaved(state) {
state.libraryDirty = false;
}

/** Record a successful run in history (most-recent first, capped at 50). */
Expand Down
73 changes: 73 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,79 @@ body {
.user-menu .um-item.danger { color: #ef4444; }
.user-menu .um-item.danger:hover { background: color-mix(in oklab, #ef4444 12%, transparent); }

/* ------------ header File menu + library title ------------ */
.hd-divider { width: 1px; height: 18px; background: var(--border); flex-shrink: 0; }
.hd-file-btn {
display: flex; align-items: center; gap: 5px; height: 26px; padding: 0 8px;
border: none; border-radius: 6px; background: transparent; color: var(--fg-mute);
cursor: pointer; font-family: inherit; font-size: 12.5px; font-weight: 500; flex-shrink: 0;
}
.hd-file-btn:hover { background: var(--bg-hover); color: var(--fg); }
.lib-title { display: flex; align-items: center; min-width: 0; }
.lib-name {
display: flex; align-items: center; gap: 6px; height: 24px; padding: 0 7px;
border: none; border-radius: 6px; background: transparent; cursor: pointer;
font-family: inherit; font-size: 12.5px; font-weight: 500; color: var(--fg);
max-width: 280px; min-width: 0;
}
.lib-name:hover { background: var(--bg-hover); }
.lib-name-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lib-dirty { width: 6px; height: 6px; border-radius: 3px; background: var(--accent); flex-shrink: 0; }
.lib-name-input {
height: 24px; min-width: 120px; max-width: 280px; padding: 0 8px;
background: var(--bg-input); border: 1px solid var(--accent); border-radius: 6px;
color: var(--fg); font-family: inherit; font-size: 12.5px; outline: none;
}
/* File dropdown */
.fm-overlay { position: fixed; inset: 0; z-index: 60; }
.file-menu {
z-index: 61; width: 252px; padding-bottom: 4px;
background: var(--bg-modal); border: 1px solid var(--border);
border-radius: 9px; box-shadow: 0 12px 36px rgba(0,0,0,.35); overflow: hidden;
}
.fm-item {
width: 100%; display: flex; align-items: center; gap: 9px; padding: 7px 12px;
border: none; background: transparent; color: var(--fg); font-family: inherit;
font-size: 12.5px; cursor: pointer; text-align: left;
}
.fm-item:hover { background: var(--bg-hover); }
.fm-icon { display: flex; width: 15px; justify-content: center; color: var(--fg-mute); flex-shrink: 0; }
.fm-label { flex: 1; white-space: nowrap; }
.fm-meta { font-size: 10px; color: var(--fg-faint); font-family: var(--mono); flex-shrink: 0; }
.fm-sep { height: 1px; background: var(--border-faint); margin: 5px 0; }
.fm-section {
font-size: 10px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase;
color: var(--fg-faint); padding: 9px 12px 4px;
}
.fm-count {
margin-top: 4px; padding: 8px 12px; border-top: 1px solid var(--border-faint);
font-size: 10.5px; color: var(--fg-faint); font-family: var(--mono);
}
/* Replace / New confirm dialog */
.fm-dialog-backdrop {
position: fixed; inset: 0; z-index: 130; background: rgba(0,0,0,.5);
backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
}
.fm-dialog-card {
width: 392px; max-width: calc(100vw - 32px);
background: var(--bg-modal); border: 1px solid var(--border); border-radius: 11px;
box-shadow: 0 20px 60px rgba(0,0,0,.45); padding: 20px 22px;
}
.fm-dialog-title { font-size: 14.5px; font-weight: 600; color: var(--fg); margin-bottom: 6px; }
.fm-dialog-body { font-size: 12.5px; color: var(--fg-mute); line-height: 1.55; margin-bottom: 18px; }
.fm-dialog-body b { color: var(--fg); }
.fm-mono { color: var(--fg); font-family: var(--mono); }
.fm-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
.fm-dialog-cancel, .fm-dialog-confirm {
height: 30px; padding: 0 14px; border-radius: 6px; font-family: inherit;
font-size: 12px; font-weight: 500; cursor: pointer;
}
.fm-dialog-cancel { border: 1px solid var(--border); background: transparent; color: var(--fg); }
.fm-dialog-cancel:hover { background: var(--bg-hover); }
.fm-dialog-confirm { border: none; background: var(--accent); color: #fff; font-weight: 600; }
.fm-dialog-confirm:hover { filter: brightness(1.08); }

/* ------------ main row ------------ */
.main-row { flex: 1; display: flex; min-height: 0; overflow: hidden; }
.sidebar {
Expand Down
42 changes: 12 additions & 30 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@
import { h } from './dom.js';
import { Icon } from './icons.js';
import {
createState, activeTab, KEYS, recordHistory, saveQuery, savedForTab, importSaved, tabChart,
createState, activeTab, KEYS, recordHistory, saveQuery, savedForTab, tabChart,
} from '../state.js';
import { saveJSON, saveStr } from '../core/storage.js';
import { decodeJwtPayload, isTokenExpired } from '../core/jwt.js';
import { sqlString, inferQueryName, shortVersion, userShortName } from '../core/format.js';
import { resolveTarget } from '../core/target.js';
import { buildExportDoc, parseImportDoc } from '../core/saved-io.js';
import { toTSV, toCSV } from '../core/export.js';
import { newResult, applyStreamLine } from '../core/stream.js';
import { encodeShare } from '../core/share.js';
Expand All @@ -27,6 +26,7 @@ import { renderTabs, selectTab, newTab, closeTab, loadIntoNewTab } from './tabs.
import { renderSchema } from './schema.js';
import { renderResults } from './results.js';
import { renderSavedHistory } from './saved-history.js';
import { libraryControls, renderLibraryTitle } from './file-menu.js';
import { renderLogin } from './login.js';
import { openShortcuts } from './shortcuts.js';
import { startDrag } from './splitters.js';
Expand Down Expand Up @@ -81,7 +81,14 @@ export function createApp(env = {}) {

// --- persistence -------------------------------------------------------
app.saveJSON = saveJSON;
app.saveStr = saveStr;
app.savePref = (name, value) => saveStr(KEYS[name], String(value));
app.FileReader = env.FileReader || win.FileReader;
// Exposed seams for the header File menu (file-menu.js): the file-download
// helper (defined below) and a library-title refresh (dirty dot + name) run
// after a library mutation made outside file-menu.js (e.g. the save popover).
app.downloadFile = downloadFile;
app.updateLibraryTitle = () => renderLibraryTitle(app);

// --- identity ----------------------------------------------------------
app.host = () => (app.authMode === 'basic'
Expand Down Expand Up @@ -597,6 +604,7 @@ export function createApp(env = {}) {
app.updateSaveBtn();
app.actions.rerenderTabs();
renderSavedHistory(app);
app.updateLibraryTitle();
flashToast('Saved', { document: doc });
};
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } });
Expand Down Expand Up @@ -627,32 +635,6 @@ export function createApp(env = {}) {
}
app.openUserMenu = openUserMenu;

// --- export / import saved queries -------------------------------------
function exportSaved() {
const qs = app.state.savedQueries;
if (!qs.length) { flashToast('Nothing to export', { document: doc }); return; }
const nowISO = new Date().toISOString();
downloadFile('sql-browser-queries-' + nowISO.slice(0, 10) + '.json', 'application/json',
JSON.stringify(buildExportDoc(qs, nowISO), null, 2));
flashToast('Exported ' + qs.length + (qs.length === 1 ? ' query' : ' queries'), { document: doc });
}
function importSavedFile(file) {
const reader = new (env.FileReader || win.FileReader)();
reader.onload = () => {
try {
const { queries } = parseImportDoc(String(reader.result));
const { added, updated, skipped } = importSaved(app.state, queries, saveJSON);
app.updateSaveBtn();
renderSavedHistory(app);
flashToast('Added ' + added + ' · updated ' + updated + ' · skipped ' + skipped, { document: doc });
} catch (e) {
flashToast('✕ ' + ((e && e.message) || e), { document: doc });
}
};
reader.onerror = () => flashToast('✕ Could not read file', { document: doc });
reader.readAsText(file);
}

function toggleTheme() {
app.state.theme = app.state.theme === 'dark' ? 'light' : 'dark';
app.savePref('theme', app.state.theme);
Expand All @@ -675,8 +657,6 @@ export function createApp(env = {}) {
exportResult,
save: openSavePopover,
openUserMenu,
exportSaved,
importSavedFile,
formatQuery,
insertCreate,
openShortcuts: () => openShortcuts(app),
Expand Down Expand Up @@ -709,6 +689,8 @@ export function renderApp(app, helpers) {
h('div', { class: 'logo-mark' }, 'A'),
h('div', { class: 'logo-name' }, 'Altinity SQL Browser'),
h('div', { class: 'env-chip' }, app.host()),
h('div', { class: 'hd-divider' }),
...libraryControls(app),
h('div', { style: { flex: '1' } }),
app.dom.connStatus,
h('a', {
Expand Down
Loading
Loading