Files
DBClient/app/ui/sql_editor.py
T
2026-05-21 15:46:41 -04:00

341 lines
13 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,
)
from PyQt6.QtCore import Qt, QRect, QSize, pyqtSignal, QTimer
from PyQt6.QtGui import (
QColor, QPainter, QTextFormat, QFont, QKeySequence, QShortcut,
QFontMetrics, QTextCursor,
)
from app.ui.syntax_highlighter import SQLHighlighter
from app.ui.results_panel import ResultsPanel
from app.utils.worker import QueryWorker
# ── 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, and tab→spaces."""
def __init__(self, parent=None):
super().__init__(parent)
self._line_area = LineNumberArea(self)
# Font
font = QFont("Consolas", 13)
font.setFixedPitch(True)
self.setFont(font)
self.setTabStopDistance(QFontMetrics(font).horizontalAdvance(" ") * 4)
# 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("#2a2a3c"))
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("#1a1a2e"))
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("#45475a"))
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 keyPressEvent(self, event):
# Tab → 4 spaces
if event.key() == Qt.Key.Key_Tab:
cursor = self.textCursor()
cursor.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)
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()
# ── 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._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._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._export_btn)
toolbar.addStretch()
toolbar.addWidget(self._db_label)
root.addLayout(toolbar)
# ── Splitter: editor / results ──────────────────────────────────────
self._splitter = QSplitter(Qt.Orientation.Vertical)
self._splitter.setHandleWidth(3)
self._editor = CodeEditor()
SQLHighlighter(self._editor.document())
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.addWidget(self._splitter, 1)
# ── Shortcuts ──────────────────────────────────────────────────────
QShortcut(QKeySequence("F5"), self, self._run)
QShortcut(QKeySequence("Ctrl+Return"), self, self._run)
# ── 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 _on_result(self, cols, rows, cnt, elapsed):
self._results.show_data(cols, rows, cnt, elapsed)
def _on_script_done(self, results: list):
# Show the last SELECT result; messages for DML
for cols, rows, cnt, msg in results:
if cols:
self._results.show_data(cols, rows, cnt, 0)
else:
self._results.show_message(msg)
def _on_error(self, msg: str):
self._results.show_error(msg)
# ── Public ─────────────────────────────────────────────────────────────────
def set_sql(self, sql: str):
self._editor.setPlainText(sql)
def get_sql(self) -> str:
return self._editor.toPlainText()
# ── 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)
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 current_tab(self) -> EditorTab | None:
w = self._tabs.currentWidget()
return w if isinstance(w, EditorTab) else None
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)