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 && ( - - - - - - 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 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( + <> +