diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 653326c..2a7b1e6 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -215,6 +215,10 @@ class _MysqlSqlWorkspaceState extends material.State { if (oldWidget.isReadOnly != widget.isReadOnly) { _lease?.release(); _lease = null; + // Cached panes closed over the old widget.isReadOnly in their + // onApplyChanges callback (#1007): rebuild every tab so Save reflects + // the lock immediately instead of only on that tab's next query run. + _invalidateAllPanes(); } } @@ -520,7 +524,8 @@ class _MysqlSqlWorkspaceState extends material.State { Future _applyStagedChanges([SqlQueryTabSession? targetSession]) async { final session = targetSession ?? _activeSession; - if (session.stagingBuffer == null || + if (widget.isReadOnly || + session.stagingBuffer == null || !session.stagingBuffer!.isDirty || session.savingChanges) { return; diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 6f619d5..1c21cf1 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -250,6 +250,10 @@ class _PostgresSqlWorkspaceState extends material.State { } if (oldWidget.isReadOnly != widget.isReadOnly) { _dropLease(); + // Cached panes closed over the old widget.isReadOnly in their + // onApplyChanges callback (#1007): rebuild every tab so Save reflects + // the lock immediately instead of only on that tab's next query run. + _invalidateAllPanes(); } _syncPostgresSqlTreeContext(); } @@ -653,7 +657,8 @@ class _PostgresSqlWorkspaceState extends material.State { Future _applyStagedChanges([SqlQueryTabSession? targetSession]) async { final session = targetSession ?? _activeSession; - if (session.stagingBuffer == null || + if (widget.isReadOnly || + session.stagingBuffer == null || !session.stagingBuffer!.isDirty || session.savingChanges) { return; diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index d280bff..62352b9 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -71,6 +71,16 @@ class _SqliteSqlWorkspaceState extends material.State { @material.visibleForTesting SqlQueryTabSession get activeSession => _activeSession; + /// Forces the active tab's pane to rebuild from its current session state + /// on the next frame, as if a query had just finished running in it — + /// lets tests simulate "a tab already has staged edits" without going + /// through a real query execution. + @material.visibleForTesting + void debugRebuildActivePane() { + _invalidatePane(_activeSession); + setState(() {}); + } + SqliteLease? _lease; bool? _txOpen; @@ -223,6 +233,10 @@ class _SqliteSqlWorkspaceState extends material.State { if (oldWidget.isReadOnly != widget.isReadOnly) { _lease?.release(); _lease = null; + // Cached panes closed over the old widget.isReadOnly in their + // onApplyChanges callback (#1007): rebuild every tab so Save reflects + // the lock immediately instead of only on that tab's next query run. + _invalidateAllPanes(); } } @@ -502,7 +516,8 @@ class _SqliteSqlWorkspaceState extends material.State { Future _applyStagedChanges([SqlQueryTabSession? targetSession]) async { final session = targetSession ?? _activeSession; - if (session.stagingBuffer == null || + if (widget.isReadOnly || + session.stagingBuffer == null || !session.stagingBuffer!.isDirty || session.savingChanges) { return; diff --git a/test/features/workspace/sql_workspace_readonly_save_test.dart b/test/features/workspace/sql_workspace_readonly_save_test.dart new file mode 100644 index 0000000..e7e3b36 --- /dev/null +++ b/test/features/workspace/sql_workspace_readonly_save_test.dart @@ -0,0 +1,152 @@ +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/sqlite/sqlite_sql_workspace.dart'; +import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.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; + @override + Future getLibraryPath() async => _root; + @override + Future getExternalStoragePath() async => _root; + @override + Future?> getExternalCachePaths() async => [_root]; + @override + Future?> getExternalStoragePaths({StorageDirectory? type}) async => + [_root]; + @override + Future getDownloadsPath() async => _root; +} + +/// #1007: the built tab pane is cached (`_paneCache`) to skip rebuilding +/// untouched tabs on tab-switch, but the cached pane's Save button closes +/// over `widget.isReadOnly` at build time. If a connection's read-only lock +/// flips on while a tab with unsaved staged edits is already open and +/// cached, Save must not keep working just because that tab hasn't been +/// rebuilt for any other reason since. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() async { + sqfliteFfiInit(); + tempDir = + await Directory.systemTemp.createTemp('querya_sql_readonly_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + try { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + } catch (_) {} + }); + + testWidgets( + 'Save Changes becomes disabled in an already-open dirty tab once the connection turns read-only', + (tester) async { + await tester.binding.setSurfaceSize(const material.Size(1200, 800)); + + const conn = ConnectionRow( + id: 504, + type: 'sqlite', + name: 'Test SQLite', + host: ':memory:', + createdAt: '2026-09-24T00:00:00Z', + ); + + material.Widget buildWorkspace({required bool isReadOnly}) { + return queryaThemeTestShell( + child: material.SizedBox.expand( + child: SqliteSqlWorkspace( + connectionRow: conn, + isReadOnly: isReadOnly, + ), + ), + ); + } + + await tester.pumpWidget(buildWorkspace(isReadOnly: false)); + await tester.pumpAndSettle(); + + final workspaceState = tester.state(find.byType(SqliteSqlWorkspace)); + final dynamic dynamicState = workspaceState; + final activeSession = dynamicState.activeSession; + expect(activeSession, isNotNull); + + // Simulate a query having just run and produced an editable result, + // exactly as `_execute` would set it up, without needing a live + // connection: a result with a detected primary key and a dirty edit. + activeSession.lastExecutedSql = 'SELECT * FROM users;'; + activeSession.columns = ['id', 'name']; + activeSession.rows = [ + ['1', 'Alice'], + ]; + activeSession.resultGridPrimaryKeys = ['id']; + final buffer = DataGridStagingBuffer( + columns: ['id', 'name'], + rows: [ + ['1', 'Alice'], + ], + primaryKeys: ['id'], + ); + activeSession.stagingBuffer = buffer; + buffer.setCell(0, 1, 'Bob'); + expect(buffer.isDirty, isTrue); + + dynamicState.debugRebuildActivePane(); + await tester.pumpAndSettle(); + + final saveButtonFinder = find.widgetWithText(PrimaryButton, 'Save Changes'); + expect(saveButtonFinder, findsOneWidget); + expect( + tester.widget(saveButtonFinder).onPressed, + isNotNull, + reason: 'Save should be enabled while writable and dirty', + ); + + // The connection becomes read-only while this dirty tab is still open + // and its pane is cached — Save must disable immediately, not only the + // next time this tab happens to rebuild for some other reason. + await tester.pumpWidget(buildWorkspace(isReadOnly: true)); + await tester.pumpAndSettle(); + + final saveButtonAfter = find.widgetWithText(PrimaryButton, 'Save Changes'); + expect(saveButtonAfter, findsOneWidget); + expect( + tester.widget(saveButtonAfter).onPressed, + isNull, + reason: + 'Save must disable once the connection is read-only, even though ' + 'this tab was already open and cached before the lock changed', + ); + expect(buffer.isDirty, isTrue, + reason: 'toggling read-only must not touch the staged edits'); + + material.FocusManager.instance.primaryFocus?.unfocus(); + await tester.pumpWidget(const material.SizedBox()); + await tester.pumpAndSettle(); + }); +}