Skip to content

Commit c12f87f

Browse files
feat: Add vim mode plugin with editor state refactoring
- Implement plugin architecture for extensible functionality - Add full vim mode with motions, operators, text objects, command mode - Rename vim_mode to editor_mode for clarity (INSERT/NORMAL states) - Fix default editor mode escape/leader key behavior - Allow navigation keys (e/q/r) to pass through in vim NORMAL mode 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 5cf5edf commit c12f87f

26 files changed

Lines changed: 3893 additions & 54 deletions

sqlit/app.py

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
UIStateMachine,
4141
get_leader_bindings,
4242
)
43+
from .plugins import Plugin, discover_plugins
4344
from .ui.mixins import (
4445
AutocompleteMixin,
4546
ConnectionMixin,
@@ -56,6 +57,7 @@
5657
ResultsFilterInput,
5758
SqlitDataTable,
5859
TreeFilterInput,
60+
VimCommandLine,
5961
VimMode,
6062
)
6163

@@ -112,6 +114,10 @@ class SSMSTUI(
112114
&:focus > .text-area--cursor-line {
113115
background: $surface-lighten-1;
114116
}
117+
/* Make visual mode selection more visible with orange tint */
118+
& > .text-area--selection {
119+
background: $warning 40%;
120+
}
115121
}
116122
117123
DataTable.flash-cell:focus > .datatable--cursor,
@@ -339,7 +345,7 @@ def __init__(
339345
self.current_config: ConnectionConfig | None = None
340346
self.current_adapter: DatabaseAdapter | None = None
341347
self.current_ssh_tunnel: Any | None = None
342-
self.vim_mode: VimMode = VimMode.NORMAL
348+
self.editor_mode: VimMode = VimMode.INSERT # Editor state (INSERT=editing, NORMAL=read-only)
343349
self._expanded_paths: set[str] = set()
344350
self._loading_nodes: set[str] = set()
345351
self._session: Any | None = None
@@ -369,6 +375,7 @@ def __init__(
369375
self._leader_timer: Timer | None = None
370376
self._leader_pending: bool = False
371377
self._dialog_open: bool = False
378+
self._plugins: list[Plugin] = [] # Loaded plugins
372379
self._last_active_pane: str | None = None
373380
self._query_worker: Worker[Any] | None = None
374381
self._query_executing: bool = False
@@ -534,7 +541,7 @@ def compose(self) -> ComposeResult:
534541
with Horizontal(id="content"):
535542
with Vertical(id="sidebar"):
536543
yield TreeFilterInput(id="tree-filter")
537-
tree: Tree[Any] = Tree("Servers", id="object-tree")
544+
tree = Tree("Servers", id="object-tree")
538545
tree.show_root = False
539546
tree.guide_depth = 2
540547
yield tree
@@ -548,6 +555,7 @@ def compose(self) -> ComposeResult:
548555
read_only=True,
549556
)
550557
yield Lazy(AutocompleteDropdown(id="autocomplete-dropdown"))
558+
yield VimCommandLine(id="vim-command-line")
551559

552560
with Container(id="results-area"):
553561
yield ResultsFilterInput(id="results-filter")
@@ -588,6 +596,10 @@ def on_mount(self) -> None:
588596
self.refresh_tree()
589597
self._startup_stamp("tree_refreshed")
590598

599+
# Load and initialize plugins
600+
self._load_plugins(settings)
601+
self._startup_stamp("plugins_initialized")
602+
591603
self.object_tree.focus()
592604
self._startup_stamp("tree_focused")
593605
# Move cursor to first node if available
@@ -614,6 +626,27 @@ def _apply_mock_settings(self, settings: dict) -> None:
614626
self._mock_profile = mock_profile
615627
self._session_factory = self._create_mock_session_factory(mock_profile)
616628

629+
def _load_plugins(self, settings: dict) -> None:
630+
"""Load and initialize plugins."""
631+
for plugin_cls in discover_plugins():
632+
plugin = plugin_cls()
633+
plugin.register(self)
634+
plugin.on_settings_load(self, settings)
635+
self._plugins.append(plugin)
636+
637+
def _get_vim_plugin(self) -> Any:
638+
"""Get the vim mode plugin if loaded."""
639+
for plugin in self._plugins:
640+
if plugin.name == "vim_mode":
641+
return plugin
642+
return None
643+
644+
@property
645+
def vim_enabled(self) -> bool:
646+
"""Check if vim plugin is enabled (delegates to vim plugin)."""
647+
vim_plugin = self._get_vim_plugin()
648+
return vim_plugin.enabled if vim_plugin else False
649+
617650
def _setup_startup_connection(self, config: ConnectionConfig) -> None:
618651
"""Set up a startup connection to auto-connect after mount."""
619652
if not config.name:
@@ -816,3 +849,53 @@ def _apply_omarchy_theme(self) -> None:
816849
"""Match and apply the current Omarchy theme."""
817850
matched_theme = get_matching_textual_theme(self.available_themes)
818851
self._apply_theme_safe(matched_theme)
852+
853+
def on_key(self, event: Any) -> None:
854+
"""Handle key events, routing through plugins."""
855+
from textual.events import Key
856+
857+
if not isinstance(event, Key):
858+
return
859+
860+
# Only handle keys when query input has focus
861+
if not self.query_input.has_focus:
862+
return
863+
864+
# Handle autocomplete navigation when dropdown is visible
865+
# Works in INSERT mode (vim enabled) or always when vim is disabled
866+
if self._autocomplete_visible and (not self.vim_enabled or self.editor_mode == VimMode.INSERT):
867+
dropdown = self.autocomplete_dropdown
868+
if event.key in ("down", "ctrl+n"):
869+
dropdown.move_selection(1)
870+
event.prevent_default()
871+
event.stop()
872+
return
873+
elif event.key in ("up", "ctrl+p"):
874+
dropdown.move_selection(-1)
875+
event.prevent_default()
876+
event.stop()
877+
return
878+
elif event.key in ("tab", "enter", "return") and dropdown.filtered_items:
879+
self._apply_autocomplete()
880+
event.prevent_default()
881+
event.stop()
882+
return
883+
elif event.key == "escape":
884+
self._hide_autocomplete()
885+
event.prevent_default()
886+
event.stop()
887+
return
888+
889+
# In default mode NORMAL state, space triggers leader key
890+
if not self.vim_enabled and self.editor_mode == VimMode.NORMAL and event.key == "space":
891+
self.action_leader_key()
892+
event.prevent_default()
893+
event.stop()
894+
return
895+
896+
# Route to plugins
897+
for plugin in self._plugins:
898+
if plugin.on_key(self, event):
899+
event.prevent_default()
900+
event.stop()
901+
return

sqlit/keymap.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ def get_leader_commands(self) -> list[LeaderCommandDef]:
9090
# Actions
9191
LeaderCommandDef("z", "cancel_operation", "Cancel", "Actions", guard="query_executing"),
9292
LeaderCommandDef("t", "change_theme", "Change Theme", "Actions"),
93+
LeaderCommandDef("v", "toggle_vim_mode", "Toggle Vim Mode", "Actions"),
9394
LeaderCommandDef("h", "show_help", "Help", "Actions"),
9495
LeaderCommandDef("q", "quit", "Quit", "Actions"),
9596
]

