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