diff --git a/src/browser/components/app-shell.tsx b/src/browser/components/app-shell.tsx
index 1f80800..8a0422c 100644
--- a/src/browser/components/app-shell.tsx
+++ b/src/browser/components/app-shell.tsx
@@ -23,6 +23,8 @@ import {
HoverCardContent,
} from "../ui/hover-card";
import { version } from "../../../package.json";
+import { KeyboardShortcutsModal } from "./keyboard-shortcuts-modal";
+import { matchesKey } from "@/browser/lib/shortcuts";
// ============================================================================
// App Shell - Tab-based Layout
@@ -40,6 +42,7 @@ export function AppShell() {
} = useTabContext();
const params = useParams<{ owner: string; repo: string; number: string }>();
const navigate = useNavigate();
+ const [shortcutsModalOpen, setShortcutsModalOpen] = useState(false);
// URL is the source of truth - sync URL → Tab
useEffect(() => {
@@ -116,6 +119,26 @@ export function AppShell() {
return () => window.removeEventListener("keydown", handleKeyDown);
}, [tabs, activeTabId, handleTabSelect, closeTab]);
+ // Handle ? key for keyboard shortcuts modal
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ const target = e.target as HTMLElement;
+ if (
+ target.tagName === "INPUT" ||
+ target.tagName === "TEXTAREA" ||
+ target.isContentEditable
+ ) {
+ return;
+ }
+ if (matchesKey(e, "SHOW_SHORTCUTS")) {
+ e.preventDefault();
+ setShortcutsModalOpen(true);
+ }
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, []);
+
return (
{/* Native-style Tab Bar */}
@@ -210,6 +233,11 @@ export function AppShell() {
)}
+
+
);
}
diff --git a/src/browser/components/command-palette.tsx b/src/browser/components/command-palette.tsx
index fc16e95..ca38740 100644
--- a/src/browser/components/command-palette.tsx
+++ b/src/browser/components/command-palette.tsx
@@ -18,6 +18,7 @@ import { cn } from "../cn";
import { usePRReviewSelector, usePRReviewStore } from "../contexts/pr-review";
import { Keycap, KeycapGroup } from "../ui/keycap";
import type { PullRequestFile } from "@/api/types";
+import { matchesKey } from "@/browser/lib/shortcuts";
// ============================================================================
// Global Command Palette Context
@@ -44,7 +45,10 @@ export function CommandPaletteProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ctrl+K or Ctrl+P to open command palette
- if ((e.ctrlKey || e.metaKey) && (e.key === "k" || e.key === "p")) {
+ if (
+ matchesKey(e, "OPEN_FILE_SEARCH_K") ||
+ matchesKey(e, "OPEN_FILE_SEARCH_P")
+ ) {
e.preventDefault();
e.stopPropagation();
setOpen((prev) => !prev);
diff --git a/src/browser/components/keyboard-shortcuts-modal.tsx b/src/browser/components/keyboard-shortcuts-modal.tsx
new file mode 100644
index 0000000..ff51737
--- /dev/null
+++ b/src/browser/components/keyboard-shortcuts-modal.tsx
@@ -0,0 +1,54 @@
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from "@/browser/ui/dialog";
+import { KeycapGroup } from "@/browser/ui/keycap";
+import { getShortcutsByCategory } from "@/browser/lib/shortcuts";
+
+interface KeyboardShortcutsModalProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function KeyboardShortcutsModal({
+ open,
+ onOpenChange,
+}: KeyboardShortcutsModalProps) {
+ const categories = getShortcutsByCategory();
+
+ return (
+
+ );
+}
diff --git a/src/browser/contexts/pr-review/useKeyboardNavigation.ts b/src/browser/contexts/pr-review/useKeyboardNavigation.ts
index a630609..073b6ab 100644
--- a/src/browser/contexts/pr-review/useKeyboardNavigation.ts
+++ b/src/browser/contexts/pr-review/useKeyboardNavigation.ts
@@ -1,5 +1,6 @@
import { startTransition, useEffect } from "react";
import { usePRReviewStore } from ".";
+import { matchesKey } from "@/browser/lib/shortcuts";
export function useKeyboardNavigation() {
const store = usePRReviewStore();
@@ -16,16 +17,14 @@ export function useKeyboardNavigation() {
}
// Handle Ctrl/Cmd+Arrow for jumping by 10 lines
- if (
- (e.ctrlKey || e.metaKey) &&
- (e.key === "ArrowDown" || e.key === "ArrowUp")
- ) {
+ if (matchesKey(e, "JUMP_UP")) {
e.preventDefault();
- store.navigateLine(
- e.key === "ArrowDown" ? "down" : "up",
- e.shiftKey,
- 10
- );
+ store.navigateLine("up", e.shiftKey, 10);
+ return;
+ }
+ if (matchesKey(e, "JUMP_DOWN")) {
+ e.preventDefault();
+ store.navigateLine("down", e.shiftKey, 10);
return;
}
@@ -48,17 +47,17 @@ export function useKeyboardNavigation() {
store.backspaceGotoInput();
return;
}
- if (e.key === "Tab") {
+ if (matchesKey(e, "GOTO_TOGGLE_SIDE")) {
e.preventDefault();
store.toggleGotoLineSide();
return;
}
- if (e.key === "Enter" && state.gotoLineInput) {
+ if (matchesKey(e, "GOTO_EXECUTE") && state.gotoLineInput) {
e.preventDefault();
store.executeGotoLine();
return;
}
- if (e.key === "Escape") {
+ if (matchesKey(e, "GOTO_EXIT")) {
e.preventDefault();
store.exitGotoMode();
return;
@@ -67,7 +66,10 @@ export function useKeyboardNavigation() {
}
// Enter to expand focused skip block
- if (e.key === "Enter" && state.focusedSkipBlockIndex !== null) {
+ if (
+ matchesKey(e, "EXPAND_SECTION") &&
+ state.focusedSkipBlockIndex !== null
+ ) {
e.preventDefault();
// Dispatch event to expand the skip block (handled by DiffViewer)
const event = new CustomEvent("pr-review:expand-skip-block", {
@@ -78,143 +80,146 @@ export function useKeyboardNavigation() {
}
// Arrow navigation - direct call for instant response
- if (e.key === "ArrowDown") {
+ if (matchesKey(e, "NAVIGATE_DOWN")) {
e.preventDefault();
store.navigateLine("down", e.shiftKey, 1);
return;
}
- if (e.key === "ArrowUp") {
+ if (matchesKey(e, "NAVIGATE_UP")) {
e.preventDefault();
store.navigateLine("up", e.shiftKey, 1);
return;
}
// Left/Right arrows to switch between sides in split view
- if (e.key === "ArrowLeft") {
+ if (matchesKey(e, "NAVIGATE_LEFT")) {
e.preventDefault();
store.navigateSide("left");
return;
}
- if (e.key === "ArrowRight") {
+ if (matchesKey(e, "NAVIGATE_RIGHT")) {
e.preventDefault();
store.navigateSide("right");
return;
}
// Shortcuts
- switch (e.key.toLowerCase()) {
- case "j":
- e.preventDefault();
- // Use startTransition to allow React to interrupt rendering during rapid navigation
- startTransition(() => {
- store.navigateToNextUnviewedFile();
- });
- break;
- case "k":
- e.preventDefault();
- // Use startTransition to allow React to interrupt rendering during rapid navigation
- startTransition(() => {
- store.navigateToPrevUnviewedFile();
- });
- break;
- case "v":
- e.preventDefault();
- if (state.selectedFiles.size > 0) {
- store.toggleViewedMultiple([...state.selectedFiles]);
- } else if (state.selectedFile) {
- store.toggleViewed(state.selectedFile);
+ if (matchesKey(e, "NEXT_UNVIEWED_FILE")) {
+ e.preventDefault();
+ // Use startTransition to allow React to interrupt rendering during rapid navigation
+ startTransition(() => {
+ store.navigateToNextUnviewedFile();
+ });
+ return;
+ }
+ if (matchesKey(e, "PREV_UNVIEWED_FILE")) {
+ e.preventDefault();
+ startTransition(() => {
+ store.navigateToPrevUnviewedFile();
+ });
+ return;
+ }
+ if (matchesKey(e, "TOGGLE_VIEWED")) {
+ e.preventDefault();
+ if (state.selectedFiles.size > 0) {
+ store.toggleViewedMultiple([...state.selectedFiles]);
+ } else if (state.selectedFile) {
+ store.toggleViewed(state.selectedFile);
+ }
+ return;
+ }
+ if (matchesKey(e, "GOTO_LINE_MODE")) {
+ e.preventDefault();
+ store.enterGotoMode();
+ return;
+ }
+ if (matchesKey(e, "GOTO_OVERVIEW")) {
+ e.preventDefault();
+ store.selectOverview();
+ return;
+ }
+ if (matchesKey(e, "COMMENT")) {
+ e.preventDefault();
+ store.startCommentingOnFocusedLine();
+ return;
+ }
+ if (matchesKey(e, "EDIT_COMMENT")) {
+ if (state.focusedCommentId) {
+ // Check if user can edit this comment
+ // ADMIN and MAINTAIN can edit any comment, WRITE can only edit own comments
+ const commentToEdit = state.comments.find(
+ (c) => c.id === state.focusedCommentId
+ );
+ const isOwnComment =
+ commentToEdit && state.currentUser === commentToEdit.user.login;
+ const canEditAny =
+ state.viewerPermission === "ADMIN" ||
+ state.viewerPermission === "MAINTAIN";
+ if (commentToEdit && (isOwnComment || canEditAny)) {
+ e.preventDefault();
+ store.startEditing(state.focusedCommentId);
}
- break;
- case "g":
- e.preventDefault();
- store.enterGotoMode();
- break;
- case "o":
+ } else if (state.focusedPendingCommentId) {
+ // Pending comments are always owned by current user
e.preventDefault();
- store.selectOverview();
- break;
- case "c":
+ store.startEditingPendingComment(state.focusedPendingCommentId);
+ }
+ return;
+ }
+ if (matchesKey(e, "REPLY_COMMENT")) {
+ if (state.focusedCommentId) {
e.preventDefault();
- store.startCommentingOnFocusedLine();
- break;
- case "e":
- if (state.focusedCommentId) {
- // Check if user can edit this comment
- // ADMIN and MAINTAIN can edit any comment, WRITE can only edit own comments
- const commentToEdit = state.comments.find(
- (c) => c.id === state.focusedCommentId
- );
- const isOwnComment =
- commentToEdit && state.currentUser === commentToEdit.user.login;
- const canEditAny =
- state.viewerPermission === "ADMIN" ||
- state.viewerPermission === "MAINTAIN";
- if (commentToEdit && (isOwnComment || canEditAny)) {
- e.preventDefault();
- store.startEditing(state.focusedCommentId);
- }
- } else if (state.focusedPendingCommentId) {
- // Pending comments are always owned by current user
- e.preventDefault();
- store.startEditingPendingComment(state.focusedPendingCommentId);
- }
- break;
- case "r":
- if (state.focusedCommentId) {
- e.preventDefault();
- store.startReplying(state.focusedCommentId);
- }
- break;
- case "d":
- if (state.focusedCommentId) {
- // Check if user can delete this comment
- // ADMIN and MAINTAIN can delete any comment, WRITE can only delete own comments
- const commentToDelete = state.comments.find(
- (c) => c.id === state.focusedCommentId
- );
- const isOwnCommentD =
- commentToDelete &&
- state.currentUser === commentToDelete.user.login;
- const canDeleteAny =
- state.viewerPermission === "ADMIN" ||
- state.viewerPermission === "MAINTAIN";
- if (commentToDelete && (isOwnCommentD || canDeleteAny)) {
- e.preventDefault();
- if (
- window.confirm("Are you sure you want to delete this comment?")
- ) {
- // Trigger delete via API - component handles this
- const event = new CustomEvent("pr-review:delete-comment", {
- detail: { commentId: state.focusedCommentId },
- });
- window.dispatchEvent(event);
- }
- }
- } else if (state.focusedPendingCommentId) {
- // Pending comments are always owned by current user
+ store.startReplying(state.focusedCommentId);
+ }
+ return;
+ }
+ if (matchesKey(e, "DELETE_COMMENT")) {
+ if (state.focusedCommentId) {
+ // Check if user can delete this comment
+ // ADMIN and MAINTAIN can delete any comment, WRITE can only delete own comments
+ const commentToDelete = state.comments.find(
+ (c) => c.id === state.focusedCommentId
+ );
+ const isOwnCommentD =
+ commentToDelete && state.currentUser === commentToDelete.user.login;
+ const canDeleteAny =
+ state.viewerPermission === "ADMIN" ||
+ state.viewerPermission === "MAINTAIN";
+ if (commentToDelete && (isOwnCommentD || canDeleteAny)) {
e.preventDefault();
if (
- window.confirm(
- "Are you sure you want to delete this pending comment?"
- )
+ window.confirm("Are you sure you want to delete this comment?")
) {
- const event = new CustomEvent(
- "pr-review:delete-pending-comment",
- {
- detail: { commentId: state.focusedPendingCommentId },
- }
- );
+ // Trigger delete via API - component handles this
+ const event = new CustomEvent("pr-review:delete-comment", {
+ detail: { commentId: state.focusedCommentId },
+ });
window.dispatchEvent(event);
}
}
- break;
- case "escape":
+ } else if (state.focusedPendingCommentId) {
+ // Pending comments are always owned by current user
e.preventDefault();
- if (state.commentingOnLine) {
- store.cancelCommenting();
- } else {
- store.clearAllSelections();
+ if (
+ window.confirm(
+ "Are you sure you want to delete this pending comment?"
+ )
+ ) {
+ const event = new CustomEvent("pr-review:delete-pending-comment", {
+ detail: { commentId: state.focusedPendingCommentId },
+ });
+ window.dispatchEvent(event);
}
- break;
+ }
+ return;
+ }
+ if (matchesKey(e, "CANCEL")) {
+ e.preventDefault();
+ if (state.commentingOnLine) {
+ store.cancelCommenting();
+ } else {
+ store.clearAllSelections();
+ }
+ return;
}
};
diff --git a/src/browser/lib/shortcuts.ts b/src/browser/lib/shortcuts.ts
new file mode 100644
index 0000000..adf2798
--- /dev/null
+++ b/src/browser/lib/shortcuts.ts
@@ -0,0 +1,287 @@
+/**
+ * Centralized keyboard shortcuts configuration.
+ * This is the single source of truth for all keyboard shortcuts in the app.
+ *
+ * Both the keyboard handlers and the shortcuts modal read from this config.
+ */
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export type ShortcutCategory =
+ | "Navigation"
+ | "Actions"
+ | "Go to Line"
+ | "File Search"
+ | "Tabs"
+ | "Help";
+
+export interface ShortcutDefinition {
+ /** The key(s) to display in the UI */
+ keys: string[];
+ /** Human-readable description */
+ description: string;
+ /** Category for grouping in the modal */
+ category: ShortcutCategory;
+ /** If true, requires Cmd (Mac) / Ctrl (other) modifier */
+ withModifier?: boolean;
+}
+
+// ============================================================================
+// Shortcuts Configuration
+// ============================================================================
+
+export const SHORTCUTS = {
+ // ---------------------------------------------------------------------------
+ // Navigation
+ // ---------------------------------------------------------------------------
+ NAVIGATE_UP: {
+ keys: ["↑"],
+ description: "Navigate up",
+ category: "Navigation",
+ },
+ NAVIGATE_DOWN: {
+ keys: ["↓"],
+ description: "Navigate down",
+ category: "Navigation",
+ },
+ NAVIGATE_LEFT: {
+ keys: ["←"],
+ description: "Switch to left side (split view)",
+ category: "Navigation",
+ },
+ NAVIGATE_RIGHT: {
+ keys: ["→"],
+ description: "Switch to right side (split view)",
+ category: "Navigation",
+ },
+ JUMP_UP: {
+ keys: ["cmd", "↑"],
+ description: "Jump 10 lines up",
+ category: "Navigation",
+ withModifier: true,
+ },
+ JUMP_DOWN: {
+ keys: ["cmd", "↓"],
+ description: "Jump 10 lines down",
+ category: "Navigation",
+ withModifier: true,
+ },
+ NEXT_UNVIEWED_FILE: {
+ keys: ["j"],
+ description: "Next unviewed file",
+ category: "Navigation",
+ },
+ PREV_UNVIEWED_FILE: {
+ keys: ["k"],
+ description: "Previous unviewed file",
+ category: "Navigation",
+ },
+ GOTO_LINE_MODE: {
+ keys: ["g"],
+ description: "Go to line mode",
+ category: "Navigation",
+ },
+ GOTO_OVERVIEW: {
+ keys: ["o"],
+ description: "Go to overview",
+ category: "Navigation",
+ },
+
+ // ---------------------------------------------------------------------------
+ // Actions
+ // ---------------------------------------------------------------------------
+ TOGGLE_VIEWED: {
+ keys: ["v"],
+ description: "Toggle viewed status",
+ category: "Actions",
+ },
+ COMMENT: {
+ keys: ["c"],
+ description: "Comment on line",
+ category: "Actions",
+ },
+ EDIT_COMMENT: {
+ keys: ["e"],
+ description: "Edit comment",
+ category: "Actions",
+ },
+ REPLY_COMMENT: {
+ keys: ["r"],
+ description: "Reply to comment",
+ category: "Actions",
+ },
+ DELETE_COMMENT: {
+ keys: ["d"],
+ description: "Delete comment",
+ category: "Actions",
+ },
+ EXPAND_SECTION: {
+ keys: ["enter"],
+ description: "Expand collapsed section",
+ category: "Actions",
+ },
+ CANCEL: {
+ keys: ["esc"],
+ description: "Cancel / clear selection",
+ category: "Actions",
+ },
+
+ // ---------------------------------------------------------------------------
+ // Go to Line Mode
+ // ---------------------------------------------------------------------------
+ GOTO_INPUT_DIGITS: {
+ keys: ["0-9"],
+ description: "Enter line number",
+ category: "Go to Line",
+ },
+ GOTO_TOGGLE_SIDE: {
+ keys: ["tab"],
+ description: "Toggle side",
+ category: "Go to Line",
+ },
+ GOTO_EXECUTE: {
+ keys: ["enter"],
+ description: "Go to line",
+ category: "Go to Line",
+ },
+ GOTO_EXIT: {
+ keys: ["esc"],
+ description: "Exit mode",
+ category: "Go to Line",
+ },
+
+ // ---------------------------------------------------------------------------
+ // File Search
+ // ---------------------------------------------------------------------------
+ OPEN_FILE_SEARCH_K: {
+ keys: ["cmd", "k"],
+ description: "Open file search",
+ category: "File Search",
+ withModifier: true,
+ },
+ OPEN_FILE_SEARCH_P: {
+ keys: ["cmd", "p"],
+ description: "Open file search",
+ category: "File Search",
+ withModifier: true,
+ },
+
+ // ---------------------------------------------------------------------------
+ // Tabs
+ // ---------------------------------------------------------------------------
+ SWITCH_TAB: {
+ keys: ["cmd", "1-9"],
+ description: "Switch to tab",
+ category: "Tabs",
+ withModifier: true,
+ },
+ CLOSE_TAB: {
+ keys: ["cmd", "w"],
+ description: "Close current tab",
+ category: "Tabs",
+ withModifier: true,
+ },
+
+ // ---------------------------------------------------------------------------
+ // Help
+ // ---------------------------------------------------------------------------
+ SHOW_SHORTCUTS: {
+ keys: ["?"],
+ description: "Show keyboard shortcuts",
+ category: "Help",
+ },
+} as const satisfies Record;
+
+export type ShortcutId = keyof typeof SHORTCUTS;
+
+// ============================================================================
+// Utilities
+// ============================================================================
+
+/** Order for displaying categories in the modal */
+const CATEGORY_ORDER: ShortcutCategory[] = [
+ "Navigation",
+ "Actions",
+ "Go to Line",
+ "File Search",
+ "Tabs",
+ "Help",
+];
+
+/**
+ * Get all shortcuts grouped by category, ordered for display.
+ * Used by the keyboard shortcuts modal.
+ */
+export function getShortcutsByCategory(): Array<{
+ category: ShortcutCategory;
+ shortcuts: ShortcutDefinition[];
+}> {
+ const map = new Map();
+
+ // Initialize with empty arrays in order
+ for (const category of CATEGORY_ORDER) {
+ map.set(category, []);
+ }
+
+ // Group shortcuts by category
+ for (const shortcut of Object.values(SHORTCUTS)) {
+ const existing = map.get(shortcut.category);
+ if (existing) {
+ existing.push(shortcut);
+ }
+ }
+
+ // Convert to array, filtering out empty categories
+ return CATEGORY_ORDER.filter((cat) => (map.get(cat)?.length ?? 0) > 0).map(
+ (category) => ({
+ category,
+ shortcuts: map.get(category)!,
+ })
+ );
+}
+
+/**
+ * Check if a keyboard event matches a shortcut's key.
+ * Handles modifier key requirements.
+ */
+export function matchesKey(
+ event: KeyboardEvent,
+ shortcutId: ShortcutId
+): boolean {
+ const shortcut = SHORTCUTS[shortcutId];
+
+ // Check modifier requirement
+ if (
+ "withModifier" in shortcut &&
+ shortcut.withModifier &&
+ !(event.metaKey || event.ctrlKey)
+ ) {
+ return false;
+ }
+
+ // Get the actual key (last element if modifier is separate)
+ const targetKey = shortcut.keys[shortcut.keys.length - 1].toLowerCase();
+ const eventKey = event.key.toLowerCase();
+
+ // Handle special key mappings
+ switch (targetKey) {
+ case "↑":
+ return eventKey === "arrowup";
+ case "↓":
+ return eventKey === "arrowdown";
+ case "←":
+ return eventKey === "arrowleft";
+ case "→":
+ return eventKey === "arrowright";
+ case "esc":
+ return eventKey === "escape";
+ case "enter":
+ return eventKey === "enter";
+ case "tab":
+ return eventKey === "tab";
+ default:
+ return eventKey === targetKey;
+ }
+}