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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ auto-generated per-PR notes; this file is the curated, human-readable history.

## [Unreleased]

### Added
- Surface-aware keyboard shortcuts for SQL Browser and Dashboard (#417). The
shared, platform-aware shortcut catalog now drives both help and dispatch;
Dashboard gains refresh, View/Edit, and `G` navigation commands while stale
viewer sessions, loading routes, stacked overlays, and nested text inputs
fail closed. Contextual help is torn down on route and authentication
transitions so it cannot survive onto another surface or Login.

## [0.6.4] - 2026-07-24

### Fixed
Expand Down
5 changes: 5 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -732,8 +732,11 @@ body {
border: 1px solid var(--border); border-radius: 10px;
padding: 18px 20px;
min-width: 360px; max-width: 480px;
max-height: calc(100dvh - 32px);
display: flex; flex-direction: column;
box-shadow: 0 20px 60px rgba(0,0,0,.5);
}
.modal-card-body { overflow-y: auto; }
.modal-card h2 {
margin: 0 0 12px; font-size: 14px; font-weight: 600; color: var(--fg);
}
Expand All @@ -754,6 +757,8 @@ body {
border: 1px solid var(--border);
border-radius: 4px; color: var(--fg);
}
.shortcut-keys { display: inline-flex; align-items: center; gap: 4px; }
.shortcut-then { color: var(--fg-faint); font-size: 11px; }
.modal-card .close-row {
margin-top: 12px; display: flex; justify-content: flex-end;
}
Expand Down
57 changes: 53 additions & 4 deletions src/ui/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,10 @@ import { recentOptions } from '../core/recent-values.js';
import { paramComparisonColumns } from '../core/param-comparison.js';
import type { SchemaDb } from '../core/from-scope.js';
import { renderLogin } from './login.js';
import { openShortcuts } from './shortcuts.js';
import { openShortcuts, resetShortcutChord } from './shortcuts.js';
import { startDrag } from './splitters.js';
import { flashToast } from './toast.js';
import type { App, ActionsRegistry, SchemaFocus, WorkspaceChangedMessage } from './app.types.js';
import type { App, ActionsRegistry, KeyboardOwner, SchemaFocus, WorkspaceChangedMessage } from './app.types.js';
import type { CreateAppEnv, BroadcastChannelPort } from '../env.types.js';
import { createQueryExecutionService } from '../application/query-execution-service.js';
import { createConnectionSession } from '../application/connection-session.js';
Expand Down Expand Up @@ -236,6 +236,31 @@ export function createApp(env: CreateAppEnv = {}): App {
app.sqlRoute = parseSqlRoute(routeSearch);
app.currentWorkspace = null;
app.workspaceRouteStatus = 'ready';
app.keyboardOwner = null;
app.resetShortcutChord = () => resetShortcutChord(app);
const keyboardOwners: KeyboardOwner[] = [];
app.acquireKeyboardOwner = (kind) => {
const owner = { kind };
keyboardOwners.push(owner);
app.keyboardOwner = owner;
resetShortcutChord(app);
let released = false;
return () => {
if (released) return;
released = true;
const index = keyboardOwners.indexOf(owner);
if (index >= 0) keyboardOwners.splice(index, 1);
app.keyboardOwner = keyboardOwners.at(-1) ?? null;
resetShortcutChord(app);
};
};
app.shortcutDialog = null;
app.closeShortcutDialog = () => {
const dialog = app.shortcutDialog;
app.shortcutDialog = null;
dialog?.close();
};
app.surfaceCommands = null;
app.captureSurfaceGeneration = () => surfaceGeneration;
app.isSurfaceGenerationCurrent = (generation) => generation === surfaceGeneration;
app.refreshCurrentSurfaceAfterStale = (generation, committed = false) => {
Expand Down Expand Up @@ -379,7 +404,11 @@ export function createApp(env: CreateAppEnv = {}): App {
// structural-only reinterpretation, not a new runtime assumption (a null
// root would already throw inside login.ts's own `app.root.replaceChildren`
// either way).
const renderLoginApp = (msg?: string): void => renderLogin(app as App & { root: Element }, msg);
const renderLoginApp = (msg?: string): void => {
app.closeShortcutDialog();
resetShortcutChord(app);
renderLogin(app as App & { root: Element }, msg);
};
// The auth + config + ClickHouse connection lifecycle (#276 Phase 2) — OAuth
// PKCE login/refresh, Basic probing, and IdP config resolution live in
// `application/connection-session.ts`,
Expand Down Expand Up @@ -413,6 +442,8 @@ export function createApp(env: CreateAppEnv = {}): App {
// workbench session stays reusable after destroy(): the next renderApp
// re-attaches its shell effects.
app.signOut = () => {
app.closeShortcutDialog();
resetShortcutChord(app);
workbench.destroy();
// Plain abort (no clearResult settle) — the login render replaces the
// whole DOM next, so settling the visible result would be a wasted paint.
Expand Down Expand Up @@ -1260,11 +1291,13 @@ export function createApp(env: CreateAppEnv = {}): App {
function anchoredPopover(
node: HTMLElement, anchorEl: HTMLElement, refKey: 'savePopover' | 'userMenu',
): { close: () => void } {
const releaseKeyboard = app.acquireKeyboardOwner('popover');
const close = (): void => {
anchoredPopoverClosers.delete(close);
doc.removeEventListener('keydown', onKey, true);
doc.removeEventListener('mousedown', onOutside, true);
if (app.dom[refKey]) { app.dom[refKey]!.remove(); app.dom[refKey] = undefined; }
releaseKeyboard();
};
const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') { e.preventDefault(); close(); } };
const onOutside = (e: MouseEvent): void => {
Expand Down Expand Up @@ -1537,7 +1570,10 @@ export function createApp(env: CreateAppEnv = {}): App {
let disposeWorkbenchMount: (() => void) | null = null;
const ignoreExternalWorkspaceChange = (): void => {};
app.renderDashboard = () => {
app.closeShortcutDialog();
resetShortcutChord(app);
surfaceGeneration += 1;
app.surfaceCommands = null;
closeAnchoredPopovers();
disposeFileMenuOverlays(app);
disposeWorkbenchMount?.();
Expand All @@ -1546,7 +1582,10 @@ export function createApp(env: CreateAppEnv = {}): App {
return renderDashboard(app);
};
const disposeCurrentSurface = (): void => {
app.closeShortcutDialog();
resetShortcutChord(app);
surfaceGeneration += 1;
app.surfaceCommands = null;
for (const control of app.root?.querySelectorAll<HTMLButtonElement | HTMLInputElement | HTMLSelectElement>(
'button, input, select, textarea',
) ?? []) control.disabled = true;
Expand Down Expand Up @@ -1930,6 +1969,8 @@ export function createApp(env: CreateAppEnv = {}): App {
};

app.navigateSqlRoute = async (route, method) => {
app.closeShortcutDialog();
resetShortcutChord(app);
const workspaceChanged = route.workspaceKey !== app.sqlRoute.workspaceKey;
writeRoute(route, method);
if (workspaceChanged) {
Expand All @@ -1944,6 +1985,8 @@ export function createApp(env: CreateAppEnv = {}): App {
};

app.handleSqlPopState = async () => {
app.closeShortcutDialog();
resetShortcutChord(app);
const previousKey = app.sqlRoute.workspaceKey;
routeSearch = loc.search;
app.sqlRoute = parseSqlRoute(routeSearch);
Expand Down Expand Up @@ -2032,7 +2075,10 @@ export function createApp(env: CreateAppEnv = {}): App {
openNodeDetail,
insertCreate: async (target) => { await insertCreate(target); toEditorOnMobile(); },
openCreateInNewTab: (target, name) => openCreateInNewTab(target, name),
openShortcuts: () => openShortcuts(app),
openShortcuts: () => {
const dialog = openShortcuts(app, () => { app.shortcutDialog = null; });
if (dialog) app.shortcutDialog = dialog;
},
openDashboard,
// #302: Dashboard import/export invoked from the Dashboard page's own File
// menu (and still from the Workbench during the transition). Export is a
Expand All @@ -2051,7 +2097,10 @@ export function createApp(env: CreateAppEnv = {}): App {
};

app.renderApp = () => {
app.closeShortcutDialog();
resetShortcutChord(app);
surfaceGeneration += 1;
app.surfaceCommands = null;
closeAnchoredPopovers();
disposeFileMenuOverlays(app);
disposeDashboardSurface();
Expand Down
23 changes: 23 additions & 0 deletions src/ui/app.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type { WorkspaceDiagnostic } from '../dashboard/model/workspace-diagnosti
import type { StoredWorkspaceV2 } from '../generated/json-schema.types.js';
import type { SavedQueryV2 } from '../generated/json-schema.types.js';
import type { SqlRoute } from '../core/sql-route.js';
import type { SurfaceCommandPort } from './shortcuts.js';
import type { DynamicSources } from '../core/spec-completion.js';
import type { WorkbenchSession } from './workbench/workbench-session.js';
import type { WorkbenchParameterSession } from '../application/workbench-parameter-session.js';
Expand Down Expand Up @@ -154,6 +155,17 @@ export interface AppDom {
varStripDeferHooked?: boolean;
}

/** The currently open UI primitive that has exclusive keyboard handling. */
export interface KeyboardOwner {
kind: 'modal' | 'menu' | 'popover';
}
export type KeyboardOwnerRelease = () => void;

export interface ShortcutDialogHandle {
backdrop: HTMLElement;
close(): void;
}

/** The live ClickHouse auth context every query call site reads/mutates —
* a structural alias of `application/connection-session.ts`'s own
* `SessionChCtx` (the session is the one place that constructs and mutates
Expand Down Expand Up @@ -221,6 +233,14 @@ export interface App {
dom: AppDom;
root: Element | null;
document: Document;
/** Set by shared overlay primitives for the duration of their open lifecycle. */
keyboardOwner: KeyboardOwner | null;
/** Acquire exclusive application-keyboard ownership. The returned idempotent
* release removes only this acquisition, preserving any owner below it. */
acquireKeyboardOwner(kind: KeyboardOwner['kind']): KeyboardOwnerRelease;
resetShortcutChord(): void;
shortcutDialog: ShortcutDialogHandle | null;
closeShortcutDialog(): void;

/** The auth + config + ClickHouse connection lifecycle (#276 Phase 2) —
* OAuth PKCE login/refresh, Basic probing, and IdP config resolution,
Expand Down Expand Up @@ -429,6 +449,9 @@ export interface App {
sqlRoute: SqlRoute;
currentWorkspace: StoredWorkspaceV2 | null;
workspaceRouteStatus: 'loading' | 'ready' | 'not-found' | 'error';
/** Route-local commands registered by the mounted surface. They are cleared
* before every transition, so a disposed Dashboard viewer cannot be called. */
surfaceCommands: SurfaceCommandPort | null;
/** Renderer lifetime, distinct from workspace-load ordering. Any surface
* teardown/remount advances it so obsolete async callbacks can finish their
* durable work without settling against a replacement renderer. */
Expand Down
29 changes: 26 additions & 3 deletions src/ui/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ export interface DashboardApp {
currentWorkspace: StoredWorkspaceV2 | null;
sqlRoute: SqlRoute;
navigateSqlRoute(route: SqlRoute, method: 'push' | 'replace'): Promise<void>;
surfaceCommands: App['surfaceCommands'];
keyboardOwner: App['keyboardOwner'];
acquireKeyboardOwner: App['acquireKeyboardOwner'];
resetShortcutChord: App['resetShortcutChord'];
renderDashboard(): void;
captureSurfaceGeneration(): number;
isSurfaceGenerationCurrent(generation: number): boolean;
Expand Down Expand Up @@ -194,6 +198,14 @@ let installedDashboardChartInteraction: DashboardChartInteractionController | nu
let installedDashboardCleanup: (() => void) | null = null;

/** Tear down every resource owned by the currently mounted Dashboard surface. */
function keyboardOwnerChannel(app: Pick<DashboardApp, 'acquireKeyboardOwner'>): (owner: App['keyboardOwner']) => void {
let release: (() => void) | null = null;
return (owner) => {
release?.();
release = owner ? app.acquireKeyboardOwner(owner.kind) : null;
};
}

export function disposeDashboardSurface(): void {
if (installedGridResizeListener) {
installedGridResizeListener.win.removeEventListener('resize', installedGridResizeListener.handler);
Expand All @@ -216,7 +228,7 @@ export function disposeDashboardSurface(): void {
* reflects session changes without adding a second header label. */
type LayoutOption = [value: string, label: string, title?: string];
function buildLayoutMenu(
doc: Document,
doc: Document, onKeyboardOwnerChange: (owner: App['keyboardOwner']) => void,
options: LayoutOption[], getActive: () => string, onPick: (value: string) => void, ariaLabel: string,
): { el: HTMLButtonElement; sync: () => void } {
const label = h('span');
Expand Down Expand Up @@ -247,6 +259,7 @@ function buildLayoutMenu(
onClick: () => onPick(value),
})),
onClose: () => { handle = null; },
onKeyboardOwnerChange,
});
};
el.onclick = () => { if (handle) { handle.close(); el.focus(); } else open(); };
Expand Down Expand Up @@ -404,6 +417,7 @@ function buildDashboardFileMenu(app: DashboardApp, readOnly = false): HTMLButton
}, h('span', null, 'File'), Icon.chevDown()) as HTMLButtonElement;

let handle: MenuHandle | null = null;
const onKeyboardOwnerChange = keyboardOwnerChannel(app);

const open = (): void => {
const rows: MenuRow[] = [
Expand All @@ -423,6 +437,7 @@ function buildDashboardFileMenu(app: DashboardApp, readOnly = false): HTMLButton
handle = openMenu({
document: doc, trigger: btn, rows, menuClass: 'dash-file-menu',
onClose: () => { handle = null; },
onKeyboardOwnerChange,
});
};

Expand All @@ -442,6 +457,7 @@ export async function renderDashboard(app: DashboardApp): Promise<void> {
// call installed on this window before this call installs its own (see
// `installedGridResizeListener`'s own doc comment above).
disposeDashboardSurface();
app.surfaceCommands = null;

const workspace = app.currentWorkspace;
const readOnly = app.sqlRoute.surface === 'dashboard' && app.sqlRoute.mode === 'view';
Expand Down Expand Up @@ -523,6 +539,11 @@ export async function renderDashboard(app: DashboardApp): Promise<void> {
recordBoundParams: (bp) => app.params.recordBoundParams(bp),
initialFilters: initialBag,
});
// The global shortcut reaches this route-local port only while its renderer
// generation is current. It is cleared by both Dashboard cleanup and every
// application surface transition.
const commandPort = { surface: 'dashboard' as const, generation: surfaceGeneration, refresh: () => session.refresh() };
app.surfaceCommands = commandPort;
let trackedSessionTileIds = new Set(viewerDoc.tiles.map((tile) => tile.id));
const syncSessionDocument = (next: DashboardDocumentV1): void => {
session.syncDocument(next);
Expand Down Expand Up @@ -570,7 +591,7 @@ export async function renderDashboard(app: DashboardApp): Promise<void> {
? (gridRenderMode === 'full' ? 'full' : 'grafana-grid')
: typeof currentDoc.layout.preset === 'string' ? currentDoc.layout.preset : 'report');
const layoutMenu = buildLayoutMenu(
doc,
doc, keyboardOwnerChannel(app),
readOnly ? READONLY_LAYOUT_OPTIONS : EDITABLE_LAYOUT_OPTIONS,
getActiveLayoutOption,
(value) => {
Expand Down Expand Up @@ -878,7 +899,8 @@ export async function renderDashboard(app: DashboardApp): Promise<void> {
};
const bar = buildFilterBar(
filterBarApp, session.controls, onCommit, getField,
{ curatedFields, document: doc, onApplyCurated, timeRange, onApplyTimeRange },
{ curatedFields, document: doc, onApplyCurated, timeRange, onApplyTimeRange,
onKeyboardOwnerChange: keyboardOwnerChannel(app) },
);
timeFilterHost.replaceChildren(bar.timeEl);
ordinaryFilterHost.replaceChildren(bar.ordinaryEl);
Expand Down Expand Up @@ -2199,6 +2221,7 @@ export async function renderDashboard(app: DashboardApp): Promise<void> {
// rebuild must not leave Chart.js observers, signal effects, popovers, or
// viewer requests attached to the replaced page.
installedDashboardCleanup = () => {
if (app.surfaceCommands === commandPort) app.surfaceCommands = null;
currentFilterBar?.dispose();
currentFilterBar = null;
if (tileSearchTimer != null) clearTimeout(tileSearchTimer);
Expand Down
4 changes: 4 additions & 0 deletions src/ui/detached-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { h, withDocument, attachBackdropClose } from './dom.js';
import { Icon } from './icons.js';
import type { Signal } from '@preact/signals-core';
import type { KeyboardOwner } from './app.types.js';

/** The window-like object `app.openWindow()` returns — a real `Window` in
* production. Accessing `.document` may legitimately throw (COOP severing
Expand All @@ -29,6 +30,7 @@ export interface DetachedViewApp {
stylesText?: string;
faviconHref?: string;
state?: { detachedView?: Signal<number> };
acquireKeyboardOwner?(kind: KeyboardOwner['kind']): () => void;
openWindow(url: string, target: string): DetachedWindowLike | null;
}

Expand Down Expand Up @@ -128,6 +130,7 @@ function openAsOverlay(
mount: MountFn, onClose: () => void,
): DetachedView {
return withDocument(mainDoc, () => {
const releaseKeyboard = app?.acquireKeyboardOwner?.('modal') ?? (() => {});
const { panel, bar, body } = buildPanel(mode, title);
let teardown: (() => void) | null = null;
let closed = false;
Expand All @@ -140,6 +143,7 @@ function openAsOverlay(
backdrop.remove();
if (teardown) teardown();
onClose();
releaseKeyboard();
};
backdrop = h('div', { class: 'graph-overlay' }, panel);
detachBackdrop = attachBackdropClose(backdrop, close);
Expand Down
Loading
Loading