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:
+214
-5
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user