Skip to content

Commit aafd432

Browse files
committed
Add vim motion bindings, tests, and :version command
1 parent 85f6c20 commit aafd432

File tree

6 files changed

+656
-22
lines changed

6 files changed

+656
-22
lines changed

sqlit/core/keymap.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
"question_mark": "?",
1212
"slash": "/",
1313
"asterisk": "*",
14+
"dollar_sign": "$",
15+
"percent_sign": "%",
1416
"space": "<space>",
1517
"escape": "<esc>",
1618
"enter": "<enter>",
@@ -206,7 +208,7 @@ def _build_leader_commands(self) -> list[LeaderCommandDef]:
206208
LeaderCommandDef("e", "word_end", "Delete to word end", "Delete", menu="delete"),
207209
LeaderCommandDef("E", "WORD_end", "Delete to WORD end", "Delete", menu="delete"),
208210
LeaderCommandDef("0", "line_start", "Delete to line start", "Delete", menu="delete"),
209-
LeaderCommandDef("$", "line_end_motion", "Delete to line end", "Delete", menu="delete"),
211+
LeaderCommandDef("dollar_sign", "line_end_motion", "Delete to line end", "Delete", menu="delete"),
210212
LeaderCommandDef("D", "line_end", "Delete to line end", "Delete", menu="delete"),
211213
LeaderCommandDef("x", "char", "Delete char", "Delete", menu="delete"),
212214
LeaderCommandDef("X", "char_back", "Delete char back", "Delete", menu="delete"),
@@ -219,7 +221,7 @@ def _build_leader_commands(self) -> list[LeaderCommandDef]:
219221
LeaderCommandDef("F", "find_char_back", "Delete back to char...", "Delete", menu="delete"),
220222
LeaderCommandDef("t", "till_char", "Delete till char...", "Delete", menu="delete"),
221223
LeaderCommandDef("T", "till_char_back", "Delete back till char...", "Delete", menu="delete"),
222-
LeaderCommandDef("%", "matching_bracket", "Delete to bracket", "Delete", menu="delete"),
224+
LeaderCommandDef("percent_sign", "matching_bracket", "Delete to bracket", "Delete", menu="delete"),
223225
LeaderCommandDef("i", "inner", "Delete inside...", "Delete", menu="delete"),
224226
LeaderCommandDef("a", "around", "Delete around...", "Delete", menu="delete"),
225227
# Copy menu (vim-style, y for yank)
@@ -231,7 +233,7 @@ def _build_leader_commands(self) -> list[LeaderCommandDef]:
231233
LeaderCommandDef("e", "word_end", "Copy to word end", "Copy", menu="yank"),
232234
LeaderCommandDef("E", "WORD_end", "Copy to WORD end", "Copy", menu="yank"),
233235
LeaderCommandDef("0", "line_start", "Copy to line start", "Copy", menu="yank"),
234-
LeaderCommandDef("$", "line_end_motion", "Copy to line end", "Copy", menu="yank"),
236+
LeaderCommandDef("dollar_sign", "line_end_motion", "Copy to line end", "Copy", menu="yank"),
235237
LeaderCommandDef("h", "left", "Copy left", "Copy", menu="yank"),
236238
LeaderCommandDef("j", "down", "Copy line down", "Copy", menu="yank"),
237239
LeaderCommandDef("k", "up", "Copy line up", "Copy", menu="yank"),
@@ -241,7 +243,7 @@ def _build_leader_commands(self) -> list[LeaderCommandDef]:
241243
LeaderCommandDef("F", "find_char_back", "Copy back to char...", "Copy", menu="yank"),
242244
LeaderCommandDef("t", "till_char", "Copy till char...", "Copy", menu="yank"),
243245
LeaderCommandDef("T", "till_char_back", "Copy back till char...", "Copy", menu="yank"),
244-
LeaderCommandDef("%", "matching_bracket", "Copy to bracket", "Copy", menu="yank"),
246+
LeaderCommandDef("percent_sign", "matching_bracket", "Copy to bracket", "Copy", menu="yank"),
245247
LeaderCommandDef("i", "inner", "Copy inside...", "Copy", menu="yank"),
246248
LeaderCommandDef("a", "around", "Copy around...", "Copy", menu="yank"),
247249
# Change menu (vim-style)
@@ -253,7 +255,7 @@ def _build_leader_commands(self) -> list[LeaderCommandDef]:
253255
LeaderCommandDef("e", "word_end", "Change to word end", "Change", menu="change"),
254256
LeaderCommandDef("E", "WORD_end", "Change to WORD end", "Change", menu="change"),
255257
LeaderCommandDef("0", "line_start", "Change to line start", "Change", menu="change"),
256-
LeaderCommandDef("$", "line_end_motion", "Change to line end", "Change", menu="change"),
258+
LeaderCommandDef("dollar_sign", "line_end_motion", "Change to line end", "Change", menu="change"),
257259
LeaderCommandDef("h", "left", "Change left", "Change", menu="change"),
258260
LeaderCommandDef("j", "down", "Change line down", "Change", menu="change"),
259261
LeaderCommandDef("k", "up", "Change line up", "Change", menu="change"),
@@ -263,7 +265,7 @@ def _build_leader_commands(self) -> list[LeaderCommandDef]:
263265
LeaderCommandDef("F", "find_char_back", "Change back to char...", "Change", menu="change"),
264266
LeaderCommandDef("t", "till_char", "Change till char...", "Change", menu="change"),
265267
LeaderCommandDef("T", "till_char_back", "Change back till char...", "Change", menu="change"),
266-
LeaderCommandDef("%", "matching_bracket", "Change to bracket", "Change", menu="change"),
268+
LeaderCommandDef("percent_sign", "matching_bracket", "Change to bracket", "Change", menu="change"),
267269
LeaderCommandDef("i", "inner", "Change inside...", "Change", menu="change"),
268270
LeaderCommandDef("a", "around", "Change around...", "Change", menu="change"),
269271
# g motion menu (vim-style)
@@ -325,10 +327,6 @@ def _build_action_keys(self) -> list[ActionKeyDef]:
325327
ActionKeyDef("ctrl+q", "quit", "global"),
326328
ActionKeyDef("escape", "cancel_operation", "global"),
327329
ActionKeyDef("question_mark", "show_help", "global"),
328-
# Navigation
329-
ActionKeyDef("e", "focus_explorer", "navigation"),
330-
ActionKeyDef("q", "focus_query", "navigation"),
331-
ActionKeyDef("r", "focus_results", "navigation"),
332330
# Query (normal mode)
333331
ActionKeyDef("i", "enter_insert_mode", "query_normal"),
334332
ActionKeyDef("o", "open_line_below", "query_normal"),
@@ -348,10 +346,30 @@ def _build_action_keys(self) -> list[ActionKeyDef]:
348346
ActionKeyDef("j", "cursor_down", "query_normal"),
349347
ActionKeyDef("k", "cursor_up", "query_normal"),
350348
ActionKeyDef("l", "cursor_right", "query_normal"),
349+
ActionKeyDef("w", "cursor_word_forward", "query_normal"),
350+
ActionKeyDef("W", "cursor_WORD_forward", "query_normal"),
351+
ActionKeyDef("b", "cursor_word_back", "query_normal"),
352+
ActionKeyDef("B", "cursor_WORD_back", "query_normal"),
353+
ActionKeyDef("e", "cursor_word_end", "query_normal"),
354+
ActionKeyDef("E", "cursor_WORD_end", "query_normal"),
355+
ActionKeyDef("0", "cursor_line_start", "query_normal"),
356+
ActionKeyDef("dollar_sign", "cursor_line_end", "query_normal"),
357+
ActionKeyDef("G", "cursor_last_line", "query_normal"),
358+
ActionKeyDef("percent_sign", "cursor_matching_bracket", "query_normal"),
359+
ActionKeyDef("f", "cursor_find_char", "query_normal"),
360+
ActionKeyDef("F", "cursor_find_char_back", "query_normal"),
361+
ActionKeyDef("t", "cursor_till_char", "query_normal"),
362+
ActionKeyDef("T", "cursor_till_char_back", "query_normal"),
363+
ActionKeyDef("a", "append_insert_mode", "query_normal"),
364+
ActionKeyDef("A", "append_line_end", "query_normal"),
351365
# Query (insert mode)
352366
ActionKeyDef("escape", "exit_insert_mode", "query_insert"),
353367
ActionKeyDef("ctrl+enter", "execute_query_insert", "query_insert"),
354368
ActionKeyDef("tab", "autocomplete_accept", "query_insert"),
369+
# Navigation
370+
ActionKeyDef("e", "focus_explorer", "navigation"),
371+
ActionKeyDef("q", "focus_query", "navigation"),
372+
ActionKeyDef("r", "focus_results", "navigation"),
355373
# Query (autocomplete)
356374
ActionKeyDef("ctrl+j", "autocomplete_next", "autocomplete"),
357375
ActionKeyDef("ctrl+k", "autocomplete_prev", "autocomplete"),

