diff --git a/lib/features/extensions/extension_sql_workspace.dart b/lib/features/extensions/extension_sql_workspace.dart index 7c60f12d..f73e81e7 100644 --- a/lib/features/extensions/extension_sql_workspace.dart +++ b/lib/features/extensions/extension_sql_workspace.dart @@ -193,10 +193,26 @@ class _ExtensionSqlWorkspaceState if (index < 0 || index >= _sessions.length) return; if (_sessions.length <= 1) return; final session = _sessions[index]; - if (session.stagingBuffer != null && session.stagingBuffer!.isDirty) { + if (session.isDirty) { + final hasDirtyStaging = + session.stagingBuffer != null && session.stagingBuffer!.isDirty; + final hasUnsavedText = session.isModified || + (session.filePath == null && session.controller.text.trim().isNotEmpty); + final String message; + if (hasDirtyStaging && hasUnsavedText) { + message = + 'This query tab contains unsaved query text and staged database changes. Closing the tab will discard them.'; + } else if (hasDirtyStaging) { + message = + 'This query tab contains staged database changes that have not been applied yet. Closing the tab will discard these changes.'; + } else { + message = + 'This query tab contains unsaved SQL query text. Closing the tab will discard your changes.'; + } final confirmed = await showUnsavedTabChangesDialog( context: context, tabTitle: session.title, + message: message, ); if (confirmed != true) return; } @@ -391,7 +407,7 @@ class _ExtensionSqlWorkspaceState selection: material.TextSelection.collapsed(offset: text.length), ); session.title = file.name; - session.filePath = file.path; + session.markSaved(newFilePath: file.path); setState(() {}); } else { _addNewTab(initialSql: text, title: file.name, filePath: file.path); @@ -412,6 +428,7 @@ class _ExtensionSqlWorkspaceState final existingPath = session.filePath; if (existingPath != null && existingPath.isNotEmpty) { await File(existingPath).writeAsString(session.controller.text); + session.markSaved(); if (!mounted) return; showAppToast( context: context, @@ -435,8 +452,8 @@ class _ExtensionSqlWorkspaceState await File(path).writeAsString(session.controller.text); if (!mounted) return; setState(() { - session.filePath = path; session.title = File(path).uri.pathSegments.last; + session.markSaved(newFilePath: path); }); showAppToast( context: context, diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 4703153f..939257e4 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -141,10 +141,26 @@ class _MysqlSqlWorkspaceState extends material.State { if (index < 0 || index >= _sessions.length) return; if (_sessions.length <= 1) return; final session = _sessions[index]; - if (session.stagingBuffer != null && session.stagingBuffer!.isDirty) { + if (session.isDirty) { + final hasDirtyStaging = + session.stagingBuffer != null && session.stagingBuffer!.isDirty; + final hasUnsavedText = session.isModified || + (session.filePath == null && session.controller.text.trim().isNotEmpty); + final String message; + if (hasDirtyStaging && hasUnsavedText) { + message = + 'This query tab contains unsaved query text and staged database changes. Closing the tab will discard them.'; + } else if (hasDirtyStaging) { + message = + 'This query tab contains staged database changes that have not been applied yet. Closing the tab will discard these changes.'; + } else { + message = + 'This query tab contains unsaved SQL query text. Closing the tab will discard your changes.'; + } final confirmed = await showUnsavedTabChangesDialog( context: context, tabTitle: session.title, + message: message, ); if (confirmed != true) return; } @@ -588,7 +604,7 @@ class _MysqlSqlWorkspaceState extends material.State { selection: material.TextSelection.collapsed(offset: text.length), ); session.title = file.name; - session.filePath = file.path; + session.markSaved(newFilePath: file.path); setState(() {}); } else { _addNewTab(initialSql: text, title: file.name, filePath: file.path); @@ -609,6 +625,7 @@ class _MysqlSqlWorkspaceState extends material.State { final existingPath = session.filePath; if (existingPath != null && existingPath.isNotEmpty) { await File(existingPath).writeAsString(session.controller.text); + session.markSaved(); if (!mounted) return; showAppToast( context: context, @@ -632,8 +649,8 @@ class _MysqlSqlWorkspaceState extends material.State { await File(path).writeAsString(session.controller.text); if (!mounted) return; setState(() { - session.filePath = path; session.title = File(path).uri.pathSegments.last; + session.markSaved(newFilePath: path); }); showAppToast( context: context, diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 8798a860..18bed409 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -176,10 +176,26 @@ class _PostgresSqlWorkspaceState extends material.State { if (index < 0 || index >= _sessions.length) return; if (_sessions.length <= 1) return; final session = _sessions[index]; - if (session.stagingBuffer != null && session.stagingBuffer!.isDirty) { + if (session.isDirty) { + final hasDirtyStaging = + session.stagingBuffer != null && session.stagingBuffer!.isDirty; + final hasUnsavedText = session.isModified || + (session.filePath == null && session.controller.text.trim().isNotEmpty); + final String message; + if (hasDirtyStaging && hasUnsavedText) { + message = + 'This query tab contains unsaved query text and staged database changes. Closing the tab will discard them.'; + } else if (hasDirtyStaging) { + message = + 'This query tab contains staged database changes that have not been applied yet. Closing the tab will discard these changes.'; + } else { + message = + 'This query tab contains unsaved SQL query text. Closing the tab will discard your changes.'; + } final confirmed = await showUnsavedTabChangesDialog( context: context, tabTitle: session.title, + message: message, ); if (confirmed != true) return; } @@ -712,7 +728,7 @@ class _PostgresSqlWorkspaceState extends material.State { selection: material.TextSelection.collapsed(offset: text.length), ); session.title = file.name; - session.filePath = file.path; + session.markSaved(newFilePath: file.path); setState(() {}); } else { _addNewTab(initialSql: text, title: file.name, filePath: file.path); @@ -733,6 +749,7 @@ class _PostgresSqlWorkspaceState extends material.State { final existingPath = session.filePath; if (existingPath != null && existingPath.isNotEmpty) { await File(existingPath).writeAsString(session.controller.text); + session.markSaved(); if (!mounted) return; showAppToast( context: context, @@ -756,8 +773,8 @@ class _PostgresSqlWorkspaceState extends material.State { await File(path).writeAsString(session.controller.text); if (!mounted) return; setState(() { - session.filePath = path; session.title = File(path).uri.pathSegments.last; + session.markSaved(newFilePath: path); }); showAppToast( context: context, diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 1ad440ca..d3492898 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -145,10 +145,26 @@ class _SqliteSqlWorkspaceState extends material.State { if (index < 0 || index >= _sessions.length) return; if (_sessions.length <= 1) return; final session = _sessions[index]; - if (session.stagingBuffer != null && session.stagingBuffer!.isDirty) { + if (session.isDirty) { + final hasDirtyStaging = + session.stagingBuffer != null && session.stagingBuffer!.isDirty; + final hasUnsavedText = session.isModified || + (session.filePath == null && session.controller.text.trim().isNotEmpty); + final String message; + if (hasDirtyStaging && hasUnsavedText) { + message = + 'This query tab contains unsaved query text and staged database changes. Closing the tab will discard them.'; + } else if (hasDirtyStaging) { + message = + 'This query tab contains staged database changes that have not been applied yet. Closing the tab will discard these changes.'; + } else { + message = + 'This query tab contains unsaved SQL query text. Closing the tab will discard your changes.'; + } final confirmed = await showUnsavedTabChangesDialog( context: context, tabTitle: session.title, + message: message, ); if (confirmed != true) return; } @@ -547,7 +563,7 @@ class _SqliteSqlWorkspaceState extends material.State { selection: material.TextSelection.collapsed(offset: text.length), ); session.title = file.name; - session.filePath = file.path; + session.markSaved(newFilePath: file.path); setState(() {}); } else { _addNewTab(initialSql: text, title: file.name, filePath: file.path); @@ -568,6 +584,7 @@ class _SqliteSqlWorkspaceState extends material.State { final existingPath = session.filePath; if (existingPath != null && existingPath.isNotEmpty) { await File(existingPath).writeAsString(session.controller.text); + session.markSaved(); if (!mounted) return; showAppToast( context: context, @@ -591,8 +608,8 @@ class _SqliteSqlWorkspaceState extends material.State { await File(path).writeAsString(session.controller.text); if (!mounted) return; setState(() { - session.filePath = path; session.title = File(path).uri.pathSegments.last; + session.markSaved(newFilePath: path); }); showAppToast( context: context, diff --git a/lib/features/workspace/sql_query_tab_bar.dart b/lib/features/workspace/sql_query_tab_bar.dart index 853a12be..1249411d 100644 --- a/lib/features/workspace/sql_query_tab_bar.dart +++ b/lib/features/workspace/sql_query_tab_bar.dart @@ -59,6 +59,7 @@ class SqlQueryTabBar extends material.StatelessWidget { Future showUnsavedTabChangesDialog({ required material.BuildContext context, required String tabTitle, + String? message, }) { return showAppDialog( context: context, @@ -73,8 +74,9 @@ Future showUnsavedTabChangesDialog({ children: [ Text('Unsaved Changes in "$tabTitle"').semiBold().large(), const Gap(8), - const Text( - 'This query tab contains staged database changes that have not been applied yet. Closing the tab will discard these changes.', + Text( + message ?? + 'This query tab contains staged database changes that have not been applied yet. Closing the tab will discard these changes.', ).muted().small(), const Gap(20), material.Align( diff --git a/lib/features/workspace/sql_query_tab_session.dart b/lib/features/workspace/sql_query_tab_session.dart index 53c9f97b..4ff7460e 100644 --- a/lib/features/workspace/sql_query_tab_session.dart +++ b/lib/features/workspace/sql_query_tab_session.dart @@ -15,10 +15,12 @@ class SqlQueryTabSession { this.filePath, double initialFraction = 0.65, }) : controller = material.TextEditingController(text: initialSql ?? ''), + _savedSql = initialSql ?? '', topFraction = material.ValueNotifier(initialFraction) { + controller.addListener(_onTextChanged); UnsavedWorkRegistry.instance.register( this, - () => isModified || (stagingBuffer?.isDirty ?? false), + () => isDirty, ); } @@ -28,6 +30,7 @@ class SqlQueryTabSession { final material.TextEditingController controller; final material.ValueNotifier topFraction; + String _savedSql; bool running = false; String? error; List columns = const []; @@ -37,7 +40,34 @@ class SqlQueryTabSession { DataGridStagingBuffer? stagingBuffer; String? lastExecutedSql; bool savingChanges = false; - bool isModified = false; + bool? _manualModified; + + /// True if the SQL query text has unsaved changes or was manually marked modified. + bool get isModified => + _manualModified ?? + (filePath != null + ? controller.text != _savedSql + : controller.text.trim().isNotEmpty); + + set isModified(bool value) { + _manualModified = value; + } + + void _onTextChanged() { + _manualModified = null; + } + + /// True if the tab has uncommitted changes (modified text, unsaved query draft, or dirty staging buffer). + bool get isDirty => isModified || (stagingBuffer?.isDirty ?? false); + + /// Marks the current query text as saved to disk or loaded from file. + void markSaved({String? newFilePath}) { + _savedSql = controller.text; + _manualModified = false; + if (newFilePath != null) { + filePath = newFilePath; + } + } /// PK columns for SQL-grid Save, empty when Save is disabled. List resultGridPrimaryKeys = const []; @@ -51,15 +81,14 @@ class SqlQueryTabSession { text: next, selection: material.TextSelection.collapsed(offset: next.length), ); - isModified = true; } void clearSql() { controller.clear(); - isModified = true; } void dispose() { + controller.removeListener(_onTextChanged); UnsavedWorkRegistry.instance.unregister(this); controller.dispose(); topFraction.dispose(); diff --git a/test/features/workspace/multi_tab_sql_workspace_test.dart b/test/features/workspace/multi_tab_sql_workspace_test.dart index 3f8163d7..2b10c608 100644 --- a/test/features/workspace/multi_tab_sql_workspace_test.dart +++ b/test/features/workspace/multi_tab_sql_workspace_test.dart @@ -49,6 +49,56 @@ void main() { tab1.dispose(); tab2.dispose(); }); + + test('tracks text modifications and markSaved updates', () { + final session = SqlQueryTabSession( + id: 'tab_file', + title: 'File Tab', + initialSql: 'SELECT 1;', + filePath: '/tmp/test.sql', + ); + + expect(session.isModified, isFalse); + expect(session.isDirty, isFalse); + + // Typing new text marks modified & dirty + session.controller.text = 'SELECT 1;\nSELECT 2;'; + expect(session.isModified, isTrue); + expect(session.isDirty, isTrue); + + // Typing back to initial text resets modified & dirty + session.controller.text = 'SELECT 1;'; + expect(session.isModified, isFalse); + expect(session.isDirty, isFalse); + + // Mutate and save + session.controller.text = 'SELECT * FROM users;'; + expect(session.isModified, isTrue); + session.markSaved(newFilePath: '/tmp/users.sql'); + expect(session.isModified, isFalse); + expect(session.isDirty, isFalse); + expect(session.filePath, '/tmp/users.sql'); + + session.dispose(); + }); + + test('unsaved draft query without filePath is treated as dirty', () { + final session = SqlQueryTabSession( + id: 'tab_draft', + title: 'Draft Tab', + ); + + expect(session.isModified, isFalse); + expect(session.isDirty, isFalse); + + session.controller.text = 'SELECT 42;'; + expect(session.isDirty, isTrue); + + session.controller.text = ' '; + expect(session.isDirty, isFalse); + + session.dispose(); + }); }); group('SqlQueryTabBar widget', () { @@ -145,6 +195,36 @@ void main() { expect(dialogResult, isTrue); buffer.dispose(); }); + + testWidgets('shows custom message in unsaved tab changes dialog', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) { + return material.TextButton( + onPressed: () { + showUnsavedTabChangesDialog( + context: context, + tabTitle: 'Query 1', + message: 'Custom query text warning', + ); + }, + child: const material.Text('Open Custom Dialog'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Open Custom Dialog')); + await tester.pumpAndSettle(); + + expect(find.text('Unsaved Changes in "Query 1"'), findsOneWidget); + expect(find.text('Custom query text warning'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + }); }); group('SqlEditorCommandBridge tab delegation', () {