From ef06e9edac6ee2c628e5110754fe70af485bc373 Mon Sep 17 00:00:00 2001 From: Peter Adams <18162810+Maxteabag@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:55:47 +0200 Subject: [PATCH] feat(keymap): allow direct bindings for leader commands --- README.md | 19 ++ sqlit/core/key_router.py | 7 +- sqlit/core/keymap.py | 1 + sqlit/domains/shell/app/keymap_manager.py | 14 +- sqlit/domains/shell/state/machine.py | 15 +- sqlit/shared/ui/widgets_text_area.py | 32 ++++ .../test_direct_leader_bindings.py | 166 ++++++++++++++++++ 7 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 tests/ui/keybindings/test_direct_leader_bindings.py diff --git a/README.md b/README.md index cab18b4b..67633fe8 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,25 @@ Connections and settings are stored in `$XDG_CONFIG_HOME/sqlit/` (default: `~/.c 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. +Commands from the main leader menu can also be bound directly in an app scope: + +```json +{ + "keymap": { + "action_keys": { + "query_normal": { "edit_query_in_editor": "ctrl+g" } + } + } +} +``` + +This keeps the leader shortcut and adds `ctrl+g` in the query editor's normal +mode. Other examples include `format_query`, `telescope`, and `change_theme`. +Direct bindings retain command guards and state restrictions. Conflicting keys +are rejected; unbind or remap the existing action to free its key. Use a list for +aliases, or `null` to remove a direct binding without changing the leader menu. +Vim submenu motions and dialog scopes are not supported for this promotion. + ## FAQ ### How are sensitive credentials stored? diff --git a/sqlit/core/key_router.py b/sqlit/core/key_router.py index 05f24b25..5579cc2c 100644 --- a/sqlit/core/key_router.py +++ b/sqlit/core/key_router.py @@ -31,7 +31,12 @@ def resolve_action( contexts = get_binding_contexts(ctx) keymap = get_keymap() - for action_key in keymap.get_action_keys(): + bindings = keymap.get_action_keys() + if ctx.autocomplete_visible: + # Autocomplete overlays query-insert mode. Preserve that precedence + # even when user overrides are appended in a different JSON order. + bindings = sorted(bindings, key=lambda binding: binding.context != "autocomplete") + for action_key in bindings: if action_key.key != key: continue if action_key.context is not None and action_key.context not in contexts: diff --git a/sqlit/core/keymap.py b/sqlit/core/keymap.py index 0a1c535a..522e4567 100644 --- a/sqlit/core/keymap.py +++ b/sqlit/core/keymap.py @@ -65,6 +65,7 @@ class ActionKeyDef: primary: bool = True # Primary key for display vs secondary aliases show: bool = False # Whether to show in Textual's binding hints priority: bool = False # Whether to give priority to this binding + leader_command: bool = False # Explicit direct binding for a main leader command class KeymapProvider(ABC): diff --git a/sqlit/domains/shell/app/keymap_manager.py b/sqlit/domains/shell/app/keymap_manager.py index 01ee4120..3a82d179 100644 --- a/sqlit/domains/shell/app/keymap_manager.py +++ b/sqlit/domains/shell/app/keymap_manager.py @@ -412,7 +412,7 @@ def _build_provider_from_payload( base_leader = defaults.get_leader_commands() user_action_overrides, action_unbinds = self._parse_action_overrides( - keymap_data.get("action_keys", {}), base_action + keymap_data.get("action_keys", {}), base_action, base_leader ) user_leader_overrides, leader_unbinds = self._parse_leader_overrides( keymap_data.get("leader_commands", {}), base_leader @@ -470,7 +470,7 @@ def _normalize_key_list(value: Any, where: str) -> list[str] | None: @staticmethod def _parse_action_overrides( - data: Any, base: list[ActionKeyDef] + data: Any, base: list[ActionKeyDef], leader: list[LeaderCommandDef] ) -> tuple[list[ActionKeyDef], set[tuple[str, str | None]]]: if not isinstance(data, dict): raise ValueError('"action_keys" must be a JSON object keyed by state name.') @@ -488,6 +488,11 @@ def _parse_action_overrides( for ak in base: actions_in_state[ak.context].add(ak.action) + # Only the main leader menu contains standalone app actions. Vim + # submenus reuse motion names such as "word" and need their prefix. + leader_by_action = {cmd.action: cmd for cmd in leader if cmd.menu == "leader"} + app_contexts = _context_ancestors() + out: list[ActionKeyDef] = [] unbinds: set[tuple[str, str | None]] = set() for state, mapping in data.items(): @@ -501,6 +506,10 @@ def _parse_action_overrides( raise ValueError(f'action_keys."{state}": action names must be non-empty strings.') template = defaults_by_pair.get((action, state)) + if template is None and state in app_contexts: + command = leader_by_action.get(action) + if command is not None: + template = ActionKeyDef("", action, state, guard=command.guard) if template is None: suggestions = sorted(actions_in_state.get(state, set())) hint = ( @@ -533,6 +542,7 @@ def _parse_action_overrides( primary=first, show=template.show, priority=template.priority, + leader_command=action in leader_by_action, ) ) first = False diff --git a/sqlit/domains/shell/state/machine.py b/sqlit/domains/shell/state/machine.py index 28944a9f..cea1cb8c 100644 --- a/sqlit/domains/shell/state/machine.py +++ b/sqlit/domains/shell/state/machine.py @@ -10,8 +10,10 @@ from __future__ import annotations +from sqlit.core.binding_contexts import get_binding_contexts from sqlit.core.input_context import InputContext -from sqlit.core.leader_commands import get_leader_commands +from sqlit.core.keymap import get_keymap +from sqlit.core.leader_commands import LEADER_GUARDS, get_leader_commands from sqlit.core.state_base import ( ActionResult, DisplayBinding, @@ -148,6 +150,17 @@ def check_action(self, app: InputContext, action_name: str) -> bool: """Check if action is allowed in current state.""" state = self.get_active_state(app) result = state.check_action(app, action_name) + if result == ActionResult.UNHANDLED and not app.modal_open and not app.leader_pending: + contexts = get_binding_contexts(app) + for binding in get_keymap().get_action_keys(): + if ( + binding.leader_command + and binding.action == action_name + and binding.context in contexts + ): + # Explicit state prohibitions still take precedence. + guard = LEADER_GUARDS.get(binding.guard) if binding.guard else None + return guard(app) if guard else True return result == ActionResult.ALLOWED def get_display_bindings(self, app: InputContext) -> tuple[list[DisplayBinding], list[DisplayBinding]]: diff --git a/sqlit/shared/ui/widgets_text_area.py b/sqlit/shared/ui/widgets_text_area.py index 54c97c8c..b3d5ffba 100644 --- a/sqlit/shared/ui/widgets_text_area.py +++ b/sqlit/shared/ui/widgets_text_area.py @@ -152,11 +152,38 @@ async def _dispatch_query_insert_action(self, key: str) -> bool: binding.key == key and binding.context == "query_insert" and binding.action not in clipboard_actions + and not binding.leader_command and self.app.check_action(binding.action, ()) is not False ): return await self.app.run_action(binding.action) return False + async def _dispatch_direct_leader_action(self, key: str) -> bool: + """Honor scoped command bindings before editor shortcuts consume them.""" + from sqlit.core.binding_contexts import get_binding_contexts + from sqlit.core.key_router import resolve_action + from sqlit.core.keymap import get_keymap + + get_context = getattr(self.app, "_get_input_context", None) + if get_context is None or getattr(self.app, "_command_mode", False): + return False + ctx = get_context() + if ctx.modal_open or ctx.leader_pending: + return False + contexts = get_binding_contexts(ctx) + if any( + binding.leader_command and binding.key == key and binding.context in contexts + for binding in get_keymap().get_action_keys() + ): + # Another active context (such as autocomplete) may own this key. + # Forward the router's winner, not necessarily the promoted command. + action = resolve_action( + key, ctx, is_allowed=lambda name: self.app.check_action(name, ()) is not False + ) + if action: + return await self.app.run_action(action) + return False + async def _handle_autocomplete_enter(self) -> bool: """Honor an explicit Enter autocomplete binding, or insert a newline.""" app = cast("AutocompleteProtocol", self.app) @@ -188,6 +215,11 @@ async def _on_key(self, event: Key) -> None: """Intercept clipboard, undo/redo, Enter, and Tab keys.""" normalized_key = self._normalize_key(event.key) + if await self._dispatch_direct_leader_action(event.key): + event.prevent_default() + event.stop() + return + # TextArea consumes editing keys before they reach the app-level key # router. Forward query-insert actions explicitly so shortcuts such as # Ctrl+Enter (and user rebindings such as F5 or Enter) execute instead diff --git a/tests/ui/keybindings/test_direct_leader_bindings.py b/tests/ui/keybindings/test_direct_leader_bindings.py new file mode 100644 index 00000000..a5f610ab --- /dev/null +++ b/tests/ui/keybindings/test_direct_leader_bindings.py @@ -0,0 +1,166 @@ +"""Regression coverage for promoting leader commands to direct keys (#321).""" + +from __future__ import annotations + +import pytest + +from sqlit.core.key_router import resolve_action +from sqlit.core.keymap import get_keymap, set_keymap +from sqlit.core.vim import VimMode +from sqlit.domains.shell.app.keymap_manager import KeymapManager +from sqlit.domains.shell.state import UIStateMachine + +from .test_keymap_manager import MockSettingsStore +from .test_state_machine import make_context + + +def load_bindings(actions, *, leader=None): + manager = KeymapManager(settings_store=MockSettingsStore()) + provider = manager._build_provider_from_payload( + {"keymap": {"action_keys": actions, "leader_commands": leader or {}}}, "test" + ) + set_keymap(provider) + return provider + + +def route(key="ctrl+g", **context): + ctx = make_context(**context) + machine = UIStateMachine() + return resolve_action(key, ctx, is_allowed=lambda action: machine.check_action(ctx, action)) + + +@pytest.mark.parametrize("action", ["edit_query_in_editor", "format_query", "telescope", "change_theme"]) +def test_direct_command_preserves_leader_binding(action): + original = get_keymap().leader(action) + load_bindings({"query_normal": {action: "ctrl+g"}}) + assert route(focus="query") == action + assert get_keymap().leader(action) == original + assert route(focus="results") is None + assert route(focus="query", vim_mode=VimMode.INSERT) is None + assert route(original, focus="query", leader_pending=True) == f"leader_{action}" + + +def test_aliases_and_independent_leader_unbinding(): + provider = load_bindings( + {"query_normal": {"telescope": ["ctrl+g", "ctrl+t"]}}, + leader={"leader": {"telescope": None}}, + ) + assert provider.keys_for_action("telescope") == ["ctrl+g", "ctrl+t"] + assert provider.leader("telescope") is None + assert route(focus="query") == "telescope" + assert route("ctrl+t", focus="query") == "telescope" + + +@pytest.mark.parametrize("value", [None, "", []]) +def test_unbinding_direct_command_keeps_leader(value): + load_bindings({"query_normal": {"telescope": value}}) + assert route(focus="query") is None + assert get_keymap().leader("telescope") == "space" + + +@pytest.mark.parametrize( + ("action", "context", "expected"), + [ + ("format_query", {"focus": "results"}, None), + ("format_query", {"focus": "query"}, "format_query"), + ("disconnect", {"has_connection": False}, None), + ("disconnect", {"has_connection": True}, "disconnect"), + ("cancel_operation", {"query_executing": False}, None), + ("cancel_operation", {"query_executing": True}, "cancel_operation"), + ("telescope", {"modal_open": True}, None), + ("show_help", {"focus": "query", "vim_mode": VimMode.INSERT}, None), + ], +) +def test_direct_command_respects_guards_and_state_restrictions(action, context, expected): + load_bindings({"global": {action: "ctrl+g"}}) + assert route(**context) == expected + + +@pytest.mark.parametrize( + "actions", + [ + {"query_normal": {"telescope": "enter"}}, + {"query_normal": {"telescope": "ctrl+g", "change_theme": "ctrl+g"}}, + {"query_normal": {"telescope": "q"}}, # shadows navigation focus_query + ], +) +def test_promoted_command_uses_existing_conflict_detection(actions): + with pytest.raises(ValueError, match=r"conflict|multiple actions|shadow"): + load_bindings(actions) + + +def test_unbinding_conflicting_default_allows_promotion(): + load_bindings({"query_normal": {"execute_query": None, "telescope": "enter"}}) + assert route("enter", focus="query") == "telescope" + + +@pytest.mark.parametrize("scope", ["made_up_state", "connection_editor", "error_dialog"]) +def test_promotions_reject_unknown_or_screen_local_scopes(scope): + with pytest.raises(ValueError, match="Unknown action"): + load_bindings({scope: {"telescope": "ctrl+g"}}) + + +@pytest.mark.parametrize("action", ["word", "line", "not_an_action"]) +def test_submenu_motions_and_unknown_actions_are_not_promoted(action): + with pytest.raises(ValueError, match="Unknown action"): + load_bindings({"query_normal": {action: "ctrl+g"}}) + + +@pytest.mark.parametrize("action", ["edit_query_in_editor", "telescope", "change_theme"]) +@pytest.mark.parametrize("key", ["ctrl+g", "ctrl+a", "ctrl+c", "ctrl+v"]) +async def test_direct_key_and_leader_key_dispatch_in_headless_app(tmp_path, monkeypatch, action, key): + import json + + from sqlit.domains.shell.app import keymap_manager + + from .test_leader import _make_app + + path = tmp_path / "keymap.json" + path.write_text(json.dumps({"keymap": {"action_keys": {"query_normal": {action: key}}}})) + monkeypatch.setattr(keymap_manager, "DEFAULT_KEYMAP_FILE", path) + app = _make_app() + calls = [] + monkeypatch.setattr(app, f"action_{action}", lambda: calls.append(action)) + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + await pilot.press(key) + assert calls == [action] + await pilot.press("space", get_keymap().leader(action)) + assert calls == [action, action] + + +@pytest.mark.parametrize("override_order", ["default", "insert_first", "autocomplete_first"]) +async def test_autocomplete_binding_takes_precedence_over_insert_command(tmp_path, monkeypatch, override_order): + import json + + from sqlit.domains.shell.app import keymap_manager + + from .test_leader import _make_app + + path = tmp_path / "keymap.json" + actions = {"query_insert": {"telescope": "ctrl+j"}} + if override_order != "default": + actions["autocomplete"] = {"autocomplete_next": "ctrl+j"} + if override_order == "autocomplete_first": + actions = dict(reversed(list(actions.items()))) + path.write_text(json.dumps({"keymap": {"action_keys": actions}})) + monkeypatch.setattr(keymap_manager, "DEFAULT_KEYMAP_FILE", path) + app = _make_app() + calls = [] + monkeypatch.setattr(app, "action_telescope", lambda: calls.append("telescope")) + monkeypatch.setattr(app, "action_autocomplete_next", lambda: calls.append("autocomplete_next")) + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + app.action_enter_insert_mode() + await pilot.pause() + app._autocomplete_visible = True + assert app._get_input_context().autocomplete_visible + assert app._get_input_context().vim_mode == VimMode.INSERT + assert app.check_action("autocomplete_next", ()) is True + await pilot.press("ctrl+j") + assert calls == ["autocomplete_next"] + app._autocomplete_visible = False + await pilot.press("ctrl+j") + assert calls == ["autocomplete_next", "telescope"]