();
+ for (const row of rows) {
+ if (seen.has(row.command)) continue;
+ seen.add(row.command);
+ ids.set(row.id, keybindingSearchAnchorId(row.command));
+ }
+ return ids;
+ }, [rows]);
return (
{isAddingBinding ? : null}
@@ -1303,6 +1320,7 @@ function KeybindingsList(props: KeybindingsListProps) {
@@ -1357,6 +1375,16 @@ export function KeybindingsSettingsPanel() {
const [savingCommand, setSavingCommand] = useState(null);
const [isAddingBinding, setIsAddingBinding] = useState(false);
const rows = useMemo(() => buildKeybindingRows(keybindings, query), [keybindings, query]);
+ // The search-target context is provided by this panel's own page container,
+ // so the jump target is read from the route hash here.
+ const searchTargetId = useLocation({ select: (location) => location.hash.replace(/^#/, "") });
+ const [handledSearchTargetId, setHandledSearchTargetId] = useState(searchTargetId);
+
+ // A settings-search jump must not be hidden by the page's own filter.
+ if (searchTargetId !== handledSearchTargetId) {
+ setHandledSearchTargetId(searchTargetId);
+ if (searchTargetId.startsWith("keybinding-")) setQuery("");
+ }
const commandOptions = useMemo(() => buildKeybindingCommandOptions(keybindings), [keybindings]);
const whenVariables = useMemo(() => buildWhenVariableOptions(), []);
diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts
index a45b20150138eba3afbaa83358b760ca0f4f2900..569d736a62de3e43cc19378d17559e3063d4aefb 100644
--- a/apps/web/src/components/settings/settingsSearch.test.ts
+++ b/apps/web/src/components/settings/settingsSearch.test.ts
@@ -216,6 +216,32 @@ describe("searchSettings", () => {
]);
});
+ it("finds keybinding commands by label, command id, and default key", () => {
+ expect(searchSettings("toggle sidebar")[0]?.id).toBe("keybinding-sidebar.toggle");
+ expect(searchSettings("sidebar.toggle")[0]?.id).toBe("keybinding-sidebar.toggle");
+ expect(searchSettings("mod+b")[0]?.id).toBe("keybinding-sidebar.toggle");
+ expect(searchSettings("copy link")[0]).toMatchObject({
+ id: "keybinding-thread.copyReference",
+ to: "/settings/keybindings",
+ });
+ });
+
+ it("ranks keybinding commands after other settings", () => {
+ const ids = searchSettings("model").map((item) => item.id);
+ expect(ids[0]).toBe("default-model");
+ expect(ids.indexOf("keybinding-modelPicker.toggle")).toBeGreaterThan(
+ ids.indexOf("text-generation-model"),
+ );
+ });
+
+ it("sends commands without a default binding to the section", () => {
+ expect(searchSettings("thread.stop")[0]).toMatchObject({
+ id: "keybinding-thread.stop",
+ targetId: "keybindings",
+ });
+ expect(searchSettings("sidebar.toggle")[0]?.targetId).toBeUndefined();
+ });
+
it("keeps catalog result ids unique", () => {
const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id);
expect(new Set(ids).size).toBe(ids.length);
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index 079287439c43817eb76af3489b1d5996af060ce8..cc83ccc92dced286555da5ef71b0178d8f11aba4 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -1,7 +1,10 @@
import { isElectron } from "~/env";
import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils";
+import { STATIC_KEYBINDING_COMMANDS, type KeybindingCommand } from "@t3tools/contracts";
import type { EnvironmentId } from "@t3tools/contracts";
import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
+import { DEFAULT_KEYBINDINGS } from "@t3tools/shared/keybindings";
+import { commandLabel } from "./KeybindingsSettings.logic";
import {
validateSettingsScopeSearch,
type ResolvedSettingsScope,
@@ -54,6 +57,11 @@ export interface SettingsSearchItem {
readonly localBackendManagementOnly?: boolean;
readonly localEnvironmentOnly?: boolean;
readonly wslAvailableOnly?: boolean;
+ /**
+ * Sorts after every other match. Keybinding commands mirror rows on other
+ * surfaces, so "model" must still lead with Default model, not Model Picker.
+ */
+ readonly secondary?: boolean;
readonly requiresThreadAutoSettlement?: boolean;
}
@@ -84,6 +92,33 @@ export const SETTINGS_SECTION_LABELS: Readonly> = {
"/settings/archived": "Archive",
};
+/** Anchor id of the first row bound to `command` on the Keybindings page. */
+export function keybindingSearchAnchorId(command: Command) {
+ return `keybinding-${command}` as const;
+}
+
+/**
+ * One result per built-in command, alphabetical by label. The anchor is
+ * the command's first row; default keys are searchable so "mod+b" lands on
+ * Sidebar: Toggle. A command with no default binding may have no row, so it
+ * points at the section instead.
+ */
+const KEYBINDING_SEARCH_ITEMS = STATIC_KEYBINDING_COMMANDS.toSorted((left, right) =>
+ commandLabel(left).localeCompare(commandLabel(right)),
+).map((command) => {
+ const defaultKeys = DEFAULT_KEYBINDINGS.filter((binding) => binding.command === command).map(
+ (binding) => binding.key,
+ );
+ return {
+ id: keybindingSearchAnchorId(command),
+ title: commandLabel(command),
+ to: "/settings/keybindings" as const,
+ searchTerms: [command, ...defaultKeys],
+ secondary: true,
+ ...(defaultKeys.length === 0 ? { targetId: "keybindings" } : {}),
+ };
+});
+
/**
* Searchable settings and stable destinations, in result order. Rows with a
* dedicated anchor render their id and title via `searchableSetting`; items
@@ -429,6 +464,7 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/keybindings",
searchTerms: ["keyboard shortcuts hotkeys commands bindings json"],
},
+ ...KEYBINDING_SEARCH_ITEMS,
{
id: "snap-shot-enabled",
title: "SnapShots",
@@ -914,6 +950,11 @@ export function searchSettings(
: 0;
return [{ item, index, rank }];
})
- .toSorted((left, right) => right.rank - left.rank || left.index - right.index)
+ .toSorted(
+ (left, right) =>
+ Number(left.item.secondary ?? false) - Number(right.item.secondary ?? false) ||
+ right.rank - left.rank ||
+ left.index - right.index,
+ )
.map(({ item }) => item);
}