From 771c92b7b63589702f8966debeabc68e411c9bf6 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Thu, 21 May 2026 17:05:54 -0400 Subject: [PATCH] feat: preferences dialog, SQL formatter, and enhanced table context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PreferencesDialog (Tools → Preferences, Ctrl+,): font family/size, word wrap, default page size, query timeout, max history, auto-commit; font changes apply live to all open editors via apply_settings() cascade - CodeEditor and TableViewer now read initial values from settings singleton - SQL formatter (≡ Format button): uses sqlparse to reindent and uppercase keywords; gracefully prompts to install sqlparse if missing - Table viewer context menu: "Filter by this value" (populates WHERE and reloads), "Copy row as SQL INSERT" (ready-to-paste INSERT statements), renamed "Copy row" → "Copy row as TSV" for clarity Co-Authored-By: Claude Sonnet 4.6 --- app/main_window.py | 17 ++++- app/ui/preferences_dialog.py | 133 +++++++++++++++++++++++++++++++++++ app/ui/sql_editor.py | 66 +++++++++++++++-- app/ui/table_viewer.py | 50 ++++++++++++- requirements.txt | 1 + 5 files changed, 259 insertions(+), 8 deletions(-) create mode 100644 app/ui/preferences_dialog.py diff --git a/app/main_window.py b/app/main_window.py index 2553077..b3615e2 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -30,7 +30,8 @@ from app.ui.explain_view import ExplainPanel from app.ui.user_manager import UserManagerPanel from app.ui.log_viewer import LogViewer from app.ui.connection_dialog import ConnectionDialog -from app.ui.shortcuts_dialog import ShortcutsDialog +from app.ui.shortcuts_dialog import ShortcutsDialog +from app.ui.preferences_dialog import PreferencesDialog from app.config.connections import load_profiles, delete_profile from app.config.recent_files import load_recent, add_recent, clear_recent from app.models.connection_model import ConnectionProfile @@ -184,6 +185,8 @@ class MainWindow(QMainWindow): # ── Tools ───────────────────────────────────────────────────────────── tools_menu = mb.addMenu("&Tools") + tools_menu.addAction(self._act("Preferences…", self._open_preferences, "Ctrl+,")) + tools_menu.addSeparator() tools_menu.addAction(self._act("Process List…", self._open_process_list, "Ctrl+P")) tools_menu.addSeparator() tools_menu.addAction(self._act("Import CSV / JSON…", self._open_import_dialog)) @@ -581,6 +584,18 @@ class MainWindow(QMainWindow): "MySQL · PostgreSQL · SQLite · SQL Server

