Browse Source

feat: open apps in tabs (#2962)

* feat: open apps in tabs

Closes: #2942 Relates: #2312

* fix: resolve comments

* fix: unfocus editor to close toolbar on open/change tab

* test: abstract open in a new tab helper
Mathias Mogensen 1 year ago
parent
commit
5b1afeb85d
23 changed files with 653 additions and 74 deletions
  1. 69 0
      frontend/appflowy_flutter/integration_test/tabs_test.dart
  2. 8 0
      frontend/appflowy_flutter/integration_test/util/common_operations.dart
  3. 3 0
      frontend/appflowy_flutter/lib/plugins/blank/blank.dart
  4. 4 0
      frontend/appflowy_flutter/lib/plugins/database_view/tar_bar/tab_bar_view.dart
  5. 4 0
      frontend/appflowy_flutter/lib/plugins/document/document.dart
  6. 6 2
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart
  7. 6 3
      frontend/appflowy_flutter/lib/plugins/trash/menu.dart
  8. 3 0
      frontend/appflowy_flutter/lib/plugins/trash/trash.dart
  9. 3 4
      frontend/appflowy_flutter/lib/startup/deps_resolver.dart
  10. 57 0
      frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart
  11. 14 0
      frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_event.dart
  12. 93 0
      frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_state.dart
  13. 21 11
      frontend/appflowy_flutter/lib/workspace/presentation/home/home_screen.dart
  14. 2 0
      frontend/appflowy_flutter/lib/workspace/presentation/home/home_sizes.dart
  15. 85 31
      frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart
  16. 14 1
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/app/section/item.dart
  17. 6 8
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/app/section/section.dart
  18. 6 4
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu.dart
  19. 6 9
      frontend/appflowy_flutter/lib/workspace/presentation/home/navigation.dart
  20. 92 0
      frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/flowy_tab.dart
  21. 103 0
      frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/tabs_manager.dart
  22. 46 0
      frontend/appflowy_flutter/lib/workspace/presentation/widgets/tab_bar_item.dart
  23. 2 1
      frontend/resources/translations/en.json

+ 69 - 0
frontend/appflowy_flutter/integration_test/tabs_test.dart

@@ -0,0 +1,69 @@
+import 'package:appflowy/workspace/presentation/home/tabs/flowy_tab.dart';
+import 'package:appflowy/workspace/presentation/home/tabs/tabs_manager.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_test.dart';
+
+import 'util/base.dart';
+import 'util/common_operations.dart';
+
+const _readmeName = 'Read me';
+const _documentName = 'Document';
+const _calendarName = 'Calendar';
+
+void main() {
+  IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+  group('Tabs', () {
+    testWidgets('Open AppFlowy and open/navigate multiple tabs',
+        (tester) async {
+      await tester.initializeAppFlowy();
+      await tester.tapGoButton();
+
+      expect(
+        find.descendant(
+          of: find.byType(TabsManager),
+          matching: find.byType(TabBar),
+        ),
+        findsNothing,
+      );
+
+      await tester.createNewPageWithName(ViewLayoutPB.Calendar, _calendarName);
+      await tester.createNewPageWithName(ViewLayoutPB.Document, _documentName);
+
+      // Navigate current view to "Read me" document again
+      await tester.tapButtonWithName(_readmeName);
+
+      /// Open second menu item in a new tab
+      await tester.openAppInNewTab(_calendarName);
+
+      /// Open third menu item in a new tab
+      await tester.openAppInNewTab(_documentName);
+
+      expect(
+        find.descendant(
+          of: find.byType(TabsManager),
+          matching: find.byType(TabBar),
+        ),
+        findsOneWidget,
+      );
+
+      expect(
+        find.descendant(
+          of: find.byType(TabBar),
+          matching: find.byType(FlowyTab),
+        ),
+        findsNWidgets(3),
+      );
+
+      /// Navigate to the first tab
+      await tester.tap(
+        find.descendant(
+          of: find.byType(FlowyTab),
+          matching: find.text(_readmeName),
+        ),
+      );
+    });
+  });
+}

+ 8 - 0
frontend/appflowy_flutter/integration_test/util/common_operations.dart

@@ -276,6 +276,14 @@ extension CommonOperations on WidgetTester {
     }
     await pumpAndSettle();
   }
