diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index 7bb22c5..1d9188e 100644 --- a/.flutter-plugins-dependencies +++ b/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"flutter_secure_storage","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"flutter_secure_storage","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni_flutter-1.0.1/","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_android-2.3.1/","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"sqflite_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_android-2.4.2+3/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"bitsdojo_window_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_macos-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_macos-3.1.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"bitsdojo_window_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_linux-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_linux-1.2.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"bitsdojo_window_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_windows-0.1.6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_windows-3.1.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false}],"web":[{"name":"flutter_secure_storage_web","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_web-1.2.1/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"bitsdojo_window","dependencies":["bitsdojo_window_windows","bitsdojo_window_macos","bitsdojo_window_linux"]},{"name":"bitsdojo_window_linux","dependencies":[]},{"name":"bitsdojo_window_macos","dependencies":[]},{"name":"bitsdojo_window_windows","dependencies":[]},{"name":"flutter_secure_storage","dependencies":["flutter_secure_storage_linux","flutter_secure_storage_macos","flutter_secure_storage_web","flutter_secure_storage_windows"]},{"name":"flutter_secure_storage_linux","dependencies":[]},{"name":"flutter_secure_storage_macos","dependencies":[]},{"name":"flutter_secure_storage_web","dependencies":[]},{"name":"flutter_secure_storage_windows","dependencies":["path_provider"]},{"name":"jni","dependencies":[]},{"name":"jni_flutter","dependencies":["jni"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":["jni","jni_flutter"]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]}],"date_created":"2026-04-24 12:51:31.086903","version":"3.41.6","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"file_selector_ios","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_ios-0.5.3+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"file_selector_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_android-0.5.2+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni_flutter-1.0.1/","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_android-2.3.1/","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"sqflite_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_android-2.4.2+3/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"bitsdojo_window_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_macos-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_macos-0.9.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_macos-3.1.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"bitsdojo_window_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_linux-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_linux-0.9.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_linux-1.2.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"bitsdojo_window_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_windows-0.1.6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_windows-0.9.3+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_windows-3.1.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false}],"web":[{"name":"file_selector_web","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_web-0.9.4+2/","dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_web","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_web-1.2.1/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"bitsdojo_window","dependencies":["bitsdojo_window_windows","bitsdojo_window_macos","bitsdojo_window_linux"]},{"name":"bitsdojo_window_linux","dependencies":[]},{"name":"bitsdojo_window_macos","dependencies":[]},{"name":"bitsdojo_window_windows","dependencies":[]},{"name":"file_selector","dependencies":["file_selector_android","file_selector_ios","file_selector_linux","file_selector_macos","file_selector_web","file_selector_windows"]},{"name":"file_selector_android","dependencies":[]},{"name":"file_selector_ios","dependencies":[]},{"name":"file_selector_linux","dependencies":[]},{"name":"file_selector_macos","dependencies":[]},{"name":"file_selector_web","dependencies":[]},{"name":"file_selector_windows","dependencies":[]},{"name":"flutter_secure_storage","dependencies":["flutter_secure_storage_linux","flutter_secure_storage_macos","flutter_secure_storage_web","flutter_secure_storage_windows"]},{"name":"flutter_secure_storage_linux","dependencies":[]},{"name":"flutter_secure_storage_macos","dependencies":[]},{"name":"flutter_secure_storage_web","dependencies":[]},{"name":"flutter_secure_storage_windows","dependencies":["path_provider"]},{"name":"jni","dependencies":[]},{"name":"jni_flutter","dependencies":["jni"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":["jni","jni_flutter"]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]}],"date_created":"2026-04-24 15:57:28.697051","version":"3.41.6","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73bfef8..504814a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.38.5' + flutter-version: '3.41.6' channel: 'stable' cache: true @@ -43,7 +43,7 @@ jobs: - name: Setup Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.38.5' + flutter-version: '3.41.6' channel: 'stable' cache: true @@ -71,7 +71,7 @@ jobs: - name: Setup Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.38.5' + flutter-version: '3.41.6' channel: 'stable' cache: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eaf951e..d53fbed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,7 +64,7 @@ jobs: - name: Setup Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.38.5' + flutter-version: '3.41.6' channel: 'stable' cache: true @@ -109,7 +109,7 @@ jobs: - name: Setup Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.38.5' + flutter-version: '3.41.6' channel: 'stable' cache: true @@ -146,7 +146,7 @@ jobs: - name: Setup Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.38.5' + flutter-version: '3.41.6' channel: 'stable' cache: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b44bbc..356684e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2026-04-24 + +### Added + +- **SQL result export** — from the PostgreSQL / MySQL **Data Output** grid: **Copy as CSV**, **Save as CSV…**, **Copy as JSON**, **Save as JSON…** (native save dialog via **`file_selector`** on Linux, macOS, and Windows). +- **SQL query history** — successful statements are stored in local **SQLite** (per saved connection and database bucket); **History** in the SQL toolbar opens a recall dialog; **Edit → Preferences** adds **Query history limit** (25–500 entries, oldest trimmed automatically). +- **PostgreSQL** — `postgres_object_workspace.dart` builds object views from the sidebar tree (logic moved out of `workspace_panel` for clarity). +- **Documentation** — contributing notes, product **roadmap**, macOS signing track; README structure refresh. +- **Tests** — MySQL SQL workspace home, driver manager, and preferences dialog widget coverage; storage tests for query history and export encoding. + +### Changed + +- **Connections sidebar** — `connections_panel` split into **part libraries** for easier maintenance (behavior preserved). +- **CI / release** — Flutter toolchain pinned to **3.41.6** for analyze, tests, and release builds. +- **PostgreSQL — workspace** — a selected table, view, or other object opens **full width**; **Server** / **SQL** tabs show only when no object is selected for that connection. **Open in SQL** still seeds the editor from the current browse context; the SQL toolbar **DB:** line follows the tree catalog when a seed is applied. +- **PostgreSQL** — shared browse query helper (`postgresBrowseSelectSql`) and default page size (`kPostgresBrowseDefaultRowLimit`) align the data grid with the SQL editor template. +- **MySQL** — comparing the table browse query to the mini-editor SQL ignores trailing semicolons and normalizes whitespace; hint text clarifies that **Run** reloads from the server even when rows look unchanged. + +### Fixed + +- **PostgreSQL — Open in SQL** — the context menu seeds the editor from the **row you right-clicked** (database, schema, table/view/matview name). Previously the app used only the last **left-click** selection, so the query could target the wrong table. + ## [0.1.3] - 2026-04-25 ### Added @@ -57,6 +79,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Linux desktop build/install layout no longer targets `/usr/local` when building the app bundle. +[0.2.0]: https://github.com/QueryaHub/Querya-Desktop/compare/0.1.3...0.2.0 [0.1.3]: https://github.com/QueryaHub/Querya-Desktop/compare/0.1.2...0.1.3 [0.1.2]: https://github.com/QueryaHub/Querya-Desktop/compare/0.1.1...0.1.2 [0.1.1]: https://github.com/QueryaHub/Querya-Desktop/compare/0.1.0...0.1.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..adc18b2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing + +## Flutter version + +CI pins a **stable** Flutter version in [`.github/workflows/ci.yml`](.github/workflows/ci.yml) and [`.github/workflows/release.yml`](.github/workflows/release.yml). Prefer matching that version locally to avoid “works on my machine” drift. When bumping the pin, run `flutter test` and a release smoke build before merging. + +## Linux: `flutter analyze` and “Too many open files” + +On some Linux setups the Dart analysis server hits the process **open file limit** (`errno = 24`). Try: + +```bash +ulimit -n 8192 +flutter analyze +``` + +## Git tags and release commits + +A **tag points at one commit**. Release artifacts are built from the tree at that commit. If you fix something **after** pushing a release tag, either: + +- move the tag to the new commit (only if the team agrees and the release is not yet consumed), or +- ship a **new** semver (update `pubspec.yaml` / `CHANGELOG.md`) and push a **new** tag. + +See [docs/tags-and-releases.md](docs/tags-and-releases.md). + +## Tests + +```bash +flutter pub get +flutter test +``` + +Widget tests that use SQLite or `path_provider` follow patterns in `test/features/connections/connections_panel_layout_test.dart` and `test/flutter_test_config.dart` (in-memory secrets backend). diff --git a/README.md b/README.md index 8ae9356..f807d0a 100644 --- a/README.md +++ b/README.md @@ -80,9 +80,11 @@ flutter build macos |------|-------------| | `lib/main.dart` | App entry, window setup (bitsdojo_window) | | `lib/app/` | App shell and theme | -| `lib/features/main_screen/` | Main layout, connections panel, new connection dialog, query/results tabs | -| `lib/shared/widgets/` | Shared UI (shadcn re-exports) | -| `lib/core/theme/` | Dark theme and colors | +| `lib/features/main_screen/` | Main layout, workspace panel, query/results tabs | +| `lib/features/connections/` | Connection tree, new connection / folder flows, driver manager | +| `lib/features/postgresql/`, `mysql/`, `redis/`, `mongodb/` | Per-engine workspace and browser UI | +| `lib/shared/widgets/` | Shared UI (shadcn re-exports, app dialog) | +| `lib/core/` | Database clients, local storage, theme, editor helpers | | `assets/images/` | Database type icons (PostgreSQL, MySQL, Redis, MongoDB) | | `linux/`, `windows/`, `macos/` | Native runners (custom frame on Linux/Windows) | @@ -96,3 +98,5 @@ flutter build macos - [User guide](docs/user-guide.md) - [Releases](docs/tags-and-releases.md) - [Release checklist](docs/release-checklist.md) +- [Contributing](CONTRIBUTING.md) (Flutter pin, tags, local analyze) +- [Roadmap](docs/roadmap.md) diff --git a/docs/macos-signing.md b/docs/macos-signing.md new file mode 100644 index 0000000..bd25451 --- /dev/null +++ b/docs/macos-signing.md @@ -0,0 +1,17 @@ +# macOS signing and notarization (future track) + +CI currently produces a **macOS zip** with an **unsigned** `.app`. Users may need to use **Open** from the context menu the first time, or adjust Gatekeeper settings. + +## Goal for broader distribution + +1. **Apple Developer Program** membership and certificates (Developer ID Application). +2. **Code sign** the app bundle and nested frameworks (`flutter build macos` output). +3. **Notarize** with `notarytool` / `xcrun notarytool`, then staple the ticket. +4. Store signing secrets in **GitHub Actions** encrypted secrets; run signing in `release.yml` only on protected branches/tags. + +## References + +- Flutter: [Build and release a macOS app](https://docs.flutter.dev/deployment/macos) +- Apple: notarization and hardened runtime requirements + +This repository does not yet automate signing; treat this file as a checklist when the project is ready to invest in that workflow. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index ebd5bbd..63db411 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -12,12 +12,14 @@ Use this before tagging or running the **Release** workflow. ## Automated -- [ ] `flutter analyze` — clean. +- [ ] `flutter analyze` — clean (on Linux, if the analyzer crashes with **Too many open files**, try `ulimit -n 8192`; see [CONTRIBUTING.md](../CONTRIBUTING.md)). - [ ] `flutter test` — all green. +- [ ] CI **Flutter version** in `.github/workflows/*.yml` matches the toolchain you validated (bump intentionally when upgrading stable). ## Versioning and release - [ ] `pubspec.yaml` `version` matches the release you intend to ship. +- [ ] **Tag** 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)). - [ ] Run the **Release** workflow from GitHub Actions (see [tags-and-releases.md](tags-and-releases.md)). - [ ] Verify **Linux** and **Windows** zip artifacts and `SHA256SUMS.txt` on the GitHub Release. @@ -25,3 +27,4 @@ Use this before tagging or running the **Release** workflow. - [ ] [security.md](security.md) still matches behavior if storage changed. - [ ] [README.md](../README.md) prerequisites (e.g. Linux deps) still accurate. +- [ ] [roadmap.md](roadmap.md) updated if you are communicating upcoming themes externally. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..46731e0 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,28 @@ +# Product roadmap (draft) + +Living document for planned work. Not a commitment order; adjust as priorities change. + +## Query history and favorites + +- **Done:** `sql_query_history` in SQLite + record/list APIs; **History** in PostgreSQL / MySQL toolbars; **Preferences → Query history limit** ([`AppSettings.getSqlHistoryMaxEntries`](lib/core/storage/app_settings.dart)). +- **Later:** favorites / pins, opt-out toggle. +- Respect existing **security** model: history stores SQL text only (no passwords); optional opt-out when UI lands. + +## Result export + +- **Done (small steps):** CSV and JSON from the Data Output grid — copy to clipboard and **Save…** (system dialog): [`ResultsTab`](lib/features/main_screen/results_tab.dart), [`lib/core/csv/`](lib/core/csv/), [`lib/core/json/`](lib/core/json/). +- **Later:** alignment with `AppSettings` max rows for very large grids (warn or truncate before export). + +## SSH and advanced networking + +- **Today:** the app does not embed SSH tunnels or jump hosts (see [security.md](security.md)). +- **Near term:** expand user-facing docs with recipes: `ssh -L`, cloud provider consoles, VPN. +- **Later (if demand):** optional “local proxy command” or documented integration with external tools; avoid shipping full SSH client scope unless clearly justified. + +## Connections tree maintainability + +- Continue splitting `connections_panel` library parts as needed; keep behavior and tests green after refactors. + +## macOS distribution + +- Unsigned builds require extra steps for end users; see [macos-signing.md](macos-signing.md) for a future signing/notarize track. diff --git a/lib/core/csv/result_grid_csv.dart b/lib/core/csv/result_grid_csv.dart new file mode 100644 index 0000000..2fa60b6 --- /dev/null +++ b/lib/core/csv/result_grid_csv.dart @@ -0,0 +1,29 @@ +/// RFC 4180–style CSV for a result grid (header + rows). +library; + +String escapeCsvField(String s) { + final needsQuotes = s.contains(',') || + s.contains('"') || + s.contains('\n') || + s.contains('\r'); + if (needsQuotes) { + return '"${s.replaceAll('"', '""')}"'; + } + return s; +} + +/// One line per row; pads short rows with empty cells to [columns.length]. +String resultGridAsCsv(List columns, List> rows) { + final lines = [ + columns.map(escapeCsvField).join(','), + ...rows.map((r) { + final cells = List.generate( + columns.length, + (i) => i < r.length ? escapeCsvField(r[i]) : '', + growable: false, + ); + return cells.join(','); + }), + ]; + return lines.join('\n'); +} diff --git a/lib/core/csv/save_result_grid_csv.dart b/lib/core/csv/save_result_grid_csv.dart new file mode 100644 index 0000000..b9a2f36 --- /dev/null +++ b/lib/core/csv/save_result_grid_csv.dart @@ -0,0 +1,44 @@ +import 'dart:io'; + +import 'package:file_selector/file_selector.dart'; + +import 'package:querya_desktop/core/csv/result_grid_csv.dart'; + +/// Outcome of [saveResultGridCsvFile]. +enum SaveResultGridCsvOutcome { + /// User cancelled the save dialog or no path was returned. + cancelled, + + /// Bytes were written successfully. + written, + + /// A path was chosen but writing failed. + error, +} + +/// Opens a platform save dialog and writes [columns]/[rows] as CSV. +Future saveResultGridCsvFile({ + required List columns, + required List> rows, + String? suggestedName, +}) async { + final csv = resultGridAsCsv(columns, rows); + final name = suggestedName ?? + 'querya_results_${DateTime.now().toIso8601String().replaceAll(':', '-')}.csv'; + final location = await getSaveLocation( + acceptedTypeGroups: const [ + XTypeGroup(label: 'CSV', extensions: ['csv']), + ], + suggestedName: name, + ); + final path = location?.path; + if (path == null || path.isEmpty) { + return SaveResultGridCsvOutcome.cancelled; + } + try { + await File(path).writeAsString(csv); + return SaveResultGridCsvOutcome.written; + } on Object { + return SaveResultGridCsvOutcome.error; + } +} diff --git a/lib/core/json/result_grid_json.dart b/lib/core/json/result_grid_json.dart new file mode 100644 index 0000000..a3b77da --- /dev/null +++ b/lib/core/json/result_grid_json.dart @@ -0,0 +1,20 @@ +import 'dart:convert'; + +/// JSON object: `{ "columns": [...], "rows": [[...], ...] }`. +/// +/// Short rows are padded with empty strings to [columns.length] (same as CSV). +String resultGridAsJson(List columns, List> rows) { + final normalizedRows = rows + .map( + (r) => List.generate( + columns.length, + (i) => i < r.length ? r[i] : '', + growable: false, + ), + ) + .toList(growable: false); + return jsonEncode({ + 'columns': columns, + 'rows': normalizedRows, + }); +} diff --git a/lib/core/json/save_result_grid_json.dart b/lib/core/json/save_result_grid_json.dart new file mode 100644 index 0000000..54b0cfd --- /dev/null +++ b/lib/core/json/save_result_grid_json.dart @@ -0,0 +1,39 @@ +import 'dart:io'; + +import 'package:file_selector/file_selector.dart'; + +import 'package:querya_desktop/core/json/result_grid_json.dart'; + +/// Outcome of [saveResultGridJsonFile]. +enum SaveResultGridJsonOutcome { + cancelled, + written, + error, +} + +/// Opens a platform save dialog and writes [columns]/[rows] as JSON. +Future saveResultGridJsonFile({ + required List columns, + required List> rows, + String? suggestedName, +}) async { + final json = resultGridAsJson(columns, rows); + final name = suggestedName ?? + 'querya_results_${DateTime.now().toIso8601String().replaceAll(':', '-')}.json'; + final location = await getSaveLocation( + acceptedTypeGroups: const [ + XTypeGroup(label: 'JSON', extensions: ['json']), + ], + suggestedName: name, + ); + final path = location?.path; + if (path == null || path.isEmpty) { + return SaveResultGridJsonOutcome.cancelled; + } + try { + await File(path).writeAsString(json); + return SaveResultGridJsonOutcome.written; + } on Object { + return SaveResultGridJsonOutcome.error; + } +} diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 346e8ae..64023fc 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -19,6 +19,19 @@ const List kSqlResultMaxRowsPresets = [ /// Default monospace size in the SQL editor (logical pixels). const double kDefaultSqlEditorFontSize = 13; +/// Default cap on stored SQL history entries per connection + database. +const int kDefaultSqlHistoryMaxEntries = 100; + +/// Allowed values for [AppSettings.getSqlHistoryMaxEntries] (nearest preset is used). +const List kSqlHistoryMaxEntriesPresets = [25, 50, 100, 200, 500]; + +int _normalizeSqlHistoryMaxEntries(int n) { + final c = n.clamp(25, 500); + return kSqlHistoryMaxEntriesPresets.reduce( + (a, b) => (c - a).abs() <= (c - b).abs() ? a : b, + ); +} + int _normalizeSqlResultMaxRows(int n) { final c = n.clamp(100, 100000); return kSqlResultMaxRowsPresets.reduce( @@ -33,6 +46,7 @@ abstract final class AppSettingsKeys { static const mysqlSqlStmtTimeoutSeconds = 'mysql_sql_stmt_timeout_seconds'; static const sqlResultMaxRows = 'sql_result_max_rows'; static const sqlEditorFontSizePoints = 'sql_editor_font_size_points'; + static const sqlHistoryMaxEntries = 'sql_history_max_entries'; } /// Bumps [listenable] when any preference is persisted so open screens can reload. @@ -134,4 +148,26 @@ class AppSettings { ); AppSettingsRevision.bump(); } + + /// Max SQL history rows kept per connection + database (oldest trimmed). + Future getSqlHistoryMaxEntries() async { + final v = await LocalDb.instance.getAppSetting( + AppSettingsKeys.sqlHistoryMaxEntries, + ); + if (v == null || v.isEmpty) return kDefaultSqlHistoryMaxEntries; + final n = int.tryParse(v); + if (n == null) return kDefaultSqlHistoryMaxEntries; + return _normalizeSqlHistoryMaxEntries(n); + } + + Future setSqlHistoryMaxEntries(int entries) async { + final preset = kSqlHistoryMaxEntriesPresets.contains(entries) + ? entries + : _normalizeSqlHistoryMaxEntries(entries); + await LocalDb.instance.setAppSetting( + AppSettingsKeys.sqlHistoryMaxEntries, + preset.toString(), + ); + AppSettingsRevision.bump(); + } } diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index 78af200..50180ae 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -6,7 +6,11 @@ import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; const _dbName = 'querya.db'; -const _dbVersion = 5; +const _dbVersion = 6; + +/// Fallback when [recordSqlQueryHistory] is called without `maxEntries`. +/// Keep in sync with [kDefaultSqlHistoryMaxEntries] in `app_settings.dart`. +const int kDefaultSqlHistoryCap = 100; int? _sqliteInt(Object? v) { if (v == null) return null; @@ -15,6 +19,12 @@ int? _sqliteInt(Object? v) { return int.tryParse(v.toString()); } +String? _normalizeHistoryDatabaseName(String? databaseName) { + final t = databaseName?.trim(); + if (t == null || t.isEmpty) return null; + return t; +} + /// Local SQLite database for folders and connections. /// File: [applicationSupport]/querya_desktop/querya.db class LocalDb { @@ -45,6 +55,9 @@ class LocalDb { version: _dbVersion, onCreate: _onCreate, onUpgrade: _onUpgrade, + onOpen: (db) async { + await db.execute('PRAGMA foreign_keys = ON'); + }, ), ); return _db!; @@ -82,6 +95,19 @@ class LocalDb { value TEXT NOT NULL ) '''); + await db.execute(''' + CREATE TABLE sql_query_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + connection_id INTEGER NOT NULL REFERENCES connections(id) ON DELETE CASCADE, + database_name TEXT, + sql_text TEXT NOT NULL, + recorded_at TEXT NOT NULL + ) + '''); + await db.execute(''' + CREATE INDEX idx_sql_query_history_lookup + ON sql_query_history (connection_id, recorded_at DESC) + '''); } Future _onUpgrade(Database db, int oldVersion, int newVersion) async { @@ -146,6 +172,21 @@ class LocalDb { 'UPDATE connections SET password = NULL, connection_string = NULL', ); } + if (oldVersion < 6) { + await db.execute(''' + CREATE TABLE sql_query_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + connection_id INTEGER NOT NULL REFERENCES connections(id) ON DELETE CASCADE, + database_name TEXT, + sql_text TEXT NOT NULL, + recorded_at TEXT NOT NULL + ) + '''); + await db.execute(''' + CREATE INDEX idx_sql_query_history_lookup + ON sql_query_history (connection_id, recorded_at DESC) + '''); + } } Future getAppSetting(String key) async { @@ -174,6 +215,85 @@ class LocalDb { await db.delete('app_settings', where: 'key = ?', whereArgs: [key]); } + Future recordSqlQueryHistory({ + required int connectionId, + String? databaseName, + required String sqlText, + int maxEntries = kDefaultSqlHistoryCap, + }) async { + final sql = sqlText.trim(); + if (sql.isEmpty) return; + if (maxEntries < 1) return; + final db = await _open(); + final dbKey = _normalizeHistoryDatabaseName(databaseName); + final now = DateTime.now().toUtc().toIso8601String(); + await db.insert('sql_query_history', { + 'connection_id': connectionId, + 'database_name': dbKey, + 'sql_text': sql, + 'recorded_at': now, + }); + await db.rawDelete( + ''' + DELETE FROM sql_query_history WHERE id IN ( + SELECT id FROM ( + SELECT id FROM sql_query_history + WHERE connection_id = ? AND database_name IS NOT DISTINCT FROM ? + ORDER BY recorded_at DESC, id DESC + LIMIT -1 OFFSET ? + ) + ) + ''', + [connectionId, dbKey, maxEntries], + ); + } + + Future> listSqlQueryHistory({ + required int connectionId, + String? databaseName, + int limit = 50, + }) async { + final db = await _open(); + final dbKey = _normalizeHistoryDatabaseName(databaseName); + final rows = await db.rawQuery( + ''' + SELECT id, connection_id, database_name, sql_text, recorded_at + FROM sql_query_history + WHERE connection_id = ? AND database_name IS NOT DISTINCT FROM ? + ORDER BY recorded_at DESC, id DESC + LIMIT ? + ''', + [connectionId, dbKey, limit], + ); + return rows.map(SqlQueryHistoryEntry.fromMap).toList(); + } + + /// Removes all history rows for [connectionId] (every database bucket). + Future clearSqlQueryHistoryForConnection(int connectionId) async { + final db = await _open(); + await db.delete( + 'sql_query_history', + where: 'connection_id = ?', + whereArgs: [connectionId], + ); + } + + /// Removes history for [connectionId] and [databaseName] only (same bucket as [listSqlQueryHistory]). + Future clearSqlQueryHistoryBucket({ + required int connectionId, + String? databaseName, + }) async { + final db = await _open(); + final dbKey = _normalizeHistoryDatabaseName(databaseName); + await db.rawDelete( + ''' + DELETE FROM sql_query_history + WHERE connection_id = ? AND database_name IS NOT DISTINCT FROM ? + ''', + [connectionId, dbKey], + ); + } + Future> getFolders() async { final db = await _open(); final rows = await db.query('folders', orderBy: 'sort_order ASC, name ASC'); @@ -260,6 +380,32 @@ class LocalDb { } } +/// One row from [LocalDb.sql_query_history] (recent SQL, no secrets). +class SqlQueryHistoryEntry { + const SqlQueryHistoryEntry({ + required this.id, + required this.connectionId, + this.databaseName, + required this.sqlText, + required this.recordedAt, + }); + + final int id; + final int connectionId; + final String? databaseName; + final String sqlText; + final String recordedAt; + + static SqlQueryHistoryEntry fromMap(Map m) => + SqlQueryHistoryEntry( + id: _sqliteInt(m['id'])!, + connectionId: _sqliteInt(m['connection_id'])!, + databaseName: m['database_name'] as String?, + sqlText: m['sql_text'] as String, + recordedAt: m['recorded_at'] as String, + ); +} + class ConnectionRow { const ConnectionRow({ this.id, diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 2669049..59e4c43 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -16,34 +16,22 @@ import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; import 'new_folder_dialog.dart'; -material.Widget _sidebarConnectionShell({ - required material.BuildContext context, - required bool isSelected, - required material.VoidCallback? onTap, - required material.Widget child, -}) { - final p = Theme.of(context).colorScheme.primary; - return material.Material( - color: material.Colors.transparent, - child: material.InkWell( - onTap: onTap, - borderRadius: material.BorderRadius.circular(20), - mouseCursor: onTap != null - ? material.SystemMouseCursors.click - : material.SystemMouseCursors.basic, - child: material.Container( - decoration: material.BoxDecoration( - color: isSelected ? p.withValues(alpha: 0.16) : null, - borderRadius: material.BorderRadius.circular(20), - border: isSelected - ? material.Border.all(color: p.withValues(alpha: 0.26)) - : null, - ), - child: child, - ), - ), - ); -} +part 'connections_panel_sidebar.dart'; +part 'connections_panel_redis.dart'; +part 'connections_panel_mongo.dart'; +part 'connections_panel_postgres_connection.dart'; +part 'connections_panel_mysql.dart'; +part 'connections_panel_pg_tree.dart'; + +/// Opens the PostgreSQL SQL tab; optional tree fields seed the editor for the +/// row that was right-clicked (left-click is not required). +typedef OnPostgresOpenSqlWorkspace = void Function( + ConnectionRow connection, { + String? database, + String? schema, + String? name, + PostgresObjectKind? kind, +}); /// Left panel: Browser tree (pgAdmin-style). Uses shadcn layout widgets. class ConnectionsPanel extends StatefulWidget { @@ -88,7 +76,7 @@ class ConnectionsPanel extends StatefulWidget { )? onPostgresObjectSelected; /// Opens the PostgreSQL workspace home and switches to the SQL tab (e.g. from tree context menu). - final void Function(ConnectionRow connection)? onPostgresOpenSqlWorkspace; + final OnPostgresOpenSqlWorkspace? onPostgresOpenSqlWorkspace; /// MySQL table or view selected in the tree. final void Function( @@ -403,2628 +391,3 @@ class ConnectionsPanelState extends State { ); } } - -class _EmptyState extends StatelessWidget { - const _EmptyState({required this.message}); - - final String message; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 14), - decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.25), - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all( - color: theme.colorScheme.border.withValues(alpha: 0.2), - width: 1, - ), - ), - child: material.Row( - crossAxisAlignment: material.CrossAxisAlignment.center, - children: [ - material.Icon( - material.Icons.info_outline_rounded, - size: 16, - color: theme.colorScheme.mutedForeground, - ), - const Gap(10), - material.Expanded( - child: Text(message).muted().small(), - ), - ], - ), - ); - } -} - -/// Tile for a single connection in the sidebar. -class _ConnectionTile extends StatelessWidget { - const _ConnectionTile({ - required this.connection, - this.isSelected = false, - required this.icon, - this.iconAsset, - required this.onRemove, - this.onTap, - }); - - final ConnectionRow connection; - final bool isSelected; - final material.IconData icon; - final String? iconAsset; - final VoidCallback onRemove; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final iconWidget = iconAsset != null - ? material.Image.asset( - iconAsset!, - width: 16, - height: 16, - fit: material.BoxFit.contain, - errorBuilder: (_, __, ___) => material.Icon( - icon, - size: 16, - color: theme.colorScheme.primary, - ), - ) - : material.Icon(icon, size: 16, color: theme.colorScheme.primary); - return ContextMenu( - items: [ - MenuButton( - leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), - onPressed: (_) => onRemove(), - child: const Text('Remove connection'), - ), - ], - child: material.Padding( - padding: const material.EdgeInsets.only(bottom: 2), - child: _sidebarConnectionShell( - context: context, - isSelected: isSelected, - onTap: onTap, - child: material.Padding( - padding: - const material.EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: material.Row( - children: [ - iconWidget, - const Gap(8), - material.Expanded( - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Text( - connection.name, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 13, - color: theme.colorScheme.foreground, - ), - ), - if (connection.host != null) - material.Text( - '${connection.host}:${connection.port ?? ''}', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.mutedForeground, - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -class _FolderTile extends StatefulWidget { - const _FolderTile({ - required this.name, - required this.initiallyExpanded, - required this.onExpansionCommitted, - required this.connections, - required this.onRemove, - required this.onNewConnection, - required this.iconForType, - required this.onRemoveConnection, - this.onConnectionTap, - this.onRedisDatabaseTap, - this.onMongoDBDatabaseTap, - this.buildConnectionTile, - }); - - final String name; - final bool initiallyExpanded; - final void Function(String folderName, bool expanded) onExpansionCommitted; - final List connections; - final VoidCallback onRemove; - final void Function(String folderName) onNewConnection; - final material.IconData Function(String type) iconForType; - final Future Function(int id) onRemoveConnection; - final void Function(ConnectionRow connection)? onConnectionTap; - final void Function(ConnectionRow connection, int database)? onRedisDatabaseTap; - final void Function(ConnectionRow connection, String database)? onMongoDBDatabaseTap; - final Widget Function(ConnectionRow conn)? buildConnectionTile; - - @override - State<_FolderTile> createState() => _FolderTileState(); -} - -class _FolderTileState extends State<_FolderTile> { - late bool _expanded; - - @override - void initState() { - super.initState(); - _expanded = widget.initiallyExpanded; - } - - @override - void didUpdateWidget(_FolderTile oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.name != oldWidget.name || - widget.initiallyExpanded != oldWidget.initiallyExpanded) { - _expanded = widget.initiallyExpanded; - } - } - - void _toggle() { - setState(() => _expanded = !_expanded); - widget.onExpansionCommitted(widget.name, _expanded); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return ContextMenu( - items: [ - MenuButton( - leading: material.Icon(material.Icons.settings_ethernet_rounded, size: 18, color: theme.colorScheme.mutedForeground), - onPressed: (menuContext) => widget.onNewConnection(widget.name), - child: const Text('New connection'), - ), - MenuButton( - leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), - onPressed: (_) => widget.onRemove(), - child: const Text('Remove folder'), - ), - ], - child: material.Padding( - padding: const material.EdgeInsets.only(bottom: 4), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.MouseRegion( - cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: _toggle, - borderRadius: material.BorderRadius.circular(6), - child: material.Padding( - padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: material.Row( - children: [ - material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 100), - child: material.Icon( - material.Icons.chevron_right_rounded, - size: 18, - color: theme.colorScheme.mutedForeground, - ), - ), - const Gap(2), - material.Icon(material.Icons.folder_rounded, size: 18, color: theme.colorScheme.primary), - const Gap(8), - material.Expanded( - child: material.Text( - widget.name, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 13, - color: theme.colorScheme.foreground, - ), - ), - ), - ], - ), - ), - ), - ), - if (_expanded) - for (final conn in widget.connections) - material.Padding( - padding: const material.EdgeInsets.only(left: 24), - child: widget.buildConnectionTile != null - ? widget.buildConnectionTile!(conn) - : _ConnectionTile( - connection: conn, - icon: widget.iconForType(conn.type), - iconAsset: ConnectionsPanelState._iconAssetForType(conn.type), - onRemove: () => widget.onRemoveConnection(conn.id!), - onTap: () => widget.onConnectionTap?.call(conn), - ), - ), - ], - ), - ), - ); - } -} - -// ─── Redis connection tile with expandable database tree ──────────────────── - -class _RedisConnectionTile extends StatefulWidget { - const _RedisConnectionTile({ - required this.connection, - this.isSelected = false, - required this.icon, - this.iconAsset, - required this.onRemove, - this.onTap, - this.onDatabaseTap, - }); - - final ConnectionRow connection; - final bool isSelected; - final material.IconData icon; - final String? iconAsset; - final VoidCallback onRemove; - final VoidCallback? onTap; - final void Function(int database)? onDatabaseTap; - - @override - State<_RedisConnectionTile> createState() => _RedisConnectionTileState(); -} - -class _RedisConnectionTileState extends State<_RedisConnectionTile> { - bool _expanded = false; - bool _loading = false; - String? _error; - // All 16 databases (db0–db15) with key counts - List<({int index, int keys})> _databases = []; - - void _toggle() { - setState(() => _expanded = !_expanded); - if (_expanded && _databases.isEmpty && !_loading) { - _loadDatabases(); - } - } - - Future _loadDatabases() async { - if (!mounted) return; - setState(() { - _loading = true; - _error = null; - }); - try { - // Use a temporary connection so we don't kill the main view's connection. - final c = widget.connection; - final conn = RedisConnection( - id: -1, - name: 'sidebar_probe', - host: c.host ?? 'localhost', - port: c.port ?? 6379, - username: c.username, - password: c.password, - ); - await conn.connect(); - final raw = await conn.info(); - await conn.disconnect(); - - final info = parseRedisInfo(raw); - final keyspace = info['Keyspace'] ?? {}; - - // Build all 16 databases with their key counts - final dbs = <({int index, int keys})>[]; - for (var i = 0; i < 16; i++) { - final dbKey = 'db$i'; - final dbInfo = keyspace[dbKey]; - int keys = 0; - if (dbInfo != null) { - for (final part in dbInfo.split(',')) { - final kv = part.split('='); - if (kv.length == 2 && kv[0].trim() == 'keys') { - keys = int.tryParse(kv[1].trim()) ?? 0; - } - } - } - dbs.add((index: i, keys: keys)); - } - - if (!mounted) return; - setState(() { - _databases = dbs; - _loading = false; - }); - } catch (e) { - if (!mounted) return; - setState(() { - _error = e.toString(); - _loading = false; - }); - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final iconWidget = widget.iconAsset != null - ? material.Image.asset( - widget.iconAsset!, - width: 16, - height: 16, - fit: material.BoxFit.contain, - errorBuilder: (_, __, ___) => material.Icon( - widget.icon, - size: 16, - color: theme.colorScheme.primary, - ), - ) - : material.Icon(widget.icon, size: 16, color: theme.colorScheme.primary); - - return ContextMenu( - items: [ - MenuButton( - leading: material.Icon(material.Icons.refresh_rounded, - size: 18, color: theme.colorScheme.mutedForeground), - onPressed: (_) { - _databases = []; - _loadDatabases(); - }, - child: const Text('Refresh databases'), - ), - MenuButton( - leading: material.Icon(material.Icons.delete_outline_rounded, - size: 18, color: theme.colorScheme.mutedForeground), - onPressed: (_) => widget.onRemove(), - child: const Text('Remove connection'), - ), - ], - child: material.Padding( - padding: const material.EdgeInsets.only(bottom: 2), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - // Connection row - material.Row( - children: [ - // Expand/collapse arrow - material.MouseRegion( - cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: _toggle, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(2), - child: material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 100), - child: material.Icon( - material.Icons.chevron_right_rounded, - size: 16, - color: theme.colorScheme.mutedForeground, - ), - ), - ), - ), - ), - // Connection name — clickable for stats - material.Expanded( - child: _sidebarConnectionShell( - context: context, - isSelected: widget.isSelected, - onTap: widget.onTap, - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 4, vertical: 6), - child: material.Row( - children: [ - iconWidget, - const Gap(8), - material.Expanded( - child: material.Column( - crossAxisAlignment: - material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Text( - widget.connection.name, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 13, - color: theme.colorScheme.foreground, - ), - ), - if (widget.connection.host != null) - material.Text( - '${widget.connection.host}:${widget.connection.port ?? ''}', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.mutedForeground, - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ], - ), - // Expanded database children — ALL 16 databases - if (_expanded) ...[ - 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(), - ], - ), - ), - if (_error != null) - material.Padding( - padding: const material.EdgeInsets.only(left: 28, top: 4, bottom: 4), - child: material.Text( - 'Error', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, color: theme.colorScheme.destructive), - ), - ), - for (final db in _databases) - _RedisDatabaseNode( - index: db.index, - keys: db.keys, - onTap: () => widget.onDatabaseTap?.call(db.index), - ), - ], - ], - ), - ), - ); - } -} - -class _RedisDatabaseNode extends StatelessWidget { - const _RedisDatabaseNode({ - required this.index, - required this.keys, - required this.onTap, - }); - - final int index; - final int keys; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return material.Padding( - padding: const material.EdgeInsets.only(left: 24), - child: material.MouseRegion( - cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: onTap, - borderRadius: material.BorderRadius.circular(6), - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 8, vertical: 5), - child: material.Row( - children: [ - material.Icon( - material.Icons.dns_rounded, - size: 14, - color: keys > 0 - ? theme.colorScheme.primary.withValues(alpha: 0.7) - : theme.colorScheme.mutedForeground.withValues(alpha: 0.5), - ), - const Gap(8), - material.Expanded( - child: material.Text( - 'db$index', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 12, - color: keys > 0 - ? theme.colorScheme.foreground - : theme.colorScheme.mutedForeground, - ), - ), - ), - if (keys > 0) - material.Text( - '$keys', - style: material.TextStyle( - fontSize: 10, - color: theme.colorScheme.mutedForeground), - ), - ], - ), - ), - ), - ), - ); - } -} - -// ─── MongoDB connection tile with expandable database tree ────────────────── - -class _MongoConnectionTile extends StatefulWidget { - const _MongoConnectionTile({ - required this.connection, - this.isSelected = false, - required this.icon, - this.iconAsset, - required this.onRemove, - this.onTap, - this.onDatabaseTap, - }); - - final ConnectionRow connection; - final bool isSelected; - final material.IconData icon; - final String? iconAsset; - final VoidCallback onRemove; - final VoidCallback? onTap; - final void Function(String database)? onDatabaseTap; - - @override - State<_MongoConnectionTile> createState() => _MongoConnectionTileState(); -} - -class _MongoConnectionTileState extends State<_MongoConnectionTile> { - bool _expanded = false; - bool _loading = false; - String? _error; - List _databases = []; - - void _toggle() { - setState(() => _expanded = !_expanded); - if (_expanded && _databases.isEmpty && !_loading) { - _loadDatabases(); - } - } - - Future _loadDatabases() async { - if (!mounted) return; - setState(() { - _loading = true; - _error = null; - }); - try { - final conn = await MongoService.instance.ensureConnected(widget.connection); - final dbs = await conn.listDatabases(); - - if (!mounted) return; - setState(() { - _databases = dbs; - _loading = false; - }); - } catch (e) { - if (!mounted) return; - setState(() { - _error = e.toString(); - _loading = false; - }); - } - } - - Future _createDatabase() async { - final dbName = await showCreateMongoDBDialog(context); - if (dbName == null || !mounted) return; - _databases = []; - await _loadDatabases(); - } - - Future _deleteDatabase(String dbName) async { - if (!mounted) return; - final ok = await showAppDialog( - context: context, - barrierDismissible: true, - builder: (ctx) => material.AlertDialog( - title: const Text('Drop database?'), - content: Text( - 'Permanently delete database "$dbName"? This cannot be undone.', - ), - actions: [ - OutlineButton( - onPressed: () => material.Navigator.of(ctx).pop(false), - child: const Text('Cancel'), - ), - DestructiveButton( - onPressed: () => material.Navigator.of(ctx).pop(true), - child: const Text('Drop database'), - ), - ], - ), - ); - if (ok != true || !mounted) return; - try { - final conn = await MongoService.instance.ensureConnected(widget.connection); - await conn.dropDatabase(dbName); - - if (mounted) { - _databases = []; - await _loadDatabases(); - } - } catch (e) { - if (mounted) { - setState(() { - _error = e.toString(); - }); - } - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final iconWidget = widget.iconAsset != null - ? material.Image.asset( - widget.iconAsset!, - width: 16, - height: 16, - fit: material.BoxFit.contain, - errorBuilder: (_, __, ___) => material.Icon( - widget.icon, - size: 16, - color: theme.colorScheme.primary, - ), - ) - : material.Icon(widget.icon, size: 16, color: theme.colorScheme.primary); - - return ContextMenu( - items: [ - MenuButton( - leading: material.Icon(material.Icons.add_rounded, - size: 18, color: theme.colorScheme.mutedForeground), - onPressed: (_) => _createDatabase(), - child: const Text('Create database'), - ), - MenuButton( - leading: material.Icon(material.Icons.refresh_rounded, - size: 18, color: theme.colorScheme.mutedForeground), - onPressed: (_) { - _databases = []; - _loadDatabases(); - }, - child: const Text('Refresh databases'), - ), - MenuButton( - leading: material.Icon(material.Icons.delete_outline_rounded, - size: 18, color: theme.colorScheme.mutedForeground), - onPressed: (_) => widget.onRemove(), - child: const Text('Remove connection'), - ), - ], - child: material.Padding( - padding: const material.EdgeInsets.only(bottom: 2), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - // Connection row - material.Row( - children: [ - // Expand/collapse arrow - material.MouseRegion( - cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: _toggle, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(2), - child: material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 100), - child: material.Icon( - material.Icons.chevron_right_rounded, - size: 16, - color: theme.colorScheme.mutedForeground, - ), - ), - ), - ), - ), - // Connection name — clickable for stats - material.Expanded( - child: _sidebarConnectionShell( - context: context, - isSelected: widget.isSelected, - onTap: widget.onTap, - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 4, vertical: 6), - child: material.Row( - children: [ - iconWidget, - const Gap(8), - material.Expanded( - child: material.Column( - crossAxisAlignment: - material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Text( - widget.connection.name, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 13, - color: theme.colorScheme.foreground, - ), - ), - if (widget.connection.host != null) - material.Text( - '${widget.connection.host}:${widget.connection.port ?? ''}', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.mutedForeground, - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ], - ), - // Expanded database children - if (_expanded) ...[ - 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(), - ], - ), - ), - if (_error != null) - material.Padding( - padding: const material.EdgeInsets.only(left: 28, top: 4, bottom: 4, right: 8), - child: material.ConstrainedBox( - constraints: const material.BoxConstraints(maxWidth: double.infinity), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Row( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Icon( - material.Icons.error_outline_rounded, - size: 14, - color: theme.colorScheme.destructive, - ), - const Gap(6), - material.Expanded( - child: material.Text( - 'Could not load databases', - maxLines: 2, - overflow: material.TextOverflow.ellipsis, - style: material.TextStyle( - fontSize: 12, - color: theme.colorScheme.destructive, - ), - ), - ), - ], - ), - const Gap(6), - material.SelectableText( - _error!, - style: material.TextStyle( - fontSize: 10, - height: 1.35, - color: theme.colorScheme.mutedForeground, - ), - ), - ], - ), - ), - ), - for (final db in _databases) - _MongoDatabaseNode( - connection: widget.connection, - name: db, - onTap: () => widget.onDatabaseTap?.call(db), - onDelete: () => _deleteDatabase(db), - onRefreshDatabases: () { - setState(() => _databases = []); - _loadDatabases(); - }, - ), - ], - ], - ), - ), - ); - } -} - -class _MongoDatabaseNode extends StatelessWidget { - const _MongoDatabaseNode({ - required this.connection, - required this.name, - required this.onTap, - required this.onDelete, - required this.onRefreshDatabases, - }); - - final ConnectionRow connection; - final String name; - final VoidCallback onTap; - final VoidCallback onDelete; - final VoidCallback onRefreshDatabases; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return material.Padding( - padding: const material.EdgeInsets.only(left: 16, top: 2, bottom: 2), - child: _PgTreeRow( - label: name, - icon: material.Icons.storage_rounded, - iconSize: 13, - 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', - ), - ); - } -} - -// ─── PostgreSQL connection tile with expandable database tree ──────────────── - -class _PostgresConnectionTile extends StatefulWidget { - const _PostgresConnectionTile({ - required this.connection, - this.isSelected = false, - required this.icon, - this.iconAsset, - required this.onRemove, - this.onTap, - this.onPostgresObjectSelected, - this.onPostgresOpenSqlWorkspace, - }); - - final ConnectionRow connection; - final bool isSelected; - final material.IconData icon; - final String? iconAsset; - final VoidCallback onRemove; - final VoidCallback? onTap; - final void Function( - ConnectionRow connection, - String database, - String schema, - String name, - PostgresObjectKind kind, - )? onPostgresObjectSelected; - final void Function(ConnectionRow connection)? onPostgresOpenSqlWorkspace; - - @override - State<_PostgresConnectionTile> createState() => - _PostgresConnectionTileState(); -} - -class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { - bool _expanded = false; - bool _loading = false; - String? _error; - List _databases = []; - - void _toggle() { - setState(() => _expanded = !_expanded); - if (_expanded && _databases.isEmpty && !_loading) { - _loadDatabases(); - } - } - - Future _loadDatabases() async { - if (!mounted) return; - setState(() { - _loading = true; - _error = null; - }); - PgLease? lease; - try { - final c = widget.connection; - lease = await PostgresService.instance.acquire( - c, - database: c.databaseName ?? 'postgres', - mode: PgSessionMode.readOnly, - ); - final dbs = await lease.connection.listDatabases(); - - if (!mounted) return; - setState(() { - _databases = dbs; - _loading = false; - }); - } catch (e) { - if (!mounted) return; - setState(() { - _error = e.toString(); - _loading = false; - }); - } finally { - lease?.release(); - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final iconWidget = widget.iconAsset != null - ? material.Image.asset( - widget.iconAsset!, - width: 16, - height: 16, - fit: material.BoxFit.contain, - errorBuilder: (_, __, ___) => material.Icon( - widget.icon, - size: 16, - color: theme.colorScheme.primary, - ), - ) - : material.Icon(widget.icon, size: 16, color: theme.colorScheme.primary); - - return ContextMenu( - items: [ - MenuButton( - leading: material.Icon(material.Icons.refresh_rounded, - size: 18, color: theme.colorScheme.mutedForeground), - onPressed: (_) { - _databases = []; - _loadDatabases(); - }, - child: const Text('Refresh databases'), - ), - MenuButton( - leading: material.Icon(material.Icons.delete_outline_rounded, - size: 18, color: theme.colorScheme.mutedForeground), - onPressed: (_) => widget.onRemove(), - child: const Text('Remove connection'), - ), - ], - child: material.Padding( - padding: const material.EdgeInsets.only(bottom: 2), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Row( - children: [ - material.MouseRegion( - cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: _toggle, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(2), - child: material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 100), - child: material.Icon( - material.Icons.chevron_right_rounded, - size: 16, - color: theme.colorScheme.mutedForeground, - ), - ), - ), - ), - ), - material.Expanded( - child: _sidebarConnectionShell( - context: context, - isSelected: widget.isSelected, - onTap: widget.onTap, - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 4, vertical: 6), - child: material.Row( - children: [ - iconWidget, - const Gap(8), - material.Expanded( - child: material.Column( - crossAxisAlignment: - material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Text( - widget.connection.name, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 13, - color: theme.colorScheme.foreground, - ), - ), - if (widget.connection.host != null) - material.Text( - '${widget.connection.host}:${widget.connection.port ?? ''}', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.mutedForeground, - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ], - ), - if (_expanded) ...[ - 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(), - ], - ), - ), - if (_error != null) - material.Padding( - padding: - const material.EdgeInsets.only(left: 28, top: 4, bottom: 4), - child: material.Text( - 'Error', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, color: theme.colorScheme.destructive), - ), - ), - if (_databases.isNotEmpty) - _PgDatabasesNode( - connection: widget.connection, - databases: _databases, - onPostgresObjectSelected: widget.onPostgresObjectSelected, - onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - onRefreshDatabases: () { - setState(() => _databases = []); - _loadDatabases(); - }, - ), - ], - ], - ), - ), - ); - } -} - -// ─── MySQL connection tile (databases → tables) ─────────────────────────────── - -class _MysqlConnectionTile extends StatefulWidget { - const _MysqlConnectionTile({ - required this.connection, - this.isSelected = false, - required this.icon, - this.iconAsset, - required this.onRemove, - this.onTap, - this.onMysqlObjectSelected, - this.onMysqlOpenSqlWorkspace, - }); - - final ConnectionRow connection; - final bool isSelected; - final material.IconData icon; - final String? iconAsset; - final VoidCallback onRemove; - final VoidCallback? onTap; - final void Function( - ConnectionRow connection, - String database, - String name, - MysqlObjectKind kind, - )? onMysqlObjectSelected; - final void Function(ConnectionRow connection)? onMysqlOpenSqlWorkspace; - - @override - State<_MysqlConnectionTile> createState() => _MysqlConnectionTileState(); -} - -class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { - bool _expanded = false; - bool _loading = false; - String? _error; - List _databases = []; - - void _toggle() { - setState(() => _expanded = !_expanded); - if (_expanded && _databases.isEmpty && !_loading) { - _loadDatabases(); - } - } - - Future _loadDatabases() async { - if (!mounted) return; - setState(() { - _loading = true; - _error = null; - }); - MysqlLease? lease; - try { - final c = widget.connection; - lease = await MysqlService.instance.acquire( - c, - database: c.databaseName ?? '', - mode: MysqlSessionMode.readOnly, - ); - final dbs = await lease.connection.listDatabases(); - if (!mounted) return; - setState(() { - _databases = dbs; - _loading = false; - }); - } catch (e) { - if (!mounted) return; - setState(() { - _error = e.toString(); - _loading = false; - }); - } finally { - lease?.release(); - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final iconWidget = widget.iconAsset != null - ? material.Image.asset( - widget.iconAsset!, - width: 16, - height: 16, - fit: material.BoxFit.contain, - errorBuilder: (_, __, ___) => material.Icon( - widget.icon, - size: 16, - color: theme.colorScheme.primary, - ), - ) - : material.Icon(widget.icon, size: 16, color: theme.colorScheme.primary); - - return ContextMenu( - items: [ - MenuButton( - leading: material.Icon(material.Icons.refresh_rounded, - size: 18, color: theme.colorScheme.mutedForeground), - onPressed: (_) { - _databases = []; - _loadDatabases(); - }, - child: const Text('Refresh databases'), - ), - if (widget.onMysqlOpenSqlWorkspace != null) - MenuButton( - leading: material.Icon(material.Icons.terminal_rounded, - size: 18, color: theme.colorScheme.mutedForeground), - onPressed: (_) => widget.onMysqlOpenSqlWorkspace!(widget.connection), - child: const Text('Open in SQL'), - ), - MenuButton( - leading: material.Icon(material.Icons.delete_outline_rounded, - size: 18, color: theme.colorScheme.mutedForeground), - onPressed: (_) => widget.onRemove(), - child: const Text('Remove connection'), - ), - ], - child: material.Padding( - padding: const material.EdgeInsets.only(bottom: 2), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Row( - children: [ - material.MouseRegion( - cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: _toggle, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(2), - child: material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 100), - child: material.Icon( - material.Icons.chevron_right_rounded, - size: 16, - color: theme.colorScheme.mutedForeground, - ), - ), - ), - ), - ), - material.Expanded( - child: _sidebarConnectionShell( - context: context, - isSelected: widget.isSelected, - onTap: widget.onTap, - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 4, vertical: 6), - child: material.Row( - children: [ - iconWidget, - const Gap(8), - material.Expanded( - child: material.Column( - crossAxisAlignment: - material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Text( - widget.connection.name, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 13, - color: theme.colorScheme.foreground, - ), - ), - if (widget.connection.host != null) - material.Text( - '${widget.connection.host}:${widget.connection.port ?? ''}', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, - color: - theme.colorScheme.mutedForeground, - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ], - ), - if (_expanded) ...[ - 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(), - ], - ), - ), - if (_error != null) - material.Padding( - padding: - const material.EdgeInsets.only(left: 28, top: 4, bottom: 4), - child: material.Text( - 'Error', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, color: theme.colorScheme.destructive), - ), - ), - if (_databases.isNotEmpty) - _MysqlDatabasesNode( - connection: widget.connection, - databases: _databases, - onRefreshDatabases: () { - setState(() => _databases = []); - _loadDatabases(); - }, - onMysqlObjectSelected: widget.onMysqlObjectSelected, - onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, - ), - ], - ], - ), - ), - ); - } -} - -class _MysqlDatabasesNode extends material.StatelessWidget { - const _MysqlDatabasesNode({ - required this.connection, - required this.databases, - required this.onRefreshDatabases, - this.onMysqlObjectSelected, - this.onMysqlOpenSqlWorkspace, - }); - - final ConnectionRow connection; - final List databases; - final VoidCallback onRefreshDatabases; - final void Function( - ConnectionRow connection, - String database, - String name, - MysqlObjectKind kind, - )? onMysqlObjectSelected; - final void Function(ConnectionRow connection)? onMysqlOpenSqlWorkspace; - - @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: material.Icons.dns_rounded, - iconSize: 14, - iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), - textStyle: material.TextStyle( - fontSize: 12, - color: theme.colorScheme.foreground, - ), - verticalPadding: 4, - onTap: null, - connection: connection, - onContextRefresh: onRefreshDatabases, - onOpenSqlWorkspace: onMysqlOpenSqlWorkspace, - ), - for (final db in databases) - _MysqlDatabaseNode( - key: material.ValueKey('mysql-db-${connection.id ?? 0}-$db'), - connection: connection, - databaseName: db, - onMysqlObjectSelected: onMysqlObjectSelected, - onMysqlOpenSqlWorkspace: onMysqlOpenSqlWorkspace, - ), - ], - ), - ); - } -} - -class _MysqlDatabaseNode extends StatefulWidget { - const _MysqlDatabaseNode({ - super.key, - required this.connection, - required this.databaseName, - this.onMysqlObjectSelected, - this.onMysqlOpenSqlWorkspace, - }); - - final ConnectionRow connection; - final String databaseName; - final void Function( - ConnectionRow connection, - String database, - String name, - MysqlObjectKind kind, - )? onMysqlObjectSelected; - final void Function(ConnectionRow connection)? onMysqlOpenSqlWorkspace; - - @override - State<_MysqlDatabaseNode> createState() => _MysqlDatabaseNodeState(); -} - -class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { - bool _expanded = false; - bool _loading = false; - List _tables = []; - List _views = []; - - void _toggle() { - setState(() => _expanded = !_expanded); - if (_expanded && _tables.isEmpty && _views.isEmpty && !_loading) { - _loadTables(); - } - } - - Future _loadTables() async { - if (!mounted) return; - setState(() => _loading = true); - MysqlLease? lease; - try { - final c = widget.connection; - lease = await MysqlService.instance.acquire( - c, - database: widget.databaseName, - mode: MysqlSessionMode.readOnly, - ); - final tables = - await lease.connection.listTables(schema: widget.databaseName); - final views = - await lease.connection.listViews(schema: widget.databaseName); - if (!mounted) return; - setState(() { - _tables = tables; - _views = views; - _loading = false; - }); - } catch (e) { - if (!mounted) return; - setState(() => _loading = false); - } finally { - lease?.release(); - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return material.Padding( - padding: const material.EdgeInsets.only(left: 16), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - _PgTreeRow( - label: widget.databaseName, - leading: material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 100), - child: material.Icon( - material.Icons.chevron_right_rounded, - size: 14, - color: theme.colorScheme.mutedForeground, - ), - ), - icon: material.Icons.storage_rounded, - iconSize: 14, - iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), - textStyle: material.TextStyle( - fontSize: 12, - color: theme.colorScheme.foreground, - ), - verticalPadding: 4, - onTap: _toggle, - connection: widget.connection, - onContextRefresh: _loadTables, - onOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, - ), - if (_expanded) ...[ - 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(), - ], - ), - ), - if (_tables.isNotEmpty || _views.isNotEmpty) - material.Padding( - padding: const material.EdgeInsets.only(left: 16), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - if (_tables.isNotEmpty) ...[ - _PgTreeRow( - label: 'Tables (${_tables.length})', - icon: material.Icons.table_chart_rounded, - iconSize: 13, - iconColor: theme.colorScheme.mutedForeground, - textStyle: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.mutedForeground, - ), - verticalPadding: 3, - onTap: null, - connection: widget.connection, - onContextRefresh: _loadTables, - onOpenSqlWorkspace: null, - ), - for (final t in _tables) - material.Padding( - padding: const material.EdgeInsets.only(left: 12), - child: _PgTreeRow( - label: t, - icon: material.Icons.grid_on_rounded, - iconSize: 12, - iconColor: theme.colorScheme.mutedForeground, - textStyle: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.foreground, - ), - verticalPadding: 2, - onTap: widget.onMysqlObjectSelected == null - ? null - : () => widget.onMysqlObjectSelected!( - widget.connection, - widget.databaseName, - t, - MysqlObjectKind.table, - ), - connection: widget.connection, - onContextRefresh: null, - onOpenSqlWorkspace: null, - ), - ), - ], - if (_views.isNotEmpty) ...[ - _PgTreeRow( - label: 'Views (${_views.length})', - icon: material.Icons.view_agenda_rounded, - iconSize: 13, - iconColor: theme.colorScheme.mutedForeground, - textStyle: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.mutedForeground, - ), - verticalPadding: 3, - onTap: null, - connection: widget.connection, - onContextRefresh: _loadTables, - onOpenSqlWorkspace: null, - ), - for (final v in _views) - material.Padding( - padding: const material.EdgeInsets.only(left: 12), - child: _PgTreeRow( - label: v, - icon: material.Icons.view_week_rounded, - iconSize: 12, - iconColor: theme.colorScheme.mutedForeground, - textStyle: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.foreground, - ), - verticalPadding: 2, - onTap: widget.onMysqlObjectSelected == null - ? null - : () => widget.onMysqlObjectSelected!( - widget.connection, - widget.databaseName, - v, - MysqlObjectKind.view, - ), - connection: widget.connection, - onContextRefresh: null, - onOpenSqlWorkspace: null, - ), - ), - ], - ], - ), - ), - ], - ], - ), - ); - } -} - -class _PgDatabasesNode extends StatefulWidget { - const _PgDatabasesNode({ - required this.connection, - required this.databases, - this.onPostgresObjectSelected, - this.onPostgresOpenSqlWorkspace, - required this.onRefreshDatabases, - }); - - final ConnectionRow connection; - final List databases; - final void Function( - ConnectionRow connection, - String database, - String schema, - String name, - PostgresObjectKind kind, - )? onPostgresObjectSelected; - final void Function(ConnectionRow connection)? onPostgresOpenSqlWorkspace; - final VoidCallback onRefreshDatabases; - - @override - State<_PgDatabasesNode> createState() => _PgDatabasesNodeState(); -} - -/// Ellipsis label; tooltip only when text overflows (intrinsic width > slot). -class _PgTreeRowLabel extends material.StatelessWidget { - const _PgTreeRowLabel({ - required this.label, - required this.textStyle, - }); - - final String label; - final material.TextStyle textStyle; - - @override - material.Widget build(material.BuildContext context) { - return material.LayoutBuilder( - builder: (context, constraints) { - final tp = material.TextPainter( - text: material.TextSpan(text: label, style: textStyle), - maxLines: 1, - textDirection: material.TextDirection.ltr, - ); - tp.layout(maxWidth: double.infinity); - final overflow = tp.width > constraints.maxWidth + 0.5; - final text = material.Text( - label, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: textStyle, - ); - if (!overflow) return text; - return material.Tooltip( - message: label, - waitDuration: const Duration(milliseconds: 450), - child: text, - ); - }, - ); - } -} - -/// Shared tree row: consistent ink hover, optional context menu, tooltips when truncated. -class _PgTreeRow extends material.StatelessWidget { - const _PgTreeRow({ - required this.label, - this.leading, - this.icon, - this.iconSize = 13, - this.iconColor, - this.trailing, - this.onTap, - this.verticalPadding = 3, - required this.textStyle, - this.connection, - this.onContextRefresh, - this.onOpenSqlWorkspace, - this.onContextDelete, - this.contextDeleteLabel, - }); - - final String label; - final material.Widget? leading; - final material.IconData? icon; - final double iconSize; - final material.Color? iconColor; - final material.Widget? trailing; - final void Function()? onTap; - final double verticalPadding; - final material.TextStyle textStyle; - final ConnectionRow? connection; - final VoidCallback? onContextRefresh; - final void Function(ConnectionRow connection)? onOpenSqlWorkspace; - final VoidCallback? onContextDelete; - final String? contextDeleteLabel; - - @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.Material( - color: material.Colors.transparent, - child: material.InkWell( - onTap: onTap, - borderRadius: material.BorderRadius.circular(4), - hoverColor: primary.withValues(alpha: 0.07), - 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: iconColor ?? muted, - ), - const Gap(6), - ], - material.Expanded( - child: _PgTreeRowLabel(label: label, textStyle: textStyle), - ), - if (trailing != null) trailing!, - ], - ), - ), - ), - ); - if (connection == null) return row; - return ContextMenu( - items: [ - 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) - MenuButton( - leading: material.Icon( - material.Icons.terminal_rounded, - size: 18, - color: theme.colorScheme.mutedForeground, - ), - onPressed: (_) => onOpenSqlWorkspace!(connection!), - child: const Text('Open in SQL'), - ), - if (onContextDelete != null) - MenuButton( - leading: material.Icon( - material.Icons.delete_outline_rounded, - size: 18, - color: theme.colorScheme.destructive, - ), - onPressed: (_) => onContextDelete!(), - child: Text(contextDeleteLabel ?? 'Delete'), - ), - ], - child: row, - ); - } -} - -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: const Duration(milliseconds: 100), - child: material.Icon( - material.Icons.chevron_right_rounded, - size: 14, - color: theme.colorScheme.mutedForeground, - ), - ), - icon: material.Icons.dns_rounded, - iconSize: 14, - iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), - textStyle: material.TextStyle( - fontSize: 12, - color: theme.colorScheme.foreground, - ), - verticalPadding: 4, - onTap: () => setState(() => _expanded = !_expanded), - connection: widget.connection, - onContextRefresh: widget.onRefreshDatabases, - onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - ), - if (_expanded) - for (final db in widget.databases) - _PgDatabaseNode( - key: material.ValueKey('pg-db-${widget.connection.id ?? 0}-$db'), - connection: widget.connection, - databaseName: db, - onPostgresObjectSelected: widget.onPostgresObjectSelected, - onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - ), - ], - ), - ); - } -} - -class _PgDatabaseNode extends StatefulWidget { - const _PgDatabaseNode({ - super.key, - required this.connection, - required this.databaseName, - this.onPostgresObjectSelected, - this.onPostgresOpenSqlWorkspace, - }); - - final ConnectionRow connection; - final String databaseName; - final void Function( - ConnectionRow connection, - String database, - String schema, - String name, - PostgresObjectKind kind, - )? onPostgresObjectSelected; - final void Function(ConnectionRow connection)? onPostgresOpenSqlWorkspace; - - @override - State<_PgDatabaseNode> createState() => _PgDatabaseNodeState(); -} - -class _PgDatabaseNodeState extends State<_PgDatabaseNode> { - bool _expanded = false; - bool _loading = false; - List _schemas = []; - - void _toggle() { - setState(() => _expanded = !_expanded); - if (_expanded && _schemas.isEmpty && !_loading) { - _loadSchemas(); - } - } - - Future _loadSchemas() async { - if (!mounted) return; - setState(() => _loading = true); - PgLease? lease; - try { - final c = widget.connection; - lease = await PostgresService.instance.acquire( - c, - database: widget.databaseName, - mode: PgSessionMode.readOnly, - ); - final schemas = await lease.connection.listSchemas(); - if (!mounted) return; - setState(() { - _schemas = schemas; - _loading = false; - }); - } catch (e) { - if (!mounted) return; - setState(() => _loading = false); - } finally { - lease?.release(); - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return material.Padding( - padding: const material.EdgeInsets.only(left: 16), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - _PgTreeRow( - label: widget.databaseName, - leading: material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 100), - child: material.Icon( - material.Icons.chevron_right_rounded, - size: 14, - color: theme.colorScheme.mutedForeground, - ), - ), - icon: material.Icons.storage_rounded, - iconSize: 14, - iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), - textStyle: material.TextStyle( - fontSize: 12, - color: theme.colorScheme.foreground, - ), - verticalPadding: 4, - onTap: _toggle, - connection: widget.connection, - onContextRefresh: _loadSchemas, - onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - ), - if (_expanded) ...[ - _PgDbToolRow( - connection: widget.connection, - databaseName: widget.databaseName, - label: 'Extensions', - icon: material.Icons.extension_rounded, - kind: PostgresObjectKind.databaseExtensions, - onPostgresObjectSelected: widget.onPostgresObjectSelected, - onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - onContextRefresh: _loadSchemas, - ), - _PgDbToolRow( - connection: widget.connection, - databaseName: widget.databaseName, - label: 'Foreign data', - icon: material.Icons.public_rounded, - kind: PostgresObjectKind.databaseForeignData, - onPostgresObjectSelected: widget.onPostgresObjectSelected, - onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - 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(), - ], - ), - ), - if (_schemas.isNotEmpty) - _PgSchemasNode( - connection: widget.connection, - databaseName: widget.databaseName, - schemas: _schemas, - onPostgresObjectSelected: widget.onPostgresObjectSelected, - onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - onRefreshSchemas: _loadSchemas, - ), - ], - ], - ), - ); - } -} - -class _PgDbToolRow extends material.StatelessWidget { - const _PgDbToolRow({ - required this.connection, - required this.databaseName, - required this.label, - required this.icon, - required this.kind, - this.onPostgresObjectSelected, - this.onPostgresOpenSqlWorkspace, - this.onContextRefresh, - }); - - final ConnectionRow connection; - final String databaseName; - final String label; - final material.IconData icon; - final PostgresObjectKind kind; - final void Function( - ConnectionRow connection, - String database, - String schema, - String name, - PostgresObjectKind kind, - )? onPostgresObjectSelected; - final void Function(ConnectionRow connection)? onPostgresOpenSqlWorkspace; - final VoidCallback? onContextRefresh; - - @override - 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( - label: label, - icon: icon, - iconSize: 13, - iconColor: muted, - trailing: material.Icon( - material.Icons.chevron_right_rounded, - size: 13, - color: muted, - ), - onTap: onPostgresObjectSelected == null - ? null - : () => onPostgresObjectSelected!( - connection, - databaseName, - '', - '', - kind, - ), - textStyle: material.TextStyle( - fontSize: 11, - color: muted, - ), - connection: connection, - onContextRefresh: onContextRefresh, - onOpenSqlWorkspace: onPostgresOpenSqlWorkspace, - ), - ); - } -} - -class _PgSchemasNode extends StatefulWidget { - const _PgSchemasNode({ - required this.connection, - required this.databaseName, - required this.schemas, - this.onPostgresObjectSelected, - this.onPostgresOpenSqlWorkspace, - required this.onRefreshSchemas, - }); - - final ConnectionRow connection; - final String databaseName; - final List schemas; - final void Function( - ConnectionRow connection, - String database, - String schema, - String name, - PostgresObjectKind kind, - )? onPostgresObjectSelected; - final void Function(ConnectionRow connection)? onPostgresOpenSqlWorkspace; - final VoidCallback onRefreshSchemas; - - @override - State<_PgSchemasNode> createState() => _PgSchemasNodeState(); -} - -class _PgSchemasNodeState extends State<_PgSchemasNode> { - bool _expanded = true; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return material.Padding( - padding: const material.EdgeInsets.only(left: 16), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - _PgTreeRow( - label: 'Schemas (${widget.schemas.length})', - leading: material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 100), - child: material.Icon( - material.Icons.chevron_right_rounded, - size: 14, - color: theme.colorScheme.mutedForeground, - ), - ), - icon: material.Icons.account_tree_rounded, - iconSize: 13, - iconColor: theme.colorScheme.mutedForeground, - textStyle: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.mutedForeground, - ), - onTap: () => setState(() => _expanded = !_expanded), - connection: widget.connection, - onContextRefresh: widget.onRefreshSchemas, - onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - ), - if (_expanded) - for (final schema in widget.schemas) - _PgSchemaNode( - key: material.ValueKey( - 'pg-schema-${widget.connection.id ?? 0}-${widget.databaseName}-$schema', - ), - connection: widget.connection, - databaseName: widget.databaseName, - schemaName: schema, - onPostgresObjectSelected: widget.onPostgresObjectSelected, - onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - ), - ], - ), - ); - } -} - -class _PgSchemaNode extends StatefulWidget { - const _PgSchemaNode({ - super.key, - required this.connection, - required this.databaseName, - required this.schemaName, - this.onPostgresObjectSelected, - this.onPostgresOpenSqlWorkspace, - }); - - final ConnectionRow connection; - final String databaseName; - final String schemaName; - final void Function( - ConnectionRow connection, - String database, - String schema, - String name, - PostgresObjectKind kind, - )? onPostgresObjectSelected; - final void Function(ConnectionRow connection)? onPostgresOpenSqlWorkspace; - - @override - State<_PgSchemaNode> createState() => _PgSchemaNodeState(); -} - -class _PgSchemaNodeState extends State<_PgSchemaNode> { - bool _expanded = false; - bool _loading = false; - List _tables = []; - List _views = []; - List _matviews = []; - List _functions = []; - List _sequences = []; - bool _loaded = false; - - void _toggle() { - setState(() => _expanded = !_expanded); - if (_expanded && !_loaded && !_loading) { - _loadObjects(); - } - } - - Future _loadObjects() async { - if (!mounted) return; - setState(() => _loading = true); - PgLease? lease; - try { - final c = widget.connection; - lease = await PostgresService.instance.acquire( - c, - database: widget.databaseName, - mode: PgSessionMode.readOnly, - ); - final conn = lease.connection; - final tables = await conn.listTables(schema: widget.schemaName); - final views = await conn.listViews(schema: widget.schemaName); - List matviews = []; - try { - matviews = - await conn.listMaterializedViews(schema: widget.schemaName); - } catch (_) { - // pg_matviews / permissions may fail on some servers; keep tree usable. - } - final functions = await conn.listFunctions(schema: widget.schemaName); - final sequences = await conn.listSequences(schema: widget.schemaName); - if (!mounted) return; - setState(() { - _tables = tables; - _views = views; - _matviews = matviews; - _functions = functions; - _sequences = sequences; - _loading = false; - _loaded = true; - }); - } catch (e) { - if (!mounted) return; - setState(() => _loading = false); - } finally { - lease?.release(); - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return material.Padding( - padding: const material.EdgeInsets.only(left: 12), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - _PgTreeRow( - label: widget.schemaName, - leading: material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 100), - child: material.Icon( - material.Icons.chevron_right_rounded, - size: 14, - color: theme.colorScheme.mutedForeground, - ), - ), - icon: material.Icons.diamond_outlined, - iconSize: 13, - iconColor: theme.colorScheme.primary.withValues(alpha: 0.6), - textStyle: material.TextStyle( - fontSize: 12, - color: theme.colorScheme.foreground, - ), - onTap: _toggle, - connection: widget.connection, - onContextRefresh: _loadObjects, - onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - ), - if (_expanded) ...[ - 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(), - ], - ), - ), - if (_loaded) ...[ - _PgObjectGroup( - connection: widget.connection, - onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - onRefresh: _loadObjects, - label: 'Tables', - icon: material.Icons.table_chart_rounded, - items: _tables, - onItemTap: widget.onPostgresObjectSelected != null - ? (name) => widget.onPostgresObjectSelected!( - widget.connection, - widget.databaseName, - widget.schemaName, - name, - PostgresObjectKind.table, - ) - : null, - ), - _PgObjectGroup( - connection: widget.connection, - onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - onRefresh: _loadObjects, - label: 'Views', - icon: material.Icons.view_agenda_rounded, - items: _views, - onItemTap: widget.onPostgresObjectSelected != null - ? (name) => widget.onPostgresObjectSelected!( - widget.connection, - widget.databaseName, - widget.schemaName, - name, - PostgresObjectKind.view, - ) - : null, - ), - _PgObjectGroup( - connection: widget.connection, - onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - onRefresh: _loadObjects, - label: 'Materialized views', - icon: material.Icons.dynamic_feed_rounded, - items: _matviews, - onItemTap: widget.onPostgresObjectSelected != null - ? (name) => widget.onPostgresObjectSelected!( - widget.connection, - widget.databaseName, - widget.schemaName, - name, - PostgresObjectKind.materializedView, - ) - : null, - ), - _PgObjectGroup( - connection: widget.connection, - onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - onRefresh: _loadObjects, - label: 'Functions', - icon: material.Icons.functions_rounded, - items: _functions, - onItemTap: widget.onPostgresObjectSelected != null - ? (name) => widget.onPostgresObjectSelected!( - widget.connection, - widget.databaseName, - widget.schemaName, - name, - PostgresObjectKind.function, - ) - : null, - ), - _PgObjectGroup( - connection: widget.connection, - onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - onRefresh: _loadObjects, - label: 'Sequences', - icon: material.Icons.format_list_numbered_rounded, - items: _sequences, - onItemTap: widget.onPostgresObjectSelected != null - ? (name) => widget.onPostgresObjectSelected!( - widget.connection, - widget.databaseName, - widget.schemaName, - name, - PostgresObjectKind.sequence, - ) - : null, - ), - _PgSchemaToolRow( - connection: widget.connection, - databaseName: widget.databaseName, - schemaName: widget.schemaName, - label: 'Indexes', - icon: material.Icons.table_rows_rounded, - kind: PostgresObjectKind.schemaIndexes, - onPostgresObjectSelected: widget.onPostgresObjectSelected, - onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - onContextRefresh: _loadObjects, - ), - _PgSchemaToolRow( - connection: widget.connection, - databaseName: widget.databaseName, - schemaName: widget.schemaName, - label: 'Triggers', - icon: material.Icons.bolt_rounded, - kind: PostgresObjectKind.schemaTriggers, - onPostgresObjectSelected: widget.onPostgresObjectSelected, - onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - onContextRefresh: _loadObjects, - ), - _PgSchemaToolRow( - connection: widget.connection, - databaseName: widget.databaseName, - schemaName: widget.schemaName, - label: 'Types', - icon: material.Icons.category_rounded, - kind: PostgresObjectKind.schemaTypes, - onPostgresObjectSelected: widget.onPostgresObjectSelected, - onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - onContextRefresh: _loadObjects, - ), - ], - ], - ], - ), - ); - } -} - -class _PgSchemaToolRow extends material.StatelessWidget { - const _PgSchemaToolRow({ - required this.connection, - required this.databaseName, - required this.schemaName, - required this.label, - required this.icon, - required this.kind, - this.onPostgresObjectSelected, - this.onPostgresOpenSqlWorkspace, - this.onContextRefresh, - }); - - final ConnectionRow connection; - final String databaseName; - final String schemaName; - final String label; - final material.IconData icon; - final PostgresObjectKind kind; - final void Function( - ConnectionRow connection, - String database, - String schema, - String name, - PostgresObjectKind kind, - )? onPostgresObjectSelected; - final void Function(ConnectionRow connection)? onPostgresOpenSqlWorkspace; - final VoidCallback? onContextRefresh; - - @override - 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( - label: label, - icon: icon, - iconSize: 13, - iconColor: muted, - trailing: material.Icon( - material.Icons.chevron_right_rounded, - size: 13, - color: muted, - ), - onTap: onPostgresObjectSelected == null - ? null - : () => onPostgresObjectSelected!( - connection, - databaseName, - schemaName, - '', - kind, - ), - textStyle: material.TextStyle( - fontSize: 11, - color: muted, - ), - connection: connection, - onContextRefresh: onContextRefresh, - onOpenSqlWorkspace: onPostgresOpenSqlWorkspace, - ), - ); - } -} - -class _PgObjectGroup extends StatefulWidget { - const _PgObjectGroup({ - required this.connection, - required this.onRefresh, - required this.label, - required this.icon, - required this.items, - this.onPostgresOpenSqlWorkspace, - this.onItemTap, - }); - - final ConnectionRow connection; - final VoidCallback onRefresh; - final String label; - final material.IconData icon; - final List items; - final void Function(ConnectionRow connection)? onPostgresOpenSqlWorkspace; - final void Function(String itemName)? onItemTap; - - @override - State<_PgObjectGroup> createState() => _PgObjectGroupState(); -} - -class _PgObjectGroupState extends State<_PgObjectGroup> { - bool _expanded = false; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return material.Padding( - padding: const material.EdgeInsets.only(left: 16), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - _PgTreeRow( - label: '${widget.label} (${widget.items.length})', - leading: material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 100), - child: material.Icon( - material.Icons.chevron_right_rounded, - size: 13, - color: theme.colorScheme.mutedForeground, - ), - ), - icon: widget.icon, - iconSize: 13, - iconColor: theme.colorScheme.mutedForeground, - textStyle: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.mutedForeground, - ), - onTap: () => setState(() => _expanded = !_expanded), - connection: widget.connection, - onContextRefresh: widget.onRefresh, - onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - ), - if (_expanded) - for (final item in widget.items) - material.Padding( - padding: const material.EdgeInsets.only(left: 22), - child: _PgTreeRow( - label: item, - icon: widget.icon, - iconSize: 12, - iconColor: - theme.colorScheme.primary.withValues(alpha: 0.5), - 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, - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/connections/connections_panel_mongo.dart b/lib/features/connections/connections_panel_mongo.dart new file mode 100644 index 0000000..2e5a422 --- /dev/null +++ b/lib/features/connections/connections_panel_mongo.dart @@ -0,0 +1,349 @@ +part of 'package:querya_desktop/features/connections/connections_panel.dart'; + +// ─── MongoDB connection tile with expandable database tree ────────────────── + +class _MongoConnectionTile extends StatefulWidget { + const _MongoConnectionTile({ + required this.connection, + this.isSelected = false, + required this.icon, + this.iconAsset, + required this.onRemove, + this.onTap, + this.onDatabaseTap, + }); + + final ConnectionRow connection; + final bool isSelected; + final material.IconData icon; + final String? iconAsset; + final VoidCallback onRemove; + final VoidCallback? onTap; + final void Function(String database)? onDatabaseTap; + + @override + State<_MongoConnectionTile> createState() => _MongoConnectionTileState(); +} + +class _MongoConnectionTileState extends State<_MongoConnectionTile> { + bool _expanded = false; + bool _loading = false; + String? _error; + List _databases = []; + + void _toggle() { + setState(() => _expanded = !_expanded); + if (_expanded && _databases.isEmpty && !_loading) { + _loadDatabases(); + } + } + + Future _loadDatabases() async { + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final conn = await MongoService.instance.ensureConnected(widget.connection); + final dbs = await conn.listDatabases(); + + if (!mounted) return; + setState(() { + _databases = dbs; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + Future _createDatabase() async { + final dbName = await showCreateMongoDBDialog(context); + if (dbName == null || !mounted) return; + _databases = []; + await _loadDatabases(); + } + + Future _deleteDatabase(String dbName) async { + if (!mounted) return; + final ok = await showAppDialog( + context: context, + barrierDismissible: true, + builder: (ctx) => material.AlertDialog( + title: const Text('Drop database?'), + content: Text( + 'Permanently delete database "$dbName"? This cannot be undone.', + ), + actions: [ + OutlineButton( + onPressed: () => material.Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + DestructiveButton( + onPressed: () => material.Navigator.of(ctx).pop(true), + child: const Text('Drop database'), + ), + ], + ), + ); + if (ok != true || !mounted) return; + try { + final conn = await MongoService.instance.ensureConnected(widget.connection); + await conn.dropDatabase(dbName); + + if (mounted) { + _databases = []; + await _loadDatabases(); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + }); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final iconWidget = widget.iconAsset != null + ? material.Image.asset( + widget.iconAsset!, + width: 16, + height: 16, + fit: material.BoxFit.contain, + errorBuilder: (_, __, ___) => material.Icon( + widget.icon, + size: 16, + color: theme.colorScheme.primary, + ), + ) + : material.Icon(widget.icon, size: 16, color: theme.colorScheme.primary); + + return ContextMenu( + items: [ + MenuButton( + leading: material.Icon(material.Icons.add_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => _createDatabase(), + child: const Text('Create database'), + ), + MenuButton( + leading: material.Icon(material.Icons.refresh_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) { + _databases = []; + _loadDatabases(); + }, + child: const Text('Refresh databases'), + ), + MenuButton( + leading: material.Icon(material.Icons.delete_outline_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onRemove(), + child: const Text('Remove connection'), + ), + ], + child: material.Padding( + padding: const material.EdgeInsets.only(bottom: 2), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + // Connection row + material.Row( + children: [ + // Expand/collapse arrow + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 100), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 16, + color: theme.colorScheme.mutedForeground, + ), + ), + ), + ), + ), + // Connection name — clickable for stats + material.Expanded( + child: _sidebarConnectionShell( + context: context, + isSelected: widget.isSelected, + onTap: widget.onTap, + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 4, vertical: 6), + child: material.Row( + children: [ + iconWidget, + const Gap(8), + material.Expanded( + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Text( + widget.connection.name, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + color: theme.colorScheme.foreground, + ), + ), + if (widget.connection.host != null) + material.Text( + '${widget.connection.host}:${widget.connection.port ?? ''}', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ], + ), + // Expanded database children + if (_expanded) ...[ + 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(), + ], + ), + ), + if (_error != null) + material.Padding( + padding: const material.EdgeInsets.only(left: 28, top: 4, bottom: 4, right: 8), + child: material.ConstrainedBox( + constraints: const material.BoxConstraints(maxWidth: double.infinity), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Icon( + material.Icons.error_outline_rounded, + size: 14, + color: theme.colorScheme.destructive, + ), + const Gap(6), + material.Expanded( + child: material.Text( + 'Could not load databases', + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.destructive, + ), + ), + ), + ], + ), + const Gap(6), + material.SelectableText( + _error!, + style: material.TextStyle( + fontSize: 10, + height: 1.35, + color: theme.colorScheme.mutedForeground, + ), + ), + ], + ), + ), + ), + for (final db in _databases) + _MongoDatabaseNode( + connection: widget.connection, + name: db, + onTap: () => widget.onDatabaseTap?.call(db), + onDelete: () => _deleteDatabase(db), + onRefreshDatabases: () { + setState(() => _databases = []); + _loadDatabases(); + }, + ), + ], + ], + ), + ), + ); + } +} + +class _MongoDatabaseNode extends StatelessWidget { + const _MongoDatabaseNode({ + required this.connection, + required this.name, + required this.onTap, + required this.onDelete, + required this.onRefreshDatabases, + }); + + final ConnectionRow connection; + final String name; + final VoidCallback onTap; + final VoidCallback onDelete; + final VoidCallback onRefreshDatabases; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 16, top: 2, bottom: 2), + child: _PgTreeRow( + label: name, + icon: material.Icons.storage_rounded, + iconSize: 13, + 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 new file mode 100644 index 0000000..61684b7 --- /dev/null +++ b/lib/features/connections/connections_panel_mysql.dart @@ -0,0 +1,525 @@ +part of 'package:querya_desktop/features/connections/connections_panel.dart'; + +// ─── MySQL connection tile (databases → tables) ─────────────────────────────── + +class _MysqlConnectionTile extends StatefulWidget { + const _MysqlConnectionTile({ + required this.connection, + this.isSelected = false, + required this.icon, + this.iconAsset, + required this.onRemove, + this.onTap, + this.onMysqlObjectSelected, + this.onMysqlOpenSqlWorkspace, + }); + + final ConnectionRow connection; + final bool isSelected; + final material.IconData icon; + final String? iconAsset; + final VoidCallback onRemove; + final VoidCallback? onTap; + final void Function( + ConnectionRow connection, + String database, + String name, + MysqlObjectKind kind, + )? onMysqlObjectSelected; + final void Function(ConnectionRow connection)? onMysqlOpenSqlWorkspace; + + @override + State<_MysqlConnectionTile> createState() => _MysqlConnectionTileState(); +} + +class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { + bool _expanded = false; + bool _loading = false; + String? _error; + List _databases = []; + + void _toggle() { + setState(() => _expanded = !_expanded); + if (_expanded && _databases.isEmpty && !_loading) { + _loadDatabases(); + } + } + + Future _loadDatabases() async { + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + MysqlLease? lease; + try { + final c = widget.connection; + lease = await MysqlService.instance.acquire( + c, + database: c.databaseName ?? '', + mode: MysqlSessionMode.readOnly, + ); + final dbs = await lease.connection.listDatabases(); + if (!mounted) return; + setState(() { + _databases = dbs; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } finally { + lease?.release(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final iconWidget = widget.iconAsset != null + ? material.Image.asset( + widget.iconAsset!, + width: 16, + height: 16, + fit: material.BoxFit.contain, + errorBuilder: (_, __, ___) => material.Icon( + widget.icon, + size: 16, + color: theme.colorScheme.primary, + ), + ) + : material.Icon(widget.icon, size: 16, color: theme.colorScheme.primary); + + return ContextMenu( + items: [ + MenuButton( + leading: material.Icon(material.Icons.refresh_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) { + _databases = []; + _loadDatabases(); + }, + child: const Text('Refresh databases'), + ), + if (widget.onMysqlOpenSqlWorkspace != null) + MenuButton( + leading: material.Icon(material.Icons.terminal_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onMysqlOpenSqlWorkspace!(widget.connection), + child: const Text('Open in SQL'), + ), + MenuButton( + leading: material.Icon(material.Icons.delete_outline_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onRemove(), + child: const Text('Remove connection'), + ), + ], + child: material.Padding( + padding: const material.EdgeInsets.only(bottom: 2), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Row( + children: [ + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 100), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 16, + color: theme.colorScheme.mutedForeground, + ), + ), + ), + ), + ), + material.Expanded( + child: _sidebarConnectionShell( + context: context, + isSelected: widget.isSelected, + onTap: widget.onTap, + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 4, vertical: 6), + child: material.Row( + children: [ + iconWidget, + const Gap(8), + material.Expanded( + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Text( + widget.connection.name, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + color: theme.colorScheme.foreground, + ), + ), + if (widget.connection.host != null) + material.Text( + '${widget.connection.host}:${widget.connection.port ?? ''}', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, + color: + theme.colorScheme.mutedForeground, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ], + ), + if (_expanded) ...[ + 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(), + ], + ), + ), + if (_error != null) + material.Padding( + padding: + const material.EdgeInsets.only(left: 28, top: 4, bottom: 4), + child: material.Text( + 'Error', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, color: theme.colorScheme.destructive), + ), + ), + if (_databases.isNotEmpty) + _MysqlDatabasesNode( + connection: widget.connection, + databases: _databases, + onRefreshDatabases: () { + setState(() => _databases = []); + _loadDatabases(); + }, + onMysqlObjectSelected: widget.onMysqlObjectSelected, + onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, + ), + ], + ], + ), + ), + ); + } +} + +class _MysqlDatabasesNode extends material.StatelessWidget { + const _MysqlDatabasesNode({ + required this.connection, + required this.databases, + required this.onRefreshDatabases, + this.onMysqlObjectSelected, + this.onMysqlOpenSqlWorkspace, + }); + + final ConnectionRow connection; + final List databases; + final VoidCallback onRefreshDatabases; + final void Function( + ConnectionRow connection, + String database, + String name, + MysqlObjectKind kind, + )? onMysqlObjectSelected; + final void Function(ConnectionRow connection)? onMysqlOpenSqlWorkspace; + + @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: material.Icons.dns_rounded, + iconSize: 14, + iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), + textStyle: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.foreground, + ), + verticalPadding: 4, + onTap: null, + connection: connection, + onContextRefresh: onRefreshDatabases, + onOpenSqlWorkspace: onMysqlOpenSqlWorkspace == null + ? null + : (c, {database, schema, name, kind}) => + onMysqlOpenSqlWorkspace!(c), + ), + for (final db in databases) + _MysqlDatabaseNode( + key: material.ValueKey('mysql-db-${connection.id ?? 0}-$db'), + connection: connection, + databaseName: db, + onMysqlObjectSelected: onMysqlObjectSelected, + onMysqlOpenSqlWorkspace: onMysqlOpenSqlWorkspace, + ), + ], + ), + ); + } +} + +class _MysqlDatabaseNode extends StatefulWidget { + const _MysqlDatabaseNode({ + super.key, + required this.connection, + required this.databaseName, + this.onMysqlObjectSelected, + this.onMysqlOpenSqlWorkspace, + }); + + final ConnectionRow connection; + final String databaseName; + final void Function( + ConnectionRow connection, + String database, + String name, + MysqlObjectKind kind, + )? onMysqlObjectSelected; + final void Function(ConnectionRow connection)? onMysqlOpenSqlWorkspace; + + @override + State<_MysqlDatabaseNode> createState() => _MysqlDatabaseNodeState(); +} + +class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { + bool _expanded = false; + bool _loading = false; + List _tables = []; + List _views = []; + + void _toggle() { + setState(() => _expanded = !_expanded); + if (_expanded && _tables.isEmpty && _views.isEmpty && !_loading) { + _loadTables(); + } + } + + Future _loadTables() async { + if (!mounted) return; + setState(() => _loading = true); + MysqlLease? lease; + try { + final c = widget.connection; + lease = await MysqlService.instance.acquire( + c, + database: widget.databaseName, + mode: MysqlSessionMode.readOnly, + ); + final tables = + await lease.connection.listTables(schema: widget.databaseName); + final views = + await lease.connection.listViews(schema: widget.databaseName); + if (!mounted) return; + setState(() { + _tables = tables; + _views = views; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() => _loading = false); + } finally { + lease?.release(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + _PgTreeRow( + label: widget.databaseName, + leading: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 100), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, + ), + ), + icon: material.Icons.storage_rounded, + iconSize: 14, + iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), + textStyle: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.foreground, + ), + verticalPadding: 4, + onTap: _toggle, + connection: widget.connection, + onContextRefresh: _loadTables, + onOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace == null + ? null + : (c, {database, schema, name, kind}) => + widget.onMysqlOpenSqlWorkspace!(c), + ), + if (_expanded) ...[ + 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(), + ], + ), + ), + if (_tables.isNotEmpty || _views.isNotEmpty) + material.Padding( + padding: const material.EdgeInsets.only(left: 16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + if (_tables.isNotEmpty) ...[ + _PgTreeRow( + label: 'Tables (${_tables.length})', + icon: material.Icons.table_chart_rounded, + iconSize: 13, + iconColor: theme.colorScheme.mutedForeground, + textStyle: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + verticalPadding: 3, + onTap: null, + connection: widget.connection, + onContextRefresh: _loadTables, + onOpenSqlWorkspace: null, + ), + for (final t in _tables) + material.Padding( + padding: const material.EdgeInsets.only(left: 12), + child: _PgTreeRow( + label: t, + icon: material.Icons.grid_on_rounded, + iconSize: 12, + iconColor: theme.colorScheme.mutedForeground, + textStyle: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.foreground, + ), + verticalPadding: 2, + onTap: widget.onMysqlObjectSelected == null + ? null + : () => widget.onMysqlObjectSelected!( + widget.connection, + widget.databaseName, + t, + MysqlObjectKind.table, + ), + connection: widget.connection, + onContextRefresh: null, + onOpenSqlWorkspace: null, + ), + ), + ], + if (_views.isNotEmpty) ...[ + _PgTreeRow( + label: 'Views (${_views.length})', + icon: material.Icons.view_agenda_rounded, + iconSize: 13, + iconColor: theme.colorScheme.mutedForeground, + textStyle: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + verticalPadding: 3, + onTap: null, + connection: widget.connection, + onContextRefresh: _loadTables, + onOpenSqlWorkspace: null, + ), + for (final v in _views) + material.Padding( + padding: const material.EdgeInsets.only(left: 12), + child: _PgTreeRow( + label: v, + icon: material.Icons.view_week_rounded, + iconSize: 12, + iconColor: theme.colorScheme.mutedForeground, + textStyle: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.foreground, + ), + verticalPadding: 2, + onTap: widget.onMysqlObjectSelected == null + ? null + : () => widget.onMysqlObjectSelected!( + widget.connection, + widget.databaseName, + v, + MysqlObjectKind.view, + ), + connection: widget.connection, + onContextRefresh: null, + onOpenSqlWorkspace: null, + ), + ), + ], + ], + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart new file mode 100644 index 0000000..2ff0fc9 --- /dev/null +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -0,0 +1,988 @@ +part of 'package:querya_desktop/features/connections/connections_panel.dart'; + +class _PgDatabasesNode extends StatefulWidget { + const _PgDatabasesNode({ + required this.connection, + required this.databases, + this.onPostgresObjectSelected, + this.onPostgresOpenSqlWorkspace, + required this.onRefreshDatabases, + }); + + final ConnectionRow connection; + final List databases; + final void Function( + ConnectionRow connection, + String database, + String schema, + String name, + PostgresObjectKind kind, + )? onPostgresObjectSelected; + final OnPostgresOpenSqlWorkspace? onPostgresOpenSqlWorkspace; + final VoidCallback onRefreshDatabases; + + @override + State<_PgDatabasesNode> createState() => _PgDatabasesNodeState(); +} + +/// Ellipsis label; tooltip only when text overflows (intrinsic width > slot). +class _PgTreeRowLabel extends material.StatelessWidget { + const _PgTreeRowLabel({ + required this.label, + required this.textStyle, + }); + + final String label; + final material.TextStyle textStyle; + + @override + material.Widget build(material.BuildContext context) { + return material.LayoutBuilder( + builder: (context, constraints) { + final tp = material.TextPainter( + text: material.TextSpan(text: label, style: textStyle), + maxLines: 1, + textDirection: material.TextDirection.ltr, + ); + tp.layout(maxWidth: double.infinity); + final overflow = tp.width > constraints.maxWidth + 0.5; + final text = material.Text( + label, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: textStyle, + ); + if (!overflow) return text; + return material.Tooltip( + message: label, + waitDuration: const Duration(milliseconds: 450), + child: text, + ); + }, + ); + } +} + +/// Shared tree row: consistent ink hover, optional context menu, tooltips when truncated. +class _PgTreeRow extends material.StatelessWidget { + const _PgTreeRow({ + required this.label, + this.leading, + this.icon, + this.iconSize = 13, + this.iconColor, + this.trailing, + this.onTap, + this.verticalPadding = 3, + required this.textStyle, + this.connection, + this.onContextRefresh, + this.onOpenSqlWorkspace, + this.openSqlDatabase, + this.openSqlSchema, + this.openSqlName, + this.openSqlKind, + this.onContextDelete, + this.contextDeleteLabel, + }); + + final String label; + final material.Widget? leading; + final material.IconData? icon; + final double iconSize; + final material.Color? iconColor; + final material.Widget? trailing; + final void Function()? onTap; + 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; + + @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.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: onTap, + borderRadius: material.BorderRadius.circular(4), + hoverColor: primary.withValues(alpha: 0.07), + 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: iconColor ?? muted, + ), + const Gap(6), + ], + material.Expanded( + child: _PgTreeRowLabel(label: label, textStyle: textStyle), + ), + if (trailing != null) trailing!, + ], + ), + ), + ), + ); + if (connection == null) return row; + return ContextMenu( + items: [ + 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) + 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) + MenuButton( + leading: material.Icon( + material.Icons.delete_outline_rounded, + size: 18, + color: theme.colorScheme.destructive, + ), + onPressed: (_) => onContextDelete!(), + child: Text(contextDeleteLabel ?? 'Delete'), + ), + ], + child: row, + ); + } +} + +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: const Duration(milliseconds: 100), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, + ), + ), + icon: material.Icons.dns_rounded, + iconSize: 14, + iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), + textStyle: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.foreground, + ), + verticalPadding: 4, + onTap: () => setState(() => _expanded = !_expanded), + connection: widget.connection, + onContextRefresh: widget.onRefreshDatabases, + onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + ), + if (_expanded) + for (final db in widget.databases) + _PgDatabaseNode( + key: material.ValueKey('pg-db-${widget.connection.id ?? 0}-$db'), + connection: widget.connection, + databaseName: db, + onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + ), + ], + ), + ); + } +} + +class _PgDatabaseNode extends StatefulWidget { + const _PgDatabaseNode({ + super.key, + required this.connection, + required this.databaseName, + this.onPostgresObjectSelected, + this.onPostgresOpenSqlWorkspace, + }); + + final ConnectionRow connection; + final String databaseName; + final void Function( + ConnectionRow connection, + String database, + String schema, + String name, + PostgresObjectKind kind, + )? onPostgresObjectSelected; + final OnPostgresOpenSqlWorkspace? onPostgresOpenSqlWorkspace; + + @override + State<_PgDatabaseNode> createState() => _PgDatabaseNodeState(); +} + +class _PgDatabaseNodeState extends State<_PgDatabaseNode> { + bool _expanded = false; + bool _loading = false; + List _schemas = []; + + void _toggle() { + setState(() => _expanded = !_expanded); + if (_expanded && _schemas.isEmpty && !_loading) { + _loadSchemas(); + } + } + + Future _loadSchemas() async { + if (!mounted) return; + setState(() => _loading = true); + PgLease? lease; + try { + final c = widget.connection; + lease = await PostgresService.instance.acquire( + c, + database: widget.databaseName, + mode: PgSessionMode.readOnly, + ); + final schemas = await lease.connection.listSchemas(); + if (!mounted) return; + setState(() { + _schemas = schemas; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() => _loading = false); + } finally { + lease?.release(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + _PgTreeRow( + label: widget.databaseName, + leading: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 100), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, + ), + ), + icon: material.Icons.storage_rounded, + iconSize: 14, + iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), + textStyle: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.foreground, + ), + verticalPadding: 4, + onTap: _toggle, + connection: widget.connection, + onContextRefresh: _loadSchemas, + onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + ), + if (_expanded) ...[ + _PgDbToolRow( + connection: widget.connection, + databaseName: widget.databaseName, + label: 'Extensions', + icon: material.Icons.extension_rounded, + kind: PostgresObjectKind.databaseExtensions, + onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onContextRefresh: _loadSchemas, + ), + _PgDbToolRow( + connection: widget.connection, + databaseName: widget.databaseName, + label: 'Foreign data', + icon: material.Icons.public_rounded, + kind: PostgresObjectKind.databaseForeignData, + onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + 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(), + ], + ), + ), + if (_schemas.isNotEmpty) + _PgSchemasNode( + connection: widget.connection, + databaseName: widget.databaseName, + schemas: _schemas, + onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onRefreshSchemas: _loadSchemas, + ), + ], + ], + ), + ); + } +} + +class _PgDbToolRow extends material.StatelessWidget { + const _PgDbToolRow({ + required this.connection, + required this.databaseName, + required this.label, + required this.icon, + required this.kind, + this.onPostgresObjectSelected, + this.onPostgresOpenSqlWorkspace, + this.onContextRefresh, + }); + + final ConnectionRow connection; + final String databaseName; + final String label; + final material.IconData icon; + final PostgresObjectKind kind; + final void Function( + ConnectionRow connection, + String database, + String schema, + String name, + PostgresObjectKind kind, + )? onPostgresObjectSelected; + final OnPostgresOpenSqlWorkspace? onPostgresOpenSqlWorkspace; + final VoidCallback? onContextRefresh; + + @override + 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( + label: label, + icon: icon, + iconSize: 13, + iconColor: muted, + trailing: material.Icon( + material.Icons.chevron_right_rounded, + size: 13, + color: muted, + ), + onTap: onPostgresObjectSelected == null + ? null + : () => onPostgresObjectSelected!( + connection, + databaseName, + '', + '', + kind, + ), + textStyle: material.TextStyle( + fontSize: 11, + color: muted, + ), + connection: connection, + onContextRefresh: onContextRefresh, + onOpenSqlWorkspace: onPostgresOpenSqlWorkspace, + ), + ); + } +} + +class _PgSchemasNode extends StatefulWidget { + const _PgSchemasNode({ + required this.connection, + required this.databaseName, + required this.schemas, + this.onPostgresObjectSelected, + this.onPostgresOpenSqlWorkspace, + required this.onRefreshSchemas, + }); + + final ConnectionRow connection; + final String databaseName; + final List schemas; + final void Function( + ConnectionRow connection, + String database, + String schema, + String name, + PostgresObjectKind kind, + )? onPostgresObjectSelected; + final OnPostgresOpenSqlWorkspace? onPostgresOpenSqlWorkspace; + final VoidCallback onRefreshSchemas; + + @override + State<_PgSchemasNode> createState() => _PgSchemasNodeState(); +} + +class _PgSchemasNodeState extends State<_PgSchemasNode> { + bool _expanded = true; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + _PgTreeRow( + label: 'Schemas (${widget.schemas.length})', + leading: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 100), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, + ), + ), + icon: material.Icons.account_tree_rounded, + iconSize: 13, + iconColor: theme.colorScheme.mutedForeground, + textStyle: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + onTap: () => setState(() => _expanded = !_expanded), + connection: widget.connection, + onContextRefresh: widget.onRefreshSchemas, + onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + ), + if (_expanded) + for (final schema in widget.schemas) + _PgSchemaNode( + key: material.ValueKey( + 'pg-schema-${widget.connection.id ?? 0}-${widget.databaseName}-$schema', + ), + connection: widget.connection, + databaseName: widget.databaseName, + schemaName: schema, + onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + ), + ], + ), + ); + } +} + +class _PgSchemaNode extends StatefulWidget { + const _PgSchemaNode({ + super.key, + required this.connection, + required this.databaseName, + required this.schemaName, + this.onPostgresObjectSelected, + this.onPostgresOpenSqlWorkspace, + }); + + final ConnectionRow connection; + final String databaseName; + final String schemaName; + final void Function( + ConnectionRow connection, + String database, + String schema, + String name, + PostgresObjectKind kind, + )? onPostgresObjectSelected; + final OnPostgresOpenSqlWorkspace? onPostgresOpenSqlWorkspace; + + @override + State<_PgSchemaNode> createState() => _PgSchemaNodeState(); +} + +class _PgSchemaNodeState extends State<_PgSchemaNode> { + bool _expanded = false; + bool _loading = false; + List _tables = []; + List _views = []; + List _matviews = []; + List _functions = []; + List _sequences = []; + bool _loaded = false; + + void _toggle() { + setState(() => _expanded = !_expanded); + if (_expanded && !_loaded && !_loading) { + _loadObjects(); + } + } + + Future _loadObjects() async { + if (!mounted) return; + setState(() => _loading = true); + PgLease? lease; + try { + final c = widget.connection; + lease = await PostgresService.instance.acquire( + c, + database: widget.databaseName, + mode: PgSessionMode.readOnly, + ); + final conn = lease.connection; + final tables = await conn.listTables(schema: widget.schemaName); + final views = await conn.listViews(schema: widget.schemaName); + List matviews = []; + try { + matviews = + await conn.listMaterializedViews(schema: widget.schemaName); + } catch (_) { + // pg_matviews / permissions may fail on some servers; keep tree usable. + } + final functions = await conn.listFunctions(schema: widget.schemaName); + final sequences = await conn.listSequences(schema: widget.schemaName); + if (!mounted) return; + setState(() { + _tables = tables; + _views = views; + _matviews = matviews; + _functions = functions; + _sequences = sequences; + _loading = false; + _loaded = true; + }); + } catch (e) { + if (!mounted) return; + setState(() => _loading = false); + } finally { + lease?.release(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 12), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + _PgTreeRow( + label: widget.schemaName, + leading: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 100), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, + ), + ), + icon: material.Icons.diamond_outlined, + iconSize: 13, + iconColor: theme.colorScheme.primary.withValues(alpha: 0.6), + textStyle: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.foreground, + ), + onTap: _toggle, + connection: widget.connection, + onContextRefresh: _loadObjects, + onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + ), + if (_expanded) ...[ + 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(), + ], + ), + ), + if (_loaded) ...[ + _PgObjectGroup( + connection: widget.connection, + databaseName: widget.databaseName, + schemaName: widget.schemaName, + objectKind: PostgresObjectKind.table, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onRefresh: _loadObjects, + label: 'Tables', + icon: material.Icons.table_chart_rounded, + items: _tables, + onItemTap: widget.onPostgresObjectSelected != null + ? (name) => widget.onPostgresObjectSelected!( + widget.connection, + widget.databaseName, + widget.schemaName, + name, + PostgresObjectKind.table, + ) + : null, + ), + _PgObjectGroup( + connection: widget.connection, + databaseName: widget.databaseName, + schemaName: widget.schemaName, + objectKind: PostgresObjectKind.view, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onRefresh: _loadObjects, + label: 'Views', + icon: material.Icons.view_agenda_rounded, + items: _views, + onItemTap: widget.onPostgresObjectSelected != null + ? (name) => widget.onPostgresObjectSelected!( + widget.connection, + widget.databaseName, + widget.schemaName, + name, + PostgresObjectKind.view, + ) + : null, + ), + _PgObjectGroup( + connection: widget.connection, + databaseName: widget.databaseName, + schemaName: widget.schemaName, + objectKind: PostgresObjectKind.materializedView, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onRefresh: _loadObjects, + label: 'Materialized views', + icon: material.Icons.dynamic_feed_rounded, + items: _matviews, + onItemTap: widget.onPostgresObjectSelected != null + ? (name) => widget.onPostgresObjectSelected!( + widget.connection, + widget.databaseName, + widget.schemaName, + name, + PostgresObjectKind.materializedView, + ) + : null, + ), + _PgObjectGroup( + connection: widget.connection, + databaseName: widget.databaseName, + schemaName: widget.schemaName, + objectKind: PostgresObjectKind.function, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onRefresh: _loadObjects, + label: 'Functions', + icon: material.Icons.functions_rounded, + items: _functions, + onItemTap: widget.onPostgresObjectSelected != null + ? (name) => widget.onPostgresObjectSelected!( + widget.connection, + widget.databaseName, + widget.schemaName, + name, + PostgresObjectKind.function, + ) + : null, + ), + _PgObjectGroup( + connection: widget.connection, + databaseName: widget.databaseName, + schemaName: widget.schemaName, + objectKind: PostgresObjectKind.sequence, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onRefresh: _loadObjects, + label: 'Sequences', + icon: material.Icons.format_list_numbered_rounded, + items: _sequences, + onItemTap: widget.onPostgresObjectSelected != null + ? (name) => widget.onPostgresObjectSelected!( + widget.connection, + widget.databaseName, + widget.schemaName, + name, + PostgresObjectKind.sequence, + ) + : null, + ), + _PgSchemaToolRow( + connection: widget.connection, + databaseName: widget.databaseName, + schemaName: widget.schemaName, + label: 'Indexes', + icon: material.Icons.table_rows_rounded, + kind: PostgresObjectKind.schemaIndexes, + onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onContextRefresh: _loadObjects, + ), + _PgSchemaToolRow( + connection: widget.connection, + databaseName: widget.databaseName, + schemaName: widget.schemaName, + label: 'Triggers', + icon: material.Icons.bolt_rounded, + kind: PostgresObjectKind.schemaTriggers, + onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onContextRefresh: _loadObjects, + ), + _PgSchemaToolRow( + connection: widget.connection, + databaseName: widget.databaseName, + schemaName: widget.schemaName, + label: 'Types', + icon: material.Icons.category_rounded, + kind: PostgresObjectKind.schemaTypes, + onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onContextRefresh: _loadObjects, + ), + ], + ], + ], + ), + ); + } +} + +class _PgSchemaToolRow extends material.StatelessWidget { + const _PgSchemaToolRow({ + required this.connection, + required this.databaseName, + required this.schemaName, + required this.label, + required this.icon, + required this.kind, + this.onPostgresObjectSelected, + this.onPostgresOpenSqlWorkspace, + this.onContextRefresh, + }); + + final ConnectionRow connection; + final String databaseName; + final String schemaName; + final String label; + final material.IconData icon; + final PostgresObjectKind kind; + final void Function( + ConnectionRow connection, + String database, + String schema, + String name, + PostgresObjectKind kind, + )? onPostgresObjectSelected; + final OnPostgresOpenSqlWorkspace? onPostgresOpenSqlWorkspace; + final VoidCallback? onContextRefresh; + + @override + 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( + label: label, + icon: icon, + iconSize: 13, + iconColor: muted, + trailing: material.Icon( + material.Icons.chevron_right_rounded, + size: 13, + color: muted, + ), + onTap: onPostgresObjectSelected == null + ? null + : () => onPostgresObjectSelected!( + connection, + databaseName, + schemaName, + '', + kind, + ), + textStyle: material.TextStyle( + fontSize: 11, + color: muted, + ), + connection: connection, + onContextRefresh: onContextRefresh, + onOpenSqlWorkspace: onPostgresOpenSqlWorkspace, + ), + ); + } +} + +class _PgObjectGroup extends StatefulWidget { + const _PgObjectGroup({ + required this.connection, + required this.databaseName, + required this.schemaName, + required this.objectKind, + required this.onRefresh, + required this.label, + required this.icon, + required this.items, + this.onPostgresOpenSqlWorkspace, + this.onItemTap, + }); + + final ConnectionRow connection; + final String databaseName; + final String schemaName; + final PostgresObjectKind objectKind; + final VoidCallback onRefresh; + final String label; + final material.IconData icon; + final List items; + final OnPostgresOpenSqlWorkspace? onPostgresOpenSqlWorkspace; + final void Function(String itemName)? onItemTap; + + @override + State<_PgObjectGroup> createState() => _PgObjectGroupState(); +} + +class _PgObjectGroupState extends State<_PgObjectGroup> { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + _PgTreeRow( + label: '${widget.label} (${widget.items.length})', + leading: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 100), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 13, + color: theme.colorScheme.mutedForeground, + ), + ), + icon: widget.icon, + iconSize: 13, + iconColor: theme.colorScheme.mutedForeground, + textStyle: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + onTap: () => setState(() => _expanded = !_expanded), + connection: widget.connection, + onContextRefresh: widget.onRefresh, + onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + ), + if (_expanded) + for (final item in widget.items) + material.Padding( + padding: const material.EdgeInsets.only(left: 22), + child: _PgTreeRow( + label: item, + icon: widget.icon, + iconSize: 12, + iconColor: + theme.colorScheme.primary.withValues(alpha: 0.5), + 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 new file mode 100644 index 0000000..aace624 --- /dev/null +++ b/lib/features/connections/connections_panel_postgres_connection.dart @@ -0,0 +1,238 @@ +part of 'package:querya_desktop/features/connections/connections_panel.dart'; + +// ─── PostgreSQL connection tile with expandable database tree ──────────────── + +class _PostgresConnectionTile extends StatefulWidget { + const _PostgresConnectionTile({ + required this.connection, + this.isSelected = false, + required this.icon, + this.iconAsset, + required this.onRemove, + this.onTap, + this.onPostgresObjectSelected, + this.onPostgresOpenSqlWorkspace, + }); + + final ConnectionRow connection; + final bool isSelected; + final material.IconData icon; + final String? iconAsset; + final VoidCallback onRemove; + final VoidCallback? onTap; + final void Function( + ConnectionRow connection, + String database, + String schema, + String name, + PostgresObjectKind kind, + )? onPostgresObjectSelected; + final OnPostgresOpenSqlWorkspace? onPostgresOpenSqlWorkspace; + + @override + State<_PostgresConnectionTile> createState() => + _PostgresConnectionTileState(); +} + +class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { + bool _expanded = false; + bool _loading = false; + String? _error; + List _databases = []; + + void _toggle() { + setState(() => _expanded = !_expanded); + if (_expanded && _databases.isEmpty && !_loading) { + _loadDatabases(); + } + } + + Future _loadDatabases() async { + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + PgLease? lease; + try { + final c = widget.connection; + lease = await PostgresService.instance.acquire( + c, + database: c.databaseName ?? 'postgres', + mode: PgSessionMode.readOnly, + ); + final dbs = await lease.connection.listDatabases(); + + if (!mounted) return; + setState(() { + _databases = dbs; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } finally { + lease?.release(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final iconWidget = widget.iconAsset != null + ? material.Image.asset( + widget.iconAsset!, + width: 16, + height: 16, + fit: material.BoxFit.contain, + errorBuilder: (_, __, ___) => material.Icon( + widget.icon, + size: 16, + color: theme.colorScheme.primary, + ), + ) + : material.Icon(widget.icon, size: 16, color: theme.colorScheme.primary); + + return ContextMenu( + items: [ + MenuButton( + leading: material.Icon(material.Icons.refresh_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) { + _databases = []; + _loadDatabases(); + }, + child: const Text('Refresh databases'), + ), + MenuButton( + leading: material.Icon(material.Icons.delete_outline_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onRemove(), + child: const Text('Remove connection'), + ), + ], + child: material.Padding( + padding: const material.EdgeInsets.only(bottom: 2), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Row( + children: [ + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 100), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 16, + color: theme.colorScheme.mutedForeground, + ), + ), + ), + ), + ), + material.Expanded( + child: _sidebarConnectionShell( + context: context, + isSelected: widget.isSelected, + onTap: widget.onTap, + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 4, vertical: 6), + child: material.Row( + children: [ + iconWidget, + const Gap(8), + material.Expanded( + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Text( + widget.connection.name, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + color: theme.colorScheme.foreground, + ), + ), + if (widget.connection.host != null) + material.Text( + '${widget.connection.host}:${widget.connection.port ?? ''}', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ], + ), + if (_expanded) ...[ + 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(), + ], + ), + ), + if (_error != null) + material.Padding( + padding: + const material.EdgeInsets.only(left: 28, top: 4, bottom: 4), + child: material.Text( + 'Error', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, color: theme.colorScheme.destructive), + ), + ), + if (_databases.isNotEmpty) + _PgDatabasesNode( + connection: widget.connection, + databases: _databases, + onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onRefreshDatabases: () { + setState(() => _databases = []); + _loadDatabases(); + }, + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/connections/connections_panel_redis.dart b/lib/features/connections/connections_panel_redis.dart new file mode 100644 index 0000000..a70ab12 --- /dev/null +++ b/lib/features/connections/connections_panel_redis.dart @@ -0,0 +1,312 @@ +part of 'package:querya_desktop/features/connections/connections_panel.dart'; + +// ─── Redis connection tile with expandable database tree ──────────────────── + +class _RedisConnectionTile extends StatefulWidget { + const _RedisConnectionTile({ + required this.connection, + this.isSelected = false, + required this.icon, + this.iconAsset, + required this.onRemove, + this.onTap, + this.onDatabaseTap, + }); + + final ConnectionRow connection; + final bool isSelected; + final material.IconData icon; + final String? iconAsset; + final VoidCallback onRemove; + final VoidCallback? onTap; + final void Function(int database)? onDatabaseTap; + + @override + State<_RedisConnectionTile> createState() => _RedisConnectionTileState(); +} + +class _RedisConnectionTileState extends State<_RedisConnectionTile> { + bool _expanded = false; + bool _loading = false; + String? _error; + // All 16 databases (db0–db15) with key counts + List<({int index, int keys})> _databases = []; + + void _toggle() { + setState(() => _expanded = !_expanded); + if (_expanded && _databases.isEmpty && !_loading) { + _loadDatabases(); + } + } + + Future _loadDatabases() async { + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + try { + // Use a temporary connection so we don't kill the main view's connection. + final c = widget.connection; + final conn = RedisConnection( + id: -1, + name: 'sidebar_probe', + host: c.host ?? 'localhost', + port: c.port ?? 6379, + username: c.username, + password: c.password, + ); + await conn.connect(); + final raw = await conn.info(); + await conn.disconnect(); + + final info = parseRedisInfo(raw); + final keyspace = info['Keyspace'] ?? {}; + + // Build all 16 databases with their key counts + final dbs = <({int index, int keys})>[]; + for (var i = 0; i < 16; i++) { + final dbKey = 'db$i'; + final dbInfo = keyspace[dbKey]; + int keys = 0; + if (dbInfo != null) { + for (final part in dbInfo.split(',')) { + final kv = part.split('='); + if (kv.length == 2 && kv[0].trim() == 'keys') { + keys = int.tryParse(kv[1].trim()) ?? 0; + } + } + } + dbs.add((index: i, keys: keys)); + } + + if (!mounted) return; + setState(() { + _databases = dbs; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final iconWidget = widget.iconAsset != null + ? material.Image.asset( + widget.iconAsset!, + width: 16, + height: 16, + fit: material.BoxFit.contain, + errorBuilder: (_, __, ___) => material.Icon( + widget.icon, + size: 16, + color: theme.colorScheme.primary, + ), + ) + : material.Icon(widget.icon, size: 16, color: theme.colorScheme.primary); + + return ContextMenu( + items: [ + MenuButton( + leading: material.Icon(material.Icons.refresh_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) { + _databases = []; + _loadDatabases(); + }, + child: const Text('Refresh databases'), + ), + MenuButton( + leading: material.Icon(material.Icons.delete_outline_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onRemove(), + child: const Text('Remove connection'), + ), + ], + child: material.Padding( + padding: const material.EdgeInsets.only(bottom: 2), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + // Connection row + material.Row( + children: [ + // Expand/collapse arrow + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 100), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 16, + color: theme.colorScheme.mutedForeground, + ), + ), + ), + ), + ), + // Connection name — clickable for stats + material.Expanded( + child: _sidebarConnectionShell( + context: context, + isSelected: widget.isSelected, + onTap: widget.onTap, + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 4, vertical: 6), + child: material.Row( + children: [ + iconWidget, + const Gap(8), + material.Expanded( + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Text( + widget.connection.name, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + color: theme.colorScheme.foreground, + ), + ), + if (widget.connection.host != null) + material.Text( + '${widget.connection.host}:${widget.connection.port ?? ''}', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ], + ), + // Expanded database children — ALL 16 databases + if (_expanded) ...[ + 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(), + ], + ), + ), + if (_error != null) + material.Padding( + padding: const material.EdgeInsets.only(left: 28, top: 4, bottom: 4), + child: material.Text( + 'Error', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, color: theme.colorScheme.destructive), + ), + ), + for (final db in _databases) + _RedisDatabaseNode( + index: db.index, + keys: db.keys, + onTap: () => widget.onDatabaseTap?.call(db.index), + ), + ], + ], + ), + ), + ); + } +} + +class _RedisDatabaseNode extends StatelessWidget { + const _RedisDatabaseNode({ + required this.index, + required this.keys, + required this.onTap, + }); + + final int index; + final int keys; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 24), + child: material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: onTap, + borderRadius: material.BorderRadius.circular(6), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, vertical: 5), + child: material.Row( + children: [ + material.Icon( + material.Icons.dns_rounded, + size: 14, + color: keys > 0 + ? theme.colorScheme.primary.withValues(alpha: 0.7) + : theme.colorScheme.mutedForeground.withValues(alpha: 0.5), + ), + const Gap(8), + material.Expanded( + child: material.Text( + 'db$index', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 12, + color: keys > 0 + ? theme.colorScheme.foreground + : theme.colorScheme.mutedForeground, + ), + ), + ), + if (keys > 0) + material.Text( + '$keys', + style: material.TextStyle( + fontSize: 10, + color: theme.colorScheme.mutedForeground), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/connections/connections_panel_sidebar.dart b/lib/features/connections/connections_panel_sidebar.dart new file mode 100644 index 0000000..0aae05b --- /dev/null +++ b/lib/features/connections/connections_panel_sidebar.dart @@ -0,0 +1,292 @@ +part of 'package:querya_desktop/features/connections/connections_panel.dart'; + +material.Widget _sidebarConnectionShell({ + required material.BuildContext context, + required bool isSelected, + required material.VoidCallback? onTap, + required material.Widget child, +}) { + final p = Theme.of(context).colorScheme.primary; + return material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: onTap, + borderRadius: material.BorderRadius.circular(20), + mouseCursor: onTap != null + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + child: material.Container( + decoration: material.BoxDecoration( + color: isSelected ? p.withValues(alpha: 0.16) : null, + borderRadius: material.BorderRadius.circular(20), + border: isSelected + ? material.Border.all(color: p.withValues(alpha: 0.26)) + : null, + ), + child: child, + ), + ), + ); +} +class _EmptyState extends StatelessWidget { + const _EmptyState({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: material.BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.25), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: theme.colorScheme.border.withValues(alpha: 0.2), + width: 1, + ), + ), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.center, + children: [ + material.Icon( + material.Icons.info_outline_rounded, + size: 16, + color: theme.colorScheme.mutedForeground, + ), + const Gap(10), + material.Expanded( + child: Text(message).muted().small(), + ), + ], + ), + ); + } +} + +/// Tile for a single connection in the sidebar. +class _ConnectionTile extends StatelessWidget { + const _ConnectionTile({ + required this.connection, + this.isSelected = false, + required this.icon, + this.iconAsset, + required this.onRemove, + this.onTap, + }); + + final ConnectionRow connection; + final bool isSelected; + final material.IconData icon; + final String? iconAsset; + final VoidCallback onRemove; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final iconWidget = iconAsset != null + ? material.Image.asset( + iconAsset!, + width: 16, + height: 16, + fit: material.BoxFit.contain, + errorBuilder: (_, __, ___) => material.Icon( + icon, + size: 16, + color: theme.colorScheme.primary, + ), + ) + : material.Icon(icon, size: 16, color: theme.colorScheme.primary); + return ContextMenu( + items: [ + MenuButton( + leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => onRemove(), + child: const Text('Remove connection'), + ), + ], + child: material.Padding( + padding: const material.EdgeInsets.only(bottom: 2), + child: _sidebarConnectionShell( + context: context, + isSelected: isSelected, + onTap: onTap, + child: material.Padding( + padding: + const material.EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: material.Row( + children: [ + iconWidget, + const Gap(8), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Text( + connection.name, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + color: theme.colorScheme.foreground, + ), + ), + if (connection.host != null) + material.Text( + '${connection.host}:${connection.port ?? ''}', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _FolderTile extends StatefulWidget { + const _FolderTile({ + required this.name, + required this.initiallyExpanded, + required this.onExpansionCommitted, + required this.connections, + required this.onRemove, + required this.onNewConnection, + required this.iconForType, + required this.onRemoveConnection, + this.onConnectionTap, + this.onRedisDatabaseTap, + this.onMongoDBDatabaseTap, + this.buildConnectionTile, + }); + + final String name; + final bool initiallyExpanded; + final void Function(String folderName, bool expanded) onExpansionCommitted; + final List connections; + final VoidCallback onRemove; + final void Function(String folderName) onNewConnection; + final material.IconData Function(String type) iconForType; + final Future Function(int id) onRemoveConnection; + final void Function(ConnectionRow connection)? onConnectionTap; + final void Function(ConnectionRow connection, int database)? onRedisDatabaseTap; + final void Function(ConnectionRow connection, String database)? onMongoDBDatabaseTap; + final Widget Function(ConnectionRow conn)? buildConnectionTile; + + @override + State<_FolderTile> createState() => _FolderTileState(); +} + +class _FolderTileState extends State<_FolderTile> { + late bool _expanded; + + @override + void initState() { + super.initState(); + _expanded = widget.initiallyExpanded; + } + + @override + void didUpdateWidget(_FolderTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.name != oldWidget.name || + widget.initiallyExpanded != oldWidget.initiallyExpanded) { + _expanded = widget.initiallyExpanded; + } + } + + void _toggle() { + setState(() => _expanded = !_expanded); + widget.onExpansionCommitted(widget.name, _expanded); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return ContextMenu( + items: [ + MenuButton( + leading: material.Icon(material.Icons.settings_ethernet_rounded, size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (menuContext) => widget.onNewConnection(widget.name), + child: const Text('New connection'), + ), + MenuButton( + leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onRemove(), + child: const Text('Remove folder'), + ), + ], + child: material.Padding( + padding: const material.EdgeInsets.only(bottom: 4), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(6), + child: material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: material.Row( + children: [ + material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 100), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 18, + color: theme.colorScheme.mutedForeground, + ), + ), + const Gap(2), + material.Icon(material.Icons.folder_rounded, size: 18, color: theme.colorScheme.primary), + const Gap(8), + material.Expanded( + child: material.Text( + widget.name, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + color: theme.colorScheme.foreground, + ), + ), + ), + ], + ), + ), + ), + ), + if (_expanded) + for (final conn in widget.connections) + material.Padding( + padding: const material.EdgeInsets.only(left: 24), + child: widget.buildConnectionTile != null + ? widget.buildConnectionTile!(conn) + : _ConnectionTile( + connection: conn, + icon: widget.iconForType(conn.type), + iconAsset: ConnectionsPanelState._iconAssetForType(conn.type), + onRemove: () => widget.onRemoveConnection(conn.id!), + onTap: () => widget.onConnectionTap?.call(conn), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 17a8a22..cbf1c5c 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -90,8 +90,20 @@ class _MainScreenState extends State { _workspace.value = _workspace.value.selectMongoDb(connection, database); } - void _onPostgresOpenSqlWorkspace(ConnectionRow connection) { - _workspace.value = _workspace.value.openPostgresSqlWorkspace(connection); + void _onPostgresOpenSqlWorkspace( + ConnectionRow connection, { + String? database, + String? schema, + String? name, + PostgresObjectKind? kind, + }) { + _workspace.value = _workspace.value.openPostgresSqlWorkspace( + connection, + seedDatabase: database, + seedSchema: schema, + seedName: name, + seedKind: kind, + ); } void _onMysqlOpenSqlWorkspace(ConnectionRow connection) { @@ -185,7 +197,7 @@ class _MainContentSplit extends StatefulWidget { ) onMysqlObjectSelected; final void Function(ConnectionRow, int) onRedisDatabaseSelected; final void Function(ConnectionRow, String) onMongoDBDatabaseSelected; - final void Function(ConnectionRow) onPostgresOpenSqlWorkspace; + final OnPostgresOpenSqlWorkspace onPostgresOpenSqlWorkspace; final void Function(ConnectionRow) onMysqlOpenSqlWorkspace; final VoidCallback onRequestNewConnection; @@ -273,6 +285,9 @@ class _MainContentSplitState extends State<_MainContentSplit> { selectedPostgresObject: ws.selectedPostgresObject, postgresSqlTabRequestToken: ws.postgresSqlTabRequestToken, + postgresSqlEditorContext: ws.postgresSqlEditorContext, + postgresSqlEditorContextToken: + ws.postgresSqlEditorContextToken, selectedMysqlObject: ws.selectedMysqlObject, mysqlSqlTabRequestToken: ws.mysqlSqlTabRequestToken, onRequestNewConnection: widget.onRequestNewConnection, diff --git a/lib/features/main_screen/main_screen_workspace_state.dart b/lib/features/main_screen/main_screen_workspace_state.dart index d3aecf0..dd8bb41 100644 --- a/lib/features/main_screen/main_screen_workspace_state.dart +++ b/lib/features/main_screen/main_screen_workspace_state.dart @@ -12,6 +12,8 @@ class MainScreenWorkspaceState { this.activeMongoDB, this.selectedPostgresObject, this.postgresSqlTabRequestToken = 0, + this.postgresSqlEditorContext, + this.postgresSqlEditorContextToken = 0, this.selectedMysqlObject, this.mysqlSqlTabRequestToken = 0, }); @@ -22,6 +24,11 @@ class MainScreenWorkspaceState { final ({String database, String schema, String name, PostgresObjectKind kind})? selectedPostgresObject; final int postgresSqlTabRequestToken; + + /// Seeds the SQL editor when using "Open in SQL" (table/view/matview from current tree selection). + final ({String database, String schema, String name, PostgresObjectKind kind})? + postgresSqlEditorContext; + final int postgresSqlEditorContextToken; final ({String database, String name, MysqlObjectKind kind})? selectedMysqlObject; final int mysqlSqlTabRequestToken; @@ -35,6 +42,8 @@ class MainScreenWorkspaceState { activeMongoDB: null, selectedPostgresObject: null, postgresSqlTabRequestToken: postgresSqlTabRequestToken, + postgresSqlEditorContext: null, + postgresSqlEditorContextToken: 0, selectedMysqlObject: null, mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, ); @@ -58,6 +67,8 @@ class MainScreenWorkspaceState { kind: kind, ), postgresSqlTabRequestToken: postgresSqlTabRequestToken, + postgresSqlEditorContext: null, + postgresSqlEditorContextToken: 0, selectedMysqlObject: null, mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, ); @@ -75,6 +86,8 @@ class MainScreenWorkspaceState { activeMongoDB: null, selectedPostgresObject: null, postgresSqlTabRequestToken: postgresSqlTabRequestToken, + postgresSqlEditorContext: null, + postgresSqlEditorContextToken: 0, selectedMysqlObject: ( database: database, name: name, @@ -91,6 +104,8 @@ class MainScreenWorkspaceState { activeMongoDB: null, selectedPostgresObject: null, postgresSqlTabRequestToken: postgresSqlTabRequestToken, + postgresSqlEditorContext: null, + postgresSqlEditorContextToken: 0, selectedMysqlObject: null, mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, ); @@ -104,18 +119,60 @@ class MainScreenWorkspaceState { activeMongoDB: database, selectedPostgresObject: null, postgresSqlTabRequestToken: postgresSqlTabRequestToken, + postgresSqlEditorContext: null, + postgresSqlEditorContextToken: 0, selectedMysqlObject: null, mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, ); } - MainScreenWorkspaceState openPostgresSqlWorkspace(ConnectionRow connection) { + MainScreenWorkspaceState openPostgresSqlWorkspace( + ConnectionRow connection, { + String? seedDatabase, + String? seedSchema, + String? seedName, + PostgresObjectKind? seedKind, + }) { + ({String database, String schema, String name, PostgresObjectKind kind})? + seed; + + final explicit = seedDatabase != null && + seedSchema != null && + seedName != null && + seedName.isNotEmpty && + seedKind != null && + (seedKind == PostgresObjectKind.table || + seedKind == PostgresObjectKind.view || + seedKind == PostgresObjectKind.materializedView); + + if (explicit) { + seed = ( + database: seedDatabase, + schema: seedSchema, + name: seedName, + kind: seedKind, + ); + } else { + final sameConn = activeConnection?.id == connection.id; + if (sameConn && selectedPostgresObject != null) { + final o = selectedPostgresObject!; + final k = o.kind; + if (k == PostgresObjectKind.table || + k == PostgresObjectKind.view || + k == PostgresObjectKind.materializedView) { + seed = o; + } + } + } return MainScreenWorkspaceState( activeConnection: connection, activeRedisDb: null, activeMongoDB: null, selectedPostgresObject: null, postgresSqlTabRequestToken: postgresSqlTabRequestToken + 1, + postgresSqlEditorContext: seed, + postgresSqlEditorContextToken: + seed != null ? postgresSqlEditorContextToken + 1 : 0, selectedMysqlObject: null, mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, ); @@ -128,6 +185,8 @@ class MainScreenWorkspaceState { activeMongoDB: null, selectedPostgresObject: null, postgresSqlTabRequestToken: postgresSqlTabRequestToken, + postgresSqlEditorContext: postgresSqlEditorContext, + postgresSqlEditorContextToken: postgresSqlEditorContextToken, selectedMysqlObject: null, mysqlSqlTabRequestToken: mysqlSqlTabRequestToken + 1, ); @@ -142,6 +201,8 @@ class MainScreenWorkspaceState { activeMongoDB == other.activeMongoDB && _pgEquals(selectedPostgresObject, other.selectedPostgresObject) && postgresSqlTabRequestToken == other.postgresSqlTabRequestToken && + _pgEquals(postgresSqlEditorContext, other.postgresSqlEditorContext) && + postgresSqlEditorContextToken == other.postgresSqlEditorContextToken && _mysqlEquals(selectedMysqlObject, other.selectedMysqlObject) && mysqlSqlTabRequestToken == other.mysqlSqlTabRequestToken; } @@ -160,6 +221,15 @@ class MainScreenWorkspaceState { selectedPostgresObject!.kind, ), postgresSqlTabRequestToken, + postgresSqlEditorContext == null + ? 0 + : Object.hash( + postgresSqlEditorContext!.database, + postgresSqlEditorContext!.schema, + postgresSqlEditorContext!.name, + postgresSqlEditorContext!.kind, + ), + postgresSqlEditorContextToken, selectedMysqlObject == null ? 0 : Object.hash( diff --git a/lib/features/main_screen/results_tab.dart b/lib/features/main_screen/results_tab.dart index 6082985..3667575 100644 --- a/lib/features/main_screen/results_tab.dart +++ b/lib/features/main_screen/results_tab.dart @@ -1,4 +1,11 @@ +import 'dart:async' show unawaited; + import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart' show Clipboard, ClipboardData; +import 'package:querya_desktop/core/csv/result_grid_csv.dart'; +import 'package:querya_desktop/core/csv/save_result_grid_csv.dart'; +import 'package:querya_desktop/core/json/result_grid_json.dart'; +import 'package:querya_desktop/core/json/save_result_grid_json.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Query output: grid, loading, error, or placeholder. @@ -60,51 +67,159 @@ class ResultsTab extends StatelessWidget { ); } - return material.Scrollbar( - child: material.SingleChildScrollView( - scrollDirection: material.Axis.horizontal, - child: material.SingleChildScrollView( - child: material.Table( - border: material.TableBorder.all( - color: Theme.of(context).colorScheme.border.withValues(alpha: 0.35), - ), - defaultColumnWidth: const material.IntrinsicColumnWidth(), - children: [ - material.TableRow( - decoration: material.BoxDecoration( - color: Theme.of(context).colorScheme.muted.withValues(alpha: 0.35), + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(8, 6, 8, 4), + child: material.Align( + alignment: material.Alignment.centerRight, + child: material.Wrap( + alignment: material.WrapAlignment.end, + spacing: 8, + runSpacing: 6, + children: [ + OutlineButton( + size: ButtonSize.small, + onPressed: () { + unawaited(() async { + final outcome = await saveResultGridCsvFile( + columns: columns, + rows: rows, + ); + if (!context.mounted) return; + if (outcome == SaveResultGridCsvOutcome.error) { + await _showSaveFileErrorDialog(context); + } + }()); + }, + leading: const material.Icon( + material.Icons.save_alt_rounded, + size: 14, + ), + child: const Text('Save as CSV…'), + ), + OutlineButton( + size: ButtonSize.small, + onPressed: () { + unawaited(() async { + final outcome = await saveResultGridJsonFile( + columns: columns, + rows: rows, + ); + if (!context.mounted) return; + if (outcome == SaveResultGridJsonOutcome.error) { + await _showSaveFileErrorDialog(context); + } + }()); + }, + leading: const material.Icon( + material.Icons.data_object_rounded, + size: 14, + ), + child: const Text('Save as JSON…'), + ), + OutlineButton( + size: ButtonSize.small, + onPressed: () { + final csv = resultGridAsCsv(columns, rows); + Clipboard.setData(ClipboardData(text: csv)); + }, + leading: const material.Icon( + material.Icons.copy_rounded, + size: 14, + ), + child: const Text('Copy as CSV'), ), - children: columns - .map( - (c) => material.Padding( - padding: const material.EdgeInsets.all(8), - child: Text(c).semiBold().small(), + OutlineButton( + size: ButtonSize.small, + onPressed: () { + final json = resultGridAsJson(columns, rows); + Clipboard.setData(ClipboardData(text: json)); + }, + leading: const material.Icon( + material.Icons.copy_rounded, + size: 14, + ), + child: const Text('Copy as JSON'), + ), + ], + ), + ), + ), + material.Expanded( + child: material.Scrollbar( + child: material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.SingleChildScrollView( + child: material.Table( + border: material.TableBorder.all( + color: Theme.of(context) + .colorScheme + .border + .withValues(alpha: 0.35), + ), + defaultColumnWidth: const material.IntrinsicColumnWidth(), + children: [ + material.TableRow( + decoration: material.BoxDecoration( + color: Theme.of(context) + .colorScheme + .muted + .withValues(alpha: 0.35), ), - ) - .toList(), - ), - ...rows.map( - (r) => material.TableRow( - children: r - .map( - (cell) => material.Padding( - padding: const material.EdgeInsets.all(8), - child: material.SelectableText( - cell, - style: const material.TextStyle( - fontFamily: 'monospace', - fontSize: 12, + children: columns + .map( + (c) => material.Padding( + padding: const material.EdgeInsets.all(8), + child: Text(c).semiBold().small(), ), - ), - ), - ) - .toList(), + ) + .toList(), + ), + ...rows.map( + (r) => material.TableRow( + children: r + .map( + (cell) => material.Padding( + padding: const material.EdgeInsets.all(8), + child: material.SelectableText( + cell, + style: const material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ), + ) + .toList(), + ), + ), + ], ), ), - ], + ), ), ), - ), + ], ); } } + +Future _showSaveFileErrorDialog(material.BuildContext context) { + return material.showDialog( + context: context, + builder: (ctx) => material.AlertDialog( + title: const material.Text('Could not save file'), + content: const material.Text( + 'Check folder permissions or disk space.', + ), + actions: [ + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(), + child: const material.Text('OK'), + ), + ], + ), + ); +} diff --git a/lib/features/main_screen/sql_query_history_dialog.dart b/lib/features/main_screen/sql_query_history_dialog.dart new file mode 100644 index 0000000..c9e01a1 --- /dev/null +++ b/lib/features/main_screen/sql_query_history_dialog.dart @@ -0,0 +1,258 @@ +import 'dart:async' show unawaited; + +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Shows recent SQL for this connection + database; choosing a row replaces the editor text. +void showSqlQueryHistoryDialog({ + required BuildContext context, + required int connectionId, + String? databaseName, + required material.TextEditingController sqlController, +}) { + showAppDialog( + context: context, + builder: (ctx) => material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: WindowLayout.dialogSymmetricInsets(ctx), + child: _SqlQueryHistoryDialogContent( + connectionId: connectionId, + databaseName: databaseName, + sqlController: sqlController, + ), + ), + ); +} + +class _SqlQueryHistoryDialogContent extends material.StatefulWidget { + const _SqlQueryHistoryDialogContent({ + required this.connectionId, + required this.databaseName, + required this.sqlController, + }); + + final int connectionId; + final String? databaseName; + final material.TextEditingController sqlController; + + @override + material.State<_SqlQueryHistoryDialogContent> createState() => + _SqlQueryHistoryDialogContentState(); +} + +class _SqlQueryHistoryDialogContentState + extends material.State<_SqlQueryHistoryDialogContent> { + late Future> _future; + + @override + void initState() { + super.initState(); + _future = _load(); + } + + Future> _load() async { + final cap = await AppSettings.instance.getSqlHistoryMaxEntries(); + return LocalDb.instance.listSqlQueryHistory( + connectionId: widget.connectionId, + databaseName: widget.databaseName, + limit: cap, + ); + } + + void _reload() { + setState(() { + _future = _load(); + }); + } + + static String _previewOneLine(String sql) { + final collapsed = sql.replaceAll(RegExp(r'\s+'), ' ').trim(); + if (collapsed.length <= 96) return collapsed; + return '${collapsed.substring(0, 93)}…'; + } + + static String? _formatWhen(String iso) { + final t = DateTime.tryParse(iso)?.toLocal(); + if (t == null) return null; + String z(int n) => n.toString().padLeft(2, '0'); + return '${t.year}-${z(t.month)}-${z(t.day)} ${z(t.hour)}:${z(t.minute)}'; + } + + Future _confirmClear() async { + final ok = await material.showDialog( + context: context, + builder: (ctx) => material.AlertDialog( + title: const material.Text('Clear query history?'), + content: const material.Text( + 'Removes saved SQL for this connection and database. This cannot be undone.', + ), + actions: [ + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(false), + child: const material.Text('Cancel'), + ), + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(true), + child: const material.Text('Clear'), + ), + ], + ), + ); + if (ok != true || !mounted) return; + await LocalDb.instance.clearSqlQueryHistoryBucket( + connectionId: widget.connectionId, + databaseName: widget.databaseName, + ); + if (!mounted) return; + _reload(); + } + + void _apply(SqlQueryHistoryEntry e) { + final text = e.sqlText; + widget.sqlController.value = material.TextEditingValue( + text: text, + selection: material.TextSelection.collapsed(offset: text.length), + ); + material.Navigator.of(context).pop(); + } + + @override + material.Widget build(material.BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusXxl; + return material.Container( + constraints: const material.BoxConstraints( + maxWidth: 520, + minWidth: 320, + maxHeight: 440, + ), + decoration: material.BoxDecoration( + color: scheme.popover, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: scheme.muted), + ), + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(radius), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(20, 20, 20, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Query history').large().semiBold(), + const material.SizedBox(height: 4), + const Text( + 'Successful runs from this workspace (newest first).', + ).muted() + .small(), + ], + ), + ), + material.Expanded( + child: material.FutureBuilder>( + future: _future, + builder: (context, snap) { + if (snap.connectionState != material.ConnectionState.done) { + return const material.Center( + child: material.Padding( + padding: material.EdgeInsets.all(24), + child: material.CircularProgressIndicator(), + ), + ); + } + if (snap.hasError) { + return material.Padding( + padding: const material.EdgeInsets.all(20), + child: Text( + 'Could not load history: ${snap.error}', + style: material.TextStyle(color: scheme.destructive), + ).small(), + ); + } + final items = snap.data ?? []; + if (items.isEmpty) { + return material.Center( + child: const Text( + 'No queries yet. Run SQL to build history.', + ).muted() + .small(), + ); + } + return material.Scrollbar( + child: material.ListView.separated( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + itemCount: items.length, + separatorBuilder: (_, __) => + material.Divider(height: 1, color: scheme.border), + itemBuilder: (context, i) { + final e = items[i]; + final when = _formatWhen(e.recordedAt); + return material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: () => _apply(e), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.start, + children: [ + material.Text( + _previewOneLine(e.sqlText), + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontFamily: QueryaTypography.mono, + fontSize: 12, + color: scheme.foreground, + ), + ), + if (when != null) ...[ + const material.SizedBox(height: 4), + Text(when).muted().xSmall(), + ], + ], + ), + ), + ), + ); + }, + ), + ); + }, + ), + ), + material.Padding( + padding: const material.EdgeInsets.fromLTRB(16, 8, 16, 16), + child: material.Row( + children: [ + GhostButton( + onPressed: () => unawaited(_confirmClear()), + child: const Text('Clear history'), + ), + const Spacer(), + OutlineButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index 5e0bd9f..7d777f3 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -7,111 +7,15 @@ import 'package:querya_desktop/features/mysql/mysql_table_view.dart'; import 'package:querya_desktop/features/mysql/mysql_workspace_home.dart'; import 'package:querya_desktop/features/mongodb/mongo_explorer_view.dart'; import 'package:querya_desktop/features/mongodb/mongo_stats_view.dart'; -import 'package:querya_desktop/features/postgresql/postgres_browser_views.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; -import 'package:querya_desktop/features/postgresql/postgres_routine_view.dart'; -import 'package:querya_desktop/features/postgresql/postgres_sequence_view.dart'; +import 'package:querya_desktop/features/postgresql/postgres_object_workspace.dart'; import 'package:querya_desktop/features/postgresql/postgres_workspace_home.dart'; -import 'package:querya_desktop/features/postgresql/postgres_table_view.dart'; import 'package:querya_desktop/features/redis/redis_explorer_view.dart'; import 'package:querya_desktop/features/redis/redis_view.dart'; import 'query_editor_tab.dart'; import 'results_tab.dart'; import 'workspace_empty_hero.dart'; -Widget _pgObjectWorkspace({ - required ConnectionRow connection, - required ({String database, String schema, String name, PostgresObjectKind kind}) pg, -}) { - switch (pg.kind) { - case PostgresObjectKind.table: - return PostgresTableView( - key: ValueKey( - 'pg_table_${connection.id}_${pg.schema}_${pg.name}_${pg.kind}', - ), - connectionRow: connection, - database: pg.database, - schema: pg.schema, - tableName: pg.name, - isView: false, - isMaterializedView: false, - ); - case PostgresObjectKind.view: - return PostgresTableView( - key: ValueKey( - 'pg_table_${connection.id}_${pg.schema}_${pg.name}_${pg.kind}', - ), - connectionRow: connection, - database: pg.database, - schema: pg.schema, - tableName: pg.name, - isView: true, - isMaterializedView: false, - ); - case PostgresObjectKind.materializedView: - return PostgresTableView( - key: ValueKey( - 'pg_mat_${connection.id}_${pg.schema}_${pg.name}_${pg.kind}', - ), - connectionRow: connection, - database: pg.database, - schema: pg.schema, - tableName: pg.name, - isView: false, - isMaterializedView: true, - ); - case PostgresObjectKind.function: - return PostgresRoutineView( - key: ValueKey('pg_fn_${connection.id}_${pg.schema}_${pg.name}'), - connectionRow: connection, - database: pg.database, - schema: pg.schema, - routineName: pg.name, - ); - case PostgresObjectKind.sequence: - return PostgresSequenceView( - key: ValueKey('pg_seq_${connection.id}_${pg.schema}_${pg.name}'), - connectionRow: connection, - database: pg.database, - schema: pg.schema, - sequenceName: pg.name, - ); - case PostgresObjectKind.schemaIndexes: - return PostgresIndexListView( - key: ValueKey('pg_idx_${connection.id}_${pg.schema}'), - connectionRow: connection, - database: pg.database, - schema: pg.schema, - ); - case PostgresObjectKind.schemaTriggers: - return PostgresTriggerListView( - key: ValueKey('pg_trg_${connection.id}_${pg.schema}'), - connectionRow: connection, - database: pg.database, - schema: pg.schema, - ); - case PostgresObjectKind.schemaTypes: - return PostgresTypeListView( - key: ValueKey('pg_typ_${connection.id}_${pg.schema}'), - connectionRow: connection, - database: pg.database, - schema: pg.schema, - ); - case PostgresObjectKind.databaseExtensions: - return PostgresExtensionListView( - key: ValueKey('pg_ext_${connection.id}_${pg.database}'), - connectionRow: connection, - database: pg.database, - ); - case PostgresObjectKind.databaseForeignData: - return PostgresFdwListView( - key: ValueKey('pg_fdw_${connection.id}_${pg.database}'), - connectionRow: connection, - database: pg.database, - ); - } -} - /// Main workspace: top = Query Editor / Query History, bottom = Data Output / Messages (pgAdmin-style). Uses shadcn layout. class WorkspacePanel extends StatefulWidget { const WorkspacePanel({ @@ -121,6 +25,8 @@ class WorkspacePanel extends StatefulWidget { this.selectedMongoDb, this.selectedPostgresObject, this.postgresSqlTabRequestToken = 0, + this.postgresSqlEditorContext, + this.postgresSqlEditorContextToken = 0, this.selectedMysqlObject, this.mysqlSqlTabRequestToken = 0, this.onRequestNewConnection, @@ -145,6 +51,11 @@ class WorkspacePanel extends StatefulWidget { /// Incremented by [MainScreen] to switch the PostgreSQL home view to the SQL tab. final int postgresSqlTabRequestToken; + /// Seeds the SQL editor from the last table/view/matview tree selection. + final ({String database, String schema, String name, PostgresObjectKind kind})? + postgresSqlEditorContext; + final int postgresSqlEditorContextToken; + /// When set, the user selected a MySQL table or view in the sidebar tree. final ({String database, String name, MysqlObjectKind kind})? selectedMysqlObject; @@ -190,9 +101,12 @@ class _WorkspacePanelState extends State { ? PostgresWorkspaceHome( key: ValueKey('pg_home_${widget.activeConnection!.id}'), connectionRow: widget.activeConnection!, + postgresSqlEditorContext: widget.postgresSqlEditorContext, + postgresSqlEditorContextToken: + widget.postgresSqlEditorContextToken, sqlTabRequestToken: widget.postgresSqlTabRequestToken, ) - : _pgObjectWorkspace( + : buildPostgresObjectWorkspace( connection: widget.activeConnection!, pg: pg, ), diff --git a/lib/features/mysql/mysql_sql_editor_dialog.dart b/lib/features/mysql/mysql_sql_editor_dialog.dart index eb69d33..72dba9e 100644 --- a/lib/features/mysql/mysql_sql_editor_dialog.dart +++ b/lib/features/mysql/mysql_sql_editor_dialog.dart @@ -102,7 +102,8 @@ class _MysqlSqlEditorDialogState extends material.State<_MysqlSqlEditorDialog> { const material.SizedBox(height: 6), const Text( 'Table browse uses SELECT with LIMIT/OFFSET. ' - 'Edit or write your own SELECT. Reset restores the browse query.', + 'Edit or write your own SELECT. Reset restores the browse query. ' + 'Run reloads the grid; unchanged data looks the same.', ).muted().xSmall(), ], ), diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 076fefc..1d959bc 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -9,6 +9,7 @@ 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/main_screen/query_editor_tab.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; +import 'package:querya_desktop/features/main_screen/sql_query_history_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Ad-hoc SQL editor + results for MySQL / MariaDB. @@ -41,6 +42,7 @@ class _MysqlSqlWorkspaceState extends material.State { int? _queryTimeoutSeconds; int _resultMaxRows = kDefaultSqlResultMaxRows; + int _historyMaxEntries = kDefaultSqlHistoryMaxEntries; double _editorFontSize = kDefaultSqlEditorFontSize; late final VoidCallback _appSettingsListener; @@ -60,11 +62,13 @@ class _MysqlSqlWorkspaceState extends material.State { Future _loadWorkspaceSettings() async { final t = await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(); final rows = await AppSettings.instance.getSqlResultMaxRows(); + final hist = await AppSettings.instance.getSqlHistoryMaxEntries(); final font = await AppSettings.instance.getSqlEditorFontSize(); if (!mounted) return; setState(() { _queryTimeoutSeconds = t; _resultMaxRows = rows; + _historyMaxEntries = hist; _editorFontSize = font; }); } @@ -111,8 +115,8 @@ class _MysqlSqlWorkspaceState extends material.State { } Future _execute() async { - final sql = _sqlController.text.trim(); - if (sql.isEmpty) return; + final userSql = _sqlController.text.trim(); + if (userSql.isEmpty) return; setState(() { _running = true; @@ -137,7 +141,7 @@ class _MysqlSqlWorkspaceState extends material.State { } final to = _statementTimeout(); - final rs = await conn.executeWithTimeout(sql, timeout: to); + final rs = await conn.executeWithTimeout(userSql, timeout: to); if (!mounted) return; @@ -182,6 +186,17 @@ class _MysqlSqlWorkspaceState extends material.State { } _running = false; }); + final cid = widget.connectionRow.id; + if (cid != null) { + unawaited( + LocalDb.instance.recordSqlQueryHistory( + connectionId: cid, + databaseName: widget.connectionRow.databaseName, + sqlText: userSql, + maxEntries: _historyMaxEntries, + ), + ); + } } on TimeoutException catch (e) { if (mounted) { setState(() { @@ -238,6 +253,18 @@ class _MysqlSqlWorkspaceState extends material.State { onQueryTimeoutChanged: _onStmtTimeoutChanged, onOpenPreferences: () => showPreferencesDialog(context), + onOpenHistory: widget.connectionRow.id != null && + !_running + ? () { + showSqlQueryHistoryDialog( + context: context, + connectionId: widget.connectionRow.id!, + databaseName: + widget.connectionRow.databaseName, + sqlController: _sqlController, + ); + } + : null, ), const Divider(height: 1), Expanded( @@ -305,6 +332,7 @@ class _MysqlSqlToolbar extends material.StatelessWidget { required this.queryTimeoutSeconds, required this.onQueryTimeoutChanged, required this.onOpenPreferences, + this.onOpenHistory, }); final Future Function()? onExecute; @@ -312,6 +340,7 @@ class _MysqlSqlToolbar extends material.StatelessWidget { final int? queryTimeoutSeconds; final void Function(int?) onQueryTimeoutChanged; final VoidCallback onOpenPreferences; + final VoidCallback? onOpenHistory; @override material.Widget build(material.BuildContext context) { @@ -329,6 +358,16 @@ class _MysqlSqlToolbar extends material.StatelessWidget { children: [ const Text('Query').semiBold().small(), const Spacer(), + OutlineButton( + size: ButtonSize.small, + onPressed: onOpenHistory, + leading: const material.Icon( + material.Icons.history_rounded, + size: 16, + ), + child: const Text('History'), + ), + const Gap(8), OutlineButton( onPressed: onExecute, leading: running diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index b2fe14f..d02c88a 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -248,7 +248,7 @@ class _MysqlTableViewState extends material.State { final trimmed = sql.trim(); if (!isAllowedMysqlSelectQuery(trimmed)) return; final browse = _browseDataSql().trim(); - if (trimmed == browse) { + if (_browseSqlCompareKey(trimmed) == _browseSqlCompareKey(browse)) { setState(() { _customSqlActive = false; _customSql = null; @@ -577,6 +577,14 @@ 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; diff --git a/lib/features/postgresql/postgres_object_workspace.dart b/lib/features/postgresql/postgres_object_workspace.dart new file mode 100644 index 0000000..61a1916 --- /dev/null +++ b/lib/features/postgresql/postgres_object_workspace.dart @@ -0,0 +1,101 @@ +import 'package:flutter/widgets.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/postgresql/postgres_browser_views.dart'; +import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; +import 'package:querya_desktop/features/postgresql/postgres_routine_view.dart'; +import 'package:querya_desktop/features/postgresql/postgres_sequence_view.dart'; +import 'package:querya_desktop/features/postgresql/postgres_table_view.dart'; + +/// Workspace content for a single PostgreSQL tree object (grid, routine, lists). +Widget buildPostgresObjectWorkspace({ + required ConnectionRow connection, + required ({String database, String schema, String name, PostgresObjectKind kind}) pg, +}) { + switch (pg.kind) { + case PostgresObjectKind.table: + return PostgresTableView( + key: ValueKey( + 'pg_table_${connection.id}_${pg.schema}_${pg.name}_${pg.kind}', + ), + connectionRow: connection, + database: pg.database, + schema: pg.schema, + tableName: pg.name, + isView: false, + isMaterializedView: false, + ); + case PostgresObjectKind.view: + return PostgresTableView( + key: ValueKey( + 'pg_table_${connection.id}_${pg.schema}_${pg.name}_${pg.kind}', + ), + connectionRow: connection, + database: pg.database, + schema: pg.schema, + tableName: pg.name, + isView: true, + isMaterializedView: false, + ); + case PostgresObjectKind.materializedView: + return PostgresTableView( + key: ValueKey( + 'pg_mat_${connection.id}_${pg.schema}_${pg.name}_${pg.kind}', + ), + connectionRow: connection, + database: pg.database, + schema: pg.schema, + tableName: pg.name, + isView: false, + isMaterializedView: true, + ); + case PostgresObjectKind.function: + return PostgresRoutineView( + key: ValueKey('pg_fn_${connection.id}_${pg.schema}_${pg.name}'), + connectionRow: connection, + database: pg.database, + schema: pg.schema, + routineName: pg.name, + ); + case PostgresObjectKind.sequence: + return PostgresSequenceView( + key: ValueKey('pg_seq_${connection.id}_${pg.schema}_${pg.name}'), + connectionRow: connection, + database: pg.database, + schema: pg.schema, + sequenceName: pg.name, + ); + case PostgresObjectKind.schemaIndexes: + return PostgresIndexListView( + key: ValueKey('pg_idx_${connection.id}_${pg.schema}'), + connectionRow: connection, + database: pg.database, + schema: pg.schema, + ); + case PostgresObjectKind.schemaTriggers: + return PostgresTriggerListView( + key: ValueKey('pg_trg_${connection.id}_${pg.schema}'), + connectionRow: connection, + database: pg.database, + schema: pg.schema, + ); + case PostgresObjectKind.schemaTypes: + return PostgresTypeListView( + key: ValueKey('pg_typ_${connection.id}_${pg.schema}'), + connectionRow: connection, + database: pg.database, + schema: pg.schema, + ); + case PostgresObjectKind.databaseExtensions: + return PostgresExtensionListView( + key: ValueKey('pg_ext_${connection.id}_${pg.database}'), + connectionRow: connection, + database: pg.database, + ); + case PostgresObjectKind.databaseForeignData: + return PostgresFdwListView( + key: ValueKey('pg_fdw_${connection.id}_${pg.database}'), + connectionRow: connection, + database: pg.database, + ); + } +} diff --git a/lib/features/postgresql/postgres_sql_editor_dialog.dart b/lib/features/postgresql/postgres_sql_editor_dialog.dart index 86dd032..9f2bcd2 100644 --- a/lib/features/postgresql/postgres_sql_editor_dialog.dart +++ b/lib/features/postgresql/postgres_sql_editor_dialog.dart @@ -129,7 +129,8 @@ class _PostgresSqlEditorDialogState extends material.State<_PostgresSqlEditorDia const material.SizedBox(height: 6), const Text( 'Table browse uses SELECT with LIMIT/OFFSET. ' - 'Edit it or write your own SELECT. Reset restores the table browse query.', + 'Edit it or write your own SELECT. Reset restores the table browse query. ' + 'Run reloads the grid from the database; if rows are unchanged, the view will look the same.', ).muted().xSmall(), ], ), diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index e871a98..6df3747 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -7,18 +7,30 @@ import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/postgres_sql.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.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'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; +import 'package:querya_desktop/features/main_screen/sql_query_history_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; +/// Database used for this SQL workspace session (matches [PostgresService.acquire]). +String _pgSqlSessionDatabase(ConnectionRow row) { + final d = row.databaseName?.trim(); + if (d == null || d.isEmpty) return 'postgres'; + return d; +} + /// Ad-hoc SQL editor + results for a PostgreSQL connection (pgAdmin-style). class PostgresSqlWorkspace extends material.StatefulWidget { const PostgresSqlWorkspace({ super.key, required this.connectionRow, this.transactionOpenNotifier, + this.postgresSqlEditorContext, + this.postgresSqlEditorContextToken = 0, }); final ConnectionRow connectionRow; @@ -26,6 +38,13 @@ class PostgresSqlWorkspace extends material.StatefulWidget { /// Updated when transaction state changes (for tab-switch warnings). final material.ValueNotifier? transactionOpenNotifier; + /// Table/view/matview from the tree: sets session DB (if non-empty) and editor template. + final ({String database, String schema, String name, PostgresObjectKind kind})? + postgresSqlEditorContext; + + /// Increments when [postgresSqlEditorContext] should be re-applied to the editor. + final int postgresSqlEditorContextToken; + @override material.State createState() => _PostgresSqlWorkspaceState(); @@ -37,6 +56,11 @@ class _PostgresSqlWorkspaceState extends material.State { PgLease? _lease; + /// Database used for the current lease (for [PostgresService.interrupt]). + String? _interruptDatabase; + + int _lastAppliedSqlContextToken = -1; + bool _running = false; String? _error; List _columns = []; @@ -52,6 +76,7 @@ class _PostgresSqlWorkspaceState extends material.State { int? _queryTimeoutSeconds; int _resultMaxRows = kDefaultSqlResultMaxRows; + int _historyMaxEntries = kDefaultSqlHistoryMaxEntries; double _editorFontSize = kDefaultSqlEditorFontSize; /// `null` = unknown (older server or error). @@ -67,18 +92,62 @@ class _PostgresSqlWorkspaceState extends material.State { }; AppSettingsRevision.listenable.addListener(_appSettingsListener); material.WidgetsBinding.instance.addPostFrameCallback((_) { + _syncPostgresSqlTreeContext(); unawaited(_loadWorkspaceSettings()); }); } + @override + void didUpdateWidget(covariant PostgresSqlWorkspace oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.connectionRow.id != widget.connectionRow.id) { + _lastAppliedSqlContextToken = -1; + } + _syncPostgresSqlTreeContext(); + } + + String _effectiveSessionDatabase() { + final ctx = widget.postgresSqlEditorContext; + if (ctx != null) { + final d = ctx.database.trim(); + if (d.isNotEmpty) return d; + } + return _pgSqlSessionDatabase(widget.connectionRow); + } + + void _dropLease() { + _lease?.release(); + _lease = null; + _interruptDatabase = null; + } + + void _syncPostgresSqlTreeContext() { + final ctx = widget.postgresSqlEditorContext; + final tok = widget.postgresSqlEditorContextToken; + if (ctx == null) { + _lastAppliedSqlContextToken = tok; + return; + } + if (tok == _lastAppliedSqlContextToken) return; + _lastAppliedSqlContextToken = tok; + _dropLease(); + final sql = postgresBrowseSelectSql(schema: ctx.schema, table: ctx.name); + _sqlController.value = material.TextEditingValue( + text: sql, + selection: material.TextSelection.collapsed(offset: sql.length), + ); + } + Future _loadWorkspaceSettings() async { final t = await AppSettings.instance.getPostgresSqlStmtTimeoutSeconds(); final rows = await AppSettings.instance.getSqlResultMaxRows(); + final hist = await AppSettings.instance.getSqlHistoryMaxEntries(); final font = await AppSettings.instance.getSqlEditorFontSize(); if (!mounted) return; setState(() { _queryTimeoutSeconds = t; _resultMaxRows = rows; + _historyMaxEntries = hist; _editorFontSize = font; }); } @@ -94,11 +163,11 @@ class _PostgresSqlWorkspaceState extends material.State { Future _ensureLease() async { if (_lease != null && _lease!.connection.isConnected) return; - _lease?.release(); - _lease = null; + _dropLease(); + final db = _effectiveSessionDatabase(); final lease = await PostgresService.instance.acquire( widget.connectionRow, - database: widget.connectionRow.databaseName ?? 'postgres', + database: db, mode: PgSessionMode.readWrite, ); if (!mounted) { @@ -106,6 +175,7 @@ class _PostgresSqlWorkspaceState extends material.State { return; } _lease = lease; + _interruptDatabase = db; } Duration? _statementTimeout() => @@ -175,18 +245,19 @@ class _PostgresSqlWorkspaceState extends material.State { if (_running) { PostgresService.instance.interrupt( widget.connectionRow, - database: widget.connectionRow.databaseName ?? 'postgres', + database: _interruptDatabase ?? _effectiveSessionDatabase(), mode: PgSessionMode.readWrite, ); } - _lease?.release(); + _dropLease(); _sqlController.dispose(); super.dispose(); } Future _execute() async { - var sql = _sqlController.text.trim(); - if (sql.isEmpty) return; + final userSql = _sqlController.text.trim(); + if (userSql.isEmpty) return; + var sql = userSql; setState(() { _running = true; @@ -255,6 +326,17 @@ class _PostgresSqlWorkspaceState extends material.State { } _running = false; }); + final cid = widget.connectionRow.id; + if (cid != null) { + unawaited( + LocalDb.instance.recordSqlQueryHistory( + connectionId: cid, + databaseName: _effectiveSessionDatabase(), + sqlText: userSql, + maxEntries: _historyMaxEntries, + ), + ); + } } on pg.ServerException catch (e) { if (mounted) { setState(() { @@ -305,6 +387,7 @@ class _PostgresSqlWorkspaceState extends material.State { crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ _SqlToolbar( + sessionDatabase: _effectiveSessionDatabase(), onExecute: _running ? null : _execute, running: _running, autocommit: _autocommit, @@ -314,6 +397,17 @@ class _PostgresSqlWorkspaceState extends material.State { onQueryTimeoutChanged: _onStmtTimeoutChanged, onOpenPreferences: () => showPreferencesDialog(context), + onOpenHistory: widget.connectionRow.id != null && + !_running + ? () { + showSqlQueryHistoryDialog( + context: context, + connectionId: widget.connectionRow.id!, + databaseName: _effectiveSessionDatabase(), + sqlController: _sqlController, + ); + } + : null, txOpen: _txOpen, onBegin: _running ? null @@ -386,6 +480,7 @@ class _PostgresSqlWorkspaceState extends material.State { class _SqlToolbar extends material.StatelessWidget { const _SqlToolbar({ + required this.sessionDatabase, required this.onExecute, required this.running, required this.autocommit, @@ -393,12 +488,15 @@ class _SqlToolbar extends material.StatelessWidget { required this.queryTimeoutSeconds, required this.onQueryTimeoutChanged, required this.onOpenPreferences, + this.onOpenHistory, required this.txOpen, required this.onBegin, required this.onCommit, required this.onRollback, }); + /// Effective PostgreSQL database for queries in this tab (from the connection profile). + final String sessionDatabase; final Future Function()? onExecute; final bool running; final bool autocommit; @@ -406,6 +504,7 @@ class _SqlToolbar extends material.StatelessWidget { final int? queryTimeoutSeconds; final void Function(int?) onQueryTimeoutChanged; final VoidCallback onOpenPreferences; + final VoidCallback? onOpenHistory; final bool? txOpen; final void Function()? onBegin; final void Function()? onCommit; @@ -432,8 +531,20 @@ class _SqlToolbar extends material.StatelessWidget { children: [ const Text('Query').semiBold().small(), const Gap(12), + Text('DB: $sessionDatabase').muted().small(), + const Gap(12), Text(_txLabel()).muted().small(), const Spacer(), + OutlineButton( + size: ButtonSize.small, + onPressed: onOpenHistory, + leading: const material.Icon( + material.Icons.history_rounded, + size: 16, + ), + child: const Text('History'), + ), + const Gap(8), OutlineButton( onPressed: onExecute, leading: running diff --git a/lib/features/postgresql/postgres_table_privileges_dialog.dart b/lib/features/postgresql/postgres_table_privileges_dialog.dart index 512055d..b66dbc2 100644 --- a/lib/features/postgresql/postgres_table_privileges_dialog.dart +++ b/lib/features/postgresql/postgres_table_privileges_dialog.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; @@ -78,126 +80,159 @@ class _PrivilegesDialogBodyState extends material.State<_PrivilegesDialogBody> { material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; final radius = Theme.of(context).radiusXxl; + final mq = material.MediaQuery.sizeOf(context); + final dialogHeight = + math.min(480.0, math.max(260.0, mq.height * 0.72)).toDouble(); + final dialogWidth = + math.min(560.0, math.max(300.0, mq.width - 48)).toDouble(); + return material.Dialog( backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), - child: material.Container( - constraints: const material.BoxConstraints( - maxWidth: 560, - maxHeight: 480, - ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(20, 16, 20, 8), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - const Text('Table privileges').large().semiBold(), - const material.SizedBox(height: 4), - Text( - '${widget.schema}.${widget.tableName}', - style: material.TextStyle( - fontFamily: 'monospace', - fontSize: 12, - color: theme.mutedForeground, - ), + child: material.SizedBox( + width: dialogWidth, + height: dialogHeight, + child: material.DecoratedBox( + decoration: material.BoxDecoration( + color: theme.popover, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: theme.muted), + ), + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(radius), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(20, 16, 20, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('Table privileges').large().semiBold(), + const material.SizedBox(height: 4), + material.Text( + '${widget.schema}.${widget.tableName}', + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: theme.mutedForeground, + ), + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + ), + const material.SizedBox(height: 4), + const Text( + 'From information_schema.role_table_grants (read-only).', + ).muted().xSmall(), + ], ), - const material.SizedBox(height: 4), - const Text( - 'From information_schema.role_table_grants (read-only).', - ).muted().xSmall(), - ], - ), - ), - const material.Divider(height: 1), - material.SizedBox( - height: 300, - child: _loading - ? const material.Center( - child: material.CircularProgressIndicator(strokeWidth: 2), - ) - : _error != null - ? material.Center( - child: material.Padding( - padding: const material.EdgeInsets.all(16), - child: material.SelectableText( - _error!, - style: material.TextStyle(color: theme.destructive), - ), - ), - ) - : material.ListView( - padding: const material.EdgeInsets.all(12), - children: [ - for (final r in _rows) - material.Padding( - padding: - const material.EdgeInsets.only(bottom: 8), - child: material.Row( - crossAxisAlignment: - material.CrossAxisAlignment.start, - children: [ - material.SizedBox( - width: 140, - child: material.Text( - r.grantee, - style: material.TextStyle( - fontSize: 12, - color: theme.foreground, - ), - ), - ), - material.Expanded( - child: material.Text( - r.privilegeType, - style: material.TextStyle( - fontFamily: 'monospace', - fontSize: 12, - color: theme.foreground, - ), - ), - ), - Text(r.isGrantable).xSmall().muted(), - ], - ), - ), - if (_rows.isEmpty) - const Text( - 'No grants found (or no permission to view).', - ).muted().small(), - ], + ), + const material.Divider(height: 1), + material.Expanded(child: _buildListArea(theme)), + const material.Divider(height: 1), + material.Padding( + padding: const material.EdgeInsets.all(12), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + OutlineButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + if (!_loading && _error == null) ...[ + const Gap(8), + OutlineButton( + onPressed: _load, + child: const Text('Reload'), ), - ), - material.Padding( - padding: const material.EdgeInsets.all(12), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - children: [ - OutlineButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Close'), + ], + ], ), - if (!_loading && _error == null) ...[ - const Gap(8), - OutlineButton( - onPressed: _load, - child: const Text('Reload'), - ), - ], - ], - ), + ), + ], ), - ], + ), ), ), ); } + + material.Widget _buildListArea(ColorScheme theme) { + if (_loading) { + return const material.Center( + child: material.CircularProgressIndicator(strokeWidth: 2), + ); + } + if (_error != null) { + return material.Center( + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.all(16), + child: material.SelectableText( + _error!, + style: material.TextStyle(color: theme.destructive, fontSize: 13), + ), + ), + ); + } + if (_rows.isEmpty) { + return material.Center( + child: const Text( + 'No grants found (or no permission to view).', + ).muted().small(), + ); + } + return material.ListView.builder( + padding: const material.EdgeInsets.all(12), + itemCount: _rows.length, + itemBuilder: (context, i) { + final r = _rows[i]; + return material.Padding( + padding: const material.EdgeInsets.only(bottom: 8), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.SizedBox( + width: 132, + child: material.Text( + r.grantee, + style: material.TextStyle( + fontSize: 12, + color: theme.foreground, + ), + maxLines: 3, + overflow: material.TextOverflow.ellipsis, + ), + ), + material.Expanded( + child: material.Text( + r.privilegeType, + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: theme.foreground, + ), + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + ), + ), + material.SizedBox( + width: 48, + child: material.Text( + r.isGrantable, + style: material.TextStyle( + fontSize: 11, + color: theme.mutedForeground, + ), + textAlign: material.TextAlign.right, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }, + ); + } } diff --git a/lib/features/postgresql/postgres_table_utils.dart b/lib/features/postgresql/postgres_table_utils.dart index bf50e81..19355bb 100644 --- a/lib/features/postgresql/postgres_table_utils.dart +++ b/lib/features/postgresql/postgres_table_utils.dart @@ -1,6 +1,19 @@ /// Utilities for PostgreSQL table data view (quoting, row conversion). library; +/// Default page size for table browse and the SQL template filled from the tree. +const kPostgresBrowseDefaultRowLimit = 200; + +/// `SELECT *` template matching [PostgresTableView] browse (same limit/offset). +String postgresBrowseSelectSql({ + required String schema, + required String table, + int limit = kPostgresBrowseDefaultRowLimit, +}) { + String q(String name) => quotePostgresIdentifier(name); + return 'SELECT * FROM ${q(schema)}.${q(table)} LIMIT $limit OFFSET 0;\n'; +} + /// Quotes a PostgreSQL identifier (e.g. schema or table name). /// Doubles any internal double-quote. String quotePostgresIdentifier(String name) { diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index c01aeb3..fd51e98 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -9,8 +9,6 @@ import 'package:querya_desktop/features/postgresql/postgres_table_toolbar.dart'; import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -const _defaultLimit = 200; - class PostgresTableView extends material.StatefulWidget { const PostgresTableView({ super.key, @@ -20,7 +18,7 @@ class PostgresTableView extends material.StatefulWidget { required this.tableName, this.isView = false, this.isMaterializedView = false, - this.limit = _defaultLimit, + this.limit = kPostgresBrowseDefaultRowLimit, }); final ConnectionRow connectionRow; @@ -268,7 +266,7 @@ class _PostgresTableViewState extends material.State { final trimmed = sql.trim(); if (!isAllowedPostgresSelectQuery(trimmed)) return; final browse = _browseDataSql().trim(); - if (trimmed == browse) { + if (_browseSqlCompareKey(trimmed) == _browseSqlCompareKey(browse)) { setState(() { _customSqlActive = false; _customSql = null; @@ -574,6 +572,15 @@ class _PostgresTableViewState extends material.State { ); } + /// Ignores trailing semicolons and whitespace so Run matches the browse query. + 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; diff --git a/lib/features/postgresql/postgres_workspace_home.dart b/lib/features/postgresql/postgres_workspace_home.dart index f60f17f..7a170bc 100644 --- a/lib/features/postgresql/postgres_workspace_home.dart +++ b/lib/features/postgresql/postgres_workspace_home.dart @@ -2,6 +2,7 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; import 'package:querya_desktop/features/postgresql/postgres_sql_workspace.dart'; import 'package:querya_desktop/features/postgresql/postgres_stats_view.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -11,11 +12,20 @@ class PostgresWorkspaceHome extends material.StatefulWidget { const PostgresWorkspaceHome({ super.key, required this.connectionRow, + this.postgresSqlEditorContext, + this.postgresSqlEditorContextToken = 0, this.sqlTabRequestToken = 0, }); final ConnectionRow connectionRow; + /// Set when opening SQL from the tree (e.g. "Open in SQL") to seed session DB + template. + final ({String database, String schema, String name, PostgresObjectKind kind})? + postgresSqlEditorContext; + + /// Bumps when [postgresSqlEditorContext] should be applied to the editor. + final int postgresSqlEditorContextToken; + /// Parent increments this to request switching to the SQL tab (e.g. from browser context menu). final int sqlTabRequestToken; @@ -151,6 +161,9 @@ class _PostgresWorkspaceHomeState extends material.State key: ValueKey('pg_sql_${widget.connectionRow.id}'), connectionRow: widget.connectionRow, transactionOpenNotifier: _sqlTxNotifier, + postgresSqlEditorContext: widget.postgresSqlEditorContext, + postgresSqlEditorContextToken: + widget.postgresSqlEditorContextToken, ), ], ), diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 0f98fde..701a7ce 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -30,6 +30,7 @@ class _PreferencesDialogContentState extends material.State<_PreferencesDialogCo int? _pgTimeout; int? _mysqlTimeout; int _maxRows = kDefaultSqlResultMaxRows; + int _historyMax = kDefaultSqlHistoryMaxEntries; double _fontSize = kDefaultSqlEditorFontSize; @override @@ -42,12 +43,14 @@ class _PreferencesDialogContentState extends material.State<_PreferencesDialogCo final pg = await AppSettings.instance.getPostgresSqlStmtTimeoutSeconds(); final my = await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(); final rows = await AppSettings.instance.getSqlResultMaxRows(); + final hist = await AppSettings.instance.getSqlHistoryMaxEntries(); final font = await AppSettings.instance.getSqlEditorFontSize(); if (!mounted) return; setState(() { _pgTimeout = pg; _mysqlTimeout = my; _maxRows = rows; + _historyMax = hist; _fontSize = font; _loading = false; }); @@ -73,6 +76,11 @@ class _PreferencesDialogContentState extends material.State<_PreferencesDialogCo await AppSettings.instance.setSqlEditorFontSize(v); } + Future _setHistoryMax(int v) async { + setState(() => _historyMax = v); + await AppSettings.instance.setSqlHistoryMaxEntries(v); + } + @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; @@ -168,6 +176,46 @@ class _PreferencesDialogContentState extends material.State<_PreferencesDialogCo ], ), const material.SizedBox(height: 12), + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Padding( + padding: const material.EdgeInsets.only(top: 8), + child: const Text('Query history limit').small(), + ), + const material.SizedBox(width: 12), + material.Expanded( + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.start, + children: [ + material.DropdownButton( + value: _historyMax, + isExpanded: true, + onChanged: (v) { + if (v != null) { + unawaited(_setHistoryMax(v)); + } + }, + items: [ + for (final n + in kSqlHistoryMaxEntriesPresets) + material.DropdownMenuItem( + value: n, + child: material.Text('$n entries'), + ), + ], + ), + const material.SizedBox(height: 4), + const Text( + 'Per connection and database; oldest queries are dropped.', + ).muted().xSmall(), + ], + ), + ), + ], + ), + const material.SizedBox(height: 12), material.Row( children: [ const Text('Font size').small(), diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index a3eaab3..c3070bc 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,12 +7,16 @@ #include "generated_plugin_registrant.h" #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) bitsdojo_window_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "BitsdojoWindowPlugin"); bitsdojo_window_plugin_register_with_registrar(bitsdojo_window_linux_registrar); + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 869134d..5d0c373 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST bitsdojo_window_linux + file_selector_linux flutter_secure_storage_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 3a8b621..1f85d46 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,11 +6,13 @@ import FlutterMacOS import Foundation import bitsdojo_window_macos +import file_selector_macos import flutter_secure_storage_macos import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { BitsdojoWindowPlugin.register(with: registry.registrar(forPlugin: "BitsdojoWindowPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) } diff --git a/pubspec.yaml b/pubspec.yaml index 45e0ba1..a3181b2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,7 @@ name: querya_desktop description: Lightweight desktop SQL/NoSQL client. Flutter (Dart). -version: 0.1.4+2 +version: 0.2.0+2 + environment: @@ -22,6 +23,7 @@ dependencies: fl_chart: ^0.69.0 mysql_client: ^0.0.27 flutter_secure_storage: ^9.2.4 + file_selector: ^1.1.0 dev_dependencies: flutter_test: diff --git a/test/core/csv/result_grid_csv_test.dart b/test/core/csv/result_grid_csv_test.dart new file mode 100644 index 0000000..d459d07 --- /dev/null +++ b/test/core/csv/result_grid_csv_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/csv/result_grid_csv.dart'; + +void main() { + group('resultGridAsCsv', () { + test('escapes commas and quotes', () { + expect( + resultGridAsCsv( + const ['a', 'b'], + const [ + ['1', 'two,comma'], + ['quote', 'say "hi"'], + ], + ), + 'a,b\n1,"two,comma"\nquote,"say ""hi"""', + ); + }); + + test('pads short rows', () { + expect( + resultGridAsCsv(const ['x', 'y', 'z'], const [ + ['only'], + ]), + 'x,y,z\nonly,,', + ); + }); + }); +} diff --git a/test/core/json/result_grid_json_test.dart b/test/core/json/result_grid_json_test.dart new file mode 100644 index 0000000..4a480fd --- /dev/null +++ b/test/core/json/result_grid_json_test.dart @@ -0,0 +1,54 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/json/result_grid_json.dart'; + +void main() { + group('resultGridAsJson', () { + test('encodes columns and rows', () { + final s = resultGridAsJson( + const ['a', 'b'], + const [ + ['1', 'two'], + ['x', 'y'], + ], + ); + expect(jsonDecode(s), { + 'columns': ['a', 'b'], + 'rows': [ + ['1', 'two'], + ['x', 'y'], + ], + }); + }); + + test('escapes strings for JSON', () { + final s = resultGridAsJson( + const ['q'], + const [ + ['say "hi"'], + ['line\nbreak'], + ], + ); + expect(jsonDecode(s), { + 'columns': ['q'], + 'rows': [ + ['say "hi"'], + ['line\nbreak'], + ], + }); + }); + + test('pads short rows', () { + final s = resultGridAsJson(const ['x', 'y', 'z'], const [ + ['only'], + ]); + expect(jsonDecode(s), { + 'columns': ['x', 'y', 'z'], + 'rows': [ + ['only', '', ''], + ], + }); + }); + }); +} diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index 4a0cd94..7a50557 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -62,6 +62,7 @@ void main() { await AppSettings.instance.setMysqlSqlStmtTimeoutSeconds(null); await LocalDb.instance.deleteAppSetting(AppSettingsKeys.sqlResultMaxRows); await LocalDb.instance.deleteAppSetting(AppSettingsKeys.sqlEditorFontSizePoints); + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.sqlHistoryMaxEntries); }); group('AppSettings', () { @@ -142,6 +143,39 @@ void main() { await AppSettings.instance.setSqlEditorFontSize(30); expect(await AppSettings.instance.getSqlEditorFontSize(), 24); }); + + test('getSqlHistoryMaxEntries defaults and normalizes', () async { + expect( + await AppSettings.instance.getSqlHistoryMaxEntries(), + kDefaultSqlHistoryMaxEntries, + ); + + await AppSettings.instance.setSqlHistoryMaxEntries(200); + expect(await AppSettings.instance.getSqlHistoryMaxEntries(), 200); + + await LocalDb.instance.setAppSetting( + AppSettingsKeys.sqlHistoryMaxEntries, + '40', + ); + expect(await AppSettings.instance.getSqlHistoryMaxEntries(), 50); + + await LocalDb.instance.setAppSetting( + AppSettingsKeys.sqlHistoryMaxEntries, + 'not-int', + ); + expect( + await AppSettings.instance.getSqlHistoryMaxEntries(), + kDefaultSqlHistoryMaxEntries, + ); + }); + + test('setSqlHistoryMaxEntries snaps non-preset to nearest', () async { + await AppSettings.instance.setSqlHistoryMaxEntries(30); + expect(await AppSettings.instance.getSqlHistoryMaxEntries(), 25); + + await AppSettings.instance.setSqlHistoryMaxEntries(180); + expect(await AppSettings.instance.getSqlHistoryMaxEntries(), 200); + }); }); group('AppSettingsRevision', () { diff --git a/test/core/storage/sql_query_history_test.dart b/test/core/storage/sql_query_history_test.dart new file mode 100644 index 0000000..38a9c20 --- /dev/null +++ b/test/core/storage/sql_query_history_test.dart @@ -0,0 +1,245 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import '../../memory_secrets_backend.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; + + @override + Future getTemporaryPath() async => _root; + + @override + Future getApplicationDocumentsPath() async => _root; + + @override + Future getApplicationCachePath() async => _root; + + @override + Future getLibraryPath() async => _root; + + @override + Future getExternalStoragePath() async => _root; + + @override + Future?> getExternalCachePaths() async => [_root]; + + @override + Future?> getExternalStoragePaths({StorageDirectory? type}) async => + [_root]; + + @override + Future getDownloadsPath() async => _root; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('querya_sql_history_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + tearDown(() async { + testMemorySecrets.clear(); + for (final c in await LocalDb.instance.getConnections()) { + if (c.id != null) await LocalDb.instance.removeConnection(c.id!); + } + }); + + group('Sql query history', () { + test('records and lists newest first', () async { + const row = ConnectionRow( + type: 'postgres', + name: 'P1', + host: '127.0.0.1', + port: 5432, + createdAt: '2026-01-01T00:00:00Z', + ); + final id = await LocalDb.instance.addConnection(row); + + await LocalDb.instance.recordSqlQueryHistory( + connectionId: id, + databaseName: null, + sqlText: 'select 1', + ); + await LocalDb.instance.recordSqlQueryHistory( + connectionId: id, + databaseName: null, + sqlText: 'select 2', + ); + + final list = await LocalDb.instance.listSqlQueryHistory( + connectionId: id, + databaseName: null, + limit: 10, + ); + expect(list.map((e) => e.sqlText), ['select 2', 'select 1']); + }); + + test('trims to maxEntries per connection and database bucket', () async { + const row = ConnectionRow( + type: 'mysql', + name: 'M1', + host: '127.0.0.1', + port: 3306, + createdAt: '2026-01-01T00:00:00Z', + ); + final id = await LocalDb.instance.addConnection(row); + + for (var i = 0; i < 5; i++) { + await LocalDb.instance.recordSqlQueryHistory( + connectionId: id, + databaseName: 'db1', + sqlText: 'q$i', + maxEntries: 3, + ); + } + + final list = await LocalDb.instance.listSqlQueryHistory( + connectionId: id, + databaseName: 'db1', + limit: 10, + ); + expect(list.map((e) => e.sqlText), ['q4', 'q3', 'q2']); + }); + + test('separate buckets for different database_name', () async { + const row = ConnectionRow( + type: 'postgres', + name: 'P2', + host: '127.0.0.1', + port: 5432, + createdAt: '2026-01-01T00:00:00Z', + ); + final id = await LocalDb.instance.addConnection(row); + + await LocalDb.instance.recordSqlQueryHistory( + connectionId: id, + databaseName: null, + sqlText: 'on default', + ); + await LocalDb.instance.recordSqlQueryHistory( + connectionId: id, + databaseName: 'other', + sqlText: 'on other', + ); + + final a = await LocalDb.instance.listSqlQueryHistory( + connectionId: id, + databaseName: null, + ); + final b = await LocalDb.instance.listSqlQueryHistory( + connectionId: id, + databaseName: 'other', + ); + expect(a.single.sqlText, 'on default'); + expect(b.single.sqlText, 'on other'); + }); + + test('clearSqlQueryHistoryBucket removes only matching database', () async { + const row = ConnectionRow( + type: 'postgres', + name: 'P3', + host: '127.0.0.1', + port: 5432, + createdAt: '2026-01-01T00:00:00Z', + ); + final id = await LocalDb.instance.addConnection(row); + await LocalDb.instance.recordSqlQueryHistory( + connectionId: id, + databaseName: null, + sqlText: 'a', + ); + await LocalDb.instance.recordSqlQueryHistory( + connectionId: id, + databaseName: 'x', + sqlText: 'b', + ); + await LocalDb.instance.clearSqlQueryHistoryBucket( + connectionId: id, + databaseName: 'x', + ); + final def = await LocalDb.instance.listSqlQueryHistory( + connectionId: id, + databaseName: null, + ); + final x = await LocalDb.instance.listSqlQueryHistory( + connectionId: id, + databaseName: 'x', + ); + expect(def.single.sqlText, 'a'); + expect(x, isEmpty); + }); + + test('clearSqlQueryHistoryForConnection removes all rows', () async { + const row = ConnectionRow( + type: 'redis', + name: 'R9', + host: '127.0.0.1', + port: 6379, + createdAt: '2026-01-01T00:00:00Z', + ); + final id = await LocalDb.instance.addConnection(row); + await LocalDb.instance.recordSqlQueryHistory( + connectionId: id, + sqlText: 'x', + ); + await LocalDb.instance.clearSqlQueryHistoryForConnection(id); + final list = await LocalDb.instance.listSqlQueryHistory( + connectionId: id, + limit: 5, + ); + expect(list, isEmpty); + }); + + test('removeConnection drops history via FK', () async { + const row = ConnectionRow( + type: 'redis', + name: 'R8', + host: '127.0.0.1', + port: 6379, + createdAt: '2026-01-01T00:00:00Z', + ); + final id = await LocalDb.instance.addConnection(row); + await LocalDb.instance.recordSqlQueryHistory( + connectionId: id, + sqlText: 'x', + ); + await LocalDb.instance.removeConnection(id); + await LocalDb.instance.close(); + sqfliteFfiInit(); + final dbFile = p.join(tempDir.path, 'querya_desktop', 'querya.db'); + final raw = await databaseFactoryFfi.openDatabase( + dbFile, + options: OpenDatabaseOptions(readOnly: true), + ); + try { + final rows = + await raw.rawQuery('SELECT COUNT(*) AS c FROM sql_query_history'); + expect(rows.first['c'], 0); + } finally { + await raw.close(); + } + }); + }); +} diff --git a/test/features/connections/connections_panel_layout_test.dart b/test/features/connections/connections_panel_layout_test.dart index 13832ac..244cbfe 100644 --- a/test/features/connections/connections_panel_layout_test.dart +++ b/test/features/connections/connections_panel_layout_test.dart @@ -103,7 +103,7 @@ void main() { themeMode: ThemeMode.dark, home: material.SizedBox.expand( child: ConnectionsPanel( - onPostgresOpenSqlWorkspace: (_) {}, + onPostgresOpenSqlWorkspace: (_, {database, schema, name, kind}) {}, ), ), ), @@ -189,7 +189,7 @@ void main() { home: material.SizedBox.expand( child: ConnectionsPanel( skipInitialDbLoadForTest: true, - onPostgresOpenSqlWorkspace: (_) {}, + onPostgresOpenSqlWorkspace: (_, {database, schema, name, kind}) {}, ), ), ), 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 1c712e9..4023ef9 100644 --- a/test/features/main_screen/main_screen_workspace_state_test.dart +++ b/test/features/main_screen/main_screen_workspace_state_test.dart @@ -41,6 +41,8 @@ void main() { final next = withPg.selectConnection(mysqlConn); expect(next.activeConnection?.id, 11); expect(next.selectedPostgresObject, isNull); + expect(next.postgresSqlEditorContext, isNull); + expect(next.postgresSqlEditorContextToken, 0); expect(next.activeRedisDb, isNull); expect(next.activeMongoDB, isNull); }); @@ -56,6 +58,8 @@ void main() { expect(s.activeConnection?.id, 10); expect(s.selectedPostgresObject?.name, 'users'); expect(s.selectedPostgresObject?.kind, PostgresObjectKind.table); + expect(s.postgresSqlEditorContext, isNull); + expect(s.postgresSqlEditorContextToken, 0); }); test('openPostgresSqlWorkspace bumps token and clears selection', () { @@ -69,6 +73,66 @@ void main() { final sql = withObj.openPostgresSqlWorkspace(pgConn); expect(sql.postgresSqlTabRequestToken, 1); expect(sql.selectedPostgresObject, isNull); + expect(sql.postgresSqlEditorContext, isNull); + expect(sql.postgresSqlEditorContextToken, 0); + }); + + test('openPostgresSqlWorkspace seeds editor from selected table/view', () { + final withTable = MainScreenWorkspaceState.empty.selectPostgresObject( + pgConn, + 'warehouse', + 'public', + 'stock', + PostgresObjectKind.table, + ); + final sql = withTable.openPostgresSqlWorkspace(pgConn); + expect(sql.postgresSqlTabRequestToken, 1); + expect(sql.selectedPostgresObject, isNull); + expect(sql.postgresSqlEditorContext?.database, 'warehouse'); + expect(sql.postgresSqlEditorContext?.name, 'stock'); + expect(sql.postgresSqlEditorContextToken, 1); + }); + + test('openPostgresSqlWorkspace explicit seed overrides workspace selection', + () { + final withBatches = MainScreenWorkspaceState.empty.selectPostgresObject( + pgConn, + 'postgres', + 'public', + 'batches', + PostgresObjectKind.table, + ); + final sql = withBatches.openPostgresSqlWorkspace( + pgConn, + seedDatabase: 'postgres', + seedSchema: 'public', + seedName: 'stock', + seedKind: PostgresObjectKind.table, + ); + expect(sql.postgresSqlEditorContext?.name, 'stock'); + expect(sql.postgresSqlTabRequestToken, 1); + }); + + test('openPostgresSqlWorkspace does not seed for another connection id', + () { + final otherPg = ConnectionRow( + type: 'postgresql', + name: 'pg2', + host: '127.0.0.1', + port: 5432, + createdAt: createdAt, + id: 99, + ); + final withTable = MainScreenWorkspaceState.empty.selectPostgresObject( + pgConn, + 'warehouse', + 'public', + 'stock', + PostgresObjectKind.table, + ); + final sql = withTable.openPostgresSqlWorkspace(otherPg); + expect(sql.postgresSqlEditorContext, isNull); + expect(sql.postgresSqlEditorContextToken, 0); }); test('selectRedisDb and selectMongoDb are mutually exclusive fields', () { diff --git a/test/features/main_screen/workspace_homes_and_preferences_test.dart b/test/features/main_screen/workspace_homes_and_preferences_test.dart new file mode 100644 index 0000000..59e71b0 --- /dev/null +++ b/test/features/main_screen/workspace_homes_and_preferences_test.dart @@ -0,0 +1,164 @@ +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/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:querya_desktop/features/connections/driver_manager_dialog.dart'; +import 'package:querya_desktop/features/mysql/mysql_workspace_home.dart'; +import 'package:querya_desktop/features/settings/preferences_dialog.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; + + @override + Future getTemporaryPath() async => _root; + + @override + Future getApplicationDocumentsPath() async => _root; + + @override + Future getApplicationCachePath() async => _root; + + @override + Future getLibraryPath() async => _root; + + @override + Future getExternalStoragePath() async => _root; + + @override + Future?> getExternalCachePaths() async => [_root]; + + @override + Future?> getExternalStoragePaths({StorageDirectory? type}) async => + [_root]; + + @override + Future getDownloadsPath() async => _root; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const myConn = ConnectionRow( + id: 2, + type: 'mysql', + name: 'Test MySQL', + host: '127.0.0.1', + port: 3306, + createdAt: '2026-01-01T00:00:00.000Z', + ); + + group('MysqlWorkspaceHome', () { + testWidgets('shows tabs and SQL tab exposes Execute control', (tester) async { + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: const material.Scaffold( + body: material.SizedBox( + width: 700, + height: 500, + child: MysqlWorkspaceHome(connectionRow: myConn), + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('MySQL'), findsOneWidget); + expect(find.text('Server'), findsOneWidget); + expect(find.text('SQL'), findsOneWidget); + + await tester.tap(find.text('SQL')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.textContaining('Execute'), findsWidgets); + }); + }); + + group('Driver Manager dialog', () { + testWidgets('showDriverManagerDialog shows built-in drivers copy', (tester) async { + await tester.binding.setSurfaceSize(const material.Size(900, 1200)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + late material.BuildContext ctx; + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: material.Builder( + builder: (c) { + ctx = c; + return material.TextButton( + onPressed: () => showDriverManagerDialog(ctx), + child: const material.Text('open-drivers'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('open-drivers')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.text('Driver Manager'), findsOneWidget); + expect(find.textContaining('built-in Dart'), findsWidgets); + }); + }); + + group('Preferences dialog', () { + late Directory tempDir; + + setUpAll(() async { + tempDir = await Directory.systemTemp + .createTemp('querya_prefs_dialog_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + await LocalDb.instance.close(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + testWidgets('showPreferencesDialog shows preferences title', (tester) async { + late material.BuildContext ctx; + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: material.Builder( + builder: (c) { + ctx = c; + return material.TextButton( + onPressed: () => showPreferencesDialog(ctx), + child: const material.Text('open-prefs'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('open-prefs')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.text('Preferences'), findsOneWidget); + }); + }); +} diff --git a/test/features/postgresql/postgres_table_utils_test.dart b/test/features/postgresql/postgres_table_utils_test.dart index 372b26b..221ccb5 100644 --- a/test/features/postgresql/postgres_table_utils_test.dart +++ b/test/features/postgresql/postgres_table_utils_test.dart @@ -23,6 +23,15 @@ void main() { }); }); + group('postgresBrowseSelectSql', () { + test('quotes identifiers and uses default limit', () { + expect( + postgresBrowseSelectSql(schema: 'public', table: 'stock'), + 'SELECT * FROM "public"."stock" LIMIT 200 OFFSET 0;\n', + ); + }); + }); + group('convertResultRowsToStrings', () { test('converts null to "NULL"', () { final result = convertResultRowsToStrings([ diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index beebfba..d1b0b32 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,11 +7,14 @@ #include "generated_plugin_registrant.h" #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { BitsdojoWindowPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("BitsdojoWindowPlugin")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 5181336..25b9150 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST bitsdojo_window_windows + file_selector_windows flutter_secure_storage_windows )