gloomberb 0.14.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api-client/types.ts +17 -0
- package/src/app/global-shortcuts.test.tsx +85 -1
- package/src/app/global-shortcuts.ts +83 -36
- package/src/app/keybindings/actions.ts +223 -0
- package/src/app/keybindings/capture-request.ts +36 -0
- package/src/app/keybindings/chord.test.ts +131 -0
- package/src/app/keybindings/chord.ts +302 -0
- package/src/app/keybindings/config.ts +83 -0
- package/src/app/keybindings/index.ts +62 -0
- package/src/app/keybindings/labels.ts +87 -0
- package/src/app/keybindings/react.tsx +32 -0
- package/src/app/keybindings/resolve.test.ts +103 -0
- package/src/app/keybindings/resolve.ts +280 -0
- package/src/app.tsx +25 -17
- package/src/cli/commands/keybindings.test.ts +48 -0
- package/src/cli/commands/keybindings.ts +102 -0
- package/src/cli/commands/system.ts +33 -1
- package/src/cli/context.ts +1 -0
- package/src/cli/pane-functions/intraday-shot.test.ts +15 -0
- package/src/cli/restore-plugins.ts +2 -1
- package/src/components/command-bar/keyboard-shortcuts.ts +19 -2
- package/src/components/command-bar/routes/root/results.ts +33 -0
- package/src/components/command-bar/routes/root/runtime.ts +4 -0
- package/src/components/command-bar/routing/launch-request.ts +3 -0
- package/src/components/command-bar/surface/index.test.tsx +58 -0
- package/src/components/command-bar/surface/index.tsx +22 -0
- package/src/components/data-table/sections.ts +55 -0
- package/src/components/index.ts +11 -0
- package/src/components/layout/header.tsx +11 -4
- package/src/components/layout/pane/sidebar.test.tsx +87 -0
- package/src/components/layout/pane/sidebar.tsx +118 -6
- package/src/components/layout/shell/index.tsx +8 -3
- package/src/components/layout/shell/menu.ts +12 -11
- package/src/components/layout/shell/pane/management-shortcuts.ts +4 -2
- package/src/components/layout/shell/shortcuts.ts +55 -35
- package/src/components/ui/action-row.tsx +20 -3
- package/src/components/ui/button.tsx +8 -0
- package/src/core/app-runtime.test.ts +31 -0
- package/src/core/app-runtime.ts +32 -2
- package/src/core/app-service-ports.ts +8 -0
- package/src/core/app-services.ts +2 -2
- package/src/core/state/app/state.ts +4 -0
- package/src/core/state/app/types.ts +23 -3
- package/src/data/config/home.test.ts +70 -0
- package/src/data/config/home.ts +41 -0
- package/src/data/config/store/index.test.ts +25 -0
- package/src/data/config/store/node.ts +21 -19
- package/src/data/config/store/normalize.ts +41 -0
- package/src/i18n/es.ts +1 -1
- package/src/i18n/ja.ts +1 -1
- package/src/i18n/ko.ts +1 -1
- package/src/i18n/zh-cn.ts +1 -1
- package/src/i18n/zh-tw.ts +1 -1
- package/src/plugins/builtin/chart-composer/headless.ts +12 -3
- package/src/plugins/builtin/chat/channels.ts +4 -2
- package/src/plugins/builtin/chat/content/channel-navigation.ts +24 -16
- package/src/plugins/builtin/chat/content/composer-runtime.ts +5 -5
- package/src/plugins/builtin/chat/content/index.tsx +13 -10
- package/src/plugins/builtin/chat/content/layout-metrics.ts +4 -1
- package/src/plugins/builtin/chat/content/snapshot.ts +0 -3
- package/src/plugins/builtin/chat/sidebar-store.ts +100 -0
- package/src/plugins/builtin/chat/sidebar.test.tsx +49 -19
- package/src/plugins/builtin/chat/sidebar.tsx +111 -78
- package/src/plugins/builtin/chat/test-harness.tsx +3 -0
- package/src/plugins/builtin/cloud/plugin.tsx +2 -0
- package/src/plugins/builtin/help/functions-table.tsx +158 -0
- package/src/plugins/builtin/help/index.tsx +142 -301
- package/src/plugins/builtin/help/keybindings-editor.test.tsx +147 -0
- package/src/plugins/builtin/help/keybindings-editor.tsx +427 -0
- package/src/plugins/builtin/help/shortcut-model.ts +1 -35
- package/src/plugins/builtin/help/shortcut-table.tsx +90 -0
- package/src/plugins/builtin/help/table-cells.tsx +66 -0
- package/src/plugins/builtin/jobs/headless.ts +13 -0
- package/src/plugins/builtin/jobs/pane.tsx +11 -1
- package/src/plugins/builtin/research/analyst-model.test.ts +68 -2
- package/src/plugins/builtin/research/analyst-model.ts +180 -17
- package/src/plugins/builtin/research/analyst-pane.test.tsx +56 -13
- package/src/plugins/builtin/research/analyst-pane.tsx +123 -29
- package/src/plugins/builtin/research/index.test.ts +0 -35
- package/src/plugins/catalog-backend.ts +12 -2
- package/src/plugins/loader.ts +3 -3
- package/src/renderers/browser/app-services.ts +2 -2
- package/src/renderers/electrobun/bun/application-menu/index.ts +14 -7
- package/src/renderers/electrobun/bun/desktop/backend-requests.ts +1 -1
- package/src/renderers/electrobun/bun/desktop/initialization.ts +3 -4
- package/src/renderers/electrobun/bun/index.ts +12 -1
- package/src/renderers/electrobun/view/app-services.ts +2 -2
- package/src/renderers/electrobun/view/desktop/controls.tsx +3 -2
- package/src/renderers/electrobun/view/host/media-surface.tsx +3 -0
- package/src/renderers/electrobun/view/test-utils.tsx +17 -8
- package/src/renderers/opentui/host.tsx +6 -15
- package/src/renderers/opentui/start.tsx +2 -5
- package/src/renderers/opentui/terminal-media.test.ts +78 -7
- package/src/renderers/opentui/terminal-media.ts +123 -31
- package/src/time-series/resolution.ts +28 -0
- package/src/time-series/resolve.test.ts +173 -0
- package/src/time-series/resolve.ts +84 -29
- package/src/time-series/session-history.ts +5 -2
- package/src/time-series/use-chart-resolution.ts +7 -0
- package/src/types/config.ts +22 -0
- package/src/ui/host.tsx +6 -4
- package/src/updater.test.ts +46 -0
- package/src/updater.ts +42 -5
- package/src/utils/shortcut-labels.ts +0 -14
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
|
2
|
+
import type { KeybindingsConfig } from "../../types/config";
|
|
3
|
+
import { resolveKeybindings, type ResolvedKeybindings } from "./resolve";
|
|
4
|
+
|
|
5
|
+
const KeybindingsContext = createContext<ResolvedKeybindings | null>(null);
|
|
6
|
+
|
|
7
|
+
let defaultKeybindings: ResolvedKeybindings | null = null;
|
|
8
|
+
|
|
9
|
+
/** The shipped table, for surfaces rendered outside the app tree such as tests. */
|
|
10
|
+
export function getDefaultKeybindings(): ResolvedKeybindings {
|
|
11
|
+
if (!defaultKeybindings) defaultKeybindings = resolveKeybindings(undefined);
|
|
12
|
+
return defaultKeybindings;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Resolves the config once per change; the app passes the result down and into its own hooks. */
|
|
16
|
+
export function useResolvedKeybindings(config: KeybindingsConfig | undefined): ResolvedKeybindings {
|
|
17
|
+
return useMemo(() => resolveKeybindings(config), [config]);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function KeybindingsProvider({
|
|
21
|
+
value,
|
|
22
|
+
children,
|
|
23
|
+
}: {
|
|
24
|
+
value: ResolvedKeybindings;
|
|
25
|
+
children: ReactNode;
|
|
26
|
+
}) {
|
|
27
|
+
return <KeybindingsContext value={value}>{children}</KeybindingsContext>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useKeybindings(): ResolvedKeybindings {
|
|
31
|
+
return useContext(KeybindingsContext) ?? getDefaultKeybindings();
|
|
32
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { applyActionBinding, removeCommandBinding, setCommandBinding, updateKeybindingsConfig } from "./config";
|
|
3
|
+
import { parseKeyChord, serializeKeyChord } from "./chord";
|
|
4
|
+
import { matchKeybinding, resolveKeybindings, resolvePluginShortcutChords } from "./resolve";
|
|
5
|
+
import { createDefaultConfig } from "../../types/config";
|
|
6
|
+
|
|
7
|
+
const base = { ctrl: false, shift: false, alt: false, meta: false, super: false };
|
|
8
|
+
|
|
9
|
+
describe("resolveKeybindings", () => {
|
|
10
|
+
test("ships every default and reports nothing", () => {
|
|
11
|
+
const resolved = resolveKeybindings(undefined);
|
|
12
|
+
expect(resolved.issues).toEqual([]);
|
|
13
|
+
expect(resolved.actionsById.get("ticker-search")?.chords.map(serializeKeyChord)).toEqual(["`"]);
|
|
14
|
+
expect(matchKeybinding(resolved, { ...base, name: "`" })).toMatchObject({ kind: "action", id: "ticker-search" });
|
|
15
|
+
expect(matchKeybinding(resolved, { ...base, name: "t", ctrl: true })).toBeNull();
|
|
16
|
+
expect(matchKeybinding(resolved, { ...base, name: "2", super: true })).toMatchObject({ kind: "action", id: "switch-layout", digit: 2 });
|
|
17
|
+
expect(matchKeybinding(resolved, { ...base, name: "w", ctrl: true, alt: true })).toMatchObject({ id: "close-floating-panes" });
|
|
18
|
+
expect(matchKeybinding(resolved, { ...base, name: "w", ctrl: true })).toMatchObject({ id: "pane-close" });
|
|
19
|
+
expect(matchKeybinding(resolved, { ...base, name: "x" })).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("overrides replace defaults, null unbinds, and the rest stay put", () => {
|
|
23
|
+
const resolved = resolveKeybindings({
|
|
24
|
+
actions: { "ticker-search": "Ctrl+T", "command-bar": "Ctrl+Shift+P", help: null },
|
|
25
|
+
});
|
|
26
|
+
expect(resolved.issues).toEqual([]);
|
|
27
|
+
expect(matchKeybinding(resolved, { ...base, name: "`" })).toBeNull();
|
|
28
|
+
expect(matchKeybinding(resolved, { ...base, name: "t", ctrl: true })).toMatchObject({ id: "ticker-search" });
|
|
29
|
+
expect(matchKeybinding(resolved, { ...base, name: "k", ctrl: true })).toBeNull();
|
|
30
|
+
expect(matchKeybinding(resolved, { ...base, name: "p", ctrl: true, shift: true })).toMatchObject({ id: "command-bar" });
|
|
31
|
+
expect(matchKeybinding(resolved, { ...base, name: "?", shift: true })).toBeNull();
|
|
32
|
+
expect(resolved.actionsById.get("help")).toMatchObject({ custom: true, chords: [] });
|
|
33
|
+
expect(resolved.actionsById.get("quit")).toMatchObject({ custom: false });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("commands run after actions and need a modifier or function key", () => {
|
|
37
|
+
const resolved = resolveKeybindings({
|
|
38
|
+
commands: { "Alt+1": "DES AAPL", F5: " NEWS ", "CmdOrCtrl+W": "HN", a: "HN", "": "HN", "Alt+2": "" },
|
|
39
|
+
});
|
|
40
|
+
expect(matchKeybinding(resolved, { ...base, name: "1", alt: true })).toMatchObject({ kind: "command", command: { query: "DES AAPL" } });
|
|
41
|
+
expect(matchKeybinding(resolved, { ...base, name: "f5" })).toMatchObject({ kind: "command", command: { query: "NEWS" } });
|
|
42
|
+
expect(matchKeybinding(resolved, { ...base, name: "w", ctrl: true })).toMatchObject({ kind: "action", id: "pane-close" });
|
|
43
|
+
expect(resolved.commands.map((command) => command.text)).toEqual(["Alt+1", "F5", "CmdOrCtrl+W"]);
|
|
44
|
+
expect(resolved.issues).toEqual([
|
|
45
|
+
{ kind: "typing-chord", target: "commands", text: "a" },
|
|
46
|
+
{ kind: "invalid-chord", target: "commands", text: "" },
|
|
47
|
+
{ kind: "conflict", chord: parseKeyChord("CmdOrCtrl+W"), targets: ["pane-close", "command:CmdOrCtrl+W"] },
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("reports unparseable overrides, unknown actions and overlapping chords", () => {
|
|
52
|
+
const resolved = resolveKeybindings({
|
|
53
|
+
actions: { "ticker-search": ["Ctrl+K", "Bogus+Q"], "not-a-thing": "F1" },
|
|
54
|
+
});
|
|
55
|
+
expect(resolved.issues).toEqual([
|
|
56
|
+
{ kind: "invalid-chord", target: "ticker-search", text: "Bogus+Q" },
|
|
57
|
+
{ kind: "unknown-action", actionId: "not-a-thing" },
|
|
58
|
+
{ kind: "conflict", chord: parseKeyChord("CmdOrCtrl+K"), targets: ["command-bar", "ticker-search"] },
|
|
59
|
+
]);
|
|
60
|
+
// Table order decides, so the earlier action keeps the chord.
|
|
61
|
+
expect(matchKeybinding(resolved, { ...base, name: "k", ctrl: true })).toMatchObject({ id: "command-bar" });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("folds plugin shortcuts in under their own namespace", () => {
|
|
65
|
+
const shortcut = { id: "hn-refresh", key: "x", ctrl: true, description: "Refresh", execute: () => {} };
|
|
66
|
+
const plain = resolveKeybindings(undefined, { pluginShortcuts: [shortcut] });
|
|
67
|
+
expect(matchKeybinding(plain, { ...base, name: "x", ctrl: true })).toMatchObject({ id: "plugin:hn-refresh" });
|
|
68
|
+
expect(resolvePluginShortcutChords(undefined, shortcut).map(serializeKeyChord)).toEqual(["Ctrl+X"]);
|
|
69
|
+
|
|
70
|
+
const config = { actions: { "plugin:hn-refresh": "Alt+X" } };
|
|
71
|
+
const custom = resolveKeybindings(config, { pluginShortcuts: [shortcut] });
|
|
72
|
+
expect(custom.issues).toEqual([]);
|
|
73
|
+
expect(matchKeybinding(custom, { ...base, name: "x", ctrl: true })).toBeNull();
|
|
74
|
+
expect(matchKeybinding(custom, { ...base, name: "x", alt: true })).toMatchObject({ id: "plugin:hn-refresh", action: { custom: true } });
|
|
75
|
+
expect(resolvePluginShortcutChords(config, shortcut).map(serializeKeyChord)).toEqual(["Alt+X"]);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("keybinding config edits", () => {
|
|
80
|
+
test("applyActionBinding records overrides and drops ones equal to the default", () => {
|
|
81
|
+
const bound = applyActionBinding(undefined, "ticker-search", [parseKeyChord("Ctrl+T")!]);
|
|
82
|
+
expect(bound).toEqual({ actions: { "ticker-search": "Ctrl+T" } });
|
|
83
|
+
const unbound = applyActionBinding(bound, "help", []);
|
|
84
|
+
expect(unbound).toEqual({ actions: { "ticker-search": "Ctrl+T", help: null } });
|
|
85
|
+
const restored = applyActionBinding(unbound, "ticker-search", [parseKeyChord("`")!]);
|
|
86
|
+
expect(restored).toEqual({ actions: { help: null } });
|
|
87
|
+
expect(applyActionBinding(restored, "help", [parseKeyChord("?")!])).toBeUndefined();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("setCommandBinding replaces a command already on the chord, and removal clears the key", () => {
|
|
91
|
+
const first = setCommandBinding(undefined, parseKeyChord("Alt+1")!, "DES AAPL");
|
|
92
|
+
const second = setCommandBinding(first, parseKeyChord("alt+1")!, "HN");
|
|
93
|
+
expect(second).toEqual({ commands: { "Alt+1": "HN" } });
|
|
94
|
+
expect(removeCommandBinding(second, "Alt+1")).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("updateKeybindingsConfig drops the key once nothing is customised", () => {
|
|
98
|
+
const config = { ...createDefaultConfig("/tmp/keybindings"), keybindings: { actions: { help: null } } };
|
|
99
|
+
expect(updateKeybindingsConfig(config, undefined).keybindings).toBeUndefined();
|
|
100
|
+
expect("keybindings" in updateKeybindingsConfig(config, { actions: {} })).toBe(false);
|
|
101
|
+
expect(updateKeybindingsConfig(config, { commands: { F5: "NEWS" } }).keybindings).toEqual({ commands: { F5: "NEWS" } });
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import type { KeybindingsConfig } from "../../types/config";
|
|
2
|
+
import type { KeyboardShortcut } from "../../types/plugin";
|
|
3
|
+
import {
|
|
4
|
+
getKeybindingAction,
|
|
5
|
+
isCoreKeybindingActionId,
|
|
6
|
+
KEYBINDING_ACTIONS,
|
|
7
|
+
PANE_ACTION_IDS,
|
|
8
|
+
PLUGIN_ACTION_PREFIX,
|
|
9
|
+
pluginShortcutActionId,
|
|
10
|
+
type KeybindingActionDef,
|
|
11
|
+
} from "./actions";
|
|
12
|
+
import {
|
|
13
|
+
isTypingChord,
|
|
14
|
+
keyChordDigit,
|
|
15
|
+
matchesKeyChord,
|
|
16
|
+
parseKeyChord,
|
|
17
|
+
serializeKeyChord,
|
|
18
|
+
type KeyChord,
|
|
19
|
+
type KeyChordEventLike,
|
|
20
|
+
} from "./chord";
|
|
21
|
+
|
|
22
|
+
export interface ResolvedKeybindingAction {
|
|
23
|
+
id: string;
|
|
24
|
+
/** Set for built-in actions. */
|
|
25
|
+
def?: KeybindingActionDef;
|
|
26
|
+
/** Set for actions a plugin registered. */
|
|
27
|
+
pluginShortcut?: KeyboardShortcut;
|
|
28
|
+
chords: KeyChord[];
|
|
29
|
+
defaults: KeyChord[];
|
|
30
|
+
/** The user replaced or removed the default chords. */
|
|
31
|
+
custom: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface KeybindingCommand {
|
|
35
|
+
chord: KeyChord;
|
|
36
|
+
/** The chord text as written in the config, which is the map key. */
|
|
37
|
+
text: string;
|
|
38
|
+
/** Command bar text submitted when the chord is pressed. */
|
|
39
|
+
query: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type KeybindingIssue =
|
|
43
|
+
| { kind: "invalid-chord"; target: string; text: string }
|
|
44
|
+
| { kind: "unknown-action"; actionId: string }
|
|
45
|
+
| { kind: "typing-chord"; target: string; text: string }
|
|
46
|
+
| { kind: "conflict"; chord: KeyChord; targets: string[] };
|
|
47
|
+
|
|
48
|
+
export interface ResolvedKeybindings {
|
|
49
|
+
actions: ResolvedKeybindingAction[];
|
|
50
|
+
actionsById: Map<string, ResolvedKeybindingAction>;
|
|
51
|
+
commands: KeybindingCommand[];
|
|
52
|
+
issues: KeybindingIssue[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type KeybindingMatch =
|
|
56
|
+
| { kind: "action"; id: string; action: ResolvedKeybindingAction; digit: number | null }
|
|
57
|
+
| { kind: "command"; command: KeybindingCommand };
|
|
58
|
+
|
|
59
|
+
export interface ResolveKeybindingsOptions {
|
|
60
|
+
/** Plugin shortcuts to fold into the table, for listing and conflict checks. */
|
|
61
|
+
pluginShortcuts?: Iterable<KeyboardShortcut>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function pluginShortcutDefaultChord(shortcut: KeyboardShortcut): KeyChord {
|
|
65
|
+
return {
|
|
66
|
+
key: shortcut.key.toLowerCase(),
|
|
67
|
+
ctrl: shortcut.ctrl === true,
|
|
68
|
+
cmd: false,
|
|
69
|
+
primary: false,
|
|
70
|
+
alt: false,
|
|
71
|
+
shift: shortcut.shift === true || (shortcut.key.length === 1 && shortcut.key !== shortcut.key.toLowerCase()),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* An override's chords. `null` in the config is a deliberate unbind and gives
|
|
77
|
+
* an empty list; text that does not parse is reported and dropped, and when
|
|
78
|
+
* nothing parses the override is ignored so a typo never silently unbinds.
|
|
79
|
+
*/
|
|
80
|
+
function parseChordList(
|
|
81
|
+
value: string | string[] | null | undefined,
|
|
82
|
+
target: string,
|
|
83
|
+
issues: KeybindingIssue[],
|
|
84
|
+
): KeyChord[] | null {
|
|
85
|
+
if (value === undefined) return null;
|
|
86
|
+
if (value === null) return [];
|
|
87
|
+
const texts = Array.isArray(value) ? value : [value];
|
|
88
|
+
const chords: KeyChord[] = [];
|
|
89
|
+
let invalid = false;
|
|
90
|
+
for (const text of texts) {
|
|
91
|
+
if (typeof text !== "string") continue;
|
|
92
|
+
const chord = parseKeyChord(text);
|
|
93
|
+
if (!chord) {
|
|
94
|
+
issues.push({ kind: "invalid-chord", target, text });
|
|
95
|
+
invalid = true;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
chords.push(chord);
|
|
99
|
+
}
|
|
100
|
+
return chords.length === 0 && invalid ? null : chords;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseDefaults(texts: readonly string[]): KeyChord[] {
|
|
104
|
+
return texts.map((text) => {
|
|
105
|
+
const chord = parseKeyChord(text);
|
|
106
|
+
if (!chord) throw new Error(`Default keybinding "${text}" does not parse.`);
|
|
107
|
+
return chord;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const DEFAULT_CHORDS = new Map(KEYBINDING_ACTIONS.map((action) => [action.id, parseDefaults(action.defaults)]));
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Two chords fire on the same keypress somewhere: identical, or one says
|
|
115
|
+
* `CmdOrCtrl` where the other names Control or Command explicitly.
|
|
116
|
+
*/
|
|
117
|
+
export function keyChordsOverlap(left: KeyChord, right: KeyChord): boolean {
|
|
118
|
+
if (left.key !== right.key || left.alt !== right.alt || left.shift !== right.shift) return false;
|
|
119
|
+
if (left.primary && right.primary) return true;
|
|
120
|
+
if (left.primary) return (right.ctrl || right.cmd) && !(right.ctrl && right.cmd);
|
|
121
|
+
if (right.primary) return (left.ctrl || left.cmd) && !(left.ctrl && left.cmd);
|
|
122
|
+
return left.ctrl === right.ctrl && left.cmd === right.cmd;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function resolveAction(
|
|
126
|
+
def: KeybindingActionDef | undefined,
|
|
127
|
+
pluginShortcut: KeyboardShortcut | undefined,
|
|
128
|
+
id: string,
|
|
129
|
+
defaults: KeyChord[],
|
|
130
|
+
overrides: NonNullable<KeybindingsConfig["actions"]>,
|
|
131
|
+
issues: KeybindingIssue[],
|
|
132
|
+
): ResolvedKeybindingAction {
|
|
133
|
+
const override = parseChordList(overrides[id], id, issues);
|
|
134
|
+
return {
|
|
135
|
+
id,
|
|
136
|
+
def,
|
|
137
|
+
pluginShortcut,
|
|
138
|
+
defaults,
|
|
139
|
+
chords: override ?? defaults,
|
|
140
|
+
custom: override !== null,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function collectConflicts(actions: ResolvedKeybindingAction[], commands: KeybindingCommand[]): KeybindingIssue[] {
|
|
145
|
+
const entries: Array<{ target: string; chord: KeyChord }> = [
|
|
146
|
+
...actions.flatMap((action) => action.chords.map((chord) => ({ target: action.id, chord }))),
|
|
147
|
+
...commands.map((command) => ({ target: `command:${command.text}`, chord: command.chord })),
|
|
148
|
+
];
|
|
149
|
+
const reported = new Set<string>();
|
|
150
|
+
const issues: KeybindingIssue[] = [];
|
|
151
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
152
|
+
const entry = entries[index]!;
|
|
153
|
+
const targets = [entry.target];
|
|
154
|
+
for (let other = index + 1; other < entries.length; other += 1) {
|
|
155
|
+
const candidate = entries[other]!;
|
|
156
|
+
if (candidate.target !== entry.target && keyChordsOverlap(entry.chord, candidate.chord)) {
|
|
157
|
+
targets.push(candidate.target);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (targets.length < 2) continue;
|
|
161
|
+
const key = `${serializeKeyChord(entry.chord)}|${[...new Set(targets)].sort().join(",")}`;
|
|
162
|
+
if (reported.has(key)) continue;
|
|
163
|
+
reported.add(key);
|
|
164
|
+
issues.push({ kind: "conflict", chord: entry.chord, targets: [...new Set(targets)] });
|
|
165
|
+
}
|
|
166
|
+
return issues;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function resolveKeybindings(
|
|
170
|
+
config: KeybindingsConfig | undefined,
|
|
171
|
+
options: ResolveKeybindingsOptions = {},
|
|
172
|
+
): ResolvedKeybindings {
|
|
173
|
+
const issues: KeybindingIssue[] = [];
|
|
174
|
+
const overrides = config?.actions ?? {};
|
|
175
|
+
const actions: ResolvedKeybindingAction[] = KEYBINDING_ACTIONS.map((def) => resolveAction(
|
|
176
|
+
def,
|
|
177
|
+
undefined,
|
|
178
|
+
def.id,
|
|
179
|
+
DEFAULT_CHORDS.get(def.id)!,
|
|
180
|
+
overrides,
|
|
181
|
+
issues,
|
|
182
|
+
));
|
|
183
|
+
for (const shortcut of options.pluginShortcuts ?? []) {
|
|
184
|
+
actions.push(resolveAction(
|
|
185
|
+
undefined,
|
|
186
|
+
shortcut,
|
|
187
|
+
pluginShortcutActionId(shortcut.id),
|
|
188
|
+
[pluginShortcutDefaultChord(shortcut)],
|
|
189
|
+
overrides,
|
|
190
|
+
issues,
|
|
191
|
+
));
|
|
192
|
+
}
|
|
193
|
+
for (const actionId of Object.keys(overrides)) {
|
|
194
|
+
if (isCoreKeybindingActionId(actionId) || actionId.startsWith(PLUGIN_ACTION_PREFIX)) continue;
|
|
195
|
+
issues.push({ kind: "unknown-action", actionId });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const commands: KeybindingCommand[] = [];
|
|
199
|
+
for (const [text, query] of Object.entries(config?.commands ?? {})) {
|
|
200
|
+
if (typeof query !== "string" || !query.trim()) continue;
|
|
201
|
+
const chord = parseKeyChord(text);
|
|
202
|
+
if (!chord) {
|
|
203
|
+
issues.push({ kind: "invalid-chord", target: "commands", text });
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (isTypingChord(chord)) {
|
|
207
|
+
issues.push({ kind: "typing-chord", target: "commands", text });
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
commands.push({ chord, text, query: query.trim() });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
issues.push(...collectConflicts(actions, commands));
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
actions,
|
|
217
|
+
actionsById: new Map(actions.map((action) => [action.id, action])),
|
|
218
|
+
commands,
|
|
219
|
+
issues,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The first action or command whose chord the event is, in table order. Actions
|
|
225
|
+
* win over commands, so a stray command binding never shadows a core key.
|
|
226
|
+
*/
|
|
227
|
+
export function matchKeybinding(resolved: ResolvedKeybindings, event: KeyChordEventLike): KeybindingMatch | null {
|
|
228
|
+
for (const action of resolved.actions) {
|
|
229
|
+
for (const chord of action.chords) {
|
|
230
|
+
if (!matchesKeyChord(chord, event)) continue;
|
|
231
|
+
return {
|
|
232
|
+
kind: "action",
|
|
233
|
+
id: action.id,
|
|
234
|
+
action,
|
|
235
|
+
digit: chord.key === "digit" ? keyChordDigit(event) : null,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
for (const command of resolved.commands) {
|
|
240
|
+
if (matchesKeyChord(command.chord, event)) return { kind: "command", command };
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function matchesKeybindingAction(resolved: ResolvedKeybindings, id: string, event: KeyChordEventLike): boolean {
|
|
246
|
+
const action = resolved.actionsById.get(id);
|
|
247
|
+
return !!action && action.chords.some((chord) => matchesKeyChord(chord, event));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function isPaneKeybindingAction(id: string): boolean {
|
|
251
|
+
return isCoreKeybindingActionId(id) && PANE_ACTION_IDS.has(id);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Chords for one plugin shortcut, honouring an override, for a handler that
|
|
256
|
+
* reads the live registry instead of a resolved table.
|
|
257
|
+
*/
|
|
258
|
+
export function resolvePluginShortcutChords(config: KeybindingsConfig | undefined, shortcut: KeyboardShortcut): KeyChord[] {
|
|
259
|
+
const override = parseChordList(config?.actions?.[pluginShortcutActionId(shortcut.id)], shortcut.id, []);
|
|
260
|
+
return override ?? [pluginShortcutDefaultChord(shortcut)];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function describeKeybindingIssue(issue: KeybindingIssue): string {
|
|
264
|
+
switch (issue.kind) {
|
|
265
|
+
case "invalid-chord":
|
|
266
|
+
return `"${issue.text}" is not a key chord (${issue.target}).`;
|
|
267
|
+
case "unknown-action":
|
|
268
|
+
return `"${issue.actionId}" is not a keybinding action.`;
|
|
269
|
+
case "typing-chord":
|
|
270
|
+
return `"${issue.text}" would fire while typing; command bindings need Ctrl, Cmd, Alt or a function key.`;
|
|
271
|
+
case "conflict":
|
|
272
|
+
return `${serializeKeyChord(issue.chord)} is bound to ${issue.targets.join(" and ")}.`;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function keybindingActionLabel(action: ResolvedKeybindingAction): string {
|
|
277
|
+
return action.def?.description ?? action.pluginShortcut?.description ?? action.id;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export { getKeybindingAction };
|
package/src/app.tsx
CHANGED
|
@@ -39,6 +39,7 @@ import { useBrokerImportRuntime } from "./app/runtime/broker-import";
|
|
|
39
39
|
import { useDesktopDeepLinkRuntime } from "./app/runtime/desktop-deeplink";
|
|
40
40
|
import { useDesktopApplicationMenuRuntime } from "./app/runtime/desktop-menu";
|
|
41
41
|
import { useAppGlobalShortcuts } from "./app/global-shortcuts";
|
|
42
|
+
import { KeybindingsProvider, useResolvedKeybindings } from "./app/keybindings";
|
|
42
43
|
import { useAppPaneRuntime } from "./app/pane-runtime";
|
|
43
44
|
import { bindPluginRegistryRuntimeAccess } from "./app/runtime/plugin-bindings";
|
|
44
45
|
import { useAppStartupRuntime } from "./app/runtime/startup";
|
|
@@ -380,10 +381,12 @@ function AppInner({
|
|
|
380
381
|
}
|
|
381
382
|
}, [focusedTickerSymbol]);
|
|
382
383
|
|
|
384
|
+
const keybindings = useResolvedKeybindings(state.config.keybindings);
|
|
383
385
|
useAppGlobalShortcuts({
|
|
384
386
|
dispatch,
|
|
385
387
|
focusedTickerSymbol,
|
|
386
388
|
isDetachedWindow,
|
|
389
|
+
keybindings,
|
|
387
390
|
pluginRegistry,
|
|
388
391
|
refreshTicker,
|
|
389
392
|
startUpdate,
|
|
@@ -392,27 +395,30 @@ function AppInner({
|
|
|
392
395
|
|
|
393
396
|
if (desktopWindowBridge?.kind === "detached" && desktopWindowBridge.paneId) {
|
|
394
397
|
return (
|
|
395
|
-
<
|
|
396
|
-
<
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
<
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
398
|
+
<KeybindingsProvider value={keybindings}>
|
|
399
|
+
<ContextMenuProvider pluginRegistry={pluginRegistry}>
|
|
400
|
+
<RemoteControlHost
|
|
401
|
+
adapter={remoteControlAdapter}
|
|
402
|
+
dispatch={dispatch}
|
|
403
|
+
getState={getRemoteState}
|
|
404
|
+
pluginRegistry={pluginRegistry}
|
|
405
|
+
desktopWindowBridge={desktopWindowBridge}
|
|
406
|
+
>
|
|
407
|
+
<ThemedAppRoot>
|
|
408
|
+
<DetachedPaneShell
|
|
409
|
+
pluginRegistry={pluginRegistry}
|
|
410
|
+
desktopWindowBridge={{ ...desktopWindowBridge, kind: "detached", paneId: desktopWindowBridge.paneId }}
|
|
411
|
+
/>
|
|
412
|
+
<ToastViewport position="bottom-right" />
|
|
413
|
+
</ThemedAppRoot>
|
|
414
|
+
</RemoteControlHost>
|
|
415
|
+
</ContextMenuProvider>
|
|
416
|
+
</KeybindingsProvider>
|
|
412
417
|
);
|
|
413
418
|
}
|
|
414
419
|
|
|
415
420
|
return (
|
|
421
|
+
<KeybindingsProvider value={keybindings}>
|
|
416
422
|
<ContextMenuProvider pluginRegistry={pluginRegistry}>
|
|
417
423
|
<RemoteControlHost
|
|
418
424
|
adapter={remoteControlAdapter}
|
|
@@ -460,6 +466,7 @@ function AppInner({
|
|
|
460
466
|
</ThemedAppRoot>
|
|
461
467
|
</RemoteControlHost>
|
|
462
468
|
</ContextMenuProvider>
|
|
469
|
+
</KeybindingsProvider>
|
|
463
470
|
);
|
|
464
471
|
}
|
|
465
472
|
|
|
@@ -548,6 +555,7 @@ export function App({
|
|
|
548
555
|
servicesFactory({
|
|
549
556
|
config,
|
|
550
557
|
plugins,
|
|
558
|
+
externalPlugins,
|
|
551
559
|
})
|
|
552
560
|
), {
|
|
553
561
|
externalPluginCount: externalPlugins.length,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createDefaultConfig } from "../../types/config";
|
|
3
|
+
import { applyKeybindingCliSet, describeKeybindingsForCli } from "./keybindings";
|
|
4
|
+
|
|
5
|
+
const config = createDefaultConfig("/tmp/gloomberb-cli-keybindings");
|
|
6
|
+
|
|
7
|
+
describe("config set keybindings", () => {
|
|
8
|
+
test("binds, unbinds and restores actions", () => {
|
|
9
|
+
const bound = applyKeybindingCliSet(config, "keybindings.actions.ticker-search", "Ctrl+T `");
|
|
10
|
+
if (!bound.ok) throw new Error(bound.message);
|
|
11
|
+
expect(bound.value).toEqual(["Ctrl+T", "`"]);
|
|
12
|
+
expect(bound.config.keybindings).toEqual({ actions: { "ticker-search": ["Ctrl+T", "`"] } });
|
|
13
|
+
|
|
14
|
+
const unbound = applyKeybindingCliSet(bound.config, "keybindings.actions.help", "null");
|
|
15
|
+
if (!unbound.ok) throw new Error(unbound.message);
|
|
16
|
+
expect(unbound.config.keybindings?.actions).toEqual({ "ticker-search": ["Ctrl+T", "`"], help: null });
|
|
17
|
+
|
|
18
|
+
const restored = applyKeybindingCliSet(unbound.config, "keybindings.actions.ticker-search", "default");
|
|
19
|
+
if (!restored.ok) throw new Error(restored.message);
|
|
20
|
+
expect(restored.config.keybindings).toEqual({ actions: { help: null } });
|
|
21
|
+
expect(describeKeybindingsForCli(restored.config)).toMatchObject({
|
|
22
|
+
actions: {
|
|
23
|
+
help: { keys: [], custom: true, defaults: ["?"] },
|
|
24
|
+
"ticker-search": { keys: ["`"] },
|
|
25
|
+
},
|
|
26
|
+
issues: [],
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("binds and removes commands, refusing chords a text field would eat", () => {
|
|
31
|
+
const bound = applyKeybindingCliSet(config, "keybindings.commands.alt+1", "DES AAPL");
|
|
32
|
+
if (!bound.ok) throw new Error(bound.message);
|
|
33
|
+
expect(bound.config.keybindings).toEqual({ commands: { "Alt+1": "DES AAPL" } });
|
|
34
|
+
|
|
35
|
+
const removed = applyKeybindingCliSet(bound.config, "keybindings.commands.Alt+1", "null");
|
|
36
|
+
if (!removed.ok) throw new Error(removed.message);
|
|
37
|
+
expect(removed.config.keybindings).toBeUndefined();
|
|
38
|
+
|
|
39
|
+
expect(applyKeybindingCliSet(config, "keybindings.commands.a", "HN")).toMatchObject({ ok: false });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("rejects unknown actions, bad chords and bad sections with a usable message", () => {
|
|
43
|
+
expect(applyKeybindingCliSet(config, "keybindings.actions.nope", "F1")).toMatchObject({ ok: false, message: expect.stringContaining("not a keybinding action") });
|
|
44
|
+
expect(applyKeybindingCliSet(config, "keybindings.actions.quit", "Hyper+Q")).toMatchObject({ ok: false, message: expect.stringContaining("not a key chord") });
|
|
45
|
+
expect(applyKeybindingCliSet(config, "keybindings.other.x", "F1")).toMatchObject({ ok: false });
|
|
46
|
+
expect(applyKeybindingCliSet(config, "keybindings.actions", "F1")).toMatchObject({ ok: false });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyActionBinding,
|
|
3
|
+
describeKeybindingIssue,
|
|
4
|
+
isCoreKeybindingActionId,
|
|
5
|
+
isTypingChord,
|
|
6
|
+
parseKeyChord,
|
|
7
|
+
PLUGIN_ACTION_PREFIX,
|
|
8
|
+
removeCommandBinding,
|
|
9
|
+
resolveKeybindings,
|
|
10
|
+
serializeKeyChord,
|
|
11
|
+
setCommandBinding,
|
|
12
|
+
updateKeybindingsConfig,
|
|
13
|
+
} from "../../app/keybindings";
|
|
14
|
+
import type { AppConfig } from "../../types/config";
|
|
15
|
+
|
|
16
|
+
export const KEYBINDINGS_CONFIG_KEY = "keybindings";
|
|
17
|
+
|
|
18
|
+
const UNBIND_WORDS = new Set(["null", "none", "off", "unbind"]);
|
|
19
|
+
const DEFAULT_WORDS = new Set(["default", "defaults", "reset"]);
|
|
20
|
+
|
|
21
|
+
/** The effective table with defaults filled in, plus anything that needs fixing. */
|
|
22
|
+
export function describeKeybindingsForCli(config: AppConfig): Record<string, unknown> {
|
|
23
|
+
const resolved = resolveKeybindings(config.keybindings);
|
|
24
|
+
return {
|
|
25
|
+
actions: Object.fromEntries(resolved.actions.map((action) => [
|
|
26
|
+
action.id,
|
|
27
|
+
{
|
|
28
|
+
keys: action.chords.map(serializeKeyChord),
|
|
29
|
+
...(action.custom ? { custom: true, defaults: action.defaults.map(serializeKeyChord) } : {}),
|
|
30
|
+
},
|
|
31
|
+
])),
|
|
32
|
+
commands: Object.fromEntries(resolved.commands.map((command) => [command.text, command.query])),
|
|
33
|
+
issues: resolved.issues.map(describeKeybindingIssue),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type KeybindingCliSetResult =
|
|
38
|
+
| { ok: true; config: AppConfig; value: unknown }
|
|
39
|
+
| { ok: false; message: string };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `config set keybindings.actions.<action> <chords>` and
|
|
43
|
+
* `config set keybindings.commands.<chord> <query>`. Chords are separated by
|
|
44
|
+
* whitespace, since no chord contains a space; `null` unbinds and `default`
|
|
45
|
+
* clears an override.
|
|
46
|
+
*/
|
|
47
|
+
export function applyKeybindingCliSet(config: AppConfig, key: string, rawValue: string): KeybindingCliSetResult {
|
|
48
|
+
const [, section, ...rest] = key.split(".");
|
|
49
|
+
const target = rest.join(".");
|
|
50
|
+
const value = rawValue.trim();
|
|
51
|
+
if (!target) {
|
|
52
|
+
return { ok: false, message: `Usage: gloomberb config set ${KEYBINDINGS_CONFIG_KEY}.actions.<action> <chord> or ${KEYBINDINGS_CONFIG_KEY}.commands.<chord> <query>` };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (section === "actions") {
|
|
56
|
+
if (!isCoreKeybindingActionId(target) && !target.startsWith(PLUGIN_ACTION_PREFIX)) {
|
|
57
|
+
return { ok: false, message: `"${target}" is not a keybinding action. Run "gloomberb config get keybindings" for the list.` };
|
|
58
|
+
}
|
|
59
|
+
const lowered = value.toLowerCase();
|
|
60
|
+
if (DEFAULT_WORDS.has(lowered)) {
|
|
61
|
+
const actions = { ...(config.keybindings?.actions ?? {}) };
|
|
62
|
+
delete actions[target];
|
|
63
|
+
return { ok: true, config: updateKeybindingsConfig(config, { ...config.keybindings, actions }), value: "default" };
|
|
64
|
+
}
|
|
65
|
+
if (UNBIND_WORDS.has(lowered) || value === "") {
|
|
66
|
+
return { ok: true, config: updateKeybindingsConfig(config, applyActionBinding(config.keybindings, target, [])), value: null };
|
|
67
|
+
}
|
|
68
|
+
const chords = [];
|
|
69
|
+
for (const text of value.split(/\s+/)) {
|
|
70
|
+
const chord = parseKeyChord(text);
|
|
71
|
+
if (!chord) return { ok: false, message: `"${text}" is not a key chord. Examples: Ctrl+T, CmdOrCtrl+Shift+F, Alt+1, F5, \`` };
|
|
72
|
+
chords.push(chord);
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
ok: true,
|
|
76
|
+
config: updateKeybindingsConfig(config, applyActionBinding(config.keybindings, target, chords)),
|
|
77
|
+
value: chords.map(serializeKeyChord),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (section === "commands") {
|
|
82
|
+
const chord = parseKeyChord(target);
|
|
83
|
+
if (!chord) return { ok: false, message: `"${target}" is not a key chord. Examples: Alt+1, CmdOrCtrl+Shift+N, F5` };
|
|
84
|
+
if (UNBIND_WORDS.has(value.toLowerCase()) || value === "") {
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
config: updateKeybindingsConfig(config, removeCommandBinding(config.keybindings, serializeKeyChord(chord))),
|
|
88
|
+
value: null,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (isTypingChord(chord)) {
|
|
92
|
+
return { ok: false, message: `"${target}" would fire while typing; command bindings need Ctrl, Cmd, Alt or a function key.` };
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
config: updateKeybindingsConfig(config, setCommandBinding(config.keybindings, chord, value)),
|
|
97
|
+
value,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { ok: false, message: `Unknown keybindings section "${section ?? ""}". Use keybindings.actions or keybindings.commands.` };
|
|
102
|
+
}
|