sqlit/domains/query/state/query_normal.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,22 @@ def _setup_actions(self) -> None:
2626
self.allows("cursor_right", help="Move cursor right")
2727
self.allows("cursor_up", help="Move cursor up")
2828
self.allows("cursor_down", help="Move cursor down")
29+
self.allows("cursor_word_forward", help="Move to next word")
30+
self.allows("cursor_WORD_forward", help="Move to next WORD")
31+
self.allows("cursor_word_back", help="Move to previous word")
32+
self.allows("cursor_WORD_back", help="Move to previous WORD")
33+
self.allows("cursor_word_end", help="Move to end of word")
34+
self.allows("cursor_WORD_end", help="Move to end of WORD")
35+
self.allows("cursor_line_start", help="Move to line start")
36+
self.allows("cursor_line_end", help="Move to line end")
37+
self.allows("cursor_last_line", help="Move to last line")
38+
self.allows("cursor_matching_bracket", help="Move to matching bracket")
39+
self.allows("cursor_find_char", help="Find char forward")
40+
self.allows("cursor_find_char_back", help="Find char backward")
41+
self.allows("cursor_till_char", help="Move till char forward")
42+
self.allows("cursor_till_char_back", help="Move till char backward")
43+
self.allows("append_insert_mode", help="Append after cursor")
44+
self.allows("append_line_end", help="Append at line end")
2945
# Vim open line
3046
self.allows("open_line_below", help="Open line below")
3147
self.allows("open_line_above", help="Open line above")