sqlit/plugins/__init__.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Plugin system for sqlit.
2+
3+
Plugins can extend sqlit with optional features like vim mode.
4+
Each plugin is a self-contained module that registers hooks with the app.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from abc import ABC, abstractmethod
10+
from dataclasses import dataclass
11+
from typing import TYPE_CHECKING, Any
12+
13+
if TYPE_CHECKING:
14+
from ..app import SSMSTUI
15+
16+
17+
@dataclass
18+
class LeaderCommand:
19+
"""Definition of a leader command provided by a plugin."""
20+
21+
key: str # The key to press (e.g., "v")
22+
action: str # The action name (e.g., "toggle_vim_mode")
23+
label: str # Display label (e.g., "Toggle Vim Mode")
24+
category: str # For grouping in menu ("Actions", "View", etc.)
25+
guard: str | None = None # Optional guard function name
26+
27+
28+
class Plugin(ABC):
29+
"""Base class for sqlit plugins.
30+
31+
Plugins extend sqlit with optional features. They:
32+
- Register themselves with the app at startup
33+
- Can intercept and handle key events
34+
- Can provide leader commands (<space>+key)
35+
- Can persist settings
36+
"""
37+
38+
name: str = "unnamed" # Unique plugin identifier
39+
40+
@abstractmethod
41+
def register(self, app: "SSMSTUI") -> None:
42+
"""Called when app initializes. Set up the plugin here.
43+
44+
Args:
45+
app: The main application instance
46+
"""
47+
pass
48+
49+
def on_key(self, app: "SSMSTUI", event: Any) -> bool:
50+
"""Handle a key event.
51+
52+
Args:
53+
app: The main application instance
54+
event: The key event from Textual
55+
56+
Returns:
57+
True if the key was consumed, False to let it propagate
58+
"""
59+
return False
60+
61+
def on_focus_change(self, app: "SSMSTUI", widget: Any) -> None:
62+
"""Called when focus changes in the app.
63+
64+
Args:
65+
app: The main application instance
66+
widget: The newly focused widget
67+
"""
68+
pass
69+
70+
def get_leader_commands(self) -> list[LeaderCommand]:
71+
"""Return leader commands this plugin provides.
72+
73+
Returns:
74+
List of LeaderCommand definitions
75+
"""
76+
return []
77+
78+
def get_settings_defaults(self) -> dict[str, Any]:
79+
"""Return default settings for this plugin.
80+
81+
Returns:
82+
Dictionary of setting_name -> default_value
83+
"""
84+
return {}
85+
86+
def on_settings_load(self, app: "SSMSTUI", settings: dict[str, Any]) -> None:
87+
"""Called when settings are loaded.
88+
89+
Args:
90+
app: The main application instance
91+
settings: The loaded settings dictionary
92+
"""
93+
pass
94+
95+
def on_settings_save(self, app: "SSMSTUI", settings: dict[str, Any]) -> None:
96+
"""Called before settings are saved. Modify settings dict to persist plugin state.
97+
98+
Args:
99+
app: The main application instance
100+
settings: The settings dictionary to be saved
101+
"""
102+
pass
103+
104+
105+
# Plugin registry
106+
_plugins: list[type[Plugin]] = []
107+
108+
109+
def register_plugin(plugin_cls: type[Plugin]) -> type[Plugin]:
110+
"""Decorator to register a plugin class.
111+
112+
Usage:
113+
@register_plugin
114+
class MyPlugin(Plugin):
115+
...
116+
"""
117+
_plugins.append(plugin_cls)
118+
return plugin_cls
119+
120+
121+
def discover_plugins() -> list[type[Plugin]]:
122+
"""Discover and return all registered plugin classes.
123+
124+
This imports plugin modules which triggers their @register_plugin decorators.
125+
126+
Returns:
127+
List of plugin classes
128+
"""
129+
# Import plugin modules to trigger registration
130+
try:
131+
from . import vim_mode # noqa: F401
132+
except ImportError:
133+
pass
134+
135+
return _plugins.copy()
136+
137+
138+
def get_registered_plugins() -> list[type[Plugin]]:
139+
"""Get already registered plugins without triggering discovery.
140+
141+
Returns:
142+
List of registered plugin classes
143+
"""
144+
return _plugins.copy()

