From 26f74f82d085c0dd02ee08ac3c04f6af429c6d93 Mon Sep 17 00:00:00 2001 From: Ankit Gabani Date: Mon, 14 Sep 2026 01:32:03 +0530 Subject: [PATCH 1/2] feat(shortcuts): add a keyboard shortcuts dialog (#746) Nothing in the app answered "what shortcuts exist" in one place - Cmd/Ctrl+K was the only one documented anywhere in the UI, and Monaco's own bindings were discoverable only through its right-click menu. ShortcutsDialog lists every app-wide shortcut, Monaco's included, from a single registry (src/lib/shortcuts.ts). It opens on "?" (ignored while typing in an input, textarea or contentEditable element) and, in the standalone shell, from a new CommandPalette entry via an imperative ref. Mounted directly in both Studio.tsx and DataProfiler.tsx rather than threaded through props: DataProfiler is rendered by both the standalone shell and the embedded workspace, so mounting it there once covers both hosts, the same way its own Escape-to-close effect already does. --- src/components/CommandPalette.tsx | 9 + src/components/DataProfiler.tsx | 390 +++++++++++----------- src/components/ShortcutsDialog.tsx | 72 ++++ src/components/Studio.tsx | 5 + src/components/studio/StudioTabBar.tsx | 3 +- src/lib/shortcuts.ts | 48 +++ tests/components/CommandPalette.test.tsx | 14 + tests/components/DataProfiler.test.tsx | 21 ++ tests/components/ShortcutsDialog.test.tsx | 115 +++++++ tests/components/Studio.test.tsx | 10 + tests/run-components.sh | 1 + tests/unit/lib/shortcuts.test.ts | 29 ++ 12 files changed, 529 insertions(+), 188 deletions(-) create mode 100644 src/components/ShortcutsDialog.tsx create mode 100644 src/lib/shortcuts.ts create mode 100644 tests/components/ShortcutsDialog.test.tsx create mode 100644 tests/unit/lib/shortcuts.test.ts diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index ac6bd0309..83a012949 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -23,6 +23,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"; @@ -63,6 +64,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; } @@ -83,6 +86,7 @@ export function CommandPalette({ onFormatQuery, onSaveQuery, onAskAgent, + onShowShortcuts, onLogout, }: CommandPaletteProps) { const [open, setOpen] = useState(false); @@ -141,6 +145,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 60aa76b23..8033e69fd 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; @@ -214,221 +215,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 && ( - - - - - - exportProfile("csv")} className="text-xs cursor-pointer"> - Export as CSV - - exportProfile("json")} className="text-xs cursor-pointer"> - Export as JSON - - - - )} +
+ {profile && ( + + + + + + 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 000000000..083059efe --- /dev/null +++ b/src/components/ShortcutsDialog.tsx @@ -0,0 +1,72 @@ +"use client"; + +import React, { forwardRef, useEffect, useImperativeHandle, useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { SHORTCUT_GROUPS } from "@/lib/shortcuts"; + +export interface ShortcutsDialogRef { + open: () => void; +} + +function isTypingTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) return true; + return target.isContentEditable; +} + +/** + * The single place that answers "what shortcuts exist" (#746). Self-contained, following + * `CommandPalette`'s own Cmd/Ctrl+K effect: it owns its open state and its "?" 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. + * + * `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. + */ +export const ShortcutsDialog = forwardRef(function ShortcutsDialog(_props, ref) { + const [open, setOpen] = useState(false); + + useImperativeHandle(ref, () => ({ open: () => setOpen(true) }), []); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== "?" || isTypingTarget(e.target)) return; + e.preventDefault(); + setOpen(true); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, []); + + 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 a78512ae9..22adcf8d0 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"; @@ -97,6 +98,7 @@ const SchemaDiagram = React.lazy( export default function Studio() { const queryEditorRef = useRef(null); + const shortcutsDialogRef = useRef(null); const router = useRouter(); const { toast } = useToast(); @@ -1219,9 +1221,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, }; @@ -415,6 +416,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 068b6a0a1..2bfed3f7a 100644 --- a/tests/components/DataProfiler.test.tsx +++ b/tests/components/DataProfiler.test.tsx @@ -791,6 +791,27 @@ 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(); + }); + // 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 000000000..7605b1a0c --- /dev/null +++ b/tests/components/ShortcutsDialog.test.tsx @@ -0,0 +1,115 @@ +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 { ShortcutsDialog, type ShortcutsDialogRef } from "@/components/ShortcutsDialog"; +import { SHORTCUT_GROUPS } from "@/lib/shortcuts"; + +afterEach(() => { + cleanup(); +}); + +describe("ShortcutsDialog", () => { + 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( + <> +