From eae6e4440cdb91f6cde575cbbc344cbfaf790cdd Mon Sep 17 00:00:00 2001 From: devHaitham481 Date: Tue, 21 Jul 2026 00:56:53 +0200 Subject: [PATCH] Add vim scroll motions to the explorer tree The explorer only had j/k for single-step cursor movement, so getting around a large schema tree meant holding a key. This brings it in line with the results pane: - Ctrl+D / Ctrl+U page down/up (full visible page, matching results) - gg jumps to the first node, G jumps to the last node Details: - keymap: bind ctrl+d/ctrl+u/g/G in the tree context and add a "tg" leader menu for gg, mirroring the results "rg" menu - tree mixin: build on Textual Tree's page/cursor API and reuse a shared helper for the visual-mode selection refresh - tree_focused state: allow the new actions - help: document the motions in the EXPLORER section of the ? screen - tests: cover gg, G, Ctrl+D/Ctrl+U, and the help entries --- sqlit/core/keymap.py | 6 ++ sqlit/domains/explorer/state/tree_focused.py | 5 ++ sqlit/domains/explorer/ui/mixins/tree.py | 47 ++++++++-- sqlit/domains/shell/state/machine.py | 4 + tests/ui/keybindings/test_tree_vim_motions.py | 89 +++++++++++++++++++ 5 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 tests/ui/keybindings/test_tree_vim_motions.py diff --git a/sqlit/core/keymap.py b/sqlit/core/keymap.py index 7f320e49..f80910af 100644 --- a/sqlit/core/keymap.py +++ b/sqlit/core/keymap.py @@ -325,6 +325,8 @@ def _build_leader_commands(self) -> list[LeaderCommandDef]: LeaderCommandDef("o", "columns", "Columns as CSV...", "Copy as", menu="ryfc"), # rg results g motion menu (vim-style gg) LeaderCommandDef("g", "first_row", "Go to first row", "Go to", menu="rg"), + # tg tree g motion menu (vim-style gg) + LeaderCommandDef("g", "first_node", "Go to first node", "Go to", menu="tg"), # vy value view yank menu (tree mode) LeaderCommandDef("y", "value", "Copy value", "Copy", menu="vy"), LeaderCommandDef("f", "field", "Copy field", "Copy", menu="vy"), @@ -356,6 +358,10 @@ def _build_action_keys(self) -> list[ActionKeyDef]: ActionKeyDef("down", "tree_cursor_down", "tree", primary=False), ActionKeyDef("k", "tree_cursor_up", "tree"), ActionKeyDef("up", "tree_cursor_up", "tree", primary=False), + ActionKeyDef("ctrl+d", "tree_page_down", "tree"), + ActionKeyDef("ctrl+u", "tree_page_up", "tree"), + ActionKeyDef("g", "tg_leader_key", "tree"), + ActionKeyDef("G", "tree_cursor_last", "tree"), ActionKeyDef("slash", "tree_filter", "tree"), ActionKeyDef("escape", "tree_filter_close", "tree_filter"), ActionKeyDef("enter", "tree_filter_accept", "tree_filter"), diff --git a/sqlit/domains/explorer/state/tree_focused.py b/sqlit/domains/explorer/state/tree_focused.py index 406719f3..6ac8662e 100644 --- a/sqlit/domains/explorer/state/tree_focused.py +++ b/sqlit/domains/explorer/state/tree_focused.py @@ -18,6 +18,11 @@ def _setup_actions(self) -> None: self.allows("collapse_tree", help="Collapse all") self.allows("tree_cursor_down") # vim j self.allows("tree_cursor_up") # vim k + self.allows("tree_page_down") # vim Ctrl+D + self.allows("tree_page_up") # vim Ctrl+U + self.allows("tg_leader_key") # vim gg (first step) + self.allows("tg_first_node") # vim gg (second step) + self.allows("tree_cursor_last") # vim G self.allows("tree_filter", help="Filter items") self.allows( "enter_tree_visual_mode", diff --git a/sqlit/domains/explorer/ui/mixins/tree.py b/sqlit/domains/explorer/ui/mixins/tree.py index 26cf34b3..9ab61e20 100644 --- a/sqlit/domains/explorer/ui/mixins/tree.py +++ b/sqlit/domains/explorer/ui/mixins/tree.py @@ -294,23 +294,54 @@ def collapse_all(node: Any) -> None: self._expanded_paths.clear() self._schedule_expanded_state_persist() + def _refresh_tree_visual_selection(self: TreeMixinHost) -> None: + """Update visual-mode selection after a cursor move, if in visual mode.""" + update_visual = getattr(self, "_update_visual_selection", None) + if callable(update_visual): + update_visual() + def action_tree_cursor_down(self: TreeMixinHost) -> None: """Move tree cursor down (vim j).""" if self.object_tree.has_focus: self.object_tree.action_cursor_down() - # Update visual selection if in visual mode - update_visual = getattr(self, "_update_visual_selection", None) - if callable(update_visual): - update_visual() + self._refresh_tree_visual_selection() def action_tree_cursor_up(self: TreeMixinHost) -> None: """Move tree cursor up (vim k).""" if self.object_tree.has_focus: self.object_tree.action_cursor_up() - # Update visual selection if in visual mode - update_visual = getattr(self, "_update_visual_selection", None) - if callable(update_visual): - update_visual() + self._refresh_tree_visual_selection() + + def action_tree_page_down(self: TreeMixinHost) -> None: + """Scroll the tree down one page (vim Ctrl+D).""" + if self.object_tree.has_focus: + self.object_tree.action_page_down() + self._refresh_tree_visual_selection() + + def action_tree_page_up(self: TreeMixinHost) -> None: + """Scroll the tree up one page (vim Ctrl+U).""" + if self.object_tree.has_focus: + self.object_tree.action_page_up() + self._refresh_tree_visual_selection() + + def action_tg_leader_key(self: TreeMixinHost) -> None: + """Show the tree g motion leader menu (first press of gg).""" + self._start_leader_pending("tg") + + def action_tg_first_node(self: TreeMixinHost) -> None: + """Jump to the first node in the tree (vim gg).""" + self._clear_leader_pending() + if self.object_tree.has_focus: + self.object_tree.move_cursor_to_line(0) + self._refresh_tree_visual_selection() + + def action_tree_cursor_last(self: TreeMixinHost) -> None: + """Jump to the last node in the tree (vim G).""" + if self.object_tree.has_focus: + last_line = self.object_tree.last_line + if last_line >= 0: + self.object_tree.move_cursor_to_line(last_line) + self._refresh_tree_visual_selection() def action_select_table(self: TreeMixinHost) -> None: """Generate and execute SELECT query for selected table/view, or show info for indexes/triggers/sequences.""" diff --git a/sqlit/domains/shell/state/machine.py b/sqlit/domains/shell/state/machine.py index dc146c35..02aabef8 100644 --- a/sqlit/domains/shell/state/machine.py +++ b/sqlit/domains/shell/state/machine.py @@ -211,6 +211,10 @@ def lk(action: str, menu: str, fallback: str) -> str: # EXPLORER s = HelpSection(id="explorer", title="EXPLORER") s.binding(ks([("tree_cursor_down", "j"), ("tree_cursor_up", "k")]), "Move cursor down/up") + s.binding(ks([("tree_page_down", "^d"), ("tree_page_up", "^u")]), "Page down/up") + tree_g = k("tg_leader_key", "g") + s.binding(f"{tree_g}{lk('first_node', 'tg', 'g')}", "Jump to first node") + s.binding(k("tree_cursor_last", "G"), "Jump to last node") s.binding("", "Expand node / Connect") s.binding(k("new_connection", "n"), "New connection") s.binding(k("select_table", "s"), "SELECT TOP 100 (on table/view)") diff --git a/tests/ui/keybindings/test_tree_vim_motions.py b/tests/ui/keybindings/test_tree_vim_motions.py new file mode 100644 index 00000000..19548fea --- /dev/null +++ b/tests/ui/keybindings/test_tree_vim_motions.py @@ -0,0 +1,89 @@ +"""UI tests for vim motion keybindings in the explorer tree.""" + +from __future__ import annotations + +import pytest + +from sqlit.domains.shell.app.main import SSMSTUI + +from ..mocks import MockConnectionStore, MockSettingsStore, build_test_services, create_test_connection + + +def _make_app(connection_count: int) -> SSMSTUI: + connections = [create_test_connection(f"conn-{i:02d}", "sqlite") for i in range(connection_count)] + services = build_test_services( + connection_store=MockConnectionStore(connections), + settings_store=MockSettingsStore({"theme": "tokyo-night"}), + ) + return SSMSTUI(services=services) + + +class TestTreeVimMotions: + """Vim scroll motions bound to the explorer tree.""" + + @pytest.mark.asyncio + async def test_G_jumps_to_last_node(self) -> None: + app = _make_app(connection_count=20) + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_explorer() + await pilot.pause() + assert app.object_tree.has_focus + + await pilot.press("g", "g") + await pilot.pause() + assert app.object_tree.cursor_line == 0 + + await pilot.press("G") + await pilot.pause() + assert app.object_tree.cursor_line == app.object_tree.last_line + + @pytest.mark.asyncio + async def test_gg_jumps_to_first_node(self) -> None: + app = _make_app(connection_count=20) + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_explorer() + await pilot.pause() + + await pilot.press("G") + await pilot.pause() + assert app.object_tree.cursor_line == app.object_tree.last_line + + await pilot.press("g", "g") + await pilot.pause() + assert app.object_tree.cursor_line == 0 + + def test_scroll_motions_documented_in_help(self) -> None: + from sqlit.domains.shell.state.help_doc import render_section + from sqlit.domains.shell.state.machine import UIStateMachine + + explorer = next(s for s in UIStateMachine().generate_help_sections() if s.id == "explorer") + descriptions = {item.description for item in explorer.items} + assert "Page down/up" in descriptions + assert "Jump to first node" in descriptions + assert "Jump to last node" in descriptions + + rendered, _ = render_section(explorer) + assert "gg" in rendered + assert "G" in rendered + + @pytest.mark.asyncio + async def test_ctrl_d_and_ctrl_u_page(self) -> None: + app = _make_app(connection_count=60) + async with app.run_test(size=(100, 20)) as pilot: + app.action_focus_explorer() + await pilot.pause() + + await pilot.press("g", "g") + await pilot.pause() + start_line = app.object_tree.cursor_line + assert start_line == 0 + + await pilot.press("ctrl+d") + await pilot.pause() + after_page_down = app.object_tree.cursor_line + assert after_page_down > start_line, "ctrl+d should move the cursor down by a page" + + await pilot.press("ctrl+u") + await pilot.pause() + after_page_up = app.object_tree.cursor_line + assert after_page_up < after_page_down, "ctrl+u should move the cursor up again"