diff --git a/app/main_window.py b/app/main_window.py
index b3615e2..b2237ff 100644
--- a/app/main_window.py
+++ b/app/main_window.py
@@ -182,6 +182,7 @@ class MainWindow(QMainWindow):
view_menu = mb.addMenu("&View")
view_menu.addAction(self._act("Toggle Query History", self._toggle_history, "Ctrl+Shift+H"))
view_menu.addAction(self._act("New SQL Tab", self._new_sql_tab, "Ctrl+T"))
+ view_menu.addAction(self._act("Close Tab", self._close_current_tab, "Ctrl+W"))
# ── Tools ─────────────────────────────────────────────────────────────
tools_menu = mb.addMenu("&Tools")
@@ -470,6 +471,11 @@ class MainWindow(QMainWindow):
self._workspace.setVisible(False)
self._empty_label.setVisible(True)
+ def _close_current_tab(self):
+ idx = self._workspace.currentIndex()
+ if idx >= 0:
+ self._close_tab(idx)
+
def _on_cell_selected(self, value: str):
"""Show a truncated cell value in the status bar."""
if not value or value == "NULL":
diff --git a/app/ui/column_stats_dialog.py b/app/ui/column_stats_dialog.py
new file mode 100644
index 0000000..b4ca521
--- /dev/null
+++ b/app/ui/column_stats_dialog.py
@@ -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"{self._table}.{self._column}")
+ 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;")
diff --git a/app/ui/results_panel.py b/app/ui/results_panel.py
index 6f108f8..821b86d 100644
--- a/app/ui/results_panel.py
+++ b/app/ui/results_panel.py
@@ -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())
diff --git a/app/ui/sql_editor.py b/app/ui/sql_editor.py
index e95b721..d68a0ab 100644
--- a/app/ui/sql_editor.py
+++ b/app/ui/sql_editor.py
@@ -582,11 +582,7 @@ class EditorTab(QWidget):
self._maybe_invalidate_schema(self._editor.toPlainText())
def _on_script_done(self, results: list):
- for cols, rows, cnt, msg in results:
- if cols:
- self._results.show_data(cols, rows, cnt, 0)
- else:
- self._results.show_message(msg)
+ self._results.show_script_results(results)
self._maybe_invalidate_schema(self._editor.toPlainText())
def _on_error(self, msg: str):
diff --git a/app/ui/table_viewer.py b/app/ui/table_viewer.py
index 858de93..def00ff 100644
--- a/app/ui/table_viewer.py
+++ b/app/ui/table_viewer.py
@@ -399,6 +399,11 @@ class TableViewer(QWidget):
Qt.ContextMenuPolicy.CustomContextMenu)
self._table_view.customContextMenuRequested.connect(self._context_menu)
self._table_view.doubleClicked.connect(self._on_double_click)
+
+ # Column header context menu
+ hdr = self._table_view.horizontalHeader()
+ hdr.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
+ hdr.customContextMenuRequested.connect(self._header_context_menu)
# Track selection to enable/disable Edit/Delete buttons.
# Use both signals: clicked covers mouse, selectionChanged covers keyboard.
self._table_view.clicked.connect(self._on_cell_click)
@@ -629,6 +634,35 @@ class TableViewer(QWidget):
self._copy_selected_as_insert)
menu.exec(self._table_view.viewport().mapToGlobal(pos))
+ def _header_context_menu(self, pos):
+ hdr = self._table_view.horizontalHeader()
+ col_idx = hdr.logicalIndexAt(pos)
+ if col_idx < 0 or col_idx >= len(self._model.column_names()):
+ return
+ col_name = self._model.column_names()[col_idx]
+ menu = QMenu(self)
+ menu.addAction(
+ f"📊 Statistics: {col_name}",
+ lambda: self._show_column_stats(col_name),
+ )
+ menu.addSeparator()
+ menu.addAction(
+ "⟺ Resize to fit",
+ lambda: self._table_view.resizeColumnToContents(col_idx),
+ )
+ menu.addAction(
+ "⟺ Resize all to fit",
+ self._table_view.resizeColumnsToContents,
+ )
+ menu.exec(hdr.mapToGlobal(pos))
+
+ def _show_column_stats(self, col_name: str):
+ from app.ui.column_stats_dialog import ColumnStatsDialog
+ dlg = ColumnStatsDialog(
+ self._driver, self._database, self._table, col_name, parent=self
+ )
+ dlg.exec()
+
# ── Double-click: edit ────────────────────────────────────────────────────
def _on_double_click(self, index: QModelIndex):