sqlit/domains/query/ui/mixins/query_editing_cursor.py

Lines changed: 97 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,19 @@
88
class QueryEditingCursorMixin:
99
"""Cursor movement and navigation for the query editor."""
1010

11+
def _move_with_motion(self: QueryMixinHost, motion_key: str, char: str | None = None) -> None:
12+
"""Move cursor using a vim motion."""
13+
from sqlit.domains.query.editing import MOTIONS
14+
15+
motion_func = MOTIONS.get(motion_key)
16+
if not motion_func:
17+
return
18+
19+
text = self.query_input.text
20+
row, col = self.query_input.cursor_location
21+
result = motion_func(text, row, col, char)
22+
self.query_input.cursor_location = (result.position.row, result.position.col)
23+
1124
def action_g_leader_key(self: QueryMixinHost) -> None:
1225
"""Show the g motion leader menu."""
1326
self._start_leader_pending("g")
@@ -20,22 +33,12 @@ def action_g_first_line(self: QueryMixinHost) -> None:
2033
def action_g_word_end_back(self: QueryMixinHost) -> None:
2134
"""Go to end of previous word (ge)."""
2235
self._clear_leader_pending()
23-
from sqlit.domains.query.editing import MOTIONS
24-
25-
text = self.query_input.text
26-
row, col = self.query_input.cursor_location
27-
result = MOTIONS["ge"](text, row, col, None)
28-
self.query_input.cursor_location = (result.position.row, result.position.col)
36+
self._move_with_motion("ge")
2937

3038
def action_g_WORD_end_back(self: QueryMixinHost) -> None:
3139
"""Go to end of previous WORD (gE)."""
3240
self._clear_leader_pending()
33-
from sqlit.domains.query.editing import MOTIONS
34-
35-
text = self.query_input.text
36-
row, col = self.query_input.cursor_location
37-
result = MOTIONS["gE"](text, row, col, None)
38-
self.query_input.cursor_location = (result.position.row, result.position.col)
41+
self._move_with_motion("gE")
3942

