feat: shortcuts dialog, tab context menus, smart status bar, recent files
- ShortcutsDialog: grouped tree of all keyboard shortcuts (Help menu) - Workspace tab context menu: Rename, Close, Close Others, Close to Right - SQL query tab context menu: Rename, Duplicate, Close, Close Others - Smart status bar: right-side connection indicator + selected cell preview - File → Open SQL File (Ctrl+O), Save (Ctrl+S), Save As, Open Recent - Recent files persisted to ~/.dbclient/recent_files.json (last 10) - TableViewer.cell_selected signal wired to status bar cell preview - EditorTab._filepath tracks the file associated with each query tab Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
"""Persistent list of recently opened SQL files (~/.dbclient/recent_files.json)."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_RECENT_FILE = Path.home() / ".dbclient" / "recent_files.json"
|
||||||
|
_MAX = 10
|
||||||
|
|
||||||
|
|
||||||
|
def load_recent() -> list[str]:
|
||||||
|
"""Return up to _MAX recent file paths that still exist on disk."""
|
||||||
|
if not _RECENT_FILE.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
with open(_RECENT_FILE, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return [p for p in data if isinstance(p, str) and Path(p).exists()][:_MAX]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def add_recent(path: str) -> None:
|
||||||
|
"""Prepend path to the recent list, deduplicating and capping at _MAX."""
|
||||||
|
recent = load_recent()
|
||||||
|
recent = [p for p in recent if p != path]
|
||||||
|
recent.insert(0, path)
|
||||||
|
_save(recent[:_MAX])
|
||||||
|
|
||||||
|
|
||||||
|
def clear_recent() -> None:
|
||||||
|
_save([])
|
||||||
|
|
||||||
|
|
||||||
|
def _save(data: list) -> None:
|
||||||
|
_RECENT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
with open(_RECENT_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
+215
-52
@@ -8,10 +8,12 @@ On startup:
|
|||||||
• New Connection dialog saves the profile AND connects immediately.
|
• New Connection dialog saves the profile AND connects immediately.
|
||||||
• Edit / Delete work on both connected and saved-only profiles.
|
• Edit / Delete work on both connected and saved-only profiles.
|
||||||
"""
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QSplitter,
|
QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QSplitter,
|
||||||
QTabWidget, QStatusBar, QLabel, QMessageBox, QDockWidget,
|
QTabWidget, QStatusBar, QLabel, QMessageBox, QDockWidget,
|
||||||
QPushButton, QApplication,
|
QPushButton, QApplication, QMenu, QInputDialog, QFileDialog,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, QTimer
|
from PyQt6.QtCore import Qt, QTimer
|
||||||
from PyQt6.QtGui import QAction, QColor, QKeySequence
|
from PyQt6.QtGui import QAction, QColor, QKeySequence
|
||||||
@@ -28,7 +30,9 @@ from app.ui.explain_view import ExplainPanel
|
|||||||
from app.ui.user_manager import UserManagerPanel
|
from app.ui.user_manager import UserManagerPanel
|
||||||
from app.ui.log_viewer import LogViewer
|
from app.ui.log_viewer import LogViewer
|
||||||
from app.ui.connection_dialog import ConnectionDialog
|
from app.ui.connection_dialog import ConnectionDialog
|
||||||
|
from app.ui.shortcuts_dialog import ShortcutsDialog
|
||||||
from app.config.connections import load_profiles, delete_profile
|
from app.config.connections import load_profiles, delete_profile
|
||||||
|
from app.config.recent_files import load_recent, add_recent, clear_recent
|
||||||
from app.models.connection_model import ConnectionProfile
|
from app.models.connection_model import ConnectionProfile
|
||||||
from app.drivers import get_driver
|
from app.drivers import get_driver
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
@@ -99,12 +103,10 @@ class MainWindow(QMainWindow):
|
|||||||
ll.addWidget(hdr)
|
ll.addWidget(hdr)
|
||||||
|
|
||||||
self._schema_browser = SchemaBrowser()
|
self._schema_browser = SchemaBrowser()
|
||||||
# Tree → workspace wiring
|
|
||||||
self._schema_browser.open_table_viewer.connect(self._open_table_viewer)
|
self._schema_browser.open_table_viewer.connect(self._open_table_viewer)
|
||||||
self._schema_browser.open_table_structure.connect(self._open_table_structure)
|
self._schema_browser.open_table_structure.connect(self._open_table_structure)
|
||||||
self._schema_browser.open_sql_editor.connect(self._open_sql_editor)
|
self._schema_browser.open_sql_editor.connect(self._open_sql_editor)
|
||||||
self._schema_browser.run_query_requested.connect(self._paste_query)
|
self._schema_browser.run_query_requested.connect(self._paste_query)
|
||||||
# Saved-profile management signals
|
|
||||||
self._schema_browser.connect_requested.connect(self._connect_by_id)
|
self._schema_browser.connect_requested.connect(self._connect_by_id)
|
||||||
self._schema_browser.edit_requested.connect(self._edit_connection)
|
self._schema_browser.edit_requested.connect(self._edit_connection)
|
||||||
self._schema_browser.delete_requested.connect(self._delete_connection)
|
self._schema_browser.delete_requested.connect(self._delete_connection)
|
||||||
@@ -121,6 +123,15 @@ class MainWindow(QMainWindow):
|
|||||||
self._workspace.setMovable(True)
|
self._workspace.setMovable(True)
|
||||||
self._workspace.tabCloseRequested.connect(self._close_tab)
|
self._workspace.tabCloseRequested.connect(self._close_tab)
|
||||||
self._workspace.setObjectName("workspace")
|
self._workspace.setObjectName("workspace")
|
||||||
|
self._workspace.currentChanged.connect(self._on_workspace_tab_changed)
|
||||||
|
|
||||||
|
# Right-click context menu on workspace tab bar
|
||||||
|
self._workspace.tabBar().setContextMenuPolicy(
|
||||||
|
Qt.ContextMenuPolicy.CustomContextMenu
|
||||||
|
)
|
||||||
|
self._workspace.tabBar().customContextMenuRequested.connect(
|
||||||
|
self._workspace_tab_context_menu
|
||||||
|
)
|
||||||
|
|
||||||
self._empty_label = QLabel(
|
self._empty_label = QLabel(
|
||||||
"🔌 Double-click a saved connection to connect\n\n"
|
"🔌 Double-click a saved connection to connect\n\n"
|
||||||
@@ -153,15 +164,25 @@ class MainWindow(QMainWindow):
|
|||||||
def _build_menus(self):
|
def _build_menus(self):
|
||||||
mb = self.menuBar()
|
mb = self.menuBar()
|
||||||
|
|
||||||
|
# ── File ──────────────────────────────────────────────────────────────
|
||||||
file_menu = mb.addMenu("&File")
|
file_menu = mb.addMenu("&File")
|
||||||
file_menu.addAction(self._act("+ New Connection…", self._new_connection, "Ctrl+N"))
|
file_menu.addAction(self._act("+ New Connection…", self._new_connection, "Ctrl+N"))
|
||||||
file_menu.addSeparator()
|
file_menu.addSeparator()
|
||||||
|
file_menu.addAction(self._act("📂 Open SQL File…", self._open_sql_file, "Ctrl+O"))
|
||||||
|
file_menu.addAction(self._act("💾 Save SQL File", self._save_sql_file, "Ctrl+S"))
|
||||||
|
file_menu.addAction(self._act("💾 Save SQL File As…", self._save_sql_file_as))
|
||||||
|
file_menu.addSeparator()
|
||||||
|
self._recent_menu = file_menu.addMenu("Open Recent")
|
||||||
|
self._rebuild_recent_menu()
|
||||||
|
file_menu.addSeparator()
|
||||||
file_menu.addAction(self._act("Exit", QApplication.quit, "Ctrl+Q"))
|
file_menu.addAction(self._act("Exit", QApplication.quit, "Ctrl+Q"))
|
||||||
|
|
||||||
|
# ── View ──────────────────────────────────────────────────────────────
|
||||||
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"))
|
||||||
|
|
||||||
|
# ── Tools ─────────────────────────────────────────────────────────────
|
||||||
tools_menu = mb.addMenu("&Tools")
|
tools_menu = mb.addMenu("&Tools")
|
||||||
tools_menu.addAction(self._act("Process List…", self._open_process_list, "Ctrl+P"))
|
tools_menu.addAction(self._act("Process List…", self._open_process_list, "Ctrl+P"))
|
||||||
tools_menu.addSeparator()
|
tools_menu.addSeparator()
|
||||||
@@ -170,14 +191,14 @@ class MainWindow(QMainWindow):
|
|||||||
tools_menu.addSeparator()
|
tools_menu.addSeparator()
|
||||||
tools_menu.addAction(self._act("User & Privilege Management…", self._open_user_manager, "Ctrl+U"))
|
tools_menu.addAction(self._act("User & Privilege Management…", self._open_user_manager, "Ctrl+U"))
|
||||||
|
|
||||||
|
# ── Help ──────────────────────────────────────────────────────────────
|
||||||
help_menu = mb.addMenu("&Help")
|
help_menu = mb.addMenu("&Help")
|
||||||
help_menu.addAction(self._act("Keyboard Shortcuts", self._show_shortcuts))
|
help_menu.addAction(self._act("Keyboard Shortcuts…", self._show_shortcuts, "Ctrl+?"))
|
||||||
help_menu.addAction(self._act("View App Logs", self._open_log_viewer, "Ctrl+L"))
|
help_menu.addAction(self._act("View App Logs", self._open_log_viewer, "Ctrl+L"))
|
||||||
help_menu.addSeparator()
|
help_menu.addSeparator()
|
||||||
help_menu.addAction(self._act("About DBClient", self._show_about))
|
help_menu.addAction(self._act("About DBClient", self._show_about))
|
||||||
|
|
||||||
def _act(self, text: str, slot, shortcut: str = None) -> QAction:
|
def _act(self, text: str, slot, shortcut: str = None) -> QAction:
|
||||||
"""Create a QAction parented to this window (prevents GC from killing it)."""
|
|
||||||
a = QAction(text, self)
|
a = QAction(text, self)
|
||||||
a.triggered.connect(slot)
|
a.triggered.connect(slot)
|
||||||
if shortcut:
|
if shortcut:
|
||||||
@@ -189,19 +210,98 @@ class MainWindow(QMainWindow):
|
|||||||
def _build_status_bar(self):
|
def _build_status_bar(self):
|
||||||
sb = QStatusBar()
|
sb = QStatusBar()
|
||||||
self.setStatusBar(sb)
|
self.setStatusBar(sb)
|
||||||
|
|
||||||
self._status_lbl = QLabel("Ready")
|
self._status_lbl = QLabel("Ready")
|
||||||
sb.addWidget(self._status_lbl, 1)
|
sb.addWidget(self._status_lbl, 1)
|
||||||
|
|
||||||
|
# ── Permanent right-side widgets ──────────────────────────────────────
|
||||||
|
self._cell_lbl = QLabel("")
|
||||||
|
self._cell_lbl.setObjectName("statusLabel")
|
||||||
|
self._cell_lbl.setMaximumWidth(340)
|
||||||
|
self._cell_lbl.setToolTip("Selected cell value")
|
||||||
|
sb.addPermanentWidget(self._cell_lbl)
|
||||||
|
|
||||||
|
_sep = QLabel("│")
|
||||||
|
_sep.setStyleSheet("color: #51576d; padding: 0 4px;")
|
||||||
|
sb.addPermanentWidget(_sep)
|
||||||
|
|
||||||
|
self._conn_lbl = QLabel("")
|
||||||
|
self._conn_lbl.setObjectName("statusLabel")
|
||||||
|
self._conn_lbl.setMinimumWidth(160)
|
||||||
|
sb.addPermanentWidget(self._conn_lbl)
|
||||||
|
|
||||||
def _set_status(self, msg: str):
|
def _set_status(self, msg: str):
|
||||||
self._status_lbl.setText(msg)
|
self._status_lbl.setText(msg)
|
||||||
|
|
||||||
|
def _update_conn_label(self, widget):
|
||||||
|
"""Refresh the connection indicator for the given workspace widget."""
|
||||||
|
driver = None
|
||||||
|
if isinstance(widget, SQLEditorWidget):
|
||||||
|
tab = widget.current_tab()
|
||||||
|
driver = getattr(tab, "_driver", None) if tab else None
|
||||||
|
else:
|
||||||
|
driver = getattr(widget, "_driver", None)
|
||||||
|
|
||||||
|
profile = self._profile_for_driver(driver) if driver else None
|
||||||
|
if profile:
|
||||||
|
db = ""
|
||||||
|
if isinstance(widget, SQLEditorWidget):
|
||||||
|
tab = widget.current_tab()
|
||||||
|
db = getattr(tab, "_database", "") if tab else ""
|
||||||
|
else:
|
||||||
|
db = getattr(widget, "_database", "")
|
||||||
|
self._conn_lbl.setText(
|
||||||
|
f"🔌 {profile.name}" + (f" / {db}" if db else "")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._conn_lbl.setText("")
|
||||||
|
|
||||||
|
def _on_workspace_tab_changed(self, _idx: int):
|
||||||
|
self._cell_lbl.setText("")
|
||||||
|
widget = self._workspace.currentWidget()
|
||||||
|
if widget:
|
||||||
|
self._update_conn_label(widget)
|
||||||
|
|
||||||
|
# ── Tab context menu (workspace) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def _workspace_tab_context_menu(self, pos):
|
||||||
|
idx = self._workspace.tabBar().tabAt(pos)
|
||||||
|
if idx < 0:
|
||||||
|
return
|
||||||
|
menu = QMenu(self)
|
||||||
|
rename_act = menu.addAction("Rename Tab…")
|
||||||
|
menu.addSeparator()
|
||||||
|
close_act = menu.addAction("Close Tab")
|
||||||
|
close_others = menu.addAction("Close Others")
|
||||||
|
close_right = menu.addAction("Close to the Right")
|
||||||
|
|
||||||
|
close_others.setEnabled(self._workspace.count() > 1)
|
||||||
|
close_right.setEnabled(idx < self._workspace.count() - 1)
|
||||||
|
|
||||||
|
act = menu.exec(self._workspace.tabBar().mapToGlobal(pos))
|
||||||
|
if act == rename_act:
|
||||||
|
text, ok = QInputDialog.getText(
|
||||||
|
self, "Rename Tab", "Tab name:",
|
||||||
|
text=self._workspace.tabText(idx),
|
||||||
|
)
|
||||||
|
if ok and text.strip():
|
||||||
|
self._workspace.setTabText(idx, text.strip())
|
||||||
|
elif act == close_act:
|
||||||
|
self._close_tab(idx)
|
||||||
|
elif act == close_others:
|
||||||
|
for i in range(self._workspace.count() - 1, -1, -1):
|
||||||
|
if i != idx:
|
||||||
|
self._workspace.removeTab(i)
|
||||||
|
if self._workspace.count() == 0:
|
||||||
|
self._workspace.setVisible(False)
|
||||||
|
self._empty_label.setVisible(True)
|
||||||
|
elif act == close_right:
|
||||||
|
for i in range(self._workspace.count() - 1, idx, -1):
|
||||||
|
self._workspace.removeTab(i)
|
||||||
|
|
||||||
# ── Startup: load saved profiles ──────────────────────────────────────────
|
# ── Startup: load saved profiles ──────────────────────────────────────────
|
||||||
|
|
||||||
def _load_saved_profiles(self):
|
def _load_saved_profiles(self):
|
||||||
"""
|
|
||||||
Load all saved connection profiles from disk and add them to the
|
|
||||||
sidebar as disconnected nodes. Called once after the window shows.
|
|
||||||
"""
|
|
||||||
profiles = load_profiles()
|
profiles = load_profiles()
|
||||||
for profile in profiles:
|
for profile in profiles:
|
||||||
self._all_profiles[profile.id] = profile
|
self._all_profiles[profile.id] = profile
|
||||||
@@ -216,18 +316,14 @@ class MainWindow(QMainWindow):
|
|||||||
# ── Connection management ─────────────────────────────────────────────────
|
# ── Connection management ─────────────────────────────────────────────────
|
||||||
|
|
||||||
def _new_connection(self):
|
def _new_connection(self):
|
||||||
"""Open the New Connection dialog, save the profile, and connect."""
|
|
||||||
dlg = ConnectionDialog(parent=self)
|
dlg = ConnectionDialog(parent=self)
|
||||||
if dlg.exec():
|
if dlg.exec():
|
||||||
profile = dlg.profile
|
profile = dlg.profile
|
||||||
self._all_profiles[profile.id] = profile
|
self._all_profiles[profile.id] = profile
|
||||||
# Dialog already persisted it via save_profile()
|
|
||||||
# Show it as saved first, then auto-connect
|
|
||||||
self._schema_browser.add_saved_profile(profile)
|
self._schema_browser.add_saved_profile(profile)
|
||||||
self._do_connect(profile)
|
self._do_connect(profile)
|
||||||
|
|
||||||
def _connect_by_id(self, profile_id: str):
|
def _connect_by_id(self, profile_id: str):
|
||||||
"""Called when user double-clicks / right-clicks Connect on a saved node."""
|
|
||||||
if self._schema_browser.is_connected(profile_id):
|
if self._schema_browser.is_connected(profile_id):
|
||||||
self._set_status("Already connected.")
|
self._set_status("Already connected.")
|
||||||
return
|
return
|
||||||
@@ -237,7 +333,6 @@ class MainWindow(QMainWindow):
|
|||||||
self._do_connect(profile)
|
self._do_connect(profile)
|
||||||
|
|
||||||
def _do_connect(self, profile: ConnectionProfile):
|
def _do_connect(self, profile: ConnectionProfile):
|
||||||
"""Build the driver, connect, and upgrade the sidebar node."""
|
|
||||||
pid = profile.id
|
pid = profile.id
|
||||||
config = {
|
config = {
|
||||||
"host": profile.host,
|
"host": profile.host,
|
||||||
@@ -268,37 +363,27 @@ class MainWindow(QMainWindow):
|
|||||||
f"Could not connect to '{profile.name}':\n\n{e}")
|
f"Could not connect to '{profile.name}':\n\n{e}")
|
||||||
|
|
||||||
def _edit_connection(self, profile_id: str):
|
def _edit_connection(self, profile_id: str):
|
||||||
"""Open edit dialog for a saved or connected profile."""
|
|
||||||
profile = self._all_profiles.get(profile_id)
|
profile = self._all_profiles.get(profile_id)
|
||||||
if not profile:
|
if not profile:
|
||||||
return
|
return
|
||||||
|
|
||||||
was_connected = self._schema_browser.is_connected(profile_id)
|
was_connected = self._schema_browser.is_connected(profile_id)
|
||||||
|
|
||||||
dlg = ConnectionDialog(profile=profile, parent=self)
|
dlg = ConnectionDialog(profile=profile, parent=self)
|
||||||
if not dlg.exec():
|
if not dlg.exec():
|
||||||
return
|
return
|
||||||
|
|
||||||
updated = dlg.profile
|
updated = dlg.profile
|
||||||
self._all_profiles[profile_id] = updated
|
self._all_profiles[profile_id] = updated
|
||||||
# save_profile() was already called inside the dialog
|
|
||||||
|
|
||||||
if was_connected:
|
if was_connected:
|
||||||
# Disconnect first, then reconnect with new credentials
|
|
||||||
self._schema_browser.remove_connection(profile_id, keep_saved=True)
|
self._schema_browser.remove_connection(profile_id, keep_saved=True)
|
||||||
self._active_drivers.pop(profile_id, None)
|
self._active_drivers.pop(profile_id, None)
|
||||||
self._do_connect(updated)
|
self._do_connect(updated)
|
||||||
else:
|
else:
|
||||||
self._schema_browser.update_saved_profile(updated)
|
self._schema_browser.update_saved_profile(updated)
|
||||||
|
|
||||||
self._set_status(f"Connection '{updated.name}' updated.")
|
self._set_status(f"Connection '{updated.name}' updated.")
|
||||||
|
|
||||||
def _delete_connection(self, profile_id: str):
|
def _delete_connection(self, profile_id: str):
|
||||||
"""Delete a profile entirely from memory and disk."""
|
|
||||||
profile = self._all_profiles.get(profile_id)
|
profile = self._all_profiles.get(profile_id)
|
||||||
if not profile:
|
if not profile:
|
||||||
return
|
return
|
||||||
|
|
||||||
btn = QMessageBox.warning(
|
btn = QMessageBox.warning(
|
||||||
self, "Delete Connection",
|
self, "Delete Connection",
|
||||||
f"Delete the connection profile '{profile.name}'?\n\n"
|
f"Delete the connection profile '{profile.name}'?\n\n"
|
||||||
@@ -309,14 +394,11 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
if btn != QMessageBox.StandardButton.Yes:
|
if btn != QMessageBox.StandardButton.Yes:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Disconnect if active
|
|
||||||
if self._schema_browser.is_connected(profile_id):
|
if self._schema_browser.is_connected(profile_id):
|
||||||
self._schema_browser.remove_connection(profile_id, keep_saved=False)
|
self._schema_browser.remove_connection(profile_id, keep_saved=False)
|
||||||
self._active_drivers.pop(profile_id, None)
|
self._active_drivers.pop(profile_id, None)
|
||||||
else:
|
else:
|
||||||
self._schema_browser.remove_connection(profile_id, keep_saved=False)
|
self._schema_browser.remove_connection(profile_id, keep_saved=False)
|
||||||
|
|
||||||
self._all_profiles.pop(profile_id, None)
|
self._all_profiles.pop(profile_id, None)
|
||||||
delete_profile(profile_id)
|
delete_profile(profile_id)
|
||||||
self._set_status(f"Connection '{profile.name}' deleted.")
|
self._set_status(f"Connection '{profile.name}' deleted.")
|
||||||
@@ -341,6 +423,7 @@ class MainWindow(QMainWindow):
|
|||||||
def _open_table_viewer(self, driver, database: str, table: str):
|
def _open_table_viewer(self, driver, database: str, table: str):
|
||||||
tab = TableViewer(driver, database, table)
|
tab = TableViewer(driver, database, table)
|
||||||
tab.status_message.connect(self._set_status)
|
tab.status_message.connect(self._set_status)
|
||||||
|
tab.cell_selected.connect(self._on_cell_selected)
|
||||||
idx = self._workspace.addTab(tab, f"📋 {table}")
|
idx = self._workspace.addTab(tab, f"📋 {table}")
|
||||||
self._workspace.setCurrentIndex(idx)
|
self._workspace.setCurrentIndex(idx)
|
||||||
self._apply_tab_color(idx, driver)
|
self._apply_tab_color(idx, driver)
|
||||||
@@ -384,6 +467,108 @@ class MainWindow(QMainWindow):
|
|||||||
self._workspace.setVisible(False)
|
self._workspace.setVisible(False)
|
||||||
self._empty_label.setVisible(True)
|
self._empty_label.setVisible(True)
|
||||||
|
|
||||||
|
def _on_cell_selected(self, value: str):
|
||||||
|
"""Show a truncated cell value in the status bar."""
|
||||||
|
if not value or value == "NULL":
|
||||||
|
self._cell_lbl.setText("")
|
||||||
|
else:
|
||||||
|
preview = value[:60] + ("…" if len(value) > 60 else "")
|
||||||
|
self._cell_lbl.setText(f" {preview}")
|
||||||
|
|
||||||
|
# ── File open / save ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _open_sql_file(self, filepath: str = None):
|
||||||
|
if not filepath:
|
||||||
|
filepath, _ = QFileDialog.getOpenFileName(
|
||||||
|
self, "Open SQL File", "",
|
||||||
|
"SQL Files (*.sql);;Text Files (*.txt);;All Files (*)",
|
||||||
|
)
|
||||||
|
if not filepath:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(filepath, "r", encoding="utf-8") as f:
|
||||||
|
sql = f.read()
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.warning(self, "Open Failed", str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
name = Path(filepath).name
|
||||||
|
sql_widget = SQLEditorWidget()
|
||||||
|
sql_widget.status_message.connect(self._set_status)
|
||||||
|
tab = sql_widget.new_tab(sql=sql, title=name)
|
||||||
|
tab._filepath = filepath
|
||||||
|
|
||||||
|
idx = self._workspace.addTab(sql_widget, f"✏️ {name}")
|
||||||
|
self._workspace.setCurrentIndex(idx)
|
||||||
|
self._show_workspace()
|
||||||
|
|
||||||
|
add_recent(filepath)
|
||||||
|
self._rebuild_recent_menu()
|
||||||
|
self._set_status(f"Opened: {filepath}")
|
||||||
|
|
||||||
|
def _save_sql_file(self):
|
||||||
|
current = self._workspace.currentWidget()
|
||||||
|
if not isinstance(current, SQLEditorWidget):
|
||||||
|
return
|
||||||
|
tab = current.current_tab()
|
||||||
|
if tab is None:
|
||||||
|
return
|
||||||
|
filepath = getattr(tab, "_filepath", None)
|
||||||
|
if not filepath:
|
||||||
|
self._save_sql_file_as()
|
||||||
|
return
|
||||||
|
self._write_sql_file(tab, filepath)
|
||||||
|
|
||||||
|
def _save_sql_file_as(self):
|
||||||
|
current = self._workspace.currentWidget()
|
||||||
|
if not isinstance(current, SQLEditorWidget):
|
||||||
|
return
|
||||||
|
tab = current.current_tab()
|
||||||
|
if tab is None:
|
||||||
|
return
|
||||||
|
filepath, _ = QFileDialog.getSaveFileName(
|
||||||
|
self, "Save SQL File", "",
|
||||||
|
"SQL Files (*.sql);;Text Files (*.txt);;All Files (*)",
|
||||||
|
)
|
||||||
|
if filepath:
|
||||||
|
self._write_sql_file(tab, filepath)
|
||||||
|
|
||||||
|
def _write_sql_file(self, tab, filepath: str):
|
||||||
|
try:
|
||||||
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
|
f.write(tab.get_sql())
|
||||||
|
tab._filepath = filepath
|
||||||
|
# Update workspace tab label
|
||||||
|
idx = self._workspace.currentIndex()
|
||||||
|
self._workspace.setTabText(idx, f"✏️ {Path(filepath).name}")
|
||||||
|
add_recent(filepath)
|
||||||
|
self._rebuild_recent_menu()
|
||||||
|
self._set_status(f"Saved: {filepath}")
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.warning(self, "Save Failed", str(e))
|
||||||
|
|
||||||
|
# ── Recent files ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _rebuild_recent_menu(self):
|
||||||
|
self._recent_menu.clear()
|
||||||
|
recent = load_recent()
|
||||||
|
if not recent:
|
||||||
|
no_act = self._recent_menu.addAction("(no recent files)")
|
||||||
|
no_act.setEnabled(False)
|
||||||
|
return
|
||||||
|
for path in recent:
|
||||||
|
act = self._recent_menu.addAction(Path(path).name)
|
||||||
|
act.setToolTip(path)
|
||||||
|
act.triggered.connect(lambda checked, p=path: self._open_sql_file(p))
|
||||||
|
self._recent_menu.addSeparator()
|
||||||
|
self._recent_menu.addAction("Clear Recent Files").triggered.connect(
|
||||||
|
self._clear_recent_files
|
||||||
|
)
|
||||||
|
|
||||||
|
def _clear_recent_files(self):
|
||||||
|
clear_recent()
|
||||||
|
self._rebuild_recent_menu()
|
||||||
|
|
||||||
# ── Misc ──────────────────────────────────────────────────────────────────
|
# ── Misc ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _toggle_history(self):
|
def _toggle_history(self):
|
||||||
@@ -397,30 +582,16 @@ class MainWindow(QMainWindow):
|
|||||||
"Built with Python + PyQt6.")
|
"Built with Python + PyQt6.")
|
||||||
|
|
||||||
def _show_shortcuts(self):
|
def _show_shortcuts(self):
|
||||||
QMessageBox.information(self, "Keyboard Shortcuts",
|
dlg = ShortcutsDialog(parent=self)
|
||||||
"F5 / Ctrl+Enter — Run query\n"
|
dlg.exec()
|
||||||
"Ctrl+/ — Toggle comment\n"
|
|
||||||
"Ctrl+Space — Force auto-complete\n"
|
|
||||||
"Ctrl+F — Find in SQL editor\n"
|
|
||||||
"Ctrl+H — Find & Replace in SQL editor\n"
|
|
||||||
"Ctrl+N — New connection\n"
|
|
||||||
"Ctrl+T — New SQL tab\n"
|
|
||||||
"Ctrl+Shift+H — Toggle query history\n"
|
|
||||||
"Ctrl+U — User management\n"
|
|
||||||
"Ctrl+L — View app logs\n"
|
|
||||||
"Delete — Delete selected row (table viewer)\n"
|
|
||||||
"Ins — Add new row (table viewer)\n"
|
|
||||||
"Ctrl+Q — Quit")
|
|
||||||
|
|
||||||
def _open_log_viewer(self):
|
def _open_log_viewer(self):
|
||||||
"""Open the application log viewer as a workspace tab."""
|
|
||||||
viewer = LogViewer(parent=self)
|
viewer = LogViewer(parent=self)
|
||||||
idx = self._workspace.addTab(viewer, "📋 App Logs")
|
idx = self._workspace.addTab(viewer, "📋 App Logs")
|
||||||
self._workspace.setCurrentIndex(idx)
|
self._workspace.setCurrentIndex(idx)
|
||||||
self._show_workspace()
|
self._show_workspace()
|
||||||
|
|
||||||
def _open_process_list(self):
|
def _open_process_list(self):
|
||||||
"""Open a Process List tab for the currently active connection."""
|
|
||||||
driver, name = self._active_driver_for_tools()
|
driver, name = self._active_driver_for_tools()
|
||||||
if driver is None:
|
if driver is None:
|
||||||
return
|
return
|
||||||
@@ -432,11 +603,9 @@ class MainWindow(QMainWindow):
|
|||||||
self._show_workspace()
|
self._show_workspace()
|
||||||
|
|
||||||
def _open_import_dialog(self):
|
def _open_import_dialog(self):
|
||||||
"""Open the Import CSV/JSON dialog targeting the active connection."""
|
|
||||||
driver, name = self._active_driver_for_tools()
|
driver, name = self._active_driver_for_tools()
|
||||||
if driver is None:
|
if driver is None:
|
||||||
return
|
return
|
||||||
# Determine current database from the active workspace tab if possible
|
|
||||||
database = ""
|
database = ""
|
||||||
current = self._workspace.currentWidget()
|
current = self._workspace.currentWidget()
|
||||||
if hasattr(current, "_database"):
|
if hasattr(current, "_database"):
|
||||||
@@ -445,11 +614,9 @@ class MainWindow(QMainWindow):
|
|||||||
dlg.exec()
|
dlg.exec()
|
||||||
|
|
||||||
def _open_dump_dialog(self):
|
def _open_dump_dialog(self):
|
||||||
"""Open the Export Database Dump dialog targeting the active connection."""
|
|
||||||
driver, name = self._active_driver_for_tools()
|
driver, name = self._active_driver_for_tools()
|
||||||
if driver is None:
|
if driver is None:
|
||||||
return
|
return
|
||||||
# Pre-select the database visible in the current workspace tab
|
|
||||||
database = ""
|
database = ""
|
||||||
current = self._workspace.currentWidget()
|
current = self._workspace.currentWidget()
|
||||||
if hasattr(current, "_database"):
|
if hasattr(current, "_database"):
|
||||||
@@ -458,7 +625,6 @@ class MainWindow(QMainWindow):
|
|||||||
dlg.exec()
|
dlg.exec()
|
||||||
|
|
||||||
def _open_user_manager(self):
|
def _open_user_manager(self):
|
||||||
"""Open the User & Privilege Management tab."""
|
|
||||||
driver, name = self._active_driver_for_tools()
|
driver, name = self._active_driver_for_tools()
|
||||||
if driver is None:
|
if driver is None:
|
||||||
return
|
return
|
||||||
@@ -469,18 +635,15 @@ class MainWindow(QMainWindow):
|
|||||||
self._show_workspace()
|
self._show_workspace()
|
||||||
|
|
||||||
def open_explain_tab(self, driver, database: str, sql: str):
|
def open_explain_tab(self, driver, database: str, sql: str):
|
||||||
"""Open an EXPLAIN plan tab (called from SQLEditorWidget)."""
|
|
||||||
panel = ExplainPanel(driver, sql, parent=self)
|
panel = ExplainPanel(driver, sql, parent=self)
|
||||||
panel.status_message.connect(self._set_status)
|
panel.status_message.connect(self._set_status)
|
||||||
short_sql = sql[:40].replace("\n", " ") + ("…" if len(sql) > 40 else "")
|
short_sql = sql[:40].replace("\n", " ") + ("…" if len(sql) > 40 else "")
|
||||||
idx = self._workspace.addTab(panel, f"🔎 EXPLAIN")
|
idx = self._workspace.addTab(panel, "🔎 EXPLAIN")
|
||||||
self._workspace.setTabToolTip(idx, short_sql)
|
self._workspace.setTabToolTip(idx, short_sql)
|
||||||
self._workspace.setCurrentIndex(idx)
|
self._workspace.setCurrentIndex(idx)
|
||||||
self._show_workspace()
|
self._show_workspace()
|
||||||
|
|
||||||
def _active_driver_for_tools(self):
|
def _active_driver_for_tools(self):
|
||||||
"""Return (driver, connection_name) for the first active connection,
|
|
||||||
or show a warning and return (None, '') if none are connected."""
|
|
||||||
if not self._active_drivers:
|
if not self._active_drivers:
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self, "No Active Connection",
|
self, "No Active Connection",
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Keyboard Shortcuts reference dialog."""
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QDialog, QVBoxLayout, QTreeWidget, QTreeWidgetItem, QDialogButtonBox,
|
||||||
|
)
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
from PyQt6.QtGui import QFont
|
||||||
|
|
||||||
|
_SHORTCUTS = [
|
||||||
|
("SQL Editor", [
|
||||||
|
("F5 / Ctrl+Enter", "Run query"),
|
||||||
|
("Ctrl+/", "Toggle line comment"),
|
||||||
|
("Ctrl+Space", "Force auto-complete"),
|
||||||
|
("Ctrl+F", "Find in editor"),
|
||||||
|
("Ctrl+H", "Find & Replace in editor"),
|
||||||
|
("Ctrl+Z / Ctrl+Y", "Undo / Redo"),
|
||||||
|
("Ctrl+A", "Select all"),
|
||||||
|
("Ctrl+O", "Open SQL file"),
|
||||||
|
("Ctrl+S", "Save SQL file"),
|
||||||
|
]),
|
||||||
|
("Navigation", [
|
||||||
|
("Ctrl+N", "New connection"),
|
||||||
|
("Ctrl+T", "New SQL tab"),
|
||||||
|
("Ctrl+W", "Close current tab"),
|
||||||
|
("Ctrl+Tab", "Next tab"),
|
||||||
|
]),
|
||||||
|
("Tools & Panels", [
|
||||||
|
("Ctrl+P", "Process list"),
|
||||||
|
("Ctrl+U", "User management"),
|
||||||
|
("Ctrl+Shift+H", "Toggle query history"),
|
||||||
|
("Ctrl+L", "View app logs"),
|
||||||
|
]),
|
||||||
|
("Table Viewer", [
|
||||||
|
("Ins", "Add new row"),
|
||||||
|
("Delete", "Delete selected row(s)"),
|
||||||
|
("Double-click", "Edit row"),
|
||||||
|
]),
|
||||||
|
("General", [
|
||||||
|
("Ctrl+Q", "Quit"),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ShortcutsDialog(QDialog):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Keyboard Shortcuts")
|
||||||
|
self.setMinimumSize(500, 540)
|
||||||
|
self.resize(540, 580)
|
||||||
|
self._build_ui()
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
root.setContentsMargins(12, 12, 12, 12)
|
||||||
|
root.setSpacing(8)
|
||||||
|
|
||||||
|
tree = QTreeWidget()
|
||||||
|
tree.setColumnCount(2)
|
||||||
|
tree.setHeaderLabels(["Shortcut", "Action"])
|
||||||
|
tree.setRootIsDecorated(True)
|
||||||
|
tree.setAlternatingRowColors(True)
|
||||||
|
tree.setEditTriggers(QTreeWidget.EditTrigger.NoEditTriggers)
|
||||||
|
tree.setSelectionMode(QTreeWidget.SelectionMode.NoSelection)
|
||||||
|
tree.header().setStretchLastSection(True)
|
||||||
|
tree.setIndentation(20)
|
||||||
|
|
||||||
|
bold = QFont()
|
||||||
|
bold.setBold(True)
|
||||||
|
|
||||||
|
for category, items in _SHORTCUTS:
|
||||||
|
cat_item = QTreeWidgetItem([category, ""])
|
||||||
|
cat_item.setFont(0, bold)
|
||||||
|
cat_item.setFlags(cat_item.flags() & ~Qt.ItemFlag.ItemIsSelectable)
|
||||||
|
for key, desc in items:
|
||||||
|
child = QTreeWidgetItem([key, desc])
|
||||||
|
child.setTextAlignment(
|
||||||
|
0, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
||||||
|
)
|
||||||
|
cat_item.addChild(child)
|
||||||
|
tree.addTopLevelItem(cat_item)
|
||||||
|
cat_item.setExpanded(True)
|
||||||
|
|
||||||
|
tree.resizeColumnToContents(0)
|
||||||
|
tree.setColumnWidth(0, max(200, tree.columnWidth(0) + 20))
|
||||||
|
root.addWidget(tree, 1)
|
||||||
|
|
||||||
|
bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
||||||
|
bb.rejected.connect(self.reject)
|
||||||
|
root.addWidget(bb)
|
||||||
@@ -6,6 +6,7 @@ from PyQt6.QtWidgets import (
|
|||||||
QWidget, QVBoxLayout, QHBoxLayout, QPlainTextEdit, QTextEdit,
|
QWidget, QVBoxLayout, QHBoxLayout, QPlainTextEdit, QTextEdit,
|
||||||
QTabWidget, QPushButton, QLabel, QSplitter, QTabBar, QSizePolicy,
|
QTabWidget, QPushButton, QLabel, QSplitter, QTabBar, QSizePolicy,
|
||||||
QFileDialog, QMessageBox, QToolButton, QComboBox, QLineEdit, QCheckBox,
|
QFileDialog, QMessageBox, QToolButton, QComboBox, QLineEdit, QCheckBox,
|
||||||
|
QMenu, QInputDialog,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, QRect, QSize, pyqtSignal, QTimer
|
from PyQt6.QtCore import Qt, QRect, QSize, pyqtSignal, QTimer
|
||||||
from PyQt6.QtGui import (
|
from PyQt6.QtGui import (
|
||||||
@@ -404,6 +405,7 @@ class EditorTab(QWidget):
|
|||||||
self._driver = driver
|
self._driver = driver
|
||||||
self._database = database
|
self._database = database
|
||||||
self._worker: QueryWorker | None = None
|
self._worker: QueryWorker | None = None
|
||||||
|
self._filepath: str | None = None
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
|
|
||||||
def _build_ui(self):
|
def _build_ui(self):
|
||||||
@@ -582,6 +584,14 @@ class SQLEditorWidget(QWidget):
|
|||||||
new_btn.clicked.connect(lambda: self.new_tab())
|
new_btn.clicked.connect(lambda: self.new_tab())
|
||||||
self._tabs.setCornerWidget(new_btn, Qt.Corner.TopRightCorner)
|
self._tabs.setCornerWidget(new_btn, Qt.Corner.TopRightCorner)
|
||||||
|
|
||||||
|
# Right-click context menu on query tab bar
|
||||||
|
self._tabs.tabBar().setContextMenuPolicy(
|
||||||
|
Qt.ContextMenuPolicy.CustomContextMenu
|
||||||
|
)
|
||||||
|
self._tabs.tabBar().customContextMenuRequested.connect(
|
||||||
|
self._tab_context_menu
|
||||||
|
)
|
||||||
|
|
||||||
root.addWidget(self._tabs)
|
root.addWidget(self._tabs)
|
||||||
|
|
||||||
def new_tab(self, driver=None, database: str = "",
|
def new_tab(self, driver=None, database: str = "",
|
||||||
@@ -599,6 +609,44 @@ class SQLEditorWidget(QWidget):
|
|||||||
if self._tabs.count() > 1:
|
if self._tabs.count() > 1:
|
||||||
self._tabs.removeTab(idx)
|
self._tabs.removeTab(idx)
|
||||||
|
|
||||||
|
def _tab_context_menu(self, pos) -> None:
|
||||||
|
idx = self._tabs.tabBar().tabAt(pos)
|
||||||
|
if idx < 0:
|
||||||
|
return
|
||||||
|
menu = QMenu(self)
|
||||||
|
rename_act = menu.addAction("Rename…")
|
||||||
|
dup_act = menu.addAction("Duplicate")
|
||||||
|
menu.addSeparator()
|
||||||
|
close_act = menu.addAction("Close")
|
||||||
|
close_others = menu.addAction("Close Others")
|
||||||
|
|
||||||
|
close_act.setEnabled(self._tabs.count() > 1)
|
||||||
|
close_others.setEnabled(self._tabs.count() > 1)
|
||||||
|
|
||||||
|
act = menu.exec(self._tabs.tabBar().mapToGlobal(pos))
|
||||||
|
if act == rename_act:
|
||||||
|
text, ok = QInputDialog.getText(
|
||||||
|
self, "Rename Tab", "Tab name:",
|
||||||
|
text=self._tabs.tabText(idx),
|
||||||
|
)
|
||||||
|
if ok and text.strip():
|
||||||
|
self._tabs.setTabText(idx, text.strip())
|
||||||
|
elif act == dup_act:
|
||||||
|
tab = self._tabs.widget(idx)
|
||||||
|
if isinstance(tab, EditorTab):
|
||||||
|
new = self.new_tab(
|
||||||
|
tab._driver, tab._database,
|
||||||
|
sql=tab.get_sql(),
|
||||||
|
title=self._tabs.tabText(idx) + " (copy)",
|
||||||
|
)
|
||||||
|
new._filepath = tab._filepath
|
||||||
|
elif act == close_act:
|
||||||
|
self._close_tab(idx)
|
||||||
|
elif act == close_others:
|
||||||
|
for i in range(self._tabs.count() - 1, -1, -1):
|
||||||
|
if i != idx:
|
||||||
|
self._close_tab(i)
|
||||||
|
|
||||||
def current_tab(self) -> EditorTab | None:
|
def current_tab(self) -> EditorTab | None:
|
||||||
w = self._tabs.currentWidget()
|
w = self._tabs.currentWidget()
|
||||||
return w if isinstance(w, EditorTab) else None
|
return w if isinstance(w, EditorTab) else None
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ _PAGE_OPTIONS = [("50", 50), ("100", 100), ("All", 0)]
|
|||||||
|
|
||||||
class TableViewer(QWidget):
|
class TableViewer(QWidget):
|
||||||
status_message = pyqtSignal(str)
|
status_message = pyqtSignal(str)
|
||||||
|
cell_selected = pyqtSignal(str) # emits display value of clicked cell
|
||||||
|
|
||||||
def __init__(self, driver, database: str, table: str, parent=None):
|
def __init__(self, driver, database: str, table: str, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -398,7 +399,7 @@ class TableViewer(QWidget):
|
|||||||
self._table_view.doubleClicked.connect(self._on_double_click)
|
self._table_view.doubleClicked.connect(self._on_double_click)
|
||||||
# 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(lambda _: self._refresh_action_states())
|
self._table_view.clicked.connect(self._on_cell_click)
|
||||||
self._table_view.selectionModel().selectionChanged.connect(
|
self._table_view.selectionModel().selectionChanged.connect(
|
||||||
self._on_selection_changed)
|
self._on_selection_changed)
|
||||||
|
|
||||||
@@ -584,6 +585,12 @@ class TableViewer(QWidget):
|
|||||||
|
|
||||||
# ── Selection tracking ─────────────────────────────────────────────────────
|
# ── Selection tracking ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_cell_click(self, index: "QModelIndex") -> None:
|
||||||
|
self._refresh_action_states()
|
||||||
|
if index.isValid():
|
||||||
|
val = self._model.data(index, Qt.ItemDataRole.DisplayRole)
|
||||||
|
self.cell_selected.emit(str(val) if val is not None else "NULL")
|
||||||
|
|
||||||
def _on_selection_changed(self, *_):
|
def _on_selection_changed(self, *_):
|
||||||
has_sel = bool(self._table_view.selectionModel().selectedRows())
|
has_sel = bool(self._table_view.selectionModel().selectedRows())
|
||||||
self._edit_btn.setEnabled(has_sel)
|
self._edit_btn.setEnabled(has_sel)
|
||||||
|
|||||||
Reference in New Issue
Block a user