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>
This commit is contained in:
+159
-76
@@ -1,11 +1,17 @@
|
||||
"""
|
||||
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,
|
||||
QPlainTextEdit, QProgressBar, QTabWidget, QApplication, QMenu,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QSortFilterProxyModel
|
||||
from PyQt6.QtGui import QColor, QFont
|
||||
@@ -18,7 +24,6 @@ class ResultsPanel(QWidget):
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._model = ResultTableModel()
|
||||
self._build_ui()
|
||||
|
||||
# ── UI ────────────────────────────────────────────────────────────────────
|
||||
@@ -52,35 +57,13 @@ class ResultsPanel(QWidget):
|
||||
self._toolbar.addWidget(self._export_sql)
|
||||
root.addLayout(self._toolbar)
|
||||
|
||||
# ── Stacked pages ─────────────────────────────────────────────────────
|
||||
# ── Stack: 0 = result tabs, 1 = loading ──────────────────────────────
|
||||
self._stack = QStackedWidget()
|
||||
|
||||
# Page 0 — table
|
||||
self._table = QTableView()
|
||||
self._proxy = QSortFilterProxyModel()
|
||||
self._proxy.setSourceModel(self._model)
|
||||
self._table.setModel(self._proxy)
|
||||
self._table.setSortingEnabled(True)
|
||||
self._table.setAlternatingRowColors(True)
|
||||
self._table.setSelectionBehavior(
|
||||
QAbstractItemView.SelectionBehavior.SelectItems)
|
||||
self._table.setSelectionMode(
|
||||
QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
self._table.horizontalHeader().setSectionResizeMode(
|
||||
QHeaderView.ResizeMode.Interactive)
|
||||
self._table.horizontalHeader().setStretchLastSection(True)
|
||||
self._table.verticalHeader().setDefaultSectionSize(24)
|
||||
self._table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self._table.customContextMenuRequested.connect(self._table_context_menu)
|
||||
self._stack.addWidget(self._table) # idx 0
|
||||
self._result_tabs = QTabWidget()
|
||||
self._result_tabs.tabBar().setVisible(False)
|
||||
self._stack.addWidget(self._result_tabs) # idx 0
|
||||
|
||||
# Page 1 — message / log
|
||||
self._msg_view = QPlainTextEdit()
|
||||
self._msg_view.setReadOnly(True)
|
||||
self._msg_view.setFont(QFont("Consolas", 11))
|
||||
self._stack.addWidget(self._msg_view) # idx 1
|
||||
|
||||
# Page 2 — loading spinner
|
||||
loading = QWidget()
|
||||
ll = QVBoxLayout(loading)
|
||||
ll.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
@@ -89,41 +72,145 @@ class ResultsPanel(QWidget):
|
||||
bar.setFixedWidth(200)
|
||||
ll.addWidget(QLabel("Executing query…"))
|
||||
ll.addWidget(bar)
|
||||
self._stack.addWidget(loading) # idx 2
|
||||
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._model.clear()
|
||||
self._stack.setCurrentIndex(2)
|
||||
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):
|
||||
self._model.set_data(cols, rows)
|
||||
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}")
|
||||
|
||||
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()
|
||||
self._auto_resize(table)
|
||||
|
||||
def show_message(self, msg: str):
|
||||
self._msg_view.appendPlainText(msg)
|
||||
self._stack.setCurrentIndex(1)
|
||||
self._status_lbl.setText(msg)
|
||||
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):
|
||||
self._msg_view.setPlainText(f"❌ {msg}")
|
||||
self._stack.setCurrentIndex(1)
|
||||
self._status_lbl.setText(f"Error: {msg[:80]}")
|
||||
self.status_message.emit(f"Error: {msg[:80]}")
|
||||
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()
|
||||
|
||||
@@ -134,74 +221,70 @@ class ResultsPanel(QWidget):
|
||||
self._export_json.setVisible(v)
|
||||
self._export_sql.setVisible(v)
|
||||
|
||||
def _auto_resize(self):
|
||||
header = self._table.horizontalHeader()
|
||||
for i in range(self._model.columnCount()):
|
||||
header.resizeSection(
|
||||
i, min(self._table.columnWidth(i) + 20, 300)
|
||||
)
|
||||
self._table.resizeColumnsToContents()
|
||||
|
||||
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:
|
||||
self._model.export_csv(path)
|
||||
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:
|
||||
self._model.export_json(path)
|
||||
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:
|
||||
self._model.export_sql(path)
|
||||
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):
|
||||
from PyQt6.QtWidgets import QMenu
|
||||
from PyQt6.QtGui import QClipboard
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
idx = self._table.indexAt(pos)
|
||||
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))
|
||||
menu.addAction("📋 Copy row", lambda: self._copy_row(idx))
|
||||
menu.addAction("📋 Copy all", lambda: self._copy_all())
|
||||
menu.exec(self._table.viewport().mapToGlobal(pos))
|
||||
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):
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
val = self._proxy.data(idx, Qt.ItemDataRole.DisplayRole) or ""
|
||||
def _copy_cell(self, idx, proxy):
|
||||
val = proxy.data(idx, Qt.ItemDataRole.DisplayRole) or ""
|
||||
QApplication.clipboard().setText(str(val))
|
||||
|
||||
def _copy_row(self, idx):
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
row = self._proxy.mapToSource(idx).row()
|
||||
vals = [str(v or "") for v in self._model.get_row(row)]
|
||||
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):
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
import csv, io
|
||||
def _copy_all(self, model):
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(self._model.column_names())
|
||||
for r in range(self._model.rowCount()):
|
||||
writer.writerow(self._model.get_row(r))
|
||||
writer.writerow(model.column_names())
|
||||
for r in range(model.rowCount()):
|
||||
writer.writerow(model.get_row(r))
|
||||
QApplication.clipboard().setText(buf.getvalue())
|
||||
|
||||
Reference in New Issue
Block a user