| ... | ... | @@ -59,13 +59,16 @@ import { |
| 59 | 59 | keybindingConflictLabels, |
| 60 | 60 | keybindingFromKeyboardEvent, |
| 61 | 61 | parseWhenExpressionDraft, |
| 62 | groupKeybindingRows, |
| 62 | 63 | type KeybindingCommandOption, |
| 64 | type KeybindingGroup, |
| 63 | 65 | type KeybindingRow, |
| 64 | 66 | type WhenVariableOption, |
| 65 | 67 | unknownWhenVariables, |
| 66 | 68 | whenAstToExpression, |
| 67 | 69 | whenNodeRemoveLabel, |
| 68 | 70 | } from "./KeybindingsSettings.logic"; |
| 71 | import { SettingsGroup } from "./SettingsGroup"; |
| 69 | 72 | import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; |
| 70 | 73 | import { keybindingSearchAnchorId, searchableSetting } from "./settingsSearch"; |
| 71 | 74 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| ... | ... | @@ -104,65 +107,33 @@ function KeybindingPill({ value }: { value: string }) { |
| 104 | 107 | ); |
| 105 | 108 | } |
| 106 | 109 | |
| 107 | | function ExpandableHeaderSearch({ |
| 110 | /** Filter box in the page toolbar; Mod+F focuses it from anywhere on the page. */ |
| 111 | function KeybindingsSearchInput({ |
| 108 | 112 | query, |
| 109 | 113 | onChange, |
| 110 | | isOpen, |
| 111 | | onOpenChange, |
| 112 | 114 | inputRef, |
| 113 | | collapsedAccessory, |
| 114 | 115 | }: { |
| 115 | 116 | query: string; |
| 116 | 117 | onChange: (next: string) => void; |
| 117 | | isOpen: boolean; |
| 118 | | onOpenChange: (next: boolean) => void; |
| 119 | | inputRef?: RefObject<HTMLInputElement | null>; |
| 120 | | collapsedAccessory?: ReactNode; |
| 118 | inputRef: RefObject<HTMLInputElement | null>; |
| 121 | 119 | }) { |
| 122 | | if (!isOpen) { |
| 123 | | return ( |
| 124 | | <> |
| 125 | | {collapsedAccessory} |
| 126 | | <Tooltip> |
| 127 | | <TooltipTrigger |
| 128 | | render={ |
| 129 | | <Button |
| 130 | | type="button" |
| 131 | | size="icon-xs" |
| 132 | | variant="ghost-muted" |
| 133 | | onClick={() => onOpenChange(true)} |
| 134 | | aria-label="Search keybindings" |
| 135 | | > |
| 136 | | <SearchIcon /> |
| 137 | | </Button> |
| 138 | | } |
| 139 | | /> |
| 140 | | <TooltipPopup side="top">Search keybindings</TooltipPopup> |
| 141 | | </Tooltip> |
| 142 | | </> |
| 143 | | ); |
| 144 | | } |
| 145 | | |
| 146 | 120 | return ( |
| 147 | | <InputGroup className="w-44"> |
| 121 | <InputGroup className="min-w-0 basis-full **:[input]:h-9 sm:basis-0 sm:flex-1 sm:**:[input]:h-8"> |
| 148 | 122 | <InputGroupAddon> |
| 149 | | <SearchIcon aria-hidden className="size-3" /> |
| 123 | <SearchIcon aria-hidden className="size-3.5" /> |
| 150 | 124 | </InputGroupAddon> |
| 151 | 125 | <InputGroupInput |
| 152 | 126 | ref={inputRef} |
| 153 | | autoFocus |
| 154 | 127 | type="search" |
| 155 | 128 | value={query} |
| 156 | 129 | onChange={(event) => onChange(event.currentTarget.value)} |
| 157 | | onBlur={() => { |
| 158 | | if (query.length === 0) onOpenChange(false); |
| 159 | | }} |
| 160 | 130 | onKeyDown={(event) => { |
| 161 | | if (event.key === "Escape") { |
| 162 | | event.preventDefault(); |
| 163 | | onChange(""); |
| 164 | | onOpenChange(false); |
| 165 | | } |
| 131 | // The settings route treats an unhandled Escape as "go back"; |
| 132 | // inside the search box it clears, then leaves the field. |
| 133 | if (event.key !== "Escape") return; |
| 134 | event.preventDefault(); |
| 135 | if (query.length > 0) onChange(""); |
| 136 | else event.currentTarget.blur(); |
| 166 | 137 | }} |
| 167 | 138 | placeholder="Search keybindings" |
| 168 | 139 | aria-label="Search keybindings" |
| ... | ... | @@ -1232,7 +1203,7 @@ function NewKeybindingSettingsRow(props: NewKeybindingProps) { |
| 1232 | 1203 | |
| 1233 | 1204 | return ( |
| 1234 | 1205 | <SettingsRow |
| 1235 | | className="rounded-none bg-muted/15" |
| 1206 | className="bg-muted/15" |
| 1236 | 1207 | title="New keybinding" |
| 1237 | 1208 | description={ |
| 1238 | 1209 | <span className="flex h-6 items-center gap-1.5"> |
| ... | ... | @@ -1259,41 +1230,18 @@ function NewKeybindingSettingsRow(props: NewKeybindingProps) { |
| 1259 | 1230 | ); |
| 1260 | 1231 | } |
| 1261 | 1232 | |
| 1262 | | interface KeybindingsListProps extends KeybindingRowActions { |
| 1263 | | rows: ReadonlyArray<KeybindingRow>; |
| 1264 | | commandOptions: ReadonlyArray<KeybindingCommandOption>; |
| 1233 | interface KeybindingsGroupsProps extends KeybindingRowActions { |
| 1234 | groups: ReadonlyArray<KeybindingGroup>; |
| 1235 | anchorIds: ReadonlyMap<string, string>; |
| 1265 | 1236 | savingCommand: KeybindingCommand | null; |
| 1266 | | isAddingBinding: boolean; |
| 1267 | | onCancelAdd: () => void; |
| 1268 | 1237 | } |
| 1269 | 1238 | |
| 1270 | | /** The add-binding row, one settings row per binding, and the empty state. */ |
| 1271 | | function KeybindingsList(props: KeybindingsListProps) { |
| 1272 | | const { rows, commandOptions, savingCommand, isAddingBinding, onCancelAdd, ...rowActions } = |
| 1273 | | props; |
| 1274 | | const newProps: NewKeybindingProps = { |
| 1275 | | commandOptions, |
| 1276 | | allRows: rows, |
| 1277 | | variables: rowActions.variables, |
| 1278 | | isSaving: savingCommand !== null, |
| 1279 | | onSave: rowActions.onSave, |
| 1280 | | onCancel: onCancelAdd, |
| 1281 | | }; |
| 1282 | | // Settings search jumps to a command, so only its first row anchors. |
| 1283 | | const anchorIds = useMemo(() => { |
| 1284 | | const ids = new Map<string, string>(); |
| 1285 | | const seen = new Set<KeybindingCommand>(); |
| 1286 | | for (const row of rows) { |
| 1287 | | if (seen.has(row.command)) continue; |
| 1288 | | seen.add(row.command); |
| 1289 | | ids.set(row.id, keybindingSearchAnchorId(row.command)); |
| 1290 | | } |
| 1291 | | return ids; |
| 1292 | | }, [rows]); |
| 1293 | | return ( |
| 1294 | | <div> |
| 1295 | | {isAddingBinding ? <NewKeybindingSettingsRow {...newProps} /> : null} |
| 1296 | | {rows.map((row) => ( |
| 1239 | /** One titled section per command area, each holding its binding rows. */ |
| 1240 | function KeybindingsGroups(props: KeybindingsGroupsProps) { |
| 1241 | const { groups, anchorIds, savingCommand, ...rowActions } = props; |
| 1242 | return groups.map((group) => ( |
| 1243 | <SettingsSection key={group.id} id={`keybindings-${group.id}`} title={group.title}> |
| 1244 | {group.rows.map((row) => ( |
| 1297 | 1245 | <KeybindingSettingsRow |
| 1298 | 1246 | key={row.id} |
| 1299 | 1247 | row={row} |
| ... | ... | @@ -1302,25 +1250,29 @@ function KeybindingsList(props: KeybindingsListProps) { |
| 1302 | 1250 | {...rowActions} |
| 1303 | 1251 | /> |
| 1304 | 1252 | ))} |
| 1305 | | {rows.length === 0 && !isAddingBinding ? ( |
| 1306 | | <div className="px-4 py-12 text-center text-sm text-muted-foreground"> |
| 1307 | | No keybindings match your search. |
| 1308 | | </div> |
| 1309 | | ) : null} |
| 1310 | | </div> |
| 1311 | | ); |
| 1253 | </SettingsSection> |
| 1254 | )); |
| 1312 | 1255 | } |
| 1313 | 1256 | |
| 1314 | 1257 | /** Shown in the browser build only; the desktop app receives every shortcut. */ |
| 1315 | 1258 | function BrowserKeybindingNotice() { |
| 1259 | // The label carries the whole sentence so assistive tech reads it without |
| 1260 | // opening the tooltip. |
| 1261 | const message = "The browser may claim some shortcuts first. The desktop app receives them all."; |
| 1316 | 1262 | return ( |
| 1317 | | <div className="flex items-center gap-2 px-3 py-2.5 text-xs leading-normal text-muted-foreground sm:px-4"> |
| 1318 | | <TriangleAlertIcon className="size-3.5 shrink-0 text-warning" aria-hidden /> |
| 1319 | | <span> |
| 1320 | | Some shortcuts may be claimed by the browser before T3 Code sees them. Use the desktop app |
| 1321 | | for better keybinding support. |
| 1322 | | </span> |
| 1323 | | </div> |
| 1263 | <Tooltip> |
| 1264 | <TooltipTrigger |
| 1265 | delay={200} |
| 1266 | render={ |
| 1267 | <Button size="icon-micro" variant="ghost-muted" aria-label={message}> |
| 1268 | <TriangleAlertIcon className="size-3.5 text-warning" /> |
| 1269 | </Button> |
| 1270 | } |
| 1271 | /> |
| 1272 | <TooltipPopup side="top" className="max-w-72"> |
| 1273 | {message} |
| 1274 | </TooltipPopup> |
| 1275 | </Tooltip> |
| 1324 | 1276 | ); |
| 1325 | 1277 | } |
| 1326 | 1278 | |
| ... | ... | @@ -1347,11 +1299,22 @@ export function KeybindingsSettingsPanel() { |
| 1347 | 1299 | availableEditors, |
| 1348 | 1300 | ); |
| 1349 | 1301 | const [query, setQuery] = useState(""); |
| 1350 | | const [isSearchOpen, setIsSearchOpen] = useState(false); |
| 1351 | 1302 | const searchInputRef = useRef<HTMLInputElement>(null); |
| 1352 | 1303 | const [savingCommand, setSavingCommand] = useState<KeybindingCommand | null>(null); |
| 1353 | 1304 | const [isAddingBinding, setIsAddingBinding] = useState(false); |
| 1354 | 1305 | const rows = useMemo(() => buildKeybindingRows(keybindings, query), [keybindings, query]); |
| 1306 | const groups = useMemo(() => groupKeybindingRows(rows), [rows]); |
| 1307 | // Settings search jumps to a command, so only its first row anchors. |
| 1308 | const anchorIds = useMemo(() => { |
| 1309 | const ids = new Map<string, string>(); |
| 1310 | const seen = new Set<KeybindingCommand>(); |
| 1311 | for (const row of rows) { |
| 1312 | if (seen.has(row.command)) continue; |
| 1313 | seen.add(row.command); |
| 1314 | ids.set(row.id, keybindingSearchAnchorId(row.command)); |
| 1315 | } |
| 1316 | return ids; |
| 1317 | }, [rows]); |
| 1355 | 1318 | // The search-target context is provided by this panel's own page container, |
| 1356 | 1319 | // so the jump target is read from the route hash here. |
| 1357 | 1320 | const searchTargetId = useLocation({ select: (location) => location.hash.replace(/^#/, "") }); |
| ... | ... | @@ -1380,11 +1343,8 @@ export function KeybindingsSettingsPanel() { |
| 1380 | 1343 | } |
| 1381 | 1344 | |
| 1382 | 1345 | event.preventDefault(); |
| 1383 | | setIsSearchOpen(true); |
| 1384 | | requestAnimationFrame(() => { |
| 1385 | | searchInputRef.current?.focus(); |
| 1386 | | searchInputRef.current?.select(); |
| 1387 | | }); |
| 1346 | searchInputRef.current?.focus(); |
| 1347 | searchInputRef.current?.select(); |
| 1388 | 1348 | }; |
| 1389 | 1349 | window.addEventListener("keydown", handleKeyDown); |
| 1390 | 1350 | return () => window.removeEventListener("keydown", handleKeyDown); |
| ... | ... | @@ -1489,21 +1449,9 @@ export function KeybindingsSettingsPanel() { |
| 1489 | 1449 | |
| 1490 | 1450 | const cancelAdd = useCallback(() => setIsAddingBinding(false), []); |
| 1491 | 1451 | |
| 1492 | | const bindingsCount = ( |
| 1493 | | <span className="text-2xs text-muted-foreground"> |
| 1494 | | {rows.length + (isAddingBinding ? 1 : 0)}{" "} |
| 1495 | | {rows.length + (isAddingBinding ? 1 : 0) === 1 ? "binding" : "bindings"} |
| 1496 | | </span> |
| 1497 | | ); |
| 1498 | | |
| 1499 | | const listProps: KeybindingsListProps = { |
| 1500 | | rows, |
| 1452 | const rowActions: KeybindingRowActions = { |
| 1501 | 1453 | allRows: rows, |
| 1502 | | commandOptions, |
| 1503 | 1454 | variables: whenVariables, |
| 1504 | | savingCommand, |
| 1505 | | isAddingBinding, |
| 1506 | | onCancelAdd: cancelAdd, |
| 1507 | 1455 | onSave: saveKeybinding, |
| 1508 | 1456 | onReset: resetKeybinding, |
| 1509 | 1457 | onRemove: removeKeybinding, |
| ... | ... | @@ -1513,56 +1461,66 @@ export function KeybindingsSettingsPanel() { |
| 1513 | 1461 | <SettingsPageContainer> |
| 1514 | 1462 | <SettingsSection |
| 1515 | 1463 | {...searchableSetting("keybindings")} |
| 1516 | | headerAction={ |
| 1517 | | <div className="flex items-center gap-1.5"> |
| 1518 | | <ExpandableHeaderSearch |
| 1519 | | query={query} |
| 1520 | | onChange={setQuery} |
| 1521 | | isOpen={isSearchOpen} |
| 1522 | | onOpenChange={setIsSearchOpen} |
| 1523 | | inputRef={searchInputRef} |
| 1524 | | collapsedAccessory={bindingsCount} |
| 1525 | | /> |
| 1526 | | <Tooltip> |
| 1527 | | <TooltipTrigger |
| 1528 | | render={ |
| 1529 | | <Button |
| 1530 | | type="button" |
| 1531 | | size="icon-xs" |
| 1532 | | variant="ghost-muted" |
| 1533 | | onClick={() => setIsAddingBinding(true)} |
| 1534 | | aria-label="Add keybinding" |
| 1535 | | > |
| 1536 | | <PlusIcon /> |
| 1537 | | </Button> |
| 1538 | | } |
| 1539 | | /> |
| 1540 | | <TooltipPopup side="top">Add keybinding</TooltipPopup> |
| 1541 | | </Tooltip> |
| 1542 | | <Tooltip> |
| 1543 | | <TooltipTrigger |
| 1544 | | render={ |
| 1545 | | <Button |
| 1546 | | type="button" |
| 1547 | | size="icon-xs" |
| 1548 | | variant="ghost-muted" |
| 1549 | | disabled={!keybindingsConfigPath} |
| 1550 | | onClick={openKeybindingsFile} |
| 1551 | | aria-label="Open keybindings.json" |
| 1552 | | > |
| 1553 | | <FileJsonIcon /> |
| 1554 | | </Button> |
| 1555 | | } |
| 1556 | | /> |
| 1557 | | <TooltipPopup side="top">Open keybindings.json</TooltipPopup> |
| 1558 | | </Tooltip> |
| 1559 | | </div> |
| 1560 | | } |
| 1464 | headerAction={!isElectron ? <BrowserKeybindingNotice /> : null} |
| 1561 | 1465 | > |
| 1562 | | {!isElectron ? <BrowserKeybindingNotice /> : null} |
| 1563 | | |
| 1564 | | <KeybindingsList {...listProps} /> |
| 1466 | <div className="flex flex-wrap items-center gap-2 px-3 py-3 sm:px-4"> |
| 1467 | <KeybindingsSearchInput query={query} onChange={setQuery} inputRef={searchInputRef} /> |
| 1468 | <Button |
| 1469 | type="button" |
| 1470 | variant="outline" |
| 1471 | disabled={isAddingBinding} |
| 1472 | onClick={() => setIsAddingBinding(true)} |
| 1473 | > |
| 1474 | <PlusIcon aria-hidden className="size-4" /> |
| 1475 | Add keybinding |
| 1476 | </Button> |
| 1477 | <Tooltip> |
| 1478 | <TooltipTrigger |
| 1479 | render={ |
| 1480 | <Button |
| 1481 | type="button" |
| 1482 | variant="outline" |
| 1483 | size="icon" |
| 1484 | disabled={!keybindingsConfigPath} |
| 1485 | onClick={openKeybindingsFile} |
| 1486 | aria-label="Open keybindings.json" |
| 1487 | > |
| 1488 | <FileJsonIcon aria-hidden className="size-4" /> |
| 1489 | </Button> |
| 1490 | } |
| 1491 | /> |
| 1492 | <TooltipPopup side="top">Open keybindings.json</TooltipPopup> |
| 1493 | </Tooltip> |
| 1494 | </div> |
| 1565 | 1495 | </SettingsSection> |
| 1496 | |
| 1497 | {isAddingBinding ? ( |
| 1498 | <SettingsGroup> |
| 1499 | <NewKeybindingSettingsRow |
| 1500 | commandOptions={commandOptions} |
| 1501 | allRows={rows} |
| 1502 | variables={whenVariables} |
| 1503 | isSaving={savingCommand !== null} |
| 1504 | onSave={saveKeybinding} |
| 1505 | onCancel={cancelAdd} |
| 1506 | /> |
| 1507 | </SettingsGroup> |
| 1508 | ) : null} |
| 1509 | |
| 1510 | {groups.length > 0 ? ( |
| 1511 | <KeybindingsGroups |
| 1512 | groups={groups} |
| 1513 | anchorIds={anchorIds} |
| 1514 | savingCommand={savingCommand} |
| 1515 | {...rowActions} |
| 1516 | /> |
| 1517 | ) : ( |
| 1518 | <SettingsGroup> |
| 1519 | <div className="px-4 py-12 text-center text-sm text-muted-foreground"> |
| 1520 | No keybindings match your search. |
| 1521 | </div> |
| 1522 | </SettingsGroup> |
| 1523 | )} |
| 1566 | 1524 | </SettingsPageContainer> |
| 1567 | 1525 | ); |
| 1568 | 1526 | } |