diff --git a/CHANGELOG.md b/CHANGELOG.md index 09ad39be..b9c62686 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.16] - 2026-09-20 + +In-place editing in Table Browser (same DML staging as SQL Workspace), Command Palette and Quick Switcher, workspace chrome parity across drivers, connections-tree visual + FPS work, MongoDB per-field Save to DB, and a hardened in-app updater. + +### Added + +- **Table Browser In-Place Editing (#759, PR #760)** — Postgres, MySQL, and SQLite table views attach `DataGridStagingBuffer` and `ResultsTab` so double-click edits a cell when a PRIMARY KEY is present. Views, materialized views, custom SQL, and tables without a PK stay read-only with an explicit status line. Toolbar **Save** / **Revert** + pending-change badge; pagination and Refresh confirm before discard; commit goes through DML preview and a transaction, then a success toast. +- **Command Palette & Quick Switcher (#742–#747, #754, PRs #749–#753, #757)** — `QueryaCommandRegistry` and a modal palette on **Ctrl/Cmd+P** (CP-01…02); Quick Switcher for database objects on **Ctrl/Cmd+K** (CP-03); palette wired to menus, SQL, grid, and connections (CP-04); extension `contributes.commands` in the palette with RPC error toasts and reserved core ids (CP-05). +- **Workspace UI Parity (#714–#717, #729, PRs #728, #730)** — Shared `QueryaTabStrip` on driver homes (Overview / SQL), `QueryaStatusBar` for busy / duration / rows / cols, read-only badge moved out of the title bar, and **Return to** last MongoDB database / Redis `dbN` from stats. +- **Connections Tree Chrome (#718–#722, #740, PRs #731–#734, #748)** — Shared `QueryaTreeTokens`, collapsible Databases folder, SDUI/`QueryaConnectionTreeRow` parity, Extension Refresh / SQLite path subtitle / Redis DB menu polish, indent guides, and a compact SERVERS header. +- **MongoDB Field Inspector + Save to DB (#763, PR #764)** — Expanded document cards list top-level fields; tapping a non-`_id` field opens the Cell Inspector with **Save to DB** (`$set` one field and reload). SQL inspector **Apply** is unchanged when the callback is not wired. +- **Grid Cell UX (#761, PR #762)** — Text cursor on editable cells; hover tooltip `column · sql_type` (long values still included); README Feature Matrix: **Live In-Grid Editing + Staged Commit**. + +### Changed & Performance + +- **Connections Tree FPS (#724–#727, PRs #735–#738)** — Flatten nested shrinkWrap lists into a virtualized native tree, selection rebuilds only the selected slice, skip `AnimatedSize` on large expands and Motion Off, and a DevTools checklist for 120 Hz captures (QA tracked in #739 / epic #723). +- **Motion Token Cleanup (#741, PR #758)** — Leftover welcome-tour, empty-hero, and sidebar durations use `QueryaMotion` tokens. + +### Fixed + +- **In-App Updater Hardening (#755, PR #756)** — Surface SHA-256 mismatches, refuse unsafe installs (dirty tree / managed / zip), and avoid using `BuildContext` across the restart-confirm await. +- **Extension Palette Invoke (#754, PR #757)** — Toast RPC failures from extension commands and stop extensions from clobbering core command ids. + ## [0.4.15] - 2026-09-07 Multi-tab SQL workspace sessions, fluid motion transitions, cohesive dialog UI system, content-aware adaptive column sizing and auto-fit in data grid, rich cell context menus, desktop file associations (.sql, .sqlite) with CLI open-with handling, categorized Master-Detail preferences with Keymap reference and theme editor modal, server-side column sorting in table views, connection tree quick search and pinning, and comprehensive UI freeze and isolate performance optimizations. diff --git a/README.md b/README.md index 216df436..0ca067f3 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Whether you are navigating multi-million row datasets, authoring complex analyti - **Streaming Exporter:** Stream table data directly to **CSV**, **JSON**, or executable **SQL INSERT** statements. ### ✍️ Interactive In-Place DML Staging Buffer -- **Safe Transactional Editing:** Edit cells directly within the grid with real-time data type validation (integers, floats, booleans, timestamps, UUIDs, and JSON). +- **Safe Transactional Editing:** Edit cells directly in SQL Workspace **and Table Browser** (Postgres, MySQL, SQLite) with real-time data type validation (integers, floats, booleans, timestamps, UUIDs, and JSON). Views and tables without a primary key stay read-only. - **Atomic DML Generation:** Querya tracks modifications (`UPDATE`, `INSERT`, `DELETE`) in a dedicated staging buffer, generating dialect-accurate SQL statements. - **Review Before Execution:** Inspect generated SQL diffs with primary-key safety checks, row counts, and single-click commit or rollback. @@ -66,6 +66,12 @@ Whether you are navigating multi-million row datasets, authoring complex analyti ### 🔬 Rich Cell & Payload Inspector - **Structured Pretty-Printers:** Inspect complex cell values in dedicated preview panes with automatic formatting and syntax validation for **JSON**, **XML**, and **HTML**. - **Raw Hex / Binary Viewer:** Inspect BLOBs, binary hashes, and raw payloads with side-by-side hex and ASCII representations. +- **MongoDB Field Save:** Expand a document card, inspect a field, and **Save to DB** (`$set`) without replacing the whole document. + +### ⌘ Command Palette & Quick Switcher +- **Command Palette (Ctrl/Cmd+P):** Fuzzy-search app commands — SQL run, grid staging, connections, preferences — from one modal. +- **Quick Switcher (Ctrl/Cmd+K):** Jump to tables, views, collections, and other database objects in the current connection. +- **Extension Commands:** Installed drivers can contribute commands via `contributes.commands` in their manifest. ### 🔒 Security & Privacy by Design - **Hardware-Backed Credential Vaults:** Connection passwords and keys are never stored in plaintext. Querya integrates directly with native OS credential stores (**Freedesktop Secret Service / Keyring** on Linux, **Apple Keychain** on macOS, and **Windows DPAPI Credential Manager**). @@ -102,7 +108,8 @@ Whether you are navigating multi-million row datasets, authoring complex analyti |:---|:---:|:---:|:---:| | **Runtime Overhead** | **Zero JVM (Native Flutter / Dart)** | Heavy (~1–2 GB JVM/Electron) | Browser Tab Memory Limits | | **Data Grid Architecture** | **2D Virtualized ($O(1)$ Memory)** | Paginated or Unbounded Heap | DOM Virtualization Limits | -| **In-Place Cell Editing** | **Yes (DML Staging & Preview)** | Often Direct or Unchecked | Read-Only or Manual Forms | +| **In-Place Cell Editing** | **Live In-Grid Editing + Staged Commit** | Often Direct or Unchecked | Read-Only or Manual Forms | +| **Command Palette** | **Ctrl/Cmd+P + Quick Switcher (Ctrl/Cmd+K)** | Varies / Buried Menus | Limited by Browser Shortcuts | | **Multi-Tab SQL Sessions** | **Yes (Isolated Sessions)** | Varies | Limited by Tab Lifecycles | | **Local SQLite File Association** | **Native (.db, .sqlite, .sqlite3)** | Partial | Requires Server Upload | | **Complex Cell Inspector** | **JSON / XML / HTML / Hex-Binary** | Basic String View | Basic Textarea | @@ -173,7 +180,7 @@ flutter build linux --release ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ Querya Desktop Shell │ -│ (bitsdojo_window, Multi-Tab Workspaces, Command Router) │ +│ (bitsdojo_window, Multi-Tab Workspaces, Command Palette Ctrl+P) │ ├───────────────────────────────────┬─────────────────────────────────────┤ │ UI & Presentation │ State & Sessions │ │ • 2D Virtual Data Grid │ • Connection Session Pool │ diff --git a/docs/README.md b/docs/README.md index f47d50be..1cbc579d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,6 +33,7 @@ Index of Querya Desktop documentation, grouped by audience. - [Theme parser implementation plan](theme-parser-implementation-tasks.md) — task breakdown and architecture. - [Theme parser GitHub issues](theme-parser-github-issues.md) — issue templates for epic #96–#125. - [Marketplace extensions spec](market-tech.md) — extensions manager and marketplace integration. +- [UI Parity & Command Palette GitHub issues](ui-parity-and-command-palette-issues.md) — issue templates for UI consistency, status bar, and command palette. ## Archive diff --git a/docs/perf-baseline.md b/docs/perf-baseline.md index feaa6260..e7125e2a 100644 --- a/docs/perf-baseline.md +++ b/docs/perf-baseline.md @@ -4,7 +4,7 @@ Use this checklist once per milestone so timeline comparisons stay meaningful. R 1. **Open DevTools → Performance** and start recording. 2. **Modal**: open any screen that uses `showAppDialog`; stop recording; note frame build/raster time around the transition. -3. **Connections tree**: expand/collapse a folder and a DB branch; note jank spikes. +3. **Connections tree**: expand/collapse a folder and a DB branch; note jank spikes. For a full 120 Hz runbook see [Connections sidebar tree @ 120 Hz](#connections-sidebar-tree--120-hz-723--727) below. 4. **Heavy scroll**: PostgreSQL/MySQL table view or Mongo documents list with many rows; scroll quickly for 2–3 seconds. Save a screenshot or export the timeline when filing regressions. After UI changes, repeat the same steps and compare peak frame times and rebuild counts (Widget rebuild stats in DevTools). @@ -40,3 +40,37 @@ Repeat on a **120 Hz** display (budget **≤ 8.3 ms** build+raster). Prefer prof **Cost note:** theme morph rebuilds app-wide InheritedTheme dependents every tick — keep **Animate theme** **off by default** (`ThemeController`); only enable for demos/profile. Never ship it default-on without a 120 Hz timeline. 15. **Split settle**: drag the connections sidebar handle and the SQL/results vertical split with a fling — mid-drag stays 1:1; release may soft-settle. Focus the handle — ring uses motion tokens (not mid-drag animation). +## Connections sidebar tree @ 120 Hz (#723 / #727) + +Repeatable capture for the **native** connections tree (PG / MySQL / SQLite / Redis / Mongo / extension SDUI). Use this before and after tree perf work so regressions show up in DevTools timelines. + +**Setup** + +- **Profile or release** build (not debug). +- **120 Hz** display; frame budget **≤ 8.3 ms** build + raster combined (see § Motion and High-Hz). +- Optional: `--dart-define=QUERYA_REFRESH_OVERLAY=true` to confirm target Hz on screen. +- DevTools → **Performance** (+ **Rebuild stats** when checking selection fan-out). +- Test data: at least one PG connection with a **large** database (many tables in one schema); **3+ connections** expanded in the sidebar when scrolling. + +**Scenarios** (record each separately; note peak frame time and any sustained jank) + +| # | Action | What to watch | +|---|--------|----------------| +| 1 | **Cold expand PG** — connection → Databases → database → schema → **Tables** (large DB) | Expand/collapse spikes; avoid nested shrink-wrap layout storms; large table lists should virtualize (viewport-only builds). | +| 2 | **Outer sidebar scroll** with **3+ connections** expanded (mix drivers if possible) | Scroll the panel `CustomScrollView` for 2–3 s; nested inner scrollers fighting the outer list cause dropped frames. | +| 3 | **Tables filter** — expand Tables, type in the group filter quickly | Debounced filter should not rebuild the whole expanded tree; typing should stay responsive. | +| 4 | **Rapid leaf selection** — 10 consecutive table/view clicks in the same group | Only previous + newly selected rows should rebuild (selection slice builder); avoid full-tree InheritedWidget fan-out. | +| 5 | **Motion Full vs Off** — repeat scenario 1 with **Preferences → Appearance → Motion → Full**, then **Off** | Off should skip height morph on large expands; expand should feel instant without multi-frame `AnimatedSize` on big lists. | + +**Filing results** + +- Attach DevTools Performance screenshots or exported timelines to epic [#723](https://github.com/QueryaHub/Querya-Desktop/issues/723) or the relevant child PR. +- Label captures **baseline** vs **post-fix** (e.g. after #725 selection rebuilds, #724 shrinkWrap removal, #726 expand motion gate). +- **Pass:** scenarios 1–4 stay within the 120 Hz budget during the action (no sustained >8.3 ms frames); scenario 5 shows clearly lower animation cost with Motion Off. + +**Related code** (when investigating regressions) + +- `lib/features/connections/connections_panel.dart` — `lazyConnectionTreeList`, selection `ValueNotifier` / `_ConnectionsTreeSelectionBuilder` +- `lib/core/motion/querya_animated_expand.dart` — large-list expand morph gate +- `lib/core/sdui/sdui_tree_builder.dart` — reference flat virtualized tree for extensions + diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 4e38e2b9..3878819f 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -1,10 +1,10 @@ -# Pre-release checklist (release **0.4.11**) +# Pre-release checklist (release **0.4.16**) -Use this before tagging **`0.4.11`** or running the **Release** workflow. -See [tags-and-releases.md](tags-and-releases.md), [CHANGELOG.md](../CHANGELOG.md), and epic [#341](https://github.com/QueryaHub/Querya-Desktop/issues/341). -Detailed QA track: issue [#344](https://github.com/QueryaHub/Querya-Desktop/issues/344). +Use this before tagging **`0.4.16`** or running the **Release** workflow. +See [tags-and-releases.md](tags-and-releases.md), [CHANGELOG.md](../CHANGELOG.md). +Manual 120 Hz DevTools QA: issue [#739](https://github.com/QueryaHub/Querya-Desktop/issues/739) (does not block this cut). -## Product smoke (manual) — 0.4.11 +## Product smoke (manual) — 0.4.16 - [ ] Fresh profile / empty state: create one connection per supported type (PostgreSQL, MySQL, Redis, MongoDB, SQLite). - [ ] Reopen the app: connections still appear; **connect** succeeds (secrets migrated or loaded from secure store). @@ -16,6 +16,9 @@ Detailed QA track: issue [#344](https://github.com/QueryaHub/Querya-Desktop/issu - [ ] **Extension table view** — open a sandboxed driver table (or fixture); toolbar filter + export present. - [ ] **Updater** — Check for Updates / badge sees Latest Release channel correctly after tag. - [ ] **Fluid shell** — tab strip sliding pill; dialog/dropdown fade-slide; Motion Off snaps (see also [perf-baseline.md](perf-baseline.md) Fluid §). +- [ ] **Table Browser edit** — Postgres/MySQL/SQLite table with a PK: double-click cell, Save via DML preview; view / no-PK stays read-only. +- [ ] **Command Palette** — Ctrl/Cmd+P runs a command; Ctrl/Cmd+K jumps to a table. +- [ ] **Mongo field Save** — expand a document card, tap a non-`_id` field, Save to DB, card reloads. ## Regression smoke (prior releases) @@ -66,14 +69,14 @@ Verify the 0.4.4 motion tokens, smooth animations, and high refresh rate support ## Versioning and release -- [x] `pubspec.yaml` on the release branch is **`0.4.11+N`** (currently **`0.4.11+1`** on `dev` after #346). -- [x] After merge to `main`, confirm any **Auto Version Bump** still yields a **0.4.11+…** product version (do not ship as 0.4.12). -- [x] **Tag** `0.4.11` is placed on the **commit that includes all fixes** you want in binaries (a tag does not auto-include later commits; see [CONTRIBUTING.md](../CONTRIBUTING.md)). -- [x] Run the **Release** workflow from GitHub Actions (see [tags-and-releases.md](tags-and-releases.md)). +- [x] `pubspec.yaml` on the release branch is **`0.4.16+1`**. +- [ ] After merge to `main`, confirm **Auto Version Bump** yields a **0.4.17+…** placeholder (do not ship binaries as 0.4.17). +- [ ] **Tag** `0.4.16` is placed on the **main merge commit that includes `0.4.16+1`** (not the auto-bump commit). +- [ ] Run the **Release** workflow via that tag (see [tags-and-releases.md](tags-and-releases.md)). - [ ] Verify **portable** zips (`*-linux.zip`, `*-windows.zip`, `*-macos.zip`), **installable** artifacts (`*.AppImage`, `*.deb`, `*.rpm`, `*.flatpak`, `*-windows-setup.exe`), and `SHA256SUMS.txt` on the GitHub Release. ## Docs -- [x] [CHANGELOG.md](../CHANGELOG.md) has a dated **`## [0.4.11]`** section for the release (CI copies it into the GitHub Release body). +- [x] [CHANGELOG.md](../CHANGELOG.md) has a dated **`## [0.4.16]`** section for the release (CI copies it into the GitHub Release body). - [x] [security.md](security.md) still matches behavior if storage changed. -- [x] [roadmap.md](roadmap.md) marks 0.4.7–0.4.10 shipped and 0.4.11 as the packaging/next release. +- [x] [roadmap.md](roadmap.md) marks 0.4.16 as this cut and 0.5.0 as next. diff --git a/docs/roadmap.md b/docs/roadmap.md index f4e6ff3f..399cdc97 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,8 +2,8 @@ Living document for planned work. Not a commitment order; adjust as priorities change. -**GitHub Latest Release:** [0.4.11](https://github.com/QueryaHub/Querya-Desktop/releases/tag/0.4.11) (2026-07-27). -**Next patch:** **0.4.11-b** — security + packaging (from mistagged 0.4.11-a) + perf (#414) + UI (#445) + code-review (#463); tag after merge to `main`. +**GitHub Latest Release:** [0.4.15](https://github.com/QueryaHub/Querya-Desktop/releases/tag/0.4.15) (2026-09-06). +**This cut:** **0.4.16** — Table Browser in-place editing, Command Palette, workspace/tree parity, Mongo field Save to DB — [CHANGELOG.md](../CHANGELOG.md) `[0.4.16]`. **Next product release:** **0.5.0** — live Marketplace download and install — see below. ## Theme system @@ -27,7 +27,9 @@ Living document for planned work. Not a commitment order; adjust as priorities c - **Shipped in 0.4.10:** Sandboxed extension runtime (Block E), Plugin RPC bridge (Block C), SDUI form/tree builders, local `.zip`/`.qext` install, Registration/Activation for external database drivers (e.g. ClickHouse), in-app updater — see [CHANGELOG.md](../CHANGELOG.md). - **Shipped in 0.4.11:** Universal UI / SDUI RPC expand, ExtensionTableView, universal export, MySQL/SQLite parity, shell UX (#339), Fluid QueryaMotion (#342) + perf follow-ups (#356), grid/pool/timeout fixes, dual-channel packaging (portable zip + AppImage / `.deb` / Windows setup) — [CHANGELOG.md](../CHANGELOG.md) `[0.4.11]`, [packaging.md](packaging.md), epic [#379](https://github.com/QueryaHub/Querya-Desktop/issues/379). - **0.4.11-a:** changelog prepared; GitHub tag mistargeted onto `0.4.11` — do not treat as a shipped patch. -- **Pending 0.4.11-b:** ships security (#395–#402), Linux `.rpm` / Flatpak / AUR (#386), perf (#414), UI reliability (#445), code-review fixes (#463) — [CHANGELOG.md](../CHANGELOG.md) `[0.4.11-b]`. +- **Shipped in 0.4.11-b:** security (#395–#402), Linux `.rpm` / Flatpak / AUR (#386), perf (#414), UI reliability (#445), code-review fixes (#463) — [CHANGELOG.md](../CHANGELOG.md) `[0.4.11-b]`. +- **Shipped in 0.4.13–0.4.15:** interactive Data Grid, fluid motion, multi-tab SQL, preferences/keymap — [CHANGELOG.md](../CHANGELOG.md). +- **0.4.16 (this cut):** Table Browser in-place DML, Command Palette + Quick Switcher, workspace chrome / connections-tree parity, Mongo per-field Save to DB, updater hardening — [CHANGELOG.md](../CHANGELOG.md) `[0.4.16]`. Manual 120 Hz DevTools sign-off remains [#739](https://github.com/QueryaHub/Querya-Desktop/issues/739). - **Planned 0.5.0:** Marketplace Launch — live download, `sha256` validation, install themes (and later DB drivers) from the network. ## Query history and favorites diff --git a/docs/ui-parity-and-command-palette-issues.md b/docs/ui-parity-and-command-palette-issues.md new file mode 100644 index 00000000..cc0efc62 --- /dev/null +++ b/docs/ui-parity-and-command-palette-issues.md @@ -0,0 +1,344 @@ +# GitHub Issues: Консистентность UI рабочих областей и Командная палитра (Command Palette) + +Формат ниже рассчитан на перенос в GitHub / GitLab Issues или трекер задач (Linear, Jira). Задачи разбиты на два ключевых направления: +1. **Эпик A: UI Parity & Workspace Coherence** — приведение всех СУБД-экранов к единому стандарту, унификация табов и внедрение строки состояния (Status Bar). +2. **Эпик B: Command Palette & Quick Switcher** — внедрение клавиатурно-ориентированной навигации (Obsidian / VS Code style) для мгновенного выполнения команд и поиска таблиц/схем. + +--- + +## Labels + +- `ui` +- `ux` +- `command-palette` +- `parity` +- `workspace` +- `frontend` +- `accessibility` +- `tests` + +--- + +## Milestones / Epics + +- **Epic A — Workspace & Driver UI Parity (Консистентность экранов СУБД)** + - `UI-01`: Унификация переключателей рабочих областей через `QueryaTabStrip` + - `UI-02`: Стандартизация именования вкладок и навигации (`Overview` vs `SQL`) + - `UI-03`: Поддержка 1-Click Return to Object во всех драйверах + - `UI-04`: Создание нижнего статус-бара (`QueryaStatusBar`) и разгрузка TitleBar + - `UI-05`: Иерархические направляющие и оптимизация отступов в дереве `ConnectionsPanel` + +- **Epic B — Command Palette & Quick Switcher (Командная палитра)** + - `CP-01`: Архитектура реестра команд приложения (`QueryaCommandRegistry`) + - `CP-02`: UI модального окна командной палитры (`CommandPaletteDialog`, `Ctrl/Cmd+P`) + - `CP-03`: Quick Switcher объектов базы данных (`Ctrl/Cmd+O` / `Ctrl/Cmd+K`) + - `CP-04`: Связывание команд с `SqlEditorCommandBridge`, TitleBar и Shortcuts + - `CP-05`: Регистрация команд из манифестов расширений (Extensions SDK Command Contribution) + +--- + +# Epic A — Workspace & Driver UI Parity + +## UI-01 — Унификация таб-баров рабочих областей через QueryaTabStrip + +**Labels:** `ui`, `parity`, `workspace`, `frontend` +**Epic:** A +**Depends on:** none + +### Goal +Заменить разнородные и рукописные реализации переключения вкладок на единый компонент `QueryaTabStrip` во всех рабочих пространствах СУБД. + +### Context +- В `PostgresWorkspaceHome` и `MysqlWorkspaceHome` используется стандартный `QueryaTabStrip`. +- В `ExtensionWorkspaceHome` табы `Server` и `SQL` сверстаны вручную через `material.AnimatedContainer` со своим стилем, hover-эффектами и отступами. +- Это приводит к визуальному дрейфу, разному поведению при изменении тем и рассинхрону анимаций `QueryaMotion`. + +### Implementation +1. В `lib/features/extensions/extension_workspace_home.dart`: + - Заменить блок `...List.generate(2, (i) { ... })` в строках 100–131 на `QueryaTabStrip`. + - Подключить `selectedIndex: _tab` и `onSelected: (index) => unawaited(_selectTab(index))`. +2. Проверить `SqliteWorkspaceHome` на использование токенов `QueryaTabStrip`. +3. Добавить в `test/features/extensions/extension_workspace_home_test.dart` тест на корректный рендеринг `QueryaTabStrip` и переключение вкладок. + +### Acceptance Criteria +- [ ] `ExtensionWorkspaceHome` использует `QueryaTabStrip` без кастомного дублирования кода. +- [ ] Вкладки корректно меняют темы и используют токены `context.workbench`. +- [ ] Юнит- и виджет-тесты проходят без регрессий. + +--- + +## UI-02 — Стандартизация именования вкладок и навигации (Parity Standard) + +**Labels:** `ui`, `ux`, `parity` +**Epic:** A +**Depends on:** `UI-01` + +### Goal +Привести текстовые метрики и структуру вкладок рабочих областей всех драйверов к единому словарю: **`Overview`** (сводка/метрики) и **`SQL`** (консоль запросов). + +### Context +- PostgreSQL и MySQL используют метку `Server`. +- SQLite использует метку `Overview` (так как локальный файл — это не сервер). +- Extensions используют `Server` даже для встроенных файловых баз (например, DuckDB/LibSQL). +- Разнобой терминов сбивает пользователей при переключении между вкладками. + +### Implementation +1. Стандартизировать названия вкладок первого уровня: + - Если драйвер серверный (PostgreSQL, MySQL): `Overview` (или `Server Stats` с тултипом) / `SQL`. + - Рекомендуемый общий стандарт для всех: `Overview` и `SQL`. +2. Обновить: + - `lib/features/postgresql/postgres_workspace_home.dart` + - `lib/features/mysql/mysql_workspace_home.dart` + - `lib/features/extensions/extension_workspace_home.dart` + - `lib/features/sqlite/sqlite_workspace_home.dart` +3. Обновить текстовые ожидания в тестах (`find.text('Server')` → `find.text('Overview')`). + +### Acceptance Criteria +- [ ] Во всех реляционных и плагинных СУБД верхние табы именуются единообразно: `Overview` и `SQL`. +- [ ] Соответствующие тесты обновлены и успешно выполняются. + +--- + +## UI-03 — Поддержка 1-Click Return to Object во всех драйверах + +**Labels:** `ui`, `ux`, `workspace` +**Epic:** A +**Depends on:** `UI-02` + +### Goal +Реализовать кнопку быстрого возврата к последней открытой таблице/представлению (`Return to `) для драйверов расширений (`ExtensionWorkspaceHome`), MongoDB и Redis. + +### Context +- В `PostgresWorkspaceHome`, `MysqlWorkspaceHome` и `SqliteWorkspaceHome` реализован удобный паттерн: если пользователь перешел с таблицы в корень соединения (SQL или Server Stats), в шапке появляется кнопка `Return to users` со значком таблицы, позволяющая в один клик вернуться к данным. +- В `ExtensionWorkspaceHome` и NoSQL данный механизм отсутствует, из-за чего пользователю приходится заново раскрывать дерево в сайдбаре. + +### Implementation +1. В `lib/features/extensions/extension_workspace_home.dart`: + - Добавить параметры `lastSelectedObject` и `onRestoreLastSelectedObject`. + - Встроить кнопку `OutlineButton(leading: Icon(Icons.table_chart_outlined), child: Text('Return to ${widget.lastSelectedObject!.name}'))`. +2. В `lib/features/main_screen/workspace_panel.dart` пробросить запомненный `lastSelectedExtensionObject`. +3. Добавить аналогичную кнопку быстрого возврата для MongoDB коллекций в `MongoStatsView`. + +### Acceptance Criteria +- [ ] При переходе из таблицы стороннего драйвера в корень базы отображается кнопка возврата. +- [ ] Нажатие восстанавливает просмотр таблицы и её состояние. + +--- + +## UI-04 — Создание нижнего статус-бара (QueryaStatusBar) и разгрузка TitleBar + +**Labels:** `ui`, `ux`, `workspace`, `frontend` +**Epic:** A +**Depends on:** none + +### Goal +Разгрузить верхний `QueryaWindowTitleBar` и создать современный нижний `QueryaStatusBar` по стандарту Obsidian / IDE, отображающий статус соединения, задержку (ping), счетчики строк и состояние транзакции. + +### Context +- В текущем интерфейсе `QueryaWindowTitleBar` перегружен меню, кнопками сайдбара, индикаторами `Read-only` и кнопками закрытия. +- Внизу окна отсутствует статус-бар, из-за чего пользователю негде увидеть глобальное состояние (например: `Connected: postgres-prod (12ms)`, `Encoding: UTF-8`, `Autocommit: ON`, `Selected: 12 rows`). + +### Implementation +1. Создать `lib/features/main_screen/querya_status_bar.dart`: + - Фиксированная высота: 26px (масштабируется через `context.scaled`). + - Фоновый цвет: `context.workbench.surface` с верхней границей `borderSubtle.withValues(alpha: 0.22)`. + - Левая секция: + - Индикатор статуса соединения (зеленый / серый dot). + - Имя активного подключения и драйвер (`postgresql://localhost:5432`). + - Иконка `Read-only` (замочек), если включен безопасный режим. + - Центральная секция: + - Индикатор активных фоновых операций (Spinner при загрузке схемы или выполнении тяжелого запроса). + - Правая секция: + - Время выполнения последнего запроса (например: `48 ms`). + - Количество строк/колонок активного дата-грида (`500 rows, 14 cols`). + - Кнопка вызова настроек масштаба или переключателя темы. +2. Подключить `QueryaStatusBar` в `lib/features/main_screen/main_screen.dart` в нижней части `WindowBorder`. +3. Убрать избыточный бейдж `QueryaReadOnlyBadge` из правого угла `QueryaWindowTitleBar` (оставив индикатор в статус-баре). + +### Acceptance Criteria +- [ ] Внизу приложения отображается компактный статус-бар. +- [ ] Статус-бар корректно обновляется при смене соединений, выполнении запросов и смене read-only режима. +- [ ] TitleBar выглядит разгруженным и сбалансированным. + +--- + +## UI-05 — Иерархические направляющие и отступы в ConnectionsPanel + +**Labels:** `ui`, `ux`, `accessibility` +**Epic:** A +**Depends on:** none + +### Goal +Улучшить визуальную читаемость глубоко вложенных деревьев объектов БД (Connection → Database → Schema → Tables → Views → Columns) за счет тонких направляющих линий (indentation guide lines) и сбалансированных отступов. + +### Context +- При раскрытии множества схем и папок в `ConnectionsPanel` строки деревьев сливаются по горизонтали, пользователю сложно отследить принадлежность таблицы к схеме. +- Отступ заголовка `SERVERS` (`fromLTRB(20, 24, 16, 12)`) тратит драгоценное вертикальное пространство на небольших экранах ноутбуков. + +### Implementation +1. В `lib/features/connections/connections_panel.dart`: + - Оптимизировать верхний паддинг заголовка `SERVERS`: уменьшить `top: 24` до `top: 12`. + - Внедрить `QueryaTreeIndentGuide`: тонкие вертикальные полупрозрачные линии (1px `borderSubtle.withValues(alpha: 0.15)`) на каждом уровне отступа `level * 16px`. +2. Стилизовать счетчик найденных элементов в поиске как деликатный chip-бейдж. + +### Acceptance Criteria +- [ ] В дереве отображаются деликатные вертикальные направляющие для вложенных узлов. +- [ ] Сайдбар вмещает на 1–2 строки больше за счет оптимизации отступов шапки. + +--- + +# Epic B — Command Palette & Quick Switcher + +## CP-01 — Архитектура реестра команд приложения (QueryaCommandRegistry) + +**Labels:** `command-palette`, `frontend`, `architecture` +**Epic:** B +**Depends on:** none + +### Goal +Создать централизованный сервис `QueryaCommandRegistry` для регистрации, управления и фильтрации действий приложения с поддержкой категорий, хоткеев, иконок и предикатов доступности (`isEnabled`). + +### Context +- Сейчас действия размазаны по `main_screen.dart` (`_handleGlobalKeyEvent`, `CallbackShortcuts`), `Menubar` в `querya_window_title_bar.dart` и `SqlEditorCommandBridge`. +- Чтобы внедрить Command Palette как в Obsidian или VS Code, необходим единый реестр, знающий обо всех действиях в системе и их текущем статусе (активно / неактивно). + +### Implementation +1. Создать `lib/core/actions/querya_command.dart`: + ```dart + class QueryaCommand { + final String id; + final String title; + final String? category; // 'Connection', 'SQL', 'View', 'Tools' + final IconData? icon; + final String? shortcutLabel; // 'Ctrl+Enter', 'Ctrl+N' + final bool Function(BuildContext context)? isEnabled; + final void Function(BuildContext context) execute; + } + ``` +2. Создать синглтон/сервис `lib/core/actions/querya_command_registry.dart`: + - Регистрация базовых команд (новое соединение, открыть SQL, запустить запрос, переключить сайдбар, открыть настройки, переключить тему, очистить историю, экспортировать в CSV). + - Метод `List getAvailableCommands(BuildContext context)`. + - Поиск по ключевым словам и алиасам (например: запрос `dark` находит команду `Toggle Dark/Light Theme`). +3. Покрыть реестр тестами в `test/core/actions/querya_command_registry_test.dart`. + +### Acceptance Criteria +- [ ] `QueryaCommandRegistry` регистрирует системные и контекстные команды. +- [ ] Команды проверяют предикат `isEnabled` (например, `Execute Query` доступен только при активном редакторе). + +--- + +## CP-02 — UI модального окна командной палитры (Ctrl/Cmd+P) + +**Labels:** `command-palette`, `ui`, `ux`, `frontend` +**Epic:** B +**Depends on:** `CP-01` + +### Goal +Разработать всплывающее диалоговое окно Command Palette на базе `shadcn_flutter Command / Dialog`, вызываемое глобальным сочетанием клавиш Ctrl+P (или Cmd+P на macOS). + +### Context +- Пользователи Obsidian, VS Code и Sublime Text ожидают возможность выполнить любое действие системы без мыши. +- Палитра должна мгновенно открываться, поддерживать навигацию стрелками /, нечеткий поиск (fuzzy search) и закрытие по Esc. + +### Implementation +1. Создать `lib/features/command_palette/command_palette_dialog.dart`: + - Использовать `shadcn_flutter` компоненты: `Command`, `CommandInput`, `CommandList`, `CommandGroup`, `CommandItem`. + - Интегрировать анимацию появления через `QueryaFadeSlide`. + - Отображать категорию серым текстом, иконку действия и бейдж сочетания клавиш справа. +2. В `lib/features/main_screen/main_screen.dart`: + - Зарегистрировать глобальный шорткат Ctrl+P / Cmd+P (а также русскую раскладку З). + - Обработчик вызывает `showCommandPalette(context)`. +3. При выборе команды: закрывать диалог и вызывать `command.execute(context)`. + +### Acceptance Criteria +- [ ] Нажатие Ctrl+P / Cmd+P в любом месте приложения открывает палитру команд. +- [ ] Ввод текста фильтрует команды с подсветкой совпадений. +- [ ] Нажатие Enter выполняет команду и закрывает палитру; Esc отменяет выбор. +- [ ] Написаны виджет-тесты в `test/features/command_palette/command_palette_dialog_test.dart`. + +--- + +## CP-03 — Quick Switcher объектов базы данных (Ctrl/Cmd+O / Ctrl+K) + +**Labels:** `command-palette`, `ui`, `ux`, `workspace` +**Epic:** B +**Depends on:** `CP-02` + +### Goal +Создать инструмент быстрого перехода (Quick Switcher) к любой таблице, представлению или сохраненному запросу по сочетанию клавиш Ctrl+O / Cmd+O (или Ctrl+K). + +### Context +- В Obsidian Ctrl+O открывает Quick Switcher заметок. +- В СУБД с сотнями таблиц поиск нужной таблицы в дереве вручную занимает слишком много времени. Быстрый нечеткий поиск по кэшу схемы драматически повышает скорость работы. + +### Implementation +1. В `lib/features/command_palette/quick_switcher_dialog.dart`: + - Источник данных: кэш объектов активного соединения (таблицы, вьюхи, процедуры, коллекции). + - Элемент списка: имя таблицы, схема/база данных, иконка типа объекта (таблица / вью / коллекция), количество строк (если известно). +2. Действие при выборе: + - Мгновенно открывает вкладку таблицы в Grid-режиме. +3. Добавить шорткат Ctrl+K / Cmd+K (альтернатива: переключение режимов внутри Command Palette через префикс `#` или `@`). + +### Acceptance Criteria +- [ ] Пользователь может нажать Ctrl+K, набрать `users` и нажать Enter для немедленного перехода к таблице `public.users`. +- [ ] Поиск не блокирует UI-поток при наличии сотен объектов в схеме. + +--- + +## CP-04 — Связывание команд с существующими подсистемами приложения + +**Labels:** `command-palette`, `architecture`, `tests` +**Epic:** B +**Depends on:** `CP-01`, `CP-02` + +### Goal +Связать реестр команд со всеми существующими экшенами и меню (`SqlEditorCommandBridge`, `MainScreenWorkspaceState`, экспорт, управление соединениями). + +### Context +- Чтобы палитра не была оторвана от функционала, она должна вызывать те же обработчики, что и пункты верхнего Menubar и кнопки тулбаров. + +### Implementation +1. Зарегистрировать в `QueryaCommandRegistry`: + - **Workspace**: `Toggle Sidebar`, `Close Active Workspace`, `Return to Home`. + - **Connection**: `New Connection`, `Connect`, `Disconnect`, `Reconnect / Invalidate`, `Toggle Read-Only Mode`. + - **SQL Editor**: `Execute Query`, `New Query Tab`, `Close Current Tab`, `Format SQL Query`, `Clear Editor`. + - **Data Grid**: `Toggle Quick Filter`, `Inspect Cell Panel`, `Copy as CSV`, `Copy as JSON`, `Copy as Markdown`, `Save Export File`, `Apply Staged Edits (Save)`. + - **Application**: `Open Preferences`, `Open Extension Manager`, `Check for Updates`, `Welcome Tour`, `About Querya`. +2. Добавить юнит-тесты на связку реестра и вызовы команд. + +### Acceptance Criteria +- [ ] Все ключевые команды приложения доступны через Command Palette. +- [ ] Команды контекстно активны только тогда, когда доступна соответствующая рабочая область. + +--- + +## CP-05 — Регистрация команд из манифестов расширений (Extensions Contribution) + +**Labels:** `command-palette`, `extensions`, `architecture` +**Epic:** B +**Depends on:** `CP-01` + +### Goal +Предоставить сторонним драйверам и плагинам возможность декларировать собственные команды в `manifest.json`, которые автоматически попадают в `QueryaCommandRegistry` и Command Palette. + +### Context +- В рамках блоков архитектуры плагинов (Roadmap 0.5.0) расширениям требуется точка входа для пользовательских действий (например, `ClickHouse: Show Cluster Status`, `DuckDB: Export Parquet`). + +### Implementation +1. Расширить модель `ExtensionManifest` полем: + ```json + "contributes": { + "commands": [ + { + "id": "ext.clickhouse.cluster_status", + "title": "ClickHouse: Show Cluster Status", + "category": "ClickHouse" + } + ] + } + ``` +2. При активации плагина регистрировать его команды в `QueryaCommandRegistry` с перенаправлением вызова в `PluginRpcBridge`. + +### Acceptance Criteria +- [ ] Установленные расширения отображают свои команды в Command Palette. +- [ ] При отключении расширения команды удаляются из реестра. diff --git a/lib/app/app.dart b/lib/app/app.dart index 2e6b16c2..832ae152 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,3 +1,7 @@ +import 'dart:async'; + +import 'package:querya_desktop/core/actions/querya_command_registry.dart'; +import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; import 'package:querya_desktop/core/motion/querya_motion_controller.dart'; @@ -15,6 +19,8 @@ class QueryaApp extends StatelessWidget { @override Widget build(BuildContext context) { + QueryaCommandRegistry.instance.ensureCoreDefaults(); + unawaited(LocalExtensionRegistry.instance.load()); final themeController = ThemeController.instance; final uiScaleController = UiScaleController.instance; final motionController = QueryaMotionController.instance; diff --git a/lib/core/actions/data_grid_command_bridge.dart b/lib/core/actions/data_grid_command_bridge.dart new file mode 100644 index 00000000..affe3a01 --- /dev/null +++ b/lib/core/actions/data_grid_command_bridge.dart @@ -0,0 +1,58 @@ +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/shared/services/data_export_service.dart'; + +/// Lets the Command Palette hit the same ResultsTab handlers as the toolbar. +class DataGridCommandBridge { + DataGridCommandBridge._(); + + static final DataGridCommandBridge instance = DataGridCommandBridge._(); + + VoidCallback? _onToggleFilter; + VoidCallback? _onToggleInspector; + void Function(DataExportFormat format)? _onCopy; + VoidCallback? _onSaveExport; + VoidCallback? _onApplyStaged; + bool Function()? _canApplyStaged; + var _hasRows = false; + + bool get isActive => _onToggleFilter != null; + bool get canCopy => isActive && _hasRows; + bool get canApplyStaged => _canApplyStaged?.call() ?? false; + + void register({ + required VoidCallback onToggleFilter, + required VoidCallback onToggleInspector, + required void Function(DataExportFormat format) onCopy, + required VoidCallback onSaveExport, + VoidCallback? onApplyStaged, + bool Function()? canApplyStaged, + bool hasRows = false, + }) { + _onToggleFilter = onToggleFilter; + _onToggleInspector = onToggleInspector; + _onCopy = onCopy; + _onSaveExport = onSaveExport; + _onApplyStaged = onApplyStaged; + _canApplyStaged = canApplyStaged; + _hasRows = hasRows; + } + + void unregister() { + _onToggleFilter = null; + _onToggleInspector = null; + _onCopy = null; + _onSaveExport = null; + _onApplyStaged = null; + _canApplyStaged = null; + _hasRows = false; + } + + void invokeToggleFilter() => _onToggleFilter?.call(); + void invokeToggleInspector() => _onToggleInspector?.call(); + void invokeCopy(DataExportFormat format) => _onCopy?.call(format); + void invokeSaveExport() => _onSaveExport?.call(); + void invokeApplyStaged() => _onApplyStaged?.call(); + + @visibleForTesting + void resetForTest() => unregister(); +} diff --git a/lib/core/actions/querya_command.dart b/lib/core/actions/querya_command.dart new file mode 100644 index 00000000..2bc1c074 --- /dev/null +++ b/lib/core/actions/querya_command.dart @@ -0,0 +1,57 @@ +import 'package:flutter/widgets.dart'; + +/// A palette / menubar action with optional enablement and search aliases. +@immutable +class QueryaCommand { + const QueryaCommand({ + required this.id, + required this.title, + required this.execute, + this.category, + this.icon, + this.shortcutLabel, + this.aliases = const [], + this.isEnabled, + this.sourceExtensionId, + }); + + /// Stable id (`querya.sql.execute`, `ext.clickhouse.cluster_status`). + final String id; + + final String title; + final String? category; + final IconData? icon; + + /// Display-only accelerator (`Ctrl+Enter`). + final String? shortcutLabel; + + /// Extra search terms (`dark` → toggle theme). + final List aliases; + + /// When null, the command is always available. + final bool Function(BuildContext context)? isEnabled; + + /// Package id when this command came from an extension manifest (CP-05). + final String? sourceExtensionId; + + final void Function(BuildContext context) execute; + + bool enabledIn(BuildContext context) => isEnabled?.call(context) ?? true; + + /// Case-insensitive match on title, category, aliases, and the last id segment. + bool matches(String query) { + final q = query.trim().toLowerCase(); + if (q.isEmpty) return true; + final idTail = id.split('.').last.toLowerCase(); + final fields = [ + title.toLowerCase(), + id.toLowerCase(), + idTail, + if (category != null) category!.toLowerCase(), + ...aliases.map((a) => a.toLowerCase()), + ]; + return q.split(RegExp(r'\s+')).every( + (part) => fields.any((field) => field.contains(part)), + ); + } +} diff --git a/lib/core/actions/querya_command_host.dart b/lib/core/actions/querya_command_host.dart new file mode 100644 index 00000000..e0542661 --- /dev/null +++ b/lib/core/actions/querya_command_host.dart @@ -0,0 +1,62 @@ +import 'package:flutter/widgets.dart'; +import 'package:querya_desktop/core/actions/querya_schema_object.dart'; + +/// Optional shell hooks for commands that cannot run from [BuildContext] alone. +class QueryaCommandHost extends InheritedWidget { + const QueryaCommandHost({ + super.key, + this.onToggleSidebar, + this.onNewConnection, + this.onShowQuickSwitcher, + this.onOpenSchemaObject, + this.onGoHome, + this.onCloseWorkspace, + this.onConnect, + this.onDisconnect, + this.onReconnect, + this.onToggleReadOnly, + this.onOpenWelcomeTour, + this.selectedConnectionId, + required super.child, + }); + + final VoidCallback? onToggleSidebar; + final VoidCallback? onNewConnection; + + /// Opens Quick Switcher; [query] is a prefilled filter (`#users` remainder). + final void Function(String query)? onShowQuickSwitcher; + + /// Same path as tapping a tree leaf (grid / explorer). + final void Function(QueryaSchemaObject object)? onOpenSchemaObject; + + final VoidCallback? onGoHome; + final VoidCallback? onCloseWorkspace; + final VoidCallback? onConnect; + final VoidCallback? onDisconnect; + final VoidCallback? onReconnect; + final VoidCallback? onToggleReadOnly; + final VoidCallback? onOpenWelcomeTour; + + /// Workspace-selected connection, used to target extension commands (CP-05). + final int? selectedConnectionId; + + static QueryaCommandHost? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + @override + bool updateShouldNotify(QueryaCommandHost oldWidget) { + return onToggleSidebar != oldWidget.onToggleSidebar || + onNewConnection != oldWidget.onNewConnection || + onShowQuickSwitcher != oldWidget.onShowQuickSwitcher || + onOpenSchemaObject != oldWidget.onOpenSchemaObject || + onGoHome != oldWidget.onGoHome || + onCloseWorkspace != oldWidget.onCloseWorkspace || + onConnect != oldWidget.onConnect || + onDisconnect != oldWidget.onDisconnect || + onReconnect != oldWidget.onReconnect || + onToggleReadOnly != oldWidget.onToggleReadOnly || + onOpenWelcomeTour != oldWidget.onOpenWelcomeTour || + selectedConnectionId != oldWidget.selectedConnectionId; + } +} diff --git a/lib/core/actions/querya_command_registry.dart b/lib/core/actions/querya_command_registry.dart new file mode 100644 index 00000000..f70e037f --- /dev/null +++ b/lib/core/actions/querya_command_registry.dart @@ -0,0 +1,98 @@ +import 'package:flutter/widgets.dart'; +import 'package:querya_desktop/core/actions/querya_command.dart'; +import 'package:querya_desktop/core/actions/querya_core_commands.dart'; + +/// App-wide command catalog for Command Palette (CP-01) and later menus. +class QueryaCommandRegistry extends ChangeNotifier { + QueryaCommandRegistry._(); + + static final QueryaCommandRegistry instance = QueryaCommandRegistry._(); + + final Map _commands = {}; + var _coreInstalled = false; + + /// Registers built-in commands once. Safe to call from [QueryaApp]. + void ensureCoreDefaults() { + if (_coreInstalled) return; + _coreInstalled = true; + registerAll(queryaCoreCommands()); + } + + void register(QueryaCommand command) { + final existing = _commands[command.id]; + if (existing != null && + existing.sourceExtensionId == null && + command.sourceExtensionId != null) { + return; + } + _commands[command.id] = command; + notifyListeners(); + } + + /// Re-installs any built-in ids that are missing or still owned by an extension. + void restoreMissingCoreCommands() { + var changed = false; + for (final command in queryaCoreCommands()) { + final existing = _commands[command.id]; + if (existing == null || existing.sourceExtensionId != null) { + _commands[command.id] = command; + changed = true; + } + } + _coreInstalled = true; + if (changed) notifyListeners(); + } + + void registerAll(Iterable commands) { + for (final command in commands) { + _commands[command.id] = command; + } + notifyListeners(); + } + + void unregister(String id) { + if (_commands.remove(id) != null) { + notifyListeners(); + } + } + + void unregisterWhere(bool Function(QueryaCommand command) test) { + final before = _commands.length; + _commands.removeWhere((_, command) => test(command)); + if (_commands.length != before) { + notifyListeners(); + } + } + + QueryaCommand? operator [](String id) => _commands[id]; + + List get commands => + List.unmodifiable(_commands.values); + + /// Commands whose [QueryaCommand.isEnabled] is true (or unset). + List getAvailableCommands(BuildContext context) { + ensureCoreDefaults(); + return [ + for (final command in _commands.values) + if (command.enabledIn(context)) command, + ]; + } + + /// Filter [getAvailableCommands] (or all, if [context] is null) by [query]. + List search(String query, {BuildContext? context}) { + ensureCoreDefaults(); + final pool = context == null + ? _commands.values + : getAvailableCommands(context); + if (query.trim().isEmpty) { + return List.from(pool); + } + return [for (final command in pool) if (command.matches(query)) command]; + } + + @visibleForTesting + void resetForTest() { + _commands.clear(); + _coreInstalled = false; + } +} diff --git a/lib/core/actions/querya_core_commands.dart b/lib/core/actions/querya_core_commands.dart new file mode 100644 index 00000000..1633c25a --- /dev/null +++ b/lib/core/actions/querya_core_commands.dart @@ -0,0 +1,323 @@ +import 'package:flutter/material.dart' show Icons; +import 'package:shadcn_flutter/shadcn_flutter.dart' show ThemeMode; +import 'package:querya_desktop/core/actions/data_grid_command_bridge.dart'; +import 'package:querya_desktop/core/actions/querya_command.dart'; +import 'package:querya_desktop/core/actions/querya_command_host.dart'; +import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; +import 'package:querya_desktop/core/theme/theme_controller.dart'; +import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; +import 'package:querya_desktop/features/extensions/presentation/pages/extension_manager_dialog.dart'; +import 'package:querya_desktop/features/help/about_dialog.dart'; +import 'package:querya_desktop/features/onboarding/welcome_tour_dialog.dart'; +import 'package:querya_desktop/features/settings/preferences_dialog.dart'; +import 'package:querya_desktop/features/updater/update_dialog.dart'; +import 'package:querya_desktop/shared/services/data_export_service.dart'; + +/// Built-in commands for Command Palette. Host / bridge actions stay disabled +/// until the matching workspace is mounted (CP-04). +List queryaCoreCommands() { + final sql = SqlEditorCommandBridge.instance; + final grid = DataGridCommandBridge.instance; + return [ + // -- Workspace ---------------------------------------------------------- + QueryaCommand( + id: 'querya.view.toggleSidebar', + title: 'Toggle Sidebar', + category: 'Workspace', + icon: Icons.view_sidebar_outlined, + shortcutLabel: 'Ctrl+B', + aliases: const ['connections', 'panel'], + isEnabled: (context) => + QueryaCommandHost.maybeOf(context)?.onToggleSidebar != null, + execute: (context) => + QueryaCommandHost.maybeOf(context)?.onToggleSidebar?.call(), + ), + QueryaCommand( + id: 'querya.workspace.close', + title: 'Close Active Workspace', + category: 'Workspace', + icon: Icons.close_fullscreen_rounded, + aliases: const ['unselect', 'stats', 'back'], + isEnabled: (context) => + QueryaCommandHost.maybeOf(context)?.onCloseWorkspace != null, + execute: (context) => + QueryaCommandHost.maybeOf(context)?.onCloseWorkspace?.call(), + ), + QueryaCommand( + id: 'querya.workspace.home', + title: 'Return to Home', + category: 'Workspace', + icon: Icons.home_outlined, + shortcutLabel: 'Ctrl+Shift+0', + aliases: const ['start', 'welcome screen'], + isEnabled: (context) => + QueryaCommandHost.maybeOf(context)?.onGoHome != null, + execute: (context) => + QueryaCommandHost.maybeOf(context)?.onGoHome?.call(), + ), + QueryaCommand( + id: 'querya.goto.object', + title: 'Go to Table or View…', + category: 'Workspace', + icon: Icons.search_rounded, + shortcutLabel: 'Ctrl+K', + aliases: const [ + 'quick switcher', + 'goto', + 'table', + 'jump', + '#', + '@', + ], + isEnabled: (context) => + QueryaCommandHost.maybeOf(context)?.onShowQuickSwitcher != null, + execute: (context) => + QueryaCommandHost.maybeOf(context)?.onShowQuickSwitcher?.call(''), + ), + + // -- Connection --------------------------------------------------------- + QueryaCommand( + id: 'querya.connection.new', + title: 'New Connection', + category: 'Connection', + icon: Icons.add_rounded, + shortcutLabel: 'Ctrl+N', + aliases: const ['database', 'connect', 'add'], + execute: (context) { + final host = QueryaCommandHost.maybeOf(context); + if (host?.onNewConnection != null) { + host!.onNewConnection!(); + return; + } + promptCreateConnection(context); + }, + ), + QueryaCommand( + id: 'querya.connection.connect', + title: 'Connect', + category: 'Connection', + icon: Icons.link_rounded, + aliases: const ['open', 'expand'], + isEnabled: (context) => + QueryaCommandHost.maybeOf(context)?.onConnect != null, + execute: (context) => + QueryaCommandHost.maybeOf(context)?.onConnect?.call(), + ), + QueryaCommand( + id: 'querya.connection.disconnect', + title: 'Disconnect', + category: 'Connection', + icon: Icons.link_off_rounded, + aliases: const ['close connection'], + isEnabled: (context) => + QueryaCommandHost.maybeOf(context)?.onDisconnect != null, + execute: (context) => + QueryaCommandHost.maybeOf(context)?.onDisconnect?.call(), + ), + QueryaCommand( + id: 'querya.connection.reconnect', + title: 'Reconnect', + category: 'Connection', + icon: Icons.refresh_rounded, + aliases: const ['invalidate', 'reload connection'], + isEnabled: (context) => + QueryaCommandHost.maybeOf(context)?.onReconnect != null, + execute: (context) => + QueryaCommandHost.maybeOf(context)?.onReconnect?.call(), + ), + QueryaCommand( + id: 'querya.connection.toggleReadOnly', + title: 'Toggle Read-Only Mode', + category: 'Connection', + icon: Icons.lock_outline_rounded, + aliases: const ['readonly', 'write', 'ro'], + isEnabled: (context) => + QueryaCommandHost.maybeOf(context)?.onToggleReadOnly != null, + execute: (context) => + QueryaCommandHost.maybeOf(context)?.onToggleReadOnly?.call(), + ), + + // -- SQL ---------------------------------------------------------------- + QueryaCommand( + id: 'querya.sql.execute', + title: 'Execute Query', + category: 'SQL', + icon: Icons.play_arrow_rounded, + shortcutLabel: 'Ctrl+Enter', + aliases: const ['run', 'query'], + isEnabled: (_) => sql.canExecute, + execute: (_) => sql.invokeExecute(), + ), + QueryaCommand( + id: 'querya.sql.newTab', + title: 'New Query Tab', + category: 'SQL', + icon: Icons.add_box_outlined, + shortcutLabel: 'Ctrl+T', + aliases: const ['tab'], + isEnabled: (_) => sql.isActive, + execute: (_) => sql.invokeNew(), + ), + QueryaCommand( + id: 'querya.sql.closeTab', + title: 'Close Current Tab', + category: 'SQL', + icon: Icons.close_rounded, + shortcutLabel: 'Ctrl+W', + aliases: const ['close tab'], + isEnabled: (_) => sql.canCloseTab, + execute: (_) => sql.invokeCloseTab(), + ), + QueryaCommand( + id: 'querya.sql.format', + title: 'Format SQL Query', + category: 'SQL', + icon: Icons.auto_fix_high_rounded, + shortcutLabel: 'Shift+Alt+F', + aliases: const ['pretty', 'indent'], + isEnabled: (_) => sql.canFormat, + execute: (_) => sql.invokeFormat(), + ), + QueryaCommand( + id: 'querya.sql.clear', + title: 'Clear Editor', + category: 'SQL', + icon: Icons.backspace_outlined, + aliases: const ['empty', 'reset editor'], + isEnabled: (_) => sql.canClear, + execute: (_) => sql.invokeClear(), + ), + + // -- Data Grid ---------------------------------------------------------- + QueryaCommand( + id: 'querya.grid.toggleFilter', + title: 'Toggle Quick Filter', + category: 'Data Grid', + icon: Icons.filter_alt_outlined, + shortcutLabel: 'Ctrl+F', + aliases: const ['search rows', 'filter'], + isEnabled: (_) => grid.isActive, + execute: (_) => grid.invokeToggleFilter(), + ), + QueryaCommand( + id: 'querya.grid.inspectCell', + title: 'Inspect Cell Panel', + category: 'Data Grid', + icon: Icons.data_object_rounded, + aliases: const ['value panel', 'json'], + isEnabled: (_) => grid.isActive, + execute: (_) => grid.invokeToggleInspector(), + ), + QueryaCommand( + id: 'querya.grid.copyCsv', + title: 'Copy as CSV', + category: 'Data Grid', + icon: Icons.table_chart_outlined, + aliases: const ['clipboard csv'], + isEnabled: (_) => grid.canCopy, + execute: (_) => grid.invokeCopy(DataExportFormat.csv), + ), + QueryaCommand( + id: 'querya.grid.copyJson', + title: 'Copy as JSON', + category: 'Data Grid', + icon: Icons.data_object_outlined, + aliases: const ['clipboard json'], + isEnabled: (_) => grid.canCopy, + execute: (_) => grid.invokeCopy(DataExportFormat.json), + ), + QueryaCommand( + id: 'querya.grid.copyMarkdown', + title: 'Copy as Markdown', + category: 'Data Grid', + icon: Icons.code_rounded, + aliases: const ['clipboard md', 'md table'], + isEnabled: (_) => grid.canCopy, + execute: (_) => grid.invokeCopy(DataExportFormat.markdown), + ), + QueryaCommand( + id: 'querya.grid.saveExport', + title: 'Save Export File', + category: 'Data Grid', + icon: Icons.download_rounded, + aliases: const ['export file', 'save csv'], + isEnabled: (_) => grid.canCopy, + execute: (_) => grid.invokeSaveExport(), + ), + QueryaCommand( + id: 'querya.grid.applyStaged', + title: 'Apply Staged Edits', + category: 'Data Grid', + icon: Icons.save_outlined, + shortcutLabel: 'Ctrl+S', + aliases: const ['commit', 'save changes', 'dml'], + isEnabled: (_) => grid.canApplyStaged, + execute: (_) => grid.invokeApplyStaged(), + ), + + // -- Application -------------------------------------------------------- + const QueryaCommand( + id: 'querya.app.preferences', + title: 'Open Preferences', + category: 'Application', + icon: Icons.settings_outlined, + shortcutLabel: 'Ctrl+,', + aliases: ['settings', 'options'], + execute: showPreferencesDialog, + ), + const QueryaCommand( + id: 'querya.app.extensions', + title: 'Open Extension Manager', + category: 'Application', + icon: Icons.extension_outlined, + aliases: ['plugins', 'marketplace'], + execute: showExtensionManagerDialog, + ), + const QueryaCommand( + id: 'querya.app.updates', + title: 'Check for Updates', + category: 'Application', + icon: Icons.system_update_alt_rounded, + aliases: ['upgrade', 'version'], + execute: showUpdateDialog, + ), + QueryaCommand( + id: 'querya.app.welcome', + title: 'Welcome Tour', + category: 'Application', + icon: Icons.auto_awesome_outlined, + shortcutLabel: 'F1', + aliases: const ['tutorial', 'onboarding', 'help'], + execute: (context) { + final host = QueryaCommandHost.maybeOf(context); + if (host?.onOpenWelcomeTour != null) { + host!.onOpenWelcomeTour!(); + return; + } + showWelcomeTourDialog(context); + }, + ), + const QueryaCommand( + id: 'querya.app.about', + title: 'About Querya', + category: 'Application', + icon: Icons.info_outline_rounded, + aliases: ['version', 'license'], + execute: showAboutDialog, + ), + QueryaCommand( + id: 'querya.theme.toggle', + title: 'Toggle Dark/Light Theme', + category: 'Application', + icon: Icons.contrast, + aliases: const ['dark', 'light', 'appearance', 'mode'], + execute: (_) { + final controller = ThemeController.instance; + final next = controller.themeMode == ThemeMode.light + ? ThemeMode.dark + : ThemeMode.light; + controller.setThemeMode(next); + }, + ), + ]; +} diff --git a/lib/core/actions/querya_schema_object.dart b/lib/core/actions/querya_schema_object.dart new file mode 100644 index 00000000..26158a79 --- /dev/null +++ b/lib/core/actions/querya_schema_object.dart @@ -0,0 +1,159 @@ +import 'package:flutter/widgets.dart'; +import 'package:querya_desktop/core/ui/querya_icons.dart'; + +/// Browsable object the Quick Switcher can jump to. +enum QueryaSchemaObjectKind { + table, + view, + materializedView, + collection, +} + +@immutable +class QueryaSchemaObject { + const QueryaSchemaObject({ + required this.id, + required this.name, + required this.kind, + this.schema, + this.database, + this.rowCount, + }); + + factory QueryaSchemaObject.postgres({ + required String database, + required String schema, + required String name, + required QueryaSchemaObjectKind kind, + int? rowCount, + }) { + return QueryaSchemaObject( + id: 'pg:$database:$schema:${kind.name}:$name', + name: name, + schema: schema, + database: database, + kind: kind, + rowCount: rowCount, + ); + } + + factory QueryaSchemaObject.mysql({ + required String database, + required String name, + required QueryaSchemaObjectKind kind, + int? rowCount, + }) { + return QueryaSchemaObject( + id: 'mysql:$database:${kind.name}:$name', + name: name, + database: database, + kind: kind, + rowCount: rowCount, + ); + } + + factory QueryaSchemaObject.sqlite({ + required String name, + required QueryaSchemaObjectKind kind, + int? rowCount, + }) { + return QueryaSchemaObject( + id: 'sqlite:${kind.name}:$name', + name: name, + kind: kind, + rowCount: rowCount, + ); + } + + factory QueryaSchemaObject.mongo({ + required String database, + required String name, + int? rowCount, + }) { + return QueryaSchemaObject( + id: 'mongo:$database:$name', + name: name, + database: database, + kind: QueryaSchemaObjectKind.collection, + rowCount: rowCount, + ); + } + + factory QueryaSchemaObject.extension({ + required String database, + required String name, + QueryaSchemaObjectKind kind = QueryaSchemaObjectKind.table, + }) { + return QueryaSchemaObject( + id: 'ext:$database:${kind.name}:$name', + name: name, + database: database, + kind: kind, + ); + } + + final String id; + final String name; + final String? schema; + final String? database; + final QueryaSchemaObjectKind kind; + final int? rowCount; + + String get qualifiedName { + if (schema != null && schema!.isNotEmpty) return '$schema.$name'; + if (database != null && database!.isNotEmpty) return '$database.$name'; + return name; + } + + String get kindLabel => switch (kind) { + QueryaSchemaObjectKind.table => 'Table', + QueryaSchemaObjectKind.view => 'View', + QueryaSchemaObjectKind.materializedView => 'Materialized view', + QueryaSchemaObjectKind.collection => 'Collection', + }; + + IconData get icon => switch (kind) { + QueryaSchemaObjectKind.table => QueryaIcons.tableLeaf, + QueryaSchemaObjectKind.view => QueryaIcons.viewLeaf, + QueryaSchemaObjectKind.materializedView => + QueryaIcons.materializedViewLeaf, + QueryaSchemaObjectKind.collection => QueryaIcons.database, + }; + + /// Fields the switcher matches against (`users` → `public.users`). + String get searchHaystack => + '$name $qualifiedName ${kind.name} $kindLabel ${database ?? ''} ${schema ?? ''}'; + + @override + bool operator ==(Object other) => + identical(this, other) || other is QueryaSchemaObject && other.id == id; + + @override + int get hashCode => id.hashCode; +} + +/// In-memory substring filter. Works on a cached snapshot — never I/O. +/// +/// Top-level so large lists can run via [compute] without capturing the UI +/// isolate. Caps [limit] so the list stays cheap to build. +List filterSchemaObjects( + List objects, + String query, { + int limit = 80, +}) { + if (objects.isEmpty || limit <= 0) return const []; + final q = query.trim().toLowerCase(); + if (q.isEmpty) { + return objects.length <= limit ? objects : objects.sublist(0, limit); + } + final parts = q.split(RegExp(r'\s+')); + final hits = []; + for (final object in objects) { + final hay = object.searchHaystack.toLowerCase(); + if (parts.every(hay.contains)) { + hits.add(object); + if (hits.length >= limit) break; + } + } + return hits; +} diff --git a/lib/core/actions/querya_schema_object_cache.dart b/lib/core/actions/querya_schema_object_cache.dart new file mode 100644 index 00000000..8e7e6aae --- /dev/null +++ b/lib/core/actions/querya_schema_object_cache.dart @@ -0,0 +1,62 @@ +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/actions/querya_schema_object.dart'; + +/// In-memory schema snapshot per connection / database. +/// +/// Tree loaders [merge] as they fetch; the Quick Switcher [peek]s first and +/// only hits the network when a scope is empty. +class QueryaSchemaObjectCache { + QueryaSchemaObjectCache._(); + + static final QueryaSchemaObjectCache instance = QueryaSchemaObjectCache._(); + + final Map> _scopes = {}; + + static String scopePostgres(String database) => 'pg:$database'; + static String scopeMysql(String database) => 'mysql:$database'; + static String scopeSqlite() => 'sqlite'; + static String scopeMongo(String database) => 'mongo:$database'; + static String scopeExtension() => 'ext'; + + static String key(int connectionId, String scope) => '$connectionId|$scope'; + + List? peek(int connectionId, String scope) { + final items = _scopes[key(connectionId, scope)]; + if (items == null) return null; + return List.unmodifiable(items); + } + + void put( + int connectionId, + String scope, + List objects, + ) { + final copy = List.from(objects) + ..sort((a, b) => a.qualifiedName.compareTo(b.qualifiedName)); + _scopes[key(connectionId, scope)] = copy; + } + + /// Upserts [objects] into an existing snapshot (tree folder loads). + void merge( + int connectionId, + String scope, + List objects, + ) { + if (objects.isEmpty) return; + final existing = _scopes[key(connectionId, scope)] ?? const []; + final byId = { + for (final object in existing) object.id: object, + }; + for (final object in objects) { + byId[object.id] = object; + } + put(connectionId, scope, byId.values.toList()); + } + + void invalidate(int connectionId) { + _scopes.removeWhere((k, _) => k.startsWith('$connectionId|')); + } + + @visibleForTesting + void resetForTest() => _scopes.clear(); +} diff --git a/lib/core/actions/querya_schema_object_loader.dart b/lib/core/actions/querya_schema_object_loader.dart new file mode 100644 index 00000000..c2680c08 --- /dev/null +++ b/lib/core/actions/querya_schema_object_loader.dart @@ -0,0 +1,220 @@ +import 'package:querya_desktop/core/actions/querya_schema_object.dart'; +import 'package:querya_desktop/core/actions/querya_schema_object_cache.dart'; +import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/core/database/mysql_service.dart'; +import 'package:querya_desktop/core/database/postgres_service.dart'; +import 'package:querya_desktop/core/database/sqlite_service.dart'; +import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; +import 'package:querya_desktop/core/sdui/sdui_tree_schema.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +const _extensionTableLikeKinds = { + 'table', + 'view', + 'dict', + 'dictionary', + 'materialized-view', + 'mv', +}; + +/// Loads browsable objects once per connection scope, then serves the cache. +class QueryaSchemaObjectLoader { + QueryaSchemaObjectLoader._(); + + /// [database] is the PG / MySQL / extension catalog. [mongoDatabase] is the + /// active Mongo DB (collections live there). + static Future> load({ + required ConnectionRow? connection, + String? database, + String? mongoDatabase, + }) async { + if (connection == null || connection.id == null) { + return const []; + } + final id = connection.id!; + final type = connection.type.toLowerCase(); + final cache = QueryaSchemaObjectCache.instance; + + switch (type) { + case 'postgres': + case 'postgresql': + final db = (database ?? connection.databaseName)?.trim(); + if (db == null || db.isEmpty) return const []; + final scope = QueryaSchemaObjectCache.scopePostgres(db); + final cached = cache.peek(id, scope); + if (cached != null && cached.isNotEmpty) return cached; + final fetched = await _loadPostgres(connection, db); + cache.put(id, scope, fetched); + return fetched; + case 'mysql': + final db = (database ?? connection.databaseName)?.trim(); + if (db == null || db.isEmpty) return const []; + final scope = QueryaSchemaObjectCache.scopeMysql(db); + final cached = cache.peek(id, scope); + if (cached != null && cached.isNotEmpty) return cached; + final fetched = await _loadMysql(connection, db); + cache.put(id, scope, fetched); + return fetched; + case 'sqlite': + final cached = cache.peek(id, QueryaSchemaObjectCache.scopeSqlite()); + if (cached != null && cached.isNotEmpty) return cached; + final fetched = await _loadSqlite(connection); + cache.put(id, QueryaSchemaObjectCache.scopeSqlite(), fetched); + return fetched; + case 'mongodb': + final db = (mongoDatabase ?? connection.databaseName)?.trim(); + if (db == null || db.isEmpty) return const []; + final scope = QueryaSchemaObjectCache.scopeMongo(db); + final cached = cache.peek(id, scope); + if (cached != null && cached.isNotEmpty) return cached; + final fetched = await _loadMongo(connection, db); + cache.put(id, scope, fetched); + return fetched; + default: + if (!connection.isExtensionDriver) return const []; + final scope = QueryaSchemaObjectCache.scopeExtension(); + final cached = cache.peek(id, scope); + if (cached != null && cached.isNotEmpty) return cached; + final fetched = await _loadExtension(connection); + cache.put(id, scope, fetched); + return fetched; + } + } + + static Future> _loadPostgres( + ConnectionRow connection, + String database, + ) async { + final lease = await PostgresService.instance.acquire( + connection, + database: database, + mode: PgSessionMode.readOnly, + ); + try { + final rows = await lease.connection.listBrowsableRelations(); + return [ + for (final row in rows) + QueryaSchemaObject.postgres( + database: database, + schema: row.schema, + name: row.name, + kind: switch (row.kind) { + 'view' => QueryaSchemaObjectKind.view, + 'matview' => QueryaSchemaObjectKind.materializedView, + _ => QueryaSchemaObjectKind.table, + }, + ), + ]; + } finally { + lease.release(); + } + } + + static Future> _loadMysql( + ConnectionRow connection, + String database, + ) async { + final lease = await MysqlService.instance.acquire( + connection, + database: database, + mode: MysqlSessionMode.readOnly, + ); + try { + final tables = await lease.connection.listTables(schema: database); + final views = await lease.connection.listViews(schema: database); + return [ + for (final name in tables) + QueryaSchemaObject.mysql( + database: database, + name: name, + kind: QueryaSchemaObjectKind.table, + ), + for (final name in views) + QueryaSchemaObject.mysql( + database: database, + name: name, + kind: QueryaSchemaObjectKind.view, + ), + ]; + } finally { + lease.release(); + } + } + + static Future> _loadSqlite( + ConnectionRow connection, + ) async { + final lease = await SqliteService.instance.acquire( + connection, + mode: SqliteSessionMode.readOnly, + ); + try { + final tables = await lease.connection.listTables(); + final views = await lease.connection.listViews(); + return [ + for (final name in tables) + QueryaSchemaObject.sqlite( + name: name, + kind: QueryaSchemaObjectKind.table, + ), + for (final name in views) + QueryaSchemaObject.sqlite( + name: name, + kind: QueryaSchemaObjectKind.view, + ), + ]; + } finally { + lease.release(); + } + } + + static Future> _loadMongo( + ConnectionRow connection, + String database, + ) async { + final conn = await MongoService.instance.ensureConnected(connection); + final names = await conn.listCollections(database); + return [ + for (final name in names) + QueryaSchemaObject.mongo(database: database, name: name), + ]; + } + + static Future> _loadExtension( + ConnectionRow connection, + ) async { + final schema = + await ExtensionDriverSession.instance.getSchemaTree(connection); + return flattenExtensionTree(schema); + } +} + +/// Collects table-like SDUI nodes already present in [schema] (no expand). +List flattenExtensionTree(SduiTreeSchema schema) { + final out = []; + void walk(SduiTreeNode node) { + final parts = node.id.split('.'); + if (parts.length >= 3 && _extensionTableLikeKinds.contains(parts[0])) { + final kind = parts[0] == 'view' || + parts[0] == 'materialized-view' || + parts[0] == 'mv' + ? QueryaSchemaObjectKind.view + : QueryaSchemaObjectKind.table; + out.add( + QueryaSchemaObject.extension( + database: parts[1], + name: parts.sublist(2).join('.'), + kind: kind, + ), + ); + } + for (final child in node.children) { + walk(child); + } + } + + for (final root in schema.roots) { + walk(root); + } + return out; +} diff --git a/lib/core/actions/sql_editor_command_bridge.dart b/lib/core/actions/sql_editor_command_bridge.dart index 116ff908..01182de3 100644 --- a/lib/core/actions/sql_editor_command_bridge.dart +++ b/lib/core/actions/sql_editor_command_bridge.dart @@ -17,6 +17,8 @@ class SqlEditorCommandBridge { VoidCallback? _onCloseTab; VoidCallback? _onNextTab; VoidCallback? _onPrevTab; + VoidCallback? _onFormat; + VoidCallback? _onClear; void Function(String sql, String? filePath, String title)? _onOpenWithContent; SqlEditorPendingAction pendingAction = SqlEditorPendingAction.none; @@ -25,6 +27,8 @@ class SqlEditorCommandBridge { bool get isActive => _onNew != null; bool get canExecute => _onExecute != null; bool get canCloseTab => _onCloseTab != null; + bool get canFormat => _onFormat != null; + bool get canClear => _onClear != null; void register({ required int? connectionId, @@ -35,6 +39,8 @@ class SqlEditorCommandBridge { VoidCallback? onCloseTab, VoidCallback? onNextTab, VoidCallback? onPrevTab, + VoidCallback? onFormat, + VoidCallback? onClear, void Function(String sql, String? filePath, String title)? onOpenWithContent, }) { _ownerConnectionId = connectionId; @@ -45,6 +51,8 @@ class SqlEditorCommandBridge { _onCloseTab = onCloseTab; _onNextTab = onNextTab; _onPrevTab = onPrevTab; + _onFormat = onFormat; + _onClear = onClear; _onOpenWithContent = onOpenWithContent; _flushPending(); } @@ -59,6 +67,8 @@ class SqlEditorCommandBridge { _onCloseTab = null; _onNextTab = null; _onPrevTab = null; + _onFormat = null; + _onClear = null; _onOpenWithContent = null; } @@ -110,6 +120,14 @@ class SqlEditorCommandBridge { _onPrevTab?.call(); } + void invokeFormat() { + _onFormat?.call(); + } + + void invokeClear() { + _onClear?.call(); + } + void openFileWithContent({ required String sql, String? filePath, @@ -161,6 +179,8 @@ class SqlEditorCommandBridge { _onCloseTab = null; _onNextTab = null; _onPrevTab = null; + _onFormat = null; + _onClear = null; _onOpenWithContent = null; _pendingFileContent = null; pendingAction = SqlEditorPendingAction.none; diff --git a/lib/core/actions/sql_script_format.dart b/lib/core/actions/sql_script_format.dart new file mode 100644 index 00000000..2827cd14 --- /dev/null +++ b/lib/core/actions/sql_script_format.dart @@ -0,0 +1,70 @@ +const _kSqlKeywords = { + 'ADD', + 'ALL', + 'ALTER', + 'AND', + 'AS', + 'ASC', + 'BEGIN', + 'BETWEEN', + 'BY', + 'CASE', + 'CAST', + 'COMMIT', + 'CREATE', + 'CROSS', + 'DELETE', + 'DESC', + 'DISTINCT', + 'DROP', + 'ELSE', + 'END', + 'EXCEPT', + 'EXISTS', + 'FROM', + 'FULL', + 'GROUP', + 'HAVING', + 'IN', + 'INNER', + 'INSERT', + 'INTERSECT', + 'INTO', + 'IS', + 'JOIN', + 'LEFT', + 'LIKE', + 'LIMIT', + 'NOT', + 'NULL', + 'OFFSET', + 'ON', + 'OR', + 'ORDER', + 'OUTER', + 'RETURNING', + 'RIGHT', + 'ROLLBACK', + 'SELECT', + 'SET', + 'TABLE', + 'THEN', + 'UNION', + 'UPDATE', + 'USING', + 'VALUES', + 'WHEN', + 'WHERE', + 'WITH', +}; + +final _kSqlWord = RegExp(r"[A-Za-z_][A-Za-z0-9_]*"); + +/// Uppercases SQL keywords. Enough for palette "Format SQL" without a parser. +String formatSqlScript(String sql) { + return sql.replaceAllMapped(_kSqlWord, (match) { + final word = match[0]!; + final upper = word.toUpperCase(); + return _kSqlKeywords.contains(upper) ? upper : word; + }); +} diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart index 3a79f065..87831f55 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -341,6 +341,38 @@ class PostgresConnection { return result.map((row) => row[0] as String).toList(); } + /// Tables, views, and materialized views across user schemas (Quick Switcher). + Future> + listBrowsableRelations() async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to PostgreSQL'); + } + final result = await _conn!.execute( + "SELECT n.nspname::text, c.relname::text, " + "CASE c.relkind " + "WHEN 'r' THEN 'table' " + "WHEN 'p' THEN 'table' " + "WHEN 'v' THEN 'view' " + "WHEN 'm' THEN 'matview' " + "END " + "FROM pg_class c " + "JOIN pg_namespace n ON n.oid = c.relnamespace " + "WHERE c.relkind IN ('r', 'p', 'v', 'm') " + "AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') " + "AND n.nspname NOT LIKE 'pg_temp%' " + "AND n.nspname NOT LIKE 'pg_toast_temp%' " + "ORDER BY 1, 2", + ); + return [ + for (final row in result) + ( + schema: row[0] as String, + name: row[1] as String, + kind: row[2] as String? ?? 'table', + ), + ]; + } + Future> listTables({String schema = 'public'}) async { if (!isConnected || _conn == null) { throw StateError('Not connected to PostgreSQL'); diff --git a/lib/core/extensions/extension_command_sync.dart b/lib/core/extensions/extension_command_sync.dart new file mode 100644 index 00000000..3a18b28b --- /dev/null +++ b/lib/core/extensions/extension_command_sync.dart @@ -0,0 +1,173 @@ +import 'dart:async'; + +import 'package:querya_desktop/core/actions/querya_command.dart'; +import 'package:querya_desktop/core/actions/querya_command_host.dart'; +import 'package:querya_desktop/core/actions/querya_command_registry.dart'; +import 'package:querya_desktop/core/extensions/extension_command_target.dart'; +import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; +import 'package:querya_desktop/core/extensions/models/extension_contributions.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/rpc/json_rpc_stdio_client.dart'; +import 'package:querya_desktop/core/extensions/rpc/plugin_rpc_exceptions.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Pushes [ExtensionManifest.contributedCommands] into [QueryaCommandRegistry]. +class ExtensionCommandSync { + ExtensionCommandSync._(); + + static final ExtensionCommandSync instance = ExtensionCommandSync._(); + + final Set _disabledIds = {}; + List _lastManifests = const []; + + /// Test/DI override. Receives extension id, command id, and host context. + Future Function( + ExtensionManifest manifest, + CommandContribution command, + BuildContext context, + )? invokeOverride; + + /// Test override for toasts (palette is already closed). + void Function(String message)? toastOverride; + + bool isEnabled(String extensionId) => !_disabledIds.contains(extensionId); + + /// Disables (or re-enables) an installed extension's palette commands. + void setEnabled(String extensionId, bool enabled) { + if (enabled) { + _disabledIds.remove(extensionId); + } else { + _disabledIds.add(extensionId); + } + sync(_lastManifests); + } + + /// Replaces all extension-sourced commands with those from [manifests]. + void sync(Iterable manifests) { + _lastManifests = List.from(manifests); + final registry = QueryaCommandRegistry.instance; + registry.ensureCoreDefaults(); + registry.unregisterWhere((command) => command.sourceExtensionId != null); + registry.restoreMissingCoreCommands(); + + for (final manifest in manifests) { + if (!isEnabled(manifest.id)) continue; + for (final contribution in manifest.contributedCommands) { + if (!isAllowedExtensionCommandId(contribution.id)) continue; + registry.register( + QueryaCommand( + id: contribution.id, + title: contribution.title, + category: contribution.category ?? manifest.name, + aliases: contribution.aliases, + sourceExtensionId: manifest.id, + execute: (context) => unawaited( + _invoke(manifest, contribution, context), + ), + ), + ); + } + } + } + + Future _invoke( + ExtensionManifest manifest, + CommandContribution command, + BuildContext context, + ) async { + try { + final override = invokeOverride; + if (override != null) { + await override(manifest, command, context); + return; + } + + final preferred = + QueryaCommandHost.maybeOf(context)?.selectedConnectionId; + final session = ExtensionDriverSession.instance; + final target = session.targetForExtension( + manifest.id, + preferredConnectionId: preferred, + ); + + switch (target.kind) { + case ExtensionCommandTargetKind.none: + _toast( + context, + 'Connect a ${manifest.name} session to run “${command.title}”.', + variant: AppToastVariant.info, + ); + return; + case ExtensionCommandTargetKind.ambiguous: + _toast( + context, + 'Select a ${manifest.name} connection to run “${command.title}”.', + variant: AppToastVariant.info, + ); + return; + case ExtensionCommandTargetKind.ready: + final bridge = session.activeBridgeForExtension( + manifest.id, + preferredConnectionId: target.connectionId, + ); + if (bridge == null) { + _toast( + context, + 'Connect a ${manifest.name} session to run “${command.title}”.', + variant: AppToastVariant.info, + ); + return; + } + await bridge.sendRequest('commands.execute', { + 'id': command.id, + 'commandId': command.id, + 'connectionId': target.connectionId, + }); + } + } catch (error) { + if (!context.mounted) return; + _toast(context, _messageFor(command, error)); + } + } + + void _toast( + BuildContext context, + String message, { + AppToastVariant variant = AppToastVariant.error, + }) { + final override = toastOverride; + if (override != null) { + override(message); + return; + } + if (!context.mounted) return; + showAppToast( + context: context, + message: message, + variant: variant, + ); + } + + static String _messageFor(CommandContribution command, Object error) { + if (error is PluginProtocolTimeoutException) { + return '“${command.title}” timed out.'; + } + if (error is PluginCrashedException || error is PluginDeadlockException) { + return 'The extension plugin crashed while running “${command.title}”.'; + } + if (error is JsonRpcException) { + return '“${command.title}” failed: ${error.message}'; + } + return 'Could not run “${command.title}”.'; + } + + @visibleForTesting + void resetForTest() { + _disabledIds.clear(); + _lastManifests = const []; + invokeOverride = null; + toastOverride = null; + QueryaCommandRegistry.instance + .unregisterWhere((command) => command.sourceExtensionId != null); + } +} diff --git a/lib/core/extensions/extension_command_target.dart b/lib/core/extensions/extension_command_target.dart new file mode 100644 index 00000000..f2e88a20 --- /dev/null +++ b/lib/core/extensions/extension_command_target.dart @@ -0,0 +1,57 @@ +/// Result of picking which live extension session should run a palette command. +enum ExtensionCommandTargetKind { none, ready, ambiguous } + +class ExtensionCommandTarget { + const ExtensionCommandTarget._(this.kind, this.connectionId); + + const ExtensionCommandTarget.none() + : this._(ExtensionCommandTargetKind.none, null); + + const ExtensionCommandTarget.ready(int connectionId) + : this._(ExtensionCommandTargetKind.ready, connectionId); + + const ExtensionCommandTarget.ambiguous() + : this._(ExtensionCommandTargetKind.ambiguous, null); + + final ExtensionCommandTargetKind kind; + final int? connectionId; + + bool get isReady => kind == ExtensionCommandTargetKind.ready; +} + +/// Whether an extension may publish this command id into the palette. +/// +/// Built-ins use `querya.*`. Contributions must use the `ext.` prefix so they +/// cannot clobber Execute / theme / etc. +bool isAllowedExtensionCommandId(String id) { + final trimmed = id.trim(); + if (trimmed.isEmpty) return false; + if (trimmed.startsWith('querya.')) return false; + return trimmed.startsWith('ext.'); +} + +/// Picks a live connection for [extensionId]. +/// +/// Prefers [preferredConnectionId] when that session is live for the same +/// package. A single live session is used even if nothing is selected. +/// Several live sessions without a matching selection are [ambiguous]. +ExtensionCommandTarget resolveExtensionCommandTarget({ + required String extensionId, + required Iterable liveConnectionIds, + required String? Function(int connectionId) extensionIdFor, + int? preferredConnectionId, +}) { + final live = [ + for (final id in liveConnectionIds) + if (extensionIdFor(id) == extensionId) id, + ]; + if (live.isEmpty) return const ExtensionCommandTarget.none(); + if (preferredConnectionId != null && + live.contains(preferredConnectionId)) { + return ExtensionCommandTarget.ready(preferredConnectionId); + } + if (live.length == 1) { + return ExtensionCommandTarget.ready(live.single); + } + return const ExtensionCommandTarget.ambiguous(); +} diff --git a/lib/core/extensions/extension_driver_session.dart b/lib/core/extensions/extension_driver_session.dart index 01455aa3..4e638c7a 100644 --- a/lib/core/extensions/extension_driver_session.dart +++ b/lib/core/extensions/extension_driver_session.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:querya_desktop/core/database/table_schema_meta.dart'; +import 'package:querya_desktop/core/extensions/extension_command_target.dart'; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/extensions/extension_support.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; @@ -33,6 +34,42 @@ class ExtensionDriverSession { bool isConnected(int connectionId) => _bridges[connectionId]?.isStarted == true; + /// Live plugin process for [extensionId], if a connection already started it. + PluginRpcBridge? activeBridgeForExtension( + String extensionId, { + int? preferredConnectionId, + }) { + final target = targetForExtension( + extensionId, + preferredConnectionId: preferredConnectionId, + ); + final id = target.connectionId; + if (id == null) return null; + return _startedBridge(id); + } + + /// Which live session should receive a palette command for [extensionId]. + ExtensionCommandTarget targetForExtension( + String extensionId, { + int? preferredConnectionId, + }) { + return resolveExtensionCommandTarget( + extensionId: extensionId, + liveConnectionIds: [ + for (final entry in _bridges.entries) + if (entry.value.isStarted) entry.key, + ], + extensionIdFor: (id) => _manifests[id]?.id, + preferredConnectionId: preferredConnectionId, + ); + } + + PluginRpcBridge? _startedBridge(int connectionId) { + final bridge = _bridges[connectionId]; + if (bridge == null || !bridge.isStarted) return null; + return bridge; + } + /// Starts the plugin (if needed), injects credentials, and calls `db.connect`. Future ensureConnected(ConnectionRow row) async { final id = row.id; diff --git a/lib/core/extensions/local_extension_registry.dart b/lib/core/extensions/local_extension_registry.dart index aa3a1034..2bbf375a 100644 --- a/lib/core/extensions/local_extension_registry.dart +++ b/lib/core/extensions/local_extension_registry.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; +import 'extension_command_sync.dart'; import 'extension_paths.dart'; import 'models/extension_manifest.dart'; import 'sandbox/sandbox_policy.dart'; @@ -79,6 +80,7 @@ class LocalExtensionRegistry { _manifests = loadedManifests; _loaded = true; + ExtensionCommandSync.instance.sync(_manifests); return manifests; } } diff --git a/lib/core/extensions/models/extension_contributions.dart b/lib/core/extensions/models/extension_contributions.dart index 3531c1f0..b6bfc193 100644 --- a/lib/core/extensions/models/extension_contributions.dart +++ b/lib/core/extensions/models/extension_contributions.dart @@ -84,11 +84,50 @@ class DriverContribution { }; } -/// `contributions` block from an extension manifest. +/// A Command Palette action declared under `contributions.commands` +/// or the VS Code-style `contributes.commands` alias. +class CommandContribution { + const CommandContribution({ + required this.id, + required this.title, + this.category, + this.aliases = const [], + }); + + final String id; + final String title; + final String? category; + final List aliases; + + factory CommandContribution.fromJson(Map json) { + final aliasesRaw = json['aliases']; + return CommandContribution( + id: '${json['id'] ?? ''}'.trim(), + title: '${json['title'] ?? json['id'] ?? ''}'.trim(), + category: json['category'] as String?, + aliases: aliasesRaw is List + ? [for (final a in aliasesRaw) '$a'] + : const [], + ); + } + + Map toJson() => { + 'id': id, + 'title': title, + if (category != null) 'category': category, + if (aliases.isNotEmpty) 'aliases': aliases, + }; +} + +/// `contributions` / `contributes` block from an extension manifest. class ExtensionContributions { - const ExtensionContributions({this.drivers = const []}); + const ExtensionContributions({ + this.drivers = const [], + this.commands = const [], + }); final List drivers; + final List commands; factory ExtensionContributions.fromJson(Map json) { final driversRaw = json['drivers']; @@ -104,13 +143,45 @@ class ExtensionContributions { } } } - return ExtensionContributions(drivers: drivers); + return ExtensionContributions( + drivers: drivers, + commands: parseCommandList(json['commands']), + ); + } + + static List parseCommandList(Object? raw) { + final commands = []; + if (raw is! List) return commands; + for (final item in raw) { + if (item is Map) { + commands.add(CommandContribution.fromJson(item)); + } else if (item is Map) { + commands.add( + CommandContribution.fromJson(Map.from(item)), + ); + } + } + return commands.where((c) => c.id.isNotEmpty && c.title.isNotEmpty).toList(); + } + + static ExtensionContributions merge( + ExtensionContributions? a, + ExtensionContributions? b, + ) { + if (a == null) return b ?? const ExtensionContributions(); + if (b == null) return a; + return ExtensionContributions( + drivers: [...a.drivers, ...b.drivers], + commands: [...a.commands, ...b.commands], + ); } Map toJson() => { if (drivers.isNotEmpty) 'drivers': drivers.map((d) => d.toJson()).toList(), + if (commands.isNotEmpty) + 'commands': commands.map((c) => c.toJson()).toList(), }; - bool get isEmpty => drivers.isEmpty; + bool get isEmpty => drivers.isEmpty && commands.isEmpty; } diff --git a/lib/core/extensions/models/extension_manifest.dart b/lib/core/extensions/models/extension_manifest.dart index 9a030cd6..6e0ab098 100644 --- a/lib/core/extensions/models/extension_manifest.dart +++ b/lib/core/extensions/models/extension_manifest.dart @@ -65,6 +65,10 @@ class ExtensionManifest { Iterable get contributedDrivers => contributions?.drivers ?? const []; + /// Command Palette actions from `contributions.commands` / `contributes.commands`. + Iterable get contributedCommands => + contributions?.commands ?? const []; + /// Absolute path to the packaged icon, or null when missing on disk. String? get resolvedIconPath { final rel = icon?.trim(); @@ -135,8 +139,23 @@ class ExtensionManifest { Map.from(json['sandbox'] as Map)) : null), capabilities: _parseCapabilities(json['capabilities']), - contributions: _parseContributions(json['contributions']), + contributions: _mergeContributionBlocks( + json['contributions'], + json['contributes'], + ), + ); + } + + static ExtensionContributions? _mergeContributionBlocks( + Object? contributions, + Object? contributes, + ) { + final merged = ExtensionContributions.merge( + _parseContributions(contributions), + _parseContributions(contributes), ); + if (merged.isEmpty) return null; + return merged; } static ExtensionCapabilities? _parseCapabilities(Object? raw) { diff --git a/lib/core/motion/querya_animated_expand.dart b/lib/core/motion/querya_animated_expand.dart index 62108875..b857aa88 100644 --- a/lib/core/motion/querya_animated_expand.dart +++ b/lib/core/motion/querya_animated_expand.dart @@ -4,27 +4,58 @@ import 'querya_motion.dart'; import 'querya_motion_context.dart'; /// Animates height when [expanded] toggles (connection tree sections, etc.). +/// +/// Height morph via [AnimatedSize] is **skipped** when: +/// - Motion is Off / OS `disableAnimations` (effective duration is zero), or +/// - [estimatedChildCount] exceeds [skipSizeAnimationAbove] (large lists — +/// same spirit as SDUI flat trees that avoid fighting `itemExtent`). +/// +/// Chevron rotation at call sites stays independent and cheap. class QueryaAnimatedExpand extends StatelessWidget { const QueryaAnimatedExpand({ super.key, required this.expanded, required this.child, this.alignment = Alignment.topCenter, + this.estimatedChildCount, + this.skipSizeAnimationAbove = defaultSkipSizeAnimationAbove, }); + /// Matches [kConnectionTreeEagerThreshold]: above this, expand is instant. + static const int defaultSkipSizeAnimationAbove = 24; + final bool expanded; final Widget child; final Alignment alignment; + /// When known, used to skip multi-frame [AnimatedSize] for large subtrees. + final int? estimatedChildCount; + + /// Instant expand/collapse when [estimatedChildCount] is greater than this. + final int skipSizeAnimationAbove; + @override Widget build(BuildContext context) { + final duration = context.motionDuration(QueryaMotion.treeExpand); + final curve = context.motionCurve(QueryaMotion.treeExpandCurve); + final large = estimatedChildCount != null && + estimatedChildCount! > skipSizeAnimationAbove; + final skipMorph = duration == Duration.zero || large; + + final content = expanded + ? child + : const SizedBox(width: double.infinity, height: 0); + + if (skipMorph) { + return content; + } + return AnimatedSize( - duration: context.motionDuration(QueryaMotion.treeExpand), - curve: context.motionCurve(QueryaMotion.treeExpandCurve), + duration: duration, + curve: curve, alignment: alignment, clipBehavior: Clip.hardEdge, - child: - expanded ? child : const SizedBox(width: double.infinity, height: 0), + child: content, ); } } diff --git a/lib/core/motion/querya_motion.dart b/lib/core/motion/querya_motion.dart index 14efdd37..b2dd3440 100644 --- a/lib/core/motion/querya_motion.dart +++ b/lib/core/motion/querya_motion.dart @@ -24,6 +24,10 @@ abstract final class QueryaMotion { /// this duration so one gesture does not finish on two clocks (#480). static const Duration treeExpand = standard; + /// Sidebar width cubic when springs are off (#741). Shorter than [standard] + /// so Reduced / spring-off toggle stays snappy (~160 ms). + static const Duration sidebarCubic = Duration(milliseconds: 160); + /// Elements appearing (decelerate). static const Curve enter = Curves.easeOutCubic; diff --git a/lib/core/sdui/sdui_tree_builder.dart b/lib/core/sdui/sdui_tree_builder.dart index 50484fb6..95a9b714 100644 --- a/lib/core/sdui/sdui_tree_builder.dart +++ b/lib/core/sdui/sdui_tree_builder.dart @@ -1,18 +1,19 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/sdui/sdui_tree_schema.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/ui/querya_icon_sizes.dart'; import 'package:querya_desktop/core/ui/querya_icons.dart'; +import 'package:querya_desktop/core/ui/querya_tree_indent_guide.dart'; import 'package:querya_desktop/core/ui/querya_tree_tokens.dart'; +import 'package:querya_desktop/features/connections/querya_connection_tree_row.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Renders a sidebar-style tree from an SDUI schema with lazy expansion. /// /// Visible rows are flattened into a [ListView.builder] so only viewport /// rows are built (large schemas no longer create a full widget Column). -/// Expand chevrons and height morph share [QueryaMotion.treeExpand] / -/// [QueryaMotion.treeExpandCurve]. Height morph via [QueryaAnimatedExpand] is -/// not used on the flat virtualized row list (nested expand would fight -/// `ListView` itemExtent); chevron timing still matches native trees. +/// Row chrome matches native [QueryaConnectionTreeRow] (selection, gaps, +/// bold-when-selected, keyboard expand, context menu). class SduiTreeBuilder extends material.StatefulWidget { const SduiTreeBuilder({ super.key, @@ -22,6 +23,7 @@ class SduiTreeBuilder extends material.StatefulWidget { this.selectedNodeId, this.isNodeSelected, this.maxHeight, + this.connection, }); final SduiTreeSchema schema; @@ -33,6 +35,9 @@ class SduiTreeBuilder extends material.StatefulWidget { /// When set, the tree scrolls inside a height cap (sidebar use). final double? maxHeight; + /// Optional connection for tree context menus (Refresh / Copy name). + final ConnectionRow? connection; + @override material.State createState() => SduiTreeBuilderState(); } @@ -40,13 +45,15 @@ class SduiTreeBuilder extends material.StatefulWidget { class _VisibleRow { const _VisibleRow.node(this.node, this.depth) : error = null, + parent = null, isError = false; - const _VisibleRow.error(this.error, this.depth) + const _VisibleRow.error(this.error, this.depth, this.parent) : node = null, isError = true; final SduiTreeNode? node; + final SduiTreeNode? parent; final int depth; final String? error; final bool isError; @@ -127,6 +134,19 @@ class SduiTreeBuilderState extends material.State { } } + Future _retryExpand(SduiTreeNode node) async { + setState(() { + _expandErrors.remove(node.id); + _loaded.remove(node.id); + _roots = _replaceNode( + _roots, + node.id, + (n) => n.copyWith(children: const []), + ); + }); + await _onExpand(node); + } + List _replaceNode( List nodes, String id, @@ -150,7 +170,7 @@ class SduiTreeBuilderState extends material.State { if (!_expanded.contains(node.id)) return; final err = _expandErrors[node.id]; if (err != null) { - out.add(_VisibleRow.error(err, depth)); + out.add(_VisibleRow.error(err, depth, node)); } for (final child in node.children) { walk(child, depth + 1); @@ -176,15 +196,12 @@ class SduiTreeBuilderState extends material.State { itemBuilder: (context, index) { final row = rows[index]; if (row.isError) { + final parent = row.parent!; return TreeLoadError( title: 'Could not expand', message: row.error!, - detailFontSize: 10, - padding: material.EdgeInsets.only( - left: 36.0 + row.depth * QueryaTreeTokens.indent, - top: 2, - bottom: 2, - ), + padding: QueryaTreeTokens.errorPaddingForDepth(row.depth), + onRetry: () => _retryExpand(parent), ); } return _buildNodeRow(row.node!, depth: row.depth); @@ -212,7 +229,6 @@ class SduiTreeBuilderState extends material.State { node.id == widget.selectedNodeId) || (widget.isNodeSelected != null && widget.isNodeSelected!(node)); - // Same hierarchy as native trees (#476 / #497) — no separate sduiNode size. final iconSize = canExpand ? QueryaIconSizes.treeGroup : QueryaIconSizes.treeLeaf; final iconColor = isSelected @@ -220,111 +236,60 @@ class SduiTreeBuilderState extends material.State { : (isBrowsable ? QueryaTreeTokens.leafIconColor(primary) : muted); - final rowLeft = - 8.0 + depth * QueryaTreeTokens.indent + (canExpand ? 0 : 4.0); + final leading = canExpand + ? material.AnimatedRotation( + turns: isExpanded ? 0.25 : 0, + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), + child: material.Icon( + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, + color: muted, + ), + ) + : const material.SizedBox(width: QueryaIconSizes.treeExpand); - final row = material.AnimatedContainer( - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), - decoration: material.BoxDecoration( - color: isSelected - ? primary.withValues(alpha: 0.12) - : material.Colors.transparent, - borderRadius: material.BorderRadius.circular(4), - border: isSelected - ? material.Border.all( - color: primary.withValues(alpha: 0.35), - width: 1, + return QueryaTreeIndentGuide( + depth: depth, + leading: 8.0 + (canExpand ? 0 : 4.0), + child: QueryaConnectionTreeRow( + label: node.label, + isSelected: isSelected, + leading: leading, + icon: isLoading + ? null + : QueryaIcons.sduiNodeIcon( + node.icon, + expandable: node.expandable, + ), + iconWidget: isLoading + ? const material.SizedBox( + width: QueryaTreeTokens.spinnerNested, + height: QueryaTreeTokens.spinnerNested, + child: material.CircularProgressIndicator( + strokeWidth: QueryaTreeTokens.spinnerStroke, + ), ) : null, - ), - child: material.Material( - color: material.Colors.transparent, - child: material.InkWell( - onTap: () { - if (isBrowsable) { - widget.onNodeSelected?.call(node); - } else if (canExpand) { - _toggleExpand(node); - } - }, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: material.EdgeInsets.only( - left: rowLeft, - right: 8, - ), - child: material.Row( - children: [ - if (canExpand) - material.MouseRegion( - cursor: material.SystemMouseCursors.click, - child: material.GestureDetector( - behavior: material.HitTestBehavior.opaque, - onTap: () => _toggleExpand(node), - child: material.Padding( - padding: const material.EdgeInsets.all(2), - child: material.AnimatedRotation( - turns: isExpanded ? 0.25 : 0, - duration: - context.motionDuration(QueryaMotion.treeExpand), - curve: - context.motionCurve(QueryaMotion.treeExpandCurve), - child: material.Icon( - QueryaIcons.expandClosed, - size: QueryaIconSizes.treeExpand, - color: muted, - ), - ), - ), - ), - ) - else - const material.SizedBox(width: QueryaIconSizes.treeExpand + 4), - if (isLoading) - const material.SizedBox( - width: 14, - height: 14, - child: material.CircularProgressIndicator(strokeWidth: 2), - ) - else - material.Icon( - QueryaIcons.sduiNodeIcon( - node.icon, - expandable: node.expandable, - ), - size: iconSize, - color: iconColor, - ), - const Gap(8), - material.Expanded( - child: material.Text( - node.label, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, - color: isSelected - ? primary - : (isBrowsable ? theme.colorScheme.foreground : muted), - fontWeight: (isSelected || isBrowsable) - ? material.FontWeight.w600 - : null, - ), - ), - ), - ], - ), - ), + iconSize: iconSize, + iconColor: iconColor, + textStyle: material.TextStyle( + fontSize: 11, + color: isBrowsable ? theme.colorScheme.foreground : muted, ), + verticalPadding: 3, + expanded: canExpand ? isExpanded : null, + onTap: () { + if (isBrowsable) { + widget.onNodeSelected?.call(node); + } else if (canExpand) { + _toggleExpand(node); + } + }, + connection: widget.connection, + onContextRefresh: canExpand ? () => _retryExpand(node) : null, ), ); - if (!canExpand) return row; - return material.Semantics( - button: true, - expanded: isExpanded, - child: row, - ); } String _resolveNodeKind(SduiTreeNode node) { diff --git a/lib/core/ui/querya_shell_status.dart b/lib/core/ui/querya_shell_status.dart new file mode 100644 index 00000000..43fd0fd6 --- /dev/null +++ b/lib/core/ui/querya_shell_status.dart @@ -0,0 +1,70 @@ +import 'package:flutter/foundation.dart'; + +/// Global shell metrics for [QueryaStatusBar] (busy, last query, grid size). +/// +/// SQL workspaces and other long-running UI paths publish here; [MainScreen] +/// listens and forwards into the status bar. Cleared on connection switch. +class QueryaShellStatus extends ChangeNotifier { + QueryaShellStatus._(); + + static final QueryaShellStatus instance = QueryaShellStatus._(); + + bool _isBusy = false; + String? _statusMessage; + int? _rowCount; + int? _columnCount; + Duration? _lastQueryDuration; + + bool get isBusy => _isBusy; + String? get statusMessage => _statusMessage; + int? get rowCount => _rowCount; + int? get columnCount => _columnCount; + Duration? get lastQueryDuration => _lastQueryDuration; + + /// Marks the shell busy (spinner + optional message). + void beginBusy({String? message}) { + _isBusy = true; + if (message != null) _statusMessage = message; + notifyListeners(); + } + + /// Clears busy; keeps last query metrics unless [clearMetrics] is true. + void endBusy({bool clearMetrics = false}) { + _isBusy = false; + if (clearMetrics) { + _statusMessage = null; + _rowCount = null; + _columnCount = null; + _lastQueryDuration = null; + } + notifyListeners(); + } + + /// Publishes a completed query (or command) and clears busy. + void reportQueryResult({ + Duration? duration, + int? rowCount, + int? columnCount, + String? message, + }) { + _isBusy = false; + if (duration != null) _lastQueryDuration = duration; + if (rowCount != null) _rowCount = rowCount; + if (columnCount != null) _columnCount = columnCount; + if (message != null) _statusMessage = message; + notifyListeners(); + } + + /// Clears busy + metrics (e.g. connection switch). + void clear() { + _isBusy = false; + _statusMessage = null; + _rowCount = null; + _columnCount = null; + _lastQueryDuration = null; + notifyListeners(); + } + + @visibleForTesting + void resetForTest() => clear(); +} diff --git a/lib/core/ui/querya_tree_indent_guide.dart b/lib/core/ui/querya_tree_indent_guide.dart new file mode 100644 index 00000000..60636c6f --- /dev/null +++ b/lib/core/ui/querya_tree_indent_guide.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/ui/querya_tree_tokens.dart'; + +/// Vertical indent guides for the connections tree (UI-05). +/// +/// Paints one 1px line per [depth] in the left gutter, then pads [child] +/// by `leading + depth * step`. No animation — cheap [CustomPaint] only. +class QueryaTreeIndentGuide extends StatelessWidget { + const QueryaTreeIndentGuide({ + super.key, + required this.depth, + required this.child, + this.step = QueryaTreeTokens.indent, + this.leading = 0, + this.padding = EdgeInsets.zero, + }); + + /// Number of guide lines (and indent bands). + final int depth; + + final Widget child; + + /// Width of one indent band. Defaults to [QueryaTreeTokens.indent]. + final double step; + + /// Extra left inset before the first band (SDUI root / leaf alignment). + final double leading; + + /// Additional padding (top/right/bottom, extra left). + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final left = leading + depth * step; + final inset = padding.add(EdgeInsets.only(left: left)); + final content = left == 0 && padding == EdgeInsets.zero + ? child + : Padding(padding: inset, child: child); + + if (depth <= 0) return content; + + final base = QueryaThemeScope.maybeOf(context)?.workbench.borderSubtle ?? + Theme.of(context).colorScheme.outline; + final color = base.withValues(alpha: QueryaTreeTokens.guideAlpha); + + return CustomPaint( + painter: _QueryaTreeIndentGuidePainter( + depth: depth, + step: step, + leading: leading, + color: color, + ), + child: content, + ); + } +} + +class _QueryaTreeIndentGuidePainter extends CustomPainter { + _QueryaTreeIndentGuidePainter({ + required this.depth, + required this.step, + required this.leading, + required this.color, + }); + + final int depth; + final double step; + final double leading; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = QueryaTreeTokens.guideWidth + ..style = PaintingStyle.stroke; + for (var i = 0; i < depth; i++) { + final x = leading + i * step + QueryaTreeTokens.guideInset; + canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint); + } + } + + @override + bool shouldRepaint(covariant _QueryaTreeIndentGuidePainter oldDelegate) { + return depth != oldDelegate.depth || + step != oldDelegate.step || + leading != oldDelegate.leading || + color != oldDelegate.color; + } +} diff --git a/lib/core/ui/querya_tree_tokens.dart b/lib/core/ui/querya_tree_tokens.dart index 20ef57ca..0347a9bc 100644 --- a/lib/core/ui/querya_tree_tokens.dart +++ b/lib/core/ui/querya_tree_tokens.dart @@ -5,6 +5,76 @@ abstract final class QueryaTreeTokens { /// Indent for schema rows and sibling object folders under a database. static const double indent = 16; + /// First level under a connection tile (Databases / root folder). + static const double underConnection = 20; + + /// Leaf list indent (tables / views under an object folder). + static const double leafList = 26; + + /// Compact **SERVERS** header top padding (UI-05). + static const double serversHeaderTop = 12; + + /// Indent-guide stroke (UI-05). + static const double guideWidth = 1; + + /// Horizontal offset of the guide inside each indent band. + static const double guideInset = 8; + + /// Guide line alpha on [QueryaWorkbenchTheme.borderSubtle] / outline. + static const double guideAlpha = 0.15; + + /// Loading / error inset under a connection expand (databases load). + static const double errorConnection = 28; + + /// Nested loading / error inset (schemas under database, etc.). + static const double errorNested = 24; + + /// Base left inset for SDUI expand errors before depth scaling. + static const double errorSduiBase = 36; + + static const double spinnerConnection = 12; + static const double spinnerNested = 10; + static const double spinnerInline = 14; + static const double spinnerStroke = 1.5; + static const double spinnerStrokeInline = 2; + + static const EdgeInsets loadingPaddingConnection = EdgeInsets.only( + left: errorConnection, + top: 4, + bottom: 4, + ); + + static const EdgeInsets loadingPaddingNested = EdgeInsets.only( + left: errorNested, + top: 2, + bottom: 2, + ); + + static const EdgeInsets errorPaddingConnection = EdgeInsets.only( + left: errorConnection, + top: 4, + bottom: 4, + ); + + static const EdgeInsets errorPaddingNested = EdgeInsets.only( + left: errorNested, + top: 4, + bottom: 8, + ); + + static const EdgeInsets emptyFilterPadding = EdgeInsets.only( + left: leafList, + top: 4, + bottom: 6, + ); + + /// SDUI expand-error inset: [errorSduiBase] + [depth] × [indent]. + static EdgeInsets errorPaddingForDepth(int depth) => EdgeInsets.only( + left: errorSduiBase + depth * indent, + top: 2, + bottom: 2, + ); + /// Leaf-row icon tint (tables, views, sequences, …). /// /// Takes [primary] (not [ColorScheme]) so both Material and shadcn schemes diff --git a/lib/core/unsaved_work_registry.dart b/lib/core/unsaved_work_registry.dart new file mode 100644 index 00000000..8511a9df --- /dev/null +++ b/lib/core/unsaved_work_registry.dart @@ -0,0 +1,31 @@ +import 'package:flutter/foundation.dart'; + +/// Process-wide probes for unsaved SQL / staged grid edits. +/// +/// Used by the in-app updater (and similar quit paths) to warn before +/// discarding work. Owners register a probe and must unregister on dispose. +class UnsavedWorkRegistry { + UnsavedWorkRegistry._(); + + static final UnsavedWorkRegistry instance = UnsavedWorkRegistry._(); + + final Map _probes = {}; + + void register(Object owner, bool Function() hasUnsaved) { + _probes[owner] = hasUnsaved; + } + + void unregister(Object owner) { + _probes.remove(owner); + } + + bool get hasUnsaved { + for (final probe in _probes.values) { + if (probe()) return true; + } + return false; + } + + @visibleForTesting + void resetForTest() => _probes.clear(); +} diff --git a/lib/core/updater/app_updater_service.dart b/lib/core/updater/app_updater_service.dart index 28ec2aaa..b5fe0c42 100644 --- a/lib/core/updater/app_updater_service.dart +++ b/lib/core/updater/app_updater_service.dart @@ -131,41 +131,59 @@ class AppUpdaterService { await destination.delete(); } - final request = http.Request('GET', Uri.parse(asset.downloadUrl)); - final response = await _downloadClient.send(request); - if (response.statusCode != 200) { - throw AppUpdaterException( - 'Download failed for ${asset.name} (HTTP ${response.statusCode})', - ); - } - - final total = response.contentLength ?? asset.sizeBytes ?? 0; - var received = 0; - final sink = destination.openWrite(); try { - await for (final chunk in response.stream) { - if (shouldCancel?.call() == true) { - throw const AppUpdaterException('Download cancelled'); - } - received += chunk.length; - sink.add(chunk); - if (onProgress != null) { - onProgress(received, total > 0 ? total : received); + final request = http.Request('GET', Uri.parse(asset.downloadUrl)); + final response = await _downloadClient.send(request); + if (response.statusCode != 200) { + throw AppUpdaterException( + 'Download failed for ${asset.name} (HTTP ${response.statusCode})', + ); + } + + final total = response.contentLength ?? asset.sizeBytes ?? 0; + var received = 0; + final sink = destination.openWrite(); + try { + await for (final chunk in response.stream) { + if (shouldCancel?.call() == true) { + throw const AppUpdaterException('Download cancelled'); + } + received += chunk.length; + sink.add(chunk); + if (onProgress != null) { + onProgress(received, total > 0 ? total : received); + } } + } finally { + await sink.close(); } - } finally { - await sink.close(); - } - if (shouldCancel?.call() == true) { - if (await destination.exists()) { - await destination.delete(); + if (shouldCancel?.call() == true) { + throw const AppUpdaterException('Download cancelled'); } - throw const AppUpdaterException('Download cancelled'); + + try { + await verifyFileSha256(file: destination, expectedHex: expected); + } on UpdateChecksumMismatchException catch (e) { + throw AppUpdaterException( + 'The downloaded update failed integrity verification (SHA256 mismatch). ' + 'The file was discarded.', + cause: e, + ); + } + return destination; + } catch (e) { + await _deleteIfExists(destination); + rethrow; } + } - await verifyFileSha256(file: destination, expectedHex: expected); - return destination; + static Future _deleteIfExists(File file) async { + try { + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} } /// Installs a verified update package using the platform-specific installer. @@ -179,6 +197,11 @@ class AppUpdaterService { return context.isManagedPackage; } + /// `snap refresh` / `flatpak update`, or null when in-app install is allowed. + String? get packageManagerUpdateCommand { + return UpdateInstallContext.current().packageManagerUpdateCommand; + } + /// Picks the best Release asset for the current packaging context. UpdateAsset? platformAssetFor( UpdateManifest manifest, { diff --git a/lib/core/updater/installers/linux_appimage_installer.dart b/lib/core/updater/installers/linux_appimage_installer.dart index f6cdfd95..2f6bc2c1 100644 --- a/lib/core/updater/installers/linux_appimage_installer.dart +++ b/lib/core/updater/installers/linux_appimage_installer.dart @@ -57,13 +57,17 @@ class LinuxAppImageInstaller { } final executable = context.resolvedExecutable; + final sourceDir = resolveLinuxFlutterBundleRoot( + extractDir: extractDir, + executableName: p.basename(executable), + ); final scriptFile = File( p.join(tempRoot.path, 'querya-linux-update-$pid.sh'), ); await scriptFile.writeAsString( buildLinuxBundleReplaceScript( pid: pid, - sourceDir: extractDir.path, + sourceDir: sourceDir.path, targetDir: targetDir, executable: executable, ), diff --git a/lib/core/updater/installers/update_install_context.dart b/lib/core/updater/installers/update_install_context.dart index d363e741..54d51a64 100644 --- a/lib/core/updater/installers/update_install_context.dart +++ b/lib/core/updater/installers/update_install_context.dart @@ -33,12 +33,17 @@ class UpdateInstallContext { Platform.isLinux && environment.containsKey('SNAP'); bool get isFlatpak => - Platform.isLinux && - (environment.containsKey('FLATPAK_ID') || - environment.containsKey('container')); + Platform.isLinux && environment.containsKey('FLATPAK_ID'); bool get isManagedPackage => isSnap || isFlatpak; + /// Command the user should run when [isManagedPackage] is true. + String? get packageManagerUpdateCommand { + if (isSnap) return 'snap refresh'; + if (isFlatpak) return 'flatpak update'; + return null; + } + String? get linuxBundleRoot { if (!Platform.isLinux) return null; return p.dirname(resolvedExecutable); diff --git a/lib/core/updater/installers/update_install_utils.dart b/lib/core/updater/installers/update_install_utils.dart index 6755191e..a7ac3c1b 100644 --- a/lib/core/updater/installers/update_install_utils.dart +++ b/lib/core/updater/installers/update_install_utils.dart @@ -96,6 +96,48 @@ Future launchDetachedScript(String scriptPath, List args) async { ); } +/// True when [dir] looks like a Flutter Linux desktop bundle for [executableName]. +bool looksLikeLinuxFlutterBundle(Directory dir, String executableName) { + final exe = File(p.join(dir.path, executableName)); + if (!exe.existsSync()) return false; + final lib = Directory(p.join(dir.path, 'lib')); + final data = Directory(p.join(dir.path, 'data')); + return lib.existsSync() || data.existsSync(); +} + +/// Resolves the Flutter bundle root inside an extracted Linux zip. +/// +/// GitHub zips often wrap the bundle in a single top-level folder. A mismatched +/// layout is refused so the replace script never `rsync --delete`s into the +/// running executable directory. +Directory resolveLinuxFlutterBundleRoot({ + required Directory extractDir, + required String executableName, +}) { + if (looksLikeLinuxFlutterBundle(extractDir, executableName)) { + return extractDir; + } + + final children = extractDir.existsSync() + ? extractDir + .listSync() + .whereType() + .where((d) => !p.basename(d.path).startsWith('.')) + .toList() + : const []; + + for (final child in children) { + if (looksLikeLinuxFlutterBundle(child, executableName)) { + return child; + } + } + + throw AppUpdaterException( + 'Linux update zip is not a Flutter desktop bundle ' + '(expected $executableName plus lib/ or data/). Refusing install.', + ); +} + /// Shell script that waits for [pid], syncs [sourceDir] into [targetDir], then execs [executable]. String buildLinuxBundleReplaceScript({ required int pid, @@ -110,7 +152,16 @@ PID="$pid" SRC='${_shellQuote(sourceDir)}' DST='${_shellQuote(targetDir)}' EXE='${_shellQuote(executable)}' +EXE_NAME=\$(basename "\$EXE") while kill -0 "\$PID" 2>/dev/null; do sleep 0.2; done +if [ ! -f "\$SRC/\$EXE_NAME" ]; then + echo "querya-update: refusing replace, \$SRC is not a Flutter bundle" >&2 + exit 1 +fi +if [ ! -d "\$SRC/lib" ] && [ ! -d "\$SRC/data" ]; then + echo "querya-update: refusing replace, missing lib/ or data/" >&2 + exit 1 +fi if command -v rsync >/dev/null 2>&1; then rsync -a --delete "\$SRC"/ "\$DST"/ else diff --git a/lib/core/updater/update_version.dart b/lib/core/updater/update_version.dart index 7d0e35d2..9dde8438 100644 --- a/lib/core/updater/update_version.dart +++ b/lib/core/updater/update_version.dart @@ -66,7 +66,30 @@ class UpdateVersion implements Comparable { if (!isPreRelease && !other.isPreRelease) return 0; if (!isPreRelease && other.isPreRelease) return 1; if (isPreRelease && !other.isPreRelease) return -1; - return preRelease!.compareTo(other.preRelease!); + return _comparePreRelease(preRelease!, other.preRelease!); + } + + /// SemVer 2.0.0 pre-release identifiers (numeric vs alphanumeric). + static int _comparePreRelease(String left, String right) { + final a = left.split('.'); + final b = right.split('.'); + final n = a.length < b.length ? a.length : b.length; + for (var i = 0; i < n; i++) { + final cmp = _comparePreIdentifier(a[i], b[i]); + if (cmp != 0) return cmp; + } + return a.length.compareTo(b.length); + } + + static int _comparePreIdentifier(String left, String right) { + final leftNum = int.tryParse(left); + final rightNum = int.tryParse(right); + if (leftNum != null && rightNum != null) { + return leftNum.compareTo(rightNum); + } + if (leftNum != null) return -1; + if (rightNum != null) return 1; + return left.compareTo(right); } int _compareCore(UpdateVersion other) { diff --git a/lib/features/command_palette/command_match_highlight.dart b/lib/features/command_palette/command_match_highlight.dart new file mode 100644 index 00000000..39ba1047 --- /dev/null +++ b/lib/features/command_palette/command_match_highlight.dart @@ -0,0 +1,39 @@ +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Bold/primary span for the first case-insensitive [query] hit in [text]. +class CommandMatchHighlight extends StatelessWidget { + const CommandMatchHighlight({ + super.key, + required this.text, + required this.query, + }); + + final String text; + final String query; + + @override + Widget build(BuildContext context) { + final q = query.trim(); + if (q.isEmpty) return Text(text); + final lower = text.toLowerCase(); + final needle = q.toLowerCase(); + final index = lower.indexOf(needle); + if (index < 0) return Text(text); + final theme = Theme.of(context).colorScheme; + return Text.rich( + TextSpan( + children: [ + TextSpan(text: text.substring(0, index)), + TextSpan( + text: text.substring(index, index + needle.length), + style: TextStyle( + fontWeight: FontWeight.w600, + color: theme.primary, + ), + ), + TextSpan(text: text.substring(index + needle.length)), + ], + ), + ); + } +} diff --git a/lib/features/command_palette/command_palette_dialog.dart b/lib/features/command_palette/command_palette_dialog.dart new file mode 100644 index 00000000..3cd0a206 --- /dev/null +++ b/lib/features/command_palette/command_palette_dialog.dart @@ -0,0 +1,227 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/actions/querya_command.dart'; +import 'package:querya_desktop/core/actions/querya_command_host.dart'; +import 'package:querya_desktop/core/actions/querya_command_registry.dart'; +import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/features/command_palette/command_match_highlight.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Opens the Command Palette (`Ctrl/Cmd+P`). Motion Off snaps via [showAppDialog]. +Future showCommandPalette(BuildContext hostContext) { + QueryaCommandRegistry.instance.ensureCoreDefaults(); + return showAppDialog( + context: hostContext, + builder: (dialogContext) => material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: WindowLayout.dialogSymmetricInsets(dialogContext), + child: CommandPaletteDialog(hostContext: hostContext), + ), + ); +} + +class CommandPaletteDialog extends StatefulWidget { + const CommandPaletteDialog({ + super.key, + required this.hostContext, + }); + + /// Context that owns [QueryaCommandHost] / workspace (not the overlay). + final BuildContext hostContext; + + @override + State createState() => _CommandPaletteDialogState(); +} + +class _CommandPaletteDialogState extends State { + final _controller = TextEditingController(); + final _focusNode = FocusNode(); + var _selected = 0; + + @override + void initState() { + super.initState(); + _controller.addListener(() => setState(() => _selected = 0)); + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + bool get _objectPrefix { + final t = _controller.text; + return t.startsWith('#') || t.startsWith('@'); + } + + String get _objectQuery => + _objectPrefix ? _controller.text.substring(1) : ''; + + List get _hits { + if (_objectPrefix) return const []; + final ctx = widget.hostContext.mounted ? widget.hostContext : context; + return QueryaCommandRegistry.instance.search(_controller.text, context: ctx); + } + + void _openQuickSwitcher() { + Navigator.of(context).pop(); + final target = + widget.hostContext.mounted ? widget.hostContext : context; + QueryaCommandHost.maybeOf(target) + ?.onShowQuickSwitcher + ?.call(_objectQuery); + } + + void _run(QueryaCommand command) { + Navigator.of(context).pop(); + final target = + widget.hostContext.mounted ? widget.hostContext : context; + command.execute(target); + } + + void _move(int delta) { + final hits = _hits; + if (hits.isEmpty) return; + setState(() { + _selected = (_selected + delta) % hits.length; + if (_selected < 0) _selected += hits.length; + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final hits = _hits; + final selectedIndex = + hits.isEmpty ? 0 : _selected.clamp(0, hits.length - 1); + + return QueryaDialogCard( + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 520, + minWidth: 360, + maxHeight: 420, + ), + child: CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.arrowDown): () => _move(1), + const SingleActivator(LogicalKeyboardKey.arrowUp): () => _move(-1), + const SingleActivator(LogicalKeyboardKey.enter): () { + if (_objectPrefix) { + _openQuickSwitcher(); + } else if (hits.isNotEmpty) { + _run(hits[selectedIndex]); + } + }, + }, + child: SizedBox( + height: 360, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 8), + child: TextField( + controller: _controller, + focusNode: _focusNode, + autofocus: true, + placeholder: const Text('Type a command…'), + onSubmitted: (_) { + if (_objectPrefix) { + _openQuickSwitcher(); + } else if (hits.isNotEmpty) { + _run(hits[selectedIndex]); + } + }, + ), + ), + const Divider(), + Expanded( + child: _objectPrefix + ? _PrefixHint(query: _objectQuery, onOpen: _openQuickSwitcher) + : hits.isEmpty + ? const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text('No matching commands'), + ), + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 8), + itemCount: hits.length, + itemExtent: 36, + itemBuilder: (context, index) { + final command = hits[index]; + final active = index == selectedIndex; + return material.InkWell( + key: ValueKey(command.id), + onTap: () => _run(command), + onHover: (_) => setState(() => _selected = index), + borderRadius: BorderRadius.circular(6), + child: DecoratedBox( + decoration: BoxDecoration( + color: active + ? theme.colorScheme.primary + .withValues(alpha: 0.12) + : Colors.transparent, + borderRadius: BorderRadius.circular(6), + ), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + if (command.icon != null) ...[ + Icon(command.icon, size: 16), + const Gap(8), + ], + Expanded( + child: CommandMatchHighlight( + text: command.title, + query: _controller.text, + ), + ), + if (command.shortcutLabel != null) + Text(command.shortcutLabel!) + .xSmall() + .muted(), + ], + ), + ), + ), + ); + }, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _PrefixHint extends StatelessWidget { + const _PrefixHint({required this.query, required this.onOpen}); + + final String query; + final VoidCallback onOpen; + + @override + Widget build(BuildContext context) { + final label = query.trim().isEmpty + ? 'Open Quick Switcher' + : 'Go to “${query.trim()}”'; + return material.InkWell( + onTap: onOpen, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text(label), + ), + ), + ); + } +} diff --git a/lib/features/command_palette/quick_switcher_dialog.dart b/lib/features/command_palette/quick_switcher_dialog.dart new file mode 100644 index 00000000..492cdf9a --- /dev/null +++ b/lib/features/command_palette/quick_switcher_dialog.dart @@ -0,0 +1,274 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/actions/querya_command_host.dart'; +import 'package:querya_desktop/core/actions/querya_schema_object.dart'; +import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/features/command_palette/command_match_highlight.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Threshold where filter runs on a worker isolate (hundreds of tables). +const kQuickSwitcherIsolateThreshold = 250; + +/// Opens the Quick Switcher (`Ctrl/Cmd+K`). Motion Off snaps via [showAppDialog]. +Future showQuickSwitcher( + BuildContext hostContext, { + String initialQuery = '', + List? seed, + Future> Function()? load, +}) { + return showAppDialog( + context: hostContext, + builder: (dialogContext) => material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: WindowLayout.dialogSymmetricInsets(dialogContext), + child: QuickSwitcherDialog( + hostContext: hostContext, + initialQuery: initialQuery, + seed: seed, + load: load, + ), + ), + ); +} + +class QuickSwitcherDialog extends StatefulWidget { + const QuickSwitcherDialog({ + super.key, + required this.hostContext, + this.initialQuery = '', + this.seed, + this.load, + }); + + final BuildContext hostContext; + final String initialQuery; + final List? seed; + final Future> Function()? load; + + @override + State createState() => _QuickSwitcherDialogState(); +} + +class _QuickSwitcherDialogState extends State { + late final TextEditingController _controller; + final _focusNode = FocusNode(); + var _selected = 0; + var _loading = false; + String? _error; + List _all = const []; + List _hits = const []; + var _filterSeq = 0; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialQuery); + _controller.addListener(_onQueryChanged); + final seed = widget.seed; + if (seed != null) { + _all = seed; + _hits = filterSchemaObjects(seed, widget.initialQuery); + } else { + _loading = true; + _load(); + } + } + + @override + void dispose() { + _controller.removeListener(_onQueryChanged); + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + Future _load() async { + try { + final objects = await (widget.load?.call() ?? + Future>.value(const [])); + if (!mounted) return; + setState(() { + _all = objects; + _loading = false; + _error = null; + }); + await _applyFilter(_controller.text); + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _error = e.toString(); + }); + } + } + + void _onQueryChanged() { + setState(() => _selected = 0); + _applyFilter(_controller.text); + } + + Future _applyFilter(String query) async { + final seq = ++_filterSeq; + final source = _all; + final List next; + if (source.length >= kQuickSwitcherIsolateThreshold) { + next = await compute(_filterSchemaObjectsIsolate, (source, query)); + } else { + next = filterSchemaObjects(source, query); + } + if (!mounted || seq != _filterSeq) return; + setState(() => _hits = next); + } + + void _open(QueryaSchemaObject object) { + Navigator.of(context).pop(); + final target = + widget.hostContext.mounted ? widget.hostContext : context; + QueryaCommandHost.maybeOf(target)?.onOpenSchemaObject?.call(object); + } + + void _move(int delta) { + if (_hits.isEmpty) return; + setState(() { + _selected = (_selected + delta) % _hits.length; + if (_selected < 0) _selected += _hits.length; + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final hits = _hits; + final selectedIndex = + hits.isEmpty ? 0 : _selected.clamp(0, hits.length - 1); + + return QueryaDialogCard( + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 520, + minWidth: 360, + maxHeight: 420, + ), + child: CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.arrowDown): () => _move(1), + const SingleActivator(LogicalKeyboardKey.arrowUp): () => _move(-1), + const SingleActivator(LogicalKeyboardKey.enter): () { + if (hits.isNotEmpty) _open(hits[selectedIndex]); + }, + }, + child: SizedBox( + height: 360, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 8), + child: TextField( + controller: _controller, + focusNode: _focusNode, + autofocus: true, + placeholder: const Text('Go to table, view, collection…'), + onSubmitted: (_) { + if (hits.isNotEmpty) _open(hits[selectedIndex]); + }, + ), + ), + const Divider(), + Expanded(child: _body(theme.colorScheme, hits, selectedIndex)), + ], + ), + ), + ), + ); + } + + Widget _body( + ColorScheme colors, + List hits, + int selectedIndex, + ) { + if (_loading) { + return const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text('Loading objects…'), + ), + ); + } + if (_error != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text(_error!), + ), + ); + } + if (hits.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + _all.isEmpty + ? 'No objects in the active connection' + : 'No matching objects', + ), + ), + ); + } + return ListView.builder( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 8), + itemCount: hits.length, + itemExtent: 40, + itemBuilder: (context, index) { + final object = hits[index]; + final active = index == selectedIndex; + return material.InkWell( + key: ValueKey(object.id), + onTap: () => _open(object), + onHover: (_) => setState(() => _selected = index), + borderRadius: BorderRadius.circular(6), + child: DecoratedBox( + decoration: BoxDecoration( + color: active + ? colors.primary.withValues(alpha: 0.12) + : Colors.transparent, + borderRadius: BorderRadius.circular(6), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + Icon(object.icon, size: 16), + const Gap(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CommandMatchHighlight( + text: object.qualifiedName, + query: _controller.text, + ), + Text(object.kindLabel).xSmall().muted(), + ], + ), + ), + if (object.rowCount != null) + Text('${object.rowCount}').xSmall().muted(), + ], + ), + ), + ), + ); + }, + ); + } +} + +List _filterSchemaObjectsIsolate( + (List, String) args, +) { + return filterSchemaObjects(args.$1, args.$2); +} diff --git a/lib/features/connections/connection_databases_folder.dart b/lib/features/connections/connection_databases_folder.dart new file mode 100644 index 00000000..716a0e8f --- /dev/null +++ b/lib/features/connections/connection_databases_folder.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart' + show Clipboard, ClipboardData, LogicalKeyboardKey; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/ui/querya_icon_sizes.dart'; +import 'package:querya_desktop/core/ui/querya_icons.dart'; +import 'package:querya_desktop/core/ui/querya_tree_indent_guide.dart'; +import 'package:querya_desktop/core/ui/querya_tree_tokens.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Collapsible **Databases (N)** folder chrome for network drivers +/// (PostgreSQL / MySQL / MongoDB / Redis). +/// +/// Defaults to expanded; uses [QueryaMotion.treeExpand] for the chevron and +/// [QueryaAnimatedExpand] for the child list. +class ConnectionDatabasesFolder extends material.StatefulWidget { + const ConnectionDatabasesFolder({ + super.key, + required this.connection, + required this.databaseCount, + required this.child, + this.onRefresh, + this.onOpenSql, + this.initiallyExpanded = true, + }); + + final ConnectionRow connection; + final int databaseCount; + final material.Widget child; + final material.VoidCallback? onRefresh; + + /// Optional "Open in SQL" context action (PG / MySQL). + final material.VoidCallback? onOpenSql; + + final bool initiallyExpanded; + + @override + material.State createState() => + _ConnectionDatabasesFolderState(); +} + +class _ConnectionDatabasesFolderState + extends material.State { + late bool _expanded = widget.initiallyExpanded; + + @override + void didUpdateWidget(covariant ConnectionDatabasesFolder oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.initiallyExpanded != widget.initiallyExpanded && + oldWidget.databaseCount == 0 && + widget.databaseCount > 0) { + _expanded = widget.initiallyExpanded; + } + } + + void _toggle() => setState(() => _expanded = !_expanded); + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final primary = theme.colorScheme.primary; + final muted = theme.colorScheme.mutedForeground; + final label = 'Databases (${widget.databaseCount})'; + + final row = material.CallbackShortcuts( + bindings: { + if (!_expanded) + const material.SingleActivator(LogicalKeyboardKey.arrowRight): _toggle, + if (_expanded) + const material.SingleActivator(LogicalKeyboardKey.arrowLeft): _toggle, + }, + child: material.AnimatedContainer( + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.standardCurve), + decoration: material.BoxDecoration( + color: material.Colors.transparent, + borderRadius: material.BorderRadius.circular(4), + ), + child: material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + hoverColor: primary.withValues(alpha: 0.07), + focusColor: primary.withValues(alpha: 0.14), + splashColor: primary.withValues(alpha: 0.10), + highlightColor: primary.withValues(alpha: 0.05), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 4, + vertical: 4, + ), + child: material.Row( + children: [ + material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), + child: material.Icon( + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, + color: muted, + ), + ), + const Gap(4), + material.Icon( + QueryaIcons.databasesFolder, + size: QueryaIconSizes.treeConnection, + color: primary.withValues(alpha: 0.7), + ), + const Gap(6), + material.Expanded( + child: material.Text( + label, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.foreground, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + + final menu = ContextMenu( + items: [ + if (widget.onRefresh != null) + MenuButton( + leading: material.Icon( + material.Icons.refresh_rounded, + size: 18, + color: muted, + ), + onPressed: (_) => widget.onRefresh!(), + child: const Text('Refresh'), + ), + MenuButton( + leading: material.Icon( + material.Icons.copy_rounded, + size: 18, + color: muted, + ), + onPressed: (_) { + Clipboard.setData(ClipboardData(text: label)); + }, + child: const Text('Copy name'), + ), + if (widget.onOpenSql != null) + MenuButton( + leading: material.Icon( + material.Icons.terminal_rounded, + size: 18, + color: muted, + ), + onPressed: (_) => widget.onOpenSql!(), + child: const Text('Open in SQL'), + ), + ], + child: row, + ); + + return QueryaTreeIndentGuide( + depth: 1, + step: QueryaTreeTokens.underConnection, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Semantics( + button: true, + expanded: _expanded, + label: label, + child: menu, + ), + QueryaAnimatedExpand( + expanded: _expanded, + estimatedChildCount: widget.databaseCount, + child: widget.child, + ), + ], + ), + ); + } +} diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 68d6fa2c..e0b193be 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -1,10 +1,8 @@ import 'package:flutter/material.dart' as material show AlertDialog, - BoxConstraints, BuildContext, Column, - ConstrainedBox, Container, BoxDecoration, Border, @@ -36,13 +34,10 @@ import 'package:flutter/material.dart' as material Text, TextOverflow, Expanded, - CircularProgressIndicator, Material, Semantics, StatelessWidget, Colors, - Tooltip, - Color, Padding, Widget, Navigator, @@ -54,14 +49,15 @@ import 'package:flutter/material.dart' as material ClampingScrollPhysics, CallbackShortcuts, SingleActivator, - AnimatedContainer, TextField, InputDecoration, InputBorder, TextEditingController, FocusNode; -import 'package:flutter/services.dart' - show Clipboard, ClipboardData, LogicalKeyboardKey; +import 'package:flutter/services.dart' show LogicalKeyboardKey; +import 'package:querya_desktop/core/actions/querya_schema_object.dart'; +import 'package:querya_desktop/core/actions/querya_schema_object_cache.dart'; +import 'package:querya_desktop/core/actions/querya_schema_object_loader.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; @@ -78,10 +74,12 @@ import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/core/ui/querya_icon_sizes.dart'; import 'package:querya_desktop/core/ui/querya_icons.dart'; -import 'package:querya_desktop/core/ui/querya_tooltip.dart'; +import 'package:querya_desktop/core/ui/querya_tree_indent_guide.dart'; import 'package:querya_desktop/core/ui/querya_tree_tokens.dart'; import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; +import 'package:querya_desktop/features/connections/connection_databases_folder.dart'; import 'package:querya_desktop/features/connections/driver_icon.dart'; +import 'package:querya_desktop/features/connections/querya_connection_tree_row.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/database/redis_service.dart'; import 'package:querya_desktop/app/app_shutdown.dart'; @@ -116,10 +114,17 @@ const double kConnectionTreeRowExtent = 28; /// Build all rows inline when the list is short. const int kConnectionTreeEagerThreshold = 24; -/// Max rows visible before nested list scrolls (virtualized via [ListView.builder]). +/// Max rows visible in a nested leaf viewport (virtualized via [ListView.builder]). const int kConnectionTreeMaxVisibleRows = 14; -/// Builds a short [Column] or a height-capped [ListView.builder] for large lists. +/// Builds connection-tree children without nested [shrinkWrap] layout passes. +/// +/// - **No [itemExtent]** (databases, schemas, folder tiles): always a [Column] +/// so expand/collapse participates in the outer sidebar [CustomScrollView] +/// instead of nesting a second scroller (#724). +/// - **With [itemExtent]** and `itemCount > [eagerThreshold]`: a fixed-height +/// [ListView.builder] (SDUI-style — **not** shrink-wrapped) so only the +/// viewport (+ cacheExtent) leaf rows are built for large table lists. material.Widget lazyConnectionTreeList({ required material.BuildContext context, required int itemCount, @@ -133,26 +138,29 @@ material.Widget lazyConnectionTreeList({ if (itemCount == 0) { return const material.SizedBox.shrink(); } - if (itemCount <= eagerThreshold) { - return material.Column( + + final rowExtent = itemExtent; + if (rowExtent == null || itemCount <= eagerThreshold) { + final pad = padding; + final column = material.Column( mainAxisSize: material.MainAxisSize.min, crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ for (var i = 0; i < itemCount; i++) itemBuilder(context, i), ], ); + if (pad == null) return column; + return material.Padding(padding: pad, child: column); } - final rowExtent = itemExtent ?? kConnectionTreeRowExtent; - return material.ConstrainedBox( - constraints: material.BoxConstraints( - maxHeight: maxVisibleRows * rowExtent, - ), + + return material.SizedBox( + height: maxVisibleRows * rowExtent, child: material.ListView.builder( padding: padding ?? material.EdgeInsets.zero, - shrinkWrap: true, physics: const material.ClampingScrollPhysics(), itemCount: itemCount, - itemExtent: itemExtent, + itemExtent: rowExtent, + cacheExtent: rowExtent * 4, itemBuilder: itemBuilder, ), ); @@ -260,43 +268,161 @@ class TreeObjectFilterBar extends material.StatelessWidget { } /// Inherited scope providing the active connection and object selection down the connections tree. +/// +/// The [listenable] is obtained via [listenableOf] **without** registering an +/// InheritedWidget dependency. Leaves should use [_ConnectionsTreeSelectionBuilder] +/// so only rows whose selected slice changes call [State.setState]. class _ConnectionsTreeSelectionScope extends InheritedWidget { const _ConnectionsTreeSelectionScope({ - required this.selectedConnectionId, - required this.selectedPostgresObject, - required this.selectedMysqlObject, - required this.selectedSqliteObject, - required this.selectedExtensionObject, - required this.selectedRedisDb, - required this.selectedMongoDb, + required this.listenable, required super.child, }); + final ValueNotifier<_ConnectionsTreeSelection> listenable; + + static ValueNotifier<_ConnectionsTreeSelection>? listenableOf( + material.BuildContext context, + ) { + return context + .getInheritedWidgetOfExactType<_ConnectionsTreeSelectionScope>() + ?.listenable; + } + + @override + bool updateShouldNotify(_ConnectionsTreeSelectionScope oldWidget) { + return listenable != oldWidget.listenable; + } +} + +@immutable +class _ConnectionsTreeSelection { + const _ConnectionsTreeSelection({ + this.selectedConnectionId, + this.selectedPostgresObject, + this.selectedMysqlObject, + this.selectedSqliteObject, + this.selectedExtensionObject, + this.selectedRedisDb, + this.selectedMongoDb, + }); + + static const empty = _ConnectionsTreeSelection(); + final int? selectedConnectionId; - final ({String database, String schema, String name, PostgresObjectKind kind})? - selectedPostgresObject; - final ({String database, String name, MysqlObjectKind kind})? - selectedMysqlObject; + final ({ + String database, + String schema, + String name, + PostgresObjectKind kind + })? selectedPostgresObject; + final ({ + String database, + String name, + MysqlObjectKind kind + })? selectedMysqlObject; final ({String name, SqliteObjectKind kind})? selectedSqliteObject; final ({String database, String name})? selectedExtensionObject; final int? selectedRedisDb; final String? selectedMongoDb; - static _ConnectionsTreeSelectionScope? of(material.BuildContext context) { - return context - .dependOnInheritedWidgetOfExactType<_ConnectionsTreeSelectionScope>(); + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is _ConnectionsTreeSelection && + selectedConnectionId == other.selectedConnectionId && + selectedPostgresObject == other.selectedPostgresObject && + selectedMysqlObject == other.selectedMysqlObject && + selectedSqliteObject == other.selectedSqliteObject && + selectedExtensionObject == other.selectedExtensionObject && + selectedRedisDb == other.selectedRedisDb && + selectedMongoDb == other.selectedMongoDb; } @override - bool updateShouldNotify(_ConnectionsTreeSelectionScope oldWidget) { - return selectedConnectionId != oldWidget.selectedConnectionId || - selectedPostgresObject != oldWidget.selectedPostgresObject || - selectedMysqlObject != oldWidget.selectedMysqlObject || - selectedSqliteObject != oldWidget.selectedSqliteObject || - selectedExtensionObject != oldWidget.selectedExtensionObject || - selectedRedisDb != oldWidget.selectedRedisDb || - selectedMongoDb != oldWidget.selectedMongoDb; + int get hashCode => Object.hash( + selectedConnectionId, + selectedPostgresObject, + selectedMysqlObject, + selectedSqliteObject, + selectedExtensionObject, + selectedRedisDb, + selectedMongoDb, + ); +} + +/// Rebuilds only when [select] yields a new value (`==`), avoiding full-tree +/// InheritedWidget fan-out on every selection change. +class _ConnectionsTreeSelectionBuilder extends StatefulWidget { + const _ConnectionsTreeSelectionBuilder({ + required this.select, + required this.builder, + }); + + final T Function(_ConnectionsTreeSelection selection) select; + final material.Widget Function(material.BuildContext context, T selected) + builder; + + @override + State<_ConnectionsTreeSelectionBuilder> createState() => + _ConnectionsTreeSelectionBuilderState(); +} + +class _ConnectionsTreeSelectionBuilderState + extends State<_ConnectionsTreeSelectionBuilder> { + ValueNotifier<_ConnectionsTreeSelection>? _listenable; + late T _slice; + var _hasSlice = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final next = _ConnectionsTreeSelectionScope.listenableOf(context); + if (!identical(_listenable, next)) { + _listenable?.removeListener(_onSelectionChanged); + _listenable = next; + _listenable?.addListener(_onSelectionChanged); + } + final computed = _compute(); + if (!_hasSlice) { + _slice = computed; + _hasSlice = true; + } else if (computed != _slice) { + setState(() => _slice = computed); + } + } + + @override + void didUpdateWidget(covariant _ConnectionsTreeSelectionBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.select != widget.select) { + final next = _compute(); + if (next != _slice) { + setState(() => _slice = next); + } + } + } + + T _compute() { + final value = _listenable?.value ?? _ConnectionsTreeSelection.empty; + return widget.select(value); + } + + void _onSelectionChanged() { + final next = _compute(); + if (next != _slice) { + setState(() => _slice = next); + } + } + + @override + void dispose() { + _listenable?.removeListener(_onSelectionChanged); + super.dispose(); } + + @override + material.Widget build(material.BuildContext context) => + widget.builder(context, _slice); } /// Left panel: Browser tree (pgAdmin-style). Uses shadcn layout widgets. @@ -412,21 +538,46 @@ class ConnectionsPanelState extends State { /// Ignores stale [setState] when multiple [_loadData] runs overlap (e.g. tests). int _loadDataGeneration = 0; + late final ValueNotifier<_ConnectionsTreeSelection> _treeSelection; + @override void initState() { super.initState(); + _treeSelection = ValueNotifier(_selectionFromWidget()); if (!widget.skipInitialDbLoadForTest) { _loadData(); } } + @override + void didUpdateWidget(covariant ConnectionsPanel oldWidget) { + super.didUpdateWidget(oldWidget); + final next = _selectionFromWidget(); + if (next != _treeSelection.value) { + _treeSelection.value = next; + } + } + @override void dispose() { + _treeSelection.dispose(); _searchController.dispose(); _searchFocusNode.dispose(); super.dispose(); } + _ConnectionsTreeSelection _selectionFromWidget() { + return _ConnectionsTreeSelection( + selectedConnectionId: widget.selectedConnectionId, + selectedPostgresObject: widget.selectedPostgresObject, + selectedMysqlObject: widget.selectedMysqlObject, + selectedSqliteObject: widget.selectedSqliteObject, + selectedExtensionObject: widget.selectedExtensionObject, + selectedRedisDb: widget.selectedRedisDb, + selectedMongoDb: widget.selectedMongoDb, + ); + } + /// For testing global search filtering. void setSearchQueryForTest(String query) { _searchController.text = query; @@ -756,13 +907,7 @@ class ConnectionsPanelState extends State { filteredFolders.length + rootConnections.length + (showEmptyState ? 1 : 0); return _ConnectionsTreeSelectionScope( - selectedConnectionId: widget.selectedConnectionId, - selectedPostgresObject: widget.selectedPostgresObject, - selectedMysqlObject: widget.selectedMysqlObject, - selectedSqliteObject: widget.selectedSqliteObject, - selectedExtensionObject: widget.selectedExtensionObject, - selectedRedisDb: widget.selectedRedisDb, - selectedMongoDb: widget.selectedMongoDb, + listenable: _treeSelection, child: material.CallbackShortcuts( bindings: { const material.SingleActivator(LogicalKeyboardKey.keyF, control: true): @@ -784,7 +929,13 @@ class ConnectionsPanelState extends State { crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ material.Padding( - padding: const material.EdgeInsets.fromLTRB(20, 24, 16, 12), + key: const material.ValueKey('connections-servers-header'), + padding: const material.EdgeInsets.fromLTRB( + 20, + QueryaTreeTokens.serversHeaderTop, + 16, + 12, + ), child: material.Row( children: [ material.Expanded( @@ -800,14 +951,24 @@ class ConnectionsPanelState extends State { ), ), if (_connections.isNotEmpty) - material.Text( - q.isEmpty - ? '${_connections.length}' - : '${filteredConnections.length}/${_connections.length}', - style: material.TextStyle( - fontSize: 10, - fontWeight: material.FontWeight.w600, - color: theme.colorScheme.mutedForeground, + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 6, + vertical: 1, + ), + decoration: material.BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.28), + borderRadius: material.BorderRadius.circular(8), + ), + child: material.Text( + q.isEmpty + ? '${_connections.length}' + : '${filteredConnections.length}/${_connections.length}', + style: material.TextStyle( + fontSize: 10, + fontWeight: material.FontWeight.w600, + color: theme.colorScheme.mutedForeground, + ), ), ), ], diff --git a/lib/features/connections/connections_panel_extension.dart b/lib/features/connections/connections_panel_extension.dart index 99888de0..505c9b7c 100644 --- a/lib/features/connections/connections_panel_extension.dart +++ b/lib/features/connections/connections_panel_extension.dart @@ -100,6 +100,14 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { _schema = schema; _loading = false; }); + final cacheId = widget.connection.id; + if (cacheId != null) { + QueryaSchemaObjectCache.instance.merge( + cacheId, + QueryaSchemaObjectCache.scopeExtension(), + flattenExtensionTree(schema), + ); + } } catch (e) { if (!mounted) return; setState(() { @@ -139,11 +147,6 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final sel = _ConnectionsTreeSelectionScope.of(context); - final selectedExt = (sel != null && - sel.selectedConnectionId == widget.connection.id) - ? sel.selectedExtensionObject - : null; final material.Widget iconWidget; if (_iconFilePath != null) { iconWidget = DriverIconImage( @@ -175,6 +178,15 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { return ContextMenu( items: [ + MenuButton( + leading: material.Icon(material.Icons.refresh_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) { + setState(() => _schema = null); + _loadTree(); + }, + child: const Text('Refresh tree'), + ), MenuButton( leading: material.Icon(material.Icons.edit_outlined, size: 18, color: theme.colorScheme.mutedForeground), @@ -248,9 +260,6 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { maxLines: 1, style: material.TextStyle( fontSize: 13, - fontWeight: widget.isSelected - ? material.FontWeight.w600 - : material.FontWeight.w500, color: theme.colorScheme.foreground, ), ), @@ -281,40 +290,18 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { mainAxisSize: material.MainAxisSize.min, children: [ if (_loading) - material.Padding( - padding: const material.EdgeInsets.only( - left: 28, - top: 4, - bottom: 4, - ), - child: material.Row( - children: [ - const material.SizedBox( - width: 12, - height: 12, - child: material.CircularProgressIndicator( - strokeWidth: 1.5, - ), - ), - const Gap(8), - const Text('Loading...').muted().xSmall(), - ], - ), - ) + const ConnectionTreeLoadingRow.connection() else if (_error != null) TreeLoadError( title: 'Could not load extension tree', message: _error!, - padding: const material.EdgeInsets.only( - left: 28, - top: 4, - bottom: 8, - ), + padding: QueryaTreeTokens.errorPaddingConnection, onRetry: _loadTree, ) else if (_schema != null) material.Padding( - padding: const material.EdgeInsets.only(left: 20), + padding: const material.EdgeInsets.only( + left: QueryaTreeTokens.underConnection), child: _schema!.roots.isEmpty ? material.Padding( padding: const material.EdgeInsets.fromLTRB( @@ -323,35 +310,53 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { 'No databases found on this server.', ).muted().small(), ) - : SduiTreeBuilder( - schema: _schema!, - fetchChildren: _fetchChildren, - onNodeSelected: _onNodeSelected, - isNodeSelected: selectedExt == null - ? null - : (node) { - final metaDb = node.meta['database'] ?? - node.meta['db']; - final metaName = node.meta['table'] ?? - node.meta['tableName'] ?? - node.meta['name']; - if (metaDb != null && metaName != null) { - if (metaDb == selectedExt.database && - metaName == selectedExt.name) { - return true; - } - } - final parts = node.id.split('.'); - if (parts.length >= 3) { - final db = parts[1]; - final name = parts.sublist(2).join('.'); - return db == selectedExt.database && - name == selectedExt.name; - } - return false; - }, - maxHeight: kConnectionTreeMaxVisibleRows * - kConnectionTreeRowExtent, + : _ConnectionsTreeSelectionBuilder< + ({String database, String name})?>( + select: (sel) => + sel.selectedConnectionId == + widget.connection.id + ? sel.selectedExtensionObject + : null, + builder: (context, selectedExt) { + return SduiTreeBuilder( + schema: _schema!, + connection: widget.connection, + fetchChildren: _fetchChildren, + onNodeSelected: _onNodeSelected, + isNodeSelected: selectedExt == null + ? null + : (node) { + final metaDb = + node.meta['database'] ?? + node.meta['db']; + final metaName = + node.meta['table'] ?? + node.meta['tableName'] ?? + node.meta['name']; + if (metaDb != null && + metaName != null) { + if (metaDb == + selectedExt.database && + metaName == selectedExt.name) { + return true; + } + } + final parts = node.id.split('.'); + if (parts.length >= 3) { + final db = parts[1]; + final name = + parts.sublist(2).join('.'); + return db == + selectedExt.database && + name == selectedExt.name; + } + return false; + }, + maxHeight: + kConnectionTreeMaxVisibleRows * + kConnectionTreeRowExtent, + ); + }, ), ), ], diff --git a/lib/features/connections/connections_panel_mongo.dart b/lib/features/connections/connections_panel_mongo.dart index 57468398..a990e903 100644 --- a/lib/features/connections/connections_panel_mongo.dart +++ b/lib/features/connections/connections_panel_mongo.dart @@ -276,38 +276,18 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { // Expanded database children QueryaAnimatedExpand( expanded: _expanded, + estimatedChildCount: _databases.length, child: material.Column( mainAxisSize: material.MainAxisSize.min, crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ if (_loading) - material.Padding( - padding: const material.EdgeInsets.only( - left: 28, top: 4, bottom: 4), - child: material.Row( - children: [ - const material.SizedBox( - width: 12, - height: 12, - child: material.CircularProgressIndicator( - strokeWidth: 1.5), - ), - const Gap(8), - const Text('Loading...').muted().xSmall(), - ], - ), - ), + const ConnectionTreeLoadingRow.connection(), if (_error != null) TreeLoadError( title: 'Could not load databases', message: _error!, - detailFontSize: 10, - padding: const material.EdgeInsets.only( - left: 28, - top: 4, - bottom: 4, - right: 8, - ), + padding: QueryaTreeTokens.errorPaddingConnection, onRetry: _loadDatabases, ), if (_databases.isNotEmpty) @@ -348,42 +328,23 @@ class _MongoDatabasesNode extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return material.Padding( - padding: const material.EdgeInsets.only(left: 20), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - _PgTreeRow( - label: 'Databases (${databases.length})', - icon: QueryaIcons.databasesFolder, - iconSize: QueryaIconSizes.treeConnection, - iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), - textStyle: material.TextStyle( - fontSize: 12, - color: theme.colorScheme.foreground, - ), - verticalPadding: 4, - onTap: null, + return ConnectionDatabasesFolder( + connection: connection, + databaseCount: databases.length, + onRefresh: onRefreshDatabases, + child: lazyConnectionTreeList( + context: context, + itemCount: databases.length, + itemBuilder: (context, index) { + final db = databases[index]; + return _MongoDatabaseNode( connection: connection, - onContextRefresh: onRefreshDatabases, - ), - lazyConnectionTreeList( - context: context, - itemCount: databases.length, - itemBuilder: (context, index) { - final db = databases[index]; - return _MongoDatabaseNode( - connection: connection, - name: db, - onTap: () => onDatabaseTap?.call(db), - onDelete: () => onDeleteDatabase(db), - onRefreshDatabases: onRefreshDatabases, - ); - }, - ), - ], + name: db, + onTap: () => onDatabaseTap?.call(db), + onDelete: () => onDeleteDatabase(db), + onRefreshDatabases: onRefreshDatabases, + ); + }, ), ); } @@ -407,30 +368,34 @@ class _MongoDatabaseNode extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final sel = _ConnectionsTreeSelectionScope.of(context); - final isSelected = sel != null && - sel.selectedConnectionId == connection.id && - sel.selectedMongoDb == name; - return material.Padding( - padding: const material.EdgeInsets.only(left: 16, top: 2, bottom: 2), - child: _PgTreeRow( - label: name, - isSelected: isSelected, - icon: QueryaIcons.database, - iconSize: QueryaIconSizes.treeConnection, - iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), - textStyle: material.TextStyle( - fontSize: 12, - color: theme.colorScheme.foreground, - ), - verticalPadding: 3, - onTap: onTap, - connection: connection, - onContextRefresh: onRefreshDatabases, - onOpenSqlWorkspace: null, - onContextDelete: onDelete, - contextDeleteLabel: 'Delete database', - ), + return _ConnectionsTreeSelectionBuilder( + select: (sel) => + sel.selectedConnectionId == connection.id && + sel.selectedMongoDb == name, + builder: (context, isSelected) { + return QueryaTreeIndentGuide( + depth: 1, + padding: const material.EdgeInsets.only(top: 2, bottom: 2), + child: QueryaConnectionTreeRow( + label: name, + isSelected: isSelected, + icon: QueryaIcons.database, + iconSize: QueryaIconSizes.treeConnection, + iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), + textStyle: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.foreground, + ), + verticalPadding: 3, + onTap: onTap, + connection: connection, + onContextRefresh: onRefreshDatabases, + onOpenSqlWorkspace: null, + onContextDelete: onDelete, + contextDeleteLabel: 'Delete database', + ), + ); + }, ); } } diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index 32fc7ae9..8d9cf507 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -239,36 +239,18 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { ), QueryaAnimatedExpand( expanded: _expanded, + estimatedChildCount: _databases.length, child: material.Column( mainAxisSize: material.MainAxisSize.min, crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ if (_loading) - material.Padding( - padding: const material.EdgeInsets.only( - left: 28, top: 4, bottom: 4), - child: material.Row( - children: [ - const material.SizedBox( - width: 12, - height: 12, - child: material.CircularProgressIndicator( - strokeWidth: 1.5), - ), - const Gap(8), - const Text('Loading...').muted().xSmall(), - ], - ), - ), + const ConnectionTreeLoadingRow.connection(), if (_error != null) TreeLoadError( title: 'Could not load databases', message: _error!, - padding: const material.EdgeInsets.only( - left: 28, - top: 4, - bottom: 4, - ), + padding: QueryaTreeTokens.errorPaddingConnection, onRetry: _loadDatabases, ), if (_databases.isNotEmpty) @@ -314,46 +296,26 @@ class _MysqlDatabasesNode extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); - return material.Padding( - padding: const material.EdgeInsets.only(left: 20), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - _PgTreeRow( - label: 'Databases (${databases.length})', - icon: QueryaIcons.databasesFolder, - iconSize: QueryaIconSizes.treeConnection, - iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), - textStyle: material.TextStyle( - fontSize: 12, - color: theme.colorScheme.foreground, - ), - verticalPadding: 4, - onTap: null, + return ConnectionDatabasesFolder( + connection: connection, + databaseCount: databases.length, + onRefresh: onRefreshDatabases, + onOpenSql: onMysqlOpenSqlWorkspace == null + ? null + : () => onMysqlOpenSqlWorkspace!(connection), + child: lazyConnectionTreeList( + context: context, + itemCount: databases.length, + itemBuilder: (context, index) { + final db = databases[index]; + return _MysqlDatabaseNode( + key: material.ValueKey('mysql-db-${connection.id ?? 0}-$db'), connection: connection, - onContextRefresh: onRefreshDatabases, - onOpenSqlWorkspace: onMysqlOpenSqlWorkspace == null - ? null - : (c, {database, schema, name, kind}) => - onMysqlOpenSqlWorkspace!(c), - ), - lazyConnectionTreeList( - context: context, - itemCount: databases.length, - itemBuilder: (context, index) { - final db = databases[index]; - return _MysqlDatabaseNode( - key: material.ValueKey('mysql-db-${connection.id ?? 0}-$db'), - connection: connection, - databaseName: db, - onMysqlObjectSelected: onMysqlObjectSelected, - onMysqlOpenSqlWorkspace: onMysqlOpenSqlWorkspace, - ); - }, - ), - ], + databaseName: db, + onMysqlObjectSelected: onMysqlObjectSelected, + onMysqlOpenSqlWorkspace: onMysqlOpenSqlWorkspace, + ); + }, ), ); } @@ -434,6 +396,27 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { _loading = false; _error = null; }); + final cacheId = widget.connection.id; + if (cacheId != null) { + QueryaSchemaObjectCache.instance.merge( + cacheId, + QueryaSchemaObjectCache.scopeMysql(widget.databaseName), + [ + for (final name in tables) + QueryaSchemaObject.mysql( + database: widget.databaseName, + name: name, + kind: QueryaSchemaObjectKind.table, + ), + for (final name in views) + QueryaSchemaObject.mysql( + database: widget.databaseName, + name: name, + kind: QueryaSchemaObjectKind.view, + ), + ], + ); + } } catch (e) { if (!mounted) return; setState(() { @@ -448,13 +431,13 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { @override Widget build(BuildContext context) { final theme = Theme.of(context); - return material.Padding( - padding: const material.EdgeInsets.only(left: 16), + return QueryaTreeIndentGuide( + depth: 1, child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, mainAxisSize: material.MainAxisSize.min, children: [ - _PgTreeRow( + QueryaConnectionTreeRow( label: widget.databaseName, leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, @@ -490,22 +473,7 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ if (_loading) - material.Padding( - padding: const material.EdgeInsets.only( - left: 24, top: 2, bottom: 2), - child: material.Row( - children: [ - const material.SizedBox( - width: 10, - height: 10, - child: material.CircularProgressIndicator( - strokeWidth: 1.5), - ), - const Gap(6), - const Text('Loading...').muted().xSmall(), - ], - ), - ) + const ConnectionTreeLoadingRow.nested() else if (_error != null) TreeLoadError( title: 'Could not load tables', @@ -516,8 +484,8 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { _views.isNotEmpty || _procedures.isNotEmpty || _functions.isNotEmpty) - material.Padding( - padding: const material.EdgeInsets.only(left: 16), + QueryaTreeIndentGuide( + depth: 1, child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ @@ -674,7 +642,7 @@ class _MysqlObjectGroupState extends State<_MysqlObjectGroup> { crossAxisAlignment: material.CrossAxisAlignment.start, mainAxisSize: material.MainAxisSize.min, children: [ - _PgTreeRow( + QueryaConnectionTreeRow( label: _filter.isEmpty ? '${widget.label} (${widget.items.length})' : '${widget.label} (${sorted.length}/${widget.items.length})', @@ -702,6 +670,7 @@ class _MysqlObjectGroupState extends State<_MysqlObjectGroup> { ), QueryaAnimatedExpand( expanded: _expanded, + estimatedChildCount: widget.items.length, child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, mainAxisSize: material.MainAxisSize.min, @@ -720,68 +689,74 @@ class _MysqlObjectGroupState extends State<_MysqlObjectGroup> { ), if (sorted.isEmpty && widget.items.isNotEmpty) material.Padding( - padding: const material.EdgeInsets.only( - left: 26, top: 4, bottom: 6), + padding: QueryaTreeTokens.emptyFilterPadding, child: Text('No matching ${widget.label.toLowerCase()}') .muted() .xSmall(), ) else - lazyConnectionTreeList( + QueryaTreeIndentGuide( + depth: 1, + leading: QueryaTreeTokens.leafList - QueryaTreeTokens.indent, + child: lazyConnectionTreeList( context: context, itemCount: sorted.length, itemExtent: kConnectionTreeRowExtent, - padding: const material.EdgeInsets.only(left: 26), itemBuilder: (context, index) { final item = sorted[index]; final isPinned = _pinnedItems.contains(item); - final sel = _ConnectionsTreeSelectionScope.of(context); - final isSelected = sel != null && - sel.selectedConnectionId == widget.connection.id && - sel.selectedMysqlObject != null && - sel.selectedMysqlObject!.database == widget.databaseName && - sel.selectedMysqlObject!.name == item && - sel.selectedMysqlObject!.kind == widget.objectKind; - return _PgTreeRow( - key: material.ValueKey( - 'mysql-${widget.objectKind.name}-${widget.databaseName}-$item', - ), - label: item, - isSelected: isSelected, - isPinned: isPinned, - onTogglePin: () { - setState(() { - if (isPinned) { - _pinnedItems.remove(item); - } else { - _pinnedItems.add(item); - } - }); + return _ConnectionsTreeSelectionBuilder( + select: (sel) => + sel.selectedConnectionId == widget.connection.id && + sel.selectedMysqlObject != null && + sel.selectedMysqlObject!.database == + widget.databaseName && + sel.selectedMysqlObject!.name == item && + sel.selectedMysqlObject!.kind == widget.objectKind, + builder: (context, isSelected) { + return QueryaConnectionTreeRow( + key: material.ValueKey( + 'mysql-${widget.objectKind.name}-${widget.databaseName}-$item', + ), + label: item, + isSelected: isSelected, + isPinned: isPinned, + onTogglePin: () { + setState(() { + if (isPinned) { + _pinnedItems.remove(item); + } else { + _pinnedItems.add(item); + } + }); + }, + icon: widget.itemIcon, + iconSize: QueryaIconSizes.treeLeaf, + iconColor: QueryaTreeTokens.leafIconColor( + theme.colorScheme.primary, + ), + textStyle: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.foreground, + ), + verticalPadding: 2, + onTap: widget.onItemTap != null + ? () => widget.onItemTap!(item) + : null, + connection: widget.connection, + openSqlDatabase: widget.databaseName, + openSqlName: item, + onContextRefresh: widget.onRefresh, + onOpenSqlWorkspace: widget.onOpenSqlWorkspace != null + ? (conn, {database, schema, name, kind}) => + widget.onOpenSqlWorkspace!(conn) + : null, + ); }, - icon: widget.itemIcon, - iconSize: QueryaIconSizes.treeLeaf, - iconColor: QueryaTreeTokens.leafIconColor( - theme.colorScheme.primary, - ), - textStyle: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.foreground, - ), - verticalPadding: 2, - onTap: widget.onItemTap != null - ? () => widget.onItemTap!(item) - : null, - connection: widget.connection, - openSqlDatabase: widget.databaseName, - openSqlName: item, - onContextRefresh: widget.onRefresh, - onOpenSqlWorkspace: widget.onOpenSqlWorkspace != null - ? (conn, {database, schema, name, kind}) => - widget.onOpenSqlWorkspace!(conn) - : null, ); }, ), + ), ], ), ), diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart index 8112f149..7133f6b4 100644 --- a/lib/features/connections/connections_panel_pg_tree.dart +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -1,6 +1,6 @@ part of 'package:querya_desktop/features/connections/connections_panel.dart'; -class _PgDatabasesNode extends StatefulWidget { +class _PgDatabasesNode extends StatelessWidget { const _PgDatabasesNode({ required this.connection, required this.databases, @@ -21,367 +21,28 @@ class _PgDatabasesNode extends StatefulWidget { final OnPostgresOpenSqlWorkspace? onPostgresOpenSqlWorkspace; final VoidCallback onRefreshDatabases; - @override - State<_PgDatabasesNode> createState() => _PgDatabasesNodeState(); -} - -/// Ellipsis label; tooltip when the name is long enough to likely truncate. -class _PgTreeRowLabel extends material.StatelessWidget { - const _PgTreeRowLabel({ - required this.label, - required this.textStyle, - }); - - final String label; - final material.TextStyle textStyle; - - static const int _tooltipMinLength = 28; - - @override - material.Widget build(material.BuildContext context) { - final text = material.Text( - label, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: textStyle, - ); - if (label.length < _tooltipMinLength) return text; - return material.Tooltip( - message: label, - waitDuration: kQueryaTooltipWait, - child: text, - ); - } -} - -/// Shared tree row: consistent ink hover, optional context menu, tooltips when truncated. -class _PgTreeRow extends material.StatelessWidget { - const _PgTreeRow({ - super.key, - required this.label, - this.isSelected = false, - this.leading, - this.icon, - this.iconSize = QueryaIconSizes.treeGroup, - this.iconColor, - this.trailing, - this.onTap, - this.expanded, - this.verticalPadding = 3, - required this.textStyle, - this.connection, - this.onContextRefresh, - this.onOpenSqlWorkspace, - this.openSqlDatabase, - this.openSqlSchema, - this.openSqlName, - this.openSqlKind, - this.onContextDelete, - this.contextDeleteLabel, - this.isPinned = false, - this.onTogglePin, - }); - - final String label; - final bool isSelected; - final material.Widget? leading; - final material.IconData? icon; - final double iconSize; - final material.Color? iconColor; - final material.Widget? trailing; - final void Function()? onTap; - - /// When non-null, row is an expand control ([Semantics.button] + expanded). - final bool? expanded; - final double verticalPadding; - final material.TextStyle textStyle; - final ConnectionRow? connection; - final VoidCallback? onContextRefresh; - final OnPostgresOpenSqlWorkspace? onOpenSqlWorkspace; - final String? openSqlDatabase; - final String? openSqlSchema; - final String? openSqlName; - final PostgresObjectKind? openSqlKind; - final VoidCallback? onContextDelete; - final String? contextDeleteLabel; - final bool isPinned; - final VoidCallback? onTogglePin; - - @override - material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); - final primary = theme.colorScheme.primary; - final muted = theme.colorScheme.mutedForeground; - final row = material.CallbackShortcuts( - bindings: { - if (expanded == false && onTap != null) - const material.SingleActivator(LogicalKeyboardKey.arrowRight): () => - onTap!(), - if (expanded == true && onTap != null) - const material.SingleActivator(LogicalKeyboardKey.arrowLeft): () => - onTap!(), - }, - child: material.AnimatedContainer( - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), - decoration: material.BoxDecoration( - color: isSelected - ? primary.withValues(alpha: 0.12) - : material.Colors.transparent, - borderRadius: material.BorderRadius.circular(4), - border: isSelected - ? material.Border.all( - color: primary.withValues(alpha: 0.35), - width: 1, - ) - : null, - ), - child: material.Material( - color: material.Colors.transparent, - child: material.InkWell( - onTap: onTap, - canRequestFocus: onTap != null, - borderRadius: material.BorderRadius.circular(4), - hoverColor: primary.withValues(alpha: 0.07), - focusColor: primary.withValues(alpha: 0.14), - splashColor: primary.withValues(alpha: 0.10), - highlightColor: primary.withValues(alpha: 0.05), - mouseCursor: onTap != null - ? material.SystemMouseCursors.click - : material.SystemMouseCursors.basic, - child: material.Padding( - padding: material.EdgeInsets.symmetric( - horizontal: 4, - vertical: verticalPadding, - ), - child: material.Row( - children: [ - if (leading != null) ...[ - leading!, - const Gap(4), - ], - if (icon != null) ...[ - material.Icon( - icon, - size: iconSize, - color: isSelected ? primary : (iconColor ?? muted), - ), - const Gap(6), - ], - if (isPinned) ...[ - material.Icon( - material.Icons.star_rounded, - size: 13, - color: material.Colors.amber.shade600, - ), - const Gap(4), - ], - material.Expanded( - child: _PgTreeRowLabel( - label: label, - textStyle: isSelected - ? textStyle.copyWith( - fontWeight: material.FontWeight.w600, - color: theme.colorScheme.foreground, - ) - : textStyle, - ), - ), - if (trailing != null) trailing!, - ], - ), - ), - ), - ), - ), - ); - if (connection == null) { - return expanded == null - ? row - : material.Semantics( - button: true, - expanded: expanded, - child: row, - ); - } - final menu = ContextMenu( - items: [ - if (openSqlName != null) ...[ - if (onTogglePin != null) ...[ - MenuButton( - leading: material.Icon( - isPinned - ? material.Icons.star_rounded - : material.Icons.star_border_rounded, - size: 18, - color: isPinned - ? material.Colors.amber.shade600 - : theme.colorScheme.mutedForeground, - ), - onPressed: (_) => onTogglePin!(), - child: Text(isPinned ? 'Unpin from top' : 'Pin to top ⭐'), - ), - const MenuDivider(), - ], - MenuButton( - leading: material.Icon( - material.Icons.table_rows_outlined, - size: 18, - color: theme.colorScheme.primary, - ), - onPressed: (_) { - final qualified = openSqlSchema != null && openSqlSchema!.isNotEmpty - ? '"$openSqlSchema"."$openSqlName"' - : '"$openSqlName"'; - Clipboard.setData( - ClipboardData(text: 'SELECT * FROM $qualified LIMIT 100;'), - ); - if (onOpenSqlWorkspace != null && connection != null) { - onOpenSqlWorkspace!( - connection!, - database: openSqlDatabase, - schema: openSqlSchema, - name: openSqlName, - kind: openSqlKind, - ); - } - }, - child: const Text('Select TOP 100'), - ), - MenuButton( - leading: material.Icon( - material.Icons.code_rounded, - size: 18, - color: theme.colorScheme.mutedForeground, - ), - onPressed: (_) { - final qualified = openSqlSchema != null && openSqlSchema!.isNotEmpty - ? '"$openSqlSchema"."$openSqlName"' - : '"$openSqlName"'; - Clipboard.setData(ClipboardData(text: 'SELECT * FROM $qualified;')); - }, - child: const Text('Copy SELECT statement'), - ), - const MenuDivider(), - ], - if (onContextRefresh != null) - MenuButton( - leading: material.Icon( - material.Icons.refresh_rounded, - size: 18, - color: theme.colorScheme.mutedForeground, - ), - onPressed: (_) => onContextRefresh!(), - child: const Text('Refresh'), - ), - MenuButton( - leading: material.Icon( - material.Icons.copy_rounded, - size: 18, - color: theme.colorScheme.mutedForeground, - ), - onPressed: (_) { - Clipboard.setData(ClipboardData(text: label)); - }, - child: const Text('Copy name'), - ), - if (onOpenSqlWorkspace != null && openSqlName == null) - MenuButton( - leading: material.Icon( - material.Icons.terminal_rounded, - size: 18, - color: theme.colorScheme.mutedForeground, - ), - onPressed: (_) => onOpenSqlWorkspace!( - connection!, - database: openSqlDatabase, - schema: openSqlSchema, - name: openSqlName, - kind: openSqlKind, - ), - child: const Text('Open in SQL'), - ), - if (onContextDelete != null) ...[ - const MenuDivider(), - MenuButton( - leading: material.Icon( - material.Icons.delete_outline_rounded, - size: 18, - color: theme.colorScheme.destructive, - ), - onPressed: (_) => onContextDelete!(), - child: Text(contextDeleteLabel ?? 'Delete'), - ), - ], - ], - child: row, - ); - if (expanded == null) return menu; - return material.Semantics( - button: true, - expanded: expanded, - child: menu, - ); - } -} - -class _PgDatabasesNodeState extends State<_PgDatabasesNode> { - bool _expanded = true; - @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return material.Padding( - padding: const material.EdgeInsets.only(left: 20), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - _PgTreeRow( - label: 'Databases (${widget.databases.length})', - leading: material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.treeExpand), - curve: context.motionCurve(QueryaMotion.treeExpandCurve), - child: material.Icon( - QueryaIcons.expandClosed, - size: QueryaIconSizes.treeExpand, - color: theme.colorScheme.mutedForeground, - ), - ), - icon: QueryaIcons.databasesFolder, - iconSize: QueryaIconSizes.treeConnection, - iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), - textStyle: material.TextStyle( - fontSize: 12, - color: theme.colorScheme.foreground, - ), - verticalPadding: 4, - expanded: _expanded, - onTap: () => setState(() => _expanded = !_expanded), - connection: widget.connection, - onContextRefresh: widget.onRefreshDatabases, - onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - ), - QueryaAnimatedExpand( - expanded: _expanded, - child: lazyConnectionTreeList( - context: context, - itemCount: widget.databases.length, - itemBuilder: (context, index) { - final db = widget.databases[index]; - return _PgDatabaseNode( - key: material.ValueKey( - 'pg-db-${widget.connection.id ?? 0}-$db'), - connection: widget.connection, - databaseName: db, - onPostgresObjectSelected: widget.onPostgresObjectSelected, - onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - ); - }, - ), - ), - ], + return ConnectionDatabasesFolder( + connection: connection, + databaseCount: databases.length, + onRefresh: onRefreshDatabases, + onOpenSql: onPostgresOpenSqlWorkspace == null + ? null + : () => onPostgresOpenSqlWorkspace!(connection), + child: lazyConnectionTreeList( + context: context, + itemCount: databases.length, + itemBuilder: (context, index) { + final db = databases[index]; + return _PgDatabaseNode( + key: material.ValueKey('pg-db-${connection.id ?? 0}-$db'), + connection: connection, + databaseName: db, + onPostgresObjectSelected: onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: onPostgresOpenSqlWorkspace, + ); + }, ), ); } @@ -459,13 +120,13 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { @override Widget build(BuildContext context) { final theme = Theme.of(context); - return material.Padding( - padding: const material.EdgeInsets.only(left: 16), + return QueryaTreeIndentGuide( + depth: 1, child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, mainAxisSize: material.MainAxisSize.min, children: [ - _PgTreeRow( + QueryaConnectionTreeRow( label: widget.databaseName, leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, @@ -518,22 +179,7 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { onContextRefresh: _loadSchemas, ), if (_loading) - material.Padding( - padding: const material.EdgeInsets.only( - left: 24, top: 2, bottom: 2), - child: material.Row( - children: [ - const material.SizedBox( - width: 10, - height: 10, - child: material.CircularProgressIndicator( - strokeWidth: 1.5), - ), - const Gap(6), - const Text('Loading...').muted().xSmall(), - ], - ), - ) + const ConnectionTreeLoadingRow.nested() else if (_error != null) TreeLoadError( title: 'Could not load schemas', @@ -590,9 +236,10 @@ class _PgDbToolRow extends material.StatelessWidget { material.Widget build(material.BuildContext context) { final theme = Theme.of(context); final muted = theme.colorScheme.mutedForeground; - return material.Padding( - padding: const material.EdgeInsets.only(left: 16, top: 2, bottom: 2), - child: _PgTreeRow( + return QueryaTreeIndentGuide( + depth: 1, + padding: const material.EdgeInsets.only(top: 2, bottom: 2), + child: QueryaConnectionTreeRow( label: label, icon: icon, iconSize: QueryaIconSizes.treeGroup, @@ -656,13 +303,13 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { @override Widget build(BuildContext context) { final theme = Theme.of(context); - return material.Padding( - padding: const material.EdgeInsets.only(left: 16), + return QueryaTreeIndentGuide( + depth: 1, child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, mainAxisSize: material.MainAxisSize.min, children: [ - _PgTreeRow( + QueryaConnectionTreeRow( label: 'Schemas (${widget.schemas.length})', leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, @@ -689,6 +336,7 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { ), QueryaAnimatedExpand( expanded: _expanded, + estimatedChildCount: widget.schemas.length, child: lazyConnectionTreeList( context: context, itemCount: widget.schemas.length, @@ -793,6 +441,36 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { _loaded = true; _error = null; }); + final cacheId = widget.connection.id; + if (cacheId != null) { + QueryaSchemaObjectCache.instance.merge( + cacheId, + QueryaSchemaObjectCache.scopePostgres(widget.databaseName), + [ + for (final name in tables) + QueryaSchemaObject.postgres( + database: widget.databaseName, + schema: widget.schemaName, + name: name, + kind: QueryaSchemaObjectKind.table, + ), + for (final name in views) + QueryaSchemaObject.postgres( + database: widget.databaseName, + schema: widget.schemaName, + name: name, + kind: QueryaSchemaObjectKind.view, + ), + for (final name in matviews) + QueryaSchemaObject.postgres( + database: widget.databaseName, + schema: widget.schemaName, + name: name, + kind: QueryaSchemaObjectKind.materializedView, + ), + ], + ); + } } catch (e) { if (!mounted) return; setState(() { @@ -813,13 +491,13 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { @override Widget build(BuildContext context) { final theme = Theme.of(context); - return material.Padding( - padding: const material.EdgeInsets.only(left: QueryaTreeTokens.indent), + return QueryaTreeIndentGuide( + depth: 1, child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, mainAxisSize: material.MainAxisSize.min, children: [ - _PgTreeRow( + QueryaConnectionTreeRow( label: widget.schemaName, leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, @@ -851,22 +529,7 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ if (_loading) - material.Padding( - padding: const material.EdgeInsets.only( - left: 24, top: 2, bottom: 2), - child: material.Row( - children: [ - const material.SizedBox( - width: 10, - height: 10, - child: material.CircularProgressIndicator( - strokeWidth: 1.5), - ), - const Gap(6), - const Text('Loading...').muted().xSmall(), - ], - ), - ) + const ConnectionTreeLoadingRow.nested() else if (_error != null) TreeLoadError( title: 'Could not load objects', @@ -1063,9 +726,10 @@ class _PgSchemaToolRow extends material.StatelessWidget { material.Widget build(material.BuildContext context) { final theme = Theme.of(context); final muted = theme.colorScheme.mutedForeground; - return material.Padding( - padding: const material.EdgeInsets.only(left: 16, top: 2, bottom: 2), - child: _PgTreeRow( + return QueryaTreeIndentGuide( + depth: 1, + padding: const material.EdgeInsets.only(top: 2, bottom: 2), + child: QueryaConnectionTreeRow( label: label, icon: icon, iconSize: QueryaIconSizes.treeGroup, @@ -1153,13 +817,13 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { final showFilter = widget.items.length >= 8 || _filter.isNotEmpty; - return material.Padding( - padding: const material.EdgeInsets.only(left: 16), + return QueryaTreeIndentGuide( + depth: 1, child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, mainAxisSize: material.MainAxisSize.min, children: [ - _PgTreeRow( + QueryaConnectionTreeRow( label: _filter.isEmpty ? '${widget.label} (${widget.items.length})' : '${widget.label} (${sorted.length}/${widget.items.length})', @@ -1188,6 +852,7 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { ), QueryaAnimatedExpand( expanded: _expanded, + estimatedChildCount: widget.items.length, child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, mainAxisSize: material.MainAxisSize.min, @@ -1206,70 +871,77 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { ), if (sorted.isEmpty && widget.items.isNotEmpty) material.Padding( - padding: const material.EdgeInsets.only( - left: 26, top: 4, bottom: 6), + padding: QueryaTreeTokens.emptyFilterPadding, child: Text('No matching ${widget.label.toLowerCase()}') .muted() .xSmall(), ) else - lazyConnectionTreeList( + QueryaTreeIndentGuide( + depth: 1, + leading: QueryaTreeTokens.leafList - QueryaTreeTokens.indent, + child: lazyConnectionTreeList( context: context, itemCount: sorted.length, itemExtent: kConnectionTreeRowExtent, - padding: const material.EdgeInsets.only(left: 26), itemBuilder: (context, index) { final item = sorted[index]; final isPinned = _pinnedItems.contains(item); - final sel = _ConnectionsTreeSelectionScope.of(context); - final isSelected = sel != null && - sel.selectedConnectionId == widget.connection.id && - sel.selectedPostgresObject != null && - sel.selectedPostgresObject!.database == - widget.databaseName && - sel.selectedPostgresObject!.schema == - widget.schemaName && - sel.selectedPostgresObject!.name == item && - sel.selectedPostgresObject!.kind == widget.objectKind; - return _PgTreeRow( - key: material.ValueKey( - 'pg-${widget.objectKind.name}-${widget.databaseName}-${widget.schemaName}-$item', - ), - label: item, - isSelected: isSelected, - isPinned: isPinned, - onTogglePin: () { - setState(() { - if (isPinned) { - _pinnedItems.remove(item); - } else { - _pinnedItems.add(item); - } - }); + return _ConnectionsTreeSelectionBuilder( + select: (sel) => + sel.selectedConnectionId == widget.connection.id && + sel.selectedPostgresObject != null && + sel.selectedPostgresObject!.database == + widget.databaseName && + sel.selectedPostgresObject!.schema == + widget.schemaName && + sel.selectedPostgresObject!.name == item && + sel.selectedPostgresObject!.kind == + widget.objectKind, + builder: (context, isSelected) { + return QueryaConnectionTreeRow( + key: material.ValueKey( + 'pg-${widget.objectKind.name}-${widget.databaseName}-${widget.schemaName}-$item', + ), + label: item, + isSelected: isSelected, + isPinned: isPinned, + onTogglePin: () { + setState(() { + if (isPinned) { + _pinnedItems.remove(item); + } else { + _pinnedItems.add(item); + } + }); + }, + icon: widget.itemIcon, + iconSize: QueryaIconSizes.treeLeaf, + iconColor: QueryaTreeTokens.leafIconColor( + theme.colorScheme.primary, + ), + textStyle: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.foreground, + ), + verticalPadding: 2, + onTap: widget.onItemTap != null + ? () => widget.onItemTap!(item) + : null, + connection: widget.connection, + onContextRefresh: widget.onRefresh, + onOpenSqlWorkspace: + widget.onPostgresOpenSqlWorkspace, + openSqlDatabase: widget.databaseName, + openSqlSchema: widget.schemaName, + openSqlName: item, + openSqlKind: widget.objectKind, + ); }, - icon: widget.itemIcon, - iconSize: QueryaIconSizes.treeLeaf, - iconColor: QueryaTreeTokens.leafIconColor( - theme.colorScheme.primary, - ), - textStyle: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.foreground, - ), - verticalPadding: 2, - onTap: widget.onItemTap != null - ? () => widget.onItemTap!(item) - : null, - connection: widget.connection, - onContextRefresh: widget.onRefresh, - onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - openSqlDatabase: widget.databaseName, - openSqlSchema: widget.schemaName, - openSqlName: item, - openSqlKind: widget.objectKind, ); }, ), + ), ], ), ), diff --git a/lib/features/connections/connections_panel_postgres_connection.dart b/lib/features/connections/connections_panel_postgres_connection.dart index 87084347..29bcd929 100644 --- a/lib/features/connections/connections_panel_postgres_connection.dart +++ b/lib/features/connections/connections_panel_postgres_connection.dart @@ -234,36 +234,18 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { ), QueryaAnimatedExpand( expanded: _expanded, + estimatedChildCount: _databases.length, child: material.Column( mainAxisSize: material.MainAxisSize.min, crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ if (_loading) - material.Padding( - padding: const material.EdgeInsets.only( - left: 28, top: 4, bottom: 4), - child: material.Row( - children: [ - const material.SizedBox( - width: 12, - height: 12, - child: material.CircularProgressIndicator( - strokeWidth: 1.5), - ), - const Gap(8), - const Text('Loading...').muted().xSmall(), - ], - ), - ), + const ConnectionTreeLoadingRow.connection(), if (_error != null) TreeLoadError( title: 'Could not load databases', message: _error!, - padding: const material.EdgeInsets.only( - left: 28, - top: 4, - bottom: 4, - ), + padding: QueryaTreeTokens.errorPaddingConnection, onRetry: _loadDatabases, ), if (_databases.isNotEmpty) diff --git a/lib/features/connections/connections_panel_redis.dart b/lib/features/connections/connections_panel_redis.dart index 752bdfa0..c53787e3 100644 --- a/lib/features/connections/connections_panel_redis.dart +++ b/lib/features/connections/connections_panel_redis.dart @@ -253,36 +253,18 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { // Expanded database children — ALL 16 databases QueryaAnimatedExpand( expanded: _expanded, + estimatedChildCount: _databases.length, child: material.Column( mainAxisSize: material.MainAxisSize.min, crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ if (_loading) - material.Padding( - padding: const material.EdgeInsets.only( - left: 28, top: 4, bottom: 4), - child: material.Row( - children: [ - const material.SizedBox( - width: 12, - height: 12, - child: material.CircularProgressIndicator( - strokeWidth: 1.5), - ), - const Gap(8), - const Text('Loading...').muted().xSmall(), - ], - ), - ), + const ConnectionTreeLoadingRow.connection(), if (_error != null) TreeLoadError( title: 'Could not load Redis info', message: _error!, - padding: const material.EdgeInsets.only( - left: 28, - top: 4, - bottom: 4, - ), + padding: QueryaTreeTokens.errorPaddingConnection, onRetry: _loadDatabases, ), if (_databases.isNotEmpty) @@ -320,41 +302,23 @@ class _RedisDatabasesNode extends material.StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return material.Padding( - padding: const material.EdgeInsets.only(left: 20), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - _PgTreeRow( - label: 'Databases (${databases.length})', - icon: QueryaIcons.databasesFolder, - iconSize: QueryaIconSizes.treeConnection, - iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), - textStyle: material.TextStyle( - fontSize: 12, - color: theme.colorScheme.foreground, - ), - verticalPadding: 4, - onTap: null, + return ConnectionDatabasesFolder( + connection: connection, + databaseCount: databases.length, + onRefresh: onRefreshDatabases, + child: lazyConnectionTreeList( + context: context, + itemCount: databases.length, + itemBuilder: (context, index) { + final db = databases[index]; + return _RedisDatabaseNode( connection: connection, - onContextRefresh: onRefreshDatabases, - ), - lazyConnectionTreeList( - context: context, - itemCount: databases.length, - itemBuilder: (context, index) { - final db = databases[index]; - return _RedisDatabaseNode( - connection: connection, - index: db.index, - keys: db.keys, - onTap: () => onDatabaseTap?.call(db.index), - ); - }, - ), - ], + index: db.index, + keys: db.keys, + onTap: () => onDatabaseTap?.call(db.index), + onRefreshDatabases: onRefreshDatabases, + ); + }, ), ); } @@ -366,48 +330,55 @@ class _RedisDatabaseNode extends StatelessWidget { required this.index, required this.keys, required this.onTap, + required this.onRefreshDatabases, }); final ConnectionRow connection; final int index; final int keys; final VoidCallback onTap; + final VoidCallback onRefreshDatabases; @override Widget build(BuildContext context) { final theme = Theme.of(context); - final sel = _ConnectionsTreeSelectionScope.of(context); - final isSelected = sel != null && - sel.selectedConnectionId == connection.id && - sel.selectedRedisDb == index; - return material.Padding( - padding: const material.EdgeInsets.only(left: 16), - child: _PgTreeRow( - label: 'db$index', - isSelected: isSelected, - icon: QueryaIcons.database, - iconSize: QueryaIconSizes.treeConnection, - iconColor: keys > 0 - ? theme.colorScheme.primary.withValues(alpha: 0.7) - : theme.colorScheme.mutedForeground.withValues(alpha: 0.5), - trailing: keys > 0 - ? material.Text( - '$keys', - style: material.TextStyle( - fontSize: 10, - color: theme.colorScheme.mutedForeground, - ), - ) - : null, - textStyle: material.TextStyle( - fontSize: 12, - color: keys > 0 - ? theme.colorScheme.foreground - : theme.colorScheme.mutedForeground, - ), - verticalPadding: 3, - onTap: onTap, - ), + return _ConnectionsTreeSelectionBuilder( + select: (sel) => + sel.selectedConnectionId == connection.id && + sel.selectedRedisDb == index, + builder: (context, isSelected) { + return QueryaTreeIndentGuide( + depth: 1, + child: QueryaConnectionTreeRow( + label: 'db$index', + isSelected: isSelected, + icon: QueryaIcons.database, + iconSize: QueryaIconSizes.treeConnection, + iconColor: keys > 0 + ? theme.colorScheme.primary.withValues(alpha: 0.7) + : theme.colorScheme.mutedForeground.withValues(alpha: 0.5), + trailing: keys > 0 + ? material.Text( + '$keys', + style: material.TextStyle( + fontSize: 10, + color: theme.colorScheme.mutedForeground, + ), + ) + : null, + textStyle: material.TextStyle( + fontSize: 12, + color: keys > 0 + ? theme.colorScheme.foreground + : theme.colorScheme.mutedForeground, + ), + verticalPadding: 3, + onTap: onTap, + connection: connection, + onContextRefresh: onRefreshDatabases, + ), + ); + }, ); } } diff --git a/lib/features/connections/connections_panel_sidebar.dart b/lib/features/connections/connections_panel_sidebar.dart index 5b74129a..11c1f489 100644 --- a/lib/features/connections/connections_panel_sidebar.dart +++ b/lib/features/connections/connections_panel_sidebar.dart @@ -304,8 +304,9 @@ class _FolderTileState extends State<_FolderTile> { ), QueryaAnimatedExpand( expanded: _expanded, - child: material.Padding( - padding: const material.EdgeInsets.only(left: 24), + child: QueryaTreeIndentGuide( + depth: 1, + step: 24, child: lazyConnectionTreeList( context: context, itemCount: widget.connections.length, diff --git a/lib/features/connections/connections_panel_sqlite.dart b/lib/features/connections/connections_panel_sqlite.dart index 5760938f..4f48ce0e 100644 --- a/lib/features/connections/connections_panel_sqlite.dart +++ b/lib/features/connections/connections_panel_sqlite.dart @@ -96,6 +96,25 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { _views = views; _loading = false; }); + final cacheId = widget.connection.id; + if (cacheId != null) { + QueryaSchemaObjectCache.instance.merge( + cacheId, + QueryaSchemaObjectCache.scopeSqlite(), + [ + for (final name in tables) + QueryaSchemaObject.sqlite( + name: name, + kind: QueryaSchemaObjectKind.table, + ), + for (final name in views) + QueryaSchemaObject.sqlite( + name: name, + kind: QueryaSchemaObjectKind.view, + ), + ], + ); + } } catch (e) { if (!mounted) return; setState(() { @@ -224,9 +243,12 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { color: theme.colorScheme.foreground, ), ), - if (widget.connection.host != null) + if ((widget.connection.host ?? + widget.connection.databaseName) != + null) material.Text( - widget.connection.host!, + widget.connection.host ?? + widget.connection.databaseName!, overflow: material.TextOverflow.ellipsis, maxLines: 1, style: material.TextStyle( @@ -246,41 +268,23 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { ), QueryaAnimatedExpand( expanded: _expanded, + estimatedChildCount: _tables.length + _views.length, child: material.Column( mainAxisSize: material.MainAxisSize.min, crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ if (_loading) - material.Padding( - padding: const material.EdgeInsets.only( - left: 28, top: 4, bottom: 4), - child: material.Row( - children: [ - const material.SizedBox( - width: 12, - height: 12, - child: material.CircularProgressIndicator( - strokeWidth: 1.5), - ), - const Gap(8), - const Text('Loading...').muted().xSmall(), - ], - ), - ), + const ConnectionTreeLoadingRow.connection(), if (_error != null) TreeLoadError( title: 'Could not load objects', message: _error!, - padding: const material.EdgeInsets.only( - left: 28, - top: 4, - bottom: 4, - ), + padding: QueryaTreeTokens.errorPaddingConnection, onRetry: _loadTables, ), if (!_loading && _error == null) - material.Padding( - padding: const material.EdgeInsets.only(left: 16), + QueryaTreeIndentGuide( + depth: 1, child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ @@ -402,7 +406,7 @@ class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { crossAxisAlignment: material.CrossAxisAlignment.start, mainAxisSize: material.MainAxisSize.min, children: [ - _PgTreeRow( + QueryaConnectionTreeRow( label: _filter.isEmpty ? '${widget.label} (${widget.items.length})' : '${widget.label} (${sorted.length}/${widget.items.length})', @@ -430,6 +434,7 @@ class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { ), QueryaAnimatedExpand( expanded: _expanded, + estimatedChildCount: widget.items.length, child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, mainAxisSize: material.MainAxisSize.min, @@ -448,66 +453,71 @@ class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { ), if (sorted.isEmpty && widget.items.isNotEmpty) material.Padding( - padding: const material.EdgeInsets.only( - left: 26, top: 4, bottom: 6), + padding: QueryaTreeTokens.emptyFilterPadding, child: Text('No matching ${widget.label.toLowerCase()}') .muted() .xSmall(), ) else - lazyConnectionTreeList( + QueryaTreeIndentGuide( + depth: 1, + leading: QueryaTreeTokens.leafList - QueryaTreeTokens.indent, + child: lazyConnectionTreeList( context: context, itemCount: sorted.length, itemExtent: kConnectionTreeRowExtent, - padding: const material.EdgeInsets.only(left: 26), itemBuilder: (context, index) { final item = sorted[index]; final isPinned = _pinnedItems.contains(item); - final sel = _ConnectionsTreeSelectionScope.of(context); - final isSelected = sel != null && - sel.selectedConnectionId == widget.connection.id && - sel.selectedSqliteObject != null && - sel.selectedSqliteObject!.name == item && - sel.selectedSqliteObject!.kind == widget.objectKind; - return _PgTreeRow( - key: material.ValueKey( - 'sqlite-${widget.objectKind.name}-$item', - ), - label: item, - isSelected: isSelected, - isPinned: isPinned, - onTogglePin: () { - setState(() { - if (isPinned) { - _pinnedItems.remove(item); - } else { - _pinnedItems.add(item); - } - }); + return _ConnectionsTreeSelectionBuilder( + select: (sel) => + sel.selectedConnectionId == widget.connection.id && + sel.selectedSqliteObject != null && + sel.selectedSqliteObject!.name == item && + sel.selectedSqliteObject!.kind == widget.objectKind, + builder: (context, isSelected) { + return QueryaConnectionTreeRow( + key: material.ValueKey( + 'sqlite-${widget.objectKind.name}-$item', + ), + label: item, + isSelected: isSelected, + isPinned: isPinned, + onTogglePin: () { + setState(() { + if (isPinned) { + _pinnedItems.remove(item); + } else { + _pinnedItems.add(item); + } + }); + }, + icon: widget.itemIcon, + iconSize: QueryaIconSizes.treeLeaf, + iconColor: QueryaTreeTokens.leafIconColor( + theme.colorScheme.primary, + ), + textStyle: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.foreground, + ), + verticalPadding: 2, + onTap: widget.onItemTap != null + ? () => widget.onItemTap!(item) + : null, + connection: widget.connection, + openSqlName: item, + onContextRefresh: widget.onRefresh, + onOpenSqlWorkspace: widget.onOpenSqlWorkspace != null + ? (conn, {database, schema, name, kind}) => + widget.onOpenSqlWorkspace!(conn) + : null, + ); }, - icon: widget.itemIcon, - iconSize: QueryaIconSizes.treeLeaf, - iconColor: QueryaTreeTokens.leafIconColor( - theme.colorScheme.primary, - ), - textStyle: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.foreground, - ), - verticalPadding: 2, - onTap: widget.onItemTap != null - ? () => widget.onItemTap!(item) - : null, - connection: widget.connection, - openSqlName: item, - onContextRefresh: widget.onRefresh, - onOpenSqlWorkspace: widget.onOpenSqlWorkspace != null - ? (conn, {database, schema, name, kind}) => - widget.onOpenSqlWorkspace!(conn) - : null, ); }, ), + ), ], ), ), diff --git a/lib/features/connections/querya_connection_tree_row.dart b/lib/features/connections/querya_connection_tree_row.dart new file mode 100644 index 00000000..6d836029 --- /dev/null +++ b/lib/features/connections/querya_connection_tree_row.dart @@ -0,0 +1,350 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart' + show Clipboard, ClipboardData, LogicalKeyboardKey; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/ui/querya_icon_sizes.dart'; +import 'package:querya_desktop/core/ui/querya_tooltip.dart'; +import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Opens SQL workspace; optional tree fields seed the editor. +typedef QueryaOpenSqlWorkspace = void Function( + ConnectionRow connection, { + String? database, + String? schema, + String? name, + PostgresObjectKind? kind, +}); + +/// Ellipsis label; tooltip when the name is long enough to likely truncate. +class _QueryaConnectionTreeRowLabel extends material.StatelessWidget { + const _QueryaConnectionTreeRowLabel({ + required this.label, + required this.textStyle, + }); + + final String label; + final material.TextStyle textStyle; + + static const int _tooltipMinLength = 28; + + @override + material.Widget build(material.BuildContext context) { + final text = material.Text( + label, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: textStyle, + ); + if (label.length < _tooltipMinLength) return text; + return material.Tooltip( + message: label, + waitDuration: kQueryaTooltipWait, + child: text, + ); + } +} + +/// Shared tree row: consistent ink hover, optional context menu, tooltips when truncated. +class QueryaConnectionTreeRow extends material.StatelessWidget { + const QueryaConnectionTreeRow({ + super.key, + required this.label, + this.isSelected = false, + this.leading, + this.icon, + this.iconWidget, + this.iconSize = QueryaIconSizes.treeGroup, + this.iconColor, + this.trailing, + this.onTap, + this.expanded, + this.verticalPadding = 3, + required this.textStyle, + this.connection, + this.onContextRefresh, + this.onOpenSqlWorkspace, + this.openSqlDatabase, + this.openSqlSchema, + this.openSqlName, + this.openSqlKind, + this.onContextDelete, + this.contextDeleteLabel, + this.isPinned = false, + this.onTogglePin, + }); + + final String label; + final bool isSelected; + final material.Widget? leading; + final material.IconData? icon; + /// When set, drawn instead of [icon] (e.g. inline loading spinner). + final material.Widget? iconWidget; + final double iconSize; + final material.Color? iconColor; + final material.Widget? trailing; + final void Function()? onTap; + + /// When non-null, row is an expand control ([Semantics.button] + expanded). + final bool? expanded; + final double verticalPadding; + final material.TextStyle textStyle; + final ConnectionRow? connection; + final VoidCallback? onContextRefresh; + final QueryaOpenSqlWorkspace? onOpenSqlWorkspace; + final String? openSqlDatabase; + final String? openSqlSchema; + final String? openSqlName; + final PostgresObjectKind? openSqlKind; + final VoidCallback? onContextDelete; + final String? contextDeleteLabel; + final bool isPinned; + final VoidCallback? onTogglePin; + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final primary = theme.colorScheme.primary; + final muted = theme.colorScheme.mutedForeground; + final row = material.CallbackShortcuts( + bindings: { + if (expanded == false && onTap != null) + const material.SingleActivator(LogicalKeyboardKey.arrowRight): () => + onTap!(), + if (expanded == true && onTap != null) + const material.SingleActivator(LogicalKeyboardKey.arrowLeft): () => + onTap!(), + }, + child: _selectionChrome( + context: context, + primary: primary, + child: material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: onTap, + canRequestFocus: onTap != null, + borderRadius: material.BorderRadius.circular(4), + hoverColor: primary.withValues(alpha: 0.07), + focusColor: primary.withValues(alpha: 0.14), + splashColor: primary.withValues(alpha: 0.10), + highlightColor: primary.withValues(alpha: 0.05), + mouseCursor: onTap != null + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + child: material.Padding( + padding: material.EdgeInsets.symmetric( + horizontal: 4, + vertical: verticalPadding, + ), + child: material.Row( + children: [ + if (leading != null) ...[ + leading!, + const Gap(4), + ], + if (iconWidget != null) ...[ + iconWidget!, + const Gap(6), + ] else if (icon != null) ...[ + material.Icon( + icon, + size: iconSize, + color: isSelected ? primary : (iconColor ?? muted), + ), + const Gap(6), + ], + if (isPinned) ...[ + material.Icon( + material.Icons.star_rounded, + size: 13, + color: material.Colors.amber.shade600, + ), + const Gap(4), + ], + material.Expanded( + child: _QueryaConnectionTreeRowLabel( + label: label, + textStyle: isSelected + ? textStyle.copyWith( + fontWeight: material.FontWeight.w600, + color: theme.colorScheme.foreground, + ) + : textStyle, + ), + ), + if (trailing != null) trailing!, + ], + ), + ), + ), + ), + ), + ); + final wantsMenu = connection != null || + onContextRefresh != null || + onContextDelete != null || + onOpenSqlWorkspace != null || + onTogglePin != null; + if (!wantsMenu) { + return expanded == null + ? row + : material.Semantics( + button: true, + expanded: expanded, + child: row, + ); + } + final menu = ContextMenu( + items: [ + if (openSqlName != null) ...[ + if (onTogglePin != null) ...[ + MenuButton( + leading: material.Icon( + isPinned + ? material.Icons.star_rounded + : material.Icons.star_border_rounded, + size: 18, + color: isPinned + ? material.Colors.amber.shade600 + : theme.colorScheme.mutedForeground, + ), + onPressed: (_) => onTogglePin!(), + child: Text(isPinned ? 'Unpin from top' : 'Pin to top ⭐'), + ), + const MenuDivider(), + ], + MenuButton( + leading: material.Icon( + material.Icons.table_rows_outlined, + size: 18, + color: theme.colorScheme.primary, + ), + onPressed: (_) { + final qualified = openSqlSchema != null && openSqlSchema!.isNotEmpty + ? '"$openSqlSchema"."$openSqlName"' + : '"$openSqlName"'; + Clipboard.setData( + ClipboardData(text: 'SELECT * FROM $qualified LIMIT 100;'), + ); + if (onOpenSqlWorkspace != null && connection != null) { + onOpenSqlWorkspace!( + connection!, + database: openSqlDatabase, + schema: openSqlSchema, + name: openSqlName, + kind: openSqlKind, + ); + } + }, + child: const Text('Select TOP 100'), + ), + MenuButton( + leading: material.Icon( + material.Icons.code_rounded, + size: 18, + color: theme.colorScheme.mutedForeground, + ), + onPressed: (_) { + final qualified = openSqlSchema != null && openSqlSchema!.isNotEmpty + ? '"$openSqlSchema"."$openSqlName"' + : '"$openSqlName"'; + Clipboard.setData(ClipboardData(text: 'SELECT * FROM $qualified;')); + }, + child: const Text('Copy SELECT statement'), + ), + const MenuDivider(), + ], + if (onContextRefresh != null) + MenuButton( + leading: material.Icon( + material.Icons.refresh_rounded, + size: 18, + color: theme.colorScheme.mutedForeground, + ), + onPressed: (_) => onContextRefresh!(), + child: const Text('Refresh'), + ), + MenuButton( + leading: material.Icon( + material.Icons.copy_rounded, + size: 18, + color: theme.colorScheme.mutedForeground, + ), + onPressed: (_) { + Clipboard.setData(ClipboardData(text: label)); + }, + child: const Text('Copy name'), + ), + if (onOpenSqlWorkspace != null && + openSqlName == null && + connection != null) + MenuButton( + leading: material.Icon( + material.Icons.terminal_rounded, + size: 18, + color: theme.colorScheme.mutedForeground, + ), + onPressed: (_) => onOpenSqlWorkspace!( + connection!, + database: openSqlDatabase, + schema: openSqlSchema, + name: openSqlName, + kind: openSqlKind, + ), + child: const Text('Open in SQL'), + ), + if (onContextDelete != null) ...[ + const MenuDivider(), + MenuButton( + leading: material.Icon( + material.Icons.delete_outline_rounded, + size: 18, + color: theme.colorScheme.destructive, + ), + onPressed: (_) => onContextDelete!(), + child: Text(contextDeleteLabel ?? 'Delete'), + ), + ], + ], + child: row, + ); + if (expanded == null) return menu; + return material.Semantics( + button: true, + expanded: expanded, + child: menu, + ); + } + + /// Selection fill: [AnimatedContainer] on Full motion, instant [DecoratedBox] + /// when Motion Off / OS reduce-motion (no leftover animation controllers). + material.Widget _selectionChrome({ + required material.BuildContext context, + required material.Color primary, + required material.Widget child, + }) { + final decoration = material.BoxDecoration( + color: isSelected + ? primary.withValues(alpha: 0.12) + : material.Colors.transparent, + borderRadius: material.BorderRadius.circular(4), + border: isSelected + ? material.Border.all( + color: primary.withValues(alpha: 0.35), + width: 1, + ) + : null, + ); + final duration = context.motionDuration(QueryaMotion.fast); + if (duration == Duration.zero) { + return material.DecoratedBox(decoration: decoration, child: child); + } + return material.AnimatedContainer( + duration: duration, + curve: context.motionCurve(QueryaMotion.standardCurve), + decoration: decoration, + child: child, + ); + } +} + diff --git a/lib/features/extensions/extension_sql_workspace.dart b/lib/features/extensions/extension_sql_workspace.dart index 9dbcd35c..e1fc160f 100644 --- a/lib/features/extensions/extension_sql_workspace.dart +++ b/lib/features/extensions/extension_sql_workspace.dart @@ -11,6 +11,7 @@ import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/ui/querya_shell_status.dart'; import 'package:querya_desktop/features/extensions/extension_driver_recovery_banner.dart'; import 'package:querya_desktop/features/workspace/workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -143,6 +144,14 @@ class _ExtensionSqlWorkspaceState }, onNextTab: _nextTab, onPrevTab: _prevTab, + onFormat: () { + _activeSession.formatSql(); + setState(() {}); + }, + onClear: () { + _activeSession.clearSql(); + setState(() {}); + }, onExecute: () { if (!_activeSession.running) unawaited(_execute(_activeSession)); }, @@ -295,6 +304,8 @@ class _ExtensionSqlWorkspaceState session.rows = []; session.statusLine = null; }); + QueryaShellStatus.instance.beginBusy(message: 'Running query…'); + final sw = Stopwatch()..start(); try { final result = await ExtensionDriverSession.instance.query( @@ -319,6 +330,16 @@ class _ExtensionSqlWorkspaceState } session.running = false; }); + sw.stop(); + final duration = result.elapsedMs != null + ? Duration(milliseconds: result.elapsedMs!) + : sw.elapsed; + QueryaShellStatus.instance.reportQueryResult( + duration: duration, + rowCount: result.rows.length, + columnCount: result.columns.length, + message: session.statusLine, + ); final cid = widget.connectionRow.id; if (cid != null) { @@ -337,6 +358,7 @@ class _ExtensionSqlWorkspaceState session.error = e.toString(); session.running = false; }); + QueryaShellStatus.instance.endBusy(); } } } diff --git a/lib/features/extensions/extension_workspace_home.dart b/lib/features/extensions/extension_workspace_home.dart index 6f99e3e3..bef8c0f9 100644 --- a/lib/features/extensions/extension_workspace_home.dart +++ b/lib/features/extensions/extension_workspace_home.dart @@ -92,7 +92,12 @@ class _ExtensionWorkspaceHomeState ), child: material.Row( children: [ - Text(widget.connectionRow.name).semiBold().small(), + material.Flexible( + child: Text(widget.connectionRow.name) + .semiBold() + .small() + .ellipsis(), + ), if (widget.isReadOnly) ...[ const Gap(6), material.Icon( @@ -116,7 +121,7 @@ class _ExtensionWorkspaceHomeState ], const Spacer(), QueryaTabStrip( - labels: const ['Server', 'SQL'], + labels: const ['Overview', 'SQL'], selectedIndex: _tab, onSelected: (index) => unawaited(_selectTab(index)), ), diff --git a/lib/features/macos/querya_platform_menu_bar.dart b/lib/features/macos/querya_platform_menu_bar.dart index 5b77e428..591e9609 100644 --- a/lib/features/macos/querya_platform_menu_bar.dart +++ b/lib/features/macos/querya_platform_menu_bar.dart @@ -4,6 +4,9 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../core/actions/querya_command_host.dart'; +import '../command_palette/command_palette_dialog.dart'; +import '../command_palette/quick_switcher_dialog.dart'; import '../settings/preferences_dialog.dart'; /// Native macOS top menu bar integration via Flutter's [PlatformMenuBar]. @@ -65,6 +68,29 @@ class QueryaPlatformMenuBar extends StatelessWidget { ), PlatformMenuItemGroup( members: [ + PlatformMenuItem( + label: 'Command Palette...', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyP, + meta: true, + ), + onSelected: () => showCommandPalette(context), + ), + PlatformMenuItem( + label: 'Go to Object...', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyK, + meta: true, + ), + onSelected: () { + final host = QueryaCommandHost.maybeOf(context); + if (host?.onShowQuickSwitcher != null) { + host!.onShowQuickSwitcher!(''); + } else { + showQuickSwitcher(context); + } + }, + ), PlatformMenuItem( label: 'Preferences...', shortcut: const SingleActivator( diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 81192f29..a84997d5 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -5,6 +5,11 @@ import 'dart:math' as math; import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/actions/querya_command_host.dart'; +import 'package:querya_desktop/core/actions/querya_schema_object.dart'; +import 'package:querya_desktop/core/actions/querya_schema_object_loader.dart'; +import 'package:querya_desktop/features/command_palette/command_palette_dialog.dart'; +import 'package:querya_desktop/features/command_palette/quick_switcher_dialog.dart'; import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; import 'package:querya_desktop/core/actions/sql_editor_global_actions.dart'; @@ -22,7 +27,9 @@ import 'package:querya_desktop/features/connections/connection_creation_flow.dar import 'package:querya_desktop/features/connections/new_connection_url_dialog.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart'; import 'package:querya_desktop/features/connections/sqlite_connection_form.dart'; +import 'package:querya_desktop/core/ui/querya_shell_status.dart'; import 'package:querya_desktop/features/main_screen/connections_panel_width_persist.dart'; +import 'package:querya_desktop/features/main_screen/querya_status_bar.dart'; import 'package:querya_desktop/features/main_screen/querya_window_title_bar.dart'; import 'package:querya_desktop/features/macos/querya_platform_menu_bar.dart'; import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; @@ -50,6 +57,7 @@ class _MainScreenState extends State { ValueNotifier(MainScreenWorkspaceState.empty); final ValueNotifier _isSidebarVisible = ValueNotifier(true); bool _openingConnectionDialog = false; + String? _pendingMongoCollection; @override void initState() { @@ -128,6 +136,27 @@ class _MainScreenState extends State { return true; } + // Command Palette: Ctrl+P / Cmd+P (Physical P, Logical P, Russian 'з') + final isKeyP = physical == PhysicalKeyboardKey.keyP || + logical == LogicalKeyboardKey.keyP || + logical == const LogicalKeyboardKey(0x00000437) || // з + logical == const LogicalKeyboardKey(0x00000417); // З + if (isCmdOrCtrl && !isShift && !isAlt && isKeyP) { + final ctx = FocusManager.instance.primaryFocus?.context ?? context; + unawaited(showCommandPalette(ctx)); + return true; + } + + // Quick Switcher: Ctrl+K / Cmd+K (Physical K, Logical K, Russian 'л') + final isKeyK = physical == PhysicalKeyboardKey.keyK || + logical == LogicalKeyboardKey.keyK || + logical == const LogicalKeyboardKey(0x0000043b) || // л + logical == const LogicalKeyboardKey(0x0000041b); // Л + if (isCmdOrCtrl && !isShift && !isAlt && isKeyK) { + _openQuickSwitcher(); + return true; + } + // 3. New Connection / New Query Tab: (Physical N, Logical N, Russian 'т') final isKeyN = physical == PhysicalKeyboardKey.keyN || logical == LogicalKeyboardKey.keyN || @@ -234,7 +263,12 @@ class _MainScreenState extends State { } void _onConnectionSelected(ConnectionRow connection) { + final prevId = _workspace.value.activeConnection?.id; _workspace.value = _workspace.value.selectConnection(connection); + if (prevId != connection.id) { + QueryaShellStatus.instance.clear(); + _pendingMongoCollection = null; + } final id = connection.id; if (id != null) { unawaited(AppSettings.instance.recordRecentConnection(id)); @@ -327,6 +361,84 @@ class _MainScreenState extends State { ); } + void _openQuickSwitcher([String query = '']) { + final ctx = FocusManager.instance.primaryFocus?.context ?? context; + final ws = _workspace.value; + final conn = ws.activeConnection; + unawaited(showQuickSwitcher( + ctx, + initialQuery: query, + load: () => QueryaSchemaObjectLoader.load( + connection: conn, + database: ws.selectedPostgresObject?.database ?? + ws.selectedMysqlObject?.database ?? + ws.selectedExtensionObject?.database ?? + conn?.databaseName, + mongoDatabase: ws.activeMongoDB ?? conn?.databaseName, + ), + )); + } + + void _onOpenSchemaObject(QueryaSchemaObject object) { + final conn = _workspace.value.activeConnection; + if (conn == null) return; + final type = conn.type.toLowerCase(); + switch (object.kind) { + case QueryaSchemaObjectKind.table: + case QueryaSchemaObjectKind.view: + case QueryaSchemaObjectKind.materializedView: + if (type == 'postgres' || type == 'postgresql') { + _onPostgresObjectSelected( + conn, + object.database ?? conn.databaseName ?? '', + object.schema ?? 'public', + object.name, + switch (object.kind) { + QueryaSchemaObjectKind.view => PostgresObjectKind.view, + QueryaSchemaObjectKind.materializedView => + PostgresObjectKind.materializedView, + _ => PostgresObjectKind.table, + }, + ); + return; + } + if (type == 'mysql') { + _onMysqlObjectSelected( + conn, + object.database ?? conn.databaseName ?? '', + object.name, + object.kind == QueryaSchemaObjectKind.view + ? MysqlObjectKind.view + : MysqlObjectKind.table, + ); + return; + } + if (type == 'sqlite') { + _onSqliteObjectSelected( + conn, + object.name, + object.kind == QueryaSchemaObjectKind.view + ? SqliteObjectKind.view + : SqliteObjectKind.table, + ); + return; + } + if (conn.isExtensionDriver) { + _onExtensionObjectSelected( + conn, + object.database ?? conn.databaseName ?? '', + object.name, + ); + } + case QueryaSchemaObjectKind.collection: + setState(() => _pendingMongoCollection = object.name); + _onMongoDBDatabaseSelected( + conn, + object.database ?? conn.databaseName ?? '', + ); + } + } + void _openSqlWorkspaceForConnection(ConnectionRow connection) { switch (connection.type) { case 'postgresql': @@ -389,6 +501,16 @@ class _MainScreenState extends State { void _onGoHome() { _workspace.value = MainScreenWorkspaceState.empty; + QueryaShellStatus.instance.clear(); + } + + bool _hasCloseableWorkspace(MainScreenWorkspaceState ws) { + return ws.selectedPostgresObject != null || + ws.selectedMysqlObject != null || + ws.selectedSqliteObject != null || + ws.selectedExtensionObject != null || + ws.activeMongoDB != null || + ws.activeRedisDb != null; } void _onOpenWelcomeTour() { @@ -472,7 +594,44 @@ class _MainScreenState extends State { return ValueListenableBuilder( valueListenable: _workspace, builder: (context, workspace, _) { - return FocusScope( + return QueryaCommandHost( + onToggleSidebar: () => _splitKey.currentState?.toggleSidebar(), + onNewConnection: () => unawaited(_onNewDatabaseConnectionFromMenu()), + onShowQuickSwitcher: _openQuickSwitcher, + onOpenSchemaObject: _onOpenSchemaObject, + onGoHome: + workspace.activeConnection != null ? _onGoHome : null, + onCloseWorkspace: _hasCloseableWorkspace(workspace) + ? () { + _workspace.value = _workspace.value.unselectActiveObject(); + } + : null, + onConnect: workspace.activeConnection?.id != null + ? () => _connectionsPanelKey.currentState + ?.connect(workspace.activeConnection!.id!) + : null, + onDisconnect: workspace.activeConnection != null + ? () => unawaited( + _connectionsPanelKey.currentState + ?.disconnect(workspace.activeConnection!) ?? + Future.value(), + ) + : null, + onReconnect: workspace.activeConnection != null + ? () => unawaited( + _connectionsPanelKey.currentState + ?.reconnect(workspace.activeConnection!) ?? + Future.value(), + ) + : null, + onToggleReadOnly: workspace.activeConnection != null + ? () { + _workspace.value = _workspace.value.toggleReadOnly(); + } + : null, + onOpenWelcomeTour: _onOpenWelcomeTour, + selectedConnectionId: workspace.activeConnection?.id, + child: FocusScope( autofocus: true, child: material.CallbackShortcuts( bindings: { @@ -552,6 +711,23 @@ class _MainScreenState extends State { meta: true, ): () => _splitKey.currentState?.toggleSidebar(), + const material.SingleActivator( + LogicalKeyboardKey.keyP, + control: true, + ): () => unawaited(showCommandPalette(context)), + const material.SingleActivator( + LogicalKeyboardKey.keyP, + meta: true, + ): () => unawaited(showCommandPalette(context)), + const material.SingleActivator( + LogicalKeyboardKey.keyK, + control: true, + ): () => _openQuickSwitcher(), + const material.SingleActivator( + LogicalKeyboardKey.keyK, + meta: true, + ): () => _openQuickSwitcher(), + // Welcome Tour & Tutorial: F1 / Cmd+Shift+H / Ctrl+Shift+H const material.SingleActivator( LogicalKeyboardKey.f1, @@ -693,8 +869,34 @@ class _MainScreenState extends State { onRequestLaunchDemo: _onLaunchDemoPlayground, onRequestOpenTour: _onOpenWelcomeTour, onOpenConnection: _onConnectionSelected, + initialMongoCollection: _pendingMongoCollection, ), ), + ValueListenableBuilder( + valueListenable: _isSidebarVisible, + builder: (context, isSidebarVisible, _) { + return ListenableBuilder( + listenable: QueryaShellStatus.instance, + builder: (context, _) { + final status = QueryaShellStatus.instance; + return QueryaStatusBar( + activeConnection: workspace.activeConnection, + isReadOnly: workspace.isReadOnly, + isSidebarVisible: isSidebarVisible, + onToggleSidebar: () => + _splitKey.currentState?.toggleSidebar(), + onOpenPreferences: () => + showPreferencesDialog(context), + isBusy: status.isBusy, + statusMessage: status.statusMessage, + rowCount: status.rowCount, + columnCount: status.columnCount, + lastQueryDuration: status.lastQueryDuration, + ); + }, + ); + }, + ), ], ), ), @@ -702,6 +904,7 @@ class _MainScreenState extends State { ), ), ), + ), ); }, ); @@ -731,6 +934,7 @@ class _MainContentSplit extends StatefulWidget { this.onRequestOpenTour, required this.onOpenConnection, this.onSidebarVisibilityChanged, + this.initialMongoCollection, }); final GlobalKey connectionsPanelKey; @@ -768,6 +972,7 @@ class _MainContentSplit extends StatefulWidget { final VoidCallback onRequestNewConnection; final VoidCallback onRequestNewConnectionFromUrl; final VoidCallback onRequestOpenSqlite; + final String? initialMongoCollection; final VoidCallback? onRequestLaunchDemo; final VoidCallback? onRequestOpenTour; final void Function(ConnectionRow) onOpenConnection; @@ -807,13 +1012,22 @@ class _MainContentSplitState extends State<_MainContentSplit> vsync: this, value: kDefaultConnectionsPanelWidth, spring: _sidebarSpring, - cubicDuration: const Duration(milliseconds: 160), - cubicCurve: Curves.easeOutCubic, + cubicDuration: QueryaMotion.sidebarCubic, + cubicCurve: QueryaMotion.enter, ); _widthSpring.addListener(_onWidthSpringChanged); unawaited(_restoreState()); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _widthSpring.cubicDuration = + context.motionDuration(QueryaMotion.sidebarCubic); + _widthSpring.cubicCurve = context.motionCurve(QueryaMotion.enter); + _widthSpring.useSprings = QueryaSpring.springsEnabled(context); + } + Future _restoreState() async { final width = await AppSettings.instance.getConnectionsPanelWidth(); final visible = await AppSettings.instance.getSidebarVisible(); @@ -1036,6 +1250,8 @@ class _MainContentSplitState extends State<_MainContentSplit> lastSelectedSqliteObject: ws.lastSelectedSqliteObject, lastSelectedExtensionObject: ws.lastSelectedExtensionObject, + lastSelectedMongoDb: ws.lastSelectedMongoDb, + lastSelectedRedisDb: ws.lastSelectedRedisDb, onNavigateHome: () { widget.workspace.value = widget.workspace.value.unselectActiveObject(); @@ -1052,6 +1268,7 @@ class _MainContentSplitState extends State<_MainContentSplit> onRequestLaunchDemo: widget.onRequestLaunchDemo, onRequestOpenTour: widget.onRequestOpenTour, onOpenConnection: widget.onOpenConnection, + initialMongoCollection: widget.initialMongoCollection, ); }, ), diff --git a/lib/features/main_screen/main_screen_workspace_state.dart b/lib/features/main_screen/main_screen_workspace_state.dart index f3c35326..1b781da2 100644 --- a/lib/features/main_screen/main_screen_workspace_state.dart +++ b/lib/features/main_screen/main_screen_workspace_state.dart @@ -24,6 +24,8 @@ class MainScreenWorkspaceState { this.lastSelectedMysqlObject, this.lastSelectedSqliteObject, this.lastSelectedExtensionObject, + this.lastSelectedMongoDb, + this.lastSelectedRedisDb, this.isReadOnly = false, }); @@ -90,6 +92,12 @@ class MainScreenWorkspaceState { String name, })? lastSelectedExtensionObject; + /// Last MongoDB database opened for 1-click return from server stats. + final String? lastSelectedMongoDb; + + /// Last Redis database index opened for 1-click return from server stats. + final int? lastSelectedRedisDb; + final bool isReadOnly; static const empty = MainScreenWorkspaceState(); @@ -112,6 +120,8 @@ class MainScreenWorkspaceState { lastSelectedMysqlObject: lastSelectedMysqlObject, lastSelectedSqliteObject: lastSelectedSqliteObject, lastSelectedExtensionObject: lastSelectedExtensionObject, + lastSelectedMongoDb: lastSelectedMongoDb, + lastSelectedRedisDb: lastSelectedRedisDb, isReadOnly: !isReadOnly, ); } @@ -140,17 +150,21 @@ class MainScreenWorkspaceState { lastSelectedExtensionObject: same ? (selectedExtensionObject ?? lastSelectedExtensionObject) : null, + lastSelectedMongoDb: + same ? (activeMongoDB ?? lastSelectedMongoDb) : null, + lastSelectedRedisDb: + same ? (activeRedisDb ?? lastSelectedRedisDb) : null, isReadOnly: false, ); } - /// Clears the active table/view selection to show server stats/home, but retains + /// Clears the active table/view/db selection to show server stats/home, but retains /// the reference in [lastSelectedPostgresObject] etc. so users can return in 1 click. MainScreenWorkspaceState unselectActiveObject() { return MainScreenWorkspaceState( activeConnection: activeConnection, - activeRedisDb: activeRedisDb, - activeMongoDB: activeMongoDB, + activeRedisDb: null, + activeMongoDB: null, selectedPostgresObject: null, postgresSqlTabRequestToken: postgresSqlTabRequestToken, postgresSqlEditorContext: postgresSqlEditorContext, @@ -168,6 +182,8 @@ class MainScreenWorkspaceState { selectedSqliteObject ?? lastSelectedSqliteObject, lastSelectedExtensionObject: selectedExtensionObject ?? lastSelectedExtensionObject, + lastSelectedMongoDb: activeMongoDB ?? lastSelectedMongoDb, + lastSelectedRedisDb: activeRedisDb ?? lastSelectedRedisDb, isReadOnly: isReadOnly, ); } @@ -212,6 +228,16 @@ class MainScreenWorkspaceState { ); } break; + case 'mongodb': + if (lastSelectedMongoDb != null) { + return selectMongoDb(conn, lastSelectedMongoDb!); + } + break; + case 'redis': + if (lastSelectedRedisDb != null) { + return selectRedisDb(conn, lastSelectedRedisDb!); + } + break; default: if (lastSelectedExtensionObject != null) { final obj = lastSelectedExtensionObject!; @@ -255,6 +281,8 @@ class MainScreenWorkspaceState { lastSelectedMysqlObject: null, lastSelectedSqliteObject: null, lastSelectedExtensionObject: null, + lastSelectedMongoDb: null, + lastSelectedRedisDb: null, isReadOnly: isReadOnly, ); } @@ -286,6 +314,8 @@ class MainScreenWorkspaceState { lastSelectedMysqlObject: my, lastSelectedSqliteObject: null, lastSelectedExtensionObject: null, + lastSelectedMongoDb: null, + lastSelectedRedisDb: null, isReadOnly: isReadOnly, ); } @@ -315,6 +345,8 @@ class MainScreenWorkspaceState { lastSelectedMysqlObject: null, lastSelectedSqliteObject: sq, lastSelectedExtensionObject: null, + lastSelectedMongoDb: null, + lastSelectedRedisDb: null, isReadOnly: isReadOnly, ); } @@ -345,6 +377,8 @@ class MainScreenWorkspaceState { lastSelectedMysqlObject: null, lastSelectedSqliteObject: null, lastSelectedExtensionObject: ext, + lastSelectedMongoDb: null, + lastSelectedRedisDb: null, isReadOnly: isReadOnly, ); } @@ -366,6 +400,8 @@ class MainScreenWorkspaceState { lastSelectedMysqlObject: null, lastSelectedSqliteObject: null, lastSelectedExtensionObject: null, + lastSelectedMongoDb: null, + lastSelectedRedisDb: db, isReadOnly: isReadOnly, ); } @@ -388,6 +424,8 @@ class MainScreenWorkspaceState { lastSelectedMysqlObject: null, lastSelectedSqliteObject: null, lastSelectedExtensionObject: null, + lastSelectedMongoDb: database, + lastSelectedRedisDb: null, isReadOnly: isReadOnly, ); } @@ -451,6 +489,8 @@ class MainScreenWorkspaceState { lastSelectedMysqlObject: lastSelectedMysqlObject, lastSelectedSqliteObject: lastSelectedSqliteObject, lastSelectedExtensionObject: lastSelectedExtensionObject, + lastSelectedMongoDb: lastSelectedMongoDb, + lastSelectedRedisDb: lastSelectedRedisDb, isReadOnly: isReadOnly, ); } @@ -472,6 +512,8 @@ class MainScreenWorkspaceState { lastSelectedMysqlObject: lastSelectedMysqlObject, lastSelectedSqliteObject: lastSelectedSqliteObject, lastSelectedExtensionObject: lastSelectedExtensionObject, + lastSelectedMongoDb: lastSelectedMongoDb, + lastSelectedRedisDb: lastSelectedRedisDb, isReadOnly: isReadOnly, ); } @@ -493,6 +535,8 @@ class MainScreenWorkspaceState { lastSelectedMysqlObject: lastSelectedMysqlObject, lastSelectedSqliteObject: lastSelectedSqliteObject, lastSelectedExtensionObject: lastSelectedExtensionObject, + lastSelectedMongoDb: lastSelectedMongoDb, + lastSelectedRedisDb: lastSelectedRedisDb, isReadOnly: isReadOnly, ); } @@ -519,11 +563,13 @@ class MainScreenWorkspaceState { _sqliteEquals(lastSelectedSqliteObject, other.lastSelectedSqliteObject) && _extensionEquals( lastSelectedExtensionObject, other.lastSelectedExtensionObject) && + lastSelectedMongoDb == other.lastSelectedMongoDb && + lastSelectedRedisDb == other.lastSelectedRedisDb && isReadOnly == other.isReadOnly; } @override - int get hashCode => Object.hash( + int get hashCode => Object.hashAll([ activeConnection?.id, activeRedisDb, activeMongoDB, @@ -566,8 +612,37 @@ class MainScreenWorkspaceState { selectedExtensionObject!.database, selectedExtensionObject!.name, ), + lastSelectedPostgresObject == null + ? 0 + : Object.hash( + lastSelectedPostgresObject!.database, + lastSelectedPostgresObject!.schema, + lastSelectedPostgresObject!.name, + lastSelectedPostgresObject!.kind, + ), + lastSelectedMysqlObject == null + ? 0 + : Object.hash( + lastSelectedMysqlObject!.database, + lastSelectedMysqlObject!.name, + lastSelectedMysqlObject!.kind, + ), + lastSelectedSqliteObject == null + ? 0 + : Object.hash( + lastSelectedSqliteObject!.name, + lastSelectedSqliteObject!.kind, + ), + lastSelectedExtensionObject == null + ? 0 + : Object.hash( + lastSelectedExtensionObject!.database, + lastSelectedExtensionObject!.name, + ), + lastSelectedMongoDb, + lastSelectedRedisDb, isReadOnly, - ); + ]); } bool _extensionEquals( diff --git a/lib/features/main_screen/querya_status_bar.dart b/lib/features/main_screen/querya_status_bar.dart new file mode 100644 index 00000000..bea5f7ff --- /dev/null +++ b/lib/features/main_screen/querya_status_bar.dart @@ -0,0 +1,289 @@ +import 'dart:io' show Platform; + +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; +import 'package:querya_desktop/core/ui/querya_icons.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Obsidian/IDE standard bottom status bar. +/// +/// Displays active connection, read-only lock, busy state, last query +/// duration, and result row/column counts (via [QueryaShellStatus]). +class QueryaStatusBar extends material.StatelessWidget { + const QueryaStatusBar({ + super.key, + this.activeConnection, + this.isReadOnly = false, + this.isSidebarVisible = true, + this.onToggleSidebar, + this.statusMessage, + this.isBusy = false, + this.rowCount, + this.columnCount, + this.lastQueryDuration, + this.onOpenPreferences, + }); + + final ConnectionRow? activeConnection; + final bool isReadOnly; + final bool isSidebarVisible; + final material.VoidCallback? onToggleSidebar; + final String? statusMessage; + final bool isBusy; + final int? rowCount; + final int? columnCount; + final Duration? lastQueryDuration; + final material.VoidCallback? onOpenPreferences; + + @override + material.Widget build(material.BuildContext context) { + final wb = context.workbench; + final theme = Theme.of(context); + final isMac = Platform.isMacOS; + + return material.Container( + height: context.scaled(26), + decoration: material.BoxDecoration( + color: wb.surface, + border: material.Border( + top: material.BorderSide( + color: wb.borderSubtle.withValues(alpha: 0.22), + width: 1, + ), + ), + ), + padding: const material.EdgeInsets.symmetric(horizontal: 10), + child: material.Row( + children: [ + // 1. Sidebar toggle quick button + if (onToggleSidebar != null) ...[ + material.Tooltip( + message: 'Toggle Sidebar (${isMac ? "Cmd+B" : "Ctrl+B"})', + child: material.InkWell( + key: const material.Key('status_bar_toggle_sidebar'), + onTap: onToggleSidebar, + borderRadius: material.BorderRadius.circular(3), + child: material.Padding( + padding: const material.EdgeInsets.all(3), + child: material.Icon( + isSidebarVisible + ? material.Icons.view_sidebar_rounded + : material.Icons.view_sidebar_outlined, + size: 13, + color: isSidebarVisible + ? wb.accent + : wb.mutedForeground, + ), + ), + ), + ), + const Gap(8), + _vDivider(wb.borderSubtle), + const Gap(8), + ], + + // 2. Active connection status & dot & central messages + material.Expanded( + child: material.Row( + children: [ + material.Container( + width: 7, + height: 7, + decoration: material.BoxDecoration( + shape: material.BoxShape.circle, + color: activeConnection != null ? wb.success : wb.mutedForeground.withValues(alpha: 0.4), + ), + ), + const Gap(6), + if (activeConnection != null) ...[ + material.Icon( + QueryaIcons.connectionIcon(activeConnection!.type), + size: 13, + color: wb.accent, + ), + const Gap(5), + material.Flexible( + flex: 2, + child: material.Text( + activeConnection!.name, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 11, + fontWeight: material.FontWeight.w500, + color: theme.colorScheme.foreground, + ), + ), + ), + if (activeConnection!.host != null) ...[ + const Gap(4), + material.Flexible( + flex: 3, + child: material.Text( + '(${activeConnection!.host}:${activeConnection!.port ?? ""})', + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 10, + fontFamily: QueryaTypography.mono, + color: wb.mutedForeground, + ), + ), + ), + ], + if (isReadOnly) ...[ + const Gap(6), + material.Tooltip( + message: 'Read-only connection mode active', + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + material.Icons.lock_outline_rounded, + size: 12, + color: wb.warning, + ), + const Gap(2), + material.Text( + 'Read-only', + style: material.TextStyle( + fontSize: 10, + fontWeight: material.FontWeight.w600, + color: wb.warning, + ), + ), + ], + ), + ), + ], + ] else ...[ + material.Text( + 'No connection', + style: material.TextStyle( + fontSize: 11, + color: wb.mutedForeground, + ), + ), + ], + + if (isBusy) ...[ + const Gap(10), + material.SizedBox( + width: 10, + height: 10, + child: material.CircularProgressIndicator( + strokeWidth: 1.5, + color: wb.accent, + ), + ), + ], + if (statusMessage != null && statusMessage!.isNotEmpty) ...[ + const Gap(8), + material.Flexible( + flex: 4, + child: material.Text( + statusMessage!, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 11, + color: wb.mutedForeground, + ), + ), + ), + ], + ], + ), + ), + + const Gap(12), + + // Right status metrics: execution time, rows/cols + if (lastQueryDuration != null) ...[ + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + material.Icons.timer_outlined, + size: 12, + color: wb.mutedForeground, + ), + const Gap(3), + material.Text( + '${lastQueryDuration!.inMilliseconds} ms', + style: material.TextStyle( + fontSize: 10, + fontFamily: QueryaTypography.mono, + color: wb.mutedForeground, + ), + ), + ], + ), + if (rowCount != null || onOpenPreferences != null) ...[ + const Gap(8), + _vDivider(wb.borderSubtle), + const Gap(8), + ], + ], + + if (rowCount != null) ...[ + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + material.Icons.table_rows_rounded, + size: 12, + color: wb.mutedForeground, + ), + const Gap(3), + material.Text( + '$rowCount rows${columnCount != null ? ", $columnCount cols" : ""}', + style: material.TextStyle( + fontSize: 10, + fontFamily: QueryaTypography.mono, + color: wb.mutedForeground, + ), + ), + ], + ), + if (onOpenPreferences != null) ...[ + const Gap(8), + _vDivider(wb.borderSubtle), + const Gap(6), + ], + ], + + if (onOpenPreferences != null) ...[ + if (lastQueryDuration == null && rowCount == null) ...[ + const Gap(8), + _vDivider(wb.borderSubtle), + const Gap(6), + ], + material.Tooltip( + message: 'Preferences (${isMac ? "Cmd+," : "Ctrl+,"})', + child: material.InkWell( + onTap: onOpenPreferences, + borderRadius: material.BorderRadius.circular(3), + child: material.Padding( + padding: const material.EdgeInsets.all(3), + child: material.Icon( + material.Icons.tune_rounded, + size: 13, + color: wb.mutedForeground, + ), + ), + ), + ), + ], + ], + ), + ); + } + + material.Widget _vDivider(material.Color color) { + return material.Container( + width: 1, + height: 12, + color: color.withValues(alpha: 0.25), + ); + } +} diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index 64c7801d..2b19906f 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -6,6 +6,9 @@ import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/connections/driver_manager_dialog.dart'; +import 'package:querya_desktop/core/actions/querya_command_host.dart'; +import 'package:querya_desktop/features/command_palette/command_palette_dialog.dart'; +import 'package:querya_desktop/features/command_palette/quick_switcher_dialog.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/extensions/presentation/pages/extension_manager_dialog.dart'; import 'package:querya_desktop/features/help/about_dialog.dart'; @@ -216,6 +219,35 @@ class QueryaWindowTitleBar extends StatelessWidget { ), MenuButton( subMenu: [ + MenuButton( + leading: const material.Icon( + material.Icons.search_rounded, + size: 18, + ), + trailing: Text( + Platform.isMacOS ? 'Cmd+P' : 'Ctrl+P', + ).xSmall().muted(), + onPressed: (ctx) => showCommandPalette(ctx), + child: const Text('Command Palette…'), + ), + MenuButton( + leading: const material.Icon( + material.Icons.swap_horiz_rounded, + size: 18, + ), + trailing: Text( + Platform.isMacOS ? 'Cmd+K' : 'Ctrl+K', + ).xSmall().muted(), + onPressed: (ctx) { + final host = QueryaCommandHost.maybeOf(ctx); + if (host?.onShowQuickSwitcher != null) { + host!.onShowQuickSwitcher!(''); + } else { + showQuickSwitcher(ctx); + } + }, + child: const Text('Go to Object…'), + ), MenuButton( leading: const material.Icon( material.Icons.tune_rounded, @@ -400,8 +432,6 @@ class QueryaWindowTitleBar extends StatelessWidget { Row( mainAxisSize: material.MainAxisSize.min, children: [ - if (activeConnection != null && isReadOnly) - const QueryaReadOnlyBadge(), UpdateAvailableBadge(controller: UpdateController.instance), if (useNativeWindowChrome && QueryaWindowTitleBar.showBitsdojoWindowButtons( @@ -424,7 +454,10 @@ class QueryaWindowTitleBar extends StatelessWidget { } } -/// Persistent title-bar indicator for a read-only workspace. +/// Compact read-only badge (kept for goldens / isolated tests). +/// +/// The live title bar no longer shows this — read-only lives on +/// [QueryaStatusBar] (#715 / UI-04). class QueryaReadOnlyBadge extends StatelessWidget { const QueryaReadOnlyBadge({super.key}); diff --git a/lib/features/main_screen/workspace_empty_hero.dart b/lib/features/main_screen/workspace_empty_hero.dart index f45d183e..53c15470 100644 --- a/lib/features/main_screen/workspace_empty_hero.dart +++ b/lib/features/main_screen/workspace_empty_hero.dart @@ -490,7 +490,8 @@ class _InteractiveQuickStartRowState extends State<_InteractiveQuickStartRow> { child: material.GestureDetector( onTap: widget.onTap, child: material.AnimatedContainer( - duration: const Duration(milliseconds: 150), + key: const material.ValueKey('empty_hero_hover_row'), + duration: context.motionDuration(QueryaMotion.fast), padding: const material.EdgeInsets.symmetric( horizontal: 12, vertical: 10, diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index f48b232e..4157fc87 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -58,6 +58,8 @@ class WorkspacePanel extends StatefulWidget { this.lastSelectedMysqlObject, this.lastSelectedSqliteObject, this.lastSelectedExtensionObject, + this.lastSelectedMongoDb, + this.lastSelectedRedisDb, this.onNavigateHome, this.onRestoreLastSelectedObject, this.isReadOnly = false, @@ -67,6 +69,7 @@ class WorkspacePanel extends StatefulWidget { this.onRequestLaunchDemo, this.onRequestOpenTour, this.onOpenConnection, + this.initialMongoCollection, }); final VoidCallback? onRequestLaunchDemo; @@ -154,6 +157,15 @@ class WorkspacePanel extends StatefulWidget { String name, })? lastSelectedExtensionObject; + /// Last MongoDB database for 1-click return from stats. + final String? lastSelectedMongoDb; + + /// Last Redis database index for 1-click return from stats. + final int? lastSelectedRedisDb; + + /// Collection to open in [MongoExplorerView] after Quick Switcher jump. + final String? initialMongoCollection; + /// Callback to return to the active connection's stats / overview home. final VoidCallback? onNavigateHome; @@ -302,6 +314,8 @@ class _WorkspacePanelState extends State { home: MongoStatsView( key: ValueKey('mongo_stats_${activeConn.id}'), connectionRow: activeConn, + lastSelectedMongoDb: widget.lastSelectedMongoDb, + onRestoreLastSelectedObject: widget.onRestoreLastSelectedObject, ), object: mongoDb == null ? null @@ -309,6 +323,7 @@ class _WorkspacePanelState extends State { key: ValueKey('mongo_${activeConn.id}_db_$mongoDb'), connectionRow: activeConn, database: mongoDb, + initialCollection: widget.initialMongoCollection, ), ); break; @@ -319,6 +334,8 @@ class _WorkspacePanelState extends State { home: RedisView( key: ValueKey('redis_stats_${activeConn.id}'), connectionRow: activeConn, + lastSelectedRedisDb: widget.lastSelectedRedisDb, + onRestoreLastSelectedObject: widget.onRestoreLastSelectedObject, ), object: redisDb == null ? null diff --git a/lib/features/mongodb/mongo_collections_view.dart b/lib/features/mongodb/mongo_collections_view.dart index 8e6a9168..3622039c 100644 --- a/lib/features/mongodb/mongo_collections_view.dart +++ b/lib/features/mongodb/mongo_collections_view.dart @@ -1,6 +1,8 @@ import 'dart:math' show min; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/actions/querya_schema_object.dart'; +import 'package:querya_desktop/core/actions/querya_schema_object_cache.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -89,6 +91,17 @@ class _MongoCollectionsViewState extends material.State { _statsTotal = names.length; _statsProgress = 0; }); + QueryaSchemaObjectCache.instance.merge( + widget.connection.id, + QueryaSchemaObjectCache.scopeMongo(widget.database), + [ + for (final name in names) + QueryaSchemaObject.mongo( + database: widget.database, + name: name, + ), + ], + ); if (names.isEmpty) return; diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart index a34a8768..e38f6e27 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -1,15 +1,15 @@ +import 'dart:async' show unawaited; import 'dart:convert'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; -import 'package:querya_desktop/core/editor/querya_code_editor.dart'; -import 'package:querya_desktop/core/editor/querya_code_language.dart'; +import 'package:querya_desktop/features/mongodb/mongo_field_codec.dart'; +import 'package:querya_desktop/features/workspace/grid_cell_popover_inspector.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; const _defaultLimit = 25; -const _prettyJsonEncoder = JsonEncoder.withIndent(' '); /// Paginated document browser for a MongoDB collection. class MongoDocumentsView extends material.StatefulWidget { @@ -160,6 +160,31 @@ class _MongoDocumentsViewState extends material.State { } } + Future _inspectField(Map doc, String field) async { + if (field == '_id') return; + final id = doc['_id']; + if (id == null) return; + await showGridCellInspectorDialog( + context: context, + columnName: field, + initialValue: mongoFieldToDisplay(doc[field]), + dataTypeName: 'MongoDB field', + onSaveToDatabase: (value) async { + await MongoService.instance.updateDocument( + widget.connection, + widget.database, + widget.collection, + {'_id': id}, + { + r'$set': {field: mongoDisplayToValue(value)} + }, + ); + if (!mounted) return; + await _load(); + }, + ); + } + Future _deleteDocument(Map doc) async { final id = doc['_id']; if (id == null) return; @@ -234,6 +259,8 @@ class _MongoDocumentsViewState extends material.State { shadcnCs: shadcnCs, onView: () => widget.onDocumentTap?.call(_documents[i]), onDelete: () => _deleteDocument(_documents[i]), + onInspectField: (field) => + unawaited(_inspectField(_documents[i], field)), ); }, ), @@ -380,6 +407,7 @@ class _DocumentCard extends StatefulWidget { required this.shadcnCs, required this.onView, required this.onDelete, + required this.onInspectField, }); final Map document; @@ -388,6 +416,7 @@ class _DocumentCard extends StatefulWidget { final shadcn.ColorScheme shadcnCs; final VoidCallback onView; final VoidCallback onDelete; + final ValueChanged onInspectField; @override State<_DocumentCard> createState() => _DocumentCardState(); @@ -396,8 +425,6 @@ class _DocumentCard extends StatefulWidget { class _DocumentCardState extends State<_DocumentCard> { bool _expanded = false; late String _keysPreviewText; - String? _prettyJsonCache; - material.TextEditingController? _jsonController; @override void initState() { @@ -410,25 +437,11 @@ class _DocumentCardState extends State<_DocumentCard> { super.didUpdateWidget(oldWidget); if (!identical(oldWidget.document, widget.document)) { _keysPreviewText = _computeKeysPreview(widget.document); - _prettyJsonCache = null; - _jsonController?.dispose(); - _jsonController = null; } } - @override - void dispose() { - _jsonController?.dispose(); - super.dispose(); - } - void _toggleExpanded() { - setState(() { - _expanded = !_expanded; - if (_expanded && _prettyJsonCache == null) { - _prettyJsonCache = _encodePrettyJson(widget.document); - } - }); + setState(() => _expanded = !_expanded); } @override @@ -506,27 +519,11 @@ class _DocumentCardState extends State<_DocumentCard> { padding: const material.EdgeInsets.only( left: 16, right: 16, bottom: 10), child: _expanded - ? material.Container( - constraints: - const material.BoxConstraints(maxHeight: 320), - decoration: material.BoxDecoration( - color: cs.muted.withValues(alpha: 0.15), - borderRadius: material.BorderRadius.circular(6), - border: material.Border.all( - color: cs.border.withValues(alpha: 0.3), - ), - ), - child: QueryaCodeEditor( - controller: _jsonController ??= - material.TextEditingController( - text: _prettyJsonCache ?? '', - ), - language: QueryaCodeLanguage.json, - readOnly: true, - fontSize: 12, - variant: QueryaCodeEditorVariant.material, - contentPadding: const material.EdgeInsets.all(10), - ), + ? _FieldList( + document: widget.document, + colorScheme: cs, + shadcnCs: scs, + onInspectField: widget.onInspectField, ) : Text( _keysPreviewText, @@ -550,13 +547,94 @@ class _DocumentCardState extends State<_DocumentCard> { if (keys.isEmpty) return '{ }'; return keys.join(', '); } +} - static String _encodePrettyJson(Map doc) { - try { - return _prettyJsonEncoder.convert(doc); - } catch (_) { - return doc.toString(); - } +class _FieldList extends StatelessWidget { + const _FieldList({ + required this.document, + required this.colorScheme, + required this.shadcnCs, + required this.onInspectField, + }); + + final Map document; + final ColorScheme colorScheme; + final shadcn.ColorScheme shadcnCs; + final ValueChanged onInspectField; + + @override + Widget build(BuildContext context) { + final fields = document.keys.toList(); + return material.Container( + constraints: const material.BoxConstraints(maxHeight: 320), + decoration: material.BoxDecoration( + color: colorScheme.muted.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: colorScheme.border.withValues(alpha: 0.3), + ), + ), + child: ListView.separated( + shrinkWrap: true, + padding: const material.EdgeInsets.symmetric(vertical: 4), + itemCount: fields.length, + separatorBuilder: (_, __) => material.Divider( + height: 1, + color: colorScheme.border.withValues(alpha: 0.2), + ), + itemBuilder: (context, i) { + final key = fields[i]; + final canEdit = key != '_id'; + final display = mongoFieldToDisplay(document[key]); + final oneLine = display.replaceAll('\n', ' '); + return material.InkWell( + onTap: canEdit ? () => onInspectField(key) : null, + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + child: Row( + children: [ + material.SizedBox( + width: 120, + child: Text( + key, + overflow: TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 12, + fontFamily: 'monospace', + fontWeight: material.FontWeight.w600, + color: colorScheme.foreground, + ), + ), + ), + const Gap(8), + material.Expanded( + child: Text( + oneLine, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: shadcnCs.mutedForeground, + ), + ), + ), + if (canEdit) + material.Icon( + material.Icons.edit_note_rounded, + size: 16, + color: shadcnCs.mutedForeground, + ), + ], + ), + ), + ); + }, + ), + ); } } diff --git a/lib/features/mongodb/mongo_explorer_view.dart b/lib/features/mongodb/mongo_explorer_view.dart index 08b7bdca..ddb1580a 100644 --- a/lib/features/mongodb/mongo_explorer_view.dart +++ b/lib/features/mongodb/mongo_explorer_view.dart @@ -32,9 +32,11 @@ class MongoExplorerView extends material.StatefulWidget { super.key, required this.connectionRow, this.database, + this.initialCollection, }); final ConnectionRow connectionRow; final String? database; + final String? initialCollection; @override material.State createState() => _MongoExplorerViewState(); @@ -63,6 +65,9 @@ class _MongoExplorerViewState extends material.State { if (widget.database != null) { _selectedDatabase = widget.database; } + if (widget.initialCollection != null) { + _selectedCollection = widget.initialCollection; + } } @override @@ -104,6 +109,12 @@ class _MongoExplorerViewState extends material.State { setState(() { _connection = conn; _connecting = false; + if (widget.database != null) { + _selectedDatabase = widget.database; + } + if (widget.initialCollection != null) { + _selectedCollection = widget.initialCollection; + } }); } catch (e) { if (mounted) { diff --git a/lib/features/mongodb/mongo_field_codec.dart b/lib/features/mongodb/mongo_field_codec.dart new file mode 100644 index 00000000..30df2b5c --- /dev/null +++ b/lib/features/mongodb/mongo_field_codec.dart @@ -0,0 +1,36 @@ +import 'dart:convert'; + +/// Display string for a MongoDB document field in the Cell Inspector. +String mongoFieldToDisplay(Object? value) { + if (value == null) return 'NULL'; + if (value is Map || value is List) { + try { + return const JsonEncoder.withIndent(' ').convert(value); + } catch (_) { + return value.toString(); + } + } + if (value is bool || value is num) return value.toString(); + if (value is String) return value; + return value.toString(); +} + +/// Parses an inspector string back into a BSON-JSON-friendly Dart value. +Object? mongoDisplayToValue(String text) { + if (text == 'NULL') return null; + final trimmed = text.trim(); + if (trimmed.isEmpty) return ''; + if (trimmed == 'true') return true; + if (trimmed == 'false') return false; + final asNum = num.tryParse(trimmed); + if (asNum != null) return asNum; + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + try { + return json.decode(trimmed); + } catch (_) { + return text; + } + } + return text; +} diff --git a/lib/features/mongodb/mongo_stats_view.dart b/lib/features/mongodb/mongo_stats_view.dart index 7bcfdeee..0d69d077 100644 --- a/lib/features/mongodb/mongo_stats_view.dart +++ b/lib/features/mongodb/mongo_stats_view.dart @@ -20,6 +20,8 @@ class MongoStatsView extends material.StatefulWidget { required this.connectionRow, this.connection, this.onBack, + this.lastSelectedMongoDb, + this.onRestoreLastSelectedObject, }); final ConnectionRow connectionRow; @@ -31,6 +33,10 @@ class MongoStatsView extends material.StatefulWidget { /// Called when the user taps the "back to explorer" button. final material.VoidCallback? onBack; + /// Remembers the last visited database for 1-click return from stats. + final String? lastSelectedMongoDb; + final material.VoidCallback? onRestoreLastSelectedObject; + @override material.State createState() => _MongoStatsViewState(); } @@ -396,6 +402,16 @@ class _MongoStatsViewState extends material.State { alignment: material.WrapAlignment.end, crossAxisAlignment: material.WrapCrossAlignment.center, children: [ + if (widget.lastSelectedMongoDb != null && + widget.onRestoreLastSelectedObject != null) + OutlineButton( + onPressed: widget.onRestoreLastSelectedObject, + leading: const material.Icon( + material.Icons.folder_outlined, + size: 18, + ), + child: Text('Return to ${widget.lastSelectedMongoDb}'), + ), if (widget.onBack != null) OutlineButton( onPressed: widget.onBack, diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 0a4b85ee..99473c67 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -14,6 +14,7 @@ import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/ui/querya_shell_status.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; import 'package:querya_desktop/features/workspace/workspace.dart'; @@ -85,6 +86,14 @@ class _MysqlSqlWorkspaceState extends material.State { }, onNextTab: _nextTab, onPrevTab: _prevTab, + onFormat: () { + _activeSession.formatSql(); + setState(() {}); + }, + onClear: () { + _activeSession.clearSql(); + setState(() {}); + }, onOpenWithContent: (sql, filePath, title) { if (!mounted) return; final session = _activeSession; @@ -261,6 +270,8 @@ class _MysqlSqlWorkspaceState extends material.State { session.affectedRows = null; session.statusLine = null; }); + QueryaShellStatus.instance.beginBusy(message: 'Running query…'); + final sw = Stopwatch()..start(); try { await _ensureLease(); @@ -271,6 +282,7 @@ class _MysqlSqlWorkspaceState extends material.State { session.error = 'Could not connect to MySQL.'; session.running = false; }); + QueryaShellStatus.instance.endBusy(); } return; } @@ -333,6 +345,13 @@ class _MysqlSqlWorkspaceState extends material.State { } session.running = false; }); + sw.stop(); + QueryaShellStatus.instance.reportQueryResult( + duration: sw.elapsed, + rowCount: outRows.length, + columnCount: cols.length, + message: session.statusLine, + ); final cid = widget.connectionRow.id; if (cid != null) { unawaited( @@ -351,6 +370,7 @@ class _MysqlSqlWorkspaceState extends material.State { session.error = e.toString(); session.running = false; }); + QueryaShellStatus.instance.endBusy(); } } catch (e) { if (mounted) { @@ -358,6 +378,7 @@ class _MysqlSqlWorkspaceState extends material.State { session.error = e.toString(); session.running = false; }); + QueryaShellStatus.instance.endBusy(); } } } diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index b92aaea5..ad22e733 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -1,13 +1,16 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:mysql_client/mysql_client.dart'; import 'package:querya_desktop/core/database/mysql_connection.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/mysql/mysql_sql_editor_dialog.dart'; import 'package:querya_desktop/features/mysql/mysql_table_utils.dart'; +import 'package:querya_desktop/features/workspace/workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; const _defaultLimit = 200; @@ -46,13 +49,24 @@ class _MysqlTableViewState extends material.State { int _rowsOnPage = 0; int? _totalRowCount; int _offset = 0; - String? _sortColumn; - bool _sortAscending = true; bool _customSqlActive = false; String? _customSql; - final _verticalController = material.ScrollController(); - final _horizontalController = material.ScrollController(); + DataGridStagingBuffer? _stagingBuffer; + List _primaryKeys = []; + Map _columnDataTypes = {}; + bool _schemaLoaded = false; + bool _isSaving = false; + + String get _tableTitle => '${widget.database}.${widget.tableName}'; + + bool get _isDirty => _stagingBuffer?.isDirty ?? false; + + bool get _editingEnabled => tableViewEditingEnabled( + isView: widget.isView, + customSqlActive: _customSqlActive, + hasPrimaryKey: _primaryKeys.isNotEmpty, + ); String _qualifiedFrom() { final d = MysqlConnection.quoteIdentifier(widget.database); @@ -61,29 +75,7 @@ class _MysqlTableViewState extends material.State { } String _browseDataSql() { - final orderBy = _sortColumn != null - ? ' ORDER BY ${MysqlConnection.quoteIdentifier(_sortColumn!)} ${_sortAscending ? "ASC" : "DESC"}' - : ''; - return 'SELECT * FROM ${_qualifiedFrom()}$orderBy LIMIT ${widget.limit} OFFSET $_offset'; - } - - void _toggleSort(String columnName) { - if (_loading || _customSqlActive) return; - setState(() { - if (_sortColumn == columnName) { - if (_sortAscending) { - _sortAscending = false; - } else { - _sortColumn = null; - _sortAscending = true; - } - } else { - _sortColumn = columnName; - _sortAscending = true; - } - _offset = 0; - }); - _fetch(); + return 'SELECT * FROM ${_qualifiedFrom()} LIMIT ${widget.limit} OFFSET $_offset'; } @override @@ -99,10 +91,9 @@ class _MysqlTableViewState extends material.State { oldWidget.database != widget.database || oldWidget.tableName != widget.tableName || oldWidget.isView != widget.isView) { - _sortColumn = null; - _sortAscending = true; _customSqlActive = false; _customSql = null; + _resetStaging(); _disconnectCurrent(interruptIfBusy: true); _connectAndLoad(); } @@ -110,12 +101,20 @@ class _MysqlTableViewState extends material.State { @override void dispose() { - _verticalController.dispose(); - _horizontalController.dispose(); + _resetStaging(); _disconnectCurrent(interruptIfBusy: true); super.dispose(); } + void _resetStaging() { + _stagingBuffer?.dispose(); + _stagingBuffer = null; + _primaryKeys = []; + _columnDataTypes = {}; + _schemaLoaded = false; + _isSaving = false; + } + void _disconnectCurrent({bool interruptIfBusy = false}) { if (interruptIfBusy && _loading) { MysqlService.instance.interrupt( @@ -141,6 +140,7 @@ class _MysqlTableViewState extends material.State { _offset = 0; _customSqlActive = false; _customSql = null; + _resetStaging(); }); try { final lease = await MysqlService.instance.acquire( @@ -191,6 +191,45 @@ class _MysqlTableViewState extends material.State { return out; } + Future _confirmDiscardIfNeeded() { + return confirmDiscardTableEditsIfDirty( + context: context, + buffer: _stagingBuffer, + tableTitle: _tableTitle, + ); + } + + Future _ensureSchema(MysqlConnection conn) async { + if (_schemaLoaded) return; + if (widget.isView) { + _schemaLoaded = true; + _primaryKeys = []; + _columnDataTypes = {}; + return; + } + try { + final schema = await conn.getTableSchema( + database: widget.database, + table: widget.tableName, + ); + _primaryKeys = List.from(schema.primaryKeys); + _columnDataTypes = columnDataTypesFromSchema(schema); + } catch (_) { + _primaryKeys = []; + _columnDataTypes = {}; + } + _schemaLoaded = true; + } + + void _installStagingBuffer(List columns, List> rows) { + _stagingBuffer = replaceTableViewStagingBuffer( + previous: _stagingBuffer, + columns: columns, + rows: rows, + enabled: _editingEnabled, + ); + } + Future _fetch({bool refreshCount = false}) async { final conn = _connection; if (conn == null || !conn.isConnected) { @@ -227,9 +266,13 @@ class _MysqlTableViewState extends material.State { final result = await conn.execute(dataSql); if (!mounted) return; + await _ensureSchema(conn); + if (!mounted) return; + final colNames = _resultColumns(result); final stringRows = await _resultRowsAsync(result); + if (!mounted) return; setState(() { _columnNames = colNames; _rows = stringRows; @@ -238,10 +281,8 @@ class _MysqlTableViewState extends material.State { _totalRowCount = totalRows; } _loading = false; + _installStagingBuffer(colNames, stringRows); }); - if (_verticalController.hasClients) { - _verticalController.jumpTo(0); - } } catch (e) { if (mounted) { setState(() { @@ -274,16 +315,15 @@ class _MysqlTableViewState extends material.State { final result = await conn.execute(sql); if (!mounted) return; final stringRows = await _resultRowsAsync(result); + if (!mounted) return; setState(() { _columnNames = _resultColumns(result); _rows = stringRows; _rowsOnPage = _rows.length; _totalRowCount = null; _loading = false; + _installStagingBuffer(_columnNames, stringRows); }); - if (_verticalController.hasClients) { - _verticalController.jumpTo(0); - } } catch (e) { if (mounted) { setState(() { @@ -294,22 +334,24 @@ class _MysqlTableViewState extends material.State { } } - void _onSqlRun(String sql) { + Future _onSqlRun(String sql) async { final trimmed = sql.trim(); if (!isAllowedMysqlSelectQuery(trimmed)) return; + if (!await _confirmDiscardIfNeeded()) return; + if (!mounted) return; final browse = _browseDataSql().trim(); if (_browseSqlCompareKey(trimmed) == _browseSqlCompareKey(browse)) { setState(() { _customSqlActive = false; _customSql = null; }); - unawaited(_fetch(refreshCount: true)); + await _fetch(refreshCount: true); } else { setState(() { _customSqlActive = true; _customSql = trimmed; }); - unawaited(_fetchCustom()); + await _fetchCustom(); } } @@ -320,21 +362,23 @@ class _MysqlTableViewState extends material.State { ? _customSql! : _browseDataSql(), browseSql: _browseDataSql(), - onRun: _onSqlRun, + onRun: (sql) => unawaited(_onSqlRun(sql)), ); } - void _exitCustomMode() { + Future _exitCustomMode() async { + if (!await _confirmDiscardIfNeeded()) return; + if (!mounted) return; setState(() { _customSqlActive = false; _customSql = null; }); - unawaited(_fetch(refreshCount: true)); + await _fetch(refreshCount: true); } void _goToPreviousPage() { if (_customSqlActive) return; - if (_offset <= 0 || _loading) return; + if (_offset <= 0 || _loading || _isDirty) return; setState(() { final next = _offset - widget.limit; _offset = next < 0 ? 0 : next; @@ -344,7 +388,7 @@ class _MysqlTableViewState extends material.State { void _goToNextPage() { if (_customSqlActive) return; - if (_loading) return; + if (_loading || _isDirty) return; final total = _totalRowCount; final limit = widget.limit; if (total != null && _offset + _rowsOnPage >= total) return; @@ -355,11 +399,11 @@ class _MysqlTableViewState extends material.State { unawaited(_fetch()); } - bool get _canGoPrevious => !_customSqlActive && _offset > 0 && !_loading; + bool get _canGoPrevious => + !_customSqlActive && _offset > 0 && !_loading && !_isDirty; bool get _canGoNext { - if (_customSqlActive) return false; - if (_loading) return false; + if (_customSqlActive || _loading || _isDirty) return false; final total = _totalRowCount; final limit = widget.limit; if (total != null) { @@ -387,337 +431,242 @@ class _MysqlTableViewState extends material.State { return '$start–$end'; } - @override - material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + String? _statusLine() { + final reason = tableViewEditDisabledReason( + isView: widget.isView, + customSqlActive: _customSqlActive, + hasPrimaryKey: _primaryKeys.isNotEmpty, + schemaLoaded: _schemaLoaded, + ); + final pag = _paginationLabel(); + if (reason != null) return '$pag · $reason'; + return pag; + } - if (_loading) { - return material.Container( - color: cs.background, - child: material.Center( - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - children: [ - const material.SizedBox( - width: 28, - height: 28, - child: material.CircularProgressIndicator(strokeWidth: 2), - ), - const Gap(12), - const Text('Loading data...').muted().small(), - ], - ), - ), - ); + Future _onRefresh() async { + if (!await _confirmDiscardIfNeeded()) return; + if (!mounted) return; + if (_customSqlActive) { + await _fetchCustom(); + } else { + await _fetch(refreshCount: true); } + } - if (_error != null) { - return material.Container( - color: cs.background, - child: material.Center( - child: material.Padding( - padding: const material.EdgeInsets.all(32), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Icon(material.Icons.error_outline_rounded, - size: 48, color: cs.destructive), - const Gap(16), - const Text('Query Error').large().semiBold(), - const Gap(8), - material.SelectableText(_error!, - style: material.TextStyle( - color: cs.mutedForeground, fontSize: 13)), - const Gap(24), - OutlineButton( - onPressed: () { - if (_customSqlActive) { - unawaited(_fetchCustom()); - } else { - unawaited(_fetch(refreshCount: true)); - } - }, - leading: const material.Icon(material.Icons.refresh_rounded, - size: 18), - child: const Text('Retry'), - ), - ], - ), - ), - ), + Future _onNavigateHome() async { + final home = widget.onNavigateHome; + if (home == null) return; + if (!await _confirmDiscardIfNeeded()) return; + if (!mounted) return; + home(); + } + + Future _applyStagedChanges() async { + final buffer = _stagingBuffer; + if (buffer == null || !buffer.isDirty || _isSaving) return; + setState(() => _isSaving = true); + final outcome = await applyTableViewStagedChanges( + context: context, + buffer: buffer, + dialect: SqlDialect.mysql, + tableName: widget.tableName, + schema: widget.database, + primaryKeys: _primaryKeys, + columnDataTypes: _columnDataTypes.isEmpty ? null : _columnDataTypes, + execute: (plan) async { + final conn = _connection; + if (conn == null || !conn.isConnected) { + throw StateError('Could not connect to MySQL.'); + } + await conn.execute('START TRANSACTION'); + try { + for (final stmt in plan.statements) { + await conn.execute(stmt.sql); + } + await conn.execute('COMMIT'); + } catch (e) { + try { + await conn.execute('ROLLBACK'); + } catch (_) {} + rethrow; + } + }, + ); + if (!mounted) return; + if (outcome.isApplied) { + final newRows = buffer.effectiveRows; + buffer.dispose(); + setState(() { + _rows = newRows; + _rowsOnPage = newRows.length; + _stagingBuffer = replaceTableViewStagingBuffer( + previous: null, + columns: _columnNames, + rows: newRows, + enabled: _editingEnabled, + ); + _isSaving = false; + }); + showAppToast( + context: context, + message: '${outcome.statementCount} change(s) saved', + variant: AppToastVariant.success, ); + return; } - - if (_columnNames.isEmpty) { - return material.Container( - color: cs.background, - child: material.Center( - child: material.Padding( - padding: const material.EdgeInsets.all(32), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - children: [ - const Text('No columns returned').muted().small(), - const Gap(24), - OutlineButton( - onPressed: () { - if (_customSqlActive) { - unawaited(_fetchCustom()); - } else { - unawaited(_fetch(refreshCount: true)); - } - }, - leading: const material.Icon(material.Icons.refresh_rounded, - size: 18), - child: const Text('Retry'), - ), - ], - ), - ), - ), + setState(() => _isSaving = false); + if (outcome.isFailed && outcome.error != null) { + await showTableViewSaveFailedDialog( + context: context, + error: outcome.error!, ); } + } - const double rowHeight = 36; - const double headerHeight = 40; - final colCount = _columnNames.length; - final title = - '${widget.database}.${widget.tableName}${widget.isView ? ' (view)' : ''}'; - + material.Widget _buildChromeRow(ColorScheme cs) { + final title = '$_tableTitle${widget.isView ? ' (view)' : ''}'; return material.Container( - color: cs.background, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, + height: 48, + padding: const material.EdgeInsets.symmetric(horizontal: 12), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.35), + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.4), + ), + ), + ), + child: material.Row( children: [ - material.Container( - height: 48, - padding: const material.EdgeInsets.symmetric(horizontal: 12), - decoration: material.BoxDecoration( - color: cs.muted.withValues(alpha: 0.35), - border: material.Border( - bottom: material.BorderSide( - color: cs.border.withValues(alpha: 0.4), + if (widget.onNavigateHome != null) ...[ + material.Tooltip( + message: 'Return to server overview', + child: OutlineButton( + size: ButtonSize.small, + onPressed: () => unawaited(_onNavigateHome()), + leading: const material.Icon( + material.Icons.dns_outlined, + size: 14, ), + child: const Text('Server'), ), ), - child: material.Row( - children: [ - if (widget.onNavigateHome != null) ...[ - material.Tooltip( - message: 'Return to server overview', - child: OutlineButton( - size: ButtonSize.small, - onPressed: widget.onNavigateHome, - leading: const material.Icon( - material.Icons.dns_outlined, - size: 14, - ), - child: const Text('Server'), - ), - ), - const Gap(10), - ], - material.Icon( - widget.isView - ? material.Icons.view_agenda_rounded - : material.Icons.table_chart_rounded, - size: 20, - color: cs.primary, - ), - const Gap(8), - material.Expanded( - child: material.Text( - title, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 13, - fontWeight: material.FontWeight.w600, - color: cs.foreground, - ), - ), - ), - material.Expanded( - flex: 2, - child: material.LayoutBuilder( - builder: (context, constraints) { - return material.SingleChildScrollView( - scrollDirection: material.Axis.horizontal, - child: material.ConstrainedBox( - constraints: material.BoxConstraints( - minWidth: constraints.maxWidth, - ), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: material.BoxDecoration( - color: cs.muted.withValues(alpha: 0.4), - borderRadius: material.BorderRadius.circular(4), - ), - child: material.Text( - _paginationLabel(), - style: material.TextStyle( - fontSize: 11, - color: cs.mutedForeground, - ), - ), - ), - const Gap(6), - OutlineButton( - size: ButtonSize.small, - onPressed: _openSqlEditor, - leading: const material.Icon( - material.Icons.code_rounded, - size: 15, - ), - child: const Text('SQL'), - ), - if (_customSqlActive) ...[ - const Gap(4), - OutlineButton( - size: ButtonSize.small, - onPressed: _exitCustomMode, - leading: const material.Icon( - material.Icons.table_chart_rounded, - size: 15, - ), - child: const Text('Browse'), - ), - ], - const Gap(4), - OutlineButton( - size: ButtonSize.small, - onPressed: (!_canGoPrevious || _loading) - ? null - : _goToPreviousPage, - leading: const material.Icon( - material.Icons.chevron_left_rounded, - size: 16, - ), - child: const Text('Prev'), - ), - const Gap(4), - OutlineButton( - size: ButtonSize.small, - onPressed: (!_canGoNext || _loading) - ? null - : _goToNextPage, - leading: const material.Icon( - material.Icons.chevron_right_rounded, - size: 16, - ), - child: const Text('Next'), - ), - const Gap(8), - OutlineButton( - size: ButtonSize.small, - onPressed: _loading - ? null - : () { - if (_customSqlActive) { - unawaited(_fetchCustom()); - } else { - unawaited(_fetch(refreshCount: true)); - } - }, - leading: const material.Icon( - material.Icons.refresh_rounded, - size: 14, - ), - child: const Text('Refresh'), - ), - ], - ), - ), - ); - }, - ), - ), - ], + const Gap(10), + ], + material.Icon( + widget.isView + ? material.Icons.view_agenda_rounded + : material.Icons.table_chart_rounded, + size: 20, + color: cs.primary, + ), + const Gap(8), + material.Expanded( + child: material.Text( + title, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + ), ), ), material.Expanded( + flex: 2, child: material.LayoutBuilder( builder: (context, constraints) { - return material.Scrollbar( - controller: _horizontalController, - thumbVisibility: true, - notificationPredicate: (_) => true, - child: material.SingleChildScrollView( - controller: _horizontalController, - scrollDirection: material.Axis.horizontal, - child: material.SizedBox( - width: _calcTableWidth(colCount, constraints.maxWidth), - child: material.Column( - children: [ - material.Container( - height: headerHeight, - decoration: material.BoxDecoration( - color: cs.muted.withValues(alpha: 0.35), - border: material.Border( - bottom: material.BorderSide( - color: cs.border.withValues(alpha: 0.5)), - ), - ), - child: material.Row( - children: [ - _rowNumberCell(cs, '#', isHeader: true), - for (var i = 0; i < colCount; i++) - _headerCell(cs, _columnNames[i]), - ], + return material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.ConstrainedBox( + constraints: material.BoxConstraints( + minWidth: constraints.maxWidth, + ), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.4), + borderRadius: material.BorderRadius.circular(4), + ), + child: material.Text( + _paginationLabel(), + style: material.TextStyle( + fontSize: 11, + color: cs.mutedForeground, ), ), - material.Expanded( - child: material.Scrollbar( - controller: _verticalController, - thumbVisibility: true, - child: material.ListView.builder( - controller: _verticalController, - itemCount: _rowsOnPage, - itemExtent: rowHeight, - itemBuilder: (context, rowIdx) { - final row = _rows[rowIdx]; - final isEven = rowIdx % 2 == 0; - final displayRowNum = _customSqlActive - ? rowIdx + 1 - : _offset + rowIdx + 1; - return material.RepaintBoundary( - child: material.Container( - height: rowHeight, - decoration: material.BoxDecoration( - color: isEven - ? material.Colors.transparent - : cs.muted.withValues(alpha: 0.12), - border: material.Border( - bottom: material.BorderSide( - color: cs.border - .withValues(alpha: 0.15), - ), - ), - ), - child: material.Row( - children: [ - _rowNumberCell(cs, '$displayRowNum'), - for (var c = 0; c < colCount; c++) - _dataCell( - cs, - row.length > c ? row[c] : ''), - ], - ), - ), - ); - }, - ), + ), + const Gap(6), + if (_stagingBuffer != null) + TableBrowserPendingActions( + buffer: _stagingBuffer!, + onSave: () => unawaited(_applyStagedChanges()), + isSaving: _isSaving, + ), + OutlineButton( + size: ButtonSize.small, + onPressed: _openSqlEditor, + leading: const material.Icon( + material.Icons.code_rounded, + size: 15, + ), + child: const Text('SQL'), + ), + if (_customSqlActive) ...[ + const Gap(4), + OutlineButton( + size: ButtonSize.small, + onPressed: () => unawaited(_exitCustomMode()), + leading: const material.Icon( + material.Icons.table_chart_rounded, + size: 15, ), + child: const Text('Browse'), ), ], - ), + const Gap(4), + OutlineButton( + size: ButtonSize.small, + onPressed: (!_canGoPrevious || _loading) + ? null + : _goToPreviousPage, + leading: const material.Icon( + material.Icons.chevron_left_rounded, + size: 16, + ), + child: const Text('Prev'), + ), + const Gap(4), + OutlineButton( + size: ButtonSize.small, + onPressed: + (!_canGoNext || _loading) ? null : _goToNextPage, + leading: const material.Icon( + material.Icons.chevron_right_rounded, + size: 16, + ), + child: const Text('Next'), + ), + const Gap(8), + OutlineButton( + size: ButtonSize.small, + onPressed: + _loading ? null : () => unawaited(_onRefresh()), + leading: const material.Icon( + material.Icons.refresh_rounded, + size: 14, + ), + child: const Text('Refresh'), + ), + ], ), ), ); @@ -729,110 +678,59 @@ class _MysqlTableViewState extends material.State { ); } - static String _browseSqlCompareKey(String sql) { - var s = sql.trim(); - while (s.endsWith(';')) { - s = s.substring(0, s.length - 1).trimRight(); - } - return s.replaceAll(RegExp(r'\s+'), ' '); - } - - double _calcTableWidth(int colCount, double availableWidth) { - const double rowNumWidth = 52; - const double minColWidth = 150; - final calculated = rowNumWidth + colCount * minColWidth; - return calculated > availableWidth ? calculated : availableWidth; - } - - material.Widget _rowNumberCell(ColorScheme cs, String text, - {bool isHeader = false}) { - return material.Container( - width: 52, - padding: const material.EdgeInsets.symmetric(horizontal: 8), - alignment: material.Alignment.centerRight, - decoration: material.BoxDecoration( - border: material.Border( - right: material.BorderSide(color: cs.border.withValues(alpha: 0.3)), - ), - ), - child: material.Text( - text, - style: material.TextStyle( - fontSize: 11, - fontWeight: - isHeader ? material.FontWeight.w600 : material.FontWeight.normal, - color: cs.mutedForeground.withValues(alpha: 0.7), - fontFamily: 'monospace', - ), - ), - ); - } - - material.Widget _headerCell(ColorScheme cs, String name) { - final isSorted = !_customSqlActive && _sortColumn == name; - return material.Expanded( - child: material.MouseRegion( - cursor: !_customSqlActive - ? material.SystemMouseCursors.click - : material.SystemMouseCursors.basic, - child: material.GestureDetector( - behavior: material.HitTestBehavior.opaque, - onTap: () => _toggleSort(name), - child: material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 10), - alignment: material.Alignment.centerLeft, - child: material.Row( - children: [ - material.Expanded( - child: material.Text( - name, - style: material.TextStyle( - fontSize: 12, - fontWeight: material.FontWeight.w600, - color: isSorted ? cs.primary : cs.foreground, - ), - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - ), + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final buffer = _stagingBuffer; + + return material.CallbackShortcuts( + bindings: { + const material.SingleActivator(LogicalKeyboardKey.f5): () { + if (!_loading) unawaited(_onRefresh()); + }, + }, + child: material.Focus( + autofocus: true, + child: material.Container( + color: cs.background, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + if (buffer == null) + _buildChromeRow(cs) + else + ListenableBuilder( + listenable: buffer, + builder: (context, _) => _buildChromeRow(cs), ), - if (isSorted) ...[ - const Gap(4), - material.Icon( - _sortAscending - ? material.Icons.arrow_upward_rounded - : material.Icons.arrow_downward_rounded, - size: 14, - color: cs.primary, - ), - ], - ], - ), + material.Expanded( + child: ResultsTab( + columns: _columnNames, + rows: _rows, + errorMessage: _error, + isLoading: _loading, + statusLine: _statusLine(), + showExportToolbar: true, + stagingBuffer: _stagingBuffer, + columnDataTypes: + _columnDataTypes.isEmpty ? null : _columnDataTypes, + onApplyChanges: + _stagingBuffer != null ? _applyStagedChanges : null, + isSaving: _isSaving, + ), + ), + ], ), ), ), ); } - material.Widget _dataCell(ColorScheme cs, String value) { - final isNull = value == 'NULL'; - return material.Expanded( - child: material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 10), - alignment: material.Alignment.centerLeft, - child: material.Text( - value, - style: material.TextStyle( - fontSize: 12, - color: isNull - ? cs.mutedForeground.withValues(alpha: 0.5) - : cs.foreground, - fontStyle: - isNull ? material.FontStyle.italic : material.FontStyle.normal, - ), - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - ), - ), - ); + static String _browseSqlCompareKey(String sql) { + var s = sql.trim(); + while (s.endsWith(';')) { + s = s.substring(0, s.length - 1).trimRight(); + } + return s.replaceAll(RegExp(r'\s+'), ' '); } } diff --git a/lib/features/mysql/mysql_workspace_home.dart b/lib/features/mysql/mysql_workspace_home.dart index 91096b8f..04333594 100644 --- a/lib/features/mysql/mysql_workspace_home.dart +++ b/lib/features/mysql/mysql_workspace_home.dart @@ -108,7 +108,7 @@ class _MysqlWorkspaceHomeState extends material.State { ], const Spacer(), QueryaTabStrip( - labels: const ['Server', 'SQL'], + labels: const ['Overview', 'SQL'], selectedIndex: _tab, onSelected: (index) => unawaited(_selectTab(index)), ), diff --git a/lib/features/onboarding/welcome_tour_dialog.dart b/lib/features/onboarding/welcome_tour_dialog.dart index a15536df..293de8db 100644 --- a/lib/features/onboarding/welcome_tour_dialog.dart +++ b/lib/features/onboarding/welcome_tour_dialog.dart @@ -221,8 +221,8 @@ class _WelcomeTourDialogState extends State { minWidth: 460, ), child: material.AnimatedSize( - duration: const Duration(milliseconds: 240), - curve: Curves.easeOutCubic, + duration: context.motionDuration(QueryaMotion.standard), + curve: context.motionCurve(QueryaMotion.enter), alignment: material.Alignment.topCenter, child: material.Padding( padding: const material.EdgeInsets.all(24), @@ -288,9 +288,9 @@ class _WelcomeTourDialogState extends State { // Animated Slide body material.AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, + duration: context.motionDuration(QueryaMotion.standard), + switchInCurve: context.motionCurve(QueryaMotion.enter), + switchOutCurve: context.motionCurve(QueryaMotion.exit), transitionBuilder: (child, animation) { return material.FadeTransition( opacity: animation, @@ -329,8 +329,8 @@ class _WelcomeTourDialogState extends State { vertical: 6, ), child: material.AnimatedContainer( - duration: const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.enter), width: active ? 22 : 6, height: 6, decoration: material.BoxDecoration( diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 2e5ea792..fcaf0544 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -16,6 +16,7 @@ import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/ui/querya_shell_status.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; @@ -128,6 +129,14 @@ class _PostgresSqlWorkspaceState extends material.State { }, onNextTab: _nextTab, onPrevTab: _prevTab, + onFormat: () { + _activeSession.formatSql(); + setState(() {}); + }, + onClear: () { + _activeSession.clearSql(); + setState(() {}); + }, onOpenWithContent: (sql, filePath, title) { if (!mounted) return; final session = _activeSession; @@ -417,6 +426,8 @@ class _PostgresSqlWorkspaceState extends material.State { session.affectedRows = null; session.statusLine = null; }); + QueryaShellStatus.instance.beginBusy(message: 'Running query…'); + final sw = Stopwatch()..start(); try { await _ensureLease(); @@ -427,6 +438,7 @@ class _PostgresSqlWorkspaceState extends material.State { session.error = 'Could not connect to PostgreSQL.'; session.running = false; }); + QueryaShellStatus.instance.endBusy(); } return; } @@ -484,6 +496,13 @@ class _PostgresSqlWorkspaceState extends material.State { } session.running = false; }); + sw.stop(); + QueryaShellStatus.instance.reportQueryResult( + duration: sw.elapsed, + rowCount: outRows.length, + columnCount: cols.length, + message: session.statusLine, + ); final cid = widget.connectionRow.id; if (cid != null) { unawaited( @@ -502,6 +521,7 @@ class _PostgresSqlWorkspaceState extends material.State { session.error = 'Query timed out: ${e.message ?? e}'; session.running = false; }); + QueryaShellStatus.instance.endBusy(); } } on pg.ServerException catch (e) { if (mounted) { @@ -509,6 +529,7 @@ class _PostgresSqlWorkspaceState extends material.State { session.error = e.message; session.running = false; }); + QueryaShellStatus.instance.endBusy(); } } catch (e) { if (mounted) { @@ -516,6 +537,7 @@ class _PostgresSqlWorkspaceState extends material.State { session.error = e.toString(); session.running = false; }); + QueryaShellStatus.instance.endBusy(); } } finally { await _refreshTxStatus(); diff --git a/lib/features/postgresql/postgres_table_toolbar.dart b/lib/features/postgresql/postgres_table_toolbar.dart index 698f0fa1..e147d7c9 100644 --- a/lib/features/postgresql/postgres_table_toolbar.dart +++ b/lib/features/postgresql/postgres_table_toolbar.dart @@ -21,6 +21,7 @@ class PostgresTableToolbar extends material.StatelessWidget { required this.onGoNext, required this.onRefresh, this.onNavigateHome, + this.pendingActions, }); final String title; @@ -40,6 +41,9 @@ class PostgresTableToolbar extends material.StatelessWidget { final VoidCallback onRefresh; final VoidCallback? onNavigateHome; + /// Compact pending-change badge + Save / Revert (Table Browser staging). + final material.Widget? pendingActions; + @override material.Widget build(material.BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -116,6 +120,7 @@ class PostgresTableToolbar extends material.StatelessWidget { ), ), const Gap(6), + if (pendingActions != null) pendingActions!, OutlineButton( size: ButtonSize.small, onPressed: onOpenSql, diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index 679bcfde..ade09570 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -1,12 +1,17 @@ +import 'dart:async' show unawaited; + import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/postgresql/postgres_sql_editor_dialog.dart'; import 'package:querya_desktop/features/postgresql/postgres_table_privileges_dialog.dart'; import 'package:querya_desktop/features/postgresql/postgres_table_toolbar.dart'; import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart'; +import 'package:querya_desktop/features/workspace/workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; class PostgresTableView extends material.StatefulWidget { @@ -55,15 +60,27 @@ class _PostgresTableViewState extends material.State { /// Zero-based offset for LIMIT/OFFSET pagination. int _offset = 0; - String? _sortColumn; - bool _sortAscending = true; /// When true, [dataSql] comes from [_customSql] (no pagination). bool _customSqlActive = false; String? _customSql; - final _verticalController = material.ScrollController(); - final _horizontalController = material.ScrollController(); + DataGridStagingBuffer? _stagingBuffer; + List _primaryKeys = []; + Map _columnDataTypes = {}; + bool _schemaLoaded = false; + bool _isSaving = false; + + String get _tableTitle => '${widget.schema}.${widget.tableName}'; + + bool get _isDirty => _stagingBuffer?.isDirty ?? false; + + bool get _editingEnabled => tableViewEditingEnabled( + isView: widget.isView, + isMaterializedView: widget.isMaterializedView, + customSqlActive: _customSqlActive, + hasPrimaryKey: _primaryKeys.isNotEmpty, + ); @override void initState() { @@ -79,10 +96,9 @@ class _PostgresTableViewState extends material.State { oldWidget.schema != widget.schema || oldWidget.tableName != widget.tableName || oldWidget.isMaterializedView != widget.isMaterializedView) { - _sortColumn = null; - _sortAscending = true; _customSqlActive = false; _customSql = null; + _resetStaging(); _disconnectCurrent(); _connectAndLoad(); } @@ -90,12 +106,20 @@ class _PostgresTableViewState extends material.State { @override void dispose() { - _verticalController.dispose(); - _horizontalController.dispose(); + _resetStaging(); _disconnectCurrent(interruptIfBusy: true); super.dispose(); } + void _resetStaging() { + _stagingBuffer?.dispose(); + _stagingBuffer = null; + _primaryKeys = []; + _columnDataTypes = {}; + _schemaLoaded = false; + _isSaving = false; + } + void _disconnectCurrent({bool interruptIfBusy = false}) { if (interruptIfBusy && _loading) { PostgresService.instance.interrupt( @@ -121,6 +145,7 @@ class _PostgresTableViewState extends material.State { _offset = 0; _customSqlActive = false; _customSql = null; + _resetStaging(); }); try { final lease = await PostgresService.instance.acquire( @@ -156,29 +181,46 @@ class _PostgresTableViewState extends material.State { String _browseDataSql() { final schemaQ = quotePostgresIdentifier(widget.schema); final tableQ = quotePostgresIdentifier(widget.tableName); - final orderBy = _sortColumn != null - ? ' ORDER BY ${quotePostgresIdentifier(_sortColumn!)} ${_sortAscending ? "ASC" : "DESC"}' - : ''; - return 'SELECT * FROM $schemaQ.$tableQ$orderBy LIMIT ${widget.limit} OFFSET $_offset'; + return 'SELECT * FROM $schemaQ.$tableQ LIMIT ${widget.limit} OFFSET $_offset'; } - void _toggleSort(String columnName) { - if (_loading || _customSqlActive) return; - setState(() { - if (_sortColumn == columnName) { - if (_sortAscending) { - _sortAscending = false; - } else { - _sortColumn = null; - _sortAscending = true; - } - } else { - _sortColumn = columnName; - _sortAscending = true; - } - _offset = 0; - }); - _fetch(); + Future _confirmDiscardIfNeeded() { + return confirmDiscardTableEditsIfDirty( + context: context, + buffer: _stagingBuffer, + tableTitle: _tableTitle, + ); + } + + Future _ensureSchema(PostgresConnection conn) async { + if (_schemaLoaded) return; + if (widget.isView || widget.isMaterializedView) { + _schemaLoaded = true; + _primaryKeys = []; + _columnDataTypes = {}; + return; + } + try { + final schema = await conn.getTableSchema( + schema: widget.schema, + table: widget.tableName, + ); + _primaryKeys = List.from(schema.primaryKeys); + _columnDataTypes = columnDataTypesFromSchema(schema); + } catch (_) { + _primaryKeys = []; + _columnDataTypes = {}; + } + _schemaLoaded = true; + } + + void _installStagingBuffer(List columns, List> rows) { + _stagingBuffer = replaceTableViewStagingBuffer( + previous: _stagingBuffer, + columns: columns, + rows: rows, + enabled: _editingEnabled, + ); } /// [refreshCount] runs `COUNT(*)` (e.g. first load or Refresh). Pagination only runs SELECT. @@ -218,6 +260,9 @@ class _PostgresTableViewState extends material.State { final result = await conn.execute(dataSql); if (!mounted) return; + await _ensureSchema(conn); + if (!mounted) return; + final colNames = List.generate( result.schema.columns.length, (i) => result.schema.columns[i].columnName ?? 'col_$i', @@ -239,10 +284,8 @@ class _PostgresTableViewState extends material.State { _totalRowCount = totalRows; } _loading = false; + _installStagingBuffer(colNames, stringRows); }); - if (_verticalController.hasClients) { - _verticalController.jumpTo(0); - } } catch (e) { if (mounted) { setState(() { @@ -294,10 +337,8 @@ class _PostgresTableViewState extends material.State { _rowsOnPage = stringRows.length; _totalRowCount = null; _loading = false; + _installStagingBuffer(colNames, stringRows); }); - if (_verticalController.hasClients) { - _verticalController.jumpTo(0); - } } catch (e) { if (mounted) { setState(() { @@ -308,28 +349,32 @@ class _PostgresTableViewState extends material.State { } } - void _onSqlRun(String sql) { + Future _onSqlRun(String sql) async { final trimmed = sql.trim(); if (!isAllowedPostgresSelectQuery(trimmed)) return; + if (!await _confirmDiscardIfNeeded()) return; + if (!mounted) return; final browse = _browseDataSql().trim(); if (_browseSqlCompareKey(trimmed) == _browseSqlCompareKey(browse)) { setState(() { _customSqlActive = false; _customSql = null; }); - _fetch(refreshCount: true); + await _fetch(refreshCount: true); } else { setState(() { _customSqlActive = true; _customSql = trimmed; }); - _fetchCustom(); + await _fetchCustom(); } } Future _refreshMaterializedView() async { final conn = _connection; if (conn == null || !conn.isConnected || _loading) return; + if (!await _confirmDiscardIfNeeded()) return; + if (!mounted) return; try { await conn.refreshMaterializedView(widget.schema, widget.tableName); if (!mounted) return; @@ -362,31 +407,33 @@ class _PostgresTableViewState extends material.State { ? _customSql! : _browseDataSql(), browseSql: _browseDataSql(), - onRun: _onSqlRun, + onRun: (sql) => unawaited(_onSqlRun(sql)), ); } - void _exitCustomMode() { + Future _exitCustomMode() async { + if (!await _confirmDiscardIfNeeded()) return; + if (!mounted) return; setState(() { _customSqlActive = false; _customSql = null; }); - _fetch(refreshCount: true); + await _fetch(refreshCount: true); } void _goToPreviousPage() { if (_customSqlActive) return; - if (_offset <= 0 || _loading) return; + if (_offset <= 0 || _loading || _isDirty) return; setState(() { final next = _offset - widget.limit; _offset = next < 0 ? 0 : next; }); - _fetch(); + unawaited(_fetch()); } void _goToNextPage() { if (_customSqlActive) return; - if (_loading) return; + if (_loading || _isDirty) return; final total = _totalRowCount; final limit = widget.limit; if (total != null && _offset + _rowsOnPage >= total) return; @@ -394,14 +441,14 @@ class _PostgresTableViewState extends material.State { setState(() { _offset += limit; }); - _fetch(); + unawaited(_fetch()); } - bool get _canGoPrevious => !_customSqlActive && _offset > 0 && !_loading; + bool get _canGoPrevious => + !_customSqlActive && _offset > 0 && !_loading && !_isDirty; bool get _canGoNext { - if (_customSqlActive) return false; - if (_loading) return false; + if (_customSqlActive || _loading || _isDirty) return false; final total = _totalRowCount; final limit = widget.limit; if (total != null) { @@ -429,221 +476,167 @@ class _PostgresTableViewState extends material.State { return '$start–$end'; } + String? _statusLine() { + final reason = tableViewEditDisabledReason( + isView: widget.isView, + isMaterializedView: widget.isMaterializedView, + customSqlActive: _customSqlActive, + hasPrimaryKey: _primaryKeys.isNotEmpty, + schemaLoaded: _schemaLoaded, + ); + final pag = _paginationLabel(); + if (reason != null) return '$pag · $reason'; + return pag; + } + + Future _onRefresh() async { + if (!await _confirmDiscardIfNeeded()) return; + if (!mounted) return; + if (_customSqlActive) { + await _fetchCustom(); + } else { + await _fetch(refreshCount: true); + } + } + + Future _onNavigateHome() async { + final home = widget.onNavigateHome; + if (home == null) return; + if (!await _confirmDiscardIfNeeded()) return; + if (!mounted) return; + home(); + } + + Future _applyStagedChanges() async { + final buffer = _stagingBuffer; + if (buffer == null || !buffer.isDirty || _isSaving) return; + setState(() => _isSaving = true); + final outcome = await applyTableViewStagedChanges( + context: context, + buffer: buffer, + dialect: SqlDialect.postgres, + tableName: widget.tableName, + schema: widget.schema, + primaryKeys: _primaryKeys, + columnDataTypes: _columnDataTypes.isEmpty ? null : _columnDataTypes, + execute: (plan) async { + final conn = _connection; + if (conn == null || !conn.isConnected) { + throw StateError('Could not connect to PostgreSQL.'); + } + await conn.execute(plan.toTransactionSql()); + }, + ); + if (!mounted) return; + if (outcome.isApplied) { + final newRows = buffer.effectiveRows; + buffer.dispose(); + setState(() { + _rows = newRows; + _rowsOnPage = newRows.length; + _stagingBuffer = replaceTableViewStagingBuffer( + previous: null, + columns: _columnNames, + rows: newRows, + enabled: _editingEnabled, + ); + _isSaving = false; + }); + showAppToast( + context: context, + message: '${outcome.statementCount} change(s) saved', + variant: AppToastVariant.success, + ); + return; + } + setState(() => _isSaving = false); + if (outcome.isFailed && outcome.error != null) { + await showTableViewSaveFailedDialog( + context: context, + error: outcome.error!, + ); + } + } + + material.Widget _buildToolbar() { + PostgresTableToolbar toolbar() => PostgresTableToolbar( + title: + '$_tableTitle${widget.isMaterializedView ? ' (materialized view)' : widget.isView ? ' (view)' : ''}', + paginationLabel: _paginationLabel(), + tableIcon: widget.isMaterializedView + ? material.Icons.dynamic_feed_rounded + : widget.isView + ? material.Icons.view_agenda_rounded + : material.Icons.table_chart_rounded, + customSqlActive: _customSqlActive, + isMaterializedView: widget.isMaterializedView, + loading: _loading, + canGoPrevious: _canGoPrevious, + canGoNext: _canGoNext, + onNavigateHome: widget.onNavigateHome == null + ? null + : () => unawaited(_onNavigateHome()), + onOpenSql: _openSqlEditor, + onOpenPrivileges: _openPrivileges, + onRefreshMaterializedView: () => + unawaited(_refreshMaterializedView()), + onExitCustomMode: () => unawaited(_exitCustomMode()), + onGoPrevious: _goToPreviousPage, + onGoNext: _goToNextPage, + onRefresh: () => unawaited(_onRefresh()), + pendingActions: _stagingBuffer == null + ? null + : TableBrowserPendingActions( + buffer: _stagingBuffer!, + onSave: () => unawaited(_applyStagedChanges()), + isSaving: _isSaving, + ), + ); + final buffer = _stagingBuffer; + if (buffer == null) return toolbar(); + return ListenableBuilder( + listenable: buffer, + builder: (context, _) => toolbar(), + ); + } + @override material.Widget build(material.BuildContext context) { final cs = Theme.of(context).colorScheme; - if (_loading) { - return material.Container( - color: cs.background, - child: material.Center( + return material.CallbackShortcuts( + bindings: { + const material.SingleActivator(LogicalKeyboardKey.f5): () { + if (!_loading) unawaited(_onRefresh()); + }, + }, + child: material.Focus( + autofocus: true, + child: material.Container( + color: cs.background, child: material.Column( - mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - const material.SizedBox( - width: 28, - height: 28, - child: material.CircularProgressIndicator(strokeWidth: 2), + _buildToolbar(), + material.Expanded( + child: ResultsTab( + columns: _columnNames, + rows: _rows, + errorMessage: _error, + isLoading: _loading, + statusLine: _statusLine(), + showExportToolbar: true, + stagingBuffer: _stagingBuffer, + columnDataTypes: + _columnDataTypes.isEmpty ? null : _columnDataTypes, + onApplyChanges: + _stagingBuffer != null ? _applyStagedChanges : null, + isSaving: _isSaving, + ), ), - const Gap(12), - const Text('Loading data...').muted().small(), ], ), ), - ); - } - - if (_error != null) { - return material.Container( - color: cs.background, - child: material.Center( - child: material.Padding( - padding: const material.EdgeInsets.all(32), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Icon(material.Icons.error_outline_rounded, - size: 48, color: cs.destructive), - const Gap(16), - const Text('Query Error').large().semiBold(), - const Gap(8), - material.SelectableText(_error!, - style: material.TextStyle( - color: cs.mutedForeground, fontSize: 13)), - const Gap(24), - OutlineButton( - onPressed: () { - if (_customSqlActive) { - _fetchCustom(); - } else { - _fetch(refreshCount: true); - } - }, - leading: const material.Icon(material.Icons.refresh_rounded, - size: 18), - child: const Text('Retry'), - ), - ], - ), - ), - ), - ); - } - - if (_columnNames.isEmpty) { - return material.Container( - color: cs.background, - child: material.Center( - child: material.Padding( - padding: const material.EdgeInsets.all(32), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - children: [ - const Text('No columns returned').muted().small(), - const Gap(24), - OutlineButton( - onPressed: () { - if (_customSqlActive) { - _fetchCustom(); - } else { - _fetch(refreshCount: true); - } - }, - leading: const material.Icon(material.Icons.refresh_rounded, - size: 18), - child: const Text('Retry'), - ), - ], - ), - ), - ), - ); - } - - const double rowHeight = 36; - const double headerHeight = 40; - final colCount = _columnNames.length; - - return material.Container( - color: cs.background, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - PostgresTableToolbar( - title: - '${widget.schema}.${widget.tableName}${widget.isMaterializedView ? ' (materialized view)' : widget.isView ? ' (view)' : ''}', - paginationLabel: _paginationLabel(), - tableIcon: widget.isMaterializedView - ? material.Icons.dynamic_feed_rounded - : widget.isView - ? material.Icons.view_agenda_rounded - : material.Icons.table_chart_rounded, - customSqlActive: _customSqlActive, - isMaterializedView: widget.isMaterializedView, - loading: _loading, - canGoPrevious: _canGoPrevious, - canGoNext: _canGoNext, - onNavigateHome: widget.onNavigateHome, - onOpenSql: _openSqlEditor, - onOpenPrivileges: _openPrivileges, - onRefreshMaterializedView: _refreshMaterializedView, - onExitCustomMode: _exitCustomMode, - onGoPrevious: _goToPreviousPage, - onGoNext: _goToNextPage, - onRefresh: () { - if (_customSqlActive) { - _fetchCustom(); - } else { - _fetch(refreshCount: true); - } - }, - ), - // Table grid - material.Expanded( - child: material.LayoutBuilder( - builder: (context, constraints) { - return material.Scrollbar( - controller: _horizontalController, - thumbVisibility: true, - notificationPredicate: (_) => true, - child: material.SingleChildScrollView( - controller: _horizontalController, - scrollDirection: material.Axis.horizontal, - child: material.SizedBox( - width: _calcTableWidth(colCount, constraints.maxWidth), - child: material.Column( - children: [ - // Header - material.Container( - height: headerHeight, - decoration: material.BoxDecoration( - color: cs.muted.withValues(alpha: 0.35), - border: material.Border( - bottom: material.BorderSide( - color: cs.border.withValues(alpha: 0.5)), - ), - ), - child: material.Row( - children: [ - _rowNumberCell(cs, '#', isHeader: true), - for (var i = 0; i < colCount; i++) - _headerCell(cs, _columnNames[i]), - ], - ), - ), - // Data rows via ListView.builder for virtualization - material.Expanded( - child: material.Scrollbar( - controller: _verticalController, - thumbVisibility: true, - child: material.ListView.builder( - controller: _verticalController, - itemCount: _rowsOnPage, - itemExtent: rowHeight, - itemBuilder: (context, rowIdx) { - final row = _rows[rowIdx]; - final isEven = rowIdx % 2 == 0; - final displayRowNum = _customSqlActive - ? rowIdx + 1 - : _offset + rowIdx + 1; - return material.RepaintBoundary( - child: material.Container( - height: rowHeight, - decoration: material.BoxDecoration( - color: isEven - ? material.Colors.transparent - : cs.muted.withValues(alpha: 0.12), - border: material.Border( - bottom: material.BorderSide( - color: cs.border - .withValues(alpha: 0.15), - ), - ), - ), - child: material.Row( - children: [ - _rowNumberCell(cs, '$displayRowNum'), - for (var c = 0; c < colCount; c++) - _dataCell( - cs, - row.length > c ? row[c] : ''), - ], - ), - ), - ); - }, - ), - ), - ), - ], - ), - ), - ), - ); - }, - ), - ), - ], ), ); } @@ -656,103 +649,4 @@ class _PostgresTableViewState extends material.State { } return s.replaceAll(RegExp(r'\s+'), ' '); } - - double _calcTableWidth(int colCount, double availableWidth) { - const double rowNumWidth = 52; - const double minColWidth = 150; - final calculated = rowNumWidth + colCount * minColWidth; - return calculated > availableWidth ? calculated : availableWidth; - } - - material.Widget _rowNumberCell(ColorScheme cs, String text, - {bool isHeader = false}) { - return material.Container( - width: 52, - padding: const material.EdgeInsets.symmetric(horizontal: 8), - alignment: material.Alignment.centerRight, - decoration: material.BoxDecoration( - border: material.Border( - right: material.BorderSide(color: cs.border.withValues(alpha: 0.3)), - ), - ), - child: material.Text( - text, - style: material.TextStyle( - fontSize: 11, - fontWeight: - isHeader ? material.FontWeight.w600 : material.FontWeight.normal, - color: cs.mutedForeground.withValues(alpha: 0.7), - fontFamily: 'monospace', - ), - ), - ); - } - - material.Widget _headerCell(ColorScheme cs, String name) { - final isSorted = !_customSqlActive && _sortColumn == name; - return material.Expanded( - child: material.MouseRegion( - cursor: !_customSqlActive - ? material.SystemMouseCursors.click - : material.SystemMouseCursors.basic, - child: material.GestureDetector( - behavior: material.HitTestBehavior.opaque, - onTap: () => _toggleSort(name), - child: material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 10), - alignment: material.Alignment.centerLeft, - child: material.Row( - children: [ - material.Expanded( - child: material.Text( - name, - style: material.TextStyle( - fontSize: 12, - fontWeight: material.FontWeight.w600, - color: isSorted ? cs.primary : cs.foreground, - ), - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - ), - ), - if (isSorted) ...[ - const Gap(4), - material.Icon( - _sortAscending - ? material.Icons.arrow_upward_rounded - : material.Icons.arrow_downward_rounded, - size: 14, - color: cs.primary, - ), - ], - ], - ), - ), - ), - ), - ); - } - - material.Widget _dataCell(ColorScheme cs, String value) { - final isNull = value == 'NULL'; - return material.Expanded( - child: material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 10), - alignment: material.Alignment.centerLeft, - child: material.Text( - value, - style: material.TextStyle( - fontSize: 12, - color: isNull - ? cs.mutedForeground.withValues(alpha: 0.5) - : cs.foreground, - fontStyle: - isNull ? material.FontStyle.italic : material.FontStyle.normal, - ), - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - ), - ), - ); - } } diff --git a/lib/features/postgresql/postgres_workspace_home.dart b/lib/features/postgresql/postgres_workspace_home.dart index efe5d129..095f35fa 100644 --- a/lib/features/postgresql/postgres_workspace_home.dart +++ b/lib/features/postgresql/postgres_workspace_home.dart @@ -154,7 +154,7 @@ class _PostgresWorkspaceHomeState ], const Spacer(), QueryaTabStrip( - labels: const ['Server', 'SQL'], + labels: const ['Overview', 'SQL'], selectedIndex: _tab, onSelected: (index) => unawaited(_selectTab(index)), ), diff --git a/lib/features/redis/redis_view.dart b/lib/features/redis/redis_view.dart index 2a6963c7..b5c98bad 100644 --- a/lib/features/redis/redis_view.dart +++ b/lib/features/redis/redis_view.dart @@ -43,6 +43,8 @@ class RedisView extends material.StatefulWidget { required this.connectionRow, this.connection, this.onBack, + this.lastSelectedRedisDb, + this.onRestoreLastSelectedObject, }); final ConnectionRow connectionRow; @@ -54,6 +56,10 @@ class RedisView extends material.StatefulWidget { /// Called when the user taps the "back to explorer" button. final material.VoidCallback? onBack; + /// Remembers the last visited database index for 1-click return from stats. + final int? lastSelectedRedisDb; + final material.VoidCallback? onRestoreLastSelectedObject; + @override material.State createState() => _RedisViewState(); } @@ -379,6 +385,18 @@ class _RedisViewState extends material.State { ], ), ), + if (widget.lastSelectedRedisDb != null && + widget.onRestoreLastSelectedObject != null) ...[ + OutlineButton( + onPressed: widget.onRestoreLastSelectedObject, + leading: const material.Icon( + material.Icons.memory_outlined, + size: 18, + ), + child: Text('Return to db${widget.lastSelectedRedisDb}'), + ), + const Gap(8), + ], if (widget.onBack != null) ...[ OutlineButton( onPressed: widget.onBack, diff --git a/lib/features/settings/preferences_about_section.dart b/lib/features/settings/preferences_about_section.dart index 469d2325..14697530 100644 --- a/lib/features/settings/preferences_about_section.dart +++ b/lib/features/settings/preferences_about_section.dart @@ -10,8 +10,9 @@ import 'package:url_launcher/url_launcher_string.dart'; class PreferencesAboutSection extends StatelessWidget { const PreferencesAboutSection({super.key}); - static const String appVersion = '0.4.15'; - static const String githubRepoUrl = 'https://github.com/QueryaHub/Querya-Desktop'; + static const String appVersion = '0.4.16'; + static const String githubRepoUrl = + 'https://github.com/QueryaHub/Querya-Desktop'; @override Widget build(BuildContext context) { @@ -55,9 +56,14 @@ class PreferencesAboutSection extends StatelessWidget { child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ - const Text('Querya Desktop').semiBold().large().foreground(), + const Text('Querya Desktop') + .semiBold() + .large() + .foreground(), const material.SizedBox(height: 2), - const Text('Version $appVersion (Desktop Build)').muted().small(), + const Text('Version $appVersion (Desktop Build)') + .muted() + .small(), const material.SizedBox(height: 4), const Text('Fast, native multi-database management studio.') .muted() diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index a72388e2..2a16d554 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -14,6 +14,7 @@ import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/ui/querya_shell_status.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/workspace/workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -81,6 +82,14 @@ class _SqliteSqlWorkspaceState extends material.State { }, onNextTab: _nextTab, onPrevTab: _prevTab, + onFormat: () { + _activeSession.formatSql(); + setState(() {}); + }, + onClear: () { + _activeSession.clearSql(); + setState(() {}); + }, onExecute: () { if (!_activeSession.running) unawaited(_execute(_activeSession)); }, @@ -239,6 +248,8 @@ class _SqliteSqlWorkspaceState extends material.State { session.affectedRows = null; session.statusLine = null; }); + QueryaShellStatus.instance.beginBusy(message: 'Running query…'); + final sw = Stopwatch()..start(); try { await _ensureLease(); @@ -249,6 +260,7 @@ class _SqliteSqlWorkspaceState extends material.State { session.error = 'Could not connect to SQLite.'; session.running = false; }); + QueryaShellStatus.instance.endBusy(); } return; } @@ -296,6 +308,14 @@ class _SqliteSqlWorkspaceState extends material.State { session.running = false; }); + sw.stop(); + QueryaShellStatus.instance.reportQueryResult( + duration: sw.elapsed, + rowCount: outRows.length, + columnCount: cols.length, + message: session.statusLine, + ); + final cid = widget.connectionRow.id; if (cid != null) { unawaited( @@ -314,6 +334,7 @@ class _SqliteSqlWorkspaceState extends material.State { session.error = 'Query timed out: ${e.message ?? e}'; session.running = false; }); + QueryaShellStatus.instance.endBusy(); } } catch (e) { if (mounted) { @@ -321,6 +342,7 @@ class _SqliteSqlWorkspaceState extends material.State { session.error = e.toString(); session.running = false; }); + QueryaShellStatus.instance.endBusy(); } } } diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index 57c52abb..4310615b 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -1,11 +1,14 @@ import 'dart:async' show unawaited; + import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:querya_desktop/core/database/sqlite_connection.dart'; import 'package:querya_desktop/core/database/sqlite_service.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/features/workspace/sql_editor_chrome.dart'; +import 'package:querya_desktop/features/workspace/workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; const _defaultLimit = 200; @@ -42,40 +45,27 @@ class _SqliteTableViewState extends material.State { int _rowsOnPage = 0; int? _totalRowCount; int _offset = 0; - String? _sortColumn; - bool _sortAscending = true; - final _verticalController = material.ScrollController(); - final _horizontalController = material.ScrollController(); + DataGridStagingBuffer? _stagingBuffer; + List _primaryKeys = []; + Map _columnDataTypes = {}; + bool _schemaLoaded = false; + bool _isSaving = false; + + bool get _isDirty => _stagingBuffer?.isDirty ?? false; + + bool get _editingEnabled => tableViewEditingEnabled( + isView: widget.isView, + customSqlActive: false, + hasPrimaryKey: _primaryKeys.isNotEmpty, + ); String _qualifiedFrom() { return SqliteConnection.quoteIdentifier(widget.tableName); } String _browseDataSql() { - final orderBy = _sortColumn != null - ? ' ORDER BY ${SqliteConnection.quoteIdentifier(_sortColumn!)} ${_sortAscending ? "ASC" : "DESC"}' - : ''; - return 'SELECT * FROM ${_qualifiedFrom()}$orderBy LIMIT ${widget.limit} OFFSET $_offset'; - } - - void _toggleSort(String columnName) { - if (_loading) return; - setState(() { - if (_sortColumn == columnName) { - if (_sortAscending) { - _sortAscending = false; - } else { - _sortColumn = null; - _sortAscending = true; - } - } else { - _sortColumn = columnName; - _sortAscending = true; - } - _offset = 0; - }); - unawaited(_fetch()); + return 'SELECT * FROM ${_qualifiedFrom()} LIMIT ${widget.limit} OFFSET $_offset'; } @override @@ -90,8 +80,7 @@ class _SqliteTableViewState extends material.State { if (oldWidget.connectionRow.id != widget.connectionRow.id || oldWidget.tableName != widget.tableName || oldWidget.isView != widget.isView) { - _sortColumn = null; - _sortAscending = true; + _resetStaging(); _disconnectCurrent(); _connectAndLoad(); } @@ -99,12 +88,20 @@ class _SqliteTableViewState extends material.State { @override void dispose() { - _verticalController.dispose(); - _horizontalController.dispose(); + _resetStaging(); _disconnectCurrent(); super.dispose(); } + void _resetStaging() { + _stagingBuffer?.dispose(); + _stagingBuffer = null; + _primaryKeys = []; + _columnDataTypes = {}; + _schemaLoaded = false; + _isSaving = false; + } + void _disconnectCurrent() { _lease?.release(); _lease = null; @@ -121,11 +118,12 @@ class _SqliteTableViewState extends material.State { _rowsOnPage = 0; _totalRowCount = null; _offset = 0; + _resetStaging(); }); try { final lease = await SqliteService.instance.acquire( widget.connectionRow, - mode: SqliteSessionMode.readOnly, + mode: SqliteSessionMode.readWrite, ); if (!mounted) { lease.release(); @@ -142,6 +140,42 @@ class _SqliteTableViewState extends material.State { } } + Future _confirmDiscardIfNeeded() { + return confirmDiscardTableEditsIfDirty( + context: context, + buffer: _stagingBuffer, + tableTitle: widget.tableName, + ); + } + + Future _ensureSchema(SqliteConnection conn) async { + if (_schemaLoaded) return; + if (widget.isView) { + _schemaLoaded = true; + _primaryKeys = []; + _columnDataTypes = {}; + return; + } + try { + final schema = await conn.getTableSchema(table: widget.tableName); + _primaryKeys = List.from(schema.primaryKeys); + _columnDataTypes = columnDataTypesFromSchema(schema); + } catch (_) { + _primaryKeys = []; + _columnDataTypes = {}; + } + _schemaLoaded = true; + } + + void _installStagingBuffer(List columns, List> rows) { + _stagingBuffer = replaceTableViewStagingBuffer( + previous: _stagingBuffer, + columns: columns, + rows: rows, + enabled: _editingEnabled, + ); + } + Future _fetch({bool refreshCount = false}) async { final conn = _connection; if (conn == null || !conn.isConnected) { @@ -175,6 +209,9 @@ class _SqliteTableViewState extends material.State { if (!mounted) return; + await _ensureSchema(conn); + if (!mounted) return; + final cols = []; if (rs.isNotEmpty) { cols.addAll(rs.first.keys); @@ -189,11 +226,13 @@ class _SqliteTableViewState extends material.State { }).toList(); }).toList(); + if (!mounted) return; setState(() { _columnNames = cols; _rows = outRows; _rowsOnPage = outRows.length; _loading = false; + _installStagingBuffer(cols, outRows); }); } catch (e) { if (!mounted) return; @@ -204,8 +243,10 @@ class _SqliteTableViewState extends material.State { } } - bool get _canGoPrevious => _offset > 0; + bool get _canGoPrevious => _offset > 0 && !_loading && !_isDirty; + bool get _canGoNext { + if (_loading || _isDirty) return false; final total = _totalRowCount; if (total != null) { return _offset + _rowsOnPage < total; @@ -230,6 +271,81 @@ class _SqliteTableViewState extends material.State { unawaited(_fetch()); } + Future _onRefresh() async { + if (!await _confirmDiscardIfNeeded()) return; + if (!mounted) return; + await _fetch(refreshCount: true); + } + + Future _onNavigateHome() async { + final home = widget.onNavigateHome; + if (home == null) return; + if (!await _confirmDiscardIfNeeded()) return; + if (!mounted) return; + home(); + } + + Future _applyStagedChanges() async { + final buffer = _stagingBuffer; + if (buffer == null || !buffer.isDirty || _isSaving) return; + setState(() => _isSaving = true); + final outcome = await applyTableViewStagedChanges( + context: context, + buffer: buffer, + dialect: SqlDialect.sqlite, + tableName: widget.tableName, + primaryKeys: _primaryKeys, + columnDataTypes: _columnDataTypes.isEmpty ? null : _columnDataTypes, + execute: (plan) async { + final conn = _connection; + if (conn == null || !conn.isConnected) { + throw StateError('Could not connect to SQLite.'); + } + await conn.execute('BEGIN TRANSACTION'); + try { + for (final stmt in plan.statements) { + await conn.execute(stmt.sql); + } + await conn.execute('COMMIT'); + } catch (e) { + try { + await conn.execute('ROLLBACK'); + } catch (_) {} + rethrow; + } + }, + ); + if (!mounted) return; + if (outcome.isApplied) { + final newRows = buffer.effectiveRows; + buffer.dispose(); + setState(() { + _rows = newRows; + _rowsOnPage = newRows.length; + _stagingBuffer = replaceTableViewStagingBuffer( + previous: null, + columns: _columnNames, + rows: newRows, + enabled: _editingEnabled, + ); + _isSaving = false; + }); + showAppToast( + context: context, + message: '${outcome.statementCount} change(s) saved', + variant: AppToastVariant.success, + ); + return; + } + setState(() => _isSaving = false); + if (outcome.isFailed && outcome.error != null) { + await showTableViewSaveFailedDialog( + context: context, + error: outcome.error!, + ); + } + } + Future _showDdlDialog() async { final conn = _connection; if (conn == null || !conn.isConnected) return; @@ -248,7 +364,8 @@ class _SqliteTableViewState extends material.State { await showAppDialog( context: context, builder: (ctx) => QueryaDialogCard( - constraints: const material.BoxConstraints(maxWidth: 640, maxHeight: 500), + constraints: + const material.BoxConstraints(maxWidth: 640, maxHeight: 500), child: material.Padding( padding: const material.EdgeInsets.all(20), child: material.Column( @@ -261,7 +378,8 @@ class _SqliteTableViewState extends material.State { const Gap(16), material.Expanded( child: material.Container( - decoration: SqlEditorChrome.inlineFieldDecorationFromContext(ctx), + decoration: + SqlEditorChrome.inlineFieldDecorationFromContext(ctx), child: QueryaCodeEditor( controller: material.TextEditingController(text: ddl), language: QueryaCodeLanguage.sql, @@ -307,412 +425,149 @@ class _SqliteTableViewState extends material.State { return 'Showing $start-$end row(s)'; } - double _calcTableWidth(int colCount, double availableWidth) { - const double rowNumWidth = 52; - const double minColWidth = 150; - final calculated = rowNumWidth + colCount * minColWidth; - return calculated > availableWidth ? calculated : availableWidth; + String? _statusLine() { + final reason = tableViewEditDisabledReason( + isView: widget.isView, + customSqlActive: false, + hasPrimaryKey: _primaryKeys.isNotEmpty, + schemaLoaded: _schemaLoaded, + ); + final pag = _paginationLabel(); + if (pag.isEmpty) return reason; + if (reason != null) return '$pag · $reason'; + return pag; } - material.Widget _rowNumberCell(ColorScheme cs, String text, - {bool isHeader = false}) { + material.Widget _buildChromeRow(ColorScheme cs) { + final title = '${widget.tableName}${widget.isView ? ' (view)' : ''}'; return material.Container( - width: 52, - padding: const material.EdgeInsets.symmetric(horizontal: 8), - alignment: material.Alignment.centerRight, + height: 48, + padding: const material.EdgeInsets.symmetric(horizontal: 12), decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.35), border: material.Border( - right: material.BorderSide( - color: cs.border.withValues(alpha: 0.22), - width: 1, + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.4), ), ), ), - child: material.Text( - text, - style: material.TextStyle( - fontSize: 11, - fontWeight: - isHeader ? material.FontWeight.w600 : material.FontWeight.w400, - color: isHeader ? cs.foreground : cs.mutedForeground, - ), - ), - ); - } - - material.Widget _headerCell(ColorScheme cs, String name) { - final isSorted = _sortColumn == name; - return material.Expanded( - child: material.MouseRegion( - cursor: material.SystemMouseCursors.click, - child: material.GestureDetector( - behavior: material.HitTestBehavior.opaque, - onTap: () => _toggleSort(name), - child: material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 10), - alignment: material.Alignment.centerLeft, - decoration: material.BoxDecoration( - border: material.Border( - right: material.BorderSide( - color: cs.border.withValues(alpha: 0.22), - width: 1, + child: material.Row( + children: [ + if (widget.onNavigateHome != null) ...[ + material.Tooltip( + message: 'Return to overview', + child: OutlineButton( + size: ButtonSize.small, + onPressed: () => unawaited(_onNavigateHome()), + leading: const material.Icon( + material.Icons.dns_outlined, + size: 14, ), + child: const Text('Overview'), ), ), - child: material.Row( - children: [ - material.Expanded( - child: material.Text( - name, - overflow: material.TextOverflow.ellipsis, - style: material.TextStyle( - fontSize: 12, - fontWeight: material.FontWeight.w600, - color: isSorted ? cs.primary : cs.foreground, - ), - ), - ), - if (isSorted) ...[ - const Gap(4), - material.Icon( - _sortAscending - ? material.Icons.arrow_upward_rounded - : material.Icons.arrow_downward_rounded, - size: 14, - color: cs.primary, - ), - ], - ], - ), + const Gap(10), + ], + material.Icon( + widget.isView + ? material.Icons.view_agenda_rounded + : material.Icons.table_chart_rounded, + size: 20, + color: cs.primary, ), - ), - ), - ); - } - - material.Widget _dataCell(ColorScheme cs, String value) { - return material.Expanded( - child: material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 10), - alignment: material.Alignment.centerLeft, - decoration: material.BoxDecoration( - border: material.Border( - right: material.BorderSide( - color: cs.border.withValues(alpha: 0.15), - width: 1, - ), - ), - ), - child: material.SelectableText( - value, - maxLines: 1, - style: material.TextStyle( - fontSize: 12, - fontFamily: 'monospace', - color: value == 'NULL' - ? cs.mutedForeground.withValues(alpha: 0.7) - : cs.foreground, - ), - ), - ), - ); - } - - @override - material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; - - if (_loading && _columnNames.isEmpty) { - return material.Container( - color: cs.background, - child: material.Center( - child: material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - const material.SizedBox( - width: 16, - height: 16, - child: material.CircularProgressIndicator(strokeWidth: 2), - ), - const Gap(12), - const Text('Loading table data...').muted().small(), - ], - ), - ), - ); - } - - if (_error != null) { - return material.Container( - color: cs.background, - child: material.Center( - child: material.Padding( - padding: const material.EdgeInsets.all(32), - child: material.Column( - mainAxisAlignment: material.MainAxisAlignment.center, - children: [ - material.Icon(material.Icons.error_outline_rounded, - size: 48, color: cs.destructive), - const Gap(16), - const Text('Query Error').large().semiBold(), - const Gap(8), - material.SelectableText(_error!, - style: material.TextStyle( - color: cs.mutedForeground, fontSize: 13)), - const Gap(24), - OutlineButton( - onPressed: _connectAndLoad, - leading: const material.Icon(material.Icons.refresh_rounded, - size: 18), - child: const Text('Retry'), - ), - ], - ), - ), - ), - ); - } - - if (_columnNames.isEmpty) { - return material.Container( - color: cs.background, - child: material.Center( - child: material.Padding( - padding: const material.EdgeInsets.all(32), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - children: [ - const Text('No columns returned').muted().small(), - const Gap(24), - OutlineButton( - onPressed: _connectAndLoad, - leading: const material.Icon(material.Icons.refresh_rounded, - size: 18), - child: const Text('Retry'), - ), - ], - ), - ), - ), - ); - } - - const double rowHeight = 36; - const double headerHeight = 40; - final colCount = _columnNames.length; - final title = '${widget.tableName}${widget.isView ? ' (view)' : ''}'; - - return material.Container( - color: cs.background, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Container( - height: 48, - padding: const material.EdgeInsets.symmetric(horizontal: 12), - decoration: material.BoxDecoration( - color: cs.muted.withValues(alpha: 0.35), - border: material.Border( - bottom: material.BorderSide( - color: cs.border.withValues(alpha: 0.4), - ), + const Gap(8), + material.Expanded( + child: material.Text( + title, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, ), ), - child: material.Row( - children: [ - if (widget.onNavigateHome != null) ...[ - material.Tooltip( - message: 'Return to overview', - child: OutlineButton( - size: ButtonSize.small, - onPressed: widget.onNavigateHome, - leading: const material.Icon( - material.Icons.dns_outlined, - size: 14, - ), - child: const Text('Overview'), - ), - ), - const Gap(10), - ], - material.Icon( - widget.isView - ? material.Icons.view_agenda_rounded - : material.Icons.table_chart_rounded, - size: 20, - color: cs.primary, - ), - const Gap(8), - material.Expanded( - child: material.Text( - title, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 13, - fontWeight: material.FontWeight.w600, - color: cs.foreground, - ), - ), - ), - material.Expanded( - flex: 2, - child: material.LayoutBuilder( - builder: (context, constraints) { - return material.SingleChildScrollView( - scrollDirection: material.Axis.horizontal, - child: material.ConstrainedBox( - constraints: material.BoxConstraints( - minWidth: constraints.maxWidth, - ), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: material.BoxDecoration( - color: cs.muted.withValues(alpha: 0.4), - borderRadius: material.BorderRadius.circular(4), - ), - child: material.Text( - _paginationLabel(), - style: material.TextStyle( - fontSize: 11, - color: cs.mutedForeground, - ), - ), - ), - const Gap(6), - OutlineButton( - size: ButtonSize.small, - onPressed: _loading - ? null - : () => unawaited(_showDdlDialog()), - child: const Text('DDL'), - ), - const Gap(4), - OutlineButton( - size: ButtonSize.small, - onPressed: _loading - ? null - : () => unawaited(_fetch()), - leading: const material.Icon( - material.Icons.refresh_rounded, - size: 14, - ), - child: const Text('Refresh'), - ), - const Gap(4), - OutlineButton( - size: ButtonSize.small, - onPressed: (!_canGoPrevious || _loading) - ? null - : _goToPreviousPage, - leading: const material.Icon( - material.Icons.chevron_left_rounded, - size: 16, - ), - child: const Text('Prev'), - ), - const Gap(4), - OutlineButton( - size: ButtonSize.small, - onPressed: (!_canGoNext || _loading) - ? null - : _goToNextPage, - leading: const material.Icon( - material.Icons.chevron_right_rounded, - size: 16, - ), - child: const Text('Next'), - ), - ], - ), - ), - ); - }, - ), - ), - ], - ), ), material.Expanded( + flex: 2, child: material.LayoutBuilder( builder: (context, constraints) { - return material.Scrollbar( - controller: _horizontalController, - thumbVisibility: true, - notificationPredicate: (_) => true, - child: material.SingleChildScrollView( - controller: _horizontalController, - scrollDirection: material.Axis.horizontal, - child: material.SizedBox( - width: _calcTableWidth(colCount, constraints.maxWidth), - child: material.Column( - children: [ - material.Container( - height: headerHeight, - decoration: material.BoxDecoration( - color: cs.muted.withValues(alpha: 0.35), - border: material.Border( - bottom: material.BorderSide( - color: cs.border.withValues(alpha: 0.5)), - ), - ), - child: material.Row( - children: [ - _rowNumberCell(cs, '#', isHeader: true), - for (var i = 0; i < colCount; i++) - _headerCell(cs, _columnNames[i]), - ], - ), + return material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.ConstrainedBox( + constraints: material.BoxConstraints( + minWidth: constraints.maxWidth, + ), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.4), + borderRadius: material.BorderRadius.circular(4), ), - material.Expanded( - child: material.Scrollbar( - controller: _verticalController, - thumbVisibility: true, - child: material.ListView.builder( - controller: _verticalController, - itemCount: _rowsOnPage, - itemExtent: rowHeight, - itemBuilder: (context, rowIdx) { - final row = _rows[rowIdx]; - final isEven = rowIdx % 2 == 0; - final displayRowNum = _offset + rowIdx + 1; - return material.RepaintBoundary( - child: material.Container( - height: rowHeight, - decoration: material.BoxDecoration( - color: isEven - ? material.Colors.transparent - : cs.muted.withValues(alpha: 0.12), - border: material.Border( - bottom: material.BorderSide( - color: cs.border - .withValues(alpha: 0.15), - ), - ), - ), - child: material.Row( - children: [ - _rowNumberCell(cs, '$displayRowNum'), - for (var c = 0; c < colCount; c++) - _dataCell(cs, - row.length > c ? row[c] : ''), - ], - ), - ), - ); - }, - ), + child: material.Text( + _paginationLabel(), + style: material.TextStyle( + fontSize: 11, + color: cs.mutedForeground, ), ), - ], - ), + ), + const Gap(6), + if (_stagingBuffer != null) + TableBrowserPendingActions( + buffer: _stagingBuffer!, + onSave: () => unawaited(_applyStagedChanges()), + isSaving: _isSaving, + ), + OutlineButton( + size: ButtonSize.small, + onPressed: _loading + ? null + : () => unawaited(_showDdlDialog()), + child: const Text('DDL'), + ), + const Gap(4), + OutlineButton( + size: ButtonSize.small, + onPressed: + _loading ? null : () => unawaited(_onRefresh()), + leading: const material.Icon( + material.Icons.refresh_rounded, + size: 14, + ), + child: const Text('Refresh'), + ), + const Gap(4), + OutlineButton( + size: ButtonSize.small, + onPressed: (!_canGoPrevious || _loading) + ? null + : _goToPreviousPage, + leading: const material.Icon( + material.Icons.chevron_left_rounded, + size: 16, + ), + child: const Text('Prev'), + ), + const Gap(4), + OutlineButton( + size: ButtonSize.small, + onPressed: + (!_canGoNext || _loading) ? null : _goToNextPage, + leading: const material.Icon( + material.Icons.chevron_right_rounded, + size: 16, + ), + child: const Text('Next'), + ), + ], ), ), ); @@ -723,4 +578,52 @@ class _SqliteTableViewState extends material.State { ), ); } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final buffer = _stagingBuffer; + + return material.CallbackShortcuts( + bindings: { + const material.SingleActivator(LogicalKeyboardKey.f5): () { + if (!_loading) unawaited(_onRefresh()); + }, + }, + child: material.Focus( + autofocus: true, + child: material.Container( + color: cs.background, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + if (buffer == null) + _buildChromeRow(cs) + else + ListenableBuilder( + listenable: buffer, + builder: (context, _) => _buildChromeRow(cs), + ), + material.Expanded( + child: ResultsTab( + columns: _columnNames, + rows: _rows, + errorMessage: _error, + isLoading: _loading, + statusLine: _statusLine(), + showExportToolbar: true, + stagingBuffer: _stagingBuffer, + columnDataTypes: + _columnDataTypes.isEmpty ? null : _columnDataTypes, + onApplyChanges: + _stagingBuffer != null ? _applyStagedChanges : null, + isSaving: _isSaving, + ), + ), + ], + ), + ), + ), + ); + } } diff --git a/lib/features/updater/update_dialog.dart b/lib/features/updater/update_dialog.dart index 827e606a..758a03db 100644 --- a/lib/features/updater/update_dialog.dart +++ b/lib/features/updater/update_dialog.dart @@ -2,8 +2,11 @@ import 'dart:async' show unawaited; import 'dart:io'; import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/unsaved_work_registry.dart'; import 'package:querya_desktop/core/updater/app_updater_service.dart'; +import 'package:querya_desktop/core/updater/sha256_checksums.dart'; import 'package:querya_desktop/core/updater/update_manifest.dart'; import 'package:querya_desktop/features/updater/update_changelog_view.dart'; import 'package:querya_desktop/features/updater/update_controller.dart'; @@ -29,24 +32,97 @@ Future showUpdateDialog( builder: (ctx) => material.Dialog( backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(ctx), - child: _UpdateDialogContent(initialManifest: initialManifest), + child: UpdateDialogContent(initialManifest: initialManifest), ), ); } -class _UpdateDialogContent extends material.StatefulWidget { - const _UpdateDialogContent({this.initialManifest}); +/// Warns before `exit(0)` when SQL tabs or staged grid edits are dirty. +Future showUnsavedUpdateRestartDialog(material.BuildContext context) { + return showAppDialog( + context: context, + builder: (ctx) { + return QueryaDialogCard( + constraints: const material.BoxConstraints(maxWidth: 420), + child: material.Padding( + padding: const material.EdgeInsets.all(20), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Unsaved changes').semiBold().large(), + const Gap(8), + const Text( + 'You have unsaved SQL or staged table changes. ' + 'Restarting to install the update will discard them.', + ).muted().small(), + const Gap(20), + material.Align( + alignment: material.Alignment.centerRight, + child: material.Wrap( + alignment: material.WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: [ + OutlineButton( + onPressed: () => material.Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + DestructiveButton( + onPressed: () => material.Navigator.of(ctx).pop(true), + child: const Text('Restart anyway'), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); +} + +/// Update check / download / install pane. Test hooks are optional overrides. +class UpdateDialogContent extends material.StatefulWidget { + const UpdateDialogContent({ + super.key, + this.initialManifest, + this.updater, + this.controller, + this.hasUnsavedWork, + this.isInstallBlocked, + this.packageUpdateCommand, + this.selectAsset, + this.download, + this.install, + this.confirmRestartIfUnsaved, + this.showCopyToast = true, + }); final UpdateManifest? initialManifest; + final AppUpdaterService? updater; + final UpdateController? controller; + final bool Function()? hasUnsavedWork; + final bool? isInstallBlocked; + final String? packageUpdateCommand; + final UpdateAsset? Function(UpdateManifest manifest)? selectAsset; + final Future Function(UpdateAsset asset, UpdateManifest manifest)? + download; + final Future Function(File file)? install; + final Future Function(material.BuildContext context)? + confirmRestartIfUnsaved; + final bool showCopyToast; @override - material.State<_UpdateDialogContent> createState() => - _UpdateDialogContentState(); + material.State createState() => + UpdateDialogContentState(); } -class _UpdateDialogContentState extends material.State<_UpdateDialogContent> { - final _updater = AppUpdaterService.instance; - final _controller = UpdateController.instance; +@visibleForTesting +class UpdateDialogContentState extends material.State { + late final AppUpdaterService _updater; + late final UpdateController _controller; UpdateDialogPhase _phase = UpdateDialogPhase.checking; String _currentVersion = ''; @@ -62,9 +138,23 @@ class _UpdateDialogContentState extends material.State<_UpdateDialogContent> { DateTime? _lastProgressAt; int _lastProgressBytes = 0; + bool get _installBlocked => + widget.isInstallBlocked ?? _updater.isInstallBlockedByPackageManager; + + String? get _packageCommand => + widget.packageUpdateCommand ?? _updater.packageManagerUpdateCommand; + + @visibleForTesting + UpdateDialogPhase get phase => _phase; + + @visibleForTesting + String? get errorMessage => _errorMessage; + @override void initState() { super.initState(); + _updater = widget.updater ?? AppUpdaterService.instance; + _controller = widget.controller ?? UpdateController.instance; if (widget.initialManifest != null) { _manifest = widget.initialManifest; _phase = UpdateDialogPhase.available; @@ -90,32 +180,20 @@ class _UpdateDialogContentState extends material.State<_UpdateDialogContent> { } else { setState(() => _phase = UpdateDialogPhase.upToDate); } - } on AppUpdaterException catch (e) { - if (!mounted) return; - setState(() { - _phase = UpdateDialogPhase.error; - _errorMessage = e.message; - }); } catch (e) { - if (!mounted) return; - setState(() { - _phase = UpdateDialogPhase.error; - _errorMessage = e.toString(); - }); + _setError(_messageFor(e)); } } Future _startDownload() async { final manifest = _manifest; if (manifest == null) return; + if (_installBlocked) return; - final asset = _updater.platformAssetFor(manifest); + final asset = widget.selectAsset?.call(manifest) ?? + _updater.platformAssetFor(manifest); if (asset == null) { - setState(() { - _phase = UpdateDialogPhase.error; - _errorMessage = - 'No update package found for ${Platform.operatingSystem}.'; - }); + _setError('No update package found for ${Platform.operatingSystem}.'); return; } @@ -132,27 +210,29 @@ class _UpdateDialogContentState extends material.State<_UpdateDialogContent> { }); try { - final file = await _updater.downloadAsset( - asset, - manifest: manifest, - shouldCancel: () => _downloadCancelled, - onProgress: (received, total) { - if (!mounted) return; - final now = DateTime.now(); - final elapsedMs = - now.difference(_lastProgressAt ?? now).inMilliseconds; - if (elapsedMs >= 250) { - final deltaBytes = received - _lastProgressBytes; - _bytesPerSecond = deltaBytes / (elapsedMs / 1000); - _lastProgressAt = now; - _lastProgressBytes = received; - } - setState(() { - _receivedBytes = received; - if (total > 0) _totalBytes = total; - }); - }, - ); + final file = widget.download != null + ? await widget.download!(asset, manifest) + : await _updater.downloadAsset( + asset, + manifest: manifest, + shouldCancel: () => _downloadCancelled, + onProgress: (received, total) { + if (!mounted) return; + final now = DateTime.now(); + final elapsedMs = + now.difference(_lastProgressAt ?? now).inMilliseconds; + if (elapsedMs >= 250) { + final deltaBytes = received - _lastProgressBytes; + _bytesPerSecond = deltaBytes / (elapsedMs / 1000); + _lastProgressAt = now; + _lastProgressBytes = received; + } + setState(() { + _receivedBytes = received; + if (total > 0) _totalBytes = total; + }); + }, + ); if (!mounted) return; setState(() { _downloadedFile = file; @@ -164,32 +244,76 @@ class _UpdateDialogContentState extends material.State<_UpdateDialogContent> { setState(() => _phase = UpdateDialogPhase.available); return; } - setState(() { - _phase = UpdateDialogPhase.error; - _errorMessage = e.message; - }); + _setError(e.message); + } catch (e) { + _setError(_messageFor(e)); } } Future _install() async { final file = _downloadedFile; if (file == null) return; + + final dirty = widget.hasUnsavedWork?.call() ?? + UnsavedWorkRegistry.instance.hasUnsaved; + if (dirty) { + final confirmFn = widget.confirmRestartIfUnsaved; + final bool? confirmed; + if (confirmFn != null) { + confirmed = await confirmFn(context); + } else { + confirmed = await showUnsavedUpdateRestartDialog(context); + } + if (!mounted || confirmed != true) return; + } + try { - await _updater.installDownloadedUpdate(file); + if (widget.install != null) { + await widget.install!(file); + } else { + await _updater.installDownloadedUpdate(file); + } } on AppUpdaterException catch (e) { - if (!mounted) return; - setState(() { - _phase = UpdateDialogPhase.error; - _errorMessage = e.message; - }); + _setError(e.message); + } catch (e) { + _setError(_messageFor(e)); } } + Future _copyPackageCommand() async { + final cmd = _packageCommand; + if (cmd == null || cmd.isEmpty) return; + await Clipboard.setData(ClipboardData(text: cmd)); + if (!mounted || !widget.showCopyToast) return; + showAppToast( + context: context, + message: 'Copied: $cmd', + variant: AppToastVariant.success, + ); + } + Future _remindLater() async { await _controller.remindLater(); if (mounted) material.Navigator.of(context).pop(); } + void _setError(String message) { + if (!mounted) return; + setState(() { + _phase = UpdateDialogPhase.error; + _errorMessage = message; + }); + } + + String _messageFor(Object error) { + if (error is AppUpdaterException) return error.message; + if (error is UpdateChecksumMismatchException) { + return 'The downloaded update failed integrity verification (SHA256 mismatch). ' + 'The file was discarded.'; + } + return error.toString(); + } + String _formatBytes(int bytes) { if (bytes <= 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB']; @@ -309,11 +433,12 @@ class _UpdateDialogContentState extends material.State<_UpdateDialogContent> { UpdateDialogPhase.checking => 'Checking for updates…', UpdateDialogPhase.upToDate => 'You are running the latest version of Querya Desktop (v$_currentVersion).', - UpdateDialogPhase.available => - 'Querya Desktop v${_manifest?.version ?? ''} is available!', + UpdateDialogPhase.available => _installBlocked + ? 'This build is managed by ${_packageCommand ?? 'the package manager'}.' + : 'Querya Desktop v${_manifest?.version ?? ''} is available!', UpdateDialogPhase.downloading => 'Downloading update…', UpdateDialogPhase.readyToInstall => 'Update ready to install.', - UpdateDialogPhase.error => 'Update check failed.', + UpdateDialogPhase.error => 'Update failed.', }; } @@ -350,6 +475,13 @@ class _UpdateDialogContentState extends material.State<_UpdateDialogContent> { return material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ + if (_installBlocked && _phase == UpdateDialogPhase.available) ...[ + Text( + 'Install updates with ${_packageCommand ?? 'the system package manager'} ' + 'instead of downloading a GitHub package.', + ).muted().small(), + const material.SizedBox(height: 12), + ], if (dateLabel != null) ...[ Text('Released $dateLabel').muted().xSmall(), const material.SizedBox(height: 12), @@ -411,10 +543,16 @@ class _UpdateDialogContentState extends material.State<_UpdateDialogContent> { child: const Text('Remind me later'), ), const material.SizedBox(width: 8), - PrimaryButton( - onPressed: () => unawaited(_startDownload()), - child: const Text('Download & Install'), - ), + if (_installBlocked) + PrimaryButton( + onPressed: () => unawaited(_copyPackageCommand()), + child: const Text('Copy update command'), + ) + else + PrimaryButton( + onPressed: () => unawaited(_startDownload()), + child: const Text('Download'), + ), ], UpdateDialogPhase.downloading => [ GhostButton( diff --git a/lib/features/workspace/data_grid_staging_buffer.dart b/lib/features/workspace/data_grid_staging_buffer.dart index 94dc6228..23399db6 100644 --- a/lib/features/workspace/data_grid_staging_buffer.dart +++ b/lib/features/workspace/data_grid_staging_buffer.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/core/unsaved_work_registry.dart'; /// Status of a row within the staging buffer. enum StagedRowStatus { @@ -27,7 +28,9 @@ class DataGridStagingBuffer extends ChangeNotifier { }) : _originalColumns = List.unmodifiable(columns), _originalRows = List.unmodifiable( rows.map((r) => List.unmodifiable(r)).toList(), - ); + ) { + UnsavedWorkRegistry.instance.register(this, () => isDirty); + } final List _originalColumns; final List> _originalRows; @@ -344,6 +347,7 @@ class DataGridStagingBuffer extends ChangeNotifier { @override void dispose() { + UnsavedWorkRegistry.instance.unregister(this); _modifiedCells.clear(); _insertedRows.clear(); _deletedRowIndices.clear(); diff --git a/lib/features/workspace/grid_cell_popover_inspector.dart b/lib/features/workspace/grid_cell_popover_inspector.dart index 87252678..ec0d1132 100644 --- a/lib/features/workspace/grid_cell_popover_inspector.dart +++ b/lib/features/workspace/grid_cell_popover_inspector.dart @@ -13,6 +13,7 @@ Future showGridCellInspectorDialog({ required String initialValue, int? rowIndex, String? dataTypeName, + Future Function(String value)? onSaveToDatabase, }) { return showAppDialog( context: context, @@ -21,6 +22,7 @@ Future showGridCellInspectorDialog({ initialValue: initialValue, rowIndex: rowIndex, dataTypeName: dataTypeName, + onSaveToDatabase: onSaveToDatabase, ), ); } @@ -31,12 +33,14 @@ class _GridCellInspectorDialog extends material.StatefulWidget { required this.initialValue, this.rowIndex, this.dataTypeName, + this.onSaveToDatabase, }); final String columnName; final String initialValue; final int? rowIndex; final String? dataTypeName; + final Future Function(String value)? onSaveToDatabase; @override material.State<_GridCellInspectorDialog> createState() => @@ -48,6 +52,8 @@ class _GridCellInspectorDialogState late final material.TextEditingController _controller; bool _isNull = false; bool _wordWrap = true; + bool _savingToDb = false; + String? _saveError; QueryaCodeLanguage get _detectedLanguage { final t = _controller.text.trimLeft(); @@ -199,6 +205,26 @@ class _GridCellInspectorDialogState material.Navigator.of(context).pop(result); } + Future _saveToDatabase() async { + final save = widget.onSaveToDatabase; + if (save == null || _savingToDb) return; + setState(() { + _savingToDb = true; + _saveError = null; + }); + try { + await save(_isNull ? 'NULL' : _controller.text); + if (!mounted) return; + material.Navigator.of(context).pop(_isNull ? 'NULL' : _controller.text); + } catch (e) { + if (!mounted) return; + setState(() { + _savingToDb = false; + _saveError = e.toString(); + }); + } + } + @override material.Widget build(material.BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -301,142 +327,189 @@ class _GridCellInspectorDialogState ), ), if (_isHex()) ...[ - GhostButton( - density: ButtonDensity.compact, - onPressed: _formatHex, - leading: const material.Icon( - material.Icons.grid_view_rounded, - size: 14, - ), - child: const Text('Format Hex'), - ), - const Gap(6), - ], GhostButton( density: ButtonDensity.compact, - onPressed: () => setState(() => _wordWrap = !_wordWrap), - leading: material.Icon( - _wordWrap - ? material.Icons.wrap_text_rounded - : material.Icons.notes_rounded, + onPressed: _formatHex, + leading: const material.Icon( + material.Icons.grid_view_rounded, size: 14, ), - child: Text(_wordWrap ? 'Wrap' : 'No Wrap'), + child: const Text('Format Hex'), ), const Gap(6), - GhostButton( - density: ButtonDensity.compact, - onPressed: _isNull ? null : _setNull, - child: const Text('Set NULL'), - ), ], - ), - const Gap(12), + GhostButton( + density: ButtonDensity.compact, + onPressed: () => setState(() => _wordWrap = !_wordWrap), + leading: material.Icon( + _wordWrap + ? material.Icons.wrap_text_rounded + : material.Icons.notes_rounded, + size: 14, + ), + child: Text(_wordWrap ? 'Wrap' : 'No Wrap'), + ), + const Gap(6), + GhostButton( + density: ButtonDensity.compact, + onPressed: _isNull ? null : _setNull, + child: const Text('Set NULL'), + ), + ], + ), + const Gap(12), - // Editor Body - material.Expanded( - child: material.Container( - decoration: material.BoxDecoration( - color: cs.background, - borderRadius: material.BorderRadius.circular(6), - border: material.Border.all( - color: _isNull - ? cs.primary.withValues(alpha: 0.5) - : cs.border, - width: 1, - ), + // Editor Body + material.Expanded( + child: material.Container( + decoration: material.BoxDecoration( + color: cs.background, + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: _isNull + ? cs.primary.withValues(alpha: 0.5) + : cs.border, + width: 1, ), - child: _isNull - ? material.Center( - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - children: [ - const Text('Value is NULL').muted().semiBold(), - const Gap(8), - GhostButton( - density: ButtonDensity.compact, - onPressed: () => - setState(() => _isNull = false), - child: const Text('Enter text value'), - ), - ], - ), - ) - : _wordWrap - ? QueryaCodeEditor( - controller: _controller, - language: _detectedLanguage, - autofocus: true, - fontSize: 12, - variant: QueryaCodeEditorVariant.material, - hintText: 'Enter cell value…', - contentPadding: - const material.EdgeInsets.all(12), - ) - : material.SingleChildScrollView( - scrollDirection: material.Axis.horizontal, - child: material.SizedBox( - width: 3000, - child: QueryaCodeEditor( - controller: _controller, - language: _detectedLanguage, - autofocus: true, - fontSize: 12, - variant: QueryaCodeEditorVariant.material, - hintText: 'Enter cell value…', - contentPadding: - const material.EdgeInsets.all(12), - ), + ), + child: _isNull + ? material.Center( + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('Value is NULL').muted().semiBold(), + const Gap(8), + GhostButton( + density: ButtonDensity.compact, + onPressed: () => + setState(() => _isNull = false), + child: const Text('Enter text value'), + ), + ], + ), + ) + : _wordWrap + ? QueryaCodeEditor( + controller: _controller, + language: _detectedLanguage, + autofocus: true, + fontSize: 12, + variant: QueryaCodeEditorVariant.material, + hintText: 'Enter cell value…', + contentPadding: const material.EdgeInsets.all(12), + ) + : material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.SizedBox( + width: 3000, + child: QueryaCodeEditor( + controller: _controller, + language: _detectedLanguage, + autofocus: true, + fontSize: 12, + variant: QueryaCodeEditorVariant.material, + hintText: 'Enter cell value…', + contentPadding: + const material.EdgeInsets.all(12), ), ), - ), + ), ), - const Gap(12), - - // Footer - material.Row( - children: [ - GhostButton( - density: ButtonDensity.compact, - onPressed: () { - Clipboard.setData( - ClipboardData( - text: _isNull ? 'NULL' : _controller.text), - ); - }, - leading: const material.Icon( - material.Icons.copy_rounded, - size: 14, + ), + const Gap(12), + if (_saveError != null) ...[ + Text(_saveError!).small().muted(), + const Gap(8), + ], + // Footer: two rows so Copy + metrics + extra Save to DB + // actions fit the 420px dialog without overflowing. + material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Row( + children: [ + GhostButton( + density: ButtonDensity.compact, + onPressed: _savingToDb + ? null + : () { + Clipboard.setData( + ClipboardData( + text: + _isNull ? 'NULL' : _controller.text), + ); + }, + leading: const material.Icon( + material.Icons.copy_rounded, + size: 14, + ), + child: const Text('Copy'), ), - child: const Text('Copy'), - ), - const Gap(12), - if (!_isNull) - material.Text( - '$linesCount ${linesCount == 1 ? "line" : "lines"} · $charsCount chars · $bytesCount B', - style: material.TextStyle( - fontSize: 11, - color: cs.mutedForeground, + const Gap(12), + if (!_isNull) + material.Expanded( + child: material.Text( + '$linesCount ${linesCount == 1 ? "line" : "lines"} · $charsCount chars · $bytesCount B', + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 11, + color: cs.mutedForeground, + ), + ), ), + ], + ), + const Gap(8), + material.Wrap( + alignment: material.WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: [ + OutlineButton( + density: ButtonDensity.compact, + onPressed: _savingToDb + ? null + : () => material.Navigator.of(context).pop(null), + child: const Text('Cancel'), ), - const material.Spacer(), - OutlineButton( - density: ButtonDensity.compact, - onPressed: () => material.Navigator.of(context).pop(null), - child: const Text('Cancel'), - ), - const Gap(8), - PrimaryButton( - density: ButtonDensity.compact, - onPressed: _apply, - child: const Text('Apply'), - ), - ], - ), - ], - ), + if (widget.onSaveToDatabase != null) ...[ + PrimaryButton( + density: ButtonDensity.compact, + onPressed: _savingToDb ? null : _saveToDatabase, + leading: _savingToDb + ? material.SizedBox( + width: 12, + height: 12, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: cs.primaryForeground, + ), + ) + : const material.Icon( + material.Icons.save_rounded, + size: 14, + ), + child: Text(_savingToDb ? 'Saving…' : 'Save to DB'), + ), + OutlineButton( + density: ButtonDensity.compact, + onPressed: _savingToDb ? null : _apply, + child: const Text('Apply'), + ), + ] else + PrimaryButton( + density: ButtonDensity.compact, + onPressed: _apply, + child: const Text('Apply'), + ), + ], + ), + ], + ), + ], ), ), - ); + ), + ); } } diff --git a/lib/features/workspace/result_grid_view.dart b/lib/features/workspace/result_grid_view.dart index 3c0a709d..ce57c8bc 100644 --- a/lib/features/workspace/result_grid_view.dart +++ b/lib/features/workspace/result_grid_view.dart @@ -26,6 +26,22 @@ abstract final class ResultGridMetrics { /// Extra columns built beyond the viewport to reduce scroll flicker. static const int columnOverscan = 2; + + /// Hover tooltip: column SQL type when known, plus the cell value when long. + static String? cellTooltipMessage({ + required String text, + String? dataTypeName, + String columnName = '', + }) { + final type = dataTypeName?.trim() ?? ''; + final typePart = type.isEmpty + ? null + : (columnName.isNotEmpty ? '$columnName · $type' : type); + final showValue = text.length >= tooltipMinLength; + if (typePart == null && !showValue) return null; + if (typePart != null && showValue) return '$typePart\n$text'; + return typePart ?? text; + } } /// Inclusive visible column window with spacer widths for off-screen columns. @@ -424,14 +440,20 @@ class ResultGridSelection { } static String _escapeTsv(String val) { - if (val.contains('\t') || val.contains('\n') || val.contains('\r') || val.contains('"')) { + if (val.contains('\t') || + val.contains('\n') || + val.contains('\r') || + val.contains('"')) { return '"${val.replaceAll('"', '""')}"'; } return val; } static String _escapeCsv(String val) { - if (val.contains(',') || val.contains('"') || val.contains('\n') || val.contains('\r')) { + if (val.contains(',') || + val.contains('"') || + val.contains('\n') || + val.contains('\r')) { return '"${val.replaceAll('"', '""')}"'; } return val; @@ -494,7 +516,8 @@ class _SortKey implements Comparable<_SortKey> { @override int compareTo(_SortKey other) { - if (type == _SortKeyType.nullOrEmpty && other.type == _SortKeyType.nullOrEmpty) { + if (type == _SortKeyType.nullOrEmpty && + other.type == _SortKeyType.nullOrEmpty) { return 0; } if (type == _SortKeyType.nullOrEmpty) return 1; @@ -507,8 +530,11 @@ class _SortKey implements Comparable<_SortKey> { return dtVal!.compareTo(other.dtVal!); } - final aLower = type == _SortKeyType.string ? strLower : strRaw.toLowerCase(); - final bLower = other.type == _SortKeyType.string ? other.strLower : other.strRaw.toLowerCase(); + final aLower = + type == _SortKeyType.string ? strLower : strRaw.toLowerCase(); + final bLower = other.type == _SortKeyType.string + ? other.strLower + : other.strRaw.toLowerCase(); final cmp = aLower.compareTo(bLower); if (cmp != 0) return cmp; @@ -538,7 +564,8 @@ SortedResultGridData sortResultGridRowsWithIndices({ if (rows.isEmpty || columnIndex < 0) { return SortedResultGridData( rows: rows, - sortedToModelIndices: List.generate(rows.length, (i) => i, growable: false), + sortedToModelIndices: + List.generate(rows.length, (i) => i, growable: false), ); } final n = rows.length; @@ -556,7 +583,8 @@ SortedResultGridData sortResultGridRowsWithIndices({ return order == ResultGridSortOrder.ascending ? cmp : -cmp; }); - final sortedRows = List>.generate(n, (i) => rows[indices[i]], growable: false); + final sortedRows = + List>.generate(n, (i) => rows[indices[i]], growable: false); return SortedResultGridData( rows: sortedRows, sortedToModelIndices: indices, @@ -591,7 +619,8 @@ class _SortIsolateParams { }); } -SortedResultGridData _sortResultGridRowsWithIndicesIsolate(_SortIsolateParams params) { +SortedResultGridData _sortResultGridRowsWithIndicesIsolate( + _SortIsolateParams params) { return sortResultGridRowsWithIndices( rows: params.rows, columnIndex: params.columnIndex, @@ -632,15 +661,20 @@ class VirtualResultGrid extends material.StatefulWidget { this.onSelectionValuesChanged, this.onCellFocused, this.onFilterRequested, + this.columnDataTypes, }); final List columns; final List> rows; final DataGridStagingBuffer? stagingBuffer; + + /// Column name → SQL type, used for hover tooltips and the inline editor. + final Map? columnDataTypes; final List? rowIndicesMapping; final material.ValueChanged? onRowSelected; final material.ValueChanged>? onSelectionValuesChanged; - final void Function(String columnName, String cellValue, int rowIndex)? onCellFocused; + final void Function(String columnName, String cellValue, int rowIndex)? + onCellFocused; final void Function(String filterExpression)? onFilterRequested; @override @@ -877,12 +911,14 @@ class _VirtualResultGridState extends material.State { return; } final colName = widget.columns[column]; - final currentVal = column < _sortedRows[row].length ? _sortedRows[row][column] : ''; + final currentVal = + column < _sortedRows[row].length ? _sortedRows[row][column] : ''; final result = await showGridCellInspectorDialog( context: context, columnName: colName, initialValue: currentVal, rowIndex: row, + dataTypeName: widget.columnDataTypes?[colName], ); if (result != null && widget.stagingBuffer != null) { widget.stagingBuffer!.setCell(_toModelRowIndex(row), column, result); @@ -921,8 +957,7 @@ class _VirtualResultGridState extends material.State { var maxRowChars = 0; final sampleCount = math.min(rows.length, 200); for (var r = 0; r < sampleCount; r++) { - if (index < rows[r].length && - rows[r][index].length > maxRowChars) { + if (index < rows[r].length && rows[r][index].length > maxRowChars) { maxRowChars = rows[r][index].length; } } @@ -973,7 +1008,8 @@ class _VirtualResultGridState extends material.State { if (widget.rowIndicesMapping != null) { _sortedToModelIndices = List.from(widget.rowIndicesMapping!); } else { - _sortedToModelIndices = List.generate(rows.length, (i) => i, growable: false); + _sortedToModelIndices = + List.generate(rows.length, (i) => i, growable: false); } return; } @@ -983,10 +1019,12 @@ class _VirtualResultGridState extends material.State { final version = ++_sortVersion; if (asyncIfLarge && rows.length >= kSortIsolateThreshold) { - foundation.compute( + foundation + .compute( _sortResultGridRowsWithIndicesIsolate, _SortIsolateParams(rows: rows, columnIndex: sortCol, order: sortOrd), - ).then((sortedData) { + ) + .then((sortedData) { if (!mounted || version != _sortVersion) return; setState(() { _applySortedData(sortedData, rows); @@ -1012,7 +1050,8 @@ class _VirtualResultGridState extends material.State { } } - void _applySortedData(SortedResultGridData sortedData, List> rows) { + void _applySortedData( + SortedResultGridData sortedData, List> rows) { _sortedRows = sortedData.rows; if (widget.rowIndicesMapping != null) { final mapping = widget.rowIndicesMapping!; @@ -1033,7 +1072,9 @@ class _VirtualResultGridState extends material.State { final values = []; for (var r = _selection!.startRow; r <= _selection!.endRow; r++) { if (r >= 0 && r < rows.length) { - for (var c = _selection!.startColumn; c <= _selection!.endColumn; c++) { + for (var c = _selection!.startColumn; + c <= _selection!.endColumn; + c++) { if (c >= 0 && c < rows[r].length) { values.add(rows[r][c]); } @@ -1104,7 +1145,8 @@ class _VirtualResultGridState extends material.State { } } - void _copySelection({bool withHeaders = false, bool asCsv = false, bool asJson = false}) { + void _copySelection( + {bool withHeaders = false, bool asCsv = false, bool asJson = false}) { if (_selection == null) return; _copySelectionData( _selection!, @@ -1197,7 +1239,8 @@ class _VirtualResultGridState extends material.State { if (widget.stagingBuffer == null) return; final sel = (_selection != null && _selection!.contains(row, col)) ? _selection! - : ResultGridSelection(startRow: row, startColumn: col, endRow: row, endColumn: col); + : ResultGridSelection( + startRow: row, startColumn: col, endRow: row, endColumn: col); for (var r = sel.startRow; r <= sel.endRow; r++) { final modelRow = _toModelRowIndex(r); for (var c = sel.startColumn; c <= sel.endColumn; c++) { @@ -1210,7 +1253,8 @@ class _VirtualResultGridState extends material.State { if (widget.stagingBuffer == null) return; final sel = (_selection != null && _selection!.contains(row, col)) ? _selection! - : ResultGridSelection(startRow: row, startColumn: col, endRow: row, endColumn: col); + : ResultGridSelection( + startRow: row, startColumn: col, endRow: row, endColumn: col); for (var r = sel.startRow; r <= sel.endRow; r++) { final modelRow = _toModelRowIndex(r); for (var c = sel.startColumn; c <= sel.endColumn; c++) { @@ -1223,7 +1267,8 @@ class _VirtualResultGridState extends material.State { if (widget.stagingBuffer == null) return; final sel = (_selection != null && _selection!.contains(row, col)) ? _selection! - : ResultGridSelection(startRow: row, startColumn: col, endRow: row, endColumn: col); + : ResultGridSelection( + startRow: row, startColumn: col, endRow: row, endColumn: col); for (var r = sel.startRow; r <= sel.endRow; r++) { final modelRow = _toModelRowIndex(r); for (var c = sel.startColumn; c <= sel.endColumn; c++) { @@ -1509,7 +1554,8 @@ class _VirtualResultGridState extends material.State { control: true, ): () { if (widget.stagingBuffer != null && _selection != null) { - widget.stagingBuffer!.toggleDeleteRow(_toModelRowIndex(_selection!.startRow)); + widget.stagingBuffer! + .toggleDeleteRow(_toModelRowIndex(_selection!.startRow)); } }, const material.SingleActivator( @@ -1517,7 +1563,8 @@ class _VirtualResultGridState extends material.State { meta: true, ): () { if (widget.stagingBuffer != null && _selection != null) { - widget.stagingBuffer!.toggleDeleteRow(_toModelRowIndex(_selection!.startRow)); + widget.stagingBuffer! + .toggleDeleteRow(_toModelRowIndex(_selection!.startRow)); } }, const material.SingleActivator( @@ -1525,7 +1572,8 @@ class _VirtualResultGridState extends material.State { control: true, ): () { if (widget.stagingBuffer != null && _selection != null) { - widget.stagingBuffer!.revertRow(_toModelRowIndex(_selection!.startRow)); + widget.stagingBuffer! + .revertRow(_toModelRowIndex(_selection!.startRow)); } }, const material.SingleActivator( @@ -1533,7 +1581,8 @@ class _VirtualResultGridState extends material.State { meta: true, ): () { if (widget.stagingBuffer != null && _selection != null) { - widget.stagingBuffer!.revertRow(_toModelRowIndex(_selection!.startRow)); + widget.stagingBuffer! + .revertRow(_toModelRowIndex(_selection!.startRow)); } }, const material.SingleActivator( @@ -1819,6 +1868,7 @@ class _VirtualResultGridState extends material.State { onDuplicateRow: _handleDuplicateRow, onToggleDeleteRow: _handleToggleDeleteRow, onRevertRow: _handleRevertRow, + columnDataTypes: widget.columnDataTypes, ); }, ), @@ -1888,9 +1938,8 @@ class _HeaderRow extends material.StatelessWidget { onResize: onResizeColumn != null ? (delta) => onResizeColumn!(i, delta) : null, - onAutoFit: onAutoFitColumn != null - ? () => onAutoFitColumn!(i) - : null, + onAutoFit: + onAutoFitColumn != null ? () => onAutoFitColumn!(i) : null, ), if (window.trailingWidth > 0) material.SizedBox(width: window.trailingWidth), @@ -1968,7 +2017,9 @@ class _HeaderCell extends material.StatelessWidget { if (sortOrder != null) ...[ const Gap(4), material.AnimatedRotation( - turns: sortOrder == ResultGridSortOrder.ascending ? 0.0 : 0.5, + turns: sortOrder == ResultGridSortOrder.ascending + ? 0.0 + : 0.5, duration: context.motionDuration(QueryaMotion.fast), curve: context.motionCurve(QueryaMotion.enter), child: material.Icon( @@ -2038,6 +2089,7 @@ class _DataRow extends material.StatelessWidget { this.onDuplicateRow, this.onToggleDeleteRow, this.onRevertRow, + this.columnDataTypes, }); final int rowIndex; @@ -2074,7 +2126,8 @@ class _DataRow extends material.StatelessWidget { bool asJson, bool asCsv, })? onCopyCell; - final void Function(int row, int col, {required bool invert})? onFilterByValue; + final void Function(int row, int col, {required bool invert})? + onFilterByValue; final void Function(int row, int col, String operator)? onFilterComparison; final void Function(int row, int col)? onSetNull; final void Function(int row, int col)? onSetEmpty; @@ -2082,10 +2135,12 @@ class _DataRow extends material.StatelessWidget { final void Function(int row)? onDuplicateRow; final void Function(int row)? onToggleDeleteRow; final void Function(int row)? onRevertRow; + final Map? columnDataTypes; @override material.Widget build(material.BuildContext context) { - final rowStatus = stagingBuffer?.getRowStatus(modelRowIndex) ?? StagedRowStatus.unchanged; + final rowStatus = + stagingBuffer?.getRowStatus(modelRowIndex) ?? StagedRowStatus.unchanged; return material.RepaintBoundary( child: material.SizedBox( @@ -2104,9 +2159,11 @@ class _DataRow extends material.StatelessWidget { colorScheme: colorScheme, striped: striped, rowStatus: rowStatus, - cellStatus: stagingBuffer?.getCellStatus(modelRowIndex, c) ?? StagedCellStatus.clean, + cellStatus: stagingBuffer?.getCellStatus(modelRowIndex, c) ?? + StagedCellStatus.clean, isSelected: selection?.contains(rowIndex, c) ?? false, - isEditing: editingCell?.row == rowIndex && editingCell?.column == c, + isEditing: + editingCell?.row == rowIndex && editingCell?.column == c, isSelectionTop: selection != null && selection!.contains(rowIndex, c) && rowIndex == selection!.startRow, @@ -2121,6 +2178,8 @@ class _DataRow extends material.StatelessWidget { c == selection!.endColumn, canFilter: canFilter, hasStagingBuffer: stagingBuffer != null, + dataTypeName: + columnDataTypes?[c < columns.length ? columns[c] : ''], onTap: onCellTap, onDoubleTap: onCellDoubleTap, onSecondaryTap: onCellSecondaryTap, @@ -2165,6 +2224,7 @@ class _GridCell extends material.StatelessWidget { this.isSelectionRight = false, this.canFilter = false, this.hasStagingBuffer = false, + this.dataTypeName, this.onTap, this.onDoubleTap, this.onSecondaryTap, @@ -2199,6 +2259,7 @@ class _GridCell extends material.StatelessWidget { final bool isSelectionRight; final bool canFilter; final bool hasStagingBuffer; + final String? dataTypeName; final void Function(int row, int col, {bool isShift})? onTap; final void Function(int row, int col)? onDoubleTap; final void Function(int row, int col)? onSecondaryTap; @@ -2220,7 +2281,8 @@ class _GridCell extends material.StatelessWidget { bool asJson, bool asCsv, })? onCopyCell; - final void Function(int row, int col, {required bool invert})? onFilterByValue; + final void Function(int row, int col, {required bool invert})? + onFilterByValue; final void Function(int row, int col, String operator)? onFilterComparison; final void Function(int row, int col)? onSetNull; final void Function(int row, int col)? onSetEmpty; @@ -2236,19 +2298,24 @@ class _GridCell extends material.StatelessWidget { return [ MenuButton( - leading: const material.Icon(material.Icons.content_copy_rounded, size: 16), - trailing: const material.Text('Ctrl+C', style: material.TextStyle(fontSize: 11)), + leading: + const material.Icon(material.Icons.content_copy_rounded, size: 16), + trailing: const material.Text('Ctrl+C', + style: material.TextStyle(fontSize: 11)), onPressed: (_) => onCopyCell?.call(row, column), child: const material.Text('Copy Value'), ), MenuButton( - leading: const material.Icon(material.Icons.table_chart_outlined, size: 16), - trailing: const material.Text('Ctrl+Shift+C', style: material.TextStyle(fontSize: 11)), + leading: + const material.Icon(material.Icons.table_chart_outlined, size: 16), + trailing: const material.Text('Ctrl+Shift+C', + style: material.TextStyle(fontSize: 11)), onPressed: (_) => onCopyCell?.call(row, column, withHeaders: true), child: const material.Text('Copy with Headers'), ), MenuButton( - leading: const material.Icon(material.Icons.data_object_rounded, size: 16), + leading: + const material.Icon(material.Icons.data_object_rounded, size: 16), onPressed: (_) => onCopyCell?.call(row, column, asJson: true), child: const material.Text('Copy as JSON'), ), @@ -2260,7 +2327,8 @@ class _GridCell extends material.StatelessWidget { if (canFilter && columnName.isNotEmpty) ...[ const MenuDivider(), MenuButton( - leading: const material.Icon(material.Icons.filter_alt_outlined, size: 16), + leading: + const material.Icon(material.Icons.filter_alt_outlined, size: 16), onPressed: (_) => onFilterByValue?.call(row, column, invert: false), child: material.Text( isNull @@ -2269,7 +2337,8 @@ class _GridCell extends material.StatelessWidget { ), ), MenuButton( - leading: const material.Icon(material.Icons.filter_alt_off_outlined, size: 16), + leading: const material.Icon(material.Icons.filter_alt_off_outlined, + size: 16), onPressed: (_) => onFilterByValue?.call(row, column, invert: true), child: material.Text( isNull @@ -2279,12 +2348,14 @@ class _GridCell extends material.StatelessWidget { ), if (numVal != null) ...[ MenuButton( - leading: const material.Icon(material.Icons.chevron_right_rounded, size: 16), + leading: const material.Icon(material.Icons.chevron_right_rounded, + size: 16), onPressed: (_) => onFilterComparison?.call(row, column, '>'), child: material.Text('Filter > $text ($columnName > $text)'), ), MenuButton( - leading: const material.Icon(material.Icons.chevron_left_rounded, size: 16), + leading: const material.Icon(material.Icons.chevron_left_rounded, + size: 16), onPressed: (_) => onFilterComparison?.call(row, column, '<'), child: material.Text('Filter < $text ($columnName < $text)'), ), @@ -2292,20 +2363,26 @@ class _GridCell extends material.StatelessWidget { ], const MenuDivider(), MenuButton( - leading: const material.Icon(material.Icons.visibility_outlined, size: 16), - trailing: const material.Text('Ctrl+I', style: material.TextStyle(fontSize: 11)), + leading: + const material.Icon(material.Icons.visibility_outlined, size: 16), + trailing: const material.Text('Ctrl+I', + style: material.TextStyle(fontSize: 11)), onPressed: (_) => onOpenInspector?.call(row, column), child: const material.Text('Inspect Cell Value…'), ), if (hasStagingBuffer) ...[ MenuButton( - leading: const material.Icon(material.Icons.remove_circle_outline_rounded, size: 16), - trailing: const material.Text('Alt+N', style: material.TextStyle(fontSize: 11)), + leading: const material.Icon( + material.Icons.remove_circle_outline_rounded, + size: 16), + trailing: const material.Text('Alt+N', + style: material.TextStyle(fontSize: 11)), onPressed: (_) => onSetNull?.call(row, column), child: const material.Text('Set NULL'), ), MenuButton( - leading: const material.Icon(material.Icons.format_clear_rounded, size: 16), + leading: const material.Icon(material.Icons.format_clear_rounded, + size: 16), onPressed: (_) => onSetEmpty?.call(row, column), child: const material.Text('Set to Empty String'), ), @@ -2317,8 +2394,10 @@ class _GridCell extends material.StatelessWidget { ), const MenuDivider(), MenuButton( - leading: const material.Icon(material.Icons.content_paste_go_rounded, size: 16), - trailing: const material.Text('Ctrl+D', style: material.TextStyle(fontSize: 11)), + leading: const material.Icon(material.Icons.content_paste_go_rounded, + size: 16), + trailing: const material.Text('Ctrl+D', + style: material.TextStyle(fontSize: 11)), onPressed: (_) => onDuplicateRow?.call(row), child: const material.Text('Duplicate Row'), ), @@ -2328,19 +2407,27 @@ class _GridCell extends material.StatelessWidget { ? material.Icons.restore_from_trash_rounded : material.Icons.delete_outline_rounded, size: 16, - color: rowStatus == StagedRowStatus.deleted ? null : colorScheme.destructive, + color: rowStatus == StagedRowStatus.deleted + ? null + : colorScheme.destructive, ), onPressed: (_) => onToggleDeleteRow?.call(row), child: material.Text( - rowStatus == StagedRowStatus.deleted ? 'Restore Deleted Row' : 'Delete Row', + rowStatus == StagedRowStatus.deleted + ? 'Restore Deleted Row' + : 'Delete Row', style: material.TextStyle( - color: rowStatus == StagedRowStatus.deleted ? null : colorScheme.destructive, + color: rowStatus == StagedRowStatus.deleted + ? null + : colorScheme.destructive, ), ), ), - if (rowStatus == StagedRowStatus.modified || rowStatus == StagedRowStatus.deleted) + if (rowStatus == StagedRowStatus.modified || + rowStatus == StagedRowStatus.deleted) MenuButton( - leading: const material.Icon(material.Icons.restore_rounded, size: 16), + leading: + const material.Icon(material.Icons.restore_rounded, size: 16), onPressed: (_) => onRevertRow?.call(row), child: const material.Text('Revert Row Changes'), ), @@ -2355,7 +2442,12 @@ class _GridCell extends material.StatelessWidget { initialValue: text, width: width, height: double.infinity, - onCommit: (val, {moveNextCol = false, movePrevCol = false, moveNextRow = false, movePrevRow = false}) { + dataTypeName: dataTypeName, + onCommit: (val, + {moveNextCol = false, + movePrevCol = false, + moveNextRow = false, + movePrevRow = false}) { onCommitEdit?.call( row, column, @@ -2464,14 +2556,26 @@ class _GridCell extends material.StatelessWidget { ); material.Widget content = interactiveCell; - if (text.length >= ResultGridMetrics.tooltipMinLength) { + final tooltip = ResultGridMetrics.cellTooltipMessage( + text: text, + dataTypeName: dataTypeName, + columnName: columnName, + ); + if (tooltip != null) { content = material.Tooltip( - message: text, + message: tooltip, waitDuration: kQueryaTooltipWait, child: content, ); } + content = material.MouseRegion( + cursor: hasStagingBuffer + ? material.SystemMouseCursors.text + : material.SystemMouseCursors.basic, + child: content, + ); + return material.Listener( onPointerDown: (event) { if (event.buttons == kSecondaryMouseButton) { @@ -2505,4 +2609,3 @@ class _TriangleCornerPainter extends material.CustomPainter { bool shouldRepaint(covariant _TriangleCornerPainter oldDelegate) => oldDelegate.color != color; } - diff --git a/lib/features/workspace/results_tab.dart b/lib/features/workspace/results_tab.dart index 29c9db27..998c5f3e 100644 --- a/lib/features/workspace/results_tab.dart +++ b/lib/features/workspace/results_tab.dart @@ -2,6 +2,7 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/actions/data_grid_command_bridge.dart'; import 'package:querya_desktop/core/widgets/virtual_selectable_text_view.dart'; import 'package:querya_desktop/features/workspace/data_grid_calc_bar.dart'; import 'package:querya_desktop/features/workspace/data_grid_filter_bar.dart'; @@ -35,6 +36,7 @@ class ResultsTab extends material.StatefulWidget { this.onApplyChanges, this.isSaving = false, this.errorAction, + this.columnDataTypes, }); final List columns; @@ -49,6 +51,9 @@ class ResultsTab extends material.StatefulWidget { final bool isSaving; final material.Widget? errorAction; + /// Column name → SQL type for cell hover tooltips and the inline editor. + final Map? columnDataTypes; + @override material.State createState() => _ResultsTabState(); } @@ -103,8 +108,92 @@ class _ResultsTabState extends material.State { return filteredRows; } + @override + void initState() { + super.initState(); + _syncGridBridge(); + } + + @override + void didUpdateWidget(covariant ResultsTab oldWidget) { + super.didUpdateWidget(oldWidget); + _syncGridBridge(); + } + + void _syncGridBridge() { + final hasGrid = widget.showExportToolbar && + widget.columns.isNotEmpty && + widget.errorMessage == null && + !widget.isLoading; + if (!hasGrid) { + DataGridCommandBridge.instance.unregister(); + return; + } + final effectiveRows = widget.stagingBuffer != null + ? widget.stagingBuffer!.effectiveRows + : widget.rows; + DataGridCommandBridge.instance.register( + onToggleFilter: () { + if (!mounted) return; + setState(() => _showFilterBar = !_showFilterBar); + }, + onToggleInspector: () { + if (!mounted) return; + setState(() => _showValuePanel = !_showValuePanel); + }, + onCopy: (format) => unawaited(_copyAs(format)), + onSaveExport: () => unawaited(_saveExport()), + onApplyStaged: widget.onApplyChanges, + canApplyStaged: () => + widget.stagingBuffer?.isDirty == true && !widget.isSaving, + hasRows: effectiveRows.isNotEmpty, + ); + } + + List> _exportRows() { + final effectiveRows = widget.stagingBuffer != null + ? widget.stagingBuffer!.effectiveRows + : widget.rows; + return _getFilteredRows(effectiveRows, widget.columns); + } + + Future _copyAs(DataExportFormat format) async { + final rows = _exportRows(); + await DataExportService.copyToClipboard( + format, + columns: widget.columns, + rows: rows, + ); + if (!mounted) return; + showAppToast( + context: context, + message: 'Copied ${rows.length} rows as ${format.name.toUpperCase()}', + variant: AppToastVariant.success, + ); + } + + Future _saveExport() async { + final rows = _exportRows(); + final outcome = await DataExportService.saveToFile( + DataExportFormat.csv, + columns: widget.columns, + rows: rows, + ); + if (!mounted) return; + if (outcome == SaveExportOutcome.written) { + showAppToast( + context: context, + message: 'Exported ${rows.length} rows', + variant: AppToastVariant.success, + ); + } else if (outcome == SaveExportOutcome.error) { + await _showSaveFileErrorDialog(context); + } + } + @override void dispose() { + DataGridCommandBridge.instance.unregister(); _memoColumns = null; _memoEffectiveRows = null; _cachedFilteredRows = const []; @@ -338,22 +427,7 @@ class _ResultsTabState extends material.State { label: 'Export ▾', icon: material.Icons.copy_rounded, isSave: false, - onSelected: (format) { - unawaited(() async { - await DataExportService.copyToClipboard( - format, - columns: widget.columns, - rows: filteredRows, - ); - if (context.mounted) { - showAppToast( - context: context, - message: 'Copied ${filteredRows.length} rows as ${format.name.toUpperCase()}', - variant: AppToastVariant.success, - ); - } - }()); - }, + onSelected: (format) => unawaited(_copyAs(format)), ), const Gap(6), ExportMenuButton( @@ -362,16 +436,17 @@ class _ResultsTabState extends material.State { isSave: true, onSelected: (format) { unawaited(() async { + final rows = _exportRows(); final outcome = await DataExportService.saveToFile( format, columns: widget.columns, - rows: filteredRows, + rows: rows, ); if (context.mounted) { if (outcome == SaveExportOutcome.written) { showAppToast( context: context, - message: 'Exported ${filteredRows.length} rows', + message: 'Exported ${rows.length} rows', variant: AppToastVariant.success, ); } else if (outcome == SaveExportOutcome.error) { @@ -420,6 +495,7 @@ class _ResultsTabState extends material.State { columns: widget.columns, rows: filteredRows, stagingBuffer: widget.stagingBuffer, + columnDataTypes: widget.columnDataTypes, rowIndicesMapping: _cachedFilteredIndices, onRowSelected: (row) => setState(() => _selectedRowIndex = row), onSelectionValuesChanged: (values) { diff --git a/lib/features/workspace/sql_query_tab_session.dart b/lib/features/workspace/sql_query_tab_session.dart index a3645cce..ef8f15f0 100644 --- a/lib/features/workspace/sql_query_tab_session.dart +++ b/lib/features/workspace/sql_query_tab_session.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/actions/sql_script_format.dart'; +import 'package:querya_desktop/core/unsaved_work_registry.dart'; import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; /// A stateful query session inside an SQL workspace. @@ -13,7 +15,12 @@ class SqlQueryTabSession { this.filePath, double initialFraction = 0.65, }) : controller = material.TextEditingController(text: initialSql ?? ''), - topFraction = material.ValueNotifier(initialFraction); + topFraction = material.ValueNotifier(initialFraction) { + UnsavedWorkRegistry.instance.register( + this, + () => isModified || (stagingBuffer?.isDirty ?? false), + ); + } final String id; String title; @@ -32,7 +39,22 @@ class SqlQueryTabSession { bool savingChanges = false; bool isModified = false; + void formatSql() { + final next = formatSqlScript(controller.text); + controller.value = material.TextEditingValue( + text: next, + selection: material.TextSelection.collapsed(offset: next.length), + ); + isModified = true; + } + + void clearSql() { + controller.clear(); + isModified = true; + } + void dispose() { + UnsavedWorkRegistry.instance.unregister(this); controller.dispose(); topFraction.dispose(); stagingBuffer?.dispose(); diff --git a/lib/features/workspace/table_view_staging.dart b/lib/features/workspace/table_view_staging.dart new file mode 100644 index 00000000..87c2daa8 --- /dev/null +++ b/lib/features/workspace/table_view_staging.dart @@ -0,0 +1,296 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/core/database/table_schema_meta.dart'; +import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/workspace/dml_preview_dialog.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Whether Table Browser should attach a [DataGridStagingBuffer] for this page. +bool tableViewEditingEnabled({ + required bool isView, + bool isMaterializedView = false, + required bool customSqlActive, + required bool hasPrimaryKey, +}) { + if (isView || isMaterializedView || customSqlActive) return false; + return hasPrimaryKey; +} + +/// Human-readable reason editing is off, or null when it is allowed / not yet known. +String? tableViewEditDisabledReason({ + required bool isView, + bool isMaterializedView = false, + required bool customSqlActive, + required bool hasPrimaryKey, + required bool schemaLoaded, +}) { + if (isView || isMaterializedView) return 'Views are read-only'; + if (customSqlActive) return 'Custom SQL results are read-only'; + if (schemaLoaded && !hasPrimaryKey) { + return 'Cannot edit: no primary key detected'; + } + return null; +} + +/// Column name → SQL type from [schema], omitting empty types. +Map columnDataTypesFromSchema(TableSchemaMeta schema) { + return { + for (final c in schema.columns) + if (c.dataType.isNotEmpty) c.name: c.dataType, + }; +} + +/// Disposes [previous] and returns a new buffer when [enabled]. +DataGridStagingBuffer? replaceTableViewStagingBuffer({ + DataGridStagingBuffer? previous, + required List columns, + required List> rows, + required bool enabled, +}) { + previous?.dispose(); + if (!enabled || columns.isEmpty) return null; + return DataGridStagingBuffer(columns: columns, rows: rows); +} + +/// Confirms discarding dirty staged edits. Returns true when it is safe to proceed. +Future confirmDiscardTableEditsIfDirty({ + required material.BuildContext context, + required DataGridStagingBuffer? buffer, + required String tableTitle, +}) async { + if (buffer == null || !buffer.isDirty) return true; + final confirmed = await showDiscardTableEditsDialog( + context: context, + tableTitle: tableTitle, + changeCount: buffer.changeCount, + ); + return confirmed == true; +} + +/// Prompt when leaving / refreshing a table that has uncommitted cell edits. +Future showDiscardTableEditsDialog({ + required material.BuildContext context, + required String tableTitle, + required int changeCount, +}) { + final n = changeCount; + final noun = n == 1 ? 'change' : 'changes'; + return showAppDialog( + context: context, + builder: (ctx) { + return QueryaDialogCard( + constraints: const material.BoxConstraints(maxWidth: 420), + child: material.Padding( + padding: const material.EdgeInsets.all(20), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + Text('Unsaved changes in "$tableTitle"').semiBold().large(), + const Gap(8), + Text( + 'This table has $n pending $noun that have not been saved. ' + 'Continuing will discard them.', + ).muted().small(), + const Gap(20), + material.Align( + alignment: material.Alignment.centerRight, + child: material.Wrap( + alignment: material.WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: [ + OutlineButton( + onPressed: () => material.Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + DestructiveButton( + onPressed: () => material.Navigator.of(ctx).pop(true), + child: const Text('Discard'), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); +} + +Future showTableViewSaveFailedDialog({ + required material.BuildContext context, + required Object error, +}) { + return showAppDialog( + context: context, + builder: (ctx) => QueryaDialogCard( + constraints: const material.BoxConstraints(maxWidth: 420), + child: material.Padding( + padding: const material.EdgeInsets.all(20), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Save Changes Failed').semiBold().large(), + const Gap(8), + Text(error.toString()).muted().small(), + const Gap(20), + material.Align( + alignment: material.Alignment.centerRight, + child: PrimaryButton( + onPressed: () => material.Navigator.of(ctx).pop(), + child: const Text('OK'), + ), + ), + ], + ), + ), + ), + ); +} + +/// Result of [applyTableViewStagedChanges]. +enum TableViewApplyStatus { noop, cancelled, applied, failed } + +class TableViewApplyOutcome { + const TableViewApplyOutcome._(this.status, + {this.statementCount = 0, this.error}); + + const TableViewApplyOutcome.noop() : this._(TableViewApplyStatus.noop); + + const TableViewApplyOutcome.cancelled() + : this._(TableViewApplyStatus.cancelled); + + const TableViewApplyOutcome.applied(int statementCount) + : this._(TableViewApplyStatus.applied, statementCount: statementCount); + + const TableViewApplyOutcome.failed(Object error) + : this._(TableViewApplyStatus.failed, error: error); + + final TableViewApplyStatus status; + final int statementCount; + final Object? error; + + bool get isApplied => status == TableViewApplyStatus.applied; + bool get isFailed => status == TableViewApplyStatus.failed; +} + +/// Preview + execute a staging-buffer mutation plan for Table Browser. +Future applyTableViewStagedChanges({ + required material.BuildContext context, + required DataGridStagingBuffer buffer, + required SqlDialect dialect, + required String tableName, + String? schema, + required List primaryKeys, + Map? columnDataTypes, + required Future Function(TableMutationPlan plan) execute, +}) async { + if (!buffer.isDirty) return const TableViewApplyOutcome.noop(); + if (primaryKeys.isEmpty) { + return const TableViewApplyOutcome.failed( + 'Cannot edit: no primary key detected', + ); + } + + final plan = buffer.generateMutationPlan( + dialect: dialect, + tableName: tableName, + schema: schema, + primaryKeys: primaryKeys, + columnDataTypes: columnDataTypes, + ); + if (plan.isEmpty) return const TableViewApplyOutcome.noop(); + + final confirmed = await showDmlPreviewDialog(context: context, plan: plan); + if (confirmed != true) return const TableViewApplyOutcome.cancelled(); + if (!context.mounted) return const TableViewApplyOutcome.cancelled(); + + try { + await execute(plan); + return TableViewApplyOutcome.applied(plan.statementCount); + } catch (e) { + return TableViewApplyOutcome.failed(e); + } +} + +/// Compact Save / Revert + pending badge for Table Browser chrome. +class TableBrowserPendingActions extends material.StatelessWidget { + const TableBrowserPendingActions({ + super.key, + required this.buffer, + required this.onSave, + required this.isSaving, + }); + + final DataGridStagingBuffer buffer; + final material.VoidCallback? onSave; + final bool isSaving; + + @override + material.Widget build(material.BuildContext context) { + return ListenableBuilder( + listenable: buffer, + builder: (context, _) { + if (!buffer.isDirty) return const material.SizedBox.shrink(); + final cs = Theme.of(context).colorScheme; + final n = buffer.changeCount; + return material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(4), + border: material.Border.all( + color: cs.primary.withValues(alpha: 0.3), + ), + ), + child: material.Text( + '$n pending ${n == 1 ? 'change' : 'changes'}', + style: material.TextStyle( + fontSize: 11, + fontWeight: material.FontWeight.w600, + color: cs.primary, + ), + ), + ), + const Gap(4), + OutlineButton( + size: ButtonSize.small, + onPressed: isSaving ? null : () => buffer.revertAll(), + leading: const material.Icon( + material.Icons.undo_rounded, + size: 14, + ), + child: const Text('Revert'), + ), + const Gap(4), + PrimaryButton( + size: ButtonSize.small, + onPressed: isSaving ? null : onSave, + leading: isSaving + ? material.SizedBox( + width: 12, + height: 12, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: cs.primaryForeground, + ), + ) + : const material.Icon(material.Icons.save_rounded, size: 14), + child: Text(isSaving ? 'Saving…' : 'Save'), + ), + const Gap(6), + ], + ); + }, + ); + } +} diff --git a/lib/features/workspace/workspace.dart b/lib/features/workspace/workspace.dart index 93e4d168..916c0779 100644 --- a/lib/features/workspace/workspace.dart +++ b/lib/features/workspace/workspace.dart @@ -19,4 +19,5 @@ export 'sql_editor_chrome.dart'; export 'sql_query_history_dialog.dart'; export 'sql_query_tab_bar.dart'; export 'sql_query_tab_session.dart'; +export 'table_view_staging.dart'; export 'xml_html_formatter.dart'; diff --git a/lib/shared/widgets/connection_tree_loading_row.dart b/lib/shared/widgets/connection_tree_loading_row.dart new file mode 100644 index 00000000..2959e461 --- /dev/null +++ b/lib/shared/widgets/connection_tree_loading_row.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/ui/querya_tree_tokens.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Compact inline spinner row used while connection trees lazy-load children. +class ConnectionTreeLoadingRow extends material.StatelessWidget { + const ConnectionTreeLoadingRow.connection({ + super.key, + this.label = 'Loading...', + }) : padding = QueryaTreeTokens.loadingPaddingConnection, + spinnerSize = QueryaTreeTokens.spinnerConnection, + strokeWidth = QueryaTreeTokens.spinnerStroke, + gap = 8; + + const ConnectionTreeLoadingRow.nested({ + super.key, + this.label = 'Loading...', + }) : padding = QueryaTreeTokens.loadingPaddingNested, + spinnerSize = QueryaTreeTokens.spinnerNested, + strokeWidth = QueryaTreeTokens.spinnerStroke, + gap = 6; + + final String label; + final material.EdgeInsetsGeometry padding; + final double spinnerSize; + final double strokeWidth; + final double gap; + + @override + material.Widget build(material.BuildContext context) { + return material.Padding( + padding: padding, + child: material.Row( + children: [ + material.SizedBox( + width: spinnerSize, + height: spinnerSize, + child: material.CircularProgressIndicator(strokeWidth: strokeWidth), + ), + Gap(gap), + Text(label).muted().xSmall(), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/tree_load_error.dart b/lib/shared/widgets/tree_load_error.dart index aabaee49..bd90de68 100644 --- a/lib/shared/widgets/tree_load_error.dart +++ b/lib/shared/widgets/tree_load_error.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/ui/querya_icon_sizes.dart'; import 'package:querya_desktop/core/ui/querya_icons.dart'; +import 'package:querya_desktop/core/ui/querya_tree_tokens.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Inline error block for connection tree lazy-load failures. @@ -14,11 +15,7 @@ class TreeLoadError extends material.StatelessWidget { required this.message, this.onRetry, this.retryLabel = 'Retry', - this.padding = const material.EdgeInsets.only( - left: 24, - top: 4, - bottom: 8, - ), + this.padding = QueryaTreeTokens.errorPaddingNested, this.detailFontSize = 11, this.showTitleRow = true, }); diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index 263edfff..85dea9c7 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -19,6 +19,7 @@ export 'querya_dropdown.dart' QueryaDropdownItem, QueryaDropdownTokens, kPreferencesLabelWidth; +export 'connection_tree_loading_row.dart'; export 'tree_load_error.dart'; export 'package:querya_desktop/core/motion/querya_motion.dart'; export 'package:querya_desktop/core/motion/querya_motion_context.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index de5bdcd8..72f489d7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: querya_desktop description: Lightweight desktop SQL/NoSQL client. Flutter (Dart). -version: 0.4.16+2 +version: 0.4.16+1 diff --git a/test/core/actions/querya_command_registry_test.dart b/test/core/actions/querya_command_registry_test.dart new file mode 100644 index 00000000..8bc26c5f --- /dev/null +++ b/test/core/actions/querya_command_registry_test.dart @@ -0,0 +1,371 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/actions/querya_command.dart'; +import 'package:querya_desktop/core/actions/querya_command_host.dart'; +import 'package:querya_desktop/core/actions/querya_command_registry.dart'; +import 'package:querya_desktop/core/actions/data_grid_command_bridge.dart'; +import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; +import 'package:querya_desktop/shared/services/data_export_service.dart'; + +void main() { + final registry = QueryaCommandRegistry.instance; + + setUp(() { + registry.resetForTest(); + SqlEditorCommandBridge.instance.resetForTest(); + DataGridCommandBridge.instance.resetForTest(); + }); + + tearDown(() { + registry.resetForTest(); + SqlEditorCommandBridge.instance.resetForTest(); + DataGridCommandBridge.instance.resetForTest(); + }); + + test('register and search by title, id, and aliases', () { + registry.register( + QueryaCommand( + id: 'test.foo', + title: 'Foo Action', + category: 'Tools', + aliases: const ['bar', 'baz'], + execute: (_) {}, + ), + ); + + expect(registry.search('foo action').map((c) => c.id), contains('test.foo')); + expect(registry.search('bar').map((c) => c.id), contains('test.foo')); + expect(registry.search('zzznomatch'), isEmpty); + }); + + test('unregister removes command', () { + registry.register( + QueryaCommand(id: 'x', title: 'X', execute: (_) {}), + ); + registry.unregister('x'); + expect(registry['x'], isNull); + }); + + test('register does not let an extension overwrite a core id', () { + registry.ensureCoreDefaults(); + final before = registry['querya.sql.execute']!; + registry.register( + QueryaCommand( + id: 'querya.sql.execute', + title: 'Stolen', + sourceExtensionId: 'evil.driver', + execute: (_) {}, + ), + ); + expect(registry['querya.sql.execute']?.title, before.title); + expect(registry['querya.sql.execute']?.sourceExtensionId, isNull); + }); + + test('core defaults: dark alias finds theme command', () { + registry.ensureCoreDefaults(); + expect( + registry.search('dark').map((c) => c.id), + contains('querya.theme.toggle'), + ); + expect( + registry.search('settings').map((c) => c.id), + contains('querya.app.preferences'), + ); + }); + + testWidgets('getAvailableCommands honors isEnabled', (tester) async { + registry.register( + QueryaCommand( + id: 'test.always', + title: 'Always', + execute: (_) {}, + ), + ); + registry.register( + QueryaCommand( + id: 'test.never', + title: 'Never', + isEnabled: (_) => false, + execute: (_) {}, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + final ids = + registry.getAvailableCommands(context).map((c) => c.id); + expect(ids, contains('test.always')); + expect(ids, isNot(contains('test.never'))); + return const SizedBox.shrink(); + }, + ), + ), + ); + }); + + testWidgets('execute query is gated on SqlEditorCommandBridge', + (tester) async { + registry.ensureCoreDefaults(); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + expect( + registry.getAvailableCommands(context).map((c) => c.id), + isNot(contains('querya.sql.execute')), + ); + return const SizedBox.shrink(); + }, + ), + ), + ); + + var ran = 0; + SqlEditorCommandBridge.instance.register( + connectionId: 1, + onNew: () {}, + onOpen: () {}, + onSave: () {}, + onExecute: () => ran++, + ); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + expect( + registry.getAvailableCommands(context).map((c) => c.id), + contains('querya.sql.execute'), + ); + registry['querya.sql.execute']!.execute(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + expect(ran, 1); + }); + + testWidgets('go to object enabled under QueryaCommandHost', (tester) async { + registry.ensureCoreDefaults(); + var opened = 0; + await tester.pumpWidget( + QueryaCommandHost( + onShowQuickSwitcher: (_) => opened++, + child: MaterialApp( + home: Builder( + builder: (context) { + expect( + registry.getAvailableCommands(context).map((c) => c.id), + contains('querya.goto.object'), + ); + registry['querya.goto.object']!.execute(context); + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + expect(opened, 1); + }); + + test('core catalog includes workspace, SQL, grid, and app commands', () { + registry.ensureCoreDefaults(); + const expected = [ + 'querya.view.toggleSidebar', + 'querya.workspace.close', + 'querya.workspace.home', + 'querya.connection.new', + 'querya.connection.connect', + 'querya.connection.disconnect', + 'querya.connection.reconnect', + 'querya.connection.toggleReadOnly', + 'querya.sql.execute', + 'querya.sql.newTab', + 'querya.sql.closeTab', + 'querya.sql.format', + 'querya.sql.clear', + 'querya.grid.toggleFilter', + 'querya.grid.inspectCell', + 'querya.grid.copyCsv', + 'querya.grid.copyJson', + 'querya.grid.copyMarkdown', + 'querya.grid.saveExport', + 'querya.grid.applyStaged', + 'querya.app.preferences', + 'querya.app.extensions', + 'querya.app.updates', + 'querya.app.welcome', + 'querya.app.about', + ]; + final ids = registry.commands.map((c) => c.id).toSet(); + expect(ids, containsAll(expected)); + }); + + testWidgets('SQL format/clear/new/close hit SqlEditorCommandBridge', + (tester) async { + registry.ensureCoreDefaults(); + var format = 0; + var clear = 0; + var created = 0; + var closed = 0; + SqlEditorCommandBridge.instance.register( + connectionId: 1, + onNew: () => created++, + onOpen: () {}, + onSave: () {}, + onCloseTab: () => closed++, + onFormat: () => format++, + onClear: () => clear++, + ); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + final ids = + registry.getAvailableCommands(context).map((c) => c.id); + expect(ids, contains('querya.sql.newTab')); + expect(ids, contains('querya.sql.closeTab')); + expect(ids, contains('querya.sql.format')); + expect(ids, contains('querya.sql.clear')); + registry['querya.sql.format']!.execute(context); + registry['querya.sql.clear']!.execute(context); + registry['querya.sql.newTab']!.execute(context); + registry['querya.sql.closeTab']!.execute(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + expect(format, 1); + expect(clear, 1); + expect(created, 1); + expect(closed, 1); + }); + + testWidgets('grid commands hit DataGridCommandBridge', (tester) async { + registry.ensureCoreDefaults(); + var filter = 0; + var inspect = 0; + DataExportFormat? copied; + var exported = 0; + var applied = 0; + var dirty = true; + DataGridCommandBridge.instance.register( + onToggleFilter: () => filter++, + onToggleInspector: () => inspect++, + onCopy: (format) => copied = format, + onSaveExport: () => exported++, + onApplyStaged: () => applied++, + canApplyStaged: () => dirty, + hasRows: true, + ); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + final ids = + registry.getAvailableCommands(context).map((c) => c.id); + expect(ids, contains('querya.grid.toggleFilter')); + expect(ids, contains('querya.grid.copyCsv')); + expect(ids, contains('querya.grid.applyStaged')); + registry['querya.grid.toggleFilter']!.execute(context); + registry['querya.grid.inspectCell']!.execute(context); + registry['querya.grid.copyCsv']!.execute(context); + registry['querya.grid.saveExport']!.execute(context); + registry['querya.grid.applyStaged']!.execute(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + expect(filter, 1); + expect(inspect, 1); + expect(copied, DataExportFormat.csv); + expect(exported, 1); + expect(applied, 1); + + dirty = false; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + expect( + registry.getAvailableCommands(context).map((c) => c.id), + isNot(contains('querya.grid.applyStaged')), + ); + return const SizedBox.shrink(); + }, + ), + ), + ); + }); + + testWidgets('connection and workspace commands hit QueryaCommandHost', + (tester) async { + registry.ensureCoreDefaults(); + var home = 0; + var closed = 0; + var connected = 0; + var disconnected = 0; + var reconnected = 0; + var ro = 0; + await tester.pumpWidget( + QueryaCommandHost( + onGoHome: () => home++, + onCloseWorkspace: () => closed++, + onConnect: () => connected++, + onDisconnect: () => disconnected++, + onReconnect: () => reconnected++, + onToggleReadOnly: () => ro++, + child: MaterialApp( + home: Builder( + builder: (context) { + registry['querya.workspace.home']!.execute(context); + registry['querya.workspace.close']!.execute(context); + registry['querya.connection.connect']!.execute(context); + registry['querya.connection.disconnect']!.execute(context); + registry['querya.connection.reconnect']!.execute(context); + registry['querya.connection.toggleReadOnly']!.execute(context); + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + expect(home, 1); + expect(closed, 1); + expect(connected, 1); + expect(disconnected, 1); + expect(reconnected, 1); + expect(ro, 1); + }); + + testWidgets('toggle sidebar enabled under QueryaCommandHost', (tester) async { + registry.ensureCoreDefaults(); + var toggled = 0; + await tester.pumpWidget( + QueryaCommandHost( + onToggleSidebar: () => toggled++, + child: MaterialApp( + home: Builder( + builder: (context) { + expect( + registry.getAvailableCommands(context).map((c) => c.id), + contains('querya.view.toggleSidebar'), + ); + registry['querya.view.toggleSidebar']!.execute(context); + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + expect(toggled, 1); + }); +} diff --git a/test/core/actions/querya_schema_object_cache_test.dart b/test/core/actions/querya_schema_object_cache_test.dart new file mode 100644 index 00000000..a7401031 --- /dev/null +++ b/test/core/actions/querya_schema_object_cache_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/actions/querya_schema_object.dart'; +import 'package:querya_desktop/core/actions/querya_schema_object_cache.dart'; +import 'package:querya_desktop/core/actions/querya_schema_object_loader.dart'; +import 'package:querya_desktop/core/sdui/sdui_tree_schema.dart'; + +void main() { + final cache = QueryaSchemaObjectCache.instance; + + setUp(cache.resetForTest); + tearDown(cache.resetForTest); + + QueryaSchemaObject users() => QueryaSchemaObject.postgres( + database: 'app', + schema: 'public', + name: 'users', + kind: QueryaSchemaObjectKind.table, + ); + + QueryaSchemaObject orders() => QueryaSchemaObject.postgres( + database: 'app', + schema: 'sales', + name: 'orders', + kind: QueryaSchemaObjectKind.table, + ); + + test('filter matches name, schema.qualified, and kind', () { + final pool = [users(), orders()]; + expect( + filterSchemaObjects(pool, 'users').map((o) => o.id), + [users().id], + ); + expect( + filterSchemaObjects(pool, 'public.users').map((o) => o.id), + [users().id], + ); + expect( + filterSchemaObjects(pool, 'sales').map((o) => o.id), + [orders().id], + ); + }); + + test('filter caps results and ignores extra spaces', () { + final pool = [ + for (var i = 0; i < 120; i++) + QueryaSchemaObject.sqlite( + name: 't$i', + kind: QueryaSchemaObjectKind.table, + ), + ]; + expect(filterSchemaObjects(pool, '', limit: 80), hasLength(80)); + expect(filterSchemaObjects(pool, ' t1 ', limit: 5), isNotEmpty); + }); + + test('cache peek/put/merge/invalidate', () { + const scope = 'pg:app'; + cache.put(1, scope, [users()]); + expect(cache.peek(1, scope)!.map((o) => o.name), ['users']); + + cache.merge(1, scope, [orders()]); + expect(cache.peek(1, scope)!.map((o) => o.qualifiedName).toList()..sort(), [ + 'public.users', + 'sales.orders', + ]); + + cache.invalidate(1); + expect(cache.peek(1, scope), isNull); + }); + + test('flattenExtensionTree collects table-like leaves', () { + const schema = SduiTreeSchema( + roots: [ + SduiTreeNode( + id: 'db.app', + label: 'app', + children: [ + SduiTreeNode(id: 'table.app.users', label: 'users'), + SduiTreeNode(id: 'view.app.active_users', label: 'active_users'), + SduiTreeNode(id: 'folder.app.misc', label: 'misc'), + ], + ), + ], + ); + final objects = flattenExtensionTree(schema); + expect(objects.map((o) => o.name), ['users', 'active_users']); + expect(objects[1].kind, QueryaSchemaObjectKind.view); + }); +} diff --git a/test/core/actions/sql_script_format_test.dart b/test/core/actions/sql_script_format_test.dart new file mode 100644 index 00000000..7c74ad40 --- /dev/null +++ b/test/core/actions/sql_script_format_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/actions/sql_script_format.dart'; + +void main() { + test('uppercases SQL keywords and leaves identifiers', () { + expect( + formatSqlScript('select id from users where name = \'Ada\''), + "SELECT id FROM users WHERE name = 'Ada'", + ); + }); +} diff --git a/test/core/database/postgres_connection_string_parse_test.dart b/test/core/database/postgres_connection_string_parse_test.dart index 96e073ac..bb4845d7 100644 --- a/test/core/database/postgres_connection_string_parse_test.dart +++ b/test/core/database/postgres_connection_string_parse_test.dart @@ -56,7 +56,7 @@ void main() { test('invalid sslmode throws', () { expect( () => parseConnectionString( - 'postgresql://localhost/postgres?sslmode=prefer', + 'postgresql://localhost/postgres?sslmode=invalid', ), throwsArgumentError, ); diff --git a/test/core/extensions/extension_command_sync_test.dart b/test/core/extensions/extension_command_sync_test.dart new file mode 100644 index 00000000..bab39dcf --- /dev/null +++ b/test/core/extensions/extension_command_sync_test.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/actions/querya_command_registry.dart'; +import 'package:querya_desktop/core/extensions/extension_command_sync.dart'; +import 'package:querya_desktop/core/extensions/models/extension_contributions.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; + +void main() { + final registry = QueryaCommandRegistry.instance; + final sync = ExtensionCommandSync.instance; + + setUp(() { + registry.resetForTest(); + sync.resetForTest(); + }); + + tearDown(() { + sync.resetForTest(); + registry.resetForTest(); + }); + + ExtensionManifest clickhouse() { + return const ExtensionManifest( + id: 'queryahub.clickhouse-driver', + name: 'ClickHouse Driver', + version: '1.0.0', + publisher: 'QueryaHub', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '^0.5.0'}, + contributions: ExtensionContributions( + commands: [ + CommandContribution( + id: 'ext.clickhouse.cluster_status', + title: 'ClickHouse: Show Cluster Status', + category: 'ClickHouse', + ), + ], + ), + ); + } + + test('sync registers contributed commands in the palette registry', () { + sync.sync([clickhouse()]); + expect( + registry['ext.clickhouse.cluster_status']?.title, + 'ClickHouse: Show Cluster Status', + ); + expect( + registry['ext.clickhouse.cluster_status']?.sourceExtensionId, + 'queryahub.clickhouse-driver', + ); + expect( + registry.search('cluster').map((c) => c.id), + contains('ext.clickhouse.cluster_status'), + ); + }); + + test('removing a manifest unregisters its commands', () { + sync.sync([clickhouse()]); + expect(registry['ext.clickhouse.cluster_status'], isNotNull); + + sync.sync(const []); + expect(registry['ext.clickhouse.cluster_status'], isNull); + }); + + test('disabling an extension drops its commands; enabling restores them', () { + sync.sync([clickhouse()]); + expect(registry['ext.clickhouse.cluster_status'], isNotNull); + + sync.setEnabled('queryahub.clickhouse-driver', false); + expect(registry['ext.clickhouse.cluster_status'], isNull); + + sync.setEnabled('queryahub.clickhouse-driver', true); + expect(registry['ext.clickhouse.cluster_status'], isNotNull); + }); + + testWidgets('execute forwards to invokeOverride (PluginRpcBridge path)', + (tester) async { + var ran = 0; + sync.invokeOverride = (manifest, command, _) async { + expect(manifest.id, 'queryahub.clickhouse-driver'); + expect(command.id, 'ext.clickhouse.cluster_status'); + ran++; + }; + sync.sync([clickhouse()]); + + await tester.pumpWidget( + const MaterialApp(home: SizedBox.shrink()), + ); + final context = tester.element(find.byType(SizedBox)); + registry['ext.clickhouse.cluster_status']!.execute(context); + await tester.pump(); + expect(ran, 1); + }); + + test('skips querya.* and ids without ext. prefix', () { + registry.ensureCoreDefaults(); + sync.sync([ + const ExtensionManifest( + id: 'evil.driver', + name: 'Evil', + version: '1.0.0', + publisher: 'x', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '^0.5.0'}, + contributions: ExtensionContributions( + commands: [ + CommandContribution( + id: 'querya.sql.execute', + title: 'Hijack Execute', + ), + CommandContribution( + id: 'cluster.status', + title: 'No prefix', + ), + CommandContribution( + id: 'ext.evil.ok', + title: 'Allowed', + ), + ], + ), + ), + ]); + + expect(registry['querya.sql.execute']?.sourceExtensionId, isNull); + expect(registry['querya.sql.execute']?.title, isNot('Hijack Execute')); + expect(registry['cluster.status'], isNull); + expect(registry['ext.evil.ok']?.title, 'Allowed'); + }); + + test('sync restores a core command if it was removed', () { + registry.ensureCoreDefaults(); + registry.unregister('querya.sql.execute'); + expect(registry['querya.sql.execute'], isNull); + + sync.sync(const []); + expect(registry['querya.sql.execute'], isNotNull); + expect(registry['querya.sql.execute']?.sourceExtensionId, isNull); + }); + + testWidgets('RPC failure from invokeOverride toasts instead of hanging', + (tester) async { + final toasts = []; + sync.toastOverride = toasts.add; + sync.invokeOverride = (_, __, ___) async { + throw StateError('commands.execute is not available'); + }; + sync.sync([clickhouse()]); + + await tester.pumpWidget( + const MaterialApp(home: SizedBox.shrink()), + ); + final context = tester.element(find.byType(SizedBox)); + registry['ext.clickhouse.cluster_status']!.execute(context); + await tester.pump(); + + expect(toasts, hasLength(1)); + expect(toasts.single, contains('ClickHouse: Show Cluster Status')); + }); +} diff --git a/test/core/extensions/extension_command_target_test.dart b/test/core/extensions/extension_command_target_test.dart new file mode 100644 index 00000000..8dfe371a --- /dev/null +++ b/test/core/extensions/extension_command_target_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/extensions/extension_command_target.dart'; + +void main() { + group('isAllowedExtensionCommandId', () { + test('accepts ext. prefix only', () { + expect(isAllowedExtensionCommandId('ext.clickhouse.cluster_status'), isTrue); + expect(isAllowedExtensionCommandId('querya.sql.execute'), isFalse); + expect(isAllowedExtensionCommandId('clickhouse.cluster_status'), isFalse); + expect(isAllowedExtensionCommandId(''), isFalse); + }); + }); + + group('resolveExtensionCommandTarget', () { + String? idFor(int connectionId) => switch (connectionId) { + 1 || 2 => 'queryahub.clickhouse-driver', + 9 => 'other.driver', + _ => null, + }; + + test('none when no live session for the package', () { + final target = resolveExtensionCommandTarget( + extensionId: 'queryahub.clickhouse-driver', + liveConnectionIds: const [9], + extensionIdFor: idFor, + ); + expect(target.kind, ExtensionCommandTargetKind.none); + }); + + test('single live session is ready even without selection', () { + final target = resolveExtensionCommandTarget( + extensionId: 'queryahub.clickhouse-driver', + liveConnectionIds: const [2, 9], + extensionIdFor: idFor, + ); + expect(target.kind, ExtensionCommandTargetKind.ready); + expect(target.connectionId, 2); + }); + + test('prefers the workspace-selected connection', () { + final target = resolveExtensionCommandTarget( + extensionId: 'queryahub.clickhouse-driver', + liveConnectionIds: const [1, 2], + extensionIdFor: idFor, + preferredConnectionId: 2, + ); + expect(target.connectionId, 2); + }); + + test('ambiguous when several live and selection does not match', () { + final target = resolveExtensionCommandTarget( + extensionId: 'queryahub.clickhouse-driver', + liveConnectionIds: const [1, 2], + extensionIdFor: idFor, + preferredConnectionId: 9, + ); + expect(target.kind, ExtensionCommandTargetKind.ambiguous); + expect(target.connectionId, isNull); + }); + }); +} diff --git a/test/core/extensions/local_extension_registry_test.dart b/test/core/extensions/local_extension_registry_test.dart index 323865de..21d43ab3 100644 --- a/test/core/extensions/local_extension_registry_test.dart +++ b/test/core/extensions/local_extension_registry_test.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/actions/querya_command_registry.dart'; +import 'package:querya_desktop/core/extensions/extension_command_sync.dart'; import 'package:querya_desktop/core/extensions/extension_paths.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/extensions/models/extension_type.dart'; @@ -12,11 +14,15 @@ void main() { late Directory tempDir; setUp(() async { + ExtensionCommandSync.instance.resetForTest(); + QueryaCommandRegistry.instance.resetForTest(); tempDir = await Directory.systemTemp.createTemp('querya_extensions_test'); ExtensionPaths.mockExtensionsDirectory = tempDir; }); tearDown(() async { + ExtensionCommandSync.instance.resetForTest(); + QueryaCommandRegistry.instance.resetForTest(); ExtensionPaths.mockExtensionsDirectory = null; if (await tempDir.exists()) { await tempDir.delete(recursive: true); @@ -136,5 +142,41 @@ void main() { final manifests = await LocalExtensionRegistry.instance.load(); expect(manifests.length, 1, reason: 'Should return cached result'); }); + + test('registers contributes.commands on load and drops them on uninstall', + () async { + final extDir = Directory(p.join(tempDir.path, 'clickhouse')); + await extDir.create(); + await File(p.join(extDir.path, 'manifest.json')).writeAsString(jsonEncode({ + 'id': 'queryahub.clickhouse-driver', + 'name': 'ClickHouse Driver', + 'version': '1.0.0', + 'publisher': 'QueryaHub', + 'type': 'theme', + 'engines': {'querya_desktop': '^0.5.0'}, + 'contributes': { + 'commands': [ + { + 'id': 'ext.clickhouse.cluster_status', + 'title': 'ClickHouse: Show Cluster Status', + 'category': 'ClickHouse', + } + ], + }, + })); + + await LocalExtensionRegistry.instance.reload(); + expect( + QueryaCommandRegistry.instance['ext.clickhouse.cluster_status']?.title, + 'ClickHouse: Show Cluster Status', + ); + + await extDir.delete(recursive: true); + await LocalExtensionRegistry.instance.reload(); + expect( + QueryaCommandRegistry.instance['ext.clickhouse.cluster_status'], + isNull, + ); + }); }); } diff --git a/test/core/extensions/models/extension_manifest_test.dart b/test/core/extensions/models/extension_manifest_test.dart index 88f9dfe2..a25ed66b 100644 --- a/test/core/extensions/models/extension_manifest_test.dart +++ b/test/core/extensions/models/extension_manifest_test.dart @@ -91,6 +91,7 @@ void main() { expect(manifest.capabilities?.databaseDriver, isTrue); expect(manifest.capabilities?.sduiForms, isTrue); + expect(manifest.contributedCommands, isEmpty); expect(manifest.contributedDrivers, hasLength(1)); final driver = manifest.contributedDrivers.first; expect(driver.driverId, 'clickhouse'); @@ -100,6 +101,55 @@ void main() { expect(manifest.sandbox?.engine, SandboxEngine.process); }); + test('parses contributes.commands and contributions.commands', () { + final json = { + 'id': 'queryahub.clickhouse-driver', + 'name': 'ClickHouse Database Driver', + 'version': '1.0.0', + 'publisher': 'QueryaHub', + 'type': 'database_driver', + 'engines': {'querya_desktop': '^0.5.0'}, + 'contributions': { + 'drivers': [ + { + 'driverId': 'clickhouse', + 'displayName': 'ClickHouse', + } + ], + 'commands': [ + { + 'id': 'ext.clickhouse.cluster_status', + 'title': 'ClickHouse: Show Cluster Status', + 'category': 'ClickHouse', + } + ], + }, + 'contributes': { + 'commands': [ + { + 'id': 'ext.clickhouse.export_parquet', + 'title': 'ClickHouse: Export Parquet', + 'aliases': ['parquet'], + } + ], + }, + }; + + final manifest = ExtensionManifest.fromJson(json); + expect(manifest.contributedCommands.map((c) => c.id), [ + 'ext.clickhouse.cluster_status', + 'ext.clickhouse.export_parquet', + ]); + expect(manifest.contributedCommands.first.category, 'ClickHouse'); + expect(manifest.contributedCommands.last.aliases, ['parquet']); + + final again = ExtensionManifest.fromJson(manifest.toJson()); + expect( + again.contributedCommands.map((c) => c.id), + contains('ext.clickhouse.cluster_status'), + ); + }); + test('toJson round-trips contributions and capabilities', () { final original = { 'id': 'queryahub.clickhouse-driver', diff --git a/test/core/motion/querya_animated_expand_test.dart b/test/core/motion/querya_animated_expand_test.dart index c39090f3..9f6b8e7b 100644 --- a/test/core/motion/querya_animated_expand_test.dart +++ b/test/core/motion/querya_animated_expand_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/motion/querya_animated_expand.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; void main() { testWidgets('QueryaAnimatedExpand hides child when collapsed', @@ -25,10 +26,14 @@ void main() { expect(find.text('child'), findsOneWidget); }); - testWidgets('uses treeExpand duration/curve tokens', (tester) async { + testWidgets('uses treeExpand duration/curve tokens on Full motion', + (tester) async { await tester.pumpWidget( const MaterialApp( - home: _ExpandHost(expanded: true), + home: QueryaMotionScope( + level: QueryaMotionLevel.full, + child: _ExpandHost(expanded: true), + ), ), ); @@ -36,17 +41,69 @@ void main() { expect(size.duration, QueryaMotion.treeExpand); expect(size.curve, QueryaMotion.treeExpandCurve); }); + + testWidgets('Motion Off skips AnimatedSize entirely', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: QueryaMotionScope( + level: QueryaMotionLevel.off, + child: _ExpandHost(expanded: true), + ), + ), + ); + + expect(find.byType(AnimatedSize), findsNothing); + expect(find.text('child'), findsOneWidget); + }); + + testWidgets('large estimatedChildCount skips AnimatedSize on Full motion', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: QueryaMotionScope( + level: QueryaMotionLevel.full, + child: _ExpandHost( + expanded: true, + estimatedChildCount: + QueryaAnimatedExpand.defaultSkipSizeAnimationAbove + 1, + ), + ), + ), + ); + + expect(find.byType(AnimatedSize), findsNothing); + expect(find.text('child'), findsOneWidget); + }); + + testWidgets('small estimatedChildCount still uses AnimatedSize', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: QueryaMotionScope( + level: QueryaMotionLevel.full, + child: _ExpandHost(expanded: true, estimatedChildCount: 8), + ), + ), + ); + + expect(find.byType(AnimatedSize), findsOneWidget); + }); } class _ExpandHost extends StatelessWidget { - const _ExpandHost({required this.expanded}); + const _ExpandHost({ + required this.expanded, + this.estimatedChildCount, + }); final bool expanded; + final int? estimatedChildCount; @override Widget build(BuildContext context) { return QueryaAnimatedExpand( expanded: expanded, + estimatedChildCount: estimatedChildCount, child: const Text('child'), ); } diff --git a/test/core/motion/querya_motion_test.dart b/test/core/motion/querya_motion_test.dart index aa26700f..26f08325 100644 --- a/test/core/motion/querya_motion_test.dart +++ b/test/core/motion/querya_motion_test.dart @@ -12,6 +12,7 @@ void main() { expect(QueryaMotion.standard, const Duration(milliseconds: 200)); expect(QueryaMotion.slow, const Duration(milliseconds: 320)); expect(QueryaMotion.treeExpand, QueryaMotion.standard); + expect(QueryaMotion.sidebarCubic, const Duration(milliseconds: 160)); }); test('curve constants are set', () { diff --git a/test/core/sdui/sdui_builders_test.dart b/test/core/sdui/sdui_builders_test.dart index 3106c0b6..35d1301c 100644 --- a/test/core/sdui/sdui_builders_test.dart +++ b/test/core/sdui/sdui_builders_test.dart @@ -448,5 +448,75 @@ void main() { expect(eventsDec?.border, isNull); expect(mvDec?.border, isNotNull); }); + + testWidgets('expand error shows Retry and reloads children', (tester) async { + final schema = SduiTreeSchema.fromJson(const { + 'roots': [ + { + 'id': 'databases', + 'label': 'Databases', + 'expandable': true, + }, + ], + }); + + var fetches = 0; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: SduiTreeBuilder( + schema: schema, + fetchChildren: (id) async { + fetches++; + if (fetches == 1) { + throw Exception('boom'); + } + return const [ + SduiTreeNode(id: 'db1', label: 'analytics'), + ]; + }, + ), + ), + ), + ); + + await tester.tap(find.byIcon(QueryaIcons.expandClosed)); + await tester.pumpAndSettle(); + + expect(find.text('Could not expand'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + expect(find.text('analytics'), findsNothing); + + await tester.tap(find.text('Retry')); + await tester.pumpAndSettle(); + + expect(fetches, 2); + expect(find.text('analytics'), findsOneWidget); + expect(find.text('Could not expand'), findsNothing); + }); + + testWidgets('browsable labels are not bold unless selected', (tester) async { + final schema = SduiTreeSchema.fromJson(const { + 'roots': [ + { + 'id': 'table.db.users', + 'label': 'users', + 'node_type': 'table', + }, + ], + }); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: SduiTreeBuilder(schema: schema), + ), + ), + ); + await tester.pumpAndSettle(); + + final text = tester.widget(find.text('users')); + expect(text.style?.fontWeight, isNot(material.FontWeight.w600)); + }); }); } diff --git a/test/core/ui/querya_tree_indent_guide_test.dart b/test/core/ui/querya_tree_indent_guide_test.dart new file mode 100644 index 00000000..38a54e73 --- /dev/null +++ b/test/core/ui/querya_tree_indent_guide_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/ui/querya_tree_indent_guide.dart'; +import 'package:querya_desktop/core/ui/querya_tree_tokens.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + test('QueryaTreeTokens expose UI-05 guide + header metrics', () { + expect(QueryaTreeTokens.serversHeaderTop, 12); + expect(QueryaTreeTokens.guideWidth, 1); + expect(QueryaTreeTokens.guideInset, 8); + expect(QueryaTreeTokens.guideAlpha, 0.15); + }); + + testWidgets('QueryaTreeIndentGuide pads child and paints at depth', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const QueryaTreeIndentGuide( + depth: 2, + child: SizedBox(width: 40, height: 28, child: Text('leaf')), + ), + ), + ); + + expect(find.byType(QueryaTreeIndentGuide), findsOneWidget); + expect(find.byType(CustomPaint), findsWidgets); + expect(find.text('leaf'), findsOneWidget); + + final padding = tester.widget( + find.descendant( + of: find.byType(QueryaTreeIndentGuide), + matching: find.byType(Padding), + ).first, + ); + expect(padding.padding.resolve(TextDirection.ltr).left, 32); + }); + + testWidgets('QueryaTreeIndentGuide depth 0 does not paint', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const QueryaTreeIndentGuide( + depth: 0, + child: Text('root'), + ), + ), + ); + + expect(find.text('root'), findsOneWidget); + expect( + find.descendant( + of: find.byType(QueryaTreeIndentGuide), + matching: find.byType(CustomPaint), + ), + findsNothing, + ); + }); +} diff --git a/test/core/unsaved_work_registry_test.dart b/test/core/unsaved_work_registry_test.dart new file mode 100644 index 00000000..7694728a --- /dev/null +++ b/test/core/unsaved_work_registry_test.dart @@ -0,0 +1,18 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/unsaved_work_registry.dart'; +import 'package:querya_desktop/features/workspace/sql_query_tab_session.dart'; + +void main() { + tearDown(UnsavedWorkRegistry.instance.resetForTest); + + test('SqlQueryTabSession registers modified SQL as unsaved', () { + final session = SqlQueryTabSession(id: '1', title: 'Q'); + expect(UnsavedWorkRegistry.instance.hasUnsaved, isFalse); + + session.isModified = true; + expect(UnsavedWorkRegistry.instance.hasUnsaved, isTrue); + + session.dispose(); + expect(UnsavedWorkRegistry.instance.hasUnsaved, isFalse); + }); +} diff --git a/test/core/updater/app_updater_service_test.dart b/test/core/updater/app_updater_service_test.dart index 83c7ab6f..9acccb6c 100644 --- a/test/core/updater/app_updater_service_test.dart +++ b/test/core/updater/app_updater_service_test.dart @@ -249,10 +249,17 @@ void main() { checksumsUrl: 'https://example.com/SHA256SUMS.txt', ); - expect( + await expectLater( () => service.downloadAsset(asset, manifest: manifest), - throwsA(isA()), + throwsA( + isA().having( + (e) => e.cause, + 'cause', + isA(), + ), + ), ); + expect(File('${tempDir.path}/$fileName').existsSync(), isFalse); service.dispose(); }); }); diff --git a/test/core/updater/update_platform_installer_test.dart b/test/core/updater/update_platform_installer_test.dart index da200755..d0773772 100644 --- a/test/core/updater/update_platform_installer_test.dart +++ b/test/core/updater/update_platform_installer_test.dart @@ -33,6 +33,18 @@ void main() { ); expect(flatpak.isFlatpak, isTrue); expect(flatpak.isManagedPackage, isTrue); + expect(flatpak.packageManagerUpdateCommand, 'flatpak update'); + expect(snap.packageManagerUpdateCommand, 'snap refresh'); + }); + + test('does not treat generic container env as Flatpak', () { + const docker = UpdateInstallContext( + environment: {'container': 'oci'}, + resolvedExecutable: '/app/querya_desktop', + ); + expect(docker.isFlatpak, isFalse); + expect(docker.isManagedPackage, isFalse); + expect(docker.packageManagerUpdateCommand, isNull); }); test('finds macOS .app bundle from executable path', () { @@ -55,9 +67,45 @@ void main() { ); expect(script, contains('PID="4242"')); expect(script, contains("EXE='/opt/querya/querya_desktop'")); + expect(script, contains('refusing replace')); expect(script, contains('exec "\$EXE"')); }); + test('resolves nested Flutter zip layout and refuses garbage', () async { + final temp = await Directory.systemTemp.createTemp('querya_bundle_'); + addTearDown(() async { + if (await temp.exists()) { + await temp.delete(recursive: true); + } + }); + + final nested = Directory(p.join(temp.path, 'Querya-Desktop-0.5.0-linux')); + await nested.create(); + await File(p.join(nested.path, 'querya_desktop')).writeAsString('exe'); + await Directory(p.join(nested.path, 'lib')).create(); + + final resolved = resolveLinuxFlutterBundleRoot( + extractDir: temp, + executableName: 'querya_desktop', + ); + expect(resolved.path, nested.path); + + final junk = await Directory.systemTemp.createTemp('querya_junk_'); + addTearDown(() async { + if (await junk.exists()) { + await junk.delete(recursive: true); + } + }); + await File(p.join(junk.path, 'readme.txt')).writeAsString('no bundle'); + expect( + () => resolveLinuxFlutterBundleRoot( + extractDir: junk, + executableName: 'querya_desktop', + ), + throwsA(isA()), + ); + }); + test('windows batch script waits for pid', () { final batch = buildWindowsReplaceBatch( pid: 99, diff --git a/test/core/updater/update_version_test.dart b/test/core/updater/update_version_test.dart index 744a6761..c75da422 100644 --- a/test/core/updater/update_version_test.dart +++ b/test/core/updater/update_version_test.dart @@ -29,6 +29,13 @@ void main() { expect(b.compareTo(a), greaterThan(0)); }); + test('orders pre-release identifiers numerically (beta.10 > beta.2)', () { + final older = UpdateVersion.tryParse('1.0.0-beta.2')!; + final newer = UpdateVersion.tryParse('1.0.0-beta.10')!; + expect(older.compareTo(newer), lessThan(0)); + expect(newer.compareTo(older), greaterThan(0)); + }); + test('stable release is newer than same core pre-release', () { final stable = UpdateVersion.tryParse('1.0.0')!; final beta = UpdateVersion.tryParse('1.0.0-beta.1')!; diff --git a/test/features/command_palette/command_palette_dialog_test.dart b/test/features/command_palette/command_palette_dialog_test.dart new file mode 100644 index 00000000..49c88803 --- /dev/null +++ b/test/features/command_palette/command_palette_dialog_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/actions/querya_command.dart'; +import 'package:querya_desktop/core/actions/querya_command_registry.dart'; +import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; +import 'package:querya_desktop/features/command_palette/command_palette_dialog.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + final registry = QueryaCommandRegistry.instance; + + setUp(registry.resetForTest); + tearDown(registry.resetForTest); + + Future pumpPalette(WidgetTester tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaMotionScope( + level: QueryaMotionLevel.full, + child: Builder( + builder: (context) { + return Center( + child: Button.primary( + onPressed: () => showCommandPalette(context), + child: const Text('Open palette'), + ), + ); + }, + ), + ), + ), + ); + await tester.tap(find.text('Open palette')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + } + + testWidgets('opens core commands and filters the list', (tester) async { + registry.ensureCoreDefaults(); + await pumpPalette(tester); + + expect(find.text('Type a command…'), findsOneWidget); + expect(find.text('Toggle Dark/Light Theme'), findsOneWidget); + + await tester.enterText(find.byType(TextField), 'dark'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 20)); + + expect(find.byKey(const ValueKey('querya.theme.toggle')), findsOneWidget); + expect(find.byKey(const ValueKey('querya.connection.new')), findsNothing); + }); + + testWidgets('Escape closes the palette', (tester) async { + registry.ensureCoreDefaults(); + await pumpPalette(tester); + expect(find.text('Type a command…'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(find.text('Type a command…'), findsNothing); + }); + + testWidgets('selecting a command executes and closes', (tester) async { + var ran = 0; + registry.register( + QueryaCommand( + id: 'test.palette.run', + title: 'Palette Test Command', + category: 'Test', + execute: (_) => ran++, + ), + ); + + await pumpPalette(tester); + await tester.tap(find.text('Palette Test Command')); + await tester.pumpAndSettle(); + + expect(ran, 1); + expect(find.text('Palette Test Command'), findsNothing); + }); + + testWidgets('Motion Off uses zero dialog enter duration', (tester) async { + late BuildContext ctx; + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaMotionScope( + level: QueryaMotionLevel.off, + child: Builder( + builder: (context) { + ctx = context; + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + expect( + QueryaMotion.effectiveDuration(ctx, QueryaMotion.standard), + Duration.zero, + ); + }); +} diff --git a/test/features/command_palette/quick_switcher_dialog_test.dart b/test/features/command_palette/quick_switcher_dialog_test.dart new file mode 100644 index 00000000..545266cb --- /dev/null +++ b/test/features/command_palette/quick_switcher_dialog_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/actions/querya_command_host.dart'; +import 'package:querya_desktop/core/actions/querya_schema_object.dart'; +import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; +import 'package:querya_desktop/features/command_palette/quick_switcher_dialog.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + final seed = [ + QueryaSchemaObject.postgres( + database: 'app', + schema: 'public', + name: 'users', + kind: QueryaSchemaObjectKind.table, + ), + QueryaSchemaObject.postgres( + database: 'app', + schema: 'public', + name: 'accounts', + kind: QueryaSchemaObjectKind.view, + ), + ]; + + Future pumpSwitcher( + WidgetTester tester, { + required void Function(QueryaSchemaObject) onOpen, + String initialQuery = '', + }) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaMotionScope( + level: QueryaMotionLevel.full, + child: QueryaCommandHost( + onOpenSchemaObject: onOpen, + child: Builder( + builder: (context) { + return Center( + child: Button.primary( + onPressed: () => showQuickSwitcher( + context, + initialQuery: initialQuery, + seed: seed, + ), + child: const Text('Open switcher'), + ), + ); + }, + ), + ), + ), + ), + ); + await tester.tap(find.text('Open switcher')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + } + + testWidgets('filters users and Enter opens public.users', (tester) async { + QueryaSchemaObject? opened; + await pumpSwitcher(tester, onOpen: (o) => opened = o); + + expect(find.text('Go to table, view, collection…'), findsOneWidget); + expect(find.byKey(const ValueKey('pg:app:public:table:users')), findsOneWidget); + + await tester.enterText(find.byType(TextField), 'users'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 20)); + + expect(find.byKey(const ValueKey('pg:app:public:table:users')), findsOneWidget); + expect( + find.byKey(const ValueKey('pg:app:public:view:accounts')), + findsNothing, + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(opened?.qualifiedName, 'public.users'); + expect(find.text('Go to table, view, collection…'), findsNothing); + }); + + testWidgets('Escape closes without opening', (tester) async { + var opened = 0; + await pumpSwitcher(tester, onOpen: (_) => opened++); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(opened, 0); + expect(find.text('Go to table, view, collection…'), findsNothing); + }); + + testWidgets('empty seed shows no-objects hint', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaMotionScope( + level: QueryaMotionLevel.full, + child: Builder( + builder: (context) { + return Center( + child: Button.primary( + onPressed: () => showQuickSwitcher( + context, + seed: const [], + ), + child: const Text('Open switcher'), + ), + ); + }, + ), + ), + ), + ); + await tester.tap(find.text('Open switcher')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect(find.text('No objects in the active connection'), findsOneWidget); + }); +} diff --git a/test/features/connections/connection_databases_folder_test.dart b/test/features/connections/connection_databases_folder_test.dart new file mode 100644 index 00000000..6d5f55f7 --- /dev/null +++ b/test/features/connections/connection_databases_folder_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_databases_folder.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + final conn = ConnectionRow( + type: 'postgresql', + name: 'pg', + host: '127.0.0.1', + port: 5432, + createdAt: DateTime.utc(2025).toIso8601String(), + id: 1, + ); + + testWidgets('ConnectionDatabasesFolder collapses and expands children', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Material( + child: ConnectionDatabasesFolder( + connection: conn, + databaseCount: 2, + child: const material.Column( + children: [ + material.Text('db_alpha'), + material.Text('db_beta'), + ], + ), + ), + ), + ), + ); + + expect(find.text('Databases (2)'), findsOneWidget); + expect(find.text('db_alpha'), findsOneWidget); + expect(find.text('db_beta'), findsOneWidget); + + await tester.tap(find.text('Databases (2)')); + await tester.pumpAndSettle(); + + expect(find.text('db_alpha'), findsNothing); + expect(find.text('db_beta'), findsNothing); + + await tester.tap(find.text('Databases (2)')); + await tester.pumpAndSettle(); + + expect(find.text('db_alpha'), findsOneWidget); + expect(find.text('db_beta'), findsOneWidget); + }); + + testWidgets( + 'ConnectionDatabasesFolder keeps the same chrome for redis-style use', + (tester) async { + final redis = ConnectionRow( + type: 'redis', + name: 'redis', + host: '127.0.0.1', + port: 6379, + createdAt: DateTime.utc(2025).toIso8601String(), + id: 2, + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Material( + child: ConnectionDatabasesFolder( + connection: redis, + databaseCount: 16, + child: const Text('db0'), + ), + ), + ), + ); + + expect(find.text('Databases (16)'), findsOneWidget); + expect(find.text('db0'), findsOneWidget); + expect(find.byType(QueryaAnimatedExpand), findsOneWidget); + }); +} diff --git a/test/features/connections/connections_panel_layout_test.dart b/test/features/connections/connections_panel_layout_test.dart index ab44b038..fda22e1c 100644 --- a/test/features/connections/connections_panel_layout_test.dart +++ b/test/features/connections/connections_panel_layout_test.dart @@ -6,6 +6,7 @@ import 'package:path_provider_platform_interface/path_provider_platform_interfac import 'package:querya_desktop/core/storage/folders_storage.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:querya_desktop/core/ui/querya_tree_tokens.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -390,5 +391,35 @@ void main() { expect(find.byType(ConnectionsPanel), findsOneWidget); }); + + testWidgets('SERVERS header uses compact top padding (UI-05)', + (tester) async { + await pumpWidgetWithSurfaceSize( + tester, + const material.Size(400, 600), + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: const material.SizedBox( + width: 400, + height: 600, + child: ConnectionsPanel( + skipInitialDbLoadForTest: true, + ), + ), + ), + ); + await tester.pump(); + + final padding = tester.widget( + find.byKey(const material.ValueKey('connections-servers-header')), + ); + expect( + padding.padding.resolve(material.TextDirection.ltr).top, + QueryaTreeTokens.serversHeaderTop, + ); + expect(find.text('SERVERS'), findsOneWidget); + }); }); } diff --git a/test/features/connections/lazy_connection_tree_list_test.dart b/test/features/connections/lazy_connection_tree_list_test.dart index cb9b2564..c101f818 100644 --- a/test/features/connections/lazy_connection_tree_list_test.dart +++ b/test/features/connections/lazy_connection_tree_list_test.dart @@ -3,8 +3,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart'; void main() { - testWidgets('lazyConnectionTreeList uses ListView for large lists', + testWidgets( + 'lazyConnectionTreeList virtualizes fixed-extent leaves without shrinkWrap', (tester) async { + var builds = 0; await tester.pumpWidget( material.MaterialApp( home: material.Scaffold( @@ -13,9 +15,40 @@ void main() { context: context, itemCount: 50, itemExtent: kConnectionTreeRowExtent, - itemBuilder: (context, index) => material.SizedBox( - height: kConnectionTreeRowExtent, - child: material.Text('item $index'), + itemBuilder: (context, index) { + builds++; + return material.SizedBox( + height: kConnectionTreeRowExtent, + child: material.Text('item $index'), + ); + }, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final list = tester.widget( + find.byType(material.ListView), + ); + expect(list.shrinkWrap, isFalse); + expect(builds, lessThan(50)); + expect(builds, lessThanOrEqualTo(kConnectionTreeMaxVisibleRows + 8)); + }); + + testWidgets( + 'lazyConnectionTreeList uses Column for expandable children (no itemExtent)', + (tester) async { + await tester.pumpWidget( + material.MaterialApp( + home: material.Scaffold( + body: material.SingleChildScrollView( + child: material.Builder( + builder: (context) => lazyConnectionTreeList( + context: context, + itemCount: 50, + itemBuilder: (context, index) => material.Text('item $index'), ), ), ), @@ -24,10 +57,12 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.byType(material.ListView), findsOneWidget); + expect(find.byType(material.ListView), findsNothing); + expect(find.text('item 0'), findsOneWidget); + expect(find.text('item 49'), findsOneWidget); }); - testWidgets('lazyConnectionTreeList uses Column for small lists', + testWidgets('lazyConnectionTreeList uses Column for small leaf lists', (tester) async { await tester.pumpWidget( material.MaterialApp( @@ -36,6 +71,7 @@ void main() { builder: (context) => lazyConnectionTreeList( context: context, itemCount: 5, + itemExtent: kConnectionTreeRowExtent, itemBuilder: (context, index) => material.Text('item $index'), ), ), diff --git a/test/features/extensions/extension_workspace_home_test.dart b/test/features/extensions/extension_workspace_home_test.dart new file mode 100644 index 00000000..9891c82a --- /dev/null +++ b/test/features/extensions/extension_workspace_home_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/extensions/extension_workspace_home.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + final extConn = ConnectionRow( + id: 10, + name: 'ClickHouse Analytics', + type: 'clickhouse', + host: 'clickhouse.internal', + port: 8123, + extensionId: 'clickhouse-driver', + createdAt: DateTime.now().toIso8601String(), + ); + + testWidgets('ExtensionWorkspaceHome renders QueryaTabStrip with Overview and SQL', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 800, + height: 600, + child: ExtensionWorkspaceHome( + connectionRow: extConn, + ), + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('ClickHouse Analytics'), findsOneWidget); + expect(find.text('Overview'), findsOneWidget); + expect(find.text('SQL'), findsWidgets); + }); + + testWidgets('ExtensionWorkspaceHome renders Return to button when last selected object is set', + (tester) async { + var restored = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 800, + height: 600, + child: ExtensionWorkspaceHome( + connectionRow: extConn, + lastSelectedExtensionObject: (database: 'default', name: 'events_v1'), + onRestoreLastSelectedObject: () => restored = true, + ), + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('Return to events_v1'), findsOneWidget); + + await tester.tap(find.text('Return to events_v1')); + expect(restored, isTrue); + }); +} diff --git a/test/features/macos/querya_platform_menu_bar_test.dart b/test/features/macos/querya_platform_menu_bar_test.dart index 6212d221..5d881906 100644 --- a/test/features/macos/querya_platform_menu_bar_test.dart +++ b/test/features/macos/querya_platform_menu_bar_test.dart @@ -135,7 +135,11 @@ void main() { expect(tourInvoked, isTrue); final prefGroup = appMenu.menus.whereType().first; - final prefItem = prefGroup.members.whereType().first; + final appItems = prefGroup.members.whereType().toList(); + expect(appItems.any((m) => m.label == 'Command Palette...'), isTrue); + expect(appItems.any((m) => m.label == 'Go to Object...'), isTrue); + final prefItem = + appItems.firstWhere((m) => m.label == 'Preferences...'); prefItem.onSelected?.call(); expect(preferencesInvoked, isTrue); }); diff --git a/test/features/main_screen/main_screen_workspace_state_test.dart b/test/features/main_screen/main_screen_workspace_state_test.dart index f780b58d..779dbdb8 100644 --- a/test/features/main_screen/main_screen_workspace_state_test.dart +++ b/test/features/main_screen/main_screen_workspace_state_test.dart @@ -39,6 +39,22 @@ void main() { createdAt: createdAt, id: 13, ); + final mongoConn = ConnectionRow( + type: 'mongodb', + name: 'mongo', + host: '127.0.0.1', + port: 27017, + createdAt: createdAt, + id: 14, + ); + final redisConn = ConnectionRow( + type: 'redis', + name: 'redis', + host: '127.0.0.1', + port: 6379, + createdAt: createdAt, + id: 15, + ); group('MainScreenWorkspaceState', () { test('empty has no selection', () { @@ -153,12 +169,16 @@ void main() { }); test('selectRedisDb and selectMongoDb are mutually exclusive fields', () { - final redis = MainScreenWorkspaceState.empty.selectRedisDb(pgConn, 3); + final redis = MainScreenWorkspaceState.empty.selectRedisDb(redisConn, 3); expect(redis.activeRedisDb, 3); expect(redis.activeMongoDB, isNull); - final mongo = redis.selectMongoDb(pgConn, 'inventory'); + expect(redis.lastSelectedRedisDb, 3); + expect(redis.lastSelectedMongoDb, isNull); + final mongo = redis.selectMongoDb(mongoConn, 'inventory'); expect(mongo.activeMongoDB, 'inventory'); expect(mongo.activeRedisDb, isNull); + expect(mongo.lastSelectedMongoDb, 'inventory'); + expect(mongo.lastSelectedRedisDb, isNull); }); test('equality uses connection id and selections', () { @@ -260,6 +280,24 @@ void main() { expect(extState.lastSelectedExtensionObject?.name, 'hits'); final restoredExt = extState.restoreLastSelectedObject(); expect(restoredExt.selectedExtensionObject?.name, 'hits'); + + // 4. MongoDB + final mongoState = MainScreenWorkspaceState.empty + .selectMongoDb(mongoConn, 'analytics') + .unselectActiveObject(); + expect(mongoState.activeMongoDB, isNull); + expect(mongoState.lastSelectedMongoDb, 'analytics'); + final restoredMongo = mongoState.restoreLastSelectedObject(); + expect(restoredMongo.activeMongoDB, 'analytics'); + + // 5. Redis + final redisState = MainScreenWorkspaceState.empty + .selectRedisDb(redisConn, 7) + .selectConnection(redisConn); + expect(redisState.activeRedisDb, isNull); + expect(redisState.lastSelectedRedisDb, 7); + final restoredRedis = redisState.restoreLastSelectedObject(); + expect(restoredRedis.activeRedisDb, 7); }); test('restoreLastSelectedObject ignores cached references from other drivers', () { @@ -297,6 +335,8 @@ void main() { expect(statePg.lastSelectedMysqlObject, isNull); expect(statePg.lastSelectedSqliteObject, isNull); expect(statePg.lastSelectedExtensionObject, isNull); + expect(statePg.lastSelectedMongoDb, isNull); + expect(statePg.lastSelectedRedisDb, isNull); final stateMy = statePg.selectMysqlObject( mysqlConn, @@ -308,6 +348,8 @@ void main() { expect(stateMy.lastSelectedMysqlObject?.name, 't2'); expect(stateMy.lastSelectedSqliteObject, isNull); expect(stateMy.lastSelectedExtensionObject, isNull); + expect(stateMy.lastSelectedMongoDb, isNull); + expect(stateMy.lastSelectedRedisDb, isNull); final stateSq = stateMy.selectSqliteObject( sqliteConn, @@ -318,6 +360,8 @@ void main() { expect(stateSq.lastSelectedMysqlObject, isNull); expect(stateSq.lastSelectedSqliteObject?.name, 't3'); expect(stateSq.lastSelectedExtensionObject, isNull); + expect(stateSq.lastSelectedMongoDb, isNull); + expect(stateSq.lastSelectedRedisDb, isNull); final stateExt = stateSq.selectExtensionObject( clickhouseConn, @@ -328,6 +372,17 @@ void main() { expect(stateExt.lastSelectedMysqlObject, isNull); expect(stateExt.lastSelectedSqliteObject, isNull); expect(stateExt.lastSelectedExtensionObject?.name, 't4'); + expect(stateExt.lastSelectedMongoDb, isNull); + expect(stateExt.lastSelectedRedisDb, isNull); + + final stateMongo = stateExt.selectMongoDb(mongoConn, 'app'); + expect(stateMongo.lastSelectedExtensionObject, isNull); + expect(stateMongo.lastSelectedMongoDb, 'app'); + expect(stateMongo.lastSelectedRedisDb, isNull); + + final stateRedis = stateMongo.selectRedisDb(redisConn, 2); + expect(stateRedis.lastSelectedMongoDb, isNull); + expect(stateRedis.lastSelectedRedisDb, 2); }); }); } diff --git a/test/features/main_screen/querya_status_bar_test.dart b/test/features/main_screen/querya_status_bar_test.dart new file mode 100644 index 00000000..d7fcbdf8 --- /dev/null +++ b/test/features/main_screen/querya_status_bar_test.dart @@ -0,0 +1,110 @@ +import 'dart:io' show Platform; + +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/ui/querya_shell_status.dart'; +import 'package:querya_desktop/features/main_screen/querya_status_bar.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + tearDown(QueryaShellStatus.instance.resetForTest); + + testWidgets('QueryaStatusBar renders empty status when no connection', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: QueryaStatusBar(), + ), + ), + ); + await tester.pump(); + + expect(find.text('No connection'), findsOneWidget); + expect(find.text('UTF-8'), findsNothing); + }); + + testWidgets('QueryaStatusBar renders active connection and read-only mode', + (tester) async { + final conn = ConnectionRow( + id: 1, + name: 'Prod Postgres', + type: 'postgresql', + host: '10.0.0.1', + port: 5432, + createdAt: DateTime.now().toIso8601String(), + ); + + var toggled = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: QueryaStatusBar( + activeConnection: conn, + isReadOnly: true, + rowCount: 250, + columnCount: 12, + lastQueryDuration: const Duration(milliseconds: 42), + onToggleSidebar: () => toggled = true, + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('Prod Postgres'), findsOneWidget); + expect(find.text('(10.0.0.1:5432)'), findsOneWidget); + expect(find.text('Read-only'), findsOneWidget); + expect(find.text('250 rows, 12 cols'), findsOneWidget); + expect(find.text('42 ms'), findsOneWidget); + + await tester.tap(find.byKey(const material.Key('status_bar_toggle_sidebar'))); + expect(toggled, isTrue); + }); + + test('QueryaShellStatus reports busy and query metrics', () { + final status = QueryaShellStatus.instance; + var ticks = 0; + status.addListener(() => ticks++); + + status.beginBusy(message: 'Running query…'); + expect(status.isBusy, isTrue); + expect(status.statusMessage, 'Running query…'); + + status.reportQueryResult( + duration: const Duration(milliseconds: 17), + rowCount: 3, + columnCount: 2, + message: '3 row(s).', + ); + expect(status.isBusy, isFalse); + expect(status.lastQueryDuration, const Duration(milliseconds: 17)); + expect(status.rowCount, 3); + expect(status.columnCount, 2); + expect(ticks, greaterThanOrEqualTo(2)); + + status.clear(); + expect(status.rowCount, isNull); + }); + + testWidgets('sidebar tooltip mentions platform modifier', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: QueryaStatusBar( + onToggleSidebar: () {}, + ), + ), + ), + ); + await tester.pump(); + + final expected = Platform.isMacOS + ? 'Toggle Sidebar (Cmd+B)' + : 'Toggle Sidebar (Ctrl+B)'; + expect(find.byTooltip(expected), findsOneWidget); + }); +} diff --git a/test/features/main_screen/querya_window_title_bar_test.dart b/test/features/main_screen/querya_window_title_bar_test.dart index 74b50f22..3010e8af 100644 --- a/test/features/main_screen/querya_window_title_bar_test.dart +++ b/test/features/main_screen/querya_window_title_bar_test.dart @@ -1,6 +1,7 @@ import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/core/theme/querya_workbench_theme.dart'; import 'package:querya_desktop/features/main_screen/querya_window_title_bar.dart'; @@ -134,7 +135,7 @@ void main() { ); }); - testWidgets('read-only state is persistently visible in title bar', + testWidgets('QueryaReadOnlyBadge widget still renders for goldens', (tester) async { await tester.pumpWidget( queryaThemeTestShell( @@ -152,6 +153,31 @@ void main() { expect(find.text('Read-only'), findsWidgets); }); + testWidgets('title bar does not show Read-only badge (status bar owns it)', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.SizedBox( + width: 1000, + child: QueryaWindowTitleBar( + onNewDatabaseConnection: () async {}, + onNewDatabaseConnectionFromUrl: () async {}, + activeConnection: ConnectionRow( + id: 1, + name: 'DB', + type: 'postgresql', + createdAt: DateTime.now().toIso8601String(), + ), + isReadOnly: true, + ), + ), + ), + ); + await tester.pump(); + + expect(find.byKey(const Key('title_bar_read_only_badge')), findsNothing); + }); + testWidgets('toggle sidebar button calls onToggleSidebar when pressed', (tester) async { var toggled = false; diff --git a/test/features/main_screen/workspace_empty_hero_test.dart b/test/features/main_screen/workspace_empty_hero_test.dart index af169d75..1aeb73ab 100644 --- a/test/features/main_screen/workspace_empty_hero_test.dart +++ b/test/features/main_screen/workspace_empty_hero_test.dart @@ -237,6 +237,29 @@ void main() { expect(find.text('Recent connections'), findsOneWidget); }); + testWidgets('Motion Off makes quick-start hover instant', (tester) async { + await tester.pumpWidget( + heroShell( + level: QueryaMotionLevel.off, + child: const material.SizedBox( + width: 900, + height: 700, + child: WorkspaceEmptyHero( + onNewConnection: _noop, + recentConnections: [], + ), + ), + ), + ); + await tester.pump(); + + final hover = tester.widgetList( + find.byKey(const material.ValueKey('empty_hero_hover_row')), + ); + expect(hover, isNotEmpty); + expect(hover.every((w) => w.duration == Duration.zero), isTrue); + }); + testWidgets('recent list staggers on first paint then reaches full opacity', (tester) async { const second = ConnectionRow( diff --git a/test/features/main_screen/workspace_homes_and_preferences_test.dart b/test/features/main_screen/workspace_homes_and_preferences_test.dart index dce57565..ef08625b 100644 --- a/test/features/main_screen/workspace_homes_and_preferences_test.dart +++ b/test/features/main_screen/workspace_homes_and_preferences_test.dart @@ -77,7 +77,7 @@ void main() { await tester.pump(); expect(find.text('MySQL'), findsOneWidget); - expect(find.text('Server'), findsOneWidget); + expect(find.text('Overview'), findsOneWidget); expect(find.text('SQL'), findsOneWidget); await tester.tap(find.text('SQL')); diff --git a/test/features/mongodb/mongo_field_codec_test.dart b/test/features/mongodb/mongo_field_codec_test.dart new file mode 100644 index 00000000..fed3b167 --- /dev/null +++ b/test/features/mongodb/mongo_field_codec_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/mongodb/mongo_field_codec.dart'; + +void main() { + group('mongoFieldToDisplay', () { + test('encodes null, scalars, and JSON structures', () { + expect(mongoFieldToDisplay(null), 'NULL'); + expect(mongoFieldToDisplay(true), 'true'); + expect(mongoFieldToDisplay(42), '42'); + expect(mongoFieldToDisplay('Ada'), 'Ada'); + expect( + mongoFieldToDisplay({'ok': true}), + '{\n "ok": true\n}', + ); + expect(mongoFieldToDisplay([1, 2]), '[\n 1,\n 2\n]'); + }); + }); + + group('mongoDisplayToValue', () { + test('round-trips inspector strings', () { + expect(mongoDisplayToValue('NULL'), isNull); + expect(mongoDisplayToValue('true'), isTrue); + expect(mongoDisplayToValue('false'), isFalse); + expect(mongoDisplayToValue('3.5'), 3.5); + expect(mongoDisplayToValue('Ada'), 'Ada'); + expect(mongoDisplayToValue('{"ok":true}'), {'ok': true}); + expect(mongoDisplayToValue('[1,2]'), [1, 2]); + expect(mongoDisplayToValue('{not json'), '{not json'); + }); + }); +} diff --git a/test/features/onboarding/welcome_tour_dialog_test.dart b/test/features/onboarding/welcome_tour_dialog_test.dart index 74ae788c..5384ed67 100644 --- a/test/features/onboarding/welcome_tour_dialog_test.dart +++ b/test/features/onboarding/welcome_tour_dialog_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/onboarding/welcome_tour_dialog.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -174,4 +175,29 @@ void main() { expect(homeTriggered, isTrue); }); + + testWidgets('Motion Off makes welcome tour chrome instant', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const QueryaMotionScope( + level: QueryaMotionLevel.off, + child: material.Scaffold( + body: WelcomeTourDialog(), + ), + ), + ), + ); + await tester.pump(); + + expect( + tester.widget(find.byType(material.AnimatedSize)).duration, + Duration.zero, + ); + expect( + tester + .widget(find.byType(material.AnimatedSwitcher)) + .duration, + Duration.zero, + ); + }); } diff --git a/test/features/postgresql/postgres_table_toolbar_layout_test.dart b/test/features/postgresql/postgres_table_toolbar_layout_test.dart index 26031109..9797f449 100644 --- a/test/features/postgresql/postgres_table_toolbar_layout_test.dart +++ b/test/features/postgresql/postgres_table_toolbar_layout_test.dart @@ -82,7 +82,7 @@ void main() { }); }); - testWidgets('custom SQL mode — narrow', (tester) async { + testWidgets('pending Save/Revert — narrow', (tester) async { await expectNoLayoutOverflow(() async { await pumpWidgetWithSurfaceSize( tester, @@ -92,9 +92,38 @@ void main() { darkTheme: AppTheme.dark, themeMode: ThemeMode.dark, home: material.Scaffold( - body: _toolbar( - title: 'custom query', - customSqlActive: true, + body: PostgresTableToolbar( + title: 'public.users', + paginationLabel: '1–50 of 200', + tableIcon: material.Icons.table_chart_rounded, + customSqlActive: false, + isMaterializedView: false, + loading: false, + canGoPrevious: true, + canGoNext: true, + onOpenSql: () {}, + onOpenPrivileges: () {}, + onRefreshMaterializedView: () {}, + onExitCustomMode: () {}, + onGoPrevious: () {}, + onGoNext: () {}, + onRefresh: () {}, + pendingActions: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + const material.Text('2 pending changes'), + OutlineButton( + size: ButtonSize.small, + onPressed: () {}, + child: const Text('Revert'), + ), + PrimaryButton( + size: ButtonSize.small, + onPressed: () {}, + child: const Text('Save'), + ), + ], + ), ), ), ), diff --git a/test/features/updater/update_dialog_test.dart b/test/features/updater/update_dialog_test.dart index fe684030..a49a4c0b 100644 --- a/test/features/updater/update_dialog_test.dart +++ b/test/features/updater/update_dialog_test.dart @@ -1,10 +1,28 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/updater/sha256_checksums.dart'; import 'package:querya_desktop/core/updater/update_manifest.dart'; import 'package:querya_desktop/features/updater/update_changelog_view.dart'; import 'package:querya_desktop/features/updater/update_controller.dart'; +import 'package:querya_desktop/features/updater/update_dialog.dart'; import '../../support/querya_theme_test_shell.dart'; +const _asset = UpdateAsset( + name: 'Querya-Desktop-0.5.1-linux.zip', + downloadUrl: 'https://example.com/querya.zip', + sizeBytes: 10, +); + +const _manifest = UpdateManifest( + version: '0.5.1', + changelog: 'Integrity and packaging fixes.', + assets: [_asset], +); + void main() { group('UpdateChangelogView', () { testWidgets('renders markdown headings and bullet lists', (tester) async { @@ -55,4 +73,147 @@ void main() { expect(controller.showBadge, isFalse); }); }); + + group('UpdateDialogContent', () { + var clipboardContent = ''; + + setUp(() { + clipboardContent = ''; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') { + clipboardContent = (call.arguments as Map)['text'] as String; + return null; + } + if (call.method == 'Clipboard.getData') { + return {'text': clipboardContent}; + } + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + + testWidgets('Download label is not Download & Install', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const SizedBox( + width: 800, + height: 700, + child: UpdateDialogContent( + initialManifest: _manifest, + isInstallBlocked: false, + hasUnsavedWork: _noUnsaved, + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('Download'), findsOneWidget); + expect(find.text('Download & Install'), findsNothing); + }); + + testWidgets('SHA256 mismatch shows an error instead of hanging', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: SizedBox( + width: 800, + height: 700, + child: UpdateDialogContent( + initialManifest: _manifest, + isInstallBlocked: false, + hasUnsavedWork: _noUnsaved, + selectAsset: (_) => _asset, + download: (_, __) async { + throw const UpdateChecksumMismatchException( + fileName: 'querya.zip', + expected: 'aa', + actual: 'bb', + ); + }, + ), + ), + ), + ); + await tester.pump(); + await tester.tap(find.text('Download')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.textContaining('SHA256 mismatch'), findsWidgets); + expect(find.text('Retry'), findsOneWidget); + }); + + testWidgets('snap/flatpak offers copy command and does not download', + (tester) async { + var downloaded = false; + await tester.pumpWidget( + queryaThemeTestShell( + child: SizedBox( + width: 800, + height: 700, + child: UpdateDialogContent( + initialManifest: _manifest, + isInstallBlocked: true, + packageUpdateCommand: 'snap refresh', + showCopyToast: false, + hasUnsavedWork: _noUnsaved, + download: (_, __) async { + downloaded = true; + return File('/tmp/should-not-download.zip'); + }, + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('Download'), findsNothing); + expect(find.text('Copy update command'), findsOneWidget); + + await tester.tap(find.text('Copy update command')); + await tester.pump(); + + final clip = await Clipboard.getData(Clipboard.kTextPlain); + expect(clip?.text, 'snap refresh'); + expect(downloaded, isFalse); + }); + + testWidgets('dirty restart cancel does not install', (tester) async { + var installed = false; + await tester.pumpWidget( + queryaThemeTestShell( + child: SizedBox( + width: 800, + height: 700, + child: UpdateDialogContent( + initialManifest: _manifest, + isInstallBlocked: false, + hasUnsavedWork: () => true, + selectAsset: (_) => _asset, + download: (_, __) async => File('/tmp/querya-update.zip'), + install: (_) async => installed = true, + confirmRestartIfUnsaved: (_) async => false, + ), + ), + ), + ); + await tester.pump(); + await tester.tap(find.text('Download')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.text('Restart & Update Now'), findsOneWidget); + await tester.tap(find.text('Restart & Update Now')); + await tester.pump(); + expect(installed, isFalse); + }); + }); } + +bool _noUnsaved() => false; diff --git a/test/features/workspace/grid_cell_editor_test.dart b/test/features/workspace/grid_cell_editor_test.dart index 82203dd6..2347d386 100644 --- a/test/features/workspace/grid_cell_editor_test.dart +++ b/test/features/workspace/grid_cell_editor_test.dart @@ -11,7 +11,8 @@ import '../../support/querya_theme_test_shell.dart'; void main() { group('GridCellEditor', () { - testWidgets('renders with initial value and commits text on Enter', (tester) async { + testWidgets('renders with initial value and commits text on Enter', + (tester) async { String? committed; bool movedRow = false; @@ -22,7 +23,11 @@ void main() { initialValue: 'Original', width: 200, height: 36, - onCommit: (val, {moveNextCol = false, movePrevCol = false, moveNextRow = false, movePrevRow = false}) { + onCommit: (val, + {moveNextCol = false, + movePrevCol = false, + moveNextRow = false, + movePrevRow = false}) { committed = val; movedRow = moveNextRow; }, @@ -54,7 +59,11 @@ void main() { initialValue: 'Original', width: 200, height: 36, - onCommit: (val, {moveNextCol = false, movePrevCol = false, moveNextRow = false, movePrevRow = false}) {}, + onCommit: (val, + {moveNextCol = false, + movePrevCol = false, + moveNextRow = false, + movePrevRow = false}) {}, onCancel: () => cancelled = true, ), ), @@ -68,7 +77,8 @@ void main() { expect(cancelled, isTrue); }); - testWidgets('displays validation error icon on invalid integer input', (tester) async { + testWidgets('displays validation error icon on invalid integer input', + (tester) async { await tester.pumpWidget( queryaThemeTestShell( child: material.Material( @@ -77,7 +87,11 @@ void main() { dataTypeName: 'integer', width: 200, height: 36, - onCommit: (val, {moveNextCol = false, movePrevCol = false, moveNextRow = false, movePrevRow = false}) {}, + onCommit: (val, + {moveNextCol = false, + movePrevCol = false, + moveNextRow = false, + movePrevRow = false}) {}, onCancel: () {}, ), ), @@ -247,7 +261,8 @@ void main() { expect(result, r'\x DE AD BE EF 12 34'); }); - testWidgets('toggles wrap and applies with Ctrl+Enter shortcut', (tester) async { + testWidgets('toggles wrap and applies with Ctrl+Enter shortcut', + (tester) async { String? result; await tester.pumpWidget( @@ -289,10 +304,49 @@ void main() { expect(result, 'Hello world'); }); + + testWidgets('Save to DB writes through the callback and closes', + (tester) async { + String? saved; + String? result; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showGridCellInspectorDialog( + context: context, + columnName: 'name', + initialValue: 'Ada', + onSaveToDatabase: (value) async { + saved = value; + }, + ); + }, + child: const Text('Open Dialog'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + expect(find.text('Save to DB'), findsOneWidget); + await tester.tap(find.text('Save to DB')); + await tester.pumpAndSettle(); + + expect(saved, 'Ada'); + expect(result, 'Ada'); + expect(find.text('Save to DB'), findsNothing); + }); }); group('VirtualResultGrid Inline Editing Integration', () { - testWidgets('double click on cell activates editor and commits staged edit', (tester) async { + testWidgets('double click on cell activates editor and commits staged edit', + (tester) async { final staging = DataGridStagingBuffer( columns: ['id', 'name'], rows: [ diff --git a/test/features/workspace/result_grid_cell_ux_test.dart b/test/features/workspace/result_grid_cell_ux_test.dart new file mode 100644 index 00000000..11d7484b --- /dev/null +++ b/test/features/workspace/result_grid_cell_ux_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/workspace/result_grid_view.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('ResultGridMetrics.cellTooltipMessage', () { + test('null when the value is short and no type is known', () { + expect( + ResultGridMetrics.cellTooltipMessage(text: 'Ada'), + isNull, + ); + }); + + test('shows column · type when schema metadata is present', () { + expect( + ResultGridMetrics.cellTooltipMessage( + text: 'Ada', + columnName: 'name', + dataTypeName: 'text', + ), + 'name · text', + ); + }); + + test('shows type only when the column name is empty', () { + expect( + ResultGridMetrics.cellTooltipMessage( + text: '1', + dataTypeName: 'integer', + ), + 'integer', + ); + }); + + test('appends long cell values under the type line', () { + final long = 'x' * ResultGridMetrics.tooltipMinLength; + expect( + ResultGridMetrics.cellTooltipMessage( + text: long, + columnName: 'bio', + dataTypeName: 'text', + ), + 'bio · text\n$long', + ); + }); + + test('falls back to the long value when no type is known', () { + final long = 'y' * ResultGridMetrics.tooltipMinLength; + expect( + ResultGridMetrics.cellTooltipMessage(text: long), + long, + ); + }); + }); + + group('VirtualResultGrid cell hover', () { + testWidgets('editable cells use the text cursor', (tester) async { + final staging = DataGridStagingBuffer( + columns: const ['id', 'name'], + rows: const [ + ['1', 'Ada'], + ], + ); + addTearDown(staging.dispose); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.SizedBox( + width: 800, + height: 400, + child: VirtualResultGrid( + columns: const ['id', 'name'], + rows: const [ + ['1', 'Ada'], + ], + stagingBuffer: staging, + columnDataTypes: const {'id': 'integer', 'name': 'text'}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final textCursors = tester + .widgetList(find.byType(material.MouseRegion)) + .where((r) => r.cursor == material.SystemMouseCursors.text); + expect(textCursors, isNotEmpty); + }); + + testWidgets('read-only cells do not use the text cursor', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.SizedBox( + width: 800, + height: 400, + child: VirtualResultGrid( + columns: ['id', 'name'], + rows: [ + ['1', 'Ada'], + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final textCursors = tester + .widgetList(find.byType(material.MouseRegion)) + .where((r) => r.cursor == material.SystemMouseCursors.text); + expect(textCursors, isEmpty); + }); + }); +} diff --git a/test/features/workspace/table_view_staging_test.dart b/test/features/workspace/table_view_staging_test.dart new file mode 100644 index 00000000..58c436ca --- /dev/null +++ b/test/features/workspace/table_view_staging_test.dart @@ -0,0 +1,304 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/core/database/table_schema_meta.dart'; +import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/workspace/table_view_staging.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +void main() { + group('tableViewEditingEnabled', () { + test('true only for base tables with a primary key', () { + expect( + tableViewEditingEnabled( + isView: false, + customSqlActive: false, + hasPrimaryKey: true, + ), + isTrue, + ); + }); + + test('false for views, materialized views, custom SQL, and missing PK', () { + expect( + tableViewEditingEnabled( + isView: true, + customSqlActive: false, + hasPrimaryKey: true, + ), + isFalse, + ); + expect( + tableViewEditingEnabled( + isView: false, + isMaterializedView: true, + customSqlActive: false, + hasPrimaryKey: true, + ), + isFalse, + ); + expect( + tableViewEditingEnabled( + isView: false, + customSqlActive: true, + hasPrimaryKey: true, + ), + isFalse, + ); + expect( + tableViewEditingEnabled( + isView: false, + customSqlActive: false, + hasPrimaryKey: false, + ), + isFalse, + ); + }); + }); + + group('tableViewEditDisabledReason', () { + test('explains views, custom SQL, and missing PK', () { + expect( + tableViewEditDisabledReason( + isView: true, + customSqlActive: false, + hasPrimaryKey: true, + schemaLoaded: true, + ), + 'Views are read-only', + ); + expect( + tableViewEditDisabledReason( + isView: false, + isMaterializedView: true, + customSqlActive: false, + hasPrimaryKey: true, + schemaLoaded: true, + ), + 'Views are read-only', + ); + expect( + tableViewEditDisabledReason( + isView: false, + customSqlActive: true, + hasPrimaryKey: true, + schemaLoaded: true, + ), + 'Custom SQL results are read-only', + ); + expect( + tableViewEditDisabledReason( + isView: false, + customSqlActive: false, + hasPrimaryKey: false, + schemaLoaded: true, + ), + 'Cannot edit: no primary key detected', + ); + expect( + tableViewEditDisabledReason( + isView: false, + customSqlActive: false, + hasPrimaryKey: false, + schemaLoaded: false, + ), + isNull, + ); + expect( + tableViewEditDisabledReason( + isView: false, + customSqlActive: false, + hasPrimaryKey: true, + schemaLoaded: true, + ), + isNull, + ); + }); + }); + + group('columnDataTypesFromSchema', () { + test('maps column names to types and skips empty types', () { + const schema = TableSchemaMeta( + tableName: 'users', + columns: [ + TableColumnMeta(name: 'id', dataType: 'integer'), + TableColumnMeta(name: 'name', dataType: 'text'), + TableColumnMeta(name: 'skip', dataType: ''), + ], + ); + expect( + columnDataTypesFromSchema(schema), + {'id': 'integer', 'name': 'text'}, + ); + }); + }); + + group('replaceTableViewStagingBuffer', () { + test('disposes previous and returns null when editing is disabled', () { + final previous = DataGridStagingBuffer( + columns: ['id'], + rows: [ + ['1'], + ], + ); + final next = replaceTableViewStagingBuffer( + previous: previous, + columns: ['id'], + rows: [ + ['1'], + ], + enabled: false, + ); + expect(next, isNull); + expect(previous.isDirty, isFalse); + }); + + test('creates a buffer when enabled', () { + final next = replaceTableViewStagingBuffer( + columns: ['id', 'name'], + rows: [ + ['1', 'Ada'], + ], + enabled: true, + ); + expect(next, isNotNull); + expect(next!.columns, ['id', 'name']); + expect(next.originalRows, [ + ['1', 'Ada'], + ]); + next.dispose(); + }); + }); + + group('applyTableViewStagedChanges', () { + testWidgets('returns noop when the buffer is clean', (tester) async { + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: const material.Scaffold(body: material.SizedBox()), + ), + ); + final ctx = tester.element(find.byType(material.Scaffold)); + final buffer = DataGridStagingBuffer( + columns: ['id', 'name'], + rows: [ + ['1', 'Ada'], + ], + ); + addTearDown(buffer.dispose); + + var executed = false; + final outcome = await applyTableViewStagedChanges( + context: ctx, + buffer: buffer, + dialect: SqlDialect.postgres, + tableName: 'users', + schema: 'public', + primaryKeys: ['id'], + execute: (_) async => executed = true, + ); + + expect(outcome.status, TableViewApplyStatus.noop); + expect(executed, isFalse); + }); + + testWidgets('returns failed when dirty but no primary key', (tester) async { + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: const material.Scaffold(body: material.SizedBox()), + ), + ); + final ctx = tester.element(find.byType(material.Scaffold)); + final buffer = DataGridStagingBuffer( + columns: ['id', 'name'], + rows: [ + ['1', 'Ada'], + ], + ); + buffer.setCell(0, 1, 'Grace'); + addTearDown(buffer.dispose); + + var executed = false; + final outcome = await applyTableViewStagedChanges( + context: ctx, + buffer: buffer, + dialect: SqlDialect.postgres, + tableName: 'users', + primaryKeys: const [], + execute: (_) async => executed = true, + ); + + expect(outcome.isFailed, isTrue); + expect(outcome.error.toString(), contains('no primary key')); + expect(executed, isFalse); + }); + }); + + group('TableBrowserPendingActions', () { + testWidgets('shows pending badge and Save when dirty', (tester) async { + final buffer = DataGridStagingBuffer( + columns: ['id', 'name'], + rows: [ + ['1', 'Ada'], + ], + ); + addTearDown(buffer.dispose); + buffer.setCell(0, 1, 'Grace'); + + var saved = false; + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: material.Scaffold( + body: TableBrowserPendingActions( + buffer: buffer, + onSave: () => saved = true, + isSaving: false, + ), + ), + ), + ); + + expect(find.text('1 pending change'), findsOneWidget); + expect(find.text('Save'), findsOneWidget); + expect(find.text('Revert'), findsOneWidget); + + await tester.tap(find.text('Save')); + await tester.pump(); + expect(saved, isTrue); + + await tester.tap(find.text('Revert')); + await tester.pump(); + expect(buffer.isDirty, isFalse); + expect(find.text('1 pending change'), findsNothing); + }); + }); + + group('confirmDiscardTableEditsIfDirty', () { + testWidgets('returns true immediately when clean', (tester) async { + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: const material.Scaffold(body: material.SizedBox()), + ), + ); + final ctx = tester.element(find.byType(material.Scaffold)); + final buffer = DataGridStagingBuffer( + columns: ['id'], + rows: [ + ['1'], + ], + ); + addTearDown(buffer.dispose); + + final ok = await confirmDiscardTableEditsIfDirty( + context: ctx, + buffer: buffer, + tableTitle: 'public.users', + ); + expect(ok, isTrue); + }); + }); +} diff --git a/test/shared/widgets/connection_tree_loading_row_test.dart b/test/shared/widgets/connection_tree_loading_row_test.dart new file mode 100644 index 00000000..7d79e874 --- /dev/null +++ b/test/shared/widgets/connection_tree_loading_row_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/ui/querya_tree_tokens.dart'; +import 'package:querya_desktop/shared/widgets/connection_tree_loading_row.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + test('QueryaTreeTokens exposes named indent levels', () { + expect(QueryaTreeTokens.indent, 16); + expect(QueryaTreeTokens.underConnection, 20); + expect(QueryaTreeTokens.leafList, 26); + expect(QueryaTreeTokens.errorConnection, 28); + expect(QueryaTreeTokens.errorNested, 24); + expect(QueryaTreeTokens.errorPaddingForDepth(2).left, 36 + 2 * 16); + }); + + testWidgets('ConnectionTreeLoadingRow.connection uses connection spinner', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const ConnectionTreeLoadingRow.connection(), + ), + ); + + expect(find.text('Loading...'), findsOneWidget); + final indicator = tester.widget( + find.byType(material.CircularProgressIndicator), + ); + expect(indicator.strokeWidth, QueryaTreeTokens.spinnerStroke); + final box = tester.widget( + find.ancestor( + of: find.byType(material.CircularProgressIndicator), + matching: find.byType(material.SizedBox), + ), + ); + expect(box.width, QueryaTreeTokens.spinnerConnection); + }); + + testWidgets('ConnectionTreeLoadingRow.nested uses nested spinner', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const ConnectionTreeLoadingRow.nested(), + ), + ); + + final box = tester.widget( + find.ancestor( + of: find.byType(material.CircularProgressIndicator), + matching: find.byType(material.SizedBox), + ), + ); + expect(box.width, QueryaTreeTokens.spinnerNested); + }); +}