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
17 changes: 13 additions & 4 deletions config/keymap.template.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
"action_keys": {
"global": {
"leader_key": "space",
"quit": "ctrl+q",
"cancel_operation": "escape",
"show_help": "?",
"enter_command_mode": ":",
"enter_command_mode": [
":",
"shift+;"
],
"undo": "ctrl+z",
"redo": "ctrl+y"
},
Expand Down Expand Up @@ -224,6 +226,14 @@
"toggle_value_view_mode": "t",
"collapse_all_json_nodes": "z",
"expand_all_json_nodes": "Z"
},
"error_dialog": {
"error_copy_message": "y"
},
"connection_editor": {
"connection_save": "ctrl+s",
"connection_test": "ctrl+t",
"connection_install_driver": "ctrl+d"
}
},
"leader_commands": {
Expand All @@ -237,8 +247,7 @@
"edit_query_in_editor": "o",
"show_help": "h",
"telescope": "space",
"telescope_filter": "/",
"quit": "q"
"telescope_filter": "/"
},
"delete": {
"line": "d",
Expand Down
13 changes: 13 additions & 0 deletions sqlit/core/action_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,24 @@
from sqlit.core.keymap import get_keymap
from sqlit.core.leader_commands import get_leader_commands

# Contexts whose actions live on a Screen class, not on the App. These are
# routed via Textual's per-screen Binding system; their action methods are
# defined on the screen they belong to, so checking the App for them would
# always miss. Skipped by the validator.
_SCREEN_LOCAL_CONTEXTS: frozenset[str] = frozenset(
{
"error_dialog",
"connection_editor",
}
)


def validate_actions(app: Any) -> list[str]:
missing: set[str] = set()

for action_key in get_keymap().get_action_keys():
if action_key.context in _SCREEN_LOCAL_CONTEXTS:
continue
action_name = f"action_{action_key.action}"
if not hasattr(app, action_name):
missing.add(action_name)
Expand Down
13 changes: 11 additions & 2 deletions sqlit/core/keymap.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,6 @@ def _build_leader_commands(self) -> list[LeaderCommandDef]:
LeaderCommandDef("h", "show_help", "Help", "Actions"),
LeaderCommandDef("space", "telescope", "Telescope", "Actions"),
LeaderCommandDef("slash", "telescope_filter", "Telescope Search", "Actions"),
LeaderCommandDef("q", "quit", "Quit", "Actions"),
# Delete menu (vim-style)
LeaderCommandDef("d", "line", "Delete line", "Delete", menu="delete"),
LeaderCommandDef("w", "word", "Delete word", "Delete", menu="delete"),
Expand Down Expand Up @@ -337,7 +336,10 @@ def _build_action_keys(self) -> list[ActionKeyDef]:
ActionKeyDef("enter", "tree_filter_accept", "tree_filter"),
# Global
ActionKeyDef("space", "leader_key", "global", priority=True),
ActionKeyDef("ctrl+q", "quit", "global"),
# quit is intentionally not in the user-overridable keymap;
# it lives only as Textual's built-in App.BINDINGS (ctrl+q) and
# the `:q` command. Keeping it un-rebindable avoids the footgun
# of a user accidentally locking themselves out of the exit key.
ActionKeyDef("escape", "cancel_operation", "global"),
ActionKeyDef("question_mark", "show_help", "global"),
ActionKeyDef("colon", "enter_command_mode", "global"),
Expand Down Expand Up @@ -484,6 +486,13 @@ def _build_action_keys(self) -> list[ActionKeyDef]:
ActionKeyDef("t", "toggle_value_view_mode", "value_view"),
ActionKeyDef("z", "collapse_all_json_nodes", "value_view"),
ActionKeyDef("Z", "expand_all_json_nodes", "value_view"),
# Error dialog (screen-local — action methods live on ErrorScreen,
# not the App; the validator skips this context for that reason)
ActionKeyDef("y", "error_copy_message", "error_dialog"),
# Connection editor (screen-local, same as above)
ActionKeyDef("ctrl+s", "connection_save", "connection_editor"),
ActionKeyDef("ctrl+t", "connection_test", "connection_editor"),
ActionKeyDef("ctrl+d", "connection_install_driver", "connection_editor"),
]


Expand Down
13 changes: 9 additions & 4 deletions sqlit/domains/connections/ui/screens/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ class ConnectionScreen(ModalScreen):

