Skip to content

Commit ef06e9e

Browse files
committed
feat(keymap): allow direct bindings for leader commands
1 parent 2a09c28 commit ef06e9e

7 files changed

Lines changed: 250 additions & 4 deletions

File tree

‎README.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,25 @@ Connections and settings are stored in `$XDG_CONFIG_HOME/sqlit/` (default: `~/.c
243243

244244
Edit the `keymap.json` file in your sqlit config dir. See [`config/keymap.template.json`](config/keymap.template.json) for the full default keymap. Keymap.json need to only contain the overriding keymaps.
245245

246+
Commands from the main leader menu can also be bound directly in an app scope:
247+
248+
```json
249+
{
250+
"keymap": {
251+
"action_keys": {
252+
"query_normal": { "edit_query_in_editor": "ctrl+g" }
253+
}
254+
}
255+
}
256+
```
257+
258+
This keeps the leader shortcut and adds `ctrl+g` in the query editor's normal
259+
mode. Other examples include `format_query`, `telescope`, and `change_theme`.
260+
Direct bindings retain command guards and state restrictions. Conflicting keys
261+
are rejected; unbind or remap the existing action to free its key. Use a list for
262+
aliases, or `null` to remove a direct binding without changing the leader menu.
263+
Vim submenu motions and dialog scopes are not supported for this promotion.
264+
246265
## FAQ
247266

248267
### How are sensitive credentials stored?

‎sqlit/core/key_router.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,12 @@ def resolve_action(
3131

3232
contexts = get_binding_contexts(ctx)
3333
keymap = get_keymap()
34-
for action_key in keymap.get_action_keys():
34+
bindings = keymap.get_action_keys()
35+
if ctx.autocomplete_visible:
36+
# Autocomplete overlays query-insert mode. Preserve that precedence
37+
# even when user overrides are appended in a different JSON order.
38+
bindings = sorted(bindings, key=lambda binding: binding.context != "autocomplete")
39+
for action_key in bindings:
3540
if action_key.key != key:
3641
continue
3742
if action_key.context is not None and action_key.context not in contexts:

‎sqlit/core/keymap.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ class ActionKeyDef:
6565
primary: bool = True # Primary key for display vs secondary aliases
6666
show: bool = False # Whether to show in Textual's binding hints
6767
priority: bool = False # Whether to give priority to this binding
68+
leader_command: bool = False # Explicit direct binding for a main leader command
6869

6970

7071
class KeymapProvider(ABC):

‎sqlit/domains/shell/app/keymap_manager.py‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,7 @@ def _build_provider_from_payload(
412412
base_leader = defaults.get_leader_commands()
413413

414414
user_action_overrides, action_unbinds = self._parse_action_overrides(
415-
keymap_data.get("action_keys", {}), base_action
415+
keymap_data.get("action_keys", {}), base_action, base_leader
416416
)
417417
user_leader_overrides, leader_unbinds = self._parse_leader_overrides(
418418
keymap_data.get("leader_commands", {}), base_leader
@@ -470,7 +470,7 @@ def _normalize_key_list(value: Any, where: str) -> list[str] | None:
470470

471471
@staticmethod
472472
def _parse_action_overrides(
473-
data: Any, base: list[ActionKeyDef]
473+
data: Any, base: list[ActionKeyDef], leader: list[LeaderCommandDef]
474474
) -> tuple[list[ActionKeyDef], set[tuple[str, str | None]]]:
475475
if not isinstance(data, dict):
476476
raise ValueError('"action_keys" must be a JSON object keyed by state name.')
@@ -488,6 +488,11 @@ def _parse_action_overrides(
488488
for ak in base:
489489
actions_in_state[ak.context].add(ak.action)
490490

491+
# Only the main leader menu contains standalone app actions. Vim
492+
# submenus reuse motion names such as "word" and need their prefix.
493+
leader_by_action = {cmd.action: cmd for cmd in leader if cmd.menu == "leader"}
494+
app_contexts = _context_ancestors()
495+
491496
out: list[ActionKeyDef] = []
492497
unbinds: set[tuple[str, str | None]] = set()
493498
for state, mapping in data.items():
@@ -501,6 +506,10 @@ def _parse_action_overrides(
501506
raise ValueError(f'action_keys."{state}": action names must be non-empty strings.')
502507

503508
template = defaults_by_pair.get((action, state))
509+
if template is None and state in app_contexts:
510+
command = leader_by_action.get(action)
511+
if command is not None:
512+
template = ActionKeyDef("", action, state, guard=command.guard)
504513
if template is None:
505514
suggestions = sorted(actions_in_state.get(state, set()))
506515
hint = (
@@ -533,6 +542,7 @@ def _parse_action_overrides(
533542
primary=first,
534543
show=template.show,
535544
priority=template.priority,
545+
leader_command=action in leader_by_action,
536546
)
537547
)
538548
first = False

‎sqlit/domains/shell/state/machine.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010

1111
from __future__ import annotations
1212

13+
from sqlit.core.binding_contexts import get_binding_contexts
1314
from sqlit.core.input_context import InputContext
14-
from sqlit.core.leader_commands import get_leader_commands
15+
from sqlit.core.keymap import get_keymap
16+
from sqlit.core.leader_commands import LEADER_GUARDS, get_leader_commands
1517
from sqlit.core.state_base import (
1618
ActionResult,
1719
DisplayBinding,
@@ -148,6 +150,17 @@ def check_action(self, app: InputContext, action_name: str) -> bool:
148150
"""Check if action is allowed in current state."""
149151
state = self.get_active_state(app)
150152
result = state.check_action(app, action_name)
153+
if result == ActionResult.UNHANDLED and not app.modal_open and not app.leader_pending:
154+
contexts = get_binding_contexts(app)
155+
for binding in get_keymap().get_action_keys():
156+
if (
157+
binding.leader_command
158+
and binding.action == action_name
159+
and binding.context in contexts
160+
):
161+
# Explicit state prohibitions still take precedence.
162+
guard = LEADER_GUARDS.get(binding.guard) if binding.guard else None
163+
return guard(app) if guard else True
151164
return result == ActionResult.ALLOWED
152165

153166
def get_display_bindings(self, app: InputContext) -> tuple[list[DisplayBinding], list[DisplayBinding]]:

‎sqlit/shared/ui/widgets_text_area.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,11 +152,38 @@ async def _dispatch_query_insert_action(self, key: str) -> bool:
152152
binding.key == key
153153
and binding.context == "query_insert"
154154
and binding.action not in clipboard_actions
155+
and not binding.leader_command
155156
and self.app.check_action(binding.action, ()) is not False
156157
):
157158
return await self.app.run_action(binding.action)
158159
return False
159160

161+
async def _dispatch_direct_leader_action(self, key: str) -> bool:
162+
"""Honor scoped command bindings before editor shortcuts consume them."""
163+
from sqlit.core.binding_contexts import get_binding_contexts
164+
from sqlit.core.key_router import resolve_action
165+
from sqlit.core.keymap import get_keymap
166+
167+
get_context = getattr(self.app, "_get_input_context", None)
168+
if get_context is None or getattr(self.app, "_command_mode", False):
169+
return False
170+
ctx = get_context()
171+
if ctx.modal_open or ctx.leader_pending:
172+
return False
173+
contexts = get_binding_contexts(ctx)
174+
if any(
175+
binding.leader_command and binding.key == key and binding.context in contexts
176+
for binding in get_keymap().get_action_keys()
177+
):
178+
# Another active context (such as autocomplete) may own this key.
179+
# Forward the router's winner, not necessarily the promoted command.
180+
action = resolve_action(
181+
key, ctx, is_allowed=lambda name: self.app.check_action(name, ()) is not False
182+
)
183+
if action:
184+
return await self.app.run_action(action)
185+
return False
186+
160187
async def _handle_autocomplete_enter(self) -> bool:
161188
"""Honor an explicit Enter autocomplete binding, or insert a newline."""
162189
app = cast("AutocompleteProtocol", self.app)
@@ -188,6 +215,11 @@ async def _on_key(self, event: Key) -> None:
188215
"""Intercept clipboard, undo/redo, Enter, and Tab keys."""
189216
normalized_key = self._normalize_key(event.key)
190217

218+
if await self._dispatch_direct_leader_action(event.key):
219+
event.prevent_default()
220+
event.stop()
221+
return
222+
191223
# TextArea consumes editing keys before they reach the app-level key
192224
# router. Forward query-insert actions explicitly so shortcuts such as
193225
# Ctrl+Enter (and user rebindings such as F5 or Enter) execute instead
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
"""Regression coverage for promoting leader commands to direct keys (#321)."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from sqlit.core.key_router import resolve_action
8+
from sqlit.core.keymap import get_keymap, set_keymap
9+
from sqlit.core.vim import VimMode
10+
from sqlit.domains.shell.app.keymap_manager import KeymapManager
11+
from sqlit.domains.shell.state import UIStateMachine
12+
13+
from .test_keymap_manager import MockSettingsStore
14+
from .test_state_machine import make_context
15+
16+
17+
def load_bindings(actions, *, leader=None):
18+
manager = KeymapManager(settings_store=MockSettingsStore())
19+
provider = manager._build_provider_from_payload(
20+
{"keymap": {"action_keys": actions, "leader_commands": leader or {}}}, "test"
21+
)
22+
set_keymap(provider)
23+
return provider
24+
25+
26+
def route(key="ctrl+g", **context):
27+
ctx = make_context(**context)
28+
machine = UIStateMachine()
29+
return resolve_action(key, ctx, is_allowed=lambda action: machine.check_action(ctx, action))
30+
31+
32+
@pytest.mark.parametrize("action", ["edit_query_in_editor", "format_query", "telescope", "change_theme"])
33+
def test_direct_command_preserves_leader_binding(action):
34+
original = get_keymap().leader(action)
35+
load_bindings({"query_normal": {action: "ctrl+g"}})
36+
assert route(focus="query") == action
37+
assert get_keymap().leader(action) == original
38+
assert route(focus="results") is None
39+
assert route(focus="query", vim_mode=VimMode.INSERT) is None
40+
assert route(original, focus="query", leader_pending=True) == f"leader_{action}"
41+
42+
43+
def test_aliases_and_independent_leader_unbinding():
44+
provider = load_bindings(
45+
{"query_normal": {"telescope": ["ctrl+g", "ctrl+t"]}},
46+
leader={"leader": {"telescope": None}},
47+
)
48+
assert provider.keys_for_action("telescope") == ["ctrl+g", "ctrl+t"]
49+
assert provider.leader("telescope") is None
50+
assert route(focus="query") == "telescope"
51+
assert route("ctrl+t", focus="query") == "telescope"
52+
53+
54+
@pytest.mark.parametrize("value", [None, "", []])
55+
def test_unbinding_direct_command_keeps_leader(value):
56+
load_bindings({"query_normal": {"telescope": value}})
57+
assert route(focus="query") is None
58+
assert get_keymap().leader("telescope") == "space"
59+
60+
61+
@pytest.mark.parametrize(
62+
("action", "context", "expected"),
63+
[
64+
("format_query", {"focus": "results"}, None),
65+
("format_query", {"focus": "query"}, "format_query"),
66+
("disconnect", {"has_connection": False}, None),
67+
("disconnect", {"has_connection": True}, "disconnect"),
68+
("cancel_operation", {"query_executing": False}, None),
69+
("cancel_operation", {"query_executing": True}, "cancel_operation"),
70+
("telescope", {"modal_open": True}, None),
71+
("show_help", {"focus": "query", "vim_mode": VimMode.INSERT}, None),
72+
],
73+
)
74+
def test_direct_command_respects_guards_and_state_restrictions(action, context, expected):
75+
load_bindings({"global": {action: "ctrl+g"}})
76+
assert route(**context) == expected
77+
78+
79+
@pytest.mark.parametrize(
80+
"actions",
81+
[
82+
{"query_normal": {"telescope": "enter"}},
83+
{"query_normal": {"telescope": "ctrl+g", "change_theme": "ctrl+g"}},
84+
{"query_normal": {"telescope": "q"}}, # shadows navigation focus_query
85+
],
86+
)
87+
def test_promoted_command_uses_existing_conflict_detection(actions):
88+
with pytest.raises(ValueError, match=r"conflict|multiple actions|shadow"):
89+
load_bindings(actions)
90+
91+
92+
def test_unbinding_conflicting_default_allows_promotion():
93+
load_bindings({"query_normal": {"execute_query": None, "telescope": "enter"}})
94+
assert route("enter", focus="query") == "telescope"
95+
96+
97+
@pytest.mark.parametrize("scope", ["made_up_state", "connection_editor", "error_dialog"])
98+
def test_promotions_reject_unknown_or_screen_local_scopes(scope):
99+
with pytest.raises(ValueError, match="Unknown action"):
100+
load_bindings({scope: {"telescope": "ctrl+g"}})
101+
102+
103+
@pytest.mark.parametrize("action", ["word", "line", "not_an_action"])
104+
def test_submenu_motions_and_unknown_actions_are_not_promoted(action):
105+
with pytest.raises(ValueError, match="Unknown action"):
106+
load_bindings({"query_normal": {action: "ctrl+g"}})
107+
108+
109+
@pytest.mark.parametrize("action", ["edit_query_in_editor", "telescope", "change_theme"])
110+
@pytest.mark.parametrize("key", ["ctrl+g", "ctrl+a", "ctrl+c", "ctrl+v"])
111+
async def test_direct_key_and_leader_key_dispatch_in_headless_app(tmp_path, monkeypatch, action, key):
112+
import json
113+
114+
from sqlit.domains.shell.app import keymap_manager
115+
116+
from .test_leader import _make_app
117+
118+
path = tmp_path / "keymap.json"
119+
path.write_text(json.dumps({"keymap": {"action_keys": {"query_normal": {action: key}}}}))
120+
monkeypatch.setattr(keymap_manager, "DEFAULT_KEYMAP_FILE", path)
121+
app = _make_app()
122+
calls = []
123+
monkeypatch.setattr(app, f"action_{action}", lambda: calls.append(action))
124+
async with app.run_test(size=(100, 35)) as pilot:
125+
app.action_focus_query()
126+
await pilot.pause()
127+
await pilot.press(key)
128+
assert calls == [action]
129+
await pilot.press("space", get_keymap().leader(action))
130+
assert calls == [action, action]
131+
132+
133+
@pytest.mark.parametrize("override_order", ["default", "insert_first", "autocomplete_first"])
134+
async def test_autocomplete_binding_takes_precedence_over_insert_command(tmp_path, monkeypatch, override_order):
135+
import json
136+
137+
from sqlit.domains.shell.app import keymap_manager
138+
139+
from .test_leader import _make_app
140+
141+
path = tmp_path / "keymap.json"
142+
actions = {"query_insert": {"telescope": "ctrl+j"}}
143+
if override_order != "default":
144+
actions["autocomplete"] = {"autocomplete_next": "ctrl+j"}
145+
if override_order == "autocomplete_first":
146+
actions = dict(reversed(list(actions.items())))
147+
path.write_text(json.dumps({"keymap": {"action_keys": actions}}))
148+
monkeypatch.setattr(keymap_manager, "DEFAULT_KEYMAP_FILE", path)
149+
app = _make_app()
150+
calls = []
151+
monkeypatch.setattr(app, "action_telescope", lambda: calls.append("telescope"))
152+
monkeypatch.setattr(app, "action_autocomplete_next", lambda: calls.append("autocomplete_next"))
153+
async with app.run_test(size=(100, 35)) as pilot:
154+
app.action_focus_query()
155+
await pilot.pause()
156+
app.action_enter_insert_mode()
157+
await pilot.pause()
158+
app._autocomplete_visible = True
159+
assert app._get_input_context().autocomplete_visible
160+
assert app._get_input_context().vim_mode == VimMode.INSERT
161+
assert app.check_action("autocomplete_next", ()) is True
162+
await pilot.press("ctrl+j")
163+
assert calls == ["autocomplete_next"]
164+
app._autocomplete_visible = False
165+
await pilot.press("ctrl+j")
166+
assert calls == ["autocomplete_next", "telescope"]

0 commit comments

Comments
 (0)