- 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>
291 lines
11 KiB
Python
291 lines
11 KiB
Python
"""
|
|
Results panel — shows query result data, DML messages, errors, and export controls.
|
|
|
|
Single query: one result shown directly (tab bar hidden).
|
|
Script with multiple statements: each result set shown in its own named tab.
|
|
"""
|
|
import io
|
|
import csv
|
|
import os
|
|
|
|
from PyQt6.QtWidgets import (
|
|
QWidget, QVBoxLayout, QHBoxLayout, QTableView, QLabel, QPushButton,
|
|
QHeaderView, QAbstractItemView, QFileDialog, QMessageBox, QStackedWidget,
|
|
QPlainTextEdit, QProgressBar, QTabWidget, QApplication, QMenu,
|
|
)
|
|
from PyQt6.QtCore import Qt, pyqtSignal, QSortFilterProxyModel
|
|
from PyQt6.QtGui import QColor, QFont
|
|
|
|
from app.models.result_table_model import ResultTableModel
|
|
|
|
|
|
class ResultsPanel(QWidget):
|
|
status_message = pyqtSignal(str)
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self._build_ui()
|
|
|
|
# ── UI ────────────────────────────────────────────────────────────────────
|
|
|
|
def _build_ui(self):
|
|
root = QVBoxLayout(self)
|
|
root.setContentsMargins(0, 0, 0, 0)
|
|
root.setSpacing(0)
|
|
|
|
# ── Status / toolbar ─────────────────────────────────────────────────
|
|
self._toolbar = QHBoxLayout()
|
|
self._toolbar.setContentsMargins(8, 4, 8, 4)
|
|
self._toolbar.setSpacing(8)
|
|
|
|
self._status_lbl = QLabel("Ready")
|
|
self._status_lbl.setObjectName("statusLabel")
|
|
|
|
self._export_csv = QPushButton("⬇ CSV")
|
|
self._export_json = QPushButton("⬇ JSON")
|
|
self._export_sql = QPushButton("⬇ SQL")
|
|
self._export_csv.setFixedWidth(72)
|
|
self._export_json.setFixedWidth(72)
|
|
self._export_sql.setFixedWidth(72)
|
|
self._export_csv.clicked.connect(self._do_export_csv)
|
|
self._export_json.clicked.connect(self._do_export_json)
|
|
self._export_sql.clicked.connect(self._do_export_sql)
|
|
|
|
self._toolbar.addWidget(self._status_lbl, 1)
|
|
self._toolbar.addWidget(self._export_csv)
|
|
self._toolbar.addWidget(self._export_json)
|
|
self._toolbar.addWidget(self._export_sql)
|
|
root.addLayout(self._toolbar)
|
|
|
|
# ── Stack: 0 = result tabs, 1 = loading ──────────────────────────────
|
|
self._stack = QStackedWidget()
|
|
|
|
self._result_tabs = QTabWidget()
|
|
self._result_tabs.tabBar().setVisible(False)
|
|
self._stack.addWidget(self._result_tabs) # idx 0
|
|
|
|
loading = QWidget()
|
|
ll = QVBoxLayout(loading)
|
|
ll.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
bar = QProgressBar()
|
|
bar.setMaximum(0)
|
|
bar.setFixedWidth(200)
|
|
ll.addWidget(QLabel("Executing query…"))
|
|
ll.addWidget(bar)
|
|
self._stack.addWidget(loading) # idx 1
|
|
|
|
root.addWidget(self._stack, 1)
|
|
self._set_export_visible(False)
|
|
|
|
# ── Tab helpers ───────────────────────────────────────────────────────────
|
|
|
|
def _clear_tabs(self):
|
|
while self._result_tabs.count():
|
|
self._result_tabs.removeTab(0)
|
|
|
|
def _make_table_tab(self, cols: list, rows: list) -> tuple:
|
|
model = ResultTableModel()
|
|
model.set_data(cols, rows)
|
|
proxy = QSortFilterProxyModel()
|
|
proxy.setSourceModel(model)
|
|
|
|
table = QTableView()
|
|
table.setModel(proxy)
|
|
table.setSortingEnabled(True)
|
|
table.setAlternatingRowColors(True)
|
|
table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectItems)
|
|
table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
|
table.horizontalHeader().setSectionResizeMode(
|
|
QHeaderView.ResizeMode.Interactive)
|
|
table.horizontalHeader().setStretchLastSection(True)
|
|
table.verticalHeader().setDefaultSectionSize(24)
|
|
table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
table.customContextMenuRequested.connect(
|
|
lambda pos, t=table, p=proxy, m=model:
|
|
self._table_context_menu(pos, t, p, m)
|
|
)
|
|
return table, model, proxy
|
|
|
|
def _make_message_tab(self, msg: str, is_error: bool = False) -> QPlainTextEdit:
|
|
view = QPlainTextEdit()
|
|
view.setReadOnly(True)
|
|
view.setFont(QFont("Consolas", 11))
|
|
view.setPlainText(msg)
|
|
if is_error:
|
|
view.setStyleSheet("color: #e78284;")
|
|
return view
|
|
|
|
def _current_model(self) -> ResultTableModel | None:
|
|
widget = self._result_tabs.currentWidget()
|
|
if isinstance(widget, QTableView):
|
|
proxy = widget.model()
|
|
if isinstance(proxy, QSortFilterProxyModel):
|
|
return proxy.sourceModel()
|
|
return None
|
|
|
|
def _auto_resize(self, table: QTableView):
|
|
table.resizeColumnsToContents()
|
|
h = table.horizontalHeader()
|
|
for i in range(h.count()):
|
|
if h.sectionSize(i) > 300:
|
|
h.resizeSection(i, 300)
|
|
|
|
# ── Public API ────────────────────────────────────────────────────────────
|
|
|
|
def show_loading(self):
|
|
self._clear_tabs()
|
|
self._stack.setCurrentIndex(1)
|
|
self._status_lbl.setText("Running…")
|
|
self._set_export_visible(False)
|
|
|
|
def show_data(self, cols: list, rows: list, count: int, elapsed: float):
|
|
table, model, _ = self._make_table_tab(cols, rows)
|
|
self._clear_tabs()
|
|
self._result_tabs.addTab(table, "Result")
|
|
self._result_tabs.tabBar().setVisible(False)
|
|
self._stack.setCurrentIndex(0)
|
|
|
|
t = f" {elapsed:.3f}s" if elapsed else ""
|
|
self._status_lbl.setText(f"{count:,} row(s){t}")
|
|
self.status_message.emit(f"Fetched {count:,} rows{t}")
|
|
self._set_export_visible(bool(cols))
|
|
self._auto_resize(table)
|
|
|
|
def show_message(self, msg: str):
|
|
view = self._make_message_tab(msg)
|
|
self._clear_tabs()
|
|
self._result_tabs.addTab(view, "Message")
|
|
self._result_tabs.tabBar().setVisible(False)
|
|
self._stack.setCurrentIndex(0)
|
|
self._status_lbl.setText(msg[:100])
|
|
self._set_export_visible(False)
|
|
|
|
def show_error(self, msg: str):
|
|
view = self._make_message_tab(f"❌ {msg}", is_error=True)
|
|
self._clear_tabs()
|
|
self._result_tabs.addTab(view, "Error")
|
|
self._result_tabs.tabBar().setVisible(False)
|
|
self._stack.setCurrentIndex(0)
|
|
short = msg[:80]
|
|
self._status_lbl.setText(f"Error: {short}")
|
|
self.status_message.emit(f"Error: {short}")
|
|
self._set_export_visible(False)
|
|
|
|
def show_script_results(self, results: list):
|
|
"""
|
|
Show each statement's result in a separate tab.
|
|
results: list of (cols, rows, count, message_str)
|
|
"""
|
|
self._clear_tabs()
|
|
last_table_idx = -1
|
|
has_data = False
|
|
|
|
for i, (cols, rows, cnt, msg) in enumerate(results):
|
|
if cols:
|
|
table, _, _ = self._make_table_tab(cols, rows)
|
|
label = f"Result {i + 1} ({cnt:,})"
|
|
self._result_tabs.addTab(table, label)
|
|
last_table_idx = self._result_tabs.count() - 1
|
|
has_data = True
|
|
self._auto_resize(table)
|
|
else:
|
|
view = self._make_message_tab(msg or f"Statement {i + 1}: OK")
|
|
label = f"Step {i + 1}"
|
|
self._result_tabs.addTab(view, label)
|
|
|
|
multi = self._result_tabs.count() > 1
|
|
self._result_tabs.tabBar().setVisible(multi)
|
|
if last_table_idx >= 0:
|
|
self._result_tabs.setCurrentIndex(last_table_idx)
|
|
|
|
self._stack.setCurrentIndex(0)
|
|
|
|
n_results = sum(1 for c, *_ in results if c)
|
|
total_rows = sum(cnt for c, _, cnt, _ in results if c)
|
|
self._status_lbl.setText(
|
|
f"{len(results)} statement(s) — "
|
|
f"{n_results} result set(s), {total_rows:,} total row(s)"
|
|
)
|
|
self.status_message.emit(
|
|
f"{len(results)} statements executed"
|
|
)
|
|
self._set_export_visible(has_data)
|
|
|
|
def export_dialog(self):
|
|
self._do_export_csv()
|
|
|
|
# ── Private helpers ───────────────────────────────────────────────────────
|
|
|
|
def _set_export_visible(self, v: bool):
|
|
self._export_csv.setVisible(v)
|
|
self._export_json.setVisible(v)
|
|
self._export_sql.setVisible(v)
|
|
|
|
def _do_export_csv(self):
|
|
model = self._current_model()
|
|
if not model:
|
|
return
|
|
path, _ = QFileDialog.getSaveFileName(
|
|
self, "Export CSV", "results.csv", "CSV Files (*.csv)")
|
|
if path:
|
|
try:
|
|
model.export_csv(path)
|
|
QMessageBox.information(self, "Exported", f"Saved to {path}")
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "Error", str(e))
|
|
|
|
def _do_export_json(self):
|
|
model = self._current_model()
|
|
if not model:
|
|
return
|
|
path, _ = QFileDialog.getSaveFileName(
|
|
self, "Export JSON", "results.json", "JSON Files (*.json)")
|
|
if path:
|
|
try:
|
|
model.export_json(path)
|
|
QMessageBox.information(self, "Exported", f"Saved to {path}")
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "Error", str(e))
|
|
|
|
def _do_export_sql(self):
|
|
model = self._current_model()
|
|
if not model:
|
|
return
|
|
path, _ = QFileDialog.getSaveFileName(
|
|
self, "Export SQL", "results.sql", "SQL Files (*.sql)")
|
|
if path:
|
|
try:
|
|
model.export_sql(path)
|
|
QMessageBox.information(self, "Exported", f"Saved to {path}")
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "Error", str(e))
|
|
|
|
def _table_context_menu(self, pos, table: QTableView,
|
|
proxy: QSortFilterProxyModel,
|
|
model: ResultTableModel):
|
|
idx = table.indexAt(pos)
|
|
if not idx.isValid():
|
|
return
|
|
menu = QMenu(self)
|
|
menu.addAction("📋 Copy cell", lambda: self._copy_cell(idx, proxy))
|
|
menu.addAction("📋 Copy row", lambda: self._copy_row(idx, proxy, model))
|
|
menu.addAction("📋 Copy all", lambda: self._copy_all(model))
|
|
menu.exec(table.viewport().mapToGlobal(pos))
|
|
|
|
def _copy_cell(self, idx, proxy):
|
|
val = proxy.data(idx, Qt.ItemDataRole.DisplayRole) or ""
|
|
QApplication.clipboard().setText(str(val))
|
|
|
|
def _copy_row(self, idx, proxy, model):
|
|
row = proxy.mapToSource(idx).row()
|
|
vals = [str(v or "") for v in model.get_row(row)]
|
|
QApplication.clipboard().setText("\t".join(vals))
|
|
|
|
def _copy_all(self, model):
|
|
buf = io.StringIO()
|
|
writer = csv.writer(buf)
|
|
writer.writerow(model.column_names())
|
|
for r in range(model.rowCount()):
|
|
writer.writerow(model.get_row(r))
|
|
QApplication.clipboard().setText(buf.getvalue())
|