From 0b7c9927694596726282f7878579cc0154fe9efc Mon Sep 17 00:00:00 2001 From: Sergey Protko Date: Tue, 7 Jul 2026 15:29:40 +0300 Subject: [PATCH] fix(query): Tab inserts a real tab character and autocomplete accept still works - QueryTextArea now inserts "\t" on Tab in INSERT mode. - When the autocomplete dropdown is open, Tab accepts the suggestion instead. - Escape closes autocomplete when leaving INSERT mode. - Added unit and UI tests for Tab/autocomplete behavior. --- sqlit/domains/query/state/query_focused.py | 4 +- sqlit/shared/ui/widgets_text_area.py | 33 +++++++- tests/ui/test_autocomplete_close.py | 76 ++++++++++++++++++ tests/ui/test_query_tab_insert.py | 89 ++++++++++++++++++++++ tests/unit/test_query_tab.py | 88 +++++++++++++++++++++ 5 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 tests/ui/test_autocomplete_close.py create mode 100644 tests/ui/test_query_tab_insert.py create mode 100644 tests/unit/test_query_tab.py diff --git a/sqlit/domains/query/state/query_focused.py b/sqlit/domains/query/state/query_focused.py index 5f7c0b9a..37b8d5ed 100644 --- a/sqlit/domains/query/state/query_focused.py +++ b/sqlit/domains/query/state/query_focused.py @@ -10,7 +10,9 @@ class QueryFocusedState(State): """Base state when query editor has focus.""" def _setup_actions(self) -> None: - pass + # Close autocomplete dropdown if it somehow stays open while leaving + # INSERT mode (e.g. edge cases or future modes). + self.allows("autocomplete_close") def is_active(self, app: InputContext) -> bool: return app.focus == "query" diff --git a/sqlit/shared/ui/widgets_text_area.py b/sqlit/shared/ui/widgets_text_area.py index 16ce7cbe..51813661 100644 --- a/sqlit/shared/ui/widgets_text_area.py +++ b/sqlit/shared/ui/widgets_text_area.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast from rich.segment import Segment from textual.color import Color @@ -23,6 +23,18 @@ class QueryTextArea(TextArea): _relative_line_numbers: bool = False _last_cursor_row: int = -1 + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize with focus-friendly tab behavior and real tab characters.""" + kwargs.setdefault("tab_behavior", "focus") + super().__init__(*args, **kwargs) + self.indent_type = "tabs" + + def _tab_insert_string(self) -> str: + """Return the string to insert when Tab is pressed.""" + if self.indent_type == "spaces": + return " " * self._find_columns_to_next_tab_stop() + return "\t" + # Normalize OS-variant shortcuts to canonical forms # Maps: super → ctrl for common operations, strips shift where irrelevant _KEY_NORMALIZATION: dict[str, str] = { @@ -128,9 +140,26 @@ def _watch_has_focus(self, focus: bool) -> None: self._sync_terminal_cursor() async def _on_key(self, event: Key) -> None: - """Intercept clipboard, undo/redo, and Enter keys.""" + """Intercept clipboard, undo/redo, Enter, and Tab keys.""" normalized_key = self._normalize_key(event.key) + # 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. + if event.key == "tab": + if not self._is_insert_mode(): + return + # If the autocomplete dropdown is open, let the app's key router + # accept the suggestion instead of inserting a tab character. + app = cast("AutocompleteProtocol", self.app) + if getattr(app, "_autocomplete_visible", False): + return + self._push_undo_if_changed() + self.insert(self._tab_insert_string()) + event.prevent_default() + event.stop() + return + from sqlit.core.keymap import get_keymap keymap = get_keymap() select_all_keys = keymap.keys_for_action("select_all") diff --git a/tests/ui/test_autocomplete_close.py b/tests/ui/test_autocomplete_close.py new file mode 100644 index 00000000..e1de4c6f --- /dev/null +++ b/tests/ui/test_autocomplete_close.py @@ -0,0 +1,76 @@ +"""UI tests for autocomplete close behavior.""" + +from __future__ import annotations + +import pytest + +from sqlit.core.vim import VimMode +from sqlit.domains.shell.app.main import SSMSTUI + +from .mocks import MockConnectionStore, MockSettingsStore, build_test_services + + +def _make_app() -> SSMSTUI: + services = build_test_services( + connection_store=MockConnectionStore(), + settings_store=MockSettingsStore({"theme": "tokyo-night"}), + ) + return SSMSTUI(services=services) + + +class TestAutocompleteClose: + """Autocomplete should close when leaving INSERT mode.""" + + @pytest.mark.asyncio + async def test_escape_closes_autocomplete_and_exits_insert(self) -> None: + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + + app.query_input.text = "sel" + app.query_input.cursor_location = (0, 3) + await pilot.pause() + + # Enter INSERT mode + await pilot.press("i") + await pilot.pause() + assert app.vim_mode == VimMode.INSERT + assert app.query_input.read_only is False + + # Open autocomplete manually (simulating suggestions) + app._show_autocomplete(["select", "set"], "sel") + await pilot.pause() + assert app._autocomplete_visible is True + + # Press Escape to leave INSERT mode + await pilot.press("escape") + await pilot.pause() + + assert app.vim_mode == VimMode.NORMAL + assert app._autocomplete_visible is False + assert app.query_input.read_only is True + + @pytest.mark.asyncio + async def test_escape_closes_autocomplete_from_normal_mode(self) -> None: + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + + app.query_input.text = "sel" + await pilot.pause() + assert app.vim_mode == VimMode.NORMAL + + # Open autocomplete while in normal mode (edge case) + app._show_autocomplete(["select", "set"], "sel") + await pilot.pause() + assert app._autocomplete_visible is True + + await pilot.press("escape") + await pilot.pause() + + assert app.vim_mode == VimMode.NORMAL + assert app._autocomplete_visible is False diff --git a/tests/ui/test_query_tab_insert.py b/tests/ui/test_query_tab_insert.py new file mode 100644 index 00000000..98a0e95d --- /dev/null +++ b/tests/ui/test_query_tab_insert.py @@ -0,0 +1,89 @@ +"""UI tests for Tab behavior in the query editor.""" + +from __future__ import annotations + +import pytest + +from sqlit.core.vim import VimMode +from sqlit.domains.shell.app.main import SSMSTUI + +from .mocks import MockConnectionStore, MockSettingsStore, build_test_services + + +def _make_app() -> SSMSTUI: + services = build_test_services( + connection_store=MockConnectionStore(), + settings_store=MockSettingsStore({"theme": "tokyo-night"}), + ) + return SSMSTUI(services=services) + + +class TestQueryTabInsertion: + """Tab should insert a real tab character in INSERT mode.""" + + @pytest.mark.asyncio + async def test_tab_inserts_tab_character_in_insert_mode(self) -> None: + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + + app.query_input.text = "select" + app.query_input.cursor_location = (0, 6) + await pilot.pause() + + await pilot.press("i") + await pilot.pause() + assert app.vim_mode == VimMode.INSERT + + await pilot.press("tab") + await pilot.pause() + + assert app.query_input.text == "select\t" + assert "\t" in app.query_input.text + + @pytest.mark.asyncio + async def test_tab_accepts_autocomplete_suggestion(self) -> None: + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + + app.query_input.text = "sel" + app.query_input.cursor_location = (0, 3) + await pilot.pause() + + await pilot.press("i") + await pilot.pause() + assert app.vim_mode == VimMode.INSERT + + # Open autocomplete manually with "select" as first suggestion + app._show_autocomplete(["select", "set"], "sel") + await pilot.pause() + assert app._autocomplete_visible is True + + await pilot.press("tab") + await pilot.pause() + + assert app._autocomplete_visible is False + assert app.query_input.text == "select" + + @pytest.mark.asyncio + async def test_tab_does_not_insert_in_normal_mode(self) -> None: + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + + app.query_input.text = "select" + await pilot.pause() + assert app.vim_mode == VimMode.NORMAL + + await pilot.press("tab") + await pilot.pause() + + # Text should remain unchanged; Tab did not insert anything + assert app.query_input.text == "select" diff --git a/tests/unit/test_query_tab.py b/tests/unit/test_query_tab.py new file mode 100644 index 00000000..aa604d96 --- /dev/null +++ b/tests/unit/test_query_tab.py @@ -0,0 +1,88 @@ +"""Tests for Tab behavior in the query editor.""" + +from __future__ import annotations + +from sqlit.core.binding_contexts import get_binding_contexts +from sqlit.core.input_context import InputContext +from sqlit.core.key_router import resolve_action +from sqlit.core.vim import VimMode +from sqlit.domains.shell.state import UIStateMachine +from sqlit.shared.ui.widgets_text_area import QueryTextArea + + +def make_context(**overrides: object) -> InputContext: + """Build a default InputContext with optional overrides.""" + data = { + "focus": "query", + "vim_mode": VimMode.INSERT, + "leader_pending": False, + "leader_menu": "leader", + "tree_filter_active": False, + "tree_multi_select_active": False, + "tree_visual_mode_active": False, + "autocomplete_visible": False, + "results_filter_active": False, + "value_view_active": False, + "value_view_tree_mode": False, + "value_view_is_json": False, + "query_executing": False, + "modal_open": False, + "has_connection": False, + "current_connection_name": None, + "tree_node_kind": None, + "tree_node_connection_name": None, + "tree_node_connection_selected": False, + "last_result_is_error": False, + "has_results": False, + } + data.update(overrides) + return InputContext(**data) + + +class TestTabKeyRouting: + """Tab should indent in insert mode, but accept autocomplete when open.""" + + def _is_allowed(self, ctx: InputContext, name: str) -> bool: + sm = UIStateMachine() + return sm.check_action(ctx, name) + + def test_tab_in_insert_mode_without_autocomplete_is_unresolved(self) -> None: + sm = UIStateMachine() + ctx = make_context(focus="query", vim_mode=VimMode.INSERT, autocomplete_visible=False) + + assert resolve_action("tab", ctx, is_allowed=lambda name: sm.check_action(ctx, name)) is None + + def test_tab_in_insert_mode_with_autocomplete_accepts(self) -> None: + sm = UIStateMachine() + ctx = make_context(focus="query", vim_mode=VimMode.INSERT, autocomplete_visible=True) + + assert resolve_action("tab", ctx, is_allowed=lambda name: sm.check_action(ctx, name)) == "autocomplete_accept" + + def test_tab_in_normal_mode_is_unresolved(self) -> None: + sm = UIStateMachine() + ctx = make_context(focus="query", vim_mode=VimMode.NORMAL, autocomplete_visible=False) + + assert resolve_action("tab", ctx, is_allowed=lambda name: sm.check_action(ctx, name)) is None + + def test_autocomplete_context_only_active_when_visible(self) -> None: + ctx_hidden = make_context(focus="query", vim_mode=VimMode.INSERT, autocomplete_visible=False) + ctx_visible = make_context(focus="query", vim_mode=VimMode.INSERT, autocomplete_visible=True) + + assert "autocomplete" not in get_binding_contexts(ctx_hidden) + assert "autocomplete" in get_binding_contexts(ctx_visible) + + +class TestQueryTextAreaTabBehavior: + """QueryTextArea keeps focus-friendly tab behavior but inserts real tabs.""" + + def test_query_text_area_keeps_focus_tab_behavior(self) -> None: + ta = QueryTextArea() + assert ta.tab_behavior == "focus" + + def test_query_text_area_uses_tab_characters(self) -> None: + ta = QueryTextArea() + assert ta.indent_type == "tabs" + + def test_query_text_area_tab_insert_string(self) -> None: + ta = QueryTextArea() + assert ta._tab_insert_string() == "\t"