From aa1fffe5b78497f75ff35dd122580062002910dc Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 29 Aug 2026 10:28:24 +0300 Subject: [PATCH 1/2] ui(motion): harmonize connection workspace motion to prevent dual-axis wobble (#631) --- lib/features/main_screen/workspace_panel.dart | 5 +++- .../workspace_panel_layout_test.dart | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index f11c784..a8bbf4d 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -11,7 +11,8 @@ import 'package:flutter/material.dart' as material MainAxisSize, SizedBox, Widget, - Column; + Column, + Offset; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/motion/querya_fade_slide.dart'; import 'package:querya_desktop/core/motion/querya_switching_body.dart'; @@ -372,10 +373,12 @@ class _WorkspacePanelState extends State { required material.Widget? object, }) { return QueryaSwitchingBody( + slide: material.Offset.zero, index: showingObject ? 1 : 0, children: [ home, QueryaFadeSlide( + offset: const material.Offset(0, 0.015), child: object ?? const material.SizedBox.expand( key: ValueKey('workspace_object_placeholder'), diff --git a/test/features/main_screen/workspace_panel_layout_test.dart b/test/features/main_screen/workspace_panel_layout_test.dart index 79f795c..114388a 100644 --- a/test/features/main_screen/workspace_panel_layout_test.dart +++ b/test/features/main_screen/workspace_panel_layout_test.dart @@ -255,5 +255,30 @@ void main() { expect(find.byType(RedisView), findsOneWidget); expect(find.byType(QueryaSwitchingBody), findsNWidgets(2)); }); + + testWidgets( + 'home↔object morph uses slide: Offset.zero to prevent dual-axis wobble', + (tester) async { + await pumpWidgetWithSurfaceSize( + tester, + const material.Size(800, 600), + queryaThemeTestShell( + child: const material.SizedBox.expand( + child: WorkspacePanel(activeConnection: redisConnection), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + final switchingBodies = tester + .widgetList(find.byType(QueryaSwitchingBody)) + .toList(); + final homeObjectSwitchingBody = switchingBodies.firstWhere( + (sb) => sb.slide == material.Offset.zero, + orElse: () => throw StateError('No switching body with Offset.zero'), + ); + expect(homeObjectSwitchingBody.slide, material.Offset.zero); + }); }); } From 69bb272322b2a90c60cd8279c1069dd1439b46c8 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 29 Aug 2026 10:29:42 +0300 Subject: [PATCH 2/2] feat(ux): bi-directional navigation between statistics/home and active table (#630) --- .../extensions/extension_table_toolbar.dart | 19 +- .../extensions/extension_table_view.dart | 3 + .../extensions/extension_workspace_home.dart | 20 ++ lib/features/main_screen/main_screen.dart | 13 ++ .../main_screen_workspace_state.dart | 196 ++++++++++++++++-- lib/features/main_screen/workspace_panel.dart | 52 ++++- lib/features/mysql/mysql_table_view.dart | 19 +- lib/features/mysql/mysql_workspace_home.dart | 24 +++ .../postgresql/postgres_object_workspace.dart | 4 + .../postgresql/postgres_table_toolbar.dart | 19 +- .../postgresql/postgres_table_view.dart | 3 + .../postgresql/postgres_workspace_home.dart | 24 +++ lib/features/sqlite/sqlite_table_view.dart | 19 +- .../sqlite/sqlite_workspace_home.dart | 24 +++ .../extension_table_toolbar_test.dart | 6 +- .../main_screen_workspace_state_test.dart | 30 +++ .../workspace_panel_layout_test.dart | 39 ++++ 17 files changed, 486 insertions(+), 28 deletions(-) diff --git a/lib/features/extensions/extension_table_toolbar.dart b/lib/features/extensions/extension_table_toolbar.dart index 83b1e51..d1e6421 100644 --- a/lib/features/extensions/extension_table_toolbar.dart +++ b/lib/features/extensions/extension_table_toolbar.dart @@ -22,6 +22,7 @@ class ExtensionTableToolbar extends material.StatelessWidget { this.onCancelQuery, this.onCopyFormat, this.onSaveFormat, + this.onNavigateHome, }); final String title; @@ -40,6 +41,7 @@ class ExtensionTableToolbar extends material.StatelessWidget { final VoidCallback? onCancelQuery; final material.ValueChanged? onCopyFormat; final material.ValueChanged? onSaveFormat; + final VoidCallback? onNavigateHome; @override material.Widget build(material.BuildContext context) { @@ -58,6 +60,21 @@ class ExtensionTableToolbar extends material.StatelessWidget { ), child: material.Row( children: [ + if (onNavigateHome != null) ...[ + material.Tooltip( + message: 'Return to driver overview', + child: OutlineButton( + size: ButtonSize.small, + onPressed: onNavigateHome, + leading: const material.Icon( + material.Icons.dns_outlined, + size: 14, + ), + child: const Text('Driver'), + ), + ), + const Gap(10), + ], material.Icon(tableIcon, size: 18, color: cs.primary), const Gap(8), material.Expanded( @@ -163,7 +180,7 @@ class ExtensionTableToolbar extends material.StatelessWidget { material.Icons.chevron_left_rounded, size: 16, ), - child: const Text('Back'), + child: const Text('Prev'), ), const Gap(4), OutlineButton( diff --git a/lib/features/extensions/extension_table_view.dart b/lib/features/extensions/extension_table_view.dart index bb8429a..0b84031 100644 --- a/lib/features/extensions/extension_table_view.dart +++ b/lib/features/extensions/extension_table_view.dart @@ -22,6 +22,7 @@ class ExtensionTableView extends material.StatefulWidget { required this.tableName, this.isView = false, this.pageSize = _defaultPageSize, + this.onNavigateHome, }); final ConnectionRow connectionRow; @@ -29,6 +30,7 @@ class ExtensionTableView extends material.StatefulWidget { final String tableName; final bool isView; final int pageSize; + final VoidCallback? onNavigateHome; @override material.State createState() => @@ -401,6 +403,7 @@ class _ExtensionTableViewState extends material.State { loading: _loading, canGoPrevious: _canGoBack && !_loading, canGoNext: _canGoForward && !_loading, + onNavigateHome: widget.onNavigateHome, filterActive: _filterActive || _filterController.text.isNotEmpty, filterText: _filterController.text, onToggleFilter: () { diff --git a/lib/features/extensions/extension_workspace_home.dart b/lib/features/extensions/extension_workspace_home.dart index f58c93a..6f99e3e 100644 --- a/lib/features/extensions/extension_workspace_home.dart +++ b/lib/features/extensions/extension_workspace_home.dart @@ -15,6 +15,8 @@ class ExtensionWorkspaceHome extends material.StatefulWidget { required this.connectionRow, this.selectedObject, this.sqlTabRequestToken = 0, + this.lastSelectedExtensionObject, + this.onRestoreLastSelectedObject, this.isReadOnly = false, }); @@ -22,6 +24,11 @@ class ExtensionWorkspaceHome extends material.StatefulWidget { final ExtensionSelectedObject? selectedObject; final int sqlTabRequestToken; final bool isReadOnly; + final ({ + String database, + String name, + })? lastSelectedExtensionObject; + final VoidCallback? onRestoreLastSelectedObject; @override material.State createState() => @@ -94,6 +101,19 @@ class _ExtensionWorkspaceHomeState color: theme.colorScheme.mutedForeground, ), ], + if (widget.lastSelectedExtensionObject != null && + widget.onRestoreLastSelectedObject != null) ...[ + const Gap(12), + OutlineButton( + size: ButtonSize.small, + onPressed: widget.onRestoreLastSelectedObject, + leading: const material.Icon( + material.Icons.table_chart_outlined, + size: 14, + ), + child: Text('Return to ${widget.lastSelectedExtensionObject!.name}'), + ), + ], const Spacer(), QueryaTabStrip( labels: const ['Server', 'SQL'], diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 0b83130..043088f 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -923,6 +923,19 @@ class _MainContentSplitState extends State<_MainContentSplit> selectedSqliteObject: ws.selectedSqliteObject, sqliteSqlTabRequestToken: ws.sqliteSqlTabRequestToken, selectedExtensionObject: ws.selectedExtensionObject, + lastSelectedPostgresObject: ws.lastSelectedPostgresObject, + lastSelectedMysqlObject: ws.lastSelectedMysqlObject, + lastSelectedSqliteObject: ws.lastSelectedSqliteObject, + lastSelectedExtensionObject: + ws.lastSelectedExtensionObject, + onNavigateHome: () { + widget.workspace.value = + widget.workspace.value.unselectActiveObject(); + }, + onRestoreLastSelectedObject: () { + widget.workspace.value = + widget.workspace.value.restoreLastSelectedObject(); + }, isReadOnly: ws.isReadOnly, onRequestNewConnection: widget.onRequestNewConnection, onRequestNewConnectionFromUrl: diff --git a/lib/features/main_screen/main_screen_workspace_state.dart b/lib/features/main_screen/main_screen_workspace_state.dart index 70ecc3d..f34bc65 100644 --- a/lib/features/main_screen/main_screen_workspace_state.dart +++ b/lib/features/main_screen/main_screen_workspace_state.dart @@ -20,6 +20,10 @@ class MainScreenWorkspaceState { this.selectedSqliteObject, this.sqliteSqlTabRequestToken = 0, this.selectedExtensionObject, + this.lastSelectedPostgresObject, + this.lastSelectedMysqlObject, + this.lastSelectedSqliteObject, + this.lastSelectedExtensionObject, this.isReadOnly = false, }); @@ -61,6 +65,31 @@ class MainScreenWorkspaceState { String database, String name, })? selectedExtensionObject; + + /// Remembers the last visited table/view for the active connection to support 1-click return from stats. + final ({ + String database, + String schema, + String name, + PostgresObjectKind kind + })? lastSelectedPostgresObject; + + final ({ + String database, + String name, + MysqlObjectKind kind + })? lastSelectedMysqlObject; + + final ({ + String name, + SqliteObjectKind kind + })? lastSelectedSqliteObject; + + final ({ + String database, + String name, + })? lastSelectedExtensionObject; + final bool isReadOnly; static const empty = MainScreenWorkspaceState(); @@ -79,11 +108,16 @@ class MainScreenWorkspaceState { selectedSqliteObject: selectedSqliteObject, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, selectedExtensionObject: selectedExtensionObject, + lastSelectedPostgresObject: lastSelectedPostgresObject, + lastSelectedMysqlObject: lastSelectedMysqlObject, + lastSelectedSqliteObject: lastSelectedSqliteObject, + lastSelectedExtensionObject: lastSelectedExtensionObject, isReadOnly: !isReadOnly, ); } MainScreenWorkspaceState selectConnection(ConnectionRow connection) { + final same = activeConnection?.id == connection.id; return MainScreenWorkspaceState( activeConnection: connection, activeRedisDb: null, @@ -97,10 +131,89 @@ class MainScreenWorkspaceState { selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, selectedExtensionObject: null, + lastSelectedPostgresObject: + same ? (selectedPostgresObject ?? lastSelectedPostgresObject) : null, + lastSelectedMysqlObject: + same ? (selectedMysqlObject ?? lastSelectedMysqlObject) : null, + lastSelectedSqliteObject: + same ? (selectedSqliteObject ?? lastSelectedSqliteObject) : null, + lastSelectedExtensionObject: same + ? (selectedExtensionObject ?? lastSelectedExtensionObject) + : null, isReadOnly: false, ); } + /// Clears the active table/view selection to show server stats/home, but retains + /// the reference in [lastSelectedPostgresObject] etc. so users can return in 1 click. + MainScreenWorkspaceState unselectActiveObject() { + return MainScreenWorkspaceState( + activeConnection: activeConnection, + activeRedisDb: activeRedisDb, + activeMongoDB: activeMongoDB, + selectedPostgresObject: null, + postgresSqlTabRequestToken: postgresSqlTabRequestToken, + postgresSqlEditorContext: postgresSqlEditorContext, + postgresSqlEditorContextToken: postgresSqlEditorContextToken, + selectedMysqlObject: null, + mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, + selectedSqliteObject: null, + sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + selectedExtensionObject: null, + lastSelectedPostgresObject: + selectedPostgresObject ?? lastSelectedPostgresObject, + lastSelectedMysqlObject: + selectedMysqlObject ?? lastSelectedMysqlObject, + lastSelectedSqliteObject: + selectedSqliteObject ?? lastSelectedSqliteObject, + lastSelectedExtensionObject: + selectedExtensionObject ?? lastSelectedExtensionObject, + isReadOnly: isReadOnly, + ); + } + + /// Restores the last visited table/view for the active connection. + MainScreenWorkspaceState restoreLastSelectedObject() { + final conn = activeConnection; + if (conn == null) return this; + if (lastSelectedPostgresObject != null) { + final obj = lastSelectedPostgresObject!; + return selectPostgresObject( + conn, + obj.database, + obj.schema, + obj.name, + obj.kind, + ); + } + if (lastSelectedMysqlObject != null) { + final obj = lastSelectedMysqlObject!; + return selectMysqlObject( + conn, + obj.database, + obj.name, + obj.kind, + ); + } + if (lastSelectedSqliteObject != null) { + final obj = lastSelectedSqliteObject!; + return selectSqliteObject( + conn, + obj.name, + obj.kind, + ); + } + if (lastSelectedExtensionObject != null) { + final obj = lastSelectedExtensionObject!; + return selectExtensionObject( + conn, + obj.database, + obj.name, + ); + } + return this; + } + MainScreenWorkspaceState selectPostgresObject( ConnectionRow connection, String database, @@ -108,16 +221,17 @@ class MainScreenWorkspaceState { String name, PostgresObjectKind kind, ) { + final pg = ( + database: database, + schema: schema, + name: name, + kind: kind, + ); return MainScreenWorkspaceState( activeConnection: connection, activeRedisDb: null, activeMongoDB: null, - selectedPostgresObject: ( - database: database, - schema: schema, - name: name, - kind: kind, - ), + selectedPostgresObject: pg, postgresSqlTabRequestToken: postgresSqlTabRequestToken, postgresSqlEditorContext: null, postgresSqlEditorContextToken: 0, @@ -125,6 +239,10 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + lastSelectedPostgresObject: pg, + lastSelectedMysqlObject: lastSelectedMysqlObject, + lastSelectedSqliteObject: lastSelectedSqliteObject, + lastSelectedExtensionObject: lastSelectedExtensionObject, isReadOnly: isReadOnly, ); } @@ -135,6 +253,11 @@ class MainScreenWorkspaceState { String name, MysqlObjectKind kind, ) { + final my = ( + database: database, + name: name, + kind: kind, + ); return MainScreenWorkspaceState( activeConnection: connection, activeRedisDb: null, @@ -143,14 +266,14 @@ class MainScreenWorkspaceState { postgresSqlTabRequestToken: postgresSqlTabRequestToken, postgresSqlEditorContext: null, postgresSqlEditorContextToken: 0, - selectedMysqlObject: ( - database: database, - name: name, - kind: kind, - ), + selectedMysqlObject: my, mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + lastSelectedPostgresObject: lastSelectedPostgresObject, + lastSelectedMysqlObject: my, + lastSelectedSqliteObject: lastSelectedSqliteObject, + lastSelectedExtensionObject: lastSelectedExtensionObject, isReadOnly: isReadOnly, ); } @@ -160,6 +283,10 @@ class MainScreenWorkspaceState { String name, SqliteObjectKind kind, ) { + final sq = ( + name: name, + kind: kind, + ); return MainScreenWorkspaceState( activeConnection: connection, activeRedisDb: null, @@ -170,11 +297,12 @@ class MainScreenWorkspaceState { postgresSqlEditorContextToken: 0, selectedMysqlObject: null, mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, - selectedSqliteObject: ( - name: name, - kind: kind, - ), + selectedSqliteObject: sq, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + lastSelectedPostgresObject: lastSelectedPostgresObject, + lastSelectedMysqlObject: lastSelectedMysqlObject, + lastSelectedSqliteObject: sq, + lastSelectedExtensionObject: lastSelectedExtensionObject, isReadOnly: isReadOnly, ); } @@ -184,6 +312,10 @@ class MainScreenWorkspaceState { String database, String name, ) { + final ext = ( + database: database, + name: name, + ); return MainScreenWorkspaceState( activeConnection: connection, activeRedisDb: null, @@ -196,10 +328,11 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, - selectedExtensionObject: ( - database: database, - name: name, - ), + selectedExtensionObject: ext, + lastSelectedPostgresObject: lastSelectedPostgresObject, + lastSelectedMysqlObject: lastSelectedMysqlObject, + lastSelectedSqliteObject: lastSelectedSqliteObject, + lastSelectedExtensionObject: ext, isReadOnly: isReadOnly, ); } @@ -217,6 +350,10 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + lastSelectedPostgresObject: lastSelectedPostgresObject, + lastSelectedMysqlObject: lastSelectedMysqlObject, + lastSelectedSqliteObject: lastSelectedSqliteObject, + lastSelectedExtensionObject: lastSelectedExtensionObject, isReadOnly: isReadOnly, ); } @@ -235,6 +372,10 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + lastSelectedPostgresObject: lastSelectedPostgresObject, + lastSelectedMysqlObject: lastSelectedMysqlObject, + lastSelectedSqliteObject: lastSelectedSqliteObject, + lastSelectedExtensionObject: lastSelectedExtensionObject, isReadOnly: isReadOnly, ); } @@ -294,6 +435,10 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + lastSelectedPostgresObject: seed ?? lastSelectedPostgresObject, + lastSelectedMysqlObject: lastSelectedMysqlObject, + lastSelectedSqliteObject: lastSelectedSqliteObject, + lastSelectedExtensionObject: lastSelectedExtensionObject, isReadOnly: isReadOnly, ); } @@ -311,6 +456,10 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken + 1, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + lastSelectedPostgresObject: lastSelectedPostgresObject, + lastSelectedMysqlObject: lastSelectedMysqlObject, + lastSelectedSqliteObject: lastSelectedSqliteObject, + lastSelectedExtensionObject: lastSelectedExtensionObject, isReadOnly: isReadOnly, ); } @@ -328,6 +477,10 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken + 1, + lastSelectedPostgresObject: lastSelectedPostgresObject, + lastSelectedMysqlObject: lastSelectedMysqlObject, + lastSelectedSqliteObject: lastSelectedSqliteObject, + lastSelectedExtensionObject: lastSelectedExtensionObject, isReadOnly: isReadOnly, ); } @@ -349,6 +502,11 @@ class MainScreenWorkspaceState { sqliteSqlTabRequestToken == other.sqliteSqlTabRequestToken && _extensionEquals( selectedExtensionObject, other.selectedExtensionObject) && + _pgEquals(lastSelectedPostgresObject, other.lastSelectedPostgresObject) && + _mysqlEquals(lastSelectedMysqlObject, other.lastSelectedMysqlObject) && + _sqliteEquals(lastSelectedSqliteObject, other.lastSelectedSqliteObject) && + _extensionEquals( + lastSelectedExtensionObject, other.lastSelectedExtensionObject) && isReadOnly == other.isReadOnly; } diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index a8bbf4d..c91e0de 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material Alignment, Container, EdgeInsets, + Offset, Padding, Center, Icon, @@ -11,8 +12,7 @@ import 'package:flutter/material.dart' as material MainAxisSize, SizedBox, Widget, - Column, - Offset; + Column; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/motion/querya_fade_slide.dart'; import 'package:querya_desktop/core/motion/querya_switching_body.dart'; @@ -55,6 +55,12 @@ class WorkspacePanel extends StatefulWidget { this.sqliteSqlTabRequestToken = 0, this.selectedExtensionObject, this.extensionSqlTabRequestToken = 0, + this.lastSelectedPostgresObject, + this.lastSelectedMysqlObject, + this.lastSelectedSqliteObject, + this.lastSelectedExtensionObject, + this.onNavigateHome, + this.onRestoreLastSelectedObject, this.isReadOnly = false, this.onRequestNewConnection, this.onRequestNewConnectionFromUrl, @@ -125,6 +131,36 @@ class WorkspacePanel extends StatefulWidget { /// Incremented by [MainScreen] to switch the Extension home view to the SQL tab. final int extensionSqlTabRequestToken; + /// Last selected objects for 1-click return from stats. + final ({ + String database, + String schema, + String name, + PostgresObjectKind kind + })? lastSelectedPostgresObject; + + final ({ + String database, + String name, + MysqlObjectKind kind + })? lastSelectedMysqlObject; + + final ({ + String name, + SqliteObjectKind kind + })? lastSelectedSqliteObject; + + final ({ + String database, + String name, + })? lastSelectedExtensionObject; + + /// Callback to return to the active connection's stats / overview home. + final VoidCallback? onNavigateHome; + + /// Callback to restore the last selected table / object view. + final VoidCallback? onRestoreLastSelectedObject; + /// Empty-state hero: primary CTA to add a connection. final void Function()? onRequestNewConnection; final void Function()? onRequestNewConnectionFromUrl; @@ -206,6 +242,8 @@ class _WorkspacePanelState extends State { postgresSqlEditorContextToken: widget.postgresSqlEditorContextToken, sqlTabRequestToken: widget.postgresSqlTabRequestToken, + lastSelectedPostgresObject: widget.lastSelectedPostgresObject, + onRestoreLastSelectedObject: widget.onRestoreLastSelectedObject, isReadOnly: widget.isReadOnly, ), object: pg == null @@ -213,6 +251,7 @@ class _WorkspacePanelState extends State { : buildPostgresObjectWorkspace( connection: activeConn, pg: pg, + onNavigateHome: widget.onNavigateHome, ), ); break; @@ -240,6 +279,7 @@ class _WorkspacePanelState extends State { database: my.database, tableName: my.name, isView: my.kind == MysqlObjectKind.view, + onNavigateHome: widget.onNavigateHome, ); } } @@ -249,6 +289,8 @@ class _WorkspacePanelState extends State { key: ValueKey('mysql_home_${activeConn.id}'), connectionRow: activeConn, sqlTabRequestToken: widget.mysqlSqlTabRequestToken, + lastSelectedMysqlObject: widget.lastSelectedMysqlObject, + onRestoreLastSelectedObject: widget.onRestoreLastSelectedObject, isReadOnly: widget.isReadOnly, ), object: mysqlObject, @@ -296,6 +338,8 @@ class _WorkspacePanelState extends State { key: ValueKey('sqlite_home_${activeConn.id}'), connectionRow: activeConn, sqlTabRequestToken: widget.sqliteSqlTabRequestToken, + lastSelectedSqliteObject: widget.lastSelectedSqliteObject, + onRestoreLastSelectedObject: widget.onRestoreLastSelectedObject, isReadOnly: widget.isReadOnly, ), object: sq == null @@ -307,6 +351,7 @@ class _WorkspacePanelState extends State { connectionRow: activeConn, tableName: sq.name, isView: sq.kind == SqliteObjectKind.view, + onNavigateHome: widget.onNavigateHome, ), ); break; @@ -319,6 +364,8 @@ class _WorkspacePanelState extends State { key: ValueKey('ext_home_${activeConn.id}'), connectionRow: activeConn, sqlTabRequestToken: widget.extensionSqlTabRequestToken, + lastSelectedExtensionObject: widget.lastSelectedExtensionObject, + onRestoreLastSelectedObject: widget.onRestoreLastSelectedObject, isReadOnly: widget.isReadOnly, ), object: obj == null @@ -330,6 +377,7 @@ class _WorkspacePanelState extends State { connectionRow: activeConn, database: obj.database, tableName: obj.name, + onNavigateHome: widget.onNavigateHome, ), ); } diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index c3a116a..ff84b44 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -20,6 +20,7 @@ class MysqlTableView extends material.StatefulWidget { required this.tableName, this.isView = false, this.limit = _defaultLimit, + this.onNavigateHome, }); final ConnectionRow connectionRow; @@ -27,6 +28,7 @@ class MysqlTableView extends material.StatefulWidget { final String tableName; final bool isView; final int limit; + final VoidCallback? onNavigateHome; @override material.State createState() => _MysqlTableViewState(); @@ -474,6 +476,21 @@ class _MysqlTableViewState extends material.State { ), child: material.Row( children: [ + if (widget.onNavigateHome != null) ...[ + material.Tooltip( + message: 'Return to server overview', + child: OutlineButton( + size: ButtonSize.small, + onPressed: widget.onNavigateHome, + leading: const material.Icon( + material.Icons.dns_outlined, + size: 14, + ), + child: const Text('Server'), + ), + ), + const Gap(10), + ], material.Icon( widget.isView ? material.Icons.view_agenda_rounded @@ -557,7 +574,7 @@ class _MysqlTableViewState extends material.State { material.Icons.chevron_left_rounded, size: 16, ), - child: const Text('Back'), + child: const Text('Prev'), ), const Gap(4), OutlineButton( diff --git a/lib/features/mysql/mysql_workspace_home.dart b/lib/features/mysql/mysql_workspace_home.dart index 4d8993e..91096b8 100644 --- a/lib/features/mysql/mysql_workspace_home.dart +++ b/lib/features/mysql/mysql_workspace_home.dart @@ -3,6 +3,7 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/motion/querya_cross_fade_stack.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; import 'package:querya_desktop/features/mysql/mysql_sql_workspace.dart'; import 'package:querya_desktop/features/mysql/mysql_stats_view.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -13,12 +14,22 @@ class MysqlWorkspaceHome extends material.StatefulWidget { super.key, required this.connectionRow, this.sqlTabRequestToken = 0, + this.lastSelectedMysqlObject, + this.onRestoreLastSelectedObject, this.isReadOnly = false, }); final ConnectionRow connectionRow; final bool isReadOnly; + /// Remembers the last visited table/view for 1-click return. + final ({ + String database, + String name, + MysqlObjectKind kind + })? lastSelectedMysqlObject; + final VoidCallback? onRestoreLastSelectedObject; + /// Parent increments to switch to the SQL tab (e.g. context menu on connection). final int sqlTabRequestToken; @@ -82,6 +93,19 @@ class _MysqlWorkspaceHomeState extends material.State { color: theme.colorScheme.mutedForeground, ), ], + if (widget.lastSelectedMysqlObject != null && + widget.onRestoreLastSelectedObject != null) ...[ + const Gap(12), + OutlineButton( + size: ButtonSize.small, + onPressed: widget.onRestoreLastSelectedObject, + leading: const material.Icon( + material.Icons.table_chart_outlined, + size: 14, + ), + child: Text('Return to ${widget.lastSelectedMysqlObject!.name}'), + ), + ], const Spacer(), QueryaTabStrip( labels: const ['Server', 'SQL'], diff --git a/lib/features/postgresql/postgres_object_workspace.dart b/lib/features/postgresql/postgres_object_workspace.dart index 5ddab61..117e637 100644 --- a/lib/features/postgresql/postgres_object_workspace.dart +++ b/lib/features/postgresql/postgres_object_workspace.dart @@ -15,6 +15,7 @@ Widget buildPostgresObjectWorkspace({ String name, PostgresObjectKind kind }) pg, + VoidCallback? onNavigateHome, }) { switch (pg.kind) { case PostgresObjectKind.table: @@ -28,6 +29,7 @@ Widget buildPostgresObjectWorkspace({ tableName: pg.name, isView: false, isMaterializedView: false, + onNavigateHome: onNavigateHome, ); case PostgresObjectKind.view: return PostgresTableView( @@ -40,6 +42,7 @@ Widget buildPostgresObjectWorkspace({ tableName: pg.name, isView: true, isMaterializedView: false, + onNavigateHome: onNavigateHome, ); case PostgresObjectKind.materializedView: return PostgresTableView( @@ -52,6 +55,7 @@ Widget buildPostgresObjectWorkspace({ tableName: pg.name, isView: false, isMaterializedView: true, + onNavigateHome: onNavigateHome, ); case PostgresObjectKind.function: return PostgresRoutineView( diff --git a/lib/features/postgresql/postgres_table_toolbar.dart b/lib/features/postgresql/postgres_table_toolbar.dart index 20c1b50..698f0fa 100644 --- a/lib/features/postgresql/postgres_table_toolbar.dart +++ b/lib/features/postgresql/postgres_table_toolbar.dart @@ -20,6 +20,7 @@ class PostgresTableToolbar extends material.StatelessWidget { required this.onGoPrevious, required this.onGoNext, required this.onRefresh, + this.onNavigateHome, }); final String title; @@ -37,6 +38,7 @@ class PostgresTableToolbar extends material.StatelessWidget { final VoidCallback onGoPrevious; final VoidCallback onGoNext; final VoidCallback onRefresh; + final VoidCallback? onNavigateHome; @override material.Widget build(material.BuildContext context) { @@ -53,6 +55,21 @@ class PostgresTableToolbar extends material.StatelessWidget { ), child: material.Row( children: [ + if (onNavigateHome != null) ...[ + material.Tooltip( + message: 'Return to server overview', + child: OutlineButton( + size: ButtonSize.small, + onPressed: onNavigateHome, + leading: const material.Icon( + material.Icons.dns_outlined, + size: 14, + ), + child: const Text('Server'), + ), + ), + const Gap(10), + ], material.Icon(tableIcon, size: 18, color: cs.primary), const Gap(8), material.Expanded( @@ -151,7 +168,7 @@ class PostgresTableToolbar extends material.StatelessWidget { material.Icons.chevron_left_rounded, size: 16, ), - child: const Text('Back'), + child: const Text('Prev'), ), const Gap(4), OutlineButton( diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index 5a85e20..78b8a57 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -19,6 +19,7 @@ class PostgresTableView extends material.StatefulWidget { this.isView = false, this.isMaterializedView = false, this.limit = kPostgresBrowseDefaultRowLimit, + this.onNavigateHome, }); final ConnectionRow connectionRow; @@ -26,6 +27,7 @@ class PostgresTableView extends material.StatefulWidget { final String schema; final String tableName; final bool isView; + final VoidCallback? onNavigateHome; /// When true, toolbar offers REFRESH MATERIALIZED VIEW and matview label. final bool isMaterializedView; @@ -515,6 +517,7 @@ class _PostgresTableViewState extends material.State { loading: _loading, canGoPrevious: _canGoPrevious, canGoNext: _canGoNext, + onNavigateHome: widget.onNavigateHome, onOpenSql: _openSqlEditor, onOpenPrivileges: _openPrivileges, onRefreshMaterializedView: _refreshMaterializedView, diff --git a/lib/features/postgresql/postgres_workspace_home.dart b/lib/features/postgresql/postgres_workspace_home.dart index 95534b9..efe5d12 100644 --- a/lib/features/postgresql/postgres_workspace_home.dart +++ b/lib/features/postgresql/postgres_workspace_home.dart @@ -16,12 +16,23 @@ class PostgresWorkspaceHome extends material.StatefulWidget { this.postgresSqlEditorContext, this.postgresSqlEditorContextToken = 0, this.sqlTabRequestToken = 0, + this.lastSelectedPostgresObject, + this.onRestoreLastSelectedObject, this.isReadOnly = false, }); final ConnectionRow connectionRow; final bool isReadOnly; + /// Remembers the last visited table/view for 1-click return. + final ({ + String database, + String schema, + String name, + PostgresObjectKind kind + })? lastSelectedPostgresObject; + final VoidCallback? onRestoreLastSelectedObject; + /// Set when opening SQL from the tree (e.g. "Open in SQL") to seed session DB + template. final ({ String database, @@ -128,6 +139,19 @@ class _PostgresWorkspaceHomeState color: theme.colorScheme.mutedForeground, ), ], + if (widget.lastSelectedPostgresObject != null && + widget.onRestoreLastSelectedObject != null) ...[ + const Gap(12), + OutlineButton( + size: ButtonSize.small, + onPressed: widget.onRestoreLastSelectedObject, + leading: const material.Icon( + material.Icons.table_chart_outlined, + size: 14, + ), + child: Text('Return to ${widget.lastSelectedPostgresObject!.name}'), + ), + ], const Spacer(), QueryaTabStrip( labels: const ['Server', 'SQL'], diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index 8809598..7007bef 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -14,12 +14,14 @@ class SqliteTableView extends material.StatefulWidget { required this.tableName, this.isView = false, this.limit = _defaultLimit, + this.onNavigateHome, }); final ConnectionRow connectionRow; final String tableName; final bool isView; final int limit; + final VoidCallback? onNavigateHome; @override material.State createState() => _SqliteTableViewState(); @@ -449,6 +451,21 @@ class _SqliteTableViewState extends material.State { ), child: material.Row( children: [ + if (widget.onNavigateHome != null) ...[ + material.Tooltip( + message: 'Return to overview', + child: OutlineButton( + size: ButtonSize.small, + onPressed: widget.onNavigateHome, + leading: const material.Icon( + material.Icons.dns_outlined, + size: 14, + ), + child: const Text('Overview'), + ), + ), + const Gap(10), + ], material.Icon( widget.isView ? material.Icons.view_agenda_rounded @@ -530,7 +547,7 @@ class _SqliteTableViewState extends material.State { material.Icons.chevron_left_rounded, size: 16, ), - child: const Text('Back'), + child: const Text('Prev'), ), const Gap(4), OutlineButton( diff --git a/lib/features/sqlite/sqlite_workspace_home.dart b/lib/features/sqlite/sqlite_workspace_home.dart index 5eb50af..4530700 100644 --- a/lib/features/sqlite/sqlite_workspace_home.dart +++ b/lib/features/sqlite/sqlite_workspace_home.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/motion/querya_cross_fade_stack.dart'; import 'package:querya_desktop/core/storage/local_db.dart' show ConnectionRow; +import 'package:querya_desktop/features/connections/connections_panel.dart' + show SqliteObjectKind; import 'package:querya_desktop/features/sqlite/sqlite_overview_tab.dart'; import 'package:querya_desktop/features/sqlite/sqlite_sql_workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -10,6 +12,8 @@ class SqliteWorkspaceHome extends material.StatefulWidget { super.key, required this.connectionRow, this.sqlTabRequestToken = 0, + this.lastSelectedSqliteObject, + this.onRestoreLastSelectedObject, this.isReadOnly = false, }); @@ -17,6 +21,13 @@ class SqliteWorkspaceHome extends material.StatefulWidget { final int sqlTabRequestToken; final bool isReadOnly; + /// Remembers the last visited table/view for 1-click return. + final ({ + String name, + SqliteObjectKind kind + })? lastSelectedSqliteObject; + final VoidCallback? onRestoreLastSelectedObject; + @override material.State createState() => _SqliteWorkspaceHomeState(); @@ -77,6 +88,19 @@ class _SqliteWorkspaceHomeState extends material.State { color: theme.colorScheme.mutedForeground, ), ], + if (widget.lastSelectedSqliteObject != null && + widget.onRestoreLastSelectedObject != null) ...[ + const Gap(12), + OutlineButton( + size: ButtonSize.small, + onPressed: widget.onRestoreLastSelectedObject, + leading: const material.Icon( + material.Icons.table_chart_outlined, + size: 14, + ), + child: Text('Return to ${widget.lastSelectedSqliteObject!.name}'), + ), + ], const Spacer(), QueryaTabStrip( labels: const ['Overview', 'SQL'], diff --git a/test/features/extensions/extension_table_toolbar_test.dart b/test/features/extensions/extension_table_toolbar_test.dart index c7e6645..44dd04c 100644 --- a/test/features/extensions/extension_table_toolbar_test.dart +++ b/test/features/extensions/extension_table_toolbar_test.dart @@ -41,7 +41,7 @@ void main() { expect(find.text('Rows 1–200 of 5,000'), findsOneWidget); expect(find.text('DDL'), findsOneWidget); expect(find.text('Filter'), findsOneWidget); - expect(find.text('Back'), findsOneWidget); + expect(find.text('Prev'), findsOneWidget); expect(find.text('Next'), findsOneWidget); expect(find.text('Refresh'), findsOneWidget); @@ -53,8 +53,8 @@ void main() { await tester.tap(find.text('Filter')); expect(filterToggled, isTrue); - await tester.ensureVisible(find.text('Back')); - await tester.tap(find.text('Back'), warnIfMissed: false); + await tester.ensureVisible(find.text('Prev')); + await tester.tap(find.text('Prev'), warnIfMissed: false); expect(prevClicked, isTrue); await tester.ensureVisible(find.text('Next')); diff --git a/test/features/main_screen/main_screen_workspace_state_test.dart b/test/features/main_screen/main_screen_workspace_state_test.dart index 411eac6..dd33190 100644 --- a/test/features/main_screen/main_screen_workspace_state_test.dart +++ b/test/features/main_screen/main_screen_workspace_state_test.dart @@ -184,5 +184,35 @@ void main() { state = state.selectConnection(mysqlConn); expect(state.isReadOnly, isFalse); }); + + test('unselectActiveObject and restoreLastSelectedObject for fluid return', + () { + final withTable = MainScreenWorkspaceState.empty.selectPostgresObject( + pgConn, + 'warehouse', + 'public', + 'stock', + PostgresObjectKind.table, + ); + expect(withTable.selectedPostgresObject?.name, 'stock'); + expect(withTable.lastSelectedPostgresObject?.name, 'stock'); + + final unselected = withTable.unselectActiveObject(); + expect(unselected.selectedPostgresObject, isNull); + expect(unselected.lastSelectedPostgresObject?.name, 'stock'); + + final restored = unselected.restoreLastSelectedObject(); + expect(restored.selectedPostgresObject?.name, 'stock'); + expect(restored.selectedPostgresObject?.schema, 'public'); + + // Selecting the same connection keeps lastSelected + final reselectedSame = withTable.selectConnection(pgConn); + expect(reselectedSame.selectedPostgresObject, isNull); + expect(reselectedSame.lastSelectedPostgresObject?.name, 'stock'); + + // Selecting a different connection clears lastSelected + final diffConn = reselectedSame.selectConnection(mysqlConn); + expect(diffConn.lastSelectedPostgresObject, isNull); + }); }); } diff --git a/test/features/main_screen/workspace_panel_layout_test.dart b/test/features/main_screen/workspace_panel_layout_test.dart index 114388a..4273efe 100644 --- a/test/features/main_screen/workspace_panel_layout_test.dart +++ b/test/features/main_screen/workspace_panel_layout_test.dart @@ -4,6 +4,8 @@ import 'package:querya_desktop/core/motion/querya_fade_slide.dart'; import 'package:querya_desktop/core/motion/querya_switching_body.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/main_screen/workspace_panel.dart'; +import 'package:querya_desktop/features/connections/connections_panel.dart' + show SqliteObjectKind; import 'package:querya_desktop/features/redis/redis_explorer_view.dart'; import 'package:querya_desktop/features/redis/redis_view.dart'; @@ -280,5 +282,42 @@ void main() { ); expect(homeObjectSwitchingBody.slide, material.Offset.zero); }); + + testWidgets( + 'SqliteWorkspaceHome renders quick return chip when lastSelectedSqliteObject present', + (tester) async { + const sqConn = ConnectionRow( + id: 10, + type: 'sqlite', + name: 'sqlite-main', + host: ':memory:', + port: 0, + createdAt: '0', + ); + var restored = false; + await pumpWidgetWithSurfaceSize( + tester, + const material.Size(800, 600), + queryaThemeTestShell( + child: material.SizedBox.expand( + child: WorkspacePanel( + activeConnection: sqConn, + lastSelectedSqliteObject: ( + name: 'users', + kind: SqliteObjectKind.table, + ), + onRestoreLastSelectedObject: () => restored = true, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + final returnButton = find.text('Return to users'); + expect(returnButton, findsOneWidget); + await tester.tap(returnButton); + expect(restored, isTrue); + }); }); }