Skip to content
Open
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
6 changes: 6 additions & 0 deletions sqlit/core/keymap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down
5 changes: 5 additions & 0 deletions sqlit/domains/explorer/state/tree_focused.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
47 changes: 39 additions & 8 deletions sqlit/domains/explorer/ui/mixins/tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
4 changes: 4 additions & 0 deletions sqlit/domains/shell/state/machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<enter>", "Expand node / Connect")
s.binding(k("new_connection", "n"), "New connection")
s.binding(k("select_table", "s"), "SELECT TOP 100 (on table/view)")
Expand Down
89 changes: 89 additions & 0 deletions tests/ui/keybindings/test_tree_vim_motions.py
Original file line number Diff line number Diff line change
@@ -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"