diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c293830..bf42d0ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Mongo document editor Back (#782)** — Dirty JSON in `MongoDocumentEditor` is registered with `UnsavedWorkRegistry`. Breadcrumb Back, Home, Close, and tree navigation confirm before discarding; Cancel keeps the editor. - **Mongo JSON filter ObjectId / DateTime (#783)** — Document list filter parses Extended JSON (`$oid`, `$date`) and wraps a 24-character hex `_id` as `ObjectId`. A leftover string `_id` with zero matches shows how to write `{ "_id": { "$oid": "…" } }`. - **Postgres open-transaction probe (#787)** — `BEGIN` + `SELECT` does not assign an XID, so `pg_current_xact_id_if_assigned` stayed NULL (SQL-tab badge off; autocommit-off prepended a second `BEGIN`). The session tracks BEGIN/COMMIT/ROLLBACK and otherwise probes `pg_stat_activity.xact_start` (PG 9+). Implicit BEGIN when autocommit is off stays a separate `execute`. - **Postgres Table Browser paging (#788)** — Browse stays on `PgSessionMode.readOnly`. `SELECT` uses `ORDER BY` primary-key columns when a PK exists. Row totals come from `pg_class.reltuples` instead of a blocking `COUNT(*)` before first paint (stale estimates below the current page are ignored so Next still works). Save / REFRESH stay on `tableWrite`. diff --git a/lib/core/unsaved_work_guard.dart b/lib/core/unsaved_work_guard.dart index f8d63029..7f745245 100644 --- a/lib/core/unsaved_work_guard.dart +++ b/lib/core/unsaved_work_guard.dart @@ -2,8 +2,9 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/unsaved_work_registry.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -/// Confirms before Home / Close / tree navigation discards SQL or staged grid -/// edits. Returns true when it is safe to tear down the current view. +/// Confirms before Home / Close / tree navigation discards SQL, staged grid +/// edits, or a dirty document editor. Returns true when it is safe to tear +/// down the current view. Future confirmDiscardUnsavedWorkIfNeeded( material.BuildContext context, ) async { @@ -12,7 +13,7 @@ Future confirmDiscardUnsavedWorkIfNeeded( return confirmed == true; } -/// Prompt when leaving a workspace that has unsaved SQL or table edits. +/// Prompt when leaving a workspace that has unsaved SQL, table, or document edits. Future showDiscardUnsavedWorkDialog(material.BuildContext context) { return showAppDialog( context: context, @@ -28,8 +29,8 @@ Future showDiscardUnsavedWorkDialog(material.BuildContext context) { const Text('Unsaved changes').semiBold().large(), const Gap(8), const Text( - 'You have unsaved SQL or staged table edits. ' - 'Continuing will discard them.', + 'You have unsaved SQL, staged table edits, or document ' + 'changes. Continuing will discard them.', ).muted().small(), const Gap(20), material.Align( diff --git a/lib/core/unsaved_work_registry.dart b/lib/core/unsaved_work_registry.dart index 8511a9df..7f02855a 100644 --- a/lib/core/unsaved_work_registry.dart +++ b/lib/core/unsaved_work_registry.dart @@ -1,6 +1,6 @@ import 'package:flutter/foundation.dart'; -/// Process-wide probes for unsaved SQL / staged grid edits. +/// Process-wide probes for unsaved SQL, staged grid edits, and document editors. /// /// Used by the in-app updater (and similar quit paths) to warn before /// discarding work. Owners register a probe and must unregister on dispose. diff --git a/lib/features/mongodb/mongo_document_editor.dart b/lib/features/mongodb/mongo_document_editor.dart index 9401cc83..6e6e52c6 100644 --- a/lib/features/mongodb/mongo_document_editor.dart +++ b/lib/features/mongodb/mongo_document_editor.dart @@ -1,9 +1,13 @@ +import 'dart:async' show unawaited; + import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; +import 'package:querya_desktop/core/unsaved_work_guard.dart'; +import 'package:querya_desktop/core/unsaved_work_registry.dart'; import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -55,6 +59,7 @@ class _MongoDocumentEditorState extends material.State { text: mongoDocumentToEjson(widget.document), ); _controller.addListener(_onTextChanged); + UnsavedWorkRegistry.instance.register(this, () => _dirty || _saving); } @override @@ -102,11 +107,18 @@ class _MongoDocumentEditorState extends material.State { @override void dispose() { + UnsavedWorkRegistry.instance.unregister(this); _controller.removeListener(_onTextChanged); _controller.dispose(); super.dispose(); } + Future _onBack() async { + if (!await confirmDiscardUnsavedWorkIfNeeded(context)) return; + if (!mounted) return; + widget.onBack?.call(); + } + void _onTextChanged() { if (!_dirty) { setState(() => _dirty = true); @@ -237,7 +249,7 @@ class _MongoDocumentEditorState extends material.State { child: Row( children: [ material.InkWell( - onTap: widget.onBack, + onTap: () => unawaited(_onBack()), borderRadius: material.BorderRadius.circular(6), child: material.Padding( padding: const material.EdgeInsets.all(4), diff --git a/lib/features/mongodb/mongo_explorer_view.dart b/lib/features/mongodb/mongo_explorer_view.dart index ddb1580a..ef7938e8 100644 --- a/lib/features/mongodb/mongo_explorer_view.dart +++ b/lib/features/mongodb/mongo_explorer_view.dart @@ -1,8 +1,11 @@ +import 'dart:async' show unawaited; + import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/motion/querya_switching_body.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/unsaved_work_guard.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; @@ -192,7 +195,14 @@ class _MongoExplorerViewState extends material.State { return list; } - void _onCrumbTap(_Crumb crumb) { + Future _onCrumbTap(_Crumb crumb) async { + final leavesDocument = _selectedDocument != null && + crumb.level != _Level.document && + crumb.level != _Level.stats; + if (leavesDocument) { + if (!await confirmDiscardUnsavedWorkIfNeeded(context)) return; + if (!mounted) return; + } if (_showStats && crumb.level != _Level.stats) { setState(() => _showStats = false); } @@ -275,7 +285,7 @@ class _MongoExplorerViewState extends material.State { // Breadcrumb bar _BreadcrumbBar( crumbs: _crumbs, - onCrumbTap: _onCrumbTap, + onCrumbTap: (crumb) => unawaited(_onCrumbTap(crumb)), onRefresh: () => setState(() => _refreshToken++), onStats: () => setState(() => _showStats = !_showStats), ), diff --git a/lib/features/updater/update_dialog.dart b/lib/features/updater/update_dialog.dart index 758a03db..be638f41 100644 --- a/lib/features/updater/update_dialog.dart +++ b/lib/features/updater/update_dialog.dart @@ -53,8 +53,8 @@ Future showUnsavedUpdateRestartDialog(material.BuildContext context) { const Text('Unsaved changes').semiBold().large(), const Gap(8), const Text( - 'You have unsaved SQL or staged table changes. ' - 'Restarting to install the update will discard them.', + 'You have unsaved SQL, staged table edits, or document ' + 'changes. Restarting to install the update will discard them.', ).muted().small(), const Gap(20), material.Align( diff --git a/test/features/mongodb/mongo_document_editor_test.dart b/test/features/mongodb/mongo_document_editor_test.dart index 8f45e2ef..1cb72c26 100644 --- a/test/features/mongodb/mongo_document_editor_test.dart +++ b/test/features/mongodb/mongo_document_editor_test.dart @@ -4,6 +4,7 @@ import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/syntax_highlight_service.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/unsaved_work_registry.dart'; import 'package:querya_desktop/features/mongodb/mongo_document_editor.dart'; import '../../support/pump_syntax_highlight.dart'; @@ -15,12 +16,15 @@ void main() { await SyntaxHighlightService.ensureInitialized(); }); + tearDown(UnsavedWorkRegistry.instance.resetForTest); + final connection = MongoConnection(id: 1, name: 'test', host: 'localhost'); Future pumpEditor( WidgetTester tester, { QueryaTheme? theme, Map document = const {'_id': 'abc', 'a': 1}, + material.VoidCallback? onBack, }) async { await tester.pumpWidget( queryaThemeTestShell( @@ -33,6 +37,7 @@ void main() { database: 'db', collection: 'items', document: document, + onBack: onBack, ), ), ), @@ -123,4 +128,49 @@ void main() { expect(deleted, isFalse); expect(find.text('Delete'), findsOneWidget); }); + + testWidgets('clean Back leaves the editor without a confirm dialog', + (tester) async { + var wentBack = false; + await pumpEditor(tester, onBack: () => wentBack = true); + expect(UnsavedWorkRegistry.instance.hasUnsaved, isFalse); + + await tester.tap(find.byIcon(material.Icons.arrow_back_rounded)); + await tester.pumpAndSettle(); + + expect(wentBack, isTrue); + expect(find.text('Unsaved changes'), findsNothing); + }); + + testWidgets('dirty Back Cancel keeps edits; Discard calls onBack', + (tester) async { + await tester.binding.setSurfaceSize(const material.Size(800, 700)); + var wentBack = false; + await pumpEditor(tester, onBack: () => wentBack = true); + + await tester.enterText(find.byType(material.EditableText), '{"a":2}'); + await tester.pump(); + expect(UnsavedWorkRegistry.instance.hasUnsaved, isTrue); + + await tester.tap(find.byIcon(material.Icons.arrow_back_rounded)); + await tester.pumpAndSettle(); + expect(find.text('Unsaved changes'), findsOneWidget); + expect(wentBack, isFalse); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(wentBack, isFalse); + expect(UnsavedWorkRegistry.instance.hasUnsaved, isTrue); + + await tester.tap(find.byIcon(material.Icons.arrow_back_rounded)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Discard')); + await tester.pumpAndSettle(); + expect(wentBack, isTrue); + + await tester.pumpWidget( + queryaThemeTestShell(child: const material.SizedBox()), + ); + expect(UnsavedWorkRegistry.instance.hasUnsaved, isFalse); + }); }