diff --git a/src/styles.css b/src/styles.css index f22be93..fc16264 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 { @@ -179,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); } @@ -255,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; @@ -355,19 +352,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/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/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/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'; 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(); }); });