4043
def action_cursor_left(self: QueryMixinHost) -> None:
4144
"""Move cursor left (h in normal mode)."""
@@ -65,6 +68,88 @@ def action_cursor_down(self: QueryMixinHost) -> None:
6568
new_col = min(col, len(lines[new_row]) if new_row < len(lines) else 0)
6669
self.query_input.cursor_location = (new_row, new_col)
6770

71+
def action_cursor_word_forward(self: QueryMixinHost) -> None:
72+
"""Move cursor to next word (w)."""
73+
self._move_with_motion("w")
74+
75+
def action_cursor_WORD_forward(self: QueryMixinHost) -> None:
76+
"""Move cursor to next WORD (W)."""
77+
self._move_with_motion("W")
78+
79+
def action_cursor_word_back(self: QueryMixinHost) -> None:
80+
"""Move cursor to previous word (b)."""
81+
self._move_with_motion("b")
82+
83+
def action_cursor_WORD_back(self: QueryMixinHost) -> None:
84+
"""Move cursor to previous WORD (B)."""
85+
self._move_with_motion("B")
86+
87+
def action_cursor_word_end(self: QueryMixinHost) -> None:
88+
"""Move cursor to end of word (e)."""
89+
self._move_with_motion("e")
90+
91+
def action_cursor_WORD_end(self: QueryMixinHost) -> None:
92+
"""Move cursor to end of WORD (E)."""
93+
self._move_with_motion("E")
94+
95+
def action_cursor_line_start(self: QueryMixinHost) -> None:
96+
"""Move cursor to start of line (0)."""
97+
self._move_with_motion("0")
98+
99+
def action_cursor_line_end(self: QueryMixinHost) -> None:
100+
"""Move cursor to end of line ($)."""
101+
self._move_with_motion("$")
102+
103+
def action_cursor_last_line(self: QueryMixinHost) -> None:
104+
"""Move cursor to last line (G)."""
105+
self._move_with_motion("G")
106+
107+
def action_cursor_matching_bracket(self: QueryMixinHost) -> None:
108+
"""Move cursor to matching bracket (%)."""
109+
self._move_with_motion("%")
110+
111+
def action_cursor_find_char(self: QueryMixinHost) -> None:
112+
"""Find next occurrence of char (f{c})."""
113+
self._show_motion_char_pending_menu("f")
114+
115+
def action_cursor_find_char_back(self: QueryMixinHost) -> None:
116+
"""Find previous occurrence of char (F{c})."""
117+
self._show_motion_char_pending_menu("F")
118+
119+
def action_cursor_till_char(self: QueryMixinHost) -> None:
120+
"""Move till before next char (t{c})."""
121+
self._show_motion_char_pending_menu("t")
122+
123+
def action_cursor_till_char_back(self: QueryMixinHost) -> None:
124+
"""Move till after previous char (T{c})."""
125+
self._show_motion_char_pending_menu("T")
126+
127+
def action_append_insert_mode(self: QueryMixinHost) -> None:
128+
"""Enter insert mode after cursor (a)."""
129+
lines = self.query_input.text.split("\n")
130+
row, col = self.query_input.cursor_location
131+
line_len = len(lines[row]) if row < len(lines) else 0
132+
self.query_input.cursor_location = (row, min(col + 1, line_len))
133+
self.action_enter_insert_mode()
134+
135+
def action_append_line_end(self: QueryMixinHost) -> None:
136+
"""Enter insert mode at end of line (A)."""
137+
lines = self.query_input.text.split("\n")
138+
row, _ = self.query_input.cursor_location
139+
line_len = len(lines[row]) if row < len(lines) else 0
140+
self.query_input.cursor_location = (row, line_len)
141+
self.action_enter_insert_mode()
142+
143+
def _show_motion_char_pending_menu(self: QueryMixinHost, motion: str) -> None:
144+
"""Show char pending menu for f/F/t/T motions (cursor movement)."""
145+
from sqlit.domains.query.ui.screens import CharPendingMenuScreen
146+
147+
def handle_result(char: str | None) -> None:
148+
if char:
149+
self._move_with_motion(motion, char)
150+
151+
self.push_screen(CharPendingMenuScreen(motion), handle_result)
152+
68153
def action_open_line_below(self: QueryMixinHost) -> None:
69154
"""Open new line below current line and enter insert mode (o in normal mode)."""
70155
self._push_undo_state()

