diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_sub_page_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_sub_page_test.dart index d4d6cbab02..50f0f903bc 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_sub_page_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_sub_page_test.dart @@ -58,11 +58,6 @@ void main() { await tester.insertSubPageFromSlashMenu(); - await tester.expandOrCollapsePage( - pageName: 'SubPageBlock', - layout: ViewLayoutPB.Document, - ); - expect( find.text(LocaleKeys.menuAppHeader_defaultNewPageName.tr()), findsNWidgets(3), @@ -77,12 +72,6 @@ void main() { await tester.insertSubPageFromSlashMenu(); - await tester.expandOrCollapsePage( - pageName: 'SubPageBlock', - layout: ViewLayoutPB.Document, - ); - await tester.pumpAndSettle(); - await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); expect(find.text('Child page'), findsNWidgets(2)); @@ -101,11 +90,6 @@ void main() { await tester.insertSubPageFromSlashMenu(); - await tester.expandOrCollapsePage( - pageName: 'SubPageBlock', - layout: ViewLayoutPB.Document, - ); - await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); expect(find.text('Child page'), findsNWidgets(2)); @@ -154,11 +138,6 @@ void main() { await tester.insertSubPageFromSlashMenu(); - await tester.expandOrCollapsePage( - pageName: 'SubPageBlock', - layout: ViewLayoutPB.Document, - ); - await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); expect(find.text('Child page'), findsNWidgets(2)); @@ -212,11 +191,6 @@ void main() { await tester.insertSubPageFromSlashMenu(); - await tester.expandOrCollapsePage( - pageName: 'SubPageBlock', - layout: ViewLayoutPB.Document, - ); - await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); expect(find.text('Child page'), findsNWidgets(2)); @@ -253,11 +227,6 @@ void main() { await tester.insertSubPageFromSlashMenu(); - await tester.expandOrCollapsePage( - pageName: 'SubPageBlock', - layout: ViewLayoutPB.Document, - ); - await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); expect(find.text('Child page'), findsNWidgets(2)); @@ -303,11 +272,6 @@ void main() { await tester.insertSubPageFromSlashMenu(); - await tester.expandOrCollapsePage( - pageName: 'SubPageBlock', - layout: ViewLayoutPB.Document, - ); - await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); expect(find.text('Child page'), findsNWidgets(2)); @@ -346,11 +310,6 @@ void main() { await tester.insertSubPageFromSlashMenu(true); - await tester.expandOrCollapsePage( - pageName: 'SubPageBlock', - layout: ViewLayoutPB.Document, - ); - await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); expect(find.text('Child page'), findsNWidgets(2)); @@ -394,11 +353,6 @@ void main() { await tester.insertSubPageFromSlashMenu(); - await tester.expandOrCollapsePage( - pageName: 'SubPageBlock', - layout: ViewLayoutPB.Document, - ); - await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); expect(find.text('Child page'), findsNWidgets(2)); expect(find.byType(SubPageBlockComponent), findsOneWidget); @@ -421,12 +375,6 @@ void main() { await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock'); await tester.insertSubPageFromSlashMenu(); - - await tester.expandOrCollapsePage( - pageName: 'SubPageBlock', - layout: ViewLayoutPB.Document, - ); - await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); expect(find.text('Child page'), findsNWidgets(2)); @@ -447,11 +395,6 @@ void main() { await tester.insertSubPageFromSlashMenu(true); - await tester.expandOrCollapsePage( - pageName: 'SubPageBlock', - layout: ViewLayoutPB.Document, - ); - expect(find.byType(SubPageBlockComponent), findsOneWidget); final beforeNode = tester.editor.getNodeAtPath([1]); @@ -532,6 +475,11 @@ void main() { await tester.pumpAndSettle(); await tester.openPage(firstPage); + await tester.expandOrCollapsePage( + pageName: firstPage, + layout: ViewLayoutPB.Document, + ); + /// check if there is a icon in document final iconWidget = find.byWidgetPredicate((w) { if (w is! RawEmojiIconWidget) return false; diff --git a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_expand_test.dart b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_expand_test.dart index f2a1fae8ae..ad18cf3de6 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_expand_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_expand_test.dart @@ -1,8 +1,12 @@ import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_folder.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -44,5 +48,82 @@ void main() { ); expect(isExpanded(type: FolderSpaceType.private), true); }); + + testWidgets('Expanding with subpage', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + const page1 = 'SubPageBloc', page2 = '$page1 2'; + await tester.createNewPageWithNameUnderParent(name: page1); + await tester.createNewPageWithNameUnderParent( + name: page2, + parentName: page1, + ); + + await tester.expandOrCollapsePage( + pageName: gettingStarted, + layout: ViewLayoutPB.Document, + ); + + await tester.tapNewPageButton(); + + await tester.editor.tapLineOfEditorAt(0); + await tester.pumpAndSettle(); + await tester.editor.showSlashMenu(); + await tester.pumpAndSettle(); + + final slashMenu = find + .ancestor( + of: find.byType(SelectionMenuItemWidget), + matching: find.byWidgetPredicate( + (widget) => widget is Scrollable, + ), + ) + .first; + final slashMenuItem = find.text( + LocaleKeys.document_slashMenu_name_linkedDoc.tr(), + ); + await tester.scrollUntilVisible( + slashMenuItem, + 100, + scrollable: slashMenu, + duration: const Duration(milliseconds: 250), + ); + + final menuItemFinder = find.byWidgetPredicate( + (w) => + w is SelectionMenuItemWidget && + w.item.name == LocaleKeys.document_slashMenu_name_linkedDoc.tr(), + ); + + final menuItem = + menuItemFinder.evaluate().first.widget as SelectionMenuItemWidget; + + /// tapSlashMenuItemWithName is not working, so invoke this function directly + menuItem.item.handler( + menuItem.editorState, + menuItem.menuService, + menuItemFinder.evaluate().first, + ); + + await tester.pumpAndSettle(); + final actionHandler = find.byType(InlineActionsHandler); + final subPage = find.descendant( + of: actionHandler, + matching: find.text(page2, findRichText: true), + ); + await tester.tapButton(subPage); + + final subpageBlock = find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.text(page2, findRichText: true), + ); + + expect(find.text(page2, findRichText: true), findsOneWidget); + await tester.tapButton(subpageBlock); + + /// one is in SectionFolder, another one is in CoverTitle + /// the last one is in FlowyNavigation + expect(find.text(page2, findRichText: true), findsNWidgets(3)); + }); }); } diff --git a/frontend/appflowy_flutter/lib/startup/startup.dart b/frontend/appflowy_flutter/lib/startup/startup.dart index ac0dfbf88a..ac0be447c0 100644 --- a/frontend/appflowy_flutter/lib/startup/startup.dart +++ b/frontend/appflowy_flutter/lib/startup/startup.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/startup/tasks/feature_flag_task.dart'; +import 'package:appflowy/util/expand_views.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy_backend/appflowy_backend.dart'; import 'package:appflowy_backend/log.dart'; @@ -182,6 +183,7 @@ Future initGetIt( }, ); getIt.registerSingleton(PluginSandbox()); + getIt.registerSingleton(ViewExpanderRegistry()); await DependencyResolver.resolve(getIt, mode); } @@ -207,6 +209,7 @@ abstract class LaunchTask { LaunchTaskType get type => LaunchTaskType.dataProcessing; Future initialize(LaunchContext context); + Future dispose(); } @@ -248,7 +251,9 @@ enum IntegrationMode { // test mode bool get isTest => isUnitTest || isIntegrationTest; + bool get isUnitTest => this == IntegrationMode.unitTest; + bool get isIntegrationTest => this == IntegrationMode.integrationTest; // release mode diff --git a/frontend/appflowy_flutter/lib/util/expand_views.dart b/frontend/appflowy_flutter/lib/util/expand_views.dart new file mode 100644 index 0000000000..115c0d2d29 --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/expand_views.dart @@ -0,0 +1,40 @@ +import 'package:flutter/cupertino.dart'; + +class ViewExpanderRegistry { + /// the key is view id + final Map> _viewExpanders = {}; + + bool isViewExpanded(String id) => getExpander(id)?.isViewExpanded ?? false; + + void register(String id, ViewExpander expander) { + final expanders = _viewExpanders[id] ?? {}; + expanders.add(expander); + _viewExpanders[id] = expanders; + } + + void unregister(String id, ViewExpander expander) { + final expanders = _viewExpanders[id] ?? {}; + expanders.remove(expander); + if (expanders.isEmpty) { + _viewExpanders.remove(id); + } else { + _viewExpanders[id] = expanders; + } + } + + ViewExpander? getExpander(String id) { + final expanders = _viewExpanders[id] ?? {}; + return expanders.isEmpty ? null : expanders.first; + } +} + +class ViewExpander { + ViewExpander(this._isExpandedCallback, this._expandCallback); + + final ValueGetter _isExpandedCallback; + final VoidCallback _expandCallback; + + bool get isViewExpanded => _isExpandedCallback.call(); + + void expand() => _expandCallback.call(); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart index 3562987303..f27539cddd 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart @@ -1,11 +1,19 @@ +import 'dart:convert'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/plugins/blank/blank.dart'; import 'package:appflowy/plugins/util.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/expand_views.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; +import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; import 'package:collection/collection.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -75,6 +83,7 @@ class TabsBloc extends Bloc { return; } _setLatestOpenView(view); + if (view != null) _expandAncestors(view); } }, closeOtherTabs: (String pluginId) { @@ -213,6 +222,32 @@ class TabsBloc extends Bloc { } } + Future _expandAncestors(ViewPB view) async { + final viewExpanderRegistry = getIt.get(); + if (viewExpanderRegistry.isViewExpanded(view.parentViewId)) return; + final value = await getIt().get(KVKeys.expandedViews); + try { + final Map expandedViews = value == null ? {} : jsonDecode(value); + final List ancestors = + await ViewBackendService.getViewAncestors(view.id) + .fold((s) => s.items.map((e) => e.id).toList(), (f) => []); + ViewExpander? viewExpander; + for (final id in ancestors) { + expandedViews[id] = true; + final expander = viewExpanderRegistry.getExpander(id); + if (expander == null) continue; + if (!expander.isViewExpanded && viewExpander == null) { + viewExpander = expander; + } + } + await getIt() + .set(KVKeys.expandedViews, jsonEncode(expandedViews)); + viewExpander?.expand(); + } catch (e) { + Log.error('expandAncestors error', e); + } + } + int _adjustCurrentIndex({ required int currentIndex, required int tabIndex, @@ -250,26 +285,37 @@ class TabsBloc extends Bloc { @freezed class TabsEvent with _$TabsEvent { const factory TabsEvent.moveTab() = _MoveTab; + const factory TabsEvent.closeTab(String pluginId) = _CloseTab; + const factory TabsEvent.closeOtherTabs(String pluginId) = _CloseOtherTabs; + const factory TabsEvent.closeCurrentTab() = _CloseCurrentTab; + const factory TabsEvent.selectTab(int index) = _SelectTab; + const factory TabsEvent.togglePin(String pluginId) = _TogglePin; + const factory TabsEvent.openTab({ required Plugin plugin, required ViewPB view, }) = _OpenTab; + const factory TabsEvent.openPlugin({ required Plugin plugin, ViewPB? view, @Default(true) bool setLatest, }) = _OpenPlugin; + const factory TabsEvent.openSecondaryPlugin({ required Plugin plugin, ViewPB? view, }) = _OpenSecondaryPlugin; + const factory TabsEvent.closeSecondaryPlugin() = _CloseSecondaryPlugin; + const factory TabsEvent.expandSecondaryPlugin() = _ExpandSecondaryPlugin; + const factory TabsEvent.switchWorkspace(String workspaceId) = _SwitchWorkspace; } @@ -282,8 +328,11 @@ class TabsState { final int currentIndex; final List _pageManagers; + int get pages => _pageManagers.length; + PageManager get currentPageManager => _pageManagers[currentIndex]; + List get pageManagers => _pageManagers; bool get isAllPinned => _pageManagers.every((pm) => pm.isPinned); diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart index c31e6f0e06..2a4c472397 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart @@ -6,6 +6,7 @@ import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/expand_views.dart'; import 'package:appflowy/workspace/application/favorite/favorite_listener.dart'; import 'package:appflowy/workspace/application/recent/cached_recent_service.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; @@ -24,12 +25,22 @@ import 'package:protobuf/protobuf.dart'; part 'view_bloc.freezed.dart'; class ViewBloc extends Bloc { - ViewBloc({required this.view, this.shouldLoadChildViews = true}) - : viewBackendSvc = ViewBackendService(), + ViewBloc({ + required this.view, + this.shouldLoadChildViews = true, + this.engagedInExpanding = false, + }) : viewBackendSvc = ViewBackendService(), listener = ViewListener(viewId: view.id), favoriteListener = FavoriteListener(), super(ViewState.init(view)) { _dispatch(); + if (engagedInExpanding) { + expander = ViewExpander( + () => state.isExpanded, + () => add(const ViewEvent.setIsExpanded(true)), + ); + getIt().register(view.id, expander); + } } final ViewPB view; @@ -37,11 +48,16 @@ class ViewBloc extends Bloc { final ViewListener listener; final FavoriteListener favoriteListener; final bool shouldLoadChildViews; + final bool engagedInExpanding; + late ViewExpander expander; @override Future close() async { await listener.stop(); await favoriteListener.stop(); + if (engagedInExpanding) { + getIt().unregister(view.id, expander); + } return super.close(); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/_section_folder.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/_section_folder.dart index 0b383cc5a1..a8717e28bc 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/_section_folder.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/_section_folder.dart @@ -103,6 +103,7 @@ class _SectionFolderState extends State { (view) => ViewItem( key: ValueKey('${widget.spaceType.name} ${view.id}'), spaceType: widget.spaceType, + engagedInExpanding: true, isFirstChild: view.id == widget.views.first.id, view: view, level: 0, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart index 3f39b976d2..d39dbeedfa 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart @@ -69,6 +69,7 @@ class ViewItem extends StatelessWidget { this.extendBuilder, this.disableSelectedStatus, this.shouldIgnoreView, + this.engagedInExpanding = false, this.enableRightClickContext = false, }); @@ -136,12 +137,17 @@ class ViewItem extends StatelessWidget { /// final bool enableRightClickContext; + /// to record the ViewBlock which is expanded or collapsed + final bool engagedInExpanding; + @override Widget build(BuildContext context) { return BlocProvider( - create: (_) => - ViewBloc(view: view, shouldLoadChildViews: shouldLoadChildViews) - ..add(const ViewEvent.initial()), + create: (_) => ViewBloc( + view: view, + shouldLoadChildViews: shouldLoadChildViews, + engagedInExpanding: engagedInExpanding, + )..add(const ViewEvent.initial()), child: BlocConsumer( listenWhen: (p, c) => c.lastCreatedView != null && @@ -183,6 +189,7 @@ class ViewItem extends StatelessWidget { isExpandedNotifier: isExpandedNotifier, extendBuilder: extendBuilder, shouldIgnoreView: shouldIgnoreView, + engagedInExpanding: engagedInExpanding, ); if (shouldIgnoreView?.call(view) == IgnoreViewType.disable) { @@ -235,6 +242,7 @@ class InnerViewItem extends StatefulWidget { this.isExpandedNotifier, required this.extendBuilder, this.disableSelectedStatus, + this.engagedInExpanding = false, required this.shouldIgnoreView, }); @@ -270,6 +278,7 @@ class InnerViewItem extends StatefulWidget { final PropertyValueNotifier? isExpandedNotifier; final List Function(ViewPB view)? extendBuilder; final IgnoreViewType Function(ViewPB view)? shouldIgnoreView; + final bool engagedInExpanding; @override State createState() => _InnerViewItemState(); @@ -345,6 +354,7 @@ class _InnerViewItemState extends State { rightIconsBuilder: widget.rightIconsBuilder, extendBuilder: widget.extendBuilder, shouldIgnoreView: widget.shouldIgnoreView, + engagedInExpanding: widget.engagedInExpanding, ); }).toList();