+
+  Future<void> openAppInNewTab(String name) async {
+    await hoverOnPageName(name);
+    await tap(find.byType(ViewDisclosureButton));
+    await pumpAndSettle();
+    await tap(find.text(LocaleKeys.disclosureAction_openNewTab.tr()));
+    await pumpAndSettle();
+  }
 }
 
 extension ViewLayoutPBTest on ViewLayoutPB {

+ 3 - 0
frontend/appflowy_flutter/lib/plugins/blank/blank.dart

@@ -42,6 +42,9 @@ class BlankPagePluginWidgetBuilder extends PluginWidgetBuilder
   @override
   Widget get leftBarItem => FlowyText.medium(LocaleKeys.blankPageTitle.tr());
 
+  @override
+  Widget tabBarItem(String pluginId) => leftBarItem;
+
   @override
   Widget buildWidget({PluginContext? context}) => const BlankPage();
 

+ 4 - 0
frontend/appflowy_flutter/lib/plugins/database_view/tar_bar/tab_bar_view.dart

@@ -7,6 +7,7 @@ import 'package:appflowy/workspace/presentation/home/home_stack.dart';
 import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
 import 'package:appflowy/workspace/presentation/widgets/left_bar_item.dart';
 import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
+import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
 import 'package:appflowy_popover/appflowy_popover.dart';
 import 'package:easy_localization/easy_localization.dart';
@@ -210,6 +211,9 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
   @override
   Widget get leftBarItem => ViewLeftBarItem(view: notifier.view);
 
+  @override
+  Widget tabBarItem(String pluginId) => ViewTabBarItem(view: notifier.view);
+
   @override
   Widget buildWidget({PluginContext? context}) {
     notifier.isDeleted.addListener(() {

+ 4 - 0
frontend/appflowy_flutter/lib/plugins/document/document.dart

@@ -9,6 +9,7 @@ import 'package:appflowy/plugins/util.dart';
 import 'package:appflowy/startup/plugin/plugin.dart';
 import 'package:appflowy/workspace/presentation/home/home_stack.dart';
 import 'package:appflowy/workspace/presentation/widgets/left_bar_item.dart';
+import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
 import 'package:flutter/material.dart';
@@ -104,6 +105,9 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
   @override
   Widget get leftBarItem => ViewLeftBarItem(view: view);
 
+  @override
+  Widget tabBarItem(String pluginId) => ViewTabBarItem(view: notifier.view);
+
   @override
   Widget? get rightBarItem {
     return Row(

+ 6 - 2
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart

@@ -1,5 +1,6 @@
 import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
 import 'package:appflowy/startup/startup.dart';
+import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
 import 'package:appflowy/workspace/application/view/view_service.dart';
 import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
 import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart';
@@ -13,7 +14,6 @@ import 'package:appflowy/generated/locale_keys.g.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flowy_infra/image.dart';
 import 'package:appflowy/workspace/application/view/view_ext.dart';
-import 'package:appflowy/workspace/presentation/home/home_stack.dart';
 import 'package:appflowy/workspace/presentation/home/menu/menu.dart';
 import 'package:appflowy_popover/appflowy_popover.dart';
 import 'package:flowy_infra_ui/style_widget/icon_button.dart';
@@ -156,7 +156,11 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
               case _ActionType.viewDatabase:
                 getIt<MenuSharedState>().latestOpenView = viewPB;
 
-                getIt<HomeStackManager>().setPlugin(viewPB.plugin());
+                getIt<TabsBloc>().add(
+                  TabsEvent.openPlugin(
+                    plugin: viewPB.plugin(),
+                  ),
+                );
                 break;
               case _ActionType.delete:
                 final transaction = widget.editorState.transaction;

+ 6 - 3
frontend/appflowy_flutter/lib/plugins/trash/menu.dart

@@ -1,6 +1,6 @@
 import 'package:appflowy/startup/plugin/plugin.dart';
 import 'package:appflowy/startup/startup.dart';
-import 'package:appflowy/workspace/presentation/home/home_stack.dart';
+import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
 import 'package:appflowy/workspace/presentation/home/menu/menu.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flowy_infra/theme_extension.dart';
@@ -31,8 +31,11 @@ class MenuTrash extends StatelessWidget {
             child: InkWell(
               onTap: () {
                 getIt<MenuSharedState>().latestOpenView = null;
-                getIt<HomeStackManager>()
-                    .setPlugin(makePlugin(pluginType: PluginType.trash));
+                getIt<TabsBloc>().add(
+                  TabsEvent.openPlugin(
+                    plugin: makePlugin(pluginType: PluginType.trash),
+                  ),
+                );
               },
               child: _render(context),
             ),

+ 3 - 0
frontend/appflowy_flutter/lib/plugins/trash/trash.dart

@@ -51,6 +51,9 @@ class TrashPluginDisplay extends PluginWidgetBuilder {
   @override
   Widget get leftBarItem => FlowyText.medium(LocaleKeys.trash_text.tr());
 
+  @override
+  Widget tabBarItem(String pluginId) => leftBarItem;
+
   @override
   Widget? get rightBarItem => null;
 

+ 3 - 4
frontend/appflowy_flutter/lib/startup/deps_resolver.dart

@@ -11,6 +11,7 @@ import 'package:appflowy/user/application/auth/auth_service.dart';
 import 'package:appflowy/user/application/auth/supabase_auth_service.dart';
 import 'package:appflowy/user/application/user_listener.dart';
 import 'package:appflowy/user/application/user_service.dart';
+import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
 import 'package:flowy_infra/file_picker/file_picker_impl.dart';
 import 'package:flowy_infra/file_picker/file_picker_service.dart';
 import 'package:appflowy/plugins/document/application/prelude.dart';
@@ -23,7 +24,6 @@ import 'package:appflowy/workspace/application/settings/prelude.dart';
 import 'package:appflowy/user/application/prelude.dart';
 import 'package:appflowy/user/presentation/router.dart';
 import 'package:appflowy/plugins/trash/application/prelude.dart';
-import 'package:appflowy/workspace/presentation/home/home_stack.dart';
 import 'package:appflowy/workspace/presentation/home/menu/menu.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
@@ -108,9 +108,6 @@ void _resolveHomeDeps(GetIt getIt) {
     (user, _) => UserListener(userProfile: user),
   );
 
-  //
-  getIt.registerLazySingleton<HomeStackManager>(() => HomeStackManager());
-
   getIt.registerFactoryParam<WelcomeBloc, UserProfilePB, void>(
     (user, _) => WelcomeBloc(
       userService: UserBackendService(userId: user.id),
@@ -122,6 +119,8 @@ void _resolveHomeDeps(GetIt getIt) {
   getIt.registerFactoryParam<DocShareBloc, ViewPB, void>(
     (view, _) => DocShareBloc(view: view),
   );
+
+  getIt.registerLazySingleton<TabsBloc>(() => TabsBloc());
 }
 
 void _resolveFolderDeps(GetIt getIt) {

+ 57 - 0
frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart

@@ -0,0 +1,57 @@
+import 'package:appflowy/plugins/util.dart';
+import 'package:appflowy/startup/plugin/plugin.dart';
+import 'package:appflowy/startup/startup.dart';
+import 'package:appflowy/workspace/presentation/home/home_stack.dart';
+import 'package:appflowy/workspace/presentation/home/menu/menu.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
+import 'package:bloc/bloc.dart';
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'tabs_event.dart';
+part 'tabs_state.dart';
+part 'tabs_bloc.freezed.dart';
+
+class TabsBloc extends Bloc<TabsEvent, TabsState> {
+  late final MenuSharedState menuSharedState;
+
+  TabsBloc() : super(TabsState()) {
+    menuSharedState = getIt<MenuSharedState>();
+
+    on<TabsEvent>((event, emit) async {
+      event.when(
+        selectTab: (int index) {
+          if (index != state.currentIndex) {
+            emit(state.copyWith(newIndex: index));
+            _setLatestOpenView();
+          }
+        },
+        moveTab: () {},
+        closeTab: (String pluginId) {
+          emit(state.closeView(pluginId));
+          _setLatestOpenView();
+        },
+        openTab: (Plugin plugin, ViewPB view) {
+          emit(state.openView(plugin, view));
+          _setLatestOpenView(view);
+        },
+        openPlugin: (Plugin plugin, ViewPB? view) {
+          emit(state.openPlugin(plugin: plugin));
+          _setLatestOpenView(view);
+        },
+      );
+    });
+  }
+
+  void _setLatestOpenView([ViewPB? view]) {
+    if (view != null) {
+      menuSharedState.latestOpenView = view;
+    } else {
+      final pageManager = state.currentPageManager;
+      final notifier = pageManager.plugin.notifier;
+      if (notifier is ViewPluginNotifier) {
+        menuSharedState.latestOpenView = notifier.view;
+      }
+    }
+  }
+}

+ 14 - 0
frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_event.dart

@@ -0,0 +1,14 @@
+part of 'tabs_bloc.dart';
+
+@freezed
+class TabsEvent with _$TabsEvent {
+  const factory TabsEvent.moveTab() = _MoveTab;
+  const factory TabsEvent.closeTab(String pluginId) = _CloseTab;
+  const factory TabsEvent.selectTab(int index) = _SelectTab;
+  const factory TabsEvent.openTab({
+    required Plugin plugin,
+    required ViewPB view,
+  }) = _OpenTab;
+  const factory TabsEvent.openPlugin({required Plugin plugin, ViewPB? view}) =
+      _OpenPlugin;
+}

+ 93 - 0
frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_state.dart

@@ -0,0 +1,93 @@
+part of 'tabs_bloc.dart';
+
+class TabsState {
+  final int currentIndex;
+
+  final List<PageManager> _pageManagers;
+  int get pages => _pageManagers.length;
+  PageManager get currentPageManager => _pageManagers[currentIndex];
+  List<PageManager> get pageManagers => _pageManagers;
+
+  TabsState({
+    this.currentIndex = 0,
+    List<PageManager>? pageManagers,
+  }) : _pageManagers = pageManagers ?? [PageManager()];
+
+  /// This opens a new tab given a [Plugin] and a [View].
+  ///
+  /// If the [Plugin.id] is already associated with an open tab,
+  /// then it selects that tab.
+  ///
+  TabsState openView(Plugin plugin, ViewPB view) {
+    final selectExistingPlugin = _selectPluginIfOpen(plugin.id);
+
+    if (selectExistingPlugin == null) {
+      _pageManagers.add(PageManager()..setPlugin(plugin));
+
+      return copyWith(newIndex: pages - 1, pageManagers: [..._pageManagers]);
+    }
+
+    return selectExistingPlugin;
+  }
+
+  TabsState closeView(String pluginId) {
+    _pageManagers.removeWhere((pm) => pm.plugin.id == pluginId);
+
+    /// If currentIndex is greater than the amount of allowed indices
+    /// And the current selected tab isn't the first (index 0)
+    ///   as currentIndex cannot be -1
+    /// Then decrease currentIndex by 1
+    final newIndex = currentIndex > pages - 1 && currentIndex > 0
+        ? currentIndex - 1
+        : currentIndex;
+
+    return copyWith(
+      newIndex: newIndex,
+      pageManagers: [..._pageManagers],
+    );
+  }
+
+  /// This opens a plugin in the current selected tab,
+  /// due to how Document currently works, only one tab
+  /// per plugin can currently be active.
+  ///
+  /// If the plugin is already open in a tab, then that tab
+  /// will become selected.
+  ///
+  TabsState openPlugin({required Plugin plugin}) {
+    final selectExistingPlugin = _selectPluginIfOpen(plugin.id);
+
+    if (selectExistingPlugin == null) {
+      final pageManagers = [..._pageManagers];
+      pageManagers[currentIndex].setPlugin(plugin);
+
+      return copyWith(pageManagers: pageManagers);
+    }
+
+    return selectExistingPlugin;
+  }
+
+  /// Checks if a [Plugin.id] is already associated with an open tab.
+  /// Returns a [TabState] with new index if there is a match.
+  ///
+  /// If no match it returns null
+  ///
+  TabsState? _selectPluginIfOpen(String id) {
+    final index = _pageManagers.indexWhere((pm) => pm.plugin.id == id);
+
+    if (index == -1) {
+      return null;
+    }
+
+    return copyWith(newIndex: index);
+  }
+
+  TabsState copyWith({
+    int? newIndex,
+    List<PageManager>? pageManagers,
+  }) =>
+      TabsState(
+        currentIndex: newIndex ?? currentIndex,
+        pageManagers: pageManagers ?? _pageManagers,
+      );
+}

+ 21 - 11
frontend/appflowy_flutter/lib/workspace/presentation/home/home_screen.dart

@@ -5,6 +5,7 @@ import 'package:appflowy/workspace/application/appearance.dart';
 import 'package:appflowy/workspace/application/home/home_bloc.dart';
 import 'package:appflowy/workspace/application/home/home_service.dart';
 import 'package:appflowy/workspace/application/home/home_setting_bloc.dart';
+import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
 import 'package:appflowy/workspace/application/view/view_ext.dart';
 import 'package:appflowy/workspace/presentation/home/hotkeys.dart';
 import 'package:appflowy/workspace/presentation/widgets/edit_panel/panel_animation.dart';
@@ -39,6 +40,7 @@ class _HomeScreenState extends State<HomeScreen> {
   Widget build(BuildContext context) {
     return MultiBlocProvider(
       providers: [
+        BlocProvider<TabsBloc>.value(value: getIt<TabsBloc>()),
         BlocProvider<HomeBloc>(
           create: (context) {
             return HomeBloc(widget.user, widget.workspaceSetting)
@@ -74,14 +76,18 @@ class _HomeScreenState extends State<HomeScreen> {
                 listener: (context, state) {
                   final view = state.latestView;
                   if (view != null) {
-                    // Only open the last opened view if the [HomeStackManager] current opened plugin is blank and the last opened view is not null.
+                    // Only open the last opened view if the [TabsState.currentPageManager] current opened plugin is blank and the last opened view is not null.
                     // All opened widgets that display on the home screen are in the form of plugins. There is a list of built-in plugins defined in the [PluginType] enum, including board, grid and trash.
-                    if (getIt<HomeStackManager>().plugin.pluginType ==
+                    final currentPageManager =
+                        context.read<TabsBloc>().state.currentPageManager;
+
+                    if (currentPageManager.plugin.pluginType ==
                         PluginType.blank) {
-                      getIt<HomeStackManager>().setPlugin(
-                        view.plugin(listenOnViewChanged: true),
+                      getIt<TabsBloc>().add(
+                        TabsEvent.openPlugin(
+                          plugin: view.plugin(listenOnViewChanged: true),
+                        ),
                       );
-                      getIt<MenuSharedState>().latestOpenView = view;
                     }
                   }
                 },
@@ -275,18 +281,22 @@ class HomeScreenStackAdaptor extends HomeStackDelegate {
         (parentView) {
           final List<ViewPB> views = parentView.childViews;
           if (views.isNotEmpty) {
-            var lastView = views.last;
+            ViewPB lastView = views.last;
             if (index != null && index != 0 && views.length > index - 1) {
               lastView = views[index - 1];
             }
 
-            getIt<MenuSharedState>().latestOpenView = lastView;
-            getIt<HomeStackManager>().setPlugin(
-              lastView.plugin(listenOnViewChanged: true),
+            getIt<TabsBloc>().add(
+              TabsEvent.openPlugin(
+                plugin: lastView.plugin(listenOnViewChanged: true),
+              ),
             );
           } else {
-            getIt<MenuSharedState>().latestOpenView = null;
-            getIt<HomeStackManager>().setPlugin(BlankPagePlugin());
+            getIt<TabsBloc>().add(
+              TabsEvent.openPlugin(
+                plugin: BlankPagePlugin(),
+              ),
+            );
           }
         },
         (err) => Log.error(err),

+ 2 - 0
frontend/appflowy_flutter/lib/workspace/presentation/home/home_sizes.dart

@@ -3,6 +3,8 @@ class HomeSizes {
   static const double topBarHeight = 60;
   static const double editPanelTopBarHeight = 60;
   static const double editPanelWidth = 400;
+  static const double tabBarHeigth = 40;
+  static const double tabBarWidth = 200;
 }
 
 class HomeInsets {

+ 85 - 31
frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart

@@ -2,14 +2,17 @@ import 'package:appflowy/core/frameless_window.dart';
 import 'package:appflowy/plugins/blank/blank.dart';
 import 'package:appflowy/startup/plugin/plugin.dart';
 import 'package:appflowy/startup/startup.dart';
+import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
 import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
 import 'package:appflowy/workspace/presentation/home/navigation.dart';
+import 'package:appflowy/workspace/presentation/home/tabs/tabs_manager.dart';
 import 'package:appflowy/workspace/presentation/home/toast.dart';
 import 'package:appflowy_backend/dispatch/dispatch.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
 import 'package:flowy_infra_ui/style_widget/extension.dart';
-import 'package:flowy_infra_ui/widget/spacing.dart';
 import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
 import 'package:provider/provider.dart';
 import 'package:time/time.dart';
 
@@ -32,25 +35,71 @@ class HomeStack extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
-    return Column(
-      mainAxisAlignment: MainAxisAlignment.start,
-      children: [
-        getIt<HomeStackManager>().stackTopBar(layout: layout),
-        Expanded(
-          child: Container(
-            color: Theme.of(context).colorScheme.surface,
-            child: FocusTraversalGroup(
-              child: getIt<HomeStackManager>().stackWidget(
-                onDeleted: (view, index) {
-                  delegate.didDeleteStackWidget(view, index);
-                },
+    final pageController = PageController();
+
+    return BlocProvider<TabsBloc>.value(
+      value: getIt<TabsBloc>(),
+      child: BlocBuilder<TabsBloc, TabsState>(
+        builder: (context, state) {
+          return Column(
+            mainAxisAlignment: MainAxisAlignment.start,
+            children: [
+              TabsManager(pageController: pageController),
+              state.currentPageManager.stackTopBar(layout: layout),
+              Expanded(
+                child: PageView(
+                  physics: const NeverScrollableScrollPhysics(),
+                  controller: pageController,
+                  children: state.pageManagers
+                      .map(
+                        (pm) => PageStack(pageManager: pm, delegate: delegate),
+                      )
+                      .toList(),
+                ),
               ),
-            ),
-          ),
+            ],
+          );
+        },
+      ),
+    );
+  }
+}
+
+class PageStack extends StatefulWidget {
+  const PageStack({
+    super.key,
+    required this.pageManager,
+    required this.delegate,
+  });
+
+  final PageManager pageManager;
+
+  final HomeStackDelegate delegate;
+
+  @override
+  State<PageStack> createState() => _PageStackState();
+}
+
+class _PageStackState extends State<PageStack>
+    with AutomaticKeepAliveClientMixin {
+  @override
+  Widget build(BuildContext context) {
+    super.build(context);
+
+    return Container(
+      color: Theme.of(context).colorScheme.surface,
+      child: FocusTraversalGroup(
+        child: widget.pageManager.stackWidget(
+          onDeleted: (view, index) {
+            widget.delegate.didDeleteStackWidget(view, index);
+          },
         ),
-      ],
+      ),
     );
   }
+
+  @override
+  bool get wantKeepAlive => true;
 }
 
 class FadingIndexedStack extends StatefulWidget {
@@ -104,18 +153,20 @@ class FadingIndexedStackState extends State<FadingIndexedStack> {
 abstract mixin class NavigationItem {
   Widget get leftBarItem;
   Widget? get rightBarItem => null;
+  Widget tabBarItem(String pluginId);
 
-  NavigationCallback get action => (id) {
-        getIt<HomeStackManager>().setStackWithId(id);
-      };
+  NavigationCallback get action => (id) => throw UnimplementedError();
 }
 
-class HomeStackNotifier extends ChangeNotifier {
+class PageNotifier extends ChangeNotifier {
   Plugin _plugin;
 
   Widget get titleWidget => _plugin.widgetBuilder.leftBarItem;
 
-  HomeStackNotifier({Plugin? plugin})
+  Widget tabBarWidget(String pluginId) =>
+      _plugin.widgetBuilder.tabBarItem(pluginId);
+
+  PageNotifier({Plugin? plugin})
       : _plugin = plugin ?? makePlugin(pluginType: PluginType.blank);
 
   /// This is the only place where the plugin is set.
@@ -133,10 +184,13 @@ class HomeStackNotifier extends ChangeNotifier {
   Plugin get plugin => _plugin;
 }
 
-// HomeStack is initialized as singleton to control the page stack.
-class HomeStackManager {
-  final HomeStackNotifier _notifier = HomeStackNotifier();
-  HomeStackManager();
+// PageManager manages the view for one Tab
+class PageManager {
+  final PageNotifier _notifier = PageNotifier();
+
+  PageNotifier get notifier => _notifier;
+
+  PageManager();
 
   Widget title() {
     return _notifier.plugin.widgetBuilder.leftBarItem;
@@ -157,7 +211,7 @@ class HomeStackManager {
       providers: [
         ChangeNotifierProvider.value(value: _notifier),
       ],
-      child: Selector<HomeStackNotifier, Widget>(
+      child: Selector<PageNotifier, Widget>(
         selector: (context, notifier) => notifier.titleWidget,
         builder: (context, widget, child) {
           return MoveWindowDetector(child: HomeTopBar(layout: layout));
@@ -170,7 +224,7 @@ class HomeStackManager {
     return MultiProvider(
       providers: [ChangeNotifierProvider.value(value: _notifier)],
       child: Consumer(
-        builder: (_, HomeStackNotifier notifier, __) {
+        builder: (_, PageNotifier notifier, __) {
           return FadingIndexedStack(
             index: getIt<PluginSandbox>().indexOf(notifier.plugin.pluginType),
             children: getIt<PluginSandbox>().supportPluginTypes.map(
@@ -185,9 +239,9 @@ class HomeStackManager {
                     padding: builder.contentPadding,
                     child: pluginWidget,
                   );
-                } else {
-                  return const BlankPage();
                 }
+
+                return const BlankPage();
               },
             ).toList(),
           );
@@ -218,9 +272,9 @@ class HomeTopBar extends StatelessWidget {
             const FlowyNavigation(),
             const HSpace(16),
             ChangeNotifierProvider.value(
-              value: Provider.of<HomeStackNotifier>(context, listen: false),
+              value: Provider.of<PageNotifier>(context, listen: false),
               child: Consumer(
-                builder: (_, HomeStackNotifier notifier, __) =>
+                builder: (_, PageNotifier notifier, __) =>
                     notifier.plugin.widgetBuilder.rightBarItem ??
                     const SizedBox.shrink(),
               ),

+ 14 - 1
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/app/section/item.dart

@@ -1,4 +1,5 @@
 import 'package:appflowy/startup/startup.dart';
+import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
 import 'package:appflowy/workspace/application/view/view_bloc.dart';
 import 'package:appflowy/workspace/application/view/view_ext.dart';
 import 'package:appflowy/workspace/presentation/home/menu/menu.dart';
@@ -18,7 +19,6 @@ import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
 import 'package:appflowy_popover/appflowy_popover.dart';
 import 'package:flowy_infra_ui/style_widget/icon_button.dart';
 
-// ignore: must_be_immutable
 class ViewSectionItem extends StatelessWidget {
   final bool isSelected;
   final ViewPB view;
@@ -115,6 +115,14 @@ class ViewSectionItem extends StatelessWidget {
               case ViewDisclosureAction.duplicate:
                 blocContext.read<ViewBloc>().add(const ViewEvent.duplicate());
                 break;
+              case ViewDisclosureAction.openInNewTab:
+                blocContext.read<TabsBloc>().add(
+                      TabsEvent.openTab(
+                        plugin: state.view.plugin(),
+                        view: blocContext.read<ViewBloc>().state.view,
+                      ),
+                    );
+                break;
             }
           },
         ),
@@ -135,6 +143,7 @@ enum ViewDisclosureAction {
   rename,
   delete,
   duplicate,
+  openInNewTab,
 }
 
 extension ViewDisclosureExtension on ViewDisclosureAction {
@@ -146,6 +155,8 @@ extension ViewDisclosureExtension on ViewDisclosureAction {
         return LocaleKeys.disclosureAction_delete.tr();
       case ViewDisclosureAction.duplicate:
         return LocaleKeys.disclosureAction_duplicate.tr();
+      case ViewDisclosureAction.openInNewTab:
+        return LocaleKeys.disclosureAction_openNewTab.tr();
     }
   }
 
@@ -157,6 +168,8 @@ extension ViewDisclosureExtension on ViewDisclosureAction {
         return const FlowySvg(name: 'editor/delete');
       case ViewDisclosureAction.duplicate:
         return const FlowySvg(name: 'editor/copy');
+      case ViewDisclosureAction.openInNewTab:
+        return const FlowySvg(name: 'grid/expander');
     }
   }
 }

+ 6 - 8
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/app/section/section.dart

@@ -1,8 +1,8 @@
 import 'package:appflowy/startup/startup.dart';
 import 'package:appflowy/workspace/application/app/app_bloc.dart';
 import 'package:appflowy/workspace/application/menu/menu_view_section_bloc.dart';
+import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
 import 'package:appflowy/workspace/application/view/view_ext.dart';
-import 'package:appflowy/workspace/presentation/home/home_stack.dart';
 import 'package:appflowy/workspace/presentation/home/menu/menu.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
@@ -27,8 +27,10 @@ class ViewSection extends StatelessWidget {
         listener: (context, state) {
           if (state.selectedView != null) {
             WidgetsBinding.instance.addPostFrameCallback((_) {
-              getIt<HomeStackManager>().setPlugin(
-                state.selectedView!.plugin(listenOnViewChanged: true),
+              getIt<TabsBloc>().add(
+                TabsEvent.openPlugin(
+                  plugin: state.selectedView!.plugin(listenOnViewChanged: true),
+                ),
               );
             });
           }
@@ -73,10 +75,6 @@ class ViewSection extends StatelessWidget {
   }
 
   bool _isViewSelected(ViewSectionState state, String viewId) {
-    final view = state.selectedView;
-    if (view == null) {
-      return false;
-    }
-    return view.id == viewId;
+    return state.selectedView?.id == viewId;
   }
 }

+ 6 - 4
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu.dart

@@ -6,15 +6,14 @@ import 'package:appflowy/plugins/trash/menu.dart';
 import 'package:appflowy/startup/startup.dart';
 import 'package:appflowy/workspace/application/home/home_setting_bloc.dart';
 import 'package:appflowy/workspace/application/menu/menu_bloc.dart';
+import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
 import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
-import 'package:appflowy/workspace/presentation/home/home_stack.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder2/workspace.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'
     show UserProfilePB;
 import 'package:easy_localization/easy_localization.dart';
 import 'package:expandable/expandable.dart';
-// import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
 import 'package:flowy_infra/image.dart';
 import 'package:flowy_infra/size.dart';
 import 'package:flowy_infra/time/duration.dart';
@@ -63,7 +62,9 @@ class HomeMenu extends StatelessWidget {
           BlocListener<MenuBloc, MenuState>(
             listenWhen: (p, c) => p.plugin.id != c.plugin.id,
             listener: (context, state) {
-              getIt<HomeStackManager>().setPlugin(state.plugin);
+              getIt<TabsBloc>().add(
+                TabsEvent.openPlugin(plugin: state.plugin),
+              );
             },
           ),
         ],
@@ -131,7 +132,8 @@ class HomeMenu extends StatelessWidget {
                   //  expect:   oldIndex: 0, newIndex: 1
                   //  receive:  oldIndex: 0, newIndex: 2
                   //  Workaround: if newIndex > oldIndex, we just minus one
-                  final int index = newIndex > oldIndex ? newIndex - 1 : newIndex;
+                  final int index =
+                      newIndex > oldIndex ? newIndex - 1 : newIndex;
                   context
                       .read<MenuBloc>()
                       .add(MenuEvent.moveApp(oldIndex, index));

+ 6 - 9
frontend/appflowy_flutter/lib/workspace/presentation/home/navigation.dart

@@ -19,14 +19,9 @@ class NavigationNotifier with ChangeNotifier {
   List<NavigationItem> navigationItems;
   NavigationNotifier({required this.navigationItems});
 
-  void update(HomeStackNotifier notifier) {
-    bool shouldNotify = false;
+  void update(PageNotifier notifier) {
     if (navigationItems != notifier.plugin.widgetBuilder.navigationItems) {
       navigationItems = notifier.plugin.widgetBuilder.navigationItems;
-      shouldNotify = true;
-    }
-
-    if (shouldNotify) {
       notifyListeners();
     }
   }
@@ -37,9 +32,9 @@ class FlowyNavigation extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
-    return ChangeNotifierProxyProvider<HomeStackNotifier, NavigationNotifier>(
+    return ChangeNotifierProxyProvider<PageNotifier, NavigationNotifier>(
       create: (_) {
-        final notifier = Provider.of<HomeStackNotifier>(context, listen: false);
+        final notifier = Provider.of<PageNotifier>(context, listen: false);
         return NavigationNotifier(
           navigationItems: notifier.plugin.widgetBuilder.navigationItems,
         );
@@ -54,7 +49,6 @@ class FlowyNavigation extends StatelessWidget {
               builder: (ctx, items, child) => Expanded(
                 child: Row(
                   children: _renderNavigationItems(items),
-                  // crossAxisAlignment: WrapCrossAlignment.start,
                 ),
               ),
             ),
@@ -173,6 +167,9 @@ class EllipsisNaviItem extends NavigationItem {
         fontSize: FontSizes.s16,
       );
 
+  @override
+  Widget tabBarItem(String pluginId) => leftBarItem;
+
   @override
   NavigationCallback get action => (id) {};
 }

+ 92 - 0
frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/flowy_tab.dart

@@ -0,0 +1,92 @@
+import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
+import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
+import 'package:appflowy/workspace/presentation/home/home_stack.dart';
+import 'package:flowy_infra/image.dart';
+import 'package:flowy_infra/theme_extension.dart';
+import 'package:flowy_infra_ui/style_widget/icon_button.dart';
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+class FlowyTab extends StatefulWidget {
+  final PageManager pageManager;
+  final bool isCurrent;
+
+  const FlowyTab({
+    super.key,
+    required this.pageManager,
+    required this.isCurrent,
+  });
+
+  @override
+  State<FlowyTab> createState() => _FlowyTabState();
+}
+
+class _FlowyTabState extends State<FlowyTab> {
+  bool _isHovering = false;
+
+  @override
+  Widget build(BuildContext context) {
+    return GestureDetector(
+      onTertiaryTapUp: _closeTab,
+      child: MouseRegion(
+        onEnter: (_) => _setHovering(true),
+        onExit: (_) => _setHovering(),
+        child: Container(
+          width: HomeSizes.tabBarWidth,
+          height: HomeSizes.tabBarHeigth,
+          decoration: BoxDecoration(
+            color: _getBackgroundColor(),
+          ),
+          child: ChangeNotifierProvider.value(
+            value: widget.pageManager.notifier,
+            child: Consumer<PageNotifier>(
+              builder: (context, value, child) => Padding(
+                padding: const EdgeInsets.symmetric(horizontal: 16.0),
+                child: Row(
+                  children: [
+                    Expanded(
+                      child: widget.pageManager.notifier
+                          .tabBarWidget(widget.pageManager.plugin.id),
+                    ),
+                    Visibility(
+                      visible: _isHovering,
+                      child: FlowyIconButton(
+                        onPressed: _closeTab,
+                        icon: const FlowySvg(
+                          name: 'editor/close',
+                          size: Size.fromWidth(16),
+                        ),
+                      ),
+                    ),
+                  ],
+                ),
+              ),
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+
+  void _setHovering([bool isHovering = false]) {
+    if (mounted) {
+      setState(() => _isHovering = isHovering);
+    }
+  }
+
+  Color _getBackgroundColor() {
+    if (widget.isCurrent) {
+      return Theme.of(context).colorScheme.onSecondaryContainer;
+    }
+
+    if (_isHovering) {
+      return AFThemeExtension.of(context).lightGreyHover;
+    }
+
+    return Theme.of(context).colorScheme.surfaceVariant;
+  }
+
+  void _closeTab([TapUpDetails? details]) => context
+      .read<TabsBloc>()
+      .add(TabsEvent.closeTab(widget.pageManager.plugin.id));
+}

+ 103 - 0
frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/tabs_manager.dart

@@ -0,0 +1,103 @@
+import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
+import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
+import 'package:appflowy/workspace/presentation/home/tabs/flowy_tab.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class TabsManager extends StatefulWidget {
+  final PageController pageController;
+
+  const TabsManager({
+    super.key,
+    required this.pageController,
+  });
+
+  @override
+  State<TabsManager> createState() => _TabsManagerState();
+}
+
+class _TabsManagerState extends State<TabsManager>
+    with TickerProviderStateMixin {
+  late TabController _controller;
+
+  @override
+  void initState() {
+    super.initState();
+    _controller = TabController(vsync: this, length: 1);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return BlocProvider<TabsBloc>.value(
+      value: BlocProvider.of<TabsBloc>(context),
+      child: BlocListener<TabsBloc, TabsState>(
+        listener: (context, state) {
+          if (_controller.length != state.pages) {
+            _controller.dispose();
+            _controller = TabController(
+              vsync: this,
+              initialIndex: state.currentIndex,
+              length: state.pages,
+            );
+          }
+
+          if (state.currentIndex != widget.pageController.page) {
+            // Unfocus editor to hide selection toolbar
+            FocusScope.of(context).unfocus();
+
+            widget.pageController.animateToPage(
+              state.currentIndex,
+              duration: const Duration(milliseconds: 300),
+              curve: Curves.easeInOut,
+            );
+          }
+        },
+        child: BlocBuilder<TabsBloc, TabsState>(
+          builder: (context, state) {
+            if (_controller.length == 1) {
+              return const SizedBox.shrink();
+            }
+
+            return Container(
+              alignment: Alignment.bottomLeft,
+              height: HomeSizes.tabBarHeigth,
+              decoration: BoxDecoration(
+                color: Theme.of(context).colorScheme.surfaceVariant,
+              ),
+
+              /// TODO(Xazin): Custom Reorderable TabBar
+              child: TabBar(
+                padding: EdgeInsets.zero,
+                labelPadding: EdgeInsets.zero,
+                indicator: BoxDecoration(
+                  border: Border.all(width: 0, color: Colors.transparent),
+                ),
+                indicatorWeight: 0,
+                dividerColor: Colors.transparent,
+                isScrollable: true,
+                controller: _controller,
+                onTap: (newIndex) =>
+                    context.read<TabsBloc>().add(TabsEvent.selectTab(newIndex)),
+                tabs: state.pageManagers
+                    .map(
+                      (pm) => FlowyTab(
+                        key: UniqueKey(),
+                        pageManager: pm,
+                        isCurrent: state.currentPageManager == pm,
+                      ),
+                    )
+                    .toList(),
+              ),
+            );
+          },
+        ),
+      ),
+    );
+  }
+
+  @override
+  void dispose() {
+    _controller.dispose();
+    super.dispose();
+  }
+}

+ 46 - 0
frontend/appflowy_flutter/lib/workspace/presentation/widgets/tab_bar_item.dart

@@ -0,0 +1,46 @@
+import 'package:appflowy/workspace/application/view/view_listener.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flutter/material.dart';
+
+class ViewTabBarItem extends StatefulWidget {
+  final ViewPB view;
+
+  const ViewTabBarItem({
+    super.key,
+    required this.view,
+  });
+
+  @override
+  State<ViewTabBarItem> createState() => _ViewTabBarItemState();
+}
+
+class _ViewTabBarItemState extends State<ViewTabBarItem> {
+  late final ViewListener _viewListener;
+  late ViewPB view;
+
+  @override
+  void initState() {
+    super.initState();
+    view = widget.view;
+    _viewListener = ViewListener(viewId: widget.view.id);
+    _viewListener.start(
+      onViewUpdated: (updatedView) {
+        if (mounted) {
+          setState(() => view = updatedView);
+        }
+      },
+    );
+  }
+
+  @override
+  void dispose() {
+    _viewListener.stop();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return FlowyText.medium(view.name);
+  }
+}

+ 2 - 1
frontend/resources/translations/en.json

@@ -61,7 +61,8 @@
   "disclosureAction": {
     "rename": "Rename",
     "delete": "Delete",
-    "duplicate": "Duplicate"
+    "duplicate": "Duplicate",
+    "openNewTab": "Open in a new tab"
   },
   "blankPageTitle": "Blank page",
   "newPageText": "New page",