Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion sqlit/domains/query/state/query_focused.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
33 changes: 31 additions & 2 deletions sqlit/shared/ui/widgets_text_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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] = {
Expand Down Expand Up @@ -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")
Expand Down
76 changes: 76 additions & 0 deletions tests/ui/test_autocomplete_close.py
Original file line number Diff line number Diff line change
@@ -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
89 changes: 89 additions & 0 deletions tests/ui/test_query_tab_insert.py
Original file line number Diff line number Diff line change
@@ -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"
88 changes: 88 additions & 0 deletions tests/unit/test_query_tab.py
Original file line number Diff line number Diff line change
@@ -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"