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:
@@ -182,6 +182,7 @@ class MainWindow(QMainWindow):
|
|||||||
view_menu = mb.addMenu("&View")
|
view_menu = mb.addMenu("&View")
|
||||||
view_menu.addAction(self._act("Toggle Query History", self._toggle_history, "Ctrl+Shift+H"))
|
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("New SQL Tab", self._new_sql_tab, "Ctrl+T"))
|
||||||
|
view_menu.addAction(self._act("Close Tab", self._close_current_tab, "Ctrl+W"))
|
||||||
|
|
||||||
# ── Tools ─────────────────────────────────────────────────────────────
|
# ── Tools ─────────────────────────────────────────────────────────────
|
||||||
tools_menu = mb.addMenu("&Tools")
|
tools_menu = mb.addMenu("&Tools")
|
||||||
@@ -470,6 +471,11 @@ class MainWindow(QMainWindow):
|
|||||||
self._workspace.setVisible(False)
|
self._workspace.setVisible(False)
|
||||||
self._empty_label.setVisible(True)
|
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):
|
def _on_cell_selected(self, value: str):
|
||||||
"""Show a truncated cell value in the status bar."""
|
"""Show a truncated cell value in the status bar."""
|
||||||
if not value or value == "NULL":
|
if not value or value == "NULL":
|
||||||
|
|||||||
@@ -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;")
|
||||||
+159
-76
@@ -1,11 +1,17 @@
|
|||||||
"""
|
"""
|
||||||
Results panel — shows query result data, DML messages, errors, and export controls.
|
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
|
import os
|
||||||
|
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QHBoxLayout, QTableView, QLabel, QPushButton,
|
QWidget, QVBoxLayout, QHBoxLayout, QTableView, QLabel, QPushButton,
|
||||||
QHeaderView, QAbstractItemView, QFileDialog, QMessageBox, QStackedWidget,
|
QHeaderView, QAbstractItemView, QFileDialog, QMessageBox, QStackedWidget,
|
||||||
QPlainTextEdit, QProgressBar,
|
QPlainTextEdit, QProgressBar, QTabWidget, QApplication, QMenu,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, pyqtSignal, QSortFilterProxyModel
|
from PyQt6.QtCore import Qt, pyqtSignal, QSortFilterProxyModel
|
||||||
from PyQt6.QtGui import QColor, QFont
|
from PyQt6.QtGui import QColor, QFont
|
||||||
@@ -18,7 +24,6 @@ class ResultsPanel(QWidget):
|
|||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._model = ResultTableModel()
|
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
|
|
||||||
# ── UI ────────────────────────────────────────────────────────────────────
|
# ── UI ────────────────────────────────────────────────────────────────────
|
||||||
@@ -52,35 +57,13 @@ class ResultsPanel(QWidget):
|
|||||||
self._toolbar.addWidget(self._export_sql)
|
self._toolbar.addWidget(self._export_sql)
|
||||||
root.addLayout(self._toolbar)
|
root.addLayout(self._toolbar)
|
||||||
|
|
||||||
# ── Stacked pages ─────────────────────────────────────────────────────
|
# ── Stack: 0 = result tabs, 1 = loading ──────────────────────────────
|
||||||
self._stack = QStackedWidget()
|
self._stack = QStackedWidget()
|
||||||
|
|
||||||
# Page 0 — table
|
self._result_tabs = QTabWidget()
|
||||||
self._table = QTableView()
|
self._result_tabs.tabBar().setVisible(False)
|
||||||
self._proxy = QSortFilterProxyModel()
|
self._stack.addWidget(self._result_tabs) # idx 0
|
||||||
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
|
|
||||||
|
|
||||||
# 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()
|
loading = QWidget()
|
||||||
ll = QVBoxLayout(loading)
|
ll = QVBoxLayout(loading)
|
||||||
ll.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
ll.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
@@ -89,41 +72,145 @@ class ResultsPanel(QWidget):
|
|||||||
bar.setFixedWidth(200)
|
bar.setFixedWidth(200)
|
||||||
ll.addWidget(QLabel("Executing query…"))
|
ll.addWidget(QLabel("Executing query…"))
|
||||||
ll.addWidget(bar)
|
ll.addWidget(bar)
|
||||||
self._stack.addWidget(loading) # idx 2
|
self._stack.addWidget(loading) # idx 1
|
||||||
|
|
||||||
root.addWidget(self._stack, 1)
|
root.addWidget(self._stack, 1)
|
||||||
self._set_export_visible(False)
|
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 ────────────────────────────────────────────────────────────
|
# ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def show_loading(self):
|
def show_loading(self):
|
||||||
self._model.clear()
|
self._clear_tabs()
|
||||||
self._stack.setCurrentIndex(2)
|
self._stack.setCurrentIndex(1)
|
||||||
self._status_lbl.setText("Running…")
|
self._status_lbl.setText("Running…")
|
||||||
self._set_export_visible(False)
|
self._set_export_visible(False)
|
||||||
|
|
||||||
def show_data(self, cols: list, rows: list, count: int, elapsed: float):
|
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)
|
self._stack.setCurrentIndex(0)
|
||||||
t = f"{elapsed:.3f}s" if elapsed else ""
|
|
||||||
self._status_lbl.setText(f"{count:,} row(s) {t}")
|
t = f" {elapsed:.3f}s" if elapsed else ""
|
||||||
self.status_message.emit(f"Fetched {count:,} rows {t}")
|
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._set_export_visible(bool(cols))
|
||||||
self._auto_resize()
|
self._auto_resize(table)
|
||||||
|
|
||||||
def show_message(self, msg: str):
|
def show_message(self, msg: str):
|
||||||
self._msg_view.appendPlainText(msg)
|
view = self._make_message_tab(msg)
|
||||||
self._stack.setCurrentIndex(1)
|
self._clear_tabs()
|
||||||
self._status_lbl.setText(msg)
|
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)
|
self._set_export_visible(False)
|
||||||
|
|
||||||
def show_error(self, msg: str):
|
def show_error(self, msg: str):
|
||||||
self._msg_view.setPlainText(f"❌ {msg}")
|
view = self._make_message_tab(f"❌ {msg}", is_error=True)
|
||||||
self._stack.setCurrentIndex(1)
|
self._clear_tabs()
|
||||||
self._status_lbl.setText(f"Error: {msg[:80]}")
|
self._result_tabs.addTab(view, "Error")
|
||||||
self.status_message.emit(f"Error: {msg[:80]}")
|
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)
|
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):
|
def export_dialog(self):
|
||||||
self._do_export_csv()
|
self._do_export_csv()
|
||||||
|
|
||||||
@@ -134,74 +221,70 @@ class ResultsPanel(QWidget):
|
|||||||
self._export_json.setVisible(v)
|
self._export_json.setVisible(v)
|
||||||
self._export_sql.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):
|
def _do_export_csv(self):
|
||||||
|
model = self._current_model()
|
||||||
|
if not model:
|
||||||
|
return
|
||||||
path, _ = QFileDialog.getSaveFileName(
|
path, _ = QFileDialog.getSaveFileName(
|
||||||
self, "Export CSV", "results.csv", "CSV Files (*.csv)")
|
self, "Export CSV", "results.csv", "CSV Files (*.csv)")
|
||||||
if path:
|
if path:
|
||||||
try:
|
try:
|
||||||
self._model.export_csv(path)
|
model.export_csv(path)
|
||||||
QMessageBox.information(self, "Exported", f"Saved to {path}")
|
QMessageBox.information(self, "Exported", f"Saved to {path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, "Error", str(e))
|
QMessageBox.critical(self, "Error", str(e))
|
||||||
|
|
||||||
def _do_export_json(self):
|
def _do_export_json(self):
|
||||||
|
model = self._current_model()
|
||||||
|
if not model:
|
||||||
|
return
|
||||||
path, _ = QFileDialog.getSaveFileName(
|
path, _ = QFileDialog.getSaveFileName(
|
||||||
self, "Export JSON", "results.json", "JSON Files (*.json)")
|
self, "Export JSON", "results.json", "JSON Files (*.json)")
|
||||||
if path:
|
if path:
|
||||||
try:
|
try:
|
||||||
self._model.export_json(path)
|
model.export_json(path)
|
||||||
QMessageBox.information(self, "Exported", f"Saved to {path}")
|
QMessageBox.information(self, "Exported", f"Saved to {path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, "Error", str(e))
|
QMessageBox.critical(self, "Error", str(e))
|
||||||
|
|
||||||
def _do_export_sql(self):
|
def _do_export_sql(self):
|
||||||
|
model = self._current_model()
|
||||||
|
if not model:
|
||||||
|
return
|
||||||
path, _ = QFileDialog.getSaveFileName(
|
path, _ = QFileDialog.getSaveFileName(
|
||||||
self, "Export SQL", "results.sql", "SQL Files (*.sql)")
|
self, "Export SQL", "results.sql", "SQL Files (*.sql)")
|
||||||
if path:
|
if path:
|
||||||
try:
|
try:
|
||||||
self._model.export_sql(path)
|
model.export_sql(path)
|
||||||
QMessageBox.information(self, "Exported", f"Saved to {path}")
|
QMessageBox.information(self, "Exported", f"Saved to {path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, "Error", str(e))
|
QMessageBox.critical(self, "Error", str(e))
|
||||||
|
|
||||||
def _table_context_menu(self, pos):
|
def _table_context_menu(self, pos, table: QTableView,
|
||||||
from PyQt6.QtWidgets import QMenu
|
proxy: QSortFilterProxyModel,
|
||||||
from PyQt6.QtGui import QClipboard
|
model: ResultTableModel):
|
||||||
from PyQt6.QtWidgets import QApplication
|
idx = table.indexAt(pos)
|
||||||
idx = self._table.indexAt(pos)
|
|
||||||
if not idx.isValid():
|
if not idx.isValid():
|
||||||
return
|
return
|
||||||
menu = QMenu(self)
|
menu = QMenu(self)
|
||||||
menu.addAction("📋 Copy cell", lambda: self._copy_cell(idx))
|
menu.addAction("📋 Copy cell", lambda: self._copy_cell(idx, proxy))
|
||||||
menu.addAction("📋 Copy row", lambda: self._copy_row(idx))
|
menu.addAction("📋 Copy row", lambda: self._copy_row(idx, proxy, model))
|
||||||
menu.addAction("📋 Copy all", lambda: self._copy_all())
|
menu.addAction("📋 Copy all", lambda: self._copy_all(model))
|
||||||
menu.exec(self._table.viewport().mapToGlobal(pos))
|
menu.exec(table.viewport().mapToGlobal(pos))
|
||||||
|
|
||||||
def _copy_cell(self, idx):
|
def _copy_cell(self, idx, proxy):
|
||||||
from PyQt6.QtWidgets import QApplication
|
val = proxy.data(idx, Qt.ItemDataRole.DisplayRole) or ""
|
||||||
val = self._proxy.data(idx, Qt.ItemDataRole.DisplayRole) or ""
|
|
||||||
QApplication.clipboard().setText(str(val))
|
QApplication.clipboard().setText(str(val))
|
||||||
|
|
||||||
def _copy_row(self, idx):
|
def _copy_row(self, idx, proxy, model):
|
||||||
from PyQt6.QtWidgets import QApplication
|
row = proxy.mapToSource(idx).row()
|
||||||
row = self._proxy.mapToSource(idx).row()
|
vals = [str(v or "") for v in model.get_row(row)]
|
||||||
vals = [str(v or "") for v in self._model.get_row(row)]
|
|
||||||
QApplication.clipboard().setText("\t".join(vals))
|
QApplication.clipboard().setText("\t".join(vals))
|
||||||
|
|
||||||
def _copy_all(self):
|
def _copy_all(self, model):
|
||||||
from PyQt6.QtWidgets import QApplication
|
|
||||||
import csv, io
|
|
||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
writer = csv.writer(buf)
|
writer = csv.writer(buf)
|
||||||
writer.writerow(self._model.column_names())
|
writer.writerow(model.column_names())
|
||||||
for r in range(self._model.rowCount()):
|
for r in range(model.rowCount()):
|
||||||
writer.writerow(self._model.get_row(r))
|
writer.writerow(model.get_row(r))
|
||||||
QApplication.clipboard().setText(buf.getvalue())
|
QApplication.clipboard().setText(buf.getvalue())
|
||||||
|
|||||||
@@ -582,11 +582,7 @@ class EditorTab(QWidget):
|
|||||||
self._maybe_invalidate_schema(self._editor.toPlainText())
|
self._maybe_invalidate_schema(self._editor.toPlainText())
|
||||||
|
|
||||||
def _on_script_done(self, results: list):
|
def _on_script_done(self, results: list):
|
||||||
for cols, rows, cnt, msg in results:
|
self._results.show_script_results(results)
|
||||||
if cols:
|
|
||||||
self._results.show_data(cols, rows, cnt, 0)
|
|
||||||
else:
|
|
||||||
self._results.show_message(msg)
|
|
||||||
self._maybe_invalidate_schema(self._editor.toPlainText())
|
self._maybe_invalidate_schema(self._editor.toPlainText())
|
||||||
|
|
||||||
def _on_error(self, msg: str):
|
def _on_error(self, msg: str):
|
||||||
|
|||||||
@@ -399,6 +399,11 @@ class TableViewer(QWidget):
|
|||||||
Qt.ContextMenuPolicy.CustomContextMenu)
|
Qt.ContextMenuPolicy.CustomContextMenu)
|
||||||
self._table_view.customContextMenuRequested.connect(self._context_menu)
|
self._table_view.customContextMenuRequested.connect(self._context_menu)
|
||||||
self._table_view.doubleClicked.connect(self._on_double_click)
|
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.
|
# Track selection to enable/disable Edit/Delete buttons.
|
||||||
# Use both signals: clicked covers mouse, selectionChanged covers keyboard.
|
# Use both signals: clicked covers mouse, selectionChanged covers keyboard.
|
||||||
self._table_view.clicked.connect(self._on_cell_click)
|
self._table_view.clicked.connect(self._on_cell_click)
|
||||||
@@ -629,6 +634,35 @@ class TableViewer(QWidget):
|
|||||||
self._copy_selected_as_insert)
|
self._copy_selected_as_insert)
|
||||||
menu.exec(self._table_view.viewport().mapToGlobal(pos))
|
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 ────────────────────────────────────────────────────
|
# ── Double-click: edit ────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _on_double_click(self, index: QModelIndex):
|
def _on_double_click(self, index: QModelIndex):
|
||||||
|
|||||||
Reference in New Issue
Block a user