" "Built with Python + PyQt6.") + def _open_preferences(self): + dlg = PreferencesDialog(parent=self) + dlg.settings_applied.connect(self._apply_settings_to_open_tabs) + dlg.exec() + + def _apply_settings_to_open_tabs(self): + """Push updated settings to all currently open workspace tabs.""" + for i in range(self._workspace.count()): + widget = self._workspace.widget(i) + if hasattr(widget, "apply_settings"): + widget.apply_settings() + def _show_shortcuts(self): dlg = ShortcutsDialog(parent=self) dlg.exec() diff --git a/app/ui/preferences_dialog.py b/app/ui/preferences_dialog.py new file mode 100644 index 0000000..b75b55e --- /dev/null +++ b/app/ui/preferences_dialog.py @@ -0,0 +1,133 @@ +"""Application Preferences dialog.""" +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QFormLayout, QGroupBox, + QComboBox, QSpinBox, QCheckBox, QDialogButtonBox, QLabel, +) +from PyQt6.QtCore import pyqtSignal + +from app.config.settings import get_settings + +_FONT_FAMILIES = ["Consolas", "JetBrains Mono", "Fira Code", "Cascadia Code", "Courier New"] +_PAGE_SIZES = [50, 100, 500, 1000, 0] # 0 → All +_PAGE_LABELS = ["50", "100", "500", "1 000", "All"] + + +class PreferencesDialog(QDialog): + settings_applied = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Preferences") + self.setMinimumWidth(400) + self.resize(420, 380) + self._build_ui() + self._load() + + def _build_ui(self): + root = QVBoxLayout(self) + root.setSpacing(12) + root.setContentsMargins(16, 16, 16, 16) + + # ── SQL Editor ──────────────────────────────────────────────────────── + editor_box = QGroupBox("SQL Editor") + ef = QFormLayout(editor_box) + ef.setSpacing(8) + + self._font_family = QComboBox() + self._font_family.addItems(_FONT_FAMILIES) + ef.addRow("Font family:", self._font_family) + + self._font_size = QSpinBox() + self._font_size.setRange(8, 28) + self._font_size.setSuffix(" pt") + ef.addRow("Font size:", self._font_size) + + self._word_wrap = QCheckBox("Word wrap") + ef.addRow("", self._word_wrap) + + root.addWidget(editor_box) + + # ── Results ─────────────────────────────────────────────────────────── + results_box = QGroupBox("Results") + rf = QFormLayout(results_box) + rf.setSpacing(8) + + self._page_size = QComboBox() + for lbl in _PAGE_LABELS: + self._page_size.addItem(lbl) + rf.addRow("Default page size:", self._page_size) + + self._timeout = QSpinBox() + self._timeout.setRange(5, 600) + self._timeout.setSuffix(" s") + rf.addRow("Query timeout:", self._timeout) + + root.addWidget(results_box) + + # ── General ─────────────────────────────────────────────────────────── + general_box = QGroupBox("General") + gf = QFormLayout(general_box) + gf.setSpacing(8) + + self._max_history = QSpinBox() + self._max_history.setRange(50, 10_000) + self._max_history.setSingleStep(100) + gf.addRow("Max history entries:", self._max_history) + + self._auto_commit = QCheckBox("Auto-commit queries") + gf.addRow("", self._auto_commit) + + root.addWidget(general_box) + + root.addStretch() + + # ── Buttons ─────────────────────────────────────────────────────────── + note = QLabel("Font changes take effect immediately in open editors.") + note.setObjectName("statusLabel") + root.addWidget(note) + + bb = QDialogButtonBox( + QDialogButtonBox.StandardButton.Cancel | + QDialogButtonBox.StandardButton.Apply | + QDialogButtonBox.StandardButton.Ok + ) + bb.rejected.connect(self.reject) + bb.button(QDialogButtonBox.StandardButton.Apply).clicked.connect(self._apply) + bb.accepted.connect(self._ok) + root.addWidget(bb) + + # ── Load / save ─────────────────────────────────────────────────────────── + + def _load(self): + s = get_settings() + + family = s.get("font_family", "Consolas") + idx = self._font_family.findText(family) + self._font_family.setCurrentIndex(max(idx, 0)) + + self._font_size.setValue(s.get("font_size", 13)) + self._word_wrap.setChecked(s.get("word_wrap", False)) + + page = s.get("result_page_size", 100) + pi = _PAGE_SIZES.index(page) if page in _PAGE_SIZES else 1 + self._page_size.setCurrentIndex(pi) + + self._timeout.setValue(s.get("query_timeout", 60)) + self._max_history.setValue(s.get("max_history", 500)) + self._auto_commit.setChecked(s.get("auto_commit", True)) + + def _apply(self): + s = get_settings() + s.set("font_family", self._font_family.currentText()) + s.set("font_size", self._font_size.value()) + s.set("word_wrap", self._word_wrap.isChecked()) + s.set("result_page_size", _PAGE_SIZES[self._page_size.currentIndex()]) + s.set("query_timeout", self._timeout.value()) + s.set("max_history", self._max_history.value()) + s.set("auto_commit", self._auto_commit.isChecked()) + s.save() + self.settings_applied.emit() + + def _ok(self): + self._apply() + self.accept() diff --git a/app/ui/sql_editor.py b/app/ui/sql_editor.py index 38fc54e..e95b721 100644 --- a/app/ui/sql_editor.py +++ b/app/ui/sql_editor.py @@ -18,6 +18,7 @@ from app.ui.syntax_highlighter import SQLHighlighter from app.ui.results_panel import ResultsPanel from app.ui.sql_completer import SqlCompleter from app.utils.worker import QueryWorker +from app.config.settings import get_settings # ── Line-number gutter ──────────────────────────────────────────────────────── @@ -42,11 +43,12 @@ class CodeEditor(QPlainTextEdit): self._line_area = LineNumberArea(self) self._completer: "SqlCompleter | None" = None - # Font - font = QFont("Consolas", 13) - font.setFixedPitch(True) - self.setFont(font) - self.setTabStopDistance(QFontMetrics(font).horizontalAdvance(" ") * 4) + self._apply_font() + self.setLineWrapMode( + QPlainTextEdit.LineWrapMode.WidgetWidth + if get_settings().get("word_wrap", False) + else QPlainTextEdit.LineWrapMode.NoWrap + ) # Connect signals self.blockCountChanged.connect(self._update_line_area_width) @@ -112,6 +114,22 @@ class CodeEditor(QPlainTextEdit): bottom = top + round(self.blockBoundingRect(block).height()) number += 1 + def _apply_font(self) -> None: + s = get_settings() + font = QFont(s.get("font_family", "Consolas"), s.get("font_size", 13)) + font.setFixedPitch(True) + self.setFont(font) + self.setTabStopDistance(QFontMetrics(font).horizontalAdvance(" ") * 4) + + def apply_settings(self) -> None: + """Re-read settings and apply font + word-wrap live.""" + self._apply_font() + self.setLineWrapMode( + QPlainTextEdit.LineWrapMode.WidgetWidth + if get_settings().get("word_wrap", False) + else QPlainTextEdit.LineWrapMode.NoWrap + ) + def set_completer(self, completer: "SqlCompleter") -> None: self._completer = completer @@ -430,6 +448,10 @@ class EditorTab(QWidget): self._explain_btn = QPushButton("🔎 Explain") self._explain_btn.clicked.connect(self._explain) + self._format_btn = QPushButton("≡ Format") + self._format_btn.setToolTip("Auto-format SQL (requires sqlparse)") + self._format_btn.clicked.connect(self._format_sql) + self._export_btn = QPushButton("📤 Export") self._export_btn.clicked.connect(self._export) @@ -439,6 +461,7 @@ class EditorTab(QWidget): toolbar.addWidget(self._run_btn) toolbar.addWidget(self._stop_btn) toolbar.addWidget(self._explain_btn) + toolbar.addWidget(self._format_btn) toolbar.addWidget(self._export_btn) toolbar.addStretch() toolbar.addWidget(self._db_label) @@ -523,6 +546,30 @@ class EditorTab(QWidget): def _export(self): self._results.export_dialog() + def _format_sql(self): + try: + import sqlparse + except ImportError: + QMessageBox.warning( + self, "Package Missing", + "SQL formatting requires sqlparse.\n\nInstall it with:\n pip install sqlparse", + ) + return + sql = self._editor.toPlainText().strip() + if not sql: + return + formatted = sqlparse.format( + sql, + reindent=True, + keyword_case="upper", + identifier_case="lower", + strip_comments=False, + use_space_around_operators=True, + ) + cursor = self._editor.textCursor() + cursor.select(QTextCursor.SelectionType.Document) + cursor.insertText(formatted) + def set_context(self, driver, database: str) -> None: """Update driver/database and refresh schema completions.""" self._driver = driver @@ -558,6 +605,9 @@ class EditorTab(QWidget): def get_sql(self) -> str: return self._editor.toPlainText() + def apply_settings(self) -> None: + self._editor.apply_settings() + # ── Tabbed SQL editor container ─────────────────────────────────────────────── @@ -651,6 +701,12 @@ class SQLEditorWidget(QWidget): w = self._tabs.currentWidget() return w if isinstance(w, EditorTab) else None + def apply_settings(self) -> None: + for i in range(self._tabs.count()): + tab = self._tabs.widget(i) + if isinstance(tab, EditorTab): + tab.apply_settings() + def open_sql_for(self, driver, database: str, sql: str = ""): tab = self.new_tab(driver, database, sql, title=f"SQL — {database}") diff --git a/app/ui/table_viewer.py b/app/ui/table_viewer.py index 2083101..858de93 100644 --- a/app/ui/table_viewer.py +++ b/app/ui/table_viewer.py @@ -21,6 +21,7 @@ from PyQt6.QtCore import ( from PyQt6.QtGui import QColor, QBrush, QFont, QKeySequence, QShortcut from app.utils.worker import TableDataWorker +from app.config.settings import get_settings # ───────────────────────────────────────────────────────────────────────────── @@ -310,7 +311,8 @@ class TableViewer(QWidget): self._table = table self._offset = 0 self._total = 0 - self._page_size = 100 # default + ps = get_settings().get("result_page_size", 100) + self._page_size = ps if ps > 0 else 10_000_000 # 0 → All self._model = EditableTableModel() self._worker: TableDataWorker | None = None self._build_ui() @@ -609,14 +611,22 @@ class TableViewer(QWidget): def _context_menu(self, pos): rows = self._selected_logical_rows() + idx = self._table_view.indexAt(pos) menu = QMenu(self) menu.addAction("➕ Add Row", self._add_row) if rows: menu.addAction("✏️ Edit Row", self._edit_selected) menu.addAction("🗑️ Delete Row", self._delete_selected) menu.addSeparator() + if idx.isValid(): + menu.addAction("🔍 Filter by this value", + lambda: self._filter_by_cell(idx)) + menu.addSeparator() menu.addAction("📋 Copy cell value", lambda: self._copy_cell(pos)) - menu.addAction("📋 Copy row", self._copy_selected_rows) + menu.addAction("📋 Copy row as TSV", self._copy_selected_rows) + if rows: + menu.addAction("📋 Copy row as SQL INSERT", + self._copy_selected_as_insert) menu.exec(self._table_view.viewport().mapToGlobal(pos)) # ── Double-click: edit ──────────────────────────────────────────────────── @@ -725,3 +735,39 @@ class TableViewer(QWidget): row_data = self._model.get_row_current(r) lines.append("\t".join("" if v is None else str(v) for v in row_data)) QApplication.clipboard().setText("\n".join(lines)) + + def _copy_selected_as_insert(self): + rows = self._selected_logical_rows() + cols = self._model.column_names() + col_list = ", ".join(f'"{c}"' for c in cols) + statements = [] + for r in rows: + row_data = self._model.get_row_current(r) + values = [] + for v in row_data: + if v is None: + values.append("NULL") + elif isinstance(v, (int, float)): + values.append(str(v)) + else: + escaped = str(v).replace("'", "''") + values.append(f"'{escaped}'") + statements.append( + f'INSERT INTO "{self._table}" ({col_list}) VALUES ({", ".join(values)});' + ) + QApplication.clipboard().setText("\n".join(statements)) + + def _filter_by_cell(self, index): + if not index.isValid(): + return + col_name = self._model.column_names()[index.column()] + val = self._model.data(index, Qt.ItemDataRole.DisplayRole) + if val is None: + clause = f'"{col_name}" IS NULL' + elif isinstance(val, str): + escaped = val.replace("'", "''") + clause = f'"{col_name}" = \'{escaped}\'' + else: + clause = f'"{col_name}" = {val}' + self._filter_input.setText(clause) + self._apply_filter() diff --git a/requirements.txt b/requirements.txt index 398a573..ae72981 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ pyodbc>=5.0.1 keyring>=24.3.1 cryptography>=42.0.0 bcrypt>=4.0.0 +sqlparse>=0.5.0 # Packaging (dev dependency — only needed when building the distributable) pyinstaller>=6.0.0