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:
+21
-3
@@ -14,7 +14,7 @@ from PyQt6.QtWidgets import (
|
||||
QPushButton, QApplication,
|
||||
)
|
||||
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.sql_editor import SQLEditorWidget
|
||||
@@ -159,7 +159,7 @@ class MainWindow(QMainWindow):
|
||||
file_menu.addAction(self._act("Exit", QApplication.quit, "Ctrl+Q"))
|
||||
|
||||
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"))
|
||||
|
||||
tools_menu = mb.addMenu("&Tools")
|
||||
@@ -327,17 +327,30 @@ class MainWindow(QMainWindow):
|
||||
self._empty_label.setVisible(False)
|
||||
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):
|
||||
tab = TableViewer(driver, database, table)
|
||||
tab.status_message.connect(self._set_status)
|
||||
idx = self._workspace.addTab(tab, f"📋 {table}")
|
||||
self._workspace.setCurrentIndex(idx)
|
||||
self._apply_tab_color(idx, driver)
|
||||
|
||||
def _open_table_structure(self, driver, database: str, table: str):
|
||||
tab = TableStructureView(driver, database, table)
|
||||
tab.status_message.connect(self._set_status)
|
||||
idx = self._workspace.addTab(tab, f"🏗️ {table}")
|
||||
self._workspace.setCurrentIndex(idx)
|
||||
self._apply_tab_color(idx, driver)
|
||||
|
||||
def _open_sql_editor(self, driver, database: str):
|
||||
sql_widget = SQLEditorWidget()
|
||||
@@ -346,6 +359,7 @@ class MainWindow(QMainWindow):
|
||||
label = f"✏️ SQL — {database}" if database else "✏️ SQL"
|
||||
idx = self._workspace.addTab(sql_widget, label)
|
||||
self._workspace.setCurrentIndex(idx)
|
||||
self._apply_tab_color(idx, driver)
|
||||
|
||||
def _new_sql_tab(self):
|
||||
sql_widget = SQLEditorWidget()
|
||||
@@ -386,9 +400,12 @@ class MainWindow(QMainWindow):
|
||||
QMessageBox.information(self, "Keyboard Shortcuts",
|
||||
"F5 / Ctrl+Enter — Run query\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+T — New SQL tab\n"
|
||||
"Ctrl+H — Toggle query history\n"
|
||||
"Ctrl+Shift+H — Toggle query history\n"
|
||||
"Ctrl+U — User management\n"
|
||||
"Ctrl+L — View app logs\n"
|
||||
"Delete — Delete selected row (table viewer)\n"
|
||||
@@ -411,6 +428,7 @@ class MainWindow(QMainWindow):
|
||||
panel.status_message.connect(self._set_status)
|
||||
idx = self._workspace.addTab(panel, f"⚙️ Processes — {name}")
|
||||
self._workspace.setCurrentIndex(idx)
|
||||
self._apply_tab_color(idx, driver)
|
||||
self._show_workspace()
|
||||
|
||||
def _open_import_dialog(self):
|
||||
|
||||
+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