diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 32131f0..7da3ddb 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -53,7 +53,8 @@ import 'package:flutter/material.dart' as material ListView, ClampingScrollPhysics, CallbackShortcuts, - SingleActivator; + SingleActivator, + AnimatedContainer; import 'package:flutter/services.dart' show Clipboard, ClipboardData, LogicalKeyboardKey; import 'package:querya_desktop/core/database/mongodb_service.dart'; @@ -155,11 +156,57 @@ material.Widget lazyConnectionTreeList({ ); } +/// Inherited scope providing the active connection and object selection down the connections tree. +class _ConnectionsTreeSelectionScope extends InheritedWidget { + const _ConnectionsTreeSelectionScope({ + required this.selectedConnectionId, + required this.selectedPostgresObject, + required this.selectedMysqlObject, + required this.selectedSqliteObject, + required this.selectedExtensionObject, + required this.selectedRedisDb, + required this.selectedMongoDb, + required super.child, + }); + + final int? selectedConnectionId; + final ({String database, String schema, String name, PostgresObjectKind kind})? + selectedPostgresObject; + final ({String database, String name, MysqlObjectKind kind})? + selectedMysqlObject; + final ({String name, SqliteObjectKind kind})? selectedSqliteObject; + final ({String database, String name})? selectedExtensionObject; + final int? selectedRedisDb; + final String? selectedMongoDb; + + static _ConnectionsTreeSelectionScope? of(material.BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType<_ConnectionsTreeSelectionScope>(); + } + + @override + bool updateShouldNotify(_ConnectionsTreeSelectionScope oldWidget) { + return selectedConnectionId != oldWidget.selectedConnectionId || + selectedPostgresObject != oldWidget.selectedPostgresObject || + selectedMysqlObject != oldWidget.selectedMysqlObject || + selectedSqliteObject != oldWidget.selectedSqliteObject || + selectedExtensionObject != oldWidget.selectedExtensionObject || + selectedRedisDb != oldWidget.selectedRedisDb || + selectedMongoDb != oldWidget.selectedMongoDb; + } +} + /// Left panel: Browser tree (pgAdmin-style). Uses shadcn layout widgets. class ConnectionsPanel extends StatefulWidget { const ConnectionsPanel({ super.key, this.selectedConnectionId, + this.selectedPostgresObject, + this.selectedMysqlObject, + this.selectedSqliteObject, + this.selectedExtensionObject, + this.selectedRedisDb, + this.selectedMongoDb, this.onConnectionSelected, this.onRedisDatabaseSelected, this.onMongoDBDatabaseSelected, @@ -181,6 +228,16 @@ class ConnectionsPanel extends StatefulWidget { /// Highlights the active connection row in the sidebar (workspace selection). final int? selectedConnectionId; + /// Currently selected database objects (table, view, routine, db). + final ({String database, String schema, String name, PostgresObjectKind kind})? + selectedPostgresObject; + final ({String database, String name, MysqlObjectKind kind})? + selectedMysqlObject; + final ({String name, SqliteObjectKind kind})? selectedSqliteObject; + final ({String database, String name})? selectedExtensionObject; + final int? selectedRedisDb; + final String? selectedMongoDb; + /// Called when the user taps a connection tile. final void Function(ConnectionRow connection)? onConnectionSelected; @@ -551,7 +608,15 @@ class ConnectionsPanelState extends State { final topLevelCount = _folders.length + rootConnections.length + (showEmptyState ? 1 : 0); - return material.Container( + return _ConnectionsTreeSelectionScope( + selectedConnectionId: widget.selectedConnectionId, + selectedPostgresObject: widget.selectedPostgresObject, + selectedMysqlObject: widget.selectedMysqlObject, + selectedSqliteObject: widget.selectedSqliteObject, + selectedExtensionObject: widget.selectedExtensionObject, + selectedRedisDb: widget.selectedRedisDb, + selectedMongoDb: widget.selectedMongoDb, + child: material.Container( decoration: material.BoxDecoration( color: theme.colorScheme.background, border: material.Border( @@ -689,6 +754,7 @@ class ConnectionsPanelState extends State { ), ], ), - ); - } + ), + ); +} } diff --git a/lib/features/connections/connections_panel_mongo.dart b/lib/features/connections/connections_panel_mongo.dart index 4db6c55..5746839 100644 --- a/lib/features/connections/connections_panel_mongo.dart +++ b/lib/features/connections/connections_panel_mongo.dart @@ -407,10 +407,15 @@ class _MongoDatabaseNode extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final sel = _ConnectionsTreeSelectionScope.of(context); + final isSelected = sel != null && + sel.selectedConnectionId == connection.id && + sel.selectedMongoDb == name; return material.Padding( padding: const material.EdgeInsets.only(left: 16, top: 2, bottom: 2), child: _PgTreeRow( label: name, + isSelected: isSelected, icon: QueryaIcons.database, iconSize: QueryaIconSizes.treeConnection, iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index 3dbbd6e..9d538d3 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -687,11 +687,19 @@ class _MysqlObjectGroupState extends State<_MysqlObjectGroup> { padding: const material.EdgeInsets.only(left: 26), itemBuilder: (context, index) { final item = widget.items[index]; + final sel = _ConnectionsTreeSelectionScope.of(context); + final isSelected = sel != null && + sel.selectedConnectionId == widget.connection.id && + sel.selectedMysqlObject != null && + sel.selectedMysqlObject!.database == widget.databaseName && + sel.selectedMysqlObject!.name == item && + sel.selectedMysqlObject!.kind == widget.objectKind; return _PgTreeRow( key: material.ValueKey( 'mysql-${widget.objectKind.name}-${widget.databaseName}-$item', ), label: item, + isSelected: isSelected, icon: widget.itemIcon, iconSize: QueryaIconSizes.treeLeaf, iconColor: QueryaTreeTokens.leafIconColor( diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart index 68917cd..5282567 100644 --- a/lib/features/connections/connections_panel_pg_tree.dart +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -59,6 +59,7 @@ class _PgTreeRow extends material.StatelessWidget { const _PgTreeRow({ super.key, required this.label, + this.isSelected = false, this.leading, this.icon, this.iconSize = QueryaIconSizes.treeGroup, @@ -80,6 +81,7 @@ class _PgTreeRow extends material.StatelessWidget { }); final String label; + final bool isSelected; final material.Widget? leading; final material.IconData? icon; final double iconSize; @@ -115,43 +117,67 @@ class _PgTreeRow extends material.StatelessWidget { const material.SingleActivator(LogicalKeyboardKey.arrowLeft): () => onTap!(), }, - child: material.Material( - color: material.Colors.transparent, - child: material.InkWell( - onTap: onTap, - canRequestFocus: onTap != null, + child: material.AnimatedContainer( + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.standardCurve), + decoration: material.BoxDecoration( + color: isSelected + ? primary.withValues(alpha: 0.12) + : material.Colors.transparent, borderRadius: material.BorderRadius.circular(4), - hoverColor: primary.withValues(alpha: 0.07), - focusColor: primary.withValues(alpha: 0.14), - splashColor: primary.withValues(alpha: 0.10), - highlightColor: primary.withValues(alpha: 0.05), - mouseCursor: onTap != null - ? material.SystemMouseCursors.click - : material.SystemMouseCursors.basic, - child: material.Padding( - padding: material.EdgeInsets.symmetric( - horizontal: 4, - vertical: verticalPadding, - ), - child: material.Row( - children: [ - if (leading != null) ...[ - leading!, - const Gap(4), - ], - if (icon != null) ...[ - material.Icon( - icon, - size: iconSize, - color: iconColor ?? muted, + border: isSelected + ? material.Border.all( + color: primary.withValues(alpha: 0.35), + width: 1, + ) + : null, + ), + child: material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: onTap, + canRequestFocus: onTap != null, + borderRadius: material.BorderRadius.circular(4), + hoverColor: primary.withValues(alpha: 0.07), + focusColor: primary.withValues(alpha: 0.14), + splashColor: primary.withValues(alpha: 0.10), + highlightColor: primary.withValues(alpha: 0.05), + mouseCursor: onTap != null + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + child: material.Padding( + padding: material.EdgeInsets.symmetric( + horizontal: 4, + vertical: verticalPadding, + ), + child: material.Row( + children: [ + if (leading != null) ...[ + leading!, + const Gap(4), + ], + if (icon != null) ...[ + material.Icon( + icon, + size: iconSize, + color: isSelected ? primary : (iconColor ?? muted), + ), + const Gap(6), + ], + material.Expanded( + child: _PgTreeRowLabel( + label: label, + textStyle: isSelected + ? textStyle.copyWith( + fontWeight: material.FontWeight.w600, + color: theme.colorScheme.foreground, + ) + : textStyle, + ), ), - const Gap(6), + if (trailing != null) trailing!, ], - material.Expanded( - child: _PgTreeRowLabel(label: label, textStyle: textStyle), - ), - if (trailing != null) trailing!, - ], + ), ), ), ), @@ -1119,11 +1145,21 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { padding: const material.EdgeInsets.only(left: 26), itemBuilder: (context, index) { final item = widget.items[index]; + final sel = _ConnectionsTreeSelectionScope.of(context); + final isSelected = sel != null && + sel.selectedConnectionId == widget.connection.id && + sel.selectedPostgresObject != null && + sel.selectedPostgresObject!.database == + widget.databaseName && + sel.selectedPostgresObject!.schema == widget.schemaName && + sel.selectedPostgresObject!.name == item && + sel.selectedPostgresObject!.kind == widget.objectKind; return _PgTreeRow( key: material.ValueKey( 'pg-${widget.objectKind.name}-${widget.databaseName}-${widget.schemaName}-$item', ), label: item, + isSelected: isSelected, icon: widget.itemIcon, iconSize: QueryaIconSizes.treeLeaf, iconColor: QueryaTreeTokens.leafIconColor( diff --git a/lib/features/connections/connections_panel_redis.dart b/lib/features/connections/connections_panel_redis.dart index 0ea0f9c..752bdfa 100644 --- a/lib/features/connections/connections_panel_redis.dart +++ b/lib/features/connections/connections_panel_redis.dart @@ -347,6 +347,7 @@ class _RedisDatabasesNode extends material.StatelessWidget { itemBuilder: (context, index) { final db = databases[index]; return _RedisDatabaseNode( + connection: connection, index: db.index, keys: db.keys, onTap: () => onDatabaseTap?.call(db.index), @@ -361,11 +362,13 @@ class _RedisDatabasesNode extends material.StatelessWidget { class _RedisDatabaseNode extends StatelessWidget { const _RedisDatabaseNode({ + required this.connection, required this.index, required this.keys, required this.onTap, }); + final ConnectionRow connection; final int index; final int keys; final VoidCallback onTap; @@ -373,10 +376,15 @@ class _RedisDatabaseNode extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final sel = _ConnectionsTreeSelectionScope.of(context); + final isSelected = sel != null && + sel.selectedConnectionId == connection.id && + sel.selectedRedisDb == index; return material.Padding( padding: const material.EdgeInsets.only(left: 16), child: _PgTreeRow( label: 'db$index', + isSelected: isSelected, icon: QueryaIcons.database, iconSize: QueryaIconSizes.treeConnection, iconColor: keys > 0 diff --git a/lib/features/connections/connections_panel_sqlite.dart b/lib/features/connections/connections_panel_sqlite.dart index 033fabd..72fe509 100644 --- a/lib/features/connections/connections_panel_sqlite.dart +++ b/lib/features/connections/connections_panel_sqlite.dart @@ -415,11 +415,18 @@ class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { padding: const material.EdgeInsets.only(left: 26), itemBuilder: (context, index) { final item = widget.items[index]; + final sel = _ConnectionsTreeSelectionScope.of(context); + final isSelected = sel != null && + sel.selectedConnectionId == widget.connection.id && + sel.selectedSqliteObject != null && + sel.selectedSqliteObject!.name == item && + sel.selectedSqliteObject!.kind == widget.objectKind; return _PgTreeRow( key: material.ValueKey( 'sqlite-${widget.objectKind.name}-$item', ), label: item, + isSelected: isSelected, icon: widget.itemIcon, iconSize: QueryaIconSizes.treeLeaf, iconColor: QueryaTreeTokens.leafIconColor( 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..553e839 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: @@ -997,12 +1010,9 @@ class _ConnectionsPanelSlot extends StatefulWidget { } class _ConnectionsPanelSlotState extends State<_ConnectionsPanelSlot> { - int? _selectedConnectionId; - @override void initState() { super.initState(); - _selectedConnectionId = widget.workspace.value.activeConnection?.id; widget.workspace.addListener(_onWorkspaceChanged); } @@ -1013,17 +1023,21 @@ class _ConnectionsPanelSlotState extends State<_ConnectionsPanelSlot> { } void _onWorkspaceChanged() { - final next = widget.workspace.value.activeConnection?.id; - if (next != _selectedConnectionId) { - setState(() => _selectedConnectionId = next); - } + setState(() {}); } @override material.Widget build(material.BuildContext context) { + final ws = widget.workspace.value; return ConnectionsPanel( key: widget.connectionsPanelKey, - selectedConnectionId: _selectedConnectionId, + selectedConnectionId: ws.activeConnection?.id, + selectedPostgresObject: ws.selectedPostgresObject, + selectedMysqlObject: ws.selectedMysqlObject, + selectedSqliteObject: ws.selectedSqliteObject, + selectedExtensionObject: ws.selectedExtensionObject, + selectedRedisDb: ws.activeRedisDb, + selectedMongoDb: ws.activeMongoDB, onConnectionSelected: widget.onConnectionSelected, onRedisDatabaseSelected: widget.onRedisDatabaseSelected, onMongoDBDatabaseSelected: widget.onMongoDBDatabaseSelected, 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 f11c784..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, @@ -54,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, @@ -124,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; @@ -205,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 @@ -212,6 +251,7 @@ class _WorkspacePanelState extends State { : buildPostgresObjectWorkspace( connection: activeConn, pg: pg, + onNavigateHome: widget.onNavigateHome, ), ); break; @@ -239,6 +279,7 @@ class _WorkspacePanelState extends State { database: my.database, tableName: my.name, isView: my.kind == MysqlObjectKind.view, + onNavigateHome: widget.onNavigateHome, ); } } @@ -248,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, @@ -295,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 @@ -306,6 +351,7 @@ class _WorkspacePanelState extends State { connectionRow: activeConn, tableName: sq.name, isView: sq.kind == SqliteObjectKind.view, + onNavigateHome: widget.onNavigateHome, ), ); break; @@ -318,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 @@ -329,6 +377,7 @@ class _WorkspacePanelState extends State { connectionRow: activeConn, database: obj.database, tableName: obj.name, + onNavigateHome: widget.onNavigateHome, ), ); } @@ -372,10 +421,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/lib/features/mongodb/mongo_explorer_view.dart b/lib/features/mongodb/mongo_explorer_view.dart index e38a446..08b7bdc 100644 --- a/lib/features/mongodb/mongo_explorer_view.dart +++ b/lib/features/mongodb/mongo_explorer_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/core/motion/querya_switching_body.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; @@ -20,7 +21,7 @@ class _Crumb { final _Level level; } -enum _Level { databases, collections, documents, document } +enum _Level { databases, collections, documents, document, stats } // ─── Main explorer widget ─────────────────────────────────────────────────── @@ -174,10 +175,16 @@ class _MongoExplorerViewState extends material.State { final id = _selectedDocument!['_id']?.toString() ?? 'Document'; list.add(_Crumb(id, _Level.document)); } + if (_showStats) { + list.add(const _Crumb('Statistics', _Level.stats)); + } return list; } void _onCrumbTap(_Crumb crumb) { + if (_showStats && crumb.level != _Level.stats) { + setState(() => _showStats = false); + } switch (crumb.level) { case _Level.databases: _navigateToDatabases(); @@ -187,6 +194,8 @@ class _MongoExplorerViewState extends material.State { _navigateToDocuments(); case _Level.document: break; // Already on the document + case _Level.stats: + break; } } @@ -247,16 +256,6 @@ class _MongoExplorerViewState extends material.State { final conn = _connection; if (conn == null) return const material.SizedBox.shrink(); - // Statistics mode — render MongoStatsView full-screen - if (_showStats) { - return MongoStatsView( - key: ValueKey('stats_${widget.connectionRow.id}'), - connectionRow: widget.connectionRow, - connection: conn, - onBack: () => setState(() => _showStats = false), - ); - } - return material.Container( color: cs.background, child: material.Column( @@ -267,11 +266,25 @@ class _MongoExplorerViewState extends material.State { crumbs: _crumbs, onCrumbTap: _onCrumbTap, onRefresh: () => setState(() => _refreshToken++), - onStats: () => setState(() => _showStats = true), + onStats: () => setState(() => _showStats = !_showStats), ), const Divider(height: 1), - // Content - material.Expanded(child: _buildContent(conn)), + // Content with fluid cross-fade morph between explorer and stats + material.Expanded( + child: QueryaSwitchingBody( + slide: material.Offset.zero, + index: _showStats ? 1 : 0, + children: [ + _buildContent(conn), + MongoStatsView( + key: ValueKey('stats_${widget.connectionRow.id}'), + connectionRow: widget.connectionRow, + connection: conn, + onBack: () => setState(() => _showStats = false), + ), + ], + ), + ), ], ), ); 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/redis/redis_explorer_view.dart b/lib/features/redis/redis_explorer_view.dart index af037da..5a5e163 100644 --- a/lib/features/redis/redis_explorer_view.dart +++ b/lib/features/redis/redis_explorer_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/database/redis_service.dart'; +import 'package:querya_desktop/core/motion/querya_switching_body.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; @@ -17,7 +18,7 @@ class _Crumb { final _Level level; } -enum _Level { keys, key } +enum _Level { keys, key, stats } // ─── Main explorer widget ─────────────────────────────────────────────────── @@ -138,15 +139,23 @@ class _RedisExplorerViewState extends material.State { if (_selectedKey != null) { list.add(_Crumb(_selectedKey!, _Level.key)); } + if (_showStats) { + list.add(const _Crumb('Statistics', _Level.stats)); + } return list; } void _onCrumbTap(_Crumb crumb) { + if (_showStats && crumb.level != _Level.stats) { + setState(() => _showStats = false); + } switch (crumb.level) { case _Level.keys: _navigateToKeys(); case _Level.key: break; + case _Level.stats: + break; } } @@ -207,16 +216,6 @@ class _RedisExplorerViewState extends material.State { final conn = _connection; if (conn == null) return const material.SizedBox.shrink(); - // Statistics mode - if (_showStats) { - return RedisView( - key: ValueKey('stats_${widget.connectionRow.id}'), - connectionRow: widget.connectionRow, - connection: conn, - onBack: () => setState(() => _showStats = false), - ); - } - return material.Container( color: cs.background, child: material.Column( @@ -226,10 +225,25 @@ class _RedisExplorerViewState extends material.State { crumbs: _crumbs, onCrumbTap: _onCrumbTap, onRefresh: () => setState(() => _refreshEpoch++), - onStats: () => setState(() => _showStats = true), + onStats: () => setState(() => _showStats = !_showStats), ), const Divider(height: 1), - material.Expanded(child: _buildContent(conn)), + // Content with fluid cross-fade morph between keys and stats + material.Expanded( + child: QueryaSwitchingBody( + slide: material.Offset.zero, + index: _showStats ? 1 : 0, + children: [ + _buildContent(conn), + RedisView( + key: ValueKey('stats_${widget.connectionRow.id}'), + connectionRow: widget.connectionRow, + connection: conn, + onBack: () => setState(() => _showStats = false), + ), + ], + ), + ), ], ), ); 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/connections/connections_panel_layout_test.dart b/test/features/connections/connections_panel_layout_test.dart index 4a67b09..ab44b03 100644 --- a/test/features/connections/connections_panel_layout_test.dart +++ b/test/features/connections/connections_panel_layout_test.dart @@ -365,5 +365,30 @@ void main() { await tester.pump(const Duration(milliseconds: 100)); expect(panelState.isConnectionExpanded(id1), true); }); + + testWidgets('ConnectionsPanel propagates selection scope to child tree', + (tester) async { + await pumpWidgetWithSurfaceSize( + tester, + const material.Size(400, 600), + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: const material.SizedBox( + width: 400, + height: 600, + child: ConnectionsPanel( + skipInitialDbLoadForTest: true, + selectedConnectionId: 10, + selectedRedisDb: 0, + ), + ), + ), + ); + await tester.pump(); + + expect(find.byType(ConnectionsPanel), findsOneWidget); + }); }); } 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 79f795c..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'; @@ -255,5 +257,67 @@ 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); + }); + + 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); + }); }); }