diff --git a/CHANGELOG.md b/CHANGELOG.md index 41f75e9b..b0720909 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.13] - 2026-08-25 + +Production-ready UI polish, complete interactive Data Grid editing suite, advanced query filtering, fluid collapsible navigation, and platform hardening. + +### Added + +- **In-Place Cell Editing & Staging Engine (#560, #561, #564, #565)** — Double-click cell to edit in-place with type-aware inline editors, dirty state indicators, staging buffer for staged mutations (inserts/updates/deletions), and keyboard navigation (Tab/Shift+Tab, Enter, Escape). +- **Atomic DML Preview & Multi-Dialect Generation (#562, #566)** — Visual DML confirmation modal displaying compiled atomic `UPDATE`, `INSERT`, and `DELETE` statements with primary key resolution before committing to SQLite, PostgreSQL, and MySQL. +- **In-Cell Validation & Safety Guardrails (#567)** — Real-time cell validation for integer, float, boolean, UUID, JSON, date, and timestamp data types with visual error cues. +- **Advanced Query Filter Engine (#568, #569, #570)** — Compound predicate filter bar with `AND`, `OR`, `NOT`, parentheses, `LIKE`, `ILIKE`, `IN`, `IS NULL`, `BETWEEN`, escaped quotes, and intelligent popup autocomplete suggestions. +- **Syntax Highlighting & Value Inspector (#571, #572)** — Dedicated inspector panel with syntax highlighting for JSON, XML, YAML, and automated XML/HTML formatting and validation. +- **Selection Statistics & Quick Calc (#573)** — Extended selection calculations in the status bar (Count, Distinct, Sum, Avg, Min, Max, Median, Standard Deviation) with one-click clipboard summary export. +- **Multi-Column Grouping & Pivot View (#574, #575, #576)** — Hierarchical multi-level grouping, custom aggregations (SUM, AVG, MIN, MAX, COUNT), sorting, and CSV export for grouped summaries. +- **Column Drag-Resizing & 3-Phase Sorting (#548, #549)** — Interactive column width drag-resizing with divider handles and 3-phase client-side sorting (`natural` -> `asc` -> `desc`). +- **Multi-Cell Range Selection & Clipboard (#550)** — Rectangular multi-cell selection (Shift+Click) with TSV/CSV clipboard copy for Excel/Google Sheets. +- **Fluid Collapsible Sidebar (#557, #559)** — Physics-driven animated sidebar toggle with `QueryaSpring`, global `Cmd+B` / `Ctrl+B` hotkey, titlebar toggle button, and width persistence. +- **Rich Object Context Menus (#551)** — Right-click native context menus for database tables, views, procedures, and connections (*«Select TOP 100»*, *«Copy SELECT statement»*, *«Copy name»*, *«Open in SQL»*). +- **Tree Keyboard Navigation (#552)** — Full arrow-key navigation (Left/Right to expand/collapse, Up/Down, Enter/Space) across database trees. +- **Interactive Welcome Tour & 1-Click Playground (#558)** — Built-in onboarding tour and 1-click SQLite demo database playground. +- **Global Shortcuts & Focus Polish (#579)** — Added global shortcuts (`Ctrl+F` for filter bar, `Ctrl+S` for staging commit, `Ctrl+G` for groupings panel) and enhanced focus accessibility. + +### Fixed + +- **Connection Dialogs Inline Validation (#553)** — Real-time URI parser and validation with dynamic database driver and host badges. +- **Toolbar Layout Responsive Scroll (#577)** — Wrapped data grid action bars in horizontal scroll to eliminate overflow on compact viewports. +- **macOS SPM Path (#555)** — Corrected relative path to `refresh_rate` plugin source in `Package.swift` for Swift Package Manager builds. +- **macOS App Sandbox Entitlements (#556)** — Added `network.client` and `files.user-selected.read-write` entitlements for outbound TCP connections and local file access. + ## [0.4.11-c] - 2026-07-29 Flutter SDK & dependency compatibility update, image downsampling memory optimization, and UI polish. diff --git a/analysis_options.yaml b/analysis_options.yaml index a584332e..12a3eb0d 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -4,6 +4,12 @@ analyzer: exclude: - "third_party/**" - "build/**" + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** errors: unnecessary_underscores: ignore prefer_initializing_formals: ignore diff --git a/docs/data-grid-interactive-editor-issues.md b/docs/data-grid-interactive-editor-issues.md new file mode 100644 index 00000000..c09742c3 --- /dev/null +++ b/docs/data-grid-interactive-editor-issues.md @@ -0,0 +1,455 @@ +# GitHub Issues: Интерактивный редактор данных (Data Grid) + +Формат ниже рассчитан на перенос в GitHub / GitLab Issues или трекер задач (Jira, Linear). Каждый блок содержит цель, контекст, технический дизайн, шаги реализации и критерии приемки. + +## Labels + +- `data-grid` +- `sql-editor` +- `frontend` +- `backend` +- `ux` +- `parser` +- `tests` + +## Milestones / Epics + +- **Epic A: In-Place Editing & Database DML Synchronization (Редактирование «на лету» и сохранение Ctrl+S)** +- **Epic B: Advanced Quick Filter Engine (Составная фильтрация, логические выражения и предикаты)** +- **Epic C: Value Inspector & Selection Calculations (Подсветка JSON/XML, расширенные метрики Calc)** +- **Epic D: Groupings & Pivot View (Многоуровневая группировка, агрегации SUM/AVG/MIN/MAX)** +- **Epic E: UI Hardening, UX Polish & Regression Tests (Адаптивность тулбара, интеграционные тесты)** + +## Recommended Execution Order + +1. **DG-01 → DG-04:** Архитектура DML-генератора и подключение сохранения к SQL-воркспейсам (PostgreSQL, MySQL, SQLite, Extensions). +2. **DG-05 → DG-07:** Парсер составных выражений `AND`/`OR`, кавычек, `LIKE`, `IN`, `IS NULL` для быстрого фильтра. +3. **DG-08 → DG-10:** Подсветка синтаксиса JSON/XML/YAML, форматирование XML и расширение панели Calc. +4. **DG-11 → DG-13:** Иерархическая группировка по нескольким колонкам и кастомные агрегации в сводных таблицах. +5. **DG-14 → DG-16:** Адаптивность тулбаров, предотвращение layout overflow и сквозные тесты. + +--- + +# Epic A: In-Place Editing & Database DML Synchronization + +## [DG-01](https://github.com/QueryaHub/Querya-Desktop/issues/564) (#564) — Data Grid DML SQL Generator (UPDATE, INSERT, DELETE builder with PK resolution) + +**Labels:** `data-grid`, `backend`, `sql-editor` +**Epic:** A +**Depends on:** none + +### Goal +Создать сервис `DataGridDmlGenerator`, который на основе изменений в `DataGridStagingBuffer` и метаданных таблицы (название таблицы, первичные ключи / уникальные колонки) генерирует безопасные параметризованные или экранированные SQL-выражения `UPDATE`, `INSERT` и `DELETE`. + +### Context +`DataGridStagingBuffer` уже хранит `_modifiedCells`, `_insertedRows` и `_deletedRowIndices`. Однако в проекте отсутствует модуль, преобразующий эти дельты в конкретные SQL-запросы под диалекты PostgreSQL, MySQL и SQLite. + +### Implementation +1. Создать `lib/features/main_screen/data_grid_dml_generator.dart`: + - Модель `DmlStatement` (тип операции `insert`/`update`/`delete`, SQL-текст, исходная строка). + - Метод `generateDml({required String tableName, required List columns, required List primaryKeys, required DataGridStagingBuffer buffer, required SqlDialect dialect})`. + - Для `UPDATE`: генерировать `UPDATE SET col1 = 'val1', col2 = 'val2' WHERE pk1 = 'old_pk1' AND ...`. + - Для `INSERT`: генерировать `INSERT INTO
(col1, col2) VALUES ('val1', 'val2')`. + - Для `DELETE`: генерировать `DELETE FROM
WHERE pk1 = 'pk1'`. + - Корректное экранирование спецсимволов и кавычек под целевой диалект (Postgres `$1` / кавычки, MySQL backticks, SQLite). +2. Написать модульные тесты в `test/features/main_screen/data_grid_dml_generator_test.dart`. + +### Acceptance Criteria +- [ ] Генерируются точные SQL-запросы для единичных и множественных правок ячеек. +- [ ] Если в строке изменены 3 ячейки, генерируется один `UPDATE` со всеми 3 колонками в `SET`. +- [ ] Если primary key отсутствует, используется сопоставление по всем исходным колонкам строки (`WHERE col1 = 'old1' AND col2 = 'old2' ...`). +- [ ] Тесты покрывают диалекты PostgreSQL, MySQL и SQLite. + +--- + +## [DG-02](https://github.com/QueryaHub/Querya-Desktop/issues/565) (#565) — Staging Buffer & Save Integration in SQL Workspaces + +**Labels:** `data-grid`, `frontend`, `sql-editor` +**Epic:** A +**Depends on:** DG-01 + +### Goal +Подключить `DataGridStagingBuffer` и кнопку сохранения к реальным воркспейсам: `postgres_sql_workspace.dart`, `mysql_sql_workspace.dart`, `sqlite_sql_workspace.dart` и `extension_sql_workspace.dart`. + +### Context +Сейчас `ResultsTab` в этих воркспейсах вызывается без передачи `stagingBuffer` и `onApplyChanges`, из-за чего тулбар редактирования не отображается для результатов SQL-запросов. + +### Implementation +1. В состояниях SQL-воркспейсов (`_PostgresSqlWorkspaceState`, `_MysqlSqlWorkspaceState`, `_SqliteSqlWorkspaceState`) сохранять активный `DataGridStagingBuffer? _stagingBuffer`. +2. При успешном выполнении `SELECT`-запроса инициализировать `_stagingBuffer = DataGridStagingBuffer(columns: _columns, rows: _rows)`. +3. Передавать `stagingBuffer: _stagingBuffer`, `isSaving: _savingChanges` и `onApplyChanges: _applyStagedChanges` в `ResultsTab`. +4. В методе `_applyStagedChanges`: + - Определять имя целевой таблицы из выполненного запроса (или запрашивать у пользователя/схемы). + - Вызывать `DataGridDmlGenerator`. + - Выполнять сгенерированные DML-запросы в транзакции через драйвер подключения. + - При успехе: обновлять базовые строки `_rows`, сбрасывать `_stagingBuffer` и выводить уведомление о сохраненных строках. + - При ошибке: откатывать транзакцию и выводить всплывающую ошибку без потери правок в буфере. + +### Acceptance Criteria +- [ ] После выполнения `SELECT * FROM table` доступно редактирование ячеек, добавление строк и удаление строк. +- [ ] При нажатии кнопки *Save Changes* или Ctrl+S / ⌘S изменения атомарно применяются к базе данных. +- [ ] После успешного сохранения статус буфера становится clean (`No changes`). +- [ ] При ошибке базы данных буфер не очищается, пользователь видит текст ошибки и может исправить данные. + +--- + +## [DG-03](https://github.com/QueryaHub/Querya-Desktop/issues/566) (#566) — DML Preview & Confirmation Modal Dialog + +**Labels:** `data-grid`, `frontend`, `ux` +**Epic:** A +**Depends on:** DG-01, DG-02 + +### Goal +Предоставить пользователю возможность просмотреть и скорректировать сгенерированные SQL DML-запросы перед их отправкой в базу данных. + +### Context +При массовом редактировании или удалении строк пользователю важно видеть точные `UPDATE`/`DELETE` инструкции для предотвращения случайной перезаписи или потери данных. + +### Implementation +1. Создать `lib/features/main_screen/data_grid_dml_preview_dialog.dart`: + - Список сгенерированных SQL-запросов с подсветкой синтаксиса. + - Отображение количества затрагиваемых записей (X updates, Y inserts, Z deletes). + - Кнопки: *Cancel*, *Copy SQL*, *Execute SQL*. +2. Добавить настройку в Preferences: `Confirm before applying Data Grid changes` (по умолчанию включена). +3. Интегрировать диалог в `onApplyChanges` поток. + +### Acceptance Criteria +- [ ] При нажатии *Save Changes* открывается модальное окно с текстом SQL-запросов. +- [ ] Пользователь может скопировать запросы или сразу подтвердить их выполнение. +- [ ] Если в настройках отключено подтверждение, сохранение происходит сразу. + +--- + +## [DG-04](https://github.com/QueryaHub/Querya-Desktop/issues/567) (#567) — In-Cell Data Type Validation and Editor Shortcuts + +**Labels:** `data-grid`, `frontend`, `ux` +**Epic:** A +**Depends on:** none + +### Goal +Добавить валидацию типов вводимых данных в инлайн-редакторе `GridCellEditor` (числа, даты, булевы значения, JSON) и расширить хоткеи навигации. + +### Context +Сейчас ячейка принимает любую строку. Если колонка числовая (`INT`, `FLOAT`), а пользователь ввел текст, ошибка возникнет только на этапе сохранения в БД. + +### Implementation +1. Передавать метаданные типов колонок (если доступны из драйвера) в `GridCellEditor`. +2. Если ячейка числовая, подсвечивать некорректный ввод красной рамкой и блокировать подтверждение или показывать tooltip. +3. Для boolean колонок добавить быстрый переключатель по клику или нажатию Space (`true` / `false` / `NULL`). +4. Поддержать быстрый переход по стрелкам вверх/вниз при завершении редактирования по Enter. + +### Acceptance Criteria +- [ ] Числовые поля валидируются на клиенте перед отправкой в буфер. +- [ ] Булевы поля можно переключать клавишей Space. +- [ ] Все хоткеи работают предсказуемо и покрыты тестами. + +--- + +# Epic B: Advanced Quick Filter Engine + +## [DG-05](https://github.com/QueryaHub/Querya-Desktop/issues/568) (#568) — Multi-Clause Query Parser with AND / OR and Parentheses + +**Labels:** `data-grid`, `parser`, `frontend` +**Epic:** B +**Depends on:** none + +### Goal +Расширить `GridFilterEngine` для поддержки сложных составных выражений с логическими связками `AND`, `OR` и группирующими скобками `(...)`. + +### Context +Сейчас `GridFilterEngine._parsePredicate` обрабатывает только один предикат вида `col op val`. Запросы вроде `status = 'ACTIVE' AND amount > 100` или `dept = 'IT' OR dept = 'HR'` не парсятся как предикаты и сваливаются в простой поиск по подстроке. + +### Implementation +1. В `lib/features/main_screen/grid_filter_engine.dart` создать лексер и рекурсивный AST-парсер выражений: + - Токены: `Identifier`, `StringLiteral`, `NumberLiteral`, `Operator` (`=`, `!=`, `<`, `<=`, `>`, `>=`, `:`, `LIKE`, `ILIKE`, `IN`, `IS`), `LogicalOp` (`AND`, `OR`, `NOT`), `OpenParen`, `CloseParen`. + - Построение дерева условий `FilterExpressionNode` (`BinaryNode`, `ComparisonNode`, `NotNode`, `TextSearchNode`). + - Вычисление предиката для каждой строки: `node.evaluate(row, columnIndices)`. +2. Поддержать регистронезависимые ключевые слова (`and`, `AND`, `or`, `OR`). +3. Добавить подробные тесты в `test/features/main_screen/data_grid_engines_test.dart`. + +### Acceptance Criteria +- [ ] Корректно вычисляются выражения вида: `status = ACTIVE AND amount > 100`. +- [ ] Корректно вычисляются выражения с `OR` и скобками: `(role = admin OR role = manager) AND active = true`. +- [ ] При синтаксической ошибке в выражении фильтр плавно переключается в режим полнотекстового поиска без исключений и падений. + +--- + +## [DG-06](https://github.com/QueryaHub/Querya-Desktop/issues/569) (#569) — String Literals, Escaped Quotes and Extended Operators (`IN`, `LIKE`, `IS NULL`, `BETWEEN`) + +**Labels:** `data-grid`, `parser`, `frontend` +**Epic:** B +**Depends on:** DG-05 + +### Goal +Добавить поддержку строковых литералов в кавычках (с пробелами), а также операторов `IN (...)`, `LIKE`, `ILIKE`, `IS NULL`, `IS NOT NULL`, `BETWEEN`. + +### Context +Значения часто содержат пробелы (например `city = 'New York'` или `name LIKE '%John%'`). Без кавычек такие выражения невозможно распарсить однозначно. + +### Implementation +1. Поддержать строки в одинарных `'...'` и двойных `"..."` кавычках. +2. Реализовать операторы: + - `col IN ('A', 'B', 'C')` / `col NOT IN (...)` + - `col LIKE '%pattern%'` (с поддержкой `%` и `_`) + - `col ILIKE '%pattern%'` (регистронезависимый) + - `col IS NULL` и `col IS NOT NULL` + - `col BETWEEN 10 AND 50` +3. Реализовать экранирование кавычек (`\'`, `\"`). + +### Acceptance Criteria +- [ ] Запрос `city = 'New York'` находит только строки со значением `New York` в колонке `city`. +- [ ] Запрос `email LIKE '%@gmail.com'` корректно фильтрует почтовые адреса. +- [ ] Запрос `deleted_at IS NULL` фильтрует только `NULL` или пустые значения. + +--- + +## [DG-07](https://github.com/QueryaHub/Querya-Desktop/issues/570) (#570) — Autocomplete & Syntax Suggestions in Filter Bar + +**Labels:** `data-grid`, `frontend`, `ux` +**Epic:** B +**Depends on:** DG-05 + +### Goal +Добавить всплывающие подсказки (автокомплит) в `DataGridFilterBar` при вводе названий колонок, операторов и уникальных значений. + +### Context +Пользователю удобно видеть доступные имена колонок таблицы и подсказки по операторам сразу при наборе в строке фильтра. + +### Implementation +1. Разработать оверлей подсказок `DataGridFilterAutocomplete`: + - При вводе первого слова предлагать список колонок таблицы. + - После имени колонки предлагать список допустимых операторов (`=`, `!=`, `>`, `<`, `LIKE`, `IN`). + - После оператора предлагать топ-5 уникальных значений из текущих данных этой колонки. +2. Навигация по подсказкам клавишами / и выбор по Enter или Tab. + +### Acceptance Criteria +- [ ] При наборе первых букв колонки всплывает список подсказок. +- [ ] Подсказки корректно вставляются по нажатию Tab / Enter. +- [ ] Оверлей не мешает прокрутке таблицы и закрывается по Esc. + +--- + +# Epic C: Value Inspector & Selection Calculations + +## [DG-08](https://github.com/QueryaHub/Querya-Desktop/issues/571) (#571) — Color Syntax Highlighting for JSON, XML, HTML, SQL in Value Panel + +**Labels:** `data-grid`, `frontend`, `ux` +**Epic:** C +**Depends on:** none + +### Goal +Реализовать цветную подсветку синтаксиса (Syntax Highlighting) в `DataGridValuePanel` и `GridCellPopoverInspector` в соответствии с активной темой оформления. + +### Context +Сейчас длинные строки JSON/XML в инспекторе форматируются отступами, но отображаются монохромным текстом без цветового выделения ключей, строковых значений, чисел, тегов и атрибутов. + +### Implementation +1. Использовать легковесный парсер/хайлайтер токенов или `RichText` / `TextSpan` построитель: + - JSON: подсветка ключей, строк, чисел, boolean и null разными цветами из текущей палитры `QueryaTheme`. + - XML/HTML: подсветка тегов, названий атрибутов, строковых значений атрибутов, комментариев. + - SQL: подсветка ключевых слов, функций и строк. +2. Автоматически определять формат данных ячейки (JSON, XML, SQL, Plain text) или предоставлять переключатель формата в шапке панели. +3. Сохранить производительность для больших документов (до 1-5 МБ). + +### Acceptance Criteria +- [ ] JSON-структуры отображаются с подсветкой ключей, строк и чисел. +- [ ] XML-документы отображаются с подсветкой тегов и атрибутов. +- [ ] Цвета токенов адаптируются под темную и светлую тему Querya. + +--- + +## [DG-09](https://github.com/QueryaHub/Querya-Desktop/issues/572) (#572) — XML / HTML Formatter and Validator in Value Panel + +**Labels:** `data-grid`, `frontend`, `ux` +**Epic:** C +**Depends on:** none + +### Goal +Добавить кнопки форматирования (pretty-print) и валидации для XML и HTML контента в `DataGridValuePanel` аналогично существующему форматированию JSON. + +### Context +В `DataGridValuePanel` есть кнопки `Format JSON` и `Minify`. Для XML-данных (SOAP-ответы, конфигурации) таких инструментов нет. + +### Implementation +1. Создать легковесный утилитный класс `XmlFormatter`: + - Автоматическая расстановка отступов (indent = 2 пробела). + - Минификация XML (удаление лишних пробелов между тегами). + - Базовая валидация закрытия тегов с подсветкой строки с ошибкой. +2. Добавить в тулбар панели кнопку `Format XML` / `Format Document`, которая автоматически определяет JSON или XML и форматирует его. + +### Acceptance Criteria +- [ ] Неформатированный XML аккуратно разбивается по строкам с отступами. +- [ ] При синтаксической ошибке в XML выводится понятное сообщение без сбоя UI. + +--- + +## [DG-10](https://github.com/QueryaHub/Querya-Desktop/issues/573) (#573) — Advanced Selection Calculations (Median, Distinct Count, Copy Stats) + +**Labels:** `data-grid`, `frontend`, `ux` +**Epic:** C +**Depends on:** none + +### Goal +Расширить `GridSelectionCalcEngine` и `DataGridCalcBar` дополнительными статистическими метриками и возможностью копирования сводки. + +### Context +Сейчас `GridSelectionCalcEngine` считает: `Count`, `NULLs`, `Sum`, `Avg`, `Min`, `Max`. Пользователям часто требуются `Distinct Count` (количество уникальных значений) и `Median`. + +### Implementation +1. В `lib/features/main_screen/grid_selection_calc_engine.dart`: + - Добавить расчет `distinctCount` (Set размер). + - Добавить расчет `median` (для числовых рядов). +2. В `DataGridCalcBar`: + - Добавить бейдж `Distinct: X`. + - Добавить бейдж `Median: Y` (если выделено >= 2 чисел). + - Добавить кнопку контекстного меню / копирования полной сводки: `Copy All Stats (Summary)`. + +### Acceptance Criteria +- [ ] При выделении смешанных строк или чисел корректно вычисляется количество уникальных значений. +- [ ] Для числовых значений корректно вычисляется медиана. +- [ ] При клике на "Copy All Stats" в буфер копируется форматированный текст со всеми метриками. + +--- + +# Epic D: Groupings & Pivot View + +## [DG-11](https://github.com/QueryaHub/Querya-Desktop/issues/574) (#574) — Multi-Column Hierarchical Grouping (Nested Group By) + +**Labels:** `data-grid`, `frontend`, `ux` +**Epic:** D +**Depends on:** none + +### Goal +Реализовать многоуровневую группировку по двум и более колонкам в `DataGridGroupingsView` (например `Group by: Department → Job Title`). + +### Context +Сейчас `GridGroupingsEngine` поддерживает выбор только одной колонки. В аналитических сценариях требуется строить вложенные деревья групп. + +### Implementation +1. В `lib/features/main_screen/grid_groupings_engine.dart`: + - Обновить `buildGroups` для поддержки списка индексов колонок `List colIndices`. + - Рекурсивная структура `GroupedCategory`: поле `List? subGroups`. +2. В `lib/features/main_screen/data_grid_groupings_view.dart`: + - Тулбар с возможностью добавления нескольких чипов колонок (Add Grouping Column). + - Рекурсивное отображение дерева аккордеонов с уровнями вложенности. + - Кнопки *Expand All* / *Collapse All*. + +### Acceptance Criteria +- [ ] Пользователь может выбрать 2+ колонки для группировки. +- [ ] Вложенные группы корректно раскрываются с отображением строк нижнего уровня. +- [ ] Проценты и количества рассчитываются как для родительских, так и для дочерних групп. + +--- + +## [DG-12](https://github.com/QueryaHub/Querya-Desktop/issues/575) (#575) — Custom Aggregations in Groupings (SUM, AVG, MIN, MAX over columns) + +**Labels:** `data-grid`, `frontend`, `ux` +**Epic:** D +**Depends on:** DG-11 + +### Goal +Позволить пользователю выбирать числовую колонку и агрегатную функцию (`SUM`, `AVG`, `MIN`, `MAX`) для расчета показателей внутри каждой группы. + +### Context +Сейчас группировка отображает только количество записей (`X rows, Y%`). Для аналитики важно видеть, например, `SUM(salary)` или `AVG(duration)` по группам. + +### Implementation +1. Добавить селекторы в тулбар группировки: + - Выпадающий список выбора функции агрегации: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`. + - Выпадающий список выбора целевой числовой колонки для агрегации. +2. В `GroupedCategory` сохранять вычисленное значение агрегата. +3. Отображать агрегат рядом с количеством строк в заголовке группы (например: `Engineering — 42 rows | Sum(salary): $350,000`). + +### Acceptance Criteria +- [ ] При выборе `SUM(amount)` для каждой группы рассчитывается точная сумма. +- [ ] Корректно обрабатываются `NULL` и нечисловые значения (игнорируются). +- [ ] Значения форматируются с учетом разрядов чисел. + +--- + +## [DG-13](https://github.com/QueryaHub/Querya-Desktop/issues/576) (#576) — Sort Groups by Column Key, Count or Aggregate Value & Export Pivot + +**Labels:** `data-grid`, `frontend`, `ux` +**Epic:** D +**Depends on:** DG-12 + +### Goal +Добавить сортировку сформированных групп (по имени группы А-Я / Я-А, по количеству строк, по значению агрегата) и экспорт сводной таблицы в CSV / JSON. + +### Context +Сейчас группы всегда сортируются только по количеству записей по убыванию (`count desc`). Пользователям требуется алфавитный порядок категорий или экспорт полученного отчета. + +### Implementation +1. Добавить контролы сортировки групп: `Order by: Count ↓ / Count ↑ / Key A-Z / Key Z-A / Agg Value ↓`. +2. Добавить кнопку *Export Groupings* в форматы CSV, JSON, Markdown Table. + +### Acceptance Criteria +- [ ] Группы мгновенно пересортировываются при изменении порядка сортировки. +- [ ] Экспорт формирует корректный файл со структурой сводной таблицы. + +--- + +# Epic E: UI Hardening, UX Polish & Regression Tests + +## [DG-14](https://github.com/QueryaHub/Querya-Desktop/issues/577) (#577) — Fix ResultsTab Header Horizontal Overflow on Narrow Viewports + +**Labels:** `data-grid`, `frontend`, `bug` +**Epic:** E +**Depends on:** none + +### Goal +Устранить ошибку `RenderFlex overflowed` в тулбаре `ResultsTab` на компактных окнах или экранах с низким разрешением. + +### Context +При запуске `flutter test test/features/main_screen/results_tab_test.dart` на фиксированной ширине 800px тулбар с переключателем режимов `SegmentedButton`, статусной строкой, кнопками фильтра, инспектора и меню экспорта вызывает горизонтальный оверфлоу (`overflowed by 105 pixels`). + +### Implementation +1. В `lib/features/main_screen/results_tab.dart` (строка ~162): + - Обернуть `material.Row` тулбара экспорта и селектора в `material.SingleChildScrollView(scrollDirection: material.Axis.horizontal)` или использовать адаптивный flex layout с `material.Flexible`. + - Настроить плотность `VisualDensity.compact` для тулбарных контролов. +2. Проверить прохождение всех тестов в `test/features/main_screen/results_tab_test.dart`. + +### Acceptance Criteria +- [ ] Все тесты в `results_tab_test.dart` проходят без ошибок и предупреждений `RenderFlex overflowed`. +- [ ] Тулбар корректно скроллится или сжимается на узких экранах без графических артефактов. + +--- + +## [DG-15](https://github.com/QueryaHub/Querya-Desktop/issues/578) (#578) — End-to-End Integration Tests for Data Grid Staging and DML Execution + +**Labels:** `data-grid`, `tests` +**Epic:** E +**Depends on:** DG-01, DG-02, DG-14 + +### Goal +Написать набор интеграционных тестов для проверки полного цикла: выполнение запроса → редактирование ячейки → добавление строки → удаление строки → сохранение в базу данных (SQLite memory DB / Mock driver). + +### Implementation +1. Создать `test/features/main_screen/data_grid_e2e_editing_test.dart`: + - Тест сквозного сценария редактирования в `SqliteSqlWorkspace` с in-memory базой данных. + - Тест отката изменений по нажатию *Revert All*. + - Тест обработки ошибки внешнего ключа или уникальности при сохранении. + +### Acceptance Criteria +- [ ] Тесты выполняются в CI без внешних зависимостей. +- [ ] Покрыты сценарии `INSERT`, `UPDATE`, `DELETE` и `ROLLBACK`. + +--- + +## [DG-16](https://github.com/QueryaHub/Querya-Desktop/issues/579) (#579) — Visual Polish & Keyboard Navigation Accessibility Audit + +**Labels:** `data-grid`, `frontend`, `ux`, `accessibility` +**Epic:** E +**Depends on:** DG-04, DG-08 + +### Goal +Провести аудит доступности и удобства клавиатурной навигации: фокусные рамки, читаемость цветов в светлой/темной темах, подсказки к кнопкам тулбара. + +### Implementation +1. Убедиться, что все интерактивные кнопки Data Grid тулбаров снабжены `tooltip` с указанием горячих клавиш. +2. Проверить контрастность выделения ячеек и строк в высококонтрастных и кастомных JSON-темах. +3. Добавить визуальную индикацию текущего фокуса таблицы при навигации стрелками. + +### Acceptance Criteria +- [ ] Все кнопки имеют информативные всплывающие подсказки с хоткеями. +- [ ] Навигация стрелками и выделение ячеек четко видны на всех темах оформления. diff --git a/lib/core/actions/sql_editor_command_bridge.dart b/lib/core/actions/sql_editor_command_bridge.dart index adb9a739..2f6a91b6 100644 --- a/lib/core/actions/sql_editor_command_bridge.dart +++ b/lib/core/actions/sql_editor_command_bridge.dart @@ -1,6 +1,6 @@ import 'package:flutter/foundation.dart'; -enum SqlEditorPendingAction { none, newQuery, openFile, saveFile } +enum SqlEditorPendingAction { none, newQuery, openFile, saveFile, executeQuery } /// Lets the active SQL workspace handle File menu commands when menu focus is /// outside the editor subtree (e.g. title bar). @@ -13,21 +13,25 @@ class SqlEditorCommandBridge { VoidCallback? _onNew; VoidCallback? _onOpen; VoidCallback? _onSave; + VoidCallback? _onExecute; SqlEditorPendingAction pendingAction = SqlEditorPendingAction.none; bool get isActive => _onNew != null; + bool get canExecute => _onExecute != null; void register({ required int? connectionId, required VoidCallback onNew, required VoidCallback onOpen, required VoidCallback onSave, + VoidCallback? onExecute, }) { _ownerConnectionId = connectionId; _onNew = onNew; _onOpen = onOpen; _onSave = onSave; + _onExecute = onExecute; _flushPending(); } @@ -37,6 +41,7 @@ class SqlEditorCommandBridge { _onNew = null; _onOpen = null; _onSave = null; + _onExecute = null; } void queuePending(SqlEditorPendingAction action) { @@ -67,6 +72,14 @@ class SqlEditorCommandBridge { pendingAction = SqlEditorPendingAction.saveFile; } + void invokeExecute() { + if (_onExecute != null) { + _onExecute!(); + return; + } + pendingAction = SqlEditorPendingAction.executeQuery; + } + void _flushPending() { final action = pendingAction; if (action == SqlEditorPendingAction.none) return; @@ -81,6 +94,8 @@ class SqlEditorCommandBridge { _onOpen?.call(); case SqlEditorPendingAction.saveFile: _onSave?.call(); + case SqlEditorPendingAction.executeQuery: + _onExecute?.call(); } } @@ -90,6 +105,7 @@ class SqlEditorCommandBridge { _onNew = null; _onOpen = null; _onSave = null; + _onExecute = null; pendingAction = SqlEditorPendingAction.none; } } diff --git a/lib/core/database/connection_pool_lock.dart b/lib/core/database/connection_pool_lock.dart index 0f3f1528..ad738d89 100644 --- a/lib/core/database/connection_pool_lock.dart +++ b/lib/core/database/connection_pool_lock.dart @@ -20,21 +20,26 @@ class PoolEntryLock { } final completer = Completer(); _lock = completer.future; + Future result; try { final existing = _pending[key]; - if (existing != null) return existing; - final future = create(); - _pending[key] = future; - // Ensure the pending entry is removed once the creation finishes, and - // swallow errors on the cleanup chain so they don't become unhandled. - final guarded = future.whenComplete(() => _pending.remove(key)); - guarded.then((_) {}, onError: (_) {}); - return future; + if (existing != null) { + result = existing; + } else { + final future = create(); + _pending[key] = future; + // Ensure the pending entry is removed once the creation finishes, and + // swallow errors on the cleanup chain so they don't become unhandled. + final guarded = future.whenComplete(() => _pending.remove(key)); + guarded.then((_) {}, onError: (_) {}); + result = future; + } } finally { completer.complete(); if (_lock == completer.future) { _lock = null; } } + return result; } } diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index 6cf9f705..0eb63f63 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:mysql_client/mysql_client.dart'; +import 'package:querya_desktop/core/database/table_schema_meta.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; @@ -349,6 +350,76 @@ class MysqlConnection { return rs.rows.map((r) => r.colAt(0)!).toList(); } + /// Retrieves schema metadata and primary keys for [table] in [database]. + Future getTableSchema({ + required String database, + required String table, + }) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to MySQL'); + } + final colsRs = await execute( + 'SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT ' + 'FROM information_schema.COLUMNS ' + 'WHERE TABLE_SCHEMA = :database AND TABLE_NAME = :table ' + 'ORDER BY ORDINAL_POSITION', + { + 'database': database, + 'table': table, + }, + ); + + final pkRs = await execute( + 'SELECT COLUMN_NAME ' + 'FROM information_schema.KEY_COLUMN_USAGE ' + "WHERE CONSTRAINT_NAME = 'PRIMARY' AND TABLE_SCHEMA = :database AND TABLE_NAME = :table " + 'ORDER BY ORDINAL_POSITION', + { + 'database': database, + 'table': table, + }, + ); + + final primaryKeys = pkRs.rows.map((r) => r.colAt(0)!).toList(); + final columns = []; + + for (final r in colsRs.rows) { + final name = r.colByName('COLUMN_NAME') ?? ''; + final dataType = r.colByName('DATA_TYPE') ?? ''; + final isNullable = (r.colByName('IS_NULLABLE') ?? 'YES').toUpperCase() == 'YES'; + final isPk = primaryKeys.contains(name); + final pkPos = isPk ? primaryKeys.indexOf(name) + 1 : null; + final dflt = r.colByName('COLUMN_DEFAULT'); + + columns.add( + TableColumnMeta( + name: name, + dataType: dataType, + isNullable: isNullable, + isPrimaryKey: isPk, + primaryKeyPosition: pkPos, + defaultValue: dflt, + ), + ); + } + + return TableSchemaMeta( + tableName: table, + schema: database, + columns: columns, + primaryKeys: primaryKeys, + ); + } + + /// Returns primary key column names for [table] in [database]. + Future> getPrimaryKeys({ + required String database, + required String table, + }) async { + final schema = await getTableSchema(database: database, table: table); + return schema.primaryKeys; + } + /// Lists base tables in [schema] (MySQL: database name). Future> listTables({required String schema}) async { if (!isConnected || _conn == null) { diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart index c6929e16..bd807171 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -9,6 +9,7 @@ import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:postgres/src/connection_string.dart' show parseConnectionString; import 'postgres_metadata.dart'; +import 'table_schema_meta.dart'; /// Replaces the database in a `postgresql://` / `postgres://` URI (path or /// `database=` query param). Used when switching DB while keeping URI auth/SSL. @@ -329,6 +330,79 @@ class PostgresConnection { return result.map((row) => row[0] as String).toList(); } + /// Retrieves schema metadata and primary keys for [table] in [schema]. + Future getTableSchema({ + String schema = 'public', + required String table, + }) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to PostgreSQL'); + } + final colsRs = await _conn!.execute( + Sql.named( + 'SELECT column_name, data_type, is_nullable, column_default ' + 'FROM information_schema.columns ' + 'WHERE table_schema = @schema AND table_name = @table ' + 'ORDER BY ordinal_position', + ), + parameters: {'schema': schema, 'table': table}, + ); + + final pkRs = await _conn!.execute( + Sql.named( + 'SELECT kcu.column_name ' + 'FROM information_schema.table_constraints tc ' + 'JOIN information_schema.key_column_usage kcu ' + ' ON tc.constraint_name = kcu.constraint_name ' + ' AND tc.table_schema = kcu.table_schema ' + "WHERE tc.constraint_type = 'PRIMARY KEY' " + ' AND tc.table_schema = @schema ' + ' AND tc.table_name = @table ' + 'ORDER BY kcu.ordinal_position', + ), + parameters: {'schema': schema, 'table': table}, + ); + + final primaryKeys = pkRs.map((r) => r[0] as String).toList(); + final columns = []; + + for (final r in colsRs) { + final name = r[0] as String? ?? ''; + final dataType = r[1] as String? ?? ''; + final isNullable = (r[2] as String? ?? 'YES').toUpperCase() == 'YES'; + final isPk = primaryKeys.contains(name); + final pkPos = isPk ? primaryKeys.indexOf(name) + 1 : null; + final dflt = r[3]?.toString(); + + columns.add( + TableColumnMeta( + name: name, + dataType: dataType, + isNullable: isNullable, + isPrimaryKey: isPk, + primaryKeyPosition: pkPos, + defaultValue: dflt, + ), + ); + } + + return TableSchemaMeta( + tableName: table, + schema: schema, + columns: columns, + primaryKeys: primaryKeys, + ); + } + + /// Returns primary key column names for [table] in [schema]. + Future> getPrimaryKeys({ + String schema = 'public', + required String table, + }) async { + final s = await getTableSchema(schema: schema, table: table); + return s.primaryKeys; + } + Future> listViews({String schema = 'public'}) async { if (!isConnected || _conn == null) { throw StateError('Not connected to PostgreSQL'); diff --git a/lib/core/database/sql_table_target_extractor.dart b/lib/core/database/sql_table_target_extractor.dart new file mode 100644 index 00000000..2010fe85 --- /dev/null +++ b/lib/core/database/sql_table_target_extractor.dart @@ -0,0 +1,52 @@ +/// Result of extracting target table and schema from an SQL query. +class SqlTableTarget { + const SqlTableTarget({ + required this.tableName, + this.schema, + }); + + final String tableName; + final String? schema; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SqlTableTarget && + tableName == other.tableName && + schema == other.schema; + + @override + int get hashCode => Object.hash(tableName, schema); +} + +/// Helper utility to infer the primary target table from a simple SELECT query. +abstract final class SqlTableTargetExtractor { + static final _fromTableRegex = RegExp( + r'\bfrom\s+(?:(?:"([^"]+)"|`([^`]+)`|([a-zA-Z_]\w*))\.)?(?:"([^"]+)"|`([^`]+)`|([a-zA-Z_]\w*))', + caseSensitive: false, + ); + + /// Extracts the target schema and table name from [sql]. + /// Returns `null` if no simple target table can be determined (e.g. subqueries, joins). + static SqlTableTarget? extract(String sql) { + final trimmed = sql.trim(); + if (trimmed.isEmpty) return null; + + // If query has JOIN or multiple tables separated by comma, avoid auto-generating DML + final hasJoin = RegExp(r'\bjoin\b', caseSensitive: false).hasMatch(trimmed); + if (hasJoin) return null; + + final match = _fromTableRegex.firstMatch(trimmed); + if (match == null) return null; + + final schema = match.group(1) ?? match.group(2) ?? match.group(3); + final table = match.group(4) ?? match.group(5) ?? match.group(6); + + if (table == null || table.isEmpty) return null; + + return SqlTableTarget( + tableName: table, + schema: schema, + ); + } +} diff --git a/lib/core/database/sqlite_connection.dart b/lib/core/database/sqlite_connection.dart index d1e9cbae..c699fa2b 100644 --- a/lib/core/database/sqlite_connection.dart +++ b/lib/core/database/sqlite_connection.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:querya_desktop/core/database/table_schema_meta.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; /// SQLite database connection using sqflite_common_ffi. @@ -176,6 +177,52 @@ class SqliteConnection { return rows.map((r) => r['name'] as String).toList(); } + /// Retrieves schema metadata and primary keys for [table]. + Future getTableSchema({required String table}) async { + final rows = await execute('PRAGMA table_info(${quoteIdentifier(table)})'); + final columns = []; + final pkList = >[]; + + for (final r in rows) { + final name = r['name'] as String? ?? ''; + final type = r['type'] as String? ?? ''; + final notNull = (r['notnull'] as int? ?? 0) == 1; + final pk = r['pk'] as int? ?? 0; + final dflt = r['dflt_value']?.toString(); + + final isPk = pk > 0; + if (isPk) { + pkList.add({'name': name, 'pk': pk}); + } + + columns.add( + TableColumnMeta( + name: name, + dataType: type, + isNullable: !notNull, + isPrimaryKey: isPk, + primaryKeyPosition: isPk ? pk : null, + defaultValue: dflt, + ), + ); + } + + pkList.sort((a, b) => (a['pk'] as int).compareTo(b['pk'] as int)); + final primaryKeys = pkList.map((e) => e['name'] as String).toList(); + + return TableSchemaMeta( + tableName: table, + columns: columns, + primaryKeys: primaryKeys, + ); + } + + /// Returns primary key column names for [table]. + Future> getPrimaryKeys({required String table}) async { + final schema = await getTableSchema(table: table); + return schema.primaryKeys; + } + /// Returns the DDL (`sql`) of a table or view from sqlite_master. Future getObjectDdl(String objectName) async { final rows = await execute( diff --git a/lib/core/database/table_mutation_engine.dart b/lib/core/database/table_mutation_engine.dart new file mode 100644 index 00000000..b5eecbe3 --- /dev/null +++ b/lib/core/database/table_mutation_engine.dart @@ -0,0 +1,270 @@ +/// Supported SQL dialects for DML mutation generation. +enum SqlDialect { + postgres, + mysql, + sqlite, +} + +/// Type of DML mutation. +enum MutationType { + insert, + update, + delete, +} + +/// A single generated DML statement. +class TableMutationStatement { + const TableMutationStatement({ + required this.type, + required this.sql, + required this.description, + }); + + final MutationType type; + final String sql; + final String description; +} + +/// A complete mutation plan containing atomic statements wrapped in a transaction. +class TableMutationPlan { + const TableMutationPlan({ + required this.dialect, + required this.tableName, + this.schema, + required this.statements, + }); + + final SqlDialect dialect; + final String tableName; + final String? schema; + final List statements; + + bool get isEmpty => statements.isEmpty; + int get statementCount => statements.length; + + /// Returns the complete atomic transaction script. + String toTransactionSql() { + if (isEmpty) return ''; + + final buffer = StringBuffer(); + switch (dialect) { + case SqlDialect.postgres: + buffer.writeln('BEGIN;'); + case SqlDialect.mysql: + buffer.writeln('START TRANSACTION;'); + case SqlDialect.sqlite: + buffer.writeln('BEGIN TRANSACTION;'); + } + + for (final stmt in statements) { + buffer.writeln('${stmt.sql};'); + } + + buffer.writeln('COMMIT;'); + return buffer.toString(); + } +} + +/// Atomic DML mutation generator for PostgreSQL, MySQL, and SQLite. +abstract final class TableMutationEngine { + /// Quotes an SQL identifier according to the target [dialect]. + static String quoteIdentifier(String identifier, SqlDialect dialect) { + switch (dialect) { + case SqlDialect.mysql: + return '`${identifier.replaceAll('`', '``')}`'; + case SqlDialect.postgres: + case SqlDialect.sqlite: + return '"${identifier.replaceAll('"', '""')}"'; + } + } + + /// Quotes a qualified table name (e.g. `"public"."users"`). + static String quoteQualifiedTable( + String table, { + String? schema, + required SqlDialect dialect, + }) { + final quotedTable = quoteIdentifier(table, dialect); + if (schema != null && schema.isNotEmpty && dialect != SqlDialect.sqlite) { + final quotedSchema = quoteIdentifier(schema, dialect); + return '$quotedSchema.$quotedTable'; + } + return quotedTable; + } + + /// Formats a cell string value safely as an SQL literal or `NULL`. + static String formatLiteral(String value, SqlDialect dialect) { + if (value == 'NULL' || value == 'null') { + return 'NULL'; + } + + // Number literals (integer or floating point) + if (RegExp(r'^-?\d+(\.\d+)?$').hasMatch(value)) { + return value; + } + + // Boolean literals + if (value.toLowerCase() == 'true') { + return dialect == SqlDialect.sqlite ? '1' : 'TRUE'; + } + if (value.toLowerCase() == 'false') { + return dialect == SqlDialect.sqlite ? '0' : 'FALSE'; + } + + // String literal with single quote escape + final escaped = value.replaceAll("'", "''"); + return "'$escaped'"; + } + + /// Generates a [TableMutationPlan] from in-memory staged changes. + static TableMutationPlan generatePlan({ + required SqlDialect dialect, + required String tableName, + String? schema, + required List columns, + required List primaryKeys, + required List> originalRows, + required Map> modifiedCells, + required List> insertedRows, + required Set deletedRowIndices, + }) { + final statements = []; + final tableRef = quoteQualifiedTable( + tableName, + schema: schema, + dialect: dialect, + ); + + // 1. Generate UPDATE statements for modified cells + for (final entry in modifiedCells.entries) { + final rowIndex = entry.key; + final rowModifications = entry.value; + + // Skip if this original row was deleted + if (deletedRowIndices.contains(rowIndex)) continue; + if (rowIndex >= originalRows.length) continue; + + final origRow = originalRows[rowIndex]; + final setClauses = []; + + for (final mod in rowModifications.entries) { + final colIndex = mod.key; + final stagedVal = mod.value; + if (colIndex < columns.length) { + final colName = quoteIdentifier(columns[colIndex], dialect); + final literal = formatLiteral(stagedVal, dialect); + setClauses.add('$colName = $literal'); + } + } + + if (setClauses.isNotEmpty) { + final whereClause = _buildWhereClause( + columns: columns, + primaryKeys: primaryKeys, + row: origRow, + dialect: dialect, + ); + + final sql = 'UPDATE $tableRef SET ${setClauses.join(', ')} WHERE $whereClause'; + statements.add( + TableMutationStatement( + type: MutationType.update, + sql: sql, + description: 'Update row ${rowIndex + 1}', + ), + ); + } + } + + // 2. Generate INSERT statements for inserted rows + for (var i = 0; i < insertedRows.length; i++) { + final row = insertedRows[i]; + final colNames = []; + final values = []; + + for (var c = 0; c < columns.length; c++) { + final colName = quoteIdentifier(columns[c], dialect); + final cellVal = c < row.length ? row[c] : 'NULL'; + colNames.add(colName); + values.add(formatLiteral(cellVal, dialect)); + } + + final sql = 'INSERT INTO $tableRef (${colNames.join(', ')}) VALUES (${values.join(', ')})'; + statements.add( + TableMutationStatement( + type: MutationType.insert, + sql: sql, + description: 'Insert new row ${i + 1}', + ), + ); + } + + // 3. Generate DELETE statements for deleted rows + for (final rowIndex in deletedRowIndices) { + if (rowIndex < originalRows.length) { + final origRow = originalRows[rowIndex]; + final whereClause = _buildWhereClause( + columns: columns, + primaryKeys: primaryKeys, + row: origRow, + dialect: dialect, + ); + + final sql = 'DELETE FROM $tableRef WHERE $whereClause'; + statements.add( + TableMutationStatement( + type: MutationType.delete, + sql: sql, + description: 'Delete row ${rowIndex + 1}', + ), + ); + } + } + + return TableMutationPlan( + dialect: dialect, + tableName: tableName, + schema: schema, + statements: statements, + ); + } + + static String _buildWhereClause({ + required List columns, + required List primaryKeys, + required List row, + required SqlDialect dialect, + }) { + final clauses = []; + + if (primaryKeys.isNotEmpty) { + for (final pk in primaryKeys) { + final colIdx = columns.indexOf(pk); + if (colIdx != -1 && colIdx < row.length) { + final colName = quoteIdentifier(pk, dialect); + final val = row[colIdx]; + if (val == 'NULL' || val == 'null') { + clauses.add('$colName IS NULL'); + } else { + clauses.add('$colName = ${formatLiteral(val, dialect)}'); + } + } + } + } + + // Fallback: if no primary keys found or matched, match all columns + if (clauses.isEmpty) { + for (var c = 0; c < columns.length; c++) { + final colName = quoteIdentifier(columns[c], dialect); + final val = c < row.length ? row[c] : 'NULL'; + if (val == 'NULL' || val == 'null') { + clauses.add('$colName IS NULL'); + } else { + clauses.add('$colName = ${formatLiteral(val, dialect)}'); + } + } + } + + return clauses.join(' AND '); + } +} diff --git a/lib/core/database/table_schema_meta.dart b/lib/core/database/table_schema_meta.dart new file mode 100644 index 00000000..0043a62e --- /dev/null +++ b/lib/core/database/table_schema_meta.dart @@ -0,0 +1,80 @@ +/// Column schema metadata. +class TableColumnMeta { + const TableColumnMeta({ + required this.name, + required this.dataType, + this.isNullable = true, + this.isPrimaryKey = false, + this.primaryKeyPosition, + this.defaultValue, + }); + + final String name; + final String dataType; + final bool isNullable; + final bool isPrimaryKey; + final int? primaryKeyPosition; + final String? defaultValue; + + Map toJson() => { + 'name': name, + 'dataType': dataType, + 'isNullable': isNullable, + 'isPrimaryKey': isPrimaryKey, + 'primaryKeyPosition': primaryKeyPosition, + 'defaultValue': defaultValue, + }; + + factory TableColumnMeta.fromJson(Map json) => + TableColumnMeta( + name: json['name'] as String, + dataType: json['dataType'] as String, + isNullable: json['isNullable'] as bool? ?? true, + isPrimaryKey: json['isPrimaryKey'] as bool? ?? false, + primaryKeyPosition: json['primaryKeyPosition'] as int?, + defaultValue: json['defaultValue'] as String?, + ); +} + +/// Table schema metadata containing column descriptions and primary keys. +class TableSchemaMeta { + const TableSchemaMeta({ + required this.tableName, + this.schema, + this.columns = const [], + this.primaryKeys = const [], + }); + + final String tableName; + final String? schema; + final List columns; + final List primaryKeys; + + bool get hasPrimaryKey => primaryKeys.isNotEmpty; + + TableColumnMeta? getColumn(String name) { + for (final col in columns) { + if (col.name == name) return col; + } + return null; + } + + Map toJson() => { + 'tableName': tableName, + 'schema': schema, + 'columns': columns.map((c) => c.toJson()).toList(), + 'primaryKeys': primaryKeys, + }; + + factory TableSchemaMeta.fromJson(Map json) => + TableSchemaMeta( + tableName: json['tableName'] as String, + schema: json['schema'] as String?, + columns: (json['columns'] as List? ?? const []) + .map((c) => TableColumnMeta.fromJson(c as Map)) + .toList(), + primaryKeys: (json['primaryKeys'] as List? ?? const []) + .map((e) => e.toString()) + .toList(), + ); +} diff --git a/lib/core/demo/demo_playground_service.dart b/lib/core/demo/demo_playground_service.dart new file mode 100644 index 00000000..6df39c13 --- /dev/null +++ b/lib/core/demo/demo_playground_service.dart @@ -0,0 +1,161 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/storage/app_data_root.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// Service that creates, seeds, and manages a lightweight Demo Playground +/// SQLite database for instant exploration without configuring external servers. +abstract final class DemoPlaygroundService { + static const String demoConnectionName = 'Sample Demo Playground'; + + static const String demoDefaultQuery = ''' +-- ✨ Welcome to Querya Demo Playground! +-- Press Cmd+Enter (macOS) or Ctrl+Enter (Linux/Windows) to run queries. +-- Try: +-- • Clicking column headers to sort ascending/descending +-- • Dragging column dividers to resize +-- • Shift+Clicking to select cell ranges and Cmd+C/Ctrl+C to copy TSV +-- • Pressing Cmd+B / Ctrl+B to toggle the left sidebar with fluid spring motion + +SELECT + o.id AS order_id, + u.full_name AS customer, + u.email, + p.category, + o.product_name, + o.amount, + o.status, + o.created_at +FROM orders o +JOIN users u ON o.user_id = u.id +JOIN products p ON o.product_id = p.id +ORDER BY o.created_at DESC; +'''; + + /// Resolves the filesystem path for the demo playground database file. + static Future resolveDemoDatabasePath() async { + final supportDir = await AppDataRoot.applicationSupportDirectory(); + final demoDir = Directory(p.join(supportDir.path, 'demo')); + if (!await demoDir.exists()) { + await demoDir.create(recursive: true); + } + return p.join(demoDir.path, 'demo_playground.sqlite'); + } + + /// Seeds the demo SQLite database with sample tables and rows if not populated. + static Future seedDemoDatabase(String dbPath) async { + await LocalDb.initFfi(); + final db = await databaseFactoryFfi.openDatabase(dbPath); + try { + await db.execute(''' + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + full_name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + role TEXT NOT NULL, + avatar_color TEXT NOT NULL, + created_at TEXT NOT NULL + ); + '''); + + await db.execute(''' + CREATE TABLE IF NOT EXISTS products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + category TEXT NOT NULL, + price REAL NOT NULL, + stock INTEGER NOT NULL, + rating REAL NOT NULL + ); + '''); + + await db.execute(''' + CREATE TABLE IF NOT EXISTS orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + product_id INTEGER NOT NULL REFERENCES products(id), + product_name TEXT NOT NULL, + amount REAL NOT NULL, + currency TEXT NOT NULL DEFAULT 'USD', + status TEXT NOT NULL, + created_at TEXT NOT NULL + ); + '''); + + final userCountResult = await db.rawQuery('SELECT COUNT(*) as count FROM users;'); + final userCount = (userCountResult.first['count'] as num?)?.toInt() ?? 0; + + if (userCount == 0) { + final batch = db.batch(); + batch.rawInsert(''' + INSERT INTO users (full_name, email, role, avatar_color, created_at) VALUES + ('Alex River', 'alex.river@example.com', 'Admin', '#06B6D4', '2026-01-10T08:30:00Z'), + ('Elena Vance', 'elena.vance@example.com', 'Engineer', '#8B5CF6', '2026-01-14T11:20:00Z'), + ('Marcus Chen', 'marcus.c@example.com', 'Product Manager', '#10B981', '2026-01-18T14:45:00Z'), + ('Sophia Taylor', 'sophia.t@example.com', 'Designer', '#F59E0B', '2026-02-01T09:15:00Z'), + ('David Kim', 'david.kim@example.com', 'Data Scientist', '#EC4899', '2026-02-05T16:00:00Z'), + ('Olivia Brown', 'olivia.b@example.com', 'QA Lead', '#3B82F6', '2026-02-10T12:10:00Z'), + ('Lucas Garcia', 'lucas.g@example.com', 'Security Engineer', '#EF4444', '2026-02-12T15:30:00Z'), + ('Emma Watson', 'emma.w@example.com', 'Analyst', '#6366F1', '2026-02-15T10:00:00Z'); + '''); + + batch.rawInsert(''' + INSERT INTO products (title, category, price, stock, rating) VALUES + ('UltraWide 34" Curved Monitor', 'Hardware', 749.99, 45, 4.8), + ('Ergonomic Mechanical Keyboard', 'Peripherals', 189.50, 120, 4.9), + ('Precision Wireless Mouse', 'Peripherals', 89.00, 200, 4.7), + ('Noise Cancelling Studio Headphones', 'Audio', 299.99, 60, 4.8), + ('USB-C 100W Multiport Dock', 'Accessories', 129.99, 85, 4.6), + ('4K Ultra HD Streaming Webcam', 'Video', 159.00, 95, 4.5), + ('Adjustable Standing Desk Mat', 'Office', 69.95, 150, 4.7), + ('Thunderbolt 4 Pro Cable 2m', 'Accessories', 49.00, 300, 4.9); + '''); + + batch.rawInsert(''' + INSERT INTO orders (user_id, product_id, product_name, amount, currency, status, created_at) VALUES + (1, 1, 'UltraWide 34" Curved Monitor', 749.99, 'USD', 'delivered', '2026-02-16T10:30:00Z'), + (2, 2, 'Ergonomic Mechanical Keyboard', 189.50, 'USD', 'delivered', '2026-02-16T14:15:00Z'), + (3, 4, 'Noise Cancelling Studio Headphones', 299.99, 'USD', 'shipped', '2026-02-17T09:00:00Z'), + (4, 3, 'Precision Wireless Mouse', 89.00, 'USD', 'delivered', '2026-02-17T11:45:00Z'), + (5, 5, 'USB-C 100W Multiport Dock', 129.99, 'USD', 'processing', '2026-02-18T08:20:00Z'), + (6, 6, '4K Ultra HD Streaming Webcam', 159.00, 'USD', 'shipped', '2026-02-18T13:10:00Z'), + (7, 2, 'Ergonomic Mechanical Keyboard', 189.50, 'USD', 'delivered', '2026-02-19T16:00:00Z'), + (8, 7, 'Adjustable Standing Desk Mat', 69.95, 'USD', 'processing', '2026-02-20T10:15:00Z'), + (1, 8, 'Thunderbolt 4 Pro Cable 2m', 49.00, 'USD', 'delivered', '2026-02-20T12:40:00Z'), + (3, 1, 'UltraWide 34" Curved Monitor', 749.99, 'USD', 'processing', '2026-02-21T09:30:00Z'), + (5, 4, 'Noise Cancelling Studio Headphones', 299.99, 'USD', 'pending', '2026-02-22T15:00:00Z'), + (2, 5, 'USB-C 100W Multiport Dock', 129.99, 'USD', 'pending', '2026-02-23T11:20:00Z'); + '''); + + await batch.commit(noResult: true); + } + } finally { + await db.close(); + } + } + + /// Gets or registers the demo playground connection row in [LocalDb]. + static Future getOrCreateDemoConnection() async { + final dbPath = await resolveDemoDatabasePath(); + await seedDemoDatabase(dbPath); + + final existingConnections = await LocalDb.instance.getConnections(); + for (final conn in existingConnections) { + if (conn.type == 'sqlite' && (conn.host == dbPath || conn.name == demoConnectionName)) { + return conn; + } + } + + final newRow = ConnectionRow( + type: 'sqlite', + name: demoConnectionName, + host: dbPath, + createdAt: DateTime.now().toUtc().toIso8601String(), + ); + + final createdId = await LocalDb.instance.addConnection(newRow); + return newRow.copyWith(id: createdId); + } +} diff --git a/lib/core/motion/querya_spring_controller.dart b/lib/core/motion/querya_spring_controller.dart index be401721..5112d79a 100644 --- a/lib/core/motion/querya_spring_controller.dart +++ b/lib/core/motion/querya_spring_controller.dart @@ -136,9 +136,8 @@ class QueryaSpringController extends ChangeNotifier { final t = (elapsed - _simulationStart!).inMicroseconds / 1e6; final next = simulation.x(t); _velocity = simulation.dx(t); - final settled = simulation.isDone(t) || - ((next - _target).abs() < 0.0005 && _velocity.abs() < 0.01); + ((next - _target).abs() < 0.002 && _velocity.abs() < 0.02); if (settled) { _value = _target; diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 386a1a26..356b5727 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -122,11 +122,13 @@ abstract final class AppSettingsKeys { static const themeAnimationEnabled = 'theme_animation_enabled'; static const uiScale = 'ui_scale'; static const connectionsPanelWidth = 'connections_panel_width'; + static const sidebarVisible = 'sidebar_visible'; static const recentConnectionIds = 'recent_connection_ids'; static const motionLevel = 'motion_level'; static const updateChannel = 'update_channel'; static const checkForUpdatesOnStartup = 'check_for_updates_on_startup'; static const updateDismissedVersion = 'update_dismissed_version'; + static const hasCompletedWelcomeTour = 'has_completed_welcome_tour'; } /// Bumps [listenable] when any preference is persisted (theme, legacy listeners). @@ -272,6 +274,21 @@ class AppSettings { ); } + Future getSidebarVisible() async { + final value = await LocalDb.instance.getAppSetting( + AppSettingsKeys.sidebarVisible, + ); + if (value == null) return true; + return value == '1' || value.toLowerCase() == 'true'; + } + + Future setSidebarVisible(bool visible) async { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.sidebarVisible, + visible ? '1' : '0', + ); + } + /// Most recently selected connection ids (newest first). Future> getRecentConnectionIds() async { final raw = await LocalDb.instance.getAppSetting( @@ -682,4 +699,21 @@ class AppSettings { } AppSettingsRevision.bump(); } + + /// Whether the user has completed or dismissed the initial interactive welcome tour. + Future getHasCompletedWelcomeTour() async { + final v = await LocalDb.instance.getAppSetting( + AppSettingsKeys.hasCompletedWelcomeTour, + ); + if (v == null || v.isEmpty) return false; + return v == 'true' || v == '1'; + } + + Future setHasCompletedWelcomeTour(bool completed) async { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.hasCompletedWelcomeTour, + completed ? 'true' : 'false', + ); + AppSettingsRevision.bump(); + } } diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index e801fefb..32131f09 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -51,8 +51,11 @@ import 'package:flutter/material.dart' as material VoidCallback, RepaintBoundary, ListView, - ClampingScrollPhysics; -import 'package:flutter/services.dart' show Clipboard, ClipboardData; + ClampingScrollPhysics, + CallbackShortcuts, + SingleActivator; +import 'package:flutter/services.dart' + show Clipboard, ClipboardData, LogicalKeyboardKey; import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index d8673c16..3dbbd6e1 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -531,6 +531,7 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { icon: QueryaIcons.tableGroup, itemIcon: QueryaIcons.tableLeaf, items: _tables, + onOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, onItemTap: widget.onMysqlObjectSelected == null ? null : (name) => widget.onMysqlObjectSelected!( @@ -550,6 +551,7 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { icon: QueryaIcons.viewGroup, itemIcon: QueryaIcons.viewLeaf, items: _views, + onOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, onItemTap: widget.onMysqlObjectSelected == null ? null : (name) => widget.onMysqlObjectSelected!( @@ -569,6 +571,7 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { icon: QueryaIcons.functionGroup, itemIcon: QueryaIcons.functionLeaf, items: _procedures, + onOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, onItemTap: widget.onMysqlObjectSelected == null ? null : (name) => widget.onMysqlObjectSelected!( @@ -588,6 +591,7 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { icon: QueryaIcons.functionGroup, itemIcon: QueryaIcons.functionLeaf, items: _functions, + onOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, onItemTap: widget.onMysqlObjectSelected == null ? null : (name) => widget.onMysqlObjectSelected!( @@ -620,6 +624,7 @@ class _MysqlObjectGroup extends StatefulWidget { required this.itemIcon, required this.items, this.onItemTap, + this.onOpenSqlWorkspace, }); final ConnectionRow connection; @@ -631,6 +636,7 @@ class _MysqlObjectGroup extends StatefulWidget { final material.IconData itemIcon; final List items; final void Function(String itemName)? onItemTap; + final void Function(ConnectionRow connection)? onOpenSqlWorkspace; @override State<_MysqlObjectGroup> createState() => _MysqlObjectGroupState(); @@ -700,8 +706,13 @@ class _MysqlObjectGroupState extends State<_MysqlObjectGroup> { ? () => widget.onItemTap!(item) : null, connection: widget.connection, - onContextRefresh: null, - onOpenSqlWorkspace: null, + openSqlDatabase: widget.databaseName, + openSqlName: item, + onContextRefresh: widget.onRefresh, + onOpenSqlWorkspace: widget.onOpenSqlWorkspace != null + ? (conn, {database, schema, name, kind}) => + widget.onOpenSqlWorkspace!(conn) + : null, ); }, ), diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart index 1579556f..68917cdd 100644 --- a/lib/features/connections/connections_panel_pg_tree.dart +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -106,41 +106,53 @@ class _PgTreeRow extends material.StatelessWidget { 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, + final row = material.CallbackShortcuts( + bindings: { + if (expanded == false && onTap != null) + const material.SingleActivator(LogicalKeyboardKey.arrowRight): () => + onTap!(), + if (expanded == true && onTap != null) + const material.SingleActivator(LogicalKeyboardKey.arrowLeft): () => + onTap!(), + }, + child: material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: onTap, + canRequestFocus: onTap != null, + borderRadius: material.BorderRadius.circular(4), + hoverColor: primary.withValues(alpha: 0.07), + focusColor: primary.withValues(alpha: 0.14), + splashColor: primary.withValues(alpha: 0.10), + highlightColor: primary.withValues(alpha: 0.05), + mouseCursor: onTap != null + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + child: material.Padding( + padding: material.EdgeInsets.symmetric( + horizontal: 4, + vertical: verticalPadding, + ), + child: material.Row( + children: [ + if (leading != null) ...[ + leading!, + const Gap(4), + ], + if (icon != null) ...[ + material.Icon( + icon, + size: iconSize, + color: iconColor ?? muted, + ), + const Gap(6), + ], + material.Expanded( + child: _PgTreeRowLabel(label: label, textStyle: textStyle), ), - const Gap(6), + if (trailing != null) trailing!, ], - material.Expanded( - child: _PgTreeRowLabel(label: label, textStyle: textStyle), - ), - if (trailing != null) trailing!, - ], + ), ), ), ), @@ -156,6 +168,48 @@ class _PgTreeRow extends material.StatelessWidget { } final menu = ContextMenu( items: [ + if (openSqlName != null) ...[ + MenuButton( + leading: material.Icon( + material.Icons.table_rows_outlined, + size: 18, + color: theme.colorScheme.primary, + ), + onPressed: (_) { + final qualified = openSqlSchema != null && openSqlSchema!.isNotEmpty + ? '"$openSqlSchema"."$openSqlName"' + : '"$openSqlName"'; + Clipboard.setData( + ClipboardData(text: 'SELECT * FROM $qualified LIMIT 100;'), + ); + if (onOpenSqlWorkspace != null && connection != null) { + onOpenSqlWorkspace!( + connection!, + database: openSqlDatabase, + schema: openSqlSchema, + name: openSqlName, + kind: openSqlKind, + ); + } + }, + child: const Text('Select TOP 100'), + ), + MenuButton( + leading: material.Icon( + material.Icons.code_rounded, + size: 18, + color: theme.colorScheme.mutedForeground, + ), + onPressed: (_) { + final qualified = openSqlSchema != null && openSqlSchema!.isNotEmpty + ? '"$openSqlSchema"."$openSqlName"' + : '"$openSqlName"'; + Clipboard.setData(ClipboardData(text: 'SELECT * FROM $qualified;')); + }, + child: const Text('Copy SELECT statement'), + ), + const MenuDivider(), + ], if (onContextRefresh != null) MenuButton( leading: material.Icon( @@ -177,7 +231,7 @@ class _PgTreeRow extends material.StatelessWidget { }, child: const Text('Copy name'), ), - if (onOpenSqlWorkspace != null) + if (onOpenSqlWorkspace != null && openSqlName == null) MenuButton( leading: material.Icon( material.Icons.terminal_rounded, @@ -193,7 +247,8 @@ class _PgTreeRow extends material.StatelessWidget { ), child: const Text('Open in SQL'), ), - if (onContextDelete != null) + if (onContextDelete != null) ...[ + const MenuDivider(), MenuButton( leading: material.Icon( material.Icons.delete_outline_rounded, @@ -203,6 +258,7 @@ class _PgTreeRow extends material.StatelessWidget { onPressed: (_) => onContextDelete!(), child: Text(contextDeleteLabel ?? 'Delete'), ), + ], ], child: row, ); diff --git a/lib/features/connections/connections_panel_sqlite.dart b/lib/features/connections/connections_panel_sqlite.dart index d783df0f..033fabda 100644 --- a/lib/features/connections/connections_panel_sqlite.dart +++ b/lib/features/connections/connections_panel_sqlite.dart @@ -293,6 +293,8 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { icon: QueryaIcons.tableGroup, itemIcon: QueryaIcons.tableLeaf, items: _tables, + onOpenSqlWorkspace: + widget.onSqliteOpenSqlWorkspace, onItemTap: widget.onSqliteObjectSelected == null ? null : (name) => widget.onSqliteObjectSelected!( @@ -310,6 +312,8 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { icon: QueryaIcons.viewGroup, itemIcon: QueryaIcons.viewLeaf, items: _views, + onOpenSqlWorkspace: + widget.onSqliteOpenSqlWorkspace, onItemTap: widget.onSqliteObjectSelected == null ? null : (name) => widget.onSqliteObjectSelected!( @@ -349,6 +353,7 @@ class _SqliteObjectGroup extends StatefulWidget { required this.itemIcon, required this.items, this.onItemTap, + this.onOpenSqlWorkspace, }); final ConnectionRow connection; @@ -359,6 +364,7 @@ class _SqliteObjectGroup extends StatefulWidget { final material.IconData itemIcon; final List items; final void Function(String itemName)? onItemTap; + final void Function(ConnectionRow connection)? onOpenSqlWorkspace; @override State<_SqliteObjectGroup> createState() => _SqliteObjectGroupState(); @@ -428,8 +434,12 @@ class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { ? () => widget.onItemTap!(item) : null, connection: widget.connection, - onContextRefresh: null, - onOpenSqlWorkspace: null, + openSqlName: item, + onContextRefresh: widget.onRefresh, + onOpenSqlWorkspace: widget.onOpenSqlWorkspace != null + ? (conn, {database, schema, name, kind}) => + widget.onOpenSqlWorkspace!(conn) + : null, ); }, ), diff --git a/lib/features/connections/new_connection_url_dialog.dart b/lib/features/connections/new_connection_url_dialog.dart index 1d359f18..89a9527b 100644 --- a/lib/features/connections/new_connection_url_dialog.dart +++ b/lib/features/connections/new_connection_url_dialog.dart @@ -30,17 +30,47 @@ class _NewConnectionUrlDialogContentState extends material.State<_NewConnectionUrlDialogContent> { final _urlController = material.TextEditingController(); String? _validationError; + ConnectionRow? _parsedRow; + bool _touched = false; + + @override + void initState() { + super.initState(); + _urlController.addListener(_onUrlChanged); + } @override void dispose() { + _urlController.removeListener(_onUrlChanged); _urlController.dispose(); super.dispose(); } + void _onUrlChanged() { + final text = _urlController.text; + if (text.trim().isEmpty) { + setState(() { + _parsedRow = null; + _validationError = _touched ? 'URL/URI is required.' : null; + }); + return; + } + _touched = true; + final result = parseConnectionUrlInput(text); + setState(() { + _parsedRow = result.row; + _validationError = result.error; + }); + } + void _validateAndSubmit() { + _touched = true; final result = parseConnectionUrlInput(_urlController.text); if (result.error != null) { - setState(() => _validationError = result.error); + setState(() { + _parsedRow = null; + _validationError = result.error; + }); return; } material.Navigator.of(context).pop(result.row); @@ -49,6 +79,9 @@ class _NewConnectionUrlDialogContentState @override material.Widget build(material.BuildContext context) { final theme = context.colors; + final isSuccess = _parsedRow != null; + final isError = _validationError != null; + return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, @@ -76,9 +109,11 @@ class _NewConnectionUrlDialogContentState color: theme.muted.withValues(alpha: 0.2), borderRadius: material.BorderRadius.circular(8), border: material.Border.all( - color: _validationError != null + color: isError ? theme.destructive.withValues(alpha: 0.8) - : theme.border.withValues(alpha: 0.4), + : isSuccess + ? theme.primary.withValues(alpha: 0.8) + : theme.border.withValues(alpha: 0.4), ), ), padding: const material.EdgeInsets.symmetric( @@ -86,11 +121,17 @@ class _NewConnectionUrlDialogContentState child: material.Row( children: [ material.Icon( - material.Icons.link_rounded, + isSuccess + ? material.Icons.check_circle_outline_rounded + : isError + ? material.Icons.error_outline_rounded + : material.Icons.link_rounded, size: 20, - color: _validationError != null + color: isError ? theme.destructive - : theme.mutedForeground, + : isSuccess + ? theme.primary + : theme.mutedForeground, ), const material.SizedBox(width: 10), material.Expanded( @@ -99,22 +140,51 @@ class _NewConnectionUrlDialogContentState placeholder: const Text('database://user:pass@host:port/db'), onSubmitted: (_) => _validateAndSubmit(), - onChanged: (_) { - if (_validationError != null) { - setState(() => _validationError = null); - } - }, ), ), ], ), ), - if (_validationError != null) ...[ + if (isError) ...[ const material.SizedBox(height: 8), Text( _validationError!, style: material.TextStyle(color: theme.destructive), ).small(), + ] else if (isSuccess) ...[ + material.Container( + margin: const material.EdgeInsets.only(top: 10), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + decoration: material.BoxDecoration( + color: theme.primary.withValues(alpha: 0.1), + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: theme.primary.withValues(alpha: 0.3), + ), + ), + child: material.Row( + children: [ + material.Icon( + material.Icons.task_alt_rounded, + size: 16, + color: theme.primary, + ), + const material.SizedBox(width: 8), + material.Expanded( + child: Text( + '${_parsedRow!.type.toUpperCase()} • ${_parsedRow!.name}', + style: material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.w500, + color: theme.primary, + ), + overflow: material.TextOverflow.ellipsis, + ), + ), + ], + ), + ), ], ], ), diff --git a/lib/features/connections/sqlite_connection_form.dart b/lib/features/connections/sqlite_connection_form.dart index c0f96817..6f919c45 100644 --- a/lib/features/connections/sqlite_connection_form.dart +++ b/lib/features/connections/sqlite_connection_form.dart @@ -49,6 +49,8 @@ class _SqliteConnectionFormContentState String? _testResult; Timer? _dismissTimer; late final FormValidityNotifier _formValidNotifier; + bool _nameTouched = false; + bool _pathTouched = false; bool get _isEditing => widget.initial != null; @@ -66,6 +68,17 @@ class _SqliteConnectionFormContentState _readOnly = initial.useSSL; } + _nameController.addListener(() { + if (_nameController.text.isNotEmpty && !_nameTouched) { + setState(() => _nameTouched = true); + } + }); + _pathController.addListener(() { + if (_pathController.text.isNotEmpty && !_pathTouched) { + setState(() => _pathTouched = true); + } + }); + _formValidNotifier.seed(); } @@ -223,6 +236,16 @@ class _SqliteConnectionFormContentState controller: _nameController, placeholder: const Text('e.g. Local Cache'), ), + if (_nameTouched && _nameController.text.trim().isEmpty) ...[ + const Gap(4), + Text( + 'Connection name is required.', + style: material.TextStyle( + color: theme.destructive, + fontSize: 11, + ), + ), + ], const Gap(16), // Database File Path const Text('Database file path').small().semiBold(), @@ -242,6 +265,16 @@ class _SqliteConnectionFormContentState ), ], ), + if (_pathTouched && _pathController.text.trim().isEmpty) ...[ + const Gap(4), + Text( + 'Database file path is required.', + style: material.TextStyle( + color: theme.destructive, + fontSize: 11, + ), + ), + ], const Gap(16), // Read-only toggle material.Row( diff --git a/lib/features/extensions/extension_sql_workspace.dart b/lib/features/extensions/extension_sql_workspace.dart index 2be7c719..ff5a9380 100644 --- a/lib/features/extensions/extension_sql_workspace.dart +++ b/lib/features/extensions/extension_sql_workspace.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:file_selector/file_selector.dart'; +import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; @@ -60,9 +61,32 @@ class _ExtensionSqlWorkspaceState material.WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(_loadWorkspaceSettings()); _applySelectedObject(); + _registerSqlEditorCommands(); }); } + @override + void dispose() { + SqlEditorCommandBridge.instance + .unregister(connectionId: widget.connectionRow.id); + _sqlController.dispose(); + _topFraction.dispose(); + super.dispose(); + } + + void _registerSqlEditorCommands() { + if (!mounted) return; + SqlEditorCommandBridge.instance.register( + connectionId: widget.connectionRow.id, + onNew: () => _sqlController.clear(), + onOpen: () => unawaited(_openSqlFile()), + onSave: () => unawaited(_saveSqlFile()), + onExecute: () { + if (!_running) unawaited(_execute()); + }, + ); + } + @override void didUpdateWidget(covariant ExtensionSqlWorkspace oldWidget) { super.didUpdateWidget(oldWidget); @@ -99,12 +123,7 @@ class _ExtensionSqlWorkspaceState }); } - @override - void dispose() { - _topFraction.dispose(); - _sqlController.dispose(); - super.dispose(); - } + Future _execute() async { if (_running) return; @@ -224,9 +243,43 @@ class _ExtensionSqlWorkspaceState return material.CallbackShortcuts( bindings: { const material.SingleActivator(LogicalKeyboardKey.f5): () { - if (!_running) { - unawaited(_execute()); - } + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.enter, + control: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.enter, + meta: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.numpadEnter, + control: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.numpadEnter, + meta: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.keyR, + control: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.keyR, + meta: true, + ): () { + if (!_running) unawaited(_execute()); }, }, child: material.Focus( diff --git a/lib/features/main_screen/data_grid_calc_bar.dart b/lib/features/main_screen/data_grid_calc_bar.dart new file mode 100644 index 00000000..9a5324a0 --- /dev/null +++ b/lib/features/main_screen/data_grid_calc_bar.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/features/main_screen/grid_selection_calc_engine.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Status bar footer for Data Grid displaying live selection statistics (Count, Distinct, Sum, Avg, Median, Min, Max). +class DataGridCalcBar extends StatelessWidget { + const DataGridCalcBar({ + super.key, + required this.stats, + }); + + final GridCalcStats stats; + + @override + Widget build(BuildContext context) { + if (stats.totalCount <= 1 && !stats.hasNumericStats) { + return const material.SizedBox.shrink(); + } + + final cs = Theme.of(context).colorScheme; + + return material.Container( + height: 26, + padding: const material.EdgeInsets.symmetric(horizontal: 10), + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border( + top: material.BorderSide( + color: cs.border.withValues(alpha: 0.35), + width: 1, + ), + ), + ), + child: material.Row( + children: [ + material.Expanded( + child: material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + _StatBadge( + label: 'Count', + value: '${stats.totalCount}', + ), + const Gap(8), + _StatBadge( + label: 'Distinct', + value: '${stats.distinctCount}', + ), + if (stats.nullCount > 0) ...[ + const Gap(8), + _StatBadge( + label: 'NULLs', + value: '${stats.nullCount}', + ), + ], + if (stats.hasNumericStats) ...[ + const Gap(8), + _StatBadge( + label: 'Sum', + value: GridSelectionCalcEngine.formatNum(stats.sum), + ), + const Gap(8), + _StatBadge( + label: 'Avg', + value: GridSelectionCalcEngine.formatNum(stats.average), + ), + if (stats.median != null) ...[ + const Gap(8), + _StatBadge( + label: 'Median', + value: GridSelectionCalcEngine.formatNum(stats.median), + ), + ], + const Gap(8), + _StatBadge( + label: 'Min', + value: GridSelectionCalcEngine.formatNum(stats.min), + ), + const Gap(8), + _StatBadge( + label: 'Max', + value: GridSelectionCalcEngine.formatNum(stats.max), + ), + ], + ], + ), + ), + ), + const Gap(6), + material.Tooltip( + message: 'Copy all stats summary', + child: material.InkWell( + onTap: () { + Clipboard.setData(ClipboardData(text: stats.toSummaryString())); + }, + borderRadius: material.BorderRadius.circular(3), + child: material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + material.Icons.copy_all_rounded, + size: 13, + color: cs.mutedForeground, + ), + const Gap(3), + Text('Copy Stats', style: TextStyle(fontSize: 10.5, color: cs.mutedForeground)), + ], + ), + ), + ), + ), + ], + ), + ); + } +} + +class _StatBadge extends StatelessWidget { + const _StatBadge({ + required this.label, + required this.value, + }); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return material.Tooltip( + message: 'Click to copy $label: $value', + child: material.InkWell( + onTap: () { + Clipboard.setData(ClipboardData(text: value)); + }, + borderRadius: material.BorderRadius.circular(3), + child: material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + Text( + '$label: ', + style: TextStyle( + fontSize: 11, + color: cs.mutedForeground, + ), + ), + Text( + value, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: cs.foreground, + fontFamily: 'monospace', + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/main_screen/data_grid_filter_bar.dart b/lib/features/main_screen/data_grid_filter_bar.dart new file mode 100644 index 00000000..96da7aa0 --- /dev/null +++ b/lib/features/main_screen/data_grid_filter_bar.dart @@ -0,0 +1,387 @@ +import 'package:flutter/material.dart' as material; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Kind of filter autocomplete suggestion. +enum FilterSuggestionKind { + column, + operator, + keyword, +} + +/// Autocomplete suggestion model for the Data Grid filter bar. +class FilterSuggestion { + const FilterSuggestion({ + required this.text, + required this.kind, + this.description = '', + this.insertText, + }); + + final String text; + final FilterSuggestionKind kind; + final String description; + final String? insertText; +} + +/// Helper to compute context-aware syntax suggestions for the filter bar. +abstract final class FilterSuggestionEngine { + static const _operators = [ + FilterSuggestion(text: '=', kind: FilterSuggestionKind.operator, description: 'Equal'), + FilterSuggestion(text: '!=', kind: FilterSuggestionKind.operator, description: 'Not equal'), + FilterSuggestion(text: '>', kind: FilterSuggestionKind.operator, description: 'Greater than'), + FilterSuggestion(text: '>=', kind: FilterSuggestionKind.operator, description: 'Greater or equal'), + FilterSuggestion(text: '<', kind: FilterSuggestionKind.operator, description: 'Less than'), + FilterSuggestion(text: '<=', kind: FilterSuggestionKind.operator, description: 'Less or equal'), + FilterSuggestion(text: 'LIKE', kind: FilterSuggestionKind.operator, description: 'Wildcard match (%_)'), + FilterSuggestion(text: 'ILIKE', kind: FilterSuggestionKind.operator, description: 'Case-insensitive match'), + FilterSuggestion(text: 'IN (...)', kind: FilterSuggestionKind.operator, description: 'List inclusion', insertText: "IN ('')"), + FilterSuggestion(text: 'IS NULL', kind: FilterSuggestionKind.operator, description: 'Null check'), + FilterSuggestion(text: 'IS NOT NULL', kind: FilterSuggestionKind.operator, description: 'Not null check'), + FilterSuggestion(text: 'BETWEEN', kind: FilterSuggestionKind.operator, description: 'Range check', insertText: 'BETWEEN AND '), + ]; + + static const _keywords = [ + FilterSuggestion(text: 'AND', kind: FilterSuggestionKind.keyword, description: 'Logical AND'), + FilterSuggestion(text: 'OR', kind: FilterSuggestionKind.keyword, description: 'Logical OR'), + FilterSuggestion(text: 'NOT', kind: FilterSuggestionKind.keyword, description: 'Logical NOT'), + ]; + + /// Computes suggestions based on current [text] and available [columns]. + static List getSuggestions({ + required String text, + required List columns, + }) { + final trimmed = text.trim(); + if (trimmed.isEmpty) { + return [ + for (final col in columns) + FilterSuggestion( + text: col, + kind: FilterSuggestionKind.column, + description: 'Column', + ), + ]; + } + + final tokens = trimmed.split(RegExp(r'\s+')); + final lastToken = tokens.last; + + // Check if previous token was a column name + if (tokens.length >= 2) { + final prevToken = tokens[tokens.length - 2].toLowerCase(); + final isPrevCol = columns.any((c) => c.toLowerCase() == prevToken); + if (isPrevCol) { + final matches = _operators + .where((op) => op.text.toLowerCase().startsWith(lastToken.toLowerCase())) + .toList(); + if (matches.isNotEmpty) return matches; + } + } + + // If only one token and matches a known column exactly, suggest operators + final exactCol = columns.firstWhere( + (c) => c.toLowerCase() == lastToken.toLowerCase(), + orElse: () => '', + ); + if (exactCol.isNotEmpty) { + return _operators; + } + + // Partial column name match + final matchingCols = columns + .where((c) => c.toLowerCase().startsWith(lastToken.toLowerCase())) + .map( + (c) => FilterSuggestion( + text: c, + kind: FilterSuggestionKind.column, + description: 'Column', + ), + ) + .toList(); + + // Partial keyword match (AND, OR, NOT) + final matchingKw = _keywords + .where((k) => k.text.toLowerCase().startsWith(lastToken.toLowerCase())) + .toList(); + + return [...matchingCols, ...matchingKw]; + } +} + +/// Quick Filter Bar for Data Grid. +/// Allows live client-side row filtering with context-aware autocomplete suggestions. +class DataGridFilterBar extends material.StatefulWidget { + const DataGridFilterBar({ + super.key, + required this.filterText, + required this.onFilterChanged, + required this.totalRowCount, + required this.filteredRowCount, + this.columns = const [], + }); + + final String filterText; + final ValueChanged onFilterChanged; + final int totalRowCount; + final int filteredRowCount; + final List columns; + + @override + material.State createState() => _DataGridFilterBarState(); +} + +class _DataGridFilterBarState extends material.State { + late final material.TextEditingController _controller; + final _layerLink = material.LayerLink(); + material.OverlayEntry? _overlayEntry; + List _suggestions = []; + int _highlightedIndex = 0; + + @override + void initState() { + super.initState(); + _controller = material.TextEditingController(text: widget.filterText); + } + + @override + void didUpdateWidget(covariant DataGridFilterBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.filterText != widget.filterText && + _controller.text != widget.filterText) { + _controller.text = widget.filterText; + } + } + + @override + void dispose() { + _hideSuggestions(); + _controller.dispose(); + super.dispose(); + } + + void _onChanged(String val) { + widget.onFilterChanged(val); + _updateSuggestions(val); + } + + void _updateSuggestions(String val) { + final suggs = FilterSuggestionEngine.getSuggestions( + text: val, + columns: widget.columns, + ); + + if (suggs.isEmpty || val.trim().isEmpty) { + _hideSuggestions(); + } else { + _suggestions = suggs; + _highlightedIndex = 0; + _showSuggestions(); + } + } + + void _showSuggestions() { + _hideSuggestions(); + final overlay = material.Overlay.maybeOf(context); + if (overlay == null) return; + + _overlayEntry = material.OverlayEntry( + builder: (context) { + final cs = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + + return material.Positioned( + width: 320, + child: material.CompositedTransformFollower( + link: _layerLink, + showWhenUnlinked: false, + offset: const material.Offset(24, 32), + child: material.Material( + elevation: 4, + borderRadius: material.BorderRadius.circular(6), + color: isDark + ? const material.Color(0xFF1E1E22) + : const material.Color(0xFFFFFFFF), + child: material.Container( + decoration: material.BoxDecoration( + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: cs.border.withValues(alpha: 0.6), + ), + ), + constraints: const material.BoxConstraints(maxHeight: 200), + child: material.ListView.builder( + shrinkWrap: true, + padding: const material.EdgeInsets.symmetric(vertical: 4), + itemCount: _suggestions.length, + itemBuilder: (ctx, i) { + final s = _suggestions[i]; + final isHighlighted = i == _highlightedIndex; + return material.InkWell( + onTap: () => _applySuggestion(s), + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + color: isHighlighted + ? cs.primary.withValues(alpha: 0.12) + : material.Colors.transparent, + child: material.Row( + children: [ + _buildSuggestionIcon(s.kind, cs), + const Gap(8), + Text(s.text).semiBold().small(), + const Spacer(), + if (s.description.isNotEmpty) + Text(s.description).muted().xSmall(), + ], + ), + ), + ); + }, + ), + ), + ), + ), + ); + }, + ); + + overlay.insert(_overlayEntry!); + } + + material.Widget _buildSuggestionIcon(FilterSuggestionKind kind, ColorScheme cs) { + switch (kind) { + case FilterSuggestionKind.column: + return material.Icon( + material.Icons.table_chart_outlined, + size: 13, + color: cs.primary, + ); + case FilterSuggestionKind.operator: + return material.Icon( + material.Icons.code_rounded, + size: 13, + color: material.Colors.amber.shade700, + ); + case FilterSuggestionKind.keyword: + return material.Icon( + material.Icons.vpn_key_outlined, + size: 13, + color: material.Colors.green.shade600, + ); + } + } + + void _hideSuggestions() { + _overlayEntry?.remove(); + _overlayEntry = null; + } + + void _applySuggestion(FilterSuggestion s) { + final text = _controller.text; + final toInsert = s.insertText ?? s.text; + + final tokens = text.split(RegExp(r'\s+')); + if (tokens.isNotEmpty && s.kind == FilterSuggestionKind.column) { + tokens[tokens.length - 1] = toInsert; + final newText = '${tokens.join(' ')} '; + _controller.value = material.TextEditingValue( + text: newText, + selection: material.TextSelection.collapsed(offset: newText.length), + ); + _onChanged(newText); + } else { + final newText = '$text $toInsert '; + _controller.value = material.TextEditingValue( + text: newText, + selection: material.TextSelection.collapsed(offset: newText.length), + ); + _onChanged(newText); + } + _hideSuggestions(); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final isFiltered = widget.filterText.trim().isNotEmpty; + + return material.CompositedTransformTarget( + link: _layerLink, + child: material.Container( + height: 34, + padding: const material.EdgeInsets.symmetric(horizontal: 10, vertical: 3), + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.35), + width: 1, + ), + ), + ), + child: material.Row( + children: [ + material.Icon( + material.Icons.filter_alt_outlined, + size: 15, + color: isFiltered ? cs.primary : cs.mutedForeground, + ), + const Gap(6), + material.Expanded( + child: material.TextField( + controller: _controller, + onChanged: _onChanged, + style: TextStyle( + fontSize: 12, + color: cs.foreground, + ), + decoration: material.InputDecoration( + hintText: 'Filter results... (e.g. "active", "status = ACTIVE", "amount > 100")', + hintStyle: TextStyle( + fontSize: 12, + color: cs.mutedForeground.withValues(alpha: 0.7), + ), + border: material.InputBorder.none, + isDense: true, + contentPadding: material.EdgeInsets.zero, + ), + ), + ), + if (isFiltered) ...[ + const Gap(6), + material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(4), + ), + child: Text( + '${widget.filteredRowCount} / ${widget.totalRowCount}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: cs.primary, + ), + ), + ), + const Gap(4), + material.IconButton( + icon: const material.Icon(material.Icons.close, size: 14), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 20, minHeight: 20), + color: cs.mutedForeground, + onPressed: () { + _controller.clear(); + _hideSuggestions(); + widget.onFilterChanged(''); + }, + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/main_screen/data_grid_groupings_view.dart b/lib/features/main_screen/data_grid_groupings_view.dart new file mode 100644 index 00000000..1dbf0692 --- /dev/null +++ b/lib/features/main_screen/data_grid_groupings_view.dart @@ -0,0 +1,391 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/features/main_screen/grid_groupings_engine.dart'; +import 'package:querya_desktop/features/main_screen/result_grid_view.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Groupings / Pivot view tab for tabular data with hierarchical grouping and custom aggregations. +class DataGridGroupingsView extends material.StatefulWidget { + const DataGridGroupingsView({ + super.key, + required this.columns, + required this.rows, + }); + + final List columns; + final List> rows; + + @override + material.State createState() => + _DataGridGroupingsViewState(); +} + +class _DataGridGroupingsViewState + extends material.State { + late List _selectedColIndices; + GroupingAggType _aggType = GroupingAggType.count; + int? _aggTargetColIndex; + GroupSortBy _sortBy = GroupSortBy.count; + bool _sortAscending = false; + final Set _expandedKeys = {}; + + @override + void initState() { + super.initState(); + _selectedColIndices = widget.columns.isNotEmpty ? [0] : []; + if (widget.columns.length > 1) { + // Pick first numeric-looking column as default target for sum/avg if available + _aggTargetColIndex = 1; + } + } + + @override + void didUpdateWidget(covariant DataGridGroupingsView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.columns != widget.columns) { + if (_selectedColIndices.isEmpty && widget.columns.isNotEmpty) { + _selectedColIndices = [0]; + } else { + _selectedColIndices.removeWhere((idx) => idx >= widget.columns.length); + if (_selectedColIndices.isEmpty && widget.columns.isNotEmpty) { + _selectedColIndices = [0]; + } + } + } + } + + void _exportPivot() { + final groups = GridGroupingsEngine.buildGroups( + groupColIndices: _selectedColIndices, + rows: widget.rows, + aggConfig: GroupAggregationConfig( + aggType: _aggType, + targetColIndex: _aggTargetColIndex, + ), + sortBy: _sortBy, + sortAscending: _sortAscending, + ); + + final groupName = _selectedColIndices.isNotEmpty + ? widget.columns[_selectedColIndices.first] + : 'Group'; + final csv = GridGroupingsEngine.exportPivotToCsv( + groups: groups, + groupByColumnName: groupName, + aggConfig: GroupAggregationConfig( + aggType: _aggType, + targetColIndex: _aggTargetColIndex, + ), + ); + + Clipboard.setData(ClipboardData(text: csv)); + } + + @override + material.Widget build(material.BuildContext context) { + if (widget.columns.isEmpty || widget.rows.isEmpty) { + return material.Center( + child: const Text('No data available for grouping.').muted(), + ); + } + + final cs = Theme.of(context).colorScheme; + final groups = GridGroupingsEngine.buildGroups( + groupColIndices: _selectedColIndices, + rows: widget.rows, + aggConfig: GroupAggregationConfig( + aggType: _aggType, + targetColIndex: _aggTargetColIndex, + ), + sortBy: _sortBy, + sortAscending: _sortAscending, + ); + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Top Toolbar for selecting Group By, Aggregation, and Sorting + material.Container( + height: 40, + padding: const material.EdgeInsets.symmetric(horizontal: 10), + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.35), + width: 1, + ), + ), + ), + child: material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.Row( + children: [ + material.Icon( + material.Icons.account_tree_outlined, + size: 15, + color: cs.primary, + ), + const Gap(6), + const Text('Group:').small().semiBold(), + const Gap(6), + material.DropdownButton( + value: _selectedColIndices.isNotEmpty && + _selectedColIndices.first < widget.columns.length + ? _selectedColIndices.first + : 0, + isDense: true, + underline: const material.SizedBox.shrink(), + style: TextStyle(fontSize: 12, color: cs.foreground), + items: List.generate(widget.columns.length, (i) { + return material.DropdownMenuItem( + value: i, + child: Text(widget.columns[i]), + ); + }), + onChanged: (idx) { + if (idx != null) { + setState(() { + _selectedColIndices = [idx]; + _expandedKeys.clear(); + }); + } + }, + ), + + const Gap(12), + material.VerticalDivider( + width: 1, + thickness: 1, + indent: 8, + endIndent: 8, + color: cs.border.withValues(alpha: 0.3), + ), + const Gap(12), + + // Aggregation Selector + const Text('Agg:').small().semiBold(), + const Gap(6), + material.DropdownButton( + value: _aggType, + isDense: true, + underline: const material.SizedBox.shrink(), + style: TextStyle(fontSize: 12, color: cs.foreground), + items: GroupingAggType.values.map((t) { + return material.DropdownMenuItem( + value: t, + child: Text(t.label), + ); + }).toList(), + onChanged: (val) { + if (val != null) { + setState(() => _aggType = val); + } + }, + ), + if (_aggType != GroupingAggType.count) ...[ + const Gap(4), + material.DropdownButton( + value: _aggTargetColIndex != null && + _aggTargetColIndex! < widget.columns.length + ? _aggTargetColIndex + : 0, + isDense: true, + underline: const material.SizedBox.shrink(), + style: TextStyle(fontSize: 12, color: cs.foreground), + items: List.generate(widget.columns.length, (i) { + return material.DropdownMenuItem( + value: i, + child: Text(widget.columns[i]), + ); + }), + onChanged: (idx) { + if (idx != null) { + setState(() => _aggTargetColIndex = idx); + } + }, + ), + ], + + const Gap(12), + material.VerticalDivider( + width: 1, + thickness: 1, + indent: 8, + endIndent: 8, + color: cs.border.withValues(alpha: 0.3), + ), + const Gap(12), + + // Sort Selector + const Text('Sort:').small().semiBold(), + const Gap(6), + material.DropdownButton( + value: _sortBy, + isDense: true, + underline: const material.SizedBox.shrink(), + style: TextStyle(fontSize: 12, color: cs.foreground), + items: GroupSortBy.values.map((s) { + return material.DropdownMenuItem( + value: s, + child: Text(s.label), + ); + }).toList(), + onChanged: (val) { + if (val != null) { + setState(() => _sortBy = val); + } + }, + ), + material.IconButton( + icon: material.Icon( + _sortAscending + ? material.Icons.arrow_upward_rounded + : material.Icons.arrow_downward_rounded, + size: 14, + ), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24), + color: cs.mutedForeground, + onPressed: () => setState(() => _sortAscending = !_sortAscending), + ), + + const Gap(8), + material.Tooltip( + message: 'Copy Pivot CSV to Clipboard', + child: material.IconButton( + icon: const material.Icon(material.Icons.copy_rounded, size: 14), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24), + color: cs.mutedForeground, + onPressed: _exportPivot, + ), + ), + ], + ), + ), + ), + + // Groupings List + material.Expanded( + child: material.ListView.separated( + itemCount: groups.length, + separatorBuilder: (_, __) => material.Divider( + height: 1, + color: cs.border.withValues(alpha: 0.2), + ), + itemBuilder: (context, idx) { + final group = groups[idx]; + final isExpanded = _expandedKeys.contains(group.groupKey); + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.InkWell( + onTap: () { + setState(() { + if (isExpanded) { + _expandedKeys.remove(group.groupKey); + } else { + _expandedKeys.add(group.groupKey); + } + }); + }, + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + child: material.Row( + children: [ + material.Icon( + isExpanded + ? material.Icons.keyboard_arrow_down_rounded + : material.Icons.keyboard_arrow_right_rounded, + size: 18, + color: cs.mutedForeground, + ), + const Gap(8), + material.Expanded( + child: Text( + group.groupKey, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + ), + ), + ), + if (group.aggValue != null && _aggType != GroupingAggType.count) ...[ + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + margin: const material.EdgeInsets.only(right: 6), + decoration: material.BoxDecoration( + color: cs.secondary.withValues(alpha: 0.3), + borderRadius: material.BorderRadius.circular(6), + ), + child: Text( + '${_aggType.label}: ${group.aggValue!.toStringAsFixed(2)}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: cs.foreground, + ), + ), + ), + ], + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(10), + ), + child: Text( + '${group.count} rows (${group.percentage.toStringAsFixed(1)}%)', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: cs.primary, + ), + ), + ), + ], + ), + ), + ), + + // Expanded sub-grid + if (isExpanded) + material.Container( + height: 220, + margin: const material.EdgeInsets.only( + left: 28, + right: 12, + bottom: 8, + ), + decoration: material.BoxDecoration( + border: material.Border.all( + color: cs.border.withValues(alpha: 0.4), + ), + borderRadius: material.BorderRadius.circular(6), + ), + child: VirtualResultGrid( + columns: widget.columns, + rows: group.rows, + ), + ), + ], + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/features/main_screen/data_grid_staging_buffer.dart b/lib/features/main_screen/data_grid_staging_buffer.dart new file mode 100644 index 00000000..4f04b16f --- /dev/null +++ b/lib/features/main_screen/data_grid_staging_buffer.dart @@ -0,0 +1,289 @@ +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; + +/// Status of a row within the staging buffer. +enum StagedRowStatus { + unchanged, + modified, + inserted, + deleted, +} + +/// Status of an individual cell. +enum StagedCellStatus { + clean, + modified, +} + +/// In-memory staging buffer for interactive table data edits. +/// +/// Keeps original data intact and tracks staged changes (modified cells, +/// newly inserted rows, and rows marked for deletion). Notifies listeners +/// on any mutation so the UI (VirtualResultGrid, toolbar) updates reactively. +class DataGridStagingBuffer extends ChangeNotifier { + DataGridStagingBuffer({ + required List columns, + required List> rows, + }) : _originalColumns = List.unmodifiable(columns), + _originalRows = List.unmodifiable( + rows.map((r) => List.unmodifiable(r)).toList(), + ); + + final List _originalColumns; + final List> _originalRows; + + /// Map of `rowIndex -> (colIndex -> stagedValue)` for modified cells in baseline rows. + final Map> _modifiedCells = {}; + + /// Rows appended as new records. + final List> _insertedRows = []; + + /// Baseline row indices marked for deletion. + final Set _deletedRowIndices = {}; + + List get columns => _originalColumns; + List> get originalRows => _originalRows; + + /// Total number of visible rows (baseline + inserted). + int get totalRowCount => _originalRows.length + _insertedRows.length; + + /// True if there are any pending edits, insertions, or deletions. + bool get isDirty => + _modifiedCells.isNotEmpty || + _insertedRows.isNotEmpty || + _deletedRowIndices.isNotEmpty; + + /// Total count of staged modifications (modified cells + inserted rows + deleted rows). + int get changeCount { + var cellCount = 0; + for (final colMap in _modifiedCells.values) { + cellCount += colMap.length; + } + return cellCount + _insertedRows.length + _deletedRowIndices.length; + } + + /// Number of baseline rows marked for deletion. + int get deletedRowCount => _deletedRowIndices.length; + + /// Number of newly inserted rows. + int get insertedRowCount => _insertedRows.length; + + /// Number of modified cells in baseline rows. + int get modifiedCellCount { + var count = 0; + for (final colMap in _modifiedCells.values) { + count += colMap.length; + } + return count; + } + + /// Returns the current effective cell value. + String getCellValue(int row, int col) { + if (row < 0 || col < 0) return ''; + if (row < _originalRows.length) { + final staged = _modifiedCells[row]?[col]; + if (staged != null) return staged; + if (col < _originalRows[row].length) { + return _originalRows[row][col]; + } + return ''; + } + final insertIdx = row - _originalRows.length; + if (insertIdx < _insertedRows.length && col < _insertedRows[insertIdx].length) { + return _insertedRows[insertIdx][col]; + } + return ''; + } + + /// Returns original baseline cell value, or null if row is inserted. + String? getOriginalCellValue(int row, int col) { + if (row >= 0 && row < _originalRows.length && col >= 0 && col < _originalRows[row].length) { + return _originalRows[row][col]; + } + return null; + } + + /// Returns the status of the specified row. + StagedRowStatus getRowStatus(int row) { + if (row < 0) return StagedRowStatus.unchanged; + if (row >= _originalRows.length) { + return StagedRowStatus.inserted; + } + if (_deletedRowIndices.contains(row)) { + return StagedRowStatus.deleted; + } + if (_modifiedCells.containsKey(row) && _modifiedCells[row]!.isNotEmpty) { + return StagedRowStatus.modified; + } + return StagedRowStatus.unchanged; + } + + /// Returns the status of the specified cell. + StagedCellStatus getCellStatus(int row, int col) { + if (row >= 0 && row < _originalRows.length) { + if (_modifiedCells[row]?.containsKey(col) == true) { + return StagedCellStatus.modified; + } + } + return StagedCellStatus.clean; + } + + /// Stages an edit for the specified cell. + /// If the new value equals original value, clears the modified flag. + void setCell(int row, int col, String value) { + if (row < 0 || col < 0) return; + + if (row < _originalRows.length) { + final orig = col < _originalRows[row].length ? _originalRows[row][col] : ''; + if (value == orig) { + if (_modifiedCells.containsKey(row)) { + _modifiedCells[row]!.remove(col); + if (_modifiedCells[row]!.isEmpty) { + _modifiedCells.remove(row); + } + notifyListeners(); + } + } else { + final rowMap = _modifiedCells.putIfAbsent(row, () => {}); + if (rowMap[col] != value) { + rowMap[col] = value; + notifyListeners(); + } + } + } else { + final insertIdx = row - _originalRows.length; + if (insertIdx < _insertedRows.length) { + while (_insertedRows[insertIdx].length <= col) { + _insertedRows[insertIdx].add(''); + } + if (_insertedRows[insertIdx][col] != value) { + _insertedRows[insertIdx][col] = value; + notifyListeners(); + } + } + } + } + + /// Appends a new empty or pre-filled row. + int addRow([List? initialValues]) { + final row = initialValues != null + ? List.from(initialValues) + : List.filled(_originalColumns.length, ''); + _insertedRows.add(row); + notifyListeners(); + return totalRowCount - 1; + } + + /// Removes an inserted row at the given absolute row index. + void removeInsertedRow(int row) { + final insertIdx = row - _originalRows.length; + if (insertIdx >= 0 && insertIdx < _insertedRows.length) { + _insertedRows.removeAt(insertIdx); + notifyListeners(); + } + } + + /// Marks a baseline row as deleted, or removes an inserted row. + void toggleDeleteRow(int row) { + if (row < 0) return; + if (row < _originalRows.length) { + if (_deletedRowIndices.contains(row)) { + _deletedRowIndices.remove(row); + } else { + _deletedRowIndices.add(row); + } + notifyListeners(); + } else { + removeInsertedRow(row); + } + } + + /// Reverts changes for a single cell back to baseline. + void revertCell(int row, int col) { + if (row < _originalRows.length && _modifiedCells.containsKey(row)) { + if (_modifiedCells[row]!.remove(col) != null) { + if (_modifiedCells[row]!.isEmpty) { + _modifiedCells.remove(row); + } + notifyListeners(); + } + } + } + + /// Reverts all modifications or deletion for a given row. + void revertRow(int row) { + if (row < 0) return; + if (row < _originalRows.length) { + var changed = false; + if (_modifiedCells.remove(row) != null) changed = true; + if (_deletedRowIndices.remove(row)) changed = true; + if (changed) notifyListeners(); + } else { + removeInsertedRow(row); + } + } + + /// Reverts all staged changes and resets buffer to clean baseline. + void revertAll() { + if (!isDirty) return; + _modifiedCells.clear(); + _insertedRows.clear(); + _deletedRowIndices.clear(); + notifyListeners(); + } + + /// Read-only snapshot of modified cells mapping. + Map> get modifiedCells => + Map>.unmodifiable( + _modifiedCells.map((k, v) => MapEntry(k, Map.unmodifiable(v))), + ); + + /// Read-only list of newly inserted rows. + List> get insertedRows => + List.unmodifiable(_insertedRows.map((r) => List.unmodifiable(r))); + + /// Read-only set of deleted row indices. + Set get deletedRowIndices => Set.unmodifiable(_deletedRowIndices); + + /// Generates an atomic [TableMutationPlan] for the current staged changes. + TableMutationPlan generateMutationPlan({ + required SqlDialect dialect, + required String tableName, + String? schema, + List primaryKeys = const [], + }) { + return TableMutationEngine.generatePlan( + dialect: dialect, + tableName: tableName, + schema: schema, + columns: _originalColumns, + primaryKeys: primaryKeys, + originalRows: _originalRows, + modifiedCells: _modifiedCells, + insertedRows: _insertedRows, + deletedRowIndices: _deletedRowIndices, + ); + } + + /// Returns the full list of effective rows (original with modifications applied + inserted rows). + List> get effectiveRows { + final result = >[]; + for (var r = 0; r < _originalRows.length; r++) { + final row = List.from(_originalRows[r]); + final mods = _modifiedCells[r]; + if (mods != null) { + for (final entry in mods.entries) { + if (entry.key < row.length) { + row[entry.key] = entry.value; + } + } + } + result.add(row); + } + for (final ins in _insertedRows) { + result.add(List.from(ins)); + } + return result; + } +} + diff --git a/lib/features/main_screen/data_grid_staging_toolbar.dart b/lib/features/main_screen/data_grid_staging_toolbar.dart new file mode 100644 index 00000000..50a42cf9 --- /dev/null +++ b/lib/features/main_screen/data_grid_staging_toolbar.dart @@ -0,0 +1,257 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Toolbar for managing staged data changes (Add, Delete, Revert, Save). +class DataGridStagingToolbar extends StatelessWidget { + const DataGridStagingToolbar({ + super.key, + required this.stagingBuffer, + this.selectedRowIndex, + this.onApplyChanges, + this.isSaving = false, + }); + + final DataGridStagingBuffer stagingBuffer; + final int? selectedRowIndex; + final VoidCallback? onApplyChanges; + final bool isSaving; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return ListenableBuilder( + listenable: stagingBuffer, + builder: (context, _) { + final isDirty = stagingBuffer.isDirty; + final changeCount = stagingBuffer.changeCount; + final selectedRow = selectedRowIndex; + final hasSelectedRow = selectedRow != null && + selectedRow >= 0 && + selectedRow < stagingBuffer.totalRowCount; + + final isSelectedDeleted = hasSelectedRow && + stagingBuffer.getRowStatus(selectedRow) == StagedRowStatus.deleted; + + return material.Container( + height: 32, + padding: const material.EdgeInsets.symmetric(horizontal: 10), + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.35), + width: 1, + ), + ), + ), + child: material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + // Add Row + _ToolbarButton( + label: 'Add Row', + icon: material.Icons.add_rounded, + onPressed: isSaving ? null : () => stagingBuffer.addRow(), + ), + const Gap(4), + + // Delete / Restore Row + _ToolbarButton( + label: isSelectedDeleted ? 'Restore Row' : 'Delete Row', + icon: isSelectedDeleted + ? material.Icons.restore_from_trash_rounded + : material.Icons.remove_circle_outline_rounded, + color: isSelectedDeleted + ? cs.primary + : (hasSelectedRow ? cs.destructive : null), + onPressed: isSaving || !hasSelectedRow + ? null + : () => stagingBuffer.toggleDeleteRow(selectedRow), + ), + const Gap(4), + + // Revert + if (isDirty) ...[ + _ToolbarButton( + label: 'Revert All', + icon: material.Icons.undo_rounded, + color: cs.mutedForeground, + onPressed: isSaving ? null : () => stagingBuffer.revertAll(), + ), + const Gap(6), + ], + + // Badge + if (isDirty) + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(10), + border: material.Border.all( + color: cs.primary.withValues(alpha: 0.3), + width: 1, + ), + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Container( + width: 5, + height: 5, + decoration: material.BoxDecoration( + color: cs.primary, + shape: material.BoxShape.circle, + ), + ), + const Gap(5), + Text( + '$changeCount pending ${changeCount == 1 ? 'change' : 'changes'}', + ).xSmall().semiBold(), + ], + ), + ) + else + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + material.Icons.check_circle_outline_rounded, + size: 13, + color: cs.mutedForeground, + ), + const Gap(4), + const Text('No changes').xSmall().muted(), + ], + ), + + const Gap(12), + + // Save Changes button + material.MouseRegion( + cursor: (isDirty && !isSaving) + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + child: material.GestureDetector( + behavior: material.HitTestBehavior.opaque, + onTap: isDirty && !isSaving ? onApplyChanges : null, + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: material.BoxDecoration( + color: isDirty + ? cs.primary + : cs.muted.withValues(alpha: 0.4), + borderRadius: material.BorderRadius.circular(4), + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + if (isSaving) + material.SizedBox( + width: 12, + height: 12, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: cs.primaryForeground, + ), + ) + else + material.Icon( + material.Icons.save_rounded, + size: 14, + color: isDirty + ? cs.primaryForeground + : cs.mutedForeground, + ), + const Gap(5), + material.Text( + isSaving ? 'Saving…' : 'Save Changes', + style: material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.w500, + color: isDirty + ? cs.primaryForeground + : cs.mutedForeground, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _ToolbarButton extends material.StatelessWidget { + const _ToolbarButton({ + required this.label, + required this.icon, + this.onPressed, + this.color, + }); + + final String label; + final material.IconData icon; + final material.VoidCallback? onPressed; + final material.Color? color; + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final enabled = onPressed != null; + final fg = color ?? cs.foreground; + + return material.MouseRegion( + cursor: enabled + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + child: material.GestureDetector( + onTap: onPressed, + behavior: material.HitTestBehavior.opaque, + child: material.Opacity( + opacity: enabled ? 1.0 : 0.4, + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: material.BoxDecoration( + borderRadius: material.BorderRadius.circular(4), + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(icon, size: 14, color: fg), + const material.SizedBox(width: 4), + material.Text( + label, + style: material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.w500, + color: fg, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/main_screen/data_grid_value_panel.dart b/lib/features/main_screen/data_grid_value_panel.dart new file mode 100644 index 00000000..5b78d4fc --- /dev/null +++ b/lib/features/main_screen/data_grid_value_panel.dart @@ -0,0 +1,396 @@ +import 'dart:convert'; +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/editor/querya_code_editor.dart'; +import 'package:querya_desktop/core/editor/querya_code_language.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import 'xml_html_formatter.dart'; + +/// Language mode for Value Panel inspector. +enum ValuePanelLanguage { + auto, + json, + xml, + sql, + text, +} + +/// Collapsible right-hand side panel for inspecting cell content in detail. +class DataGridValuePanel extends material.StatefulWidget { + const DataGridValuePanel({ + super.key, + required this.columnName, + required this.cellValue, + required this.rowIndex, + required this.onClose, + this.onUpdateValue, + }); + + final String columnName; + final String cellValue; + final int? rowIndex; + final material.VoidCallback onClose; + final ValueChanged? onUpdateValue; + + @override + material.State createState() => _DataGridValuePanelState(); +} + +class _DataGridValuePanelState extends material.State { + late final material.TextEditingController _controller; + ValuePanelLanguage _selectedLanguage = ValuePanelLanguage.auto; + String? _validationError; + bool _wordWrap = true; + + @override + void initState() { + super.initState(); + _controller = material.TextEditingController(text: _formatInitialValue(widget.cellValue)); + _controller.addListener(_validateContent); + _validateContent(); + } + + @override + void didUpdateWidget(covariant DataGridValuePanel oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.cellValue != widget.cellValue) { + _controller.text = _formatInitialValue(widget.cellValue); + _validateContent(); + } + } + + @override + void dispose() { + _controller.removeListener(_validateContent); + _controller.dispose(); + super.dispose(); + } + + String _formatInitialValue(String input) { + final trimmed = input.trim(); + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + try { + final parsed = jsonDecode(trimmed); + return const JsonEncoder.withIndent(' ').convert(parsed); + } catch (_) {} + } else if (trimmed.startsWith('<') && trimmed.endsWith('>')) { + try { + return XmlHtmlFormatter.format(trimmed); + } catch (_) {} + } + return input; + } + + ValuePanelLanguage get _effectiveLanguage { + if (_selectedLanguage != ValuePanelLanguage.auto) { + return _selectedLanguage; + } + final trimmed = _controller.text.trim(); + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + return ValuePanelLanguage.json; + } + if (trimmed.startsWith('<') && trimmed.endsWith('>')) { + return ValuePanelLanguage.xml; + } + final upper = trimmed.toUpperCase(); + if (upper.startsWith('SELECT ') || + upper.startsWith('INSERT ') || + upper.startsWith('UPDATE ') || + upper.startsWith('CREATE ') || + upper.startsWith('WITH ')) { + return ValuePanelLanguage.sql; + } + return ValuePanelLanguage.text; + } + + void _validateContent() { + final text = _controller.text.trim(); + if (text.isEmpty) { + if (_validationError != null) { + setState(() => _validationError = null); + } + return; + } + + final lang = _effectiveLanguage; + String? err; + + if (lang == ValuePanelLanguage.json) { + try { + jsonDecode(text); + } catch (e) { + err = 'Invalid JSON: $e'; + } + } else if (lang == ValuePanelLanguage.xml) { + err = XmlHtmlFormatter.validate(text); + } + + if (err != _validationError) { + setState(() => _validationError = err); + } + } + + void _formatCode() { + final lang = _effectiveLanguage; + if (lang == ValuePanelLanguage.json) { + try { + final parsed = jsonDecode(_controller.text); + final pretty = const JsonEncoder.withIndent(' ').convert(parsed); + setState(() => _controller.text = pretty); + } catch (_) {} + } else if (lang == ValuePanelLanguage.xml) { + final pretty = XmlHtmlFormatter.format(_controller.text); + setState(() => _controller.text = pretty); + } + } + + void _minifyCode() { + final lang = _effectiveLanguage; + if (lang == ValuePanelLanguage.json) { + try { + final parsed = jsonDecode(_controller.text); + final compact = jsonEncode(parsed); + setState(() => _controller.text = compact); + } catch (_) {} + } else if (lang == ValuePanelLanguage.xml) { + final compact = XmlHtmlFormatter.minify(_controller.text); + setState(() => _controller.text = compact); + } + } + + QueryaCodeLanguage _toQueryaLanguage(ValuePanelLanguage lang) { + switch (lang) { + case ValuePanelLanguage.json: + return QueryaCodeLanguage.json; + case ValuePanelLanguage.sql: + return QueryaCodeLanguage.sql; + default: + return QueryaCodeLanguage.plain; + } + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final activeLang = _effectiveLanguage; + + return material.Container( + width: 340, + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border( + left: material.BorderSide( + color: cs.border.withValues(alpha: 0.35), + width: 1, + ), + ), + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Panel Header + material.Container( + height: 36, + padding: const material.EdgeInsets.symmetric(horizontal: 10), + decoration: material.BoxDecoration( + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.35), + width: 1, + ), + ), + ), + child: material.Row( + children: [ + material.Icon( + material.Icons.data_object_rounded, + size: 15, + color: cs.primary, + ), + const Gap(6), + material.Expanded( + child: Text( + '${widget.columnName}${widget.rowIndex != null ? ' [Row ${widget.rowIndex! + 1}]' : ''}', + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + ).small().semiBold(), + ), + material.IconButton( + icon: const material.Icon(material.Icons.close, size: 14), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24), + color: cs.mutedForeground, + onPressed: widget.onClose, + ), + ], + ), + ), + + // Toolbar with language selector and actions + material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), + color: cs.background.withValues(alpha: 0.4), + child: material.Row( + children: [ + // Language Dropdown / Pill + material.DropdownButton( + value: _selectedLanguage, + isDense: true, + underline: const material.SizedBox(), + icon: const material.Icon(material.Icons.arrow_drop_down, size: 16), + style: TextStyle( + fontSize: 11, + color: cs.foreground, + fontWeight: FontWeight.w600, + ), + items: const [ + material.DropdownMenuItem( + value: ValuePanelLanguage.auto, + child: Text('Auto'), + ), + material.DropdownMenuItem( + value: ValuePanelLanguage.json, + child: Text('JSON'), + ), + material.DropdownMenuItem( + value: ValuePanelLanguage.xml, + child: Text('XML/HTML'), + ), + material.DropdownMenuItem( + value: ValuePanelLanguage.sql, + child: Text('SQL'), + ), + material.DropdownMenuItem( + value: ValuePanelLanguage.text, + child: Text('Plain Text'), + ), + ], + onChanged: (val) { + if (val != null) { + setState(() => _selectedLanguage = val); + _validateContent(); + } + }, + ), + const Gap(6), + if (activeLang == ValuePanelLanguage.json || activeLang == ValuePanelLanguage.xml) ...[ + material.TextButton( + onPressed: _formatCode, + style: material.TextButton.styleFrom( + padding: const material.EdgeInsets.symmetric(horizontal: 6, vertical: 2), + minimumSize: material.Size.zero, + tapTargetSize: material.MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('Format').small(), + ), + const Gap(4), + material.TextButton( + onPressed: _minifyCode, + style: material.TextButton.styleFrom( + padding: const material.EdgeInsets.symmetric(horizontal: 6, vertical: 2), + minimumSize: material.Size.zero, + tapTargetSize: material.MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('Minify').small(), + ), + ], + const material.Spacer(), + material.IconButton( + icon: material.Icon( + _wordWrap ? material.Icons.wrap_text : material.Icons.notes, + size: 14, + ), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24), + color: _wordWrap ? cs.primary : cs.mutedForeground, + onPressed: () => setState(() => _wordWrap = !_wordWrap), + ), + material.IconButton( + icon: const material.Icon(material.Icons.copy_rounded, size: 14), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24), + color: cs.mutedForeground, + onPressed: () { + Clipboard.setData(ClipboardData(text: _controller.text)); + }, + ), + ], + ), + ), + + // Validation Error Banner (if any) + if (_validationError != null) + material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), + color: cs.destructive.withValues(alpha: 0.12), + child: material.Row( + children: [ + material.Icon( + material.Icons.warning_amber_rounded, + size: 14, + color: cs.destructive, + ), + const Gap(6), + material.Expanded( + child: Text( + _validationError!, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10.5, + color: cs.destructive, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + + // Code Editor Area with Syntax Highlighting + material.Expanded( + child: material.Padding( + padding: const material.EdgeInsets.all(6), + child: QueryaCodeEditor( + controller: _controller, + language: _toQueryaLanguage(activeLang), + enableHighlighting: true, + fontSize: 12, + variant: QueryaCodeEditorVariant.material, + ), + ), + ), + + // Apply button if editable + if (widget.onUpdateValue != null) + material.Container( + padding: const material.EdgeInsets.all(8), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: cs.border.withValues(alpha: 0.35), + width: 1, + ), + ), + ), + child: material.ElevatedButton( + onPressed: () { + widget.onUpdateValue!(_controller.text); + }, + style: material.ElevatedButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.primaryForeground, + padding: const material.EdgeInsets.symmetric(vertical: 8), + minimumSize: material.Size.zero, + ), + child: const Text('Update Cell Value').small().bold(), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/main_screen/dml_preview_dialog.dart b/lib/features/main_screen/dml_preview_dialog.dart new file mode 100644 index 00000000..e30b0884 --- /dev/null +++ b/lib/features/main_screen/dml_preview_dialog.dart @@ -0,0 +1,329 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/shared/widgets/app_dialog.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Opens the DML Preview and Confirmation dialog before executing staged changes. +/// +/// Returns `true` if user confirmed execution, or `false`/`null` if cancelled. +Future showDmlPreviewDialog({ + required material.BuildContext context, + required TableMutationPlan plan, +}) { + return showAppDialog( + context: context, + builder: (ctx) => _DmlPreviewDialog(plan: plan), + ); +} + +class _DmlPreviewDialog extends material.StatefulWidget { + const _DmlPreviewDialog({required this.plan}); + + final TableMutationPlan plan; + + @override + material.State<_DmlPreviewDialog> createState() => _DmlPreviewDialogState(); +} + +class _DmlPreviewDialogState extends material.State<_DmlPreviewDialog> { + bool _copied = false; + + int get _updateCount => + widget.plan.statements.where((s) => s.type == MutationType.update).length; + + int get _insertCount => + widget.plan.statements.where((s) => s.type == MutationType.insert).length; + + int get _deleteCount => + widget.plan.statements.where((s) => s.type == MutationType.delete).length; + + String get _dialectName { + switch (widget.plan.dialect) { + case SqlDialect.postgres: + return 'PostgreSQL'; + case SqlDialect.mysql: + return 'MySQL'; + case SqlDialect.sqlite: + return 'SQLite'; + } + } + + Future _copySql() async { + final sql = widget.plan.toTransactionSql(); + await Clipboard.setData(ClipboardData(text: sql)); + if (!mounted) return; + setState(() => _copied = true); + await Future.delayed(const Duration(seconds: 2)); + if (mounted) setState(() => _copied = false); + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; + final sql = widget.plan.toTransactionSql(); + + return material.Dialog( + backgroundColor: cs.card, + shape: material.RoundedRectangleBorder( + borderRadius: material.BorderRadius.circular(8), + side: material.BorderSide(color: cs.border, width: 1), + ), + child: material.ConstrainedBox( + constraints: const material.BoxConstraints( + minWidth: 540, + maxWidth: 680, + minHeight: 380, + maxHeight: 580, + ), + child: material.Padding( + padding: const material.EdgeInsets.all(18), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Header Row + material.Row( + children: [ + material.Icon( + material.Icons.save_as_rounded, + size: 20, + color: cs.primary, + ), + const Gap(8), + const Text('Confirm Data Changes').semiBold().large(), + ], + ), + const Gap(4), + const Text( + 'Review pending SQL mutations before applying them to the database.', + ).muted().small(), + const Gap(14), + + // Metadata badges row + material.Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: material.WrapCrossAlignment.center, + children: [ + // Target Table Badge + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: material.BoxDecoration( + color: cs.muted, + borderRadius: material.BorderRadius.circular(6), + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + material.Icons.table_chart_outlined, + size: 14, + color: cs.foreground, + ), + const Gap(6), + Text( + widget.plan.schema != null && + widget.plan.schema!.isNotEmpty + ? '${widget.plan.schema}.${widget.plan.tableName}' + : widget.plan.tableName, + ).semiBold().small(), + ], + ), + ), + + // Dialect Badge + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: cs.primary.withValues(alpha: 0.3), + ), + ), + child: Text( + _dialectName, + style: TextStyle( + color: cs.primary, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ), + + // Changes Breakdown Pills + if (_updateCount > 0) + _buildCountPill( + label: '$_updateCount UPDATE', + color: material.Colors.amber.shade700, + isDark: isDark, + ), + if (_insertCount > 0) + _buildCountPill( + label: '$_insertCount INSERT', + color: material.Colors.green.shade600, + isDark: isDark, + ), + if (_deleteCount > 0) + _buildCountPill( + label: '$_deleteCount DELETE', + color: material.Colors.red.shade600, + isDark: isDark, + ), + ], + ), + const Gap(14), + + // SQL Preview code block header + material.Row( + mainAxisAlignment: material.MainAxisAlignment.spaceBetween, + children: [ + const Text('TRANSACTION SCRIPT').semiBold().xSmall(), + material.InkWell( + onTap: _copySql, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + _copied + ? material.Icons.check_rounded + : material.Icons.copy_rounded, + size: 13, + color: _copied + ? material.Colors.green + : cs.mutedForeground, + ), + const Gap(4), + Text(_copied ? 'Copied' : 'Copy SQL').xSmall(), + ], + ), + ), + ), + ], + ), + const Gap(6), + + // SQL Code Preview Container + material.Expanded( + child: material.Container( + padding: const material.EdgeInsets.all(12), + decoration: material.BoxDecoration( + color: isDark + ? const material.Color(0xFF141416) + : const material.Color(0xFFF4F4F6), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: cs.border.withValues(alpha: 0.6), + ), + ), + child: material.SingleChildScrollView( + child: material.SelectableText( + sql, + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12.5, + height: 1.45, + color: isDark + ? const material.Color(0xFFE2E8F0) + : const material.Color(0xFF1E293B), + ), + ), + ), + ), + ), + const Gap(12), + + // Atomic Notice + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.4), + borderRadius: material.BorderRadius.circular(6), + ), + child: material.Row( + children: [ + material.Icon( + material.Icons.info_outline, + size: 15, + color: cs.mutedForeground, + ), + const Gap(8), + const material.Expanded( + child: Text( + 'All mutations will be executed atomically in a single transaction.', + ), + ), + ], + ), + ), + const Gap(16), + + // Actions + material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + OutlineButton( + onPressed: () => material.Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + const Gap(8), + PrimaryButton( + onPressed: () => material.Navigator.of(context).pop(true), + leading: const material.Icon( + material.Icons.save_outlined, + size: 16, + ), + child: const Text('Apply Changes'), + ), + ], + ), + ], + ), + ), + ), + ); + } + + material.Widget _buildCountPill({ + required String label, + required material.Color color, + required bool isDark, + }) { + return material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: material.BoxDecoration( + color: color.withValues(alpha: isDark ? 0.18 : 0.12), + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: color.withValues(alpha: isDark ? 0.45 : 0.3), + ), + ), + child: Text( + label, + style: TextStyle( + color: color, + fontWeight: FontWeight.w600, + fontSize: 11, + ), + ), + ); + } +} diff --git a/lib/features/main_screen/grid_cell_editor.dart b/lib/features/main_screen/grid_cell_editor.dart new file mode 100644 index 00000000..3b714e1a --- /dev/null +++ b/lib/features/main_screen/grid_cell_editor.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/features/main_screen/grid_data_type_validator.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Active inline editor widget for a data grid cell. +class GridCellEditor extends material.StatefulWidget { + const GridCellEditor({ + super.key, + required this.initialValue, + required this.width, + required this.height, + required this.onCommit, + required this.onCancel, + this.dataTypeName, + this.onOpenInspector, + }); + + final String initialValue; + final double width; + final double height; + final String? dataTypeName; + final void Function( + String value, { + bool moveNextCol, + bool movePrevCol, + bool moveNextRow, + bool movePrevRow, + }) onCommit; + final material.VoidCallback onCancel; + final material.VoidCallback? onOpenInspector; + + @override + material.State createState() => _GridCellEditorState(); +} + +class _GridCellEditorState extends material.State { + late final material.TextEditingController _controller; + final _focusNode = material.FocusNode(); + String? _validationError; + + @override + void initState() { + super.initState(); + final isNull = widget.initialValue == 'NULL'; + _controller = material.TextEditingController( + text: isNull ? '' : widget.initialValue, + ); + _controller.selection = material.TextSelection( + baseOffset: 0, + extentOffset: _controller.text.length, + ); + + _validate(); + _controller.addListener(_validate); + } + + void _validate() { + final error = GridDataTypeValidator.validate( + _controller.text, + dataTypeName: widget.dataTypeName, + ); + if (error != _validationError) { + setState(() { + _validationError = error; + }); + } + } + + @override + void dispose() { + _controller.removeListener(_validate); + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _handleKeyEvent(KeyEvent event) { + if (event is! KeyDownEvent) return; + + final isShift = HardwareKeyboard.instance.isShiftPressed; + final isAlt = HardwareKeyboard.instance.isAltPressed; + final isControl = HardwareKeyboard.instance.isControlPressed || + HardwareKeyboard.instance.isMetaPressed; + + // Alt+N / Ctrl+Alt+N -> Set NULL + if (event.logicalKey == LogicalKeyboardKey.keyN && (isAlt || (isControl && isAlt))) { + widget.onCommit('NULL'); + return; + } + + // Alt+Enter or Ctrl+Enter -> Open Inspector + if ((event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.numpadEnter) && + (isAlt || isControl)) { + widget.onOpenInspector?.call(); + return; + } + + // Enter / Shift+Enter -> Commit and navigate row + if (event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.numpadEnter) { + if (isShift) { + widget.onCommit(_controller.text, movePrevRow: true); + } else { + widget.onCommit(_controller.text, moveNextRow: true); + } + return; + } + + // Tab / Shift+Tab -> Commit and navigate col + if (event.logicalKey == LogicalKeyboardKey.tab) { + if (isShift) { + widget.onCommit(_controller.text, movePrevCol: true); + } else { + widget.onCommit(_controller.text, moveNextCol: true); + } + return; + } + + // Escape -> Cancel + if (event.logicalKey == LogicalKeyboardKey.escape) { + widget.onCancel(); + return; + } + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final hasError = _validationError != null; + + return material.Container( + width: widget.width, + height: widget.height, + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border.all( + color: hasError ? material.Colors.red.shade500 : cs.primary, + width: 1.5, + ), + ), + padding: const material.EdgeInsets.symmetric(horizontal: 6), + alignment: material.Alignment.centerLeft, + child: material.Row( + children: [ + material.Expanded( + child: material.KeyboardListener( + focusNode: _focusNode, + onKeyEvent: _handleKeyEvent, + autofocus: true, + child: material.TextField( + controller: _controller, + autofocus: true, + maxLines: 1, + style: const material.TextStyle( + fontSize: 12, + fontFamily: 'monospace', + ), + decoration: const material.InputDecoration( + border: material.InputBorder.none, + isDense: true, + contentPadding: material.EdgeInsets.zero, + ), + onSubmitted: (value) { + widget.onCommit(value, moveNextRow: true); + }, + ), + ), + ), + if (hasError) + material.Tooltip( + message: _validationError!, + child: material.Padding( + padding: const material.EdgeInsets.only(left: 4), + child: material.Icon( + material.Icons.error_outline_rounded, + size: 14, + color: material.Colors.red.shade500, + ), + ), + ), + if (widget.onOpenInspector != null) + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.GestureDetector( + onTap: () { + widget.onOpenInspector!(); + }, + child: material.Padding( + padding: const material.EdgeInsets.only(left: 4), + child: material.Icon( + material.Icons.open_in_full_rounded, + size: 13, + color: cs.mutedForeground, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/main_screen/grid_cell_popover_inspector.dart b/lib/features/main_screen/grid_cell_popover_inspector.dart new file mode 100644 index 00000000..8f0d7448 --- /dev/null +++ b/lib/features/main_screen/grid_cell_popover_inspector.dart @@ -0,0 +1,261 @@ +import 'dart:convert'; +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Opens a rich modal inspector for viewing and editing large text or JSON values. +Future showGridCellInspectorDialog({ + required material.BuildContext context, + required String columnName, + required String initialValue, + int? rowIndex, +}) { + return showAppDialog( + context: context, + builder: (ctx) => _GridCellInspectorDialog( + columnName: columnName, + initialValue: initialValue, + rowIndex: rowIndex, + ), + ); +} + +class _GridCellInspectorDialog extends material.StatefulWidget { + const _GridCellInspectorDialog({ + required this.columnName, + required this.initialValue, + this.rowIndex, + }); + + final String columnName; + final String initialValue; + final int? rowIndex; + + @override + material.State<_GridCellInspectorDialog> createState() => + _GridCellInspectorDialogState(); +} + +class _GridCellInspectorDialogState + extends material.State<_GridCellInspectorDialog> { + late final material.TextEditingController _controller; + bool _isNull = false; + + @override + void initState() { + super.initState(); + _isNull = widget.initialValue == 'NULL'; + _controller = material.TextEditingController( + text: _isNull ? '' : widget.initialValue, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _formatJson() { + try { + final parsed = jsonDecode(_controller.text); + final pretty = const JsonEncoder.withIndent(' ').convert(parsed); + setState(() { + _isNull = false; + _controller.text = pretty; + }); + } catch (_) { + // Not valid JSON, keep as is + } + } + + void _minifyJson() { + try { + final parsed = jsonDecode(_controller.text); + final compact = jsonEncode(parsed); + setState(() { + _isNull = false; + _controller.text = compact; + }); + } catch (_) { + // Not valid JSON, keep as is + } + } + + void _setNull() { + setState(() { + _isNull = true; + _controller.clear(); + }); + } + + bool _isJson() { + final text = _controller.text.trim(); + if ((text.startsWith('{') && text.endsWith('}')) || + (text.startsWith('[') && text.endsWith(']'))) { + try { + jsonDecode(text); + return true; + } catch (_) { + return false; + } + } + return false; + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final rowLabel = + widget.rowIndex != null ? ' (Row ${widget.rowIndex! + 1})' : ''; + + return material.Dialog( + backgroundColor: cs.card, + shape: material.RoundedRectangleBorder( + borderRadius: material.BorderRadius.circular(8), + side: material.BorderSide(color: cs.border, width: 1), + ), + child: material.ConstrainedBox( + constraints: const material.BoxConstraints( + minWidth: 500, + maxWidth: 720, + minHeight: 380, + maxHeight: 560, + ), + child: material.Padding( + padding: const material.EdgeInsets.all(16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Header + material.Row( + children: [ + material.Icon( + material.Icons.data_object_rounded, + size: 18, + color: cs.primary, + ), + const Gap(8), + material.Expanded( + child: Text( + 'Edit ${widget.columnName}$rowLabel', + ).semiBold(), + ), + if (_isJson()) ...[ + GhostButton( + density: ButtonDensity.compact, + onPressed: _formatJson, + leading: const material.Icon( + material.Icons.format_align_left_rounded, + size: 14, + ), + child: const Text('Format JSON'), + ), + const Gap(6), + GhostButton( + density: ButtonDensity.compact, + onPressed: _minifyJson, + leading: const material.Icon( + material.Icons.compress_rounded, + size: 14, + ), + child: const Text('Minify'), + ), + const Gap(6), + ], + GhostButton( + density: ButtonDensity.compact, + onPressed: _isNull ? null : _setNull, + child: const Text('Set NULL'), + ), + ], + ), + const Gap(12), + + // Editor Body + material.Expanded( + child: material.Container( + decoration: material.BoxDecoration( + color: cs.background, + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: _isNull + ? cs.primary.withValues(alpha: 0.5) + : cs.border, + width: 1, + ), + ), + child: _isNull + ? material.Center( + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('Value is NULL').muted().semiBold(), + const Gap(8), + GhostButton( + density: ButtonDensity.compact, + onPressed: () => setState(() => _isNull = false), + child: const Text('Enter text value'), + ), + ], + ), + ) + : material.TextField( + controller: _controller, + maxLines: null, + expands: true, + autofocus: true, + style: const material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + decoration: const material.InputDecoration( + border: material.InputBorder.none, + contentPadding: material.EdgeInsets.all(12), + hintText: 'Enter cell value…', + ), + ), + ), + ), + const Gap(12), + + // Footer + material.Row( + children: [ + GhostButton( + density: ButtonDensity.compact, + onPressed: () { + Clipboard.setData( + ClipboardData(text: _isNull ? 'NULL' : _controller.text), + ); + }, + leading: const material.Icon( + material.Icons.copy_rounded, + size: 14, + ), + child: const Text('Copy'), + ), + const material.Spacer(), + OutlineButton( + density: ButtonDensity.compact, + onPressed: () => material.Navigator.of(context).pop(null), + child: const Text('Cancel'), + ), + const Gap(8), + PrimaryButton( + density: ButtonDensity.compact, + onPressed: () { + final result = _isNull ? 'NULL' : _controller.text; + material.Navigator.of(context).pop(result); + }, + child: const Text('Apply'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/main_screen/grid_data_type_validator.dart b/lib/features/main_screen/grid_data_type_validator.dart new file mode 100644 index 00000000..ae999c44 --- /dev/null +++ b/lib/features/main_screen/grid_data_type_validator.dart @@ -0,0 +1,104 @@ +import 'dart:convert'; + +/// Helper utility for validating cell values against SQL data types. +abstract final class GridDataTypeValidator { + static final _uuidRegex = RegExp( + r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', + ); + static final _intRegex = RegExp(r'^-?\d+$'); + static final _numRegex = RegExp(r'^-?\d+(\.\d+)?$'); + static final _dateRegex = RegExp(r'^\d{4}-\d{2}-\d{2}$'); + static final _timestampRegex = RegExp( + r'^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}(:\d{2})?)?$', + ); + + /// Validates [value] against the column's [dataTypeName]. + /// Returns `null` if valid (or type is unknown), or an error description string if invalid. + static String? validate(String value, {String? dataTypeName}) { + if (value.isEmpty || value == 'NULL' || value == 'null') { + return null; + } + if (dataTypeName == null || dataTypeName.isEmpty) { + return null; + } + + final type = dataTypeName.toLowerCase().trim(); + + // Integer types + if (type.contains('int') || type == 'serial' || type == 'bigserial') { + if (!_intRegex.hasMatch(value.trim())) { + return 'Expected valid integer'; + } + return null; + } + + // Floating / Decimal / Numeric types + if (type.contains('num') || + type.contains('decimal') || + type.contains('float') || + type.contains('double') || + type == 'real') { + if (!_numRegex.hasMatch(value.trim())) { + return 'Expected valid number'; + } + return null; + } + + // Boolean types + if (type == 'bool' || type == 'boolean') { + final lower = value.toLowerCase().trim(); + if (lower != 'true' && + lower != 'false' && + lower != '1' && + lower != '0' && + lower != 't' && + lower != 'f') { + return 'Expected boolean (true/false/1/0)'; + } + return null; + } + + // UUID + if (type == 'uuid') { + if (!_uuidRegex.hasMatch(value.trim())) { + return 'Expected valid UUID (e.g. 123e4567-e89b-12d3-a456-426614174000)'; + } + return null; + } + + // JSON / JSONB + if (type.contains('json')) { + final trimmed = value.trim(); + if (!((trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']')))) { + return 'Expected valid JSON object or array'; + } + try { + jsonDecode(trimmed); + } catch (e) { + return 'Malformed JSON: $e'; + } + return null; + } + + // Date + if (type == 'date') { + if (!_dateRegex.hasMatch(value.trim())) { + return 'Expected date in YYYY-MM-DD format'; + } + return null; + } + + // Timestamp / DateTime + if (type.contains('timestamp') || + type.contains('datetime') || + type == 'timestamptz') { + if (!_timestampRegex.hasMatch(value.trim())) { + return 'Expected timestamp (YYYY-MM-DD HH:MM:SS)'; + } + return null; + } + + return null; + } +} diff --git a/lib/features/main_screen/grid_filter_engine.dart b/lib/features/main_screen/grid_filter_engine.dart new file mode 100644 index 00000000..5860f111 --- /dev/null +++ b/lib/features/main_screen/grid_filter_engine.dart @@ -0,0 +1,631 @@ +/// Client-side filter engine for Data Grid. +/// +/// Evaluates complex multi-clause expressions with AND / OR / NOT, parentheses, +/// column predicates (`col = val`, `col > 10`, `col LIKE '%test%'`, `col IN ('a', 'b')`, +/// `col IS NULL`, `col BETWEEN x AND y`), and free-text substring search. +abstract final class GridFilterEngine { + /// Evaluates [filterText] against [rows] with respect to [columns]. + /// Returns the list of matching row indices. + static List filterRowIndices({ + required String filterText, + required List columns, + required List> rows, + }) { + final trimmed = filterText.trim(); + if (trimmed.isEmpty || columns.isEmpty || rows.isEmpty) { + return List.generate(rows.length, (i) => i); + } + + final lowerColumns = columns.map((c) => c.toLowerCase()).toList(); + + try { + final tokens = _FilterLexer.tokenize(trimmed, lowerColumns); + if (tokens.isEmpty) { + return List.generate(rows.length, (i) => i); + } + + final parser = _FilterParser(tokens); + final ast = parser.parse(); + + if (ast == null) { + return _fallbackSubstringFilter(trimmed, rows); + } + + final matchingIndices = []; + for (var r = 0; r < rows.length; r++) { + final row = rows[r]; + if (ast.evaluate(row, lowerColumns)) { + matchingIndices.add(r); + } + } + return matchingIndices; + } catch (_) { + // Graceful fallback to multi-term substring match if syntax has parse errors + return _fallbackSubstringFilter(trimmed, rows); + } + } + + static List _fallbackSubstringFilter(String input, List> rows) { + final terms = input.toLowerCase().split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList(); + if (terms.isEmpty) { + return List.generate(rows.length, (i) => i); + } + + final result = []; + for (var r = 0; r < rows.length; r++) { + final row = rows[r]; + var matchAll = true; + for (final term in terms) { + var termMatch = false; + for (var c = 0; c < row.length; c++) { + if (row[c].toLowerCase().contains(term)) { + termMatch = true; + break; + } + } + if (!termMatch) { + matchAll = false; + break; + } + } + if (matchAll) { + result.add(r); + } + } + return result; + } +} + +// ----------------------------------------------------------------------------- +// AST Nodes +// ----------------------------------------------------------------------------- + +abstract class _FilterAstNode { + const _FilterAstNode(); + bool evaluate(List row, List lowerColumns); +} + +class _AndNode extends _FilterAstNode { + const _AndNode(this.left, this.right); + final _FilterAstNode left; + final _FilterAstNode right; + + @override + bool evaluate(List row, List lowerColumns) { + return left.evaluate(row, lowerColumns) && right.evaluate(row, lowerColumns); + } +} + +class _OrNode extends _FilterAstNode { + const _OrNode(this.left, this.right); + final _FilterAstNode left; + final _FilterAstNode right; + + @override + bool evaluate(List row, List lowerColumns) { + return left.evaluate(row, lowerColumns) || right.evaluate(row, lowerColumns); + } +} + +class _NotNode extends _FilterAstNode { + const _NotNode(this.child); + final _FilterAstNode child; + + @override + bool evaluate(List row, List lowerColumns) { + return !child.evaluate(row, lowerColumns); + } +} + +class _PredicateNode extends _FilterAstNode { + const _PredicateNode({ + required this.colIndex, + required this.op, + required this.targetValue, + this.inValues = const [], + this.betweenMin, + this.betweenMax, + }); + + final int colIndex; + final String op; + final String targetValue; + final List inValues; + final String? betweenMin; + final String? betweenMax; + + @override + bool evaluate(List row, List lowerColumns) { + if (colIndex < 0 || colIndex >= row.length) return false; + final cellValue = row[colIndex]; + final isNull = cellValue == 'NULL' || cellValue == 'null' || cellValue.isEmpty; + final upperOp = op.toUpperCase().trim(); + + // IS NULL / IS NOT NULL + if (upperOp == 'IS NULL') { + return isNull; + } + if (upperOp == 'IS NOT NULL') { + return !isNull; + } + + // IN / NOT IN + if (upperOp == 'IN') { + final lowerCell = cellValue.toLowerCase().trim(); + return inValues.any((v) => v.toLowerCase().trim() == lowerCell); + } + if (upperOp == 'NOT IN') { + final lowerCell = cellValue.toLowerCase().trim(); + return !inValues.any((v) => v.toLowerCase().trim() == lowerCell); + } + + // BETWEEN x AND y + if (upperOp == 'BETWEEN' && betweenMin != null && betweenMax != null) { + final numCell = double.tryParse(cellValue.trim()); + final numMin = double.tryParse(betweenMin!.trim()); + final numMax = double.tryParse(betweenMax!.trim()); + if (numCell != null && numMin != null && numMax != null) { + return numCell >= numMin && numCell <= numMax; + } + return cellValue.compareTo(betweenMin!) >= 0 && cellValue.compareTo(betweenMax!) <= 0; + } + + // LIKE / NOT LIKE + if (upperOp == 'LIKE') { + final regex = _likeToRegExp(targetValue, caseSensitive: true); + return regex.hasMatch(cellValue); + } + if (upperOp == 'NOT LIKE') { + final regex = _likeToRegExp(targetValue, caseSensitive: true); + return !regex.hasMatch(cellValue); + } + + // ILIKE / NOT ILIKE + if (upperOp == 'ILIKE') { + final regex = _likeToRegExp(targetValue, caseSensitive: false); + return regex.hasMatch(cellValue); + } + if (upperOp == 'NOT ILIKE') { + final regex = _likeToRegExp(targetValue, caseSensitive: false); + return !regex.hasMatch(cellValue); + } + + final lowerCell = cellValue.toLowerCase(); + final lowerTarget = targetValue.toLowerCase(); + + // Numeric comparison if both values can be parsed as numbers + final numCell = double.tryParse(cellValue.trim()); + final numTarget = double.tryParse(targetValue.trim()); + + if (numCell != null && numTarget != null) { + switch (op) { + case '=': + case '==': + case ':': + return (numCell - numTarget).abs() < 1e-9; + case '!=': + case '<>': + return (numCell - numTarget).abs() >= 1e-9; + case '>': + return numCell > numTarget; + case '>=': + return numCell >= numTarget; + case '<': + return numCell < numTarget; + case '<=': + return numCell <= numTarget; + } + } + + // String / Lexicographic comparison + switch (op) { + case '=': + case '==': + return lowerCell == lowerTarget; + case ':': + return lowerCell.contains(lowerTarget); + case '!=': + case '<>': + return lowerCell != lowerTarget; + case '>': + return lowerCell.compareTo(lowerTarget) > 0; + case '>=': + return lowerCell.compareTo(lowerTarget) >= 0; + case '<': + return lowerCell.compareTo(lowerTarget) < 0; + case '<=': + return lowerCell.compareTo(lowerTarget) <= 0; + default: + return lowerCell.contains(lowerTarget); + } + } + + static RegExp _likeToRegExp(String pattern, {required bool caseSensitive}) { + final buffer = StringBuffer('^'); + for (var i = 0; i < pattern.length; i++) { + final char = pattern[i]; + if (char == '%') { + buffer.write('.*'); + } else if (char == '_') { + buffer.write('.'); + } else { + buffer.write(RegExp.escape(char)); + } + } + buffer.write(r'$'); + return RegExp(buffer.toString(), caseSensitive: caseSensitive); + } +} + +class _FreeTextNode extends _FilterAstNode { + const _FreeTextNode(this.term); + final String term; + + @override + bool evaluate(List row, List lowerColumns) { + final lowerTerm = term.toLowerCase(); + for (var c = 0; c < row.length; c++) { + if (row[c].toLowerCase().contains(lowerTerm)) { + return true; + } + } + return false; + } +} + +// ----------------------------------------------------------------------------- +// Lexer +// ----------------------------------------------------------------------------- + +enum _TokenType { + and, + or, + not, + lparen, + rparen, + predicate, + text, +} + +class _FilterToken { + const _FilterToken(this.type, {this.value = '', this.predicate}); + final _TokenType type; + final String value; + final _PredicateNode? predicate; +} + +abstract final class _FilterLexer { + static List<_FilterToken> tokenize(String input, List lowerColumns) { + final tokens = <_FilterToken>[]; + var i = 0; + + while (i < input.length) { + // Skip whitespace + if (input[i].trim().isEmpty) { + i++; + continue; + } + + // Check for extended predicates with keywords (IS NULL, IS NOT NULL, LIKE, ILIKE, IN, BETWEEN) + final remaining = input.substring(i); + final kwPredicate = _tryMatchKeywordPredicate(remaining, lowerColumns); + if (kwPredicate != null) { + tokens.add(_FilterToken(_TokenType.predicate, predicate: kwPredicate.node)); + i += kwPredicate.consumedChars; + continue; + } + + // Parentheses + if (input[i] == '(') { + tokens.add(const _FilterToken(_TokenType.lparen, value: '(')); + i++; + continue; + } + if (input[i] == ')') { + tokens.add(const _FilterToken(_TokenType.rparen, value: ')')); + i++; + continue; + } + + // Read next chunk/word until whitespace or parenthesis + final start = i; + while (i < input.length && + input[i].trim().isNotEmpty && + input[i] != '(' && + input[i] != ')') { + // Handle quoted literals inside words + if (input[i] == '\'' || input[i] == '"') { + final quote = input[i]; + i++; + while (i < input.length) { + if (input[i] == '\\' && i + 1 < input.length) { + i += 2; + } else if (input[i] == quote) { + if (i + 1 < input.length && input[i + 1] == quote) { + // SQL-style doubled quote escape: '' + i += 2; + } else { + i++; // closing quote + break; + } + } else { + i++; + } + } + } else { + i++; + } + } + + var word = input.substring(start, i).trim(); + if (word.isEmpty) continue; + + // Check logical operators + final upper = word.toUpperCase(); + if (upper == 'AND' || word == '&&') { + tokens.add(const _FilterToken(_TokenType.and, value: 'AND')); + continue; + } + if (upper == 'OR' || word == '||') { + tokens.add(const _FilterToken(_TokenType.or, value: 'OR')); + continue; + } + if (upper == 'NOT' || word == '!') { + tokens.add(const _FilterToken(_TokenType.not, value: 'NOT')); + continue; + } + + // Check if this token or upcoming sequence forms a predicate: col OP val + final predicate = _tryExtractPredicate(word, lowerColumns); + if (predicate != null) { + tokens.add(_FilterToken(_TokenType.predicate, predicate: predicate)); + continue; + } + + // If word is just a column name and the NEXT word is an operator (e.g. "amount", ">", "100") + final colIdx = lowerColumns.indexOf(word.toLowerCase()); + if (colIdx != -1) { + final rem = input.substring(i).trimLeft(); + final opMatch = RegExp(r'^(>=|<=|!=|<>|==|=|>|<|:)\s*([^\s()]+)') + .firstMatch(rem); + if (opMatch != null) { + final op = opMatch.group(1)!; + var val = opMatch.group(2)!; + val = _stripQuotes(val); + tokens.add( + _FilterToken( + _TokenType.predicate, + predicate: _PredicateNode( + colIndex: colIdx, + op: op, + targetValue: val, + ), + ), + ); + i += input.substring(i).indexOf(opMatch.group(0)!) + + opMatch.group(0)!.length; + continue; + } + } + + word = _stripQuotes(word); + tokens.add(_FilterToken(_TokenType.text, value: word)); + } + + return tokens; + } + + static ({_PredicateNode node, int consumedChars})? _tryMatchKeywordPredicate( + String remaining, + List lowerColumns, + ) { + // 1. IS NULL / IS NOT NULL (e.g. "status IS NULL", "email IS NOT NULL") + final isNullMatch = RegExp(r'^([a-zA-Z_]\w*)\s+IS\s+(NOT\s+)?NULL\b', caseSensitive: false) + .firstMatch(remaining); + if (isNullMatch != null) { + final colName = isNullMatch.group(1)!.toLowerCase(); + final colIdx = lowerColumns.indexOf(colName); + if (colIdx != -1) { + final isNot = isNullMatch.group(2) != null; + return ( + node: _PredicateNode( + colIndex: colIdx, + op: isNot ? 'IS NOT NULL' : 'IS NULL', + targetValue: '', + ), + consumedChars: isNullMatch.group(0)!.length, + ); + } + } + + // 2. IN / NOT IN (e.g. "status IN ('ACTIVE', 'PENDING')", "id NOT IN (1, 2, 3)") + final inMatch = RegExp(r'^([a-zA-Z_]\w*)\s+(NOT\s+)?IN\s*\(([^)]+)\)', caseSensitive: false) + .firstMatch(remaining); + if (inMatch != null) { + final colName = inMatch.group(1)!.toLowerCase(); + final colIdx = lowerColumns.indexOf(colName); + if (colIdx != -1) { + final isNot = inMatch.group(2) != null; + final listStr = inMatch.group(3)!; + final items = listStr + .split(',') + .map((s) => _stripQuotes(s.trim())) + .where((s) => s.isNotEmpty) + .toList(); + return ( + node: _PredicateNode( + colIndex: colIdx, + op: isNot ? 'NOT IN' : 'IN', + targetValue: '', + inValues: items, + ), + consumedChars: inMatch.group(0)!.length, + ); + } + } + + // 3. BETWEEN x AND y (e.g. "amount BETWEEN 10 AND 100") + final betweenMatch = RegExp(r'^([a-zA-Z_]\w*)\s+BETWEEN\s+([^\s]+)\s+AND\s+([^\s()]+)', caseSensitive: false) + .firstMatch(remaining); + if (betweenMatch != null) { + final colName = betweenMatch.group(1)!.toLowerCase(); + final colIdx = lowerColumns.indexOf(colName); + if (colIdx != -1) { + final minVal = _stripQuotes(betweenMatch.group(2)!.trim()); + final maxVal = _stripQuotes(betweenMatch.group(3)!.trim()); + return ( + node: _PredicateNode( + colIndex: colIdx, + op: 'BETWEEN', + targetValue: '', + betweenMin: minVal, + betweenMax: maxVal, + ), + consumedChars: betweenMatch.group(0)!.length, + ); + } + } + + // 4. LIKE / ILIKE / NOT LIKE / NOT ILIKE (e.g. "name LIKE '%John%'", "email ILIKE '%.org'") + final likeMatch = RegExp(r'^([a-zA-Z_]\w*)\s+(NOT\s+)?(ILIKE|LIKE)\s+([^\s()]+)', caseSensitive: false) + .firstMatch(remaining); + if (likeMatch != null) { + final colName = likeMatch.group(1)!.toLowerCase(); + final colIdx = lowerColumns.indexOf(colName); + if (colIdx != -1) { + final isNot = likeMatch.group(2) != null; + final likeType = likeMatch.group(3)!.toUpperCase(); + final pattern = _stripQuotes(likeMatch.group(4)!.trim()); + final op = isNot ? 'NOT $likeType' : likeType; + return ( + node: _PredicateNode( + colIndex: colIdx, + op: op, + targetValue: pattern, + ), + consumedChars: likeMatch.group(0)!.length, + ); + } + } + + return null; + } + + static _PredicateNode? _tryExtractPredicate( + String token, + List lowerColumns, + ) { + const ops = ['>=', '<=', '!=', '<>', '==', '=', '>', '<', ':']; + for (final op in ops) { + final parts = token.split(op); + if (parts.length == 2 && parts[0].isNotEmpty && parts[1].isNotEmpty) { + final colCandidate = parts[0].trim().toLowerCase(); + final colIdx = lowerColumns.indexOf(colCandidate); + if (colIdx != -1) { + final val = _stripQuotes(parts[1].trim()); + return _PredicateNode( + colIndex: colIdx, + op: op, + targetValue: val, + ); + } + } + } + return null; + } + + static String _stripQuotes(String s) { + if ((s.startsWith("'") && s.endsWith("'")) || + (s.startsWith('"') && s.endsWith('"'))) { + if (s.length >= 2) { + return s + .substring(1, s.length - 1) + .replaceAll("''", "'") + .replaceAll(r"\'", "'") + .replaceAll(r'\"', '"'); + } + } + return s; + } +} + +// ----------------------------------------------------------------------------- +// Parser +// ----------------------------------------------------------------------------- + +class _FilterParser { + _FilterParser(this.tokens); + final List<_FilterToken> tokens; + int _pos = 0; + + _FilterAstNode? parse() { + if (tokens.isEmpty) return null; + return _parseOr(); + } + + _FilterAstNode _parseOr() { + var node = _parseAnd(); + while (_match(_TokenType.or)) { + final right = _parseAnd(); + node = _OrNode(node, right); + } + return node; + } + + _FilterAstNode _parseAnd() { + var node = _parseUnary(); + while (_match(_TokenType.and) || _isImplicitAnd()) { + final right = _parseUnary(); + node = _AndNode(node, right); + } + return node; + } + + bool _isImplicitAnd() { + if (_pos >= tokens.length) return false; + final type = tokens[_pos].type; + return type == _TokenType.predicate || + type == _TokenType.text || + type == _TokenType.lparen || + type == _TokenType.not; + } + + _FilterAstNode _parseUnary() { + if (_match(_TokenType.not)) { + return _NotNode(_parseUnary()); + } + return _parsePrimary(); + } + + _FilterAstNode _parsePrimary() { + if (_match(_TokenType.lparen)) { + final node = _parseOr(); + _consume(_TokenType.rparen); + return node; + } + + if (_pos < tokens.length) { + final token = tokens[_pos++]; + if (token.type == _TokenType.predicate && token.predicate != null) { + return token.predicate!; + } + return _FreeTextNode(token.value); + } + + return const _FreeTextNode(''); + } + + bool _match(_TokenType type) { + if (_pos < tokens.length && tokens[_pos].type == type) { + _pos++; + return true; + } + return false; + } + + void _consume(_TokenType type) { + if (_pos < tokens.length && tokens[_pos].type == type) { + _pos++; + } + } +} diff --git a/lib/features/main_screen/grid_groupings_engine.dart b/lib/features/main_screen/grid_groupings_engine.dart new file mode 100644 index 00000000..e117df19 --- /dev/null +++ b/lib/features/main_screen/grid_groupings_engine.dart @@ -0,0 +1,239 @@ +import 'package:flutter/foundation.dart'; + +/// Aggregation operation to perform on groups. +enum GroupingAggType { + count('COUNT'), + sum('SUM'), + avg('AVG'), + min('MIN'), + max('MAX'); + + const GroupingAggType(this.label); + final String label; +} + +/// Sort criteria for grouping categories. +enum GroupSortBy { + count('Count'), + key('Group Key'), + aggregate('Aggregate'); + + const GroupSortBy(this.label); + final String label; +} + +/// Configuration for group aggregations. +@immutable +class GroupAggregationConfig { + const GroupAggregationConfig({ + this.aggType = GroupingAggType.count, + this.targetColIndex, + }); + + final GroupingAggType aggType; + final int? targetColIndex; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GroupAggregationConfig && + aggType == other.aggType && + targetColIndex == other.targetColIndex; + + @override + int get hashCode => Object.hash(aggType, targetColIndex); +} + +/// Represents an aggregated group in Groupings / Pivot View (supports nested sub-groups). +@immutable +class GroupedCategory { + const GroupedCategory({ + required this.groupKey, + required this.count, + required this.percentage, + required this.rows, + this.aggValue, + this.subGroups = const [], + this.level = 0, + }); + + final String groupKey; + final int count; + final double percentage; + final List> rows; + final double? aggValue; + final List subGroups; + final int level; + + bool get hasSubGroups => subGroups.isNotEmpty; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GroupedCategory && + groupKey == other.groupKey && + count == other.count && + percentage == other.percentage && + aggValue == other.aggValue && + level == other.level; + + @override + int get hashCode => Object.hash(groupKey, count, percentage, aggValue, level); +} + +/// Engine to construct multi-column pivot / hierarchical grouping breakdown tables. +abstract final class GridGroupingsEngine { + /// Builds multi-level groups by [groupColIndices] with optional aggregation and sorting. + static List buildGroups({ + required List groupColIndices, + required List> rows, + GroupAggregationConfig aggConfig = const GroupAggregationConfig(), + GroupSortBy sortBy = GroupSortBy.count, + bool sortAscending = false, + }) { + if (rows.isEmpty || groupColIndices.isEmpty) return const []; + + return _buildSubGroups( + groupColIndices: groupColIndices, + levelIndex: 0, + rows: rows, + totalRootRows: rows.length, + aggConfig: aggConfig, + sortBy: sortBy, + sortAscending: sortAscending, + ); + } + + static List _buildSubGroups({ + required List groupColIndices, + required int levelIndex, + required List> rows, + required int totalRootRows, + required GroupAggregationConfig aggConfig, + required GroupSortBy sortBy, + required bool sortAscending, + }) { + if (levelIndex >= groupColIndices.length || rows.isEmpty) return const []; + + final colIndex = groupColIndices[levelIndex]; + final map = >>{}; + + for (final row in rows) { + final key = colIndex < row.length ? row[colIndex] : 'NULL'; + final effectiveKey = key.isEmpty ? '(Empty)' : key; + map.putIfAbsent(effectiveKey, () => []).add(row); + } + + final categories = []; + final hasNextLevel = levelIndex + 1 < groupColIndices.length; + + map.forEach((key, categoryRows) { + final count = categoryRows.length; + final pct = totalRootRows > 0 ? (count / totalRootRows) * 100 : 0.0; + final agg = _computeAggregation(categoryRows, aggConfig); + + List subGroups = const []; + if (hasNextLevel) { + subGroups = _buildSubGroups( + groupColIndices: groupColIndices, + levelIndex: levelIndex + 1, + rows: categoryRows, + totalRootRows: totalRootRows, + aggConfig: aggConfig, + sortBy: sortBy, + sortAscending: sortAscending, + ); + } + + categories.add( + GroupedCategory( + groupKey: key, + count: count, + percentage: pct, + rows: categoryRows, + aggValue: agg, + subGroups: subGroups, + level: levelIndex, + ), + ); + }); + + // Sorting + categories.sort((a, b) { + int cmp; + switch (sortBy) { + case GroupSortBy.count: + cmp = a.count.compareTo(b.count); + break; + case GroupSortBy.key: + cmp = a.groupKey.compareTo(b.groupKey); + break; + case GroupSortBy.aggregate: + final aVal = a.aggValue ?? (a.count.toDouble()); + final bVal = b.aggValue ?? (b.count.toDouble()); + cmp = aVal.compareTo(bVal); + break; + } + return sortAscending ? cmp : -cmp; + }); + + return categories; + } + + static double? _computeAggregation( + List> rows, + GroupAggregationConfig config, + ) { + if (config.aggType == GroupingAggType.count) { + return rows.length.toDouble(); + } + if (config.targetColIndex == null) return null; + + final targetCol = config.targetColIndex!; + final numbers = []; + + for (final row in rows) { + if (targetCol < row.length) { + final val = row[targetCol].replaceAll(',', '').trim(); + final parsed = double.tryParse(val); + if (parsed != null && !parsed.isNaN && !parsed.isInfinite) { + numbers.add(parsed); + } + } + } + + if (numbers.isEmpty) return null; + + switch (config.aggType) { + case GroupingAggType.count: + return numbers.length.toDouble(); + case GroupingAggType.sum: + return numbers.reduce((a, b) => a + b); + case GroupingAggType.avg: + return numbers.reduce((a, b) => a + b) / numbers.length; + case GroupingAggType.min: + return numbers.reduce((a, b) => a < b ? a : b); + case GroupingAggType.max: + return numbers.reduce((a, b) => a > b ? a : b); + } + } + + /// Exports pivot summary to CSV format. + static String exportPivotToCsv({ + required List groups, + required String groupByColumnName, + GroupAggregationConfig aggConfig = const GroupAggregationConfig(), + }) { + final buffer = StringBuffer(); + buffer.writeln('Group Key,Count,Percentage,Aggregate'); + + for (final g in groups) { + final aggStr = g.aggValue != null ? g.aggValue!.toStringAsFixed(2) : '-'; + buffer.writeln( + '"${g.groupKey.replaceAll('"', '""')}",${g.count},${g.percentage.toStringAsFixed(2)}%,$aggStr', + ); + } + + return buffer.toString(); + } +} diff --git a/lib/features/main_screen/grid_selection_calc_engine.dart b/lib/features/main_screen/grid_selection_calc_engine.dart new file mode 100644 index 00000000..54b698d4 --- /dev/null +++ b/lib/features/main_screen/grid_selection_calc_engine.dart @@ -0,0 +1,181 @@ +import 'dart:math' as math; +import 'package:flutter/foundation.dart'; + +/// Aggregated statistical results for a selection of grid cell values. +@immutable +class GridCalcStats { + const GridCalcStats({ + required this.totalCount, + required this.distinctCount, + required this.numericCount, + required this.nullCount, + this.sum, + this.average, + this.median, + this.min, + this.max, + this.stdDev, + }); + + static const empty = GridCalcStats( + totalCount: 0, + distinctCount: 0, + numericCount: 0, + nullCount: 0, + ); + + final int totalCount; + final int distinctCount; + final int numericCount; + final int nullCount; + final double? sum; + final double? average; + final double? median; + final double? min; + final double? max; + final double? stdDev; + + bool get hasNumericStats => numericCount > 0 && sum != null; + + /// Formats all available statistics into a single copyable summary string. + String toSummaryString() { + final parts = [ + 'Count: $totalCount', + 'Distinct: $distinctCount', + ]; + if (nullCount > 0) { + parts.add('NULLs: $nullCount'); + } + if (hasNumericStats) { + parts.add('Sum: ${GridSelectionCalcEngine.formatNum(sum)}'); + parts.add('Avg: ${GridSelectionCalcEngine.formatNum(average)}'); + if (median != null) { + parts.add('Median: ${GridSelectionCalcEngine.formatNum(median)}'); + } + parts.add('Min: ${GridSelectionCalcEngine.formatNum(min)}'); + parts.add('Max: ${GridSelectionCalcEngine.formatNum(max)}'); + if (stdDev != null) { + parts.add('StdDev: ${GridSelectionCalcEngine.formatNum(stdDev)}'); + } + } + return parts.join(' | '); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GridCalcStats && + totalCount == other.totalCount && + distinctCount == other.distinctCount && + numericCount == other.numericCount && + nullCount == other.nullCount && + sum == other.sum && + average == other.average && + median == other.median && + min == other.min && + max == other.max && + stdDev == other.stdDev; + + @override + int get hashCode => Object.hash( + totalCount, + distinctCount, + numericCount, + nullCount, + sum, + average, + median, + min, + max, + stdDev, + ); +} + +/// Calculation engine for computing stats (Count, Distinct, Sum, Avg, Median, Min, Max, StdDev) on grid selections. +abstract final class GridSelectionCalcEngine { + /// Computes statistics for a list of string cell values. + static GridCalcStats compute(List values) { + if (values.isEmpty) return GridCalcStats.empty; + + final total = values.length; + var nulls = 0; + final distinctSet = {}; + final numericList = []; + var sum = 0.0; + double? minVal; + double? maxVal; + + for (final raw in values) { + final trimmed = raw.trim(); + if (trimmed == 'NULL' || trimmed.isEmpty) { + nulls++; + continue; + } + + distinctSet.add(trimmed); + + // Try parsing numeric values (stripping commas if present) + final normalized = trimmed.replaceAll(',', ''); + final parsed = double.tryParse(normalized); + if (parsed != null && !parsed.isNaN && !parsed.isInfinite) { + numericList.add(parsed); + sum += parsed; + if (minVal == null || parsed < minVal) { + minVal = parsed; + } + if (maxVal == null || parsed > maxVal) { + maxVal = parsed; + } + } + } + + final numericCount = numericList.length; + final avg = numericCount > 0 ? sum / numericCount : null; + + // Calculate median + double? median; + if (numericCount > 0) { + numericList.sort(); + final mid = numericCount ~/ 2; + if (numericCount.isOdd) { + median = numericList[mid]; + } else { + median = (numericList[mid - 1] + numericList[mid]) / 2.0; + } + } + + // Calculate standard deviation + double? stdDev; + if (numericCount > 1 && avg != null) { + var varianceSum = 0.0; + for (final n in numericList) { + varianceSum += math.pow(n - avg, 2); + } + stdDev = math.sqrt(varianceSum / (numericCount - 1)); + } + + return GridCalcStats( + totalCount: total, + distinctCount: distinctSet.length, + numericCount: numericCount, + nullCount: nulls, + sum: numericCount > 0 ? sum : null, + average: avg, + median: median, + min: minVal, + max: maxVal, + stdDev: stdDev, + ); + } + + /// Formats a numeric stat cleanly for UI display. + static String formatNum(double? val) { + if (val == null) return '-'; + if (val == val.roundToDouble()) { + return val.toInt().toString(); + } + // Limit decimal precision to 4 decimal places + final formatted = val.toStringAsFixed(4); + return formatted.replaceAll(RegExp(r'0+$'), '').replaceAll(RegExp(r'\.$'), ''); + } +} diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index f3f5a2f4..d13196cb 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -1,14 +1,18 @@ import 'dart:async'; +import 'dart:io'; import 'dart:math' as math; import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart' show LogicalKeyboardKey; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; +import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; import 'package:querya_desktop/core/actions/sql_editor_global_actions.dart'; +import 'package:querya_desktop/core/demo/demo_playground_service.dart'; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; -import 'package:querya_desktop/core/layout/querya_drag_settle.dart'; import 'package:querya_desktop/core/layout/querya_split_handle.dart'; import 'package:querya_desktop/core/motion/querya_spring.dart'; +import 'package:querya_desktop/core/motion/querya_spring_controller.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/extensions/sandbox/unsandboxed_launch_consent_gate.dart'; @@ -19,6 +23,7 @@ import 'package:querya_desktop/features/connections/connections_panel.dart'; import 'package:querya_desktop/features/connections/sqlite_connection_form.dart'; import 'package:querya_desktop/features/main_screen/connections_panel_width_persist.dart'; import 'package:querya_desktop/features/main_screen/querya_window_title_bar.dart'; +import 'package:querya_desktop/features/onboarding/welcome_tour_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; @@ -35,26 +40,146 @@ class MainScreen extends StatefulWidget { class _MainScreenState extends State { final GlobalKey _connectionsPanelKey = GlobalKey(); + final GlobalKey<_MainContentSplitState> _splitKey = + GlobalKey<_MainContentSplitState>(); final ValueNotifier _workspace = ValueNotifier(MainScreenWorkspaceState.empty); + final ValueNotifier _isSidebarVisible = ValueNotifier(true); + bool _openingConnectionDialog = false; @override void initState() { super.initState(); + HardwareKeyboard.instance.addHandler(_handleGlobalKeyEvent); UnsandboxedLaunchConsentGate.instance.handler = (details) { if (!mounted) return Future.value(false); return showUnsandboxedDriverConsentDialog(context, details); }; + + WidgetsBinding.instance.addPostFrameCallback((_) async { + final completed = await AppSettings.instance.getHasCompletedWelcomeTour(); + if (!completed && mounted) { + final connections = await LocalDb.instance.getConnections(); + if (connections.isEmpty && mounted) { + _onOpenWelcomeTour(); + } + } + }); } @override void dispose() { + HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent); UnsandboxedLaunchConsentGate.instance.handler = null; _workspace.dispose(); + _isSidebarVisible.dispose(); super.dispose(); } + bool _handleGlobalKeyEvent(KeyEvent event) { + if (event is! KeyDownEvent) return false; + + final isMac = Platform.isMacOS; + final isCmdOrCtrl = isMac + ? (HardwareKeyboard.instance.isMetaPressed || + HardwareKeyboard.instance.isControlPressed) + : HardwareKeyboard.instance.isControlPressed; + final isShift = HardwareKeyboard.instance.isShiftPressed; + final isAlt = HardwareKeyboard.instance.isAltPressed; + + final physical = event.physicalKey; + final logical = event.logicalKey; + + // 1. Execute SQL: F5, or (Ctrl/Cmd + Enter), or (Ctrl/Cmd + R) + final isEnter = physical == PhysicalKeyboardKey.enter || + physical == PhysicalKeyboardKey.numpadEnter || + logical == LogicalKeyboardKey.enter || + logical == LogicalKeyboardKey.numpadEnter; + final isF5 = physical == PhysicalKeyboardKey.f5 || + logical == LogicalKeyboardKey.f5; + final isKeyR = physical == PhysicalKeyboardKey.keyR || + logical == LogicalKeyboardKey.keyR || + logical == const LogicalKeyboardKey(0x0000043a); // Russian 'к' + + if ((isCmdOrCtrl && isEnter) || + isF5 || + (isCmdOrCtrl && !isShift && !isAlt && isKeyR)) { + if (SqlEditorCommandBridge.instance.canExecute) { + SqlEditorCommandBridge.instance.invokeExecute(); + return true; + } + } + + // 2. Toggle Left Sidebar: Ctrl+B / Cmd+B (Physical B, Logical B, Russian 'и') + final isKeyB = physical == PhysicalKeyboardKey.keyB || + logical == LogicalKeyboardKey.keyB || + logical == const LogicalKeyboardKey(0x00000438); // Russian 'и' + if (isCmdOrCtrl && !isShift && !isAlt && isKeyB) { + _splitKey.currentState?.toggleSidebar(); + return true; + } + + // 3. New Connection / New Query Tab: (Physical N, Logical N, Russian 'т') + final isKeyN = physical == PhysicalKeyboardKey.keyN || + logical == LogicalKeyboardKey.keyN || + logical == const LogicalKeyboardKey(0x00000442); // Russian 'т' + if (isCmdOrCtrl && !isAlt && isKeyN) { + if (isShift) { + // Ctrl+Shift+N: New Query + final ctx = FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke(ctx, const NewSqlIntent()); + } else { + // Ctrl+N: New Database Connection + unawaited(_onNewDatabaseConnectionFromMenu()); + } + return true; + } + + // 4. Open SQL: Ctrl+O / Cmd+O (Physical O, Logical O, Russian 'щ') + final isKeyO = physical == PhysicalKeyboardKey.keyO || + logical == LogicalKeyboardKey.keyO || + logical == const LogicalKeyboardKey(0x00000449); // Russian 'щ' + if (isCmdOrCtrl && !isShift && !isAlt && isKeyO) { + final ctx = FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke(ctx, const OpenSqlIntent()); + return true; + } + + // 5. Save SQL: Ctrl+S / Cmd+S (Physical S, Logical S, Russian 'ы') + final isKeyS = physical == PhysicalKeyboardKey.keyS || + logical == LogicalKeyboardKey.keyS || + logical == const LogicalKeyboardKey(0x0000044b); // Russian 'ы' + if (isCmdOrCtrl && !isShift && !isAlt && isKeyS) { + final ctx = FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke(ctx, const SaveSqlIntent()); + return true; + } + + // 6. Tutorial & Welcome: F1 or Ctrl+Shift+H / Cmd+Shift+H (Physical H, Logical H, Russian 'р') + final isF1 = physical == PhysicalKeyboardKey.f1 || + logical == LogicalKeyboardKey.f1; + final isKeyH = physical == PhysicalKeyboardKey.keyH || + logical == LogicalKeyboardKey.keyH || + logical == const LogicalKeyboardKey(0x00000440); // Russian 'р' + if (isF1 || (isCmdOrCtrl && isShift && isKeyH)) { + _onOpenWelcomeTour(); + return true; + } + + // 7. Home / Start Screen: Ctrl+Shift+0 / Cmd+Shift+0 + final isDigit0 = physical == PhysicalKeyboardKey.digit0 || + physical == PhysicalKeyboardKey.numpad0 || + logical == LogicalKeyboardKey.digit0 || + logical == LogicalKeyboardKey.numpad0; + if (isCmdOrCtrl && isShift && isDigit0) { + _onGoHome(); + return true; + } + + return false; + } + void _onConnectionSelected(ConnectionRow connection) { _workspace.value = _workspace.value.selectConnection(connection); final id = connection.id; @@ -172,22 +297,34 @@ class _MainScreenState extends State { } Future _onNewDatabaseConnectionFromMenu() async { - // Menu overlay context is torn down before the connection form opens. - await Future.delayed(const Duration(milliseconds: 100)); - if (!mounted) return; - final row = await promptCreateConnection(context, folderId: null); - if (!mounted || row == null) return; - await LocalDb.instance.addConnection(row); - await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + if (_openingConnectionDialog) return; + _openingConnectionDialog = true; + try { + // Menu overlay context is torn down before the connection form opens. + await Future.delayed(const Duration(milliseconds: 100)); + if (!mounted) return; + final row = await promptCreateConnection(context, folderId: null); + if (!mounted || row == null) return; + await LocalDb.instance.addConnection(row); + await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + } finally { + _openingConnectionDialog = false; + } } Future _onNewDatabaseConnectionFromUrl() async { - await Future.delayed(const Duration(milliseconds: 100)); - if (!mounted) return; - final row = await showNewConnectionUrlDialog(context); - if (!mounted || row == null) return; - await LocalDb.instance.addConnection(row); - await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + if (_openingConnectionDialog) return; + _openingConnectionDialog = true; + try { + await Future.delayed(const Duration(milliseconds: 100)); + if (!mounted) return; + final row = await showNewConnectionUrlDialog(context); + if (!mounted || row == null) return; + await LocalDb.instance.addConnection(row); + await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + } finally { + _openingConnectionDialog = false; + } } Future _openSqliteFromHero() async { @@ -197,67 +334,202 @@ class _MainScreenState extends State { await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); } + void _onGoHome() { + _workspace.value = MainScreenWorkspaceState.empty; + } + + void _onOpenWelcomeTour() { + showWelcomeTourDialog( + context, + onLaunchDemo: _onLaunchDemoPlayground, + onGoHome: _workspace.value.activeConnection != null ? _onGoHome : null, + ); + } + + Future _onLaunchDemoPlayground() async { + try { + final demoConn = await DemoPlaygroundService.getOrCreateDemoConnection(); + if (!mounted) return; + await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + _workspace.value = _workspace.value.selectConnection(demoConn); + _onSqliteOpenSqlWorkspace(demoConn); + } catch (_) {} + } + @override material.Widget build(material.BuildContext context) { final wb = context.workbench; return ValueListenableBuilder( valueListenable: _workspace, builder: (context, workspace, _) { - return material.CallbackShortcuts( - bindings: { - const material.SingleActivator( - LogicalKeyboardKey.keyN, - control: true, - shift: true, - ): () => unawaited(_onNewDatabaseConnectionFromMenu()), - }, - child: SqlEditorGlobalActions( - activeConnection: workspace.activeConnection, - onOpenSqlWorkspace: _openSqlWorkspaceForConnection, - child: material.Scaffold( - backgroundColor: wb.canvas, - body: WindowBorder( + return FocusScope( + autofocus: true, + child: material.CallbackShortcuts( + bindings: { + // New Database Connection: Ctrl+N / Cmd+N + const material.SingleActivator( + LogicalKeyboardKey.keyN, + meta: true, + ): () => unawaited(_onNewDatabaseConnectionFromMenu()), + const material.SingleActivator( + LogicalKeyboardKey.keyN, + control: true, + ): () => unawaited(_onNewDatabaseConnectionFromMenu()), + + // New Query Tab: Ctrl+Shift+N / Cmd+Shift+N + const material.SingleActivator( + LogicalKeyboardKey.keyN, + meta: true, + shift: true, + ): () { + final ctx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke(ctx, const NewSqlIntent()); + }, + const material.SingleActivator( + LogicalKeyboardKey.keyN, + control: true, + shift: true, + ): () { + final ctx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke(ctx, const NewSqlIntent()); + }, + + // Open SQL File: Ctrl+O / Cmd+O + const material.SingleActivator( + LogicalKeyboardKey.keyO, + meta: true, + ): () { + final ctx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke(ctx, const OpenSqlIntent()); + }, + const material.SingleActivator( + LogicalKeyboardKey.keyO, + control: true, + ): () { + final ctx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke(ctx, const OpenSqlIntent()); + }, + + // Save SQL File: Ctrl+S / Cmd+S + const material.SingleActivator( + LogicalKeyboardKey.keyS, + meta: true, + ): () { + final ctx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke(ctx, const SaveSqlIntent()); + }, + const material.SingleActivator( + LogicalKeyboardKey.keyS, + control: true, + ): () { + final ctx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke(ctx, const SaveSqlIntent()); + }, + + // Toggle Left Sidebar: Ctrl+B / Cmd+B + const material.SingleActivator( + LogicalKeyboardKey.keyB, + control: true, + ): () => _splitKey.currentState?.toggleSidebar(), + const material.SingleActivator( + LogicalKeyboardKey.keyB, + meta: true, + ): () => _splitKey.currentState?.toggleSidebar(), + + // Welcome Tour & Tutorial: F1 / Cmd+Shift+H / Ctrl+Shift+H + const material.SingleActivator( + LogicalKeyboardKey.f1, + ): () => _onOpenWelcomeTour(), + const material.SingleActivator( + LogicalKeyboardKey.keyH, + meta: true, + shift: true, + ): () => _onOpenWelcomeTour(), + const material.SingleActivator( + LogicalKeyboardKey.keyH, + control: true, + shift: true, + ): () => _onOpenWelcomeTour(), + + // Return to Start / Home Screen: Cmd+Shift+0 / Ctrl+Shift+0 + const material.SingleActivator( + LogicalKeyboardKey.digit0, + meta: true, + shift: true, + ): _onGoHome, + const material.SingleActivator( + LogicalKeyboardKey.digit0, + control: true, + shift: true, + ): _onGoHome, + }, + child: SqlEditorGlobalActions( + activeConnection: workspace.activeConnection, + onOpenSqlWorkspace: _openSqlWorkspaceForConnection, + child: material.Scaffold( + backgroundColor: wb.canvas, + body: WindowBorder( color: wb.borderSubtle.withValues(alpha: 0.35), width: 1, child: Column( children: [ - QueryaWindowTitleBar( - onNewDatabaseConnection: _onNewDatabaseConnectionFromMenu, - onNewDatabaseConnectionFromUrl: - _onNewDatabaseConnectionFromUrl, - activeConnection: workspace.activeConnection, - isReadOnly: workspace.isReadOnly, - onReadOnlyChanged: () { - _workspace.value = _workspace.value.toggleReadOnly(); - }, - onConnect: () { - final active = workspace.activeConnection; - if (active != null && active.id != null) { - _connectionsPanelKey.currentState - ?.connect(active.id!); - } - }, - onReconnect: () { - final active = workspace.activeConnection; - if (active != null) { - _connectionsPanelKey.currentState?.reconnect(active); - } - }, - onDisconnect: () { - final active = workspace.activeConnection; - if (active != null) { - _connectionsPanelKey.currentState?.disconnect(active); - } - }, - onDisconnectAll: () { - _connectionsPanelKey.currentState?.disconnectAll(); - }, - onDisconnectOthers: () { - final active = workspace.activeConnection; - if (active != null) { - _connectionsPanelKey.currentState - ?.disconnectOthers(active); - } + ValueListenableBuilder( + valueListenable: _isSidebarVisible, + builder: (context, isSidebarVisible, _) { + return QueryaWindowTitleBar( + onNewDatabaseConnection: + _onNewDatabaseConnectionFromMenu, + onNewDatabaseConnectionFromUrl: + _onNewDatabaseConnectionFromUrl, + activeConnection: workspace.activeConnection, + isReadOnly: workspace.isReadOnly, + isSidebarVisible: isSidebarVisible, + onToggleSidebar: () => + _splitKey.currentState?.toggleSidebar(), + onOpenWelcomeTour: _onOpenWelcomeTour, + onGoHome: _onGoHome, + onReadOnlyChanged: () { + _workspace.value = + _workspace.value.toggleReadOnly(); + }, + onConnect: () { + final active = workspace.activeConnection; + if (active != null && active.id != null) { + _connectionsPanelKey.currentState + ?.connect(active.id!); + } + }, + onReconnect: () { + final active = workspace.activeConnection; + if (active != null) { + _connectionsPanelKey.currentState + ?.reconnect(active); + } + }, + onDisconnect: () { + final active = workspace.activeConnection; + if (active != null) { + _connectionsPanelKey.currentState + ?.disconnect(active); + } + }, + onDisconnectAll: () { + _connectionsPanelKey.currentState?.disconnectAll(); + }, + onDisconnectOthers: () { + final active = workspace.activeConnection; + if (active != null) { + _connectionsPanelKey.currentState + ?.disconnectOthers(active); + } + }, + ); }, ), Divider( @@ -265,8 +537,11 @@ class _MainScreenState extends State { color: wb.borderSubtle.withValues(alpha: 0.22)), Expanded( child: _MainContentSplit( + key: _splitKey, connectionsPanelKey: _connectionsPanelKey, workspace: _workspace, + onSidebarVisibilityChanged: (v) => + _isSidebarVisible.value = v, onConnectionSelected: _onConnectionSelected, onPostgresObjectSelected: _onPostgresObjectSelected, onMysqlObjectSelected: _onMysqlObjectSelected, @@ -281,6 +556,8 @@ class _MainScreenState extends State { onRequestNewConnectionFromUrl: _onNewDatabaseConnectionFromUrl, onRequestOpenSqlite: _openSqliteFromHero, + onRequestLaunchDemo: _onLaunchDemoPlayground, + onRequestOpenTour: _onOpenWelcomeTour, onOpenConnection: _onConnectionSelected, ), ), @@ -289,15 +566,17 @@ class _MainScreenState extends State { ), ), ), - ); - }, - ); - } + ), + ); + }, + ); +} } -/// Owns splitter width so resizing does not rebuild [MainScreen] or title bar. +/// Owns splitter width and collapsible spring motion. class _MainContentSplit extends StatefulWidget { const _MainContentSplit({ + super.key, required this.connectionsPanelKey, required this.workspace, required this.onConnectionSelected, @@ -313,11 +592,15 @@ class _MainContentSplit extends StatefulWidget { required this.onRequestNewConnection, required this.onRequestNewConnectionFromUrl, required this.onRequestOpenSqlite, + this.onRequestLaunchDemo, + this.onRequestOpenTour, required this.onOpenConnection, + this.onSidebarVisibilityChanged, }); final GlobalKey connectionsPanelKey; final ValueNotifier workspace; + final ValueChanged? onSidebarVisibilityChanged; final void Function(ConnectionRow) onConnectionSelected; final void Function( ConnectionRow, @@ -350,6 +633,8 @@ class _MainContentSplit extends StatefulWidget { final VoidCallback onRequestNewConnection; final VoidCallback onRequestNewConnectionFromUrl; final VoidCallback onRequestOpenSqlite; + final VoidCallback? onRequestLaunchDemo; + final VoidCallback? onRequestOpenTour; final void Function(ConnectionRow) onOpenConnection; @override @@ -362,12 +647,20 @@ class _MainContentSplitState extends State<_MainContentSplit> static const double _maxLeftWidth = 500; static const double _minWorkspaceWidth = 64; static const double _resizeHandleWidth = 6; - final ValueNotifier _leftPanelWidth = - ValueNotifier(kDefaultConnectionsPanelWidth); - late final QueryaDragSettleController _widthSettle; + + /// High-responsiveness critically damped spring for fluid sidebar toggling (~0.22s). + static const SpringDescription _sidebarSpring = SpringDescription( + mass: 1.0, + stiffness: 580.0, + damping: 48.0, + ); + + late final QueryaSpringController _widthSpring; late final ConnectionsPanelWidthPersist _widthPersist; VoidCallback? _persistWhenSettled; double _lastMaxWidth = 1200; + double _lastExpandedWidth = kDefaultConnectionsPanelWidth; + bool _sidebarVisible = true; @override void initState() { @@ -375,29 +668,42 @@ class _MainContentSplitState extends State<_MainContentSplit> _widthPersist = ConnectionsPanelWidthPersist( write: AppSettings.instance.setConnectionsPanelWidth, ); - _widthSettle = QueryaDragSettleController( + _widthSpring = QueryaSpringController( vsync: this, value: kDefaultConnectionsPanelWidth, - maxSettleVelocity: 1200, + spring: _sidebarSpring, + cubicDuration: const Duration(milliseconds: 160), + cubicCurve: Curves.easeOutCubic, ); - _widthSettle.addListener(_onWidthSettle); - unawaited(_restoreConnectionsPanelWidth()); + _widthSpring.addListener(_onWidthSpringChanged); + unawaited(_restoreState()); } - Future _restoreConnectionsPanelWidth() async { + Future _restoreState() async { final width = await AppSettings.instance.getConnectionsPanelWidth(); + final visible = await AppSettings.instance.getSidebarVisible(); if (!mounted) return; final clamped = _clampLeftWidth(width, _lastMaxWidth); - _widthSettle.jumpTo(clamped); - _leftPanelWidth.value = clamped; + _lastExpandedWidth = clamped; + _sidebarVisible = visible; + widget.onSidebarVisibilityChanged?.call(visible); + final targetW = visible ? clamped : 0.0; + _widthSpring.jumpTo(targetW); + } + + void toggleSidebar({bool? visible}) { + final target = visible ?? !_sidebarVisible; + _sidebarVisible = target; + widget.onSidebarVisibilityChanged?.call(target); + _widthSpring.useSprings = QueryaSpring.springsEnabled(context); + final targetWidth = target ? _lastExpandedWidth : 0.0; + _widthSpring.animateTo(targetWidth); + unawaited(AppSettings.instance.setSidebarVisible(target)); } - void _onWidthSettle() { - final clamped = _clampLeftWidth(_widthSettle.value, _lastMaxWidth); - _leftPanelWidth.value = clamped; - if (!_widthSettle.isSettling && - (clamped - _widthSettle.value).abs() > 0.5) { - _widthSettle.jumpTo(clamped); + void _onWidthSpringChanged() { + if (mounted) { + setState(() {}); } } @@ -405,18 +711,20 @@ class _MainContentSplitState extends State<_MainContentSplit> _widthPersist.markDirty(); final pending = _persistWhenSettled; if (pending != null) { - _widthSettle.removeListener(pending); + _widthSpring.removeListener(pending); } void listener() { - if (_widthSettle.isSettling) return; - _widthSettle.removeListener(listener); + if (_widthSpring.isAnimating) return; + _widthSpring.removeListener(listener); _persistWhenSettled = null; - unawaited(_widthPersist.persist(_leftPanelWidth.value)); + if (_sidebarVisible) { + unawaited(_widthPersist.persist(_lastExpandedWidth)); + } } _persistWhenSettled = listener; - if (_widthSettle.isSettling) { - _widthSettle.addListener(listener); + if (_widthSpring.isAnimating) { + _widthSpring.addListener(listener); } else { listener(); } @@ -426,13 +734,14 @@ class _MainContentSplitState extends State<_MainContentSplit> void dispose() { final pending = _persistWhenSettled; if (pending != null) { - _widthSettle.removeListener(pending); + _widthSpring.removeListener(pending); _persistWhenSettled = null; } - _widthPersist.disposeFlush(_leftPanelWidth.value); - _widthSettle.removeListener(_onWidthSettle); - _widthSettle.dispose(); - _leftPanelWidth.dispose(); + if (_sidebarVisible) { + _widthPersist.disposeFlush(_lastExpandedWidth); + } + _widthSpring.removeListener(_onWidthSpringChanged); + _widthSpring.dispose(); super.dispose(); } @@ -448,60 +757,126 @@ class _MainContentSplitState extends State<_MainContentSplit> return LayoutBuilder( builder: (context, constraints) { _lastMaxWidth = constraints.maxWidth; + final currentW = _widthSpring.value.clamp(0.0, constraints.maxWidth); + final isFullyCollapsed = currentW <= 0.001 && !_widthSpring.isAnimating; + final progress = _lastExpandedWidth > 0 + ? (currentW / _lastExpandedWidth).clamp(0.0, 1.0) + : 0.0; + final currentHandleW = + (progress * _resizeHandleWidth).clamp(0.0, _resizeHandleWidth); + final handleOpacity = (progress * 5.0).clamp(0.0, 1.0); + final contentOpacity = (progress * 2.2).clamp(0.0, 1.0); + final parallaxOffset = (progress - 1.0) * 36.0; + return Row( children: [ - ValueListenableBuilder( - valueListenable: _leftPanelWidth, - builder: (context, rawWidth, connectionsPanel) { - final leftW = _clampLeftWidth(rawWidth, constraints.maxWidth); - return SizedBox(width: leftW, child: connectionsPanel); - }, - child: material.RepaintBoundary( - child: _ConnectionsPanelSlot( - connectionsPanelKey: widget.connectionsPanelKey, - workspace: widget.workspace, - onConnectionSelected: widget.onConnectionSelected, - onRedisDatabaseSelected: widget.onRedisDatabaseSelected, - onMongoDBDatabaseSelected: widget.onMongoDBDatabaseSelected, - onPostgresObjectSelected: widget.onPostgresObjectSelected, - onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - onMysqlObjectSelected: widget.onMysqlObjectSelected, - onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, - onSqliteObjectSelected: widget.onSqliteObjectSelected, - onSqliteOpenSqlWorkspace: widget.onSqliteOpenSqlWorkspace, - onExtensionObjectSelected: widget.onExtensionObjectSelected, + if (!isFullyCollapsed) + ClipRect( + child: SizedBox( + width: currentW, + child: OverflowBox( + minWidth: _lastExpandedWidth, + maxWidth: _lastExpandedWidth, + alignment: Alignment.topLeft, + child: Transform.translate( + offset: Offset(parallaxOffset, 0), + child: Opacity( + opacity: contentOpacity, + child: material.RepaintBoundary( + child: _ConnectionsPanelSlot( + connectionsPanelKey: widget.connectionsPanelKey, + workspace: widget.workspace, + onConnectionSelected: widget.onConnectionSelected, + onRedisDatabaseSelected: + widget.onRedisDatabaseSelected, + onMongoDBDatabaseSelected: + widget.onMongoDBDatabaseSelected, + onPostgresObjectSelected: + widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: + widget.onPostgresOpenSqlWorkspace, + onMysqlObjectSelected: widget.onMysqlObjectSelected, + onMysqlOpenSqlWorkspace: + widget.onMysqlOpenSqlWorkspace, + onSqliteObjectSelected: + widget.onSqliteObjectSelected, + onSqliteOpenSqlWorkspace: + widget.onSqliteOpenSqlWorkspace, + onExtensionObjectSelected: + widget.onExtensionObjectSelected, + ), + ), + ), + ), + ), ), ), - ), - QueryaSplitHandle( - key: const Key('main_content_resize_handle'), - axis: Axis.horizontal, - semanticsLabel: 'Resize connections and workspace panes', - onDragDelta: (dx) { - final ml = constraints.maxWidth - - _resizeHandleWidth - - _minWorkspaceWidth; - if (ml <= 0) return; - _widthSettle.dragTo( - _clampLeftWidth( - _widthSettle.value + dx, - constraints.maxWidth, + if (!isFullyCollapsed) + SizedBox( + width: currentHandleW, + child: ClipRect( + child: OverflowBox( + minWidth: _resizeHandleWidth, + maxWidth: _resizeHandleWidth, + alignment: Alignment.center, + child: Opacity( + opacity: handleOpacity, + child: IgnorePointer( + ignoring: currentW <= 20.0 || !_sidebarVisible, + child: QueryaSplitHandle( + key: const Key('main_content_resize_handle'), + axis: Axis.horizontal, + semanticsLabel: + 'Resize connections and workspace panes', + onDragDelta: (dx) { + final ml = constraints.maxWidth - + _resizeHandleWidth - + _minWorkspaceWidth; + if (ml <= 0) return; + final nextW = _clampLeftWidth( + _widthSpring.value + dx, + constraints.maxWidth, + ); + _lastExpandedWidth = nextW; + _widthSpring.jumpTo(nextW); + }, + onDiscreteResize: () => _widthPersist + .onDiscreteResize(() => _lastExpandedWidth), + onDragEnd: (details) { + _widthPersist.cancelDiscreteTimer(); + final velocity = details.primaryVelocity ?? + details.velocity.pixelsPerSecond.dx; + final useSprings = + QueryaSpring.springsEnabled(context); + _widthSpring.useSprings = useSprings; + if (_widthSpring.value < 100 && velocity < -50) { + _sidebarVisible = false; + widget.onSidebarVisibilityChanged?.call(false); + _widthSpring.animateTo(0.0, velocity: velocity); + unawaited( + AppSettings.instance.setSidebarVisible(false)); + } else { + _sidebarVisible = true; + widget.onSidebarVisibilityChanged?.call(true); + _lastExpandedWidth = _clampLeftWidth( + _widthSpring.value, + constraints.maxWidth, + ); + _widthSpring.animateTo( + _lastExpandedWidth, + velocity: velocity, + ); + unawaited( + AppSettings.instance.setSidebarVisible(true)); + _schedulePersistAfterSettle(); + } + }, + ), + ), + ), ), - ); - }, - onDiscreteResize: () => _widthPersist - .onDiscreteResize(() => _leftPanelWidth.value), - onDragEnd: (details) { - _widthPersist.cancelDiscreteTimer(); - final velocity = details.primaryVelocity ?? - details.velocity.pixelsPerSecond.dx; - _widthSettle.settle( - velocity: velocity, - useSprings: QueryaSpring.springsEnabled(context), - ); - _schedulePersistAfterSettle(); - }, - ), + ), + ), Expanded( child: material.RepaintBoundary( child: ValueListenableBuilder( @@ -526,6 +901,8 @@ class _MainContentSplitState extends State<_MainContentSplit> onRequestNewConnectionFromUrl: widget.onRequestNewConnectionFromUrl, onRequestOpenSqlite: widget.onRequestOpenSqlite, + onRequestLaunchDemo: widget.onRequestLaunchDemo, + onRequestOpenTour: widget.onRequestOpenTour, onOpenConnection: widget.onOpenConnection, ); }, diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index 7625db02..d30252b5 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -29,6 +29,10 @@ class QueryaWindowTitleBar extends StatelessWidget { this.onDisconnectOthers, this.isReadOnly = false, this.onReadOnlyChanged, + this.isSidebarVisible = true, + this.onToggleSidebar, + this.onOpenWelcomeTour, + this.onGoHome, }); final Future Function() onNewDatabaseConnection; @@ -41,6 +45,10 @@ class QueryaWindowTitleBar extends StatelessWidget { final VoidCallback? onDisconnectOthers; final bool isReadOnly; final VoidCallback? onReadOnlyChanged; + final bool isSidebarVisible; + final VoidCallback? onToggleSidebar; + final VoidCallback? onOpenWelcomeTour; + final VoidCallback? onGoHome; @visibleForTesting static Color titleBarBackground(BuildContext context) => @@ -84,36 +92,59 @@ class QueryaWindowTitleBar extends StatelessWidget { ); } + /// Allows headless widget tests to render without native bitsdojo symbols. + @visibleForTesting + static bool useNativeWindowChrome = true; + @override material.Widget build(material.BuildContext context) { final wb = context.workbench; final buttonColors = windowButtonColors(context); final closeButtonColors = QueryaWindowTitleBar.closeButtonColors(context); - return material.Container( - height: context.scaled(40), - color: titleBarBackground(context), - child: WindowTitleBarBox( - child: Row( - children: [ - Expanded( - child: MoveWindow( - child: Row( - children: [ - SizedBox( - width: QueryaWindowTitleBar.titleBarLeadingInset( - isMacOS: Platform.isMacOS, - scale: context.scaled, - ), - ), - material.Icon( - material.Icons.storage_rounded, - size: 18, - color: wb.accent, - ), + final rowContent = Row( + children: [ + SizedBox( + width: QueryaWindowTitleBar.titleBarLeadingInset( + isMacOS: Platform.isMacOS, + scale: context.scaled, + ), + ), + material.Icon( + material.Icons.storage_rounded, + size: 18, + color: wb.accent, + ), const Gap(8), const Text('Querya').semiBold().small(), - const Gap(24), + const Gap(12), + if (onToggleSidebar != null) ...[ + material.Tooltip( + message: + 'Toggle Sidebar (${Platform.isMacOS ? "Cmd+B" : "Ctrl+B"})', + child: material.IconButton( + key: const Key('title_bar_toggle_sidebar_button'), + icon: material.Icon( + isSidebarVisible + ? material.Icons.view_sidebar_rounded + : material.Icons.view_sidebar_outlined, + size: 16, + color: isSidebarVisible + ? Theme.of(context).colorScheme.foreground + : wb.mutedForeground, + ), + splashRadius: 14, + padding: const material.EdgeInsets.all(4), + constraints: const material.BoxConstraints( + minWidth: 26, + minHeight: 26, + ), + onPressed: onToggleSidebar, + ), + ), + const Gap(12), + ] else + const Gap(12), Menubar( border: false, popoverOffset: const Offset(0, 8), @@ -121,6 +152,11 @@ class QueryaWindowTitleBar extends StatelessWidget { MenuButton( subMenu: [ MenuButton( + trailing: Text( + Platform.isMacOS + ? 'Cmd+Shift+N' + : 'Ctrl+Shift+N', + ).xSmall().muted(), onPressed: (ctx) { Actions.maybeInvoke( FocusManager @@ -129,8 +165,11 @@ class QueryaWindowTitleBar extends StatelessWidget { const NewSqlIntent(), ); }, - child: const Text('New')), + child: const Text('New Query')), MenuButton( + trailing: Text( + Platform.isMacOS ? 'Cmd+O' : 'Ctrl+O', + ).xSmall().muted(), onPressed: (ctx) { Actions.maybeInvoke( FocusManager @@ -141,6 +180,9 @@ class QueryaWindowTitleBar extends StatelessWidget { }, child: const Text('Open...')), MenuButton( + trailing: Text( + Platform.isMacOS ? 'Cmd+S' : 'Ctrl+S', + ).xSmall().muted(), onPressed: (ctx) { Actions.maybeInvoke( FocusManager @@ -150,6 +192,20 @@ class QueryaWindowTitleBar extends StatelessWidget { ); }, child: const Text('Save')), + if (activeConnection != null && onGoHome != null) ...[ + const MenuDivider(), + MenuButton( + leading: const material.Icon( + material.Icons.home_rounded, + size: 18, + ), + trailing: Text( + Platform.isMacOS ? 'Cmd+Shift+0' : 'Ctrl+Shift+0', + ).xSmall().muted(), + onPressed: (_) => onGoHome?.call(), + child: const Text('Close Workspace (Home)'), + ), + ], const MenuDivider(), MenuButton( onPressed: (_) => appWindow.close(), @@ -180,14 +236,59 @@ class QueryaWindowTitleBar extends StatelessWidget { ], child: const Text('Edit'), ), + MenuButton( + subMenu: [ + MenuButton( + leading: const material.Icon( + material.Icons.view_sidebar_rounded, + size: 18, + ), + trailing: Text( + Platform.isMacOS ? 'Cmd+B' : 'Ctrl+B', + ).xSmall().muted(), + onPressed: (_) => onToggleSidebar?.call(), + child: const Text('Toggle Sidebar'), + ), + const MenuDivider(), + MenuButton( + leading: const material.Icon( + material.Icons.auto_awesome_rounded, + size: 18, + ), + trailing: Text( + Platform.isMacOS ? 'Cmd+Shift+H' : 'F1', + ).xSmall().muted(), + onPressed: (_) => onOpenWelcomeTour?.call(), + child: const Text('Welcome & Tutorial…'), + ), + if (activeConnection != null && onGoHome != null) ...[ + const MenuDivider(), + MenuButton( + leading: const material.Icon( + material.Icons.home_rounded, + size: 18, + ), + trailing: Text( + Platform.isMacOS + ? 'Cmd+Shift+0' + : 'Ctrl+Shift+0', + ).xSmall().muted(), + onPressed: (_) => onGoHome?.call(), + child: const Text('Home / Start Screen'), + ), + ], + ], + child: const Text('View'), + ), MenuButton( subMenu: [ MenuButton( leading: const material.Icon( material.Icons.add_link_rounded, size: 18), - trailing: - const Text('Shift+Ctrl+N').xSmall().muted(), + trailing: Text( + Platform.isMacOS ? 'Cmd+N' : 'Ctrl+N', + ).xSmall().muted(), onPressed: (_) => onNewDatabaseConnection(), child: const Text('New Database Connection'), ), @@ -257,6 +358,18 @@ class QueryaWindowTitleBar extends StatelessWidget { ), MenuButton( subMenu: [ + MenuButton( + leading: const material.Icon( + material.Icons.auto_awesome_rounded, + size: 18, + ), + trailing: Text( + Platform.isMacOS ? 'Cmd+Shift+H' : 'F1', + ).xSmall().muted(), + onPressed: (_) => onOpenWelcomeTour?.call(), + child: const Text('Welcome & Quick Tour…'), + ), + const MenuDivider(), MenuButton( onPressed: (ctx) => showAboutDialog(ctx), child: const Text('About')), @@ -273,27 +386,38 @@ class QueryaWindowTitleBar extends StatelessWidget { ], ), ], - ), - ), - ), - Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - if (activeConnection != null && isReadOnly) - const QueryaReadOnlyBadge(), - UpdateAvailableBadge(controller: UpdateController.instance), - if (QueryaWindowTitleBar.showBitsdojoWindowButtons( + ); + + final inner = Row( + children: [ + Expanded( + child: useNativeWindowChrome + ? MoveWindow(child: rowContent) + : rowContent, + ), + Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + if (activeConnection != null && isReadOnly) + const QueryaReadOnlyBadge(), + UpdateAvailableBadge(controller: UpdateController.instance), + if (useNativeWindowChrome && + QueryaWindowTitleBar.showBitsdojoWindowButtons( isMacOS: Platform.isMacOS, )) ...[ - MinimizeWindowButton(colors: buttonColors), - MaximizeWindowButton(colors: buttonColors), - CloseWindowButton(colors: closeButtonColors), - ], - ], - ) + MinimizeWindowButton(colors: buttonColors), + MaximizeWindowButton(colors: buttonColors), + CloseWindowButton(colors: closeButtonColors), + ], ], - ), - ), + ) + ], + ); + + return material.Container( + height: context.scaled(40), + color: titleBarBackground(context), + child: useNativeWindowChrome ? WindowTitleBarBox(child: inner) : inner, ); } } diff --git a/lib/features/main_screen/result_grid_view.dart b/lib/features/main_screen/result_grid_view.dart index f56c3779..12acce16 100644 --- a/lib/features/main_screen/result_grid_view.dart +++ b/lib/features/main_screen/result_grid_view.dart @@ -1,7 +1,11 @@ import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart' show Clipboard, ClipboardData; +import 'package:flutter/services.dart' + show Clipboard, ClipboardData, HardwareKeyboard, LogicalKeyboardKey; import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/core/ui/querya_tooltip.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/main_screen/grid_cell_editor.dart'; +import 'package:querya_desktop/features/main_screen/grid_cell_popover_inspector.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; /// Layout metrics for [VirtualResultGrid]. @@ -168,16 +172,182 @@ ResultGridColumnWindow computeVisibleColumnWindow({ ); } -/// Virtualized read-only grid for SQL query results (rows + columns). +/// Coordinate of a cell in [VirtualResultGrid]. +@immutable +class ResultGridCellCoordinate { + const ResultGridCellCoordinate(this.row, this.column); + + final int row; + final int column; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ResultGridCellCoordinate && + row == other.row && + column == other.column; + + @override + int get hashCode => Object.hash(row, column); +} + +/// Rectangular cell selection range in [VirtualResultGrid]. +@immutable +class ResultGridSelection { + const ResultGridSelection({ + required this.startRow, + required this.startColumn, + required this.endRow, + required this.endColumn, + }); + + factory ResultGridSelection.fromPoints({ + required ResultGridCellCoordinate anchor, + required ResultGridCellCoordinate focus, + }) { + final minR = anchor.row < focus.row ? anchor.row : focus.row; + final maxR = anchor.row > focus.row ? anchor.row : focus.row; + final minC = anchor.column < focus.column ? anchor.column : focus.column; + final maxC = anchor.column > focus.column ? anchor.column : focus.column; + return ResultGridSelection( + startRow: minR, + startColumn: minC, + endRow: maxR, + endColumn: maxC, + ); + } + + final int startRow; + final int startColumn; + final int endRow; + final int endColumn; + + bool contains(int row, int column) => + row >= startRow && + row <= endRow && + column >= startColumn && + column <= endColumn; + + int get rowCount => endRow - startRow + 1; + int get columnCount => endColumn - startColumn + 1; + + /// Formats selected cell values as a Tab-Separated Values (TSV) string. + String toTsv(List> rows) { + if (rows.isEmpty) return ''; + final buffer = StringBuffer(); + for (var r = startRow; r <= endRow; r++) { + if (r < 0 || r >= rows.length) continue; + final rowData = rows[r]; + final cells = []; + for (var c = startColumn; c <= endColumn; c++) { + cells.add(c < rowData.length ? rowData[c] : ''); + } + buffer.writeln(cells.join('\t')); + } + return buffer.toString().trimRight(); + } + + /// Formats selected cell values as a CSV string. + String toCsv(List> rows) { + if (rows.isEmpty) return ''; + final buffer = StringBuffer(); + for (var r = startRow; r <= endRow; r++) { + if (r < 0 || r >= rows.length) continue; + final rowData = rows[r]; + final cells = []; + for (var c = startColumn; c <= endColumn; c++) { + final val = c < rowData.length ? rowData[c] : ''; + if (val.contains(',') || val.contains('"') || val.contains('\n')) { + cells.add('"${val.replaceAll('"', '""')}"'); + } else { + cells.add(val); + } + } + buffer.writeln(cells.join(',')); + } + return buffer.toString().trimRight(); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ResultGridSelection && + startRow == other.startRow && + startColumn == other.startColumn && + endRow == other.endRow && + endColumn == other.endColumn; + + @override + int get hashCode => Object.hash(startRow, startColumn, endRow, endColumn); +} + +/// Sorting direction for [VirtualResultGrid]. +enum ResultGridSortOrder { + ascending, + descending, +} + +/// Sorts rows by the specified column index with natural numeric / temporal / lexicographic comparison. +List> sortResultGridRows({ + required List> rows, + required int columnIndex, + required ResultGridSortOrder order, +}) { + if (rows.isEmpty || columnIndex < 0) return rows; + final sorted = List>.from(rows); + + sorted.sort((a, b) { + final valA = columnIndex < a.length ? a[columnIndex] : ''; + final valB = columnIndex < b.length ? b[columnIndex] : ''; + + final isNullA = valA == 'NULL' || valA.isEmpty; + final isNullB = valB == 'NULL' || valB.isEmpty; + if (isNullA && isNullB) return 0; + if (isNullA) return 1; + if (isNullB) return -1; + + final numA = num.tryParse(valA); + final numB = num.tryParse(valB); + int cmp; + if (numA != null && numB != null) { + cmp = numA.compareTo(numB); + } else { + final dtA = DateTime.tryParse(valA); + final dtB = DateTime.tryParse(valB); + if (dtA != null && dtB != null) { + cmp = dtA.compareTo(dtB); + } else { + cmp = valA.toLowerCase().compareTo(valB.toLowerCase()); + if (cmp == 0) { + cmp = valA.compareTo(valB); + } + } + } + + return order == ResultGridSortOrder.ascending ? cmp : -cmp; + }); + + return sorted; +} + +/// Virtualized read-only or interactive grid for SQL query results (rows + columns). class VirtualResultGrid extends material.StatefulWidget { const VirtualResultGrid({ super.key, required this.columns, required this.rows, + this.stagingBuffer, + this.onRowSelected, + this.onSelectionValuesChanged, + this.onCellFocused, }); final List columns; final List> rows; + final DataGridStagingBuffer? stagingBuffer; + final material.ValueChanged? onRowSelected; + final material.ValueChanged>? onSelectionValuesChanged; + final void Function(String columnName, String cellValue, int rowIndex)? onCellFocused; @override material.State createState() => _VirtualResultGridState(); @@ -186,16 +356,36 @@ class VirtualResultGrid extends material.StatefulWidget { class _VirtualResultGridState extends material.State { final _horizontalController = material.ScrollController(); final _verticalController = material.ScrollController(); + final _focusNode = material.FocusNode(); List _columnWidths = const []; List _columnOffsets = const [0]; bool _widthsNeedUpdate = true; + bool _userHasResized = false; double _scrollOffset = 0; + int? _sortColumnIndex; + ResultGridSortOrder? _sortOrder; + List> _sortedRows = const []; + + ResultGridCellCoordinate? _selectionAnchor; + ResultGridSelection? _selection; + ResultGridCellCoordinate? _editingCell; + @override void initState() { super.initState(); _horizontalController.addListener(_onHorizontalScroll); + widget.stagingBuffer?.addListener(_onStagingBufferChanged); + _updateSortedRows(); + } + + void _onStagingBufferChanged() { + if (!mounted) return; + setState(() { + _updateSortedRows(); + _widthsNeedUpdate = true; + }); } @override @@ -207,19 +397,156 @@ class _VirtualResultGridState extends material.State { @override void didUpdateWidget(VirtualResultGrid oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.columns != widget.columns || oldWidget.rows != widget.rows) { + if (oldWidget.stagingBuffer != widget.stagingBuffer) { + oldWidget.stagingBuffer?.removeListener(_onStagingBufferChanged); + widget.stagingBuffer?.addListener(_onStagingBufferChanged); + } + if (oldWidget.columns != widget.columns || + oldWidget.rows != widget.rows || + oldWidget.stagingBuffer != widget.stagingBuffer) { _widthsNeedUpdate = true; + if (oldWidget.columns != widget.columns) { + _userHasResized = false; + _sortColumnIndex = null; + _sortOrder = null; + _selectionAnchor = null; + _selection = null; + _editingCell = null; + widget.onRowSelected?.call(null); + } + _updateSortedRows(); } } @override void dispose() { + widget.stagingBuffer?.removeListener(_onStagingBufferChanged); _horizontalController.removeListener(_onHorizontalScroll); _horizontalController.dispose(); _verticalController.dispose(); + _focusNode.dispose(); super.dispose(); } + void _startEditing(int row, int column) { + if (widget.stagingBuffer == null) return; + if (row < 0 || row >= _sortedRows.length || column < 0 || column >= widget.columns.length) return; + setState(() { + _editingCell = ResultGridCellCoordinate(row, column); + _selectionAnchor = _editingCell; + _selection = ResultGridSelection( + startRow: row, + startColumn: column, + endRow: row, + endColumn: column, + ); + }); + } + + void _commitEdit( + int row, + int column, + String value, { + bool moveNextCol = false, + bool movePrevCol = false, + bool moveNextRow = false, + bool movePrevRow = false, + }) { + if (widget.stagingBuffer != null) { + widget.stagingBuffer!.setCell(row, column, value); + } + setState(() { + if (moveNextCol) { + if (column + 1 < widget.columns.length) { + _editingCell = ResultGridCellCoordinate(row, column + 1); + _selection = ResultGridSelection( + startRow: row, + startColumn: column + 1, + endRow: row, + endColumn: column + 1, + ); + } else if (row + 1 < _sortedRows.length) { + _editingCell = ResultGridCellCoordinate(row + 1, 0); + _selection = ResultGridSelection( + startRow: row + 1, + startColumn: 0, + endRow: row + 1, + endColumn: 0, + ); + } else { + _editingCell = null; + } + } else if (movePrevCol) { + if (column > 0) { + _editingCell = ResultGridCellCoordinate(row, column - 1); + _selection = ResultGridSelection( + startRow: row, + startColumn: column - 1, + endRow: row, + endColumn: column - 1, + ); + } else if (row > 0) { + _editingCell = ResultGridCellCoordinate(row - 1, widget.columns.length - 1); + _selection = ResultGridSelection( + startRow: row - 1, + startColumn: widget.columns.length - 1, + endRow: row - 1, + endColumn: widget.columns.length - 1, + ); + } else { + _editingCell = null; + } + } else if (moveNextRow) { + if (row + 1 < _sortedRows.length) { + _editingCell = ResultGridCellCoordinate(row + 1, column); + _selection = ResultGridSelection( + startRow: row + 1, + startColumn: column, + endRow: row + 1, + endColumn: column, + ); + } else { + _editingCell = null; + } + } else if (movePrevRow) { + if (row > 0) { + _editingCell = ResultGridCellCoordinate(row - 1, column); + _selection = ResultGridSelection( + startRow: row - 1, + startColumn: column, + endRow: row - 1, + endColumn: column, + ); + } else { + _editingCell = null; + } + } else { + _editingCell = null; + } + }); + } + + void _cancelEdit() { + setState(() { + _editingCell = null; + }); + } + + Future _openInspector(int row, int column) async { + if (row < 0 || row >= _sortedRows.length || column < 0 || column >= widget.columns.length) return; + final colName = widget.columns[column]; + final currentVal = column < _sortedRows[row].length ? _sortedRows[row][column] : ''; + final result = await showGridCellInspectorDialog( + context: context, + columnName: colName, + initialValue: currentVal, + rowIndex: row, + ); + if (result != null && widget.stagingBuffer != null) { + widget.stagingBuffer!.setCell(row, column, result); + } + } + void _onHorizontalScroll() { if (!_horizontalController.hasClients) return; final offset = _horizontalController.offset; @@ -227,10 +554,143 @@ class _VirtualResultGridState extends material.State { setState(() => _scrollOffset = offset); } + void _onColumnResize(int index, double delta) { + if (index < 0 || index >= _columnWidths.length) return; + setState(() { + _userHasResized = true; + final minWidth = context.scaled(ResultGridMetrics.minColumnWidth); + final maxWidth = context.scaled(ResultGridMetrics.maxColumnWidth * 3); + final newWidth = (_columnWidths[index] + delta).clamp(minWidth, maxWidth); + _columnWidths = List.from(_columnWidths); + _columnWidths[index] = newWidth; + _columnOffsets = computeResultGridColumnOffsets(_columnWidths); + _widthsNeedUpdate = false; + }); + } + + void _toggleSort(int columnIndex) { + if (columnIndex < 0 || columnIndex >= widget.columns.length) return; + setState(() { + if (_sortColumnIndex == columnIndex) { + if (_sortOrder == ResultGridSortOrder.ascending) { + _sortOrder = ResultGridSortOrder.descending; + } else { + _sortColumnIndex = null; + _sortOrder = null; + } + } else { + _sortColumnIndex = columnIndex; + _sortOrder = ResultGridSortOrder.ascending; + } + _updateSortedRows(); + }); + } + + List> get _baseRows => + widget.stagingBuffer?.effectiveRows ?? widget.rows; + + void _updateSortedRows() { + final rows = _baseRows; + if (_sortColumnIndex == null || _sortOrder == null) { + _sortedRows = rows; + } else { + _sortedRows = sortResultGridRows( + rows: rows, + columnIndex: _sortColumnIndex!, + order: _sortOrder!, + ); + } + } + + void _notifySelectionAndFocus() { + if (widget.onSelectionValuesChanged != null) { + if (_selection == null) { + widget.onSelectionValuesChanged!(const []); + } else { + final rows = _sortedRows; + final values = []; + for (var r = _selection!.startRow; r <= _selection!.endRow; r++) { + if (r >= 0 && r < rows.length) { + for (var c = _selection!.startColumn; c <= _selection!.endColumn; c++) { + if (c >= 0 && c < rows[r].length) { + values.add(rows[r][c]); + } + } + } + } + widget.onSelectionValuesChanged!(values); + } + } + + if (widget.onCellFocused != null && _selectionAnchor != null) { + final r = _selectionAnchor!.row; + final c = _selectionAnchor!.column; + final rows = _sortedRows; + if (r >= 0 && r < rows.length && c >= 0 && c < widget.columns.length) { + final colName = widget.columns[c]; + final val = c < rows[r].length ? rows[r][c] : ''; + widget.onCellFocused!(colName, val, r); + } + } + } + + void _onCellTap(int row, int column, {bool isShift = false}) { + _focusNode.requestFocus(); + widget.onRowSelected?.call(row); + setState(() { + final coord = ResultGridCellCoordinate(row, column); + if (isShift && _selectionAnchor != null) { + _selection = ResultGridSelection.fromPoints( + anchor: _selectionAnchor!, + focus: coord, + ); + } else { + _selectionAnchor = coord; + _selection = ResultGridSelection( + startRow: row, + startColumn: column, + endRow: row, + endColumn: column, + ); + } + }); + _notifySelectionAndFocus(); + } + + void _onCellSecondaryTap(int row, int column) { + _focusNode.requestFocus(); + widget.onRowSelected?.call(row); + if (_selection != null && _selection!.contains(row, column)) { + _copySelection(); + } else { + setState(() { + _selectionAnchor = ResultGridCellCoordinate(row, column); + _selection = ResultGridSelection( + startRow: row, + startColumn: column, + endRow: row, + endColumn: column, + ); + }); + _notifySelectionAndFocus(); + _copySelection(); + } + } + + void _copySelection({bool asCsv = false}) { + if (_selection == null) return; + final text = asCsv + ? _selection!.toCsv(_sortedRows) + : _selection!.toTsv(_sortedRows); + if (text.isNotEmpty) { + Clipboard.setData(ClipboardData(text: text)); + } + } + List _computeColumnWidths() { return computeResultGridColumnWidths( columns: widget.columns, - rows: widget.rows, + rows: _baseRows, minWidth: context.scaled(ResultGridMetrics.minColumnWidth), maxWidth: context.scaled(ResultGridMetrics.maxColumnWidth), ); @@ -264,7 +724,7 @@ class _VirtualResultGridState extends material.State { @override material.Widget build(material.BuildContext context) { - if (_widthsNeedUpdate) { + if (_widthsNeedUpdate && !_userHasResized) { _columnWidths = _computeColumnWidths(); _columnOffsets = computeResultGridColumnOffsets(_columnWidths); _widthsNeedUpdate = false; @@ -273,75 +733,201 @@ class _VirtualResultGridState extends material.State { final rowHeight = _scaledRowHeight(context); final headerHeight = _scaledHeaderHeight(context); - return material.RepaintBoundary( - child: material.LayoutBuilder( - builder: (context, constraints) { - final availableWidth = constraints.maxWidth; - - var displayWidths = _columnWidths; - var tableWidth = _tableWidth; - if (tableWidth < availableWidth && _columnWidths.isNotEmpty) { - final extraPerCol = - (availableWidth - tableWidth) / _columnWidths.length; - displayWidths = [for (final w in _columnWidths) w + extraPerCol]; - tableWidth = availableWidth; - } else if (tableWidth > availableWidth) { - tableWidth = _tableWidth; + return material.CallbackShortcuts( + bindings: { + const material.SingleActivator( + LogicalKeyboardKey.keyC, + meta: true, + ): () => _copySelection(), + const material.SingleActivator( + LogicalKeyboardKey.keyC, + control: true, + ): () => _copySelection(), + const material.SingleActivator( + LogicalKeyboardKey.insert, + control: true, + ): () => widget.stagingBuffer?.addRow(), + const material.SingleActivator( + LogicalKeyboardKey.keyN, + meta: true, + ): () { + if (widget.stagingBuffer != null) { + widget.stagingBuffer!.addRow(); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.delete, + control: true, + ): () { + if (widget.stagingBuffer != null && _selection != null) { + widget.stagingBuffer!.toggleDeleteRow(_selection!.startRow); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.backspace, + meta: true, + ): () { + if (widget.stagingBuffer != null && _selection != null) { + widget.stagingBuffer!.toggleDeleteRow(_selection!.startRow); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.keyZ, + control: true, + ): () { + if (widget.stagingBuffer != null && _selection != null) { + widget.stagingBuffer!.revertRow(_selection!.startRow); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.keyZ, + meta: true, + ): () { + if (widget.stagingBuffer != null && _selection != null) { + widget.stagingBuffer!.revertRow(_selection!.startRow); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.escape, + ): () { + if (_editingCell != null) { + _cancelEdit(); } else { - tableWidth = availableWidth; + widget.onRowSelected?.call(null); + setState(() { + _selection = null; + _selectionAnchor = null; + }); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.f2, + ): () { + if (_selection != null && _editingCell == null) { + _startEditing(_selection!.startRow, _selection!.startColumn); } + }, + const material.SingleActivator( + LogicalKeyboardKey.enter, + ): () { + if (_selection != null && _editingCell == null) { + _startEditing(_selection!.startRow, _selection!.startColumn); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.numpadEnter, + ): () { + if (_selection != null && _editingCell == null) { + _startEditing(_selection!.startRow, _selection!.startColumn); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.space, + ): () { + if (_selection != null && _editingCell == null) { + _openInspector(_selection!.startRow, _selection!.startColumn); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.keyN, + alt: true, + ): () { + if (_selection != null && widget.stagingBuffer != null) { + widget.stagingBuffer!.setCell( + _selection!.startRow, + _selection!.startColumn, + 'NULL', + ); + } + }, + }, + child: material.Focus( + focusNode: _focusNode, + child: material.RepaintBoundary( + child: material.LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth; - final window = _columnWindow(displayWidths, availableWidth); - - return material.Scrollbar( - controller: _horizontalController, - thumbVisibility: true, - notificationPredicate: (_) => true, - child: material.SingleChildScrollView( - controller: _horizontalController, - scrollDirection: material.Axis.horizontal, - child: material.SizedBox( - width: tableWidth, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _HeaderRow( - columns: widget.columns, - columnWidths: displayWidths, - window: window, - height: headerHeight, - colorScheme: cs, - ), - material.Expanded( - child: material.Scrollbar( - controller: _verticalController, - thumbVisibility: true, - child: material.ListView.builder( - controller: _verticalController, - itemCount: widget.rows.length, - itemExtent: rowHeight, - itemBuilder: (context, rowIndex) { - final row = widget.rows[rowIndex]; - final isEven = rowIndex.isEven; - return _DataRow( - key: ValueKey('result-row-$rowIndex'), - row: row, - columnWidths: displayWidths, - window: window, - height: rowHeight, - colorScheme: cs, - striped: !isEven, - ); - }, + var displayWidths = _columnWidths; + var tableWidth = _tableWidth; + if (!_userHasResized && + tableWidth < availableWidth && + _columnWidths.isNotEmpty) { + final extraPerCol = + (availableWidth - tableWidth) / _columnWidths.length; + displayWidths = [for (final w in _columnWidths) w + extraPerCol]; + tableWidth = availableWidth; + } else if (tableWidth < availableWidth) { + tableWidth = availableWidth; + } + + final window = _columnWindow(displayWidths, availableWidth); + + return material.Scrollbar( + controller: _horizontalController, + thumbVisibility: true, + notificationPredicate: (_) => true, + child: material.SingleChildScrollView( + controller: _horizontalController, + scrollDirection: material.Axis.horizontal, + child: material.SizedBox( + width: tableWidth, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _HeaderRow( + columns: widget.columns, + columnWidths: displayWidths, + window: window, + height: headerHeight, + colorScheme: cs, + sortColumnIndex: _sortColumnIndex, + sortOrder: _sortOrder, + onSortColumn: _toggleSort, + onResizeColumn: _onColumnResize, ), - ), + material.Expanded( + child: material.Scrollbar( + controller: _verticalController, + thumbVisibility: true, + child: material.ListView.builder( + controller: _verticalController, + itemCount: _sortedRows.length, + itemExtent: rowHeight, + itemBuilder: (context, rowIndex) { + final row = _sortedRows[rowIndex]; + final isEven = rowIndex.isEven; + return _DataRow( + key: ValueKey('result-row-$rowIndex'), + rowIndex: rowIndex, + row: row, + columnWidths: displayWidths, + window: window, + height: rowHeight, + colorScheme: cs, + striped: !isEven, + selection: _selection, + stagingBuffer: widget.stagingBuffer, + editingCell: _editingCell, + onCellTap: _onCellTap, + onCellDoubleTap: _startEditing, + onCellSecondaryTap: _onCellSecondaryTap, + onCommitEdit: _commitEdit, + onCancelEdit: _cancelEdit, + onOpenInspector: _openInspector, + ); + }, + ), + ), + ), + ], ), - ], + ), ), - ), - ), - ); - }, + ); + }, + ), + ), ), ); } @@ -354,6 +940,10 @@ class _HeaderRow extends material.StatelessWidget { required this.window, required this.height, required this.colorScheme, + this.sortColumnIndex, + this.sortOrder, + this.onSortColumn, + this.onResizeColumn, }); final List columns; @@ -361,6 +951,10 @@ class _HeaderRow extends material.StatelessWidget { final ResultGridColumnWindow window; final double height; final ColorScheme colorScheme; + final int? sortColumnIndex; + final ResultGridSortOrder? sortOrder; + final material.ValueChanged? onSortColumn; + final void Function(int index, double delta)? onResizeColumn; @override material.Widget build(material.BuildContext context) { @@ -379,11 +973,15 @@ class _HeaderRow extends material.StatelessWidget { if (window.leadingWidth > 0) material.SizedBox(width: window.leadingWidth), for (var i = window.first; i <= window.last; i++) - _GridCell( + _HeaderCell( text: columns[i], width: columnWidths[i], - isHeader: true, colorScheme: colorScheme, + sortOrder: sortColumnIndex == i ? sortOrder : null, + onSort: onSortColumn != null ? () => onSortColumn!(i) : null, + onResize: onResizeColumn != null + ? (delta) => onResizeColumn!(i, delta) + : null, ), if (window.trailingWidth > 0) material.SizedBox(width: window.trailingWidth), @@ -393,48 +991,190 @@ class _HeaderRow extends material.StatelessWidget { } } +class _HeaderCell extends material.StatelessWidget { + const _HeaderCell({ + required this.text, + required this.width, + required this.colorScheme, + this.sortOrder, + this.onSort, + this.onResize, + }); + + final String text; + final double width; + final ColorScheme colorScheme; + final ResultGridSortOrder? sortOrder; + final material.VoidCallback? onSort; + final material.ValueChanged? onResize; + + @override + material.Widget build(material.BuildContext context) { + final isSorted = sortOrder != null; + final style = material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.w600, + color: isSorted ? colorScheme.primary : colorScheme.foreground, + ); + + return material.Container( + width: width, + height: double.infinity, + decoration: material.BoxDecoration( + border: material.Border( + right: material.BorderSide( + color: colorScheme.border.withValues(alpha: 0.3), + ), + ), + ), + child: material.Stack( + clipBehavior: material.Clip.none, + children: [ + material.Positioned.fill( + child: material.MouseRegion( + cursor: onSort != null + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + child: material.GestureDetector( + behavior: material.HitTestBehavior.opaque, + onTap: onSort, + child: material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 10), + child: material.Row( + children: [ + material.Expanded( + child: material.Text( + text, + style: style, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + ), + ), + if (sortOrder != null) ...[ + const Gap(4), + material.Icon( + sortOrder == ResultGridSortOrder.ascending + ? material.Icons.arrow_upward_rounded + : material.Icons.arrow_downward_rounded, + size: 14, + color: colorScheme.primary, + ), + ], + ], + ), + ), + ), + ), + ), + if (onResize != null) + material.Positioned( + right: -4, + top: 0, + bottom: 0, + width: 10, + child: material.MouseRegion( + cursor: material.SystemMouseCursors.resizeColumn, + child: material.GestureDetector( + behavior: material.HitTestBehavior.translucent, + onHorizontalDragUpdate: (details) { + onResize!(details.delta.dx); + }, + ), + ), + ), + ], + ), + ); + } +} + class _DataRow extends material.StatelessWidget { const _DataRow({ super.key, + required this.rowIndex, required this.row, required this.columnWidths, required this.window, required this.height, required this.colorScheme, required this.striped, + this.selection, + this.stagingBuffer, + this.editingCell, + this.onCellTap, + this.onCellDoubleTap, + this.onCellSecondaryTap, + this.onCommitEdit, + this.onCancelEdit, + this.onOpenInspector, }); + final int rowIndex; final List row; final List columnWidths; final ResultGridColumnWindow window; final double height; final ColorScheme colorScheme; final bool striped; + final ResultGridSelection? selection; + final DataGridStagingBuffer? stagingBuffer; + final ResultGridCellCoordinate? editingCell; + final void Function(int row, int col, {bool isShift})? onCellTap; + final void Function(int row, int col)? onCellDoubleTap; + final void Function(int row, int col)? onCellSecondaryTap; + final void Function( + int row, + int col, + String val, { + bool moveNextCol, + bool movePrevCol, + bool moveNextRow, + bool movePrevRow, + })? onCommitEdit; + final material.VoidCallback? onCancelEdit; + final void Function(int row, int col)? onOpenInspector; @override material.Widget build(material.BuildContext context) { + final rowStatus = stagingBuffer?.getRowStatus(rowIndex) ?? StagedRowStatus.unchanged; + return material.RepaintBoundary( - child: material.Container( + child: material.SizedBox( height: height, - decoration: material.BoxDecoration( - color: striped - ? colorScheme.muted.withValues(alpha: 0.12) - : material.Colors.transparent, - border: material.Border( - bottom: material.BorderSide( - color: colorScheme.border.withValues(alpha: 0.15), - ), - ), - ), child: material.Row( children: [ if (window.leadingWidth > 0) material.SizedBox(width: window.leadingWidth), for (var c = window.first; c <= window.last; c++) _GridCell( + row: rowIndex, + column: c, text: c < row.length ? row[c] : '', width: columnWidths[c], colorScheme: colorScheme, + striped: striped, + rowStatus: rowStatus, + cellStatus: stagingBuffer?.getCellStatus(rowIndex, c) ?? StagedCellStatus.clean, + isSelected: selection?.contains(rowIndex, c) ?? false, + isEditing: editingCell?.row == rowIndex && editingCell?.column == c, + isSelectionTop: selection != null && + selection!.contains(rowIndex, c) && + rowIndex == selection!.startRow, + isSelectionBottom: selection != null && + selection!.contains(rowIndex, c) && + rowIndex == selection!.endRow, + isSelectionLeft: selection != null && + selection!.contains(rowIndex, c) && + c == selection!.startColumn, + isSelectionRight: selection != null && + selection!.contains(rowIndex, c) && + c == selection!.endColumn, + onTap: onCellTap, + onDoubleTap: onCellDoubleTap, + onSecondaryTap: onCellSecondaryTap, + onCommitEdit: onCommitEdit, + onCancelEdit: onCancelEdit, + onOpenInspector: onOpenInspector, ), if (window.trailingWidth > 0) material.SizedBox(width: window.trailingWidth), @@ -447,54 +1187,172 @@ class _DataRow extends material.StatelessWidget { class _GridCell extends material.StatelessWidget { const _GridCell({ + required this.row, + required this.column, required this.text, required this.width, required this.colorScheme, - this.isHeader = false, + required this.striped, + this.rowStatus = StagedRowStatus.unchanged, + this.cellStatus = StagedCellStatus.clean, + this.isSelected = false, + this.isEditing = false, + this.isSelectionTop = false, + this.isSelectionBottom = false, + this.isSelectionLeft = false, + this.isSelectionRight = false, + this.onTap, + this.onDoubleTap, + this.onSecondaryTap, + this.onCommitEdit, + this.onCancelEdit, + this.onOpenInspector, }); + final int row; + final int column; final String text; final double width; final ColorScheme colorScheme; - final bool isHeader; + final bool striped; + final StagedRowStatus rowStatus; + final StagedCellStatus cellStatus; + final bool isSelected; + final bool isEditing; + final bool isSelectionTop; + final bool isSelectionBottom; + final bool isSelectionLeft; + final bool isSelectionRight; + final void Function(int row, int col, {bool isShift})? onTap; + final void Function(int row, int col)? onDoubleTap; + final void Function(int row, int col)? onSecondaryTap; + final void Function( + int row, + int col, + String val, { + bool moveNextCol, + bool movePrevCol, + bool moveNextRow, + bool movePrevRow, + })? onCommitEdit; + final material.VoidCallback? onCancelEdit; + final void Function(int row, int col)? onOpenInspector; @override material.Widget build(material.BuildContext context) { - final isNull = !isHeader && text == 'NULL'; - final style = material.TextStyle( - fontSize: isHeader ? 12 : 12, - fontWeight: - isHeader ? material.FontWeight.w600 : material.FontWeight.normal, - fontFamily: isHeader ? null : 'monospace', - color: isNull - ? colorScheme.mutedForeground.withValues(alpha: 0.5) - : (isHeader ? colorScheme.foreground : colorScheme.foreground), + if (isEditing) { + return GridCellEditor( + initialValue: text, + width: width, + height: double.infinity, + onCommit: (val, {moveNextCol = false, movePrevCol = false, moveNextRow = false, movePrevRow = false}) { + onCommitEdit?.call( + row, + column, + val, + moveNextCol: moveNextCol, + movePrevCol: movePrevCol, + moveNextRow: moveNextRow, + movePrevRow: movePrevRow, + ); + }, + onCancel: () => onCancelEdit?.call(), + onOpenInspector: () => onOpenInspector?.call(row, column), + ); + } + + final isNull = text == 'NULL'; + final isDeleted = rowStatus == StagedRowStatus.deleted; + final isInserted = rowStatus == StagedRowStatus.inserted; + final isModified = cellStatus == StagedCellStatus.modified; + + var style = material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.normal, + fontFamily: 'monospace', + color: isDeleted + ? colorScheme.destructive.withValues(alpha: 0.7) + : (isNull + ? colorScheme.mutedForeground.withValues(alpha: 0.5) + : colorScheme.foreground), + decoration: isDeleted ? material.TextDecoration.lineThrough : null, fontStyle: isNull ? material.FontStyle.italic : material.FontStyle.normal, ); + var bg = isSelected + ? colorScheme.primary.withValues(alpha: 0.18) + : (isDeleted + ? colorScheme.destructive.withValues(alpha: 0.08) + : (isModified + ? colorScheme.primary.withValues(alpha: 0.14) + : (isInserted + ? colorScheme.primary.withValues(alpha: 0.08) + : (striped + ? colorScheme.muted.withValues(alpha: 0.12) + : material.Colors.transparent)))); + final cell = material.Container( width: width, + height: double.infinity, padding: const material.EdgeInsets.symmetric(horizontal: 10), alignment: material.Alignment.centerLeft, decoration: material.BoxDecoration( + color: bg, border: material.Border( right: material.BorderSide( - color: colorScheme.border.withValues(alpha: 0.3), + color: isSelectionRight + ? colorScheme.primary + : colorScheme.border.withValues(alpha: 0.3), + width: isSelectionRight ? 1.5 : 1.0, ), + left: isSelectionLeft + ? material.BorderSide(color: colorScheme.primary, width: 1.5) + : material.BorderSide.none, + top: isSelectionTop + ? material.BorderSide(color: colorScheme.primary, width: 1.5) + : material.BorderSide.none, + bottom: isSelectionBottom + ? material.BorderSide(color: colorScheme.primary, width: 1.5) + : material.BorderSide( + color: colorScheme.border.withValues(alpha: 0.15), + ), ), ), - child: material.Text( - text, - style: style, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, + child: material.Stack( + clipBehavior: material.Clip.none, + alignment: material.Alignment.centerLeft, + children: [ + material.Text( + text, + style: style, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + ), + if (isModified) + material.Positioned( + top: -8, + right: -8, + child: material.CustomPaint( + size: const material.Size(6, 6), + painter: _TriangleCornerPainter(color: colorScheme.primary), + ), + ), + ], ), ); - if (isHeader) return cell; - final interactiveCell = material.GestureDetector( - onSecondaryTap: () => Clipboard.setData(ClipboardData(text: text)), + behavior: material.HitTestBehavior.opaque, + onTap: () { + final isShift = HardwareKeyboard.instance.isShiftPressed; + onTap?.call(row, column, isShift: isShift); + }, + onDoubleTap: () { + onDoubleTap?.call(row, column); + }, + onSecondaryTap: () { + onSecondaryTap?.call(row, column); + }, child: cell, ); @@ -509,3 +1367,24 @@ class _GridCell extends material.StatelessWidget { ); } } + +class _TriangleCornerPainter extends material.CustomPainter { + const _TriangleCornerPainter({required this.color}); + final material.Color color; + + @override + void paint(material.Canvas canvas, material.Size size) { + final paint = material.Paint()..color = color; + final path = material.Path() + ..moveTo(0, 0) + ..lineTo(size.width, 0) + ..lineTo(size.width, size.height) + ..close(); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant _TriangleCornerPainter oldDelegate) => + oldDelegate.color != color; +} + diff --git a/lib/features/main_screen/results_tab.dart b/lib/features/main_screen/results_tab.dart index 77b1c308..118628f9 100644 --- a/lib/features/main_screen/results_tab.dart +++ b/lib/features/main_screen/results_tab.dart @@ -1,22 +1,28 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; import 'package:querya_desktop/core/motion/querya_fade_slide.dart'; import 'package:querya_desktop/core/widgets/virtual_selectable_text_view.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_calc_bar.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_filter_bar.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_groupings_view.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_staging_toolbar.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_value_panel.dart'; +import 'package:querya_desktop/features/main_screen/grid_filter_engine.dart'; +import 'package:querya_desktop/features/main_screen/grid_selection_calc_engine.dart'; import 'package:querya_desktop/features/main_screen/result_grid_view.dart'; import 'package:querya_desktop/shared/services/data_export_service.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; +enum ResultViewMode { + grid, + groupings, +} + /// Query output: grid, loading, error, or placeholder. -/// -/// Render order in [_buildBody]: **loading** first (only when [isLoading]), -/// then **error** when [errorMessage] is non-empty (including when -/// `isLoading` is false), then status / affected / idle / grid content. -/// -/// Mode changes (idle / loading / error / status / grid) morph via -/// [QueryaFadeSlide]. Keys are per **mode**, not per row — so grid data updates -/// and scroll rebuilds do not re-trigger the transition. -class ResultsTab extends StatelessWidget { +class ResultsTab extends material.StatefulWidget { const ResultsTab({ super.key, this.columns = const [], @@ -26,6 +32,9 @@ class ResultsTab extends StatelessWidget { this.affectedRows, this.statusLine, this.showExportToolbar = true, + this.stagingBuffer, + this.onApplyChanges, + this.isSaving = false, }); final List columns; @@ -35,6 +44,27 @@ class ResultsTab extends StatelessWidget { final int? affectedRows; final String? statusLine; final bool showExportToolbar; + final DataGridStagingBuffer? stagingBuffer; + final material.VoidCallback? onApplyChanges; + final bool isSaving; + + @override + material.State createState() => _ResultsTabState(); +} + +class _ResultsTabState extends material.State { + int? _selectedRowIndex; + ResultViewMode _viewMode = ResultViewMode.grid; + + bool _showFilterBar = false; + String _filterText = ''; + + bool _showValuePanel = false; + String? _focusedColumnName; + String? _focusedCellValue; + int? _focusedRowIndex; + + GridCalcStats _selectionStats = GridCalcStats.empty; @override Widget build(BuildContext context) { @@ -46,17 +76,17 @@ class ResultsTab extends StatelessWidget { } material.Widget _buildBody(material.BuildContext context) { - if (isLoading) { + if (widget.isLoading) { return const material.Center( key: material.ValueKey('results_mode_loading'), child: material.CircularProgressIndicator(), ); } - if (errorMessage != null && errorMessage!.isNotEmpty) { + if (widget.errorMessage != null && widget.errorMessage!.isNotEmpty) { return material.KeyedSubtree( key: const material.ValueKey('results_mode_error'), child: VirtualSelectableTextView( - text: errorMessage!, + text: widget.errorMessage!, style: material.TextStyle( fontFamily: 'monospace', fontSize: 12, @@ -65,21 +95,21 @@ class ResultsTab extends StatelessWidget { ), ); } - if (columns.isEmpty && rows.isEmpty) { - if (statusLine != null) { + if (widget.columns.isEmpty && widget.rows.isEmpty && widget.stagingBuffer == null) { + if (widget.statusLine != null) { return material.Padding( key: const material.ValueKey('results_mode_status'), padding: const material.EdgeInsets.all(16), child: Align( alignment: material.Alignment.topLeft, - child: Text(statusLine!).muted().small(), + child: Text(widget.statusLine!).muted().small(), ), ); } - if (affectedRows != null) { + if (widget.affectedRows != null) { return material.Center( key: const material.ValueKey('results_mode_affected'), - child: Text('Rows affected: $affectedRows').muted(), + child: Text('Rows affected: ${widget.affectedRows}').muted(), ); } return material.Center( @@ -88,15 +118,79 @@ class ResultsTab extends StatelessWidget { ); } - return material.Column( - key: const material.ValueKey('results_mode_grid'), - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - if (showExportToolbar && columns.isNotEmpty) + final effectiveRows = widget.stagingBuffer != null + ? widget.stagingBuffer!.effectiveRows + : widget.rows; + + final filteredIndices = GridFilterEngine.filterRowIndices( + filterText: _filterText, + columns: widget.columns, + rows: effectiveRows, + ); + + final filteredRows = filteredIndices.length == effectiveRows.length + ? effectiveRows + : filteredIndices.map((i) => effectiveRows[i]).toList(); + + return material.CallbackShortcuts( + bindings: { + const material.SingleActivator( + LogicalKeyboardKey.keyF, + meta: true, + ): () => setState(() => _showFilterBar = !_showFilterBar), + const material.SingleActivator( + LogicalKeyboardKey.keyF, + control: true, + ): () => setState(() => _showFilterBar = !_showFilterBar), + const material.SingleActivator( + LogicalKeyboardKey.keyS, + meta: true, + ): () { + if (widget.stagingBuffer?.isDirty == true && !widget.isSaving) { + widget.onApplyChanges?.call(); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.keyS, + control: true, + ): () { + if (widget.stagingBuffer?.isDirty == true && !widget.isSaving) { + widget.onApplyChanges?.call(); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.keyG, + meta: true, + ): () => setState(() { + _viewMode = _viewMode == ResultViewMode.grid + ? ResultViewMode.groupings + : ResultViewMode.grid; + }), + const material.SingleActivator( + LogicalKeyboardKey.keyG, + control: true, + ): () => setState(() { + _viewMode = _viewMode == ResultViewMode.grid + ? ResultViewMode.groupings + : ResultViewMode.grid; + }), + }, + child: material.Column( + key: const material.ValueKey('results_mode_grid'), + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + if (widget.stagingBuffer != null) + DataGridStagingToolbar( + stagingBuffer: widget.stagingBuffer!, + selectedRowIndex: _selectedRowIndex, + onApplyChanges: widget.onApplyChanges, + isSaving: widget.isSaving, + ), + if (widget.showExportToolbar && widget.columns.isNotEmpty) material.Container( padding: const material.EdgeInsets.symmetric( horizontal: 12, - vertical: 6, + vertical: 4, ), decoration: material.BoxDecoration( color: Theme.of(context).colorScheme.card, @@ -109,62 +203,197 @@ class ResultsTab extends StatelessWidget { ), ), ), - child: material.Row( - children: [ - material.Icon( - material.Icons.table_rows_rounded, - size: 14, - color: Theme.of(context).colorScheme.primary, - ), - const Gap(6), - Text( - statusLine ?? - (affectedRows != null - ? 'Rows affected: $affectedRows' - : '${rows.length} rows returned'), - ).small().semiBold(), - const material.Spacer(), - ExportMenuButton( - label: 'Copy ▾', - icon: material.Icons.copy_rounded, - isSave: false, - onSelected: (format) { - unawaited(() async { - await DataExportService.copyToClipboard( - format, - columns: columns, - rows: rows, - ); - }()); - }, - ), - const Gap(6), - ExportMenuButton( - label: 'Save ▾', - icon: material.Icons.save_alt_rounded, - isSave: true, - onSelected: (format) { - unawaited(() async { - final outcome = await DataExportService.saveToFile( - format, - columns: columns, - rows: rows, - ); - if (!context.mounted) return; - if (outcome == SaveExportOutcome.error) { - await _showSaveFileErrorDialog(context); - } - }()); - }, - ), - ], + child: material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + // Grid / Groupings View Selector + material.SizedBox( + height: 28, + child: material.SegmentedButton( + segments: const [ + material.ButtonSegment( + value: ResultViewMode.grid, + label: Text('Grid'), + icon: material.Icon(material.Icons.table_chart_outlined, size: 14), + ), + material.ButtonSegment( + value: ResultViewMode.groupings, + label: Text('Groupings'), + icon: material.Icon(material.Icons.grid_view_rounded, size: 14), + ), + ], + selected: {_viewMode}, + onSelectionChanged: (selected) { + setState(() => _viewMode = selected.first); + }, + showSelectedIcon: false, + style: material.SegmentedButton.styleFrom( + padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 0), + visualDensity: material.VisualDensity.compact, + ), + ), + ), + const Gap(10), + Text( + widget.statusLine ?? + (widget.affectedRows != null + ? 'Rows affected: ${widget.affectedRows}' + : '${filteredRows.length}${_filterText.isNotEmpty ? ' of ${effectiveRows.length}' : ''} rows'), + ).small().semiBold(), + + const Gap(16), + + // Toggle Quick Filter + material.IconButton( + icon: material.Icon( + _showFilterBar ? material.Icons.filter_alt : material.Icons.filter_alt_outlined, + size: 15, + ), + tooltip: 'Toggle Quick Filter', + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 28, minHeight: 28), + color: _showFilterBar || _filterText.isNotEmpty + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.mutedForeground, + onPressed: () { + setState(() => _showFilterBar = !_showFilterBar); + }, + ), + const Gap(4), + + // Toggle Value Side Panel + material.IconButton( + icon: material.Icon( + _showValuePanel ? material.Icons.dock : material.Icons.data_object_rounded, + size: 15, + ), + tooltip: 'Inspect Cell Panel', + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 28, minHeight: 28), + color: _showValuePanel + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.mutedForeground, + onPressed: () { + setState(() => _showValuePanel = !_showValuePanel); + }, + ), + const Gap(8), + + ExportMenuButton( + label: 'Copy ▾', + icon: material.Icons.copy_rounded, + isSave: false, + onSelected: (format) { + unawaited(() async { + await DataExportService.copyToClipboard( + format, + columns: widget.columns, + rows: filteredRows, + ); + }()); + }, + ), + const Gap(6), + ExportMenuButton( + label: 'Save ▾', + icon: material.Icons.save_alt_rounded, + isSave: true, + onSelected: (format) { + unawaited(() async { + final outcome = await DataExportService.saveToFile( + format, + columns: widget.columns, + rows: filteredRows, + ); + if (!context.mounted) return; + if (outcome == SaveExportOutcome.error) { + await _showSaveFileErrorDialog(context); + } + }()); + }, + ), + ], + ), ), ), + + // Quick Filter Bar + if (_showFilterBar || _filterText.isNotEmpty) + DataGridFilterBar( + filterText: _filterText, + onFilterChanged: (text) => setState(() => _filterText = text), + totalRowCount: effectiveRows.length, + filteredRowCount: filteredRows.length, + columns: widget.columns, + ), + + // Main Grid Body / Groupings View + Side Panel material.Expanded( - child: VirtualResultGrid(columns: columns, rows: rows), + child: _viewMode == ResultViewMode.groupings + ? DataGridGroupingsView( + columns: widget.columns, + rows: filteredRows, + ) + : material.Row( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Expanded( + child: VirtualResultGrid( + columns: widget.columns, + rows: filteredRows, + stagingBuffer: widget.stagingBuffer, + onRowSelected: (row) => setState(() => _selectedRowIndex = row), + onSelectionValuesChanged: (values) { + setState(() { + _selectionStats = GridSelectionCalcEngine.compute(values); + }); + }, + onCellFocused: (colName, cellVal, rowIdx) { + setState(() { + _focusedColumnName = colName; + _focusedCellValue = cellVal; + _focusedRowIndex = rowIdx; + }); + }, + ), + ), + + // Value Inspector Panel + if (_showValuePanel && + _focusedColumnName != null && + _focusedCellValue != null) + DataGridValuePanel( + columnName: _focusedColumnName!, + cellValue: _focusedCellValue!, + rowIndex: _focusedRowIndex, + onClose: () => setState(() => _showValuePanel = false), + onUpdateValue: widget.stagingBuffer != null && + _focusedRowIndex != null && + _focusedColumnName != null + ? (newVal) { + final colIdx = widget.columns.indexOf(_focusedColumnName!); + if (colIdx != -1) { + widget.stagingBuffer!.setCell( + _focusedRowIndex!, + colIdx, + newVal, + ); + } + } + : null, + ), + ], + ), ), + + // Calc Bar Footer + if (_viewMode == ResultViewMode.grid) + DataGridCalcBar(stats: _selectionStats), ], - ); + ), + ); } } diff --git a/lib/features/main_screen/workspace_empty_hero.dart b/lib/features/main_screen/workspace_empty_hero.dart index 06252381..903e750f 100644 --- a/lib/features/main_screen/workspace_empty_hero.dart +++ b/lib/features/main_screen/workspace_empty_hero.dart @@ -17,6 +17,8 @@ class WorkspaceEmptyHero extends StatefulWidget { required this.onNewConnection, this.onNewConnectionFromUrl, this.onOpenSqlite, + this.onLaunchDemo, + this.onOpenTour, this.onOpenConnection, this.recentConnections, }); @@ -24,6 +26,8 @@ class WorkspaceEmptyHero extends StatefulWidget { final VoidCallback onNewConnection; final VoidCallback? onNewConnectionFromUrl; final VoidCallback? onOpenSqlite; + final VoidCallback? onLaunchDemo; + final VoidCallback? onOpenTour; final ValueChanged? onOpenConnection; /// When provided (e.g. in tests), skips loading recent connections from storage. @@ -133,7 +137,7 @@ class _WorkspaceEmptyHeroState extends State { ), const material.SizedBox(height: 8), material.Text( - 'Connect to a database or open a local SQLite file.', + 'Connect to a database, try the demo playground, or take a quick tour.', textAlign: material.TextAlign.center, style: material.TextStyle( color: cs.mutedForeground, @@ -155,6 +159,16 @@ class _WorkspaceEmptyHeroState extends State { ), child: const Text('New connection'), ), + if (widget.onLaunchDemo != null) + OutlineButton( + key: const Key('empty_try_demo_playground'), + onPressed: widget.onLaunchDemo, + leading: const material.Icon( + material.Icons.play_circle_outline_rounded, + size: 18, + ), + child: const Text('Demo Playground'), + ), if (widget.onNewConnectionFromUrl != null) OutlineButton( key: const Key('empty_new_from_url'), @@ -205,6 +219,9 @@ class _WorkspaceEmptyHeroState extends State { wb.borderSubtle.withValues(alpha: 0.55), foreground: cs.foreground, primary: cs.primary, + onLaunchDemo: widget.onLaunchDemo, + onOpenTour: widget.onOpenTour, + onNewConnection: widget.onNewConnection, ), ), ], @@ -225,6 +242,9 @@ class _QuickStartSection extends StatelessWidget { required this.borderColor, required this.foreground, required this.primary, + this.onLaunchDemo, + this.onOpenTour, + required this.onNewConnection, }); final bool compact; @@ -232,9 +252,15 @@ class _QuickStartSection extends StatelessWidget { final material.Color borderColor; final material.Color foreground; final material.Color primary; + final VoidCallback? onLaunchDemo; + final VoidCallback? onOpenTour; + final VoidCallback onNewConnection; @override Widget build(BuildContext context) { + final openTour = onOpenTour; + final launchDemo = onLaunchDemo; + return material.Container( padding: material.EdgeInsets.all(compact ? 16 : 20), decoration: material.BoxDecoration( @@ -254,28 +280,38 @@ class _QuickStartSection extends StatelessWidget { ), ), const material.SizedBox(height: 12), - _QuickStartRow( + if (openTour != null) ...[ + _InteractiveQuickStartRow( + icon: material.Icons.auto_awesome_rounded, + title: 'Interactive Quick Tour', + description: + 'Learn connections, shortcuts & grid navigation in 4 visual steps', + color: primary, + actionLabel: 'Start Tour', + onTap: openTour, + ), + const material.SizedBox(height: 10), + ], + if (launchDemo != null) ...[ + _InteractiveQuickStartRow( + icon: material.Icons.play_arrow_rounded, + title: '1-Click Demo Playground', + description: + 'Instant sample SQLite database with ready-to-run queries & grid', + color: const material.Color(0xFF10B981), + actionLabel: 'Launch Demo', + onTap: launchDemo, + ), + const material.SizedBox(height: 10), + ], + _InteractiveQuickStartRow( icon: material.Icons.dns_rounded, - title: 'Server databases', + title: 'Server & Local Databases', description: - 'PostgreSQL, MySQL, MongoDB, Redis and extension drivers', - color: primary, - ), - const material.SizedBox(height: 12), - _QuickStartRow( - icon: material.Icons.insert_drive_file_rounded, - title: 'Local database', - description: - 'Open an existing SQLite file or create a connection', - color: primary, - ), - const material.SizedBox(height: 12), - _QuickStartRow( - icon: material.Icons.security_rounded, - title: 'Credentials stay protected', - description: - 'Passwords are stored in your operating system secure store', - color: primary, + 'PostgreSQL, MySQL, MongoDB, Redis, SQLite & custom extension drivers', + color: const material.Color(0xFF8B5CF6), + actionLabel: 'Connect', + onTap: onNewConnection, ), ], ), @@ -418,52 +454,132 @@ class _RecentConnectionRow extends StatelessWidget { } } -class _QuickStartRow extends StatelessWidget { - const _QuickStartRow({ +class _InteractiveQuickStartRow extends StatefulWidget { + const _InteractiveQuickStartRow({ required this.icon, required this.title, required this.description, required this.color, + required this.actionLabel, + required this.onTap, }); final material.IconData icon; final String title; final String description; final material.Color color; + final String actionLabel; + final VoidCallback onTap; + + @override + State<_InteractiveQuickStartRow> createState() => + _InteractiveQuickStartRowState(); +} + +class _InteractiveQuickStartRowState extends State<_InteractiveQuickStartRow> { + var _hovered = false; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - return material.Row( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Icon(icon, size: 18, color: color), - const material.SizedBox(width: 12), - material.Expanded( - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, + final wb = context.workbench; + + return material.MouseRegion( + cursor: material.SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: material.GestureDetector( + onTap: widget.onTap, + child: material.AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + decoration: material.BoxDecoration( + color: _hovered + ? wb.borderSubtle.withValues(alpha: 0.2) + : material.Colors.transparent, + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: _hovered + ? wb.borderSubtle.withValues(alpha: 0.6) + : material.Colors.transparent, + ), + ), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.center, children: [ - material.Text( - title, - style: material.TextStyle( - color: cs.foreground, - fontSize: 13, - fontWeight: material.FontWeight.w500, + material.Container( + width: 32, + height: 32, + decoration: material.BoxDecoration( + color: widget.color.withValues(alpha: 0.15), + shape: material.BoxShape.circle, ), + child: material.Icon(widget.icon, size: 16, color: widget.color), ), - const material.SizedBox(height: 2), - material.Text( - description, - style: material.TextStyle( - color: cs.mutedForeground, - fontSize: 12, - height: 1.35, + const material.SizedBox(width: 12), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Text( + widget.title, + style: material.TextStyle( + color: cs.foreground, + fontSize: 13, + fontWeight: material.FontWeight.w600, + ), + ), + const material.SizedBox(height: 2), + material.Text( + widget.description, + style: material.TextStyle( + color: cs.mutedForeground, + fontSize: 12, + height: 1.35, + ), + ), + ], + ), + ), + const material.SizedBox(width: 8), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: material.BoxDecoration( + color: _hovered + ? wb.accent.withValues(alpha: 0.2) + : wb.borderSubtle.withValues(alpha: 0.3), + borderRadius: material.BorderRadius.circular(6), + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Text( + widget.actionLabel, + style: material.TextStyle( + color: _hovered ? wb.accent : cs.mutedForeground, + fontSize: 11, + fontWeight: material.FontWeight.w600, + ), + ), + const material.SizedBox(width: 4), + material.Icon( + material.Icons.chevron_right_rounded, + size: 14, + color: _hovered ? wb.accent : cs.mutedForeground, + ), + ], ), ), ], ), ), - ], + ), ); } } diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index c33bd719..f11c7846 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -58,9 +58,14 @@ class WorkspacePanel extends StatefulWidget { this.onRequestNewConnection, this.onRequestNewConnectionFromUrl, this.onRequestOpenSqlite, + this.onRequestLaunchDemo, + this.onRequestOpenTour, this.onOpenConnection, }); + final VoidCallback? onRequestLaunchDemo; + final VoidCallback? onRequestOpenTour; + /// Currently selected connection from the sidebar. final ConnectionRow? activeConnection; final bool isReadOnly; @@ -147,6 +152,8 @@ class _WorkspacePanelState extends State { onNewConnection: widget.onRequestNewConnection ?? () {}, onNewConnectionFromUrl: widget.onRequestNewConnectionFromUrl, onOpenSqlite: widget.onRequestOpenSqlite, + onLaunchDemo: widget.onRequestLaunchDemo, + onOpenTour: widget.onRequestOpenTour, onOpenConnection: widget.onOpenConnection, ), ); diff --git a/lib/features/main_screen/xml_html_formatter.dart b/lib/features/main_screen/xml_html_formatter.dart new file mode 100644 index 00000000..f170036a --- /dev/null +++ b/lib/features/main_screen/xml_html_formatter.dart @@ -0,0 +1,117 @@ +/// Formatter and validator for XML and HTML strings. +abstract final class XmlHtmlFormatter { + /// Validates [xml] string and returns null if valid, or an error message if invalid. + static String? validate(String xml) { + final trimmed = xml.trim(); + if (trimmed.isEmpty) return null; + + final tagStack = []; + final tagRegex = RegExp(r'<(/)?([a-zA-Z0-9_\-:]+)([^>]*)>'); + final matches = tagRegex.allMatches(trimmed); + + if (matches.isEmpty) { + if (trimmed.contains('<') || trimmed.contains('>')) { + return 'Malformed XML/HTML tags'; + } + return null; + } + + for (final match in matches) { + final fullMatch = match.group(0)!; + final isClosing = match.group(1) != null; + final tagName = match.group(2)!; + final rest = match.group(3) ?? ''; + + // Check for self-closing tag: or XML declaration or comment + if (fullMatch.startsWith(''; + } + final last = tagStack.removeLast(); + if (last.toLowerCase() != tagName.toLowerCase()) { + return 'Mismatched closing tag: expected , got '; + } + } else { + tagStack.add(tagName); + } + } + + if (tagStack.isNotEmpty) { + return 'Unclosed tag: <${tagStack.last}>'; + } + + return null; + } + + /// Formats / pretty-prints [xml] with [indent] spaces per level. + static String format(String xml, {int indent = 2}) { + final trimmed = xml.trim(); + if (trimmed.isEmpty) return xml; + + final indentStr = ' ' * indent; + final buffer = StringBuffer(); + var level = 0; + + final tokenRegex = RegExp(r'(|<\?[^>]*\?>|]*>|<[^>]+>|[^<]+)'); + final matches = tokenRegex.allMatches(trimmed); + + for (final match in matches) { + var token = match.group(0)!.trim(); + if (token.isEmpty) continue; + + if (token.startsWith(' 0) level--; + if (buffer.isNotEmpty) buffer.writeln(); + buffer.write(indentStr * level); + buffer.write(token); + } else if (token.startsWith('<') && token.endsWith('/>')) { + // Self closing tag + if (buffer.isNotEmpty) buffer.writeln(); + buffer.write(indentStr * level); + buffer.write(token); + } else if (token.startsWith('')) { + // Opening tag + if (buffer.isNotEmpty) buffer.writeln(); + buffer.write(indentStr * level); + buffer.write(token); + level++; + } else { + // Text node + if (buffer.isNotEmpty) buffer.writeln(); + buffer.write(indentStr * level); + buffer.write(token); + } + } + + return buffer.toString(); + } + + /// Minifies [xml] by removing newlines and extraneous spaces between tags. + static String minify(String xml) { + return xml + .replaceAll(RegExp(r'>\s+<'), '><') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + } +} diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 5bf34a27..62c9ce22 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -8,11 +8,15 @@ import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; +import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/main_screen/dml_preview_dialog.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_editor_chrome.dart'; @@ -46,6 +50,9 @@ class _MysqlSqlWorkspaceState extends material.State { List> _rows = []; int? _affectedRows; String? _statusLine; + DataGridStagingBuffer? _stagingBuffer; + String? _lastExecutedSql; + bool _savingChanges = false; int? _queryTimeoutSeconds; @@ -75,6 +82,9 @@ class _MysqlSqlWorkspaceState extends material.State { onNew: () => _sqlController.clear(), onOpen: () => unawaited(_openSqlFile()), onSave: () => unawaited(_saveSqlFile()), + onExecute: () { + if (!_running) unawaited(_execute()); + }, ); } @@ -221,6 +231,10 @@ class _MysqlSqlWorkspaceState extends material.State { _columns = cols; _rows = outRows; _affectedRows = affected; + _lastExecutedSql = userSql; + _stagingBuffer = cols.isNotEmpty + ? DataGridStagingBuffer(columns: cols, rows: outRows) + : null; if (cols.isEmpty && outRows.isEmpty) { _statusLine = affected != null ? 'OK. Rows affected: $affected.' @@ -261,6 +275,69 @@ class _MysqlSqlWorkspaceState extends material.State { } } + Future _applyStagedChanges() async { + if (_stagingBuffer == null || !_stagingBuffer!.isDirty || _savingChanges) return; + final target = _lastExecutedSql != null ? SqlTableTargetExtractor.extract(_lastExecutedSql!) : null; + final tableName = target?.tableName ?? 'table'; + final schemaName = target?.schema; + + setState(() => _savingChanges = true); + try { + final plan = _stagingBuffer!.generateMutationPlan( + dialect: SqlDialect.mysql, + tableName: tableName, + schema: schemaName, + ); + if (plan.isEmpty) { + setState(() => _savingChanges = false); + return; + } + + final confirmed = await showDmlPreviewDialog( + context: context, + plan: plan, + ); + if (confirmed != true) { + setState(() => _savingChanges = false); + return; + } + + await _ensureLease(); + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + throw StateError('Could not connect to MySQL.'); + } + + for (final stmt in plan.statements) { + await conn.execute(stmt.sql); + } + + if (!mounted) return; + setState(() { + _rows = _stagingBuffer!.effectiveRows; + _stagingBuffer = DataGridStagingBuffer(columns: _columns, rows: _rows); + _savingChanges = false; + }); + } catch (e) { + if (mounted) { + setState(() => _savingChanges = false); + await showAppDialog( + context: context, + builder: (ctx) => material.AlertDialog( + title: const material.Text('Save Changes Failed'), + content: material.Text(e.toString()), + actions: [ + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(), + child: const material.Text('OK'), + ), + ], + ), + ); + } + } + } + static int? _affectedInt(BigInt v) { if (v == BigInt.zero) return null; return v.toInt(); @@ -351,9 +428,43 @@ class _MysqlSqlWorkspaceState extends material.State { child: material.CallbackShortcuts( bindings: { const material.SingleActivator(LogicalKeyboardKey.f5): () { - if (!_running) { - unawaited(_execute()); - } + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.enter, + control: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.enter, + meta: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.numpadEnter, + control: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.numpadEnter, + meta: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.keyR, + control: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.keyR, + meta: true, + ): () { + if (!_running) unawaited(_execute()); }, }, child: material.Focus( @@ -416,6 +527,9 @@ class _MysqlSqlWorkspaceState extends material.State { isLoading: _running, affectedRows: _affectedRows, statusLine: _statusLine, + stagingBuffer: _stagingBuffer, + onApplyChanges: widget.isReadOnly ? null : _applyStagedChanges, + isSaving: _savingChanges, ), ), ], diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index 9f188247..c3a116a8 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -508,54 +508,86 @@ class _MysqlTableViewState extends material.State { mainAxisAlignment: material.MainAxisAlignment.end, mainAxisSize: material.MainAxisSize.min, children: [ - material.Text( - _paginationLabel(), - style: material.TextStyle( - fontSize: 11, - color: cs.mutedForeground, + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.4), + borderRadius: material.BorderRadius.circular(4), + ), + child: material.Text( + _paginationLabel(), + style: material.TextStyle( + fontSize: 11, + color: cs.mutedForeground, + ), ), - ), - const Gap(8), - OutlineButton( - onPressed: _loading - ? null - : () { - if (_customSqlActive) { - unawaited(_fetchCustom()); - } else { - unawaited(_fetch(refreshCount: true)); - } - }, - child: const Text('Refresh'), ), const Gap(6), OutlineButton( + size: ButtonSize.small, onPressed: _openSqlEditor, + leading: const material.Icon( + material.Icons.code_rounded, + size: 15, + ), child: const Text('SQL'), ), if (_customSqlActive) ...[ - const Gap(6), + const Gap(4), OutlineButton( + size: ButtonSize.small, onPressed: _exitCustomMode, + leading: const material.Icon( + material.Icons.table_chart_rounded, + size: 15, + ), child: const Text('Browse'), ), ], - const Gap(6), - GhostButton( + const Gap(4), + OutlineButton( + size: ButtonSize.small, onPressed: (!_canGoPrevious || _loading) ? null : _goToPreviousPage, - child: const Icon( - material.Icons.chevron_left_rounded, - size: 20), + leading: const material.Icon( + material.Icons.chevron_left_rounded, + size: 16, + ), + child: const Text('Back'), ), - GhostButton( + const Gap(4), + OutlineButton( + size: ButtonSize.small, onPressed: (!_canGoNext || _loading) ? null : _goToNextPage, - child: const Icon( - material.Icons.chevron_right_rounded, - size: 20), + leading: const material.Icon( + material.Icons.chevron_right_rounded, + size: 16, + ), + child: const Text('Next'), + ), + const Gap(8), + OutlineButton( + size: ButtonSize.small, + onPressed: _loading + ? null + : () { + if (_customSqlActive) { + unawaited(_fetchCustom()); + } else { + unawaited(_fetch(refreshCount: true)); + } + }, + leading: const material.Icon( + material.Icons.refresh_rounded, + size: 14, + ), + child: const Text('Refresh'), ), ], ), diff --git a/lib/features/onboarding/welcome_tour_dialog.dart b/lib/features/onboarding/welcome_tour_dialog.dart new file mode 100644 index 00000000..e020216a --- /dev/null +++ b/lib/features/onboarding/welcome_tour_dialog.dart @@ -0,0 +1,563 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/theme/querya_workbench_theme.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Shows or toggles the interactive Welcome Tour dialog. +Future showWelcomeTourDialog( + material.BuildContext context, { + material.VoidCallback? onLaunchDemo, + material.VoidCallback? onGoHome, +}) { + if (WelcomeTourDialog.isOpen) { + WelcomeTourDialog.close(); + return Future.value(); + } + + return showAppDialog( + context: context, + builder: (ctx) => material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: WindowLayout.dialogSymmetricInsets(ctx), + child: WelcomeTourDialog( + onLaunchDemo: onLaunchDemo, + onGoHome: onGoHome, + ), + ), + ); +} + +class WelcomeTourDialog extends StatefulWidget { + const WelcomeTourDialog({ + super.key, + this.onLaunchDemo, + this.onGoHome, + }); + + final material.VoidCallback? onLaunchDemo; + final material.VoidCallback? onGoHome; + + static material.BuildContext? _activeContext; + static bool get isOpen => _activeContext != null; + + static void close() { + if (_activeContext != null) { + if (material.Navigator.of(_activeContext!).canPop()) { + material.Navigator.of(_activeContext!).pop(); + } + _activeContext = null; + } + } + + @override + State createState() => _WelcomeTourDialogState(); +} + +class _WelcomeTourDialogState extends State { + int _currentStep = 0; + static const int _totalSteps = 4; + + @override + void initState() { + super.initState(); + WelcomeTourDialog._activeContext = context; + } + + @override + void dispose() { + if (WelcomeTourDialog._activeContext == context) { + WelcomeTourDialog._activeContext = null; + } + super.dispose(); + } + + void _goToStep(int step) { + if (step >= 0 && step < _totalSteps && step != _currentStep) { + setState(() => _currentStep = step); + } + } + + void _next() { + if (_currentStep < _totalSteps - 1) { + setState(() => _currentStep++); + } else { + _finish(); + } + } + + void _previous() { + if (_currentStep > 0) { + setState(() => _currentStep--); + } + } + + void _finish() { + unawaited(AppSettings.instance.setHasCompletedWelcomeTour(true)); + if (mounted && material.Navigator.of(context).canPop()) { + material.Navigator.of(context).pop(); + } + } + + void _launchDemoAndClose() { + unawaited(AppSettings.instance.setHasCompletedWelcomeTour(true)); + if (mounted && material.Navigator.of(context).canPop()) { + material.Navigator.of(context).pop(); + } + widget.onLaunchDemo?.call(); + } + + void _goHomeAndClose() { + unawaited(AppSettings.instance.setHasCompletedWelcomeTour(true)); + if (mounted && material.Navigator.of(context).canPop()) { + material.Navigator.of(context).pop(); + } + widget.onGoHome?.call(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final wb = context.workbench; + + final isMac = Platform.isMacOS; + final cmdCtrl = isMac ? 'Cmd' : 'Ctrl'; + + return material.CallbackShortcuts( + bindings: { + const material.SingleActivator(LogicalKeyboardKey.arrowRight): _next, + const material.SingleActivator(LogicalKeyboardKey.arrowLeft): _previous, + const material.SingleActivator(LogicalKeyboardKey.enter): _next, + const material.SingleActivator(LogicalKeyboardKey.escape): _finish, + const material.SingleActivator(LogicalKeyboardKey.f1): _finish, + const material.SingleActivator( + LogicalKeyboardKey.keyH, + control: true, + shift: true, + ): _finish, + const material.SingleActivator( + LogicalKeyboardKey.keyH, + meta: true, + shift: true, + ): _finish, + }, + child: QueryaDialogCard( + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 580, + minWidth: 420, + ), + child: material.AnimatedSize( + duration: const Duration(milliseconds: 240), + curve: Curves.easeOutCubic, + alignment: material.Alignment.topCenter, + child: material.Padding( + padding: const material.EdgeInsets.all(24), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Header row + material.Row( + children: [ + material.Container( + padding: const material.EdgeInsets.all(8), + decoration: material.BoxDecoration( + color: wb.accent.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(10), + border: material.Border.all( + color: wb.accent.withValues(alpha: 0.3), + ), + ), + child: material.Icon( + material.Icons.auto_awesome_rounded, + size: 20, + color: wb.accent, + ), + ), + const material.SizedBox(width: 12), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Welcome to Querya').semiBold().large(), + Text('Step ${_currentStep + 1} of $_totalSteps') + .xSmall() + .muted(), + ], + ), + ), + if (widget.onGoHome != null) ...[ + GhostButton( + key: const Key('welcome_tour_home_button'), + density: ButtonDensity.compact, + onPressed: _goHomeAndClose, + leading: const material.Icon( + material.Icons.home_rounded, + size: 16, + ), + child: const Text('Home'), + ), + const material.SizedBox(width: 4), + ], + material.IconButton( + icon: const material.Icon( + material.Icons.close_rounded, + size: 18, + ), + tooltip: 'Close', + splashRadius: 16, + onPressed: _finish, + ), + ], + ), + const material.SizedBox(height: 18), + + // Animated Slide body + material.AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) { + return material.FadeTransition( + opacity: animation, + child: material.SlideTransition( + position: material.Tween( + begin: const material.Offset(0.04, 0), + end: material.Offset.zero, + ).animate(animation), + child: child, + ), + ); + }, + child: _buildSlideContent( + step: _currentStep, + cmdCtrl: cmdCtrl, + cs: cs, + wb: wb, + ), + ), + const material.SizedBox(height: 20), + + // Footer controls & Step dots + material.Row( + children: [ + // Step indicator dots (clickable) + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: List.generate(_totalSteps, (index) { + final active = index == _currentStep; + return material.GestureDetector( + onTap: () => _goToStep(index), + behavior: material.HitTestBehavior.opaque, + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 3, + vertical: 6, + ), + child: material.AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + width: active ? 22 : 6, + height: 6, + decoration: material.BoxDecoration( + color: active + ? wb.accent + : wb.mutedForeground.withValues(alpha: 0.3), + borderRadius: material.BorderRadius.circular(3), + ), + ), + ), + ); + }), + ), + const material.Spacer(), + + // Action buttons + if (_currentStep > 0) + OutlineButton( + key: const Key('welcome_tour_prev_button'), + onPressed: _previous, + child: const Text('Back'), + ) + else + GhostButton( + key: const Key('welcome_tour_skip_button'), + onPressed: _finish, + child: const Text('Skip'), + ), + const material.SizedBox(width: 8), + + PrimaryButton( + key: const Key('welcome_tour_next_button'), + onPressed: _next, + child: Text( + _currentStep == _totalSteps - 1 + ? 'Get Started' + : 'Next', + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); +} + + Widget _buildSlideContent({ + required int step, + required String cmdCtrl, + required ColorScheme cs, + required QueryaWorkbenchTheme wb, + }) { + switch (step) { + case 0: + return _TourSlideView( + key: const ValueKey(0), + icon: material.Icons.hub_rounded, + iconColor: const material.Color(0xFF06B6D4), // Cyan + title: 'Connect in Seconds', + description: + 'Connect directly to PostgreSQL, MySQL, SQLite, Redis, MongoDB or custom extension drivers. Paste a connection URL or use our visual form with real-time field validation.', + tipText: 'Press $cmdCtrl+N anytime to create a new connection.', + shortcutBadge: '$cmdCtrl+N', + wb: wb, + ); + case 1: + return _TourSlideView( + key: const ValueKey(1), + icon: material.Icons.view_sidebar_rounded, + iconColor: const material.Color(0xFF8B5CF6), // Purple + title: 'Fluid Sidebar & Navigation', + description: + 'Browse database schemas, tables, and views with full keyboard navigation. Right-click any object for instant SELECT queries and DDL copy.', + tipText: + 'Collapse the sidebar with $cmdCtrl+B for an expansive full-screen workspace.', + shortcutBadge: '$cmdCtrl+B', + wb: wb, + ); + case 2: + return _TourSlideView( + key: const ValueKey(2), + icon: material.Icons.table_chart_rounded, + iconColor: const material.Color(0xFF10B981), // Emerald + title: 'Interactive SQL & 2D Grid', + description: + 'Run queries instantly with $cmdCtrl+Enter. Sort results by clicking headers, drag column borders to resize, and Shift+Click to copy cell ranges in TSV format.', + tipText: + 'Copied cell ranges paste directly into Google Sheets and Excel.', + shortcutBadge: '$cmdCtrl+Enter', + wb: wb, + ); + case 3: + default: + return _TourSlideView( + key: const ValueKey(3), + icon: material.Icons.shield_rounded, + iconColor: const material.Color(0xFFF59E0B), // Amber + title: 'Zero-Trust Security & Playground', + description: + 'Database credentials stay protected inside your operating system secure keychain. Try our 1-click Demo Playground to explore Querya instantly without server setup.', + tipText: + 'Toggle Read-Only mode in the title bar for risk-free production exploration.', + shortcutBadge: 'Protected', + actionButtons: [ + if (widget.onLaunchDemo != null) + OutlineButton( + key: const Key('welcome_tour_demo_button'), + onPressed: _launchDemoAndClose, + leading: const material.Icon( + material.Icons.play_arrow_rounded, + size: 18, + ), + child: const Text('Try Demo Playground Now'), + ), + if (widget.onGoHome != null) ...[ + const material.SizedBox(height: 8), + GhostButton( + key: const Key('welcome_tour_slide_home_button'), + onPressed: _goHomeAndClose, + leading: const material.Icon( + material.Icons.home_rounded, + size: 16, + ), + child: const Text('Return to Start Screen'), + ), + ], + ], + wb: wb, + ); + } + } +} + +class _TourSlideView extends StatelessWidget { + const _TourSlideView({ + super.key, + required this.icon, + required this.iconColor, + required this.title, + required this.description, + required this.tipText, + required this.shortcutBadge, + this.actionButtons, + required this.wb, + }); + + final material.IconData icon; + final material.Color iconColor; + final String title; + final String description; + final String tipText; + final String shortcutBadge; + final List? actionButtons; + final QueryaWorkbenchTheme wb; + + @override + Widget build(BuildContext context) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + // Graphic & Badge banner + material.Container( + padding: const material.EdgeInsets.all(16), + decoration: material.BoxDecoration( + color: wb.canvas, + borderRadius: material.BorderRadius.circular(12), + border: material.Border.all( + color: wb.borderSubtle.withValues(alpha: 0.5), + ), + gradient: material.LinearGradient( + begin: material.Alignment.topLeft, + end: material.Alignment.bottomRight, + colors: [ + wb.canvas, + wb.surface.withValues(alpha: 0.85), + ], + ), + ), + child: material.Row( + children: [ + material.Container( + width: 44, + height: 44, + decoration: material.BoxDecoration( + color: iconColor.withValues(alpha: 0.15), + shape: material.BoxShape.circle, + border: material.Border.all( + color: iconColor.withValues(alpha: 0.35), + ), + ), + child: material.Icon( + icon, + size: 24, + color: iconColor, + ), + ), + const material.SizedBox(width: 14), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + Text(title).semiBold().base(), + const material.SizedBox(height: 4), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: material.BoxDecoration( + color: wb.surface, + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: wb.borderSubtle.withValues(alpha: 0.6), + ), + ), + child: Text( + shortcutBadge, + style: TextStyle( + fontSize: 11, + fontWeight: material.FontWeight.w600, + color: wb.accent, + ), + ), + ), + ], + ), + ), + ], + ), + ), + const material.SizedBox(height: 14), + + // Description + Text( + description, + style: TextStyle( + color: wb.mutedForeground, + fontSize: 13, + height: 1.4, + ), + ), + const material.SizedBox(height: 12), + + // Tip banner + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + decoration: material.BoxDecoration( + color: wb.surface, + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: wb.borderSubtle.withValues(alpha: 0.65), + ), + ), + child: material.Row( + children: [ + material.Container( + padding: const material.EdgeInsets.all(4), + decoration: material.BoxDecoration( + color: wb.accent.withValues(alpha: 0.15), + shape: material.BoxShape.circle, + ), + child: material.Icon( + material.Icons.lightbulb_rounded, + size: 14, + color: wb.accent, + ), + ), + const material.SizedBox(width: 10), + material.Expanded( + child: Text( + tipText, + style: TextStyle( + fontSize: 12.5, + color: Theme.of(context) + .colorScheme + .foreground + .withValues(alpha: 0.9), + height: 1.35, + ), + ), + ), + ], + ), + ), + + if (actionButtons != null && actionButtons!.isNotEmpty) ...[ + const material.SizedBox(height: 14), + ...actionButtons!, + ], + ], + ); + } +} diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 2efe818b..b87f2873 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -10,6 +10,8 @@ import 'package:postgres/postgres.dart' as pg; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/postgres_sql.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; +import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -17,6 +19,8 @@ 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/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/main_screen/dml_preview_dialog.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_editor_chrome.dart'; @@ -80,6 +84,9 @@ class _PostgresSqlWorkspaceState extends material.State { List> _rows = []; int? _affectedRows; String? _statusLine; + DataGridStagingBuffer? _stagingBuffer; + String? _lastExecutedSql; + bool _savingChanges = false; /// PostgreSQL default: each statement is its own transaction unless you use /// `BEGIN` / `BEGIN`+implicit when autocommit is off. @@ -118,6 +125,9 @@ class _PostgresSqlWorkspaceState extends material.State { onNew: () => _sqlController.clear(), onOpen: () => unawaited(_openSqlFile()), onSave: () => unawaited(_saveSqlFile()), + onExecute: () { + if (!_running) _execute(); + }, ); } @@ -364,6 +374,10 @@ class _PostgresSqlWorkspaceState extends material.State { _columns = cols; _rows = outRows; _affectedRows = result.affectedRows; + _lastExecutedSql = userSql; + _stagingBuffer = cols.isNotEmpty + ? DataGridStagingBuffer(columns: cols, rows: outRows) + : null; if (cols.isEmpty && outRows.isEmpty) { _statusLine = 'Command completed. Rows affected: ${result.affectedRows}.'; @@ -413,6 +427,70 @@ class _PostgresSqlWorkspaceState extends material.State { } } + Future _applyStagedChanges() async { + if (_stagingBuffer == null || !_stagingBuffer!.isDirty || _savingChanges) return; + final target = _lastExecutedSql != null ? SqlTableTargetExtractor.extract(_lastExecutedSql!) : null; + final tableName = target?.tableName ?? 'table'; + final schemaName = target?.schema; + + setState(() => _savingChanges = true); + try { + final plan = _stagingBuffer!.generateMutationPlan( + dialect: SqlDialect.postgres, + tableName: tableName, + schema: schemaName, + ); + if (plan.isEmpty) { + setState(() => _savingChanges = false); + return; + } + + final confirmed = await showDmlPreviewDialog( + context: context, + plan: plan, + ); + if (confirmed != true) { + setState(() => _savingChanges = false); + return; + } + + await _ensureLease(); + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + throw StateError('Could not connect to PostgreSQL.'); + } + + final txSql = plan.toTransactionSql(); + final to = _statementTimeout(); + await conn.execute(txSql, timeout: to); + + if (!mounted) return; + setState(() { + _rows = _stagingBuffer!.effectiveRows; + _stagingBuffer = DataGridStagingBuffer(columns: _columns, rows: _rows); + _savingChanges = false; + }); + await _refreshTxStatus(); + } catch (e) { + if (mounted) { + setState(() => _savingChanges = false); + await showAppDialog( + context: context, + builder: (ctx) => material.AlertDialog( + title: const material.Text('Save Changes Failed'), + content: material.Text(e.toString()), + actions: [ + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(), + child: const material.Text('OK'), + ), + ], + ), + ); + } + } + } + Future _openSqlFile() async { try { final file = await openFile( @@ -492,6 +570,42 @@ class _PostgresSqlWorkspaceState extends material.State { const material.SingleActivator(LogicalKeyboardKey.f5): () { if (!_running) _execute(); }, + const material.SingleActivator( + LogicalKeyboardKey.enter, + control: true, + ): () { + if (!_running) _execute(); + }, + const material.SingleActivator( + LogicalKeyboardKey.enter, + meta: true, + ): () { + if (!_running) _execute(); + }, + const material.SingleActivator( + LogicalKeyboardKey.numpadEnter, + control: true, + ): () { + if (!_running) _execute(); + }, + const material.SingleActivator( + LogicalKeyboardKey.numpadEnter, + meta: true, + ): () { + if (!_running) _execute(); + }, + const material.SingleActivator( + LogicalKeyboardKey.keyR, + control: true, + ): () { + if (!_running) _execute(); + }, + const material.SingleActivator( + LogicalKeyboardKey.keyR, + meta: true, + ): () { + if (!_running) _execute(); + }, }, child: material.Focus( autofocus: true, @@ -558,6 +672,9 @@ class _PostgresSqlWorkspaceState extends material.State { isLoading: _running, affectedRows: _affectedRows, statusLine: _statusLine, + stagingBuffer: _stagingBuffer, + onApplyChanges: widget.isReadOnly ? null : _applyStagedChanges, + isSaving: _savingChanges, ), ), ], diff --git a/lib/features/sqlite/sqlite_overview_tab.dart b/lib/features/sqlite/sqlite_overview_tab.dart index c7f75c7c..296dc88f 100644 --- a/lib/features/sqlite/sqlite_overview_tab.dart +++ b/lib/features/sqlite/sqlite_overview_tab.dart @@ -133,7 +133,7 @@ class _SqliteOverviewTabState extends material.State { @override material.Widget build(material.BuildContext context) { final cs = Theme.of(context).colorScheme; - final width = material.MediaQuery.of(context).size.width; + final width = material.MediaQuery.sizeOf(context).width; if (_loading && _overview == null) { return material.Center( diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index f8dd7eb9..04534f02 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -6,12 +6,16 @@ import 'package:file_selector/file_selector.dart'; import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; +import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; import 'package:querya_desktop/core/database/sqlite_service.dart'; import 'package:querya_desktop/core/database/sql_limit.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/main_screen/dml_preview_dialog.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_editor_chrome.dart'; @@ -45,6 +49,9 @@ class _SqliteSqlWorkspaceState extends material.State { List> _rows = []; int? _affectedRows; String? _statusLine; + DataGridStagingBuffer? _stagingBuffer; + String? _lastExecutedSql; + bool _savingChanges = false; int _resultMaxRows = kDefaultSqlResultMaxRows; int _historyMaxEntries = kDefaultSqlHistoryMaxEntries; @@ -72,6 +79,9 @@ class _SqliteSqlWorkspaceState extends material.State { onNew: () => _sqlController.clear(), onOpen: () => unawaited(_openSqlFile()), onSave: () => unawaited(_saveSqlFile()), + onExecute: () { + if (!_running) unawaited(_execute()); + }, ); } @@ -183,6 +193,10 @@ class _SqliteSqlWorkspaceState extends material.State { _columns = cols; _rows = outRows; _affectedRows = null; + _lastExecutedSql = userSql; + _stagingBuffer = cols.isNotEmpty + ? DataGridStagingBuffer(columns: cols, rows: outRows) + : null; if (cols.isEmpty && outRows.isEmpty) { _statusLine = 'Command completed.'; } else if (truncated || (injectedLimit && results.length >= cap)) { @@ -222,6 +236,68 @@ class _SqliteSqlWorkspaceState extends material.State { } } + Future _applyStagedChanges() async { + if (_stagingBuffer == null || !_stagingBuffer!.isDirty || _savingChanges) return; + final target = _lastExecutedSql != null ? SqlTableTargetExtractor.extract(_lastExecutedSql!) : null; + final tableName = target?.tableName ?? 'table'; + + setState(() => _savingChanges = true); + try { + final plan = _stagingBuffer!.generateMutationPlan( + dialect: SqlDialect.sqlite, + tableName: tableName, + schema: target?.schema, + ); + if (plan.isEmpty) { + setState(() => _savingChanges = false); + return; + } + + final confirmed = await showDmlPreviewDialog( + context: context, + plan: plan, + ); + if (confirmed != true) { + setState(() => _savingChanges = false); + return; + } + + await _ensureLease(); + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + throw StateError('Could not connect to SQLite.'); + } + + for (final stmt in plan.statements) { + await conn.execute(stmt.sql); + } + + if (!mounted) return; + setState(() { + _rows = _stagingBuffer!.effectiveRows; + _stagingBuffer = DataGridStagingBuffer(columns: _columns, rows: _rows); + _savingChanges = false; + }); + } catch (e) { + if (mounted) { + setState(() => _savingChanges = false); + await showAppDialog( + context: context, + builder: (ctx) => material.AlertDialog( + title: const material.Text('Save Changes Failed'), + content: material.Text(e.toString()), + actions: [ + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(), + child: const material.Text('OK'), + ), + ], + ), + ); + } + } + } + Future _openSqlFile() async { try { final file = await openFile( @@ -299,9 +375,43 @@ class _SqliteSqlWorkspaceState extends material.State { child: material.CallbackShortcuts( bindings: { const material.SingleActivator(LogicalKeyboardKey.f5): () { - if (!_running) { - unawaited(_execute()); - } + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.enter, + control: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.enter, + meta: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.numpadEnter, + control: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.numpadEnter, + meta: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.keyR, + control: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.keyR, + meta: true, + ): () { + if (!_running) unawaited(_execute()); }, }, child: material.Focus( @@ -359,6 +469,9 @@ class _SqliteSqlWorkspaceState extends material.State { isLoading: _running, affectedRows: _affectedRows, statusLine: _statusLine, + stagingBuffer: _stagingBuffer, + onApplyChanges: widget.isReadOnly ? null : _applyStagedChanges, + isSaving: _savingChanges, ), ), ], diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index 4484d657..8809598d 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -294,52 +294,54 @@ class _SqliteTableViewState extends material.State { } material.Widget _headerCell(ColorScheme cs, String name) { - return material.Container( - width: 150, - padding: const material.EdgeInsets.symmetric(horizontal: 10), - alignment: material.Alignment.centerLeft, - decoration: material.BoxDecoration( - border: material.Border( - right: material.BorderSide( - color: cs.border.withValues(alpha: 0.22), - width: 1, + return material.Expanded( + child: material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 10), + alignment: material.Alignment.centerLeft, + decoration: material.BoxDecoration( + border: material.Border( + right: material.BorderSide( + color: cs.border.withValues(alpha: 0.22), + width: 1, + ), ), ), - ), - child: material.Text( - name, - overflow: material.TextOverflow.ellipsis, - style: material.TextStyle( - fontSize: 12, - fontWeight: material.FontWeight.w600, - color: cs.foreground, + child: material.Text( + name, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + ), ), ), ); } material.Widget _dataCell(ColorScheme cs, String value) { - return material.Container( - width: 150, - padding: const material.EdgeInsets.symmetric(horizontal: 10), - alignment: material.Alignment.centerLeft, - decoration: material.BoxDecoration( - border: material.Border( - right: material.BorderSide( - color: cs.border.withValues(alpha: 0.15), - width: 1, + return material.Expanded( + child: material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 10), + alignment: material.Alignment.centerLeft, + decoration: material.BoxDecoration( + border: material.Border( + right: material.BorderSide( + color: cs.border.withValues(alpha: 0.15), + width: 1, + ), ), ), - ), - child: material.SelectableText( - value, - maxLines: 1, - style: material.TextStyle( - fontSize: 12, - fontFamily: 'monospace', - color: value == 'NULL' - ? cs.mutedForeground.withValues(alpha: 0.7) - : cs.foreground, + child: material.SelectableText( + value, + maxLines: 1, + style: material.TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: value == 'NULL' + ? cs.mutedForeground.withValues(alpha: 0.7) + : cs.foreground, + ), ), ), ); @@ -481,14 +483,24 @@ class _SqliteTableViewState extends material.State { mainAxisAlignment: material.MainAxisAlignment.end, mainAxisSize: material.MainAxisSize.min, children: [ - material.Text( - _paginationLabel(), - style: material.TextStyle( - fontSize: 11, - color: cs.mutedForeground, + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.4), + borderRadius: material.BorderRadius.circular(4), + ), + child: material.Text( + _paginationLabel(), + style: material.TextStyle( + fontSize: 11, + color: cs.mutedForeground, + ), ), ), - const Gap(8), + const Gap(6), OutlineButton( size: ButtonSize.small, onPressed: _loading @@ -496,29 +508,41 @@ class _SqliteTableViewState extends material.State { : () => unawaited(_showDdlDialog()), child: const Text('DDL'), ), - const Gap(6), + const Gap(4), OutlineButton( + size: ButtonSize.small, onPressed: _loading ? null : () => unawaited(_fetch()), + leading: const material.Icon( + material.Icons.refresh_rounded, + size: 14, + ), child: const Text('Refresh'), ), - const Gap(6), - GhostButton( + const Gap(4), + OutlineButton( + size: ButtonSize.small, onPressed: (!_canGoPrevious || _loading) ? null : _goToPreviousPage, - child: const Icon( - material.Icons.chevron_left_rounded, - size: 20), + leading: const material.Icon( + material.Icons.chevron_left_rounded, + size: 16, + ), + child: const Text('Back'), ), - GhostButton( + const Gap(4), + OutlineButton( + size: ButtonSize.small, onPressed: (!_canGoNext || _loading) ? null : _goToNextPage, - child: const Icon( - material.Icons.chevron_right_rounded, - size: 20), + leading: const material.Icon( + material.Icons.chevron_right_rounded, + size: 16, + ), + child: const Text('Next'), ), ], ), diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index dddb8a30..9158f3eb 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -6,7 +6,11 @@ com.apple.security.cs.allow-jit + com.apple.security.network.client + com.apple.security.network.server + com.apple.security.files.user-selected.read-write + diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 852fa1a4..c1a22b8a 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -4,5 +4,11 @@ com.apple.security.app-sandbox + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.files.user-selected.read-write + diff --git a/packaging/linux/aur/PKGBUILD b/packaging/linux/aur/PKGBUILD index 138c5d78..31c526db 100644 --- a/packaging/linux/aur/PKGBUILD +++ b/packaging/linux/aur/PKGBUILD @@ -2,7 +2,7 @@ # AUR package — installs the official Release portable Linux zip under /opt. # pkgver is synced by version-bump.yml; CI publishes real sha256sums on Release. pkgname=querya-desktop -pkgver=0.4.11-c +pkgver=0.4.13 pkgrel=1 pkgdesc="Multi-database desktop client (PostgreSQL, MySQL, Redis, MongoDB, SQLite)" arch=('x86_64') diff --git a/pubspec.yaml b/pubspec.yaml index 72bf256c..34929080 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: querya_desktop description: Lightweight desktop SQL/NoSQL client. Flutter (Dart). -version: 0.4.11-c +version: 0.4.13 @@ -20,7 +20,7 @@ dependencies: path_provider: ^2.1.5 sqflite: ^2.3.2 - sqflite_common_ffi: ^2.3.2 + sqflite_common_ffi: ^2.4.0+3 mongo_dart: ^0.10.8 redis: ^4.0.0 postgres: ^3.5.6 @@ -29,7 +29,7 @@ dependencies: flutter_secure_storage: ^10.3.1 file_selector: ^1.1.0 syntax_highlight: ^0.5.0 - archive: ^4.0.9 + archive: ^4.2.0 url_launcher: ^6.3.1 package_info_plus: ^9.0.1 flutter_svg: ^2.3.0 diff --git a/test/core/database/sql_table_target_extractor_test.dart b/test/core/database/sql_table_target_extractor_test.dart new file mode 100644 index 00000000..8bf3a4f7 --- /dev/null +++ b/test/core/database/sql_table_target_extractor_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; + +void main() { + group('SqlTableTargetExtractor', () { + test('extracts table name from simple SELECT', () { + final target = SqlTableTargetExtractor.extract('SELECT * FROM users'); + expect(target, isNotNull); + expect(target!.tableName, 'users'); + expect(target.schema, isNull); + }); + + test('extracts schema and table from quoted identifiers', () { + final target = SqlTableTargetExtractor.extract('SELECT id, name FROM "public"."accounts" WHERE id > 10'); + expect(target, isNotNull); + expect(target!.tableName, 'accounts'); + expect(target.schema, 'public'); + }); + + test('extracts schema and table from MySQL backticks', () { + final target = SqlTableTargetExtractor.extract('SELECT * FROM `shop_db`.`orders` ORDER BY date DESC'); + expect(target, isNotNull); + expect(target!.tableName, 'orders'); + expect(target.schema, 'shop_db'); + }); + + test('returns null for queries with JOINs to avoid ambiguous mutations', () { + final target = SqlTableTargetExtractor.extract('SELECT u.id, o.amount FROM users u JOIN orders o ON u.id = o.user_id'); + expect(target, isNull); + }); + + test('returns null for empty or non-FROM queries', () { + expect(SqlTableTargetExtractor.extract(''), isNull); + expect(SqlTableTargetExtractor.extract('SELECT 1 + 1'), isNull); + }); + }); +} diff --git a/test/core/database/table_mutation_engine_test.dart b/test/core/database/table_mutation_engine_test.dart new file mode 100644 index 00000000..2e191885 --- /dev/null +++ b/test/core/database/table_mutation_engine_test.dart @@ -0,0 +1,201 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/core/database/table_schema_meta.dart'; + +void main() { + group('TableSchemaMeta', () { + test('serializes and deserializes correctly', () { + const meta = TableSchemaMeta( + tableName: 'users', + schema: 'public', + columns: [ + TableColumnMeta( + name: 'id', + dataType: 'integer', + isNullable: false, + isPrimaryKey: true, + primaryKeyPosition: 1, + ), + TableColumnMeta( + name: 'email', + dataType: 'varchar', + isNullable: true, + ), + ], + primaryKeys: ['id'], + ); + + final json = meta.toJson(); + final restored = TableSchemaMeta.fromJson(json); + + expect(restored.tableName, 'users'); + expect(restored.schema, 'public'); + expect(restored.hasPrimaryKey, isTrue); + expect(restored.primaryKeys, ['id']); + expect(restored.columns.length, 2); + expect(restored.getColumn('id')?.isPrimaryKey, isTrue); + expect(restored.getColumn('email')?.isNullable, isTrue); + }); + }); + + group('TableMutationEngine', () { + const columns = ['id', 'username', 'age', 'active']; + const originalRows = [ + ['1', 'alice', '30', 'true'], + ['2', 'bob', '25', 'false'], + ]; + + test('generates UPDATE statement for modified cells with single PK in Postgres dialect', () { + final plan = TableMutationEngine.generatePlan( + dialect: SqlDialect.postgres, + tableName: 'users', + schema: 'public', + columns: columns, + primaryKeys: ['id'], + originalRows: originalRows, + modifiedCells: { + 0: {1: 'alice_updated', 2: '31'}, + }, + insertedRows: [], + deletedRowIndices: {}, + ); + + expect(plan.statementCount, 1); + final stmt = plan.statements.first; + expect(stmt.type, MutationType.update); + expect( + stmt.sql, + 'UPDATE "public"."users" SET "username" = \'alice_updated\', "age" = 31 WHERE "id" = 1', + ); + + final tx = plan.toTransactionSql(); + expect(tx.startsWith('BEGIN;'), isTrue); + expect(tx.endsWith('COMMIT;\n'), isTrue); + }); + + test('generates UPDATE statement for MySQL dialect with backticks', () { + final plan = TableMutationEngine.generatePlan( + dialect: SqlDialect.mysql, + tableName: 'users', + schema: 'mydb', + columns: columns, + primaryKeys: ['id'], + originalRows: originalRows, + modifiedCells: { + 1: {3: 'true'}, + }, + insertedRows: [], + deletedRowIndices: {}, + ); + + expect(plan.statementCount, 1); + final stmt = plan.statements.first; + expect(stmt.type, MutationType.update); + expect( + stmt.sql, + 'UPDATE `mydb`.`users` SET `active` = TRUE WHERE `id` = 2', + ); + + final tx = plan.toTransactionSql(); + expect(tx.startsWith('START TRANSACTION;'), isTrue); + expect(tx.contains('COMMIT;'), isTrue); + }); + + test('generates INSERT statement with NULL values in SQLite dialect', () { + final plan = TableMutationEngine.generatePlan( + dialect: SqlDialect.sqlite, + tableName: 'users', + columns: columns, + primaryKeys: ['id'], + originalRows: originalRows, + modifiedCells: {}, + insertedRows: [ + ['3', 'charlie', 'NULL', 'false'], + ], + deletedRowIndices: {}, + ); + + expect(plan.statementCount, 1); + final stmt = plan.statements.first; + expect(stmt.type, MutationType.insert); + expect( + stmt.sql, + 'INSERT INTO "users" ("id", "username", "age", "active") VALUES (3, \'charlie\', NULL, 0)', + ); + + final tx = plan.toTransactionSql(); + expect(tx.startsWith('BEGIN TRANSACTION;'), isTrue); + }); + + test('generates DELETE statement for deleted row index', () { + final plan = TableMutationEngine.generatePlan( + dialect: SqlDialect.postgres, + tableName: 'users', + schema: 'public', + columns: columns, + primaryKeys: ['id'], + originalRows: originalRows, + modifiedCells: {}, + insertedRows: [], + deletedRowIndices: {0}, + ); + + expect(plan.statementCount, 1); + final stmt = plan.statements.first; + expect(stmt.type, MutationType.delete); + expect(stmt.sql, 'DELETE FROM "public"."users" WHERE "id" = 1'); + }); + + test('handles composite primary keys in WHERE clause', () { + const compositeCols = ['tenant_id', 'user_id', 'role']; + const compositeRows = [ + ['100', '1', 'admin'], + ]; + + final plan = TableMutationEngine.generatePlan( + dialect: SqlDialect.postgres, + tableName: 'user_roles', + columns: compositeCols, + primaryKeys: ['tenant_id', 'user_id'], + originalRows: compositeRows, + modifiedCells: { + 0: {2: 'superadmin'}, + }, + insertedRows: [], + deletedRowIndices: {}, + ); + + expect(plan.statementCount, 1); + expect( + plan.statements.first.sql, + 'UPDATE "user_roles" SET "role" = \'superadmin\' WHERE "tenant_id" = 100 AND "user_id" = 1', + ); + }); + + test('falls back to all columns when no primary key is specified', () { + const noPkCols = ['category', 'description']; + const noPkRows = [ + ['sales', 'retail store'], + ]; + + final plan = TableMutationEngine.generatePlan( + dialect: SqlDialect.postgres, + tableName: 'tags', + columns: noPkCols, + primaryKeys: [], + originalRows: noPkRows, + modifiedCells: { + 0: {1: 'online store'}, + }, + insertedRows: [], + deletedRowIndices: {}, + ); + + expect(plan.statementCount, 1); + expect( + plan.statements.first.sql, + 'UPDATE "tags" SET "description" = \'online store\' WHERE "category" = \'sales\' AND "description" = \'retail store\'', + ); + }); + }); +} diff --git a/test/core/demo/demo_playground_service_test.dart b/test/core/demo/demo_playground_service_test.dart new file mode 100644 index 00000000..5e74ea60 --- /dev/null +++ b/test/core/demo/demo_playground_service_test.dart @@ -0,0 +1,93 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/demo/demo_playground_service.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.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; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('querya_demo_service_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('DemoPlaygroundService resolves path and seeds demo SQLite database', () async { + final dbPath = await DemoPlaygroundService.resolveDemoDatabasePath(); + expect(dbPath.endsWith('demo_playground.sqlite'), isTrue); + + await DemoPlaygroundService.seedDemoDatabase(dbPath); + expect(File(dbPath).existsSync(), isTrue); + + final db = await databaseFactoryFfi.openDatabase(dbPath); + try { + final users = await db.rawQuery('SELECT * FROM users;'); + expect(users.length, equals(8)); + + final products = await db.rawQuery('SELECT * FROM products;'); + expect(products.length, equals(8)); + + final orders = await db.rawQuery('SELECT * FROM orders;'); + expect(orders.length, equals(12)); + } finally { + await db.close(); + } + + // Calling seedDemoDatabase a second time must be idempotent + await DemoPlaygroundService.seedDemoDatabase(dbPath); + final db2 = await databaseFactoryFfi.openDatabase(dbPath); + try { + final users2 = await db2.rawQuery('SELECT * FROM users;'); + expect(users2.length, equals(8)); + } finally { + await db2.close(); + } + }); + + test('DemoPlaygroundService getOrCreateDemoConnection creates and reuses connection row', () async { + final conn1 = await DemoPlaygroundService.getOrCreateDemoConnection(); + expect(conn1.name, equals(DemoPlaygroundService.demoConnectionName)); + expect(conn1.type, equals('sqlite')); + expect(conn1.id, isNotNull); + + final conn2 = await DemoPlaygroundService.getOrCreateDemoConnection(); + expect(conn2.id, equals(conn1.id)); + expect(conn2.name, equals(conn1.name)); + }); + + test('DemoPlaygroundService demoDefaultQuery is non-empty and contains key tables', () { + const query = DemoPlaygroundService.demoDefaultQuery; + expect(query.contains('FROM orders'), isTrue); + expect(query.contains('JOIN users'), isTrue); + expect(query.contains('JOIN products'), isTrue); + }); +} diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index ec00eb0f..7cf70e6c 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -415,5 +415,13 @@ void main() { await AppSettings.instance.setCheckForUpdatesOnStartup(false); expect(await AppSettings.instance.getCheckForUpdatesOnStartup(), isFalse); }); + + test('hasCompletedWelcomeTour defaults to false and roundtrips true', () async { + expect(await AppSettings.instance.getHasCompletedWelcomeTour(), isFalse); + await AppSettings.instance.setHasCompletedWelcomeTour(true); + expect(await AppSettings.instance.getHasCompletedWelcomeTour(), isTrue); + await AppSettings.instance.setHasCompletedWelcomeTour(false); + expect(await AppSettings.instance.getHasCompletedWelcomeTour(), isFalse); + }); }); } diff --git a/test/features/main_screen/data_grid_e2e_integration_test.dart b/test/features/main_screen/data_grid_e2e_integration_test.dart new file mode 100644 index 00000000..1a7a5843 --- /dev/null +++ b/test/features/main_screen/data_grid_e2e_integration_test.dart @@ -0,0 +1,155 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/main_screen/grid_filter_engine.dart'; +import 'package:querya_desktop/features/main_screen/grid_groupings_engine.dart'; +import 'package:querya_desktop/features/main_screen/grid_selection_calc_engine.dart'; + +void main() { + group('Data Grid End-to-End Integration Tests', () { + const columns = ['id', 'username', 'email', 'balance', 'status']; + final baseRows = [ + ['1', 'alice', 'alice@example.com', '150.00', 'ACTIVE'], + ['2', 'bob', 'bob@example.com', '50.00', 'PENDING'], + ['3', 'charlie', 'charlie@example.com', '300.00', 'ACTIVE'], + ['4', 'david', 'david@example.com', '0.00', 'CANCELLED'], + ]; + + test('E2E: Staging workflow -> Plan generation -> Multi-dialect DML compilation', () { + final buffer = DataGridStagingBuffer( + columns: columns, + rows: baseRows, + ); + + expect(buffer.isDirty, isFalse); + + // 1. Modify cell (Row 0, balance: 150.00 -> 250.00) + buffer.setCell(0, 3, '250.00'); + // 2. Modify cell (Row 1, status: PENDING -> ACTIVE) + buffer.setCell(1, 4, 'ACTIVE'); + // 3. Mark Row 3 as deleted + buffer.toggleDeleteRow(3); + // 4. Insert new row + buffer.addRow(['5', 'eve', 'eve@example.com', '500.00', 'ACTIVE']); + + expect(buffer.isDirty, isTrue); + expect(buffer.modifiedCells.length, equals(2)); + expect(buffer.deletedRowIndices, contains(3)); + expect(buffer.insertedRows.length, equals(1)); + + // 5. Generate Postgres mutation plan with primary key 'id' + final pgPlan = buffer.generateMutationPlan( + dialect: SqlDialect.postgres, + tableName: 'users', + primaryKeys: ['id'], + ); + + expect(pgPlan.statements.length, equals(4)); + expect(pgPlan.statements[0].sql, contains('UPDATE "users" SET "balance" = 250.00 WHERE "id" = 1')); + expect(pgPlan.statements[1].sql, contains('UPDATE "users" SET "status" = \'ACTIVE\' WHERE "id" = 2')); + expect(pgPlan.statements[2].sql, contains('INSERT INTO "users"')); + expect(pgPlan.statements[3].sql, contains('DELETE FROM "users" WHERE "id" = 4')); + expect(pgPlan.toTransactionSql(), startsWith('BEGIN;\n')); + expect(pgPlan.toTransactionSql(), contains('COMMIT;')); + + // 6. Generate MySQL mutation plan + final mysqlPlan = buffer.generateMutationPlan( + dialect: SqlDialect.mysql, + tableName: 'users', + primaryKeys: ['id'], + ); + expect(mysqlPlan.statements[0].sql, contains('UPDATE `users` SET `balance` = 250.00 WHERE `id` = 1')); + expect(mysqlPlan.toTransactionSql(), startsWith('START TRANSACTION;\n')); + + // 7. Generate SQLite mutation plan + final sqlitePlan = buffer.generateMutationPlan( + dialect: SqlDialect.sqlite, + tableName: 'users', + primaryKeys: ['id'], + ); + expect(sqlitePlan.statements[0].sql, contains('UPDATE "users" SET "balance" = 250.00 WHERE "id" = 1')); + expect(sqlitePlan.toTransactionSql(), startsWith('BEGIN TRANSACTION;\n')); + + // 8. Reset / clear on successful commit + buffer.revertAll(); + expect(buffer.isDirty, isFalse); + expect(buffer.modifiedCells, isEmpty); + expect(buffer.insertedRows, isEmpty); + expect(buffer.deletedRowIndices, isEmpty); + }); + + test('E2E: SQL Query -> Target Extractor -> Schema & Table resolution', () { + expect( + SqlTableTargetExtractor.extract('SELECT * FROM users'), + equals(const SqlTableTarget(tableName: 'users')), + ); + + expect( + SqlTableTargetExtractor.extract('SELECT id, name FROM public.accounts WHERE active = 1'), + equals(const SqlTableTarget(tableName: 'accounts', schema: 'public')), + ); + + expect( + SqlTableTargetExtractor.extract('SELECT u.id FROM `my_db`.`customers` u ORDER BY id DESC'), + equals(const SqlTableTarget(tableName: 'customers', schema: 'my_db')), + ); + + // Complex join / non-simple select should return null + expect( + SqlTableTargetExtractor.extract('SELECT a.id, b.name FROM a JOIN b ON a.id = b.id'), + isNull, + ); + }); + + test('E2E: Filter Engine -> Selection Calc -> Groupings Pivot pipeline', () { + // 1. Filter rows where status is ACTIVE or PENDING and balance > 0 + final matchingIndices = GridFilterEngine.filterRowIndices( + filterText: "status IN ('ACTIVE', 'PENDING') AND balance > 0", + columns: columns, + rows: baseRows, + ); + expect(matchingIndices, equals([0, 1, 2])); + + final filteredRows = matchingIndices.map((i) => baseRows[i]).toList(); + + // 2. Select balance column values from filtered rows + final balanceValues = filteredRows.map((r) => r[3]).toList(); + final stats = GridSelectionCalcEngine.compute(balanceValues); + + expect(stats.totalCount, equals(3)); + expect(stats.sum, equals(500.00)); + expect(stats.average, closeTo(166.666, 0.01)); + expect(stats.median, equals(150.00)); + expect(stats.min, equals(50.00)); + expect(stats.max, equals(300.00)); + + // 3. Group filtered rows by status with SUM(balance) aggregation + final groups = GridGroupingsEngine.buildGroups( + groupColIndices: [4], + rows: filteredRows, + aggConfig: const GroupAggregationConfig( + aggType: GroupingAggType.sum, + targetColIndex: 3, + ), + ); + + expect(groups.length, equals(2)); + final activeGroup = groups.firstWhere((g) => g.groupKey == 'ACTIVE'); + expect(activeGroup.count, equals(2)); + expect(activeGroup.aggValue, equals(450.00)); + + final pendingGroup = groups.firstWhere((g) => g.groupKey == 'PENDING'); + expect(pendingGroup.count, equals(1)); + expect(pendingGroup.aggValue, equals(50.00)); + + // 4. Export pivot summary to CSV + final csv = GridGroupingsEngine.exportPivotToCsv( + groups: groups, + groupByColumnName: 'status', + ); + expect(csv, contains('"ACTIVE",2,66.67%,450.00')); + expect(csv, contains('"PENDING",1,33.33%,50.00')); + }); + }); +} diff --git a/test/features/main_screen/data_grid_engines_test.dart b/test/features/main_screen/data_grid_engines_test.dart new file mode 100644 index 00000000..07b8f8c6 --- /dev/null +++ b/test/features/main_screen/data_grid_engines_test.dart @@ -0,0 +1,279 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/main_screen/grid_filter_engine.dart'; +import 'package:querya_desktop/features/main_screen/grid_groupings_engine.dart'; +import 'package:querya_desktop/features/main_screen/grid_selection_calc_engine.dart'; + +void main() { + group('GridFilterEngine', () { + final columns = ['id', 'status', 'amount']; + final rows = [ + ['1', 'ACTIVE', '150.0'], + ['2', 'PENDING', '50.0'], + ['3', 'ACTIVE', '300.0'], + ['4', 'CANCELLED', '0.0'], + ]; + + test('returns all rows when filter is empty', () { + final res = GridFilterEngine.filterRowIndices( + filterText: '', + columns: columns, + rows: rows, + ); + expect(res, equals([0, 1, 2, 3])); + }); + + test('filters by free text substring search', () { + final res = GridFilterEngine.filterRowIndices( + filterText: 'active', + columns: columns, + rows: rows, + ); + expect(res, equals([0, 2])); + }); + + test('filters by predicate column = value', () { + final res = GridFilterEngine.filterRowIndices( + filterText: 'status = PENDING', + columns: columns, + rows: rows, + ); + expect(res, equals([1])); + }); + + test('filters by numeric predicate amount > 100', () { + final res = GridFilterEngine.filterRowIndices( + filterText: 'amount > 100', + columns: columns, + rows: rows, + ); + expect(res, equals([0, 2])); + }); + + test('filters with AND conjunction', () { + final res = GridFilterEngine.filterRowIndices( + filterText: "status = 'ACTIVE' AND amount > 200", + columns: columns, + rows: rows, + ); + expect(res, equals([2])); + }); + + test('filters with OR disjunction', () { + final res = GridFilterEngine.filterRowIndices( + filterText: "status = 'PENDING' OR status = 'CANCELLED'", + columns: columns, + rows: rows, + ); + expect(res, equals([1, 3])); + }); + + test('filters with NOT negation and parentheses', () { + final res = GridFilterEngine.filterRowIndices( + filterText: "NOT (status = 'ACTIVE') AND amount >= 50", + columns: columns, + rows: rows, + ); + expect(res, equals([1])); + }); + + test('handles combined complex nested logic', () { + final res = GridFilterEngine.filterRowIndices( + filterText: "(status = 'ACTIVE' OR status = 'PENDING') AND (amount < 200)", + columns: columns, + rows: rows, + ); + expect(res, equals([0, 1])); + }); + + test('filters with LIKE and ILIKE wildcards', () { + final resLike = GridFilterEngine.filterRowIndices( + filterText: "status LIKE 'ACT%'", + columns: columns, + rows: rows, + ); + expect(resLike, equals([0, 2])); + + final resIlike = GridFilterEngine.filterRowIndices( + filterText: "status ILIKE '%pend%'", + columns: columns, + rows: rows, + ); + expect(resIlike, equals([1])); + }); + + test('filters with IN and NOT IN list of literals', () { + final resIn = GridFilterEngine.filterRowIndices( + filterText: "status IN ('PENDING', 'CANCELLED')", + columns: columns, + rows: rows, + ); + expect(resIn, equals([1, 3])); + + final resNotIn = GridFilterEngine.filterRowIndices( + filterText: "status NOT IN ('ACTIVE')", + columns: columns, + rows: rows, + ); + expect(resNotIn, equals([1, 3])); + }); + + test('filters with IS NULL and IS NOT NULL', () { + final rowsWithNull = [ + ['1', 'ACTIVE', '100'], + ['2', 'NULL', '200'], + ['3', '', '300'], + ]; + final resNull = GridFilterEngine.filterRowIndices( + filterText: 'status IS NULL', + columns: columns, + rows: rowsWithNull, + ); + expect(resNull, equals([1, 2])); + + final resNotNull = GridFilterEngine.filterRowIndices( + filterText: 'status IS NOT NULL', + columns: columns, + rows: rowsWithNull, + ); + expect(resNotNull, equals([0])); + }); + + test('filters with BETWEEN range', () { + final res = GridFilterEngine.filterRowIndices( + filterText: 'amount BETWEEN 50 AND 200', + columns: columns, + rows: rows, + ); + expect(res, equals([0, 1])); + }); + + test('handles escaped quotes inside string literals', () { + final rowsWithQuotes = [ + ['1', "O'Connor", '100'], + ['2', 'Smith', '200'], + ]; + final res = GridFilterEngine.filterRowIndices( + filterText: "status = 'O''Connor'", + columns: columns, + rows: rowsWithQuotes, + ); + expect(res, equals([0])); + }); + }); + + group('GridSelectionCalcEngine', () { + test('computes correct stats for numeric values', () { + final stats = GridSelectionCalcEngine.compute(['10', '20', '30', '40']); + expect(stats.totalCount, equals(4)); + expect(stats.distinctCount, equals(4)); + expect(stats.numericCount, equals(4)); + expect(stats.nullCount, equals(0)); + expect(stats.sum, equals(100.0)); + expect(stats.average, equals(25.0)); + expect(stats.median, equals(25.0)); + expect(stats.min, equals(10.0)); + expect(stats.max, equals(40.0)); + }); + + test('computes odd-length median and distinct count with duplicates', () { + final stats = GridSelectionCalcEngine.compute(['10', '20', '20', '50', '100']); + expect(stats.distinctCount, equals(4)); + expect(stats.median, equals(20.0)); + expect(stats.toSummaryString(), contains('Count: 5 | Distinct: 4')); + expect(stats.toSummaryString(), contains('Median: 20')); + }); + + test('handles NULLs and mixed string data', () { + final stats = GridSelectionCalcEngine.compute(['10', 'NULL', 'text', '50.5']); + expect(stats.totalCount, equals(4)); + expect(stats.distinctCount, equals(3)); + expect(stats.numericCount, equals(2)); + expect(stats.nullCount, equals(1)); + expect(stats.sum, equals(60.5)); + expect(stats.average, equals(30.25)); + expect(stats.min, equals(10.0)); + expect(stats.max, equals(50.5)); + }); + }); + + group('GridGroupingsEngine', () { + final rows = [ + ['1', 'ACTIVE', '100'], + ['2', 'PENDING', '50'], + ['3', 'ACTIVE', '200'], + ['4', 'ACTIVE', '300'], + ['5', 'CANCELLED', '0'], + ]; + + test('groups rows by column index and calculates percentages', () { + final groups = GridGroupingsEngine.buildGroups( + groupColIndices: [1], + rows: rows, + ); + + expect(groups.length, equals(3)); + expect(groups[0].groupKey, equals('ACTIVE')); + expect(groups[0].count, equals(3)); + expect(groups[0].percentage, closeTo(60.0, 0.1)); + expect(groups[1].count, equals(1)); + }); + + test('computes custom aggregations (SUM and AVG)', () { + final sumGroups = GridGroupingsEngine.buildGroups( + groupColIndices: [1], + rows: rows, + aggConfig: const GroupAggregationConfig( + aggType: GroupingAggType.sum, + targetColIndex: 2, + ), + ); + + final activeGroup = sumGroups.firstWhere((g) => g.groupKey == 'ACTIVE'); + expect(activeGroup.aggValue, equals(600.0)); + + final avgGroups = GridGroupingsEngine.buildGroups( + groupColIndices: [1], + rows: rows, + aggConfig: const GroupAggregationConfig( + aggType: GroupingAggType.avg, + targetColIndex: 2, + ), + ); + + final activeAvg = avgGroups.firstWhere((g) => g.groupKey == 'ACTIVE'); + expect(activeAvg.aggValue, equals(200.0)); + }); + + test('sorts groups by key or aggregate', () { + final keySorted = GridGroupingsEngine.buildGroups( + groupColIndices: [1], + rows: rows, + sortBy: GroupSortBy.key, + sortAscending: true, + ); + + expect(keySorted[0].groupKey, equals('ACTIVE')); + expect(keySorted[1].groupKey, equals('CANCELLED')); + expect(keySorted[2].groupKey, equals('PENDING')); + }); + + test('exports pivot table to CSV format', () { + final groups = GridGroupingsEngine.buildGroups( + groupColIndices: [1], + rows: rows, + aggConfig: const GroupAggregationConfig( + aggType: GroupingAggType.sum, + targetColIndex: 2, + ), + ); + + final csv = GridGroupingsEngine.exportPivotToCsv( + groups: groups, + groupByColumnName: 'status', + ); + + expect(csv, contains('Group Key,Count,Percentage,Aggregate')); + expect(csv, contains('"ACTIVE",3,60.00%,600.00')); + }); + }); +} diff --git a/test/features/main_screen/data_grid_filter_bar_test.dart b/test/features/main_screen/data_grid_filter_bar_test.dart new file mode 100644 index 00000000..400152e6 --- /dev/null +++ b/test/features/main_screen/data_grid_filter_bar_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_filter_bar.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('FilterSuggestionEngine', () { + final columns = ['id', 'status', 'amount', 'created_at']; + + test('returns column suggestions when text is empty', () { + final suggs = FilterSuggestionEngine.getSuggestions( + text: '', + columns: columns, + ); + expect(suggs.length, 4); + expect(suggs.map((s) => s.text), containsAll(['id', 'status', 'amount', 'created_at'])); + }); + + test('suggests matching columns on partial prefix', () { + final suggs = FilterSuggestionEngine.getSuggestions( + text: 'sta', + columns: columns, + ); + expect(suggs.length, 1); + expect(suggs.first.text, 'status'); + expect(suggs.first.kind, FilterSuggestionKind.column); + }); + + test('suggests operators after full column name', () { + final suggs = FilterSuggestionEngine.getSuggestions( + text: 'status', + columns: columns, + ); + expect(suggs.map((s) => s.text), containsAll(['=', '!=', 'LIKE', 'ILIKE', 'IN (...)', 'IS NULL'])); + }); + + test('suggests keywords on partial keyword prefix', () { + final suggs = FilterSuggestionEngine.getSuggestions( + text: 'status = ACTIVE AN', + columns: columns, + ); + expect(suggs.any((s) => s.text == 'AND'), isTrue); + }); + }); + + group('DataGridFilterBar Widget', () { + testWidgets('renders filter bar and updates text on input', (tester) async { + String currentFilter = ''; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Material( + child: DataGridFilterBar( + filterText: currentFilter, + onFilterChanged: (text) => currentFilter = text, + totalRowCount: 100, + filteredRowCount: 20, + columns: const ['id', 'name', 'status'], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(material.TextField), findsOneWidget); + + await tester.enterText(find.byType(material.TextField), 'stat'); + await tester.pumpAndSettle(); + + expect(currentFilter, 'stat'); + expect(find.text('status'), findsOneWidget); + }); + }); +} diff --git a/test/features/main_screen/data_grid_staging_buffer_test.dart b/test/features/main_screen/data_grid_staging_buffer_test.dart new file mode 100644 index 00000000..09eb68b4 --- /dev/null +++ b/test/features/main_screen/data_grid_staging_buffer_test.dart @@ -0,0 +1,145 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; + +void main() { + group('DataGridStagingBuffer', () { + late DataGridStagingBuffer buffer; + + setUp(() { + buffer = DataGridStagingBuffer( + columns: ['id', 'name', 'email'], + rows: [ + ['1', 'Alice', 'alice@test.com'], + ['2', 'Bob', 'bob@test.com'], + ['3', 'Charlie', 'charlie@test.com'], + ], + ); + }); + + test('initial state is clean and non-dirty', () { + expect(buffer.isDirty, isFalse); + expect(buffer.changeCount, 0); + expect(buffer.totalRowCount, 3); + expect(buffer.getRowStatus(0), StagedRowStatus.unchanged); + expect(buffer.getCellStatus(0, 1), StagedCellStatus.clean); + expect(buffer.getCellValue(0, 1), 'Alice'); + expect(buffer.getOriginalCellValue(0, 1), 'Alice'); + }); + + test('modifying cell sets dirty and updates cell value', () { + buffer.setCell(0, 1, 'Alice Cooper'); + + expect(buffer.isDirty, isTrue); + expect(buffer.changeCount, 1); + expect(buffer.modifiedCellCount, 1); + expect(buffer.getCellValue(0, 1), 'Alice Cooper'); + expect(buffer.getOriginalCellValue(0, 1), 'Alice'); + expect(buffer.getCellStatus(0, 1), StagedCellStatus.modified); + expect(buffer.getRowStatus(0), StagedRowStatus.modified); + + // Reverting cell value to original clears dirty status + buffer.setCell(0, 1, 'Alice'); + expect(buffer.isDirty, isFalse); + expect(buffer.changeCount, 0); + expect(buffer.getCellStatus(0, 1), StagedCellStatus.clean); + expect(buffer.getRowStatus(0), StagedRowStatus.unchanged); + }); + + test('adding row appends inserted row and increments row count', () { + final newIdx = buffer.addRow(['4', 'Diana', 'diana@test.com']); + + expect(newIdx, 3); + expect(buffer.totalRowCount, 4); + expect(buffer.isDirty, isTrue); + expect(buffer.insertedRowCount, 1); + expect(buffer.getRowStatus(3), StagedRowStatus.inserted); + expect(buffer.getCellValue(3, 1), 'Diana'); + expect(buffer.getOriginalCellValue(3, 1), isNull); + + // Modifying cell in inserted row + buffer.setCell(3, 1, 'Diana Prince'); + expect(buffer.getCellValue(3, 1), 'Diana Prince'); + }); + + test('toggling delete row marks row deleted or removes inserted row', () { + // Delete baseline row + buffer.toggleDeleteRow(1); + expect(buffer.isDirty, isTrue); + expect(buffer.deletedRowCount, 1); + expect(buffer.getRowStatus(1), StagedRowStatus.deleted); + + // Toggle again restores it + buffer.toggleDeleteRow(1); + expect(buffer.isDirty, isFalse); + expect(buffer.deletedRowCount, 0); + expect(buffer.getRowStatus(1), StagedRowStatus.unchanged); + + // Insert row and then delete it -> removes from inserted list + final insIdx = buffer.addRow(['4', 'New', 'new@test.com']); + expect(buffer.totalRowCount, 4); + buffer.toggleDeleteRow(insIdx); + expect(buffer.totalRowCount, 3); + expect(buffer.isDirty, isFalse); + }); + + test('revertCell, revertRow, and revertAll restore pristine state', () { + buffer.setCell(0, 1, 'Alice Modified'); + buffer.setCell(0, 2, 'modified@test.com'); + buffer.toggleDeleteRow(1); + buffer.addRow(['4', 'Extra', 'extra@test.com']); + + expect(buffer.isDirty, isTrue); + expect(buffer.changeCount, 4); // 2 cells + 1 deleted row + 1 inserted row + + // Revert single cell + buffer.revertCell(0, 1); + expect(buffer.getCellValue(0, 1), 'Alice'); + expect(buffer.getCellValue(0, 2), 'modified@test.com'); + + // Revert whole row 0 + buffer.revertRow(0); + expect(buffer.getCellValue(0, 2), 'alice@test.com'); + expect(buffer.getRowStatus(0), StagedRowStatus.unchanged); + + // Revert all + buffer.revertAll(); + expect(buffer.isDirty, isFalse); + expect(buffer.changeCount, 0); + expect(buffer.totalRowCount, 3); + }); + + test('effectiveRows computes combined snapshot accurately', () { + buffer.setCell(0, 1, 'Alice Updated'); + buffer.addRow(['4', 'David', 'david@test.com']); + + final eff = buffer.effectiveRows; + expect(eff.length, 4); + expect(eff[0], ['1', 'Alice Updated', 'alice@test.com']); + expect(eff[1], ['2', 'Bob', 'bob@test.com']); + expect(eff[2], ['3', 'Charlie', 'charlie@test.com']); + expect(eff[3], ['4', 'David', 'david@test.com']); + }); + + test('generateMutationPlan builds correct DML statements from staged modifications', () { + buffer.setCell(0, 1, 'Alice Updated'); + buffer.addRow(['4', 'Diana', 'diana@test.com']); + buffer.toggleDeleteRow(2); + + final plan = buffer.generateMutationPlan( + dialect: SqlDialect.postgres, + tableName: 'users', + schema: 'public', + primaryKeys: ['id'], + ); + + expect(plan.statementCount, 3); + expect(plan.statements[0].type, MutationType.update); + expect(plan.statements[0].sql, 'UPDATE "public"."users" SET "name" = \'Alice Updated\' WHERE "id" = 1'); + expect(plan.statements[1].type, MutationType.insert); + expect(plan.statements[1].sql, 'INSERT INTO "public"."users" ("id", "name", "email") VALUES (4, \'Diana\', \'diana@test.com\')'); + expect(plan.statements[2].type, MutationType.delete); + expect(plan.statements[2].sql, 'DELETE FROM "public"."users" WHERE "id" = 3'); + }); + }); +} diff --git a/test/features/main_screen/data_grid_value_panel_test.dart b/test/features/main_screen/data_grid_value_panel_test.dart new file mode 100644 index 00000000..71b1f364 --- /dev/null +++ b/test/features/main_screen/data_grid_value_panel_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_value_panel.dart'; +import 'package:querya_desktop/features/main_screen/xml_html_formatter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('XmlHtmlFormatter', () { + test('validates correct XML tags', () { + expect(XmlHtmlFormatter.validate('value'), isNull); + expect(XmlHtmlFormatter.validate(''), isNull); + expect(XmlHtmlFormatter.validate('
'), isNull); + }); + + test('detects mismatched or unclosed XML tags', () { + expect(XmlHtmlFormatter.validate(''), isNotNull); + expect(XmlHtmlFormatter.validate(''), isNotNull); + expect(XmlHtmlFormatter.validate(''), isNotNull); + }); + + test('pretty prints XML with indentation', () { + const input = 'Alice'; + final formatted = XmlHtmlFormatter.format(input); + expect(formatted, contains(' ')); + expect(formatted, contains(' ')); + expect(formatted, contains(' Alice')); + expect(formatted, contains(' ')); + }); + + test('minifies XML by collapsing whitespace', () { + const input = ''' + + + Alice + + +'''; + final minified = XmlHtmlFormatter.minify(input); + expect(minified, contains(' Alice ')); + }); + }); + + group('DataGridValuePanel Widget', () { + testWidgets('renders panel with initial JSON and formatting buttons', (tester) async { + String? updatedVal; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Material( + child: DataGridValuePanel( + columnName: 'payload', + cellValue: '{"name":"John","age":30}', + rowIndex: 0, + onClose: () {}, + onUpdateValue: (val) => updatedVal = val, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('payload [Row 1]'), findsOneWidget); + expect(find.text('Format'), findsOneWidget); + expect(find.text('Minify'), findsOneWidget); + + await tester.tap(find.text('Update Cell Value')); + await tester.pumpAndSettle(); + expect(updatedVal, isNotNull); + }); + }); +} diff --git a/test/features/main_screen/dml_preview_dialog_test.dart b/test/features/main_screen/dml_preview_dialog_test.dart new file mode 100644 index 00000000..8212f45a --- /dev/null +++ b/test/features/main_screen/dml_preview_dialog_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/features/main_screen/dml_preview_dialog.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + material.Widget buildTestDialog({required TableMutationPlan plan, void Function(bool?)? onResult}) { + return queryaThemeTestShell( + child: material.Material( + child: material.Builder( + builder: (context) { + return OutlineButton( + onPressed: () async { + final res = await showDmlPreviewDialog(context: context, plan: plan); + onResult?.call(res); + }, + child: const Text('Open Dialog'), + ); + }, + ), + ), + ); + } + + group('DmlPreviewConfirmationDialog', () { + const samplePlan = TableMutationPlan( + dialect: SqlDialect.postgres, + tableName: 'users', + schema: 'public', + statements: [ + TableMutationStatement( + type: MutationType.update, + sql: 'UPDATE "public"."users" SET "name" = \'Alice\' WHERE "id" = 1', + description: 'Update row 1', + ), + TableMutationStatement( + type: MutationType.insert, + sql: 'INSERT INTO "public"."users" ("id", "name") VALUES (2, \'Bob\')', + description: 'Insert new row 2', + ), + TableMutationStatement( + type: MutationType.delete, + sql: 'DELETE FROM "public"."users" WHERE "id" = 3', + description: 'Delete row 3', + ), + ], + ); + + testWidgets('renders dialog header, badges, and SQL preview', (tester) async { + await tester.pumpWidget(buildTestDialog(plan: samplePlan)); + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + expect(find.text('Confirm Data Changes'), findsOneWidget); + expect(find.text('public.users'), findsOneWidget); + expect(find.text('PostgreSQL'), findsOneWidget); + expect(find.text('1 UPDATE'), findsOneWidget); + expect(find.text('1 INSERT'), findsOneWidget); + expect(find.text('1 DELETE'), findsOneWidget); + expect(find.text('Apply Changes'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + expect(find.textContaining('BEGIN;'), findsOneWidget); + expect(find.textContaining('COMMIT;'), findsOneWidget); + }); + + testWidgets('cancelling dialog pops false', (tester) async { + bool? result; + await tester.pumpWidget( + buildTestDialog( + plan: samplePlan, + onResult: (res) => result = res, + ), + ); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(result, isFalse); + }); + + testWidgets('confirming dialog pops true', (tester) async { + bool? result; + await tester.pumpWidget( + buildTestDialog( + plan: samplePlan, + onResult: (res) => result = res, + ), + ); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Apply Changes')); + await tester.pumpAndSettle(); + + expect(result, isTrue); + }); + }); +} diff --git a/test/features/main_screen/goldens/workspace_empty_hero.png b/test/features/main_screen/goldens/workspace_empty_hero.png index c0145856..1d746c4a 100644 Binary files a/test/features/main_screen/goldens/workspace_empty_hero.png and b/test/features/main_screen/goldens/workspace_empty_hero.png differ diff --git a/test/features/main_screen/grid_cell_editor_test.dart b/test/features/main_screen/grid_cell_editor_test.dart new file mode 100644 index 00000000..cfaa10b7 --- /dev/null +++ b/test/features/main_screen/grid_cell_editor_test.dart @@ -0,0 +1,225 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/main_screen/grid_cell_editor.dart'; +import 'package:querya_desktop/features/main_screen/grid_cell_popover_inspector.dart'; +import 'package:querya_desktop/features/main_screen/result_grid_view.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('GridCellEditor', () { + testWidgets('renders with initial value and commits text on Enter', (tester) async { + String? committed; + bool movedRow = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Material( + child: GridCellEditor( + initialValue: 'Original', + width: 200, + height: 36, + onCommit: (val, {moveNextCol = false, movePrevCol = false, moveNextRow = false, movePrevRow = false}) { + committed = val; + movedRow = moveNextRow; + }, + onCancel: () {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Original'), findsOneWidget); + + // Enter new text and submit + await tester.enterText(find.byType(material.TextField), 'Modified'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(committed, 'Modified'); + expect(movedRow, isTrue); + }); + + testWidgets('triggers onCancel when Escape key is pressed', (tester) async { + bool cancelled = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Material( + child: GridCellEditor( + initialValue: 'Original', + width: 200, + height: 36, + onCommit: (val, {moveNextCol = false, movePrevCol = false, moveNextRow = false, movePrevRow = false}) {}, + onCancel: () => cancelled = true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(cancelled, isTrue); + }); + + testWidgets('displays validation error icon on invalid integer input', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Material( + child: GridCellEditor( + initialValue: '123', + dataTypeName: 'integer', + width: 200, + height: 36, + onCommit: (val, {moveNextCol = false, movePrevCol = false, moveNextRow = false, movePrevRow = false}) {}, + onCancel: () {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(material.Icons.error_outline_rounded), findsNothing); + + // Enter non-integer text + await tester.enterText(find.byType(material.TextField), 'not_a_number'); + await tester.pumpAndSettle(); + + expect(find.byIcon(material.Icons.error_outline_rounded), findsOneWidget); + }); + }); + + group('GridCellPopoverInspector', () { + testWidgets('allows editing and formatting JSON in dialog', (tester) async { + String? result; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showGridCellInspectorDialog( + context: context, + columnName: 'metadata', + initialValue: '{"name":"querya","active":true}', + rowIndex: 0, + ); + }, + child: const Text('Open Dialog'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + expect(find.text('Edit metadata (Row 1)'), findsOneWidget); + expect(find.text('Format JSON'), findsOneWidget); + + // Format JSON + await tester.tap(find.text('Format JSON')); + await tester.pumpAndSettle(); + + // Apply changes + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + + expect(result, isNotNull); + expect(result!.contains('\n'), isTrue); + expect(result!.contains('"name": "querya"'), isTrue); + }); + + testWidgets('allows setting value to NULL in dialog', (tester) async { + String? result; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showGridCellInspectorDialog( + context: context, + columnName: 'age', + initialValue: '25', + rowIndex: 1, + ); + }, + child: const Text('Open Dialog'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Set NULL')); + await tester.pumpAndSettle(); + + expect(find.text('Value is NULL'), findsOneWidget); + + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + + expect(result, 'NULL'); + }); + }); + + group('VirtualResultGrid Inline Editing Integration', () { + testWidgets('double click on cell activates editor and commits staged edit', (tester) async { + final staging = DataGridStagingBuffer( + columns: ['id', 'name'], + rows: [ + ['1', 'Alice'], + ['2', 'Bob'], + ], + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.SizedBox( + width: 800, + height: 400, + child: VirtualResultGrid( + columns: const ['id', 'name'], + rows: const [ + ['1', 'Alice'], + ['2', 'Bob'], + ], + stagingBuffer: staging, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(staging.isDirty, isFalse); + + // Double-click on 'Alice' + await tester.tap(find.text('Alice')); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tap(find.text('Alice')); + await tester.pumpAndSettle(); + + // TextField should now be present for editing + expect(find.byType(material.TextField), findsOneWidget); + + // Type 'Alice Cooper' and press Done/Enter + await tester.enterText(find.byType(material.TextField), 'Alice Cooper'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(staging.isDirty, isTrue); + expect(staging.getCellValue(0, 1), 'Alice Cooper'); + }); + }); +} diff --git a/test/features/main_screen/grid_data_type_validator_test.dart b/test/features/main_screen/grid_data_type_validator_test.dart new file mode 100644 index 00000000..c4b9f544 --- /dev/null +++ b/test/features/main_screen/grid_data_type_validator_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/main_screen/grid_data_type_validator.dart'; + +void main() { + group('GridDataTypeValidator', () { + test('validates integer types', () { + expect(GridDataTypeValidator.validate('123', dataTypeName: 'integer'), isNull); + expect(GridDataTypeValidator.validate('-456', dataTypeName: 'int4'), isNull); + expect(GridDataTypeValidator.validate('0', dataTypeName: 'bigint'), isNull); + expect(GridDataTypeValidator.validate('abc', dataTypeName: 'int'), isNotNull); + expect(GridDataTypeValidator.validate('12.34', dataTypeName: 'int'), isNotNull); + }); + + test('validates numeric / float / decimal types', () { + expect(GridDataTypeValidator.validate('123.45', dataTypeName: 'numeric'), isNull); + expect(GridDataTypeValidator.validate('-0.99', dataTypeName: 'float8'), isNull); + expect(GridDataTypeValidator.validate('100', dataTypeName: 'decimal'), isNull); + expect(GridDataTypeValidator.validate('xyz', dataTypeName: 'double'), isNotNull); + }); + + test('validates boolean types', () { + expect(GridDataTypeValidator.validate('true', dataTypeName: 'boolean'), isNull); + expect(GridDataTypeValidator.validate('false', dataTypeName: 'bool'), isNull); + expect(GridDataTypeValidator.validate('1', dataTypeName: 'bool'), isNull); + expect(GridDataTypeValidator.validate('0', dataTypeName: 'bool'), isNull); + expect(GridDataTypeValidator.validate('yes', dataTypeName: 'bool'), isNotNull); + }); + + test('validates UUID types', () { + expect( + GridDataTypeValidator.validate( + 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', + dataTypeName: 'uuid', + ), + isNull, + ); + expect( + GridDataTypeValidator.validate('not-a-uuid', dataTypeName: 'uuid'), + isNotNull, + ); + }); + + test('validates JSON types', () { + expect( + GridDataTypeValidator.validate('{"key": "value"}', dataTypeName: 'json'), + isNull, + ); + expect( + GridDataTypeValidator.validate('[1, 2, 3]', dataTypeName: 'jsonb'), + isNull, + ); + expect( + GridDataTypeValidator.validate('{invalid-json}', dataTypeName: 'json'), + isNotNull, + ); + expect( + GridDataTypeValidator.validate('plain text', dataTypeName: 'json'), + isNotNull, + ); + }); + + test('validates date and timestamp types', () { + expect(GridDataTypeValidator.validate('2026-08-25', dataTypeName: 'date'), isNull); + expect(GridDataTypeValidator.validate('25-08-2026', dataTypeName: 'date'), isNotNull); + expect( + GridDataTypeValidator.validate( + '2026-08-25 14:30:00', + dataTypeName: 'timestamp', + ), + isNull, + ); + expect( + GridDataTypeValidator.validate('invalid date', dataTypeName: 'timestamp'), + isNotNull, + ); + }); + + test('allows empty and NULL values regardless of type', () { + expect(GridDataTypeValidator.validate('', dataTypeName: 'int'), isNull); + expect(GridDataTypeValidator.validate('NULL', dataTypeName: 'uuid'), isNull); + expect(GridDataTypeValidator.validate('null', dataTypeName: 'json'), isNull); + }); + }); +} diff --git a/test/features/main_screen/querya_window_title_bar_test.dart b/test/features/main_screen/querya_window_title_bar_test.dart index afe0a7e6..74b50f22 100644 --- a/test/features/main_screen/querya_window_title_bar_test.dart +++ b/test/features/main_screen/querya_window_title_bar_test.dart @@ -17,6 +17,14 @@ QueryaTheme _themeWithWorkbench(QueryaWorkbenchTheme workbench) { } void main() { + setUp(() { + QueryaWindowTitleBar.useNativeWindowChrome = false; + }); + + tearDown(() { + QueryaWindowTitleBar.useNativeWindowChrome = true; + }); + testWidgets('title bar background follows workbench canvas', (tester) async { late Color background; @@ -143,4 +151,33 @@ void main() { ); expect(find.text('Read-only'), findsWidgets); }); + + testWidgets('toggle sidebar button calls onToggleSidebar when pressed', + (tester) async { + var toggled = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.SizedBox( + width: 1000, + child: QueryaWindowTitleBar( + onNewDatabaseConnection: () async {}, + onNewDatabaseConnectionFromUrl: () async {}, + isSidebarVisible: true, + onToggleSidebar: () { + toggled = true; + }, + ), + ), + ), + ); + + final button = find.byKey(const Key('title_bar_toggle_sidebar_button')); + expect(button, findsOneWidget); + + await tester.tap(button); + await tester.pump(); + + expect(toggled, isTrue); + }); } diff --git a/test/features/main_screen/results_tab_test.dart b/test/features/main_screen/results_tab_test.dart index 2ed4a56a..e25eeeb6 100644 --- a/test/features/main_screen/results_tab_test.dart +++ b/test/features/main_screen/results_tab_test.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/motion/querya_fade_slide.dart'; import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_staging_toolbar.dart'; import 'package:querya_desktop/features/main_screen/result_grid_view.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; @@ -61,6 +63,101 @@ void main() { }); }); + group('sortResultGridRows', () { + test('sorts numerically when all values are numbers', () { + final rows = [ + ['10', 'b'], + ['2', 'a'], + ['1', 'c'], + ]; + final sortedAsc = sortResultGridRows( + rows: rows, + columnIndex: 0, + order: ResultGridSortOrder.ascending, + ); + expect(sortedAsc.map((r) => r[0]).toList(), ['1', '2', '10']); + + final sortedDesc = sortResultGridRows( + rows: rows, + columnIndex: 0, + order: ResultGridSortOrder.descending, + ); + expect(sortedDesc.map((r) => r[0]).toList(), ['10', '2', '1']); + }); + + test('sorts lexicographically for non-numeric strings', () { + final rows = [ + ['banana'], + ['Apple'], + ['cherry'], + ]; + final sorted = sortResultGridRows( + rows: rows, + columnIndex: 0, + order: ResultGridSortOrder.ascending, + ); + expect(sorted.map((r) => r[0]).toList(), ['Apple', 'banana', 'cherry']); + }); + + test('handles NULL and empty values gracefully', () { + final rows = [ + ['100'], + ['NULL'], + ['50'], + [''], + ]; + final sorted = sortResultGridRows( + rows: rows, + columnIndex: 0, + order: ResultGridSortOrder.ascending, + ); + expect(sorted.map((r) => r[0]).take(2).toList(), ['50', '100']); + }); + }); + + group('ResultGridSelection', () { + test('contains point inside bounding box', () { + const selection = ResultGridSelection( + startRow: 1, + startColumn: 1, + endRow: 3, + endColumn: 4, + ); + expect(selection.contains(1, 1), isTrue); + expect(selection.contains(2, 3), isTrue); + expect(selection.contains(3, 4), isTrue); + expect(selection.contains(0, 1), isFalse); + expect(selection.contains(1, 5), isFalse); + }); + + test('toTsv formats tab-delimited grid', () { + final rows = [ + ['1', 'Alice', 'Engineer'], + ['2', 'Bob', 'Designer'], + ['3', 'Charlie', 'Manager'], + ]; + final selection = ResultGridSelection.fromPoints( + anchor: const ResultGridCellCoordinate(0, 1), + focus: const ResultGridCellCoordinate(1, 2), + ); + final tsv = selection.toTsv(rows); + expect(tsv, 'Alice\tEngineer\nBob\tDesigner'); + }); + + test('toCsv formats comma-separated grid with quotes if needed', () { + final rows = [ + ['1', 'Alice, Jr.', 'Engineer'], + ['2', 'Bob', 'Designer'], + ]; + final selection = ResultGridSelection.fromPoints( + anchor: const ResultGridCellCoordinate(0, 0), + focus: const ResultGridCellCoordinate(1, 1), + ); + final csv = selection.toCsv(rows); + expect(csv, '1,"Alice, Jr."\n2,Bob'); + }); + }); + group('computeVisibleColumnWindow', () { test('returns empty for no columns', () { expect( @@ -494,5 +591,170 @@ void main() { ); expect(find.text('Rows affected: 4'), findsOneWidget); }); + + testWidgets('allows manual column drag-resizing in VirtualResultGrid', + (tester) async { + await tester.pumpWidget( + resultsShell( + child: const material.Scaffold( + body: material.SizedBox( + width: 800, + height: 400, + child: VirtualResultGrid( + columns: ['id', 'username', 'email'], + rows: [ + ['1', 'alice', 'alice@example.com'], + ['2', 'bob', 'bob@example.com'], + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('id'), findsOneWidget); + expect(find.text('username'), findsOneWidget); + expect(find.text('email'), findsOneWidget); + + // Verify resize handles are rendered with resizeColumn cursor + final resizeRegions = find.byWidgetPredicate( + (w) => + w is material.MouseRegion && + w.cursor == material.SystemMouseCursors.resizeColumn, + ); + expect(resizeRegions, findsNWidgets(3)); + + // Drag the first column handle by +50 pixels + final firstHandle = resizeRegions.first; + final startCenter = tester.getCenter(firstHandle); + await tester.dragFrom(startCenter, const material.Offset(50, 0)); + await tester.pumpAndSettle(); + + // Column headers and rows remain stable and rendered + expect(find.text('alice'), findsOneWidget); + expect(find.text('bob'), findsOneWidget); + }); + + testWidgets('allows sorting by clicking column headers in VirtualResultGrid', + (tester) async { + await tester.pumpWidget( + resultsShell( + child: const material.Scaffold( + body: material.SizedBox( + width: 800, + height: 400, + child: VirtualResultGrid( + columns: ['id', 'score'], + rows: [ + ['1', '100'], + ['2', '20'], + ['3', '500'], + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Tap on 'score' header -> Ascending sort + await tester.tap(find.text('score')); + await tester.pumpAndSettle(); + + // Arrow up icon should appear + expect(find.byIcon(material.Icons.arrow_upward_rounded), findsOneWidget); + + // Tap again -> Descending sort + await tester.tap(find.text('score')); + await tester.pumpAndSettle(); + + // Arrow down icon should appear + expect(find.byIcon(material.Icons.arrow_downward_rounded), findsOneWidget); + + // Tap again -> Reset to natural order + await tester.tap(find.text('score')); + await tester.pumpAndSettle(); + + expect(find.byIcon(material.Icons.arrow_upward_rounded), findsNothing); + expect(find.byIcon(material.Icons.arrow_downward_rounded), findsNothing); + }); + + testWidgets('renders DataGridStagingToolbar and updates on add/delete/revert', + (tester) async { + final staging = DataGridStagingBuffer( + columns: ['id', 'name'], + rows: [ + ['1', 'Alice'], + ['2', 'Bob'], + ], + ); + + var appliedChanges = 0; + + await tester.pumpWidget( + resultsShell( + child: material.Scaffold( + body: material.SizedBox( + width: 800, + height: 500, + child: ResultsTab( + columns: const ['id', 'name'], + rows: const [ + ['1', 'Alice'], + ['2', 'Bob'], + ], + stagingBuffer: staging, + onApplyChanges: () => appliedChanges++, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Verify DataGridStagingToolbar is rendered + expect(find.byType(DataGridStagingToolbar), findsOneWidget); + expect(find.text('No changes'), findsOneWidget); + expect(find.text('Add Row'), findsOneWidget); + + // Tap 'Add Row' -> appends a new row + await tester.tap(find.text('Add Row')); + await tester.pumpAndSettle(); + + expect(staging.isDirty, isTrue); + expect(staging.totalRowCount, 3); + expect(find.text('1 pending change'), findsOneWidget); + expect(find.text('Revert All'), findsOneWidget); + + // Tap 'Revert All' -> resets buffer + await tester.tap(find.text('Revert All')); + await tester.pumpAndSettle(); + + expect(staging.isDirty, isFalse); + expect(staging.totalRowCount, 2); + expect(find.text('No changes'), findsOneWidget); + + // Select row 0 in grid + await tester.tap(find.text('Alice')); + await tester.pump(const Duration(milliseconds: 350)); + await tester.pumpAndSettle(); + + // Tap 'Delete Row' in toolbar + await tester.tap(find.text('Delete Row')); + await tester.pumpAndSettle(); + + expect(staging.isDirty, isTrue); + expect(staging.deletedRowCount, 1); + expect(find.text('Restore Row'), findsOneWidget); + + // Save button should now be enabled; tap it + await tester.ensureVisible(find.text('Save Changes')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Save Changes')); + await tester.pumpAndSettle(); + + expect(appliedChanges, 1); + }); }); } diff --git a/test/features/main_screen/sidebar_toggle_test.dart b/test/features/main_screen/sidebar_toggle_test.dart new file mode 100644 index 00000000..29084640 --- /dev/null +++ b/test/features/main_screen/sidebar_toggle_test.dart @@ -0,0 +1,193 @@ +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/motion/querya_spring.dart'; +import 'package:querya_desktop/core/motion/querya_spring_controller.dart'; +import 'package:querya_desktop/core/storage/app_data_root.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/main_screen/querya_window_title_bar.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('querya_sidebar_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + AppDataRoot.resetMocks(); + AppDataRoot.mockInstallDirectory = tempDir.path; + AppDataRoot.mockEnvironment = {'QUERYA_PORTABLE': '1'}; + QueryaWindowTitleBar.useNativeWindowChrome = false; + }); + + tearDown(() async { + QueryaWindowTitleBar.useNativeWindowChrome = true; + AppDataRoot.resetMocks(); + try { + await LocalDb.instance.close(); + await tempDir.delete(recursive: true); + } catch (_) {} + }); + + group('Fluid Collapsible Sidebar (#557)', () { + test('AppSettings preserves sidebarVisible across sets and gets', + () async { + expect(await AppSettings.instance.getSidebarVisible(), isTrue); + + await AppSettings.instance.setSidebarVisible(false); + expect(await AppSettings.instance.getSidebarVisible(), isFalse); + + await AppSettings.instance.setSidebarVisible(true); + expect(await AppSettings.instance.getSidebarVisible(), isTrue); + }); + + testWidgets('title bar sidebar button reflects visibility and fires toggle', + (tester) async { + var toggleCount = 0; + var isVisible = true; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.StatefulBuilder( + builder: (context, setState) { + return material.SizedBox( + width: 800, + child: QueryaWindowTitleBar( + onNewDatabaseConnection: () async {}, + onNewDatabaseConnectionFromUrl: () async {}, + isSidebarVisible: isVisible, + onToggleSidebar: () { + toggleCount++; + setState(() { + isVisible = !isVisible; + }); + }, + ), + ); + }, + ), + ), + ); + await tester.pump(); + + final button = find.byKey(const Key('title_bar_toggle_sidebar_button')); + expect(button, findsOneWidget); + + await tester.tap(button); + await tester.pump(); + + expect(toggleCount, 1); + expect(isVisible, isFalse); + + await tester.tap(button); + await tester.pump(); + + expect(toggleCount, 2); + expect(isVisible, isTrue); + }); + + testWidgets('QueryaSpringController smoothly collapses and expands width', + (tester) async { + late QueryaSpringController controller; + + await tester.pumpWidget( + material.MaterialApp( + home: _SpringTestHost( + initialWidth: 260, + onCreated: (c) => controller = c, + ), + ), + ); + await tester.pump(); + + expect(controller.value, 260); + expect(controller.isAnimating, isFalse); + + // Animate collapse to 0 + controller.animateTo(0); + await tester.pump(); + expect(controller.isAnimating, isTrue); + + // Step animation forward + await tester.pump(const Duration(milliseconds: 100)); + expect(controller.value, lessThan(260)); + expect(controller.value, greaterThan(0)); + + // Step until settled + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(controller.value, closeTo(0, 0.01)); + expect(controller.isAnimating, isFalse); + + // Animate expand back to 260 + controller.animateTo(260); + await tester.pump(); + expect(controller.isAnimating, isTrue); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(controller.value, closeTo(260, 0.01)); + expect(controller.isAnimating, isFalse); + }); + }); +} + +class _SpringTestHost extends StatefulWidget { + const _SpringTestHost({ + required this.initialWidth, + required this.onCreated, + }); + + final double initialWidth; + final ValueChanged onCreated; + + @override + State<_SpringTestHost> createState() => _SpringTestHostState(); +} + +class _SpringTestHostState extends State<_SpringTestHost> + with SingleTickerProviderStateMixin { + late final QueryaSpringController _controller; + + @override + void initState() { + super.initState(); + _controller = QueryaSpringController( + vsync: this, + value: widget.initialWidth, + spring: QueryaSpring.gentle, + ); + widget.onCreated(_controller); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + return SizedBox( + width: _controller.value, + height: 400, + ); + }, + ); + } +} diff --git a/test/features/onboarding/welcome_tour_dialog_test.dart b/test/features/onboarding/welcome_tour_dialog_test.dart new file mode 100644 index 00000000..761c24d5 --- /dev/null +++ b/test/features/onboarding/welcome_tour_dialog_test.dart @@ -0,0 +1,165 @@ +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/features/onboarding/welcome_tour_dialog.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.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; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('querya_welcome_tour_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + testWidgets('WelcomeTourDialog displays steps and allows navigation through all 4 slides', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: WelcomeTourDialog(), + ), + ), + ); + await tester.pumpAndSettle(); + + // Slide 1: Connect in Seconds + expect(find.text('Welcome to Querya'), findsOneWidget); + expect(find.text('Step 1 of 4'), findsOneWidget); + expect(find.text('Connect in Seconds'), findsOneWidget); + expect(find.byKey(const Key('welcome_tour_skip_button')), findsOneWidget); + expect(find.byKey(const Key('welcome_tour_next_button')), findsOneWidget); + + // Click Next -> Slide 2 + await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Step 2 of 4'), findsOneWidget); + expect(find.text('Fluid Sidebar & Navigation'), findsOneWidget); + expect(find.byKey(const Key('welcome_tour_prev_button')), findsOneWidget); + + // Click Next -> Slide 3 + await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Step 3 of 4'), findsOneWidget); + expect(find.text('Interactive SQL & 2D Grid'), findsOneWidget); + + // Click Next -> Slide 4 + await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Step 4 of 4'), findsOneWidget); + expect(find.text('Zero-Trust Security & Playground'), findsOneWidget); + expect(find.text('Get Started'), findsOneWidget); + + // Click Back -> Slide 3 + await tester.tap(find.byKey(const Key('welcome_tour_prev_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Step 3 of 4'), findsOneWidget); + expect(find.text('Interactive SQL & 2D Grid'), findsOneWidget); + }); + + testWidgets('WelcomeTourDialog launches demo playground on action button tap', + (tester) async { + var demoLaunched = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: WelcomeTourDialog( + onLaunchDemo: () => demoLaunched = true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Advance to slide 4 + await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Step 4 of 4'), findsOneWidget); + expect(find.byKey(const Key('welcome_tour_demo_button')), findsOneWidget); + + await tester.tap(find.byKey(const Key('welcome_tour_demo_button'))); + await tester.pumpAndSettle(); + + expect(demoLaunched, isTrue); + }); + + testWidgets('WelcomeTourDialog handles Skip button and dismisses dialog', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: WelcomeTourDialog(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('welcome_tour_skip_button')), findsOneWidget); + await tester.tap(find.byKey(const Key('welcome_tour_skip_button'))); + await tester.pumpAndSettle(); + }); + + testWidgets('WelcomeTourDialog exposes Home buttons and triggers onGoHome', + (tester) async { + var homeTriggered = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: WelcomeTourDialog( + onGoHome: () => homeTriggered = true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('welcome_tour_home_button')), findsOneWidget); + await tester.tap(find.byKey(const Key('welcome_tour_home_button'))); + await tester.pumpAndSettle(); + + expect(homeTriggered, isTrue); + }); +} diff --git a/third_party/refresh_rate/macos/refresh_rate/Package.swift b/third_party/refresh_rate/macos/refresh_rate/Package.swift index b4ad3b46..24bee2af 100644 --- a/third_party/refresh_rate/macos/refresh_rate/Package.swift +++ b/third_party/refresh_rate/macos/refresh_rate/Package.swift @@ -25,10 +25,10 @@ let package = Package( dependencies: [ .product(name: "FlutterFramework", package: "FlutterFramework"), ], - path: "Classes", + path: "../Classes", publicHeadersPath: ".", cSettings: [ - .headerSearchPath("Classes"), + .headerSearchPath("../Classes"), ] ), ]