diff --git a/src/core/format.js b/src/core/format.js
index 08abbd8..dce19bd 100644
--- a/src/core/format.js
+++ b/src/core/format.js
@@ -75,3 +75,14 @@ export function shortVersion(v) {
const parts = String(v || '').split('.');
return parts.length > 3 ? parts.slice(0, 3).join('.') : String(v || '');
}
+
+/**
+ * Short display name for the header user control: the local-part of an email
+ * (before '@'). Falls back to the whole string when there's no '@', and '' for
+ * empty/nullish input.
+ */
+export function userShortName(email) {
+ const s = String(email || '');
+ const at = s.indexOf('@');
+ return at > 0 ? s.slice(0, at) : s;
+}
diff --git a/src/core/saved-io.js b/src/core/saved-io.js
new file mode 100644
index 0000000..7c6a39f
Binary files /dev/null and b/src/core/saved-io.js differ
diff --git a/src/state.js b/src/state.js
index 7239f91..ff3869c 100644
--- a/src/state.js
+++ b/src/state.js
@@ -3,6 +3,7 @@
// every operation is unit-testable with a spy and no real localStorage.
import { clamp } from './core/format.js';
+import { mergeSaved } from './core/saved-io.js';
import { loadJSON, saveJSON, loadStr } from './core/storage.js';
export const KEYS = {
@@ -120,6 +121,17 @@ export function sortedSaved(state) {
.map(([q]) => q);
}
+/**
+ * Merge imported queries into savedQueries (dedupe by content, update by id,
+ * else add). Returns { added, updated, skipped }.
+ */
+export function importSaved(state, queries, save = saveJSON, genId = () => 's' + Date.now() + rnd()) {
+ const { merged, added, updated, skipped } = mergeSaved(state.savedQueries, queries, genId);
+ state.savedQueries = merged;
+ save(KEYS.saved, state.savedQueries);
+ return { added, updated, skipped };
+}
+
/** Delete a saved query by id and clear any tab pointer to it. */
export function deleteSaved(state, id, save = saveJSON) {
state.savedQueries = state.savedQueries.filter((q) => q.id !== id);
diff --git a/src/styles.css b/src/styles.css
index 0f4570f..5450eac 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -189,6 +189,31 @@ body {
}
.hd-btn:hover { background: var(--bg-hover); color: var(--fg); }
.hd-btn.text { width: auto; padding: 0 10px; font-size: 11.5px; font-family: inherit; border: 1px solid var(--border); }
+.hd-btn.user-btn {
+ width: auto; gap: 5px; padding: 0 8px;
+ font-size: 11.5px; font-family: var(--mono);
+}
+.hd-btn.user-btn .user-short { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.user-menu {
+ z-index: 50; min-width: 200px; padding: 5px;
+ 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: 2px;
+}
+.user-menu .um-id {
+ padding: 7px 9px; font-size: 11px; color: var(--fg-faint); font-family: var(--mono);
+ border-bottom: 1px solid var(--border-faint); margin-bottom: 3px;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+.user-menu .um-item {
+ display: flex; align-items: center; gap: 8px;
+ width: 100%; padding: 7px 9px; border: none; background: transparent;
+ font: inherit; font-size: 12px; color: var(--fg); cursor: pointer;
+ border-radius: 5px; text-align: left;
+}
+.user-menu .um-item:hover { background: var(--bg-hover); }
+.user-menu .um-item.danger { color: #ef4444; }
+.user-menu .um-item.danger:hover { background: color-mix(in oklab, #ef4444 12%, transparent); }
/* ------------ main row ------------ */
.main-row { flex: 1; display: flex; min-height: 0; overflow: hidden; }
@@ -232,7 +257,7 @@ body {
}
.side-tab.active { color: var(--fg); border-bottom-color: var(--accent); }
.side-tab:hover { color: var(--fg); }
-.saved-list { flex: 1; overflow: auto; padding: 0; }
+.saved-list { flex: 1; overflow: auto; padding: 0; display: flex; flex-direction: column; }
.saved-empty {
padding: 18px 14px; color: var(--fg-faint); font-size: 11.5px;
text-align: center; line-height: 1.5;
@@ -273,6 +298,21 @@ body {
.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; }
+/* Export/Import row at the end of the Saved panel. margin-top:auto sinks it to
+ the bottom when the list is short, but it scrolls away with a long list (not
+ sticky) — no need to keep it on screen once there's plenty to scroll. */
+.saved-actions {
+ margin-top: auto; flex-shrink: 0;
+ display: flex; gap: 6px; padding: 6px 10px;
+ border-top: 1px solid var(--border); background: var(--bg-panel, var(--bg-sidebar, var(--bg-editor)));
+}
+.sv-io {
+ flex: 1; height: 24px; border: 1px solid var(--border); border-radius: 5px;
+ background: transparent; color: var(--fg-mute); font-size: 11px; font-family: inherit;
+ cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 5px;
+}
+.sv-io:hover:not([disabled]) { background: var(--bg-hover); color: var(--fg); }
+.sv-io[disabled] { opacity: .45; cursor: not-allowed; }
.history-row {
position: relative;
padding: 8px 10px; cursor: pointer; user-select: none;
diff --git a/src/ui/app.js b/src/ui/app.js
index 12bd02c..40b3269 100644
--- a/src/ui/app.js
+++ b/src/ui/app.js
@@ -7,11 +7,12 @@
import { h } from './dom.js';
import { Icon } from './icons.js';
import {
- createState, activeTab, KEYS, recordHistory, saveQuery, savedForTab,
+ createState, activeTab, KEYS, recordHistory, saveQuery, savedForTab, importSaved,
} from '../state.js';
import { saveJSON, saveStr } from '../core/storage.js';
import { decodeJwtPayload, isTokenExpired } from '../core/jwt.js';
-import { sqlString, inferQueryName, shortVersion } from '../core/format.js';
+import { sqlString, inferQueryName, shortVersion, userShortName } from '../core/format.js';
+import { buildExportDoc, parseImportDoc } from '../core/saved-io.js';
import { toTSV, toCSV } from '../core/export.js';
import { newResult, applyStreamLine } from '../core/stream.js';
import { encodeSqlForHash } from '../core/share.js';
@@ -458,6 +459,57 @@ export function createApp(env = {}) {
}
app.openSavePopover = openSavePopover;
+ // User menu: dropdown under the header user button, holding the identity and
+ // a Log out item. Same close model as the save popover (Esc + outside click).
+ function openUserMenu() {
+ if (app.dom.userMenu) return;
+ const close = () => {
+ doc.removeEventListener('keydown', onKey, true);
+ doc.removeEventListener('mousedown', onOutside, true);
+ if (app.dom.userMenu) { app.dom.userMenu.remove(); app.dom.userMenu = null; }
+ };
+ const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } };
+ const onOutside = (e) => { if (app.dom.userMenu && !app.dom.userMenu.contains(e.target) && e.target !== app.dom.userBtn && !app.dom.userBtn.contains(e.target)) close(); };
+ const menu = h('div', { class: 'user-menu' },
+ h('div', { class: 'um-id' }, app.email()),
+ h('button', { class: 'um-item danger', onclick: () => { close(); app.signOut(); } }, Icon.logout(), h('span', null, 'Log out')));
+ app.dom.userMenu = menu;
+ const r = app.dom.userBtn.getBoundingClientRect();
+ menu.style.position = 'fixed';
+ menu.style.top = (r.bottom + 6) + 'px';
+ menu.style.right = Math.max(8, (win.innerWidth || 0) - r.right) + 'px';
+ doc.body.appendChild(menu);
+ doc.addEventListener('keydown', onKey, true);
+ doc.addEventListener('mousedown', onOutside, true);
+ }
+ 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);
@@ -477,6 +529,9 @@ export function createApp(env = {}) {
copyResult,
exportResult,
save: openSavePopover,
+ openUserMenu,
+ exportSaved,
+ importSavedFile,
formatQuery,
insertCreate,
openShortcuts: () => openShortcuts(app),
@@ -502,6 +557,8 @@ export function renderApp(app, helpers) {
app.dom.connStatus = h('div', { class: 'conn-status dim' }, h('span', { class: 'ver' }, 'Connecting…'));
app.dom.themeBtn = h('button', { class: 'hd-btn', title: 'Toggle theme', onclick: helpers.toggleTheme });
app.dom.themeBtn.appendChild(state.theme === 'dark' ? Icon.sun() : Icon.moon());
+ app.dom.userBtn = h('button', { class: 'hd-btn user-btn', title: app.email(), onclick: () => app.actions.openUserMenu() },
+ h('span', { class: 'user-short' }, userShortName(app.email())), Icon.chevDown());
const header = h('div', { class: 'app-header' },
h('div', { class: 'logo-mark' }, 'A'),
@@ -515,8 +572,7 @@ export function renderApp(app, helpers) {
}, Icon.github()),
h('button', { class: 'hd-btn', title: 'Keyboard shortcuts (?)', onclick: () => app.actions.openShortcuts() }, Icon.shortcuts()),
app.dom.themeBtn,
- h('div', { class: 'user-email', title: app.email() }, app.email()),
- h('button', { class: 'hd-btn text', title: 'Log out', onclick: () => app.signOut() }, 'Log Out'));
+ app.dom.userBtn);
app.dom.schemaSearchInput = h('input', {
type: 'text', placeholder: 'Search tables, columns…',
diff --git a/src/ui/icons.js b/src/ui/icons.js
index a3e96a2..39eaf35 100644
--- a/src/ui/icons.js
+++ b/src/ui/icons.js
@@ -81,6 +81,8 @@ export const Icon = {
shortcuts: () => iconEl('', 12, 12, 1.3),
copy: () => iconEl('', 12, 12),
download: () => iconEl('', 12, 12),
+ upload: () => iconEl('', 12, 12),
+ logout: () => 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),
diff --git a/src/ui/saved-history.js b/src/ui/saved-history.js
index ff2822e..c8f3462 100644
--- a/src/ui/saved-history.js
+++ b/src/ui/saved-history.js
@@ -35,7 +35,6 @@ function renderSaved(app, list) {
if (state.savedQueries.length === 0) {
list.appendChild(h('div', { class: 'saved-empty' },
'No saved queries yet.', h('br'), 'Click ', Icon.bookmark(), ' Save next to Run.'));
- return;
}
for (const q of sortedSaved(state)) {
const editing = app.editingSavedId === q.id;
@@ -84,6 +83,26 @@ function renderSaved(app, list) {
h('div', { class: 'preview' }, q.sql.split('\n')[0]));
list.appendChild(row);
}
+ list.appendChild(savedActions(app));
+}
+
+/** Export / Import row pinned at the bottom of the Saved panel. */
+function savedActions(app) {
+ const empty = app.state.savedQueries.length === 0;
+ const fileInput = h('input', {
+ type: 'file', accept: 'application/json,.json', style: { display: 'none' },
+ onchange: (e) => { const f = e.target.files && e.target.files[0]; if (f) app.actions.importSavedFile(f); e.target.value = ''; },
+ });
+ return h('div', { class: 'saved-actions' },
+ h('button', {
+ class: 'sv-io', disabled: empty ? true : null, title: 'Download all saved queries as JSON',
+ onclick: () => app.actions.exportSaved(),
+ }, Icon.download(), h('span', null, 'Export')),
+ h('button', {
+ class: 'sv-io', title: 'Import saved queries from a JSON file',
+ onclick: () => fileInput.click(),
+ }, Icon.upload(), h('span', null, 'Import')),
+ fileInput);
}
function renderHistory(app, list) {
diff --git a/tests/helpers/fake-app.js b/tests/helpers/fake-app.js
index 8272b80..2cf1ace 100644
--- a/tests/helpers/fake-app.js
+++ b/tests/helpers/fake-app.js
@@ -42,6 +42,8 @@ export function makeApp(over = {}) {
copyResult: vi.fn(),
exportResult: vi.fn(),
save: vi.fn(),
+ exportSaved: vi.fn(),
+ importSavedFile: vi.fn(),
formatQuery: vi.fn(),
insertCreate: vi.fn(),
openShortcuts: vi.fn(),
diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js
index d62c20d..1c89321 100644
--- a/tests/unit/app.test.js
+++ b/tests/unit/app.test.js
@@ -93,7 +93,9 @@ describe('renderApp shell', () => {
expect(app.root.querySelector('.app-header')).not.toBeNull();
expect(app.root.querySelector('.sidebar')).not.toBeNull();
expect(app.root.querySelector('.sql-editor')).not.toBeNull();
- expect(app.root.querySelector('.user-email').textContent).toBe('me@example.com');
+ // user control shows the short name (local-part) + full email on hover
+ expect(app.dom.userBtn.querySelector('.user-short').textContent).toBe('me');
+ expect(app.dom.userBtn.getAttribute('title')).toBe('me@example.com');
await Promise.resolve();
});
it('toggles theme via the header button', () => {
@@ -103,16 +105,28 @@ describe('renderApp shell', () => {
expect(app.savePref).toBeUndefined; // savePref is internal; theme attr set
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
});
- it('sign-out clears tokens and shows login', () => {
+ it('user menu: open → Log out clears tokens and shows login', () => {
const { app, e } = rendered();
- app.root.querySelector('.hd-btn.text').dispatchEvent(new Event('click'));
+ app.dom.userBtn.dispatchEvent(new Event('click'));
+ const menu = document.querySelector('.user-menu');
+ expect(menu).not.toBeNull();
+ expect(menu.querySelector('.um-id').textContent).toBe('me@example.com');
+ menu.querySelector('.um-item.danger').dispatchEvent(new Event('click', { bubbles: true }));
expect(app.token).toBeNull();
expect(e.sessionStorage.getItem('oauth_id_token')).toBeNull();
expect(app.root.querySelector('.login-screen')).not.toBeNull();
+ expect(document.querySelector('.user-menu')).toBeNull(); // closed
});
- it('header has a Log Out button and a GitHub source link', () => {
+ it('user menu closes on Escape and outside-click; header has a GitHub source link', () => {
const { app } = rendered();
- expect(app.root.querySelector('.hd-btn.text').textContent).toContain('Log Out');
+ app.dom.userBtn.dispatchEvent(new Event('click'));
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
+ expect(document.querySelector('.user-menu')).toBeNull();
+ app.actions.openUserMenu();
+ app.actions.openUserMenu(); // idempotent while open
+ expect(document.querySelectorAll('.user-menu')).toHaveLength(1);
+ document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
+ expect(document.querySelector('.user-menu')).toBeNull();
const gh = app.root.querySelector('a.hd-btn[href*="github.com"]');
expect(gh).not.toBeNull();
expect(gh.getAttribute('target')).toBe('_blank');
@@ -452,6 +466,44 @@ describe('share + star + columns', () => {
expect(app.dom.saveBtn.classList.contains('saved')).toBe(true);
expect(app.dom.saveBtn.textContent).toContain('Saved');
});
+ const fakeReader = (content, fail) => class {
+ readAsText() { this.result = content; if (fail) this.onerror && this.onerror(); else this.onload && this.onload(); }
+ };
+ it('exportSaved downloads the envelope; empty list → toast only', () => {
+ const download = vi.fn();
+ const app = createApp(env({ download }));
+ app.renderApp();
+ app.actions.exportSaved(); // empty
+ expect(download).not.toHaveBeenCalled();
+ expect(document.querySelector('.share-toast').textContent).toBe('Nothing to export');
+ app.state.savedQueries = [{ id: 's1', name: 'A', sql: 'SELECT 1', favorite: true }];
+ app.actions.exportSaved();
+ const [fname, mime, content] = download.mock.calls[0];
+ expect(fname).toMatch(/^sql-browser-queries-\d{4}-\d{2}-\d{2}\.json$/);
+ expect(mime).toBe('application/json');
+ const docObj = JSON.parse(content);
+ expect(docObj.format).toBe('altinity-sql-browser/saved-queries');
+ expect(docObj.queries).toEqual([{ id: 's1', name: 'A', sql: 'SELECT 1', favorite: true }]);
+ expect(document.querySelector('.share-toast').textContent).toBe('Exported 1 query');
+ });
+ it('importSavedFile merges a valid file and toasts counts', () => {
+ const text = JSON.stringify({ format: 'altinity-sql-browser/saved-queries', version: 1, queries: [{ id: 'x1', name: 'New', sql: 'SELECT 9' }] });
+ const app = createApp(env({ FileReader: fakeReader(text) }));
+ app.renderApp();
+ app.actions.importSavedFile({});
+ expect(app.state.savedQueries.some((q) => q.name === 'New')).toBe(true);
+ expect(document.querySelector('.share-toast').textContent).toBe('Added 1 · updated 0 · skipped 0');
+ });
+ it('importSavedFile reports parse errors and read errors with ✕', () => {
+ const bad = createApp(env({ FileReader: fakeReader('{not json') }));
+ bad.renderApp();
+ bad.actions.importSavedFile({});
+ expect(document.querySelector('.share-toast').textContent).toBe('✕ Not a valid JSON file');
+ const err = createApp(env({ FileReader: fakeReader('', true) }));
+ err.renderApp();
+ err.actions.importSavedFile({});
+ expect(document.querySelector('.share-toast').textContent).toBe('✕ Could not read file');
+ });
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: '' }] } })]]) });
const app = createApp(e);
diff --git a/tests/unit/format.test.js b/tests/unit/format.test.js
index 2d342ed..44730e8 100644
--- a/tests/unit/format.test.js
+++ b/tests/unit/format.test.js
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import {
- clamp, formatRows, formatBytes, timeAgo, sqlString, inferQueryName, isNumericType, shortVersion,
+ clamp, formatRows, formatBytes, timeAgo, sqlString, inferQueryName, isNumericType, shortVersion, userShortName,
} from '../../src/core/format.js';
describe('clamp', () => {
@@ -113,3 +113,15 @@ describe('shortVersion', () => {
expect(shortVersion(null)).toBe('');
});
});
+
+describe('userShortName', () => {
+ it('returns the email local-part', () => {
+ expect(userShortName('btyshkevich@altinity.com')).toBe('btyshkevich');
+ });
+ it('falls back to the whole string with no @, and "" for empty/nullish', () => {
+ expect(userShortName('justname')).toBe('justname');
+ expect(userShortName('@nolocal')).toBe('@nolocal'); // at index 0 → no split
+ expect(userShortName('')).toBe('');
+ expect(userShortName(null)).toBe('');
+ });
+});
diff --git a/tests/unit/saved-history.test.js b/tests/unit/saved-history.test.js
index 60c4d0b..f1a5453 100644
--- a/tests/unit/saved-history.test.js
+++ b/tests/unit/saved-history.test.js
@@ -1,4 +1,4 @@
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, vi } from 'vitest';
import { renderSavedHistory } from '../../src/ui/saved-history.js';
import { makeApp } from '../helpers/fake-app.js';
@@ -75,6 +75,37 @@ describe('renderSavedHistory', () => {
// clicking the row while editing another does not load (guard) — covered by Enter path above
});
+ it('saved: Export/Import row — Export disabled when empty, enabled with queries, wired', () => {
+ const app = makeApp();
+ app.state.sidePanel = 'saved';
+ renderSavedHistory(app);
+ let exportBtn = [...app.dom.savedList.querySelectorAll('.sv-io')].find((b) => /Export/.test(b.textContent));
+ expect(exportBtn.disabled).toBe(true); // empty list
+ app.state.savedQueries = [{ id: 's1', name: 'A', sql: '1', favorite: false }];
+ renderSavedHistory(app);
+ exportBtn = [...app.dom.savedList.querySelectorAll('.sv-io')].find((b) => /Export/.test(b.textContent));
+ expect(exportBtn.disabled).toBe(false);
+ click(exportBtn);
+ expect(app.actions.exportSaved).toHaveBeenCalled();
+ });
+ it('saved: Import button opens the file input; change with a file imports it', () => {
+ const app = makeApp();
+ app.state.sidePanel = 'saved';
+ renderSavedHistory(app);
+ const input = app.dom.savedList.querySelector('.saved-actions input[type="file"]');
+ input.click = vi.fn();
+ const importBtn = [...app.dom.savedList.querySelectorAll('.sv-io')].find((b) => /Import/.test(b.textContent));
+ click(importBtn);
+ expect(input.click).toHaveBeenCalled();
+ // change with a file → importSavedFile(file); without → no call
+ const file = { name: 'q.json' };
+ Object.defineProperty(input, 'files', { configurable: true, value: [file] });
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ expect(app.actions.importSavedFile).toHaveBeenCalledWith(file);
+ Object.defineProperty(input, 'files', { configurable: true, value: [] });
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ expect(app.actions.importSavedFile).toHaveBeenCalledTimes(1);
+ });
it('history: empty state', () => {
const app = makeApp();
app.state.sidePanel = 'history';
diff --git a/tests/unit/saved-io.test.js b/tests/unit/saved-io.test.js
new file mode 100644
index 0000000..9236cdd
--- /dev/null
+++ b/tests/unit/saved-io.test.js
@@ -0,0 +1,62 @@
+import { describe, it, expect } from 'vitest';
+import { buildExportDoc, parseImportDoc, mergeSaved } from '../../src/core/saved-io.js';
+
+describe('buildExportDoc', () => {
+ it('wraps queries in the envelope, keeps only id/name/sql/favorite, coerces favorite', () => {
+ const doc = buildExportDoc([{ id: 's1', name: 'A', sql: 'SELECT 1', favorite: 1, extra: 'x' }], '2026-06-21T00:00:00.000Z');
+ expect(doc).toEqual({
+ format: 'altinity-sql-browser/saved-queries',
+ version: 1,
+ exportedAt: '2026-06-21T00:00:00.000Z',
+ queries: [{ id: 's1', name: 'A', sql: 'SELECT 1', favorite: true }],
+ });
+ });
+ it('handles an empty list', () => {
+ expect(buildExportDoc([], 'T').queries).toEqual([]);
+ });
+});
+
+describe('parseImportDoc', () => {
+ const env = (over) => JSON.stringify({ format: 'altinity-sql-browser/saved-queries', version: 1, queries: [], ...over });
+ it('parses a valid doc and normalizes entries (drops invalid ones)', () => {
+ const { queries } = parseImportDoc(env({ queries: [
+ { id: 's1', name: 'A', sql: 'SELECT 1', favorite: 1 },
+ { name: 'B', sql: 'SELECT 2' }, // no id → id undefined
+ { name: 'bad', sql: 5 }, // non-string sql → dropped
+ { sql: 'no name' }, // no name → dropped
+ ] }));
+ expect(queries).toEqual([
+ { id: 's1', name: 'A', sql: 'SELECT 1', favorite: true },
+ { id: undefined, name: 'B', sql: 'SELECT 2', favorite: false },
+ ]);
+ });
+ it('throws a user message for each invalid envelope', () => {
+ expect(() => parseImportDoc('{not json')).toThrow('Not a valid JSON file');
+ expect(() => parseImportDoc(JSON.stringify({ format: 'other' }))).toThrow('Unrecognized file format');
+ expect(() => parseImportDoc(env({ version: 2 }))).toThrow('Unsupported file version');
+ expect(() => parseImportDoc(env({ version: 'x' }))).toThrow('Unsupported file version');
+ expect(() => parseImportDoc(env({ queries: 'nope' }))).toThrow('No queries in file');
+ expect(() => parseImportDoc(env({ queries: Array.from({ length: 1001 }, () => ({ name: 'n', sql: 's' })) }))).toThrow('Too many queries');
+ expect(() => parseImportDoc('null')).toThrow('Unrecognized file format'); // doc falsy
+ });
+});
+
+describe('mergeSaved', () => {
+ const gen = (() => { let n = 0; return () => 'gen' + (++n); });
+ it('adds new, skips content dup, updates by id, generates id when missing', () => {
+ const existing = [{ id: 's1', name: 'A', sql: '1', favorite: false }];
+ const incoming = [
+ { id: 's1', name: 'A', sql: '1', favorite: false }, // identical → skip (content)
+ { id: 's1', name: 'A2', sql: '1b', favorite: true }, // same id, differs → update
+ { name: 'B', sql: '2', favorite: false }, // no id → genId, add
+ { id: 's2', name: 'C', sql: '3', favorite: false }, // new id → add (keeps id)
+ ];
+ const r = mergeSaved(existing, incoming, gen());
+ expect(r).toMatchObject({ skipped: 1, updated: 1, added: 2 });
+ expect(r.merged.find((q) => q.id === 's1')).toMatchObject({ name: 'A2', sql: '1b', favorite: true });
+ expect(r.merged.find((q) => q.name === 'B').id).toBe('gen1'); // genId for the id-less entry
+ expect(r.merged.find((q) => q.name === 'C').id).toBe('s2'); // given id kept
+ expect(r.merged.map((q) => q.name)).toEqual(['A2', 'B', 'C']);
+ expect(existing[0]).toEqual({ id: 's1', name: 'A', sql: '1', favorite: false }); // not mutated
+ });
+});
diff --git a/tests/unit/state.test.js b/tests/unit/state.test.js
index 8e44a4e..65b2141 100644
--- a/tests/unit/state.test.js
+++ b/tests/unit/state.test.js
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import {
KEYS, newTabObj, createState, activeTab, allocTabId,
- saveQuery, savedForTab, renameSaved, toggleFavorite, sortedSaved,
+ saveQuery, savedForTab, renameSaved, toggleFavorite, sortedSaved, importSaved,
deleteSaved, recordHistory, clearHistory, deleteHistory,
} from '../../src/state.js';
@@ -142,6 +142,23 @@ describe('saved queries', () => {
expect(sortedSaved(s).map((q) => q.id)).toEqual(['c', 'a', 'b']);
expect(save).toHaveBeenCalledTimes(1);
});
+ it('importSaved merges (add/skip/update), persists, and uses injected genId', () => {
+ const s = createState(reader());
+ s.savedQueries = [{ id: 's1', name: 'A', sql: '1', favorite: false }];
+ const save = vi.fn();
+ const r = importSaved(s, [
+ { id: 's1', name: 'A', sql: '1' }, // skip (content dup)
+ { id: 's1', name: 'A2', sql: '1b' }, // update by id
+ { name: 'B', sql: '2' }, // add (genId)
+ ], save, () => 'gx');
+ expect(r).toEqual({ added: 1, updated: 1, skipped: 1 });
+ expect(s.savedQueries.map((q) => q.name)).toEqual(['A2', 'B']);
+ expect(s.savedQueries.find((q) => q.name === 'B').id).toBe('gx');
+ expect(save).toHaveBeenCalledWith(KEYS.saved, s.savedQueries);
+ // default save + genId (no injection) — exercises the default id generator
+ importSaved(s, [{ name: 'Z', sql: 'zz' }]);
+ expect(s.savedQueries.find((q) => q.name === 'Z').id).toMatch(/^s/);
+ });
it('deleteSaved removes + clears tab pointers', () => {
const s = createState(reader());
s.savedQueries = [{ id: 's1', sql: 'x', name: 'n' }];