Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
7 changes: 6 additions & 1 deletion sqlit/core/key_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions sqlit/core/keymap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
14 changes: 12 additions & 2 deletions sqlit/domains/shell/app/keymap_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.')
Expand All @@ -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():
Expand All @@ -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 = (
Expand Down Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion sqlit/domains/shell/state/machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]]:
Expand Down
32 changes: 32 additions & 0 deletions sqlit/shared/ui/widgets_text_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
166 changes: 166 additions & 0 deletions tests/ui/keybindings/test_direct_leader_bindings.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading