feat: preferences dialog, SQL formatter, and enhanced table context menu
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@ 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.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<br><br>"
|
||||
"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()
|
||||
|
||||
@@ -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()
|
||||
+61
-5
@@ -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}")
|
||||
|
||||
+48
-2
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user