mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Refine shortcut settings UX (#2787)
* Compact shortcut row actions * Redesign shortcuts settings controls * Refine shortcuts settings UX * Polish shortcut settings interactions
This commit is contained in:
@@ -22,12 +22,14 @@ import {
|
||||
GENERAL_CACHE_TIMER_SEARCH_ENTRIES,
|
||||
GENERAL_CLI_SEARCH_ENTRIES,
|
||||
GENERAL_EDITOR_SEARCH_ENTRIES,
|
||||
GENERAL_NAVIGATION_SEARCH_ENTRIES,
|
||||
GENERAL_PANE_SEARCH_ENTRIES,
|
||||
GENERAL_SUPPORT_SEARCH_ENTRIES,
|
||||
GENERAL_UPDATE_SEARCH_ENTRIES,
|
||||
GENERAL_WORKSPACE_SEARCH_ENTRIES
|
||||
} from './general-search'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { RecentTabOrderControl } from './RecentTabOrderControl'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { matchesSettingsSearch } from './settings-search'
|
||||
import {
|
||||
@@ -220,6 +222,20 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
|
||||
}
|
||||
|
||||
const visibleSections = [
|
||||
matchesSettingsSearch(searchQuery, GENERAL_NAVIGATION_SEARCH_ENTRIES) ? (
|
||||
<section key="navigation" className="space-y-4">
|
||||
<SettingsSubsectionHeader title="Navigation" />
|
||||
<RecentTabOrderControl
|
||||
ctrlTabOrderMode={settings.ctrlTabOrderMode ?? 'mru'}
|
||||
keywords={GENERAL_NAVIGATION_SEARCH_ENTRIES.flatMap((entry) => [
|
||||
entry.title,
|
||||
entry.description ?? '',
|
||||
...(entry.keywords ?? [])
|
||||
])}
|
||||
updateSettings={updateSettings}
|
||||
/>
|
||||
</section>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, GENERAL_WORKSPACE_SEARCH_ENTRIES) ? (
|
||||
<section key="workspace" className="space-y-4">
|
||||
<SettingsSubsectionHeader
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react'
|
||||
import type { CtrlTabOrderMode } from '../../../../shared/types'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { SettingsRow } from './SettingsFormControls'
|
||||
|
||||
export function RecentTabOrderControl({
|
||||
ctrlTabOrderMode,
|
||||
keywords,
|
||||
updateSettings
|
||||
}: {
|
||||
ctrlTabOrderMode: CtrlTabOrderMode
|
||||
keywords?: string[]
|
||||
updateSettings: (updates: { ctrlTabOrderMode?: CtrlTabOrderMode }) => Promise<void> | void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<SearchableSetting
|
||||
title="Tab Order"
|
||||
description="Recent or tab strip."
|
||||
keywords={keywords}
|
||||
className="max-w-none"
|
||||
>
|
||||
<SettingsRow
|
||||
label="Tab Order"
|
||||
control={
|
||||
<Select
|
||||
value={ctrlTabOrderMode}
|
||||
onValueChange={(value) =>
|
||||
void updateSettings({ ctrlTabOrderMode: value as CtrlTabOrderMode })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mru">Most recent</SelectItem>
|
||||
<SelectItem value="sequential">Tab strip order</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable max-lines */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Info } from 'lucide-react'
|
||||
import type { OrcaHooks } from '../../../../shared/types'
|
||||
import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||
@@ -43,6 +44,7 @@ import { PrivacyPane } from './PrivacyPane'
|
||||
import { SettingsSidebar } from './SettingsSidebar'
|
||||
import { ActiveSettingsSectionProvider, SettingsSection } from './SettingsSection'
|
||||
import { matchesSettingsSearch } from './settings-search'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client'
|
||||
import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities'
|
||||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
@@ -73,6 +75,9 @@ const SETTINGS_NAV_GROUPS = [
|
||||
{ id: 'experimental', title: 'Experimental' }
|
||||
] as const
|
||||
|
||||
const SHORTCUTS_ESCAPE_CONFIRM_TOAST_ID = 'shortcuts-escape-confirm'
|
||||
const SHORTCUTS_ESCAPE_CONFIRM_WINDOW_MS = 2200
|
||||
|
||||
function getSettingsSectionId(pane: SettingsNavTarget, repoId: string | null): string {
|
||||
if (pane === 'repo' && repoId) {
|
||||
return `repo-${repoId}`
|
||||
@@ -195,6 +200,7 @@ function Settings(): React.JSX.Element {
|
||||
const pendingScrollTargetRef = useRef<string | null>(null)
|
||||
const repoHooksRequestSeqRef = useRef(0)
|
||||
const repoHooksRuntimeIdentityRef = useRef<string>('local')
|
||||
const shortcutsEscapeConfirmUntilRef = useRef(0)
|
||||
|
||||
const confirmDiscardCommitPromptChanges = useCallback(async (): Promise<boolean> => {
|
||||
if (!hasUnsavedCommitPromptChanges) {
|
||||
@@ -263,12 +269,29 @@ function Settings(): React.JSX.Element {
|
||||
if (isEditableTarget(event.target)) {
|
||||
return
|
||||
}
|
||||
if (activeSectionId === 'shortcuts') {
|
||||
event.preventDefault()
|
||||
const now = Date.now()
|
||||
if (now <= shortcutsEscapeConfirmUntilRef.current) {
|
||||
shortcutsEscapeConfirmUntilRef.current = 0
|
||||
toast.dismiss(SHORTCUTS_ESCAPE_CONFIRM_TOAST_ID)
|
||||
void closeSettingsPageWithPromptGuard()
|
||||
return
|
||||
}
|
||||
shortcutsEscapeConfirmUntilRef.current = now + SHORTCUTS_ESCAPE_CONFIRM_WINDOW_MS
|
||||
toast.info('Press ESC again to exit settings', {
|
||||
id: SHORTCUTS_ESCAPE_CONFIRM_TOAST_ID,
|
||||
duration: SHORTCUTS_ESCAPE_CONFIRM_WINDOW_MS,
|
||||
className: 'whitespace-nowrap'
|
||||
})
|
||||
return
|
||||
}
|
||||
void closeSettingsPageWithPromptGuard()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [closeSettingsPageWithPromptGuard])
|
||||
}, [activeSectionId, closeSettingsPageWithPromptGuard])
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent): void => {
|
||||
@@ -655,6 +678,8 @@ function Settings(): React.JSX.Element {
|
||||
return { ...section, badgeColor: repo?.badgeColor, isRemote: !!repo?.connectionId }
|
||||
})
|
||||
const isSectionMounted = (sectionId: string): boolean => neededSectionIds.has(sectionId)
|
||||
const isFocusedShortcutsPane =
|
||||
activeSectionId === 'shortcuts' && settingsSearchQuery.trim() === ''
|
||||
|
||||
return (
|
||||
<div className="settings-view-shell flex min-h-0 flex-1 overflow-hidden bg-background">
|
||||
@@ -671,8 +696,19 @@ function Settings(): React.JSX.Element {
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div ref={contentScrollRef} className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
|
||||
<div className="flex w-full max-w-4xl flex-col gap-10 px-8 pb-24 pt-10">
|
||||
<div
|
||||
ref={contentScrollRef}
|
||||
className={cn(
|
||||
'min-h-0 flex-1',
|
||||
isFocusedShortcutsPane ? 'overflow-hidden' : 'overflow-y-auto scrollbar-sleek'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full max-w-4xl flex-col gap-10 px-8 pt-10',
|
||||
isFocusedShortcutsPane ? 'h-full pb-6' : 'pb-24'
|
||||
)}
|
||||
>
|
||||
{visibleNavSections.length === 0 ? (
|
||||
<div className="flex min-h-[24rem] items-center justify-center rounded-2xl border border-dashed border-border/60 bg-card/30 text-sm text-muted-foreground">
|
||||
No settings found for "{settingsSearchQuery.trim()}"
|
||||
@@ -877,6 +913,14 @@ function Settings(): React.JSX.Element {
|
||||
title="Shortcuts"
|
||||
description="Keyboard shortcuts for common actions."
|
||||
searchEntries={getSectionSearchEntries('shortcuts')}
|
||||
className={
|
||||
isFocusedShortcutsPane
|
||||
? 'flex min-h-0 flex-1 flex-col space-y-0 gap-6'
|
||||
: undefined
|
||||
}
|
||||
bodyClassName={
|
||||
isFocusedShortcutsPane ? 'min-h-0 flex-1 overflow-hidden' : undefined
|
||||
}
|
||||
>
|
||||
{isSectionMounted('shortcuts') ? <ShortcutsPane /> : null}
|
||||
</SettingsSection>
|
||||
|
||||
@@ -18,6 +18,7 @@ type SettingsSectionProps = {
|
||||
searchEntries?: SettingsSearchEntry[]
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
bodyClassName?: string
|
||||
badge?: string
|
||||
badgeAccessory?: React.ReactNode
|
||||
forceVisible?: boolean
|
||||
@@ -39,6 +40,7 @@ export function SettingsSection({
|
||||
searchEntries,
|
||||
children,
|
||||
className,
|
||||
bodyClassName,
|
||||
badge,
|
||||
badgeAccessory,
|
||||
forceVisible = false,
|
||||
@@ -80,7 +82,12 @@ export function SettingsSection({
|
||||
{/* Why: body content sits in a visually distinct band — a soft card with
|
||||
rounded corners and tight inner padding — so each row group reads as
|
||||
contained inside the section, not as a continuation of the sidebar. */}
|
||||
<div className="rounded-xl border border-border/40 bg-card/30 px-8 py-7 shadow-[0_1px_0_rgba(0,0,0,0.02)]">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-border/40 bg-card/30 px-8 py-7 shadow-[0_1px_0_rgba(0,0,0,0.02)]',
|
||||
bodyClassName
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -10,6 +10,7 @@ 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'
|
||||
|
||||
@@ -45,17 +46,17 @@ function BindingPreview({
|
||||
}): React.JSX.Element {
|
||||
if (bindings.length === 0) {
|
||||
return (
|
||||
<div className="flex min-h-7 items-center">
|
||||
<span className="flex min-h-7 items-center">
|
||||
<span className="text-xs text-muted-foreground">Unassigned</span>
|
||||
</div>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex min-h-7 flex-wrap items-center justify-start gap-1.5">
|
||||
<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)} />
|
||||
))}
|
||||
</div>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -124,109 +125,132 @@ export function ShortcutBindingRow({
|
||||
title={item.title}
|
||||
description={`${groupTitle} shortcut`}
|
||||
keywords={[...item.searchKeywords]}
|
||||
className="grid min-h-[54px] grid-cols-1 gap-x-3 rounded-md px-2 py-1.5 transition-colors hover:bg-accent/40 lg:grid-cols-[minmax(0,1.1fr)_minmax(10rem,0.8fr)_10rem_4rem] lg:items-center"
|
||||
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"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<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 ? (
|
||||
<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">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 gap-1 border-border/70 text-[11px] text-muted-foreground"
|
||||
<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'
|
||||
)}
|
||||
>
|
||||
<Terminal className="size-3" />
|
||||
{terminalStatus.label}
|
||||
</Badge>
|
||||
<Keyboard className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{terminalStatus.description}
|
||||
{recording ? 'Listening for shortcut' : 'Change shortcut'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'h-[16px] overflow-hidden text-[11px] leading-4',
|
||||
error ? 'text-destructive' : 'text-muted-foreground'
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
{helperMessage ? <span className="block truncate">{helperMessage}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 min-w-0 lg:mt-0">
|
||||
<BindingPreview bindings={effective} platform={platform} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
ref={recordButtonRef}
|
||||
type="button"
|
||||
variant={recording ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
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(
|
||||
'mt-2 h-8 w-full justify-start px-2.5 text-xs lg:mt-0 lg:w-40',
|
||||
!recording &&
|
||||
'border-border/70 bg-background/60 text-foreground/80 shadow-none hover:border-border hover:bg-accent/70 hover:text-foreground dark:text-foreground dark:shadow-xs',
|
||||
recording && 'border-ring bg-accent text-accent-foreground ring-[3px] ring-ring/30'
|
||||
)}
|
||||
>
|
||||
<Keyboard className="size-3.5" />
|
||||
<span className="truncate">{recording ? 'Press keys...' : 'Change shortcut'}</span>
|
||||
</Button>
|
||||
|
||||
<div className="mt-2 flex items-center gap-1 lg:mt-0 lg:justify-end">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={`Disable ${item.title}`}
|
||||
onClick={() => onDisable(item.id)}
|
||||
>
|
||||
<Ban className="size-3" />
|
||||
</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
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={`Disable ${item.title}`}
|
||||
onClick={() => onDisable(item.id)}
|
||||
>
|
||||
<Ban className="size-3" />
|
||||
</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
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</SearchableSetting>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import React from 'react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { formatKeybindingList, type KeybindingDefinition } from '../../../../shared/keybindings'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { Button } from '../ui/button'
|
||||
import { Input } from '../ui/input'
|
||||
import type { ShortcutTerminalStatus } from './ShortcutBindingRow'
|
||||
import type { SettingsSearchEntry } from './settings-search'
|
||||
|
||||
export type ShortcutFilter = 'all' | 'modified' | 'unassigned' | 'conflicts'
|
||||
|
||||
export type ShortcutRowModel = {
|
||||
item: KeybindingDefinition
|
||||
groupTitle: string
|
||||
effective: readonly string[]
|
||||
modified: boolean
|
||||
warnings: readonly string[]
|
||||
terminalStatus?: ShortcutTerminalStatus
|
||||
}
|
||||
|
||||
export type ShortcutRowsByGroup = {
|
||||
title: string
|
||||
rows: ShortcutRowModel[]
|
||||
}
|
||||
|
||||
export type ShortcutGroupSummary = {
|
||||
id: string
|
||||
label: string
|
||||
count: number
|
||||
}
|
||||
|
||||
const SHORTCUT_FILTER_LABELS: Record<ShortcutFilter, string> = {
|
||||
all: 'All',
|
||||
modified: 'Modified',
|
||||
unassigned: 'Unassigned',
|
||||
conflicts: 'Conflicts'
|
||||
}
|
||||
|
||||
export function getShortcutSearchEntry(row: ShortcutRowModel): SettingsSearchEntry {
|
||||
return {
|
||||
title: row.item.title,
|
||||
description: `${row.groupTitle} shortcut`,
|
||||
keywords: [...row.item.searchKeywords]
|
||||
}
|
||||
}
|
||||
|
||||
export function matchesShortcutFilter(row: ShortcutRowModel, filter: ShortcutFilter): boolean {
|
||||
switch (filter) {
|
||||
case 'modified':
|
||||
return row.modified
|
||||
case 'unassigned':
|
||||
return row.effective.length === 0
|
||||
case 'conflicts':
|
||||
return row.warnings.length > 0
|
||||
case 'all':
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function matchesShortcutLocalSearch(
|
||||
row: ShortcutRowModel,
|
||||
query: string,
|
||||
platform: NodeJS.Platform
|
||||
): boolean {
|
||||
if (!query) {
|
||||
return true
|
||||
}
|
||||
const searchableText = [
|
||||
row.item.title,
|
||||
row.item.id,
|
||||
row.groupTitle,
|
||||
...row.item.searchKeywords,
|
||||
formatKeybindingList(row.effective, platform)
|
||||
]
|
||||
return searchableText.some((value) => value.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
export function ShortcutFilterRail({
|
||||
query,
|
||||
onQueryChange,
|
||||
filter,
|
||||
onFilterChange,
|
||||
activeGroup,
|
||||
onActiveGroupChange,
|
||||
filterCounts,
|
||||
groupSummaries,
|
||||
visibleCount,
|
||||
totalCount
|
||||
}: {
|
||||
query: string
|
||||
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 {
|
||||
const filters = (Object.keys(SHORTCUT_FILTER_LABELS) as ShortcutFilter[]).map((id) => ({
|
||||
id,
|
||||
label: SHORTCUT_FILTER_LABELS[id],
|
||||
count: filterCounts[id]
|
||||
}))
|
||||
|
||||
return (
|
||||
<aside className="flex min-h-0 flex-col gap-5 xl:h-full">
|
||||
<div className="shrink-0 space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label htmlFor="shortcut-filter-search" className="text-xs font-medium">
|
||||
Find shortcuts
|
||||
</label>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{visibleCount}/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="shortcut-filter-search"
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
placeholder="Search command or keys"
|
||||
className="h-8 pl-8 pr-8 text-sm"
|
||||
/>
|
||||
{query ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="Clear shortcut search"
|
||||
onClick={() => onQueryChange('')}
|
||||
className="absolute top-1/2 right-1 -translate-y-1/2 text-muted-foreground"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Shortcut status filters" className="shrink-0 space-y-2">
|
||||
<p className="text-[11px] font-semibold tracking-[0.05em] text-muted-foreground uppercase">
|
||||
Status
|
||||
</p>
|
||||
<div className="grid gap-1">
|
||||
{filters.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onFilterChange(option.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',
|
||||
filter === option.id
|
||||
? 'bg-accent font-medium text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{option.label}</span>
|
||||
<span className="text-[11px] tabular-nums opacity-80">{option.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from 'react'
|
||||
import type { KeybindingActionId, KeybindingInput } from '../../../../shared/keybindings'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { ShortcutBindingRow } from './ShortcutBindingRow'
|
||||
import type { ShortcutRowsByGroup } from './ShortcutFilterRail'
|
||||
|
||||
export function ShortcutRowsList({
|
||||
className,
|
||||
groups,
|
||||
platform,
|
||||
errors,
|
||||
recordingActionId,
|
||||
onStartRecording,
|
||||
onCancelRecording,
|
||||
onCapture,
|
||||
onClearError,
|
||||
onDisable,
|
||||
onReset
|
||||
}: {
|
||||
className?: string
|
||||
groups: ShortcutRowsByGroup[]
|
||||
platform: NodeJS.Platform
|
||||
errors: Partial<Record<KeybindingActionId, string>>
|
||||
recordingActionId: KeybindingActionId | null
|
||||
onStartRecording: (actionId: KeybindingActionId) => void
|
||||
onCancelRecording: () => void
|
||||
onCapture: (actionId: KeybindingActionId, input: KeybindingInput) => void
|
||||
onClearError: (actionId: KeybindingActionId) => void
|
||||
onDisable: (actionId: KeybindingActionId) => void
|
||||
onReset: (actionId: KeybindingActionId) => void
|
||||
}): React.JSX.Element {
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border border-dashed border-border/70 px-4 py-8 text-center text-sm text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
>
|
||||
No shortcuts match those filters.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('grid 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">
|
||||
{group.title}
|
||||
</h3>
|
||||
<div className="grid gap-2">
|
||||
{group.rows.map((row) => (
|
||||
<ShortcutBindingRow
|
||||
key={row.item.id}
|
||||
item={row.item}
|
||||
groupTitle={group.title}
|
||||
platform={platform}
|
||||
effective={row.effective}
|
||||
modified={row.modified}
|
||||
error={errors[row.item.id]}
|
||||
warnings={row.warnings}
|
||||
recording={recordingActionId === row.item.id}
|
||||
terminalStatus={row.terminalStatus}
|
||||
onStartRecording={onStartRecording}
|
||||
onCancelRecording={onCancelRecording}
|
||||
onCapture={onCapture}
|
||||
onClearError={onClearError}
|
||||
onDisable={onDisable}
|
||||
onReset={onReset}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react'
|
||||
import type { TerminalShortcutPolicy } from '../../../../shared/keybindings'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { SettingsRow } from './SettingsFormControls'
|
||||
|
||||
export function ShortcutTerminalPolicyControl({
|
||||
terminalShortcutPolicy,
|
||||
keywords,
|
||||
updateSettings
|
||||
}: {
|
||||
terminalShortcutPolicy: TerminalShortcutPolicy
|
||||
keywords?: string[]
|
||||
updateSettings: (updates: {
|
||||
terminalShortcutPolicy?: TerminalShortcutPolicy
|
||||
}) => Promise<void> | void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<SearchableSetting
|
||||
id="terminal-shortcut-policy"
|
||||
title="Shortcuts in Terminal"
|
||||
description="Choose whether Orca or the focused terminal wins when shortcuts overlap."
|
||||
keywords={keywords}
|
||||
className="max-w-none"
|
||||
>
|
||||
<SettingsRow
|
||||
label="Shortcuts in Terminal"
|
||||
description="Decide who first intercepts shortcuts"
|
||||
control={
|
||||
<Select
|
||||
value={terminalShortcutPolicy}
|
||||
onValueChange={(value) =>
|
||||
void updateSettings({
|
||||
terminalShortcutPolicy: value as TerminalShortcutPolicy
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="orca-first">Orca first</SelectItem>
|
||||
<SelectItem value="terminal-first">Terminal first</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import type { CtrlTabOrderMode } from '../../../../shared/types'
|
||||
import {
|
||||
KEYBINDING_DEFINITIONS,
|
||||
findKeybindingConflicts,
|
||||
@@ -18,18 +17,22 @@ import {
|
||||
type TerminalShortcutPolicy
|
||||
} from '../../../../shared/keybindings'
|
||||
import { useAppStore } from '../../store'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { KeybindingsFileActions } from './KeybindingsFileActions'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { SettingsRow, SettingsSubsectionHeader } from './SettingsFormControls'
|
||||
import { ShortcutBindingRow, type ShortcutTerminalStatus } from './ShortcutBindingRow'
|
||||
import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search'
|
||||
import { SettingsSubsectionHeader } from './SettingsFormControls'
|
||||
import type { ShortcutTerminalStatus } from './ShortcutBindingRow'
|
||||
import {
|
||||
CTRL_TAB_BEHAVIOR_SEARCH_ENTRY,
|
||||
SHORTCUTS_PANE_SEARCH_ENTRIES,
|
||||
TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY
|
||||
} from './shortcuts-search'
|
||||
export { SHORTCUTS_PANE_SEARCH_ENTRIES }
|
||||
getShortcutSearchEntry,
|
||||
matchesShortcutFilter,
|
||||
matchesShortcutLocalSearch,
|
||||
ShortcutFilterRail,
|
||||
type ShortcutFilter,
|
||||
type ShortcutGroupSummary,
|
||||
type ShortcutRowsByGroup
|
||||
} from './ShortcutFilterRail'
|
||||
import { ShortcutRowsList } from './ShortcutRowsList'
|
||||
import { ShortcutTerminalPolicyControl } from './ShortcutTerminalPolicyControl'
|
||||
import { TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY } from './shortcuts-search'
|
||||
import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search'
|
||||
|
||||
type ShortcutGroup = {
|
||||
title: string
|
||||
@@ -118,7 +121,6 @@ function getShortcutTerminalStatus(
|
||||
|
||||
export function ShortcutsPane(): React.JSX.Element {
|
||||
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
|
||||
const ctrlTabOrderMode = useAppStore((state) => state.settings?.ctrlTabOrderMode ?? 'mru')
|
||||
const terminalShortcutPolicy = useAppStore(
|
||||
(state) => state.settings?.terminalShortcutPolicy ?? 'orca-first'
|
||||
)
|
||||
@@ -130,22 +132,11 @@ export function ShortcutsPane(): React.JSX.Element {
|
||||
const disableKeybindingAction = useAppStore((state) => state.disableKeybindingAction)
|
||||
const [errors, setErrors] = useState<Partial<Record<KeybindingActionId, string>>>({})
|
||||
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 groupEntries = useMemo<Record<string, SettingsSearchEntry[]>>(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
groups.map((group) => [
|
||||
group.title,
|
||||
group.items.map((item) => ({
|
||||
title: item.title,
|
||||
description: `${group.title} shortcut`,
|
||||
keywords: [...item.searchKeywords]
|
||||
}))
|
||||
])
|
||||
),
|
||||
[groups]
|
||||
)
|
||||
const conflictByAction = useMemo(() => {
|
||||
const result = new Map<KeybindingActionId, string[]>()
|
||||
for (const conflict of findKeybindingConflicts(platform, keybindings)) {
|
||||
@@ -161,6 +152,76 @@ export function ShortcutsPane(): React.JSX.Element {
|
||||
}
|
||||
return result
|
||||
}, [keybindings])
|
||||
const shortcutGroups = useMemo<ShortcutRowsByGroup[]>(
|
||||
() =>
|
||||
groups.map((group) => ({
|
||||
title: group.title,
|
||||
rows: group.items.map((item) => {
|
||||
const effective = getEffectiveKeybindingsForAction(item.id, platform, keybindings)
|
||||
const modified = hasOwnBindingOverride(keybindings, item.id)
|
||||
const warnings = conflictByAction.get(item.id) ?? []
|
||||
return {
|
||||
item,
|
||||
groupTitle: group.title,
|
||||
effective,
|
||||
modified,
|
||||
warnings,
|
||||
terminalStatus: getShortcutTerminalStatus(
|
||||
item,
|
||||
terminalShortcutPolicy,
|
||||
effective.length > 0
|
||||
)
|
||||
}
|
||||
})
|
||||
})),
|
||||
[conflictByAction, groups, keybindings, terminalShortcutPolicy]
|
||||
)
|
||||
const shortcutSearchQuery = normalizeSettingsSearchQuery(shortcutQuery)
|
||||
const shortcutRows = shortcutGroups.flatMap((group) => group.rows)
|
||||
const baseVisibleRows = shortcutRows.filter(
|
||||
(row) =>
|
||||
matchesSettingsSearch(searchQuery, getShortcutSearchEntry(row)) &&
|
||||
matchesShortcutLocalSearch(row, shortcutSearchQuery, platform)
|
||||
)
|
||||
const filterCounts: Record<ShortcutFilter, number> = {
|
||||
all: baseVisibleRows.length,
|
||||
modified: baseVisibleRows.filter((row) => row.modified).length,
|
||||
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)
|
||||
)
|
||||
}))
|
||||
.filter((group) => group.rows.length > 0)
|
||||
const visibleShortcutCount = visibleShortcutGroups.reduce(
|
||||
(sum, group) => sum + group.rows.length,
|
||||
0
|
||||
)
|
||||
|
||||
const saveBindings = async (
|
||||
actionId: KeybindingActionId,
|
||||
@@ -262,133 +323,57 @@ export function ShortcutsPane(): React.JSX.Element {
|
||||
}
|
||||
|
||||
const showPolicy = matchesSettingsSearch(searchQuery, TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY)
|
||||
const showCtrlTab = matchesSettingsSearch(searchQuery, CTRL_TAB_BEHAVIOR_SEARCH_ENTRY)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="space-y-3">
|
||||
<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">
|
||||
<SettingsSubsectionHeader
|
||||
title="Keyboard Shortcuts"
|
||||
description="Customize shortcuts visually or edit the file directly."
|
||||
/>
|
||||
|
||||
{showPolicy || showCtrlTab ? (
|
||||
<div className="divide-y divide-border/40">
|
||||
<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 ? (
|
||||
<SearchableSetting
|
||||
id="terminal-shortcut-policy"
|
||||
title="Shortcuts in Terminal"
|
||||
description="Choose whether Orca or the focused terminal wins when shortcuts overlap."
|
||||
<ShortcutTerminalPolicyControl
|
||||
terminalShortcutPolicy={terminalShortcutPolicy}
|
||||
keywords={TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY.keywords}
|
||||
>
|
||||
<SettingsRow
|
||||
label="Shortcuts in Terminal"
|
||||
description="Orca first keeps app shortcuts active in TUIs. Terminal first lets shell shortcuts win unless marked terminal-active."
|
||||
control={
|
||||
<Select
|
||||
value={terminalShortcutPolicy}
|
||||
onValueChange={(value) =>
|
||||
void updateSettings({
|
||||
terminalShortcutPolicy: value as TerminalShortcutPolicy
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="orca-first">Orca first</SelectItem>
|
||||
<SelectItem value="terminal-first">Terminal first</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
updateSettings={updateSettings}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showCtrlTab ? (
|
||||
<SearchableSetting
|
||||
title="Recent Tab Order"
|
||||
description="Choose recent or sequential tab switching."
|
||||
keywords={CTRL_TAB_BEHAVIOR_SEARCH_ENTRY.keywords}
|
||||
>
|
||||
<SettingsRow
|
||||
label="Recent Tab Order"
|
||||
description="Choose whether recent tab switching follows recent use or the tab strip order."
|
||||
control={
|
||||
<Select
|
||||
value={ctrlTabOrderMode}
|
||||
onValueChange={(value) =>
|
||||
void updateSettings({ ctrlTabOrderMode: value as CtrlTabOrderMode })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mru">Most recent</SelectItem>
|
||||
<SelectItem value="sequential">Tab strip order</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
) : 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) => void disableBinding(actionId)}
|
||||
onReset={(actionId) => void resetBinding(actionId)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<KeybindingsFileActions />
|
||||
|
||||
<div className="grid gap-8">
|
||||
{groups
|
||||
.filter((group) => matchesSettingsSearch(searchQuery, groupEntries[group.title] ?? []))
|
||||
.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">
|
||||
{group.title}
|
||||
</h3>
|
||||
<div className="grid gap-2">
|
||||
{group.items.map((item) => {
|
||||
const effective = getEffectiveKeybindingsForAction(
|
||||
item.id,
|
||||
platform,
|
||||
keybindings
|
||||
)
|
||||
const modified = hasOwnBindingOverride(keybindings, item.id)
|
||||
const warnings = conflictByAction.get(item.id) ?? []
|
||||
const terminalStatus = getShortcutTerminalStatus(
|
||||
item,
|
||||
terminalShortcutPolicy,
|
||||
effective.length > 0
|
||||
)
|
||||
|
||||
return (
|
||||
<ShortcutBindingRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
groupTitle={group.title}
|
||||
platform={platform}
|
||||
effective={effective}
|
||||
modified={modified}
|
||||
error={errors[item.id]}
|
||||
warnings={warnings}
|
||||
recording={recordingActionId === item.id}
|
||||
terminalStatus={terminalStatus}
|
||||
onStartRecording={(actionId) => {
|
||||
setRecordingActionId(actionId)
|
||||
clearError(actionId)
|
||||
}}
|
||||
onCancelRecording={() => setRecordingActionId(null)}
|
||||
onCapture={(actionId, input) => void captureBinding(actionId, input)}
|
||||
onClearError={clearError}
|
||||
onDisable={(actionId) => void disableBinding(actionId)}
|
||||
onReset={(actionId) => void resetBinding(actionId)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -61,6 +61,23 @@ export const GENERAL_EDITOR_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
}
|
||||
]
|
||||
|
||||
export const GENERAL_NAVIGATION_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Tab Order',
|
||||
description: 'Recent or tab strip.',
|
||||
keywords: [
|
||||
'recent tab order',
|
||||
'tab',
|
||||
'ctrl',
|
||||
'control',
|
||||
'recent',
|
||||
'mru',
|
||||
'sequential',
|
||||
'switch'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export const GENERAL_CLI_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Shell command',
|
||||
@@ -119,6 +136,7 @@ export const GENERAL_SUPPORT_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
|
||||
export const GENERAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
...GENERAL_WORKSPACE_SEARCH_ENTRIES,
|
||||
...GENERAL_NAVIGATION_SEARCH_ENTRIES,
|
||||
...GENERAL_EDITOR_SEARCH_ENTRIES,
|
||||
...GENERAL_CLI_SEARCH_ENTRIES,
|
||||
...GENERAL_CACHE_TIMER_SEARCH_ENTRIES,
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { KEYBINDING_DEFINITIONS } from '../../../../shared/keybindings'
|
||||
import type { SettingsSearchEntry } from './settings-search'
|
||||
|
||||
export const CTRL_TAB_BEHAVIOR_SEARCH_ENTRY: SettingsSearchEntry = {
|
||||
title: 'Recent Tab Order',
|
||||
description: 'Choose recent or sequential tab switching.',
|
||||
keywords: ['shortcut', 'tab', 'ctrl', 'control', 'recent', 'mru', 'sequential', 'switch']
|
||||
}
|
||||
|
||||
export const TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY: SettingsSearchEntry = {
|
||||
title: 'Shortcuts in Terminal',
|
||||
description: 'Choose whether Orca or the focused terminal wins when shortcuts overlap.',
|
||||
@@ -29,6 +23,5 @@ export const SHORTCUTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
description: `${item.group} shortcut`,
|
||||
keywords: [...item.searchKeywords]
|
||||
})),
|
||||
TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY,
|
||||
CTRL_TAB_BEHAVIOR_SEARCH_ENTRY
|
||||
TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY
|
||||
]
|
||||
|
||||
@@ -48,8 +48,6 @@ import {
|
||||
type LinkedWorkItemSummary,
|
||||
type SetupConfig
|
||||
} from '@/lib/new-workspace'
|
||||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
import {
|
||||
getFullComposerCreateDisabled,
|
||||
getQuickComposerCreateDisabled
|
||||
@@ -77,7 +75,6 @@ import {
|
||||
} from '@/lib/workspace-create-error-format'
|
||||
import type { SshConnectionStatus } from '../../../shared/ssh-types'
|
||||
import { resolveComposerBranchSelection } from './composer-branch-selection'
|
||||
import { keybindingMatchesAction } from '../../../shared/keybindings'
|
||||
|
||||
export type UseComposerStateOptions = {
|
||||
initialRepoId?: string
|
||||
@@ -134,7 +131,6 @@ export type ComposerCardProps = {
|
||||
onClearSmartNameSelection: () => void
|
||||
agentPrompt: string
|
||||
onAgentPromptChange: (value: string) => void
|
||||
onPromptKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void
|
||||
/** Rendered issueCommand template to preview inside the empty prompt
|
||||
* textarea when the user has linked a work item but not typed anything. */
|
||||
linkedOnlyTemplatePreview: string | null
|
||||
@@ -142,7 +138,6 @@ export type ComposerCardProps = {
|
||||
getAttachmentLabel: (pathValue: string) => string
|
||||
onAddAttachment: () => void
|
||||
onRemoveAttachment: (pathValue: string) => void
|
||||
addAttachmentShortcut: string
|
||||
linkedWorkItem: LinkedWorkItemSummary | null
|
||||
onRemoveLinkedWorkItem: () => void
|
||||
linkPopoverOpen: boolean
|
||||
@@ -1372,28 +1367,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handlePromptKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||
if (
|
||||
!keybindingMatchesAction(
|
||||
'composer.addAttachment',
|
||||
event,
|
||||
getShortcutPlatform(),
|
||||
useAppStore.getState().keybindings
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: the attachment picker should only steal Cmd/Ctrl+U while the user
|
||||
// is composing a prompt, so the shortcut is scoped to the textarea rather
|
||||
// than registered globally for the whole new-workspace surface.
|
||||
event.preventDefault()
|
||||
void handleAddAttachment()
|
||||
},
|
||||
[handleAddAttachment]
|
||||
)
|
||||
|
||||
const handleRepoChange = useCallback(
|
||||
(value: string): void => {
|
||||
if (value === repoId) {
|
||||
@@ -2153,8 +2126,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
createGateMode === 'quick'
|
||||
? getQuickComposerCreateDisabled(createGateInput)
|
||||
: getFullComposerCreateDisabled(createGateInput)
|
||||
const addAttachmentShortcut = useShortcutLabel('composer.addAttachment')
|
||||
|
||||
const cardProps: ComposerCardProps = {
|
||||
eligibleRepos,
|
||||
repoId,
|
||||
@@ -2170,14 +2141,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
onClearSmartNameSelection: handleClearSmartNameSelection,
|
||||
agentPrompt,
|
||||
onAgentPromptChange: setAgentPrompt,
|
||||
onPromptKeyDown: handlePromptKeyDown,
|
||||
linkedOnlyTemplatePreview: shouldApplyLinkedOnlyTemplate ? linkedOnlyTemplatePrompt : null,
|
||||
attachmentPaths,
|
||||
getAttachmentLabel,
|
||||
onAddAttachment: () => void handleAddAttachment(),
|
||||
onRemoveAttachment: (pathValue) =>
|
||||
setAttachmentPaths((current) => current.filter((currentPath) => currentPath !== pathValue)),
|
||||
addAttachmentShortcut,
|
||||
linkedWorkItem,
|
||||
onRemoveLinkedWorkItem: handleRemoveLinkedWorkItem,
|
||||
linkPopoverOpen,
|
||||
|
||||
@@ -71,7 +71,6 @@ export type KeybindingActionId =
|
||||
| 'fileExplorer.copyPath'
|
||||
| 'fileExplorer.copyRelativePath'
|
||||
| 'fileExplorer.delete'
|
||||
| 'composer.addAttachment'
|
||||
| 'settings.search'
|
||||
| 'terminal.copySelection'
|
||||
| 'terminal.paste'
|
||||
@@ -566,14 +565,6 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [
|
||||
},
|
||||
allowBareKeybindings: true
|
||||
},
|
||||
{
|
||||
id: 'composer.addAttachment',
|
||||
title: 'Add Attachment',
|
||||
group: 'Composer',
|
||||
scope: 'composer',
|
||||
searchKeywords: ['shortcut', 'composer', 'attachment', 'upload'],
|
||||
defaultBindings: platformBindings(['Mod+U'])
|
||||
},
|
||||
{
|
||||
id: 'settings.search',
|
||||
title: 'Search Settings',
|
||||
|
||||
Reference in New Issue
Block a user