mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
- Add 300ms cooldown to prevent rapid terminal creation when holding keys - Merge DEFAULT_KEYBOARD_SHORTCUTS with user shortcuts so terminal shortcuts (Alt+D, Alt+Shift+D, Alt+W) show in keyboard layout - Fix keyboard map to handle undefined shortcuts from old persisted state 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
661 lines
25 KiB
TypeScript
661 lines
25 KiB
TypeScript
"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",
|
|
settings: "Settings",
|
|
profiles: "AI Profiles",
|
|
terminal: "Terminal",
|
|
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",
|
|
splitTerminalRight: "Split Right",
|
|
splitTerminalDown: "Split Down",
|
|
closeTerminal: "Close Terminal",
|
|
};
|
|
|
|
// Categorize shortcuts for color coding
|
|
const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, "navigation" | "ui" | "action"> = {
|
|
board: "navigation",
|
|
agent: "navigation",
|
|
spec: "navigation",
|
|
context: "navigation",
|
|
settings: "navigation",
|
|
profiles: "navigation",
|
|
terminal: "navigation",
|
|
toggleSidebar: "ui",
|
|
addFeature: "action",
|
|
addContextFile: "action",
|
|
startNext: "action",
|
|
newSession: "action",
|
|
openProject: "action",
|
|
projectPicker: "action",
|
|
cyclePrevProject: "action",
|
|
cycleNextProject: "action",
|
|
addProfile: "action",
|
|
splitTerminalRight: "action",
|
|
splitTerminalDown: "action",
|
|
closeTerminal: "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();
|
|
|
|
// Merge with defaults to ensure new shortcuts are always shown
|
|
const mergedShortcuts = React.useMemo(() => ({
|
|
...DEFAULT_KEYBOARD_SHORTCUTS,
|
|
...keyboardShortcuts,
|
|
}), [keyboardShortcuts]);
|
|
|
|
// 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(mergedShortcuts) as [keyof KeyboardShortcuts, string][]).forEach(
|
|
([shortcutName, shortcutStr]) => {
|
|
if (!shortcutStr) return; // Skip undefined shortcuts
|
|
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;
|
|
}, [mergedShortcuts]);
|
|
|
|
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) => mergedShortcuts[s] !== DEFAULT_KEYBOARD_SHORTCUTS[s]
|
|
);
|
|
|
|
// 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-11 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[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 = mergedShortcuts[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",
|
|
SHORTCUT_CATEGORIES[shortcut] && CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]]
|
|
? CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]].bg.replace("/20", "")
|
|
: "bg-muted-foreground"
|
|
)}
|
|
/>
|
|
<span className="text-sm">{SHORTCUT_LABELS[shortcut] ?? shortcut}</span>
|
|
<kbd className="text-xs font-mono bg-sidebar-accent/30 px-1 rounded">
|
|
{displayShortcut}
|
|
</kbd>
|
|
{mergedShortcuts[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);
|
|
|
|
// Merge with defaults to ensure new shortcuts are always shown
|
|
const mergedShortcuts = React.useMemo(() => ({
|
|
...DEFAULT_KEYBOARD_SHORTCUTS,
|
|
...keyboardShortcuts,
|
|
}), [keyboardShortcuts]);
|
|
|
|
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] ?? shortcut,
|
|
value: mergedShortcuts[shortcut],
|
|
});
|
|
}
|
|
);
|
|
|
|
return groups;
|
|
}, [mergedShortcuts]);
|
|
|
|
// 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(mergedShortcuts).find(
|
|
([k, v]) => k !== currentKey && v?.toUpperCase() === shortcutStr.toUpperCase()
|
|
);
|
|
return conflict ? (SHORTCUT_LABELS[conflict[0] as keyof KeyboardShortcuts] ?? conflict[0]) : null;
|
|
}, [mergedShortcuts]);
|
|
|
|
const handleStartEdit = (key: keyof KeyboardShortcuts) => {
|
|
const currentValue = mergedShortcuts[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 = mergedShortcuts[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>
|
|
);
|
|
}
|