diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx
index bb643d9c..f8a7ac09 100644
--- a/src/components/CommandPalette.tsx
+++ b/src/components/CommandPalette.tsx
@@ -24,6 +24,7 @@ import {
Bot,
TextAlignStart,
Save,
+ Keyboard,
} from "lucide-react";
import { DatabaseConnection, SavedQuery, QueryHistoryItem } from "@/lib/types";
import { relationObjects, type DetailedObject } from "@/lib/db/detailed-object";
@@ -64,6 +65,8 @@ interface CommandPaletteProps {
* shell declines to offer elsewhere too (`MobileNav.onOpenAgent`).
*/
onAskAgent?: () => void;
+ /** Opens the standalone shell's `ShortcutsDialog` instance (#746). */
+ onShowShortcuts: () => void;
onLogout: () => void;
}
@@ -84,6 +87,7 @@ export function CommandPalette({
onFormatQuery,
onSaveQuery,
onAskAgent,
+ onShowShortcuts,
onLogout,
}: CommandPaletteProps) {
const [open, setOpen] = useState(false);
@@ -142,6 +146,11 @@ export function CommandPalette({
Save Current Query
+ runAction(onShowShortcuts)}>
+
+ Keyboard Shortcuts
+ ?
+
{onAskAgent && (
/*
Named for the ask, not for the surface: `MobileNav` has a control
diff --git a/src/components/DataProfiler.tsx b/src/components/DataProfiler.tsx
index 60aa76b2..9abf6153 100644
--- a/src/components/DataProfiler.tsx
+++ b/src/components/DataProfiler.tsx
@@ -18,6 +18,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import { ShortcutsDialog } from "@/components/ShortcutsDialog";
interface DataProfilerProps {
isOpen: boolean;
@@ -205,6 +206,11 @@ export function DataProfiler({
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
+ // Radix's Dialog (the shortcuts dialog, #746) handles Escape in the capture
+ // phase and only calls preventDefault(), never stopPropagation() - so with
+ // both open, this bubble-phase listener still fires. Without this check, one
+ // Escape closed the shortcuts dialog AND the profiler underneath it.
+ if (event.defaultPrevented) return;
onClose();
};
document.addEventListener("keydown", handleKeyDown);
@@ -214,221 +220,236 @@ export function DataProfiler({
if (!isOpen) return null;
return (
-
-
- {/* Header */}
- {/*
+ <>
+ {/*
+ Mounted only while the profiler is open (#746) - it unmounts, listener and all, the
+ same instant `isOpen` does, matching the Escape effect above. This is what makes it
+ reachable from the embedded workspace too: that shell renders this component but not
+ `Studio.tsx`, so there is nowhere else to mount it that would still cover both hosts.
+ */}
+
+
+
+ {/* Header */}
+ {/*
`shrink-0` and `relative z-10`, and the title row `min-w-0` with the table
name truncating: the card is `overflow-hidden`, so a header that shrinks or
overflows takes its close control out of reach along with it - and the names
that overflow it are exactly the ones the profile route fails on (a Redis key
prefix is a whole glob, not an identifier).
*/}
-
-
-
- Data Profiler
- {tableName}
-
+
+
+
+ Data Profiler
+ {tableName}
+
-
- {profile && (
-
-
-
-
- Export
-
-
-
- exportProfile("csv")} className="text-xs cursor-pointer">
- Export as CSV
-
- exportProfile("json")} className="text-xs cursor-pointer">
- Export as JSON
-
-
-
- )}
+
+ {profile && (
+
+
+
+
+ Export
+
+
+
+ exportProfile("csv")} className="text-xs cursor-pointer">
+ Export as CSV
+
+ exportProfile("json")} className="text-xs cursor-pointer">
+ Export as JSON
+
+
+
+ )}
-
-
-
+
+
+
+
-
- {/* Content */}
-
- {isLoading && (
-
-
- Profiling {tableName}...
-
- )}
+ {/* Content */}
+
+ {isLoading && (
+
+
+ Profiling {tableName}...
+
+ )}
- {error && (
-
-
- {error}
-
- )}
-
- {profile && (
- <>
- {/* Summary Stats */}
-
-
-
Total Rows
-
{profile.totalRows.toLocaleString()}
-
-
-
Columns
-
{profile.columns.length}
-
-
-
Avg Null %
-
- {profile.columns.length > 0
- ? Math.round(profile.columns.reduce((sum, c) => sum + c.nullPercent, 0) / profile.columns.length)
- : 0}
- %
-
-
+ {error && (
+
+
+ {error}
+ )}
+
+ {profile && (
+ <>
+ {/* Summary Stats */}
+
+
+
Total Rows
+
{profile.totalRows.toLocaleString()}
+
+
+
Columns
+
{profile.columns.length}
+
+
+
Avg Null %
+
+ {profile.columns.length > 0
+ ? Math.round(
+ profile.columns.reduce((sum, c) => sum + c.nullPercent, 0) / profile.columns.length,
+ )
+ : 0}
+ %
+
+
+
- {/* Column Profiles */}
-
-
Column Profiles
- {profile.columns.map((col) => (
-
-
-
-
-
{col.name}
- {col.type &&
{col.type} }
- {sensitiveColumnNames.has(col.name) && (
-
-
-
- )}
+ {/* Column Profiles */}
+
+
Column Profiles
+ {profile.columns.map((col) => (
+
+
+
+
+ {col.name}
+ {col.type && {col.type} }
+ {sensitiveColumnNames.has(col.name) && (
+
+
+
+ )}
+
+
{col.distinctCount.toLocaleString()} distinct
-
{col.distinctCount.toLocaleString()} distinct
-
- {col.error ? (
-
{col.error}
- ) : (
- <>
- {/* Null bar */}
-
-
-
{col.error}
+ ) : (
+ <>
+ {/* Null bar */}
+
+
+
50
+ ? "bg-danger-tint"
+ : col.nullPercent > 20
+ ? "bg-warning-tint"
+ : "bg-success-tint",
+ )}
+ style={{ width: `${100 - col.nullPercent}%` }}
+ />
+
+
50
- ? "bg-danger-tint"
+ ? "text-danger"
: col.nullPercent > 20
- ? "bg-warning-tint"
- : "bg-success-tint",
+ ? "text-warning"
+ : "text-success",
)}
- style={{ width: `${100 - col.nullPercent}%` }}
- />
+ >
+ {col.nullPercent}% null
+
-
50
- ? "text-danger"
- : col.nullPercent > 20
- ? "text-warning"
- : "text-success",
- )}
- >
- {col.nullPercent}% null
-
-
- {/* Min/Max */}
-
- {col.minValue &&
- (() => {
- const rule = sensitiveColumnNames.get(col.name);
- const display = rule ? maskValue(col.minValue, rule) : col.minValue.substring(0, 30);
- return (
-
- min:{" "}
-
- {display}
+ {/* Min/Max */}
+
+ {col.minValue &&
+ (() => {
+ const rule = sensitiveColumnNames.get(col.name);
+ const display = rule ? maskValue(col.minValue, rule) : col.minValue.substring(0, 30);
+ return (
+
+ min:{" "}
+
+ {display}
+
-
- );
- })()}
- {col.maxValue &&
- (() => {
- const rule = sensitiveColumnNames.get(col.name);
- const display = rule ? maskValue(col.maxValue, rule) : col.maxValue.substring(0, 30);
- return (
-
- max:{" "}
-
+ );
+ })()}
+ {col.maxValue &&
+ (() => {
+ const rule = sensitiveColumnNames.get(col.name);
+ const display = rule ? maskValue(col.maxValue, rule) : col.maxValue.substring(0, 30);
+ return (
+
+ max:{" "}
+
+ {display}
+
+
+ );
+ })()}
+
+
+ {/* Sample Values */}
+ {col.sampleValues && col.sampleValues.length > 0 && (
+
+ {col.sampleValues.map((val, i) => {
+ const rule = sensitiveColumnNames.get(col.name);
+ const display = rule ? maskValue(val, rule) : val.substring(0, 20);
+ return (
+
{display}
-
- );
- })()}
-
+ );
+ })}
+
+ )}
+ >
+ )}
+
+ ))}
+
- {/* Sample Values */}
- {col.sampleValues && col.sampleValues.length > 0 && (
-
- {col.sampleValues.map((val, i) => {
- const rule = sensitiveColumnNames.get(col.name);
- const display = rule ? maskValue(val, rule) : val.substring(0, 20);
- return (
-
- {display}
-
- );
- })}
-
- )}
- >
+ {/* AI Summary */}
+ {(aiSummary || isAiLoading) && (
+
+
+
+ AI Analysis
+ {isAiLoading && }
+
+ {aiSummary && (
+
{aiSummary}
)}
- ))}
-
-
- {/* AI Summary */}
- {(aiSummary || isAiLoading) && (
-
-
-
- AI Analysis
- {isAiLoading && }
-
- {aiSummary && (
-
{aiSummary}
- )}
-
- )}
- >
- )}
+ )}
+ >
+ )}
+
-
+ >
);
}
diff --git a/src/components/ShortcutsDialog.tsx b/src/components/ShortcutsDialog.tsx
new file mode 100644
index 00000000..b1b90da1
--- /dev/null
+++ b/src/components/ShortcutsDialog.tsx
@@ -0,0 +1,152 @@
+"use client";
+
+import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useSyncExternalStore } from "react";
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { SHORTCUT_GROUPS } from "@/lib/shortcuts";
+
+export interface ShortcutsDialogRef {
+ open: () => void;
+}
+
+/**
+ * Open state lives at module scope rather than in this component's own `useState` (#746
+ * review): `Studio.tsx` mounts one instance unconditionally and `DataProfiler.tsx` mounts a
+ * second whenever it's open, so in the standalone shell with the profiler open BOTH are
+ * mounted at once. Two independent `useState`s would mean two independent dialogs — "?"
+ * opening both, and one Escape closing only the topmost, leaving the other (and the profiler
+ * underneath) still up. A shared store fixes the STATE half of that; `primaryInstanceKey`
+ * below fixes the other half, which instance actually renders the `Dialog`.
+ */
+let sharedOpen = false;
+const openListeners = new Set<() => void>();
+
+function setSharedOpen(next: boolean): void {
+ if (sharedOpen === next) return;
+ sharedOpen = next;
+ openListeners.forEach((listener) => listener());
+}
+
+function subscribeOpen(callback: () => void): () => void {
+ openListeners.add(callback);
+ return () => openListeners.delete(callback);
+}
+
+function getOpenSnapshot(): boolean {
+ return sharedOpen;
+}
+
+function getServerOpenSnapshot(): boolean {
+ return false;
+}
+
+/**
+ * Whichever instance mounts first renders the `Dialog`; a later one sharing the tree (the
+ * standalone shell's Studio-level instance is always first in practice, since `DataProfiler`
+ * mounts only once the profiler opens) shares the same open flag but renders nothing, so
+ * there is exactly one `Dialog` no matter how many instances are mounted at once. The
+ * mutation lives inside `subscribePrimary`, which `useSyncExternalStore` calls from its own
+ * effect — not inside an effect of this component's — so this never calls setState from
+ * render or from an effect body of its own; `isPrimary` is a pure read of external state,
+ * the same shape `useFavoriteConnections`/`useConnectionOrder` already use for this reason.
+ */
+let primaryInstanceKey: object | null = null;
+const mountedInstances = new Map
void>();
+
+function subscribePrimary(key: object, callback: () => void): () => void {
+ mountedInstances.set(key, callback);
+ if (primaryInstanceKey === null) primaryInstanceKey = key;
+ return () => {
+ mountedInstances.delete(key);
+ if (primaryInstanceKey === key) {
+ // Promote whichever instance is still mounted, if any, so it starts rendering the
+ // Dialog. In practice this is Studio.tsx's own instance outliving DataProfiler's, not
+ // the reverse, but nothing here assumes that ordering.
+ const [nextKey] = mountedInstances.keys();
+ primaryInstanceKey = nextKey ?? null;
+ }
+ // Nothing left to show it to - and in the embedded workspace, DataProfiler's is the
+ // only instance there is, so this is what closes the dialog on the profiler's own
+ // unmount rather than leaving a stale "open" flag for the next time it mounts.
+ if (mountedInstances.size === 0) setSharedOpen(false);
+ mountedInstances.forEach((listener) => listener());
+ };
+}
+
+function isTypingTarget(target: EventTarget | null): boolean {
+ if (!(target instanceof HTMLElement)) return false;
+ if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) return true;
+ if (target.isContentEditable) return true;
+ // Monaco 0.56 focuses a `div.native-edit-context`, not a textarea or a contentEditable
+ // element, so neither check above sees it - and `?` is the positional-parameter
+ // placeholder in SQLite and MySQL, so missing this let the dialog eat the keystroke
+ // mid-query. `.monaco-editor` is Monaco's own stable root class, not an internal we're
+ // reaching past: checking "inside the editor at all" survives Monaco changing which
+ // element it focuses next, where chasing that element by name would not.
+ return target.closest(".monaco-editor") !== null;
+}
+
+/**
+ * The single place that answers "what shortcuts exist" (#746). Self-contained, following
+ * `CommandPalette`'s own Cmd/Ctrl+K effect: every instance owns its own "?" listener, so
+ * mounting it in both `Studio.tsx` and `DataProfiler.tsx` — `DataProfiler` is itself mounted
+ * by both the standalone shell and the embedded workspace — is what makes the dialog reachable
+ * everywhere without either host threading open state through props. What's shared across
+ * instances (module scope, above) is the open flag itself and which one actually renders.
+ *
+ * `CommandPalette`'s "Keyboard Shortcuts" entry reaches the standalone shell's instance
+ * through the imperative handle below, the same seam `QueryEditorRef` already uses for the
+ * editor. Its `open()` writes the shared flag, so it opens whichever instance is currently
+ * rendering the dialog regardless of which one the ref happens to be attached to.
+ */
+export const ShortcutsDialog = forwardRef(function ShortcutsDialog(_props, ref) {
+ const instanceKey = useRef({}).current;
+ const subscribe = useCallback((callback: () => void) => subscribePrimary(instanceKey, callback), [instanceKey]);
+ const getIsPrimary = useCallback(() => primaryInstanceKey === instanceKey, [instanceKey]);
+ const rendersDialog = useSyncExternalStore(subscribe, getIsPrimary, () => false);
+
+ const open = useSyncExternalStore(subscribeOpen, getOpenSnapshot, getServerOpenSnapshot);
+
+ useImperativeHandle(ref, () => ({ open: () => setSharedOpen(true) }), []);
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key !== "?" || isTypingTarget(e.target)) return;
+ e.preventDefault();
+ setSharedOpen(true);
+ };
+ document.addEventListener("keydown", handleKeyDown);
+ return () => document.removeEventListener("keydown", handleKeyDown);
+ }, []);
+
+ if (!rendersDialog) return null;
+
+ return (
+
+
+
+ Keyboard Shortcuts
+
+
+ {SHORTCUT_GROUPS.map((group) => (
+
+
{group.heading}
+
+ {group.shortcuts.map((shortcut) => (
+
+ {shortcut.description}
+
+ {shortcut.keys}
+
+
+ ))}
+
+
+ ))}
+
+
+
+ );
+});
diff --git a/src/components/Studio.tsx b/src/components/Studio.tsx
index b2790407..fad16689 100644
--- a/src/components/Studio.tsx
+++ b/src/components/Studio.tsx
@@ -13,6 +13,7 @@ import { SchemaExplorer } from "@/components/schema-explorer";
import { ConnectionModal } from "@/components/ConnectionModal";
import { CommandPalette } from "@/components/CommandPalette";
import { QueryEditor, QueryEditorRef } from "@/components/QueryEditor";
+import { ShortcutsDialog, type ShortcutsDialogRef } from "@/components/ShortcutsDialog";
import { DataImportModal } from "@/components/DataImportModal";
import { QuerySafetyDialog } from "@/components/QuerySafetyDialog";
import { DataProfiler } from "@/components/DataProfiler";
@@ -100,6 +101,7 @@ const SchemaDiagram = React.lazy(
export default function Studio() {
const queryEditorRef = useRef(null);
+ const shortcutsDialogRef = useRef(null);
const router = useRouter();
const { toast } = useToast();
@@ -303,7 +305,7 @@ export default function Studio() {
* because an unmeasured "nothing else can reach this" is the mistake D82 was filed over. The
* dialog refuses every exit IT owns: while `applying` it withholds its close button and prevents
* Escape, a press outside and every other interaction outside. What it cannot refuse is a global
- * listener, and `grep -rE 'addEventListener\(\s*"keydown' src` answers FOUR, of which TWO can
+ * listener, and `grep -rE 'addEventListener\(\s*"keydown' src` answers FIVE, of which TWO can
* move the active tab here:
*
* - `src/components/studio/StudioTabBar.tsx`, on `document`: the new-tab shortcut (#745).
@@ -314,6 +316,9 @@ export default function Studio() {
* own `onClose`. It moves no tab. An earlier form of this paragraph said there were two
* listeners and missed it, which is the unmeasured-absence mistake D82 was filed over, so it is
* named here rather than left out for being harmless.
+ * - `src/components/ShortcutsDialog.tsx`, on `document` (#746), and MOUNTED BY THIS SHELL. It
+ * answers `?` alone (guarded against the editor and every text input), opens a dialog that
+ * reads shortcut labels and closes itself, and moves no tab.
* - `src/components/ui/sidebar.tsx`, on `window`, toggling a sidebar. An unused shadcn primitive
* with no importer anywhere in `src` (P5), so it is mounted nowhere.
*
@@ -1470,9 +1475,12 @@ export default function Studio() {
onFormatQuery={() => queryEditorRef.current?.format()}
onSaveQuery={() => setIsSaveQueryModalOpen(true)}
onAskAgent={agentEnabled ? askAgentAboutStatement : undefined}
+ onShowShortcuts={() => shortcutsDialogRef.current?.open()}
onLogout={handleLogout}
/>
+
+
onFormatQuery: mock(() => {}),
onSaveQuery: mock(() => {}),
onAskAgent: mock(() => {}),
+ onShowShortcuts: mock(() => {}),
onLogout: mock(() => {}),
...overrides,
};
@@ -446,6 +447,19 @@ describe("CommandPalette", () => {
fireEvent.click(saveItem!);
});
+ test("Keyboard Shortcuts action callback fires via runAction", () => {
+ const onShowShortcuts = mock(() => {});
+ const props = createDefaultProps({ onShowShortcuts });
+ const { getByText } = render( );
+
+ // Open dialog
+ fireEvent.keyDown(document, { key: "k", metaKey: true });
+
+ const shortcutsItem = getByText("Keyboard Shortcuts").closest('[role="option"]');
+ expect(shortcutsItem).not.toBeNull();
+ fireEvent.click(shortcutsItem!);
+ });
+
/**
* The item names the agent because the in-editor assistant it used to open no
* longer exists (#331 T3), and names the QUERY because the ask is about the
diff --git a/tests/components/DataProfiler.test.tsx b/tests/components/DataProfiler.test.tsx
index 068b6a0a..f64c75a0 100644
--- a/tests/components/DataProfiler.test.tsx
+++ b/tests/components/DataProfiler.test.tsx
@@ -791,6 +791,49 @@ describe("DataProfiler", () => {
expect(onClose).not.toHaveBeenCalled();
});
+ // ── Shortcuts dialog (#746) ────────────────────────────────────────────────
+
+ test("? opens the shortcuts dialog while the profiler is open", () => {
+ const props = createDefaultProps({ isOpen: true });
+ const { queryByText } = render( );
+ expect(queryByText("Keyboard Shortcuts")).toBeNull();
+
+ fireEvent.keyDown(document, { key: "?" });
+
+ expect(queryByText("Keyboard Shortcuts")).not.toBeNull();
+ });
+
+ test("? does nothing while the profiler is closed", () => {
+ const props = createDefaultProps({ isOpen: false });
+ const { queryByText } = render( );
+
+ fireEvent.keyDown(document, { key: "?" });
+
+ expect(queryByText("Keyboard Shortcuts")).toBeNull();
+ });
+
+ // Radix's Dialog handles Escape in the capture phase and only calls
+ // preventDefault() - not stopPropagation() - so this component's OWN Escape
+ // listener (bound on `document`, above) still ran and closed the profiler
+ // underneath the shortcuts dialog on the very same keypress.
+ test("Escape closes only the shortcuts dialog, leaving the profiler open", async () => {
+ const onClosed = mock(() => {});
+ const { container, queryByText } = render( );
+
+ await waitFor(() => {
+ expect(within(container).queryByText("Data Profiler")).not.toBeNull();
+ });
+
+ fireEvent.keyDown(document, { key: "?" });
+ expect(queryByText("Keyboard Shortcuts")).not.toBeNull();
+
+ fireEvent.keyDown(document, { key: "Escape" });
+
+ expect(queryByText("Keyboard Shortcuts")).toBeNull();
+ expect(onClosed).not.toHaveBeenCalled();
+ expect(within(container).queryByText("Data Profiler")).not.toBeNull();
+ });
+
// Same rule as every other connection-bearing request: a managed (seed)
// connection is sent as its seed id, because the copy the browser holds has had
// `password` and `connectionString` stripped. Sending the object made
diff --git a/tests/components/ShortcutsDialog.test.tsx b/tests/components/ShortcutsDialog.test.tsx
new file mode 100644
index 00000000..7cede5f6
--- /dev/null
+++ b/tests/components/ShortcutsDialog.test.tsx
@@ -0,0 +1,214 @@
+import "../setup-dom";
+import "../helpers/mock-sonner";
+import "../helpers/mock-navigation";
+
+import React from "react";
+import { describe, test, expect, afterEach } from "bun:test";
+import { render, cleanup, fireEvent, act } from "@testing-library/react";
+import ReactDOMServer from "react-dom/server";
+
+import { ShortcutsDialog, type ShortcutsDialogRef } from "@/components/ShortcutsDialog";
+import { SHORTCUT_GROUPS } from "@/lib/shortcuts";
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("ShortcutsDialog", () => {
+ test("renders nothing during server rendering", () => {
+ // Both useSyncExternalStore calls take their SERVER snapshot during SSR (React never
+ // runs the effects that would let either one see real module state), so this renders
+ // null regardless of what any client-side instance has done to `sharedOpen` elsewhere -
+ // there is no "started open" case to prove wrong here, only that this doesn't throw.
+ expect(ReactDOMServer.renderToString(React.createElement(ShortcutsDialog))).toBe("");
+ });
+
+ test("is closed on mount", () => {
+ const { queryByText } = render( );
+ expect(queryByText("Keyboard Shortcuts")).toBeNull();
+ });
+
+ test("pressing ? opens the dialog", () => {
+ const { getByText } = render( );
+
+ fireEvent.keyDown(document, { key: "?" });
+
+ expect(getByText("Keyboard Shortcuts")).not.toBeNull();
+ });
+
+ test("pressing ? while typing in an input does not open the dialog", () => {
+ const { queryByText } = render(
+ <>
+
+
+ >,
+ );
+
+ const input = document.querySelector('input[aria-label="search"]')!;
+ fireEvent.keyDown(input, { key: "?" });
+
+ expect(queryByText("Keyboard Shortcuts")).toBeNull();
+ });
+
+ test("pressing ? while typing in a textarea does not open the dialog", () => {
+ const { queryByText } = render(
+ <>
+
+
+ >,
+ );
+
+ const textarea = document.querySelector('textarea[aria-label="notes"]')!;
+ fireEvent.keyDown(textarea, { key: "?" });
+
+ expect(queryByText("Keyboard Shortcuts")).toBeNull();
+ });
+
+ test("pressing ? in a contentEditable element does not open the dialog", () => {
+ const { queryByText } = render(
+ <>
+
+
+ >,
+ );
+
+ const editable = document.querySelector('[aria-label="editable"]')!;
+ fireEvent.keyDown(editable, { key: "?" });
+
+ expect(queryByText("Keyboard Shortcuts")).toBeNull();
+ });
+
+ test("pressing ? inside Monaco's edit-context element does not open the dialog", () => {
+ // Monaco 0.56 focuses a div.native-edit-context inside .monaco-editor — neither an
+ // /