Redesign shortcuts settings UX (#4554)
- Remove the Groups nav rail; filter shortcuts by search + status only - Fold keybindings.json path into the subsection subtitle and move the Edit File in Orca action into the header - Replace the floating hover-card action popover with an inline recorder: keycaps (or an Add shortcut placeholder) click to record in place, and Reset/Disable reveal on row hover/focus - Fix a grid align-content stretch bug that opened a large gap between shortcut groups in the scroll area Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
0f2b918773
commit
7834d3d297
|
|
@ -126,84 +126,54 @@ export function KeybindingsFileActions(): React.JSX.Element {
|
|||
return (
|
||||
<div
|
||||
ref={setActionsRootNode}
|
||||
className="space-y-2 rounded-md border border-border bg-card px-3 py-2 text-card-foreground shadow-xs"
|
||||
className="inline-flex shrink-0 overflow-hidden rounded-md border border-border bg-background shadow-xs"
|
||||
>
|
||||
<div className="grid w-full grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<p className="shrink-0 text-xs font-medium">Keybindings JSON</p>
|
||||
</div>
|
||||
<p className="truncate font-mono text-[11px] leading-4 text-muted-foreground">
|
||||
{keybindingSnapshot?.path ?? '~/.orca/keybindings.json'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-1.5 sm:justify-end">
|
||||
<div className="inline-flex overflow-hidden rounded-md border border-border bg-background shadow-xs">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="rounded-none border-0 shadow-none"
|
||||
onClick={() => void editKeybindingsInOrca()}
|
||||
>
|
||||
<FileText className="size-3" />
|
||||
Edit File in Orca
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="rounded-none border-l border-border"
|
||||
aria-label="Open keybindings file menu"
|
||||
>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onSelect={() => void openKeybindingsFile()}>
|
||||
<ExternalLink className="size-3.5" />
|
||||
Open with Default App
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void openKeybindingsInExternalEditor('code')}>
|
||||
<Code2 className="size-3.5" />
|
||||
Open in VS Code
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void openKeybindingsInExternalEditor('cursor')}>
|
||||
<Code2 className="size-3.5" />
|
||||
Open in Cursor
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => void revealKeybindingsFile()}>
|
||||
<FolderOpen className="size-3.5" />
|
||||
Reveal in File Manager
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void reloadKeybindings()}>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Reload from Disk
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{keybindingSnapshot?.diagnostics.length ? (
|
||||
<div className="space-y-1 border-t border-border/50 pt-2">
|
||||
{keybindingSnapshot.diagnostics.map((diagnostic, index) => (
|
||||
<p
|
||||
key={`${diagnostic.section ?? 'root'}-${diagnostic.actionId ?? index}`}
|
||||
className={
|
||||
diagnostic.severity === 'error'
|
||||
? 'text-xs text-destructive'
|
||||
: 'text-xs text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{diagnostic.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="rounded-none border-0 shadow-none"
|
||||
onClick={() => void editKeybindingsInOrca()}
|
||||
>
|
||||
<FileText className="size-3" />
|
||||
Edit File in Orca
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="rounded-none border-l border-border"
|
||||
aria-label="Open keybindings file menu"
|
||||
>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => void openKeybindingsFile()}>
|
||||
<ExternalLink className="size-3.5" />
|
||||
Open with Default App
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void openKeybindingsInExternalEditor('code')}>
|
||||
<Code2 className="size-3.5" />
|
||||
Open in VS Code
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void openKeybindingsInExternalEditor('cursor')}>
|
||||
<Code2 className="size-3.5" />
|
||||
Open in Cursor
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => void revealKeybindingsFile()}>
|
||||
<FolderOpen className="size-3.5" />
|
||||
Reveal in File Manager
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void reloadKeybindings()}>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Reload from Disk
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useEffect, useRef } from 'react'
|
||||
import { Ban, Keyboard, RotateCcw, Terminal } from 'lucide-react'
|
||||
import { Ban, Plus, RotateCcw, Terminal } from 'lucide-react'
|
||||
import {
|
||||
formatKeybinding,
|
||||
type KeybindingActionId,
|
||||
|
|
@ -10,7 +10,6 @@ import { cn } from '../../lib/utils'
|
|||
import { ShortcutKeyCombo } from '../ShortcutKeyCombo'
|
||||
import { Badge } from '../ui/badge'
|
||||
import { Button } from '../ui/button'
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '../ui/hover-card'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
|
||||
|
|
@ -37,29 +36,6 @@ export type ShortcutTerminalStatus = {
|
|||
description: string
|
||||
}
|
||||
|
||||
function BindingPreview({
|
||||
bindings,
|
||||
platform
|
||||
}: {
|
||||
bindings: readonly string[]
|
||||
platform: NodeJS.Platform
|
||||
}): React.JSX.Element {
|
||||
if (bindings.length === 0) {
|
||||
return (
|
||||
<span className="flex min-h-7 items-center">
|
||||
<span className="text-xs text-muted-foreground">Unassigned</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="flex min-h-7 flex-wrap items-center justify-start gap-1.5">
|
||||
{bindings.map((binding) => (
|
||||
<ShortcutKeyCombo key={binding} keys={formatKeybinding(binding, platform)} />
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function ShortcutBindingRow({
|
||||
item,
|
||||
groupTitle,
|
||||
|
|
@ -90,6 +66,7 @@ export function ShortcutBindingRow({
|
|||
const statusMessage = error ?? (warnings.length > 0 ? warnings.join(' ') : '')
|
||||
const recordingMessage = recording ? 'Listening for shortcut. Esc cancels recording.' : ''
|
||||
const helperMessage = statusMessage || recordingMessage
|
||||
const hasBinding = effective.length > 0
|
||||
|
||||
const handleRecordKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>): void => {
|
||||
if (!recording) {
|
||||
|
|
@ -120,108 +97,91 @@ export function ShortcutBindingRow({
|
|||
})
|
||||
}
|
||||
|
||||
// Why: the recorder is the row's primary control — clicking the keys (or the
|
||||
// "Add shortcut" placeholder) records a new binding in place, so the whole
|
||||
// affordance lives inline rather than in a detached popover.
|
||||
const recorderLabel = recording
|
||||
? `Press shortcut keys for ${item.title}. Escape cancels.`
|
||||
: hasBinding
|
||||
? `Change shortcut for ${item.title}`
|
||||
: `Add shortcut for ${item.title}`
|
||||
|
||||
return (
|
||||
<SearchableSetting
|
||||
title={item.title}
|
||||
description={`${groupTitle} shortcut`}
|
||||
keywords={[...item.searchKeywords]}
|
||||
className="group relative grid min-h-[54px] max-w-none grid-cols-1 gap-x-3 rounded-md px-2 py-1.5 transition-colors hover:bg-accent/40 lg:grid-cols-[minmax(0,1fr)_minmax(12rem,auto)] lg:grid-rows-[minmax(1.75rem,auto)_1rem] lg:items-start"
|
||||
className="group/shortcut relative flex min-h-[44px] max-w-none items-center gap-3 rounded-md px-2 py-1.5 transition-colors hover:bg-accent/40 focus-within:bg-accent/40"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 lg:col-start-1 lg:row-start-1 lg:self-center">
|
||||
<span className="truncate text-sm text-foreground">{item.title}</span>
|
||||
{modified ? (
|
||||
<Badge variant="outline" className="shrink-0 text-[11px]">
|
||||
Modified
|
||||
</Badge>
|
||||
) : null}
|
||||
{terminalStatus ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 gap-1 border-border/70 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<Terminal className="size-3" />
|
||||
{terminalStatus.label}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{terminalStatus.description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'h-[16px] overflow-hidden text-[11px] leading-4 lg:col-start-1 lg:row-start-2',
|
||||
error ? 'text-destructive' : 'text-muted-foreground'
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
{helperMessage ? <span className="block truncate">{helperMessage}</span> : null}
|
||||
</div>
|
||||
|
||||
<HoverCard openDelay={0} closeDelay={80}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Shortcut actions for ${item.title}`}
|
||||
className="mt-1 flex min-w-0 items-center rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 lg:col-start-2 lg:row-start-1 lg:mt-0 lg:self-center lg:justify-self-end"
|
||||
>
|
||||
<BindingPreview bindings={effective} platform={platform} />
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
side="right"
|
||||
align="center"
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
className="w-auto max-w-[min(22rem,calc(100vw-2rem))] p-1"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm text-foreground">{item.title}</span>
|
||||
{modified ? (
|
||||
<Badge variant="outline" className="shrink-0 text-[11px]">
|
||||
Modified
|
||||
</Badge>
|
||||
) : null}
|
||||
{terminalStatus ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
ref={recordButtonRef}
|
||||
type="button"
|
||||
variant={recording ? 'secondary' : 'ghost'}
|
||||
size="icon-sm"
|
||||
aria-label={
|
||||
recording
|
||||
? `Press shortcut keys for ${item.title}. Escape cancels.`
|
||||
: `Change shortcut for ${item.title}`
|
||||
}
|
||||
aria-invalid={Boolean(error)}
|
||||
aria-pressed={recording}
|
||||
data-shortcut-recorder=""
|
||||
data-shortcut-recorder-active={recording ? '' : undefined}
|
||||
onClick={() => {
|
||||
if (recording) {
|
||||
return
|
||||
}
|
||||
onStartRecording(item.id)
|
||||
}}
|
||||
onKeyDown={handleRecordKeyDown}
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
recording &&
|
||||
'border border-ring bg-accent text-accent-foreground ring-[3px] ring-ring/30'
|
||||
)}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 gap-1 border-border/70 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<Keyboard className="size-3.5" />
|
||||
</Button>
|
||||
<Terminal className="size-3" />
|
||||
{terminalStatus.label}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{recording ? 'Listening for shortcut' : 'Change shortcut'}
|
||||
{terminalStatus.description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
{helperMessage ? (
|
||||
<span
|
||||
className={cn(
|
||||
'block truncate text-[11px] leading-4',
|
||||
error ? 'text-destructive' : 'text-muted-foreground'
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
{helperMessage}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{/* Reset/Disable reveal on hover or keyboard focus to keep the row calm;
|
||||
they stay reachable via focus-within for keyboard users. */}
|
||||
{hasBinding ? (
|
||||
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover/shortcut:opacity-100 group-focus-within/shortcut:opacity-100">
|
||||
{modified ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label={`Reset ${item.title} to default`}
|
||||
onClick={() => onReset(item.id)}
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
Reset to default
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
aria-label={`Disable ${item.title}`}
|
||||
onClick={() => onDisable(item.id)}
|
||||
>
|
||||
|
|
@ -229,28 +189,58 @@ export function ShortcutBindingRow({
|
|||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
Disable
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={`Reset ${item.title}`}
|
||||
onClick={() => onReset(item.id)}
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
Reset
|
||||
Disable shortcut
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : null}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
ref={recordButtonRef}
|
||||
type="button"
|
||||
aria-label={recorderLabel}
|
||||
aria-invalid={Boolean(error)}
|
||||
aria-pressed={recording}
|
||||
data-shortcut-recorder=""
|
||||
data-shortcut-recorder-active={recording ? '' : undefined}
|
||||
onClick={() => {
|
||||
if (!recording) {
|
||||
onStartRecording(item.id)
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleRecordKeyDown}
|
||||
className={cn(
|
||||
'flex min-h-7 min-w-[5.5rem] items-center justify-center gap-1.5 rounded-md border px-2 py-1 text-xs outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
||||
recording
|
||||
? 'border-ring bg-accent text-accent-foreground ring-[3px] ring-ring/30'
|
||||
: hasBinding
|
||||
? 'border-transparent hover:border-border/70 hover:bg-background'
|
||||
: 'border-dashed border-border/70 text-muted-foreground hover:border-border hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{recording ? (
|
||||
<span className="px-1 text-muted-foreground">Press keys…</span>
|
||||
) : hasBinding ? (
|
||||
<span className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
{effective.map((binding) => (
|
||||
<ShortcutKeyCombo key={binding} keys={formatKeybinding(binding, platform)} />
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<Plus className="size-3" />
|
||||
Add shortcut
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{recording ? 'Listening for shortcut' : hasBinding ? 'Change shortcut' : 'Add shortcut'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</SearchableSetting>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,12 +23,6 @@ export type ShortcutRowsByGroup = {
|
|||
rows: ShortcutRowModel[]
|
||||
}
|
||||
|
||||
export type ShortcutGroupSummary = {
|
||||
id: string
|
||||
label: string
|
||||
count: number
|
||||
}
|
||||
|
||||
const SHORTCUT_FILTER_LABELS: Record<ShortcutFilter, string> = {
|
||||
all: 'All',
|
||||
modified: 'Modified',
|
||||
|
|
@ -80,10 +74,7 @@ export function ShortcutFilterRail({
|
|||
onQueryChange,
|
||||
filter,
|
||||
onFilterChange,
|
||||
activeGroup,
|
||||
onActiveGroupChange,
|
||||
filterCounts,
|
||||
groupSummaries,
|
||||
visibleCount,
|
||||
totalCount
|
||||
}: {
|
||||
|
|
@ -91,10 +82,7 @@ export function ShortcutFilterRail({
|
|||
onQueryChange: (value: string) => void
|
||||
filter: ShortcutFilter
|
||||
onFilterChange: (value: ShortcutFilter) => void
|
||||
activeGroup: string
|
||||
onActiveGroupChange: (value: string) => void
|
||||
filterCounts: Record<ShortcutFilter, number>
|
||||
groupSummaries: ShortcutGroupSummary[]
|
||||
visibleCount: number
|
||||
totalCount: number
|
||||
}): React.JSX.Element {
|
||||
|
|
@ -162,34 +150,6 @@ export function ShortcutFilterRail({
|
|||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<nav
|
||||
aria-label="Shortcut groups"
|
||||
className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1 scrollbar-sleek"
|
||||
>
|
||||
<p className="text-[11px] font-semibold tracking-[0.05em] text-muted-foreground uppercase">
|
||||
Groups
|
||||
</p>
|
||||
<div className="grid gap-1">
|
||||
{groupSummaries.map((group) => (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
onClick={() => onActiveGroupChange(group.id)}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
||||
activeGroup === group.id
|
||||
? 'bg-accent font-medium text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground',
|
||||
group.count === 0 && activeGroup !== group.id ? 'opacity-55' : ''
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{group.label}</span>
|
||||
<span className="text-[11px] tabular-nums opacity-80">{group.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export function ShortcutRowsList({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-8', className)}>
|
||||
<div className={cn('flex flex-col gap-8', className)}>
|
||||
{groups.map((group) => (
|
||||
<div key={group.title} className="space-y-3">
|
||||
<h3 className="border-b border-border/50 pb-2 text-sm font-medium text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import {
|
|||
matchesShortcutLocalSearch,
|
||||
ShortcutFilterRail,
|
||||
type ShortcutFilter,
|
||||
type ShortcutGroupSummary,
|
||||
type ShortcutRowsByGroup
|
||||
} from './ShortcutFilterRail'
|
||||
import { ShortcutRowsList } from './ShortcutRowsList'
|
||||
|
|
@ -137,7 +136,6 @@ export function ShortcutsPane(): React.JSX.Element {
|
|||
const [recordingActionId, setRecordingActionId] = useState<KeybindingActionId | null>(null)
|
||||
const [shortcutQuery, setShortcutQuery] = useState('')
|
||||
const [shortcutFilter, setShortcutFilter] = useState<ShortcutFilter>('all')
|
||||
const [activeShortcutGroup, setActiveShortcutGroup] = useState<string>('all')
|
||||
|
||||
const groups = useMemo(groupDefinitions, [])
|
||||
const conflictByAction = useMemo(() => {
|
||||
|
|
@ -192,29 +190,11 @@ export function ShortcutsPane(): React.JSX.Element {
|
|||
unassigned: baseVisibleRows.filter((row) => row.effective.length === 0).length,
|
||||
conflicts: baseVisibleRows.filter((row) => row.warnings.length > 0).length
|
||||
}
|
||||
const groupSummaries: ShortcutGroupSummary[] = [
|
||||
{
|
||||
id: 'all',
|
||||
label: 'All shortcuts',
|
||||
count: baseVisibleRows.filter((row) => matchesShortcutFilter(row, shortcutFilter)).length
|
||||
},
|
||||
...shortcutGroups.map((group) => ({
|
||||
id: group.title,
|
||||
label: group.title,
|
||||
count: group.rows.filter(
|
||||
(row) =>
|
||||
matchesSettingsSearch(searchQuery, getShortcutSearchEntry(row)) &&
|
||||
matchesShortcutLocalSearch(row, shortcutSearchQuery, platform) &&
|
||||
matchesShortcutFilter(row, shortcutFilter)
|
||||
).length
|
||||
}))
|
||||
]
|
||||
const visibleShortcutGroups = shortcutGroups
|
||||
.map((group) => ({
|
||||
title: group.title,
|
||||
rows: group.rows.filter(
|
||||
(row) =>
|
||||
(activeShortcutGroup === 'all' || row.groupTitle === activeShortcutGroup) &&
|
||||
matchesSettingsSearch(searchQuery, getShortcutSearchEntry(row)) &&
|
||||
matchesShortcutLocalSearch(row, shortcutSearchQuery, platform) &&
|
||||
matchesShortcutFilter(row, shortcutFilter)
|
||||
|
|
@ -342,59 +322,78 @@ export function ShortcutsPane(): React.JSX.Element {
|
|||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-6 overflow-hidden">
|
||||
<section className="flex min-h-0 flex-1 flex-col space-y-3">
|
||||
{showPolicy ? (
|
||||
<ShortcutTerminalPolicyControl
|
||||
terminalShortcutPolicy={terminalShortcutPolicy}
|
||||
keywords={TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY.keywords}
|
||||
updateSettings={updateSettings}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SettingsSubsectionHeader
|
||||
title="Keyboard Shortcuts"
|
||||
description="Customize shortcuts visually or edit the file directly."
|
||||
description={
|
||||
<>
|
||||
Customize shortcuts visually or edit{' '}
|
||||
<span className="font-mono text-[11px]">
|
||||
{keybindingSnapshot?.path ?? '~/.orca/keybindings.json'}
|
||||
</span>{' '}
|
||||
directly.
|
||||
</>
|
||||
}
|
||||
action={<KeybindingsFileActions />}
|
||||
/>
|
||||
|
||||
{keybindingSnapshot?.diagnostics.length ? (
|
||||
<div className="space-y-1">
|
||||
{keybindingSnapshot.diagnostics.map((diagnostic, index) => (
|
||||
<p
|
||||
key={`${diagnostic.section ?? 'root'}-${diagnostic.actionId ?? index}`}
|
||||
className={
|
||||
diagnostic.severity === 'error'
|
||||
? 'text-xs text-destructive'
|
||||
: 'text-xs text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{diagnostic.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-6 xl:grid-cols-[16rem_minmax(0,1fr)]">
|
||||
<ShortcutFilterRail
|
||||
query={shortcutQuery}
|
||||
onQueryChange={setShortcutQuery}
|
||||
filter={shortcutFilter}
|
||||
onFilterChange={setShortcutFilter}
|
||||
activeGroup={activeShortcutGroup}
|
||||
onActiveGroupChange={setActiveShortcutGroup}
|
||||
filterCounts={filterCounts}
|
||||
groupSummaries={groupSummaries}
|
||||
visibleCount={visibleShortcutCount}
|
||||
totalCount={shortcutRows.length}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-col gap-5">
|
||||
{showPolicy ? (
|
||||
<ShortcutTerminalPolicyControl
|
||||
terminalShortcutPolicy={terminalShortcutPolicy}
|
||||
keywords={TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY.keywords}
|
||||
updateSettings={updateSettings}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<KeybindingsFileActions />
|
||||
|
||||
<ShortcutRowsList
|
||||
className="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-sleek"
|
||||
groups={visibleShortcutGroups}
|
||||
platform={platform}
|
||||
errors={errors}
|
||||
recordingActionId={recordingActionId}
|
||||
onStartRecording={(actionId) => {
|
||||
setRecordingActionId(actionId)
|
||||
clearError(actionId)
|
||||
}}
|
||||
onCancelRecording={() => setRecordingActionId(null)}
|
||||
onCapture={(actionId, input) => void captureBinding(actionId, input)}
|
||||
onClearError={clearError}
|
||||
onDisable={(actionId) => {
|
||||
clearRecordingForAction(actionId)
|
||||
void disableBinding(actionId)
|
||||
}}
|
||||
onReset={(actionId) => {
|
||||
clearRecordingForAction(actionId)
|
||||
void resetBinding(actionId)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ShortcutRowsList
|
||||
className="min-h-0 min-w-0 flex-1 overflow-y-auto pr-1 scrollbar-sleek"
|
||||
groups={visibleShortcutGroups}
|
||||
platform={platform}
|
||||
errors={errors}
|
||||
recordingActionId={recordingActionId}
|
||||
onStartRecording={(actionId) => {
|
||||
setRecordingActionId(actionId)
|
||||
clearError(actionId)
|
||||
}}
|
||||
onCancelRecording={() => setRecordingActionId(null)}
|
||||
onCapture={(actionId, input) => void captureBinding(actionId, input)}
|
||||
onClearError={clearError}
|
||||
onDisable={(actionId) => {
|
||||
clearRecordingForAction(actionId)
|
||||
void disableBinding(actionId)
|
||||
}}
|
||||
onReset={(actionId) => {
|
||||
clearRecordingForAction(actionId)
|
||||
void resetBinding(actionId)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue