From 22f1dd5f453d7bba790eaf56531ae35f0d66d094 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Thu, 21 May 2026 16:42:18 -0400 Subject: [PATCH] feat: SQL auto-complete, Find/Replace bar, and connection tab coloring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/main_window.py | 24 ++++- app/ui/sql_editor.py | 219 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 235 insertions(+), 8 deletions(-) diff --git a/app/main_window.py b/app/main_window.py index 5f8d528..bebdd65 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -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): diff --git a/app/ui/sql_editor.py b/app/ui/sql_editor.py index c2c6264..e717934 100644 --- a/app/ui/sql_editor.py +++ b/app/ui/sql_editor.py @@ -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 ─────────────────────────────────────────────────────────────