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
+21 -3
View File
@@ -14,7 +14,7 @@ from PyQt6.QtWidgets import (
QPushButton, QApplication, QPushButton, QApplication,
) )
from PyQt6.QtCore import Qt, QTimer from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QAction, QKeySequence from PyQt6.QtGui import QAction, QColor, QKeySequence
from app.ui.schema_browser import SchemaBrowser from app.ui.schema_browser import SchemaBrowser
from app.ui.sql_editor import SQLEditorWidget from app.ui.sql_editor import SQLEditorWidget
@@ -159,7 +159,7 @@ class MainWindow(QMainWindow):
file_menu.addAction(self._act("Exit", QApplication.quit, "Ctrl+Q")) file_menu.addAction(self._act("Exit", QApplication.quit, "Ctrl+Q"))
view_menu = mb.addMenu("&View") view_menu = mb.addMenu("&View")
view_menu.addAction(self._act("Toggle Query History", self._toggle_history, "Ctrl+H")) view_menu.addAction(self._act("Toggle Query History", self._toggle_history, "Ctrl+Shift+H"))
view_menu.addAction(self._act("New SQL Tab", self._new_sql_tab, "Ctrl+T")) view_menu.addAction(self._act("New SQL Tab", self._new_sql_tab, "Ctrl+T"))
tools_menu = mb.addMenu("&Tools") tools_menu = mb.addMenu("&Tools")
@@ -327,17 +327,30 @@ class MainWindow(QMainWindow):
self._empty_label.setVisible(False) self._empty_label.setVisible(False)
self._workspace.setVisible(True) self._workspace.setVisible(True)
def _profile_for_driver(self, driver):
for pid, d in self._active_drivers.items():
if d is driver:
return self._all_profiles.get(pid)
return None
def _apply_tab_color(self, idx: int, driver) -> None:
profile = self._profile_for_driver(driver)
if profile:
self._workspace.tabBar().setTabTextColor(idx, QColor(profile.color))
def _open_table_viewer(self, driver, database: str, table: str): def _open_table_viewer(self, driver, database: str, table: str):
tab = TableViewer(driver, database, table) tab = TableViewer(driver, database, table)
tab.status_message.connect(self._set_status) tab.status_message.connect(self._set_status)
idx = self._workspace.addTab(tab, f"📋 {table}") idx = self._workspace.addTab(tab, f"📋 {table}")
self._workspace.setCurrentIndex(idx) self._workspace.setCurrentIndex(idx)
self._apply_tab_color(idx, driver)
def _open_table_structure(self, driver, database: str, table: str): def _open_table_structure(self, driver, database: str, table: str):
tab = TableStructureView(driver, database, table) tab = TableStructureView(driver, database, table)
tab.status_message.connect(self._set_status) tab.status_message.connect(self._set_status)
idx = self._workspace.addTab(tab, f"🏗️ {table}") idx = self._workspace.addTab(tab, f"🏗️ {table}")
self._workspace.setCurrentIndex(idx) self._workspace.setCurrentIndex(idx)
self._apply_tab_color(idx, driver)
def _open_sql_editor(self, driver, database: str): def _open_sql_editor(self, driver, database: str):
sql_widget = SQLEditorWidget() sql_widget = SQLEditorWidget()
@@ -346,6 +359,7 @@ class MainWindow(QMainWindow):
label = f"✏️ SQL — {database}" if database else "✏️ SQL" label = f"✏️ SQL — {database}" if database else "✏️ SQL"
idx = self._workspace.addTab(sql_widget, label) idx = self._workspace.addTab(sql_widget, label)
self._workspace.setCurrentIndex(idx) self._workspace.setCurrentIndex(idx)
self._apply_tab_color(idx, driver)
def _new_sql_tab(self): def _new_sql_tab(self):
sql_widget = SQLEditorWidget() sql_widget = SQLEditorWidget()
@@ -386,9 +400,12 @@ class MainWindow(QMainWindow):
QMessageBox.information(self, "Keyboard Shortcuts", QMessageBox.information(self, "Keyboard Shortcuts",
"F5 / Ctrl+Enter — Run query\n" "F5 / Ctrl+Enter — Run query\n"
"Ctrl+/ — Toggle comment\n" "Ctrl+/ — Toggle comment\n"
"Ctrl+Space — Force auto-complete\n"
"Ctrl+F — Find in SQL editor\n"
"Ctrl+H — Find & Replace in SQL editor\n"
"Ctrl+N — New connection\n" "Ctrl+N — New connection\n"
"Ctrl+T — New SQL tab\n" "Ctrl+T — New SQL tab\n"
"Ctrl+H — Toggle query history\n" "Ctrl+Shift+H — Toggle query history\n"
"Ctrl+U — User management\n" "Ctrl+U — User management\n"
"Ctrl+L — View app logs\n" "Ctrl+L — View app logs\n"
"Delete — Delete selected row (table viewer)\n" "Delete — Delete selected row (table viewer)\n"
@@ -411,6 +428,7 @@ class MainWindow(QMainWindow):
panel.status_message.connect(self._set_status) panel.status_message.connect(self._set_status)
idx = self._workspace.addTab(panel, f"⚙️ Processes — {name}") idx = self._workspace.addTab(panel, f"⚙️ Processes — {name}")
self._workspace.setCurrentIndex(idx) self._workspace.setCurrentIndex(idx)
self._apply_tab_color(idx, driver)
self._show_workspace() self._show_workspace()
def _open_import_dialog(self): def _open_import_dialog(self):
+214 -5
View File
@@ -5,12 +5,12 @@ import os
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPlainTextEdit, QTextEdit, QWidget, QVBoxLayout, QHBoxLayout, QPlainTextEdit, QTextEdit,
QTabWidget, QPushButton, QLabel, QSplitter, QTabBar, QSizePolicy, 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.QtCore import Qt, QRect, QSize, pyqtSignal, QTimer
from PyQt6.QtGui import ( from PyQt6.QtGui import (
QColor, QPainter, QTextFormat, QFont, QKeySequence, QShortcut, QColor, QPainter, QTextDocument, QTextFormat, QFont, QKeySequence,
QFontMetrics, QTextCursor, QShortcut, QFontMetrics, QTextCursor,
) )
from app.ui.syntax_highlighter import SQLHighlighter from app.ui.syntax_highlighter import SQLHighlighter
@@ -190,6 +190,210 @@ class CodeEditor(QPlainTextEdit):
return text if text.strip() else self.toPlainText() 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) ───────────────────────────── # ── Single editor tab (editor + results splitter) ─────────────────────────────
class EditorTab(QWidget): class EditorTab(QWidget):
@@ -237,8 +441,6 @@ class EditorTab(QWidget):
toolbar.addStretch() toolbar.addStretch()
toolbar.addWidget(self._db_label) toolbar.addWidget(self._db_label)
root.addLayout(toolbar)
# ── Splitter: editor / results ────────────────────────────────────── # ── Splitter: editor / results ──────────────────────────────────────
self._splitter = QSplitter(Qt.Orientation.Vertical) self._splitter = QSplitter(Qt.Orientation.Vertical)
self._splitter.setHandleWidth(3) self._splitter.setHandleWidth(3)
@@ -250,6 +452,9 @@ class EditorTab(QWidget):
if self._driver: if self._driver:
self._completer.set_context(self._driver, self._database) 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 = ResultsPanel()
self._results.status_message.connect(self.status_message) self._results.status_message.connect(self.status_message)
@@ -257,11 +462,15 @@ class EditorTab(QWidget):
self._splitter.addWidget(self._results) self._splitter.addWidget(self._results)
self._splitter.setSizes([400, 250]) self._splitter.setSizes([400, 250])
root.addLayout(toolbar)
root.addWidget(self._find_bar)
root.addWidget(self._splitter, 1) root.addWidget(self._splitter, 1)
# ── Shortcuts ────────────────────────────────────────────────────── # ── Shortcuts ──────────────────────────────────────────────────────
QShortcut(QKeySequence("F5"), self, self._run) QShortcut(QKeySequence("F5"), self, self._run)
QShortcut(QKeySequence("Ctrl+Return"), 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 ───────────────────────────────────────────────────────────── # ── Run logic ─────────────────────────────────────────────────────────────