Compare commits
10
Commits
972166e62a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fcbe5a879 | ||
|
|
5a536fb9e9 | ||
|
|
59aa235f3d | ||
|
|
82c8a6bf84 | ||
|
|
aae47125cd | ||
|
|
06b951daf7 | ||
|
|
6cff9557b3 | ||
|
|
b7e7411c1b | ||
|
|
9f0891755e | ||
|
|
771c92b7b6 |
@@ -14,6 +14,8 @@ DBClient is a desktop database client built with Python + PyQt6, supporting MySQ
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Key runtime dependencies: `PyQt6`, `sqlparse>=0.5.0`, `bcrypt`, `keyring`, `cryptography`.
|
||||
|
||||
**Run the application:**
|
||||
```bash
|
||||
python main.py
|
||||
@@ -73,7 +75,13 @@ All user data lives under `~/.dbclient/`:
|
||||
The `ResultTableModel` (`app/models/result_table_model.py`) is a `QAbstractTableModel` — query results should always go through it rather than populating `QTableWidget` directly.
|
||||
|
||||
### Styling
|
||||
`resources/style.qss` is a Catppuccin Mocha dark theme applied globally at startup in `main.py`. Widget-specific overrides belong here, not as inline `setStyleSheet()` calls.
|
||||
Theming uses a single `resources/style_template.qss` with `@{token}` placeholders. `app/config/theme.py` defines seven palette dicts (`ALL_THEMES`) and `apply_qss(app)` substitutes tokens at runtime via regex — no duplicate `.qss` files. Available themes: `dark` (Catppuccin Frappé), `light` (Catppuccin Latte), `one_dark`, `nord`, `tokyo_night`, `dracula`, `github_light`. To add a new theme, add a palette dict to `ALL_THEMES` and register it in `_THEMES` in `preferences_dialog.py`. Widget-specific color overrides must call `get_palette()` at render time (not at import time) so they react to live theme switches. `is_dark()` returns True for all dark-family themes.
|
||||
|
||||
### Recent Files (`app/config/recent_files.py`)
|
||||
`load_recent()` / `add_recent(path)` / `clear_recent()` persist to `~/.dbclient/recent_files.json` (max 10 entries, dead paths filtered on load). Called from MainWindow File menu.
|
||||
|
||||
### Settings Cascade
|
||||
`PreferencesDialog` emits `settings_applied` → `MainWindow._apply_settings_to_open_tabs()` iterates workspace tabs and calls `widget.apply_settings()` on each. `SQLEditorWidget.apply_settings()` cascades to each `EditorTab`, which cascades to `CodeEditor`. Add `apply_settings()` to any new workspace widget that reads from `get_settings()`.
|
||||
|
||||
---
|
||||
|
||||
@@ -88,6 +96,18 @@ All four drivers have a `_where(where: dict) → (clause_str, params)` static he
|
||||
### Password hashing in RowDialog
|
||||
`RowDialog` detects password-like column names (via `_is_password_col()`) and hashes plain-text input with bcrypt before storing. In edit mode, leaving a password field empty omits that column from the UPDATE so the existing hash is preserved.
|
||||
|
||||
### ResultsPanel multi-tab architecture
|
||||
`ResultsPanel` uses a `QStackedWidget` with two pages: page 0 = `QTabWidget` (result tabs), page 1 = loading spinner. Single-query results hide the tab bar; script results (`show_script_results()`) show named tabs. Each tab owns its own `ResultTableModel` + `QSortFilterProxyModel`. Export buttons operate on `_current_model()` from the active tab.
|
||||
|
||||
### SQL formatter
|
||||
`EditorTab._format_sql()` uses `sqlparse.format(..., reindent=True, keyword_case='upper')`. Requires `sqlparse>=0.5.0` in requirements.txt.
|
||||
|
||||
### Column statistics worker
|
||||
`_StatsWorker` in `app/ui/column_stats_dialog.py` runs `COUNT/COUNT(col)/COUNT(DISTINCT)/MIN/MAX` in one query and AVG in a second (AVG silently returns None for non-numeric columns). Identifiers are quoted via `driver.quote_identifier(name)` — backticks for MySQL, brackets for MSSQL, double-quotes for PostgreSQL/SQLite — defined on `BaseDriver` with per-driver overrides.
|
||||
|
||||
### Identifier quoting
|
||||
`BaseDriver.quote_identifier(name)` returns `"name"` by default. `MySQLDriver` overrides to `` `name` `` and `MSSQLDriver` to `[name]`. Always use this method when building dynamic SQL with column/table names — never hardcode a quote style.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Status
|
||||
@@ -96,19 +116,47 @@ All four drivers have a `_where(where: dict) → (clause_str, params)` static he
|
||||
- Connection manager (add/edit/delete, test, color coding, keyring storage)
|
||||
- Schema browser (live tree: connections → databases → tables/views/functions/procedures/triggers, search, context menus, delete connection from active node)
|
||||
- SQL editor (multi-tab, syntax highlighting, line numbers, Ctrl+/ comment toggle, F5/Ctrl+Enter execute)
|
||||
- SQL auto-complete (`app/ui/sql_completer.py`): schema-aware popup, dot-notation (schema.table, table.col), Ctrl+Space trigger
|
||||
- Find/Replace bar in SQL editor (`app/ui/find_bar.py`): Ctrl+F find, Ctrl+H replace, wrap-around, case-sensitive toggle
|
||||
- SQL formatter: `≡ Format` button in editor toolbar using sqlparse (reindent + uppercase keywords)
|
||||
- Results panel (sortable `QAbstractTableModel`, export CSV/JSON/SQL INSERT, pagination, execution time)
|
||||
- Multi-result tabs: scripts with multiple SELECTs show each result in a named sub-tab within ResultsPanel
|
||||
- Table viewer (paginated grid 50/100/All, add/edit/delete rows via dialog, WHERE filter, bcrypt password hashing)
|
||||
- Enhanced table context menu: "Filter by this value" (auto-populates WHERE filter), "Copy row as SQL INSERT"
|
||||
- Column statistics dialog (`app/ui/column_stats_dialog.py`): right-click column header → async COUNT/NULL/DISTINCT/MIN/MAX/AVG
|
||||
- Table structure view (columns, indexes, FKs, DDL with syntax highlighting, add/drop/rename column designer)
|
||||
- Query history (auto-log with timestamp/duration/status, search, replay, persisted in `history.db`)
|
||||
- All 4 DB drivers (MySQL, PostgreSQL, SQLite, MSSQL)
|
||||
- Process list viewer (`app/ui/process_list.py`) with 5 s auto-refresh and kill query
|
||||
- Import CSV/JSON dialog (`app/ui/import_dialog.py`) with preview and progress bar
|
||||
- Import CSV/JSON/SQL dialog (`app/ui/import_dialog.py`): CSV/JSON inserts rows into a target table; SQL dump executes statements via `_SqlImportWorker` (sqlparse split, stop-on-error option, per-statement error summary)
|
||||
- Database dump export (`app/ui/dump_dialog.py`): schema/data/both, table selector, progress bar
|
||||
- Tools menu in MainWindow (Process List, Import CSV/JSON, Export Database Dump, User Management)
|
||||
- Tools menu in MainWindow (Process List, Import CSV/JSON, Export Database Dump, User Management, Preferences)
|
||||
- Preferences dialog (`app/ui/preferences_dialog.py`): Ctrl+, / Tools → Preferences; font family/size/word-wrap, page size, timeout, history limit, auto-commit; live-applies to all open editors via `settings_applied` signal
|
||||
- PyInstaller packaging (`DBClient.spec` + `build_app.py`)
|
||||
- User & privilege management (`app/ui/user_manager.py`): list/create/drop users, GRANT/REVOKE per-DB (MySQL + PostgreSQL)
|
||||
- EXPLAIN plan diagram (`app/ui/explain_view.py`): visual node tree + raw table; opened from SQL editor "🔎 Explain" button
|
||||
- Application logging (`app/utils/logger.py` + `app/ui/log_viewer.py`): rotating file logs, unhandled exception hooks, in-app log viewer (Help → View App Logs)
|
||||
- Keyboard shortcuts dialog (`app/ui/shortcuts_dialog.py`): Help → Keyboard Shortcuts; categorised tree of all key bindings
|
||||
- Tab context menus: workspace tabs (Rename/Close/Close Others/Close to Right) + SQL query tabs (Rename/Duplicate/Close/Close Others)
|
||||
- Smart status bar: right-side connection indicator + 60-char cell value preview (updates on cell click)
|
||||
- Connection tab coloring: workspace tab labels colored by connection profile color
|
||||
- File open/save/recent: Ctrl+O open SQL file, Ctrl+S save, File → Open Recent (persisted to `~/.dbclient/recent_files.json`, max 10)
|
||||
- Ctrl+W to close current workspace tab
|
||||
- Catppuccin Frappé theme: full palette upgrade from Mocha; all colors updated in `resources/style.qss`, syntax highlighter, completer popup, and inline Python stylesheets
|
||||
- Row numbers column in results grid: `_RowNumberProxy` shows sequential visual-order numbers in the vertical header (sort-stable); fixed 48 px wide header in both `ResultsPanel` and `TableViewer`
|
||||
- Connection ping indicator: 30-second `_PingWorker` (QThread) pings each active connection via `test_connection()`; status dot (● green / ● red) shown in status bar right side with per-connection tooltip
|
||||
- Frozen first column in `TableViewer`: 📌 toggle button in toolbar; dual-view overlay (`_frozen_view` child of `_table_view`) with synced vertical scroll and row heights; event-filter updates geometry on resize
|
||||
- Keyboard navigation in dialogs: OK set as default button (Enter submits) in `RowDialog` and `ConnectionDialog`; focus jumps to first input field on open; Enter in password field submits `ConnectionDialog`
|
||||
- Multi-theme support: 7 themes (Frappé, Latte, One Dark Pro, Nord, Tokyo Night, Dracula, GitHub Light) via `resources/style_template.qss` token substitution; select in Preferences → Color theme; Ctrl+Shift+T toggles dark↔light; syntax highlighter, completer popup, and model colors all update live via `get_palette()`
|
||||
|
||||
### Not Yet Implemented
|
||||
- (All planned features are now complete)
|
||||
**High impact:**
|
||||
- Auto-reconnect on dropped connection (detect broken pipe, reconnect transparently)
|
||||
- Quick result filter: live filter bar above the results table (client-side, no re-query)
|
||||
- SQL snippets / bookmarks: save and recall frequently used query fragments
|
||||
- ERD viewer: visual entity-relationship diagram from live schema
|
||||
|
||||
**Medium impact:**
|
||||
- Transaction panel: explicit BEGIN / COMMIT / ROLLBACK controls with in-flight indicator
|
||||
- Global schema search: search across all tables/columns/procedures in a database
|
||||
- Schema diff: compare two database schemas and show structural differences
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
"""
|
||||
Theme palettes and QSS application.
|
||||
|
||||
Each theme is a flat dict of color tokens. apply_qss() loads
|
||||
style_template.qss and substitutes @{key} tokens with the palette values.
|
||||
|
||||
Supported themes
|
||||
----------------
|
||||
dark Catppuccin Frappé (dark blue-gray)
|
||||
light Catppuccin Latte (warm light)
|
||||
one_dark One Dark Pro (VS Code classic dark)
|
||||
nord Nord (arctic blue-gray dark)
|
||||
tokyo_night Tokyo Night (purple-dark)
|
||||
dracula Dracula (classic purple-dark)
|
||||
github_light GitHub Light (clean minimal light)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_QSS_DIR = Path(__file__).parent.parent.parent / "resources"
|
||||
|
||||
# ── Palettes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# Catppuccin Frappé — dark blue-gray
|
||||
_FRAPPÉ: dict = {
|
||||
"base": "#303446", "mantle": "#292c3c", "crust": "#232634",
|
||||
"surface0": "#414559", "surface1": "#51576d", "surface2": "#626880",
|
||||
"overlay": "#737994", "subtext": "#a5adce", "text": "#c6d0f5",
|
||||
"blue": "#8caaee", "lavender": "#babbf1", "green": "#a6d189",
|
||||
"teal": "#81c8be", "sky": "#99d1db", "red": "#e78284",
|
||||
"peach": "#ef9f76", "yellow": "#e5c890", "mauve": "#ca9ee6",
|
||||
"red_hover": "#ea999c",
|
||||
"tab_hover": "#353849", "tree_hover": "#363a4f", "btn_pressed": "#363849",
|
||||
"gutter_bg": "#2a2d3e",
|
||||
"dirty_bg": "#3a2f25", "dirty_fg": "#ef9f76",
|
||||
"delete_bg": "#3a2530", "delete_fg": "#e78284",
|
||||
"syn_keyword": "#8caaee", "syn_type": "#ef9f76",
|
||||
"syn_function": "#a6d189", "syn_string": "#a6d189",
|
||||
"syn_number": "#ef9f76", "syn_comment": "#737994",
|
||||
"syn_operator": "#ca9ee6", "syn_ident": "#99d1db",
|
||||
"popup_bg": "#292c3c", "popup_border": "#51576d",
|
||||
"popup_selected": "#414559", "popup_hover": "#363a4f",
|
||||
}
|
||||
|
||||
# Catppuccin Latte — warm light
|
||||
_LATTE: dict = {
|
||||
"base": "#eff1f5", "mantle": "#e6e9ef", "crust": "#dce0e8",
|
||||
"surface0": "#ccd0da", "surface1": "#bcc0cc", "surface2": "#acb0be",
|
||||
"overlay": "#9ca0b0", "subtext": "#6c6f85", "text": "#4c4f69",
|
||||
"blue": "#1e66f5", "lavender": "#7287fd", "green": "#40a02b",
|
||||
"teal": "#179299", "sky": "#04a5e5", "red": "#d20f39",
|
||||
"peach": "#fe640b", "yellow": "#df8e1d", "mauve": "#8839ef",
|
||||
"red_hover": "#da2b50",
|
||||
"tab_hover": "#d8dce8", "tree_hover": "#d8dce8", "btn_pressed": "#c8cdd9",
|
||||
"gutter_bg": "#dce0e8",
|
||||
"dirty_bg": "#fff3e6", "dirty_fg": "#fe640b",
|
||||
"delete_bg": "#ffe8ed", "delete_fg": "#d20f39",
|
||||
"syn_keyword": "#1e66f5", "syn_type": "#fe640b",
|
||||
"syn_function": "#40a02b", "syn_string": "#40a02b",
|
||||
"syn_number": "#fe640b", "syn_comment": "#9ca0b0",
|
||||
"syn_operator": "#8839ef", "syn_ident": "#04a5e5",
|
||||
"popup_bg": "#e6e9ef", "popup_border": "#bcc0cc",
|
||||
"popup_selected": "#ccd0da", "popup_hover": "#d8dce8",
|
||||
}
|
||||
|
||||
# One Dark Pro — VS Code classic
|
||||
_ONE_DARK: dict = {
|
||||
"base": "#282c34", "mantle": "#21252b", "crust": "#1d2026",
|
||||
"surface0": "#2c313c", "surface1": "#3e4452", "surface2": "#4b5363",
|
||||
"overlay": "#5c6370", "subtext": "#828997", "text": "#abb2bf",
|
||||
"blue": "#61afef", "lavender": "#c678dd", "green": "#98c379",
|
||||
"teal": "#56b6c2", "sky": "#56b6c2", "red": "#e06c75",
|
||||
"peach": "#d19a66", "yellow": "#e5c07b", "mauve": "#c678dd",
|
||||
"red_hover": "#e8868f",
|
||||
"tab_hover": "#2e3440", "tree_hover": "#2c313c", "btn_pressed": "#282c34",
|
||||
"gutter_bg": "#1e2127",
|
||||
"dirty_bg": "#3a2f1e", "dirty_fg": "#d19a66",
|
||||
"delete_bg": "#3a1e20", "delete_fg": "#e06c75",
|
||||
"syn_keyword": "#c678dd", "syn_type": "#e5c07b",
|
||||
"syn_function": "#61afef", "syn_string": "#98c379",
|
||||
"syn_number": "#d19a66", "syn_comment": "#5c6370",
|
||||
"syn_operator": "#56b6c2", "syn_ident": "#abb2bf",
|
||||
"popup_bg": "#21252b", "popup_border": "#3e4452",
|
||||
"popup_selected": "#2c313c", "popup_hover": "#2e3440",
|
||||
}
|
||||
|
||||
# Nord — arctic blue-gray
|
||||
_NORD: dict = {
|
||||
"base": "#2e3440", "mantle": "#292e3b", "crust": "#242831",
|
||||
"surface0": "#3b4252", "surface1": "#434c5e", "surface2": "#4c566a",
|
||||
"overlay": "#616e88", "subtext": "#d8dee9", "text": "#eceff4",
|
||||
"blue": "#88c0d0", "lavender": "#b48ead", "green": "#a3be8c",
|
||||
"teal": "#8fbcbb", "sky": "#81a1c1", "red": "#bf616a",
|
||||
"peach": "#d08770", "yellow": "#ebcb8b", "mauve": "#b48ead",
|
||||
"red_hover": "#ca737b",
|
||||
"tab_hover": "#333a47", "tree_hover": "#323844", "btn_pressed": "#2e3440",
|
||||
"gutter_bg": "#272c38",
|
||||
"dirty_bg": "#3a3320", "dirty_fg": "#d08770",
|
||||
"delete_bg": "#3a2730", "delete_fg": "#bf616a",
|
||||
"syn_keyword": "#81a1c1", "syn_type": "#ebcb8b",
|
||||
"syn_function": "#88c0d0", "syn_string": "#a3be8c",
|
||||
"syn_number": "#b48ead", "syn_comment": "#616e88",
|
||||
"syn_operator": "#8fbcbb", "syn_ident": "#eceff4",
|
||||
"popup_bg": "#292e3b", "popup_border": "#434c5e",
|
||||
"popup_selected": "#3b4252", "popup_hover": "#323844",
|
||||
}
|
||||
|
||||
# Tokyo Night — deep purple-dark
|
||||
_TOKYO_NIGHT: dict = {
|
||||
"base": "#1a1b2e", "mantle": "#16161e", "crust": "#13131a",
|
||||
"surface0": "#24283b", "surface1": "#292e42", "surface2": "#2f3549",
|
||||
"overlay": "#565f89", "subtext": "#9aa5ce", "text": "#c0caf5",
|
||||
"blue": "#7aa2f7", "lavender": "#bb9af7", "green": "#9ece6a",
|
||||
"teal": "#73daca", "sky": "#7dcfff", "red": "#f7768e",
|
||||
"peach": "#ff9e64", "yellow": "#e0af68", "mauve": "#bb9af7",
|
||||
"red_hover": "#f98da0",
|
||||
"tab_hover": "#1e2030", "tree_hover": "#1e2030", "btn_pressed": "#1a1b2e",
|
||||
"gutter_bg": "#16161e",
|
||||
"dirty_bg": "#2a2216", "dirty_fg": "#ff9e64",
|
||||
"delete_bg": "#2a1620", "delete_fg": "#f7768e",
|
||||
"syn_keyword": "#bb9af7", "syn_type": "#e0af68",
|
||||
"syn_function": "#7aa2f7", "syn_string": "#9ece6a",
|
||||
"syn_number": "#ff9e64", "syn_comment": "#565f89",
|
||||
"syn_operator": "#73daca", "syn_ident": "#c0caf5",
|
||||
"popup_bg": "#16161e", "popup_border": "#292e42",
|
||||
"popup_selected": "#24283b", "popup_hover": "#1e2030",
|
||||
}
|
||||
|
||||
# Dracula — classic purple-dark
|
||||
_DRACULA: dict = {
|
||||
"base": "#282a36", "mantle": "#21222c", "crust": "#191a21",
|
||||
"surface0": "#343746", "surface1": "#424450", "surface2": "#54555f",
|
||||
"overlay": "#6272a4", "subtext": "#bfbfbf", "text": "#f8f8f2",
|
||||
"blue": "#8be9fd", "lavender": "#bd93f9", "green": "#50fa7b",
|
||||
"teal": "#8be9fd", "sky": "#8be9fd", "red": "#ff5555",
|
||||
"peach": "#ffb86c", "yellow": "#f1fa8c", "mauve": "#bd93f9",
|
||||
"red_hover": "#ff7373",
|
||||
"tab_hover": "#2d2f3e", "tree_hover": "#2d2f3e", "btn_pressed": "#21222c",
|
||||
"gutter_bg": "#20212b",
|
||||
"dirty_bg": "#3a3020", "dirty_fg": "#ffb86c",
|
||||
"delete_bg": "#3a1e1e", "delete_fg": "#ff5555",
|
||||
"syn_keyword": "#ff79c6", "syn_type": "#ffb86c",
|
||||
"syn_function": "#50fa7b", "syn_string": "#f1fa8c",
|
||||
"syn_number": "#bd93f9", "syn_comment": "#6272a4",
|
||||
"syn_operator": "#ff79c6", "syn_ident": "#8be9fd",
|
||||
"popup_bg": "#21222c", "popup_border": "#424450",
|
||||
"popup_selected": "#343746", "popup_hover": "#2d2f3e",
|
||||
}
|
||||
|
||||
# GitHub Light — clean minimal light
|
||||
_GITHUB_LIGHT: dict = {
|
||||
"base": "#ffffff", "mantle": "#f6f8fa", "crust": "#eaeef2",
|
||||
"surface0": "#f0f3f6", "surface1": "#d0d7de", "surface2": "#afb8c1",
|
||||
"overlay": "#8c959f", "subtext": "#57606a", "text": "#24292f",
|
||||
"blue": "#0969da", "lavender": "#8250df", "green": "#1a7f37",
|
||||
"teal": "#0a7bca", "sky": "#0550ae", "red": "#cf222e",
|
||||
"peach": "#bc4c00", "yellow": "#9a6700", "mauve": "#8250df",
|
||||
"red_hover": "#d93f47",
|
||||
"tab_hover": "#eaeef2", "tree_hover": "#eaeef2", "btn_pressed": "#e0e6ec",
|
||||
"gutter_bg": "#f0f3f6",
|
||||
"dirty_bg": "#fff8e1", "dirty_fg": "#bc4c00",
|
||||
"delete_bg": "#fde8e8", "delete_fg": "#cf222e",
|
||||
"syn_keyword": "#cf222e", "syn_type": "#953800",
|
||||
"syn_function": "#8250df", "syn_string": "#0a3069",
|
||||
"syn_number": "#0550ae", "syn_comment": "#8c959f",
|
||||
"syn_operator": "#cf222e", "syn_ident": "#0969da",
|
||||
"popup_bg": "#f6f8fa", "popup_border": "#d0d7de",
|
||||
"popup_selected": "#e8ecf0", "popup_hover": "#eaeef2",
|
||||
}
|
||||
|
||||
# Registry: theme key → palette dict
|
||||
ALL_THEMES: dict[str, dict] = {
|
||||
"dark": _FRAPPÉ,
|
||||
"light": _LATTE,
|
||||
"one_dark": _ONE_DARK,
|
||||
"nord": _NORD,
|
||||
"tokyo_night": _TOKYO_NIGHT,
|
||||
"dracula": _DRACULA,
|
||||
"github_light": _GITHUB_LIGHT,
|
||||
}
|
||||
|
||||
# Which theme keys are considered "dark"
|
||||
_DARK_KEYS = {"dark", "one_dark", "nord", "tokyo_night", "dracula"}
|
||||
|
||||
# Kept for backward compat (imported elsewhere as DARK / LIGHT)
|
||||
DARK = _FRAPPÉ
|
||||
LIGHT = _LATTE
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_palette() -> dict:
|
||||
"""Return the color dict for the currently active theme."""
|
||||
from app.config.settings import get_settings
|
||||
name = get_settings().get("theme", "dark")
|
||||
return ALL_THEMES.get(name, _FRAPPÉ)
|
||||
|
||||
|
||||
def is_dark(theme: str | None = None) -> bool:
|
||||
from app.config.settings import get_settings
|
||||
if theme is None:
|
||||
theme = get_settings().get("theme", "dark")
|
||||
return theme in _DARK_KEYS
|
||||
|
||||
|
||||
def apply_qss(app, theme: str | None = None) -> None:
|
||||
"""Load the QSS template, substitute palette tokens, and apply to *app*."""
|
||||
from app.config.settings import get_settings
|
||||
if theme is None:
|
||||
theme = get_settings().get("theme", "dark")
|
||||
palette = ALL_THEMES.get(theme, _FRAPPÉ)
|
||||
|
||||
template_path = _QSS_DIR / "style_template.qss"
|
||||
if template_path.exists():
|
||||
template = template_path.read_text(encoding="utf-8")
|
||||
qss = re.sub(r"@\{(\w+)\}", lambda m: palette.get(m.group(1), m.group(0)), template)
|
||||
app.setStyleSheet(qss)
|
||||
return
|
||||
|
||||
# Fallback: legacy per-file approach
|
||||
legacy = _QSS_DIR / ("style.qss" if is_dark(theme) else "style_light.qss")
|
||||
if legacy.exists():
|
||||
app.setStyleSheet(legacy.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def qss_path(theme: str) -> Path:
|
||||
"""Legacy helper — kept so old call-sites don't break."""
|
||||
return _QSS_DIR / ("style.qss" if is_dark(theme) else "style_light.qss")
|
||||
@@ -87,6 +87,10 @@ class BaseDriver(ABC):
|
||||
"""Returns list of database name strings."""
|
||||
pass
|
||||
|
||||
def quote_identifier(self, name: str) -> str:
|
||||
"""Wrap an identifier in the DB-appropriate quote characters."""
|
||||
return f'"{name}"'
|
||||
|
||||
@abstractmethod
|
||||
def get_tables(self, database: str) -> list:
|
||||
"""Returns list of TableInfo for the given database."""
|
||||
|
||||
@@ -19,6 +19,9 @@ class MSSQLDriver(BaseDriver):
|
||||
super().__init__(config)
|
||||
self.db_type = "mssql"
|
||||
|
||||
def quote_identifier(self, name: str) -> str:
|
||||
return f"[{name}]"
|
||||
|
||||
def _conn_str(self) -> str:
|
||||
host = self.config.get("host", "localhost")
|
||||
port = int(self.config.get("port", 1433))
|
||||
|
||||
@@ -18,6 +18,9 @@ class MySQLDriver(BaseDriver):
|
||||
super().__init__(config)
|
||||
self.db_type = "mysql"
|
||||
|
||||
def quote_identifier(self, name: str) -> str:
|
||||
return f"`{name}`"
|
||||
|
||||
def _connect_kwargs(self) -> dict:
|
||||
kw = {
|
||||
"host": self.config.get("host", "localhost"),
|
||||
|
||||
+120
-2
@@ -15,8 +15,9 @@ from PyQt6.QtWidgets import (
|
||||
QTabWidget, QStatusBar, QLabel, QMessageBox, QDockWidget,
|
||||
QPushButton, QApplication, QMenu, QInputDialog, QFileDialog,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal
|
||||
from PyQt6.QtGui import QAction, QColor, QKeySequence
|
||||
from app.config.theme import apply_qss, is_dark
|
||||
|
||||
from app.ui.schema_browser import SchemaBrowser
|
||||
from app.ui.sql_editor import SQLEditorWidget
|
||||
@@ -30,7 +31,8 @@ from app.ui.explain_view import ExplainPanel
|
||||
from app.ui.user_manager import UserManagerPanel
|
||||
from app.ui.log_viewer import LogViewer
|
||||
from app.ui.connection_dialog import ConnectionDialog
|
||||
from app.ui.shortcuts_dialog import ShortcutsDialog
|
||||
from app.ui.shortcuts_dialog import ShortcutsDialog
|
||||
from app.ui.preferences_dialog import PreferencesDialog
|
||||
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
|
||||
@@ -40,6 +42,23 @@ from app.utils.logger import get_logger
|
||||
_log = get_logger(__name__)
|
||||
|
||||
|
||||
class _PingWorker(QThread):
|
||||
"""Background thread that pings one connection and emits the result."""
|
||||
pinged = pyqtSignal(str, bool) # (profile_id, ok)
|
||||
|
||||
def __init__(self, pid: str, driver, parent=None):
|
||||
super().__init__(parent)
|
||||
self._pid = pid
|
||||
self._driver = driver
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
ok, _ = self._driver.test_connection()
|
||||
self.pinged.emit(self._pid, ok)
|
||||
except Exception:
|
||||
self.pinged.emit(self._pid, False)
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
|
||||
def __init__(self):
|
||||
@@ -52,11 +71,19 @@ class MainWindow(QMainWindow):
|
||||
self._active_drivers: dict = {}
|
||||
# profile_id → ConnectionProfile (all loaded profiles, connected or not)
|
||||
self._all_profiles: dict = {}
|
||||
# ping state
|
||||
self._ping_results: dict = {} # profile_id → bool
|
||||
self._ping_workers: list = [] # keep QThread refs alive
|
||||
|
||||
self._build_ui()
|
||||
self._build_menus()
|
||||
self._build_status_bar()
|
||||
|
||||
# Periodic connection heartbeat (every 30 s)
|
||||
self._ping_timer = QTimer(self)
|
||||
self._ping_timer.timeout.connect(self._ping_all_connections)
|
||||
self._ping_timer.start(30_000)
|
||||
|
||||
# Load saved profiles after the window is shown
|
||||
QTimer.singleShot(0, self._load_saved_profiles)
|
||||
|
||||
@@ -181,9 +208,18 @@ 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"))
|
||||
view_menu.addSeparator()
|
||||
self._theme_action = QAction("", self)
|
||||
self._theme_action.setShortcut(QKeySequence("Ctrl+Shift+T"))
|
||||
self._theme_action.triggered.connect(self._toggle_theme)
|
||||
self._update_theme_action_label()
|
||||
view_menu.addAction(self._theme_action)
|
||||
|
||||
# ── Tools ─────────────────────────────────────────────────────────────
|
||||
tools_menu = mb.addMenu("&Tools")
|
||||
tools_menu.addAction(self._act("Preferences…", self._open_preferences, "Ctrl+,"))
|
||||
tools_menu.addSeparator()
|
||||
tools_menu.addAction(self._act("Process List…", self._open_process_list, "Ctrl+P"))
|
||||
tools_menu.addSeparator()
|
||||
tools_menu.addAction(self._act("Import CSV / JSON…", self._open_import_dialog))
|
||||
@@ -230,6 +266,12 @@ class MainWindow(QMainWindow):
|
||||
self._conn_lbl.setMinimumWidth(160)
|
||||
sb.addPermanentWidget(self._conn_lbl)
|
||||
|
||||
self._ping_lbl = QLabel("")
|
||||
self._ping_lbl.setFixedWidth(16)
|
||||
self._ping_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self._ping_lbl.setToolTip("Connection heartbeat")
|
||||
sb.addPermanentWidget(self._ping_lbl)
|
||||
|
||||
def _set_status(self, msg: str):
|
||||
self._status_lbl.setText(msg)
|
||||
|
||||
@@ -400,6 +442,7 @@ class MainWindow(QMainWindow):
|
||||
else:
|
||||
self._schema_browser.remove_connection(profile_id, keep_saved=False)
|
||||
self._all_profiles.pop(profile_id, None)
|
||||
self._ping_results.pop(profile_id, None)
|
||||
delete_profile(profile_id)
|
||||
self._set_status(f"Connection '{profile.name}' deleted.")
|
||||
|
||||
@@ -467,6 +510,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":
|
||||
@@ -475,6 +523,44 @@ class MainWindow(QMainWindow):
|
||||
preview = value[:60] + ("…" if len(value) > 60 else "")
|
||||
self._cell_lbl.setText(f" {preview}")
|
||||
|
||||
# ── Connection heartbeat ──────────────────────────────────────────────────
|
||||
|
||||
def _ping_all_connections(self):
|
||||
# Drop finished workers
|
||||
self._ping_workers = [w for w in self._ping_workers if w.isRunning()]
|
||||
if not self._active_drivers:
|
||||
self._ping_lbl.setText("")
|
||||
return
|
||||
for pid, driver in list(self._active_drivers.items()):
|
||||
# Skip if a ping for this connection is already in flight
|
||||
if any(getattr(w, "_pid", None) == pid for w in self._ping_workers):
|
||||
continue
|
||||
worker = _PingWorker(pid, driver)
|
||||
worker.pinged.connect(self._on_ping_result)
|
||||
worker.start()
|
||||
self._ping_workers.append(worker)
|
||||
|
||||
def _on_ping_result(self, pid: str, ok: bool):
|
||||
if pid not in self._active_drivers:
|
||||
return
|
||||
self._ping_results[pid] = ok
|
||||
all_ok = all(self._ping_results.get(p, True) for p in self._active_drivers)
|
||||
color = "#a6d189" if all_ok else "#e78284"
|
||||
self._ping_lbl.setText("●")
|
||||
self._ping_lbl.setStyleSheet(f"color: {color}; font-size: 11pt;")
|
||||
lines = []
|
||||
for p_id in self._active_drivers:
|
||||
profile = self._all_profiles.get(p_id)
|
||||
name = profile.name if profile else p_id
|
||||
icon = "✅" if self._ping_results.get(p_id, True) else "❌"
|
||||
lines.append(f"{icon} {name}")
|
||||
self._ping_lbl.setToolTip("Connections:\n" + "\n".join(lines))
|
||||
if not ok:
|
||||
profile = self._all_profiles.get(pid)
|
||||
name = profile.name if profile else pid
|
||||
_log.warning("Heartbeat failed for '%s'", name)
|
||||
self._set_status(f"⚠️ Connection heartbeat failed: {name}")
|
||||
|
||||
# ── File open / save ──────────────────────────────────────────────────────
|
||||
|
||||
def _open_sql_file(self, filepath: str = None):
|
||||
@@ -581,6 +667,38 @@ class MainWindow(QMainWindow):
|
||||
"MySQL · PostgreSQL · SQLite · SQL Server<br><br>"
|
||||
"Built with Python + PyQt6.")
|
||||
|
||||
def _open_preferences(self):
|
||||
dlg = PreferencesDialog(parent=self)
|
||||
dlg.settings_applied.connect(self._on_settings_applied)
|
||||
dlg.exec()
|
||||
|
||||
def _on_settings_applied(self):
|
||||
apply_qss(QApplication.instance())
|
||||
self._update_theme_action_label()
|
||||
self._apply_settings_to_open_tabs()
|
||||
|
||||
def _toggle_theme(self):
|
||||
from app.config.settings import get_settings
|
||||
new_theme = "light" if is_dark() else "dark"
|
||||
get_settings().set("theme", new_theme)
|
||||
get_settings().save()
|
||||
apply_qss(QApplication.instance(), new_theme)
|
||||
self._update_theme_action_label()
|
||||
self._apply_settings_to_open_tabs()
|
||||
|
||||
def _update_theme_action_label(self):
|
||||
if is_dark():
|
||||
self._theme_action.setText("☀️ Switch to Light Theme")
|
||||
else:
|
||||
self._theme_action.setText("🌙 Switch to Dark Theme")
|
||||
|
||||
def _apply_settings_to_open_tabs(self):
|
||||
"""Push updated settings to all currently open workspace tabs."""
|
||||
for i in range(self._workspace.count()):
|
||||
widget = self._workspace.widget(i)
|
||||
if hasattr(widget, "apply_settings"):
|
||||
widget.apply_settings()
|
||||
|
||||
def _show_shortcuts(self):
|
||||
dlg = ShortcutsDialog(parent=self)
|
||||
dlg.exec()
|
||||
|
||||
@@ -7,11 +7,15 @@ from PyQt6.QtCore import (
|
||||
)
|
||||
from PyQt6.QtGui import QColor, QFont
|
||||
|
||||
from app.config.theme import get_palette
|
||||
|
||||
|
||||
class ResultTableModel(QAbstractTableModel):
|
||||
"""Immutable result-set model — replaces data via set_data()."""
|
||||
|
||||
NULL_COLOR = QColor("#737994") # muted grey for NULL
|
||||
@property
|
||||
def NULL_COLOR(self):
|
||||
return QColor(get_palette()["overlay"])
|
||||
NUM_ALIGN = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
||||
TEXT_ALIGN = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""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):
|
||||
q = self._driver.quote_identifier
|
||||
col = q(self._column)
|
||||
tbl = q(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;")
|
||||
@@ -88,6 +88,9 @@ class ConnectionDialog(QDialog):
|
||||
)
|
||||
bbox.accepted.connect(self._accept)
|
||||
bbox.rejected.connect(self.reject)
|
||||
ok_btn = bbox.button(QDialogButtonBox.StandardButton.Ok)
|
||||
if ok_btn:
|
||||
ok_btn.setDefault(True)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addWidget(self._test_btn)
|
||||
@@ -167,6 +170,12 @@ class ConnectionDialog(QDialog):
|
||||
self._timeout.setSuffix(" sec")
|
||||
form.addRow("Timeout:", self._timeout)
|
||||
|
||||
# Enter in password submits the dialog
|
||||
self._password.returnPressed.connect(self._accept)
|
||||
|
||||
# Focus starts on the name field
|
||||
self._name.setFocus()
|
||||
|
||||
return w
|
||||
|
||||
def _build_ssl_tab(self) -> QWidget:
|
||||
|
||||
+294
-80
@@ -1,10 +1,13 @@
|
||||
"""
|
||||
Import CSV / JSON into a database table.
|
||||
Import CSV / JSON / SQL dump into a database.
|
||||
|
||||
Flow:
|
||||
1. User picks a file (.csv or .json)
|
||||
2. A preview of the first N rows is shown
|
||||
3. User confirms → rows are inserted via the driver's insert_row()
|
||||
CSV / JSON flow:
|
||||
1. Pick file → parse → table preview → insert rows via driver.insert_row()
|
||||
|
||||
SQL dump flow:
|
||||
1. Pick .sql file → split into statements (sqlparse) → text preview
|
||||
2. Execute each statement via driver.execute_query()
|
||||
3. Report progress; optional "stop on first error" mode
|
||||
"""
|
||||
import csv
|
||||
import json
|
||||
@@ -14,18 +17,21 @@ from PyQt6.QtWidgets import (
|
||||
QLabel, QLineEdit, QPushButton, QComboBox,
|
||||
QTableWidget, QTableWidgetItem, QHeaderView,
|
||||
QDialogButtonBox, QFileDialog, QProgressBar,
|
||||
QMessageBox, QCheckBox,
|
||||
QMessageBox, QCheckBox, QStackedWidget, QPlainTextEdit,
|
||||
QWidget,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal
|
||||
from PyQt6.QtGui import QFont
|
||||
|
||||
_PREVIEW_ROWS = 50
|
||||
_PREVIEW_ROWS = 50
|
||||
_PREVIEW_SQL_LINES = 200 # lines of SQL text shown in preview
|
||||
|
||||
|
||||
# ── Background import worker ──────────────────────────────────────────────────
|
||||
# ── CSV / JSON row-insert worker ──────────────────────────────────────────────
|
||||
|
||||
class _ImportWorker(QThread):
|
||||
progress = pyqtSignal(int) # rows inserted so far
|
||||
finished = pyqtSignal(int) # total rows inserted
|
||||
class _RowImportWorker(QThread):
|
||||
progress = pyqtSignal(int) # rows inserted so far
|
||||
finished = pyqtSignal(int) # total inserted
|
||||
error = pyqtSignal(str)
|
||||
|
||||
def __init__(self, driver, database: str, table: str,
|
||||
@@ -34,7 +40,7 @@ class _ImportWorker(QThread):
|
||||
self._driver = driver
|
||||
self._database = database
|
||||
self._table = table
|
||||
self._rows = rows # list of dicts {col: value}
|
||||
self._rows = rows
|
||||
|
||||
def run(self):
|
||||
inserted = 0
|
||||
@@ -49,34 +55,89 @@ class _ImportWorker(QThread):
|
||||
self.error.emit(f"Row {inserted + 1}: {e}")
|
||||
|
||||
|
||||
# ── SQL dump execution worker ─────────────────────────────────────────────────
|
||||
|
||||
class _SqlImportWorker(QThread):
|
||||
progress = pyqtSignal(int) # statements executed so far
|
||||
stmt_error = pyqtSignal(int, str) # (stmt_index, message) — non-fatal
|
||||
finished = pyqtSignal(int, int) # (executed, total)
|
||||
fatal = pyqtSignal(str) # stopped on error
|
||||
|
||||
def __init__(self, driver, database: str, sql_text: str,
|
||||
stop_on_error: bool = False, parent=None):
|
||||
super().__init__(parent)
|
||||
self._driver = driver
|
||||
self._database = database
|
||||
self._sql_text = sql_text
|
||||
self._stop_on_error = stop_on_error
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
import sqlparse
|
||||
stmts = [s.strip() for s in sqlparse.split(self._sql_text) if s.strip()]
|
||||
except ImportError:
|
||||
# Fallback: naive semicolon split
|
||||
stmts = [s.strip() for s in self._sql_text.split(";") if s.strip()]
|
||||
|
||||
total = len(stmts)
|
||||
executed = 0
|
||||
for i, stmt in enumerate(stmts):
|
||||
try:
|
||||
self._driver.execute_query(stmt)
|
||||
executed += 1
|
||||
except Exception as e:
|
||||
self.stmt_error.emit(i, str(e))
|
||||
if self._stop_on_error:
|
||||
self.fatal.emit(
|
||||
f"Stopped at statement {i + 1}/{total}:\n{e}"
|
||||
)
|
||||
return
|
||||
if (i + 1) % 20 == 0:
|
||||
self.progress.emit(i + 1)
|
||||
|
||||
self.progress.emit(total)
|
||||
self.finished.emit(executed, total)
|
||||
|
||||
|
||||
# ── Dialog ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class ImportDialog(QDialog):
|
||||
"""Select a CSV or JSON file and import its contents into a table."""
|
||||
"""Import CSV, JSON, or SQL dump into the database."""
|
||||
|
||||
def __init__(self, driver, database: str, table: str, parent=None):
|
||||
# Internal mode constants
|
||||
_MODE_TABLE = 0 # CSV / JSON
|
||||
_MODE_SQL = 1 # SQL dump
|
||||
|
||||
def __init__(self, driver, database: str, table: str = "", parent=None):
|
||||
super().__init__(parent)
|
||||
self._driver = driver
|
||||
self._database = database
|
||||
self._table = table
|
||||
self._rows: list = [] # parsed rows ready for import
|
||||
self._worker = None
|
||||
self._rows: list = [] # parsed rows (CSV/JSON mode)
|
||||
self._sql_text = "" # raw SQL text (SQL mode)
|
||||
self._sql_stmts = 0 # statement count (SQL mode)
|
||||
self._worker = None
|
||||
self._mode = self._MODE_TABLE
|
||||
self._errors: list[str] = [] # non-fatal SQL errors collected
|
||||
|
||||
self.setWindowTitle(f"Import into {table}")
|
||||
self.setWindowTitle("Import Data")
|
||||
self.setModal(True)
|
||||
self.setMinimumSize(640, 480)
|
||||
self.setMinimumSize(680, 520)
|
||||
self._build_ui()
|
||||
|
||||
# ── UI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setSpacing(8)
|
||||
|
||||
# File picker row
|
||||
# ── File picker ───────────────────────────────────────────────────────
|
||||
file_row = QHBoxLayout()
|
||||
self._path_edit = QLineEdit()
|
||||
self._path_edit.setReadOnly(True)
|
||||
self._path_edit.setPlaceholderText("Select a .csv or .json file…")
|
||||
self._path_edit.setPlaceholderText(
|
||||
"Select a .csv, .json, or .sql file…"
|
||||
)
|
||||
browse_btn = QPushButton("Browse…")
|
||||
browse_btn.setFixedWidth(80)
|
||||
browse_btn.clicked.connect(self._browse)
|
||||
@@ -84,38 +145,56 @@ class ImportDialog(QDialog):
|
||||
file_row.addWidget(browse_btn)
|
||||
root.addLayout(file_row)
|
||||
|
||||
# CSV options (hidden until a CSV is selected)
|
||||
self._csv_opts = QHBoxLayout()
|
||||
# ── CSV-only options ──────────────────────────────────────────────────
|
||||
self._csv_opts_widget = self._build_csv_opts()
|
||||
root.addWidget(self._csv_opts_widget)
|
||||
self._csv_opts_widget.setVisible(False)
|
||||
root.addWidget(self._csv_opts_widget)
|
||||
|
||||
# Skip-header checkbox
|
||||
self._header_cb = QCheckBox("First row is a header (CSV only)")
|
||||
self._header_cb.setChecked(True)
|
||||
self._header_cb.toggled.connect(self._reload_preview)
|
||||
self._header_cb.setVisible(False)
|
||||
root.addWidget(self._header_cb)
|
||||
|
||||
# Preview table
|
||||
root.addWidget(QLabel("Preview (first 50 rows):"))
|
||||
self._preview = QTableWidget(0, 0)
|
||||
self._preview.setAlternatingRowColors(True)
|
||||
self._preview.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||
self._preview.horizontalHeader().setSectionResizeMode(
|
||||
QHeaderView.ResizeMode.ResizeToContents
|
||||
)
|
||||
self._preview.verticalHeader().setDefaultSectionSize(22)
|
||||
self._preview.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
|
||||
root.addWidget(self._preview, 1)
|
||||
# ── SQL-only options ──────────────────────────────────────────────────
|
||||
self._sql_opts_widget = self._build_sql_opts()
|
||||
self._sql_opts_widget.setVisible(False)
|
||||
root.addWidget(self._sql_opts_widget)
|
||||
|
||||
# Status / progress
|
||||
# ── Preview stack: page 0 = table, page 1 = SQL text ─────────────────
|
||||
self._preview_label = QLabel("Preview:")
|
||||
root.addWidget(self._preview_label)
|
||||
|
||||
self._preview_stack = QStackedWidget()
|
||||
|
||||
self._preview_table = QTableWidget(0, 0)
|
||||
self._preview_table.setAlternatingRowColors(True)
|
||||
self._preview_table.setEditTriggers(
|
||||
QTableWidget.EditTrigger.NoEditTriggers)
|
||||
self._preview_table.horizontalHeader().setSectionResizeMode(
|
||||
QHeaderView.ResizeMode.ResizeToContents)
|
||||
self._preview_table.verticalHeader().setDefaultSectionSize(22)
|
||||
self._preview_table.setSelectionMode(
|
||||
QTableWidget.SelectionMode.NoSelection)
|
||||
self._preview_stack.addWidget(self._preview_table) # idx 0
|
||||
|
||||
self._preview_sql = QPlainTextEdit()
|
||||
self._preview_sql.setReadOnly(True)
|
||||
self._preview_sql.setFont(QFont("Consolas", 10))
|
||||
self._preview_sql.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
|
||||
self._preview_stack.addWidget(self._preview_sql) # idx 1
|
||||
|
||||
root.addWidget(self._preview_stack, 1)
|
||||
|
||||
# ── Status / progress ─────────────────────────────────────────────────
|
||||
self._status_lbl = QLabel("")
|
||||
root.addWidget(self._status_lbl)
|
||||
|
||||
self._progress = QProgressBar()
|
||||
self._progress.setVisible(False)
|
||||
root.addWidget(self._progress)
|
||||
|
||||
# Buttons
|
||||
# ── Buttons ───────────────────────────────────────────────────────────
|
||||
self._btns = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok |
|
||||
QDialogButtonBox.StandardButton.Cancel
|
||||
@@ -127,8 +206,7 @@ class ImportDialog(QDialog):
|
||||
self._btns.rejected.connect(self.reject)
|
||||
root.addWidget(self._btns)
|
||||
|
||||
def _build_csv_opts(self) -> QHBoxLayout:
|
||||
from PyQt6.QtWidgets import QWidget
|
||||
def _build_csv_opts(self) -> QWidget:
|
||||
w = QWidget()
|
||||
lay = QHBoxLayout(w)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -142,69 +220,116 @@ class ImportDialog(QDialog):
|
||||
lay.addStretch()
|
||||
return w
|
||||
|
||||
def _build_sql_opts(self) -> QWidget:
|
||||
w = QWidget()
|
||||
lay = QHBoxLayout(w)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
self._stop_on_error_cb = QCheckBox("Stop on first error")
|
||||
self._stop_on_error_cb.setChecked(False)
|
||||
self._stop_on_error_cb.setToolTip(
|
||||
"When unchecked, errors are logged and execution continues.\n"
|
||||
"When checked, the import halts at the first failing statement."
|
||||
)
|
||||
lay.addWidget(self._stop_on_error_cb)
|
||||
lay.addStretch()
|
||||
return w
|
||||
|
||||
# ── File loading ──────────────────────────────────────────────────────────
|
||||
|
||||
def _browse(self):
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Open file", "",
|
||||
"CSV / JSON files (*.csv *.json);;All files (*)"
|
||||
"Supported files (*.csv *.json *.sql);;"
|
||||
"CSV files (*.csv);;"
|
||||
"JSON files (*.json);;"
|
||||
"SQL dump files (*.sql);;"
|
||||
"All files (*)"
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
self._path_edit.setText(path)
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
self._csv_opts_widget.setVisible(ext == ".csv")
|
||||
self._header_cb.setVisible(ext == ".csv")
|
||||
is_csv = ext == ".csv"
|
||||
is_sql = ext == ".sql"
|
||||
|
||||
self._csv_opts_widget.setVisible(is_csv)
|
||||
self._header_cb.setVisible(is_csv)
|
||||
self._sql_opts_widget.setVisible(is_sql)
|
||||
|
||||
self._mode = self._MODE_SQL if is_sql else self._MODE_TABLE
|
||||
self._reload_preview()
|
||||
|
||||
def _delimiter(self) -> str:
|
||||
mapping = {0: ",", 1: ";", 2: "\t", 3: "|"}
|
||||
return mapping.get(self._delim_combo.currentIndex(), ",")
|
||||
return {0: ",", 1: ";", 2: "\t", 3: "|"}.get(
|
||||
self._delim_combo.currentIndex(), ","
|
||||
)
|
||||
|
||||
def _reload_preview(self):
|
||||
path = self._path_edit.text()
|
||||
if not path:
|
||||
return
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
|
||||
if ext == ".sql":
|
||||
self._load_sql_preview(path)
|
||||
elif ext == ".csv":
|
||||
self._load_csv_preview(path)
|
||||
elif ext == ".json":
|
||||
self._load_json_preview(path)
|
||||
else:
|
||||
self._status_lbl.setText("Unsupported file type.")
|
||||
|
||||
# ── CSV / JSON loading ────────────────────────────────────────────────────
|
||||
|
||||
def _load_csv_preview(self, path: str):
|
||||
try:
|
||||
if ext == ".csv":
|
||||
self._rows = self._parse_csv(path)
|
||||
elif ext == ".json":
|
||||
self._rows = self._parse_json(path)
|
||||
else:
|
||||
self._status_lbl.setText("Unsupported file type.")
|
||||
return
|
||||
self._rows = self._parse_csv(path)
|
||||
except Exception as e:
|
||||
self._status_lbl.setText(f"Parse error: {e}")
|
||||
self._rows = []
|
||||
self._ok_btn.setEnabled(False)
|
||||
return
|
||||
|
||||
self._populate_preview(self._rows[:_PREVIEW_ROWS])
|
||||
self._preview_stack.setCurrentIndex(0)
|
||||
self._preview_label.setText(f"Preview (first {_PREVIEW_ROWS} rows):")
|
||||
self._populate_table_preview(self._rows[:_PREVIEW_ROWS])
|
||||
target = f"'{self._database}'.'{self._table}'" if self._table else f"'{self._database}'"
|
||||
self._status_lbl.setText(
|
||||
f"{len(self._rows)} row(s) ready to import into "
|
||||
f"'{self._database}'.'{self._table}'"
|
||||
f"{len(self._rows):,} row(s) ready to import into {target}"
|
||||
)
|
||||
self._ok_btn.setEnabled(bool(self._rows))
|
||||
|
||||
def _load_json_preview(self, path: str):
|
||||
try:
|
||||
self._rows = self._parse_json(path)
|
||||
except Exception as e:
|
||||
self._status_lbl.setText(f"Parse error: {e}")
|
||||
self._rows = []
|
||||
self._ok_btn.setEnabled(False)
|
||||
return
|
||||
self._preview_stack.setCurrentIndex(0)
|
||||
self._preview_label.setText(f"Preview (first {_PREVIEW_ROWS} rows):")
|
||||
self._populate_table_preview(self._rows[:_PREVIEW_ROWS])
|
||||
target = f"'{self._database}'.'{self._table}'" if self._table else f"'{self._database}'"
|
||||
self._status_lbl.setText(
|
||||
f"{len(self._rows):,} row(s) ready to import into {target}"
|
||||
)
|
||||
self._ok_btn.setEnabled(bool(self._rows))
|
||||
|
||||
def _parse_csv(self, path: str) -> list:
|
||||
rows = []
|
||||
with open(path, newline="", encoding="utf-8-sig") as f:
|
||||
reader = csv.reader(f, delimiter=self._delimiter())
|
||||
all_rows = list(reader)
|
||||
if not all_rows:
|
||||
return []
|
||||
if self._header_cb.isChecked():
|
||||
headers = all_rows[0]
|
||||
data_rows = all_rows[1:]
|
||||
headers, data_rows = all_rows[0], all_rows[1:]
|
||||
else:
|
||||
headers = [f"col{i+1}" for i in range(len(all_rows[0]))]
|
||||
data_rows = all_rows
|
||||
for row in data_rows:
|
||||
# Pad short rows, truncate long ones
|
||||
padded = (row + [""] * len(headers))[: len(headers)]
|
||||
rows.append(dict(zip(headers, padded)))
|
||||
return rows
|
||||
return [
|
||||
dict(zip(headers, (row + [""] * len(headers))[: len(headers)]))
|
||||
for row in data_rows
|
||||
]
|
||||
|
||||
def _parse_json(self, path: str) -> list:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
@@ -212,57 +337,146 @@ class ImportDialog(QDialog):
|
||||
if isinstance(data, list):
|
||||
return [r for r in data if isinstance(r, dict)]
|
||||
if isinstance(data, dict):
|
||||
# Support {rows: [...]} or {data: [...]} wrappers
|
||||
for key in ("rows", "data", "records", "items"):
|
||||
if isinstance(data.get(key), list):
|
||||
return data[key]
|
||||
raise ValueError("JSON must be an array of objects or {rows: [...]}")
|
||||
|
||||
# ── Preview ───────────────────────────────────────────────────────────────
|
||||
# ── SQL loading ───────────────────────────────────────────────────────────
|
||||
|
||||
def _populate_preview(self, rows: list):
|
||||
def _load_sql_preview(self, path: str):
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
self._sql_text = f.read()
|
||||
except Exception as e:
|
||||
self._status_lbl.setText(f"Read error: {e}")
|
||||
self._sql_text = ""
|
||||
self._sql_stmts = 0
|
||||
self._ok_btn.setEnabled(False)
|
||||
return
|
||||
|
||||
# Count statements
|
||||
try:
|
||||
import sqlparse
|
||||
stmts = [s for s in sqlparse.split(self._sql_text) if s.strip()]
|
||||
except ImportError:
|
||||
stmts = [s for s in self._sql_text.split(";") if s.strip()]
|
||||
self._sql_stmts = len(stmts)
|
||||
|
||||
# Show first N lines in text preview
|
||||
lines = self._sql_text.splitlines()
|
||||
preview_text = "\n".join(lines[:_PREVIEW_SQL_LINES])
|
||||
if len(lines) > _PREVIEW_SQL_LINES:
|
||||
preview_text += f"\n\n… ({len(lines) - _PREVIEW_SQL_LINES:,} more lines)"
|
||||
self._preview_sql.setPlainText(preview_text)
|
||||
|
||||
self._preview_stack.setCurrentIndex(1)
|
||||
self._preview_label.setText(
|
||||
f"Preview (first {_PREVIEW_SQL_LINES} lines):"
|
||||
)
|
||||
self._status_lbl.setText(
|
||||
f"{self._sql_stmts:,} statement(s) ready to execute "
|
||||
f"on database '{self._database}'"
|
||||
)
|
||||
self._ok_btn.setEnabled(self._sql_stmts > 0)
|
||||
|
||||
# ── Table preview (CSV/JSON) ───────────────────────────────────────────────
|
||||
|
||||
def _populate_table_preview(self, rows: list):
|
||||
if not rows:
|
||||
self._preview.setRowCount(0)
|
||||
self._preview.setColumnCount(0)
|
||||
self._preview_table.setRowCount(0)
|
||||
self._preview_table.setColumnCount(0)
|
||||
return
|
||||
headers = list(rows[0].keys())
|
||||
self._preview.setColumnCount(len(headers))
|
||||
self._preview.setHorizontalHeaderLabels(headers)
|
||||
self._preview.setRowCount(len(rows))
|
||||
self._preview_table.setColumnCount(len(headers))
|
||||
self._preview_table.setHorizontalHeaderLabels(headers)
|
||||
self._preview_table.setRowCount(len(rows))
|
||||
for r, row in enumerate(rows):
|
||||
for c, key in enumerate(headers):
|
||||
val = row.get(key, "")
|
||||
self._preview.setItem(
|
||||
self._preview_table.setItem(
|
||||
r, c, QTableWidgetItem("" if val is None else str(val))
|
||||
)
|
||||
|
||||
# ── Import ────────────────────────────────────────────────────────────────
|
||||
# ── Import dispatch ───────────────────────────────────────────────────────
|
||||
|
||||
def _start_import(self):
|
||||
self._ok_btn.setEnabled(False)
|
||||
self._errors.clear()
|
||||
|
||||
if self._mode == self._MODE_SQL:
|
||||
self._start_sql_import()
|
||||
else:
|
||||
self._start_row_import()
|
||||
|
||||
def _start_row_import(self):
|
||||
if not self._rows:
|
||||
return
|
||||
self._ok_btn.setEnabled(False)
|
||||
self._progress.setMaximum(len(self._rows))
|
||||
self._progress.setValue(0)
|
||||
self._progress.setVisible(True)
|
||||
|
||||
self._worker = _ImportWorker(
|
||||
self._worker = _RowImportWorker(
|
||||
self._driver, self._database, self._table, self._rows, parent=self
|
||||
)
|
||||
self._worker.progress.connect(self._progress.setValue)
|
||||
self._worker.finished.connect(self._on_done)
|
||||
self._worker.error.connect(self._on_error)
|
||||
self._worker.finished.connect(self._on_row_done)
|
||||
self._worker.error.connect(self._on_fatal_error)
|
||||
self._worker.start()
|
||||
|
||||
def _on_done(self, count: int):
|
||||
def _start_sql_import(self):
|
||||
if not self._sql_text.strip():
|
||||
return
|
||||
self._progress.setMaximum(self._sql_stmts)
|
||||
self._progress.setValue(0)
|
||||
self._progress.setVisible(True)
|
||||
self._status_lbl.setText("Executing SQL statements…")
|
||||
|
||||
self._worker = _SqlImportWorker(
|
||||
self._driver, self._database, self._sql_text,
|
||||
stop_on_error=self._stop_on_error_cb.isChecked(),
|
||||
parent=self,
|
||||
)
|
||||
self._worker.progress.connect(self._progress.setValue)
|
||||
self._worker.stmt_error.connect(self._on_stmt_error)
|
||||
self._worker.finished.connect(self._on_sql_done)
|
||||
self._worker.fatal.connect(self._on_fatal_error)
|
||||
self._worker.start()
|
||||
|
||||
# ── Worker callbacks ──────────────────────────────────────────────────────
|
||||
|
||||
def _on_row_done(self, count: int):
|
||||
self._progress.setValue(count)
|
||||
QMessageBox.information(
|
||||
self, "Import Complete",
|
||||
f"Successfully imported {count} row(s) into '{self._table}'."
|
||||
f"Successfully imported {count:,} row(s) into '{self._table}'."
|
||||
)
|
||||
self.accept()
|
||||
|
||||
def _on_error(self, msg: str):
|
||||
def _on_sql_done(self, executed: int, total: int):
|
||||
self._progress.setValue(total)
|
||||
skipped = total - executed
|
||||
msg = (
|
||||
f"Executed {executed:,} of {total:,} statement(s) "
|
||||
f"on database '{self._database}'."
|
||||
)
|
||||
if self._errors:
|
||||
msg += f"\n\n⚠️ {len(self._errors)} statement(s) produced errors:"
|
||||
msg += "\n" + "\n".join(self._errors[:10])
|
||||
if len(self._errors) > 10:
|
||||
msg += f"\n… and {len(self._errors) - 10} more"
|
||||
QMessageBox.warning(self, "Import Finished with Errors", msg)
|
||||
else:
|
||||
QMessageBox.information(self, "Import Complete", msg)
|
||||
self.accept()
|
||||
|
||||
def _on_stmt_error(self, idx: int, msg: str):
|
||||
self._errors.append(f"Statement {idx + 1}: {msg}")
|
||||
self._status_lbl.setText(
|
||||
f"Running… ({len(self._errors)} error(s) so far)"
|
||||
)
|
||||
|
||||
def _on_fatal_error(self, msg: str):
|
||||
self._progress.setVisible(False)
|
||||
self._ok_btn.setEnabled(True)
|
||||
QMessageBox.critical(self, "Import Error", f"Import failed:\n{msg}")
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Application Preferences dialog."""
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QFormLayout, QGroupBox,
|
||||
QComboBox, QSpinBox, QCheckBox, QDialogButtonBox, QLabel,
|
||||
)
|
||||
from PyQt6.QtCore import pyqtSignal
|
||||
|
||||
from app.config.settings import get_settings
|
||||
|
||||
_FONT_FAMILIES = ["Consolas", "JetBrains Mono", "Fira Code", "Cascadia Code", "Courier New"]
|
||||
_PAGE_SIZES = [50, 100, 500, 1000, 0] # 0 → All
|
||||
_PAGE_LABELS = ["50", "100", "500", "1 000", "All"]
|
||||
_THEMES = [
|
||||
("dark", "🌙 Catppuccin Frappé (dark blue-gray)"),
|
||||
("one_dark", "⬛ One Dark Pro (VS Code classic)"),
|
||||
("nord", "❄️ Nord (arctic blue-gray)"),
|
||||
("tokyo_night", "🌃 Tokyo Night (deep purple-dark)"),
|
||||
("dracula", "🧛 Dracula (classic purple-dark)"),
|
||||
("light", "☀️ Catppuccin Latte (warm light)"),
|
||||
("github_light", "📄 GitHub Light (clean minimal)"),
|
||||
]
|
||||
|
||||
|
||||
class PreferencesDialog(QDialog):
|
||||
settings_applied = pyqtSignal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Preferences")
|
||||
self.setMinimumWidth(440)
|
||||
self.resize(460, 400)
|
||||
self._build_ui()
|
||||
self._load()
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setSpacing(12)
|
||||
root.setContentsMargins(16, 16, 16, 16)
|
||||
|
||||
# ── SQL Editor ────────────────────────────────────────────────────────
|
||||
editor_box = QGroupBox("SQL Editor")
|
||||
ef = QFormLayout(editor_box)
|
||||
ef.setSpacing(8)
|
||||
|
||||
self._font_family = QComboBox()
|
||||
self._font_family.addItems(_FONT_FAMILIES)
|
||||
ef.addRow("Font family:", self._font_family)
|
||||
|
||||
self._font_size = QSpinBox()
|
||||
self._font_size.setRange(8, 28)
|
||||
self._font_size.setSuffix(" pt")
|
||||
ef.addRow("Font size:", self._font_size)
|
||||
|
||||
self._word_wrap = QCheckBox("Word wrap")
|
||||
ef.addRow("", self._word_wrap)
|
||||
|
||||
root.addWidget(editor_box)
|
||||
|
||||
# ── Results ───────────────────────────────────────────────────────────
|
||||
results_box = QGroupBox("Results")
|
||||
rf = QFormLayout(results_box)
|
||||
rf.setSpacing(8)
|
||||
|
||||
self._page_size = QComboBox()
|
||||
for lbl in _PAGE_LABELS:
|
||||
self._page_size.addItem(lbl)
|
||||
rf.addRow("Default page size:", self._page_size)
|
||||
|
||||
self._timeout = QSpinBox()
|
||||
self._timeout.setRange(5, 600)
|
||||
self._timeout.setSuffix(" s")
|
||||
rf.addRow("Query timeout:", self._timeout)
|
||||
|
||||
root.addWidget(results_box)
|
||||
|
||||
# ── Appearance ────────────────────────────────────────────────────────
|
||||
appear_box = QGroupBox("Appearance")
|
||||
af = QFormLayout(appear_box)
|
||||
af.setSpacing(8)
|
||||
|
||||
self._theme = QComboBox()
|
||||
for _, label in _THEMES:
|
||||
self._theme.addItem(label)
|
||||
af.addRow("Color theme:", self._theme)
|
||||
|
||||
root.addWidget(appear_box)
|
||||
|
||||
# ── General ───────────────────────────────────────────────────────────
|
||||
general_box = QGroupBox("General")
|
||||
gf = QFormLayout(general_box)
|
||||
gf.setSpacing(8)
|
||||
|
||||
self._max_history = QSpinBox()
|
||||
self._max_history.setRange(50, 10_000)
|
||||
self._max_history.setSingleStep(100)
|
||||
gf.addRow("Max history entries:", self._max_history)
|
||||
|
||||
self._auto_commit = QCheckBox("Auto-commit queries")
|
||||
gf.addRow("", self._auto_commit)
|
||||
|
||||
root.addWidget(general_box)
|
||||
|
||||
root.addStretch()
|
||||
|
||||
# ── Buttons ───────────────────────────────────────────────────────────
|
||||
note = QLabel("Theme and font changes take effect immediately.")
|
||||
note.setObjectName("statusLabel")
|
||||
root.addWidget(note)
|
||||
|
||||
bb = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Cancel |
|
||||
QDialogButtonBox.StandardButton.Apply |
|
||||
QDialogButtonBox.StandardButton.Ok
|
||||
)
|
||||
bb.rejected.connect(self.reject)
|
||||
bb.button(QDialogButtonBox.StandardButton.Apply).clicked.connect(self._apply)
|
||||
bb.accepted.connect(self._ok)
|
||||
root.addWidget(bb)
|
||||
|
||||
# ── Load / save ───────────────────────────────────────────────────────────
|
||||
|
||||
def _load(self):
|
||||
s = get_settings()
|
||||
|
||||
family = s.get("font_family", "Consolas")
|
||||
idx = self._font_family.findText(family)
|
||||
self._font_family.setCurrentIndex(max(idx, 0))
|
||||
|
||||
self._font_size.setValue(s.get("font_size", 13))
|
||||
self._word_wrap.setChecked(s.get("word_wrap", False))
|
||||
|
||||
page = s.get("result_page_size", 100)
|
||||
pi = _PAGE_SIZES.index(page) if page in _PAGE_SIZES else 1
|
||||
self._page_size.setCurrentIndex(pi)
|
||||
|
||||
self._timeout.setValue(s.get("query_timeout", 60))
|
||||
self._max_history.setValue(s.get("max_history", 500))
|
||||
self._auto_commit.setChecked(s.get("auto_commit", True))
|
||||
|
||||
theme_name = s.get("theme", "dark")
|
||||
ti = next((i for i, (k, _) in enumerate(_THEMES) if k == theme_name), 0)
|
||||
self._theme.setCurrentIndex(ti)
|
||||
|
||||
def _apply(self):
|
||||
s = get_settings()
|
||||
s.set("font_family", self._font_family.currentText())
|
||||
s.set("font_size", self._font_size.value())
|
||||
s.set("word_wrap", self._word_wrap.isChecked())
|
||||
s.set("result_page_size", _PAGE_SIZES[self._page_size.currentIndex()])
|
||||
s.set("query_timeout", self._timeout.value())
|
||||
s.set("max_history", self._max_history.value())
|
||||
s.set("auto_commit", self._auto_commit.isChecked())
|
||||
s.set("theme", _THEMES[self._theme.currentIndex()][0])
|
||||
s.save()
|
||||
self.settings_applied.emit()
|
||||
|
||||
def _ok(self):
|
||||
self._apply()
|
||||
self.accept()
|
||||
+168
-76
@@ -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
|
||||
@@ -13,12 +19,19 @@ from PyQt6.QtGui import QColor, QFont
|
||||
from app.models.result_table_model import ResultTableModel
|
||||
|
||||
|
||||
class _RowNumberProxy(QSortFilterProxyModel):
|
||||
"""Proxy that shows sequential visual-order row numbers in the vertical header."""
|
||||
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
|
||||
if orientation == Qt.Orientation.Vertical and role == Qt.ItemDataRole.DisplayRole:
|
||||
return str(section + 1)
|
||||
return super().headerData(section, orientation, role)
|
||||
|
||||
|
||||
class ResultsPanel(QWidget):
|
||||
status_message = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._model = ResultTableModel()
|
||||
self._build_ui()
|
||||
|
||||
# ── UI ────────────────────────────────────────────────────────────────────
|
||||
@@ -52,35 +65,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 +80,146 @@ 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 = _RowNumberProxy()
|
||||
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.verticalHeader().setFixedWidth(48)
|
||||
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 +230,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())
|
||||
|
||||
+23
-14
@@ -12,6 +12,7 @@ from PyQt6.QtWidgets import QApplication, QListWidget, QListWidgetItem
|
||||
|
||||
from app.ui.syntax_highlighter import _KEYWORDS, _TYPES, _FUNCTIONS
|
||||
from app.utils.worker import SchemaWorker
|
||||
from app.config.theme import get_palette
|
||||
|
||||
# Combined, deduplicated keyword list used as the static completion pool.
|
||||
SQL_KEYWORDS: list[str] = list(dict.fromkeys(_KEYWORDS + _TYPES + _FUNCTIONS))
|
||||
@@ -51,24 +52,28 @@ class _CompletionPopup(QListWidget):
|
||||
self.setFont(font)
|
||||
self._row_h = QFontMetrics(font).height() + 6
|
||||
|
||||
self.setStyleSheet("""
|
||||
QListWidget {
|
||||
background: #292c3c;
|
||||
color: #c6d0f5;
|
||||
border: 1px solid #51576d;
|
||||
self._apply_popup_style()
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
|
||||
def _apply_popup_style(self):
|
||||
p = get_palette()
|
||||
self.setStyleSheet(f"""
|
||||
QListWidget {{
|
||||
background: {p['popup_bg']};
|
||||
color: {p['text']};
|
||||
border: 1px solid {p['popup_border']};
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
outline: none;
|
||||
}
|
||||
QListWidget::item { padding: 2px 8px; }
|
||||
QListWidget::item:selected {
|
||||
background: #414559;
|
||||
color: #c6d0f5;
|
||||
}
|
||||
QListWidget::item:hover { background: #363a4f; }
|
||||
}}
|
||||
QListWidget::item {{ padding: 2px 8px; }}
|
||||
QListWidget::item:selected {{
|
||||
background: {p['popup_selected']};
|
||||
color: {p['text']};
|
||||
}}
|
||||
QListWidget::item:hover {{ background: {p['popup_hover']}; }}
|
||||
""")
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
|
||||
self.itemClicked.connect(
|
||||
lambda item: self._editor._completer._accept_completion(item.text())
|
||||
@@ -142,6 +147,10 @@ class SqlCompleter(QObject):
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
def update_theme(self) -> None:
|
||||
"""Reapply popup colors after a theme change."""
|
||||
self._popup._apply_popup_style()
|
||||
|
||||
def set_context(self, driver, database: str) -> None:
|
||||
"""Update the active driver/database and refresh schema cache."""
|
||||
self._driver = driver
|
||||
|
||||
+68
-11
@@ -18,6 +18,7 @@ from app.ui.syntax_highlighter import SQLHighlighter
|
||||
from app.ui.results_panel import ResultsPanel
|
||||
from app.ui.sql_completer import SqlCompleter
|
||||
from app.utils.worker import QueryWorker
|
||||
from app.config.settings import get_settings
|
||||
|
||||
|
||||
# ── Line-number gutter ────────────────────────────────────────────────────────
|
||||
@@ -42,11 +43,12 @@ class CodeEditor(QPlainTextEdit):
|
||||
self._line_area = LineNumberArea(self)
|
||||
self._completer: "SqlCompleter | None" = None
|
||||
|
||||
# Font
|
||||
font = QFont("Consolas", 13)
|
||||
font.setFixedPitch(True)
|
||||
self.setFont(font)
|
||||
self.setTabStopDistance(QFontMetrics(font).horizontalAdvance(" ") * 4)
|
||||
self._apply_font()
|
||||
self.setLineWrapMode(
|
||||
QPlainTextEdit.LineWrapMode.WidgetWidth
|
||||
if get_settings().get("word_wrap", False)
|
||||
else QPlainTextEdit.LineWrapMode.NoWrap
|
||||
)
|
||||
|
||||
# Connect signals
|
||||
self.blockCountChanged.connect(self._update_line_area_width)
|
||||
@@ -112,6 +114,22 @@ class CodeEditor(QPlainTextEdit):
|
||||
bottom = top + round(self.blockBoundingRect(block).height())
|
||||
number += 1
|
||||
|
||||
def _apply_font(self) -> None:
|
||||
s = get_settings()
|
||||
font = QFont(s.get("font_family", "Consolas"), s.get("font_size", 13))
|
||||
font.setFixedPitch(True)
|
||||
self.setFont(font)
|
||||
self.setTabStopDistance(QFontMetrics(font).horizontalAdvance(" ") * 4)
|
||||
|
||||
def apply_settings(self) -> None:
|
||||
"""Re-read settings and apply font + word-wrap live."""
|
||||
self._apply_font()
|
||||
self.setLineWrapMode(
|
||||
QPlainTextEdit.LineWrapMode.WidgetWidth
|
||||
if get_settings().get("word_wrap", False)
|
||||
else QPlainTextEdit.LineWrapMode.NoWrap
|
||||
)
|
||||
|
||||
def set_completer(self, completer: "SqlCompleter") -> None:
|
||||
self._completer = completer
|
||||
|
||||
@@ -430,6 +448,10 @@ class EditorTab(QWidget):
|
||||
self._explain_btn = QPushButton("🔎 Explain")
|
||||
self._explain_btn.clicked.connect(self._explain)
|
||||
|
||||
self._format_btn = QPushButton("≡ Format")
|
||||
self._format_btn.setToolTip("Auto-format SQL (requires sqlparse)")
|
||||
self._format_btn.clicked.connect(self._format_sql)
|
||||
|
||||
self._export_btn = QPushButton("📤 Export")
|
||||
self._export_btn.clicked.connect(self._export)
|
||||
|
||||
@@ -439,6 +461,7 @@ class EditorTab(QWidget):
|
||||
toolbar.addWidget(self._run_btn)
|
||||
toolbar.addWidget(self._stop_btn)
|
||||
toolbar.addWidget(self._explain_btn)
|
||||
toolbar.addWidget(self._format_btn)
|
||||
toolbar.addWidget(self._export_btn)
|
||||
toolbar.addStretch()
|
||||
toolbar.addWidget(self._db_label)
|
||||
@@ -448,7 +471,7 @@ class EditorTab(QWidget):
|
||||
self._splitter.setHandleWidth(3)
|
||||
|
||||
self._editor = CodeEditor()
|
||||
SQLHighlighter(self._editor.document())
|
||||
self._highlighter = SQLHighlighter(self._editor.document())
|
||||
self._completer = SqlCompleter(self._editor, parent=self)
|
||||
self._editor.set_completer(self._completer)
|
||||
if self._driver:
|
||||
@@ -504,6 +527,9 @@ class EditorTab(QWidget):
|
||||
self._stop_btn.setEnabled(False)
|
||||
|
||||
def _explain(self):
|
||||
if not self._driver:
|
||||
self._results.show_error("No active connection. Connect to a database first.")
|
||||
return
|
||||
sql = self._editor.selected_or_all().strip()
|
||||
if not sql:
|
||||
return
|
||||
@@ -523,6 +549,30 @@ class EditorTab(QWidget):
|
||||
def _export(self):
|
||||
self._results.export_dialog()
|
||||
|
||||
def _format_sql(self):
|
||||
try:
|
||||
import sqlparse
|
||||
except ImportError:
|
||||
QMessageBox.warning(
|
||||
self, "Package Missing",
|
||||
"SQL formatting requires sqlparse.\n\nInstall it with:\n pip install sqlparse",
|
||||
)
|
||||
return
|
||||
sql = self._editor.toPlainText().strip()
|
||||
if not sql:
|
||||
return
|
||||
formatted = sqlparse.format(
|
||||
sql,
|
||||
reindent=True,
|
||||
keyword_case="upper",
|
||||
identifier_case="lower",
|
||||
strip_comments=False,
|
||||
use_space_around_operators=True,
|
||||
)
|
||||
cursor = self._editor.textCursor()
|
||||
cursor.select(QTextCursor.SelectionType.Document)
|
||||
cursor.insertText(formatted)
|
||||
|
||||
def set_context(self, driver, database: str) -> None:
|
||||
"""Update driver/database and refresh schema completions."""
|
||||
self._driver = driver
|
||||
@@ -535,11 +585,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):
|
||||
@@ -558,6 +604,11 @@ class EditorTab(QWidget):
|
||||
def get_sql(self) -> str:
|
||||
return self._editor.toPlainText()
|
||||
|
||||
def apply_settings(self) -> None:
|
||||
self._editor.apply_settings()
|
||||
self._highlighter.update_theme()
|
||||
self._completer.update_theme()
|
||||
|
||||
|
||||
# ── Tabbed SQL editor container ───────────────────────────────────────────────
|
||||
|
||||
@@ -651,6 +702,12 @@ class SQLEditorWidget(QWidget):
|
||||
w = self._tabs.currentWidget()
|
||||
return w if isinstance(w, EditorTab) else None
|
||||
|
||||
def apply_settings(self) -> None:
|
||||
for i in range(self._tabs.count()):
|
||||
tab = self._tabs.widget(i)
|
||||
if isinstance(tab, EditorTab):
|
||||
tab.apply_settings()
|
||||
|
||||
def open_sql_for(self, driver, database: str, sql: str = ""):
|
||||
tab = self.new_tab(driver, database, sql,
|
||||
title=f"SQL — {database}")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
SQL syntax highlighter for QPlainTextEdit.
|
||||
Highlights keywords, types, functions, strings, comments, and numbers.
|
||||
Colors follow the active Catppuccin theme (dark = Frappé, light = Latte).
|
||||
"""
|
||||
import re
|
||||
from PyQt6.QtCore import QRegularExpression, Qt
|
||||
@@ -8,6 +9,8 @@ from PyQt6.QtGui import (
|
||||
QSyntaxHighlighter, QTextCharFormat, QColor, QFont
|
||||
)
|
||||
|
||||
from app.config.theme import get_palette
|
||||
|
||||
|
||||
def _fmt(color: str, bold: bool = False, italic: bool = False) -> QTextCharFormat:
|
||||
f = QTextCharFormat()
|
||||
@@ -17,17 +20,7 @@ def _fmt(color: str, bold: bool = False, italic: bool = False) -> QTextCharForma
|
||||
return f
|
||||
|
||||
|
||||
# ── Token categories (Catppuccin Frappé palette) ─────────────────────────────
|
||||
_KEYWORD_FMT = _fmt("#8caaee", bold=True) # blue — DDL/DML/control
|
||||
_TYPE_FMT = _fmt("#ef9f76") # peach — data types
|
||||
_FUNCTION_FMT = _fmt("#a6d189") # green — functions
|
||||
_STRING_FMT = _fmt("#a6d189") # green — string literals
|
||||
_NUMBER_FMT = _fmt("#ef9f76") # orange — numeric literals
|
||||
_COMMENT_FMT = _fmt("#737994", italic=True) # grey — comments
|
||||
_OPERATOR_FMT = _fmt("#ca9ee6") # mauve — operators/special
|
||||
_STAR_FMT = _fmt("#ca9ee6", bold=True) # mauve — * wildcard
|
||||
|
||||
# ── Keyword lists ─────────────────────────────────────────────────────────────
|
||||
# ── Keyword lists (shared with completer) ─────────────────────────────────────
|
||||
_KEYWORDS = [
|
||||
"SELECT", "FROM", "WHERE", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER",
|
||||
"FULL", "CROSS", "ON", "AS", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN",
|
||||
@@ -79,56 +72,56 @@ class SQLHighlighter(QSyntaxHighlighter):
|
||||
def __init__(self, document):
|
||||
super().__init__(document)
|
||||
self._rules: list[tuple] = []
|
||||
self._build_rules()
|
||||
|
||||
def _build_rules(self):
|
||||
p = get_palette()
|
||||
|
||||
def kw_pattern(words: list[str]) -> str:
|
||||
return r"\b(?:" + "|".join(words) + r")\b"
|
||||
|
||||
ci = QRegularExpression.PatternOption.CaseInsensitiveOption
|
||||
self._rules = [
|
||||
# Keywords (case-insensitive handled via flag)
|
||||
(QRegularExpression(kw_pattern(_KEYWORDS),
|
||||
QRegularExpression.PatternOption.CaseInsensitiveOption),
|
||||
_KEYWORD_FMT),
|
||||
# Data types
|
||||
(QRegularExpression(kw_pattern(_TYPES),
|
||||
QRegularExpression.PatternOption.CaseInsensitiveOption),
|
||||
_TYPE_FMT),
|
||||
# Functions
|
||||
(QRegularExpression(kw_pattern(_FUNCTIONS),
|
||||
QRegularExpression.PatternOption.CaseInsensitiveOption),
|
||||
_FUNCTION_FMT),
|
||||
# Numbers
|
||||
(QRegularExpression(r"\b\d+(\.\d+)?\b"), _NUMBER_FMT),
|
||||
# Single-quoted strings
|
||||
(QRegularExpression(r"'[^'\\]*(?:\\.[^'\\]*)*'"), _STRING_FMT),
|
||||
# Double-quoted identifiers
|
||||
(QRegularExpression(r'"[^"]*"'), _fmt("#99d1db")),
|
||||
# Backtick identifiers (MySQL)
|
||||
(QRegularExpression(r"`[^`]*`"), _fmt("#99d1db")),
|
||||
# * wildcard
|
||||
(QRegularExpression(r"\bSELECT\s+\*|\*(?=\s*FROM)",
|
||||
QRegularExpression.PatternOption.CaseInsensitiveOption),
|
||||
_STAR_FMT),
|
||||
# Operators
|
||||
(QRegularExpression(r"[=<>!%&|^~+\-*/]"), _OPERATOR_FMT),
|
||||
# Single-line comment --
|
||||
(QRegularExpression(r"--[^\n]*"), _COMMENT_FMT),
|
||||
# Single-line comment #
|
||||
(QRegularExpression(r"#[^\n]*"), _COMMENT_FMT),
|
||||
(QRegularExpression(kw_pattern(_KEYWORDS), ci),
|
||||
_fmt(p["syn_keyword"], bold=True)),
|
||||
(QRegularExpression(kw_pattern(_TYPES), ci),
|
||||
_fmt(p["syn_type"])),
|
||||
(QRegularExpression(kw_pattern(_FUNCTIONS), ci),
|
||||
_fmt(p["syn_function"])),
|
||||
(QRegularExpression(r"\b\d+(\.\d+)?\b"),
|
||||
_fmt(p["syn_number"])),
|
||||
(QRegularExpression(r"'[^'\\]*(?:\\.[^'\\]*)*'"),
|
||||
_fmt(p["syn_string"])),
|
||||
(QRegularExpression(r'"[^"]*"'),
|
||||
_fmt(p["syn_ident"])),
|
||||
(QRegularExpression(r"`[^`]*`"),
|
||||
_fmt(p["syn_ident"])),
|
||||
(QRegularExpression(
|
||||
r"\bSELECT\s+\*|\*(?=\s*FROM)", ci),
|
||||
_fmt(p["syn_operator"], bold=True)),
|
||||
(QRegularExpression(r"[=<>!%&|^~+\-*/]"),
|
||||
_fmt(p["syn_operator"])),
|
||||
(QRegularExpression(r"--[^\n]*"),
|
||||
_fmt(p["syn_comment"], italic=True)),
|
||||
(QRegularExpression(r"#[^\n]*"),
|
||||
_fmt(p["syn_comment"], italic=True)),
|
||||
]
|
||||
|
||||
# Multi-line block comment /* ... */
|
||||
self._comment_fmt = _fmt(p["syn_comment"], italic=True)
|
||||
self._block_comment_start = QRegularExpression(r"/\*")
|
||||
self._block_comment_end = QRegularExpression(r"\*/")
|
||||
|
||||
def update_theme(self):
|
||||
"""Rebuild color rules for the current theme and re-highlight."""
|
||||
self._build_rules()
|
||||
self.rehighlight()
|
||||
|
||||
def highlightBlock(self, text: str) -> None:
|
||||
# Single-line rules
|
||||
for pattern, fmt in self._rules:
|
||||
it = pattern.globalMatch(text)
|
||||
while it.hasNext():
|
||||
m = it.next()
|
||||
self.setFormat(m.capturedStart(), m.capturedLength(), fmt)
|
||||
|
||||
# Multi-line block comments
|
||||
self.setCurrentBlockState(0)
|
||||
start_idx = 0
|
||||
if self.previousBlockState() != 1:
|
||||
@@ -139,10 +132,10 @@ class SQLHighlighter(QSyntaxHighlighter):
|
||||
end_m = self._block_comment_end.match(text, start_idx)
|
||||
if end_m.hasMatch():
|
||||
end_idx = end_m.capturedStart() + end_m.capturedLength()
|
||||
self.setFormat(start_idx, end_idx - start_idx, _COMMENT_FMT)
|
||||
self.setFormat(start_idx, end_idx - start_idx, self._comment_fmt)
|
||||
m = self._block_comment_start.match(text, end_idx)
|
||||
start_idx = m.capturedStart() if m.hasMatch() else -1
|
||||
else:
|
||||
self.setCurrentBlockState(1)
|
||||
self.setFormat(start_idx, len(text) - start_idx, _COMMENT_FMT)
|
||||
self.setFormat(start_idx, len(text) - start_idx, self._comment_fmt)
|
||||
break
|
||||
|
||||
+193
-8
@@ -16,11 +16,13 @@ from PyQt6.QtWidgets import (
|
||||
QMenu, QApplication,
|
||||
)
|
||||
from PyQt6.QtCore import (
|
||||
Qt, QAbstractTableModel, QModelIndex, pyqtSignal,
|
||||
Qt, QAbstractTableModel, QModelIndex, pyqtSignal, QEvent,
|
||||
)
|
||||
from PyQt6.QtGui import QColor, QBrush, QFont, QKeySequence, QShortcut
|
||||
|
||||
from app.utils.worker import TableDataWorker
|
||||
from app.config.settings import get_settings
|
||||
from app.config.theme import get_palette
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -147,18 +149,20 @@ class EditableTableModel(QAbstractTableModel):
|
||||
return "" if val is None else str(val)
|
||||
|
||||
if role == Qt.ItemDataRole.ForegroundRole:
|
||||
p = get_palette()
|
||||
if self._is_deleted_row(r):
|
||||
return QBrush(QColor("#e78284")) # red — pending delete
|
||||
return QBrush(QColor(p["delete_fg"]))
|
||||
if (r, c) in self._dirty:
|
||||
return QBrush(QColor("#ef9f76")) # orange — edited
|
||||
return QBrush(QColor(p["dirty_fg"]))
|
||||
if val is None:
|
||||
return QBrush(QColor("#737994")) # grey — NULL
|
||||
return QBrush(QColor(p["overlay"]))
|
||||
|
||||
if role == Qt.ItemDataRole.BackgroundRole:
|
||||
p = get_palette()
|
||||
if self._is_deleted_row(r):
|
||||
return QBrush(QColor("#3a2530"))
|
||||
return QBrush(QColor(p["delete_bg"]))
|
||||
if (r, c) in self._dirty:
|
||||
return QBrush(QColor("#3a2f25"))
|
||||
return QBrush(QColor(p["dirty_bg"]))
|
||||
|
||||
return None
|
||||
|
||||
@@ -266,8 +270,15 @@ class RowDialog(QDialog):
|
||||
)
|
||||
bbox.accepted.connect(self.accept)
|
||||
bbox.rejected.connect(self.reject)
|
||||
ok_btn = bbox.button(QDialogButtonBox.StandardButton.Ok)
|
||||
if ok_btn:
|
||||
ok_btn.setDefault(True)
|
||||
root.addWidget(bbox)
|
||||
|
||||
# Focus the first input field so the user can start typing immediately
|
||||
if self._fields:
|
||||
next(iter(self._fields.values())).setFocus()
|
||||
|
||||
@property
|
||||
def values(self) -> dict:
|
||||
"""Return {col: value_or_None} for all fields.
|
||||
@@ -310,7 +321,8 @@ class TableViewer(QWidget):
|
||||
self._table = table
|
||||
self._offset = 0
|
||||
self._total = 0
|
||||
self._page_size = 100 # default
|
||||
ps = get_settings().get("result_page_size", 100)
|
||||
self._page_size = ps if ps > 0 else 10_000_000 # 0 → All
|
||||
self._model = EditableTableModel()
|
||||
self._worker: TableDataWorker | None = None
|
||||
self._build_ui()
|
||||
@@ -378,6 +390,13 @@ class TableViewer(QWidget):
|
||||
tb.addWidget(self._edit_btn)
|
||||
tb.addWidget(self._delete_btn)
|
||||
tb.addStretch()
|
||||
|
||||
self._freeze_btn = QPushButton("📌")
|
||||
self._freeze_btn.setFixedWidth(34)
|
||||
self._freeze_btn.setToolTip("Freeze first column (keep it visible while scrolling)")
|
||||
self._freeze_btn.setCheckable(True)
|
||||
self._freeze_btn.clicked.connect(self._toggle_freeze)
|
||||
tb.addWidget(self._freeze_btn)
|
||||
root.addLayout(tb)
|
||||
|
||||
# ── Table view ─────────────────────────────────────────────────────────
|
||||
@@ -393,10 +412,16 @@ class TableViewer(QWidget):
|
||||
QHeaderView.ResizeMode.Interactive)
|
||||
self._table_view.horizontalHeader().setStretchLastSection(True)
|
||||
self._table_view.verticalHeader().setDefaultSectionSize(24)
|
||||
self._table_view.verticalHeader().setFixedWidth(48)
|
||||
self._table_view.setContextMenuPolicy(
|
||||
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)
|
||||
@@ -408,6 +433,40 @@ class TableViewer(QWidget):
|
||||
self._delete_selected)
|
||||
QShortcut(QKeySequence("Ins"), self._table_view, self._add_row)
|
||||
|
||||
# ── Frozen first-column overlay ───────────────────────────────────────
|
||||
self._freeze_active = False
|
||||
self._frozen_view = QTableView(self._table_view)
|
||||
self._frozen_view.setModel(self._model)
|
||||
self._frozen_view.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self._frozen_view.setVerticalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self._frozen_view.setHorizontalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self._frozen_view.setAlternatingRowColors(True)
|
||||
self._frozen_view.verticalHeader().hide()
|
||||
self._frozen_view.horizontalHeader().setSectionResizeMode(
|
||||
QHeaderView.ResizeMode.Fixed)
|
||||
self._frozen_view.setStyleSheet(
|
||||
f"QTableView {{ border: none; border-right: 2px solid {get_palette()['blue']}; }}")
|
||||
self._frozen_view.hide()
|
||||
|
||||
# Sync vertical scroll between the two views
|
||||
self._table_view.verticalScrollBar().valueChanged.connect(
|
||||
self._frozen_view.verticalScrollBar().setValue)
|
||||
self._frozen_view.verticalScrollBar().valueChanged.connect(
|
||||
self._table_view.verticalScrollBar().setValue)
|
||||
|
||||
# Sync row heights
|
||||
self._table_view.verticalHeader().sectionResized.connect(
|
||||
lambda row, _, h: self._frozen_view.setRowHeight(row, h))
|
||||
|
||||
# Update frozen geometry when column 0 is resized
|
||||
self._table_view.horizontalHeader().sectionResized.connect(
|
||||
lambda col, _old, _new: self._update_frozen_geometry() if col == 0 else None)
|
||||
|
||||
# Event filter to handle main view resize
|
||||
self._table_view.installEventFilter(self)
|
||||
|
||||
root.addWidget(self._table_view, 1)
|
||||
|
||||
# ── Pagination bar (prominent, always visible) ─────────────────────────
|
||||
@@ -498,11 +557,58 @@ class TableViewer(QWidget):
|
||||
self._refresh_action_states()
|
||||
self._table_view.resizeColumnsToContents()
|
||||
self._table_view.horizontalHeader().setStretchLastSection(True)
|
||||
if self._freeze_active and self._model.columnCount() > 0:
|
||||
self._apply_freeze()
|
||||
|
||||
def _on_error(self, msg: str):
|
||||
self._total_lbl.setText(f"Error: {msg[:80]}")
|
||||
self.status_message.emit(f"Error: {msg}")
|
||||
|
||||
# ── Frozen column ──────────────────────────────────────────────────────────
|
||||
|
||||
def _toggle_freeze(self, checked: bool):
|
||||
self._freeze_active = checked
|
||||
if checked and self._model.columnCount() > 0:
|
||||
self._apply_freeze()
|
||||
elif not checked:
|
||||
self._unapply_freeze()
|
||||
|
||||
def _apply_freeze(self):
|
||||
col_count = self._model.columnCount()
|
||||
if col_count == 0:
|
||||
return
|
||||
self._frozen_view.setSelectionModel(self._table_view.selectionModel())
|
||||
for c in range(col_count):
|
||||
self._frozen_view.setColumnHidden(c, c != 0)
|
||||
self._frozen_view.resizeColumnToContents(0)
|
||||
self._table_view.setColumnHidden(0, True)
|
||||
self._update_frozen_geometry()
|
||||
self._frozen_view.show()
|
||||
self._frozen_view.raise_()
|
||||
|
||||
def _unapply_freeze(self):
|
||||
self._frozen_view.hide()
|
||||
if self._model.columnCount() > 0:
|
||||
self._table_view.setColumnHidden(0, False)
|
||||
|
||||
def _update_frozen_geometry(self):
|
||||
if not self._freeze_active:
|
||||
return
|
||||
fw = self._table_view.frameWidth()
|
||||
vh_w = self._table_view.verticalHeader().width()
|
||||
col0_w = self._frozen_view.columnWidth(0)
|
||||
self._frozen_view.setGeometry(
|
||||
fw + vh_w,
|
||||
fw,
|
||||
col0_w,
|
||||
self._table_view.height() - 2 * fw,
|
||||
)
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
if obj is self._table_view and event.type() == QEvent.Type.Resize:
|
||||
self._update_frozen_geometry()
|
||||
return False
|
||||
|
||||
def _refresh_pagination(self):
|
||||
ps = self._page_size if self._page_size > 0 else max(self._total, 1)
|
||||
rows_loaded = self._model.rowCount()
|
||||
@@ -609,16 +715,53 @@ class TableViewer(QWidget):
|
||||
|
||||
def _context_menu(self, pos):
|
||||
rows = self._selected_logical_rows()
|
||||
idx = self._table_view.indexAt(pos)
|
||||
menu = QMenu(self)
|
||||
menu.addAction("➕ Add Row", self._add_row)
|
||||
if rows:
|
||||
menu.addAction("✏️ Edit Row", self._edit_selected)
|
||||
menu.addAction("🗑️ Delete Row", self._delete_selected)
|
||||
menu.addSeparator()
|
||||
if idx.isValid():
|
||||
menu.addAction("🔍 Filter by this value",
|
||||
lambda: self._filter_by_cell(idx))
|
||||
menu.addSeparator()
|
||||
menu.addAction("📋 Copy cell value", lambda: self._copy_cell(pos))
|
||||
menu.addAction("📋 Copy row", self._copy_selected_rows)
|
||||
menu.addAction("📋 Copy row as TSV", self._copy_selected_rows)
|
||||
if rows:
|
||||
menu.addAction("📋 Copy row as SQL INSERT",
|
||||
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):
|
||||
@@ -725,3 +868,45 @@ class TableViewer(QWidget):
|
||||
row_data = self._model.get_row_current(r)
|
||||
lines.append("\t".join("" if v is None else str(v) for v in row_data))
|
||||
QApplication.clipboard().setText("\n".join(lines))
|
||||
|
||||
def _copy_selected_as_insert(self):
|
||||
rows = self._selected_logical_rows()
|
||||
cols = self._model.column_names()
|
||||
col_list = ", ".join(f'"{c}"' for c in cols)
|
||||
statements = []
|
||||
for r in rows:
|
||||
row_data = self._model.get_row_current(r)
|
||||
values = []
|
||||
for v in row_data:
|
||||
if v is None:
|
||||
values.append("NULL")
|
||||
elif isinstance(v, (int, float)):
|
||||
values.append(str(v))
|
||||
else:
|
||||
escaped = str(v).replace("'", "''")
|
||||
values.append(f"'{escaped}'")
|
||||
statements.append(
|
||||
f'INSERT INTO "{self._table}" ({col_list}) VALUES ({", ".join(values)});'
|
||||
)
|
||||
QApplication.clipboard().setText("\n".join(statements))
|
||||
|
||||
def apply_settings(self):
|
||||
"""Called by MainWindow when preferences (including theme) change."""
|
||||
self._frozen_view.setStyleSheet(
|
||||
f"QTableView {{ border: none; border-right: 2px solid {get_palette()['blue']}; }}")
|
||||
self._model.layoutChanged.emit() # repaint dirty/delete cells
|
||||
|
||||
def _filter_by_cell(self, index):
|
||||
if not index.isValid():
|
||||
return
|
||||
col_name = self._model.column_names()[index.column()]
|
||||
val = self._model.data(index, Qt.ItemDataRole.DisplayRole)
|
||||
if val is None:
|
||||
clause = f'"{col_name}" IS NULL'
|
||||
elif isinstance(val, str):
|
||||
escaped = val.replace("'", "''")
|
||||
clause = f'"{col_name}" = \'{escaped}\''
|
||||
else:
|
||||
clause = f'"{col_name}" = {val}'
|
||||
self._filter_input.setText(clause)
|
||||
self._apply_filter()
|
||||
|
||||
@@ -9,15 +9,13 @@ from PyQt6.QtGui import QFont
|
||||
|
||||
from app.utils.logger import setup_logging, install_qt_message_handler, get_logger
|
||||
from app.main_window import MainWindow
|
||||
from app.config.theme import apply_qss
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
def load_stylesheet(app: QApplication) -> None:
|
||||
style_path = os.path.join(os.path.dirname(__file__), "resources", "style.qss")
|
||||
if os.path.exists(style_path):
|
||||
with open(style_path, "r", encoding="utf-8") as f:
|
||||
app.setStyleSheet(f.read())
|
||||
apply_qss(app) # reads theme from settings, falls back to dark
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -5,6 +5,7 @@ pyodbc>=5.0.1
|
||||
keyring>=24.3.1
|
||||
cryptography>=42.0.0
|
||||
bcrypt>=4.0.0
|
||||
sqlparse>=0.5.0
|
||||
|
||||
# Packaging (dev dependency — only needed when building the distributable)
|
||||
pyinstaller>=6.0.0
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
DBClient — Light Theme (Catppuccin Latte palette)
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Variables via flat tokens ─────────────────────────────────────────────
|
||||
base #eff1f5 crust #dce0e8
|
||||
surface0 #ccd0da surface1 #bcc0cc surface2 #acb0be
|
||||
overlay #9ca0b0 subtext #6c6f85 text #4c4f69
|
||||
blue #1e66f5 lavender #7287fd sapphire #209fb5
|
||||
green #40a02b teal #179299 sky #04a5e5
|
||||
mauve #8839ef pink #ea76cb red #d20f39
|
||||
peach #fe640b yellow #df8e1d
|
||||
─────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* ── Global reset ───────────────────────────────────────────────────────── */
|
||||
* {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
QMainWindow, QDialog {
|
||||
background-color: #eff1f5;
|
||||
color: #4c4f69;
|
||||
}
|
||||
|
||||
QWidget {
|
||||
background-color: #eff1f5;
|
||||
color: #4c4f69;
|
||||
font-family: "Segoe UI", "Inter", sans-serif;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
/* ── Menu bar ───────────────────────────────────────────────────────────── */
|
||||
QMenuBar {
|
||||
background-color: #dce0e8;
|
||||
color: #4c4f69;
|
||||
border-bottom: 1px solid #ccd0da;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
QMenuBar::item {
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
QMenuBar::item:selected {
|
||||
background-color: #ccd0da;
|
||||
}
|
||||
|
||||
QMenu {
|
||||
background-color: #e6e9ef;
|
||||
border: 1px solid #bcc0cc;
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
QMenu::item {
|
||||
padding: 6px 24px 6px 12px;
|
||||
border-radius: 4px;
|
||||
color: #4c4f69;
|
||||
}
|
||||
|
||||
QMenu::item:selected {
|
||||
background-color: #ccd0da;
|
||||
color: #4c4f69;
|
||||
}
|
||||
|
||||
QMenu::separator {
|
||||
height: 1px;
|
||||
background: #bcc0cc;
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
/* ── Status bar ─────────────────────────────────────────────────────────── */
|
||||
QStatusBar {
|
||||
background-color: #dce0e8;
|
||||
border-top: 1px solid #ccd0da;
|
||||
color: #6c6f85;
|
||||
font-size: 9pt;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
/* ── Scrollbars ─────────────────────────────────────────────────────────── */
|
||||
QScrollBar:vertical {
|
||||
background: #eff1f5;
|
||||
width: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical {
|
||||
background: #bcc0cc;
|
||||
border-radius: 5px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background: #acb0be;
|
||||
}
|
||||
|
||||
QScrollBar:horizontal {
|
||||
background: #eff1f5;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal {
|
||||
background: #bcc0cc;
|
||||
border-radius: 5px;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal:hover {
|
||||
background: #acb0be;
|
||||
}
|
||||
|
||||
QScrollBar::add-line, QScrollBar::sub-line { height: 0; width: 0; }
|
||||
QScrollBar::add-page, QScrollBar::sub-page { background: transparent; }
|
||||
|
||||
/* ── Splitter ────────────────────────────────────────────────────────────── */
|
||||
QSplitter::handle {
|
||||
background-color: #ccd0da;
|
||||
}
|
||||
|
||||
QSplitter::handle:horizontal { width: 2px; }
|
||||
QSplitter::handle:vertical { height: 2px; }
|
||||
|
||||
QSplitter::handle:hover {
|
||||
background-color: #1e66f5;
|
||||
}
|
||||
|
||||
/* ── Sidebar ─────────────────────────────────────────────────────────────── */
|
||||
#sidebarHeader {
|
||||
background-color: #e6e9ef;
|
||||
border-bottom: 1px solid #ccd0da;
|
||||
}
|
||||
|
||||
#sidebarTitle {
|
||||
color: #1e66f5;
|
||||
font-size: 11pt;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#newConnBtn {
|
||||
background-color: #ccd0da;
|
||||
color: #1e66f5;
|
||||
border: 1px solid #bcc0cc;
|
||||
border-radius: 6px;
|
||||
font-size: 14pt;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#newConnBtn:hover {
|
||||
background-color: #bcc0cc;
|
||||
color: #7287fd;
|
||||
}
|
||||
|
||||
/* ── Tree widget ─────────────────────────────────────────────────────────── */
|
||||
QTreeWidget {
|
||||
background-color: #e6e9ef;
|
||||
border: none;
|
||||
color: #4c4f69;
|
||||
font-size: 10pt;
|
||||
show-decoration-selected: 1;
|
||||
}
|
||||
|
||||
QTreeWidget::item {
|
||||
padding: 3px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
QTreeWidget::item:hover {
|
||||
background-color: #d8dce8;
|
||||
}
|
||||
|
||||
QTreeWidget::item:selected {
|
||||
background-color: #ccd0da;
|
||||
color: #4c4f69;
|
||||
}
|
||||
|
||||
QTreeWidget::branch {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ── Tab widget ──────────────────────────────────────────────────────────── */
|
||||
QTabWidget::pane {
|
||||
border: none;
|
||||
border-top: 1px solid #ccd0da;
|
||||
background-color: #eff1f5;
|
||||
}
|
||||
|
||||
QTabBar {
|
||||
background-color: #e6e9ef;
|
||||
}
|
||||
|
||||
QTabBar::tab {
|
||||
background-color: #e6e9ef;
|
||||
color: #6c6f85;
|
||||
padding: 7px 16px;
|
||||
border: none;
|
||||
border-right: 1px solid #ccd0da;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
QTabBar::tab:selected {
|
||||
background-color: #eff1f5;
|
||||
color: #4c4f69;
|
||||
border-bottom: 2px solid #1e66f5;
|
||||
}
|
||||
|
||||
QTabBar::tab:hover:!selected {
|
||||
background-color: #d0d4e0;
|
||||
color: #4c4f69;
|
||||
}
|
||||
|
||||
QTabBar::close-button {
|
||||
subcontrol-position: right;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
/* ── Table view ──────────────────────────────────────────────────────────── */
|
||||
QTableView, QTableWidget {
|
||||
background-color: #eff1f5;
|
||||
alternate-background-color: #e6e9ef;
|
||||
gridline-color: #ccd0da;
|
||||
color: #4c4f69;
|
||||
border: none;
|
||||
selection-background-color: #ccd0da;
|
||||
selection-color: #4c4f69;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
QTableView::item, QTableWidget::item {
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
QHeaderView {
|
||||
background-color: #e6e9ef;
|
||||
}
|
||||
|
||||
QHeaderView::section {
|
||||
background-color: #e6e9ef;
|
||||
color: #1e66f5;
|
||||
border: none;
|
||||
border-right: 1px solid #ccd0da;
|
||||
border-bottom: 1px solid #ccd0da;
|
||||
padding: 4px 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QHeaderView::section:hover {
|
||||
background-color: #d0d4e0;
|
||||
}
|
||||
|
||||
/* ── Plain text edit (SQL editor body) ───────────────────────────────────── */
|
||||
QPlainTextEdit {
|
||||
background-color: #eff1f5;
|
||||
color: #4c4f69;
|
||||
border: none;
|
||||
selection-background-color: #bcc0cc;
|
||||
font-family: "Consolas", "JetBrains Mono", "Courier New", monospace;
|
||||
font-size: 13pt;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Line number gutter ──────────────────────────────────────────────────── */
|
||||
LineNumberArea {
|
||||
background-color: #dce0e8;
|
||||
}
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────────────────── */
|
||||
QPushButton {
|
||||
background-color: #ccd0da;
|
||||
color: #4c4f69;
|
||||
border: 1px solid #bcc0cc;
|
||||
border-radius: 6px;
|
||||
padding: 5px 14px;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
background-color: #bcc0cc;
|
||||
border-color: #acb0be;
|
||||
}
|
||||
|
||||
QPushButton:pressed {
|
||||
background-color: #d0d4de;
|
||||
}
|
||||
|
||||
QPushButton:disabled {
|
||||
color: #acb0be;
|
||||
background-color: #d0d4de;
|
||||
border-color: #ccd0da;
|
||||
}
|
||||
|
||||
#runBtn {
|
||||
background-color: #40a02b;
|
||||
color: #eff1f5;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#runBtn:hover {
|
||||
background-color: #179299;
|
||||
}
|
||||
|
||||
#stopBtn {
|
||||
background-color: #d20f39;
|
||||
color: #eff1f5;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#stopBtn:hover {
|
||||
background-color: #e5394b;
|
||||
}
|
||||
|
||||
#commitBtn {
|
||||
background-color: #40a02b;
|
||||
color: #eff1f5;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#rollbackBtn {
|
||||
background-color: #fe640b;
|
||||
color: #eff1f5;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#crudAddBtn {
|
||||
background-color: #ccd0da;
|
||||
color: #40a02b;
|
||||
border: 1px solid #40a02b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#crudAddBtn:hover {
|
||||
background-color: #40a02b;
|
||||
color: #eff1f5;
|
||||
}
|
||||
|
||||
#crudDeleteBtn {
|
||||
background-color: #ccd0da;
|
||||
color: #d20f39;
|
||||
border: 1px solid #d20f39;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#crudDeleteBtn:hover {
|
||||
background-color: #d20f39;
|
||||
color: #eff1f5;
|
||||
}
|
||||
|
||||
/* ── Line edit ───────────────────────────────────────────────────────────── */
|
||||
QLineEdit {
|
||||
background-color: #e6e9ef;
|
||||
color: #4c4f69;
|
||||
border: 1px solid #bcc0cc;
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
font-size: 10pt;
|
||||
selection-background-color: #1e66f5;
|
||||
selection-color: #eff1f5;
|
||||
}
|
||||
|
||||
QLineEdit:focus {
|
||||
border-color: #1e66f5;
|
||||
}
|
||||
|
||||
QLineEdit::placeholder {
|
||||
color: #acb0be;
|
||||
}
|
||||
|
||||
/* ── Combo box ───────────────────────────────────────────────────────────── */
|
||||
QComboBox {
|
||||
background-color: #e6e9ef;
|
||||
color: #4c4f69;
|
||||
border: 1px solid #bcc0cc;
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
QComboBox:focus {
|
||||
border-color: #1e66f5;
|
||||
}
|
||||
|
||||
QComboBox::drop-down {
|
||||
border: none;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
QComboBox QAbstractItemView {
|
||||
background-color: #e6e9ef;
|
||||
color: #4c4f69;
|
||||
border: 1px solid #bcc0cc;
|
||||
selection-background-color: #ccd0da;
|
||||
}
|
||||
|
||||
/* ── Spin box ────────────────────────────────────────────────────────────── */
|
||||
QSpinBox {
|
||||
background-color: #e6e9ef;
|
||||
color: #4c4f69;
|
||||
border: 1px solid #bcc0cc;
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
QSpinBox:focus { border-color: #1e66f5; }
|
||||
|
||||
QSpinBox::up-button, QSpinBox::down-button {
|
||||
background-color: #bcc0cc;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
/* ── Check box ───────────────────────────────────────────────────────────── */
|
||||
QCheckBox {
|
||||
color: #4c4f69;
|
||||
spacing: 8px;
|
||||
}
|
||||
|
||||
QCheckBox::indicator {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #bcc0cc;
|
||||
border-radius: 4px;
|
||||
background: #e6e9ef;
|
||||
}
|
||||
|
||||
QCheckBox::indicator:checked {
|
||||
background-color: #1e66f5;
|
||||
border-color: #1e66f5;
|
||||
}
|
||||
|
||||
/* ── Dialog ──────────────────────────────────────────────────────────────── */
|
||||
QDialog {
|
||||
background-color: #eff1f5;
|
||||
}
|
||||
|
||||
QDialogButtonBox QPushButton {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
/* ── Form layout labels ──────────────────────────────────────────────────── */
|
||||
QFormLayout QLabel {
|
||||
color: #6c6f85;
|
||||
}
|
||||
|
||||
/* ── Tab widget in dialogs ───────────────────────────────────────────────── */
|
||||
QTabWidget#dialogTabs::pane {
|
||||
border: 1px solid #ccd0da;
|
||||
border-radius: 6px;
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
/* ── Group box ───────────────────────────────────────────────────────────── */
|
||||
QGroupBox {
|
||||
border: 1px solid #bcc0cc;
|
||||
border-radius: 6px;
|
||||
margin-top: 1em;
|
||||
color: #6c6f85;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
left: 10px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/* ── Progress bar ────────────────────────────────────────────────────────── */
|
||||
QProgressBar {
|
||||
background-color: #ccd0da;
|
||||
border: 1px solid #bcc0cc;
|
||||
border-radius: 6px;
|
||||
height: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
QProgressBar::chunk {
|
||||
background-color: #1e66f5;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* ── Dock widget ─────────────────────────────────────────────────────────── */
|
||||
QDockWidget {
|
||||
color: #4c4f69;
|
||||
titlebar-close-icon: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QDockWidget::title {
|
||||
background-color: #e6e9ef;
|
||||
padding: 6px;
|
||||
border-bottom: 1px solid #ccd0da;
|
||||
}
|
||||
|
||||
/* ── Tool button ─────────────────────────────────────────────────────────── */
|
||||
QToolButton {
|
||||
background-color: #ccd0da;
|
||||
color: #4c4f69;
|
||||
border: 1px solid #bcc0cc;
|
||||
border-radius: 5px;
|
||||
padding: 4px 8px;
|
||||
font-size: 13pt;
|
||||
}
|
||||
|
||||
QToolButton:hover {
|
||||
background-color: #bcc0cc;
|
||||
}
|
||||
|
||||
/* ── Message box ─────────────────────────────────────────────────────────── */
|
||||
QMessageBox {
|
||||
background-color: #eff1f5;
|
||||
}
|
||||
|
||||
QMessageBox QLabel {
|
||||
color: #4c4f69;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
/* ── Empty workspace label ───────────────────────────────────────────────── */
|
||||
#emptyLabel {
|
||||
color: #acb0be;
|
||||
font-size: 14pt;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
/* ── Status label in results toolbar ────────────────────────────────────── */
|
||||
#statusLabel {
|
||||
color: #6c6f85;
|
||||
font-size: 9pt;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
/* ── DB label in SQL editor toolbar ─────────────────────────────────────── */
|
||||
#dbLabel {
|
||||
color: #9ca0b0;
|
||||
font-size: 9pt;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
/* ── Workspace tab widget ────────────────────────────────────────────────── */
|
||||
#workspace > QTabBar::tab {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* ── Horizontal separator line in dialog ────────────────────────────────── */
|
||||
QFrame[frameShape="4"] { /* HLine */
|
||||
color: #ccd0da;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* ── Structure view title ────────────────────────────────────────────────── */
|
||||
#structureTitle {
|
||||
color: #7287fd;
|
||||
}
|
||||
|
||||
/* ── Pagination bar ──────────────────────────────────────────────────────── */
|
||||
#paginationBar {
|
||||
background-color: #e6e9ef;
|
||||
border-top: 1px solid #bcc0cc;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
#pgBtn {
|
||||
background-color: #d0d4de;
|
||||
color: #4c4f69;
|
||||
border: 1px solid #bcc0cc;
|
||||
border-radius: 5px;
|
||||
padding: 4px 10px;
|
||||
font-size: 9pt;
|
||||
min-width: 52px;
|
||||
}
|
||||
|
||||
#pgBtn:hover {
|
||||
background-color: #ccd0da;
|
||||
border-color: #acb0be;
|
||||
}
|
||||
|
||||
#pgBtn:disabled {
|
||||
color: #bcc0cc;
|
||||
background-color: #eff1f5;
|
||||
border-color: #ccd0da;
|
||||
}
|
||||
|
||||
#pageLbl {
|
||||
color: #1e66f5;
|
||||
font-size: 9pt;
|
||||
font-weight: 600;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
#rowCountLbl {
|
||||
color: #6c6f85;
|
||||
font-size: 9pt;
|
||||
padding-left: 8px;
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
DBClient — Theme template (tokens replaced at runtime by theme.py)
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Global reset ───────────────────────────────────────────────────────── */
|
||||
* {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
QMainWindow, QDialog {
|
||||
background-color: @{base};
|
||||
color: @{text};
|
||||
}
|
||||
|
||||
QWidget {
|
||||
background-color: @{base};
|
||||
color: @{text};
|
||||
font-family: "Segoe UI", "Inter", sans-serif;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
/* ── Menu bar ───────────────────────────────────────────────────────────── */
|
||||
QMenuBar {
|
||||
background-color: @{crust};
|
||||
color: @{text};
|
||||
border-bottom: 1px solid @{surface0};
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
QMenuBar::item {
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
QMenuBar::item:selected {
|
||||
background-color: @{surface0};
|
||||
}
|
||||
|
||||
QMenu {
|
||||
background-color: @{mantle};
|
||||
border: 1px solid @{surface1};
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
QMenu::item {
|
||||
padding: 6px 24px 6px 12px;
|
||||
border-radius: 4px;
|
||||
color: @{text};
|
||||
}
|
||||
|
||||
QMenu::item:selected {
|
||||
background-color: @{surface0};
|
||||
color: @{text};
|
||||
}
|
||||
|
||||
QMenu::separator {
|
||||
height: 1px;
|
||||
background: @{surface1};
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
/* ── Status bar ─────────────────────────────────────────────────────────── */
|
||||
QStatusBar {
|
||||
background-color: @{crust};
|
||||
border-top: 1px solid @{surface0};
|
||||
color: @{subtext};
|
||||
font-size: 9pt;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
/* ── Scrollbars ─────────────────────────────────────────────────────────── */
|
||||
QScrollBar:vertical {
|
||||
background: @{base};
|
||||
width: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical {
|
||||
background: @{surface1};
|
||||
border-radius: 5px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background: @{surface2};
|
||||
}
|
||||
|
||||
QScrollBar:horizontal {
|
||||
background: @{base};
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal {
|
||||
background: @{surface1};
|
||||
border-radius: 5px;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal:hover {
|
||||
background: @{surface2};
|
||||
}
|
||||
|
||||
QScrollBar::add-line, QScrollBar::sub-line { height: 0; width: 0; }
|
||||
QScrollBar::add-page, QScrollBar::sub-page { background: transparent; }
|
||||
|
||||
/* ── Splitter ────────────────────────────────────────────────────────────── */
|
||||
QSplitter::handle {
|
||||
background-color: @{surface0};
|
||||
}
|
||||
|
||||
QSplitter::handle:horizontal { width: 2px; }
|
||||
QSplitter::handle:vertical { height: 2px; }
|
||||
|
||||
QSplitter::handle:hover {
|
||||
background-color: @{blue};
|
||||
}
|
||||
|
||||
/* ── Sidebar ─────────────────────────────────────────────────────────────── */
|
||||
#sidebarHeader {
|
||||
background-color: @{mantle};
|
||||
border-bottom: 1px solid @{surface0};
|
||||
}
|
||||
|
||||
#sidebarTitle {
|
||||
color: @{blue};
|
||||
font-size: 11pt;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#newConnBtn {
|
||||
background-color: @{surface0};
|
||||
color: @{blue};
|
||||
border: 1px solid @{surface1};
|
||||
border-radius: 6px;
|
||||
font-size: 14pt;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#newConnBtn:hover {
|
||||
background-color: @{surface1};
|
||||
color: @{lavender};
|
||||
}
|
||||
|
||||
/* ── Tree widget ─────────────────────────────────────────────────────────── */
|
||||
QTreeWidget {
|
||||
background-color: @{mantle};
|
||||
border: none;
|
||||
color: @{text};
|
||||
font-size: 10pt;
|
||||
show-decoration-selected: 1;
|
||||
}
|
||||
|
||||
QTreeWidget::item {
|
||||
padding: 3px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
QTreeWidget::item:hover {
|
||||
background-color: @{tree_hover};
|
||||
}
|
||||
|
||||
QTreeWidget::item:selected {
|
||||
background-color: @{surface0};
|
||||
color: @{text};
|
||||
}
|
||||
|
||||
QTreeWidget::branch {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ── Tab widget ──────────────────────────────────────────────────────────── */
|
||||
QTabWidget::pane {
|
||||
border: none;
|
||||
border-top: 1px solid @{surface0};
|
||||
background-color: @{base};
|
||||
}
|
||||
|
||||
QTabBar {
|
||||
background-color: @{mantle};
|
||||
}
|
||||
|
||||
QTabBar::tab {
|
||||
background-color: @{mantle};
|
||||
color: @{subtext};
|
||||
padding: 7px 16px;
|
||||
border: none;
|
||||
border-right: 1px solid @{surface0};
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
QTabBar::tab:selected {
|
||||
background-color: @{base};
|
||||
color: @{text};
|
||||
border-bottom: 2px solid @{blue};
|
||||
}
|
||||
|
||||
QTabBar::tab:hover:!selected {
|
||||
background-color: @{tab_hover};
|
||||
color: @{text};
|
||||
}
|
||||
|
||||
QTabBar::close-button {
|
||||
subcontrol-position: right;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
/* ── Table view ──────────────────────────────────────────────────────────── */
|
||||
QTableView, QTableWidget {
|
||||
background-color: @{base};
|
||||
alternate-background-color: @{tree_hover};
|
||||
gridline-color: @{surface0};
|
||||
color: @{text};
|
||||
border: none;
|
||||
selection-background-color: @{surface0};
|
||||
selection-color: @{text};
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
QTableView::item, QTableWidget::item {
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
QHeaderView {
|
||||
background-color: @{mantle};
|
||||
}
|
||||
|
||||
QHeaderView::section {
|
||||
background-color: @{mantle};
|
||||
color: @{blue};
|
||||
border: none;
|
||||
border-right: 1px solid @{surface0};
|
||||
border-bottom: 1px solid @{surface0};
|
||||
padding: 4px 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QHeaderView::section:hover {
|
||||
background-color: @{tab_hover};
|
||||
}
|
||||
|
||||
/* ── Plain text edit (SQL editor body) ───────────────────────────────────── */
|
||||
QPlainTextEdit {
|
||||
background-color: @{base};
|
||||
color: @{text};
|
||||
border: none;
|
||||
selection-background-color: @{surface1};
|
||||
font-family: "Consolas", "JetBrains Mono", "Courier New", monospace;
|
||||
font-size: 13pt;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Line number gutter ──────────────────────────────────────────────────── */
|
||||
LineNumberArea {
|
||||
background-color: @{gutter_bg};
|
||||
}
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────────────────── */
|
||||
QPushButton {
|
||||
background-color: @{surface0};
|
||||
color: @{text};
|
||||
border: 1px solid @{surface1};
|
||||
border-radius: 6px;
|
||||
padding: 5px 14px;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
background-color: @{surface1};
|
||||
border-color: @{surface2};
|
||||
}
|
||||
|
||||
QPushButton:pressed {
|
||||
background-color: @{btn_pressed};
|
||||
}
|
||||
|
||||
QPushButton:disabled {
|
||||
color: @{surface2};
|
||||
background-color: @{btn_pressed};
|
||||
border-color: @{surface0};
|
||||
}
|
||||
|
||||
#runBtn {
|
||||
background-color: @{green};
|
||||
color: @{base};
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#runBtn:hover {
|
||||
background-color: @{teal};
|
||||
}
|
||||
|
||||
#stopBtn {
|
||||
background-color: @{red};
|
||||
color: @{base};
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#stopBtn:hover {
|
||||
background-color: @{red_hover};
|
||||
}
|
||||
|
||||
#commitBtn {
|
||||
background-color: @{green};
|
||||
color: @{base};
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#rollbackBtn {
|
||||
background-color: @{peach};
|
||||
color: @{base};
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#crudAddBtn {
|
||||
background-color: @{surface0};
|
||||
color: @{green};
|
||||
border: 1px solid @{green};
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#crudAddBtn:hover {
|
||||
background-color: @{green};
|
||||
color: @{base};
|
||||
}
|
||||
|
||||
#crudDeleteBtn {
|
||||
background-color: @{surface0};
|
||||
color: @{red};
|
||||
border: 1px solid @{red};
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#crudDeleteBtn:hover {
|
||||
background-color: @{red};
|
||||
color: @{base};
|
||||
}
|
||||
|
||||
/* ── Line edit ───────────────────────────────────────────────────────────── */
|
||||
QLineEdit {
|
||||
background-color: @{surface0};
|
||||
color: @{text};
|
||||
border: 1px solid @{surface1};
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
font-size: 10pt;
|
||||
selection-background-color: @{blue};
|
||||
selection-color: @{base};
|
||||
}
|
||||
|
||||
QLineEdit:focus {
|
||||
border-color: @{blue};
|
||||
}
|
||||
|
||||
QLineEdit::placeholder {
|
||||
color: @{surface2};
|
||||
}
|
||||
|
||||
/* ── Combo box ───────────────────────────────────────────────────────────── */
|
||||
QComboBox {
|
||||
background-color: @{surface0};
|
||||
color: @{text};
|
||||
border: 1px solid @{surface1};
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
QComboBox:focus {
|
||||
border-color: @{blue};
|
||||
}
|
||||
|
||||
QComboBox::drop-down {
|
||||
border: none;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
QComboBox QAbstractItemView {
|
||||
background-color: @{mantle};
|
||||
color: @{text};
|
||||
border: 1px solid @{surface1};
|
||||
selection-background-color: @{surface0};
|
||||
}
|
||||
|
||||
/* ── Spin box ────────────────────────────────────────────────────────────── */
|
||||
QSpinBox {
|
||||
background-color: @{surface0};
|
||||
color: @{text};
|
||||
border: 1px solid @{surface1};
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
QSpinBox:focus { border-color: @{blue}; }
|
||||
|
||||
QSpinBox::up-button, QSpinBox::down-button {
|
||||
background-color: @{surface1};
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
/* ── Check box ───────────────────────────────────────────────────────────── */
|
||||
QCheckBox {
|
||||
color: @{text};
|
||||
spacing: 8px;
|
||||
}
|
||||
|
||||
QCheckBox::indicator {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid @{surface1};
|
||||
border-radius: 4px;
|
||||
background: @{surface0};
|
||||
}
|
||||
|
||||
QCheckBox::indicator:checked {
|
||||
background-color: @{blue};
|
||||
border-color: @{blue};
|
||||
}
|
||||
|
||||
/* ── Dialog ──────────────────────────────────────────────────────────────── */
|
||||
QDialog {
|
||||
background-color: @{base};
|
||||
}
|
||||
|
||||
QDialogButtonBox QPushButton {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
/* ── Form layout labels ──────────────────────────────────────────────────── */
|
||||
QFormLayout QLabel {
|
||||
color: @{subtext};
|
||||
}
|
||||
|
||||
/* ── Tab widget in dialogs ───────────────────────────────────────────────── */
|
||||
QTabWidget#dialogTabs::pane {
|
||||
border: 1px solid @{surface0};
|
||||
border-radius: 6px;
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
/* ── Group box ───────────────────────────────────────────────────────────── */
|
||||
QGroupBox {
|
||||
border: 1px solid @{surface1};
|
||||
border-radius: 6px;
|
||||
margin-top: 1em;
|
||||
color: @{subtext};
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
left: 10px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/* ── Progress bar ────────────────────────────────────────────────────────── */
|
||||
QProgressBar {
|
||||
background-color: @{surface0};
|
||||
border: 1px solid @{surface1};
|
||||
border-radius: 6px;
|
||||
height: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
QProgressBar::chunk {
|
||||
background-color: @{blue};
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* ── Dock widget ─────────────────────────────────────────────────────────── */
|
||||
QDockWidget {
|
||||
color: @{text};
|
||||
titlebar-close-icon: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QDockWidget::title {
|
||||
background-color: @{mantle};
|
||||
padding: 6px;
|
||||
border-bottom: 1px solid @{surface0};
|
||||
}
|
||||
|
||||
/* ── Tool button ─────────────────────────────────────────────────────────── */
|
||||
QToolButton {
|
||||
background-color: @{surface0};
|
||||
color: @{text};
|
||||
border: 1px solid @{surface1};
|
||||
border-radius: 5px;
|
||||
padding: 4px 8px;
|
||||
font-size: 13pt;
|
||||
}
|
||||
|
||||
QToolButton:hover {
|
||||
background-color: @{surface1};
|
||||
}
|
||||
|
||||
/* ── Message box ─────────────────────────────────────────────────────────── */
|
||||
QMessageBox {
|
||||
background-color: @{base};
|
||||
}
|
||||
|
||||
QMessageBox QLabel {
|
||||
color: @{text};
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
/* ── Empty workspace label ───────────────────────────────────────────────── */
|
||||
#emptyLabel {
|
||||
color: @{surface2};
|
||||
font-size: 14pt;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
/* ── Status label in results toolbar ────────────────────────────────────── */
|
||||
#statusLabel {
|
||||
color: @{subtext};
|
||||
font-size: 9pt;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
/* ── DB label in SQL editor toolbar ─────────────────────────────────────── */
|
||||
#dbLabel {
|
||||
color: @{overlay};
|
||||
font-size: 9pt;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
/* ── Workspace tab widget ────────────────────────────────────────────────── */
|
||||
#workspace > QTabBar::tab {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* ── Horizontal separator line in dialog ────────────────────────────────── */
|
||||
QFrame[frameShape="4"] { /* HLine */
|
||||
color: @{surface0};
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* ── Structure view title ────────────────────────────────────────────────── */
|
||||
#structureTitle {
|
||||
color: @{lavender};
|
||||
}
|
||||
|
||||
/* ── Pagination bar ──────────────────────────────────────────────────────── */
|
||||
#paginationBar {
|
||||
background-color: @{mantle};
|
||||
border-top: 1px solid @{surface1};
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
#pgBtn {
|
||||
background-color: @{btn_pressed};
|
||||
color: @{text};
|
||||
border: 1px solid @{surface1};
|
||||
border-radius: 5px;
|
||||
padding: 4px 10px;
|
||||
font-size: 9pt;
|
||||
min-width: 52px;
|
||||
}
|
||||
|
||||
#pgBtn:hover {
|
||||
background-color: @{surface0};
|
||||
border-color: @{surface2};
|
||||
}
|
||||
|
||||
#pgBtn:disabled {
|
||||
color: @{surface1};
|
||||
background-color: @{base};
|
||||
border-color: @{surface0};
|
||||
}
|
||||
|
||||
#pageLbl {
|
||||
color: @{blue};
|
||||
font-size: 9pt;
|
||||
font-weight: 600;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
#rowCountLbl {
|
||||
color: @{subtext};
|
||||
font-size: 9pt;
|
||||
padding-left: 8px;
|
||||
}
|
||||
Reference in New Issue
Block a user