Add schema-aware SQL auto-complete to the SQL editor

New file app/ui/sql_completer.py:
- SqlCompleter (QObject) owns a _CompletionPopup (QListWidget) that
  floats above the editor as a ToolTip-style frameless window styled
  to match the Catppuccin Mocha theme.
- SQL keyword pool sourced directly from syntax_highlighter._KEYWORDS,
  _TYPES, _FUNCTIONS so the two features stay in sync.
- Schema data (tables, views, columns) loaded asynchronously via
  SchemaWorker; a generation counter discards stale results when the
  database changes rapidly.
- Column loading is lazy: fires only when the user types "table."
  (dot-qualified prefix), avoiding upfront cost for wide schemas.
- _extract_prefix() uses a regex to detect both bare words and
  "table.col" dot notation; guards against completing inside string
  literals by counting unescaped quotes to the left of the cursor.
- _accept_completion() replaces only the typed prefix, preserving any
  qualifier already in the document (e.g. "orders.cu" → "orders.customer_id").

Changes to app/ui/sql_editor.py:
- CodeEditor.set_completer() attaches the completer and stores it.
- keyPressEvent priority order:
    1. Popup visible → Esc hides, Enter/Tab accepts, Up/Down navigates.
    2. Ctrl+Space → force-show completions (1-char minimum).
    3. Tab (no popup) → existing 4-space insert behaviour preserved.
    4. Ctrl+/ → existing comment-toggle preserved.
    5. All other keys → super() then auto-trigger (2-char minimum).
- focusOutEvent hides the popup when the editor loses focus.
- EditorTab._build_ui() creates SqlCompleter, attaches it, and calls
  set_context() if a driver is already present.
- EditorTab.set_context() updates driver/database and reloads schema.
- _maybe_invalidate_schema() re-fetches schema after CREATE/DROP/ALTER
  so newly created tables appear in completions immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 16:28:58 -04:00
co-authored by Claude Sonnet 4.6
parent 04476bae11
commit f680bfe3a3
2 changed files with 357 additions and 9 deletions
+70 -9
View File
@@ -15,6 +15,7 @@ from PyQt6.QtGui import (
from app.ui.syntax_highlighter import SQLHighlighter
from app.ui.results_panel import ResultsPanel
from app.ui.sql_completer import SqlCompleter
from app.utils.worker import QueryWorker
@@ -33,11 +34,12 @@ class LineNumberArea(QWidget):
class CodeEditor(QPlainTextEdit):
"""QPlainTextEdit with line numbers, current-line highlight, and tab→spaces."""
"""QPlainTextEdit with line numbers, current-line highlight, tab→spaces, and auto-complete."""
def __init__(self, parent=None):
super().__init__(parent)
self._line_area = LineNumberArea(self)
self._completer: "SqlCompleter | None" = None
# Font
font = QFont("Consolas", 13)
@@ -109,19 +111,61 @@ class CodeEditor(QPlainTextEdit):
bottom = top + round(self.blockBoundingRect(block).height())
number += 1
def set_completer(self, completer: "SqlCompleter") -> None:
self._completer = completer
def _popup_visible(self) -> bool:
return (self._completer is not None
and self._completer._popup.isVisible())
def focusOutEvent(self, event):
super().focusOutEvent(event)
if self._completer:
self._completer._popup.hide_popup()
def keyPressEvent(self, event):
# Tab → 4 spaces
if event.key() == Qt.Key.Key_Tab:
cursor = self.textCursor()
cursor.insertText(" ")
# ── Popup navigation (highest priority) ──────────────────────────────
if self._popup_visible():
key = event.key()
if key == Qt.Key.Key_Escape:
self._completer._popup.hide_popup()
return
if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Tab):
self._completer._accept_completion(
self._completer._popup.current_text()
)
return
if key == Qt.Key.Key_Up:
self._completer._popup.move_selection(-1)
return
if key == Qt.Key.Key_Down:
self._completer._popup.move_selection(1)
return
# ── Ctrl+Space → force-show completions ──────────────────────────────
if (event.modifiers() == Qt.KeyboardModifier.ControlModifier
and event.key() == Qt.Key.Key_Space):
if self._completer:
self._completer.trigger_completion(force=True)
return
# Ctrl+/ → toggle comment
if event.modifiers() == Qt.KeyboardModifier.ControlModifier \
and event.key() == Qt.Key.Key_Slash:
# ── Tab → 4 spaces (only when popup is closed) ───────────────────────
if event.key() == Qt.Key.Key_Tab:
self.textCursor().insertText(" ")
return
# ── Ctrl+/ → toggle comment ───────────────────────────────────────────
if (event.modifiers() == Qt.KeyboardModifier.ControlModifier
and event.key() == Qt.Key.Key_Slash):
self._toggle_comment()
return
super().keyPressEvent(event)
# ── Auto-trigger completions after every regular keystroke ────────────
if self._completer:
self._completer.trigger_completion(force=False)
def _toggle_comment(self):
cursor = self.textCursor()
start = cursor.selectionStart()
@@ -201,6 +245,10 @@ class EditorTab(QWidget):
self._editor = CodeEditor()
SQLHighlighter(self._editor.document())
self._completer = SqlCompleter(self._editor, parent=self)
self._editor.set_completer(self._completer)
if self._driver:
self._completer.set_context(self._driver, self._database)
self._results = ResultsPanel()
self._results.status_message.connect(self.status_message)
@@ -264,20 +312,33 @@ class EditorTab(QWidget):
def _export(self):
self._results.export_dialog()
def set_context(self, driver, database: str) -> None:
"""Update driver/database and refresh schema completions."""
self._driver = driver
self._database = database
self._db_label.setText(f"DB: {database}" if database else "")
self._completer.set_context(driver, database)
def _on_result(self, cols, rows, cnt, elapsed):
self._results.show_data(cols, rows, cnt, elapsed)
self._maybe_invalidate_schema(self._editor.toPlainText())
def _on_script_done(self, results: list):
# Show the last SELECT result; messages for DML
for cols, rows, cnt, msg in results:
if cols:
self._results.show_data(cols, rows, cnt, 0)
else:
self._results.show_message(msg)
self._maybe_invalidate_schema(self._editor.toPlainText())
def _on_error(self, msg: str):
self._results.show_error(msg)
def _maybe_invalidate_schema(self, sql: str) -> None:
if any(sql.lstrip().lower().startswith(kw)
for kw in ("create", "drop", "alter", "rename")):
self._completer.invalidate()
# ── Public ─────────────────────────────────────────────────────────────────
def set_sql(self, sql: str):