From c210d6932b040f8f26fec2166b817215a4b66667 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 24 Sep 2026 11:39:22 +0300 Subject: [PATCH] fix(sql): confirm overwrite when restoring query from history into non-empty tab (Closes #912) --- .../extensions/extension_sql_workspace.dart | 1 + lib/features/mysql/mysql_sql_workspace.dart | 1 + .../postgresql/postgres_sql_workspace.dart | 1 + lib/features/sqlite/sqlite_sql_workspace.dart | 1 + .../workspace/sql_query_history_dialog.dart | 186 +++++++++-- .../sql_query_history_dialog_test.dart | 311 ++++++++++++++++++ 6 files changed, 482 insertions(+), 19 deletions(-) create mode 100644 test/features/workspace/sql_query_history_dialog_test.dart diff --git a/lib/features/extensions/extension_sql_workspace.dart b/lib/features/extensions/extension_sql_workspace.dart index f73e81e..cc2f20e 100644 --- a/lib/features/extensions/extension_sql_workspace.dart +++ b/lib/features/extensions/extension_sql_workspace.dart @@ -622,6 +622,7 @@ class _ExtensionSqlWorkspaceState connectionId: widget.connectionRow.id!, databaseName: widget.connectionRow.databaseName, sqlController: session.controller, + onOpenInNewTab: (sql) => _addNewTab(initialSql: sql), ); } : null, diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 939257e..e8838d7 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -871,6 +871,7 @@ class _MysqlSqlWorkspaceState extends material.State { connectionId: widget.connectionRow.id!, databaseName: widget.connectionRow.databaseName, sqlController: session.controller, + onOpenInNewTab: (sql) => _addNewTab(initialSql: sql), ); } : null, diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 18bed40..503dcfb 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -943,6 +943,7 @@ class _PostgresSqlWorkspaceState extends material.State { connectionId: widget.connectionRow.id!, databaseName: _effectiveSessionDatabase(), sqlController: session.controller, + onOpenInNewTab: (sql) => _addNewTab(initialSql: sql), ); } : null, diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index d349289..f35fb82 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -782,6 +782,7 @@ class _SqliteSqlWorkspaceState extends material.State { connectionId: widget.connectionRow.id!, databaseName: widget.connectionRow.databaseName, sqlController: session.controller, + onOpenInNewTab: (sql) => _addNewTab(initialSql: sql), ); } : null, diff --git a/lib/features/workspace/sql_query_history_dialog.dart b/lib/features/workspace/sql_query_history_dialog.dart index 086650f..1315518 100644 --- a/lib/features/workspace/sql_query_history_dialog.dart +++ b/lib/features/workspace/sql_query_history_dialog.dart @@ -1,18 +1,22 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart' show Clipboard, ClipboardData; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -/// Shows recent SQL for this connection + database; choosing a row replaces the editor text. +/// Shows recent SQL for this connection + database; choosing a row replaces the editor text +/// or prompts if the editor has uncommitted content. void showSqlQueryHistoryDialog({ required BuildContext context, required int connectionId, String? databaseName, required material.TextEditingController sqlController, + void Function(String sql)? onOpenInNewTab, + @visibleForTesting Future> Function()? loadHistory, }) { showAppDialog( context: context, @@ -20,20 +24,32 @@ void showSqlQueryHistoryDialog({ connectionId: connectionId, databaseName: databaseName, sqlController: sqlController, + onOpenInNewTab: onOpenInNewTab, + loadHistory: loadHistory, ), ); } +enum _HistoryApplyAction { + replace, + openInNewTab, + copy, +} + class _SqlQueryHistoryDialogContent extends material.StatefulWidget { const _SqlQueryHistoryDialogContent({ required this.connectionId, required this.databaseName, required this.sqlController, + this.onOpenInNewTab, + this.loadHistory, }); final int connectionId; final String? databaseName; final material.TextEditingController sqlController; + final void Function(String sql)? onOpenInNewTab; + final Future> Function()? loadHistory; @override material.State<_SqlQueryHistoryDialogContent> createState() => @@ -51,6 +67,9 @@ class _SqlQueryHistoryDialogContentState } Future> _load() async { + if (widget.loadHistory != null) { + return widget.loadHistory!(); + } final cap = await AppSettings.instance.getSqlHistoryMaxEntries(); return LocalDb.instance.listSqlQueryHistory( connectionId: widget.connectionId, @@ -125,8 +144,7 @@ class _SqlQueryHistoryDialogContentState _reload(); } - void _apply(SqlQueryHistoryEntry e) { - final text = e.sqlText; + void _applyDirectly(String text) { widget.sqlController.value = material.TextEditingValue( text: text, selection: material.TextSelection.collapsed(offset: text.length), @@ -134,6 +152,101 @@ class _SqlQueryHistoryDialogContentState material.Navigator.of(context).pop(); } + Future _copyToClipboard(String text) async { + await Clipboard.setData(ClipboardData(text: text)); + if (!mounted) return; + showAppToast( + context: context, + message: 'Query copied to clipboard', + variant: AppToastVariant.success, + ); + } + + void _openInNewTab(String text) { + widget.onOpenInNewTab?.call(text); + material.Navigator.of(context).pop(); + } + + Future _onEntryTapped(SqlQueryHistoryEntry e) async { + final current = widget.sqlController.text; + final isEditorEmpty = current.trim().isEmpty; + final isSame = current.trim() == e.sqlText.trim(); + + if (isEditorEmpty || isSame) { + _applyDirectly(e.sqlText); + return; + } + + final action = await _confirmOverwrite(e.sqlText); + if (!mounted || action == null) return; + + switch (action) { + case _HistoryApplyAction.replace: + _applyDirectly(e.sqlText); + break; + case _HistoryApplyAction.openInNewTab: + _openInNewTab(e.sqlText); + break; + case _HistoryApplyAction.copy: + await _copyToClipboard(e.sqlText); + break; + } + } + + Future<_HistoryApplyAction?> _confirmOverwrite(String sql) async { + return showAppDialog<_HistoryApplyAction>( + context: context, + builder: (ctx) => QueryaDialogCard( + constraints: const material.BoxConstraints(maxWidth: 440), + child: material.Padding( + padding: const material.EdgeInsets.all(20), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Replace editor content?').semiBold().large(), + const Gap(8), + const Text( + 'The active tab already contains query text. Overwriting it will discard unsaved text.', + ).muted().small(), + const Gap(20), + material.Align( + alignment: material.Alignment.centerRight, + child: material.Wrap( + alignment: material.WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: [ + GhostButton( + onPressed: () => material.Navigator.of(ctx).pop(null), + child: const Text('Cancel'), + ), + OutlineButton( + onPressed: () => material.Navigator.of(ctx) + .pop(_HistoryApplyAction.copy), + child: const Text('Copy to Clipboard'), + ), + if (widget.onOpenInNewTab != null) + OutlineButton( + onPressed: () => material.Navigator.of(ctx) + .pop(_HistoryApplyAction.openInNewTab), + child: const Text('Open in New Tab'), + ), + DestructiveButton( + onPressed: () => material.Navigator.of(ctx) + .pop(_HistoryApplyAction.replace), + child: const Text('Replace'), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + @override material.Widget build(material.BuildContext context) { final scheme = Theme.of(context).colorScheme; @@ -204,29 +317,64 @@ class _SqlQueryHistoryDialogContentState return material.Material( color: material.Colors.transparent, child: material.InkWell( - onTap: () => _apply(e), + onTap: () => unawaited(_onEntryTapped(e)), child: material.Padding( padding: const material.EdgeInsets.symmetric( horizontal: 12, - vertical: 10, + vertical: 8, ), - child: material.Column( - crossAxisAlignment: - material.CrossAxisAlignment.start, + child: material.Row( children: [ - material.Text( - _previewOneLine(e.sqlText), - maxLines: 2, - overflow: material.TextOverflow.ellipsis, - style: material.TextStyle( - fontFamily: QueryaTypography.mono, - fontSize: 12, - color: scheme.foreground, + material.Expanded( + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.start, + children: [ + material.Text( + _previewOneLine(e.sqlText), + maxLines: 2, + overflow: + material.TextOverflow.ellipsis, + style: material.TextStyle( + fontFamily: QueryaTypography.mono, + fontSize: 12, + color: scheme.foreground, + ), + ), + if (when != null) ...[ + const material.SizedBox(height: 4), + Text(when).muted().xSmall(), + ], + ], ), ), - if (when != null) ...[ - const material.SizedBox(height: 4), - Text(when).muted().xSmall(), + const Gap(8), + material.Tooltip( + message: 'Copy to clipboard', + child: IconButton.ghost( + density: ButtonDensity.compact, + onPressed: () => + unawaited(_copyToClipboard(e.sqlText)), + icon: const material.Icon( + material.Icons.copy_rounded, + size: 15, + ), + ), + ), + if (widget.onOpenInNewTab != null) ...[ + const Gap(4), + material.Tooltip( + message: 'Open in new tab', + child: IconButton.ghost( + density: ButtonDensity.compact, + onPressed: () => + _openInNewTab(e.sqlText), + icon: const material.Icon( + material.Icons.open_in_new_rounded, + size: 15, + ), + ), + ), ], ], ), diff --git a/test/features/workspace/sql_query_history_dialog_test.dart b/test/features/workspace/sql_query_history_dialog_test.dart new file mode 100644 index 0000000..f709096 --- /dev/null +++ b/test/features/workspace/sql_query_history_dialog_test.dart @@ -0,0 +1,311 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/workspace/sql_query_history_dialog.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const connectionId = 999; + const databaseName = 'test_db'; + + var clipboardContent = ''; + setUp(() { + clipboardContent = ''; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + SystemChannels.platform, + (MethodCall methodCall) async { + if (methodCall.method == 'Clipboard.setData') { + clipboardContent = (methodCall.arguments as Map)['text'] as String; + return null; + } + if (methodCall.method == 'Clipboard.getData') { + return {'text': clipboardContent}; + } + return null; + }, + ); + }); + + SqlQueryHistoryEntry makeEntry(String sql, {int id = 1}) { + return SqlQueryHistoryEntry( + id: id, + connectionId: connectionId, + databaseName: databaseName, + sqlText: sql, + recordedAt: '2026-09-24T10:00:00.000Z', + ); + } + + testWidgets('applies directly without prompt when active editor is empty', (tester) async { + final controller = material.TextEditingController(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () { + showSqlQueryHistoryDialog( + context: context, + connectionId: connectionId, + databaseName: databaseName, + sqlController: controller, + loadHistory: () async => [makeEntry('SELECT 100;')], + ); + }, + child: const material.Text('Open History'), + ), + ), + ), + ); + + await tester.tap(find.text('Open History')); + await tester.pumpAndSettle(); + + expect(find.text('Query history'), findsOneWidget); + expect(find.text('SELECT 100;'), findsOneWidget); + + // Tap the row + await tester.tap(find.text('SELECT 100;')); + await tester.pumpAndSettle(); + + // Editor is populated and dialog is closed + expect(controller.text, 'SELECT 100;'); + expect(find.text('Query history'), findsNothing); + }); + + testWidgets('applies directly without prompt when active editor has same text', (tester) async { + final controller = material.TextEditingController(text: 'SELECT 100;'); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () { + showSqlQueryHistoryDialog( + context: context, + connectionId: connectionId, + databaseName: databaseName, + sqlController: controller, + loadHistory: () async => [makeEntry('SELECT 100;')], + ); + }, + child: const material.Text('Open History'), + ), + ), + ), + ); + + await tester.tap(find.text('Open History')); + await tester.pumpAndSettle(); + + // Tap the row + await tester.tap(find.text('SELECT 100;')); + await tester.pumpAndSettle(); + + expect(find.text('Replace editor content?'), findsNothing); + expect(controller.text, 'SELECT 100;'); + expect(find.text('Query history'), findsNothing); + }); + + testWidgets('prompts confirmation when editor has existing text and allows Cancel', (tester) async { + final controller = material.TextEditingController(text: 'SELECT custom_work;'); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () { + showSqlQueryHistoryDialog( + context: context, + connectionId: connectionId, + databaseName: databaseName, + sqlController: controller, + loadHistory: () async => [makeEntry('SELECT * FROM users;')], + ); + }, + child: const material.Text('Open History'), + ), + ), + ), + ); + + await tester.tap(find.text('Open History')); + await tester.pumpAndSettle(); + + // Tap the history entry + await tester.tap(find.text('SELECT * FROM users;')); + await tester.pumpAndSettle(); + + // Confirmation dialog is shown + expect(find.text('Replace editor content?'), findsOneWidget); + expect(find.text('Replace'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + + // Cancel retains original editor content + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(controller.text, 'SELECT custom_work;'); + expect(find.text('Query history'), findsOneWidget); + }); + + testWidgets('confirms replace when user chooses Replace', (tester) async { + final controller = material.TextEditingController(text: 'SELECT custom_work;'); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () { + showSqlQueryHistoryDialog( + context: context, + connectionId: connectionId, + databaseName: databaseName, + sqlController: controller, + loadHistory: () async => [makeEntry('SELECT * FROM users;')], + ); + }, + child: const material.Text('Open History'), + ), + ), + ), + ); + + await tester.tap(find.text('Open History')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('SELECT * FROM users;')); + await tester.pumpAndSettle(); + + expect(find.text('Replace editor content?'), findsOneWidget); + + await tester.tap(find.text('Replace')); + await tester.pumpAndSettle(); + + expect(controller.text, 'SELECT * FROM users;'); + expect(find.text('Query history'), findsNothing); + }); + + testWidgets('allows Open in New Tab from confirmation dialog', (tester) async { + final controller = material.TextEditingController(text: 'SELECT my_draft;'); + String? newTabSql; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () { + showSqlQueryHistoryDialog( + context: context, + connectionId: connectionId, + databaseName: databaseName, + sqlController: controller, + onOpenInNewTab: (sql) => newTabSql = sql, + loadHistory: () async => [makeEntry('SELECT * FROM orders;')], + ); + }, + child: const material.Text('Open History'), + ), + ), + ), + ); + + await tester.tap(find.text('Open History')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('SELECT * FROM orders;')); + await tester.pumpAndSettle(); + + expect(find.text('Open in New Tab'), findsOneWidget); + + await tester.tap(find.text('Open in New Tab')); + await tester.pumpAndSettle(); + + expect(newTabSql, 'SELECT * FROM orders;'); + // Original editor is untouched + expect(controller.text, 'SELECT my_draft;'); + expect(find.text('Query history'), findsNothing); + }); + + testWidgets('quick action button opens in new tab directly', (tester) async { + final controller = material.TextEditingController(text: 'SELECT untouched;'); + String? newTabSql; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () { + showSqlQueryHistoryDialog( + context: context, + connectionId: connectionId, + databaseName: databaseName, + sqlController: controller, + onOpenInNewTab: (sql) => newTabSql = sql, + loadHistory: () async => [makeEntry('SELECT quick_tab;')], + ); + }, + child: const material.Text('Open History'), + ), + ), + ), + ); + + await tester.tap(find.text('Open History')); + await tester.pumpAndSettle(); + + final openTabIcon = find.byIcon(material.Icons.open_in_new_rounded); + expect(openTabIcon, findsOneWidget); + + await tester.tap(openTabIcon); + await tester.pumpAndSettle(); + + expect(newTabSql, 'SELECT quick_tab;'); + expect(controller.text, 'SELECT untouched;'); + expect(find.text('Query history'), findsNothing); + }); + + testWidgets('quick action copy to clipboard copies text', (tester) async { + final controller = material.TextEditingController(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () { + showSqlQueryHistoryDialog( + context: context, + connectionId: connectionId, + databaseName: databaseName, + sqlController: controller, + loadHistory: () async => [makeEntry('SELECT copy_me;')], + ); + }, + child: const material.Text('Open History'), + ), + ), + ), + ); + + await tester.tap(find.text('Open History')); + await tester.pumpAndSettle(); + + final copyIcon = find.byIcon(material.Icons.copy_rounded); + expect(copyIcon, findsOneWidget); + + await tester.tap(copyIcon); + await tester.pumpAndSettle(); + + final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); + expect(clipboardData?.text, 'SELECT copy_me;'); + expect(find.text('Query copied to clipboard'), findsOneWidget); + + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + }); +}