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
11 changes: 11 additions & 0 deletions src/core/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Binary file added src/core/saved-io.js
Binary file not shown.
12 changes: 12 additions & 0 deletions src/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
Expand Down
42 changes: 41 additions & 1 deletion src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
64 changes: 60 additions & 4 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -477,6 +529,9 @@ export function createApp(env = {}) {
copyResult,
exportResult,
save: openSavePopover,
openUserMenu,
exportSaved,
importSavedFile,
formatQuery,
insertCreate,
openShortcuts: () => openShortcuts(app),
Expand All @@ -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'),
Expand All @@ -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…',
Expand Down
2 changes: 2 additions & 0 deletions src/ui/icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ 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),
upload: () => iconEl('<path d="M6 8.5V2M3.5 4.5L6 2l2.5 2.5"/><path d="M2 10h8"/>', 12, 12),
logout: () => iconEl('<path d="M5.5 2.5H3a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h2.5"/><path d="M7 8.5L9.5 6 7 3.5M9.5 6H4.5"/>', 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),
Expand Down
21 changes: 20 additions & 1 deletion src/ui/saved-history.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions tests/helpers/fake-app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
62 changes: 57 additions & 5 deletions tests/unit/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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');
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 13 additions & 1 deletion tests/unit/format.test.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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('');
});
});
Loading
Loading