authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-08-29 07:45:10+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-28 22:45:10-07:00
logbe218ac768d809087bfcea4d8f15c431f0d70e16
treeb08a345b9997bc1ba8e4611d092c7f444391b30b
parentd22709f759ced90b0ae7b7cb73d7ad22c2ce962b
signature Signed by PGP key B5690EEEBB952194

feat(web): keybinding settings as settings rows (#8532)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

1 files changed, 545 insertions(+), 309 deletions(-)

apps/web/src/components/settings/KeybindingsSettings.tsx+545-309
......@@ -3,7 +3,6 @@ import {
33 CircleXIcon,
44 EllipsisIcon,
55 FileJsonIcon,
6 InfoIcon,
76 MinusIcon,
87 PlusIcon,
98 SearchIcon,
......@@ -44,12 +43,12 @@ import {
4443 serverEnvironment,
4544} from "../../state/server";
4645import { usePrimaryEnvironment } from "../../state/environments";
46import { Badge } from "../ui/badge";
4747import { Button } from "../ui/button";
4848import { Input } from "../ui/input";
4949import { Kbd, KbdGroup } from "../ui/kbd";
5050import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
5151import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
52import { ScrollArea } from "../ui/scroll-area";
5352import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
5453import { Toggle } from "../ui/toggle";
5554import { toastManager } from "../ui/toast";
......@@ -69,17 +68,23 @@ import {
6968 unknownWhenVariables,
7069 whenAstToExpression,
7170} from "./KeybindingsSettings.logic";
72import { SettingsPageContainer, SettingsSection } from "./settingsLayout";
71import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";
7372import { searchableSetting } from "./settingsSearch";
7473import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
7574import { useAtomCommand } from "../../state/use-atom-command";
7675
7776function KeybindingPill({ value }: { value: string }) {
78 const parts = value.split("+");
77 // Keys dedupe repeated parts; a literal "+" in a shortcut splits into empty strings.
78 const seenParts = new Map<string, number>();
79 const parts = value.split("+").map((part) => {
80 const seen = seenParts.get(part) ?? 0;
81 seenParts.set(part, seen + 1);
82 return { part, key: seen === 0 ? part : `${part}-${seen}` };
83 });
7984 return (
8085 <KbdGroup className="bg-transparent p-0 shadow-none">
81 {parts.map((part) => (
82 <Kbd key={part} className="min-w-6 justify-center px-1.5">
86 {parts.map(({ part, key }) => (
87 <Kbd key={key} className="min-w-6 justify-center px-1.5">
8388 {part === "mod"
8489 ? navigator.platform.toLowerCase().includes("mac")
8590 ? "⌘"
......@@ -231,19 +236,18 @@ function defaultWhenGroup(operator: BooleanOperator = "and"): KeybindingWhenNode
231236 };
232237}
233238
234function UnknownWhenVariableWarning({
235 identifiers,
239/** Warning glyph whose explanation lives in a tooltip; the one owner of that affordance here. */
240function WarningTooltipIcon({
241 label,
236242 focusable = true,
243 className,
244 children,
237245}: {
238 identifiers: ReadonlyArray<string>;
246 label: string;
239247 focusable?: boolean;
248 className?: string | undefined;
249 children: ReactNode;
240250}) {
241 if (identifiers.length === 0) return null;
242 const label =
243 identifiers.length === 1
244 ? `Unknown condition: ${identifiers[0]}`
245 : `Unknown conditions: ${identifiers.join(", ")}`;
246
247251 return (
248252 <Tooltip>
249253 <TooltipTrigger
......@@ -251,20 +255,43 @@ function UnknownWhenVariableWarning({
251255 <span
252256 tabIndex={focusable ? 0 : undefined}
253257 aria-label={label}
254 className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-sm text-warning outline-none transition-colors hover:bg-warning/10 focus-visible:ring-[3px] focus-visible:ring-warning/25"
255 >
256 <TriangleAlertIcon className="size-3.5" />
257 </span>
258 className={cn(
259 "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-[3px] focus-visible:ring-warning/25",
260 className,
261 )}
262 />
258263 }
259 />
264 >
265 <TriangleAlertIcon className="size-3.5" />
266 </TooltipTrigger>
260267 <TooltipPopup side="top" className="max-w-72 whitespace-normal leading-relaxed">
261 T3 Code does not recognize this condition yet. It can still be saved, but it may not match
262 unless the runtime provides it.
268 {children}
263269 </TooltipPopup>
264270 </Tooltip>
265271 );
266272}
267273
274function UnknownWhenVariableWarning({
275 identifiers,
276 focusable = true,
277}: {
278 identifiers: ReadonlyArray<string>;
279 focusable?: boolean;
280}) {
281 if (identifiers.length === 0) return null;
282 const label =
283 identifiers.length === 1
284 ? `Unknown condition: ${identifiers[0]}`
285 : `Unknown conditions: ${identifiers.join(", ")}`;
286
287 return (
288 <WarningTooltipIcon label={label} focusable={focusable} className="size-4.5">
289 T3 Code does not recognize this condition yet. It can still be saved, but it may not match
290 unless the runtime provides it.
291 </WarningTooltipIcon>
292 );
293}
294
268295function KeybindingConflictWarning({ labels }: { labels: ReadonlyArray<string> }) {
269296 if (labels.length === 0) return null;
270297 const description =
......@@ -273,22 +300,9 @@ function KeybindingConflictWarning({ labels }: { labels: ReadonlyArray<string> }
273300 : `Conflicts with ${labels.slice(0, 3).join(", ")}${labels.length > 3 ? ", and more" : ""}.`;
274301
275302 return (
276 <Tooltip>
277 <TooltipTrigger
278 render={
279 <span
280 tabIndex={0}
281 aria-label={description}
282 className="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-[3px] focus-visible:ring-warning/25"
283 >
284 <TriangleAlertIcon className="size-3.5" />
285 </span>
286 }
287 />
288 <TooltipPopup side="top" className="max-w-72 whitespace-normal leading-relaxed">
289 {description} The most recent matching binding wins when both conditions can apply.
290 </TooltipPopup>
291 </Tooltip>
303 <WarningTooltipIcon label={description}>
304 {description} The most recent matching binding wins when both conditions can apply.
305 </WarningTooltipIcon>
292306 );
293307}
294308
......@@ -733,32 +747,20 @@ function rowKeybindingTarget(row: KeybindingRow): ServerRemoveKeybindingInput {
733747 };
734748}
735749
736function KeybindingTableRow({
750/** Draft state and actions for editing one existing binding; layouts decide how to render it. */
751function useKeybindingRowEditor({
737752 row,
738753 allRows,
739 variables,
740 isSaving,
741754 onSave,
742 onReset,
743 onRemove,
744755}: {
745756 row: KeybindingRow;
746757 allRows: ReadonlyArray<KeybindingRow>;
747 variables: ReadonlyArray<WhenVariableOption>;
748 isSaving: boolean;
749758 onSave: (input: ServerUpsertKeybindingInput) => void;
750 onReset: (row: KeybindingRow) => void;
751 onRemove: (row: KeybindingRow) => void;
752759}) {
753760 const [draft, setDraft] = useReducer(keybindingRowDraftReducer, row, createKeybindingRowDraft);
754761 const { keyDraft, whenDraft, isRecording, isWhenDraftValid } = draft;
755762 const whenDraftExpression = whenAstToExpression(whenDraft);
756763 const isDirty = keyDraft !== row.key || whenDraftExpression !== row.when;
757 const displayShortcut = formatShortcutLabel(row.binding.shortcut);
758 const canReset = row.source === "Custom" && row.defaultKey !== null;
759 const canRemove = row.source !== "Default";
760 const hasRowActions = canReset || canRemove;
761 const showPill = !isRecording && keyDraft === row.key && row.key.length > 0 && !isDirty;
762764 const conflictLabels = keybindingConflictLabels(allRows, {
763765 rowId: row.id,
764766 key: keyDraft,
......@@ -786,139 +788,277 @@ function KeybindingTableRow({
786788 setDraft({ keyDraft: next, isRecording: false });
787789 };
788790
791 return {
792 keyDraft,
793 whenDraft,
794 isRecording,
795 isWhenDraftValid,
796 whenDraftExpression,
797 isDirty,
798 conflictLabels,
799 setDraft,
800 save,
801 captureKeybinding,
802 };
803}
804
805type KeybindingRowEditor = ReturnType<typeof useKeybindingRowEditor>;
806
807interface KeybindingRowActions {
808 allRows: ReadonlyArray<KeybindingRow>;
809 variables: ReadonlyArray<WhenVariableOption>;
810 onSave: (input: ServerUpsertKeybindingInput) => void;
811 onReset: (row: KeybindingRow) => void;
812 onRemove: (row: KeybindingRow) => void;
813}
814
815type KeybindingRowProps = KeybindingRowActions & { row: KeybindingRow; isSaving: boolean };
816
817/** Shortcut pill that turns into a capture input when clicked, plus Save once the draft changes. */
818function KeybindingKeyControl({
819 row,
820 editor,
821 isSaving,
822 pillClassName,
823}: {
824 row: KeybindingRow;
825 editor: KeybindingRowEditor;
826 isSaving: boolean;
827 pillClassName?: string | undefined;
828}) {
829 const { keyDraft, isRecording, isDirty, isWhenDraftValid, setDraft, save, captureKeybinding } =
830 editor;
831 const showPill = !isRecording && keyDraft === row.key && row.key.length > 0 && !isDirty;
832
789833 return (
790 <div className="grid grid-cols-[minmax(190px,1.1fr)_minmax(220px,0.85fr)_minmax(210px,1fr)_60px] items-center px-4 py-1.5 text-sm even:bg-muted/15 hover:bg-accent/40">
791 <div className="min-w-0 pr-4">
792 <div className="flex min-w-0 items-center gap-1.5">
793 <Tooltip>
794 <TooltipTrigger
795 render={
796 <div
797 aria-label={row.command}
798 className="truncate text-[13px] font-medium text-foreground"
799 />
800 }
801 >
802 {commandLabel(row.command)}
803 </TooltipTrigger>
804 <TooltipPopup side="top">{row.command}</TooltipPopup>
805 </Tooltip>
806 </div>
807 </div>
808 <div className="flex min-w-0 items-center gap-2 pr-4">
809 {showPill ? (
810 <button
811 type="button"
812 onClick={() => setDraft({ isRecording: true })}
813 aria-label={`Edit shortcut for ${commandLabel(row.command)}`}
814 className="group inline-flex h-7 items-center gap-1.5 rounded-md border border-transparent px-1.5 outline-none transition-colors hover:border-border/70 hover:bg-background focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24"
815 >
816 <KeybindingPill value={row.key} />
817 <span className="text-[10px] uppercase tracking-[0.08em] text-muted-foreground/0 transition-opacity group-hover:text-muted-foreground/70 group-focus-visible:text-muted-foreground/70">
818 Edit
819 </span>
820 </button>
821 ) : (
822 <Input
823 data-keybinding-capture=""
824 autoFocus={isRecording}
825 aria-label={`Keybinding for ${commandLabel(row.command)}`}
826 value={isRecording ? "" : keyDraft}
827 placeholder={isRecording ? "Press shortcut" : "Unassigned"}
828 className={cn(
829 "h-7 w-44 rounded-md font-mono text-[12px] sm:h-7",
830 isRecording && "border-primary/70 bg-primary/5",
831 )}
832 onFocus={() => setDraft({ isRecording: true })}
833 onBlur={() => setDraft({ isRecording: false })}
834 onChange={(event) => setDraft({ keyDraft: event.currentTarget.value })}
835 onKeyDown={captureKeybinding}
834 <>
835 {isDirty ? (
836 <Button
837 size="compact"
838 disabled={isSaving || keyDraft.trim().length === 0 || !isWhenDraftValid}
839 onClick={save}
840 >
841 {isSaving ? "Saving" : "Save"}
842 </Button>
843 ) : null}
844 {showPill ? (
845 <button
846 type="button"
847 onClick={() => setDraft({ isRecording: true })}
848 aria-label={`Edit shortcut for ${commandLabel(row.command)}: ${formatShortcutLabel(row.binding.shortcut)}`}
849 className={cn(
850 "inline-flex h-7 cursor-pointer items-center rounded-md border border-transparent px-1.5 outline-none transition-colors hover:border-border/70 hover:bg-accent focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24",
851 pillClassName,
852 )}
853 >
854 <KeybindingPill value={row.key} />
855 </button>
856 ) : (
857 <Input
858 data-keybinding-capture=""
859 autoFocus={isRecording}
860 aria-label={`Keybinding for ${commandLabel(row.command)}`}
861 value={isRecording ? "" : keyDraft}
862 placeholder={isRecording ? "Press shortcut" : "Unassigned"}
863 size="compact"
864 className={cn("w-44 font-mono", isRecording && "border-primary/70 bg-primary/5")}
865 onFocus={() => setDraft({ isRecording: true })}
866 onBlur={() => setDraft({ isRecording: false })}
867 onChange={(event) => setDraft({ keyDraft: event.currentTarget.value })}
868 onKeyDown={captureKeybinding}
869 />
870 )}
871 </>
872 );
873}
874
875/** Quiet inline trigger showing the when clause; opens the expression builder. */
876function WhenClauseControl({
877 label,
878 expression,
879 value,
880 variables,
881 onChange,
882 onValidityChange,
883}: {
884 label: string;
885 expression: string;
886 value: KeybindingWhenNode | undefined;
887 variables: ReadonlyArray<WhenVariableOption>;
888 onChange: (value: KeybindingWhenNode | undefined) => void;
889 onValidityChange: (valid: boolean) => void;
890}) {
891 return (
892 <Popover>
893 <PopoverTrigger
894 render={
895 <Button
896 variant={expression ? "ghost" : "ghost-muted"}
897 size="micro"
898 className="min-w-0 shrink font-mono"
836899 />
837 )}
838 {isDirty ? (
900 }
901 aria-label={`Edit when clause for ${label}`}
902 >
903 <span className="truncate">{expression || "Always"}</span>
904 <ChevronDownIcon className="size-3.5 shrink-0 opacity-60" />
905 </PopoverTrigger>
906 <PopoverContent align="start" sideOffset={6}>
907 <WhenExpressionBuilder
908 value={value}
909 variables={variables}
910 onChange={onChange}
911 onValidityChange={onValidityChange}
912 />
913 </PopoverContent>
914 </Popover>
915 );
916}
917
918function KeybindingRowMenu({
919 row,
920 isSaving,
921 onReset,
922 onRemove,
923}: {
924 row: KeybindingRow;
925 isSaving: boolean;
926 onReset: (row: KeybindingRow) => void;
927 onRemove: (row: KeybindingRow) => void;
928}) {
929 const canReset = row.source === "Custom" && row.defaultKey !== null;
930 const canRemove = row.source !== "Default";
931 if (!canReset && !canRemove) return null;
932
933 return (
934 <Menu>
935 <MenuTrigger
936 render={
839937 <Button
840 size="compact"
841 disabled={isSaving || keyDraft.trim().length === 0 || !isWhenDraftValid}
842 onClick={save}
843 >
844 {isSaving ? "Saving" : "Save"}
845 </Button>
938 type="button"
939 variant="ghost"
940 size="icon-sm"
941 className="size-7 text-muted-foreground hover:text-foreground sm:size-7"
942 disabled={isSaving}
943 aria-label={`Actions for ${commandLabel(row.command)}`}
944 />
945 }
946 >
947 <EllipsisIcon className="size-3.5" />
948 </MenuTrigger>
949 <MenuPopup align="end" className="min-w-36">
950 {canReset ? (
951 <MenuItem disabled={isSaving} onClick={() => onReset(row)}>
952 Reset to default
953 </MenuItem>
846954 ) : null}
847 </div>
848 <div className="pr-4">
849 <Popover>
850 <PopoverTrigger
851 className={cn(
852 "inline-flex h-7 w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-2.5 text-left font-mono text-[12px] text-foreground shadow-xs/5 outline-none transition-colors hover:bg-accent focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24",
853 !whenDraftExpression && "text-muted-foreground",
854 )}
855 aria-label={`Edit when clause for ${commandLabel(row.command)}`}
856 >
857 <span className="truncate">{whenDraftExpression || "Always"}</span>
858 <ChevronDownIcon className="size-3.5 shrink-0 opacity-60" />
859 </PopoverTrigger>
860 <PopoverContent align="start" sideOffset={6}>
861 <WhenExpressionBuilder
862 value={whenDraft}
863 variables={variables}
864 onChange={(nextWhenDraft) => setDraft({ whenDraft: nextWhenDraft })}
865 onValidityChange={(nextIsValid) => setDraft({ isWhenDraftValid: nextIsValid })}
866 />
867 </PopoverContent>
868 </Popover>
869 </div>
870 <div className="flex items-center justify-end gap-1">
871 <KeybindingConflictWarning labels={conflictLabels} />
872 {hasRowActions ? (
873 <Menu>
874 <MenuTrigger
875 render={
876 <Button
877 type="button"
878 variant="ghost"
879 size="icon-sm"
880 className="size-7 text-muted-foreground hover:text-foreground sm:size-7"
881 disabled={isSaving}
882 aria-label={`Actions for ${commandLabel(row.command)}`}
883 />
884 }
885 >
886 <EllipsisIcon className="size-3.5" />
887 </MenuTrigger>
888 <MenuPopup align="end" className="min-w-36">
889 {canReset ? (
890 <MenuItem disabled={isSaving} onClick={() => onReset(row)}>
891 Reset to default
892 </MenuItem>
893 ) : null}
894 {canRemove ? (
895 <MenuItem variant="destructive" disabled={isSaving} onClick={() => onRemove(row)}>
896 Remove
897 </MenuItem>
898 ) : null}
899 </MenuPopup>
900 </Menu>
955 {canRemove ? (
956 <MenuItem variant="destructive" disabled={isSaving} onClick={() => onRemove(row)}>
957 Remove
958 </MenuItem>
901959 ) : null}
902 <span className="sr-only">{displayShortcut}</span>
903 </div>
904 </div>
960 </MenuPopup>
961 </Menu>
905962 );
906963}
907964
908function NewKeybindingTableRow({
909 commandOptions,
910 allRows,
965function KeybindingSourceBadge({ source }: { source: KeybindingRow["source"] }) {
966 if (source === "Default") return null;
967 return (
968 <Badge variant="outline" size="sm" className="font-normal text-muted-foreground">
969 {source}
970 </Badge>
971 );
972}
973
974function KeybindingRowTitle({ row }: { row: KeybindingRow }) {
975 return (
976 <Tooltip>
977 <TooltipTrigger render={<span className="flex items-center gap-2" />}>
978 {commandLabel(row.command)}
979 <KeybindingSourceBadge source={row.source} />
980 </TooltipTrigger>
981 <TooltipPopup side="top">{row.command}</TooltipPopup>
982 </Tooltip>
983 );
984}
985
986function KeybindingRowWhen({
987 row,
988 editor,
911989 variables,
912 isSaving,
913 onSave,
914 onCancel,
915990}: {
916 commandOptions: ReadonlyArray<KeybindingCommandOption>;
917 allRows: ReadonlyArray<KeybindingRow>;
991 row: KeybindingRow;
992 editor: KeybindingRowEditor;
918993 variables: ReadonlyArray<WhenVariableOption>;
994}) {
995 return (
996 <span className="flex h-6 items-center gap-1.5">
997 <span className="text-[12px] leading-none text-muted-foreground/70">When</span>
998 <WhenClauseControl
999 label={commandLabel(row.command)}
1000 expression={editor.whenDraftExpression}
1001 value={editor.whenDraft}
1002 variables={variables}
1003 onChange={(whenDraft) => editor.setDraft({ whenDraft })}
1004 onValidityChange={(isWhenDraftValid) => editor.setDraft({ isWhenDraftValid })}
1005 />
1006 </span>
1007 );
1008}
1009
1010/** Row actions that stay hidden until the row is hovered or holds focus. */
1011function KeybindingHoverRowMenu(props: {
1012 row: KeybindingRow;
9191013 isSaving: boolean;
1014 onReset: (row: KeybindingRow) => void;
1015 onRemove: (row: KeybindingRow) => void;
1016}) {
1017 return (
1018 <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">
1019 <KeybindingRowMenu {...props} />
1020 </span>
1021 );
1022}
1023
1024/** One binding as a settings row: pills flush right, actions fading in beside them on hover. */
1025function KeybindingSettingsRow(props: KeybindingRowProps) {
1026 const { row, isSaving, allRows, variables, onSave, onReset, onRemove } = props;
1027 const editor = useKeybindingRowEditor({ row, allRows, onSave });
1028
1029 return (
1030 <SettingsRow
1031 className="group/row rounded-none"
1032 title={<KeybindingRowTitle row={row} />}
1033 description={<KeybindingRowWhen row={row} editor={editor} variables={variables} />}
1034 control={
1035 <div className="flex flex-wrap items-center justify-end gap-1.5">
1036 <KeybindingConflictWarning labels={editor.conflictLabels} />
1037 <KeybindingHoverRowMenu
1038 row={row}
1039 isSaving={isSaving}
1040 onReset={onReset}
1041 onRemove={onRemove}
1042 />
1043 <KeybindingKeyControl
1044 row={row}
1045 editor={editor}
1046 isSaving={isSaving}
1047 pillClassName="-mr-1.5"
1048 />
1049 </div>
1050 }
1051 />
1052 );
1053}
1054
1055/** Draft state for a binding that does not exist yet. */
1056function useNewKeybindingDraft({
1057 allRows,
1058 onSave,
1059}: {
1060 allRows: ReadonlyArray<KeybindingRow>;
9201061 onSave: (input: ServerUpsertKeybindingInput) => void;
921 onCancel: () => void;
9221062}) {
9231063 const [commandDraft, setCommandDraft] = useState<KeybindingCommand | "">("");
9241064 const [draft, setDraft] = useReducer(keybindingRowDraftReducer, {
......@@ -935,6 +1075,7 @@ function NewKeybindingTableRow({
9351075 when: whenDraftExpression,
9361076 });
9371077 const commandLabelText = commandDraft ? commandLabel(commandDraft) : "new keybinding";
1078 const canSave = Boolean(commandDraft) && keyDraft.trim().length > 0 && isWhenDraftValid;
9381079
9391080 const save = () => {
9401081 if (!commandDraft) return;
......@@ -957,93 +1098,222 @@ function NewKeybindingTableRow({
9571098 setDraft({ keyDraft: next, isRecording: false });
9581099 };
9591100
1101 return {
1102 commandDraft,
1103 setCommandDraft,
1104 keyDraft,
1105 whenDraft,
1106 whenDraftExpression,
1107 isRecording,
1108 conflictLabels,
1109 commandLabelText,
1110 canSave,
1111 setDraft,
1112 save,
1113 captureKeybinding,
1114 };
1115}
1116
1117type NewKeybindingDraft = ReturnType<typeof useNewKeybindingDraft>;
1118
1119interface NewKeybindingProps {
1120 commandOptions: ReadonlyArray<KeybindingCommandOption>;
1121 allRows: ReadonlyArray<KeybindingRow>;
1122 variables: ReadonlyArray<WhenVariableOption>;
1123 isSaving: boolean;
1124 onSave: (input: ServerUpsertKeybindingInput) => void;
1125 onCancel: () => void;
1126}
1127
1128function NewKeybindingCommandSelect({
1129 draft,
1130 commandOptions,
1131 className,
1132}: {
1133 draft: NewKeybindingDraft;
1134 commandOptions: ReadonlyArray<KeybindingCommandOption>;
1135 className?: string | undefined;
1136}) {
9601137 return (
961 <div className="grid grid-cols-[minmax(190px,1.1fr)_minmax(220px,0.85fr)_minmax(210px,1fr)_60px] items-center px-4 py-1.5 text-sm even:bg-muted/15 hover:bg-accent/40">
962 <div className="min-w-0 pr-4">
963 <Select
964 value={commandDraft}
965 onValueChange={(value) => setCommandDraft(value as KeybindingCommand)}
966 >
967 <SelectTrigger size="compact" className="w-full max-w-60">
968 <SelectValue placeholder="Command" />
969 </SelectTrigger>
970 <SelectContent
971 alignItemWithTrigger={false}
972 matchTriggerWidth={false}
973 className="max-h-72 w-fit min-w-56"
974 >
975 {commandOptions.map((command) => (
976 <SelectItem key={command} value={command} className="min-h-7 w-full py-1 text-[12px]">
977 <span className="truncate">{commandLabel(command)}</span>
978 </SelectItem>
979 ))}
980 </SelectContent>
981 </Select>
982 </div>
983 <div className="flex min-w-0 items-center gap-2 pr-4">
984 <Input
985 data-keybinding-capture=""
986 aria-label={`Keybinding for ${commandLabelText}`}
987 value={isRecording ? "" : keyDraft}
988 placeholder={isRecording ? "Press shortcut" : "Unassigned"}
989 size="compact"
990 className={cn("w-44 font-mono", isRecording && "border-primary/70 bg-primary/5")}
991 onFocus={() => setDraft({ isRecording: true })}
992 onBlur={() => setDraft({ isRecording: false })}
993 onChange={(event) => setDraft({ keyDraft: event.currentTarget.value })}
994 onKeyDown={captureKeybinding}
1138 <Select
1139 value={draft.commandDraft}
1140 onValueChange={(value) => draft.setCommandDraft(value as KeybindingCommand)}
1141 >
1142 <SelectTrigger size="compact" className={className}>
1143 <SelectValue placeholder="Command" />
1144 </SelectTrigger>
1145 <SelectContent
1146 alignItemWithTrigger={false}
1147 matchTriggerWidth={false}
1148 className="max-h-72 w-fit min-w-56"
1149 >
1150 {commandOptions.map((command) => (
1151 <SelectItem key={command} value={command} className="min-h-7 w-full py-1 text-[12px]">
1152 <span className="truncate">{commandLabel(command)}</span>
1153 </SelectItem>
1154 ))}
1155 </SelectContent>
1156 </Select>
1157 );
1158}
1159
1160function NewKeybindingKeyInput({
1161 draft,
1162 autoFocus = false,
1163 className,
1164}: {
1165 draft: NewKeybindingDraft;
1166 autoFocus?: boolean;
1167 className?: string | undefined;
1168}) {
1169 return (
1170 <Input
1171 data-keybinding-capture=""
1172 autoFocus={autoFocus}
1173 aria-label={`Keybinding for ${draft.commandLabelText}`}
1174 value={draft.isRecording ? "" : draft.keyDraft}
1175 placeholder={draft.isRecording ? "Press shortcut" : "Unassigned"}
1176 size="compact"
1177 className={cn("font-mono", draft.isRecording && "border-primary/70 bg-primary/5", className)}
1178 onFocus={() => draft.setDraft({ isRecording: true })}
1179 onBlur={() => draft.setDraft({ isRecording: false })}
1180 onChange={(event) => draft.setDraft({ keyDraft: event.currentTarget.value })}
1181 onKeyDown={draft.captureKeybinding}
1182 />
1183 );
1184}
1185
1186function NewKeybindingWhen({
1187 draft,
1188 variables,
1189}: {
1190 draft: NewKeybindingDraft;
1191 variables: ReadonlyArray<WhenVariableOption>;
1192}) {
1193 return (
1194 <WhenClauseControl
1195 label={draft.commandLabelText}
1196 expression={draft.whenDraftExpression}
1197 value={draft.whenDraft}
1198 variables={variables}
1199 onChange={(whenDraft) => draft.setDraft({ whenDraft })}
1200 onValidityChange={(isWhenDraftValid) => draft.setDraft({ isWhenDraftValid })}
1201 />
1202 );
1203}
1204
1205function NewKeybindingCancelIcon({
1206 isSaving,
1207 onCancel,
1208}: {
1209 isSaving: boolean;
1210 onCancel: () => void;
1211}) {
1212 return (
1213 <Tooltip>
1214 <TooltipTrigger
1215 render={
1216 <Button
1217 type="button"
1218 variant="ghost"
1219 size="icon-sm"
1220 className="size-7 text-muted-foreground hover:text-foreground"
1221 disabled={isSaving}
1222 aria-label="Cancel new keybinding"
1223 onClick={onCancel}
1224 />
1225 }
1226 >
1227 <XIcon className="size-3.5" />
1228 </TooltipTrigger>
1229 <TooltipPopup side="top">Cancel</TooltipPopup>
1230 </Tooltip>
1231 );
1232}
1233
1234/** Add-binding form shaped like the binding rows below it. */
1235function NewKeybindingSettingsRow(props: NewKeybindingProps) {
1236 const { commandOptions, allRows, variables, isSaving, onSave, onCancel } = props;
1237 const draft = useNewKeybindingDraft({ allRows, onSave });
1238
1239 return (
1240 <SettingsRow
1241 className="rounded-none bg-muted/15"
1242 title="New keybinding"
1243 description={
1244 <span className="flex h-6 items-center gap-1.5">
1245 <span className="text-[12px] leading-none text-muted-foreground/70">When</span>
1246 <NewKeybindingWhen draft={draft} variables={variables} />
1247 </span>
1248 }
1249 control={
1250 <div className="flex flex-wrap items-center gap-2">
1251 <NewKeybindingCommandSelect
1252 draft={draft}
1253 commandOptions={commandOptions}
1254 className="w-56"
1255 />
1256 <KeybindingConflictWarning labels={draft.conflictLabels} />
1257 <NewKeybindingKeyInput draft={draft} className="w-44" />
1258 <Button size="compact" disabled={isSaving || !draft.canSave} onClick={draft.save}>
1259 {isSaving ? "Saving" : "Save"}
1260 </Button>
1261 <NewKeybindingCancelIcon isSaving={isSaving} onCancel={onCancel} />
1262 </div>
1263 }
1264 />
1265 );
1266}
1267
1268interface KeybindingsListProps extends KeybindingRowActions {
1269 rows: ReadonlyArray<KeybindingRow>;
1270 commandOptions: ReadonlyArray<KeybindingCommandOption>;
1271 savingCommand: KeybindingCommand | null;
1272 isAddingBinding: boolean;
1273 onCancelAdd: () => void;
1274}
1275
1276/** The add-binding row, one settings row per binding, and the empty state. */
1277function KeybindingsList(props: KeybindingsListProps) {
1278 const { rows, commandOptions, savingCommand, isAddingBinding, onCancelAdd, ...rowActions } =
1279 props;
1280 const newProps: NewKeybindingProps = {
1281 commandOptions,
1282 allRows: rows,
1283 variables: rowActions.variables,
1284 isSaving: savingCommand !== null,
1285 onSave: rowActions.onSave,
1286 onCancel: onCancelAdd,
1287 };
1288 return (
1289 <div>
1290 {isAddingBinding ? <NewKeybindingSettingsRow {...newProps} /> : null}
1291 {rows.map((row) => (
1292 <KeybindingSettingsRow
1293 key={row.id}
1294 row={row}
1295 isSaving={savingCommand === row.command}
1296 {...rowActions}
9951297 />
996 <Button
997 size="compact"
998 disabled={isSaving || !commandDraft || keyDraft.trim().length === 0 || !isWhenDraftValid}
999 onClick={save}
1000 >
1001 {isSaving ? "Saving" : "Save"}
1002 </Button>
1003 </div>
1004 <div className="pr-4">
1005 <Popover>
1006 <PopoverTrigger
1007 className={cn(
1008 "inline-flex h-7 w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-2.5 text-left font-mono text-[12px] text-foreground shadow-xs/5 outline-none transition-colors hover:bg-accent focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24",
1009 !whenDraftExpression && "text-muted-foreground",
1010 )}
1011 aria-label={`Edit when clause for ${commandLabelText}`}
1012 >
1013 <span className="truncate">{whenDraftExpression || "Always"}</span>
1014 <ChevronDownIcon className="size-3.5 shrink-0 opacity-60" />
1015 </PopoverTrigger>
1016 <PopoverContent align="start" sideOffset={6}>
1017 <WhenExpressionBuilder
1018 value={whenDraft}
1019 variables={variables}
1020 onChange={(nextWhenDraft) => setDraft({ whenDraft: nextWhenDraft })}
1021 onValidityChange={(nextIsValid) => setDraft({ isWhenDraftValid: nextIsValid })}
1022 />
1023 </PopoverContent>
1024 </Popover>
1025 </div>
1026 <div className="flex items-center justify-end gap-1">
1027 <KeybindingConflictWarning labels={conflictLabels} />
1028 <Tooltip>
1029 <TooltipTrigger
1030 render={
1031 <Button
1032 type="button"
1033 variant="ghost"
1034 size="icon-sm"
1035 className="size-7 text-muted-foreground hover:text-foreground"
1036 disabled={isSaving}
1037 aria-label="Cancel new keybinding"
1038 onClick={onCancel}
1039 />
1040 }
1041 >
1042 <XIcon className="size-3.5" />
1043 </TooltipTrigger>
1044 <TooltipPopup side="top">Cancel</TooltipPopup>
1045 </Tooltip>
1046 </div>
1298 ))}
1299 {rows.length === 0 && !isAddingBinding ? (
1300 <div className="px-4 py-12 text-center text-sm text-muted-foreground">
1301 No keybindings match your search.
1302 </div>
1303 ) : null}
1304 </div>
1305 );
1306}
1307
1308/** Shown in the browser build only; the desktop app receives every shortcut. */
1309function BrowserKeybindingNotice() {
1310 return (
1311 <div className="flex items-center gap-1.5 px-3 pb-2 text-[12px] text-muted-foreground sm:px-4">
1312 <TriangleAlertIcon className="size-3.5 shrink-0 text-warning" aria-hidden />
1313 <span>
1314 Some shortcuts may be claimed by the browser before T3 Code sees them. Use the desktop app
1315 for better keybinding support.
1316 </span>
10471317 </div>
10481318 );
10491319}
......@@ -1187,6 +1457,8 @@ export function KeybindingsSettingsPanel() {
11871457 [saveKeybinding],
11881458 );
11891459
1460 const cancelAdd = useCallback(() => setIsAddingBinding(false), []);
1461
11901462 const bindingsCount = (
11911463 <span className="text-[11px] text-muted-foreground">
11921464 {rows.length + (isAddingBinding ? 1 : 0)}{" "}
......@@ -1194,8 +1466,21 @@ export function KeybindingsSettingsPanel() {
11941466 </span>
11951467 );
11961468
1469 const listProps: KeybindingsListProps = {
1470 rows,
1471 allRows: rows,
1472 commandOptions,
1473 variables: whenVariables,
1474 savingCommand,
1475 isAddingBinding,
1476 onCancelAdd: cancelAdd,
1477 onSave: saveKeybinding,
1478 onReset: resetKeybinding,
1479 onRemove: removeKeybinding,
1480 };
1481
11971482 return (
1198 <SettingsPageContainer width="wide">
1483 <SettingsPageContainer>
11991484 <SettingsSection
12001485 {...searchableSetting("keybindings")}
12011486 headerAction={
......@@ -1244,58 +1529,9 @@ export function KeybindingsSettingsPanel() {
12441529 </div>
12451530 }
12461531 >
1247 {!isElectron ? (
1248 <div className="flex items-start gap-2 border-b border-warning/20 bg-warning/5 px-3 py-2.5 text-[12px] leading-relaxed text-muted-foreground sm:px-4">
1249 <InfoIcon className="mt-0.5 size-3.5 shrink-0 text-warning" />
1250 <p>
1251 Some shortcuts may be claimed by the browser before T3 Code sees them. Use the desktop
1252 app for better keybinding support.
1253 </p>
1254 </div>
1255 ) : null}
1532 {!isElectron ? <BrowserKeybindingNotice /> : null}
12561533
1257 <ScrollArea
1258 chainVerticalScroll
1259 scrollFade
1260 hideScrollbars
1261 className="w-full max-w-full rounded-none"
1262 >
1263 <div className="grid min-w-[680px] grid-cols-[minmax(190px,1.1fr)_minmax(220px,0.85fr)_minmax(210px,1fr)_60px] border-b border-border/70 bg-muted/25 px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.07em] text-muted-foreground">
1264 <div>Command</div>
1265 <div>Keybinding</div>
1266 <div>When</div>
1267 <div>Status</div>
1268 </div>
1269 <div className="min-w-[680px] divide-y divide-border/60">
1270 {isAddingBinding ? (
1271 <NewKeybindingTableRow
1272 commandOptions={commandOptions}
1273 allRows={rows}
1274 variables={whenVariables}
1275 isSaving={savingCommand !== null}
1276 onSave={saveKeybinding}
1277 onCancel={() => setIsAddingBinding(false)}
1278 />
1279 ) : null}
1280 {rows.map((row) => (
1281 <KeybindingTableRow
1282 key={row.id}
1283 row={row}
1284 allRows={rows}
1285 variables={whenVariables}
1286 isSaving={savingCommand === row.command}
1287 onSave={saveKeybinding}
1288 onReset={resetKeybinding}
1289 onRemove={removeKeybinding}
1290 />
1291 ))}
1292 {rows.length === 0 && !isAddingBinding ? (
1293 <div className="px-4 py-12 text-center text-sm text-muted-foreground">
1294 No keybindings match your search.
1295 </div>
1296 ) : null}
1297 </div>
1298 </ScrollArea>
1534 <KeybindingsList {...listProps} />
12991535 </SettingsSection>
13001536 </SettingsPageContainer>
13011537 );