BINDINGS = [
Binding("escape", "cancel", "Cancel", priority=True),
Binding("ctrl+s", "save", "Save", priority=True),
Binding("ctrl+t", "test_connection", "Test", priority=True),
Binding("ctrl+d", "install_driver", "Install driver", show=False, priority=True),
Binding("ctrl+s", "save", "Save", priority=True, id="connection_save"),
Binding("ctrl+t", "test_connection", "Test", priority=True, id="connection_test"),
Binding("ctrl+d", "install_driver", "Install driver", show=False, priority=True, id="connection_install_driver"),
Binding("tab", "next_field", "Next field", priority=True),
Binding("shift+tab", "prev_field", "Previous field", priority=True),
Binding("down", "focus_tab_content", "Focus content", show=False),
Expand Down Expand Up @@ -306,7 +306,12 @@ def compose(self) -> ComposeResult:
title = "Edit Connection" if self.editing else "New Connection"
db_type = self._form.current_db_type

shortcuts = [("Test", "^t"), ("Save", "^s"), ("Cancel", "<esc>")]
from sqlit.core.keymap import format_key, get_keymap

km = get_keymap()
test_key = format_key(km.action("connection_test") or "ctrl+t")
save_key = format_key(km.action("connection_save") or "ctrl+s")
shortcuts = [("Test", test_key), ("Save", save_key), ("Cancel", "<esc>")]
initial_values = self._form.get_initial_visibility_values()

with Dialog(id="connection-dialog", title=title, shortcuts=shortcuts):
Expand Down
12 changes: 9 additions & 3 deletions sqlit/domains/results/ui/screens/value_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,17 @@ def _format_syntax_value(self) -> str | Syntax:
return self._raw_value

def compose(self) -> ComposeResult:
shortcuts = [("Copy", "y"), ("Close", "<enter>")]
from sqlit.core.keymap import format_key, get_keymap

km = get_keymap()
copy_key = format_key(km.action("copy_value_view") or "y")
toggle_key = format_key(km.action("toggle_value_view_mode") or "t")
collapse_key = format_key(km.action("collapse_all_json_nodes") or "z")
shortcuts = [("Copy", copy_key), ("Close", "<enter>")]
if self._is_json:
# Start in tree mode, so show "Syntax View" as what we'd switch to
shortcuts.insert(0, ("Syntax View", "t"))
shortcuts.insert(0, ("Collapse", "z"))
shortcuts.insert(0, ("Syntax View", toggle_key))
shortcuts.insert(0, ("Collapse", collapse_key))
with Dialog(id="value-dialog", title=self._title, shortcuts=shortcuts):
with VerticalScroll(id="value-scroll", classes="hidden"):
yield Static(self._format_syntax_value(), id="value-text", markup=False)
Expand Down
13 changes: 10 additions & 3 deletions sqlit/domains/shell/ui/mixins/ui_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,16 @@ def set_title(pane: Any, key: str, label: str, *, active: bool) -> None:
# Inactive pane: key and title match border color via CSS
pane.border_title = f"\\[{key}] {label}"

set_title(pane_explorer, "e", explorer_label, active=active_pane == "explorer")
set_title(pane_query, "q", "Query", active=active_pane == "query")
set_title(pane_results, "r", "Results", active=active_pane == "results")
from sqlit.core.keymap import format_key, get_keymap

km = get_keymap()
explorer_key = format_key(km.action("focus_explorer") or "e")
query_key = format_key(km.action("focus_query") or "q")
results_key = format_key(km.action("focus_results") or "r")

set_title(pane_explorer, explorer_key, explorer_label, active=active_pane == "explorer")
set_title(pane_query, query_key, "Query", active=active_pane == "query")
set_title(pane_results, results_key, "Results", active=active_pane == "results")

def _update_vim_mode_visuals(self: UINavigationMixinHost) -> None:
"""Update all visual indicators based on current vim mode.
Expand Down
8 changes: 6 additions & 2 deletions sqlit/shared/ui/screens/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class ErrorScreen(ModalScreen):

BINDINGS = [
Binding("enter,escape", "close", "Close"),
Binding("y", "copy_message", "Copy"),
Binding("y", "copy_message", "Copy", id="error_copy_message"),
]

CSS = """
Expand Down Expand Up @@ -44,7 +44,11 @@ def __init__(self, title: str, message: str):
self.message = message

def compose(self) -> ComposeResult:
shortcuts = [("Copy", "y"), ("Close", "<enter>")]
from sqlit.core.keymap import format_key, get_keymap

km = get_keymap()
copy_key = format_key(km.action("error_copy_message") or "y")
shortcuts = [("Copy", copy_key), ("Close", "<enter>")]
with Dialog(id="error-dialog", title=self.title_text, shortcuts=shortcuts):
yield Static(self.message, id="error-message")

Expand Down
6 changes: 3 additions & 3 deletions tests/ui/keybindings/test_keymap_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ def test_unrelated_defaults_remain(self, tmp_path: Path):
_load(
tmp_path,
"small",
{"keymap": {"leader_commands": {"leader": {"quit": "Z"}}}},
{"keymap": {"leader_commands": {"leader": {"change_theme": "Z"}}}},
)
keymap = get_keymap()
# toggle_explorer wasn't touched — still on its default 'e'.
Expand Down Expand Up @@ -438,11 +438,11 @@ def test_user_vs_user_action_key(self, tmp_path: Path):
assert "Conflicting keybindings detected" in manager.load_error

def test_user_vs_default_leader(self, tmp_path: Path):
# Default: <leader>e → toggle_explorer. User binds <leader>e → quit.
# Default: <leader>e → toggle_explorer. User binds <leader>e → change_theme.
manager = _load(
tmp_path,
"leader-conflict",
{"keymap": {"leader_commands": {"leader": {"quit": "e"}}}},
{"keymap": {"leader_commands": {"leader": {"change_theme": "e"}}}},
)
assert manager.load_error is not None
assert "leader key 'e'" in manager.load_error
Expand Down
4 changes: 2 additions & 2 deletions tests/ui/keybindings/test_keymap_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ def test_default_keymap_has_expected_leader_commands(self):
"""Default keymap should have standard leader commands."""
keymap = get_keymap()

# Verify leader commands exist (don't check specific keys)
assert keymap.leader("quit") is not None
# Verify leader commands exist (don't check specific keys).
# quit is intentionally NOT in the user-overridable keymap.
assert keymap.leader("show_help") is not None
assert keymap.leader("toggle_explorer") is not None
assert keymap.leader("change_theme") is not None
Expand Down
Loading