| ... | @@ -59,13 +59,16 @@ import { | ... | @@ -59,13 +59,16 @@ import { |
| 59 | keybindingConflictLabels, | 59 | keybindingConflictLabels, |
| 60 | keybindingFromKeyboardEvent, | 60 | keybindingFromKeyboardEvent, |
| 61 | parseWhenExpressionDraft, | 61 | parseWhenExpressionDraft, |
| | 62 | groupKeybindingRows, |
| 62 | type KeybindingCommandOption, | 63 | type KeybindingCommandOption, |
| | 64 | type KeybindingGroup, |
| 63 | type KeybindingRow, | 65 | type KeybindingRow, |
| 64 | type WhenVariableOption, | 66 | type WhenVariableOption, |
| 65 | unknownWhenVariables, | 67 | unknownWhenVariables, |
| 66 | whenAstToExpression, | 68 | whenAstToExpression, |
| 67 | whenNodeRemoveLabel, | 69 | whenNodeRemoveLabel, |
| 68 | } from "./KeybindingsSettings.logic"; | 70 | } from "./KeybindingsSettings.logic"; |
| | 71 | import { SettingsGroup } from "./SettingsGroup"; |
| 69 | import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; | 72 | import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; |
| 70 | import { keybindingSearchAnchorId, searchableSetting } from "./settingsSearch"; | 73 | import { keybindingSearchAnchorId, searchableSetting } from "./settingsSearch"; |
| 71 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 74 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| ... | @@ -104,65 +107,33 @@ function KeybindingPill({ value }: { value: string }) { | ... | @@ -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 | query, | 112 | query, |
| 109 | onChange, | 113 | onChange, |
| 110 | isOpen, | | |
| 111 | onOpenChange, | | |
| 112 | inputRef, | 114 | inputRef, |
| 113 | collapsedAccessory, | | |
| 114 | }: { | 115 | }: { |
| 115 | query: string; | 116 | query: string; |
| 116 | onChange: (next: string) => void; | 117 | onChange: (next: string) => void; |
| 117 | isOpen: boolean; | 118 | inputRef: RefObject<HTMLInputElement | null>; |
| 118 | onOpenChange: (next: boolean) => void; | | |
| 119 | inputRef?: RefObject<HTMLInputElement | null>; | | |
| 120 | collapsedAccessory?: ReactNode; | | |
| 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 | return ( | 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 | <InputGroupAddon> | 122 | <InputGroupAddon> |
| 149 | <SearchIcon aria-hidden className="size-3" /> | 123 | <SearchIcon aria-hidden className="size-3.5" /> |
| 150 | </InputGroupAddon> | 124 | </InputGroupAddon> |
| 151 | <InputGroupInput | 125 | <InputGroupInput |
| 152 | ref={inputRef} | 126 | ref={inputRef} |
| 153 | autoFocus | | |
| 154 | type="search" | 127 | type="search" |
| 155 | value={query} | 128 | value={query} |
| 156 | onChange={(event) => onChange(event.currentTarget.value)} | 129 | onChange={(event) => onChange(event.currentTarget.value)} |
| 157 | onBlur={() => { | | |
| 158 | if (query.length === 0) onOpenChange(false); | | |
| 159 | }} | | |
| 160 | onKeyDown={(event) => { | 130 | onKeyDown={(event) => { |
| 161 | if (event.key === "Escape") { | 131 | // The settings route treats an unhandled Escape as "go back"; |
| 162 | event.preventDefault(); | 132 | // inside the search box it clears, then leaves the field. |
| 163 | onChange(""); | 133 | if (event.key !== "Escape") return; |
| 164 | onOpenChange(false); | 134 | event.preventDefault(); |
| 165 | } | 135 | if (query.length > 0) onChange(""); |
| | 136 | else event.currentTarget.blur(); |
| 166 | }} | 137 | }} |
| 167 | placeholder="Search keybindings" | 138 | placeholder="Search keybindings" |
| 168 | aria-label="Search keybindings" | 139 | aria-label="Search keybindings" |
| ... | @@ -1232,7 +1203,7 @@ function NewKeybindingSettingsRow(props: NewKeybindingProps) { | ... | @@ -1232,7 +1203,7 @@ function NewKeybindingSettingsRow(props: NewKeybindingProps) { |
| 1232 | | 1203 | |
| 1233 | return ( | 1204 | return ( |
| 1234 | <SettingsRow | 1205 | <SettingsRow |
| 1235 | className="rounded-none bg-muted/15" | 1206 | className="bg-muted/15" |
| 1236 | title="New keybinding" | 1207 | title="New keybinding" |
| 1237 | description={ | 1208 | description={ |
| 1238 | <span className="flex h-6 items-center gap-1.5"> | 1209 | <span className="flex h-6 items-center gap-1.5"> |
| ... | @@ -1259,41 +1230,18 @@ function NewKeybindingSettingsRow(props: NewKeybindingProps) { | ... | @@ -1259,41 +1230,18 @@ function NewKeybindingSettingsRow(props: NewKeybindingProps) { |
| 1259 | ); | 1230 | ); |
| 1260 | } | 1231 | } |
| 1261 | | 1232 | |
| 1262 | interface KeybindingsListProps extends KeybindingRowActions { | 1233 | interface KeybindingsGroupsProps extends KeybindingRowActions { |
| 1263 | rows: ReadonlyArray<KeybindingRow>; | 1234 | groups: ReadonlyArray<KeybindingGroup>; |
| 1264 | commandOptions: ReadonlyArray<KeybindingCommandOption>; | 1235 | anchorIds: ReadonlyMap<string, string>; |
| 1265 | savingCommand: KeybindingCommand | null; | 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. */ | 1239 | /** One titled section per command area, each holding its binding rows. */ |
| 1271 | function KeybindingsList(props: KeybindingsListProps) { | 1240 | function KeybindingsGroups(props: KeybindingsGroupsProps) { |
| 1272 | const { rows, commandOptions, savingCommand, isAddingBinding, onCancelAdd, ...rowActions } = | 1241 | const { groups, anchorIds, savingCommand, ...rowActions } = props; |
| 1273 | props; | 1242 | return groups.map((group) => ( |
| 1274 | const newProps: NewKeybindingProps = { | 1243 | <SettingsSection key={group.id} id={`keybindings-${group.id}`} title={group.title}> |
| 1275 | commandOptions, | 1244 | {group.rows.map((row) => ( |
| 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) => ( | | |
| 1297 | <KeybindingSettingsRow | 1245 | <KeybindingSettingsRow |
| 1298 | key={row.id} | 1246 | key={row.id} |
| 1299 | row={row} | 1247 | row={row} |
| ... | @@ -1302,25 +1250,29 @@ function KeybindingsList(props: KeybindingsListProps) { | ... | @@ -1302,25 +1250,29 @@ function KeybindingsList(props: KeybindingsListProps) { |
| 1302 | {...rowActions} | 1250 | {...rowActions} |
| 1303 | /> | 1251 | /> |
| 1304 | ))} | 1252 | ))} |
| 1305 | {rows.length === 0 && !isAddingBinding ? ( | 1253 | </SettingsSection> |
| 1306 | <div className="px-4 py-12 text-center text-sm text-muted-foreground"> | 1254 | )); |
| 1307 | No keybindings match your search. | | |
| 1308 | </div> | | |
| 1309 | ) : null} | | |
| 1310 | </div> | | |
| 1311 | ); | | |
| 1312 | } | 1255 | } |
| 1313 | | 1256 | |
| 1314 | /** Shown in the browser build only; the desktop app receives every shortcut. */ | 1257 | /** Shown in the browser build only; the desktop app receives every shortcut. */ |
| 1315 | function BrowserKeybindingNotice() { | 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 | return ( | 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"> | 1263 | <Tooltip> |
| 1318 | <TriangleAlertIcon className="size-3.5 shrink-0 text-warning" aria-hidden /> | 1264 | <TooltipTrigger |
| 1319 | <span> | 1265 | delay={200} |
| 1320 | Some shortcuts may be claimed by the browser before T3 Code sees them. Use the desktop app | 1266 | render={ |
| 1321 | for better keybinding support. | 1267 | <Button size="icon-micro" variant="ghost-muted" aria-label={message}> |
| 1322 | </span> | 1268 | <TriangleAlertIcon className="size-3.5 text-warning" /> |
| 1323 | </div> | 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,11 +1299,22 @@ export function KeybindingsSettingsPanel() { |
| 1347 | availableEditors, | 1299 | availableEditors, |
| 1348 | ); | 1300 | ); |
| 1349 | const [query, setQuery] = useState(""); | 1301 | const [query, setQuery] = useState(""); |
| 1350 | const [isSearchOpen, setIsSearchOpen] = useState(false); | | |
| 1351 | const searchInputRef = useRef<HTMLInputElement>(null); | 1302 | const searchInputRef = useRef<HTMLInputElement>(null); |
| 1352 | const [savingCommand, setSavingCommand] = useState<KeybindingCommand | null>(null); | 1303 | const [savingCommand, setSavingCommand] = useState<KeybindingCommand | null>(null); |
| 1353 | const [isAddingBinding, setIsAddingBinding] = useState(false); | 1304 | const [isAddingBinding, setIsAddingBinding] = useState(false); |
| 1354 | const rows = useMemo(() => buildKeybindingRows(keybindings, query), [keybindings, query]); | 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 | // The search-target context is provided by this panel's own page container, | 1318 | // The search-target context is provided by this panel's own page container, |
| 1356 | // so the jump target is read from the route hash here. | 1319 | // so the jump target is read from the route hash here. |
| 1357 | const searchTargetId = useLocation({ select: (location) => location.hash.replace(/^#/, "") }); | 1320 | const searchTargetId = useLocation({ select: (location) => location.hash.replace(/^#/, "") }); |
| ... | @@ -1380,11 +1343,8 @@ export function KeybindingsSettingsPanel() { | ... | @@ -1380,11 +1343,8 @@ export function KeybindingsSettingsPanel() { |
| 1380 | } | 1343 | } |
| 1381 | | 1344 | |
| 1382 | event.preventDefault(); | 1345 | event.preventDefault(); |
| 1383 | setIsSearchOpen(true); | 1346 | searchInputRef.current?.focus(); |
| 1384 | requestAnimationFrame(() => { | 1347 | searchInputRef.current?.select(); |
| 1385 | searchInputRef.current?.focus(); | | |
| 1386 | searchInputRef.current?.select(); | | |
| 1387 | }); | | |
| 1388 | }; | 1348 | }; |
| 1389 | window.addEventListener("keydown", handleKeyDown); | 1349 | window.addEventListener("keydown", handleKeyDown); |
| 1390 | return () => window.removeEventListener("keydown", handleKeyDown); | 1350 | return () => window.removeEventListener("keydown", handleKeyDown); |
| ... | @@ -1489,21 +1449,9 @@ export function KeybindingsSettingsPanel() { | ... | @@ -1489,21 +1449,9 @@ export function KeybindingsSettingsPanel() { |
| 1489 | | 1449 | |
| 1490 | const cancelAdd = useCallback(() => setIsAddingBinding(false), []); | 1450 | const cancelAdd = useCallback(() => setIsAddingBinding(false), []); |
| 1491 | | 1451 | |
| 1492 | const bindingsCount = ( | 1452 | const rowActions: KeybindingRowActions = { |
| 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, | | |
| 1501 | allRows: rows, | 1453 | allRows: rows, |
| 1502 | commandOptions, | | |
| 1503 | variables: whenVariables, | 1454 | variables: whenVariables, |
| 1504 | savingCommand, | | |
| 1505 | isAddingBinding, | | |
| 1506 | onCancelAdd: cancelAdd, | | |
| 1507 | onSave: saveKeybinding, | 1455 | onSave: saveKeybinding, |
| 1508 | onReset: resetKeybinding, | 1456 | onReset: resetKeybinding, |
| 1509 | onRemove: removeKeybinding, | 1457 | onRemove: removeKeybinding, |
| ... | @@ -1513,56 +1461,66 @@ export function KeybindingsSettingsPanel() { | ... | @@ -1513,56 +1461,66 @@ export function KeybindingsSettingsPanel() { |
| 1513 | <SettingsPageContainer> | 1461 | <SettingsPageContainer> |
| 1514 | <SettingsSection | 1462 | <SettingsSection |
| 1515 | {...searchableSetting("keybindings")} | 1463 | {...searchableSetting("keybindings")} |
| 1516 | headerAction={ | 1464 | headerAction={!isElectron ? <BrowserKeybindingNotice /> : null} |
| 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 | } | | |
| 1561 | > | 1465 | > |
| 1562 | {!isElectron ? <BrowserKeybindingNotice /> : null} | 1466 | <div className="flex flex-wrap items-center gap-2 px-3 py-3 sm:px-4"> |
| 1563 | | 1467 | <KeybindingsSearchInput query={query} onChange={setQuery} inputRef={searchInputRef} /> |
| 1564 | <KeybindingsList {...listProps} /> | 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 | </SettingsSection> | 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 | </SettingsPageContainer> | 1524 | </SettingsPageContainer> |
| 1567 | ); | 1525 | ); |
| 1568 | } | 1526 | } |