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