Files
DBClient/app/ui/sql_editor.py
T
nngoandClaude Sonnet 4.6 9f0891755e feat: multi-result tabs, column statistics, and Ctrl+W close tab
- ResultsPanel rewritten: single queries show one result as before;
  scripts with multiple statements show each result in a named sub-tab
  (e.g. "Result 2 (1,234)") with the tab bar auto-shown/hidden
- EditorTab._on_script_done now calls show_script_results() instead of
  overwriting the panel on each statement
- ColumnStatsDialog: right-click any column header in TableViewer to see
  total rows, null count, distinct values, min, max, and avg (async,
  gracefully skips avg for non-numeric columns)
- TableViewer header context menu also adds "Resize to fit" shortcuts
- Ctrl+W closes the current workspace tab (View menu)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 17:10:35 -04:00

711 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Multi-tab SQL editor with syntax highlighting, line numbers, and run controls.
"""
import os
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPlainTextEdit, QTextEdit,
QTabWidget, QPushButton, QLabel, QSplitter, QTabBar, QSizePolicy,
QFileDialog, QMessageBox, QToolButton, QComboBox, QLineEdit, QCheckBox,
QMenu, QInputDialog,
)
from PyQt6.QtCore import Qt, QRect, QSize, pyqtSignal, QTimer
from PyQt6.QtGui import (
QColor, QPainter, QTextDocument, QTextFormat, QFont, QKeySequence,
QShortcut, QFontMetrics, QTextCursor,
)
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 ────────────────────────────────────────────────────────
class LineNumberArea(QWidget):
def __init__(self, editor):
super().__init__(editor)
self._editor = editor
def sizeHint(self) -> QSize:
return QSize(self._editor.line_number_area_width(), 0)
def paintEvent(self, event):
self._editor.line_number_area_paint_event(event)
class CodeEditor(QPlainTextEdit):
"""QPlainTextEdit with line numbers, current-line highlight, tab→spaces, and auto-complete."""
def __init__(self, parent=None):
super().__init__(parent)
self._line_area = LineNumberArea(self)
self._completer: "SqlCompleter | None" = None
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)
self.updateRequest.connect(self._update_line_area)
self.cursorPositionChanged.connect(self._highlight_current_line)
self._update_line_area_width(0)
self._highlight_current_line()
def line_number_area_width(self) -> int:
digits = max(3, len(str(self.blockCount())))
return 12 + self.fontMetrics().horizontalAdvance("9") * digits
def _update_line_area_width(self, _):
self.setViewportMargins(self.line_number_area_width(), 0, 0, 0)
def _update_line_area(self, rect, dy):
if dy:
self._line_area.scroll(0, dy)
else:
self._line_area.update(0, rect.y(), self._line_area.width(), rect.height())
if rect.contains(self.viewport().rect()):
self._update_line_area_width(0)
def resizeEvent(self, event):
super().resizeEvent(event)
cr = self.contentsRect()
self._line_area.setGeometry(
QRect(cr.left(), cr.top(), self.line_number_area_width(), cr.height())
)
def _highlight_current_line(self):
extra = []
if not self.isReadOnly():
sel = QTextEdit.ExtraSelection()
sel.format.setBackground(QColor("#3d4155"))
sel.format.setProperty(QTextFormat.Property.FullWidthSelection, True)
sel.cursor = self.textCursor()
sel.cursor.clearSelection()
extra.append(sel)
self.setExtraSelections(extra)
def line_number_area_paint_event(self, event):
painter = QPainter(self._line_area)
painter.fillRect(event.rect(), QColor("#2a2d3e"))
block = self.firstVisibleBlock()
number = block.blockNumber()
top = round(self.blockBoundingGeometry(block).translated(
self.contentOffset()).top())
bottom = top + round(self.blockBoundingRect(block).height())
while block.isValid() and top <= event.rect().bottom():
if block.isVisible() and bottom >= event.rect().top():
painter.setPen(QColor("#51576d"))
painter.drawText(
0, top, self._line_area.width() - 6,
self.fontMetrics().height(),
Qt.AlignmentFlag.AlignRight, str(number + 1)
)
block = block.next()
top = bottom
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
def _popup_visible(self) -> bool:
return (self._completer is not None
and self._completer._popup.isVisible())
def focusOutEvent(self, event):
super().focusOutEvent(event)
if self._completer:
self._completer._popup.hide_popup()
def keyPressEvent(self, event):
# ── Popup navigation (highest priority) ──────────────────────────────
if self._popup_visible():
key = event.key()
if key == Qt.Key.Key_Escape:
self._completer._popup.hide_popup()
return
if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Tab):
self._completer._accept_completion(
self._completer._popup.current_text()
)
return
if key == Qt.Key.Key_Up:
self._completer._popup.move_selection(-1)
return
if key == Qt.Key.Key_Down:
self._completer._popup.move_selection(1)
return
# ── Ctrl+Space → force-show completions ──────────────────────────────
if (event.modifiers() == Qt.KeyboardModifier.ControlModifier
and event.key() == Qt.Key.Key_Space):
if self._completer:
self._completer.trigger_completion(force=True)
return
# ── Tab → 4 spaces (only when popup is closed) ───────────────────────
if event.key() == Qt.Key.Key_Tab:
self.textCursor().insertText(" ")
return
# ── Ctrl+/ → toggle comment ───────────────────────────────────────────
if (event.modifiers() == Qt.KeyboardModifier.ControlModifier
and event.key() == Qt.Key.Key_Slash):
self._toggle_comment()
return
super().keyPressEvent(event)
# ── Auto-trigger completions after every regular keystroke ────────────
if self._completer:
self._completer.trigger_completion(force=False)
def _toggle_comment(self):
cursor = self.textCursor()
start = cursor.selectionStart()
end = cursor.selectionEnd()
cursor.setPosition(start)
cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock)
cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock,
QTextCursor.MoveMode.KeepAnchor)
text = cursor.selectedText()
lines = text.split("\u2029") # Qt paragraph separator
if all(l.lstrip().startswith("--") for l in lines if l.strip()):
new = [l.replace("--", "", 1) if l.lstrip().startswith("--") else l
for l in lines]
else:
new = ["--" + l for l in lines]
cursor.insertText("\u2029".join(new))
def selected_or_all(self) -> str:
cursor = self.textCursor()
text = cursor.selectedText().replace("\u2029", "\n")
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 = "#e78284" 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):
status_message = pyqtSignal(str)
def __init__(self, driver, database: str = "", parent=None):
super().__init__(parent)
self._driver = driver
self._database = database
self._worker: QueryWorker | None = None
self._filepath: str | None = None
self._build_ui()
def _build_ui(self):
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
# ── Editor toolbar ─────────────────────────────────────────────────────
toolbar = QHBoxLayout()
toolbar.setContentsMargins(6, 4, 6, 4)
toolbar.setSpacing(4)
self._run_btn = QPushButton("▶ Run F5")
self._run_btn.setObjectName("runBtn")
self._run_btn.clicked.connect(self._run)
self._stop_btn = QPushButton("⏹ Stop")
self._stop_btn.setObjectName("stopBtn")
self._stop_btn.setEnabled(False)
self._stop_btn.clicked.connect(self._stop)
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)
self._db_label = QLabel(f"DB: {self._database}" if self._database else "")
self._db_label.setObjectName("dbLabel")
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)
# ── Splitter: editor / results ──────────────────────────────────────
self._splitter = QSplitter(Qt.Orientation.Vertical)
self._splitter.setHandleWidth(3)
self._editor = CodeEditor()
SQLHighlighter(self._editor.document())
self._completer = SqlCompleter(self._editor, parent=self)
self._editor.set_completer(self._completer)
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)
self._splitter.addWidget(self._editor)
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 ─────────────────────────────────────────────────────────────
def _run(self):
sql = self._editor.selected_or_all().strip()
if not sql:
return
self._results.show_loading()
self._run_btn.setEnabled(False)
self._stop_btn.setEnabled(True)
is_script = ";" in sql[:-1] # multiple statements
self._worker = QueryWorker(self._driver, sql, is_script=is_script)
self._worker.finished.connect(self._on_result)
self._worker.script_done.connect(self._on_script_done)
self._worker.error.connect(self._on_error)
self._worker.finished.connect(lambda *_: self._reset_buttons())
self._worker.script_done.connect(lambda *_: self._reset_buttons())
self._worker.error.connect(lambda *_: self._reset_buttons())
self._worker.start()
def _stop(self):
if self._worker and self._worker.isRunning():
self._worker.terminate()
self._reset_buttons()
def _reset_buttons(self):
self._run_btn.setEnabled(True)
self._stop_btn.setEnabled(False)
def _explain(self):
sql = self._editor.selected_or_all().strip()
if not sql:
return
# Prefer opening a full ExplainPanel in the main window workspace
from app.main_window import MainWindow
win = self.window()
if isinstance(win, MainWindow):
win.open_explain_tab(self._driver, self._database, sql)
else:
# Fallback: show raw EXPLAIN in the inline results panel
try:
cols, rows = self._driver.explain_query(sql)
self._results.show_data(cols, rows, len(rows), 0)
except Exception as e:
self._results.show_error(str(e))
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
self._database = database
self._db_label.setText(f"DB: {database}" if database else "")
self._completer.set_context(driver, database)
def _on_result(self, cols, rows, cnt, elapsed):
self._results.show_data(cols, rows, cnt, elapsed)
self._maybe_invalidate_schema(self._editor.toPlainText())
def _on_script_done(self, results: list):
self._results.show_script_results(results)
self._maybe_invalidate_schema(self._editor.toPlainText())
def _on_error(self, msg: str):
self._results.show_error(msg)
def _maybe_invalidate_schema(self, sql: str) -> None:
if any(sql.lstrip().lower().startswith(kw)
for kw in ("create", "drop", "alter", "rename")):
self._completer.invalidate()
# ── Public ─────────────────────────────────────────────────────────────────
def set_sql(self, sql: str):
self._editor.setPlainText(sql)
def get_sql(self) -> str:
return self._editor.toPlainText()
def apply_settings(self) -> None:
self._editor.apply_settings()
# ── Tabbed SQL editor container ───────────────────────────────────────────────
class SQLEditorWidget(QWidget):
status_message = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._build_ui()
def _build_ui(self):
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
self._tabs = QTabWidget()
self._tabs.setTabsClosable(True)
self._tabs.setMovable(True)
self._tabs.tabCloseRequested.connect(self._close_tab)
# New tab button
new_btn = QToolButton()
new_btn.setText("")
new_btn.setToolTip("New SQL Tab")
new_btn.clicked.connect(lambda: self.new_tab())
self._tabs.setCornerWidget(new_btn, Qt.Corner.TopRightCorner)
# Right-click context menu on query tab bar
self._tabs.tabBar().setContextMenuPolicy(
Qt.ContextMenuPolicy.CustomContextMenu
)
self._tabs.tabBar().customContextMenuRequested.connect(
self._tab_context_menu
)
root.addWidget(self._tabs)
def new_tab(self, driver=None, database: str = "",
sql: str = "", title: str = None) -> EditorTab:
tab = EditorTab(driver, database)
tab.status_message.connect(self.status_message)
if sql:
tab.set_sql(sql)
label = title or (f"Query — {database}" if database else "Query")
idx = self._tabs.addTab(tab, label)
self._tabs.setCurrentIndex(idx)
return tab
def _close_tab(self, idx: int):
if self._tabs.count() > 1:
self._tabs.removeTab(idx)
def _tab_context_menu(self, pos) -> None:
idx = self._tabs.tabBar().tabAt(pos)
if idx < 0:
return
menu = QMenu(self)
rename_act = menu.addAction("Rename…")
dup_act = menu.addAction("Duplicate")
menu.addSeparator()
close_act = menu.addAction("Close")
close_others = menu.addAction("Close Others")
close_act.setEnabled(self._tabs.count() > 1)
close_others.setEnabled(self._tabs.count() > 1)
act = menu.exec(self._tabs.tabBar().mapToGlobal(pos))
if act == rename_act:
text, ok = QInputDialog.getText(
self, "Rename Tab", "Tab name:",
text=self._tabs.tabText(idx),
)
if ok and text.strip():
self._tabs.setTabText(idx, text.strip())
elif act == dup_act:
tab = self._tabs.widget(idx)
if isinstance(tab, EditorTab):
new = self.new_tab(
tab._driver, tab._database,
sql=tab.get_sql(),
title=self._tabs.tabText(idx) + " (copy)",
)
new._filepath = tab._filepath
elif act == close_act:
self._close_tab(idx)
elif act == close_others:
for i in range(self._tabs.count() - 1, -1, -1):
if i != idx:
self._close_tab(i)
def current_tab(self) -> EditorTab | None:
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}")
if sql:
tab.set_sql(sql)