mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 20:43:36 +00:00
feat(keyboard): introduce visual keyboard map and shortcut customization
- Added a new `KeyboardMap` component for a visual representation of keyboard shortcuts, allowing users to easily customize their shortcuts with single-modifier support. - Integrated `ShortcutReferencePanel` for editing shortcuts directly within the settings view. - Updated the settings view to include a button for opening the keyboard map dialog, enhancing user experience in managing keyboard shortcuts. - Refactored keyboard shortcut handling to support modifier keys and improve shortcut parsing and formatting.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useAppStore, formatShortcut } from "@/store/app-store";
|
||||
import {
|
||||
FolderOpen,
|
||||
Plus,
|
||||
@@ -722,7 +722,7 @@ export function Sidebar() {
|
||||
className="ml-1 px-1 py-0.5 bg-brand-500/10 border border-brand-500/30 rounded text-[10px] font-mono text-brand-400/70"
|
||||
data-testid="sidebar-toggle-shortcut"
|
||||
>
|
||||
{shortcuts.toggleSidebar}
|
||||
{formatShortcut(shortcuts.toggleSidebar, true)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -779,8 +779,8 @@ export function Sidebar() {
|
||||
data-testid="open-project-button"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4 shrink-0" />
|
||||
<span className="hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70 ml-2">
|
||||
{shortcuts.openProject}
|
||||
<span className="hidden lg:flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70 ml-2">
|
||||
{formatShortcut(shortcuts.openProject, true)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -819,10 +819,10 @@ export function Sidebar() {
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className="hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
|
||||
className="hidden lg:flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
|
||||
data-testid="project-picker-shortcut"
|
||||
>
|
||||
{shortcuts.projectPicker}
|
||||
{formatShortcut(shortcuts.projectPicker, true)}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
</div>
|
||||
@@ -958,14 +958,14 @@ export function Sidebar() {
|
||||
<Undo2 className="w-4 h-4 mr-2" />
|
||||
<span className="flex-1">Previous</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground ml-2">
|
||||
{shortcuts.cyclePrevProject}
|
||||
{formatShortcut(shortcuts.cyclePrevProject, true)}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={cycleNextProject} data-testid="cycle-next-project">
|
||||
<Redo2 className="w-4 h-4 mr-2" />
|
||||
<span className="flex-1">Next</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground ml-2">
|
||||
{shortcuts.cycleNextProject}
|
||||
{formatShortcut(shortcuts.cycleNextProject, true)}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={clearProjectHistory} data-testid="clear-project-history">
|
||||
@@ -1049,13 +1049,13 @@ export function Sidebar() {
|
||||
{item.shortcut && sidebarOpen && (
|
||||
<span
|
||||
className={cn(
|
||||
"hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70",
|
||||
"hidden lg:flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70",
|
||||
isActive &&
|
||||
"bg-brand-500/20 border-brand-500/50 text-brand-400"
|
||||
)}
|
||||
data-testid={`shortcut-${item.id}`}
|
||||
>
|
||||
{item.shortcut}
|
||||
{formatShortcut(item.shortcut, true)}
|
||||
</span>
|
||||
)}
|
||||
{/* Tooltip for collapsed state */}
|
||||
@@ -1115,13 +1115,13 @@ export function Sidebar() {
|
||||
{sidebarOpen && (
|
||||
<span
|
||||
className={cn(
|
||||
"hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70",
|
||||
"hidden lg:flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70",
|
||||
isActiveRoute("settings") &&
|
||||
"bg-brand-500/20 border-brand-500/50 text-brand-400"
|
||||
)}
|
||||
data-testid="shortcut-settings"
|
||||
>
|
||||
{shortcuts.settings}
|
||||
{formatShortcut(shortcuts.settings, true)}
|
||||
</span>
|
||||
)}
|
||||
{!sidebarOpen && (
|
||||
|
||||
640
app/src/components/ui/keyboard-map.tsx
Normal file
640
app/src/components/ui/keyboard-map.tsx
Normal file
@@ -0,0 +1,640 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS, parseShortcut, formatShortcut } from "@/store/app-store";
|
||||
import type { KeyboardShortcuts } from "@/store/app-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CheckCircle2, X, RotateCcw, Edit2 } from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
// Detect if running on Mac
|
||||
const isMac = typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||
|
||||
// Keyboard layout - US QWERTY
|
||||
const KEYBOARD_ROWS = [
|
||||
// Number row
|
||||
[
|
||||
{ key: "`", label: "`", width: 1 },
|
||||
{ key: "1", label: "1", width: 1 },
|
||||
{ key: "2", label: "2", width: 1 },
|
||||
{ key: "3", label: "3", width: 1 },
|
||||
{ key: "4", label: "4", width: 1 },
|
||||
{ key: "5", label: "5", width: 1 },
|
||||
{ key: "6", label: "6", width: 1 },
|
||||
{ key: "7", label: "7", width: 1 },
|
||||
{ key: "8", label: "8", width: 1 },
|
||||
{ key: "9", label: "9", width: 1 },
|
||||
{ key: "0", label: "0", width: 1 },
|
||||
{ key: "-", label: "-", width: 1 },
|
||||
{ key: "=", label: "=", width: 1 },
|
||||
],
|
||||
// Top letter row
|
||||
[
|
||||
{ key: "Q", label: "Q", width: 1 },
|
||||
{ key: "W", label: "W", width: 1 },
|
||||
{ key: "E", label: "E", width: 1 },
|
||||
{ key: "R", label: "R", width: 1 },
|
||||
{ key: "T", label: "T", width: 1 },
|
||||
{ key: "Y", label: "Y", width: 1 },
|
||||
{ key: "U", label: "U", width: 1 },
|
||||
{ key: "I", label: "I", width: 1 },
|
||||
{ key: "O", label: "O", width: 1 },
|
||||
{ key: "P", label: "P", width: 1 },
|
||||
{ key: "[", label: "[", width: 1 },
|
||||
{ key: "]", label: "]", width: 1 },
|
||||
{ key: "\\", label: "\\", width: 1 },
|
||||
],
|
||||
// Home row
|
||||
[
|
||||
{ key: "A", label: "A", width: 1 },
|
||||
{ key: "S", label: "S", width: 1 },
|
||||
{ key: "D", label: "D", width: 1 },
|
||||
{ key: "F", label: "F", width: 1 },
|
||||
{ key: "G", label: "G", width: 1 },
|
||||
{ key: "H", label: "H", width: 1 },
|
||||
{ key: "J", label: "J", width: 1 },
|
||||
{ key: "K", label: "K", width: 1 },
|
||||
{ key: "L", label: "L", width: 1 },
|
||||
{ key: ";", label: ";", width: 1 },
|
||||
{ key: "'", label: "'", width: 1 },
|
||||
],
|
||||
// Bottom letter row
|
||||
[
|
||||
{ key: "Z", label: "Z", width: 1 },
|
||||
{ key: "X", label: "X", width: 1 },
|
||||
{ key: "C", label: "C", width: 1 },
|
||||
{ key: "V", label: "V", width: 1 },
|
||||
{ key: "B", label: "B", width: 1 },
|
||||
{ key: "N", label: "N", width: 1 },
|
||||
{ key: "M", label: "M", width: 1 },
|
||||
{ key: ",", label: ",", width: 1 },
|
||||
{ key: ".", label: ".", width: 1 },
|
||||
{ key: "/", label: "/", width: 1 },
|
||||
],
|
||||
];
|
||||
|
||||
// Map shortcut names to human-readable labels
|
||||
const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
|
||||
board: "Kanban Board",
|
||||
agent: "Agent Runner",
|
||||
spec: "Spec Editor",
|
||||
context: "Context",
|
||||
tools: "Agent Tools",
|
||||
settings: "Settings",
|
||||
profiles: "AI Profiles",
|
||||
toggleSidebar: "Toggle Sidebar",
|
||||
addFeature: "Add Feature",
|
||||
addContextFile: "Add Context File",
|
||||
startNext: "Start Next",
|
||||
newSession: "New Session",
|
||||
openProject: "Open Project",
|
||||
projectPicker: "Project Picker",
|
||||
cyclePrevProject: "Prev Project",
|
||||
cycleNextProject: "Next Project",
|
||||
addProfile: "Add Profile",
|
||||
};
|
||||
|
||||
// Categorize shortcuts for color coding
|
||||
const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, "navigation" | "ui" | "action"> = {
|
||||
board: "navigation",
|
||||
agent: "navigation",
|
||||
spec: "navigation",
|
||||
context: "navigation",
|
||||
tools: "navigation",
|
||||
settings: "navigation",
|
||||
profiles: "navigation",
|
||||
toggleSidebar: "ui",
|
||||
addFeature: "action",
|
||||
addContextFile: "action",
|
||||
startNext: "action",
|
||||
newSession: "action",
|
||||
openProject: "action",
|
||||
projectPicker: "action",
|
||||
cyclePrevProject: "action",
|
||||
cycleNextProject: "action",
|
||||
addProfile: "action",
|
||||
};
|
||||
|
||||
// Category colors
|
||||
const CATEGORY_COLORS = {
|
||||
navigation: {
|
||||
bg: "bg-blue-500/20",
|
||||
border: "border-blue-500/50",
|
||||
text: "text-blue-400",
|
||||
label: "Navigation",
|
||||
},
|
||||
ui: {
|
||||
bg: "bg-purple-500/20",
|
||||
border: "border-purple-500/50",
|
||||
text: "text-purple-400",
|
||||
label: "UI Controls",
|
||||
},
|
||||
action: {
|
||||
bg: "bg-green-500/20",
|
||||
border: "border-green-500/50",
|
||||
text: "text-green-400",
|
||||
label: "Actions",
|
||||
},
|
||||
};
|
||||
|
||||
interface KeyboardMapProps {
|
||||
onKeySelect?: (key: string) => void;
|
||||
selectedKey?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMapProps) {
|
||||
const { keyboardShortcuts } = useAppStore();
|
||||
|
||||
// Create a reverse map: base key -> list of shortcut names (including info about modifiers)
|
||||
const keyToShortcuts = React.useMemo(() => {
|
||||
const map: Record<string, Array<{ name: keyof KeyboardShortcuts; hasModifiers: boolean }>> = {};
|
||||
(Object.entries(keyboardShortcuts) as [keyof KeyboardShortcuts, string][]).forEach(
|
||||
([shortcutName, shortcutStr]) => {
|
||||
const parsed = parseShortcut(shortcutStr);
|
||||
const normalizedKey = parsed.key.toUpperCase();
|
||||
const hasModifiers = !!(parsed.shift || parsed.cmdCtrl || parsed.alt);
|
||||
if (!map[normalizedKey]) {
|
||||
map[normalizedKey] = [];
|
||||
}
|
||||
map[normalizedKey].push({ name: shortcutName, hasModifiers });
|
||||
}
|
||||
);
|
||||
return map;
|
||||
}, [keyboardShortcuts]);
|
||||
|
||||
const renderKey = (keyDef: { key: string; label: string; width: number }) => {
|
||||
const normalizedKey = keyDef.key.toUpperCase();
|
||||
const shortcutInfos = keyToShortcuts[normalizedKey] || [];
|
||||
const shortcuts = shortcutInfos.map(s => s.name);
|
||||
const isBound = shortcuts.length > 0;
|
||||
const isSelected = selectedKey?.toUpperCase() === normalizedKey;
|
||||
const isModified = shortcuts.some(
|
||||
(s) => keyboardShortcuts[s] !== DEFAULT_KEYBOARD_SHORTCUTS[s]
|
||||
);
|
||||
const hasModifierShortcuts = shortcutInfos.some(s => s.hasModifiers);
|
||||
|
||||
// Get category for coloring (use first shortcut's category if multiple)
|
||||
const category = shortcuts.length > 0 ? SHORTCUT_CATEGORIES[shortcuts[0]] : null;
|
||||
const colors = category ? CATEGORY_COLORS[category] : null;
|
||||
|
||||
const keyElement = (
|
||||
<button
|
||||
key={keyDef.key}
|
||||
onClick={() => onKeySelect?.(keyDef.key)}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center justify-center rounded-lg border transition-all",
|
||||
"h-12 min-w-[2.75rem] py-1",
|
||||
keyDef.width > 1 && `w-[${keyDef.width * 2.75}rem]`,
|
||||
// Base styles
|
||||
!isBound && "bg-sidebar-accent/10 border-sidebar-border hover:bg-sidebar-accent/20",
|
||||
// Bound key styles
|
||||
isBound && colors && `${colors.bg} ${colors.border} hover:brightness-110`,
|
||||
// Selected state
|
||||
isSelected && "ring-2 ring-brand-500 ring-offset-2 ring-offset-background",
|
||||
// Modified indicator
|
||||
isModified && "ring-1 ring-yellow-500/50"
|
||||
)}
|
||||
data-testid={`keyboard-key-${keyDef.key}`}
|
||||
>
|
||||
{/* Key label - always at top */}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-mono font-bold leading-none",
|
||||
isBound && colors ? colors.text : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{keyDef.label}
|
||||
</span>
|
||||
{/* Shortcut label - always takes up space to maintain consistent height */}
|
||||
<span
|
||||
className={cn(
|
||||
"text-[9px] leading-tight text-center px-0.5 truncate max-w-full h-3 mt-0.5",
|
||||
isBound && shortcuts.length > 0
|
||||
? (colors ? colors.text : "text-muted-foreground")
|
||||
: "opacity-0"
|
||||
)}
|
||||
>
|
||||
{isBound && shortcuts.length > 0
|
||||
? (shortcuts.length === 1
|
||||
? SHORTCUT_LABELS[shortcuts[0]].split(" ")[0]
|
||||
: `${shortcuts.length}x`)
|
||||
: "\u00A0" // Non-breaking space to maintain height
|
||||
}
|
||||
</span>
|
||||
{isModified && (
|
||||
<span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-yellow-500" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
// Wrap in tooltip if bound
|
||||
if (isBound) {
|
||||
return (
|
||||
<Tooltip key={keyDef.key}>
|
||||
<TooltipTrigger asChild>{keyElement}</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<div className="space-y-1">
|
||||
{shortcuts.map((shortcut) => {
|
||||
const shortcutStr = keyboardShortcuts[shortcut];
|
||||
const displayShortcut = formatShortcut(shortcutStr, true);
|
||||
return (
|
||||
<div key={shortcut} className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full",
|
||||
CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]].bg.replace("/20", "")
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm">{SHORTCUT_LABELS[shortcut]}</span>
|
||||
<kbd className="text-xs font-mono bg-sidebar-accent/30 px-1 rounded">
|
||||
{displayShortcut}
|
||||
</kbd>
|
||||
{keyboardShortcuts[shortcut] !== DEFAULT_KEYBOARD_SHORTCUTS[shortcut] && (
|
||||
<span className="text-xs text-yellow-400">(custom)</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return keyElement;
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className={cn("space-y-4", className)} data-testid="keyboard-map">
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-4 justify-center text-xs">
|
||||
{Object.entries(CATEGORY_COLORS).map(([key, colors]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"w-4 h-4 rounded border",
|
||||
colors.bg,
|
||||
colors.border
|
||||
)}
|
||||
/>
|
||||
<span className={colors.text}>{colors.label}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-sidebar-accent/10 border border-sidebar-border" />
|
||||
<span className="text-muted-foreground">Available</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||
<span className="text-yellow-400">Modified</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard layout */}
|
||||
<div className="flex flex-col items-center gap-1.5 p-4 rounded-xl bg-sidebar-accent/5 border border-sidebar-border">
|
||||
{KEYBOARD_ROWS.map((row, rowIndex) => (
|
||||
<div key={rowIndex} className="flex gap-1.5 justify-center">
|
||||
{row.map(renderKey)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex justify-center gap-6 text-xs text-muted-foreground">
|
||||
<span>
|
||||
<strong className="text-foreground">{Object.keys(keyboardShortcuts).length}</strong> shortcuts
|
||||
configured
|
||||
</span>
|
||||
<span>
|
||||
<strong className="text-foreground">
|
||||
{Object.keys(keyToShortcuts).length}
|
||||
</strong>{" "}
|
||||
keys in use
|
||||
</span>
|
||||
<span>
|
||||
<strong className="text-foreground">
|
||||
{KEYBOARD_ROWS.flat().length - Object.keys(keyToShortcuts).length}
|
||||
</strong>{" "}
|
||||
keys available
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Full shortcut reference panel with editing capability
|
||||
interface ShortcutReferencePanelProps {
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePanelProps) {
|
||||
const { keyboardShortcuts, setKeyboardShortcut, resetKeyboardShortcuts } = useAppStore();
|
||||
const [editingShortcut, setEditingShortcut] = React.useState<keyof KeyboardShortcuts | null>(null);
|
||||
const [keyValue, setKeyValue] = React.useState("");
|
||||
const [modifiers, setModifiers] = React.useState({ shift: false, cmdCtrl: false, alt: false });
|
||||
const [shortcutError, setShortcutError] = React.useState<string | null>(null);
|
||||
|
||||
const groupedShortcuts = React.useMemo(() => {
|
||||
const groups: Record<string, Array<{ key: keyof KeyboardShortcuts; label: string; value: string }>> = {
|
||||
navigation: [],
|
||||
ui: [],
|
||||
action: [],
|
||||
};
|
||||
|
||||
(Object.entries(SHORTCUT_CATEGORIES) as [keyof KeyboardShortcuts, string][]).forEach(
|
||||
([shortcut, category]) => {
|
||||
groups[category].push({
|
||||
key: shortcut,
|
||||
label: SHORTCUT_LABELS[shortcut],
|
||||
value: keyboardShortcuts[shortcut],
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return groups;
|
||||
}, [keyboardShortcuts]);
|
||||
|
||||
// Build the full shortcut string from key + modifiers
|
||||
const buildShortcutString = React.useCallback((key: string, mods: typeof modifiers) => {
|
||||
const parts: string[] = [];
|
||||
if (mods.cmdCtrl) parts.push(isMac ? "Cmd" : "Ctrl");
|
||||
if (mods.alt) parts.push(isMac ? "Opt" : "Alt");
|
||||
if (mods.shift) parts.push("Shift");
|
||||
parts.push(key.toUpperCase());
|
||||
return parts.join("+");
|
||||
}, []);
|
||||
|
||||
// Check for conflicts with other shortcuts
|
||||
const checkConflict = React.useCallback((shortcutStr: string, currentKey: keyof KeyboardShortcuts) => {
|
||||
const conflict = Object.entries(keyboardShortcuts).find(
|
||||
([k, v]) => k !== currentKey && v.toUpperCase() === shortcutStr.toUpperCase()
|
||||
);
|
||||
return conflict ? SHORTCUT_LABELS[conflict[0] as keyof KeyboardShortcuts] : null;
|
||||
}, [keyboardShortcuts]);
|
||||
|
||||
const handleStartEdit = (key: keyof KeyboardShortcuts) => {
|
||||
const currentValue = keyboardShortcuts[key];
|
||||
const parsed = parseShortcut(currentValue);
|
||||
setEditingShortcut(key);
|
||||
setKeyValue(parsed.key);
|
||||
setModifiers({
|
||||
shift: parsed.shift || false,
|
||||
cmdCtrl: parsed.cmdCtrl || false,
|
||||
alt: parsed.alt || false,
|
||||
});
|
||||
setShortcutError(null);
|
||||
};
|
||||
|
||||
const handleSaveShortcut = () => {
|
||||
if (!editingShortcut || shortcutError || !keyValue) return;
|
||||
const shortcutStr = buildShortcutString(keyValue, modifiers);
|
||||
setKeyboardShortcut(editingShortcut, shortcutStr);
|
||||
setEditingShortcut(null);
|
||||
setKeyValue("");
|
||||
setModifiers({ shift: false, cmdCtrl: false, alt: false });
|
||||
setShortcutError(null);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingShortcut(null);
|
||||
setKeyValue("");
|
||||
setModifiers({ shift: false, cmdCtrl: false, alt: false });
|
||||
setShortcutError(null);
|
||||
};
|
||||
|
||||
const handleKeyChange = (value: string, currentKey: keyof KeyboardShortcuts) => {
|
||||
setKeyValue(value);
|
||||
// Check for conflicts with full shortcut string
|
||||
if (!value) {
|
||||
setShortcutError("Key cannot be empty");
|
||||
} else {
|
||||
const shortcutStr = buildShortcutString(value, modifiers);
|
||||
const conflictLabel = checkConflict(shortcutStr, currentKey);
|
||||
if (conflictLabel) {
|
||||
setShortcutError(`Already used by "${conflictLabel}"`);
|
||||
} else {
|
||||
setShortcutError(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleModifierChange = (modifier: keyof typeof modifiers, checked: boolean, currentKey: keyof KeyboardShortcuts) => {
|
||||
// Enforce single modifier: when checking, uncheck all others (radio-button behavior)
|
||||
const newModifiers = checked
|
||||
? { shift: false, cmdCtrl: false, alt: false, [modifier]: true }
|
||||
: { ...modifiers, [modifier]: false };
|
||||
|
||||
setModifiers(newModifiers);
|
||||
|
||||
// Recheck for conflicts
|
||||
if (keyValue) {
|
||||
const shortcutStr = buildShortcutString(keyValue, newModifiers);
|
||||
const conflictLabel = checkConflict(shortcutStr, currentKey);
|
||||
if (conflictLabel) {
|
||||
setShortcutError(`Already used by "${conflictLabel}"`);
|
||||
} else {
|
||||
setShortcutError(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !shortcutError && keyValue) {
|
||||
handleSaveShortcut();
|
||||
} else if (e.key === "Escape") {
|
||||
handleCancelEdit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetShortcut = (key: keyof KeyboardShortcuts) => {
|
||||
setKeyboardShortcut(key, DEFAULT_KEYBOARD_SHORTCUTS[key]);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="space-y-4" data-testid="shortcut-reference-panel">
|
||||
{editable && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => resetKeyboardShortcuts()}
|
||||
className="gap-2 text-xs"
|
||||
data-testid="reset-all-shortcuts-button"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Reset All to Defaults
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(groupedShortcuts).map(([category, shortcuts]) => {
|
||||
const colors = CATEGORY_COLORS[category as keyof typeof CATEGORY_COLORS];
|
||||
return (
|
||||
<div key={category} className="space-y-2">
|
||||
<h4 className={cn("text-sm font-semibold", colors.text)}>
|
||||
{colors.label}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{shortcuts.map(({ key, label, value }) => {
|
||||
const isModified = keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key];
|
||||
const isEditing = editingShortcut === key;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
"flex items-center justify-between p-2 rounded-lg bg-sidebar-accent/10 border transition-colors",
|
||||
isEditing ? "border-brand-500" : "border-sidebar-border",
|
||||
editable && !isEditing && "hover:bg-sidebar-accent/20 cursor-pointer"
|
||||
)}
|
||||
onClick={() => editable && !isEditing && handleStartEdit(key)}
|
||||
data-testid={`shortcut-row-${key}`}
|
||||
>
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Modifier checkboxes */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id={`mod-cmd-${key}`}
|
||||
checked={modifiers.cmdCtrl}
|
||||
onCheckedChange={(checked) => handleModifierChange("cmdCtrl", !!checked, key)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<Label htmlFor={`mod-cmd-${key}`} className="text-xs text-muted-foreground cursor-pointer">
|
||||
{isMac ? "⌘" : "Ctrl"}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id={`mod-alt-${key}`}
|
||||
checked={modifiers.alt}
|
||||
onCheckedChange={(checked) => handleModifierChange("alt", !!checked, key)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<Label htmlFor={`mod-alt-${key}`} className="text-xs text-muted-foreground cursor-pointer">
|
||||
{isMac ? "⌥" : "Alt"}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id={`mod-shift-${key}`}
|
||||
checked={modifiers.shift}
|
||||
onCheckedChange={(checked) => handleModifierChange("shift", !!checked, key)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<Label htmlFor={`mod-shift-${key}`} className="text-xs text-muted-foreground cursor-pointer">
|
||||
⇧
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-muted-foreground">+</span>
|
||||
<Input
|
||||
value={keyValue}
|
||||
onChange={(e) => handleKeyChange(e.target.value, key)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"w-12 h-7 text-center font-mono text-xs uppercase",
|
||||
shortcutError && "border-red-500 focus-visible:ring-red-500"
|
||||
)}
|
||||
placeholder="Key"
|
||||
maxLength={1}
|
||||
autoFocus
|
||||
data-testid={`edit-shortcut-input-${key}`}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 hover:bg-green-500/20 hover:text-green-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSaveShortcut();
|
||||
}}
|
||||
disabled={!!shortcutError || !keyValue}
|
||||
data-testid={`save-shortcut-${key}`}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 hover:bg-red-500/20 hover:text-red-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCancelEdit();
|
||||
}}
|
||||
data-testid={`cancel-shortcut-${key}`}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<kbd
|
||||
className={cn(
|
||||
"px-2 py-1 text-xs font-mono rounded border",
|
||||
colors.bg,
|
||||
colors.border,
|
||||
colors.text
|
||||
)}
|
||||
>
|
||||
{formatShortcut(value, true)}
|
||||
</kbd>
|
||||
{isModified && editable && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 hover:bg-yellow-500/20 hover:text-yellow-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleResetShortcut(key);
|
||||
}}
|
||||
data-testid={`reset-shortcut-${key}`}
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Reset to default ({DEFAULT_KEYBOARD_SHORTCUTS[key]})
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isModified && !editable && (
|
||||
<span className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||
)}
|
||||
{editable && !isModified && (
|
||||
<Edit2 className="w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{editingShortcut && shortcutError && SHORTCUT_CATEGORIES[editingShortcut] === category && (
|
||||
<p className="text-xs text-red-400 mt-1">{shortcutError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS } from "@/store/app-store";
|
||||
import type { KeyboardShortcuts } from "@/store/app-store";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -41,9 +40,9 @@ import {
|
||||
Settings2,
|
||||
RefreshCw,
|
||||
Info,
|
||||
Keyboard,
|
||||
} from "lucide-react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -53,6 +52,8 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useSetupStore } from "@/store/setup-store";
|
||||
import { KeyboardMap, ShortcutReferencePanel } from "@/components/ui/keyboard-map";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
|
||||
// Navigation items for the side panel
|
||||
const NAV_ITEMS = [
|
||||
@@ -84,9 +85,6 @@ export function SettingsView() {
|
||||
setShowProfilesOnly,
|
||||
currentProject,
|
||||
moveProjectToTrash,
|
||||
keyboardShortcuts,
|
||||
setKeyboardShortcut,
|
||||
resetKeyboardShortcuts,
|
||||
} = useAppStore();
|
||||
|
||||
// Compute the effective theme for the current project
|
||||
@@ -108,6 +106,7 @@ export function SettingsView() {
|
||||
const [showOpenaiKey, setShowOpenaiKey] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [testingConnection, setTestingConnection] = useState(false);
|
||||
|
||||
const [testResult, setTestResult] = useState<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
@@ -155,6 +154,7 @@ export function SettingsView() {
|
||||
} | null>(null);
|
||||
const [activeSection, setActiveSection] = useState("api-keys");
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
|
||||
const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false);
|
||||
const [isCheckingCodexCli, setIsCheckingCodexCli] = useState(false);
|
||||
const [apiKeyStatus, setApiKeyStatus] = useState<{
|
||||
@@ -1608,376 +1608,35 @@ export function SettingsView() {
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Customize keyboard shortcuts for navigation and actions. Click
|
||||
on any shortcut to edit it.
|
||||
Customize keyboard shortcuts for navigation and actions using the visual keyboard map.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Navigation Shortcuts */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Navigation
|
||||
<div className="p-6">
|
||||
{/* Centered message directing to keyboard map */}
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center space-y-4">
|
||||
<div className="relative">
|
||||
<Keyboard className="w-16 h-16 text-brand-500/30" />
|
||||
<div className="absolute inset-0 bg-brand-500/10 blur-xl rounded-full" />
|
||||
</div>
|
||||
<div className="space-y-2 max-w-md">
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
Use the Visual Keyboard Map
|
||||
</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => resetKeyboardShortcuts()}
|
||||
className="text-xs h-7"
|
||||
data-testid="reset-shortcuts-button"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3 mr-1" />
|
||||
Reset All to Defaults
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ key: "board" as keyof KeyboardShortcuts, label: "Kanban Board" },
|
||||
{ key: "agent" as keyof KeyboardShortcuts, label: "Agent Runner" },
|
||||
{ key: "spec" as keyof KeyboardShortcuts, label: "Spec Editor" },
|
||||
{ key: "context" as keyof KeyboardShortcuts, label: "Context" },
|
||||
{ key: "tools" as keyof KeyboardShortcuts, label: "Agent Tools" },
|
||||
{ key: "profiles" as keyof KeyboardShortcuts, label: "AI Profiles" },
|
||||
{ key: "settings" as keyof KeyboardShortcuts, label: "Settings" },
|
||||
].map(({ key, label }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border hover:bg-sidebar-accent/20 transition-colors"
|
||||
>
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{editingShortcut === key ? (
|
||||
<>
|
||||
<Input
|
||||
value={shortcutValue}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.toUpperCase();
|
||||
setShortcutValue(value);
|
||||
// Check for conflicts
|
||||
const conflict = Object.entries(keyboardShortcuts).find(
|
||||
([k, v]) => k !== key && v.toUpperCase() === value
|
||||
);
|
||||
if (conflict) {
|
||||
setShortcutError(`Already used by ${conflict[0]}`);
|
||||
} else {
|
||||
setShortcutError(null);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !shortcutError && shortcutValue) {
|
||||
setKeyboardShortcut(key, shortcutValue);
|
||||
setEditingShortcut(null);
|
||||
setShortcutValue("");
|
||||
setShortcutError(null);
|
||||
} else if (e.key === "Escape") {
|
||||
setEditingShortcut(null);
|
||||
setShortcutValue("");
|
||||
setShortcutError(null);
|
||||
}
|
||||
}}
|
||||
className="w-24 h-8 text-center font-mono"
|
||||
placeholder="Key"
|
||||
maxLength={2}
|
||||
autoFocus
|
||||
data-testid={`edit-shortcut-${key}`}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => {
|
||||
if (!shortcutError && shortcutValue) {
|
||||
setKeyboardShortcut(key, shortcutValue);
|
||||
setEditingShortcut(null);
|
||||
setShortcutValue("");
|
||||
setShortcutError(null);
|
||||
}
|
||||
}}
|
||||
disabled={!!shortcutError || !shortcutValue}
|
||||
data-testid={`save-shortcut-${key}`}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => {
|
||||
setEditingShortcut(null);
|
||||
setShortcutValue("");
|
||||
setShortcutError(null);
|
||||
}}
|
||||
data-testid={`cancel-shortcut-${key}`}
|
||||
>
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingShortcut(key);
|
||||
setShortcutValue(keyboardShortcuts[key]);
|
||||
setShortcutError(null);
|
||||
}}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-mono rounded bg-sidebar-accent/20 border border-sidebar-border hover:bg-sidebar-accent/30 transition-colors",
|
||||
keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] &&
|
||||
"border-brand-500/50 bg-brand-500/10 text-brand-400"
|
||||
)}
|
||||
data-testid={`shortcut-${key}`}
|
||||
>
|
||||
{keyboardShortcuts[key]}
|
||||
</button>
|
||||
{keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] && (
|
||||
<span className="text-xs text-brand-400">(modified)</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{shortcutError && (
|
||||
<p className="text-xs text-red-400">{shortcutError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* UI Shortcuts */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
UI Controls
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ key: "toggleSidebar" as keyof KeyboardShortcuts, label: "Toggle Sidebar" },
|
||||
].map(({ key, label }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border hover:bg-sidebar-accent/20 transition-colors"
|
||||
>
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{editingShortcut === key ? (
|
||||
<>
|
||||
<Input
|
||||
value={shortcutValue}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setShortcutValue(value);
|
||||
// Check for conflicts
|
||||
const conflict = Object.entries(keyboardShortcuts).find(
|
||||
([k, v]) => k !== key && v === value
|
||||
);
|
||||
if (conflict) {
|
||||
setShortcutError(`Already used by ${conflict[0]}`);
|
||||
} else {
|
||||
setShortcutError(null);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !shortcutError && shortcutValue) {
|
||||
setKeyboardShortcut(key, shortcutValue);
|
||||
setEditingShortcut(null);
|
||||
setShortcutValue("");
|
||||
setShortcutError(null);
|
||||
} else if (e.key === "Escape") {
|
||||
setEditingShortcut(null);
|
||||
setShortcutValue("");
|
||||
setShortcutError(null);
|
||||
}
|
||||
}}
|
||||
className="w-24 h-8 text-center font-mono"
|
||||
placeholder="Key"
|
||||
maxLength={2}
|
||||
autoFocus
|
||||
data-testid={`edit-shortcut-${key}`}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => {
|
||||
if (!shortcutError && shortcutValue) {
|
||||
setKeyboardShortcut(key, shortcutValue);
|
||||
setEditingShortcut(null);
|
||||
setShortcutValue("");
|
||||
setShortcutError(null);
|
||||
}
|
||||
}}
|
||||
disabled={!!shortcutError || !shortcutValue}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => {
|
||||
setEditingShortcut(null);
|
||||
setShortcutValue("");
|
||||
setShortcutError(null);
|
||||
}}
|
||||
>
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingShortcut(key);
|
||||
setShortcutValue(keyboardShortcuts[key]);
|
||||
setShortcutError(null);
|
||||
}}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-mono rounded bg-sidebar-accent/20 border border-sidebar-border hover:bg-sidebar-accent/30 transition-colors",
|
||||
keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] &&
|
||||
"border-brand-500/50 bg-brand-500/10 text-brand-400"
|
||||
)}
|
||||
data-testid={`shortcut-${key}`}
|
||||
>
|
||||
{keyboardShortcuts[key]}
|
||||
</button>
|
||||
{keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] && (
|
||||
<span className="text-xs text-brand-400">(modified)</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Shortcuts */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Actions
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ key: "addFeature" as keyof KeyboardShortcuts, label: "Add Feature" },
|
||||
{ key: "addContextFile" as keyof KeyboardShortcuts, label: "Add Context File" },
|
||||
{ key: "startNext" as keyof KeyboardShortcuts, label: "Start Next Features" },
|
||||
{ key: "newSession" as keyof KeyboardShortcuts, label: "New Session" },
|
||||
{ key: "openProject" as keyof KeyboardShortcuts, label: "Open Project" },
|
||||
{ key: "projectPicker" as keyof KeyboardShortcuts, label: "Project Picker" },
|
||||
{ key: "cyclePrevProject" as keyof KeyboardShortcuts, label: "Previous Project" },
|
||||
{ key: "cycleNextProject" as keyof KeyboardShortcuts, label: "Next Project" },
|
||||
{ key: "addProfile" as keyof KeyboardShortcuts, label: "Add Profile" },
|
||||
].map(({ key, label }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border hover:bg-sidebar-accent/20 transition-colors"
|
||||
>
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{editingShortcut === key ? (
|
||||
<>
|
||||
<Input
|
||||
value={shortcutValue}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.toUpperCase();
|
||||
setShortcutValue(value);
|
||||
// Check for conflicts
|
||||
const conflict = Object.entries(keyboardShortcuts).find(
|
||||
([k, v]) => k !== key && v.toUpperCase() === value
|
||||
);
|
||||
if (conflict) {
|
||||
setShortcutError(`Already used by ${conflict[0]}`);
|
||||
} else {
|
||||
setShortcutError(null);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !shortcutError && shortcutValue) {
|
||||
setKeyboardShortcut(key, shortcutValue);
|
||||
setEditingShortcut(null);
|
||||
setShortcutValue("");
|
||||
setShortcutError(null);
|
||||
} else if (e.key === "Escape") {
|
||||
setEditingShortcut(null);
|
||||
setShortcutValue("");
|
||||
setShortcutError(null);
|
||||
}
|
||||
}}
|
||||
className="w-24 h-8 text-center font-mono"
|
||||
placeholder="Key"
|
||||
maxLength={2}
|
||||
autoFocus
|
||||
data-testid={`edit-shortcut-${key}`}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => {
|
||||
if (!shortcutError && shortcutValue) {
|
||||
setKeyboardShortcut(key, shortcutValue);
|
||||
setEditingShortcut(null);
|
||||
setShortcutValue("");
|
||||
setShortcutError(null);
|
||||
}
|
||||
}}
|
||||
disabled={!!shortcutError || !shortcutValue}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => {
|
||||
setEditingShortcut(null);
|
||||
setShortcutValue("");
|
||||
setShortcutError(null);
|
||||
}}
|
||||
>
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingShortcut(key);
|
||||
setShortcutValue(keyboardShortcuts[key]);
|
||||
setShortcutError(null);
|
||||
}}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-mono rounded bg-sidebar-accent/20 border border-sidebar-border hover:bg-sidebar-accent/30 transition-colors",
|
||||
keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] &&
|
||||
"border-brand-500/50 bg-brand-500/10 text-brand-400"
|
||||
)}
|
||||
data-testid={`shortcut-${key}`}
|
||||
>
|
||||
{keyboardShortcuts[key]}
|
||||
</button>
|
||||
{keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] && (
|
||||
<span className="text-xs text-brand-400">(modified)</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Information */}
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||
<AlertCircle className="w-5 h-5 text-blue-500 mt-0.5 shrink-0" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-blue-400">
|
||||
About Keyboard Shortcuts
|
||||
</p>
|
||||
<p className="text-blue-400/80 text-xs mt-1">
|
||||
Shortcuts won't trigger when typing in input fields. Use
|
||||
single keys (A-Z, 0-9) or special keys like ` (backtick).
|
||||
Changes take effect immediately.
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click the "View Keyboard Map" button above to customize your keyboard shortcuts.
|
||||
The visual interface shows all available keys and lets you easily edit shortcuts with
|
||||
single-modifier restrictions.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="lg"
|
||||
onClick={() => setShowKeyboardMapDialog(true)}
|
||||
className="gap-2 mt-4"
|
||||
>
|
||||
<Keyboard className="w-5 h-5" />
|
||||
Open Keyboard Map
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2169,6 +1828,44 @@ export function SettingsView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard Map Dialog */}
|
||||
<Dialog open={showKeyboardMapDialog} onOpenChange={setShowKeyboardMapDialog}>
|
||||
<DialogContent className="bg-popover border-border max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Keyboard className="w-5 h-5 text-brand-500" />
|
||||
Keyboard Shortcut Map
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Visual overview of all keyboard shortcuts. Keys in color are bound to shortcuts.
|
||||
Click on any shortcut below to edit it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-6 py-4">
|
||||
{/* Visual Keyboard Map */}
|
||||
<KeyboardMap />
|
||||
|
||||
{/* Shortcut Reference - Editable */}
|
||||
<div className="border-t border-border pt-4">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-4">
|
||||
All Shortcuts Reference (Click to Edit)
|
||||
</h3>
|
||||
<ShortcutReferencePanel editable />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowKeyboardMapDialog(false)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Project Confirmation Dialog */}
|
||||
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<DialogContent className="bg-popover border-border max-w-md">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useAppStore, parseShortcut } from "@/store/app-store";
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
key: string;
|
||||
key: string; // Can be simple "K" or with modifiers "Shift+N", "Cmd+K"
|
||||
action: () => void;
|
||||
description?: string;
|
||||
}
|
||||
@@ -59,9 +59,44 @@ function isInputFocused(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a keyboard event matches a shortcut definition
|
||||
*/
|
||||
function matchesShortcut(event: KeyboardEvent, shortcutStr: string): boolean {
|
||||
const shortcut = parseShortcut(shortcutStr);
|
||||
|
||||
// Check if the key matches (case-insensitive)
|
||||
if (event.key.toLowerCase() !== shortcut.key.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check modifier keys
|
||||
const cmdCtrlPressed = event.metaKey || event.ctrlKey;
|
||||
const shiftPressed = event.shiftKey;
|
||||
const altPressed = event.altKey;
|
||||
|
||||
// If shortcut requires cmdCtrl, it must be pressed
|
||||
if (shortcut.cmdCtrl && !cmdCtrlPressed) return false;
|
||||
// If shortcut doesn't require cmdCtrl, it shouldn't be pressed
|
||||
if (!shortcut.cmdCtrl && cmdCtrlPressed) return false;
|
||||
|
||||
// If shortcut requires shift, it must be pressed
|
||||
if (shortcut.shift && !shiftPressed) return false;
|
||||
// If shortcut doesn't require shift, it shouldn't be pressed
|
||||
if (!shortcut.shift && shiftPressed) return false;
|
||||
|
||||
// If shortcut requires alt, it must be pressed
|
||||
if (shortcut.alt && !altPressed) return false;
|
||||
// If shortcut doesn't require alt, it shouldn't be pressed
|
||||
if (!shortcut.alt && altPressed) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage keyboard shortcuts
|
||||
* Shortcuts won't fire when user is typing in inputs, textareas, or when dialogs are open
|
||||
* Supports modifier keys: Shift, Cmd/Ctrl, Alt/Option
|
||||
*/
|
||||
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
|
||||
const handleKeyDown = useCallback(
|
||||
@@ -71,14 +106,9 @@ export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't trigger if any modifier keys are pressed (except for specific combos we want)
|
||||
if (event.ctrlKey || event.altKey || event.metaKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find matching shortcut
|
||||
const matchingShortcut = shortcuts.find(
|
||||
(shortcut) => shortcut.key.toLowerCase() === event.key.toLowerCase()
|
||||
(shortcut) => matchesShortcut(event, shortcut.key)
|
||||
);
|
||||
|
||||
if (matchingShortcut) {
|
||||
|
||||
@@ -37,7 +37,68 @@ export interface ApiKeys {
|
||||
openai: string;
|
||||
}
|
||||
|
||||
// Keyboard Shortcuts
|
||||
// Keyboard Shortcut with optional modifiers
|
||||
export interface ShortcutKey {
|
||||
key: string; // The main key (e.g., "K", "N", "1")
|
||||
shift?: boolean; // Shift key modifier
|
||||
cmdCtrl?: boolean; // Cmd on Mac, Ctrl on Windows/Linux
|
||||
alt?: boolean; // Alt/Option key modifier
|
||||
}
|
||||
|
||||
// Helper to parse shortcut string to ShortcutKey object
|
||||
export function parseShortcut(shortcut: string): ShortcutKey {
|
||||
const parts = shortcut.split("+").map(p => p.trim());
|
||||
const result: ShortcutKey = { key: parts[parts.length - 1] };
|
||||
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const modifier = parts[i].toLowerCase();
|
||||
if (modifier === "shift") result.shift = true;
|
||||
else if (modifier === "cmd" || modifier === "ctrl" || modifier === "win" || modifier === "super" || modifier === "⌘" || modifier === "^" || modifier === "⊞" || modifier === "◆") result.cmdCtrl = true;
|
||||
else if (modifier === "alt" || modifier === "opt" || modifier === "option" || modifier === "⌥") result.alt = true;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Helper to format ShortcutKey to display string
|
||||
export function formatShortcut(shortcut: string, forDisplay = false): string {
|
||||
const parsed = parseShortcut(shortcut);
|
||||
const parts: string[] = [];
|
||||
|
||||
// Improved OS detection
|
||||
let platform: 'darwin' | 'win32' | 'linux' = 'linux';
|
||||
if (typeof navigator !== 'undefined') {
|
||||
const p = navigator.platform.toLowerCase();
|
||||
if (p.includes('mac')) platform = 'darwin';
|
||||
else if (p.includes('win')) platform = 'win32';
|
||||
}
|
||||
|
||||
// Primary modifier - OS-specific
|
||||
if (parsed.cmdCtrl) {
|
||||
if (forDisplay) {
|
||||
parts.push(platform === 'darwin' ? '⌘' : platform === 'win32' ? '⊞' : '◆');
|
||||
} else {
|
||||
parts.push(platform === 'darwin' ? 'Cmd' : platform === 'win32' ? 'Win' : 'Super');
|
||||
}
|
||||
}
|
||||
|
||||
// Alt/Option
|
||||
if (parsed.alt) {
|
||||
parts.push(forDisplay ? (platform === 'darwin' ? '⌥' : 'Alt') : (platform === 'darwin' ? 'Opt' : 'Alt'));
|
||||
}
|
||||
|
||||
// Shift
|
||||
if (parsed.shift) {
|
||||
parts.push(forDisplay ? '⇧' : 'Shift');
|
||||
}
|
||||
|
||||
parts.push(parsed.key.toUpperCase());
|
||||
|
||||
// Add spacing when displaying symbols
|
||||
return parts.join(forDisplay ? " " : "+");
|
||||
}
|
||||
|
||||
// Keyboard Shortcuts - stored as strings like "K", "Shift+N", "Cmd+K"
|
||||
export interface KeyboardShortcuts {
|
||||
// Navigation shortcuts
|
||||
board: string;
|
||||
@@ -47,10 +108,10 @@ export interface KeyboardShortcuts {
|
||||
tools: string;
|
||||
settings: string;
|
||||
profiles: string;
|
||||
|
||||
|
||||
// UI shortcuts
|
||||
toggleSidebar: string;
|
||||
|
||||
|
||||
// Action shortcuts
|
||||
addFeature: string;
|
||||
addContextFile: string;
|
||||
|
||||
Reference in New Issue
Block a user