716 lines
28 KiB
Python
716 lines
28 KiB
Python
"""
|
||
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()
|
||
self._highlighter = 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):
|
||
if not self._driver:
|
||
self._results.show_error("No active connection. Connect to a database first.")
|
||
return
|
||
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()
|
||
self._highlighter.update_theme()
|
||
self._completer.update_theme()
|
||
|
||
|
||
# ── 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)
|