feat: SQL auto-complete, Find/Replace bar, and connection tab coloring

- Add SqlCompleter (sql_completer.py): schema-aware popup with keyword,
  table, view, and lazy column completions; dot-notation and Ctrl+Space
- Add FindBar to EditorTab: inline find/replace with wrap-around,
  case/whole-word flags, match count, and Ctrl+F / Ctrl+H shortcuts
- Color workspace tab text by connection profile in MainWindow
- Move Toggle History shortcut Ctrl+H → Ctrl+Shift+H to free Ctrl+H
  for Find & Replace

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 16:42:18 -04:00
co-authored by Claude Sonnet 4.6
parent f680bfe3a3
commit 22f1dd5f45
2 changed files with 235 additions and 8 deletions
+214 -5
View File
@@ -5,12 +5,12 @@ import os
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPlainTextEdit, QTextEdit,
QTabWidget, QPushButton, QLabel, QSplitter, QTabBar, QSizePolicy,
QFileDialog, QMessageBox, QToolButton, QComboBox,
QFileDialog, QMessageBox, QToolButton, QComboBox, QLineEdit, QCheckBox,
)
from PyQt6.QtCore import Qt, QRect, QSize, pyqtSignal, QTimer
from PyQt6.QtGui import (
QColor, QPainter, QTextFormat, QFont, QKeySequence, QShortcut,
QFontMetrics, QTextCursor,
QColor, QPainter, QTextDocument, QTextFormat, QFont, QKeySequence,
QShortcut, QFontMetrics, QTextCursor,
)
from app.ui.syntax_highlighter import SQLHighlighter
@@ -190,6 +190,210 @@ class CodeEditor(QPlainTextEdit):
return text if text.strip() else self.toPlainText()
# ── Find / Replace bar ────────────────────────────────────────────────────────
class FindBar(QWidget):
"""Inline find/replace bar that slides in above the editor splitter."""
def __init__(self, editor: "CodeEditor", parent=None):
super().__init__(parent)
self._editor = editor
self._replace_mode = False
self._last_search = ""
self._build_ui()
def _build_ui(self):
root = QVBoxLayout(self)
root.setContentsMargins(6, 4, 6, 4)
root.setSpacing(3)
# ── Find row ───────────────────────────────────────────────────────────
find_row = QHBoxLayout()
find_row.setSpacing(4)
self._find_input = QLineEdit()
self._find_input.setPlaceholderText("Find…")
self._find_input.setFixedHeight(26)
self._find_input.textChanged.connect(self._on_find_text_changed)
self._find_input.returnPressed.connect(self._find_next)
self._prev_btn = QPushButton("")
self._prev_btn.setFixedSize(26, 26)
self._prev_btn.setToolTip("Find previous (Shift+Enter)")
self._prev_btn.clicked.connect(self._find_prev)
self._next_btn = QPushButton("")
self._next_btn.setFixedSize(26, 26)
self._next_btn.setToolTip("Find next (Enter)")
self._next_btn.clicked.connect(self._find_next)
self._case_cb = QCheckBox("Aa")
self._case_cb.setToolTip("Match case")
self._case_cb.toggled.connect(self._on_find_text_changed)
self._word_cb = QCheckBox("\\b")
self._word_cb.setToolTip("Whole word")
self._word_cb.toggled.connect(self._on_find_text_changed)
self._match_lbl = QLabel("")
self._match_lbl.setObjectName("findMatchLbl")
self._match_lbl.setMinimumWidth(60)
close_btn = QPushButton("")
close_btn.setFixedSize(26, 26)
close_btn.setToolTip("Close (Esc)")
close_btn.clicked.connect(self.hide_bar)
find_row.addWidget(QLabel("Find:"))
find_row.addWidget(self._find_input, 1)
find_row.addWidget(self._prev_btn)
find_row.addWidget(self._next_btn)
find_row.addWidget(self._case_cb)
find_row.addWidget(self._word_cb)
find_row.addWidget(self._match_lbl)
find_row.addStretch()
find_row.addWidget(close_btn)
root.addLayout(find_row)
# ── Replace row (hidden in find-only mode) ─────────────────────────────
self._replace_widget = QWidget()
repl_row = QHBoxLayout(self._replace_widget)
repl_row.setContentsMargins(0, 0, 0, 0)
repl_row.setSpacing(4)
self._repl_input = QLineEdit()
self._repl_input.setPlaceholderText("Replace with…")
self._repl_input.setFixedHeight(26)
self._repl_input.returnPressed.connect(self._replace_one)
repl_btn = QPushButton("Replace")
repl_btn.setFixedHeight(26)
repl_btn.clicked.connect(self._replace_one)
repl_all_btn = QPushButton("Replace All")
repl_all_btn.setFixedHeight(26)
repl_all_btn.clicked.connect(self._replace_all)
repl_row.addWidget(QLabel("Replace:"))
repl_row.addWidget(self._repl_input, 1)
repl_row.addWidget(repl_btn)
repl_row.addWidget(repl_all_btn)
repl_row.addStretch()
root.addWidget(self._replace_widget)
self._replace_widget.setVisible(False)
# ── Public API ────────────────────────────────────────────────────────────
def show_find(self):
self._replace_widget.setVisible(False)
self._replace_mode = False
self.setVisible(True)
self._find_input.setFocus()
self._find_input.selectAll()
def show_replace(self):
self._replace_widget.setVisible(True)
self._replace_mode = True
self.setVisible(True)
self._find_input.setFocus()
self._find_input.selectAll()
def hide_bar(self):
self.setVisible(False)
self._editor.setFocus()
# ── Find logic ────────────────────────────────────────────────────────────
def _flags(self, backward: bool = False) -> QTextDocument.FindFlag:
flags = QTextDocument.FindFlag(0)
if self._case_cb.isChecked():
flags |= QTextDocument.FindFlag.FindCaseSensitively
if self._word_cb.isChecked():
flags |= QTextDocument.FindFlag.FindWholeWords
if backward:
flags |= QTextDocument.FindFlag.FindBackward
return flags
def _do_find(self, backward: bool = False) -> bool:
text = self._find_input.text()
if not text:
return False
found = self._editor.document().find(text, self._editor.textCursor(), self._flags(backward))
if found.isNull():
# Wrap around
wrap = QTextCursor(self._editor.document())
if backward:
wrap.movePosition(QTextCursor.MoveOperation.End)
found = self._editor.document().find(text, wrap, self._flags(backward))
if not found.isNull():
self._editor.setTextCursor(found)
self._set_no_match(False)
return True
self._set_no_match(True)
return False
def _find_next(self):
self._do_find(backward=False)
def _find_prev(self):
self._do_find(backward=True)
def _set_no_match(self, no_match: bool):
color = "#f38ba8" if no_match else ""
self._find_input.setStyleSheet(
f"background: {color};" if no_match else ""
)
self._match_lbl.setText("No match" if no_match else "")
def _on_find_text_changed(self):
self._set_no_match(False)
text = self._find_input.text()
if text and len(text) >= 1:
self._do_find(backward=False)
# ── Replace logic ─────────────────────────────────────────────────────────
def _replace_one(self):
if not self._do_find(backward=False):
return
tc = self._editor.textCursor()
if tc.hasSelection():
tc.insertText(self._repl_input.text())
self._editor.setTextCursor(tc)
self._do_find(backward=False)
def _replace_all(self):
text = self._find_input.text()
repl = self._repl_input.text()
if not text:
return
doc = self._editor.document()
cursor = QTextCursor(doc)
cursor.beginEditBlock()
count = 0
found = doc.find(text, cursor, self._flags())
while not found.isNull():
found.insertText(repl)
found = doc.find(text, found, self._flags())
count += 1
cursor.endEditBlock()
self._match_lbl.setText(f"{count} replaced" if count else "No match")
# ── Key events ────────────────────────────────────────────────────────────
def keyPressEvent(self, event):
if event.key() == Qt.Key.Key_Escape:
self.hide_bar()
return
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
if event.modifiers() & Qt.KeyboardModifier.ShiftModifier:
self._find_prev()
else:
self._find_next()
return
super().keyPressEvent(event)
# ── Single editor tab (editor + results splitter) ─────────────────────────────
class EditorTab(QWidget):
@@ -237,8 +441,6 @@ class EditorTab(QWidget):
toolbar.addStretch()
toolbar.addWidget(self._db_label)
root.addLayout(toolbar)
# ── Splitter: editor / results ──────────────────────────────────────
self._splitter = QSplitter(Qt.Orientation.Vertical)
self._splitter.setHandleWidth(3)
@@ -250,6 +452,9 @@ class EditorTab(QWidget):
if self._driver:
self._completer.set_context(self._driver, self._database)
self._find_bar = FindBar(self._editor, parent=self)
self._find_bar.setVisible(False)
self._results = ResultsPanel()
self._results.status_message.connect(self.status_message)
@@ -257,11 +462,15 @@ class EditorTab(QWidget):
self._splitter.addWidget(self._results)
self._splitter.setSizes([400, 250])
root.addLayout(toolbar)
root.addWidget(self._find_bar)
root.addWidget(self._splitter, 1)
# ── Shortcuts ──────────────────────────────────────────────────────
QShortcut(QKeySequence("F5"), self, self._run)
QShortcut(QKeySequence("Ctrl+Return"), self, self._run)
QShortcut(QKeySequence("Ctrl+F"), self, self._find_bar.show_find)
QShortcut(QKeySequence("Ctrl+H"), self, self._find_bar.show_replace)
# ── Run logic ─────────────────────────────────────────────────────────────