Skip to content

Commit a7c4e41

Browse files
committed
Refactor keybindings and improve UX
Keybindings: - Refactor to use centralized keybinding definitions - Add leader key menu for discoverable shortcuts - Add connection picker with fuzzy search UX improvements: - Add query execution timing to notifications (e.g. "in 156ms") - Add JSON/Python literal pretty-printing in value view - Add zebra striping to results table - Improve validation feedback in connection forms Testing: - Add validation unit tests - Add UI tests for explorer toggle and fullscreen - Add keybindings tests - Add connection picker tests
1 parent b7e8366 commit a7c4e41

19 files changed

Lines changed: 1188 additions & 378 deletions

sqlit/app.py

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,9 @@ class SSMSTUI(
182182
Binding("h", "leader_help", show=False),
183183
Binding("t", "leader_theme", show=False),
184184
Binding("q", "leader_quit", show=False),
185+
Binding("c", "leader_connect", show=False),
185186
Binding("x", "leader_disconnect", show=False),
187+
Binding("z", "leader_cancel", show=False),
186188
# Regular bindings
187189
Binding("n", "new_connection", "New", show=False),
188190
Binding("s", "select_table", "Select", show=False),
@@ -261,6 +263,43 @@ def __init__(self):
261263
self._table_metadata: dict = {}
262264
self._columns_loading: set[str] = set()
263265

266+
@property
267+
def object_tree(self) -> Tree:
268+
return self.query_one("#object-tree", Tree)
269+
270+
@property
271+
def query_input(self) -> TextArea:
272+
return self.query_one("#query-input", TextArea)
273+
274+
@property
275+
def results_table(self) -> DataTable:
276+
return self.query_one("#results-table", DataTable)
277+
278+
@property
279+
def sidebar(self):
280+
return self.query_one("#sidebar")
281+
282+
@property
283+
def main_panel(self):
284+
return self.query_one("#main-panel")
285+
286+
@property
287+
def query_area(self):
288+
return self.query_one("#query-area")
289+
290+
@property
291+
def results_area(self):
292+
return self.query_one("#results-area")
293+
294+
@property
295+
def status_bar(self) -> Static:
296+
return self.query_one("#status-bar", Static)
297+
298+
@property
299+
def autocomplete_dropdown(self):
300+
from .widgets import AutocompleteDropdown
301+
return self.query_one("#autocomplete-dropdown", AutocompleteDropdown)
302+
264303
def push_screen(self, screen, callback=None, wait_for_dismiss: bool = False):
265304
"""Override push_screen to hide footer when showing modal dialogs."""
266305
from textual.screen import ModalScreen
@@ -311,21 +350,14 @@ def check_action(self, action: str, parameters: tuple) -> bool | None:
311350
if action != "leader_key":
312351
return False
313352

314-
try:
315-
tree = self.query_one("#object-tree", Tree)
316-
query_input = self.query_one("#query-input", TextArea)
317-
results_table = self.query_one("#results-table", DataTable)
318-
except Exception:
319-
return True
320-
321-
tree_focused = tree.has_focus
322-
query_focused = query_input.has_focus
323-
results_focused = results_table.has_focus
353+
tree_focused = self.object_tree.has_focus
354+
query_focused = self.query_input.has_focus
355+
results_focused = self.results_table.has_focus
324356
in_insert_mode = self.vim_mode == VimMode.INSERT
325357

326-
node = tree.cursor_node
358+
node = self.object_tree.cursor_node
327359
node_type = None
328-
is_root = node == tree.root if node else False
360+
is_root = node == self.object_tree.root if node else False
329361
if node and node.data:
330362
node_type = node.data[0]
331363

@@ -428,7 +460,7 @@ def compose(self) -> ComposeResult:
428460
yield Static(
429461
r"\[r] Results", classes="section-label", id="label-results"
430462
)
431-
yield DataTable(id="results-table")
463+
yield DataTable(id="results-table", zebra_stripes=True)
432464

433465
yield Static("Not connected", id="status-bar")
434466

@@ -459,11 +491,10 @@ def on_mount(self) -> None:
459491
self.refresh_tree()
460492
self._update_footer_bindings()
461493

462-
tree = self.query_one("#object-tree", Tree)
463-
tree.focus()
494+
self.object_tree.focus()
464495
# Move cursor to first node if available
465-
if tree.root.children:
466-
tree.cursor_line = 0
496+
if self.object_tree.root.children:
497+
self.object_tree.cursor_line = 0
467498
self._update_section_labels()
468499

469500
# Check for ODBC drivers

sqlit/ui/mixins/autocomplete.py

Lines changed: 12 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -159,46 +159,32 @@ def _show_autocomplete(self, suggestions: list[str], filter_text: str) -> None:
159159
self._hide_autocomplete()
160160
return
161161

162-
dropdown = self.query_one("#autocomplete-dropdown", AutocompleteDropdown)
162+
dropdown = self.autocomplete_dropdown
163163
dropdown.set_items(suggestions, filter_text)
164164

165-
try:
166-
query_input = self.query_one("#query-input", TextArea)
167-
cursor_loc = query_input.cursor_location
168-
dropdown.styles.offset = (cursor_loc[1] + 2, cursor_loc[0] + 1)
169-
except Exception:
170-
pass
165+
cursor_loc = self.query_input.cursor_location
166+
dropdown.styles.offset = (cursor_loc[1] + 2, cursor_loc[0] + 1)
171167

172168
dropdown.show()
173169
self._autocomplete_visible = True
174170

175171
def _hide_autocomplete(self) -> None:
176172
"""Hide the autocomplete dropdown."""
177-
from ...widgets import AutocompleteDropdown
178-
179-
try:
180-
dropdown = self.query_one("#autocomplete-dropdown", AutocompleteDropdown)
181-
dropdown.hide()
182-
self._autocomplete_visible = False
183-
except Exception:
184-
pass
173+
self.autocomplete_dropdown.hide()
174+
self._autocomplete_visible = False
185175

186176
def _apply_autocomplete(self) -> None:
187177
"""Apply the selected autocomplete suggestion."""
188-
from ...widgets import AutocompleteDropdown
189-
190-
dropdown = self.query_one("#autocomplete-dropdown", AutocompleteDropdown)
191-
selected = dropdown.get_selected()
178+
selected = self.autocomplete_dropdown.get_selected()
192179

193180
if not selected:
194181
self._hide_autocomplete()
195182
return
196183

197184
self._autocomplete_just_applied = True
198185

199-
query_input = self.query_one("#query-input", TextArea)
200-
text = query_input.text
201-
cursor_loc = query_input.cursor_location
186+
text = self.query_input.text
187+
cursor_loc = self.query_input.cursor_location
202188
cursor_pos = self._location_to_offset(text, cursor_loc)
203189

204190
word_start = cursor_pos
@@ -214,11 +200,11 @@ def _apply_autocomplete(self) -> None:
214200
else:
215201
new_text = text[:word_start] + selected + text[cursor_pos:]
216202

217-
query_input.text = new_text
203+
self.query_input.text = new_text
218204

219205
new_cursor_pos = word_start + len(selected)
220206
new_loc = self._offset_to_location(new_text, new_cursor_pos)
221-
query_input.cursor_location = new_loc
207+
self.query_input.cursor_location = new_loc
222208

223209
self._hide_autocomplete()
224210

@@ -280,13 +266,13 @@ def on_text_area_changed(self, event: TextArea.Changed) -> None:
280266

281267
def on_key(self, event) -> None:
282268
"""Handle key events for autocomplete navigation."""
283-
from ...widgets import AutocompleteDropdown, VimMode
269+
from ...widgets import VimMode
284270

285271
# Handle autocomplete navigation
286272
if not self._autocomplete_visible:
287273
return
288274

289-
dropdown = self.query_one("#autocomplete-dropdown", AutocompleteDropdown)
275+
dropdown = self.autocomplete_dropdown
290276

291277
if event.key == "down":
292278
dropdown.move_selection(1)

sqlit/ui/mixins/connection.py

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,7 @@ def action_disconnect(self) -> None:
115115
if self.current_connection:
116116
self._disconnect_silent()
117117

118-
status = self.query_one("#status-bar", Static)
119-
status.update("Disconnected")
118+
self.status_bar.update("Disconnected")
120119

121120
self.refresh_tree()
122121
self.notify("Disconnected")
@@ -130,12 +129,9 @@ def action_new_connection(self) -> None:
130129

131130
def action_edit_connection(self) -> None:
132131
"""Edit the selected connection."""
133-
from textual.widgets import Tree
134-
135132
from ..screens import ConnectionScreen
136133

137-
tree = self.query_one("#object-tree", Tree)
138-
node = tree.cursor_node
134+
node = self.object_tree.cursor_node
139135

140136
if not node or not node.data:
141137
return
@@ -183,13 +179,9 @@ def handle_connection_result(self, result: tuple | None) -> None:
183179

184180
def action_delete_connection(self) -> None:
185181
"""Delete the selected connection."""
186-
from textual.widgets import Tree
187-
188182
from ..screens import ConfirmScreen
189-
from ...config import ConnectionConfig
190183

191-
tree = self.query_one("#object-tree", Tree)
192-
node = tree.cursor_node
184+
node = self.object_tree.cursor_node
193185

194186
if not node or not node.data:
195187
return
@@ -220,10 +212,7 @@ def _do_delete_connection(self, config: "ConnectionConfig") -> None:
220212

221213
def action_connect_selected(self) -> None:
222214
"""Connect to the selected connection."""
223-
from textual.widgets import Tree
224-
225-
tree = self.query_one("#object-tree", Tree)
226-
node = tree.cursor_node
215+
node = self.object_tree.cursor_node
227216

228217
if not node or not node.data:
229218
return
@@ -236,3 +225,36 @@ def action_connect_selected(self) -> None:
236225
if self.current_connection:
237226
self._disconnect_silent()
238227
self.connect_to_server(config)
228+
229+
def action_show_connection_picker(self) -> None:
230+
"""Show connection picker dialog."""
231+
from ..screens import ConnectionPickerScreen
232+
233+
if not self.connections:
234+
self.notify("No connections configured", severity="warning")
235+
return
236+
237+
self.push_screen(
238+
ConnectionPickerScreen(self.connections),
239+
self._handle_connection_picker_result,
240+
)
241+
242+
def _handle_connection_picker_result(self, result: str | None) -> None:
243+
"""Handle connection picker selection."""
244+
if result is None:
245+
return
246+
247+
config = next((c for c in self.connections if c.name == result), None)
248+
if config:
249+
# Select the connection node in the tree
250+
for node in self.object_tree.root.children:
251+
if node.data and node.data[0] == "connection" and node.data[1].name == result:
252+
self.object_tree.select_node(node)
253+
break
254+
255+
if self.current_config and self.current_config.name == config.name:
256+
self.notify(f"Already connected to {config.name}")
257+
return
258+
if self.current_connection:
259+
self._disconnect_silent()
260+
self.connect_to_server(config)

0 commit comments

Comments
 (0)