From f0846fe0bdefaa31616a9fce0f3038dd6a8d7d81 Mon Sep 17 00:00:00 2001 From: Peter Adams <18162810+Maxteabag@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:19:38 +0200 Subject: [PATCH] Fix query editor execution and autocomplete input --- sqlit/shared/ui/widgets_autocomplete.py | 33 +++++- sqlit/shared/ui/widgets_text_area.py | 69 ++++++++++-- tests/ui/test_query_tab_insert.py | 140 ++++++++++++++++++++++++ 3 files changed, 233 insertions(+), 9 deletions(-) diff --git a/sqlit/shared/ui/widgets_autocomplete.py b/sqlit/shared/ui/widgets_autocomplete.py index 09b323f7..24b344ea 100644 --- a/sqlit/shared/ui/widgets_autocomplete.py +++ b/sqlit/shared/ui/widgets_autocomplete.py @@ -5,11 +5,31 @@ from typing import Any from textual.containers import VerticalScroll +from textual.message import Message from textual.widgets import Static +class AutocompleteItem(Static): + """Clickable autocomplete row.""" + + class Selected(Message): + """Posted when a suggestion is clicked.""" + + def __init__(self, index: int) -> None: + super().__init__() + self.index = index + + def __init__(self, text: str, index: int) -> None: + super().__init__(f" {text} ", classes="autocomplete-item") + self.index = index + + def on_click(self) -> None: + self.post_message(self.Selected(self.index)) + + class AutocompleteDropdown(VerticalScroll): """Dropdown widget for SQL autocomplete suggestions with scrollbar.""" + MIN_WIDTH = 25 MAX_WIDTH = 80 MAX_HEIGHT = 12 @@ -99,6 +119,17 @@ def get_selected(self) -> str | None: return self.filtered_items[self.selected_index] return None + def on_autocomplete_item_selected(self, event: AutocompleteItem.Selected) -> None: + """Select the clicked row and ask the app to apply it.""" + if not (0 <= event.index < len(self.filtered_items)): + return + old_index = self.selected_index + self.selected_index = event.index + self._update_selection(old_index, self.selected_index) + action = getattr(self.app, "action_autocomplete_accept", None) + if callable(action): + action() + def _rebuild(self) -> None: """Rebuild the dropdown content (only called when items change).""" # Remove all existing children @@ -110,7 +141,7 @@ def _rebuild(self) -> None: # Create item widgets for i, item in enumerate(self.filtered_items): - label = Static(f" {item} ", classes="autocomplete-item") + label = AutocompleteItem(item, i) if i == self.selected_index: label.add_class("selected") self.mount(label) diff --git a/sqlit/shared/ui/widgets_text_area.py b/sqlit/shared/ui/widgets_text_area.py index 71475d85..54c97c8c 100644 --- a/sqlit/shared/ui/widgets_text_area.py +++ b/sqlit/shared/ui/widgets_text_area.py @@ -139,10 +139,64 @@ def _watch_has_focus(self, focus: bool) -> None: super()._watch_has_focus(focus) self._sync_terminal_cursor() + async def _dispatch_query_insert_action(self, key: str) -> bool: + """Run a query-insert binding before TextArea consumes the key.""" + if not self._is_insert_mode(): + return False + + from sqlit.core.keymap import get_keymap + + clipboard_actions = {"select_all", "copy_selection", "paste"} + for binding in get_keymap().get_action_keys(): + if ( + binding.key == key + and binding.context == "query_insert" + and binding.action not in clipboard_actions + and self.app.check_action(binding.action, ()) is not False + ): + return await self.app.run_action(binding.action) + return False + + async def _handle_autocomplete_enter(self) -> bool: + """Honor an explicit Enter autocomplete binding, or insert a newline.""" + app = cast("AutocompleteProtocol", self.app) + if not getattr(app, "_autocomplete_visible", False): + return False + + from sqlit.core.keymap import get_keymap + + enter_accepts = any( + binding.key == "enter" + and binding.action == "autocomplete_accept" + and binding.context == "autocomplete" + for binding in get_keymap().get_action_keys() + ) + dropdown = getattr(app, "autocomplete_dropdown", None) + if ( + enter_accepts + and dropdown is not None + and getattr(dropdown, "filtered_items", None) + ): + return await self.app.run_action("autocomplete_accept") + + if hasattr(app, "_hide_autocomplete"): + app._hide_autocomplete() + app._suppress_autocomplete_on_newline = True + return False + async def _on_key(self, event: Key) -> None: """Intercept clipboard, undo/redo, Enter, and Tab keys.""" normalized_key = self._normalize_key(event.key) + # TextArea consumes editing keys before they reach the app-level key + # router. Forward query-insert actions explicitly so shortcuts such as + # Ctrl+Enter (and user rebindings such as F5 or Enter) execute instead + # of being interpreted as editor input. + if await self._dispatch_query_insert_action(normalized_key): + event.prevent_default() + event.stop() + return + # Tab inserts a real tab character in INSERT mode. We do this manually # so the widget can keep the default tab_behavior='focus' and not let # Textual's indent-aware TextArea consume Escape for focus navigation. @@ -246,14 +300,13 @@ async def _on_key(self, event: Key) -> None: # Note: Shift+Arrow selection is handled natively by TextArea # (shift+left/right/up/down, shift+home/end) - # Handle Enter key when autocomplete is visible - if event.key == "enter": - app = cast("AutocompleteProtocol", self.app) - if getattr(app, "_autocomplete_visible", False): - # Hide autocomplete and suppress re-triggering from the newline - if hasattr(app, "_hide_autocomplete"): - app._hide_autocomplete() - app._suppress_autocomplete_on_newline = True + # Handle Enter key when autocomplete is visible. TextArea handles the + # event before the app mixin sees it, so accept here when there is an + # actual suggestion; otherwise preserve Enter's newline behaviour. + if event.key == "enter" and await self._handle_autocomplete_enter(): + event.prevent_default() + event.stop() + return # For text-modifying keys, push undo state before the change if self._is_text_modifying_key(normalized_key): diff --git a/tests/ui/test_query_tab_insert.py b/tests/ui/test_query_tab_insert.py index 0d1300cf..88a3183c 100644 --- a/tests/ui/test_query_tab_insert.py +++ b/tests/ui/test_query_tab_insert.py @@ -9,6 +9,7 @@ from sqlit.core.vim import VimMode from sqlit.domains.shell.app.keymap_manager import FileBasedKeymapProvider from sqlit.domains.shell.app.main import SSMSTUI +from sqlit.shared.ui.widgets_autocomplete import AutocompleteItem from .mocks import MockConnectionStore, MockSettingsStore, build_test_services @@ -73,6 +74,145 @@ async def test_tab_accepts_autocomplete_suggestion(self) -> None: assert app._autocomplete_visible is False assert app.query_input.text == "select" + @pytest.mark.asyncio + async def test_enter_inserts_newline_with_default_tab_accept_binding(self) -> None: + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.press("i") + app.query_input.text = "sel" + app.query_input.cursor_location = (0, 3) + app._show_autocomplete(["select", "set"], "sel") + await pilot.pause() + + await pilot.press("enter") + await pilot.pause() + + assert app._autocomplete_visible is False + assert app.query_input.text == "sel\n" + + @pytest.mark.asyncio + async def test_enter_accepts_autocomplete_when_explicitly_rebound(self) -> None: + app = _make_app() + + try: + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.press("i") + defaults = DefaultKeymapProvider() + action_keys = [ + binding + for binding in defaults.get_action_keys() + if not ( + binding.action == "autocomplete_accept" + and binding.context == "autocomplete" + ) + ] + action_keys.append( + ActionKeyDef("enter", "autocomplete_accept", "autocomplete") + ) + set_keymap( + FileBasedKeymapProvider( + "enter-to-accept", + defaults.get_leader_commands(), + action_keys, + ) + ) + app.query_input.text = "sel" + app.query_input.cursor_location = (0, 3) + app._show_autocomplete(["select", "set"], "sel") + await pilot.pause() + + await pilot.press("enter") + await pilot.pause() + + assert app._autocomplete_visible is False + assert app.query_input.text == "select" + finally: + reset_keymap() + + @pytest.mark.asyncio + async def test_clicking_autocomplete_suggestion_applies_clicked_item(self) -> None: + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.press("i") + app.query_input.text = "se" + app.query_input.cursor_location = (0, 2) + app._show_autocomplete(["select", "set"], "se") + await pilot.pause() + + items = list(app.autocomplete_dropdown.query(AutocompleteItem)) + assert await pilot.click(items[1]) + await pilot.pause() + + assert app._autocomplete_visible is False + assert app.query_input.text == "set" + + @pytest.mark.asyncio + async def test_ctrl_enter_executes_in_insert_mode_without_newline(self) -> None: + app = _make_app() + calls: list[bool] = [] + app._execute_query_common = ( # type: ignore[method-assign] + lambda *, keep_insert_mode: calls.append(keep_insert_mode) + ) + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.press("i") + app.query_input.text = "select 1" + await pilot.pause() + + await pilot.press("ctrl+enter") + await pilot.pause() + + assert calls == [True] + assert app.query_input.text == "select 1" + + @pytest.mark.asyncio + async def test_enter_rebinding_executes_instead_of_inserting_newline(self) -> None: + app = _make_app() + calls: list[bool] = [] + app._execute_query_common = ( # type: ignore[method-assign] + lambda *, keep_insert_mode: calls.append(keep_insert_mode) + ) + + try: + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.press("i") + defaults = DefaultKeymapProvider() + action_keys = [ + binding + for binding in defaults.get_action_keys() + if not ( + binding.action == "execute_query_insert" + and binding.context == "query_insert" + ) + ] + action_keys.append( + ActionKeyDef("enter", "execute_query_insert", "query_insert") + ) + set_keymap( + FileBasedKeymapProvider( + "enter-to-execute", + defaults.get_leader_commands(), + action_keys, + ) + ) + app.query_input.text = "select 1" + await pilot.pause() + + await pilot.press("enter") + await pilot.pause() + + assert calls == [True] + assert app.query_input.text == "select 1" + finally: + reset_keymap() + @pytest.mark.asyncio async def test_tab_does_not_insert_in_normal_mode(self) -> None: app = _make_app()