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
15 changes: 12 additions & 3 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,24 @@ export async function bootstrap(app, env) {
hist.replaceState(null, '', loc.origin + loc.pathname + (qs ? '?' + qs : '') + loc.hash);
}

const sharedSql = decodeSqlFromHash(loc.hash);
// A shared query rides in the URL hash, which is lost through the OAuth
// redirect (and we strip it below). Stash it in sessionStorage so it survives
// the round-trip and restore it once we're back, signed in.
let sharedSql = decodeSqlFromHash(loc.hash);
if (sharedSql) ss.setItem('oauth_shared_sql', sharedSql);
else sharedSql = ss.getItem('oauth_shared_sql') || '';
if (sharedSql) {
app.state.tabs[0].sql = sharedSql;
app.state.tabs[0].name = 'Shared query';
hist.replaceState(null, '', loc.pathname + loc.search);
}

if (app.token && !isTokenExpired(app.token, 0)) app.renderApp();
else app.showLogin(callbackError);
if (app.token && !isTokenExpired(app.token, 0)) {
ss.removeItem('oauth_shared_sql'); // consumed
app.renderApp();
} else {
app.showLogin(callbackError);
}
return { callbackError, signedIn: app.isSignedIn() };
}

Expand Down
5 changes: 5 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,11 @@ body {
.modal-card h2 {
margin: 0 0 12px; font-size: 14px; font-weight: 600; color: var(--fg);
}
.modal-card .section-label {
margin: 14px 0 2px; font-size: 10.5px; font-weight: 600;
color: var(--fg-faint); text-transform: uppercase; letter-spacing: .04em;
border-top: 1px solid var(--border-faint); padding-top: 12px;
}
.modal-card .row {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 0; font-size: 12.5px; color: var(--fg-mute);
Expand Down
43 changes: 41 additions & 2 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { generatePKCE, randomState } from '../core/pkce.js';
import * as oauthCfg from '../net/oauth-config.js';
import * as oauth from '../net/oauth.js';
import * as ch from '../net/ch-client.js';
import { mountEditor, insertAtCursor } from './editor.js';
import { mountEditor, insertAtCursor, insertTopLine, replaceEditor } from './editor.js';
import { renderTabs, selectTab, newTab, closeTab, loadIntoNewTab } from './tabs.js';
import { renderSchema } from './schema.js';
import { renderResults } from './results.js';
Expand Down Expand Up @@ -264,6 +264,42 @@ export function createApp(env = {}) {
running ? null : h('kbd', null, '⌘↵'));
}

// Pretty-print the editor's SQL via ClickHouse's formatQuery(), in place.
async function formatQuery() {
const sql = (app.activeTab().sql || '').trim();
if (!sql) return;
await ensureConfig();
if (!(await getToken())) { chCtx.onSignedOut(); return; }
try {
const json = await ch.queryJson(chCtx, 'SELECT formatQuery(' + sqlString(sql) + ') AS q FORMAT JSON');
const q = (json.data && json.data[0] && json.data[0].q) || '';
if (q) replaceEditor(app, q);
} catch (e) {
flashToast('Format failed: ' + String((e && e.message) || e), { document: doc });
}
}

// Fetch the DDL for `target` (e.g. 'db.table' or 'DATABASE db') with
// SHOW CREATE, pretty-print it through formatQuery(), and drop it in as a top
// line. Two round-trips by design; if formatting fails the raw DDL is used.
async function insertCreate(target) {
await ensureConfig();
if (!(await getToken())) { chCtx.onSignedOut(); return; }
try {
const show = await ch.queryJson(chCtx, 'SHOW CREATE ' + target + ' FORMAT JSON');
const stmt = (show.data && show.data[0] && show.data[0].statement) || '';
if (!stmt) return;
let out = stmt;
try {
const fmt = await ch.queryJson(chCtx, 'SELECT formatQuery(' + sqlString(stmt) + ') AS q FORMAT JSON');
out = (fmt.data && fmt.data[0] && fmt.data[0].q) || stmt;
} catch { /* formatting is best-effort — fall back to the raw DDL */ }
insertTopLine(app, out);
} catch (e) {
flashToast('SHOW CREATE failed: ' + String((e && e.message) || e), { document: doc });
}
}

// --- saved / history bridges ------------------------------------------
app.recordHistory = (tab) => {
recordHistory(app.state, tab, saveJSON);
Expand Down Expand Up @@ -315,8 +351,11 @@ export function createApp(env = {}) {
login,
share,
toggleSaved: toggleSavedActive,
formatQuery,
insertCreate,
openShortcuts: () => openShortcuts(app),
insertAtCursor: (text) => insertAtCursor(app, text),
insertTopLine: (text) => insertTopLine(app, text),
loadColumns,
rerenderTabs: () => renderTabs(app),
rerenderResults: () => renderResults(app),
Expand Down Expand Up @@ -387,7 +426,7 @@ export function renderApp(app, helpers) {

app.dom.qtabsInner = h('div', { class: 'qtabs-inner' });
const qtabsRow = h('div', { class: 'qtabs' }, app.dom.qtabsInner,
h('button', { class: 'new-tab', title: 'New query (⌘T)', onclick: () => app.actions.newTab() }, Icon.plus()));
h('button', { class: 'new-tab', title: 'New query', onclick: () => app.actions.newTab() }, Icon.plus()));

app.dom.runBtn = h('button', { class: 'run-btn', onclick: () => app.actions.run() }, Icon.play(), h('span', null, 'Run'), h('kbd', null, '⌘↵'));
app.dom.fmtSelect = h('select', {
Expand Down
47 changes: 38 additions & 9 deletions src/ui/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,7 @@ export function mountEditor(app, container) {
ta.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
e.preventDefault();
const { selectionStart: s, selectionEnd: en } = ta;
ta.value = ta.value.slice(0, s) + ' ' + ta.value.slice(en);
ta.selectionStart = ta.selectionEnd = s + 2;
ta.dispatchEvent(new Event('input'));
applyEdit(ta, ' ');
});
// Accept schema identifiers dragged from the tree; insert at the cursor.
ta.addEventListener('dragover', (e) => e.preventDefault());
Expand All @@ -92,13 +89,45 @@ export function mountEditor(app, container) {
sync();
}

/** Insert `text` at the textarea cursor and fire an input event. */
export function insertAtCursor(app, text) {
const ta = app.dom.editorTextarea;
if (!ta) return;
/**
* Replace the textarea's current selection with `text`. Uses
* execCommand('insertText') so the edit joins the native undo stack (⌘Z / ⌘⇧Z);
* falls back to a manual splice + 'input' dispatch where execCommand is absent
* (older browsers, happy-dom). execCommand fires 'input' itself, so either path
* runs the input listener that syncs tab.sql + repaints.
*/
function applyEdit(ta, text) {
ta.focus();
let ok = false;
try { ok = ta.ownerDocument.execCommand('insertText', false, text); } catch { ok = false; }
if (ok) return;
const { selectionStart: s, selectionEnd: e } = ta;
ta.value = ta.value.slice(0, s) + text + ta.value.slice(e);
ta.selectionStart = ta.selectionEnd = s + text.length;
ta.focus();
ta.dispatchEvent(new Event('input'));
}

/** Insert `text` at the textarea cursor (undoable). */
export function insertAtCursor(app, text) {
const ta = app.dom.editorTextarea;
if (!ta) return;
applyEdit(ta, text);
}

/** Prepend `text` as a new first line (does not replace existing content). */
export function insertTopLine(app, text) {
const ta = app.dom.editorTextarea;
if (!ta) return;
ta.focus();
ta.selectionStart = ta.selectionEnd = 0;
applyEdit(ta, text + (ta.value ? '\n' : ''));
}

/** Replace the whole editor content with `text` (undoable). */
export function replaceEditor(app, text) {
const ta = app.dom.editorTextarea;
if (!ta) return;
ta.focus();
ta.select();
applyEdit(ta, text);
}
23 changes: 16 additions & 7 deletions src/ui/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,13 @@ export function renderSchema(app) {
for (const db of state.schema) {
list.appendChild(h('div', {
class: 'tree-row bold',
onclick: () => { db.expanded = !db.expanded; renderSchema(app); },
title: 'Click to expand · double-click to insert · shift-click for SHOW CREATE',
onclick: (e) => {
if (e.shiftKey) { app.actions.insertCreate('DATABASE ' + db.db); return; }
db.expanded = !db.expanded;
renderSchema(app);
},
ondblclick: (e) => { e.stopPropagation(); app.actions.insertAtCursor(db.db); },
...dragProps(db.db),
},
h('span', { class: 'chev' }, db.expanded ? Icon.chevDown() : Icon.chev()),
Expand All @@ -60,20 +66,21 @@ export function renderSchema(app) {
const tbComment = (tb.comment || '').trim();
const title = tbComment
? tbComment + ' · ' + formatRows(tb.total_rows) + ' rows'
: 'Click to expand/collapse · double-click or drag to insert';
: 'Click to expand · double-click for SELECT * · shift-click for SHOW CREATE';

list.appendChild(h('div', {
class: 'tree-row' + (filter && tableMatch ? ' match' : ''),
style: { paddingLeft: '24px' },
title,
...dragProps(db.db + '.' + tb.name),
onclick: () => {
...dragProps(key),
onclick: (e) => {
if (e.shiftKey) { app.actions.insertCreate(key); return; }
if (state.expandedTables.has(key)) state.expandedTables.delete(key);
else state.expandedTables.add(key);
if (state.expandedTables.has(key) && tb.columns == null) app.actions.loadColumns(db.db, tb.name, tb);
else renderSchema(app);
},
ondblclick: (e) => { e.stopPropagation(); app.actions.insertAtCursor(db.db + '.' + tb.name); },
ondblclick: (e) => { e.stopPropagation(); app.actions.insertTopLine('SELECT * FROM ' + key + ' LIMIT 100'); },
},
h('span', { class: 'chev' }, isOpen ? Icon.chevDown() : Icon.chev()),
h('span', { class: 'icon', style: { color: 'var(--accent)' } }, Icon.table()),
Expand All @@ -93,8 +100,10 @@ export function renderSchema(app) {
list.appendChild(h('div', {
class: 'tree-row small mono' + (filter && matches(c.name) ? ' match' : ''),
style: { paddingLeft: '38px' },
title: (c.comment && c.comment.trim()) || 'Click or drag to insert ' + c.name,
onclick: (e) => { e.stopPropagation(); app.actions.insertAtCursor(c.name); },
title: (c.comment && c.comment.trim())
|| ('Double-click or drag to insert ' + c.name + ' · shift-click for ' + c.name + '::' + c.type),
onclick: (e) => { e.stopPropagation(); if (e.shiftKey) app.actions.insertAtCursor(c.name + '::' + c.type); },
ondblclick: (e) => { e.stopPropagation(); app.actions.insertAtCursor(c.name); },
...dragProps(c.name),
},
h('span', { class: 'chev' }),
Expand Down
39 changes: 23 additions & 16 deletions src/ui/shortcuts.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,23 @@ import { h } from './dom.js';

const SHORTCUTS = [
['Run query', '⌘↵'],
['New tab', '⌘T'],
['Close tab', '⌘W'],
['Format query', '⌘⇧↵'],
['Save / unsave query', '⌘S'],
['Share query', '⌘⇧S'],
['Undo', '⌘Z'],
['Redo', '⌘⇧Z'],
['Show this dialog', '?'],
['Close dialog', 'Esc'],
];

// Mouse gestures on the schema tree (db / table / column). Kept terse — the
// per-row tooltips carry the detail; this just signals the gestures exist.
const GESTURES = [
['Expand / collapse', 'Click'],
['Insert into editor', 'Double-click'],
['Insert DDL / col::type', 'Shift-click'],
];

/** Open the shortcuts modal. Idempotent while open (tracked on state). */
export function openShortcuts(app) {
const doc = app.document || document;
Expand All @@ -26,10 +35,13 @@ export function openShortcuts(app) {
if (e.key === 'Escape') close();
};
doc.addEventListener('keydown', escHandler);
const rowOf = ([label, key]) =>
h('div', { class: 'row' }, h('span', { class: 'label' }, label), h('kbd', null, key));
const card = h('div', { class: 'modal-card', onclick: (e) => e.stopPropagation() },
h('h2', null, 'Keyboard shortcuts'),
...SHORTCUTS.map(([label, key]) =>
h('div', { class: 'row' }, h('span', { class: 'label' }, label), h('kbd', null, key))),
...SHORTCUTS.map(rowOf),
h('div', { class: 'section-label' }, 'Schema tree — database · table · column'),
...GESTURES.map(rowOf),
h('div', { class: 'close-row' }, h('button', { class: 'close-btn', onclick: close }, 'Close')),
);
const backdrop = h('div', { class: 'modal-backdrop', onclick: close }, card);
Expand All @@ -45,22 +57,17 @@ export function handleKeydown(e, app) {
const mod = e.metaKey || e.ctrlKey;
const signedIn = app.isSignedIn();
if (mod && e.key === 'Enter') {
// ⌘/Ctrl+Shift+Enter = format (gated by sign-in); ⌘/Ctrl+Enter = run.
if (e.shiftKey) {
if (!signedIn) return null;
e.preventDefault();
app.actions.formatQuery();
return 'formatQuery';
}
e.preventDefault();
app.actions.run();
return 'run';
}
if (mod && e.key.toLowerCase() === 't') {
if (!signedIn) return null;
e.preventDefault();
app.actions.newTab();
return 'newTab';
}
if (mod && e.key.toLowerCase() === 'w') {
if (!signedIn || app.state.tabs.length <= 1) return null;
e.preventDefault();
app.actions.closeTab(app.state.activeTabId);
return 'closeTab';
}
if (mod && e.shiftKey && e.key.toLowerCase() === 's') {
if (!signedIn) return null;
e.preventDefault();
Expand Down
3 changes: 3 additions & 0 deletions tests/helpers/fake-app.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,11 @@ export function makeApp(over = {}) {
login: vi.fn(),
share: vi.fn(),
toggleSaved: vi.fn(),
formatQuery: vi.fn(),
insertCreate: vi.fn(),
openShortcuts: vi.fn(),
insertAtCursor: vi.fn(),
insertTopLine: vi.fn(),
loadColumns: vi.fn(),
rerenderTabs: vi.fn(),
rerenderResults: vi.fn(),
Expand Down
Loading
Loading