From 066381ca0a6d7ca8b9b00799bfd18dfd83b8e56f Mon Sep 17 00:00:00 2001 From: Isolator acm Date: Sat, 20 Jun 2026 00:11:50 +0200 Subject: [PATCH 1/3] =?UTF-8?q?fix(results):=20vertical=20scroll=20+=20rel?= =?UTF-8?q?iable=20=E2=8C=98A=20in=20the=20raw=20output=20pane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the TSV/JSON output pane: - Vertical scroll didn't work (only horizontal). .raw-text-view/.json-view used `flex: 1`, but their parent .res-body is a relatively-positioned block, not a flex container — so flex had no effect and the pane grew to content height with no scroll (the parent just clipped it). Use `height: 100%` + overflow:auto like .res-table-wrap, so both axes scroll. - ⌘A didn't select the pane on macOS. Detection required the pane to be focused (e.target.closest), but macOS WebKit doesn't focus a tabindex
on click, so e.target stayed . Re-key off "not editing + a raw pane is on screen" (document.querySelector) instead of focus; a focused editor/input still gets the native select-all (whole query). Verified in-browser against the built bundle: tall pane scrolls vertically; ⌘A fired from selects the entire pane text. 333 tests pass; shortcuts.js + results.js at 100%. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QennTvGKAtJZrv9EpQagef --- src/styles.css | 10 +++++++--- src/ui/shortcuts.js | 12 ++++++++---- tests/unit/shortcuts.test.js | 16 +++++++++++----- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/styles.css b/src/styles.css index f22be93..acc8be1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -355,19 +355,23 @@ body { .result-view-tab.active { color: var(--fg); background: var(--bg-hover); border-color: var(--border); } /* ------------ json view ------------ */ +/* height:100% (not flex:1) — .res-body is a relatively-positioned block, not a + flex container, so flex had no effect and the pane grew to content height with + no vertical scroll (only horizontal worked). A bounded height + overflow:auto + scrolls both axes, matching .res-table-wrap. */ .json-view { - flex: 1; overflow: auto; padding: 12px 14px; + height: 100%; overflow: auto; padding: 12px 14px; font-family: var(--mono); font-size: 11.5px; color: var(--fg); white-space: pre; background: var(--bg); } .raw-text-view { - flex: 1; overflow: auto; padding: 10px 12px; + height: 100%; overflow: auto; padding: 10px 12px; font-family: var(--mono); font-size: 11.5px; color: var(--fg); white-space: pre; background: var(--bg); } -/* Focusable (tabindex) so a click scopes ⌘A to the pane's text; no focus ring. */ +/* tabindex makes the pane keyboard-scrollable; suppress the focus ring. */ .raw-text-view:focus, .json-view:focus { outline: none; } /* ------------ chart placeholder ------------ */ diff --git a/src/ui/shortcuts.js b/src/ui/shortcuts.js index 13473dc..0029de0 100644 --- a/src/ui/shortcuts.js +++ b/src/ui/shortcuts.js @@ -74,11 +74,15 @@ export function handleKeydown(e, app) { return 'toggleSaved'; } if (mod && e.key.toLowerCase() === 'a') { - // Inside a raw result pane (TSV / JSON output), select just that text so it - // can be copied — not the whole page. Elsewhere (e.g. the editor textarea) - // fall through to the browser's native select-all. + // When a raw result pane (TSV / JSON output) is on screen and the user isn't + // typing, ⌘/Ctrl+A selects just that text so it can be copied — not the whole + // page. Keyed off "not editing + pane present" rather than pane focus, because + // macOS WebKit doesn't focus a tabindex
on click (so e.target stays + // ). A focused editor/input keeps the native select-all (whole query). const t = e.target; - const box = t && t.closest && t.closest('.raw-text-view, .json-view'); + if (t && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT' || t.isContentEditable)) return null; + const doc = (t && t.ownerDocument) || document; + const box = doc.querySelector('.raw-text-view, .json-view'); if (!box) return null; e.preventDefault(); box.ownerDocument.defaultView.getSelection().selectAllChildren(box); diff --git a/tests/unit/shortcuts.test.js b/tests/unit/shortcuts.test.js index feb63b6..892d817 100644 --- a/tests/unit/shortcuts.test.js +++ b/tests/unit/shortcuts.test.js @@ -90,26 +90,32 @@ describe('handleKeydown', () => { expect(handleKeydown(ev({ key: '?', target: null }), makeApp())).toBe('shortcuts'); }); - it('⌘A inside a raw result pane selects just that text', () => { + it('⌘A selects a raw result pane even when it is not focused (macOS body target)', () => { const app = makeApp(); const box = document.createElement('div'); box.className = 'raw-text-view'; box.textContent = 'a\tb\nc\td'; document.body.appendChild(box); - const e = ev({ metaKey: true, key: 'a', target: box }); + // target is (pane not focused — the macOS WebKit case), pane on screen + const e = ev({ metaKey: true, key: 'a', target: document.body }); expect(handleKeydown(e, app)).toBe('selectAll'); expect(e.preventDefault).toHaveBeenCalled(); expect(box.ownerDocument.defaultView.getSelection().toString()).toBe('a\tb\nc\td'); }); - it('⌘A elsewhere falls through to the native select-all', () => { + it('⌘A while editing keeps the native select-all (editor / inputs)', () => { const app = makeApp(); - // editor textarea (no raw-pane ancestor) → not handled + document.body.appendChild(document.createElement('div')).className = 'raw-text-view'; const ta = document.createElement('textarea'); const e = ev({ metaKey: true, key: 'A', target: ta }); expect(handleKeydown(e, app)).toBeNull(); expect(e.preventDefault).not.toHaveBeenCalled(); - // no target at all + expect(handleKeydown(ev({ metaKey: true, key: 'a', target: { tagName: 'INPUT' } }), app)).toBeNull(); + expect(handleKeydown(ev({ metaKey: true, key: 'a', target: { isContentEditable: true } }), app)).toBeNull(); + }); + + it('⌘A with no raw pane on screen falls through to native select-all', () => { + const app = makeApp(); expect(handleKeydown(ev({ metaKey: true, key: 'a', target: null }), app)).toBeNull(); }); }); From 79740ccad4ed14e4044c26d6b3272c29f6636034 Mon Sep 17 00:00:00 2001 From: Isolator acm Date: Sat, 20 Jun 2026 00:25:53 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(layout):=20shell=20overflowed=20viewpor?= =?UTF-8?q?t=20under=20zoom=20(#root=20100vh=20=E2=86=92=20100%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the "16 rows don't scroll, 2000 do" report. `html { zoom: 1.2 }` combined with `#root { height: 100vh }`: `vh` units ignore `zoom`, so 100vh (= viewport px) is then scaled ×1.2 and #root renders ~120vh tall. The whole shell overflowed the window by ~20% (measured: 970px tall in an 808px viewport), pushing the results pane's lower portion below the fold — and html is overflow:hidden, so the page can't scroll to reach it. With a tall result the inner pane's own scrollbar bridged the gap (so 2000 rows "worked"); with a short result the off-screen rows were simply unreachable. Use `height: 100%` (cascades html→body→#root, and % scales with zoom) so the shell is bounded to the viewport. Verified in-browser: #root/main-row/res-body all bottom-out exactly at the viewport; the 16-row table sits fully on screen and its pane scrolls. 333 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QennTvGKAtJZrv9EpQagef --- src/styles.css | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/styles.css b/src/styles.css index acc8be1..5695ae3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -69,7 +69,12 @@ body { -moz-osx-font-smoothing: grayscale; } -#root { width: 100%; height: 100vh; display: flex; flex-direction: column; overflow: hidden; } +/* height:100% (cascading html→body→#root), NOT 100vh: `vh` ignores the + `html { zoom: 1.2 }` above, so 100vh renders 1.2× too tall (≈120vh) and the + whole shell overflows the viewport — pushing the results pane's lower rows + below the fold with no page scroll (html is overflow:hidden). % heights scale + with zoom correctly. */ +#root { width: 100%; height: 100%; display: flex; flex-direction: column; overflow: hidden; } /* ------------ login ------------ */ .login-screen { From 998f62aa7288286320a7c90dffaec3ecf1d490e7 Mon Sep 17 00:00:00 2001 From: Isolator acm Date: Sat, 20 Jun 2026 00:35:11 +0200 Subject: [PATCH 3/3] feat(ui): Log Out rename + GitHub source link; drop Clear-history row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Header: rename "Sign out" → "Log Out" and add a GitHub-logo link (new Icon.github, opens the repo in a new tab, rel=noopener). svgFilled gains optional viewBox args so the 24×24 octocat path renders at 15px. - History panel: remove the "Clear history" row (looked out of place); per-row delete is enough. clearHistory() stays as a tested state op, just unused in UI. Verified in-browser: header shows "Log Out" + GitHub icon; history lists rows with per-row delete, no clear row. 334 tests pass; icons/saved-history at 100%. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QennTvGKAtJZrv9EpQagef --- src/styles.css | 10 +--------- src/ui/app.js | 6 +++++- src/ui/icons.js | 8 +++++--- src/ui/saved-history.js | 7 +------ tests/unit/app.test.js | 9 +++++++++ tests/unit/icons.test.js | 6 ++++++ tests/unit/saved-history.test.js | 11 ----------- 7 files changed, 27 insertions(+), 30 deletions(-) diff --git a/src/styles.css b/src/styles.css index 5695ae3..fc16264 100644 --- a/src/styles.css +++ b/src/styles.css @@ -184,6 +184,7 @@ body { cursor: pointer; display: flex; align-items: center; justify-content: center; border-radius: 5px; + text-decoration: none; /* also used as an (GitHub source link) */ } .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); } @@ -260,15 +261,6 @@ body { } .saved-row:hover .del { display: inline-flex; } .saved-row .del:hover { color: var(--fg); background: var(--bg-hover); } -.list-head { - display: flex; justify-content: flex-end; - padding: 5px 8px; border-bottom: 1px solid var(--border-faint); -} -.clear-btn { - border: none; background: transparent; color: var(--fg-faint); - font-size: 10.5px; cursor: pointer; padding: 3px 7px; border-radius: 4px; -} -.clear-btn:hover { color: var(--error-fg); background: var(--bg-hover); } .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 bc1f79c..5ba19c4 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -347,7 +347,11 @@ export function renderApp(app, helpers) { 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: 'Sign out', onclick: () => app.signOut() }, 'Sign out')); + h('button', { class: 'hd-btn text', title: 'Log out', onclick: () => app.signOut() }, 'Log Out'), + h('a', { + class: 'hd-btn', href: 'https://github.com/Altinity/altinity-sql-browser', + target: '_blank', rel: 'noopener noreferrer', title: 'View source on GitHub', + }, Icon.github())); app.dom.schemaSearchInput = h('input', { type: 'text', placeholder: 'Search tables, columns…', diff --git a/src/ui/icons.js b/src/ui/icons.js index e9868b9..1ab0a6f 100644 --- a/src/ui/icons.js +++ b/src/ui/icons.js @@ -22,12 +22,13 @@ export function svg(d, w = 12, hgt = 12, opts = {}) { return el; } -/** Single-path filled icon. */ -export function svgFilled(d, w = 12, hgt = 12) { +/** Single-path filled icon. `vbW`/`vbH` default to the display size, but can + * differ when the path is authored in a different coordinate space. */ +export function svgFilled(d, w = 12, hgt = 12, vbW = w, vbH = hgt) { const el = document.createElementNS(NS, 'svg'); el.setAttribute('width', w); el.setAttribute('height', hgt); - el.setAttribute('viewBox', `0 0 ${w} ${hgt}`); + el.setAttribute('viewBox', `0 0 ${vbW} ${vbH}`); el.setAttribute('fill', 'currentColor'); const path = document.createElementNS(NS, 'path'); path.setAttribute('d', d); @@ -78,4 +79,5 @@ export const Icon = { json: () => 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), table2: () => iconEl('', 12, 12), shortcuts: () => iconEl('', 12, 12, 1.3), + 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), }; diff --git a/src/ui/saved-history.js b/src/ui/saved-history.js index 4386efe..be1d142 100644 --- a/src/ui/saved-history.js +++ b/src/ui/saved-history.js @@ -3,7 +3,7 @@ import { h } from './dom.js'; import { Icon } from './icons.js'; import { timeAgo } from '../core/format.js'; -import { deleteSaved, deleteHistory, clearHistory } from '../state.js'; +import { deleteSaved, deleteHistory } from '../state.js'; export function renderSavedHistory(app) { const tabsRow = app.dom.savedTabsRow; @@ -53,11 +53,6 @@ function renderHistory(app, list) { list.appendChild(h('div', { class: 'saved-empty' }, 'No history yet.')); return; } - list.appendChild(h('div', { class: 'list-head' }, - h('button', { - class: 'clear-btn', title: 'Clear all history', - onclick: () => { clearHistory(state, app.saveJSON); renderSavedHistory(app); }, - }, 'Clear history'))); for (const ent of state.history) { list.appendChild(h('div', { class: 'history-row', onclick: () => app.actions.loadIntoNewTab('From history', ent.sql) }, h('button', { diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index 9912916..e1e9eda 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -110,6 +110,15 @@ describe('renderApp shell', () => { expect(e.sessionStorage.getItem('oauth_id_token')).toBeNull(); expect(app.root.querySelector('.login-screen')).not.toBeNull(); }); + it('header has a Log Out button and a GitHub source link', () => { + const { app } = rendered(); + expect(app.root.querySelector('.hd-btn.text').textContent).toContain('Log Out'); + const gh = app.root.querySelector('a.hd-btn[href*="github.com"]'); + expect(gh).not.toBeNull(); + expect(gh.getAttribute('target')).toBe('_blank'); + expect(gh.getAttribute('rel')).toContain('noopener'); + expect(gh.querySelector('svg')).not.toBeNull(); + }); it('setTokens clears the one-shot pkce verifier and csrf state', () => { const e = env({ sessionStorage: memSession({ oauth_verifier: 'v', oauth_state: 's' }) }); const app = createApp(e); diff --git a/tests/unit/icons.test.js b/tests/unit/icons.test.js index a976cb4..5bb6a9f 100644 --- a/tests/unit/icons.test.js +++ b/tests/unit/icons.test.js @@ -26,6 +26,12 @@ describe('svgFilled', () => { const el = svgFilled('M0 0z', 16, 16); expect(el.getAttribute('fill')).toBe('currentColor'); expect(el.getAttribute('width')).toBe('16'); + expect(el.getAttribute('viewBox')).toBe('0 0 16 16'); + }); + it('honours an explicit viewBox distinct from the display size', () => { + const el = svgFilled('M0 0z', 15, 15, 24, 24); + expect(el.getAttribute('width')).toBe('15'); + expect(el.getAttribute('viewBox')).toBe('0 0 24 24'); }); }); diff --git a/tests/unit/saved-history.test.js b/tests/unit/saved-history.test.js index a332786..df74e83 100644 --- a/tests/unit/saved-history.test.js +++ b/tests/unit/saved-history.test.js @@ -70,17 +70,6 @@ describe('renderSavedHistory', () => { expect(app.dom.savedList.querySelectorAll('.history-row')).toHaveLength(1); }); - it('history: clear button empties all history', () => { - const app = makeApp(); - app.state.sidePanel = 'history'; - app.state.history = [{ id: 'h1', sql: 'SELECT 1', ts: Date.now(), rows: 3, ms: 4 }]; - renderSavedHistory(app); - click(app.dom.savedList.querySelector('.clear-btn')); - expect(app.state.history).toEqual([]); - expect(app.saveJSON).toHaveBeenCalled(); - expect(app.dom.savedList.textContent).toContain('No history yet.'); - }); - it('switching panels persists the choice', () => { const app = makeApp(); app.state.sidePanel = 'saved';