Files
DBClient/app/ui/results_panel.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

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())