sqlit/domains/shell/app/main.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from __future__ import annotations
44

55
import os
6+
import platform
7+
import subprocess
68
import sys
79
import time
810
from datetime import datetime
@@ -552,6 +554,10 @@ def _run_command(self, command: str) -> None:
552554
self._show_command_list()
553555
return
554556

557+
if cmd in {"version", "ver"}:
558+
self._show_version_info()
559+
return
560+
555561
if cmd in {"q", "quit", "exit"}:
556562
try:
557563
handler = getattr(self, "action_quit", None)
@@ -648,6 +654,7 @@ def _show_command_list(self) -> None:
648654
columns = ["Title", "Command", "Description", "Hint"]
649655
rows = [
650656
("General", ":commands", "Show this command list", ""),
657+
("General", ":version, :ver", "Show version and git hash", ""),
651658
("General", ":help, :h", "Show keyboard shortcuts", ""),
652659
("General", ":q, :quit, :exit", "Quit sqlit", ""),
653660
("Connection", ":connect, :c", "Open connection picker", ""),
@@ -697,6 +704,89 @@ def _show_command_list(self) -> None:
697704
if hasattr(self, "_replace_results_table"):
698705
self._replace_results_table(columns, rows)
699706

707+
def _resolve_repo_root(self) -> Path | None:
708+
path = Path(__file__).resolve()
709+
for parent in (path, *path.parents):
710+
if (parent / ".git").exists():
711+
return parent
712+
return None
713+
714+
def _parse_git_hash_from_version(self, version: str) -> str | None:
715+
import re
716+
717+
match = re.search(r"\\+g([0-9a-fA-F]{7,})", version)
718+
if not match:
719+
return None
720+
return match.group(1)
721+
722+
def _get_git_info(self) -> tuple[str | None, bool | None]:
723+
repo_root = self._resolve_repo_root()
724+
if repo_root is None:
725+
return None, None
726+
try:
727+
result = subprocess.run(
728+
["git", "rev-parse", "--short", "HEAD"],
729+
cwd=repo_root,
730+
check=False,
731+
capture_output=True,
732+
text=True,
733+
)
734+
git_hash = result.stdout.strip() or None
735+
except Exception:
736+
git_hash = None
737+
dirty: bool | None = None
738+
try:
739+
status = subprocess.run(
740+
["git", "status", "--porcelain"],
741+
cwd=repo_root,
742+
check=False,
743+
capture_output=True,
744+
text=True,
745+
)
746+
if status.stdout is not None:
747+
dirty = bool(status.stdout.strip())
748+
except Exception:
749+
dirty = None
750+
return git_hash, dirty
751+
752+
def _show_version_info(self) -> None:
753+
try:
754+
from sqlit import __version__ as pkg_version
755+
except Exception:
756+
pkg_version = "unknown"
757+
758+
git_hash = self._parse_git_hash_from_version(pkg_version)
759+
dirty: bool | None = None
760+
if git_hash is None:
761+
git_hash, dirty = self._get_git_info()
762+
else:
763+
_repo_hash, dirty = self._get_git_info()
764+
765+
git_display = git_hash or "unknown"
766+
dirty_display = "unknown"
767+
if dirty is True:
768+
dirty_display = "yes"
769+
if git_hash:
770+
git_display = f"{git_display} (dirty)"
771+
elif dirty is False:
772+
dirty_display = "no"
773+
774+
info_rows = [
775+
("version", pkg_version),
776+
("git", git_display),
777+
("dirty", dirty_display),
778+
("python", platform.python_version()),
779+
("platform", sys.platform),
780+
]
781+
782+
if hasattr(self, "_replace_results_table"):
783+
self._replace_results_table(["Key", "Value"], info_rows)
784+
785+
summary = f"sqlit {pkg_version}"
786+
if git_hash:
787+
summary = f"{summary} ({git_hash})"
788+
self.notify(summary, severity="information")
789+
700790
def _execute_command_action(self, action: str) -> None:
701791
ctx = self._get_input_context()
702792
if not self._state_machine.check_action(ctx, action):

0 commit comments

Comments
 (0)