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
```
Key runtime dependencies: `PyQt6`, `sqlparse>=0.5.0`, `bcrypt`, `keyring`, `cryptography`.
**Run the application:**
```bash
python main.py
@@ -73,7 +75,13 @@ All user data lives under `~/.dbclient/`:
The `ResultTableModel` (`app/models/result_table_model.py`) is a `QAbstractTableModel` — query results should always go through it rather than populating `QTableWidget` directly.
### Styling
`resources/style.qss` is a Catppuccin Mocha dark theme applied globally at startup in `main.py`. Widget-specific overrides belong here, not as inline `setStyleSheet()` calls.
Theming uses a single `resources/style_template.qss` with `@{token}` placeholders. `app/config/theme.py` defines seven palette dicts (`ALL_THEMES`) and `apply_qss(app)` substitutes tokens at runtime via regex — no duplicate `.qss` files. Available themes: `dark` (Catppuccin Frappé), `light` (Catppuccin Latte), `one_dark`, `nord`, `tokyo_night`, `dracula`, `github_light`. To add a new theme, add a palette dict to `ALL_THEMES` and register it in `_THEMES` in `preferences_dialog.py`. Widget-specific color overrides must call `get_palette()` at render time (not at import time) so they react to live theme switches. `is_dark()` returns True for all dark-family themes.
### Recent Files (`app/config/recent_files.py`)
`load_recent()` / `add_recent(path)` / `clear_recent()` persist to `~/.dbclient/recent_files.json` (max 10 entries, dead paths filtered on load). Called from MainWindow File menu.
### Settings Cascade
`PreferencesDialog` emits `settings_applied``MainWindow._apply_settings_to_open_tabs()` iterates workspace tabs and calls `widget.apply_settings()` on each. `SQLEditorWidget.apply_settings()` cascades to each `EditorTab`, which cascades to `CodeEditor`. Add `apply_settings()` to any new workspace widget that reads from `get_settings()`.
---
@@ -88,6 +96,18 @@ All four drivers have a `_where(where: dict) → (clause_str, params)` static he
### Password hashing in RowDialog
`RowDialog` detects password-like column names (via `_is_password_col()`) and hashes plain-text input with bcrypt before storing. In edit mode, leaving a password field empty omits that column from the UPDATE so the existing hash is preserved.
### ResultsPanel multi-tab architecture
`ResultsPanel` uses a `QStackedWidget` with two pages: page 0 = `QTabWidget` (result tabs), page 1 = loading spinner. Single-query results hide the tab bar; script results (`show_script_results()`) show named tabs. Each tab owns its own `ResultTableModel` + `QSortFilterProxyModel`. Export buttons operate on `_current_model()` from the active tab.
### SQL formatter
`EditorTab._format_sql()` uses `sqlparse.format(..., reindent=True, keyword_case='upper')`. Requires `sqlparse>=0.5.0` in requirements.txt.
### Column statistics worker
`_StatsWorker` in `app/ui/column_stats_dialog.py` runs `COUNT/COUNT(col)/COUNT(DISTINCT)/MIN/MAX` in one query and AVG in a second (AVG silently returns None for non-numeric columns). Identifiers are quoted via `driver.quote_identifier(name)` — backticks for MySQL, brackets for MSSQL, double-quotes for PostgreSQL/SQLite — defined on `BaseDriver` with per-driver overrides.
### Identifier quoting
`BaseDriver.quote_identifier(name)` returns `"name"` by default. `MySQLDriver` overrides to `` `name` `` and `MSSQLDriver` to `[name]`. Always use this method when building dynamic SQL with column/table names — never hardcode a quote style.
---
## Implementation Status
@@ -96,19 +116,47 @@ All four drivers have a `_where(where: dict) → (clause_str, params)` static he
- Connection manager (add/edit/delete, test, color coding, keyring storage)
- Schema browser (live tree: connections → databases → tables/views/functions/procedures/triggers, search, context menus, delete connection from active node)
- SQL editor (multi-tab, syntax highlighting, line numbers, Ctrl+/ comment toggle, F5/Ctrl+Enter execute)
- SQL auto-complete (`app/ui/sql_completer.py`): schema-aware popup, dot-notation (schema.table, table.col), Ctrl+Space trigger
- Find/Replace bar in SQL editor (`app/ui/find_bar.py`): Ctrl+F find, Ctrl+H replace, wrap-around, case-sensitive toggle
- SQL formatter: `≡ Format` button in editor toolbar using sqlparse (reindent + uppercase keywords)
- Results panel (sortable `QAbstractTableModel`, export CSV/JSON/SQL INSERT, pagination, execution time)
- Multi-result tabs: scripts with multiple SELECTs show each result in a named sub-tab within ResultsPanel
- Table viewer (paginated grid 50/100/All, add/edit/delete rows via dialog, WHERE filter, bcrypt password hashing)
- Enhanced table context menu: "Filter by this value" (auto-populates WHERE filter), "Copy row as SQL INSERT"
- Column statistics dialog (`app/ui/column_stats_dialog.py`): right-click column header → async COUNT/NULL/DISTINCT/MIN/MAX/AVG
- Table structure view (columns, indexes, FKs, DDL with syntax highlighting, add/drop/rename column designer)
- Query history (auto-log with timestamp/duration/status, search, replay, persisted in `history.db`)
- All 4 DB drivers (MySQL, PostgreSQL, SQLite, MSSQL)
- Process list viewer (`app/ui/process_list.py`) with 5 s auto-refresh and kill query
- Import CSV/JSON dialog (`app/ui/import_dialog.py`) with preview and progress bar
- Import CSV/JSON/SQL dialog (`app/ui/import_dialog.py`): CSV/JSON inserts rows into a target table; SQL dump executes statements via `_SqlImportWorker` (sqlparse split, stop-on-error option, per-statement error summary)
- Database dump export (`app/ui/dump_dialog.py`): schema/data/both, table selector, progress bar
- Tools menu in MainWindow (Process List, Import CSV/JSON, Export Database Dump, User Management)
- Tools menu in MainWindow (Process List, Import CSV/JSON, Export Database Dump, User Management, Preferences)
- Preferences dialog (`app/ui/preferences_dialog.py`): Ctrl+, / Tools → Preferences; font family/size/word-wrap, page size, timeout, history limit, auto-commit; live-applies to all open editors via `settings_applied` signal
- PyInstaller packaging (`DBClient.spec` + `build_app.py`)
- User & privilege management (`app/ui/user_manager.py`): list/create/drop users, GRANT/REVOKE per-DB (MySQL + PostgreSQL)
- EXPLAIN plan diagram (`app/ui/explain_view.py`): visual node tree + raw table; opened from SQL editor "🔎 Explain" button
- Application logging (`app/utils/logger.py` + `app/ui/log_viewer.py`): rotating file logs, unhandled exception hooks, in-app log viewer (Help → View App Logs)
- Keyboard shortcuts dialog (`app/ui/shortcuts_dialog.py`): Help → Keyboard Shortcuts; categorised tree of all key bindings
- Tab context menus: workspace tabs (Rename/Close/Close Others/Close to Right) + SQL query tabs (Rename/Duplicate/Close/Close Others)
- Smart status bar: right-side connection indicator + 60-char cell value preview (updates on cell click)
- Connection tab coloring: workspace tab labels colored by connection profile color
- File open/save/recent: Ctrl+O open SQL file, Ctrl+S save, File → Open Recent (persisted to `~/.dbclient/recent_files.json`, max 10)
- Ctrl+W to close current workspace tab
- Catppuccin Frappé theme: full palette upgrade from Mocha; all colors updated in `resources/style.qss`, syntax highlighter, completer popup, and inline Python stylesheets
- Row numbers column in results grid: `_RowNumberProxy` shows sequential visual-order numbers in the vertical header (sort-stable); fixed 48 px wide header in both `ResultsPanel` and `TableViewer`
- Connection ping indicator: 30-second `_PingWorker` (QThread) pings each active connection via `test_connection()`; status dot (● green / ● red) shown in status bar right side with per-connection tooltip
- Frozen first column in `TableViewer`: 📌 toggle button in toolbar; dual-view overlay (`_frozen_view` child of `_table_view`) with synced vertical scroll and row heights; event-filter updates geometry on resize
- Keyboard navigation in dialogs: OK set as default button (Enter submits) in `RowDialog` and `ConnectionDialog`; focus jumps to first input field on open; Enter in password field submits `ConnectionDialog`
- Multi-theme support: 7 themes (Frappé, Latte, One Dark Pro, Nord, Tokyo Night, Dracula, GitHub Light) via `resources/style_template.qss` token substitution; select in Preferences → Color theme; Ctrl+Shift+T toggles dark↔light; syntax highlighter, completer popup, and model colors all update live via `get_palette()`
### Not Yet Implemented
- (All planned features are now complete)
**High impact:**
- Auto-reconnect on dropped connection (detect broken pipe, reconnect transparently)
- Quick result filter: live filter bar above the results table (client-side, no re-query)
- SQL snippets / bookmarks: save and recall frequently used query fragments
- ERD viewer: visual entity-relationship diagram from live schema
**Medium impact:**
- Transaction panel: explicit BEGIN / COMMIT / ROLLBACK controls with in-flight indicator
- Global schema search: search across all tables/columns/procedures in a database
- Schema diff: compare two database schemas and show structural differences
+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."""
pass
def quote_identifier(self, name: str) -> str:
"""Wrap an identifier in the DB-appropriate quote characters."""
return f'"{name}"'
@abstractmethod
def get_tables(self, database: str) -> list:
"""Returns list of TableInfo for the given database."""
+3
View File
@@ -19,6 +19,9 @@ class MSSQLDriver(BaseDriver):
super().__init__(config)
self.db_type = "mssql"
def quote_identifier(self, name: str) -> str:
return f"[{name}]"
def _conn_str(self) -> str:
host = self.config.get("host", "localhost")
port = int(self.config.get("port", 1433))
+3
View File
@@ -18,6 +18,9 @@ class MySQLDriver(BaseDriver):
super().__init__(config)
self.db_type = "mysql"
def quote_identifier(self, name: str) -> str:
return f"`{name}`"
def _connect_kwargs(self) -> dict:
kw = {
"host": self.config.get("host", "localhost"),
+119 -1
View File
@@ -15,8 +15,9 @@ from PyQt6.QtWidgets import (
QTabWidget, QStatusBar, QLabel, QMessageBox, QDockWidget,
QPushButton, QApplication, QMenu, QInputDialog, QFileDialog,
)
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal
from PyQt6.QtGui import QAction, QColor, QKeySequence
from app.config.theme import apply_qss, is_dark
from app.ui.schema_browser import SchemaBrowser
from app.ui.sql_editor import SQLEditorWidget
@@ -31,6 +32,7 @@ from app.ui.user_manager import UserManagerPanel
from app.ui.log_viewer import LogViewer
from app.ui.connection_dialog import ConnectionDialog
from app.ui.shortcuts_dialog import ShortcutsDialog
from app.ui.preferences_dialog import PreferencesDialog
from app.config.connections import load_profiles, delete_profile
from app.config.recent_files import load_recent, add_recent, clear_recent
from app.models.connection_model import ConnectionProfile
@@ -40,6 +42,23 @@ from app.utils.logger import get_logger
_log = get_logger(__name__)
class _PingWorker(QThread):
"""Background thread that pings one connection and emits the result."""
pinged = pyqtSignal(str, bool) # (profile_id, ok)
def __init__(self, pid: str, driver, parent=None):
super().__init__(parent)
self._pid = pid
self._driver = driver
def run(self):
try:
ok, _ = self._driver.test_connection()
self.pinged.emit(self._pid, ok)
except Exception:
self.pinged.emit(self._pid, False)
class MainWindow(QMainWindow):
def __init__(self):
@@ -52,11 +71,19 @@ class MainWindow(QMainWindow):
self._active_drivers: dict = {}
# profile_id → ConnectionProfile (all loaded profiles, connected or not)
self._all_profiles: dict = {}
# ping state
self._ping_results: dict = {} # profile_id → bool
self._ping_workers: list = [] # keep QThread refs alive
self._build_ui()
self._build_menus()
self._build_status_bar()
# Periodic connection heartbeat (every 30 s)
self._ping_timer = QTimer(self)
self._ping_timer.timeout.connect(self._ping_all_connections)
self._ping_timer.start(30_000)
# Load saved profiles after the window is shown
QTimer.singleShot(0, self._load_saved_profiles)
@@ -181,9 +208,18 @@ class MainWindow(QMainWindow):
view_menu = mb.addMenu("&View")
view_menu.addAction(self._act("Toggle Query History", self._toggle_history, "Ctrl+Shift+H"))
view_menu.addAction(self._act("New SQL Tab", self._new_sql_tab, "Ctrl+T"))
view_menu.addAction(self._act("Close Tab", self._close_current_tab, "Ctrl+W"))
view_menu.addSeparator()
self._theme_action = QAction("", self)
self._theme_action.setShortcut(QKeySequence("Ctrl+Shift+T"))
self._theme_action.triggered.connect(self._toggle_theme)
self._update_theme_action_label()
view_menu.addAction(self._theme_action)
# ── Tools ─────────────────────────────────────────────────────────────
tools_menu = mb.addMenu("&Tools")
tools_menu.addAction(self._act("Preferences…", self._open_preferences, "Ctrl+,"))
tools_menu.addSeparator()
tools_menu.addAction(self._act("Process List…", self._open_process_list, "Ctrl+P"))
tools_menu.addSeparator()
tools_menu.addAction(self._act("Import CSV / JSON…", self._open_import_dialog))
@@ -230,6 +266,12 @@ class MainWindow(QMainWindow):
self._conn_lbl.setMinimumWidth(160)
sb.addPermanentWidget(self._conn_lbl)
self._ping_lbl = QLabel("")
self._ping_lbl.setFixedWidth(16)
self._ping_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._ping_lbl.setToolTip("Connection heartbeat")
sb.addPermanentWidget(self._ping_lbl)
def _set_status(self, msg: str):
self._status_lbl.setText(msg)
@@ -400,6 +442,7 @@ class MainWindow(QMainWindow):
else:
self._schema_browser.remove_connection(profile_id, keep_saved=False)
self._all_profiles.pop(profile_id, None)
self._ping_results.pop(profile_id, None)
delete_profile(profile_id)
self._set_status(f"Connection '{profile.name}' deleted.")
@@ -467,6 +510,11 @@ class MainWindow(QMainWindow):
self._workspace.setVisible(False)
self._empty_label.setVisible(True)
def _close_current_tab(self):
idx = self._workspace.currentIndex()
if idx >= 0:
self._close_tab(idx)
def _on_cell_selected(self, value: str):
"""Show a truncated cell value in the status bar."""
if not value or value == "NULL":
@@ -475,6 +523,44 @@ class MainWindow(QMainWindow):
preview = value[:60] + ("" if len(value) > 60 else "")
self._cell_lbl.setText(f" {preview}")
# ── Connection heartbeat ──────────────────────────────────────────────────
def _ping_all_connections(self):
# Drop finished workers
self._ping_workers = [w for w in self._ping_workers if w.isRunning()]
if not self._active_drivers:
self._ping_lbl.setText("")
return
for pid, driver in list(self._active_drivers.items()):
# Skip if a ping for this connection is already in flight
if any(getattr(w, "_pid", None) == pid for w in self._ping_workers):
continue
worker = _PingWorker(pid, driver)
worker.pinged.connect(self._on_ping_result)
worker.start()
self._ping_workers.append(worker)
def _on_ping_result(self, pid: str, ok: bool):
if pid not in self._active_drivers:
return
self._ping_results[pid] = ok
all_ok = all(self._ping_results.get(p, True) for p in self._active_drivers)
color = "#a6d189" if all_ok else "#e78284"
self._ping_lbl.setText("")
self._ping_lbl.setStyleSheet(f"color: {color}; font-size: 11pt;")
lines = []
for p_id in self._active_drivers:
profile = self._all_profiles.get(p_id)
name = profile.name if profile else p_id
icon = "" if self._ping_results.get(p_id, True) else ""
lines.append(f"{icon} {name}")
self._ping_lbl.setToolTip("Connections:\n" + "\n".join(lines))
if not ok:
profile = self._all_profiles.get(pid)
name = profile.name if profile else pid
_log.warning("Heartbeat failed for '%s'", name)
self._set_status(f"⚠️ Connection heartbeat failed: {name}")
# ── File open / save ──────────────────────────────────────────────────────
def _open_sql_file(self, filepath: str = None):
@@ -581,6 +667,38 @@ class MainWindow(QMainWindow):
"MySQL · PostgreSQL · SQLite · SQL Server<br><br>"
"Built with Python + PyQt6.")
def _open_preferences(self):
dlg = PreferencesDialog(parent=self)
dlg.settings_applied.connect(self._on_settings_applied)
dlg.exec()
def _on_settings_applied(self):
apply_qss(QApplication.instance())
self._update_theme_action_label()
self._apply_settings_to_open_tabs()
def _toggle_theme(self):
from app.config.settings import get_settings
new_theme = "light" if is_dark() else "dark"
get_settings().set("theme", new_theme)
get_settings().save()
apply_qss(QApplication.instance(), new_theme)
self._update_theme_action_label()
self._apply_settings_to_open_tabs()
def _update_theme_action_label(self):
if is_dark():
self._theme_action.setText("☀️ Switch to Light Theme")
else:
self._theme_action.setText("🌙 Switch to Dark Theme")
def _apply_settings_to_open_tabs(self):
"""Push updated settings to all currently open workspace tabs."""
for i in range(self._workspace.count()):
widget = self._workspace.widget(i)
if hasattr(widget, "apply_settings"):
widget.apply_settings()
def _show_shortcuts(self):
dlg = ShortcutsDialog(parent=self)
dlg.exec()
+5 -1
View File
@@ -7,11 +7,15 @@ from PyQt6.QtCore import (
)
from PyQt6.QtGui import QColor, QFont
from app.config.theme import get_palette
class ResultTableModel(QAbstractTableModel):
"""Immutable result-set model — replaces data via set_data()."""
NULL_COLOR = QColor("#737994") # muted grey for NULL
@property
def NULL_COLOR(self):
return QColor(get_palette()["overlay"])
NUM_ALIGN = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
TEXT_ALIGN = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
+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.rejected.connect(self.reject)
ok_btn = bbox.button(QDialogButtonBox.StandardButton.Ok)
if ok_btn:
ok_btn.setDefault(True)
btn_row = QHBoxLayout()
btn_row.addWidget(self._test_btn)
@@ -167,6 +170,12 @@ class ConnectionDialog(QDialog):
self._timeout.setSuffix(" sec")
form.addRow("Timeout:", self._timeout)
# Enter in password submits the dialog
self._password.returnPressed.connect(self._accept)
# Focus starts on the name field
self._name.setFocus()
return w
def _build_ssl_tab(self) -> QWidget:
+289 -75
View File
@@ -1,10 +1,13 @@
"""
Import CSV / JSON into a database table.
Import CSV / JSON / SQL dump into a database.
Flow:
1. User picks a file (.csv or .json)
2. A preview of the first N rows is shown
3. User confirms rows are inserted via the driver's insert_row()
CSV / JSON flow:
1. Pick file parse table preview insert rows via driver.insert_row()
SQL dump flow:
1. Pick .sql file split into statements (sqlparse) text preview
2. Execute each statement via driver.execute_query()
3. Report progress; optional "stop on first error" mode
"""
import csv
import json
@@ -14,18 +17,21 @@ from PyQt6.QtWidgets import (
QLabel, QLineEdit, QPushButton, QComboBox,
QTableWidget, QTableWidgetItem, QHeaderView,
QDialogButtonBox, QFileDialog, QProgressBar,
QMessageBox, QCheckBox,
QMessageBox, QCheckBox, QStackedWidget, QPlainTextEdit,
QWidget,
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal
from PyQt6.QtGui import QFont
_PREVIEW_ROWS = 50
_PREVIEW_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
finished = pyqtSignal(int) # total rows inserted
finished = pyqtSignal(int) # total inserted
error = pyqtSignal(str)
def __init__(self, driver, database: str, table: str,
@@ -34,7 +40,7 @@ class _ImportWorker(QThread):
self._driver = driver
self._database = database
self._table = table
self._rows = rows # list of dicts {col: value}
self._rows = rows
def run(self):
inserted = 0
@@ -49,34 +55,89 @@ class _ImportWorker(QThread):
self.error.emit(f"Row {inserted + 1}: {e}")
# ── SQL dump execution worker ─────────────────────────────────────────────────
class _SqlImportWorker(QThread):
progress = pyqtSignal(int) # statements executed so far
stmt_error = pyqtSignal(int, str) # (stmt_index, message) — non-fatal
finished = pyqtSignal(int, int) # (executed, total)
fatal = pyqtSignal(str) # stopped on error
def __init__(self, driver, database: str, sql_text: str,
stop_on_error: bool = False, parent=None):
super().__init__(parent)
self._driver = driver
self._database = database
self._sql_text = sql_text
self._stop_on_error = stop_on_error
def run(self):
try:
import sqlparse
stmts = [s.strip() for s in sqlparse.split(self._sql_text) if s.strip()]
except ImportError:
# Fallback: naive semicolon split
stmts = [s.strip() for s in self._sql_text.split(";") if s.strip()]
total = len(stmts)
executed = 0
for i, stmt in enumerate(stmts):
try:
self._driver.execute_query(stmt)
executed += 1
except Exception as e:
self.stmt_error.emit(i, str(e))
if self._stop_on_error:
self.fatal.emit(
f"Stopped at statement {i + 1}/{total}:\n{e}"
)
return
if (i + 1) % 20 == 0:
self.progress.emit(i + 1)
self.progress.emit(total)
self.finished.emit(executed, total)
# ── Dialog ────────────────────────────────────────────────────────────────────
class ImportDialog(QDialog):
"""Select a CSV or JSON file and import its contents into a table."""
"""Import CSV, JSON, or SQL dump into the database."""
def __init__(self, driver, database: str, table: str, parent=None):
# Internal mode constants
_MODE_TABLE = 0 # CSV / JSON
_MODE_SQL = 1 # SQL dump
def __init__(self, driver, database: str, table: str = "", parent=None):
super().__init__(parent)
self._driver = driver
self._database = database
self._table = table
self._rows: list = [] # parsed rows ready for import
self._rows: list = [] # parsed rows (CSV/JSON mode)
self._sql_text = "" # raw SQL text (SQL mode)
self._sql_stmts = 0 # statement count (SQL mode)
self._worker = None
self._mode = self._MODE_TABLE
self._errors: list[str] = [] # non-fatal SQL errors collected
self.setWindowTitle(f"Import into {table}")
self.setWindowTitle("Import Data")
self.setModal(True)
self.setMinimumSize(640, 480)
self.setMinimumSize(680, 520)
self._build_ui()
# ── UI ────────────────────────────────────────────────────────────────────
def _build_ui(self):
root = QVBoxLayout(self)
root.setSpacing(8)
# File picker row
# ── File picker ───────────────────────────────────────────────────────
file_row = QHBoxLayout()
self._path_edit = QLineEdit()
self._path_edit.setReadOnly(True)
self._path_edit.setPlaceholderText("Select a .csv or .json file…")
self._path_edit.setPlaceholderText(
"Select a .csv, .json, or .sql file…"
)
browse_btn = QPushButton("Browse…")
browse_btn.setFixedWidth(80)
browse_btn.clicked.connect(self._browse)
@@ -84,38 +145,56 @@ class ImportDialog(QDialog):
file_row.addWidget(browse_btn)
root.addLayout(file_row)
# CSV options (hidden until a CSV is selected)
self._csv_opts = QHBoxLayout()
# ── CSV-only options ──────────────────────────────────────────────────
self._csv_opts_widget = self._build_csv_opts()
root.addWidget(self._csv_opts_widget)
self._csv_opts_widget.setVisible(False)
root.addWidget(self._csv_opts_widget)
# Skip-header checkbox
self._header_cb = QCheckBox("First row is a header (CSV only)")
self._header_cb.setChecked(True)
self._header_cb.toggled.connect(self._reload_preview)
self._header_cb.setVisible(False)
root.addWidget(self._header_cb)
# Preview table
root.addWidget(QLabel("Preview (first 50 rows):"))
self._preview = QTableWidget(0, 0)
self._preview.setAlternatingRowColors(True)
self._preview.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self._preview.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.ResizeToContents
)
self._preview.verticalHeader().setDefaultSectionSize(22)
self._preview.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
root.addWidget(self._preview, 1)
# ── SQL-only options ──────────────────────────────────────────────────
self._sql_opts_widget = self._build_sql_opts()
self._sql_opts_widget.setVisible(False)
root.addWidget(self._sql_opts_widget)
# Status / progress
# ── Preview stack: page 0 = table, page 1 = SQL text ─────────────────
self._preview_label = QLabel("Preview:")
root.addWidget(self._preview_label)
self._preview_stack = QStackedWidget()
self._preview_table = QTableWidget(0, 0)
self._preview_table.setAlternatingRowColors(True)
self._preview_table.setEditTriggers(
QTableWidget.EditTrigger.NoEditTriggers)
self._preview_table.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.ResizeToContents)
self._preview_table.verticalHeader().setDefaultSectionSize(22)
self._preview_table.setSelectionMode(
QTableWidget.SelectionMode.NoSelection)
self._preview_stack.addWidget(self._preview_table) # idx 0
self._preview_sql = QPlainTextEdit()
self._preview_sql.setReadOnly(True)
self._preview_sql.setFont(QFont("Consolas", 10))
self._preview_sql.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
self._preview_stack.addWidget(self._preview_sql) # idx 1
root.addWidget(self._preview_stack, 1)
# ── Status / progress ─────────────────────────────────────────────────
self._status_lbl = QLabel("")
root.addWidget(self._status_lbl)
self._progress = QProgressBar()
self._progress.setVisible(False)
root.addWidget(self._progress)
# Buttons
# ── Buttons ───────────────────────────────────────────────────────────
self._btns = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok |
QDialogButtonBox.StandardButton.Cancel
@@ -127,8 +206,7 @@ class ImportDialog(QDialog):
self._btns.rejected.connect(self.reject)
root.addWidget(self._btns)
def _build_csv_opts(self) -> QHBoxLayout:
from PyQt6.QtWidgets import QWidget
def _build_csv_opts(self) -> QWidget:
w = QWidget()
lay = QHBoxLayout(w)
lay.setContentsMargins(0, 0, 0, 0)
@@ -142,69 +220,116 @@ class ImportDialog(QDialog):
lay.addStretch()
return w
def _build_sql_opts(self) -> QWidget:
w = QWidget()
lay = QHBoxLayout(w)
lay.setContentsMargins(0, 0, 0, 0)
self._stop_on_error_cb = QCheckBox("Stop on first error")
self._stop_on_error_cb.setChecked(False)
self._stop_on_error_cb.setToolTip(
"When unchecked, errors are logged and execution continues.\n"
"When checked, the import halts at the first failing statement."
)
lay.addWidget(self._stop_on_error_cb)
lay.addStretch()
return w
# ── File loading ──────────────────────────────────────────────────────────
def _browse(self):
path, _ = QFileDialog.getOpenFileName(
self, "Open file", "",
"CSV / JSON files (*.csv *.json);;All files (*)"
"Supported files (*.csv *.json *.sql);;"
"CSV files (*.csv);;"
"JSON files (*.json);;"
"SQL dump files (*.sql);;"
"All files (*)"
)
if not path:
return
self._path_edit.setText(path)
ext = os.path.splitext(path)[1].lower()
self._csv_opts_widget.setVisible(ext == ".csv")
self._header_cb.setVisible(ext == ".csv")
is_csv = ext == ".csv"
is_sql = ext == ".sql"
self._csv_opts_widget.setVisible(is_csv)
self._header_cb.setVisible(is_csv)
self._sql_opts_widget.setVisible(is_sql)
self._mode = self._MODE_SQL if is_sql else self._MODE_TABLE
self._reload_preview()
def _delimiter(self) -> str:
mapping = {0: ",", 1: ";", 2: "\t", 3: "|"}
return mapping.get(self._delim_combo.currentIndex(), ",")
return {0: ",", 1: ";", 2: "\t", 3: "|"}.get(
self._delim_combo.currentIndex(), ","
)
def _reload_preview(self):
path = self._path_edit.text()
if not path:
return
ext = os.path.splitext(path)[1].lower()
try:
if ext == ".csv":
self._rows = self._parse_csv(path)
if ext == ".sql":
self._load_sql_preview(path)
elif ext == ".csv":
self._load_csv_preview(path)
elif ext == ".json":
self._rows = self._parse_json(path)
self._load_json_preview(path)
else:
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:
self._status_lbl.setText(f"Parse error: {e}")
self._rows = []
self._ok_btn.setEnabled(False)
return
self._populate_preview(self._rows[:_PREVIEW_ROWS])
self._preview_stack.setCurrentIndex(0)
self._preview_label.setText(f"Preview (first {_PREVIEW_ROWS} rows):")
self._populate_table_preview(self._rows[:_PREVIEW_ROWS])
target = f"'{self._database}'.'{self._table}'" if self._table else f"'{self._database}'"
self._status_lbl.setText(
f"{len(self._rows)} row(s) ready to import into "
f"'{self._database}'.'{self._table}'"
f"{len(self._rows):,} row(s) ready to import into {target}"
)
self._ok_btn.setEnabled(bool(self._rows))
def _load_json_preview(self, path: str):
try:
self._rows = self._parse_json(path)
except Exception as e:
self._status_lbl.setText(f"Parse error: {e}")
self._rows = []
self._ok_btn.setEnabled(False)
return
self._preview_stack.setCurrentIndex(0)
self._preview_label.setText(f"Preview (first {_PREVIEW_ROWS} rows):")
self._populate_table_preview(self._rows[:_PREVIEW_ROWS])
target = f"'{self._database}'.'{self._table}'" if self._table else f"'{self._database}'"
self._status_lbl.setText(
f"{len(self._rows):,} row(s) ready to import into {target}"
)
self._ok_btn.setEnabled(bool(self._rows))
def _parse_csv(self, path: str) -> list:
rows = []
with open(path, newline="", encoding="utf-8-sig") as f:
reader = csv.reader(f, delimiter=self._delimiter())
all_rows = list(reader)
if not all_rows:
return []
if self._header_cb.isChecked():
headers = all_rows[0]
data_rows = all_rows[1:]
headers, data_rows = all_rows[0], all_rows[1:]
else:
headers = [f"col{i+1}" for i in range(len(all_rows[0]))]
data_rows = all_rows
for row in data_rows:
# Pad short rows, truncate long ones
padded = (row + [""] * len(headers))[: len(headers)]
rows.append(dict(zip(headers, padded)))
return rows
return [
dict(zip(headers, (row + [""] * len(headers))[: len(headers)]))
for row in data_rows
]
def _parse_json(self, path: str) -> list:
with open(path, encoding="utf-8") as f:
@@ -212,57 +337,146 @@ class ImportDialog(QDialog):
if isinstance(data, list):
return [r for r in data if isinstance(r, dict)]
if isinstance(data, dict):
# Support {rows: [...]} or {data: [...]} wrappers
for key in ("rows", "data", "records", "items"):
if isinstance(data.get(key), list):
return data[key]
raise ValueError("JSON must be an array of objects or {rows: [...]}")
# ── Preview ───────────────────────────────────────────────────────────────
# ── SQL loading ───────────────────────────────────────────────────────────
def _populate_preview(self, rows: list):
def _load_sql_preview(self, path: str):
try:
with open(path, encoding="utf-8", errors="replace") as f:
self._sql_text = f.read()
except Exception as e:
self._status_lbl.setText(f"Read error: {e}")
self._sql_text = ""
self._sql_stmts = 0
self._ok_btn.setEnabled(False)
return
# Count statements
try:
import sqlparse
stmts = [s for s in sqlparse.split(self._sql_text) if s.strip()]
except ImportError:
stmts = [s for s in self._sql_text.split(";") if s.strip()]
self._sql_stmts = len(stmts)
# Show first N lines in text preview
lines = self._sql_text.splitlines()
preview_text = "\n".join(lines[:_PREVIEW_SQL_LINES])
if len(lines) > _PREVIEW_SQL_LINES:
preview_text += f"\n\n… ({len(lines) - _PREVIEW_SQL_LINES:,} more lines)"
self._preview_sql.setPlainText(preview_text)
self._preview_stack.setCurrentIndex(1)
self._preview_label.setText(
f"Preview (first {_PREVIEW_SQL_LINES} lines):"
)
self._status_lbl.setText(
f"{self._sql_stmts:,} statement(s) ready to execute "
f"on database '{self._database}'"
)
self._ok_btn.setEnabled(self._sql_stmts > 0)
# ── Table preview (CSV/JSON) ───────────────────────────────────────────────
def _populate_table_preview(self, rows: list):
if not rows:
self._preview.setRowCount(0)
self._preview.setColumnCount(0)
self._preview_table.setRowCount(0)
self._preview_table.setColumnCount(0)
return
headers = list(rows[0].keys())
self._preview.setColumnCount(len(headers))
self._preview.setHorizontalHeaderLabels(headers)
self._preview.setRowCount(len(rows))
self._preview_table.setColumnCount(len(headers))
self._preview_table.setHorizontalHeaderLabels(headers)
self._preview_table.setRowCount(len(rows))
for r, row in enumerate(rows):
for c, key in enumerate(headers):
val = row.get(key, "")
self._preview.setItem(
self._preview_table.setItem(
r, c, QTableWidgetItem("" if val is None else str(val))
)
# ── Import ────────────────────────────────────────────────────────────────
# ── Import dispatch ───────────────────────────────────────────────────────
def _start_import(self):
self._ok_btn.setEnabled(False)
self._errors.clear()
if self._mode == self._MODE_SQL:
self._start_sql_import()
else:
self._start_row_import()
def _start_row_import(self):
if not self._rows:
return
self._ok_btn.setEnabled(False)
self._progress.setMaximum(len(self._rows))
self._progress.setValue(0)
self._progress.setVisible(True)
self._worker = _ImportWorker(
self._worker = _RowImportWorker(
self._driver, self._database, self._table, self._rows, parent=self
)
self._worker.progress.connect(self._progress.setValue)
self._worker.finished.connect(self._on_done)
self._worker.error.connect(self._on_error)
self._worker.finished.connect(self._on_row_done)
self._worker.error.connect(self._on_fatal_error)
self._worker.start()
def _on_done(self, count: int):
def _start_sql_import(self):
if not self._sql_text.strip():
return
self._progress.setMaximum(self._sql_stmts)
self._progress.setValue(0)
self._progress.setVisible(True)
self._status_lbl.setText("Executing SQL statements…")
self._worker = _SqlImportWorker(
self._driver, self._database, self._sql_text,
stop_on_error=self._stop_on_error_cb.isChecked(),
parent=self,
)
self._worker.progress.connect(self._progress.setValue)
self._worker.stmt_error.connect(self._on_stmt_error)
self._worker.finished.connect(self._on_sql_done)
self._worker.fatal.connect(self._on_fatal_error)
self._worker.start()
# ── Worker callbacks ──────────────────────────────────────────────────────
def _on_row_done(self, count: int):
self._progress.setValue(count)
QMessageBox.information(
self, "Import Complete",
f"Successfully imported {count} row(s) into '{self._table}'."
f"Successfully imported {count:,} row(s) into '{self._table}'."
)
self.accept()
def _on_error(self, msg: str):
def _on_sql_done(self, executed: int, total: int):
self._progress.setValue(total)
skipped = total - executed
msg = (
f"Executed {executed:,} of {total:,} statement(s) "
f"on database '{self._database}'."
)
if self._errors:
msg += f"\n\n⚠️ {len(self._errors)} statement(s) produced errors:"
msg += "\n" + "\n".join(self._errors[:10])
if len(self._errors) > 10:
msg += f"\n… and {len(self._errors) - 10} more"
QMessageBox.warning(self, "Import Finished with Errors", msg)
else:
QMessageBox.information(self, "Import Complete", msg)
self.accept()
def _on_stmt_error(self, idx: int, msg: str):
self._errors.append(f"Statement {idx + 1}: {msg}")
self._status_lbl.setText(
f"Running… ({len(self._errors)} error(s) so far)"
)
def _on_fatal_error(self, msg: str):
self._progress.setVisible(False)
self._ok_btn.setEnabled(True)
QMessageBox.critical(self, "Import Error", f"Import failed:\n{msg}")
+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.
Single query: one result shown directly (tab bar hidden).
Script with multiple statements: each result set shown in its own named tab.
"""
import io
import csv
import os
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QTableView, QLabel, QPushButton,
QHeaderView, QAbstractItemView, QFileDialog, QMessageBox, QStackedWidget,
QPlainTextEdit, QProgressBar,
QPlainTextEdit, QProgressBar, QTabWidget, QApplication, QMenu,
)
from PyQt6.QtCore import Qt, pyqtSignal, QSortFilterProxyModel
from PyQt6.QtGui import QColor, QFont
@@ -13,12 +19,19 @@ from PyQt6.QtGui import QColor, QFont
from app.models.result_table_model import ResultTableModel
class _RowNumberProxy(QSortFilterProxyModel):
"""Proxy that shows sequential visual-order row numbers in the vertical header."""
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
if orientation == Qt.Orientation.Vertical and role == Qt.ItemDataRole.DisplayRole:
return str(section + 1)
return super().headerData(section, orientation, role)
class ResultsPanel(QWidget):
status_message = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._model = ResultTableModel()
self._build_ui()
# ── UI ────────────────────────────────────────────────────────────────────
@@ -52,35 +65,13 @@ class ResultsPanel(QWidget):
self._toolbar.addWidget(self._export_sql)
root.addLayout(self._toolbar)
# ── Stacked pages ─────────────────────────────────────────────────────
# ── Stack: 0 = result tabs, 1 = loading ──────────────────────────────
self._stack = QStackedWidget()
# Page 0 — table
self._table = QTableView()
self._proxy = QSortFilterProxyModel()
self._proxy.setSourceModel(self._model)
self._table.setModel(self._proxy)
self._table.setSortingEnabled(True)
self._table.setAlternatingRowColors(True)
self._table.setSelectionBehavior(
QAbstractItemView.SelectionBehavior.SelectItems)
self._table.setSelectionMode(
QAbstractItemView.SelectionMode.ExtendedSelection)
self._table.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Interactive)
self._table.horizontalHeader().setStretchLastSection(True)
self._table.verticalHeader().setDefaultSectionSize(24)
self._table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self._table.customContextMenuRequested.connect(self._table_context_menu)
self._stack.addWidget(self._table) # idx 0
self._result_tabs = QTabWidget()
self._result_tabs.tabBar().setVisible(False)
self._stack.addWidget(self._result_tabs) # idx 0
# Page 1 — message / log
self._msg_view = QPlainTextEdit()
self._msg_view.setReadOnly(True)
self._msg_view.setFont(QFont("Consolas", 11))
self._stack.addWidget(self._msg_view) # idx 1
# Page 2 — loading spinner
loading = QWidget()
ll = QVBoxLayout(loading)
ll.setAlignment(Qt.AlignmentFlag.AlignCenter)
@@ -89,41 +80,146 @@ class ResultsPanel(QWidget):
bar.setFixedWidth(200)
ll.addWidget(QLabel("Executing query…"))
ll.addWidget(bar)
self._stack.addWidget(loading) # idx 2
self._stack.addWidget(loading) # idx 1
root.addWidget(self._stack, 1)
self._set_export_visible(False)
# ── Tab helpers ───────────────────────────────────────────────────────────
def _clear_tabs(self):
while self._result_tabs.count():
self._result_tabs.removeTab(0)
def _make_table_tab(self, cols: list, rows: list) -> tuple:
model = ResultTableModel()
model.set_data(cols, rows)
proxy = _RowNumberProxy()
proxy.setSourceModel(model)
table = QTableView()
table.setModel(proxy)
table.setSortingEnabled(True)
table.setAlternatingRowColors(True)
table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectItems)
table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
table.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Interactive)
table.horizontalHeader().setStretchLastSection(True)
table.verticalHeader().setDefaultSectionSize(24)
table.verticalHeader().setFixedWidth(48)
table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
table.customContextMenuRequested.connect(
lambda pos, t=table, p=proxy, m=model:
self._table_context_menu(pos, t, p, m)
)
return table, model, proxy
def _make_message_tab(self, msg: str, is_error: bool = False) -> QPlainTextEdit:
view = QPlainTextEdit()
view.setReadOnly(True)
view.setFont(QFont("Consolas", 11))
view.setPlainText(msg)
if is_error:
view.setStyleSheet("color: #e78284;")
return view
def _current_model(self) -> ResultTableModel | None:
widget = self._result_tabs.currentWidget()
if isinstance(widget, QTableView):
proxy = widget.model()
if isinstance(proxy, QSortFilterProxyModel):
return proxy.sourceModel()
return None
def _auto_resize(self, table: QTableView):
table.resizeColumnsToContents()
h = table.horizontalHeader()
for i in range(h.count()):
if h.sectionSize(i) > 300:
h.resizeSection(i, 300)
# ── Public API ────────────────────────────────────────────────────────────
def show_loading(self):
self._model.clear()
self._stack.setCurrentIndex(2)
self._clear_tabs()
self._stack.setCurrentIndex(1)
self._status_lbl.setText("Running…")
self._set_export_visible(False)
def show_data(self, cols: list, rows: list, count: int, elapsed: float):
self._model.set_data(cols, rows)
table, model, _ = self._make_table_tab(cols, rows)
self._clear_tabs()
self._result_tabs.addTab(table, "Result")
self._result_tabs.tabBar().setVisible(False)
self._stack.setCurrentIndex(0)
t = f" {elapsed:.3f}s" if elapsed else ""
self._status_lbl.setText(f"{count:,} row(s){t}")
self.status_message.emit(f"Fetched {count:,} rows{t}")
self._set_export_visible(bool(cols))
self._auto_resize()
self._auto_resize(table)
def show_message(self, msg: str):
self._msg_view.appendPlainText(msg)
self._stack.setCurrentIndex(1)
self._status_lbl.setText(msg)
view = self._make_message_tab(msg)
self._clear_tabs()
self._result_tabs.addTab(view, "Message")
self._result_tabs.tabBar().setVisible(False)
self._stack.setCurrentIndex(0)
self._status_lbl.setText(msg[:100])
self._set_export_visible(False)
def show_error(self, msg: str):
self._msg_view.setPlainText(f"{msg}")
self._stack.setCurrentIndex(1)
self._status_lbl.setText(f"Error: {msg[:80]}")
self.status_message.emit(f"Error: {msg[:80]}")
view = self._make_message_tab(f"{msg}", is_error=True)
self._clear_tabs()
self._result_tabs.addTab(view, "Error")
self._result_tabs.tabBar().setVisible(False)
self._stack.setCurrentIndex(0)
short = msg[:80]
self._status_lbl.setText(f"Error: {short}")
self.status_message.emit(f"Error: {short}")
self._set_export_visible(False)
def show_script_results(self, results: list):
"""
Show each statement's result in a separate tab.
results: list of (cols, rows, count, message_str)
"""
self._clear_tabs()
last_table_idx = -1
has_data = False
for i, (cols, rows, cnt, msg) in enumerate(results):
if cols:
table, _, _ = self._make_table_tab(cols, rows)
label = f"Result {i + 1} ({cnt:,})"
self._result_tabs.addTab(table, label)
last_table_idx = self._result_tabs.count() - 1
has_data = True
self._auto_resize(table)
else:
view = self._make_message_tab(msg or f"Statement {i + 1}: OK")
label = f"Step {i + 1}"
self._result_tabs.addTab(view, label)
multi = self._result_tabs.count() > 1
self._result_tabs.tabBar().setVisible(multi)
if last_table_idx >= 0:
self._result_tabs.setCurrentIndex(last_table_idx)
self._stack.setCurrentIndex(0)
n_results = sum(1 for c, *_ in results if c)
total_rows = sum(cnt for c, _, cnt, _ in results if c)
self._status_lbl.setText(
f"{len(results)} statement(s) — "
f"{n_results} result set(s), {total_rows:,} total row(s)"
)
self.status_message.emit(
f"{len(results)} statements executed"
)
self._set_export_visible(has_data)
def export_dialog(self):
self._do_export_csv()
@@ -134,74 +230,70 @@ class ResultsPanel(QWidget):
self._export_json.setVisible(v)
self._export_sql.setVisible(v)
def _auto_resize(self):
header = self._table.horizontalHeader()
for i in range(self._model.columnCount()):
header.resizeSection(
i, min(self._table.columnWidth(i) + 20, 300)
)
self._table.resizeColumnsToContents()
def _do_export_csv(self):
model = self._current_model()
if not model:
return
path, _ = QFileDialog.getSaveFileName(
self, "Export CSV", "results.csv", "CSV Files (*.csv)")
if path:
try:
self._model.export_csv(path)
model.export_csv(path)
QMessageBox.information(self, "Exported", f"Saved to {path}")
except Exception as e:
QMessageBox.critical(self, "Error", str(e))
def _do_export_json(self):
model = self._current_model()
if not model:
return
path, _ = QFileDialog.getSaveFileName(
self, "Export JSON", "results.json", "JSON Files (*.json)")
if path:
try:
self._model.export_json(path)
model.export_json(path)
QMessageBox.information(self, "Exported", f"Saved to {path}")
except Exception as e:
QMessageBox.critical(self, "Error", str(e))
def _do_export_sql(self):
model = self._current_model()
if not model:
return
path, _ = QFileDialog.getSaveFileName(
self, "Export SQL", "results.sql", "SQL Files (*.sql)")
if path:
try:
self._model.export_sql(path)
model.export_sql(path)
QMessageBox.information(self, "Exported", f"Saved to {path}")
except Exception as e:
QMessageBox.critical(self, "Error", str(e))
def _table_context_menu(self, pos):
from PyQt6.QtWidgets import QMenu
from PyQt6.QtGui import QClipboard
from PyQt6.QtWidgets import QApplication
idx = self._table.indexAt(pos)
def _table_context_menu(self, pos, table: QTableView,
proxy: QSortFilterProxyModel,
model: ResultTableModel):
idx = table.indexAt(pos)
if not idx.isValid():
return
menu = QMenu(self)
menu.addAction("📋 Copy cell", lambda: self._copy_cell(idx))
menu.addAction("📋 Copy row", lambda: self._copy_row(idx))
menu.addAction("📋 Copy all", lambda: self._copy_all())
menu.exec(self._table.viewport().mapToGlobal(pos))
menu.addAction("📋 Copy cell", lambda: self._copy_cell(idx, proxy))
menu.addAction("📋 Copy row", lambda: self._copy_row(idx, proxy, model))
menu.addAction("📋 Copy all", lambda: self._copy_all(model))
menu.exec(table.viewport().mapToGlobal(pos))
def _copy_cell(self, idx):
from PyQt6.QtWidgets import QApplication
val = self._proxy.data(idx, Qt.ItemDataRole.DisplayRole) or ""
def _copy_cell(self, idx, proxy):
val = proxy.data(idx, Qt.ItemDataRole.DisplayRole) or ""
QApplication.clipboard().setText(str(val))
def _copy_row(self, idx):
from PyQt6.QtWidgets import QApplication
row = self._proxy.mapToSource(idx).row()
vals = [str(v or "") for v in self._model.get_row(row)]
def _copy_row(self, idx, proxy, model):
row = proxy.mapToSource(idx).row()
vals = [str(v or "") for v in model.get_row(row)]
QApplication.clipboard().setText("\t".join(vals))
def _copy_all(self):
from PyQt6.QtWidgets import QApplication
import csv, io
def _copy_all(self, model):
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(self._model.column_names())
for r in range(self._model.rowCount()):
writer.writerow(self._model.get_row(r))
writer.writerow(model.column_names())
for r in range(model.rowCount()):
writer.writerow(model.get_row(r))
QApplication.clipboard().setText(buf.getvalue())
+23 -14
View File
@@ -12,6 +12,7 @@ from PyQt6.QtWidgets import QApplication, QListWidget, QListWidgetItem
from app.ui.syntax_highlighter import _KEYWORDS, _TYPES, _FUNCTIONS
from app.utils.worker import SchemaWorker
from app.config.theme import get_palette
# Combined, deduplicated keyword list used as the static completion pool.
SQL_KEYWORDS: list[str] = list(dict.fromkeys(_KEYWORDS + _TYPES + _FUNCTIONS))
@@ -51,24 +52,28 @@ class _CompletionPopup(QListWidget):
self.setFont(font)
self._row_h = QFontMetrics(font).height() + 6
self.setStyleSheet("""
QListWidget {
background: #292c3c;
color: #c6d0f5;
border: 1px solid #51576d;
self._apply_popup_style()
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
def _apply_popup_style(self):
p = get_palette()
self.setStyleSheet(f"""
QListWidget {{
background: {p['popup_bg']};
color: {p['text']};
border: 1px solid {p['popup_border']};
border-radius: 4px;
padding: 2px;
outline: none;
}
QListWidget::item { padding: 2px 8px; }
QListWidget::item:selected {
background: #414559;
color: #c6d0f5;
}
QListWidget::item:hover { background: #363a4f; }
}}
QListWidget::item {{ padding: 2px 8px; }}
QListWidget::item:selected {{
background: {p['popup_selected']};
color: {p['text']};
}}
QListWidget::item:hover {{ background: {p['popup_hover']}; }}
""")
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.itemClicked.connect(
lambda item: self._editor._completer._accept_completion(item.text())
@@ -142,6 +147,10 @@ class SqlCompleter(QObject):
# ── Public API ────────────────────────────────────────────────────────────
def update_theme(self) -> None:
"""Reapply popup colors after a theme change."""
self._popup._apply_popup_style()
def set_context(self, driver, database: str) -> None:
"""Update the active driver/database and refresh schema cache."""
self._driver = driver
+68 -11
View File
@@ -18,6 +18,7 @@ from app.ui.syntax_highlighter import SQLHighlighter
from app.ui.results_panel import ResultsPanel
from app.ui.sql_completer import SqlCompleter
from app.utils.worker import QueryWorker
from app.config.settings import get_settings
# ── Line-number gutter ────────────────────────────────────────────────────────
@@ -42,11 +43,12 @@ class CodeEditor(QPlainTextEdit):
self._line_area = LineNumberArea(self)
self._completer: "SqlCompleter | None" = None
# Font
font = QFont("Consolas", 13)
font.setFixedPitch(True)
self.setFont(font)
self.setTabStopDistance(QFontMetrics(font).horizontalAdvance(" ") * 4)
self._apply_font()
self.setLineWrapMode(
QPlainTextEdit.LineWrapMode.WidgetWidth
if get_settings().get("word_wrap", False)
else QPlainTextEdit.LineWrapMode.NoWrap
)
# Connect signals
self.blockCountChanged.connect(self._update_line_area_width)
@@ -112,6 +114,22 @@ class CodeEditor(QPlainTextEdit):
bottom = top + round(self.blockBoundingRect(block).height())
number += 1
def _apply_font(self) -> None:
s = get_settings()
font = QFont(s.get("font_family", "Consolas"), s.get("font_size", 13))
font.setFixedPitch(True)
self.setFont(font)
self.setTabStopDistance(QFontMetrics(font).horizontalAdvance(" ") * 4)
def apply_settings(self) -> None:
"""Re-read settings and apply font + word-wrap live."""
self._apply_font()
self.setLineWrapMode(
QPlainTextEdit.LineWrapMode.WidgetWidth
if get_settings().get("word_wrap", False)
else QPlainTextEdit.LineWrapMode.NoWrap
)
def set_completer(self, completer: "SqlCompleter") -> None:
self._completer = completer
@@ -430,6 +448,10 @@ class EditorTab(QWidget):
self._explain_btn = QPushButton("🔎 Explain")
self._explain_btn.clicked.connect(self._explain)
self._format_btn = QPushButton("≡ Format")
self._format_btn.setToolTip("Auto-format SQL (requires sqlparse)")
self._format_btn.clicked.connect(self._format_sql)
self._export_btn = QPushButton("📤 Export")
self._export_btn.clicked.connect(self._export)
@@ -439,6 +461,7 @@ class EditorTab(QWidget):
toolbar.addWidget(self._run_btn)
toolbar.addWidget(self._stop_btn)
toolbar.addWidget(self._explain_btn)
toolbar.addWidget(self._format_btn)
toolbar.addWidget(self._export_btn)
toolbar.addStretch()
toolbar.addWidget(self._db_label)
@@ -448,7 +471,7 @@ class EditorTab(QWidget):
self._splitter.setHandleWidth(3)
self._editor = CodeEditor()
SQLHighlighter(self._editor.document())
self._highlighter = SQLHighlighter(self._editor.document())
self._completer = SqlCompleter(self._editor, parent=self)
self._editor.set_completer(self._completer)
if self._driver:
@@ -504,6 +527,9 @@ class EditorTab(QWidget):
self._stop_btn.setEnabled(False)
def _explain(self):
if not self._driver:
self._results.show_error("No active connection. Connect to a database first.")
return
sql = self._editor.selected_or_all().strip()
if not sql:
return
@@ -523,6 +549,30 @@ class EditorTab(QWidget):
def _export(self):
self._results.export_dialog()
def _format_sql(self):
try:
import sqlparse
except ImportError:
QMessageBox.warning(
self, "Package Missing",
"SQL formatting requires sqlparse.\n\nInstall it with:\n pip install sqlparse",
)
return
sql = self._editor.toPlainText().strip()
if not sql:
return
formatted = sqlparse.format(
sql,
reindent=True,
keyword_case="upper",
identifier_case="lower",
strip_comments=False,
use_space_around_operators=True,
)
cursor = self._editor.textCursor()
cursor.select(QTextCursor.SelectionType.Document)
cursor.insertText(formatted)
def set_context(self, driver, database: str) -> None:
"""Update driver/database and refresh schema completions."""
self._driver = driver
@@ -535,11 +585,7 @@ class EditorTab(QWidget):
self._maybe_invalidate_schema(self._editor.toPlainText())
def _on_script_done(self, results: list):
for cols, rows, cnt, msg in results:
if cols:
self._results.show_data(cols, rows, cnt, 0)
else:
self._results.show_message(msg)
self._results.show_script_results(results)
self._maybe_invalidate_schema(self._editor.toPlainText())
def _on_error(self, msg: str):
@@ -558,6 +604,11 @@ class EditorTab(QWidget):
def get_sql(self) -> str:
return self._editor.toPlainText()
def apply_settings(self) -> None:
self._editor.apply_settings()
self._highlighter.update_theme()
self._completer.update_theme()
# ── Tabbed SQL editor container ───────────────────────────────────────────────
@@ -651,6 +702,12 @@ class SQLEditorWidget(QWidget):
w = self._tabs.currentWidget()
return w if isinstance(w, EditorTab) else None
def apply_settings(self) -> None:
for i in range(self._tabs.count()):
tab = self._tabs.widget(i)
if isinstance(tab, EditorTab):
tab.apply_settings()
def open_sql_for(self, driver, database: str, sql: str = ""):
tab = self.new_tab(driver, database, sql,
title=f"SQL — {database}")
+40 -47
View File
@@ -1,6 +1,7 @@
"""
SQL syntax highlighter for QPlainTextEdit.
Highlights keywords, types, functions, strings, comments, and numbers.
Colors follow the active Catppuccin theme (dark = Frappé, light = Latte).
"""
import re
from PyQt6.QtCore import QRegularExpression, Qt
@@ -8,6 +9,8 @@ from PyQt6.QtGui import (
QSyntaxHighlighter, QTextCharFormat, QColor, QFont
)
from app.config.theme import get_palette
def _fmt(color: str, bold: bool = False, italic: bool = False) -> QTextCharFormat:
f = QTextCharFormat()
@@ -17,17 +20,7 @@ def _fmt(color: str, bold: bool = False, italic: bool = False) -> QTextCharForma
return f
# ── Token categories (Catppuccin Frappé palette) ─────────────────────────────
_KEYWORD_FMT = _fmt("#8caaee", bold=True) # blue — DDL/DML/control
_TYPE_FMT = _fmt("#ef9f76") # peach — data types
_FUNCTION_FMT = _fmt("#a6d189") # green — functions
_STRING_FMT = _fmt("#a6d189") # green — string literals
_NUMBER_FMT = _fmt("#ef9f76") # orange — numeric literals
_COMMENT_FMT = _fmt("#737994", italic=True) # grey — comments
_OPERATOR_FMT = _fmt("#ca9ee6") # mauve — operators/special
_STAR_FMT = _fmt("#ca9ee6", bold=True) # mauve — * wildcard
# ── Keyword lists ─────────────────────────────────────────────────────────────
# ── Keyword lists (shared with completer) ─────────────────────────────────────
_KEYWORDS = [
"SELECT", "FROM", "WHERE", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER",
"FULL", "CROSS", "ON", "AS", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN",
@@ -79,56 +72,56 @@ class SQLHighlighter(QSyntaxHighlighter):
def __init__(self, document):
super().__init__(document)
self._rules: list[tuple] = []
self._build_rules()
def _build_rules(self):
p = get_palette()
def kw_pattern(words: list[str]) -> str:
return r"\b(?:" + "|".join(words) + r")\b"
ci = QRegularExpression.PatternOption.CaseInsensitiveOption
self._rules = [
# Keywords (case-insensitive handled via flag)
(QRegularExpression(kw_pattern(_KEYWORDS),
QRegularExpression.PatternOption.CaseInsensitiveOption),
_KEYWORD_FMT),
# Data types
(QRegularExpression(kw_pattern(_TYPES),
QRegularExpression.PatternOption.CaseInsensitiveOption),
_TYPE_FMT),
# Functions
(QRegularExpression(kw_pattern(_FUNCTIONS),
QRegularExpression.PatternOption.CaseInsensitiveOption),
_FUNCTION_FMT),
# Numbers
(QRegularExpression(r"\b\d+(\.\d+)?\b"), _NUMBER_FMT),
# Single-quoted strings
(QRegularExpression(r"'[^'\\]*(?:\\.[^'\\]*)*'"), _STRING_FMT),
# Double-quoted identifiers
(QRegularExpression(r'"[^"]*"'), _fmt("#99d1db")),
# Backtick identifiers (MySQL)
(QRegularExpression(r"`[^`]*`"), _fmt("#99d1db")),
# * wildcard
(QRegularExpression(r"\bSELECT\s+\*|\*(?=\s*FROM)",
QRegularExpression.PatternOption.CaseInsensitiveOption),
_STAR_FMT),
# Operators
(QRegularExpression(r"[=<>!%&|^~+\-*/]"), _OPERATOR_FMT),
# Single-line comment --
(QRegularExpression(r"--[^\n]*"), _COMMENT_FMT),
# Single-line comment #
(QRegularExpression(r"#[^\n]*"), _COMMENT_FMT),
(QRegularExpression(kw_pattern(_KEYWORDS), ci),
_fmt(p["syn_keyword"], bold=True)),
(QRegularExpression(kw_pattern(_TYPES), ci),
_fmt(p["syn_type"])),
(QRegularExpression(kw_pattern(_FUNCTIONS), ci),
_fmt(p["syn_function"])),
(QRegularExpression(r"\b\d+(\.\d+)?\b"),
_fmt(p["syn_number"])),
(QRegularExpression(r"'[^'\\]*(?:\\.[^'\\]*)*'"),
_fmt(p["syn_string"])),
(QRegularExpression(r'"[^"]*"'),
_fmt(p["syn_ident"])),
(QRegularExpression(r"`[^`]*`"),
_fmt(p["syn_ident"])),
(QRegularExpression(
r"\bSELECT\s+\*|\*(?=\s*FROM)", ci),
_fmt(p["syn_operator"], bold=True)),
(QRegularExpression(r"[=<>!%&|^~+\-*/]"),
_fmt(p["syn_operator"])),
(QRegularExpression(r"--[^\n]*"),
_fmt(p["syn_comment"], italic=True)),
(QRegularExpression(r"#[^\n]*"),
_fmt(p["syn_comment"], italic=True)),
]
# Multi-line block comment /* ... */
self._comment_fmt = _fmt(p["syn_comment"], italic=True)
self._block_comment_start = QRegularExpression(r"/\*")
self._block_comment_end = QRegularExpression(r"\*/")
def update_theme(self):
"""Rebuild color rules for the current theme and re-highlight."""
self._build_rules()
self.rehighlight()
def highlightBlock(self, text: str) -> None:
# Single-line rules
for pattern, fmt in self._rules:
it = pattern.globalMatch(text)
while it.hasNext():
m = it.next()
self.setFormat(m.capturedStart(), m.capturedLength(), fmt)
# Multi-line block comments
self.setCurrentBlockState(0)
start_idx = 0
if self.previousBlockState() != 1:
@@ -139,10 +132,10 @@ class SQLHighlighter(QSyntaxHighlighter):
end_m = self._block_comment_end.match(text, start_idx)
if end_m.hasMatch():
end_idx = end_m.capturedStart() + end_m.capturedLength()
self.setFormat(start_idx, end_idx - start_idx, _COMMENT_FMT)
self.setFormat(start_idx, end_idx - start_idx, self._comment_fmt)
m = self._block_comment_start.match(text, end_idx)
start_idx = m.capturedStart() if m.hasMatch() else -1
else:
self.setCurrentBlockState(1)
self.setFormat(start_idx, len(text) - start_idx, _COMMENT_FMT)
self.setFormat(start_idx, len(text) - start_idx, self._comment_fmt)
break
+193 -8
View File
@@ -16,11 +16,13 @@ from PyQt6.QtWidgets import (
QMenu, QApplication,
)
from PyQt6.QtCore import (
Qt, QAbstractTableModel, QModelIndex, pyqtSignal,
Qt, QAbstractTableModel, QModelIndex, pyqtSignal, QEvent,
)
from PyQt6.QtGui import QColor, QBrush, QFont, QKeySequence, QShortcut
from app.utils.worker import TableDataWorker
from app.config.settings import get_settings
from app.config.theme import get_palette
# ─────────────────────────────────────────────────────────────────────────────
@@ -147,18 +149,20 @@ class EditableTableModel(QAbstractTableModel):
return "" if val is None else str(val)
if role == Qt.ItemDataRole.ForegroundRole:
p = get_palette()
if self._is_deleted_row(r):
return QBrush(QColor("#e78284")) # red — pending delete
return QBrush(QColor(p["delete_fg"]))
if (r, c) in self._dirty:
return QBrush(QColor("#ef9f76")) # orange — edited
return QBrush(QColor(p["dirty_fg"]))
if val is None:
return QBrush(QColor("#737994")) # grey — NULL
return QBrush(QColor(p["overlay"]))
if role == Qt.ItemDataRole.BackgroundRole:
p = get_palette()
if self._is_deleted_row(r):
return QBrush(QColor("#3a2530"))
return QBrush(QColor(p["delete_bg"]))
if (r, c) in self._dirty:
return QBrush(QColor("#3a2f25"))
return QBrush(QColor(p["dirty_bg"]))
return None
@@ -266,8 +270,15 @@ class RowDialog(QDialog):
)
bbox.accepted.connect(self.accept)
bbox.rejected.connect(self.reject)
ok_btn = bbox.button(QDialogButtonBox.StandardButton.Ok)
if ok_btn:
ok_btn.setDefault(True)
root.addWidget(bbox)
# Focus the first input field so the user can start typing immediately
if self._fields:
next(iter(self._fields.values())).setFocus()
@property
def values(self) -> dict:
"""Return {col: value_or_None} for all fields.
@@ -310,7 +321,8 @@ class TableViewer(QWidget):
self._table = table
self._offset = 0
self._total = 0
self._page_size = 100 # default
ps = get_settings().get("result_page_size", 100)
self._page_size = ps if ps > 0 else 10_000_000 # 0 → All
self._model = EditableTableModel()
self._worker: TableDataWorker | None = None
self._build_ui()
@@ -378,6 +390,13 @@ class TableViewer(QWidget):
tb.addWidget(self._edit_btn)
tb.addWidget(self._delete_btn)
tb.addStretch()
self._freeze_btn = QPushButton("📌")
self._freeze_btn.setFixedWidth(34)
self._freeze_btn.setToolTip("Freeze first column (keep it visible while scrolling)")
self._freeze_btn.setCheckable(True)
self._freeze_btn.clicked.connect(self._toggle_freeze)
tb.addWidget(self._freeze_btn)
root.addLayout(tb)
# ── Table view ─────────────────────────────────────────────────────────
@@ -393,10 +412,16 @@ class TableViewer(QWidget):
QHeaderView.ResizeMode.Interactive)
self._table_view.horizontalHeader().setStretchLastSection(True)
self._table_view.verticalHeader().setDefaultSectionSize(24)
self._table_view.verticalHeader().setFixedWidth(48)
self._table_view.setContextMenuPolicy(
Qt.ContextMenuPolicy.CustomContextMenu)
self._table_view.customContextMenuRequested.connect(self._context_menu)
self._table_view.doubleClicked.connect(self._on_double_click)
# Column header context menu
hdr = self._table_view.horizontalHeader()
hdr.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
hdr.customContextMenuRequested.connect(self._header_context_menu)
# Track selection to enable/disable Edit/Delete buttons.
# Use both signals: clicked covers mouse, selectionChanged covers keyboard.
self._table_view.clicked.connect(self._on_cell_click)
@@ -408,6 +433,40 @@ class TableViewer(QWidget):
self._delete_selected)
QShortcut(QKeySequence("Ins"), self._table_view, self._add_row)
# ── Frozen first-column overlay ───────────────────────────────────────
self._freeze_active = False
self._frozen_view = QTableView(self._table_view)
self._frozen_view.setModel(self._model)
self._frozen_view.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self._frozen_view.setVerticalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self._frozen_view.setHorizontalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self._frozen_view.setAlternatingRowColors(True)
self._frozen_view.verticalHeader().hide()
self._frozen_view.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Fixed)
self._frozen_view.setStyleSheet(
f"QTableView {{ border: none; border-right: 2px solid {get_palette()['blue']}; }}")
self._frozen_view.hide()
# Sync vertical scroll between the two views
self._table_view.verticalScrollBar().valueChanged.connect(
self._frozen_view.verticalScrollBar().setValue)
self._frozen_view.verticalScrollBar().valueChanged.connect(
self._table_view.verticalScrollBar().setValue)
# Sync row heights
self._table_view.verticalHeader().sectionResized.connect(
lambda row, _, h: self._frozen_view.setRowHeight(row, h))
# Update frozen geometry when column 0 is resized
self._table_view.horizontalHeader().sectionResized.connect(
lambda col, _old, _new: self._update_frozen_geometry() if col == 0 else None)
# Event filter to handle main view resize
self._table_view.installEventFilter(self)
root.addWidget(self._table_view, 1)
# ── Pagination bar (prominent, always visible) ─────────────────────────
@@ -498,11 +557,58 @@ class TableViewer(QWidget):
self._refresh_action_states()
self._table_view.resizeColumnsToContents()
self._table_view.horizontalHeader().setStretchLastSection(True)
if self._freeze_active and self._model.columnCount() > 0:
self._apply_freeze()
def _on_error(self, msg: str):
self._total_lbl.setText(f"Error: {msg[:80]}")
self.status_message.emit(f"Error: {msg}")
# ── Frozen column ──────────────────────────────────────────────────────────
def _toggle_freeze(self, checked: bool):
self._freeze_active = checked
if checked and self._model.columnCount() > 0:
self._apply_freeze()
elif not checked:
self._unapply_freeze()
def _apply_freeze(self):
col_count = self._model.columnCount()
if col_count == 0:
return
self._frozen_view.setSelectionModel(self._table_view.selectionModel())
for c in range(col_count):
self._frozen_view.setColumnHidden(c, c != 0)
self._frozen_view.resizeColumnToContents(0)
self._table_view.setColumnHidden(0, True)
self._update_frozen_geometry()
self._frozen_view.show()
self._frozen_view.raise_()
def _unapply_freeze(self):
self._frozen_view.hide()
if self._model.columnCount() > 0:
self._table_view.setColumnHidden(0, False)
def _update_frozen_geometry(self):
if not self._freeze_active:
return
fw = self._table_view.frameWidth()
vh_w = self._table_view.verticalHeader().width()
col0_w = self._frozen_view.columnWidth(0)
self._frozen_view.setGeometry(
fw + vh_w,
fw,
col0_w,
self._table_view.height() - 2 * fw,
)
def eventFilter(self, obj, event):
if obj is self._table_view and event.type() == QEvent.Type.Resize:
self._update_frozen_geometry()
return False
def _refresh_pagination(self):
ps = self._page_size if self._page_size > 0 else max(self._total, 1)
rows_loaded = self._model.rowCount()
@@ -609,16 +715,53 @@ class TableViewer(QWidget):
def _context_menu(self, pos):
rows = self._selected_logical_rows()
idx = self._table_view.indexAt(pos)
menu = QMenu(self)
menu.addAction(" Add Row", self._add_row)
if rows:
menu.addAction("✏️ Edit Row", self._edit_selected)
menu.addAction("🗑️ Delete Row", self._delete_selected)
menu.addSeparator()
if idx.isValid():
menu.addAction("🔍 Filter by this value",
lambda: self._filter_by_cell(idx))
menu.addSeparator()
menu.addAction("📋 Copy cell value", lambda: self._copy_cell(pos))
menu.addAction("📋 Copy row", self._copy_selected_rows)
menu.addAction("📋 Copy row as TSV", self._copy_selected_rows)
if rows:
menu.addAction("📋 Copy row as SQL INSERT",
self._copy_selected_as_insert)
menu.exec(self._table_view.viewport().mapToGlobal(pos))
def _header_context_menu(self, pos):
hdr = self._table_view.horizontalHeader()
col_idx = hdr.logicalIndexAt(pos)
if col_idx < 0 or col_idx >= len(self._model.column_names()):
return
col_name = self._model.column_names()[col_idx]
menu = QMenu(self)
menu.addAction(
f"📊 Statistics: {col_name}",
lambda: self._show_column_stats(col_name),
)
menu.addSeparator()
menu.addAction(
"⟺ Resize to fit",
lambda: self._table_view.resizeColumnToContents(col_idx),
)
menu.addAction(
"⟺ Resize all to fit",
self._table_view.resizeColumnsToContents,
)
menu.exec(hdr.mapToGlobal(pos))
def _show_column_stats(self, col_name: str):
from app.ui.column_stats_dialog import ColumnStatsDialog
dlg = ColumnStatsDialog(
self._driver, self._database, self._table, col_name, parent=self
)
dlg.exec()
# ── Double-click: edit ────────────────────────────────────────────────────
def _on_double_click(self, index: QModelIndex):
@@ -725,3 +868,45 @@ class TableViewer(QWidget):
row_data = self._model.get_row_current(r)
lines.append("\t".join("" if v is None else str(v) for v in row_data))
QApplication.clipboard().setText("\n".join(lines))
def _copy_selected_as_insert(self):
rows = self._selected_logical_rows()
cols = self._model.column_names()
col_list = ", ".join(f'"{c}"' for c in cols)
statements = []
for r in rows:
row_data = self._model.get_row_current(r)
values = []
for v in row_data:
if v is None:
values.append("NULL")
elif isinstance(v, (int, float)):
values.append(str(v))
else:
escaped = str(v).replace("'", "''")
values.append(f"'{escaped}'")
statements.append(
f'INSERT INTO "{self._table}" ({col_list}) VALUES ({", ".join(values)});'
)
QApplication.clipboard().setText("\n".join(statements))
def apply_settings(self):
"""Called by MainWindow when preferences (including theme) change."""
self._frozen_view.setStyleSheet(
f"QTableView {{ border: none; border-right: 2px solid {get_palette()['blue']}; }}")
self._model.layoutChanged.emit() # repaint dirty/delete cells
def _filter_by_cell(self, index):
if not index.isValid():
return
col_name = self._model.column_names()[index.column()]
val = self._model.data(index, Qt.ItemDataRole.DisplayRole)
if val is None:
clause = f'"{col_name}" IS NULL'
elif isinstance(val, str):
escaped = val.replace("'", "''")
clause = f'"{col_name}" = \'{escaped}\''
else:
clause = f'"{col_name}" = {val}'
self._filter_input.setText(clause)
self._apply_filter()
+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.main_window import MainWindow
from app.config.theme import apply_qss
log = get_logger(__name__)
def load_stylesheet(app: QApplication) -> None:
style_path = os.path.join(os.path.dirname(__file__), "resources", "style.qss")
if os.path.exists(style_path):
with open(style_path, "r", encoding="utf-8") as f:
app.setStyleSheet(f.read())
apply_qss(app) # reads theme from settings, falls back to dark
def main():
+1
View File
@@ -5,6 +5,7 @@ pyodbc>=5.0.1
keyring>=24.3.1
cryptography>=42.0.0
bcrypt>=4.0.0
sqlparse>=0.5.0
# Packaging (dev dependency — only needed when building the distributable)
pyinstaller>=6.0.0
+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;
}