Compare commits

..
10 Commits
Author SHA1 Message Date
nngoandClaude Sonnet 4.6 4fcbe5a879 docs: update CLAUDE.md — themes, identifier quoting, SQL import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 10:02:16 -04:00
nngoandClaude Sonnet 4.6 5a536fb9e9 feat: add 5 new themes via single QSS template engine
Replaces the two hard-coded .qss files with a single
resources/style_template.qss that uses @{token} placeholders.
apply_qss() now does regex substitution at runtime, so adding a
theme only requires a palette dict — no duplicate stylesheets.

New themes (Preferences → Color theme):
  • One Dark Pro   — VS Code classic dark
  • Nord           — arctic blue-gray dark
  • Tokyo Night    — deep purple-dark
  • Dracula        — classic purple-dark
  • GitHub Light   — clean minimal light

Existing Catppuccin Frappé (dark) and Latte (light) are unchanged.
Ctrl+Shift+T quick-toggle now recognises all dark themes via is_dark().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 09:54:02 -04:00
nngo 59aa235f3d Fix column's statistics errors 2026-05-22 09:41:08 -04:00
nngo 82c8a6bf84 Fix explain 'NoneType' error 2026-05-22 09:34:35 -04:00
nngoandClaude Sonnet 4.6 aae47125cd feat: add SQL dump import support to ImportDialog
Extends ImportDialog to handle .sql dump files alongside CSV/JSON.
Adds _SqlImportWorker that splits SQL with sqlparse (semicolon fallback),
executes each statement via driver.execute_query(), and collects non-fatal
errors. UI shows a text preview of the dump, statement count, a
stop-on-error checkbox, and a final summary with any per-statement errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 09:20:13 -04:00
nngoandClaude Sonnet 4.6 06b951daf7 feat: dark/light theme toggle (Catppuccin Frappé ↔ Latte)
- app/config/theme.py: palette definitions for both themes + apply_qss()
  helper used by both main.py (startup) and MainWindow (live toggle)
- resources/style_light.qss: complete Catppuccin Latte QSS
- View menu: ☀️/🌙 Switch Theme action (Ctrl+Shift+T) toggles live
- Preferences → Appearance: Color theme dropdown persists choice
- SQLHighlighter: _build_rules() reads get_palette(); update_theme()
  rebuilds rules and rehighlights all open editors on switch
- SqlCompleter: popup stylesheet reads get_palette() via
  _apply_popup_style(); update_theme() called on switch
- EditableTableModel: dirty/delete cell colors read get_palette() live
- ResultTableModel: NULL_COLOR property reads get_palette() live
- TableViewer: apply_settings() refreshes frozen-view border + repaints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 09:11:05 -04:00
nngoandClaude Sonnet 4.6 6cff9557b3 polish: row numbers, ping indicator, frozen column, keyboard nav
- ResultsPanel: _RowNumberProxy keeps vertical header numbers sequential
  in visual order (sort-stable); both views get a 48 px wide row-number header
- MainWindow: _PingWorker pings each active connection every 30 s via
  test_connection(); status dot (green/red) shown in status bar with tooltip
- TableViewer: 📌 toggle freezes first column using dual-view overlay
  (_frozen_view child widget) with synced scroll, row heights, and resize
  event-filter; column 0 is hidden in the main view while frozen
- RowDialog: OK set as default button; first field focused on open
- ConnectionDialog: OK set as default; Enter in password field submits

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 17:37:50 -04:00
nngo b7e7411c1b Update CLAUDE.md 2026-05-21 17:25:15 -04:00
nngoandClaude Sonnet 4.6 9f0891755e feat: multi-result tabs, column statistics, and Ctrl+W close tab
- ResultsPanel rewritten: single queries show one result as before;
  scripts with multiple statements show each result in a named sub-tab
  (e.g. "Result 2 (1,234)") with the tab bar auto-shown/hidden
- EditorTab._on_script_done now calls show_script_results() instead of
  overwriting the panel on each statement
- ColumnStatsDialog: right-click any column header in TableViewer to see
  total rows, null count, distinct values, min, max, and avg (async,
  gracefully skips avg for non-numeric columns)
- TableViewer header context menu also adds "Resize to fit" shortcuts
- Ctrl+W closes the current workspace tab (View menu)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 17:10:35 -04:00
nngoandClaude Sonnet 4.6 771c92b7b6 feat: preferences dialog, SQL formatter, and enhanced table context menu
- PreferencesDialog (Tools → Preferences, Ctrl+,): font family/size,
  word wrap, default page size, query timeout, max history, auto-commit;
  font changes apply live to all open editors via apply_settings() cascade
- CodeEditor and TableViewer now read initial values from settings singleton
- SQL formatter (≡ Format button): uses sqlparse to reindent and uppercase
  keywords; gracefully prompts to install sqlparse if missing
- Table viewer context menu: "Filter by this value" (populates WHERE and
  reloads), "Copy row as SQL INSERT" (ready-to-paste INSERT statements),
  renamed "Copy row" → "Copy row as TSV" for clarity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 17:05:54 -04:00
20 changed files with 2696 additions and 247 deletions
+52 -4
View File
@@ -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
+229
View File
@@ -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")
+4
View File
@@ -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."""
+3
View File
@@ -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))
+3
View File
@@ -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
View File
@@ -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()
+5 -1
View File
@@ -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
+135
View File
@@ -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;")
+9
View File
@@ -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
View File
@@ -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}")
+159
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}")
+40 -47
View File
@@ -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
View File
@@ -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()
+2 -4
View File
@@ -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():
+1
View File
@@ -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
+599
View File
@@ -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;
}
+589
View File
@@ -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;
}