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:
@@ -0,0 +1,134 @@
|
||||
"""Column statistics dialog — shows row count, nulls, distinct, min, max, avg."""
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QFormLayout, QLabel, QDialogButtonBox,
|
||||
QGroupBox, QProgressBar, QSizePolicy,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal
|
||||
from PyQt6.QtGui import QFont
|
||||
|
||||
|
||||
class _StatsWorker(QThread):
|
||||
result = pyqtSignal(dict)
|
||||
error = pyqtSignal(str)
|
||||
|
||||
def __init__(self, driver, database: str, table: str, column: str):
|
||||
super().__init__()
|
||||
self._driver = driver
|
||||
self._database = database
|
||||
self._table = table
|
||||
self._column = column
|
||||
|
||||
def run(self):
|
||||
col = self._column
|
||||
tbl = self._table
|
||||
try:
|
||||
_, rows, _ = self._driver.execute_query(
|
||||
f'SELECT COUNT(*), COUNT("{col}"), COUNT(DISTINCT "{col}"), '
|
||||
f'MIN("{col}"), MAX("{col}") FROM "{tbl}"'
|
||||
)
|
||||
total, non_null, distinct, min_val, max_val = rows[0]
|
||||
null_count = (total or 0) - (non_null or 0)
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
return
|
||||
|
||||
avg_val = None
|
||||
try:
|
||||
_, avg_rows, _ = self._driver.execute_query(
|
||||
f'SELECT AVG("{col}") FROM "{tbl}"'
|
||||
)
|
||||
avg_val = avg_rows[0][0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.result.emit({
|
||||
"total": total,
|
||||
"non_null": non_null,
|
||||
"nulls": null_count,
|
||||
"distinct": distinct,
|
||||
"min": min_val,
|
||||
"max": max_val,
|
||||
"avg": avg_val,
|
||||
})
|
||||
|
||||
|
||||
class ColumnStatsDialog(QDialog):
|
||||
def __init__(self, driver, database: str, table: str, column: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(f"Column Statistics — {column}")
|
||||
self.setMinimumWidth(360)
|
||||
self._driver = driver
|
||||
self._database = database
|
||||
self._table = table
|
||||
self._column = column
|
||||
self._build_ui()
|
||||
self._load()
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setSpacing(10)
|
||||
root.setContentsMargins(16, 16, 16, 16)
|
||||
|
||||
subtitle = QLabel(f"<b>{self._table}</b>.<i>{self._column}</i>")
|
||||
subtitle.setTextFormat(Qt.TextFormat.RichText)
|
||||
root.addWidget(subtitle)
|
||||
|
||||
# ── Stats group ───────────────────────────────────────────────────────
|
||||
box = QGroupBox("Statistics")
|
||||
self._form = QFormLayout(box)
|
||||
self._form.setSpacing(6)
|
||||
self._rows: dict[str, QLabel] = {}
|
||||
for key, label in [
|
||||
("total", "Total rows"),
|
||||
("non_null", "Non-null"),
|
||||
("nulls", "Null count"),
|
||||
("distinct", "Distinct values"),
|
||||
("min", "Min"),
|
||||
("max", "Max"),
|
||||
("avg", "Avg (numeric)"),
|
||||
]:
|
||||
lbl = QLabel("…")
|
||||
lbl.setFont(QFont("Consolas", 10))
|
||||
self._form.addRow(label + ":", lbl)
|
||||
self._rows[key] = lbl
|
||||
root.addWidget(box)
|
||||
|
||||
# ── Loading bar ───────────────────────────────────────────────────────
|
||||
self._progress = QProgressBar()
|
||||
self._progress.setMaximum(0)
|
||||
self._progress.setFixedHeight(6)
|
||||
self._progress.setTextVisible(False)
|
||||
root.addWidget(self._progress)
|
||||
|
||||
bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
||||
bb.rejected.connect(self.reject)
|
||||
root.addWidget(bb)
|
||||
|
||||
def _load(self):
|
||||
self._worker = _StatsWorker(
|
||||
self._driver, self._database, self._table, self._column
|
||||
)
|
||||
self._worker.result.connect(self._on_result)
|
||||
self._worker.error.connect(self._on_error)
|
||||
self._worker.finished.connect(self._progress.hide)
|
||||
self._worker.start()
|
||||
|
||||
def _on_result(self, stats: dict):
|
||||
for key, lbl in self._rows.items():
|
||||
val = stats.get(key)
|
||||
if val is None:
|
||||
text = "N/A"
|
||||
elif isinstance(val, float):
|
||||
text = f"{val:,.4f}"
|
||||
else:
|
||||
try:
|
||||
text = f"{int(val):,}"
|
||||
except (TypeError, ValueError):
|
||||
text = str(val)
|
||||
lbl.setText(text)
|
||||
|
||||
def _on_error(self, msg: str):
|
||||
for lbl in self._rows.values():
|
||||
lbl.setText("—")
|
||||
self._rows["total"].setText(f"Error: {msg[:60]}")
|
||||
self._rows["total"].setStyleSheet("color: #e78284;")
|
||||
Reference in New Issue
Block a user