Compare commits
10
Commits
972166e62a
...
4fcbe5a879
| 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
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Key runtime dependencies: `PyQt6`, `sqlparse>=0.5.0`, `bcrypt`, `keyring`, `cryptography`.
|
||||||
|
|
||||||
**Run the application:**
|
**Run the application:**
|
||||||
```bash
|
```bash
|
||||||
python main.py
|
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.
|
The `ResultTableModel` (`app/models/result_table_model.py`) is a `QAbstractTableModel` — query results should always go through it rather than populating `QTableWidget` directly.
|
||||||
|
|
||||||
### Styling
|
### 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
|
### 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.
|
`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
|
## 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)
|
- 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)
|
- 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 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)
|
- 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)
|
- 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)
|
- 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`)
|
- Query history (auto-log with timestamp/duration/status, search, replay, persisted in `history.db`)
|
||||||
- All 4 DB drivers (MySQL, PostgreSQL, SQLite, MSSQL)
|
- All 4 DB drivers (MySQL, PostgreSQL, SQLite, MSSQL)
|
||||||
- Process list viewer (`app/ui/process_list.py`) with 5 s auto-refresh and kill query
|
- 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
|
- 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`)
|
- 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)
|
- 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
|
- 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)
|
- 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
|
### 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."""
|
"""Returns list of database name strings."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def quote_identifier(self, name: str) -> str:
|
||||||
|
"""Wrap an identifier in the DB-appropriate quote characters."""
|
||||||
|
return f'"{name}"'
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_tables(self, database: str) -> list:
|
def get_tables(self, database: str) -> list:
|
||||||
"""Returns list of TableInfo for the given database."""
|
"""Returns list of TableInfo for the given database."""
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ class MSSQLDriver(BaseDriver):
|
|||||||
super().__init__(config)
|
super().__init__(config)
|
||||||
self.db_type = "mssql"
|
self.db_type = "mssql"
|
||||||
|
|
||||||
|
def quote_identifier(self, name: str) -> str:
|
||||||
|
return f"[{name}]"
|
||||||
|
|
||||||
def _conn_str(self) -> str:
|
def _conn_str(self) -> str:
|
||||||
host = self.config.get("host", "localhost")
|
host = self.config.get("host", "localhost")
|
||||||
port = int(self.config.get("port", 1433))
|
port = int(self.config.get("port", 1433))
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ class MySQLDriver(BaseDriver):
|
|||||||
super().__init__(config)
|
super().__init__(config)
|
||||||
self.db_type = "mysql"
|
self.db_type = "mysql"
|
||||||
|
|
||||||
|
def quote_identifier(self, name: str) -> str:
|
||||||
|
return f"`{name}`"
|
||||||
|
|
||||||
def _connect_kwargs(self) -> dict:
|
def _connect_kwargs(self) -> dict:
|
||||||
kw = {
|
kw = {
|
||||||
"host": self.config.get("host", "localhost"),
|
"host": self.config.get("host", "localhost"),
|
||||||
|
|||||||
+119
-1
@@ -15,8 +15,9 @@ from PyQt6.QtWidgets import (
|
|||||||
QTabWidget, QStatusBar, QLabel, QMessageBox, QDockWidget,
|
QTabWidget, QStatusBar, QLabel, QMessageBox, QDockWidget,
|
||||||
QPushButton, QApplication, QMenu, QInputDialog, QFileDialog,
|
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 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.schema_browser import SchemaBrowser
|
||||||
from app.ui.sql_editor import SQLEditorWidget
|
from app.ui.sql_editor import SQLEditorWidget
|
||||||
@@ -31,6 +32,7 @@ from app.ui.user_manager import UserManagerPanel
|
|||||||
from app.ui.log_viewer import LogViewer
|
from app.ui.log_viewer import LogViewer
|
||||||
from app.ui.connection_dialog import ConnectionDialog
|
from app.ui.connection_dialog import ConnectionDialog
|
||||||
from app.ui.shortcuts_dialog import ShortcutsDialog
|
from app.ui.shortcuts_dialog import ShortcutsDialog
|
||||||
|
from app.ui.preferences_dialog import PreferencesDialog
|
||||||
from app.config.connections import load_profiles, delete_profile
|
from app.config.connections import load_profiles, delete_profile
|
||||||
from app.config.recent_files import load_recent, add_recent, clear_recent
|
from app.config.recent_files import load_recent, add_recent, clear_recent
|
||||||
from app.models.connection_model import ConnectionProfile
|
from app.models.connection_model import ConnectionProfile
|
||||||
@@ -40,6 +42,23 @@ from app.utils.logger import get_logger
|
|||||||
_log = get_logger(__name__)
|
_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):
|
class MainWindow(QMainWindow):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -52,11 +71,19 @@ class MainWindow(QMainWindow):
|
|||||||
self._active_drivers: dict = {}
|
self._active_drivers: dict = {}
|
||||||
# profile_id → ConnectionProfile (all loaded profiles, connected or not)
|
# profile_id → ConnectionProfile (all loaded profiles, connected or not)
|
||||||
self._all_profiles: dict = {}
|
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_ui()
|
||||||
self._build_menus()
|
self._build_menus()
|
||||||
self._build_status_bar()
|
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
|
# Load saved profiles after the window is shown
|
||||||
QTimer.singleShot(0, self._load_saved_profiles)
|
QTimer.singleShot(0, self._load_saved_profiles)
|
||||||
|
|
||||||
@@ -181,9 +208,18 @@ class MainWindow(QMainWindow):
|
|||||||
view_menu = mb.addMenu("&View")
|
view_menu = mb.addMenu("&View")
|
||||||
view_menu.addAction(self._act("Toggle Query History", self._toggle_history, "Ctrl+Shift+H"))
|
view_menu.addAction(self._act("Toggle Query History", self._toggle_history, "Ctrl+Shift+H"))
|
||||||
view_menu.addAction(self._act("New SQL Tab", self._new_sql_tab, "Ctrl+T"))
|
view_menu.addAction(self._act("New SQL Tab", self._new_sql_tab, "Ctrl+T"))
|
||||||
|
view_menu.addAction(self._act("Close Tab", self._close_current_tab, "Ctrl+W"))
|
||||||
|
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 ─────────────────────────────────────────────────────────────
|
||||||
tools_menu = mb.addMenu("&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.addAction(self._act("Process List…", self._open_process_list, "Ctrl+P"))
|
||||||
tools_menu.addSeparator()
|
tools_menu.addSeparator()
|
||||||
tools_menu.addAction(self._act("Import CSV / JSON…", self._open_import_dialog))
|
tools_menu.addAction(self._act("Import CSV / JSON…", self._open_import_dialog))
|
||||||
@@ -230,6 +266,12 @@ class MainWindow(QMainWindow):
|
|||||||
self._conn_lbl.setMinimumWidth(160)
|
self._conn_lbl.setMinimumWidth(160)
|
||||||
sb.addPermanentWidget(self._conn_lbl)
|
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):
|
def _set_status(self, msg: str):
|
||||||
self._status_lbl.setText(msg)
|
self._status_lbl.setText(msg)
|
||||||
|
|
||||||
@@ -400,6 +442,7 @@ class MainWindow(QMainWindow):
|
|||||||
else:
|
else:
|
||||||
self._schema_browser.remove_connection(profile_id, keep_saved=False)
|
self._schema_browser.remove_connection(profile_id, keep_saved=False)
|
||||||
self._all_profiles.pop(profile_id, None)
|
self._all_profiles.pop(profile_id, None)
|
||||||
|
self._ping_results.pop(profile_id, None)
|
||||||
delete_profile(profile_id)
|
delete_profile(profile_id)
|
||||||
self._set_status(f"Connection '{profile.name}' deleted.")
|
self._set_status(f"Connection '{profile.name}' deleted.")
|
||||||
|
|
||||||
@@ -467,6 +510,11 @@ class MainWindow(QMainWindow):
|
|||||||
self._workspace.setVisible(False)
|
self._workspace.setVisible(False)
|
||||||
self._empty_label.setVisible(True)
|
self._empty_label.setVisible(True)
|
||||||
|
|
||||||
|
def _close_current_tab(self):
|
||||||
|
idx = self._workspace.currentIndex()
|
||||||
|
if idx >= 0:
|
||||||
|
self._close_tab(idx)
|
||||||
|
|
||||||
def _on_cell_selected(self, value: str):
|
def _on_cell_selected(self, value: str):
|
||||||
"""Show a truncated cell value in the status bar."""
|
"""Show a truncated cell value in the status bar."""
|
||||||
if not value or value == "NULL":
|
if not value or value == "NULL":
|
||||||
@@ -475,6 +523,44 @@ class MainWindow(QMainWindow):
|
|||||||
preview = value[:60] + ("…" if len(value) > 60 else "")
|
preview = value[:60] + ("…" if len(value) > 60 else "")
|
||||||
self._cell_lbl.setText(f" {preview}")
|
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 ──────────────────────────────────────────────────────
|
# ── File open / save ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _open_sql_file(self, filepath: str = None):
|
def _open_sql_file(self, filepath: str = None):
|
||||||
@@ -581,6 +667,38 @@ class MainWindow(QMainWindow):
|
|||||||
"MySQL · PostgreSQL · SQLite · SQL Server<br><br>"
|
"MySQL · PostgreSQL · SQLite · SQL Server<br><br>"
|
||||||
"Built with Python + PyQt6.")
|
"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):
|
def _show_shortcuts(self):
|
||||||
dlg = ShortcutsDialog(parent=self)
|
dlg = ShortcutsDialog(parent=self)
|
||||||
dlg.exec()
|
dlg.exec()
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ from PyQt6.QtCore import (
|
|||||||
)
|
)
|
||||||
from PyQt6.QtGui import QColor, QFont
|
from PyQt6.QtGui import QColor, QFont
|
||||||
|
|
||||||
|
from app.config.theme import get_palette
|
||||||
|
|
||||||
|
|
||||||
class ResultTableModel(QAbstractTableModel):
|
class ResultTableModel(QAbstractTableModel):
|
||||||
"""Immutable result-set model — replaces data via set_data()."""
|
"""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
|
NUM_ALIGN = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
||||||
TEXT_ALIGN = Qt.AlignmentFlag.AlignLeft | 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.accepted.connect(self._accept)
|
||||||
bbox.rejected.connect(self.reject)
|
bbox.rejected.connect(self.reject)
|
||||||
|
ok_btn = bbox.button(QDialogButtonBox.StandardButton.Ok)
|
||||||
|
if ok_btn:
|
||||||
|
ok_btn.setDefault(True)
|
||||||
|
|
||||||
btn_row = QHBoxLayout()
|
btn_row = QHBoxLayout()
|
||||||
btn_row.addWidget(self._test_btn)
|
btn_row.addWidget(self._test_btn)
|
||||||
@@ -167,6 +170,12 @@ class ConnectionDialog(QDialog):
|
|||||||
self._timeout.setSuffix(" sec")
|
self._timeout.setSuffix(" sec")
|
||||||
form.addRow("Timeout:", self._timeout)
|
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
|
return w
|
||||||
|
|
||||||
def _build_ssl_tab(self) -> QWidget:
|
def _build_ssl_tab(self) -> QWidget:
|
||||||
|
|||||||
+289
-75
@@ -1,10 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
Import CSV / JSON into a database table.
|
Import CSV / JSON / SQL dump into a database.
|
||||||
|
|
||||||
Flow:
|
CSV / JSON flow:
|
||||||
1. User picks a file (.csv or .json)
|
1. Pick file → parse → table preview → insert rows via driver.insert_row()
|
||||||
2. A preview of the first N rows is shown
|
|
||||||
3. User confirms → rows are inserted via the driver's 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 csv
|
||||||
import json
|
import json
|
||||||
@@ -14,18 +17,21 @@ from PyQt6.QtWidgets import (
|
|||||||
QLabel, QLineEdit, QPushButton, QComboBox,
|
QLabel, QLineEdit, QPushButton, QComboBox,
|
||||||
QTableWidget, QTableWidgetItem, QHeaderView,
|
QTableWidget, QTableWidgetItem, QHeaderView,
|
||||||
QDialogButtonBox, QFileDialog, QProgressBar,
|
QDialogButtonBox, QFileDialog, QProgressBar,
|
||||||
QMessageBox, QCheckBox,
|
QMessageBox, QCheckBox, QStackedWidget, QPlainTextEdit,
|
||||||
|
QWidget,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal
|
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):
|
class _RowImportWorker(QThread):
|
||||||
progress = pyqtSignal(int) # rows inserted so far
|
progress = pyqtSignal(int) # rows inserted so far
|
||||||
finished = pyqtSignal(int) # total rows inserted
|
finished = pyqtSignal(int) # total inserted
|
||||||
error = pyqtSignal(str)
|
error = pyqtSignal(str)
|
||||||
|
|
||||||
def __init__(self, driver, database: str, table: str,
|
def __init__(self, driver, database: str, table: str,
|
||||||
@@ -34,7 +40,7 @@ class _ImportWorker(QThread):
|
|||||||
self._driver = driver
|
self._driver = driver
|
||||||
self._database = database
|
self._database = database
|
||||||
self._table = table
|
self._table = table
|
||||||
self._rows = rows # list of dicts {col: value}
|
self._rows = rows
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
inserted = 0
|
inserted = 0
|
||||||
@@ -49,34 +55,89 @@ class _ImportWorker(QThread):
|
|||||||
self.error.emit(f"Row {inserted + 1}: {e}")
|
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 ────────────────────────────────────────────────────────────────────
|
# ── Dialog ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class ImportDialog(QDialog):
|
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)
|
super().__init__(parent)
|
||||||
self._driver = driver
|
self._driver = driver
|
||||||
self._database = database
|
self._database = database
|
||||||
self._table = table
|
self._table = table
|
||||||
self._rows: list = [] # parsed rows ready for import
|
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._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.setModal(True)
|
||||||
self.setMinimumSize(640, 480)
|
self.setMinimumSize(680, 520)
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
|
|
||||||
# ── UI ────────────────────────────────────────────────────────────────────
|
# ── UI ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _build_ui(self):
|
def _build_ui(self):
|
||||||
root = QVBoxLayout(self)
|
root = QVBoxLayout(self)
|
||||||
|
root.setSpacing(8)
|
||||||
|
|
||||||
# File picker row
|
# ── File picker ───────────────────────────────────────────────────────
|
||||||
file_row = QHBoxLayout()
|
file_row = QHBoxLayout()
|
||||||
self._path_edit = QLineEdit()
|
self._path_edit = QLineEdit()
|
||||||
self._path_edit.setReadOnly(True)
|
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 = QPushButton("Browse…")
|
||||||
browse_btn.setFixedWidth(80)
|
browse_btn.setFixedWidth(80)
|
||||||
browse_btn.clicked.connect(self._browse)
|
browse_btn.clicked.connect(self._browse)
|
||||||
@@ -84,38 +145,56 @@ class ImportDialog(QDialog):
|
|||||||
file_row.addWidget(browse_btn)
|
file_row.addWidget(browse_btn)
|
||||||
root.addLayout(file_row)
|
root.addLayout(file_row)
|
||||||
|
|
||||||
# CSV options (hidden until a CSV is selected)
|
# ── CSV-only options ──────────────────────────────────────────────────
|
||||||
self._csv_opts = QHBoxLayout()
|
|
||||||
self._csv_opts_widget = self._build_csv_opts()
|
self._csv_opts_widget = self._build_csv_opts()
|
||||||
root.addWidget(self._csv_opts_widget)
|
|
||||||
self._csv_opts_widget.setVisible(False)
|
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 = QCheckBox("First row is a header (CSV only)")
|
||||||
self._header_cb.setChecked(True)
|
self._header_cb.setChecked(True)
|
||||||
self._header_cb.toggled.connect(self._reload_preview)
|
self._header_cb.toggled.connect(self._reload_preview)
|
||||||
|
self._header_cb.setVisible(False)
|
||||||
root.addWidget(self._header_cb)
|
root.addWidget(self._header_cb)
|
||||||
|
|
||||||
# Preview table
|
# ── SQL-only options ──────────────────────────────────────────────────
|
||||||
root.addWidget(QLabel("Preview (first 50 rows):"))
|
self._sql_opts_widget = self._build_sql_opts()
|
||||||
self._preview = QTableWidget(0, 0)
|
self._sql_opts_widget.setVisible(False)
|
||||||
self._preview.setAlternatingRowColors(True)
|
root.addWidget(self._sql_opts_widget)
|
||||||
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)
|
|
||||||
|
|
||||||
# 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("")
|
self._status_lbl = QLabel("")
|
||||||
root.addWidget(self._status_lbl)
|
root.addWidget(self._status_lbl)
|
||||||
|
|
||||||
self._progress = QProgressBar()
|
self._progress = QProgressBar()
|
||||||
self._progress.setVisible(False)
|
self._progress.setVisible(False)
|
||||||
root.addWidget(self._progress)
|
root.addWidget(self._progress)
|
||||||
|
|
||||||
# Buttons
|
# ── Buttons ───────────────────────────────────────────────────────────
|
||||||
self._btns = QDialogButtonBox(
|
self._btns = QDialogButtonBox(
|
||||||
QDialogButtonBox.StandardButton.Ok |
|
QDialogButtonBox.StandardButton.Ok |
|
||||||
QDialogButtonBox.StandardButton.Cancel
|
QDialogButtonBox.StandardButton.Cancel
|
||||||
@@ -127,8 +206,7 @@ class ImportDialog(QDialog):
|
|||||||
self._btns.rejected.connect(self.reject)
|
self._btns.rejected.connect(self.reject)
|
||||||
root.addWidget(self._btns)
|
root.addWidget(self._btns)
|
||||||
|
|
||||||
def _build_csv_opts(self) -> QHBoxLayout:
|
def _build_csv_opts(self) -> QWidget:
|
||||||
from PyQt6.QtWidgets import QWidget
|
|
||||||
w = QWidget()
|
w = QWidget()
|
||||||
lay = QHBoxLayout(w)
|
lay = QHBoxLayout(w)
|
||||||
lay.setContentsMargins(0, 0, 0, 0)
|
lay.setContentsMargins(0, 0, 0, 0)
|
||||||
@@ -142,69 +220,116 @@ class ImportDialog(QDialog):
|
|||||||
lay.addStretch()
|
lay.addStretch()
|
||||||
return w
|
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 ──────────────────────────────────────────────────────────
|
# ── File loading ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _browse(self):
|
def _browse(self):
|
||||||
path, _ = QFileDialog.getOpenFileName(
|
path, _ = QFileDialog.getOpenFileName(
|
||||||
self, "Open file", "",
|
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:
|
if not path:
|
||||||
return
|
return
|
||||||
self._path_edit.setText(path)
|
self._path_edit.setText(path)
|
||||||
ext = os.path.splitext(path)[1].lower()
|
ext = os.path.splitext(path)[1].lower()
|
||||||
self._csv_opts_widget.setVisible(ext == ".csv")
|
is_csv = ext == ".csv"
|
||||||
self._header_cb.setVisible(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()
|
self._reload_preview()
|
||||||
|
|
||||||
def _delimiter(self) -> str:
|
def _delimiter(self) -> str:
|
||||||
mapping = {0: ",", 1: ";", 2: "\t", 3: "|"}
|
return {0: ",", 1: ";", 2: "\t", 3: "|"}.get(
|
||||||
return mapping.get(self._delim_combo.currentIndex(), ",")
|
self._delim_combo.currentIndex(), ","
|
||||||
|
)
|
||||||
|
|
||||||
def _reload_preview(self):
|
def _reload_preview(self):
|
||||||
path = self._path_edit.text()
|
path = self._path_edit.text()
|
||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
ext = os.path.splitext(path)[1].lower()
|
ext = os.path.splitext(path)[1].lower()
|
||||||
try:
|
|
||||||
if ext == ".csv":
|
if ext == ".sql":
|
||||||
self._rows = self._parse_csv(path)
|
self._load_sql_preview(path)
|
||||||
|
elif ext == ".csv":
|
||||||
|
self._load_csv_preview(path)
|
||||||
elif ext == ".json":
|
elif ext == ".json":
|
||||||
self._rows = self._parse_json(path)
|
self._load_json_preview(path)
|
||||||
else:
|
else:
|
||||||
self._status_lbl.setText("Unsupported file type.")
|
self._status_lbl.setText("Unsupported file type.")
|
||||||
return
|
|
||||||
|
# ── CSV / JSON loading ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _load_csv_preview(self, path: str):
|
||||||
|
try:
|
||||||
|
self._rows = self._parse_csv(path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._status_lbl.setText(f"Parse error: {e}")
|
self._status_lbl.setText(f"Parse error: {e}")
|
||||||
self._rows = []
|
self._rows = []
|
||||||
self._ok_btn.setEnabled(False)
|
self._ok_btn.setEnabled(False)
|
||||||
return
|
return
|
||||||
|
self._preview_stack.setCurrentIndex(0)
|
||||||
self._populate_preview(self._rows[:_PREVIEW_ROWS])
|
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(
|
self._status_lbl.setText(
|
||||||
f"{len(self._rows)} row(s) ready to import into "
|
f"{len(self._rows):,} row(s) ready to import into {target}"
|
||||||
f"'{self._database}'.'{self._table}'"
|
)
|
||||||
|
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))
|
self._ok_btn.setEnabled(bool(self._rows))
|
||||||
|
|
||||||
def _parse_csv(self, path: str) -> list:
|
def _parse_csv(self, path: str) -> list:
|
||||||
rows = []
|
|
||||||
with open(path, newline="", encoding="utf-8-sig") as f:
|
with open(path, newline="", encoding="utf-8-sig") as f:
|
||||||
reader = csv.reader(f, delimiter=self._delimiter())
|
reader = csv.reader(f, delimiter=self._delimiter())
|
||||||
all_rows = list(reader)
|
all_rows = list(reader)
|
||||||
if not all_rows:
|
if not all_rows:
|
||||||
return []
|
return []
|
||||||
if self._header_cb.isChecked():
|
if self._header_cb.isChecked():
|
||||||
headers = all_rows[0]
|
headers, data_rows = all_rows[0], all_rows[1:]
|
||||||
data_rows = all_rows[1:]
|
|
||||||
else:
|
else:
|
||||||
headers = [f"col{i+1}" for i in range(len(all_rows[0]))]
|
headers = [f"col{i+1}" for i in range(len(all_rows[0]))]
|
||||||
data_rows = all_rows
|
data_rows = all_rows
|
||||||
for row in data_rows:
|
return [
|
||||||
# Pad short rows, truncate long ones
|
dict(zip(headers, (row + [""] * len(headers))[: len(headers)]))
|
||||||
padded = (row + [""] * len(headers))[: len(headers)]
|
for row in data_rows
|
||||||
rows.append(dict(zip(headers, padded)))
|
]
|
||||||
return rows
|
|
||||||
|
|
||||||
def _parse_json(self, path: str) -> list:
|
def _parse_json(self, path: str) -> list:
|
||||||
with open(path, encoding="utf-8") as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
@@ -212,57 +337,146 @@ class ImportDialog(QDialog):
|
|||||||
if isinstance(data, list):
|
if isinstance(data, list):
|
||||||
return [r for r in data if isinstance(r, dict)]
|
return [r for r in data if isinstance(r, dict)]
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
# Support {rows: [...]} or {data: [...]} wrappers
|
|
||||||
for key in ("rows", "data", "records", "items"):
|
for key in ("rows", "data", "records", "items"):
|
||||||
if isinstance(data.get(key), list):
|
if isinstance(data.get(key), list):
|
||||||
return data[key]
|
return data[key]
|
||||||
raise ValueError("JSON must be an array of objects or {rows: [...]}")
|
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:
|
if not rows:
|
||||||
self._preview.setRowCount(0)
|
self._preview_table.setRowCount(0)
|
||||||
self._preview.setColumnCount(0)
|
self._preview_table.setColumnCount(0)
|
||||||
return
|
return
|
||||||
headers = list(rows[0].keys())
|
headers = list(rows[0].keys())
|
||||||
self._preview.setColumnCount(len(headers))
|
self._preview_table.setColumnCount(len(headers))
|
||||||
self._preview.setHorizontalHeaderLabels(headers)
|
self._preview_table.setHorizontalHeaderLabels(headers)
|
||||||
self._preview.setRowCount(len(rows))
|
self._preview_table.setRowCount(len(rows))
|
||||||
for r, row in enumerate(rows):
|
for r, row in enumerate(rows):
|
||||||
for c, key in enumerate(headers):
|
for c, key in enumerate(headers):
|
||||||
val = row.get(key, "")
|
val = row.get(key, "")
|
||||||
self._preview.setItem(
|
self._preview_table.setItem(
|
||||||
r, c, QTableWidgetItem("" if val is None else str(val))
|
r, c, QTableWidgetItem("" if val is None else str(val))
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Import ────────────────────────────────────────────────────────────────
|
# ── Import dispatch ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _start_import(self):
|
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:
|
if not self._rows:
|
||||||
return
|
return
|
||||||
self._ok_btn.setEnabled(False)
|
|
||||||
self._progress.setMaximum(len(self._rows))
|
self._progress.setMaximum(len(self._rows))
|
||||||
self._progress.setValue(0)
|
self._progress.setValue(0)
|
||||||
self._progress.setVisible(True)
|
self._progress.setVisible(True)
|
||||||
|
|
||||||
self._worker = _ImportWorker(
|
self._worker = _RowImportWorker(
|
||||||
self._driver, self._database, self._table, self._rows, parent=self
|
self._driver, self._database, self._table, self._rows, parent=self
|
||||||
)
|
)
|
||||||
self._worker.progress.connect(self._progress.setValue)
|
self._worker.progress.connect(self._progress.setValue)
|
||||||
self._worker.finished.connect(self._on_done)
|
self._worker.finished.connect(self._on_row_done)
|
||||||
self._worker.error.connect(self._on_error)
|
self._worker.error.connect(self._on_fatal_error)
|
||||||
self._worker.start()
|
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)
|
self._progress.setValue(count)
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self, "Import Complete",
|
self, "Import Complete",
|
||||||
f"Successfully imported {count} row(s) into '{self._table}'."
|
f"Successfully imported {count:,} row(s) into '{self._table}'."
|
||||||
)
|
)
|
||||||
self.accept()
|
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._progress.setVisible(False)
|
||||||
self._ok_btn.setEnabled(True)
|
self._ok_btn.setEnabled(True)
|
||||||
QMessageBox.critical(self, "Import Error", f"Import failed:\n{msg}")
|
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()
|
||||||
+165
-73
@@ -1,11 +1,17 @@
|
|||||||
"""
|
"""
|
||||||
Results panel — shows query result data, DML messages, errors, and export controls.
|
Results panel — shows query result data, DML messages, errors, and export controls.
|
||||||
|
|
||||||
|
Single query: one result shown directly (tab bar hidden).
|
||||||
|
Script with multiple statements: each result set shown in its own named tab.
|
||||||
"""
|
"""
|
||||||
|
import io
|
||||||
|
import csv
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QHBoxLayout, QTableView, QLabel, QPushButton,
|
QWidget, QVBoxLayout, QHBoxLayout, QTableView, QLabel, QPushButton,
|
||||||
QHeaderView, QAbstractItemView, QFileDialog, QMessageBox, QStackedWidget,
|
QHeaderView, QAbstractItemView, QFileDialog, QMessageBox, QStackedWidget,
|
||||||
QPlainTextEdit, QProgressBar,
|
QPlainTextEdit, QProgressBar, QTabWidget, QApplication, QMenu,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, pyqtSignal, QSortFilterProxyModel
|
from PyQt6.QtCore import Qt, pyqtSignal, QSortFilterProxyModel
|
||||||
from PyQt6.QtGui import QColor, QFont
|
from PyQt6.QtGui import QColor, QFont
|
||||||
@@ -13,12 +19,19 @@ from PyQt6.QtGui import QColor, QFont
|
|||||||
from app.models.result_table_model import ResultTableModel
|
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):
|
class ResultsPanel(QWidget):
|
||||||
status_message = pyqtSignal(str)
|
status_message = pyqtSignal(str)
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._model = ResultTableModel()
|
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
|
|
||||||
# ── UI ────────────────────────────────────────────────────────────────────
|
# ── UI ────────────────────────────────────────────────────────────────────
|
||||||
@@ -52,35 +65,13 @@ class ResultsPanel(QWidget):
|
|||||||
self._toolbar.addWidget(self._export_sql)
|
self._toolbar.addWidget(self._export_sql)
|
||||||
root.addLayout(self._toolbar)
|
root.addLayout(self._toolbar)
|
||||||
|
|
||||||
# ── Stacked pages ─────────────────────────────────────────────────────
|
# ── Stack: 0 = result tabs, 1 = loading ──────────────────────────────
|
||||||
self._stack = QStackedWidget()
|
self._stack = QStackedWidget()
|
||||||
|
|
||||||
# Page 0 — table
|
self._result_tabs = QTabWidget()
|
||||||
self._table = QTableView()
|
self._result_tabs.tabBar().setVisible(False)
|
||||||
self._proxy = QSortFilterProxyModel()
|
self._stack.addWidget(self._result_tabs) # idx 0
|
||||||
self._proxy.setSourceModel(self._model)
|
|
||||||
self._table.setModel(self._proxy)
|
|
||||||
self._table.setSortingEnabled(True)
|
|
||||||
self._table.setAlternatingRowColors(True)
|
|
||||||
self._table.setSelectionBehavior(
|
|
||||||
QAbstractItemView.SelectionBehavior.SelectItems)
|
|
||||||
self._table.setSelectionMode(
|
|
||||||
QAbstractItemView.SelectionMode.ExtendedSelection)
|
|
||||||
self._table.horizontalHeader().setSectionResizeMode(
|
|
||||||
QHeaderView.ResizeMode.Interactive)
|
|
||||||
self._table.horizontalHeader().setStretchLastSection(True)
|
|
||||||
self._table.verticalHeader().setDefaultSectionSize(24)
|
|
||||||
self._table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
||||||
self._table.customContextMenuRequested.connect(self._table_context_menu)
|
|
||||||
self._stack.addWidget(self._table) # idx 0
|
|
||||||
|
|
||||||
# Page 1 — message / log
|
|
||||||
self._msg_view = QPlainTextEdit()
|
|
||||||
self._msg_view.setReadOnly(True)
|
|
||||||
self._msg_view.setFont(QFont("Consolas", 11))
|
|
||||||
self._stack.addWidget(self._msg_view) # idx 1
|
|
||||||
|
|
||||||
# Page 2 — loading spinner
|
|
||||||
loading = QWidget()
|
loading = QWidget()
|
||||||
ll = QVBoxLayout(loading)
|
ll = QVBoxLayout(loading)
|
||||||
ll.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
ll.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
@@ -89,41 +80,146 @@ class ResultsPanel(QWidget):
|
|||||||
bar.setFixedWidth(200)
|
bar.setFixedWidth(200)
|
||||||
ll.addWidget(QLabel("Executing query…"))
|
ll.addWidget(QLabel("Executing query…"))
|
||||||
ll.addWidget(bar)
|
ll.addWidget(bar)
|
||||||
self._stack.addWidget(loading) # idx 2
|
self._stack.addWidget(loading) # idx 1
|
||||||
|
|
||||||
root.addWidget(self._stack, 1)
|
root.addWidget(self._stack, 1)
|
||||||
self._set_export_visible(False)
|
self._set_export_visible(False)
|
||||||
|
|
||||||
|
# ── Tab helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _clear_tabs(self):
|
||||||
|
while self._result_tabs.count():
|
||||||
|
self._result_tabs.removeTab(0)
|
||||||
|
|
||||||
|
def _make_table_tab(self, cols: list, rows: list) -> tuple:
|
||||||
|
model = ResultTableModel()
|
||||||
|
model.set_data(cols, rows)
|
||||||
|
proxy = _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 ────────────────────────────────────────────────────────────
|
# ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def show_loading(self):
|
def show_loading(self):
|
||||||
self._model.clear()
|
self._clear_tabs()
|
||||||
self._stack.setCurrentIndex(2)
|
self._stack.setCurrentIndex(1)
|
||||||
self._status_lbl.setText("Running…")
|
self._status_lbl.setText("Running…")
|
||||||
self._set_export_visible(False)
|
self._set_export_visible(False)
|
||||||
|
|
||||||
def show_data(self, cols: list, rows: list, count: int, elapsed: float):
|
def show_data(self, cols: list, rows: list, count: int, elapsed: float):
|
||||||
self._model.set_data(cols, rows)
|
table, model, _ = self._make_table_tab(cols, rows)
|
||||||
|
self._clear_tabs()
|
||||||
|
self._result_tabs.addTab(table, "Result")
|
||||||
|
self._result_tabs.tabBar().setVisible(False)
|
||||||
self._stack.setCurrentIndex(0)
|
self._stack.setCurrentIndex(0)
|
||||||
|
|
||||||
t = f" {elapsed:.3f}s" if elapsed else ""
|
t = f" {elapsed:.3f}s" if elapsed else ""
|
||||||
self._status_lbl.setText(f"{count:,} row(s){t}")
|
self._status_lbl.setText(f"{count:,} row(s){t}")
|
||||||
self.status_message.emit(f"Fetched {count:,} rows{t}")
|
self.status_message.emit(f"Fetched {count:,} rows{t}")
|
||||||
self._set_export_visible(bool(cols))
|
self._set_export_visible(bool(cols))
|
||||||
self._auto_resize()
|
self._auto_resize(table)
|
||||||
|
|
||||||
def show_message(self, msg: str):
|
def show_message(self, msg: str):
|
||||||
self._msg_view.appendPlainText(msg)
|
view = self._make_message_tab(msg)
|
||||||
self._stack.setCurrentIndex(1)
|
self._clear_tabs()
|
||||||
self._status_lbl.setText(msg)
|
self._result_tabs.addTab(view, "Message")
|
||||||
|
self._result_tabs.tabBar().setVisible(False)
|
||||||
|
self._stack.setCurrentIndex(0)
|
||||||
|
self._status_lbl.setText(msg[:100])
|
||||||
self._set_export_visible(False)
|
self._set_export_visible(False)
|
||||||
|
|
||||||
def show_error(self, msg: str):
|
def show_error(self, msg: str):
|
||||||
self._msg_view.setPlainText(f"❌ {msg}")
|
view = self._make_message_tab(f"❌ {msg}", is_error=True)
|
||||||
self._stack.setCurrentIndex(1)
|
self._clear_tabs()
|
||||||
self._status_lbl.setText(f"Error: {msg[:80]}")
|
self._result_tabs.addTab(view, "Error")
|
||||||
self.status_message.emit(f"Error: {msg[:80]}")
|
self._result_tabs.tabBar().setVisible(False)
|
||||||
|
self._stack.setCurrentIndex(0)
|
||||||
|
short = msg[:80]
|
||||||
|
self._status_lbl.setText(f"Error: {short}")
|
||||||
|
self.status_message.emit(f"Error: {short}")
|
||||||
self._set_export_visible(False)
|
self._set_export_visible(False)
|
||||||
|
|
||||||
|
def show_script_results(self, results: list):
|
||||||
|
"""
|
||||||
|
Show each statement's result in a separate tab.
|
||||||
|
results: list of (cols, rows, count, message_str)
|
||||||
|
"""
|
||||||
|
self._clear_tabs()
|
||||||
|
last_table_idx = -1
|
||||||
|
has_data = False
|
||||||
|
|
||||||
|
for i, (cols, rows, cnt, msg) in enumerate(results):
|
||||||
|
if cols:
|
||||||
|
table, _, _ = self._make_table_tab(cols, rows)
|
||||||
|
label = f"Result {i + 1} ({cnt:,})"
|
||||||
|
self._result_tabs.addTab(table, label)
|
||||||
|
last_table_idx = self._result_tabs.count() - 1
|
||||||
|
has_data = True
|
||||||
|
self._auto_resize(table)
|
||||||
|
else:
|
||||||
|
view = self._make_message_tab(msg or f"Statement {i + 1}: OK")
|
||||||
|
label = f"Step {i + 1}"
|
||||||
|
self._result_tabs.addTab(view, label)
|
||||||
|
|
||||||
|
multi = self._result_tabs.count() > 1
|
||||||
|
self._result_tabs.tabBar().setVisible(multi)
|
||||||
|
if last_table_idx >= 0:
|
||||||
|
self._result_tabs.setCurrentIndex(last_table_idx)
|
||||||
|
|
||||||
|
self._stack.setCurrentIndex(0)
|
||||||
|
|
||||||
|
n_results = sum(1 for c, *_ in results if c)
|
||||||
|
total_rows = sum(cnt for c, _, cnt, _ in results if c)
|
||||||
|
self._status_lbl.setText(
|
||||||
|
f"{len(results)} statement(s) — "
|
||||||
|
f"{n_results} result set(s), {total_rows:,} total row(s)"
|
||||||
|
)
|
||||||
|
self.status_message.emit(
|
||||||
|
f"{len(results)} statements executed"
|
||||||
|
)
|
||||||
|
self._set_export_visible(has_data)
|
||||||
|
|
||||||
def export_dialog(self):
|
def export_dialog(self):
|
||||||
self._do_export_csv()
|
self._do_export_csv()
|
||||||
|
|
||||||
@@ -134,74 +230,70 @@ class ResultsPanel(QWidget):
|
|||||||
self._export_json.setVisible(v)
|
self._export_json.setVisible(v)
|
||||||
self._export_sql.setVisible(v)
|
self._export_sql.setVisible(v)
|
||||||
|
|
||||||
def _auto_resize(self):
|
|
||||||
header = self._table.horizontalHeader()
|
|
||||||
for i in range(self._model.columnCount()):
|
|
||||||
header.resizeSection(
|
|
||||||
i, min(self._table.columnWidth(i) + 20, 300)
|
|
||||||
)
|
|
||||||
self._table.resizeColumnsToContents()
|
|
||||||
|
|
||||||
def _do_export_csv(self):
|
def _do_export_csv(self):
|
||||||
|
model = self._current_model()
|
||||||
|
if not model:
|
||||||
|
return
|
||||||
path, _ = QFileDialog.getSaveFileName(
|
path, _ = QFileDialog.getSaveFileName(
|
||||||
self, "Export CSV", "results.csv", "CSV Files (*.csv)")
|
self, "Export CSV", "results.csv", "CSV Files (*.csv)")
|
||||||
if path:
|
if path:
|
||||||
try:
|
try:
|
||||||
self._model.export_csv(path)
|
model.export_csv(path)
|
||||||
QMessageBox.information(self, "Exported", f"Saved to {path}")
|
QMessageBox.information(self, "Exported", f"Saved to {path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, "Error", str(e))
|
QMessageBox.critical(self, "Error", str(e))
|
||||||
|
|
||||||
def _do_export_json(self):
|
def _do_export_json(self):
|
||||||
|
model = self._current_model()
|
||||||
|
if not model:
|
||||||
|
return
|
||||||
path, _ = QFileDialog.getSaveFileName(
|
path, _ = QFileDialog.getSaveFileName(
|
||||||
self, "Export JSON", "results.json", "JSON Files (*.json)")
|
self, "Export JSON", "results.json", "JSON Files (*.json)")
|
||||||
if path:
|
if path:
|
||||||
try:
|
try:
|
||||||
self._model.export_json(path)
|
model.export_json(path)
|
||||||
QMessageBox.information(self, "Exported", f"Saved to {path}")
|
QMessageBox.information(self, "Exported", f"Saved to {path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, "Error", str(e))
|
QMessageBox.critical(self, "Error", str(e))
|
||||||
|
|
||||||
def _do_export_sql(self):
|
def _do_export_sql(self):
|
||||||
|
model = self._current_model()
|
||||||
|
if not model:
|
||||||
|
return
|
||||||
path, _ = QFileDialog.getSaveFileName(
|
path, _ = QFileDialog.getSaveFileName(
|
||||||
self, "Export SQL", "results.sql", "SQL Files (*.sql)")
|
self, "Export SQL", "results.sql", "SQL Files (*.sql)")
|
||||||
if path:
|
if path:
|
||||||
try:
|
try:
|
||||||
self._model.export_sql(path)
|
model.export_sql(path)
|
||||||
QMessageBox.information(self, "Exported", f"Saved to {path}")
|
QMessageBox.information(self, "Exported", f"Saved to {path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, "Error", str(e))
|
QMessageBox.critical(self, "Error", str(e))
|
||||||
|
|
||||||
def _table_context_menu(self, pos):
|
def _table_context_menu(self, pos, table: QTableView,
|
||||||
from PyQt6.QtWidgets import QMenu
|
proxy: QSortFilterProxyModel,
|
||||||
from PyQt6.QtGui import QClipboard
|
model: ResultTableModel):
|
||||||
from PyQt6.QtWidgets import QApplication
|
idx = table.indexAt(pos)
|
||||||
idx = self._table.indexAt(pos)
|
|
||||||
if not idx.isValid():
|
if not idx.isValid():
|
||||||
return
|
return
|
||||||
menu = QMenu(self)
|
menu = QMenu(self)
|
||||||
menu.addAction("📋 Copy cell", lambda: self._copy_cell(idx))
|
menu.addAction("📋 Copy cell", lambda: self._copy_cell(idx, proxy))
|
||||||
menu.addAction("📋 Copy row", lambda: self._copy_row(idx))
|
menu.addAction("📋 Copy row", lambda: self._copy_row(idx, proxy, model))
|
||||||
menu.addAction("📋 Copy all", lambda: self._copy_all())
|
menu.addAction("📋 Copy all", lambda: self._copy_all(model))
|
||||||
menu.exec(self._table.viewport().mapToGlobal(pos))
|
menu.exec(table.viewport().mapToGlobal(pos))
|
||||||
|
|
||||||
def _copy_cell(self, idx):
|
def _copy_cell(self, idx, proxy):
|
||||||
from PyQt6.QtWidgets import QApplication
|
val = proxy.data(idx, Qt.ItemDataRole.DisplayRole) or ""
|
||||||
val = self._proxy.data(idx, Qt.ItemDataRole.DisplayRole) or ""
|
|
||||||
QApplication.clipboard().setText(str(val))
|
QApplication.clipboard().setText(str(val))
|
||||||
|
|
||||||
def _copy_row(self, idx):
|
def _copy_row(self, idx, proxy, model):
|
||||||
from PyQt6.QtWidgets import QApplication
|
row = proxy.mapToSource(idx).row()
|
||||||
row = self._proxy.mapToSource(idx).row()
|
vals = [str(v or "") for v in model.get_row(row)]
|
||||||
vals = [str(v or "") for v in self._model.get_row(row)]
|
|
||||||
QApplication.clipboard().setText("\t".join(vals))
|
QApplication.clipboard().setText("\t".join(vals))
|
||||||
|
|
||||||
def _copy_all(self):
|
def _copy_all(self, model):
|
||||||
from PyQt6.QtWidgets import QApplication
|
|
||||||
import csv, io
|
|
||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
writer = csv.writer(buf)
|
writer = csv.writer(buf)
|
||||||
writer.writerow(self._model.column_names())
|
writer.writerow(model.column_names())
|
||||||
for r in range(self._model.rowCount()):
|
for r in range(model.rowCount()):
|
||||||
writer.writerow(self._model.get_row(r))
|
writer.writerow(model.get_row(r))
|
||||||
QApplication.clipboard().setText(buf.getvalue())
|
QApplication.clipboard().setText(buf.getvalue())
|
||||||
|
|||||||
+23
-14
@@ -12,6 +12,7 @@ from PyQt6.QtWidgets import QApplication, QListWidget, QListWidgetItem
|
|||||||
|
|
||||||
from app.ui.syntax_highlighter import _KEYWORDS, _TYPES, _FUNCTIONS
|
from app.ui.syntax_highlighter import _KEYWORDS, _TYPES, _FUNCTIONS
|
||||||
from app.utils.worker import SchemaWorker
|
from app.utils.worker import SchemaWorker
|
||||||
|
from app.config.theme import get_palette
|
||||||
|
|
||||||
# Combined, deduplicated keyword list used as the static completion pool.
|
# Combined, deduplicated keyword list used as the static completion pool.
|
||||||
SQL_KEYWORDS: list[str] = list(dict.fromkeys(_KEYWORDS + _TYPES + _FUNCTIONS))
|
SQL_KEYWORDS: list[str] = list(dict.fromkeys(_KEYWORDS + _TYPES + _FUNCTIONS))
|
||||||
@@ -51,24 +52,28 @@ class _CompletionPopup(QListWidget):
|
|||||||
self.setFont(font)
|
self.setFont(font)
|
||||||
self._row_h = QFontMetrics(font).height() + 6
|
self._row_h = QFontMetrics(font).height() + 6
|
||||||
|
|
||||||
self.setStyleSheet("""
|
self._apply_popup_style()
|
||||||
QListWidget {
|
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||||
background: #292c3c;
|
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
color: #c6d0f5;
|
|
||||||
border: 1px solid #51576d;
|
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;
|
border-radius: 4px;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}}
|
||||||
QListWidget::item { padding: 2px 8px; }
|
QListWidget::item {{ padding: 2px 8px; }}
|
||||||
QListWidget::item:selected {
|
QListWidget::item:selected {{
|
||||||
background: #414559;
|
background: {p['popup_selected']};
|
||||||
color: #c6d0f5;
|
color: {p['text']};
|
||||||
}
|
}}
|
||||||
QListWidget::item:hover { background: #363a4f; }
|
QListWidget::item:hover {{ background: {p['popup_hover']}; }}
|
||||||
""")
|
""")
|
||||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
|
||||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
||||||
|
|
||||||
self.itemClicked.connect(
|
self.itemClicked.connect(
|
||||||
lambda item: self._editor._completer._accept_completion(item.text())
|
lambda item: self._editor._completer._accept_completion(item.text())
|
||||||
@@ -142,6 +147,10 @@ class SqlCompleter(QObject):
|
|||||||
|
|
||||||
# ── Public API ────────────────────────────────────────────────────────────
|
# ── 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:
|
def set_context(self, driver, database: str) -> None:
|
||||||
"""Update the active driver/database and refresh schema cache."""
|
"""Update the active driver/database and refresh schema cache."""
|
||||||
self._driver = driver
|
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.results_panel import ResultsPanel
|
||||||
from app.ui.sql_completer import SqlCompleter
|
from app.ui.sql_completer import SqlCompleter
|
||||||
from app.utils.worker import QueryWorker
|
from app.utils.worker import QueryWorker
|
||||||
|
from app.config.settings import get_settings
|
||||||
|
|
||||||
|
|
||||||
# ── Line-number gutter ────────────────────────────────────────────────────────
|
# ── Line-number gutter ────────────────────────────────────────────────────────
|
||||||
@@ -42,11 +43,12 @@ class CodeEditor(QPlainTextEdit):
|
|||||||
self._line_area = LineNumberArea(self)
|
self._line_area = LineNumberArea(self)
|
||||||
self._completer: "SqlCompleter | None" = None
|
self._completer: "SqlCompleter | None" = None
|
||||||
|
|
||||||
# Font
|
self._apply_font()
|
||||||
font = QFont("Consolas", 13)
|
self.setLineWrapMode(
|
||||||
font.setFixedPitch(True)
|
QPlainTextEdit.LineWrapMode.WidgetWidth
|
||||||
self.setFont(font)
|
if get_settings().get("word_wrap", False)
|
||||||
self.setTabStopDistance(QFontMetrics(font).horizontalAdvance(" ") * 4)
|
else QPlainTextEdit.LineWrapMode.NoWrap
|
||||||
|
)
|
||||||
|
|
||||||
# Connect signals
|
# Connect signals
|
||||||
self.blockCountChanged.connect(self._update_line_area_width)
|
self.blockCountChanged.connect(self._update_line_area_width)
|
||||||
@@ -112,6 +114,22 @@ class CodeEditor(QPlainTextEdit):
|
|||||||
bottom = top + round(self.blockBoundingRect(block).height())
|
bottom = top + round(self.blockBoundingRect(block).height())
|
||||||
number += 1
|
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:
|
def set_completer(self, completer: "SqlCompleter") -> None:
|
||||||
self._completer = completer
|
self._completer = completer
|
||||||
|
|
||||||
@@ -430,6 +448,10 @@ class EditorTab(QWidget):
|
|||||||
self._explain_btn = QPushButton("🔎 Explain")
|
self._explain_btn = QPushButton("🔎 Explain")
|
||||||
self._explain_btn.clicked.connect(self._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 = QPushButton("📤 Export")
|
||||||
self._export_btn.clicked.connect(self._export)
|
self._export_btn.clicked.connect(self._export)
|
||||||
|
|
||||||
@@ -439,6 +461,7 @@ class EditorTab(QWidget):
|
|||||||
toolbar.addWidget(self._run_btn)
|
toolbar.addWidget(self._run_btn)
|
||||||
toolbar.addWidget(self._stop_btn)
|
toolbar.addWidget(self._stop_btn)
|
||||||
toolbar.addWidget(self._explain_btn)
|
toolbar.addWidget(self._explain_btn)
|
||||||
|
toolbar.addWidget(self._format_btn)
|
||||||
toolbar.addWidget(self._export_btn)
|
toolbar.addWidget(self._export_btn)
|
||||||
toolbar.addStretch()
|
toolbar.addStretch()
|
||||||
toolbar.addWidget(self._db_label)
|
toolbar.addWidget(self._db_label)
|
||||||
@@ -448,7 +471,7 @@ class EditorTab(QWidget):
|
|||||||
self._splitter.setHandleWidth(3)
|
self._splitter.setHandleWidth(3)
|
||||||
|
|
||||||
self._editor = CodeEditor()
|
self._editor = CodeEditor()
|
||||||
SQLHighlighter(self._editor.document())
|
self._highlighter = SQLHighlighter(self._editor.document())
|
||||||
self._completer = SqlCompleter(self._editor, parent=self)
|
self._completer = SqlCompleter(self._editor, parent=self)
|
||||||
self._editor.set_completer(self._completer)
|
self._editor.set_completer(self._completer)
|
||||||
if self._driver:
|
if self._driver:
|
||||||
@@ -504,6 +527,9 @@ class EditorTab(QWidget):
|
|||||||
self._stop_btn.setEnabled(False)
|
self._stop_btn.setEnabled(False)
|
||||||
|
|
||||||
def _explain(self):
|
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()
|
sql = self._editor.selected_or_all().strip()
|
||||||
if not sql:
|
if not sql:
|
||||||
return
|
return
|
||||||
@@ -523,6 +549,30 @@ class EditorTab(QWidget):
|
|||||||
def _export(self):
|
def _export(self):
|
||||||
self._results.export_dialog()
|
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:
|
def set_context(self, driver, database: str) -> None:
|
||||||
"""Update driver/database and refresh schema completions."""
|
"""Update driver/database and refresh schema completions."""
|
||||||
self._driver = driver
|
self._driver = driver
|
||||||
@@ -535,11 +585,7 @@ class EditorTab(QWidget):
|
|||||||
self._maybe_invalidate_schema(self._editor.toPlainText())
|
self._maybe_invalidate_schema(self._editor.toPlainText())
|
||||||
|
|
||||||
def _on_script_done(self, results: list):
|
def _on_script_done(self, results: list):
|
||||||
for cols, rows, cnt, msg in results:
|
self._results.show_script_results(results)
|
||||||
if cols:
|
|
||||||
self._results.show_data(cols, rows, cnt, 0)
|
|
||||||
else:
|
|
||||||
self._results.show_message(msg)
|
|
||||||
self._maybe_invalidate_schema(self._editor.toPlainText())
|
self._maybe_invalidate_schema(self._editor.toPlainText())
|
||||||
|
|
||||||
def _on_error(self, msg: str):
|
def _on_error(self, msg: str):
|
||||||
@@ -558,6 +604,11 @@ class EditorTab(QWidget):
|
|||||||
def get_sql(self) -> str:
|
def get_sql(self) -> str:
|
||||||
return self._editor.toPlainText()
|
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 ───────────────────────────────────────────────
|
# ── Tabbed SQL editor container ───────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -651,6 +702,12 @@ class SQLEditorWidget(QWidget):
|
|||||||
w = self._tabs.currentWidget()
|
w = self._tabs.currentWidget()
|
||||||
return w if isinstance(w, EditorTab) else None
|
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 = ""):
|
def open_sql_for(self, driver, database: str, sql: str = ""):
|
||||||
tab = self.new_tab(driver, database, sql,
|
tab = self.new_tab(driver, database, sql,
|
||||||
title=f"SQL — {database}")
|
title=f"SQL — {database}")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
SQL syntax highlighter for QPlainTextEdit.
|
SQL syntax highlighter for QPlainTextEdit.
|
||||||
Highlights keywords, types, functions, strings, comments, and numbers.
|
Highlights keywords, types, functions, strings, comments, and numbers.
|
||||||
|
Colors follow the active Catppuccin theme (dark = Frappé, light = Latte).
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
from PyQt6.QtCore import QRegularExpression, Qt
|
from PyQt6.QtCore import QRegularExpression, Qt
|
||||||
@@ -8,6 +9,8 @@ from PyQt6.QtGui import (
|
|||||||
QSyntaxHighlighter, QTextCharFormat, QColor, QFont
|
QSyntaxHighlighter, QTextCharFormat, QColor, QFont
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from app.config.theme import get_palette
|
||||||
|
|
||||||
|
|
||||||
def _fmt(color: str, bold: bool = False, italic: bool = False) -> QTextCharFormat:
|
def _fmt(color: str, bold: bool = False, italic: bool = False) -> QTextCharFormat:
|
||||||
f = QTextCharFormat()
|
f = QTextCharFormat()
|
||||||
@@ -17,17 +20,7 @@ def _fmt(color: str, bold: bool = False, italic: bool = False) -> QTextCharForma
|
|||||||
return f
|
return f
|
||||||
|
|
||||||
|
|
||||||
# ── Token categories (Catppuccin Frappé palette) ─────────────────────────────
|
# ── Keyword lists (shared with completer) ─────────────────────────────────────
|
||||||
_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 ─────────────────────────────────────────────────────────────
|
|
||||||
_KEYWORDS = [
|
_KEYWORDS = [
|
||||||
"SELECT", "FROM", "WHERE", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER",
|
"SELECT", "FROM", "WHERE", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER",
|
||||||
"FULL", "CROSS", "ON", "AS", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN",
|
"FULL", "CROSS", "ON", "AS", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN",
|
||||||
@@ -79,56 +72,56 @@ class SQLHighlighter(QSyntaxHighlighter):
|
|||||||
def __init__(self, document):
|
def __init__(self, document):
|
||||||
super().__init__(document)
|
super().__init__(document)
|
||||||
self._rules: list[tuple] = []
|
self._rules: list[tuple] = []
|
||||||
|
self._build_rules()
|
||||||
|
|
||||||
|
def _build_rules(self):
|
||||||
|
p = get_palette()
|
||||||
|
|
||||||
def kw_pattern(words: list[str]) -> str:
|
def kw_pattern(words: list[str]) -> str:
|
||||||
return r"\b(?:" + "|".join(words) + r")\b"
|
return r"\b(?:" + "|".join(words) + r")\b"
|
||||||
|
|
||||||
|
ci = QRegularExpression.PatternOption.CaseInsensitiveOption
|
||||||
self._rules = [
|
self._rules = [
|
||||||
# Keywords (case-insensitive handled via flag)
|
(QRegularExpression(kw_pattern(_KEYWORDS), ci),
|
||||||
(QRegularExpression(kw_pattern(_KEYWORDS),
|
_fmt(p["syn_keyword"], bold=True)),
|
||||||
QRegularExpression.PatternOption.CaseInsensitiveOption),
|
(QRegularExpression(kw_pattern(_TYPES), ci),
|
||||||
_KEYWORD_FMT),
|
_fmt(p["syn_type"])),
|
||||||
# Data types
|
(QRegularExpression(kw_pattern(_FUNCTIONS), ci),
|
||||||
(QRegularExpression(kw_pattern(_TYPES),
|
_fmt(p["syn_function"])),
|
||||||
QRegularExpression.PatternOption.CaseInsensitiveOption),
|
(QRegularExpression(r"\b\d+(\.\d+)?\b"),
|
||||||
_TYPE_FMT),
|
_fmt(p["syn_number"])),
|
||||||
# Functions
|
(QRegularExpression(r"'[^'\\]*(?:\\.[^'\\]*)*'"),
|
||||||
(QRegularExpression(kw_pattern(_FUNCTIONS),
|
_fmt(p["syn_string"])),
|
||||||
QRegularExpression.PatternOption.CaseInsensitiveOption),
|
(QRegularExpression(r'"[^"]*"'),
|
||||||
_FUNCTION_FMT),
|
_fmt(p["syn_ident"])),
|
||||||
# Numbers
|
(QRegularExpression(r"`[^`]*`"),
|
||||||
(QRegularExpression(r"\b\d+(\.\d+)?\b"), _NUMBER_FMT),
|
_fmt(p["syn_ident"])),
|
||||||
# Single-quoted strings
|
(QRegularExpression(
|
||||||
(QRegularExpression(r"'[^'\\]*(?:\\.[^'\\]*)*'"), _STRING_FMT),
|
r"\bSELECT\s+\*|\*(?=\s*FROM)", ci),
|
||||||
# Double-quoted identifiers
|
_fmt(p["syn_operator"], bold=True)),
|
||||||
(QRegularExpression(r'"[^"]*"'), _fmt("#99d1db")),
|
(QRegularExpression(r"[=<>!%&|^~+\-*/]"),
|
||||||
# Backtick identifiers (MySQL)
|
_fmt(p["syn_operator"])),
|
||||||
(QRegularExpression(r"`[^`]*`"), _fmt("#99d1db")),
|
(QRegularExpression(r"--[^\n]*"),
|
||||||
# * wildcard
|
_fmt(p["syn_comment"], italic=True)),
|
||||||
(QRegularExpression(r"\bSELECT\s+\*|\*(?=\s*FROM)",
|
(QRegularExpression(r"#[^\n]*"),
|
||||||
QRegularExpression.PatternOption.CaseInsensitiveOption),
|
_fmt(p["syn_comment"], italic=True)),
|
||||||
_STAR_FMT),
|
|
||||||
# Operators
|
|
||||||
(QRegularExpression(r"[=<>!%&|^~+\-*/]"), _OPERATOR_FMT),
|
|
||||||
# Single-line comment --
|
|
||||||
(QRegularExpression(r"--[^\n]*"), _COMMENT_FMT),
|
|
||||||
# Single-line comment #
|
|
||||||
(QRegularExpression(r"#[^\n]*"), _COMMENT_FMT),
|
|
||||||
]
|
]
|
||||||
|
self._comment_fmt = _fmt(p["syn_comment"], italic=True)
|
||||||
# Multi-line block comment /* ... */
|
|
||||||
self._block_comment_start = QRegularExpression(r"/\*")
|
self._block_comment_start = QRegularExpression(r"/\*")
|
||||||
self._block_comment_end = 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:
|
def highlightBlock(self, text: str) -> None:
|
||||||
# Single-line rules
|
|
||||||
for pattern, fmt in self._rules:
|
for pattern, fmt in self._rules:
|
||||||
it = pattern.globalMatch(text)
|
it = pattern.globalMatch(text)
|
||||||
while it.hasNext():
|
while it.hasNext():
|
||||||
m = it.next()
|
m = it.next()
|
||||||
self.setFormat(m.capturedStart(), m.capturedLength(), fmt)
|
self.setFormat(m.capturedStart(), m.capturedLength(), fmt)
|
||||||
|
|
||||||
# Multi-line block comments
|
|
||||||
self.setCurrentBlockState(0)
|
self.setCurrentBlockState(0)
|
||||||
start_idx = 0
|
start_idx = 0
|
||||||
if self.previousBlockState() != 1:
|
if self.previousBlockState() != 1:
|
||||||
@@ -139,10 +132,10 @@ class SQLHighlighter(QSyntaxHighlighter):
|
|||||||
end_m = self._block_comment_end.match(text, start_idx)
|
end_m = self._block_comment_end.match(text, start_idx)
|
||||||
if end_m.hasMatch():
|
if end_m.hasMatch():
|
||||||
end_idx = end_m.capturedStart() + end_m.capturedLength()
|
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)
|
m = self._block_comment_start.match(text, end_idx)
|
||||||
start_idx = m.capturedStart() if m.hasMatch() else -1
|
start_idx = m.capturedStart() if m.hasMatch() else -1
|
||||||
else:
|
else:
|
||||||
self.setCurrentBlockState(1)
|
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
|
break
|
||||||
|
|||||||
+193
-8
@@ -16,11 +16,13 @@ from PyQt6.QtWidgets import (
|
|||||||
QMenu, QApplication,
|
QMenu, QApplication,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import (
|
from PyQt6.QtCore import (
|
||||||
Qt, QAbstractTableModel, QModelIndex, pyqtSignal,
|
Qt, QAbstractTableModel, QModelIndex, pyqtSignal, QEvent,
|
||||||
)
|
)
|
||||||
from PyQt6.QtGui import QColor, QBrush, QFont, QKeySequence, QShortcut
|
from PyQt6.QtGui import QColor, QBrush, QFont, QKeySequence, QShortcut
|
||||||
|
|
||||||
from app.utils.worker import TableDataWorker
|
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)
|
return "" if val is None else str(val)
|
||||||
|
|
||||||
if role == Qt.ItemDataRole.ForegroundRole:
|
if role == Qt.ItemDataRole.ForegroundRole:
|
||||||
|
p = get_palette()
|
||||||
if self._is_deleted_row(r):
|
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:
|
if (r, c) in self._dirty:
|
||||||
return QBrush(QColor("#ef9f76")) # orange — edited
|
return QBrush(QColor(p["dirty_fg"]))
|
||||||
if val is None:
|
if val is None:
|
||||||
return QBrush(QColor("#737994")) # grey — NULL
|
return QBrush(QColor(p["overlay"]))
|
||||||
|
|
||||||
if role == Qt.ItemDataRole.BackgroundRole:
|
if role == Qt.ItemDataRole.BackgroundRole:
|
||||||
|
p = get_palette()
|
||||||
if self._is_deleted_row(r):
|
if self._is_deleted_row(r):
|
||||||
return QBrush(QColor("#3a2530"))
|
return QBrush(QColor(p["delete_bg"]))
|
||||||
if (r, c) in self._dirty:
|
if (r, c) in self._dirty:
|
||||||
return QBrush(QColor("#3a2f25"))
|
return QBrush(QColor(p["dirty_bg"]))
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -266,8 +270,15 @@ class RowDialog(QDialog):
|
|||||||
)
|
)
|
||||||
bbox.accepted.connect(self.accept)
|
bbox.accepted.connect(self.accept)
|
||||||
bbox.rejected.connect(self.reject)
|
bbox.rejected.connect(self.reject)
|
||||||
|
ok_btn = bbox.button(QDialogButtonBox.StandardButton.Ok)
|
||||||
|
if ok_btn:
|
||||||
|
ok_btn.setDefault(True)
|
||||||
root.addWidget(bbox)
|
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
|
@property
|
||||||
def values(self) -> dict:
|
def values(self) -> dict:
|
||||||
"""Return {col: value_or_None} for all fields.
|
"""Return {col: value_or_None} for all fields.
|
||||||
@@ -310,7 +321,8 @@ class TableViewer(QWidget):
|
|||||||
self._table = table
|
self._table = table
|
||||||
self._offset = 0
|
self._offset = 0
|
||||||
self._total = 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._model = EditableTableModel()
|
||||||
self._worker: TableDataWorker | None = None
|
self._worker: TableDataWorker | None = None
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
@@ -378,6 +390,13 @@ class TableViewer(QWidget):
|
|||||||
tb.addWidget(self._edit_btn)
|
tb.addWidget(self._edit_btn)
|
||||||
tb.addWidget(self._delete_btn)
|
tb.addWidget(self._delete_btn)
|
||||||
tb.addStretch()
|
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)
|
root.addLayout(tb)
|
||||||
|
|
||||||
# ── Table view ─────────────────────────────────────────────────────────
|
# ── Table view ─────────────────────────────────────────────────────────
|
||||||
@@ -393,10 +412,16 @@ class TableViewer(QWidget):
|
|||||||
QHeaderView.ResizeMode.Interactive)
|
QHeaderView.ResizeMode.Interactive)
|
||||||
self._table_view.horizontalHeader().setStretchLastSection(True)
|
self._table_view.horizontalHeader().setStretchLastSection(True)
|
||||||
self._table_view.verticalHeader().setDefaultSectionSize(24)
|
self._table_view.verticalHeader().setDefaultSectionSize(24)
|
||||||
|
self._table_view.verticalHeader().setFixedWidth(48)
|
||||||
self._table_view.setContextMenuPolicy(
|
self._table_view.setContextMenuPolicy(
|
||||||
Qt.ContextMenuPolicy.CustomContextMenu)
|
Qt.ContextMenuPolicy.CustomContextMenu)
|
||||||
self._table_view.customContextMenuRequested.connect(self._context_menu)
|
self._table_view.customContextMenuRequested.connect(self._context_menu)
|
||||||
self._table_view.doubleClicked.connect(self._on_double_click)
|
self._table_view.doubleClicked.connect(self._on_double_click)
|
||||||
|
|
||||||
|
# Column header context menu
|
||||||
|
hdr = self._table_view.horizontalHeader()
|
||||||
|
hdr.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||||
|
hdr.customContextMenuRequested.connect(self._header_context_menu)
|
||||||
# Track selection to enable/disable Edit/Delete buttons.
|
# Track selection to enable/disable Edit/Delete buttons.
|
||||||
# Use both signals: clicked covers mouse, selectionChanged covers keyboard.
|
# Use both signals: clicked covers mouse, selectionChanged covers keyboard.
|
||||||
self._table_view.clicked.connect(self._on_cell_click)
|
self._table_view.clicked.connect(self._on_cell_click)
|
||||||
@@ -408,6 +433,40 @@ class TableViewer(QWidget):
|
|||||||
self._delete_selected)
|
self._delete_selected)
|
||||||
QShortcut(QKeySequence("Ins"), self._table_view, self._add_row)
|
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)
|
root.addWidget(self._table_view, 1)
|
||||||
|
|
||||||
# ── Pagination bar (prominent, always visible) ─────────────────────────
|
# ── Pagination bar (prominent, always visible) ─────────────────────────
|
||||||
@@ -498,11 +557,58 @@ class TableViewer(QWidget):
|
|||||||
self._refresh_action_states()
|
self._refresh_action_states()
|
||||||
self._table_view.resizeColumnsToContents()
|
self._table_view.resizeColumnsToContents()
|
||||||
self._table_view.horizontalHeader().setStretchLastSection(True)
|
self._table_view.horizontalHeader().setStretchLastSection(True)
|
||||||
|
if self._freeze_active and self._model.columnCount() > 0:
|
||||||
|
self._apply_freeze()
|
||||||
|
|
||||||
def _on_error(self, msg: str):
|
def _on_error(self, msg: str):
|
||||||
self._total_lbl.setText(f"Error: {msg[:80]}")
|
self._total_lbl.setText(f"Error: {msg[:80]}")
|
||||||
self.status_message.emit(f"Error: {msg}")
|
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):
|
def _refresh_pagination(self):
|
||||||
ps = self._page_size if self._page_size > 0 else max(self._total, 1)
|
ps = self._page_size if self._page_size > 0 else max(self._total, 1)
|
||||||
rows_loaded = self._model.rowCount()
|
rows_loaded = self._model.rowCount()
|
||||||
@@ -609,16 +715,53 @@ class TableViewer(QWidget):
|
|||||||
|
|
||||||
def _context_menu(self, pos):
|
def _context_menu(self, pos):
|
||||||
rows = self._selected_logical_rows()
|
rows = self._selected_logical_rows()
|
||||||
|
idx = self._table_view.indexAt(pos)
|
||||||
menu = QMenu(self)
|
menu = QMenu(self)
|
||||||
menu.addAction("➕ Add Row", self._add_row)
|
menu.addAction("➕ Add Row", self._add_row)
|
||||||
if rows:
|
if rows:
|
||||||
menu.addAction("✏️ Edit Row", self._edit_selected)
|
menu.addAction("✏️ Edit Row", self._edit_selected)
|
||||||
menu.addAction("🗑️ Delete Row", self._delete_selected)
|
menu.addAction("🗑️ Delete Row", self._delete_selected)
|
||||||
menu.addSeparator()
|
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 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))
|
menu.exec(self._table_view.viewport().mapToGlobal(pos))
|
||||||
|
|
||||||
|
def _header_context_menu(self, pos):
|
||||||
|
hdr = self._table_view.horizontalHeader()
|
||||||
|
col_idx = hdr.logicalIndexAt(pos)
|
||||||
|
if col_idx < 0 or col_idx >= len(self._model.column_names()):
|
||||||
|
return
|
||||||
|
col_name = self._model.column_names()[col_idx]
|
||||||
|
menu = QMenu(self)
|
||||||
|
menu.addAction(
|
||||||
|
f"📊 Statistics: {col_name}",
|
||||||
|
lambda: self._show_column_stats(col_name),
|
||||||
|
)
|
||||||
|
menu.addSeparator()
|
||||||
|
menu.addAction(
|
||||||
|
"⟺ Resize to fit",
|
||||||
|
lambda: self._table_view.resizeColumnToContents(col_idx),
|
||||||
|
)
|
||||||
|
menu.addAction(
|
||||||
|
"⟺ Resize all to fit",
|
||||||
|
self._table_view.resizeColumnsToContents,
|
||||||
|
)
|
||||||
|
menu.exec(hdr.mapToGlobal(pos))
|
||||||
|
|
||||||
|
def _show_column_stats(self, col_name: str):
|
||||||
|
from app.ui.column_stats_dialog import ColumnStatsDialog
|
||||||
|
dlg = ColumnStatsDialog(
|
||||||
|
self._driver, self._database, self._table, col_name, parent=self
|
||||||
|
)
|
||||||
|
dlg.exec()
|
||||||
|
|
||||||
# ── Double-click: edit ────────────────────────────────────────────────────
|
# ── Double-click: edit ────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _on_double_click(self, index: QModelIndex):
|
def _on_double_click(self, index: QModelIndex):
|
||||||
@@ -725,3 +868,45 @@ class TableViewer(QWidget):
|
|||||||
row_data = self._model.get_row_current(r)
|
row_data = self._model.get_row_current(r)
|
||||||
lines.append("\t".join("" if v is None else str(v) for v in row_data))
|
lines.append("\t".join("" if v is None else str(v) for v in row_data))
|
||||||
QApplication.clipboard().setText("\n".join(lines))
|
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.utils.logger import setup_logging, install_qt_message_handler, get_logger
|
||||||
from app.main_window import MainWindow
|
from app.main_window import MainWindow
|
||||||
|
from app.config.theme import apply_qss
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def load_stylesheet(app: QApplication) -> None:
|
def load_stylesheet(app: QApplication) -> None:
|
||||||
style_path = os.path.join(os.path.dirname(__file__), "resources", "style.qss")
|
apply_qss(app) # reads theme from settings, falls back to dark
|
||||||
if os.path.exists(style_path):
|
|
||||||
with open(style_path, "r", encoding="utf-8") as f:
|
|
||||||
app.setStyleSheet(f.read())
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ pyodbc>=5.0.1
|
|||||||
keyring>=24.3.1
|
keyring>=24.3.1
|
||||||
cryptography>=42.0.0
|
cryptography>=42.0.0
|
||||||
bcrypt>=4.0.0
|
bcrypt>=4.0.0
|
||||||
|
sqlparse>=0.5.0
|
||||||
|
|
||||||
# Packaging (dev dependency — only needed when building the distributable)
|
# Packaging (dev dependency — only needed when building the distributable)
|
||||||
pyinstaller>=6.0.0
|
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