sqlit/plugins/vim_mode/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Vim mode plugin for sqlit.
2+
3+
This plugin provides vim-like keybindings for the query editor.
4+
Toggle with <space>v. Disabled by default.
5+
"""
6+
7+
from .. import register_plugin
8+
from .plugin import VimModePlugin
9+
10+
# Register the plugin
11+
register_plugin(VimModePlugin)
12+
13+
__all__ = ["VimModePlugin"]
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Vim emulation engine for sqlit.
2+
3+
This module provides a vim-like editing experience for the query editor,
4+
inspired by prompt_toolkit's vi implementation but adapted for Textual's TextArea.
5+
6+
Architecture:
7+
VimEngine - Main controller that handles key events
8+
VimState - Tracks mode, pending operator, registers, etc.
9+
DocumentWrapper - Adapts TextArea to vim-style operations
10+
VimKeymapConfig - Configurable key bindings
11+
12+
Usage:
13+
from sqlit.vim import VimEngine
14+
15+
engine = VimEngine(text_area)
16+
engine.set_mode_callback(on_mode_change)
17+
18+
# In key handler:
19+
result = engine.handle_key(key)
20+
if result.consumed:
21+
event.prevent_default()
22+
"""
23+
24+
from .state import VimMode, VimState, TextObjectType
25+
from .document import DocumentWrapper
26+
from .engine import VimEngine, KeyResult
27+
from .keymap import (
28+
VimBinding,
29+
VimKeymapConfig,
30+
VimKeymapProvider,
31+
get_vim_keymap,
32+
set_vim_keymap,
33+
)
34+
from .command import CommandAction, CommandResult, VimCommandHandler
35+
36+
__all__ = [
37+
# Core
38+
"VimEngine",
39+
"VimState",
40+
"VimMode",
41+
"KeyResult",
42+
# Document
43+
"DocumentWrapper",
44+
# State types
45+
"TextObjectType",
46+
# Keymap
47+
"VimBinding",
48+
"VimKeymapConfig",
49+
"VimKeymapProvider",
50+
"get_vim_keymap",
51+
"set_vim_keymap",
52+
# Command mode
53+
"CommandAction",
54+
"CommandResult",
55+
"VimCommandHandler",
56+
]

0 commit comments

Comments
 (0)