From 2d1c45ae9a5d5266850f8d1cb78bcac90a46f70c Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 27 Sep 2026 16:38:57 +0300 Subject: [PATCH] perf(workspace): cache SQL tab panes so switching only swaps the visible tab (Closes #985) _buildSessionPane() was called for every tab on every setState() of the workspace state, not just the switching one: setting _activeSessionIndex triggers a full build(), which reconstructed the IndexedStack's children list from scratch, rebuilding every inactive tab's toolbar, editor, and results grid along with the active one. Cache each session's built pane widget by session id and reuse the identical instance across builds; Flutter's element diffing skips rebuilding a subtree when the new widget is identical to the old one, so switching tabs (which only changes _activeSessionIndex) now touches no pane at all. Cache entries are invalidated at each site that mutates a session's own rendered state (execute, apply staged changes, format/clear, open/save file) or a value shared across all panes' toolbars (workspace settings, statement timeout, transaction status, autocommit). Applied identically to the SQLite, PostgreSQL, and MySQL SQL workspaces, which share the same tab/session/IndexedStack structure. --- lib/features/mysql/mysql_sql_workspace.dart | 54 +++++++- .../postgresql/postgres_sql_workspace.dart | 61 ++++++++- lib/features/sqlite/sqlite_sql_workspace.dart | 44 ++++++- .../sql_query_tab_switching_test.dart | 122 ++++++++++++++++++ 4 files changed, 273 insertions(+), 8 deletions(-) create mode 100644 test/features/workspace/sql_query_tab_switching_test.dart diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 204901c..653326c 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -51,6 +51,20 @@ class _MysqlSqlWorkspaceState extends material.State { SqlQueryTabSession get _activeSession => _sessions[_activeSessionIndex]; + /// Built session panes, keyed by [SqlQueryTabSession.id]. + /// + /// Switching tabs only changes [_activeSessionIndex]; reusing the same + /// widget instance for untouched sessions lets Flutter's element diffing + /// (`identical(oldWidget, newWidget)`) skip rebuilding their subtrees, so a + /// switch only rebuilds the tab strip and swaps the visible `IndexedStack` + /// child instead of rebuilding every tab's editor and results grid. + final Map _paneCache = {}; + + void _invalidatePane(SqlQueryTabSession session) => + _paneCache.remove(session.id); + + void _invalidateAllPanes() => _paneCache.clear(); + MysqlLease? _lease; bool? _txOpen; @@ -98,10 +112,12 @@ class _MysqlSqlWorkspaceState extends material.State { onPrevTab: _prevTab, onFormat: () { _activeSession.formatSql(); + _invalidatePane(_activeSession); setState(() {}); }, onClear: () { _activeSession.clearSql(); + _invalidatePane(_activeSession); setState(() {}); }, onOpenWithContent: (sql, filePath, title) { @@ -114,6 +130,7 @@ class _MysqlSqlWorkspaceState extends material.State { ); session.title = title; session.filePath = filePath; + _invalidatePane(session); setState(() {}); } else { _addNewTab(initialSql: sql, title: title, filePath: filePath); @@ -170,6 +187,7 @@ class _MysqlSqlWorkspaceState extends material.State { setState(() { _sessions.removeAt(index); session.dispose(); + _paneCache.remove(session.id); if (_activeSessionIndex >= _sessions.length) { _activeSessionIndex = _sessions.length - 1; } @@ -206,6 +224,7 @@ class _MysqlSqlWorkspaceState extends material.State { final hist = await AppSettings.instance.getSqlHistoryMaxEntries(); final font = await AppSettings.instance.getSqlEditorFontSize(); if (!mounted) return; + _invalidateAllPanes(); setState(() { _queryTimeoutSeconds = t; _resultMaxRows = rows; @@ -215,6 +234,7 @@ class _MysqlSqlWorkspaceState extends material.State { } void _onStmtTimeoutChanged(int? v) { + _invalidateAllPanes(); setState(() => _queryTimeoutSeconds = v); unawaited(AppSettings.instance.setMysqlSqlStmtTimeoutSeconds(v)); } @@ -246,12 +266,18 @@ class _MysqlSqlWorkspaceState extends material.State { Future _refreshTxStatus() async { final conn = _lease?.connection; if (conn == null || !conn.isConnected) { - if (mounted) setState(() => _txOpen = null); + if (mounted) { + _invalidateAllPanes(); + setState(() => _txOpen = null); + } _notifyTransactionOpen(); return; } final v = await conn.inOpenTransaction(); - if (mounted) setState(() => _txOpen = v); + if (mounted) { + _invalidateAllPanes(); + setState(() => _txOpen = v); + } _notifyTransactionOpen(); } @@ -279,6 +305,7 @@ class _MysqlSqlWorkspaceState extends material.State { for (final s in _sessions) { s.dispose(); } + _paneCache.clear(); super.dispose(); } @@ -320,6 +347,7 @@ class _MysqlSqlWorkspaceState extends material.State { } } + _invalidatePane(session); setState(() { session.running = true; session.error = null; @@ -339,6 +367,7 @@ class _MysqlSqlWorkspaceState extends material.State { final conn = _lease?.connection; if (conn == null || !conn.isConnected) { if (mounted) { + _invalidatePane(session); setState(() { session.error = 'Could not connect to MySQL.'; session.running = false; @@ -416,6 +445,7 @@ class _MysqlSqlWorkspaceState extends material.State { primaryKeys: pks, ); + _invalidatePane(session); setState(() { session.columns = cols; session.rows = outRows; @@ -467,6 +497,7 @@ class _MysqlSqlWorkspaceState extends material.State { } on TimeoutException catch (e) { unawaited(_lease?.connection.forceClose()); if (mounted) { + _invalidatePane(session); setState(() { session.error = e.toString(); session.running = false; @@ -475,6 +506,7 @@ class _MysqlSqlWorkspaceState extends material.State { } } catch (e) { if (mounted) { + _invalidatePane(session); setState(() { session.error = e.toString(); session.running = false; @@ -509,6 +541,7 @@ class _MysqlSqlWorkspaceState extends material.State { ? widget.connectionRow.databaseName!.trim() : null); + _invalidatePane(session); setState(() => session.savingChanges = true); try { final plan = session.stagingBuffer!.generateMutationPlan( @@ -520,6 +553,7 @@ class _MysqlSqlWorkspaceState extends material.State { columnMeta: session.resultGridColumnMeta, ); if (plan.isEmpty) { + _invalidatePane(session); setState(() => session.savingChanges = false); return; } @@ -529,6 +563,7 @@ class _MysqlSqlWorkspaceState extends material.State { plan: plan, ); if (confirmed != true) { + _invalidatePane(session); setState(() => session.savingChanges = false); return; } @@ -550,6 +585,7 @@ class _MysqlSqlWorkspaceState extends material.State { if (!mounted) return; final newRows = session.stagingBuffer!.committedRows; session.stagingBuffer?.dispose(); + _invalidatePane(session); setState(() { session.rows = newRows; session.stagingBuffer = DataGridStagingBuffer( @@ -561,6 +597,7 @@ class _MysqlSqlWorkspaceState extends material.State { }); } catch (e) { if (mounted) { + _invalidatePane(session); setState(() => session.savingChanges = false); await showTableViewSaveFailedDialog(context: context, error: e); } @@ -593,6 +630,7 @@ class _MysqlSqlWorkspaceState extends material.State { ); session.title = file.name; session.markSaved(newFilePath: file.path); + _invalidatePane(session); setState(() {}); } else { _addNewTab(initialSql: text, title: file.name, filePath: file.path); @@ -636,6 +674,7 @@ class _MysqlSqlWorkspaceState extends material.State { if (path == null || path.isEmpty) return; await File(path).writeAsString(session.controller.text); if (!mounted) return; + _invalidatePane(session); setState(() { session.title = File(path).uri.pathSegments.last; session.markSaved(newFilePath: path); @@ -657,6 +696,7 @@ class _MysqlSqlWorkspaceState extends material.State { Future _runTxCommand(String cmd) async { final session = _activeSession; + _invalidatePane(session); setState(() { session.running = true; session.error = null; @@ -666,6 +706,7 @@ class _MysqlSqlWorkspaceState extends material.State { final conn = _lease?.connection; if (conn == null || !conn.isConnected) { if (mounted) { + _invalidatePane(session); setState(() { session.error = 'Could not connect to MySQL.'; session.running = false; @@ -676,6 +717,7 @@ class _MysqlSqlWorkspaceState extends material.State { final to = _statementTimeout(); await conn.executeWithTimeout(cmd, timeout: to); if (!mounted) return; + _invalidatePane(session); setState(() { session.columns = []; session.rows = []; @@ -686,6 +728,7 @@ class _MysqlSqlWorkspaceState extends material.State { } on TimeoutException catch (e) { unawaited(_lease?.connection.forceClose()); if (mounted) { + _invalidatePane(session); setState(() { session.error = e.toString(); session.running = false; @@ -693,6 +736,7 @@ class _MysqlSqlWorkspaceState extends material.State { } } catch (e) { if (mounted) { + _invalidatePane(session); setState(() { session.error = e.toString(); session.running = false; @@ -824,7 +868,10 @@ class _MysqlSqlWorkspaceState extends material.State { index: _activeSessionIndex, children: [ for (final session in _sessions) - _buildSessionPane(context, session), + _paneCache.putIfAbsent( + session.id, + () => _buildSessionPane(context, session), + ), ], ), ), @@ -841,6 +888,7 @@ class _MysqlSqlWorkspaceState extends material.State { ) { final theme = Theme.of(context); return VerticalSplitPane( + key: material.ValueKey(session.id), fraction: session.topFraction, maxFraction: 0.85, top: Column( diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 2377c06..6f619d5 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -72,6 +72,20 @@ class _PostgresSqlWorkspaceState extends material.State { SqlQueryTabSession get _activeSession => _sessions[_activeSessionIndex]; + /// Built session panes, keyed by [SqlQueryTabSession.id]. + /// + /// Switching tabs only changes [_activeSessionIndex]; reusing the same + /// widget instance for untouched sessions lets Flutter's element diffing + /// (`identical(oldWidget, newWidget)`) skip rebuilding their subtrees, so a + /// switch only rebuilds the tab strip and swaps the visible `IndexedStack` + /// child instead of rebuilding every tab's editor and results grid. + final Map _paneCache = {}; + + void _invalidatePane(SqlQueryTabSession session) => + _paneCache.remove(session.id); + + void _invalidateAllPanes() => _paneCache.clear(); + PgLease? _lease; /// Database used for the current lease (for [PostgresService.interrupt]). @@ -132,10 +146,12 @@ class _PostgresSqlWorkspaceState extends material.State { onPrevTab: _prevTab, onFormat: () { _activeSession.formatSql(); + _invalidatePane(_activeSession); setState(() {}); }, onClear: () { _activeSession.clearSql(); + _invalidatePane(_activeSession); setState(() {}); }, onOpenWithContent: (sql, filePath, title) { @@ -148,6 +164,7 @@ class _PostgresSqlWorkspaceState extends material.State { ); session.title = title; session.filePath = filePath; + _invalidatePane(session); setState(() {}); } else { _addNewTab(initialSql: sql, title: title, filePath: filePath); @@ -203,6 +220,7 @@ class _PostgresSqlWorkspaceState extends material.State { setState(() { _sessions.removeAt(index); session.dispose(); + _paneCache.remove(session.id); if (_activeSessionIndex >= _sessions.length) { _activeSessionIndex = _sessions.length - 1; } @@ -280,6 +298,7 @@ class _PostgresSqlWorkspaceState extends material.State { final hist = await AppSettings.instance.getSqlHistoryMaxEntries(); final font = await AppSettings.instance.getSqlEditorFontSize(); if (!mounted) return; + _invalidateAllPanes(); setState(() { _queryTimeoutSeconds = t; _resultMaxRows = rows; @@ -289,6 +308,7 @@ class _PostgresSqlWorkspaceState extends material.State { } void _onStmtTimeoutChanged(int? v) { + _invalidateAllPanes(); setState(() => _queryTimeoutSeconds = v); unawaited(AppSettings.instance.setPostgresSqlStmtTimeoutSeconds(v)); } @@ -321,17 +341,24 @@ class _PostgresSqlWorkspaceState extends material.State { Future _refreshTxStatus() async { final conn = _lease?.connection; if (conn == null || !conn.isConnected) { - if (mounted) setState(() => _txOpen = null); + if (mounted) { + _invalidateAllPanes(); + setState(() => _txOpen = null); + } _notifyTransactionOpen(); return; } final v = await conn.inOpenTransaction(); - if (mounted) setState(() => _txOpen = v); + if (mounted) { + _invalidateAllPanes(); + setState(() => _txOpen = v); + } _notifyTransactionOpen(); } Future _runTxCommand(String cmd) async { final session = _activeSession; + _invalidatePane(session); setState(() { session.running = true; session.error = null; @@ -341,6 +368,7 @@ class _PostgresSqlWorkspaceState extends material.State { final conn = _lease?.connection; if (conn == null || !conn.isConnected) { if (mounted) { + _invalidatePane(session); setState(() { session.error = 'Could not connect to PostgreSQL.'; session.running = false; @@ -351,6 +379,7 @@ class _PostgresSqlWorkspaceState extends material.State { final to = _statementTimeout(); await conn.execute(cmd, timeout: to); if (!mounted) return; + _invalidatePane(session); setState(() { session.columns = []; session.rows = []; @@ -361,6 +390,7 @@ class _PostgresSqlWorkspaceState extends material.State { } on TimeoutException catch (e) { unawaited(_lease?.connection.forceClose()); if (mounted) { + _invalidatePane(session); setState(() { session.error = 'Query timed out: ${e.message ?? e}'; session.running = false; @@ -368,6 +398,7 @@ class _PostgresSqlWorkspaceState extends material.State { } } on pg.ServerException catch (e) { if (mounted) { + _invalidatePane(session); setState(() { session.error = e.message; session.running = false; @@ -375,6 +406,7 @@ class _PostgresSqlWorkspaceState extends material.State { } } catch (e) { if (mounted) { + _invalidatePane(session); setState(() { session.error = e.toString(); session.running = false; @@ -403,6 +435,7 @@ class _PostgresSqlWorkspaceState extends material.State { for (final s in _sessions) { s.dispose(); } + _paneCache.clear(); super.dispose(); } @@ -446,6 +479,7 @@ class _PostgresSqlWorkspaceState extends material.State { var sql = injectSqlLimit(userSql, _resultMaxRows); + _invalidatePane(session); setState(() { session.running = true; session.error = null; @@ -465,6 +499,7 @@ class _PostgresSqlWorkspaceState extends material.State { final conn = _lease?.connection; if (conn == null || !conn.isConnected) { if (mounted) { + _invalidatePane(session); setState(() { session.error = 'Could not connect to PostgreSQL.'; session.running = false; @@ -534,6 +569,7 @@ class _PostgresSqlWorkspaceState extends material.State { primaryKeys: pks, ); + _invalidatePane(session); setState(() { session.columns = cols; session.rows = outRows; @@ -585,6 +621,7 @@ class _PostgresSqlWorkspaceState extends material.State { } on TimeoutException catch (e) { unawaited(_lease?.connection.forceClose()); if (mounted) { + _invalidatePane(session); setState(() { session.error = 'Query timed out: ${e.message ?? e}'; session.running = false; @@ -593,6 +630,7 @@ class _PostgresSqlWorkspaceState extends material.State { } } on pg.ServerException catch (e) { if (mounted) { + _invalidatePane(session); setState(() { session.error = e.message; session.running = false; @@ -601,6 +639,7 @@ class _PostgresSqlWorkspaceState extends material.State { } } catch (e) { if (mounted) { + _invalidatePane(session); setState(() { session.error = e.toString(); session.running = false; @@ -631,6 +670,7 @@ class _PostgresSqlWorkspaceState extends material.State { return; } + _invalidatePane(session); setState(() => session.savingChanges = true); try { final plan = session.stagingBuffer!.generateMutationPlan( @@ -642,6 +682,7 @@ class _PostgresSqlWorkspaceState extends material.State { columnMeta: session.resultGridColumnMeta, ); if (plan.isEmpty) { + _invalidatePane(session); setState(() => session.savingChanges = false); return; } @@ -651,6 +692,7 @@ class _PostgresSqlWorkspaceState extends material.State { plan: plan, ); if (confirmed != true) { + _invalidatePane(session); setState(() => session.savingChanges = false); return; } @@ -675,6 +717,7 @@ class _PostgresSqlWorkspaceState extends material.State { if (!mounted) return; final newRows = session.stagingBuffer!.committedRows; session.stagingBuffer?.dispose(); + _invalidatePane(session); setState(() { session.rows = newRows; session.stagingBuffer = @@ -688,6 +731,7 @@ class _PostgresSqlWorkspaceState extends material.State { await _refreshTxStatus(); } catch (e) { if (mounted) { + _invalidatePane(session); setState(() => session.savingChanges = false); await showTableViewSaveFailedDialog(context: context, error: e); } @@ -715,6 +759,7 @@ class _PostgresSqlWorkspaceState extends material.State { ); session.title = file.name; session.markSaved(newFilePath: file.path); + _invalidatePane(session); setState(() {}); } else { _addNewTab(initialSql: text, title: file.name, filePath: file.path); @@ -758,6 +803,7 @@ class _PostgresSqlWorkspaceState extends material.State { if (path == null || path.isEmpty) return; await File(path).writeAsString(session.controller.text); if (!mounted) return; + _invalidatePane(session); setState(() { session.title = File(path).uri.pathSegments.last; session.markSaved(newFilePath: path); @@ -891,7 +937,10 @@ class _PostgresSqlWorkspaceState extends material.State { index: _activeSessionIndex, children: [ for (final session in _sessions) - _buildSessionPane(context, session), + _paneCache.putIfAbsent( + session.id, + () => _buildSessionPane(context, session), + ), ], ), ), @@ -908,6 +957,7 @@ class _PostgresSqlWorkspaceState extends material.State { ) { final theme = Theme.of(context); return VerticalSplitPane( + key: material.ValueKey(session.id), fraction: session.topFraction, maxFraction: 0.85, top: Column( @@ -918,7 +968,10 @@ class _PostgresSqlWorkspaceState extends material.State { onExecute: session.running ? null : () => _execute(session), running: session.running, autocommit: _autocommit, - onAutocommitChanged: (v) => setState(() => _autocommit = v), + onAutocommitChanged: (v) { + _invalidateAllPanes(); + setState(() => _autocommit = v); + }, queryTimeoutSeconds: _queryTimeoutSeconds, onQueryTimeoutChanged: _onStmtTimeoutChanged, onOpenPreferences: () => showPreferencesDialog(context), diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 6ea9aa8..d280bff 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -48,6 +48,24 @@ class _SqliteSqlWorkspaceState extends material.State { int _activeSessionIndex = 0; int _nextSessionId = 1; + /// Built session panes, keyed by [SqlQueryTabSession.id]. + /// + /// Switching tabs only changes [_activeSessionIndex]; reusing the same + /// widget instance for untouched sessions lets Flutter's element diffing + /// (`identical(oldWidget, newWidget)`) skip rebuilding their subtrees, so a + /// switch only rebuilds the tab strip and swaps the visible `IndexedStack` + /// child instead of rebuilding every tab's editor and results grid. + final Map _paneCache = {}; + + void _invalidatePane(SqlQueryTabSession session) => + _paneCache.remove(session.id); + + void _invalidateAllPanes() => _paneCache.clear(); + + /// Counts calls to [_buildSessionPane], i.e. pane cache misses. + @material.visibleForTesting + int paneBuildCount = 0; + SqlQueryTabSession get _activeSession => _sessions[_activeSessionIndex]; @material.visibleForTesting @@ -99,10 +117,12 @@ class _SqliteSqlWorkspaceState extends material.State { onPrevTab: _prevTab, onFormat: () { _activeSession.formatSql(); + _invalidatePane(_activeSession); setState(() {}); }, onClear: () { _activeSession.clearSql(); + _invalidatePane(_activeSession); setState(() {}); }, onExecute: () { @@ -118,6 +138,7 @@ class _SqliteSqlWorkspaceState extends material.State { ); session.title = title; session.filePath = filePath; + _invalidatePane(session); setState(() {}); } else { _addNewTab(initialSql: sql, title: title, filePath: filePath); @@ -174,6 +195,7 @@ class _SqliteSqlWorkspaceState extends material.State { setState(() { _sessions.removeAt(index); session.dispose(); + _paneCache.remove(session.id); if (_activeSessionIndex >= _sessions.length) { _activeSessionIndex = _sessions.length - 1; } @@ -210,6 +232,7 @@ class _SqliteSqlWorkspaceState extends material.State { final hist = await AppSettings.instance.getSqlHistoryMaxEntries(); final font = await AppSettings.instance.getSqlEditorFontSize(); if (!mounted) return; + _invalidateAllPanes(); setState(() { _queryTimeoutSeconds = t; _resultMaxRows = rows; @@ -219,6 +242,7 @@ class _SqliteSqlWorkspaceState extends material.State { } void _onStmtTimeoutChanged(int? v) { + _invalidateAllPanes(); setState(() => _queryTimeoutSeconds = v); unawaited(AppSettings.instance.setSqliteSqlStmtTimeoutSeconds(v)); } @@ -271,6 +295,7 @@ class _SqliteSqlWorkspaceState extends material.State { s.dispose(); } _sessions.clear(); + _paneCache.clear(); super.dispose(); } @@ -312,6 +337,7 @@ class _SqliteSqlWorkspaceState extends material.State { } } + _invalidatePane(session); setState(() { session.running = true; session.error = null; @@ -331,6 +357,7 @@ class _SqliteSqlWorkspaceState extends material.State { final conn = _lease?.connection; if (conn == null || !conn.isConnected) { if (mounted) { + _invalidatePane(session); setState(() { session.error = 'Could not connect to SQLite.'; session.running = false; @@ -399,6 +426,7 @@ class _SqliteSqlWorkspaceState extends material.State { primaryKeys: pks, ); + _invalidatePane(session); setState(() { session.columns = cols; session.rows = outRows; @@ -451,6 +479,7 @@ class _SqliteSqlWorkspaceState extends material.State { } on TimeoutException catch (e) { unawaited(_lease?.connection.forceClose()); if (mounted) { + _invalidatePane(session); setState(() { session.error = 'Query timed out: ${e.message ?? e}'; session.running = false; @@ -459,6 +488,7 @@ class _SqliteSqlWorkspaceState extends material.State { } } catch (e) { if (mounted) { + _invalidatePane(session); setState(() { session.error = e.toString(); session.running = false; @@ -489,6 +519,7 @@ class _SqliteSqlWorkspaceState extends material.State { return; } + _invalidatePane(session); setState(() => session.savingChanges = true); try { final plan = session.stagingBuffer!.generateMutationPlan( @@ -500,6 +531,7 @@ class _SqliteSqlWorkspaceState extends material.State { columnMeta: session.resultGridColumnMeta, ); if (plan.isEmpty) { + _invalidatePane(session); setState(() => session.savingChanges = false); return; } @@ -509,6 +541,7 @@ class _SqliteSqlWorkspaceState extends material.State { plan: plan, ); if (confirmed != true) { + _invalidatePane(session); setState(() => session.savingChanges = false); return; } @@ -529,6 +562,7 @@ class _SqliteSqlWorkspaceState extends material.State { if (!mounted) return; final newRows = session.stagingBuffer!.committedRows; session.stagingBuffer?.dispose(); + _invalidatePane(session); setState(() { session.rows = newRows; session.stagingBuffer = DataGridStagingBuffer( @@ -540,6 +574,7 @@ class _SqliteSqlWorkspaceState extends material.State { }); } catch (e) { if (mounted) { + _invalidatePane(session); setState(() => session.savingChanges = false); await showTableViewSaveFailedDialog(context: context, error: e); } @@ -567,6 +602,7 @@ class _SqliteSqlWorkspaceState extends material.State { ); session.title = file.name; session.markSaved(newFilePath: file.path); + _invalidatePane(session); setState(() {}); } else { _addNewTab(initialSql: text, title: file.name, filePath: file.path); @@ -610,6 +646,7 @@ class _SqliteSqlWorkspaceState extends material.State { if (path == null || path.isEmpty) return; await File(path).writeAsString(session.controller.text); if (!mounted) return; + _invalidatePane(session); setState(() { session.title = File(path).uri.pathSegments.last; session.markSaved(newFilePath: path); @@ -750,7 +787,10 @@ class _SqliteSqlWorkspaceState extends material.State { index: _activeSessionIndex, children: [ for (final session in _sessions) - _buildSessionPane(context, session), + _paneCache.putIfAbsent( + session.id, + () => _buildSessionPane(context, session), + ), ], ), ), @@ -765,8 +805,10 @@ class _SqliteSqlWorkspaceState extends material.State { material.BuildContext context, SqlQueryTabSession session, ) { + paneBuildCount++; final theme = Theme.of(context); return VerticalSplitPane( + key: material.ValueKey(session.id), fraction: session.topFraction, maxFraction: 0.85, top: material.Column( diff --git a/test/features/workspace/sql_query_tab_switching_test.dart b/test/features/workspace/sql_query_tab_switching_test.dart new file mode 100644 index 0000000..2d80d06 --- /dev/null +++ b/test/features/workspace/sql_query_tab_switching_test.dart @@ -0,0 +1,122 @@ +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/actions/sql_editor_command_bridge.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/sqlite/sqlite_sql_workspace.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; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() async { + sqfliteFfiInit(); + tempDir = await Directory.systemTemp.createTemp('querya_tab_switch_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + try { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + } catch (_) {} + }); + + tearDown(() { + SqlEditorCommandBridge.instance.unregister(connectionId: 777); + }); + + testWidgets( + 'switching SQL tabs reuses cached panes instead of rebuilding every tab', + (tester) async { + await tester.binding.setSurfaceSize(const material.Size(1200, 800)); + + final conn = ConnectionRow( + id: 777, + type: 'sqlite', + name: 'Test SQLite', + host: '${tempDir.path}/tab_switch.db', + createdAt: '2026-09-27T00:00:00Z', + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.SizedBox.expand( + child: SqliteSqlWorkspace(connectionRow: conn), + ), + ), + ); + await tester.pumpAndSettle(); + + final dynamic state = tester.state(find.byType(SqliteSqlWorkspace)); + final addTabButton = + find.byKey(const material.ValueKey('querya_tab_add_button')); + + // Add two more tabs (three total), each pane is built exactly once. + await tester.tap(addTabButton); + await tester.pumpAndSettle(); + await tester.tap(addTabButton); + await tester.pumpAndSettle(); + + expect(state.paneBuildCount, 3); + + // Mutate the (currently active, third) tab's own state, which is allowed + // to rebuild its own pane, then settle. + final dynamic activeSession = state.activeSession; + activeSession.controller.text = 'SELECT 1;'; + await tester.pump(); + + final buildCountBeforeSwitching = state.paneBuildCount as int; + + // Switching tabs back and forth must not rebuild any pane: only the tab + // strip and the swapped IndexedStack index should change. + await tester.tap(find.byKey(const material.ValueKey('querya_tab_Query 1'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const material.ValueKey('querya_tab_Query 2'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const material.ValueKey('querya_tab_Query 3'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const material.ValueKey('querya_tab_Query 1'))); + await tester.pumpAndSettle(); + + expect(state.paneBuildCount, buildCountBeforeSwitching); + + material.FocusManager.instance.primaryFocus?.unfocus(); + await tester.pumpWidget(const material.SizedBox()); + await tester.pumpAndSettle(); + }); +}