1import { useEnvironmentScope } from "../../state/session";
2import { useEnvironmentsWithScope, readEnvironmentScope } from "../../state/session";
3import {
4 ChevronDownIcon,
5 CircleXIcon,
6 EllipsisIcon,
7 FileJsonIcon,
8 MinusIcon,
9 PlusIcon,
10 SearchIcon,
11 TriangleAlertIcon,
12 XIcon,
13} from "lucide-react";
14import { useLocation } from "@tanstack/react-router";
15import {
16 type KeyboardEvent,
17 type ReactNode,
18 type RefObject,
19 useCallback,
20 useMemo,
21 useReducer,
22 useRef,
23 useState,
24} from "react";
25import {
26 AuthOrchestrationOperateScope,
27 AuthSettingsWriteScope,
28 type KeybindingCommand,
29 type KeybindingWhenNode,
30 type ServerRemoveKeybindingInput,
31 type ServerUpsertKeybindingInput,
32} from "@t3tools/contracts";
33import { mergeWithDefaultKeybindings } from "@t3tools/shared/keybindings";
34import {
35 isAtomCommandInterrupted,
36 squashAtomCommandFailure,
37} from "@t3tools/client-runtime/state/runtime";
38
39import { isElectron } from "../../env";
40import { useOpenInPreferredEditor } from "../../editorPreferences";
41import { formatShortcutLabel } from "../../keybindings";
42import { cn } from "../../lib/utils";
43import { serverEnvironment } from "../../state/server";
44import { useSettingsScope } from "./SettingsScopeContext";
45import { Badge } from "../ui/badge";
46import { Button } from "../ui/button";
47import { Input } from "../ui/input";
48import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";
49import { Kbd, KbdGroup } from "../ui/kbd";
50import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
51import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
52import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
53import { Toggle } from "../ui/toggle";
54import { toastManager } from "../ui/toast";
55import {
56 buildKeybindingRows,
57 buildKeybindingCommandOptions,
58 buildWhenVariableOptions,
59 commandLabel,
60 DEFAULT_WHEN_VARIABLE,
61 isKnownWhenVariable,
62 keybindingConflictLabels,
63 keybindingFromKeyboardEvent,
64 parseWhenExpressionDraft,
65 groupKeybindingRows,
66 type KeybindingCommandOption,
67 type KeybindingGroup,
68 type KeybindingRow,
69 type WhenVariableOption,
70 unknownWhenVariables,
71 whenAstToExpression,
72 whenNodeRemoveLabel,
73} from "./KeybindingsSettings.logic";
74import { SettingsGroup } from "./SettingsGroup";
75import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";
76import { keybindingSearchAnchorId, searchableSetting } from "./settingsSearch";
77import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
78import { useAtomCommand } from "../../state/use-atom-command";
79
80function KeybindingPill({ value }: { value: string }) {
81 // Keys dedupe repeated parts; a literal "+" in a shortcut splits into empty strings.
82 const seenParts = new Map<string, number>();
83 const parts = value.split("+").map((part) => {
84 const seen = seenParts.get(part) ?? 0;
85 seenParts.set(part, seen + 1);
86 return { part, key: seen === 0 ? part : `${part}-${seen}` };
87 });
88 return (
89 <KbdGroup>
90 {parts.map(({ part, key }) => (
91 <Kbd key={key}>
92 {part === "mod"
93 ? navigator.platform.toLowerCase().includes("mac")
94 ? "⌘"
95 : "Ctrl"
96 : part === "shift"
97 ? "⇧"
98 : part === "alt"
99 ? navigator.platform.toLowerCase().includes("mac")
100 ? "⌥"
101 : "Alt"
102 : part === "ctrl"
103 ? "⌃"
104 : part.length === 1
105 ? part.toUpperCase()
106 : part}
107 </Kbd>
108 ))}
109 </KbdGroup>
110 );
111}
112
113/** Filter box in the page toolbar; Mod+F focuses it from anywhere on the page. */
114function KeybindingsSearchInput({
115 query,
116 onChange,
117 inputRef,
118}: {
119 query: string;
120 onChange: (next: string) => void;
121 inputRef: RefObject<HTMLInputElement | null>;
122}) {
123 return (
124 <InputGroup className="min-w-0 basis-full **:[input]:h-9 sm:basis-0 sm:flex-1 sm:**:[input]:h-8">
125 <InputGroupAddon>
126 <SearchIcon aria-hidden className="size-3.5" />
127 </InputGroupAddon>
128 <InputGroupInput
129 ref={inputRef}
130 type="search"
131 value={query}
132 onChange={(event) => onChange(event.currentTarget.value)}
133 onKeyDown={(event) => {
134 // The settings route treats an unhandled Escape as "go back";
135 // inside the search box it clears, then leaves the field.
136 if (event.key !== "Escape") return;
137 event.preventDefault();
138 if (query.length > 0) onChange("");
139 else event.currentTarget.blur();
140 }}
141 placeholder="Search keybindings"
142 aria-label="Search keybindings"
143 size="sm"
144 />
145 </InputGroup>
146 );
147}
148
149type BooleanOperator = "and" | "or";
150
151function flattenWhenChildren(
152 node: KeybindingWhenNode,
153 operator: BooleanOperator,
154): KeybindingWhenNode[] {
155 if (node.type !== operator) return [node];
156 return [
157 ...flattenWhenChildren(node.left, operator),
158 ...flattenWhenChildren(node.right, operator),
159 ];
160}
161
162function buildWhenExpressionGroup(
163 children: readonly KeybindingWhenNode[],
164 operator: BooleanOperator,
165): KeybindingWhenNode | undefined {
166 const first = children[0];
167 if (!first) return undefined;
168 return children.slice(1).reduce<KeybindingWhenNode>(
169 (left, right) => ({
170 type: operator,
171 left,
172 right,
173 }),
174 first,
175 );
176}
177
178function conditionParts(node: KeybindingWhenNode): { identifier: string; negated: boolean } | null {
179 if (node.type === "identifier") return { identifier: node.name, negated: false };
180 if (node.type === "not" && node.node.type === "identifier") {
181 return { identifier: node.node.name, negated: true };
182 }
183 return null;
184}
185
186function setConditionIdentifier(node: KeybindingWhenNode, identifier: string): KeybindingWhenNode {
187 const parts = conditionParts(node);
188 if (!parts) return node;
189 const next: KeybindingWhenNode = { type: "identifier", name: identifier };
190 return parts.negated ? { type: "not", node: next } : next;
191}
192
193function setConditionNegated(node: KeybindingWhenNode, negated: boolean): KeybindingWhenNode {
194 const parts = conditionParts(node);
195 if (!parts) return negated ? { type: "not", node } : node;
196 const identifier: KeybindingWhenNode = { type: "identifier", name: parts.identifier };
197 return negated ? { type: "not", node: identifier } : identifier;
198}
199
200function defaultWhenCondition(): KeybindingWhenNode {
201 return { type: "identifier", name: DEFAULT_WHEN_VARIABLE };
202}
203
204function defaultWhenGroup(operator: BooleanOperator = "and"): KeybindingWhenNode {
205 return {
206 type: operator,
207 left: defaultWhenCondition(),
208 right: { type: "not", node: defaultWhenCondition() },
209 };
210}
211
212/** Warning glyph whose explanation lives in a tooltip; the one owner of that affordance here. */
213function WarningTooltipIcon({
214 label,
215 focusable = true,
216 className,
217 children,
218}: {
219 label: string;
220 focusable?: boolean;
221 className?: string | undefined;
222 children: ReactNode;
223}) {
224 return (
225 <Tooltip>
226 <TooltipTrigger
227 render={
228 <span
229 tabIndex={focusable ? 0 : undefined}
230 aria-label={label}
231 className={cn(
232 "inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-warning outline-none transition-colors hover:bg-warning/10 focus-visible:ring-3 focus-visible:ring-inset focus-visible:ring-warning/25",
233 className,
234 )}
235 />
236 }
237 >
238 <TriangleAlertIcon className="size-3.5" />
239 </TooltipTrigger>
240 <TooltipPopup side="top">{children}</TooltipPopup>
241 </Tooltip>
242 );
243}
244
245function UnknownWhenVariableWarning({
246 identifiers,
247 focusable = true,
248}: {
249 identifiers: ReadonlyArray<string>;
250 focusable?: boolean;
251}) {
252 if (identifiers.length === 0) return null;
253 const label =
254 identifiers.length === 1
255 ? `Unknown condition: ${identifiers[0]}`
256 : `Unknown conditions: ${identifiers.join(", ")}`;
257
258 return (
259 <WarningTooltipIcon label={label} focusable={focusable} className="size-4.5">
260 The Knife does not recognize this condition yet. It can still be saved, but it may not match
261 unless the runtime provides it.
262 </WarningTooltipIcon>
263 );
264}
265
266function KeybindingConflictWarning({ labels }: { labels: ReadonlyArray<string> }) {
267 if (labels.length === 0) return null;
268 const description =
269 labels.length === 1
270 ? `Conflicts with ${labels[0]}.`
271 : `Conflicts with ${labels.slice(0, 3).join(", ")}${labels.length > 3 ? ", and more" : ""}.`;
272
273 return (
274 <WarningTooltipIcon label={description}>
275 {description} The most recent matching binding wins when both conditions can apply.
276 </WarningTooltipIcon>
277 );
278}
279
280function WhenVariableSelect({
281 value,
282 variables,
283 unknownIdentifiers,
284 onChange,
285}: {
286 value: string;
287 variables: ReadonlyArray<WhenVariableOption>;
288 unknownIdentifiers?: ReadonlyArray<string>;
289 onChange: (value: string) => void;
290}) {
291 const selected = variables.find((option) => option === value);
292 const options =
293 selected || variables.some((option) => option === value) ? variables : [value, ...variables];
294
295 return (
296 <Select value={value} onValueChange={(nextValue) => nextValue && onChange(nextValue)}>
297 <SelectTrigger size="compact" className="min-w-0 flex-1">
298 <SelectValue placeholder="Condition" />
299 {unknownIdentifiers && unknownIdentifiers.length > 0 ? (
300 <UnknownWhenVariableWarning identifiers={unknownIdentifiers} focusable={false} />
301 ) : null}
302 </SelectTrigger>
303 <SelectContent alignItemWithTrigger={false} matchTriggerWidth={false} className="max-h-72">
304 {options.map((option) => (
305 <SelectItem key={option} value={option} className="w-full">
306 <span className="truncate">{option}</span>
307 </SelectItem>
308 ))}
309 </SelectContent>
310 </Select>
311 );
312}
313
314function WhenExpressionRemoveButton({
315 label,
316 className,
317 onRemove,
318}: {
319 label: string;
320 className?: string | undefined;
321 onRemove: () => void;
322}) {
323 return (
324 <Tooltip>
325 <TooltipTrigger
326 delay={200}
327 render={
328 <Button
329 type="button"
330 variant="ghost"
331 size="icon-sm"
332 className={cn("size-7", className)}
333 aria-label={label}
334 onClick={onRemove}
335 />
336 }
337 >
338 <MinusIcon aria-hidden className="size-3.5" />
339 </TooltipTrigger>
340 <TooltipPopup side="top">{label}</TooltipPopup>
341 </Tooltip>
342 );
343}
344
345function WhenExpressionNodeEditor({
346 node,
347 variables,
348 depth = 0,
349 onChange,
350 onRemove,
351}: {
352 node: KeybindingWhenNode;
353 variables: ReadonlyArray<WhenVariableOption>;
354 depth?: number;
355 onChange: (node: KeybindingWhenNode) => void;
356 onRemove?: () => void;
357}) {
358 const condition = conditionParts(node);
359
360 if (condition) {
361 const unknownIdentifiers = isKnownWhenVariable(condition.identifier)
362 ? []
363 : [condition.identifier];
364
365 return (
366 <div className="flex items-center gap-2 rounded-md border border-border/70 bg-background/60 px-2 py-2">
367 <Toggle
368 pressed={condition.negated}
369 onPressedChange={(pressed) => onChange(setConditionNegated(node, pressed))}
370 aria-label={`Negate ${condition.identifier}`}
371 variant="outline"
372 size="compact"
373 className="min-w-10"
374 >
375 Not
376 </Toggle>
377 <WhenVariableSelect
378 value={condition.identifier}
379 variables={variables}
380 unknownIdentifiers={unknownIdentifiers}
381 onChange={(value) => onChange(setConditionIdentifier(node, value))}
382 />
383 {onRemove ? (
384 <WhenExpressionRemoveButton
385 label={whenNodeRemoveLabel(node, depth)}
386 onRemove={onRemove}
387 />
388 ) : null}
389 </div>
390 );
391 }
392
393 if (node.type === "not") {
394 return (
395 <div
396 className={cn(
397 "space-y-2 rounded-lg border border-border/70 bg-muted/20 p-2",
398 depth > 0 && "border-border/50 bg-background/50",
399 )}
400 >
401 <div className="flex items-center gap-2">
402 <Toggle
403 pressed
404 onPressedChange={(pressed) => onChange(pressed ? node : node.node)}
405 aria-label="Negate group"
406 variant="outline"
407 size="compact"
408 className="min-w-10"
409 >
410 Not
411 </Toggle>
412 {onRemove ? (
413 <WhenExpressionRemoveButton
414 label={whenNodeRemoveLabel(node, depth)}
415 className="ml-auto"
416 onRemove={onRemove}
417 />
418 ) : null}
419 </div>
420 <div className="relative pl-4">
421 <span className="absolute top-0 bottom-0 left-1.5 w-px bg-border/70" aria-hidden />
422 <span className="absolute top-4 left-1.5 h-px w-2.5 bg-border/70" aria-hidden />
423 <WhenExpressionNodeEditor
424 node={node.node}
425 variables={variables}
426 depth={depth + 1}
427 onChange={(next) => onChange({ type: "not", node: next })}
428 />
429 </div>
430 </div>
431 );
432 }
433
434 const operator: BooleanOperator = node.type === "or" ? "or" : "and";
435 const children = flattenWhenChildren(node, operator);
436 const childKeyCounts = new Map<string, number>();
437 const childEntries = children.map((child) => {
438 const baseKey = `${child.type}-${whenAstToExpression(child)}`;
439 const count = childKeyCounts.get(baseKey) ?? 0;
440 childKeyCounts.set(baseKey, count + 1);
441 return { child, key: count === 0 ? baseKey : `${baseKey}-${count}` };
442 });
443
444 const updateChild = (target: KeybindingWhenNode, next: KeybindingWhenNode) => {
445 let didUpdate = false;
446 const nextChildren = children.map((child) => {
447 if (!didUpdate && child === target) {
448 didUpdate = true;
449 return next;
450 }
451 return child;
452 });
453 const nextNode = buildWhenExpressionGroup(nextChildren, operator);
454 if (nextNode) onChange(nextNode);
455 };
456
457 const removeChild = (target: KeybindingWhenNode) => {
458 let didRemove = false;
459 const nextChildren = children.filter((child) => {
460 if (!didRemove && child === target) {
461 didRemove = true;
462 return false;
463 }
464 return true;
465 });
466 const nextNode = buildWhenExpressionGroup(nextChildren, operator);
467 if (nextNode) {
468 onChange(nextNode);
469 } else {
470 onChange(defaultWhenCondition());
471 }
472 };
473
474 const setOperator = (nextOperator: BooleanOperator) => {
475 if (nextOperator === operator) return;
476 const nextNode = buildWhenExpressionGroup(children, nextOperator);
477 if (nextNode) onChange(nextNode);
478 };
479
480 const addCondition = () => {
481 const nextNode = buildWhenExpressionGroup([...children, defaultWhenCondition()], operator);
482 if (nextNode) onChange(nextNode);
483 };
484
485 const addGroup = () => {
486 const nestedOperator: BooleanOperator = operator === "and" ? "or" : "and";
487 const group: KeybindingWhenNode = {
488 type: nestedOperator,
489 left: defaultWhenCondition(),
490 right: { type: "not", node: defaultWhenCondition() },
491 };
492 const nextNode = buildWhenExpressionGroup([...children, group], operator);
493 if (nextNode) onChange(nextNode);
494 };
495
496 return (
497 <div
498 className={cn(
499 "space-y-2 rounded-lg border border-border/60 bg-muted/10 p-2",
500 depth > 0 && "border-border/70 bg-background/55",
501 )}
502 >
503 <div className="flex flex-wrap items-center gap-2">
504 <Select value={operator} onValueChange={(value) => setOperator(value as BooleanOperator)}>
505 <SelectTrigger size="compact" className="w-24">
506 <SelectValue />
507 </SelectTrigger>
508 <SelectContent alignItemWithTrigger={false} matchTriggerWidth={false}>
509 <SelectItem value="and">and</SelectItem>
510 <SelectItem value="or">or</SelectItem>
511 </SelectContent>
512 </Select>
513 <Button type="button" variant="outline" size="compact" onClick={addCondition}>
514 <PlusIcon className="size-3.5" />
515 Condition
516 </Button>
517 <Button type="button" variant="outline" size="compact" onClick={addGroup}>
518 <PlusIcon className="size-3.5" />
519 Group
520 </Button>
521 {onRemove ? (
522 <WhenExpressionRemoveButton
523 label={whenNodeRemoveLabel(node, depth)}
524 className="ml-auto"
525 onRemove={onRemove}
526 />
527 ) : null}
528 </div>
529 <div className="space-y-2">
530 {childEntries.map(({ child, key }) => (
531 <div key={key} className="relative pl-4">
532 <span
533 className={cn(
534 "absolute top-0 bottom-0 left-1.5 w-px",
535 depth === 0 ? "bg-border" : "bg-border/70",
536 )}
537 aria-hidden
538 />
539 <span
540 className={cn(
541 "absolute top-4 left-1.5 h-px w-2.5",
542 depth === 0 ? "bg-border" : "bg-border/70",
543 )}
544 aria-hidden
545 />
546 <WhenExpressionNodeEditor
547 node={child}
548 variables={variables}
549 depth={depth + 1}
550 onChange={(next) => updateChild(child, next)}
551 onRemove={() => removeChild(child)}
552 />
553 </div>
554 ))}
555 </div>
556 </div>
557 );
558}
559
560function WhenExpressionBuilder({
561 value,
562 variables,
563 onChange,
564 onValidityChange,
565}: {
566 value: KeybindingWhenNode | undefined;
567 variables: ReadonlyArray<WhenVariableOption>;
568 onChange: (value: KeybindingWhenNode | undefined) => void;
569 onValidityChange?: (valid: boolean) => void;
570}) {
571 const expression = whenAstToExpression(value);
572 const [expressionDraft, setExpressionDraft] = useState(expression);
573 const parseResult = useMemo(() => parseWhenExpressionDraft(expressionDraft), [expressionDraft]);
574 const parseError = parseResult.ok ? null : parseResult.message;
575 const unknownIdentifiers = parseResult.ok ? unknownWhenVariables(parseResult.value) : [];
576
577 const updateExpressionDraft = (nextExpression: string) => {
578 setExpressionDraft(nextExpression);
579 const nextResult = parseWhenExpressionDraft(nextExpression);
580 onValidityChange?.(nextResult.ok);
581 if (nextResult.ok) {
582 onChange(nextResult.value);
583 }
584 };
585
586 const updateExpressionValue = (nextValue: KeybindingWhenNode | undefined) => {
587 setExpressionDraft(whenAstToExpression(nextValue));
588 onValidityChange?.(true);
589 onChange(nextValue);
590 };
591
592 const addRootCondition = () => {
593 if (!value) {
594 updateExpressionValue(defaultWhenCondition());
595 return;
596 }
597 updateExpressionValue({ type: "and", left: value, right: defaultWhenCondition() });
598 };
599
600 const addRootGroup = () => {
601 const group = defaultWhenGroup("or");
602 if (!value) {
603 updateExpressionValue(group);
604 return;
605 }
606 updateExpressionValue({ type: "and", left: value, right: group });
607 };
608
609 return (
610 <div className="w-[min(34rem,calc(100vw-2rem))] space-y-3">
611 <div className="flex items-start justify-between gap-3">
612 <div className="min-w-0">
613 <div className="text-sm font-medium text-foreground">When</div>
614 </div>
615 <div className="flex shrink-0 items-center gap-2">
616 <Button type="button" variant="outline" size="compact" onClick={addRootCondition}>
617 <PlusIcon className="size-3.5" />
618 Condition
619 </Button>
620 <Button type="button" variant="outline" size="compact" onClick={addRootGroup}>
621 <PlusIcon className="size-3.5" />
622 Group
623 </Button>
624 </div>
625 </div>
626
627 <div className="space-y-1.5">
628 <InputGroup>
629 <InputGroupInput
630 value={expressionDraft}
631 onChange={(event) => updateExpressionDraft(event.currentTarget.value)}
632 placeholder="Always"
633 aria-invalid={Boolean(parseError)}
634 aria-label="When expression"
635 size="compact"
636 font="mono"
637 />
638 {unknownIdentifiers.length > 0 ? (
639 <InputGroupAddon align="inline-end">
640 <UnknownWhenVariableWarning identifiers={unknownIdentifiers} />
641 </InputGroupAddon>
642 ) : null}
643 </InputGroup>
644 {parseError ? (
645 <div className="flex items-center gap-1.5 text-2xs text-destructive">
646 <CircleXIcon className="size-3.5" />
647 {parseError}
648 </div>
649 ) : null}
650 </div>
651
652 <div className="relative">
653 {value ? (
654 <WhenExpressionNodeEditor
655 node={value}
656 variables={variables}
657 onChange={updateExpressionValue}
658 onRemove={() => updateExpressionValue(undefined)}
659 />
660 ) : (
661 <div className="rounded-md border border-dashed border-border/80 bg-muted/15 p-3">
662 <div className="flex flex-wrap gap-2">
663 <Button type="button" size="compact" onClick={addRootCondition}>
664 <PlusIcon className="size-3.5" />
665 Condition
666 </Button>
667 <Button type="button" variant="outline" size="compact" onClick={addRootGroup}>
668 <PlusIcon className="size-3.5" />
669 Group
670 </Button>
671 </div>
672 </div>
673 )}
674 {parseError ? (
675 <div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-lg border border-destructive/30 bg-background/75 p-4 text-center text-xs text-destructive backdrop-blur-xs">
676 Fix the expression above to continue editing visually.
677 </div>
678 ) : null}
679 </div>
680 </div>
681 );
682}
683
684type KeybindingRowDraftState = {
685 keyDraft: string;
686 whenDraft: KeybindingWhenNode | undefined;
687 isRecording: boolean;
688 isWhenDraftValid: boolean;
689};
690
691function createKeybindingRowDraft(row: KeybindingRow): KeybindingRowDraftState {
692 return {
693 keyDraft: row.key,
694 whenDraft: row.binding.whenAst,
695 isRecording: false,
696 isWhenDraftValid: true,
697 };
698}
699
700function keybindingRowDraftReducer(
701 state: KeybindingRowDraftState,
702 patch: Partial<KeybindingRowDraftState>,
703): KeybindingRowDraftState {
704 return { ...state, ...patch };
705}
706
707function rowKeybindingTarget(row: KeybindingRow): ServerRemoveKeybindingInput {
708 return {
709 command: row.command,
710 key: row.key,
711 ...(row.when.trim().length > 0 ? { when: row.when } : {}),
712 };
713}
714
715/** Draft state and actions for editing one existing binding; layouts decide how to render it. */
716function useKeybindingRowEditor({
717 row,
718 allRows,
719 onSave,
720}: {
721 row: KeybindingRow;
722 allRows: ReadonlyArray<KeybindingRow>;
723 onSave: (input: ServerUpsertKeybindingInput) => void;
724}) {
725 const [draft, setDraft] = useReducer(keybindingRowDraftReducer, row, createKeybindingRowDraft);
726 const { keyDraft, whenDraft, isRecording, isWhenDraftValid } = draft;
727 const whenDraftExpression = whenAstToExpression(whenDraft);
728 const isDirty = keyDraft !== row.key || whenDraftExpression !== row.when;
729 const conflictLabels = keybindingConflictLabels(allRows, {
730 rowId: row.id,
731 key: keyDraft,
732 when: whenDraftExpression,
733 });
734
735 const save = () => {
736 onSave({
737 command: row.command,
738 key: keyDraft,
739 when: whenDraftExpression.trim().length > 0 ? whenDraftExpression : undefined,
740 replace: rowKeybindingTarget(row),
741 });
742 };
743
744 const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => {
745 // Tab is recorded like any key while recording; after that it moves focus on.
746 if (event.key === "Tab" && !isRecording) return;
747 event.preventDefault();
748 if (event.key === "Escape") {
749 setDraft({ keyDraft: row.key, isRecording: false });
750 return;
751 }
752 const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform);
753 if (!next) return;
754 setDraft({ keyDraft: next, isRecording: false });
755 };
756
757 return {
758 keyDraft,
759 whenDraft,
760 isRecording,
761 isWhenDraftValid,
762 whenDraftExpression,
763 isDirty,
764 conflictLabels,
765 setDraft,
766 save,
767 captureKeybinding,
768 };
769}
770
771type KeybindingRowEditor = ReturnType<typeof useKeybindingRowEditor>;
772
773interface KeybindingRowActions {
774 allRows: ReadonlyArray<KeybindingRow>;
775 variables: ReadonlyArray<WhenVariableOption>;
776 onSave: (input: ServerUpsertKeybindingInput) => void;
777 onReset: (row: KeybindingRow) => void;
778 onRemove: (row: KeybindingRow) => void;
779}
780
781type KeybindingRowProps = KeybindingRowActions & {
782 row: KeybindingRow;
783 isSaving: boolean;
784 anchorId?: string | undefined;
785};
786
787/** Shortcut pill that turns into a capture input when clicked, plus Save once the draft changes. */
788function KeybindingKeyControl({
789 row,
790 editor,
791 isSaving,
792 pillClassName,
793}: {
794 row: KeybindingRow;
795 editor: KeybindingRowEditor;
796 isSaving: boolean;
797 pillClassName?: string | undefined;
798}) {
799 const { keyDraft, isRecording, isDirty, isWhenDraftValid, setDraft, save, captureKeybinding } =
800 editor;
801 const showPill = !isRecording && keyDraft === row.key && row.key.length > 0 && !isDirty;
802
803 return (
804 <>
805 {isDirty ? (
806 <Button
807 size="sm"
808 disabled={isSaving || keyDraft.trim().length === 0 || !isWhenDraftValid}
809 onClick={save}
810 >
811 {isSaving ? "Saving" : "Save"}
812 </Button>
813 ) : null}
814 {showPill ? (
815 <button
816 type="button"
817 onClick={() => setDraft({ isRecording: true })}
818 aria-label={`Edit shortcut for ${commandLabel(row.command)}: ${formatShortcutLabel(row.binding.shortcut)}`}
819 className={cn(
820 "inline-flex h-8 cursor-pointer items-center rounded-md border border-transparent px-1.5 sm:h-7 outline-none transition-colors hover:border-border/70 hover:bg-accent focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-inset focus-visible:ring-ring/24",
821 pillClassName,
822 )}
823 >
824 <KeybindingPill value={row.key} />
825 </button>
826 ) : (
827 <Input
828 data-keybinding-capture=""
829 autoFocus={isRecording}
830 aria-label={`Keybinding for ${commandLabel(row.command)}`}
831 value={isRecording ? "" : keyDraft}
832 placeholder={isRecording ? "Press shortcut" : "Unassigned"}
833 size="sm"
834 font="mono"
835 className="w-44"
836 onFocus={() => setDraft({ isRecording: true })}
837 onBlur={() => setDraft({ isRecording: false })}
838 onChange={(event) => setDraft({ keyDraft: event.currentTarget.value })}
839 onKeyDown={captureKeybinding}
840 />
841 )}
842 </>
843 );
844}
845
846/** Quiet inline trigger showing the when clause; opens the expression builder. */
847function WhenClauseControl({
848 label,
849 expression,
850 value,
851 variables,
852 onChange,
853 onValidityChange,
854}: {
855 label: string;
856 expression: string;
857 value: KeybindingWhenNode | undefined;
858 variables: ReadonlyArray<WhenVariableOption>;
859 onChange: (value: KeybindingWhenNode | undefined) => void;
860 onValidityChange: (valid: boolean) => void;
861}) {
862 return (
863 <Popover>
864 <PopoverTrigger
865 render={
866 <Button
867 variant={expression ? "ghost" : "ghost-muted"}
868 size="micro"
869 className="min-w-0 shrink"
870 />
871 }
872 aria-label={`Edit when clause for ${label}`}
873 >
874 <span className="truncate font-mono">{expression || "Always"}</span>
875 <ChevronDownIcon className="size-3.5 shrink-0 opacity-60" />
876 </PopoverTrigger>
877 <PopoverContent align="start" sideOffset={6}>
878 <WhenExpressionBuilder
879 value={value}
880 variables={variables}
881 onChange={onChange}
882 onValidityChange={onValidityChange}
883 />
884 </PopoverContent>
885 </Popover>
886 );
887}
888
889function KeybindingRowMenu({
890 row,
891 isSaving,
892 onReset,
893 onRemove,
894}: {
895 row: KeybindingRow;
896 isSaving: boolean;
897 onReset: (row: KeybindingRow) => void;
898 onRemove: (row: KeybindingRow) => void;
899}) {
900 const canReset = row.source === "Custom" && row.defaultKey !== null;
901 const canRemove = row.source !== "Default";
902 if (!canReset && !canRemove) return null;
903
904 return (
905 <Menu>
906 <MenuTrigger
907 render={
908 <Button
909 type="button"
910 variant="ghost-muted"
911 size="icon-sm"
912 disabled={isSaving}
913 aria-label={`Actions for ${commandLabel(row.command)}`}
914 />
915 }
916 >
917 <EllipsisIcon className="size-3.5" />
918 </MenuTrigger>
919 <MenuPopup align="end">
920 {canReset ? (
921 <MenuItem disabled={isSaving} onClick={() => onReset(row)}>
922 Reset to default
923 </MenuItem>
924 ) : null}
925 {canRemove ? (
926 <MenuItem variant="destructive" disabled={isSaving} onClick={() => onRemove(row)}>
927 Remove
928 </MenuItem>
929 ) : null}
930 </MenuPopup>
931 </Menu>
932 );
933}
934
935function KeybindingSourceBadge({ source }: { source: KeybindingRow["source"] }) {
936 if (source === "Default") return null;
937 return (
938 <Badge variant="outline" size="sm">
939 {source}
940 </Badge>
941 );
942}
943
944function KeybindingRowTitle({ row }: { row: KeybindingRow }) {
945 return (
946 <Tooltip>
947 <TooltipTrigger render={<span className="flex items-center gap-2" />}>
948 {commandLabel(row.command)}
949 <KeybindingSourceBadge source={row.source} />
950 </TooltipTrigger>
951 <TooltipPopup side="top">{row.command}</TooltipPopup>
952 </Tooltip>
953 );
954}
955
956function KeybindingRowWhen({
957 row,
958 editor,
959 variables,
960}: {
961 row: KeybindingRow;
962 editor: KeybindingRowEditor;
963 variables: ReadonlyArray<WhenVariableOption>;
964}) {
965 return (
966 <span className="flex h-6 items-center gap-1.5">
967 <span className="text-xs leading-none text-muted-foreground/70">When</span>
968 <WhenClauseControl
969 label={commandLabel(row.command)}
970 expression={editor.whenDraftExpression}
971 value={editor.whenDraft}
972 variables={variables}
973 onChange={(whenDraft) => editor.setDraft({ whenDraft })}
974 onValidityChange={(isWhenDraftValid) => editor.setDraft({ isWhenDraftValid })}
975 />
976 </span>
977 );
978}
979
980/** Row actions that stay hidden until the row is hovered or holds focus. */
981function KeybindingHoverRowMenu(props: {
982 row: KeybindingRow;
983 isSaving: boolean;
984 onReset: (row: KeybindingRow) => void;
985 onRemove: (row: KeybindingRow) => void;
986}) {
987 return (
988 <span className="flex items-center opacity-0 transition-opacity group-focus-within/row:opacity-100 group-hover/row:opacity-100 has-data-popup-open:opacity-100 pointer-coarse:opacity-100">
989 <KeybindingRowMenu {...props} />
990 </span>
991 );
992}
993
994/** One binding as a settings row: pills flush right, actions fading in beside them on hover. */
995function KeybindingSettingsRow(props: KeybindingRowProps) {
996 const { row, isSaving, anchorId, allRows, variables, onSave, onReset, onRemove } = props;
997 const editor = useKeybindingRowEditor({ row, allRows, onSave });
998
999 return (
1000 <SettingsRow
1001 id={anchorId}
1002 className="group/row rounded-none"
1003 title={<KeybindingRowTitle row={row} />}
1004 description={<KeybindingRowWhen row={row} editor={editor} variables={variables} />}
1005 control={
1006 <div className="flex flex-wrap items-center justify-end gap-1.5">
1007 <KeybindingConflictWarning labels={editor.conflictLabels} />
1008 <KeybindingHoverRowMenu
1009 row={row}
1010 isSaving={isSaving}
1011 onReset={onReset}
1012 onRemove={onRemove}
1013 />
1014 <KeybindingKeyControl
1015 row={row}
1016 editor={editor}
1017 isSaving={isSaving}
1018 pillClassName="-mr-1.5"
1019 />
1020 </div>
1021 }
1022 />
1023 );
1024}
1025
1026/** Draft state for a binding that does not exist yet. */
1027function useNewKeybindingDraft({
1028 allRows,
1029 onSave,
1030}: {
1031 allRows: ReadonlyArray<KeybindingRow>;
1032 onSave: (input: ServerUpsertKeybindingInput) => void;
1033}) {
1034 const [commandDraft, setCommandDraft] = useState<KeybindingCommand | "">("");
1035 const [draft, setDraft] = useReducer(keybindingRowDraftReducer, {
1036 keyDraft: "",
1037 whenDraft: undefined,
1038 isRecording: false,
1039 isWhenDraftValid: true,
1040 });
1041 const { keyDraft, whenDraft, isRecording, isWhenDraftValid } = draft;
1042 const whenDraftExpression = whenAstToExpression(whenDraft);
1043 const conflictLabels = keybindingConflictLabels(allRows, {
1044 rowId: "new",
1045 key: keyDraft,
1046 when: whenDraftExpression,
1047 });
1048 const commandLabelText = commandDraft ? commandLabel(commandDraft) : "new keybinding";
1049 const canSave = Boolean(commandDraft) && keyDraft.trim().length > 0 && isWhenDraftValid;
1050
1051 const save = () => {
1052 if (!commandDraft) return;
1053 onSave({
1054 command: commandDraft,
1055 key: keyDraft,
1056 ...(whenDraftExpression.trim().length > 0 ? { when: whenDraftExpression } : {}),
1057 });
1058 };
1059
1060 const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => {
1061 // Tab is recorded like any key while recording; after that it moves focus on.
1062 if (event.key === "Tab" && !isRecording) return;
1063 event.preventDefault();
1064 if (event.key === "Escape") {
1065 setDraft({ keyDraft: "", isRecording: false });
1066 return;
1067 }
1068 const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform);
1069 if (!next) return;
1070 setDraft({ keyDraft: next, isRecording: false });
1071 };
1072
1073 return {
1074 commandDraft,
1075 setCommandDraft,
1076 keyDraft,
1077 whenDraft,
1078 whenDraftExpression,
1079 isRecording,
1080 conflictLabels,
1081 commandLabelText,
1082 canSave,
1083 setDraft,
1084 save,
1085 captureKeybinding,
1086 };
1087}
1088
1089type NewKeybindingDraft = ReturnType<typeof useNewKeybindingDraft>;
1090
1091interface NewKeybindingProps {
1092 commandOptions: ReadonlyArray<KeybindingCommandOption>;
1093 allRows: ReadonlyArray<KeybindingRow>;
1094 variables: ReadonlyArray<WhenVariableOption>;
1095 isSaving: boolean;
1096 onSave: (input: ServerUpsertKeybindingInput) => void;
1097 onCancel: () => void;
1098}
1099
1100function NewKeybindingCommandSelect({
1101 draft,
1102 commandOptions,
1103 className,
1104}: {
1105 draft: NewKeybindingDraft;
1106 commandOptions: ReadonlyArray<KeybindingCommandOption>;
1107 className?: string | undefined;
1108}) {
1109 return (
1110 <Select
1111 value={draft.commandDraft}
1112 onValueChange={(value) => draft.setCommandDraft(value as KeybindingCommand)}
1113 >
1114 <SelectTrigger size="sm" className={className}>
1115 <SelectValue placeholder="Command" />
1116 </SelectTrigger>
1117 <SelectContent alignItemWithTrigger={false} matchTriggerWidth={false} className="max-h-72">
1118 {commandOptions.map((command) => (
1119 <SelectItem key={command} value={command} className="w-full">
1120 <span className="truncate">{commandLabel(command)}</span>
1121 </SelectItem>
1122 ))}
1123 </SelectContent>
1124 </Select>
1125 );
1126}
1127
1128function NewKeybindingKeyInput({
1129 draft,
1130 autoFocus = false,
1131 className,
1132}: {
1133 draft: NewKeybindingDraft;
1134 autoFocus?: boolean;
1135 className?: string | undefined;
1136}) {
1137 return (
1138 <Input
1139 data-keybinding-capture=""
1140 autoFocus={autoFocus}
1141 aria-label={`Keybinding for ${draft.commandLabelText}`}
1142 value={draft.isRecording ? "" : draft.keyDraft}
1143 placeholder={draft.isRecording ? "Press shortcut" : "Unassigned"}
1144 size="sm"
1145 font="mono"
1146 className={className}
1147 onFocus={() => draft.setDraft({ isRecording: true })}
1148 onBlur={() => draft.setDraft({ isRecording: false })}
1149 onChange={(event) => draft.setDraft({ keyDraft: event.currentTarget.value })}
1150 onKeyDown={draft.captureKeybinding}
1151 />
1152 );
1153}
1154
1155function NewKeybindingWhen({
1156 draft,
1157 variables,
1158}: {
1159 draft: NewKeybindingDraft;
1160 variables: ReadonlyArray<WhenVariableOption>;
1161}) {
1162 return (
1163 <WhenClauseControl
1164 label={draft.commandLabelText}
1165 expression={draft.whenDraftExpression}
1166 value={draft.whenDraft}
1167 variables={variables}
1168 onChange={(whenDraft) => draft.setDraft({ whenDraft })}
1169 onValidityChange={(isWhenDraftValid) => draft.setDraft({ isWhenDraftValid })}
1170 />
1171 );
1172}
1173
1174function NewKeybindingCancelIcon({
1175 isSaving,
1176 onCancel,
1177}: {
1178 isSaving: boolean;
1179 onCancel: () => void;
1180}) {
1181 return (
1182 <Tooltip>
1183 <TooltipTrigger
1184 render={
1185 <Button
1186 type="button"
1187 variant="ghost-muted"
1188 size="icon-sm"
1189 disabled={isSaving}
1190 aria-label="Cancel new keybinding"
1191 onClick={onCancel}
1192 />
1193 }
1194 >
1195 <XIcon className="size-3.5" />
1196 </TooltipTrigger>
1197 <TooltipPopup side="top">Cancel</TooltipPopup>
1198 </Tooltip>
1199 );
1200}
1201
1202/** Add-binding form shaped like the binding rows below it. */
1203function NewKeybindingSettingsRow(props: NewKeybindingProps) {
1204 const { commandOptions, allRows, variables, isSaving, onSave, onCancel } = props;
1205 const draft = useNewKeybindingDraft({ allRows, onSave });
1206
1207 return (
1208 <SettingsRow
1209 className="bg-muted/15"
1210 title="New keybinding"
1211 description={
1212 <span className="flex h-6 items-center gap-1.5">
1213 <span className="text-xs leading-none text-muted-foreground/70">When</span>
1214 <NewKeybindingWhen draft={draft} variables={variables} />
1215 </span>
1216 }
1217 control={
1218 <div className="flex flex-wrap items-center gap-2">
1219 <NewKeybindingCommandSelect
1220 draft={draft}
1221 commandOptions={commandOptions}
1222 className="w-56"
1223 />
1224 <KeybindingConflictWarning labels={draft.conflictLabels} />
1225 <NewKeybindingKeyInput draft={draft} className="w-44" />
1226 <Button size="sm" disabled={isSaving || !draft.canSave} onClick={draft.save}>
1227 {isSaving ? "Saving" : "Save"}
1228 </Button>
1229 <NewKeybindingCancelIcon isSaving={isSaving} onCancel={onCancel} />
1230 </div>
1231 }
1232 />
1233 );
1234}
1235
1236interface KeybindingsGroupsProps extends KeybindingRowActions {
1237 groups: ReadonlyArray<KeybindingGroup>;
1238 anchorIds: ReadonlyMap<string, string>;
1239 savingCommand: KeybindingCommand | null;
1240}
1241
1242/** One titled section per command area, each holding its binding rows. */
1243function KeybindingsGroups(props: KeybindingsGroupsProps) {
1244 const { groups, anchorIds, savingCommand, ...rowActions } = props;
1245 return groups.map((group) => (
1246 <SettingsSection key={group.id} id={`keybindings-${group.id}`} title={group.title}>
1247 {group.rows.map((row) => (
1248 <KeybindingSettingsRow
1249 key={row.id}
1250 row={row}
1251 anchorId={anchorIds.get(row.id)}
1252 isSaving={savingCommand === row.command}
1253 {...rowActions}
1254 />
1255 ))}
1256 </SettingsSection>
1257 ));
1258}
1259
1260/** Shown in the browser build only; the desktop app receives every shortcut. */
1261function BrowserKeybindingNotice() {
1262 // The label carries the whole sentence so assistive tech reads it without
1263 // opening the tooltip.
1264 const message = "The browser may claim some shortcuts first. The desktop app receives them all.";
1265 return (
1266 <Tooltip>
1267 <TooltipTrigger
1268 delay={200}
1269 render={
1270 <Button size="icon-micro" variant="ghost-muted" aria-label={message}>
1271 <TriangleAlertIcon className="size-3.5 text-warning" />
1272 </Button>
1273 }
1274 />
1275 <TooltipPopup side="top" className="max-w-72">
1276 {message}
1277 </TooltipPopup>
1278 </Tooltip>
1279 );
1280}
1281
1282export function KeybindingsSettingsPanel() {
1283 // The representative environment supplies the displayed bindings; edits
1284 // fan out to every connected environment in the selection, so one
1285 // shortcut change reaches each machine the user runs T3 Code on.
1286 const { environment: primaryEnvironment, connectedEnvironments } = useSettingsScope();
1287 const canOpenKeybindingsFile = useEnvironmentScope(
1288 primaryEnvironment?.environmentId ?? null,
1289 AuthOrchestrationOperateScope,
1290 );
1291 const writableIds = useEnvironmentsWithScope(connectedEnvironments, AuthSettingsWriteScope);
1292 const canWriteSettings =
1293 connectedEnvironments.length > 0 &&
1294 connectedEnvironments.every((target) => writableIds.has(target.environmentId));
1295 const serverKeybindings = primaryEnvironment?.serverConfig?.keybindings;
1296 const keybindings = useMemo(
1297 () => mergeWithDefaultKeybindings(serverKeybindings ?? []),
1298 [serverKeybindings],
1299 );
1300 const keybindingsConfigPath = primaryEnvironment?.serverConfig?.keybindingsConfigPath ?? null;
1301 const availableEditors = primaryEnvironment?.serverConfig?.availableEditors ?? [];
1302 const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, {
1303 reportFailure: false,
1304 });
1305 const removeKeybindingMutation = useAtomCommand(serverEnvironment.removeKeybinding, {
1306 reportFailure: false,
1307 });
1308 const openInPreferredEditor = useOpenInPreferredEditor(
1309 primaryEnvironment?.environmentId ?? null,
1310 availableEditors,
1311 );
1312 const [query, setQuery] = useState("");
1313 const searchInputRef = useRef<HTMLInputElement>(null);
1314 const [savingCommand, setSavingCommand] = useState<KeybindingCommand | null>(null);
1315 const [isAddingBinding, setIsAddingBinding] = useState(false);
1316 const rows = useMemo(() => buildKeybindingRows(keybindings, query), [keybindings, query]);
1317 const groups = useMemo(() => groupKeybindingRows(rows), [rows]);
1318 // Settings search jumps to a command, so only its first row anchors.
1319 const anchorIds = useMemo(() => {
1320 const ids = new Map<string, string>();
1321 const seen = new Set<KeybindingCommand>();
1322 for (const row of rows) {
1323 if (seen.has(row.command)) continue;
1324 seen.add(row.command);
1325 ids.set(row.id, keybindingSearchAnchorId(row.command));
1326 }
1327 return ids;
1328 }, [rows]);
1329 // The search-target context is provided by this panel's own page container,
1330 // so the jump target is read from the route hash here.
1331 const searchTargetId = useLocation({ select: (location) => location.hash.replace(/^#/, "") });
1332 const [handledSearchTargetId, setHandledSearchTargetId] = useState(searchTargetId);
1333
1334 // A settings-search jump must not be hidden by the page's own filter.
1335 if (searchTargetId !== handledSearchTargetId) {
1336 setHandledSearchTargetId(searchTargetId);
1337 if (searchTargetId.startsWith("keybinding-")) setQuery("");
1338 }
1339 const commandOptions = useMemo(() => buildKeybindingCommandOptions(keybindings), [keybindings]);
1340 const whenVariables = useMemo(() => buildWhenVariableOptions(), []);
1341
1342 const openKeybindingsFile = useCallback(() => {
1343 if (
1344 !keybindingsConfigPath ||
1345 !primaryEnvironment ||
1346 !readEnvironmentScope(primaryEnvironment.environmentId, AuthOrchestrationOperateScope)
1347 )
1348 return;
1349 void (async () => {
1350 const result = await openInPreferredEditor(keybindingsConfigPath);
1351 if (result._tag === "Success" || isAtomCommandInterrupted(result)) {
1352 return;
1353 }
1354 const error = squashAtomCommandFailure(result);
1355 toastManager.add({
1356 title: "Unable to open keybindings file",
1357 description:
1358 error instanceof Error ? error.message : "The keybindings file was not opened.",
1359 type: "error",
1360 });
1361 })();
1362 }, [keybindingsConfigPath, openInPreferredEditor, primaryEnvironment]);
1363
1364 const saveKeybinding = useCallback(
1365 (input: ServerUpsertKeybindingInput) => {
1366 if (
1367 !primaryEnvironment ||
1368 !connectedEnvironments.every((target) =>
1369 readEnvironmentScope(target.environmentId, AuthSettingsWriteScope),
1370 )
1371 )
1372 return;
1373 setSavingCommand(input.command);
1374 const payload: ServerUpsertKeybindingInput = {
1375 command: input.command,
1376 key: input.key.trim(),
1377 ...(input.when?.trim() ? { when: input.when.trim() } : {}),
1378 ...(input.replace ? { replace: input.replace } : {}),
1379 };
1380 void (async () => {
1381 const results = await Promise.all(
1382 connectedEnvironments.map((target) =>
1383 upsertKeybinding({ environmentId: target.environmentId, input: payload }),
1384 ),
1385 );
1386 setSavingCommand(null);
1387 const failed = results.find((result) => result._tag === "Failure");
1388 if (!failed) {
1389 setIsAddingBinding(false);
1390 return;
1391 }
1392 if (!isAtomCommandInterrupted(failed)) {
1393 const error = squashAtomCommandFailure(failed);
1394 toastManager.add({
1395 title: "Unable to save keybinding",
1396 description: error instanceof Error ? error.message : "The keybinding was not saved.",
1397 type: "error",
1398 });
1399 }
1400 })();
1401 },
1402 [connectedEnvironments, primaryEnvironment, upsertKeybinding],
1403 );
1404
1405 const removeKeybinding = useCallback(
1406 (row: KeybindingRow) => {
1407 if (
1408 !primaryEnvironment ||
1409 !connectedEnvironments.every((target) =>
1410 readEnvironmentScope(target.environmentId, AuthSettingsWriteScope),
1411 )
1412 )
1413 return;
1414 setSavingCommand(row.command);
1415 void (async () => {
1416 const results = await Promise.all(
1417 connectedEnvironments.map((target) =>
1418 removeKeybindingMutation({
1419 environmentId: target.environmentId,
1420 input: rowKeybindingTarget(row),
1421 }),
1422 ),
1423 );
1424 setSavingCommand(null);
1425 const result = results.find((entry) => entry._tag === "Failure") ?? results[0];
1426 if (result?._tag === "Failure" && !isAtomCommandInterrupted(result)) {
1427 const error = squashAtomCommandFailure(result);
1428 toastManager.add({
1429 title: "Unable to remove keybinding",
1430 description: error instanceof Error ? error.message : "The keybinding was not removed.",
1431 type: "error",
1432 });
1433 }
1434 })();
1435 },
1436 [connectedEnvironments, primaryEnvironment, removeKeybindingMutation],
1437 );
1438
1439 const resetKeybinding = useCallback(
1440 (row: KeybindingRow) => {
1441 if (!row.defaultKey) return;
1442 saveKeybinding({
1443 command: row.command,
1444 key: row.defaultKey,
1445 when: row.defaultWhen.trim().length > 0 ? row.defaultWhen : undefined,
1446 replace: {
1447 command: row.command,
1448 key: row.key,
1449 ...(row.when.trim().length > 0 ? { when: row.when } : {}),
1450 },
1451 });
1452 },
1453 [saveKeybinding],
1454 );
1455
1456 const cancelAdd = useCallback(() => setIsAddingBinding(false), []);
1457
1458 const rowActions: KeybindingRowActions = {
1459 allRows: rows,
1460 variables: whenVariables,
1461 onSave: saveKeybinding,
1462 onReset: resetKeybinding,
1463 onRemove: removeKeybinding,
1464 };
1465
1466 return (
1467 <SettingsPageContainer>
1468 <SettingsSection
1469 {...searchableSetting("keybindings")}
1470 headerAction={!isElectron ? <BrowserKeybindingNotice /> : null}
1471 >
1472 <div className="flex flex-wrap items-center gap-2 px-3 py-3 sm:px-4">
1473 <KeybindingsSearchInput query={query} onChange={setQuery} inputRef={searchInputRef} />
1474 <Button
1475 type="button"
1476 variant="outline"
1477 disabled={isAddingBinding || !canWriteSettings}
1478 onClick={() => setIsAddingBinding(true)}
1479 >
1480 <PlusIcon aria-hidden className="size-4" />
1481 Add keybinding
1482 </Button>
1483 <Tooltip>
1484 <TooltipTrigger
1485 render={
1486 <Button
1487 type="button"
1488 variant="outline"
1489 size="icon"
1490 disabled={!keybindingsConfigPath || !canOpenKeybindingsFile}
1491 onClick={openKeybindingsFile}
1492 aria-label="Open keybindings.json"
1493 >
1494 <FileJsonIcon aria-hidden className="size-4" />
1495 </Button>
1496 }
1497 />
1498 <TooltipPopup side="top">Open keybindings.json</TooltipPopup>
1499 </Tooltip>
1500 </div>
1501 </SettingsSection>
1502
1503 {!canWriteSettings ? (
1504 <p className="text-xs text-muted-foreground">
1505 {connectedEnvironments.length === 0
1506 ? "Connect an environment to change keybindings."
1507 : "This connection can view keybindings but cannot change them."}
1508 </p>
1509 ) : null}
1510 <div inert={!canWriteSettings} className="flex flex-col gap-8">
1511 {isAddingBinding ? (
1512 <SettingsGroup>
1513 <NewKeybindingSettingsRow
1514 commandOptions={commandOptions}
1515 allRows={rows}
1516 variables={whenVariables}
1517 isSaving={savingCommand !== null}
1518 onSave={saveKeybinding}
1519 onCancel={cancelAdd}
1520 />
1521 </SettingsGroup>
1522 ) : null}
1523
1524 {groups.length > 0 ? (
1525 <KeybindingsGroups
1526 groups={groups}
1527 anchorIds={anchorIds}
1528 savingCommand={savingCommand}
1529 {...rowActions}
1530 />
1531 ) : (
1532 <SettingsGroup>
1533 <div className="px-4 py-12 text-center text-sm text-muted-foreground">
1534 No keybindings match your search.
1535 </div>
1536 </SettingsGroup>
1537 )}
1538 </div>
1539 </SettingsPageContainer>
1540 );
1541}