Browse Source

feat: tabs shortcuts (#3112)

Mathias Mogensen 1 year ago
parent
commit
35a47bfe5d
26 changed files with 284 additions and 175 deletions
  1. 1 1
      frontend/appflowy_flutter/integration_test/database_calendar_test.dart
  2. 6 6
      frontend/appflowy_flutter/integration_test/document/document_create_and_delete_test.dart
  3. 2 2
      frontend/appflowy_flutter/integration_test/document/edit_document_test.dart
  4. 1 1
      frontend/appflowy_flutter/integration_test/import_files_test.dart
  5. 4 0
      frontend/appflowy_flutter/integration_test/runner.dart
  6. 3 3
      frontend/appflowy_flutter/integration_test/share_markdown_test.dart
  7. 13 13
      frontend/appflowy_flutter/integration_test/sidebar/sidebar_favorites_test.dart
  8. 7 7
      frontend/appflowy_flutter/integration_test/sidebar/sidebar_test.dart
  9. 35 23
      frontend/appflowy_flutter/integration_test/tabs_test.dart
  10. 16 8
      frontend/appflowy_flutter/integration_test/util/common_operations.dart
  11. 1 1
      frontend/appflowy_flutter/integration_test/util/database_test_op.dart
  12. 2 2
      frontend/appflowy_flutter/integration_test/util/expectation.dart
  13. 8 0
      frontend/appflowy_flutter/lib/core/raw_keyboard_extension.dart
  14. 0 4
      frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart
  15. 16 1
      frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart
  16. 1 0
      frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_event.dart
  17. 9 0
      frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_state.dart
  18. 7 1
      frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart
  19. 86 29
      frontend/appflowy_flutter/lib/workspace/presentation/home/hotkeys.dart
  20. 1 6
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/app/section/item.dart
  21. 7 24
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu.dart
  22. 11 8
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/favorite_folder.dart
  23. 9 8
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart
  24. 11 11
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart
  25. 1 6
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart
  26. 26 10
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart

+ 1 - 1
frontend/appflowy_flutter/integration_test/database_calendar_test.dart

@@ -50,7 +50,7 @@ void main() {
       await tester.tapFirstDayOfWeekStartFromMonday();
 
       // Open the other page and open the new calendar page again
-      await tester.openPage(gettingStated);
+      await tester.openPage(gettingStarted);
       await tester.pumpAndSettle(const Duration(milliseconds: 300));
       await tester.openPage(name, layout: ViewLayoutPB.Calendar);
 

+ 6 - 6
frontend/appflowy_flutter/integration_test/document/document_create_and_delete_test.dart

@@ -34,20 +34,20 @@ void main() {
 
       // delete the readme page
       await tester.hoverOnPageName(
-        gettingStated,
+        gettingStarted,
         onHover: () async => await tester.tapDeletePageButton(),
       );
 
       // the banner should show up and the readme page should be gone
       tester.expectToSeeDocumentBanner();
-      tester.expectNotToSeePageName(gettingStated);
+      tester.expectNotToSeePageName(gettingStarted);
 
       // restore the readme page
       await tester.tapRestoreButton();
 
       // the banner should be gone and the readme page should be back
       tester.expectNotToSeeDocumentBanner();
-      tester.expectToSeePageName(gettingStated);
+      tester.expectToSeePageName(gettingStarted);
     });
 
     testWidgets('delete the readme page and delete it permanently',
@@ -58,20 +58,20 @@ void main() {
 
       // delete the readme page
       await tester.hoverOnPageName(
-        gettingStated,
+        gettingStarted,
         onHover: () async => await tester.tapDeletePageButton(),
       );
 
       // the banner should show up and the readme page should be gone
       tester.expectToSeeDocumentBanner();
-      tester.expectNotToSeePageName(gettingStated);
+      tester.expectNotToSeePageName(gettingStarted);
 
       // delete the page permanently
       await tester.tapDeletePermanentlyButton();
 
       // the banner should be gone and the readme page should be gone
       tester.expectNotToSeeDocumentBanner();
-      tester.expectNotToSeePageName(gettingStated);
+      tester.expectNotToSeePageName(gettingStarted);
     });
   });
 }

+ 2 - 2
frontend/appflowy_flutter/integration_test/document/edit_document_test.dart

@@ -59,7 +59,7 @@ void main() {
       );
 
       // switch to other page and switch back
-      await tester.openPage(gettingStated);
+      await tester.openPage(gettingStarted);
       await tester.openPage(pageName);
 
       // the numbered list should be kept
@@ -91,7 +91,7 @@ void main() {
       }
 
       // switch to other page and switch back
-      await tester.openPage(gettingStated);
+      await tester.openPage(gettingStarted);
       await tester.openPage(pageName);
 
       // this screenshots are different on different platform, so comment it out temporarily.

+ 1 - 1
frontend/appflowy_flutter/integration_test/import_files_test.dart

@@ -17,7 +17,7 @@ void main() {
       await tester.tapGoButton();
 
       // expect to see a getting started page
-      tester.expectToSeePageName(gettingStated);
+      tester.expectToSeePageName(gettingStarted);
 
       await tester.tapAddViewButton();
       await tester.tapImportButton();

+ 4 - 0
frontend/appflowy_flutter/integration_test/runner.dart

@@ -16,6 +16,7 @@ import 'share_markdown_test.dart' as share_markdown_test;
 import 'switch_folder_test.dart' as switch_folder_test;
 import 'sidebar/sidebar_test_runner.dart' as sidebar_test_runner;
 import 'board/board_test_runner.dart' as board_test_runner;
+import 'tabs_test.dart' as tabs_test;
 
 /// The main task runner for all integration tests in AppFlowy.
 ///
@@ -51,6 +52,9 @@ void main() {
   database_view_test.main();
   database_calendar_test.main();
 
+  // Tabs
+  tabs_test.main();
+
   // board_test.main();
   // empty_document_test.main();
   // smart_menu_test.main();

+ 3 - 3
frontend/appflowy_flutter/integration_test/share_markdown_test.dart

@@ -16,7 +16,7 @@ void main() {
       await tester.tapGoButton();
 
       // expect to see a readme page
-      tester.expectToSeePageName(gettingStated);
+      tester.expectToSeePageName(gettingStarted);
 
       // mock the file picker
       final path = await mockSaveFilePath(
@@ -43,11 +43,11 @@ void main() {
         await tester.tapGoButton();
 
         // expect to see a getting started page
-        tester.expectToSeePageName(gettingStated);
+        tester.expectToSeePageName(gettingStarted);
 
         // rename the document
         await tester.hoverOnPageName(
-          gettingStated,
+          gettingStarted,
           onHover: () async {
             await tester.renamePage('example');
           },

+ 13 - 13
frontend/appflowy_flutter/integration_test/sidebar/sidebar_favorites_test.dart

@@ -30,7 +30,7 @@ void main() {
         2,
       ].map((e) => 'document_$e').toList();
       for (var i = 0; i < names.length; i++) {
-        final parentName = i == 0 ? gettingStated : names[i - 1];
+        final parentName = i == 0 ? gettingStarted : names[i - 1];
         await tester.createNewPageWithName(
           name: names[i],
           parentName: parentName,
@@ -44,9 +44,9 @@ void main() {
         );
       }
 
-      await tester.favoriteViewByName(gettingStated);
+      await tester.favoriteViewByName(gettingStarted);
       expect(
-        tester.findFavoritePageName(gettingStated),
+        tester.findFavoritePageName(gettingStarted),
         findsOneWidget,
       );
 
@@ -56,9 +56,9 @@ void main() {
         findsNWidgets(2),
       );
 
-      await tester.unfavoriteViewByName(gettingStated);
+      await tester.unfavoriteViewByName(gettingStarted);
       expect(
-        tester.findFavoritePageName(gettingStated),
+        tester.findFavoritePageName(gettingStarted),
         findsNothing,
       );
       expect(
@@ -84,9 +84,9 @@ void main() {
         await tester.tapGoButton();
 
         const name = 'test';
-        await tester.favoriteViewByName(gettingStated);
+        await tester.favoriteViewByName(gettingStarted);
         await tester.hoverOnPageName(
-          gettingStated,
+          gettingStarted,
           layout: ViewLayoutPB.Document,
           onHover: () async {
             await tester.renamePage(name);
@@ -112,7 +112,7 @@ void main() {
 
         final names = [1, 2].map((e) => 'document_$e').toList();
         for (var i = 0; i < names.length; i++) {
-          final parentName = i == 0 ? gettingStated : names[i - 1];
+          final parentName = i == 0 ? gettingStarted : names[i - 1];
           await tester.createNewPageWithName(
             name: names[i],
             parentName: parentName,
@@ -120,7 +120,7 @@ void main() {
           );
           tester.expectToSeePageName(names[i], parentName: parentName);
         }
-        await tester.favoriteViewByName(gettingStated);
+        await tester.favoriteViewByName(gettingStarted);
         await tester.favoriteViewByName(names[0]);
         await tester.favoriteViewByName(names[1]);
 
@@ -154,7 +154,7 @@ void main() {
         );
 
         await tester.hoverOnPageName(
-          gettingStated,
+          gettingStarted,
           layout: ViewLayoutPB.Document,
           onHover: () async {
             await tester.tapDeletePageButton();
@@ -181,7 +181,7 @@ void main() {
         await tester.tapGoButton();
 
         await tester.createNewPageWithName();
-        await tester.favoriteViewByName(gettingStated);
+        await tester.favoriteViewByName(gettingStarted);
         expect(
           find.byWidgetPredicate(
             (widget) =>
@@ -201,9 +201,9 @@ void main() {
         await tester.tapGoButton();
 
         await tester.createNewPageWithName();
-        await tester.favoriteViewByName(gettingStated);
+        await tester.favoriteViewByName(gettingStarted);
         await tester.hoverOnPageName(
-          gettingStated,
+          gettingStarted,
           layout: ViewLayoutPB.Document,
           useLast: false,
           onHover: () async {

+ 7 - 7
frontend/appflowy_flutter/integration_test/sidebar/sidebar_test.dart

@@ -70,7 +70,7 @@ void main() {
             break;
         }
 
-        await tester.openPage(gettingStated);
+        await tester.openPage(gettingStarted);
       }
     });
 
@@ -80,7 +80,7 @@ void main() {
 
       final names = [1, 2, 3, 4].map((e) => 'document_$e').toList();
       for (var i = 0; i < names.length; i++) {
-        final parentName = i == 0 ? gettingStated : names[i - 1];
+        final parentName = i == 0 ? gettingStarted : names[i - 1];
         await tester.createNewPageWithName(
           name: names[i],
           parentName: parentName,
@@ -92,7 +92,7 @@ void main() {
       // move the document_3 to the getting started page
       await tester.movePageToOtherPage(
         name: names[3],
-        parentName: gettingStated,
+        parentName: gettingStarted,
         layout: ViewLayoutPB.Document,
         parentLayout: ViewLayoutPB.Document,
       );
@@ -101,7 +101,7 @@ void main() {
           .view
           .parentViewId;
       final toId = tester
-          .widget<SingleInnerViewItem>(tester.findPageName(gettingStated))
+          .widget<SingleInnerViewItem>(tester.findPageName(gettingStarted))
           .view
           .id;
       expect(fromId, toId);
@@ -109,13 +109,13 @@ void main() {
       // move the document_2 before document_1
       await tester.movePageToOtherPage(
         name: names[2],
-        parentName: gettingStated,
+        parentName: gettingStarted,
         layout: ViewLayoutPB.Document,
         parentLayout: ViewLayoutPB.Document,
         position: DraggableHoverPosition.bottom,
       );
       final childViews = tester
-          .widget<SingleInnerViewItem>(tester.findPageName(gettingStated))
+          .widget<SingleInnerViewItem>(tester.findPageName(gettingStarted))
           .view
           .childViews;
       expect(
@@ -170,7 +170,7 @@ void main() {
 
       // it should not be moved
       final childViews = tester
-          .widget<SingleInnerViewItem>(tester.findPageName(gettingStated))
+          .widget<SingleInnerViewItem>(tester.findPageName(gettingStarted))
           .view
           .childViews;
       expect(

+ 35 - 23
frontend/appflowy_flutter/integration_test/tabs_test.dart

@@ -1,23 +1,26 @@
+import 'dart:io';
+
 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/services.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:integration_test/integration_test.dart';
 
 import 'util/base.dart';
 import 'util/common_operations.dart';
+import 'util/expectation.dart';
+import 'util/keyboard.dart';
 
-const _readmeName = 'Read me';
-const _documentName = 'Document';
-const _calendarName = 'Calendar';
+const _documentName = 'First Doc';
+const _documentTwoName = 'Second Doc';
 
 void main() {
   IntegrationTestWidgetsFlutterBinding.ensureInitialized();
 
   group('Tabs', () {
-    testWidgets('Open AppFlowy and open/navigate multiple tabs',
-        (tester) async {
+    testWidgets('Open AppFlowy and open/navigate/close tabs', (tester) async {
       await tester.initializeAppFlowy();
       await tester.tapGoButton();
 
@@ -29,31 +32,21 @@ void main() {
         findsNothing,
       );
 
-      await tester.createNewPageWithName(
-        name: _calendarName,
-        layout: ViewLayoutPB.Calendar,
-      );
       await tester.createNewPageWithName(
         name: _documentName,
         layout: ViewLayoutPB.Document,
       );
 
-      // Navigate current view to "Read me" document again
-      await tester.tapButtonWithName(_readmeName);
+      await tester.createNewPageWithName(
+        name: _documentTwoName,
+        layout: ViewLayoutPB.Document,
+      );
 
       /// Open second menu item in a new tab
-      await tester.openAppInNewTab(_calendarName);
+      await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document);
 
       /// Open third menu item in a new tab
-      await tester.openAppInNewTab(_documentName);
-
-      expect(
-        find.descendant(
-          of: find.byType(TabsManager),
-          matching: find.byType(TabBar),
-        ),
-        findsOneWidget,
-      );
+      await tester.openAppInNewTab(_documentName, ViewLayoutPB.Document);
 
       expect(
         find.descendant(
@@ -63,12 +56,31 @@ void main() {
         findsNWidgets(3),
       );
 
-      /// Navigate to the first tab
+      /// Navigate to the second tab
       await tester.tap(
         find.descendant(
           of: find.byType(FlowyTab),
-          matching: find.text(_readmeName),
+          matching: find.text(gettingStarted),
+        ),
+      );
+
+      /// Close tab by shortcut
+      await FlowyTestKeyboard.simulateKeyDownEvent(
+        [
+          Platform.isMacOS
+              ? LogicalKeyboardKey.meta
+              : LogicalKeyboardKey.control,
+          LogicalKeyboardKey.keyW,
+        ],
+        tester: tester,
+      );
+
+      expect(
+        find.descendant(
+          of: find.byType(TabBar),
+          matching: find.byType(FlowyTab),
         ),
+        findsNWidgets(2),
       );
     });
   });

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

@@ -8,7 +8,6 @@ import 'package:appflowy/generated/locale_keys.g.dart';
 
 import 'package:appflowy/plugins/document/presentation/share/share_button.dart';
 import 'package:appflowy/user/presentation/skip_log_in_screen.dart';
-import 'package:appflowy/workspace/presentation/home/menu/app/section/item.dart';
 import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
 import 'package:easy_localization/easy_localization.dart';
@@ -29,7 +28,7 @@ extension CommonOperations on WidgetTester {
 
   /// Tap the + button on the home page.
   Future<void> tapAddViewButton({
-    String name = gettingStated,
+    String name = gettingStarted,
   }) async {
     await hoverOnPageName(
       name,
@@ -211,6 +210,12 @@ extension CommonOperations on WidgetTester {
     await tapButtonWithName(ViewMoreActionType.unFavorite.name);
   }
 
+  /// Tap the Open in a new tab button
+  Future<void> tapOpenInTabButton() async {
+    await tapPageOptionButton();
+    await tapButtonWithName(ViewMoreActionType.openInNewTab.name);
+  }
+
   /// Rename the page.
   Future<void> renamePage(String name) async {
     await tapRenamePageButton();
@@ -272,7 +277,7 @@ extension CommonOperations on WidgetTester {
     bool openAfterCreated = true,
   }) async {
     // create a new page
-    await tapAddViewButton(name: parentName ?? gettingStated);
+    await tapAddViewButton(name: parentName ?? gettingStarted);
     await tapButtonWithName(layout.menuName);
     await pumpAndSettle();
 
@@ -336,11 +341,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()));
+  Future<void> openAppInNewTab(String name, ViewLayoutPB layout) async {
+    await hoverOnPageName(
+      name,
+      onHover: () async {
+        await tapOpenInTabButton();
+        await pumpAndSettle();
+      },
+    );
     await pumpAndSettle();
   }
 

+ 1 - 1
frontend/appflowy_flutter/integration_test/util/database_test_op.dart

@@ -76,7 +76,7 @@ extension AppFlowyDatabaseTest on WidgetTester {
     await tapGoButton();
 
     // expect to see a readme page
-    expectToSeePageName(gettingStated);
+    expectToSeePageName(gettingStarted);
 
     await tapAddViewButton();
     await tapImportButton();

+ 2 - 2
frontend/appflowy_flutter/integration_test/util/expectation.dart

@@ -11,13 +11,13 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
 import 'package:flutter_test/flutter_test.dart';
 
 // const String readme = 'Read me';
-const String gettingStated = '⭐️ Getting started';
+const String gettingStarted = '⭐️ Getting started';
 
 extension Expectation on WidgetTester {
   /// Expect to see the home page and with a default read me page.
   void expectToSeeHomePage() {
     expect(find.byType(HomeStack), findsOneWidget);
-    expect(find.textContaining(gettingStated), findsWidgets);
+    expect(find.textContaining(gettingStarted), findsWidgets);
   }
 
   /// Expect to see the page name on the home page.

+ 8 - 0
frontend/appflowy_flutter/lib/core/raw_keyboard_extension.dart

@@ -8,4 +8,12 @@ extension RawKeyboardExtension on RawKeyboard {
           LogicalKeyboardKey.altRight,
         ].contains(key),
       );
+
+  bool get isControlPressed => keysPressed.any(
+        (key) => [
+          LogicalKeyboardKey.control,
+          LogicalKeyboardKey.controlLeft,
+          LogicalKeyboardKey.controlRight,
+        ].contains(key),
+      );
 }

+ 0 - 4
frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart

@@ -35,9 +35,6 @@ class MenuBloc extends Bloc<MenuEvent, MenuState> {
           _listener.start(appsChanged: _handleAppsOrFail);
           await _fetchApps(emit);
         },
-        openPage: (e) async {
-          emit(state.copyWith(plugin: e.plugin));
-        },
         createApp: (_CreateApp event) async {
           final result = await _workspaceService.createApp(
             name: event.name,
@@ -110,7 +107,6 @@ class MenuBloc extends Bloc<MenuEvent, MenuState> {
 @freezed
 class MenuEvent with _$MenuEvent {
   const factory MenuEvent.initial() = _Initial;
-  const factory MenuEvent.openPage(Plugin plugin) = _OpenPage;
   const factory MenuEvent.createApp(String name, {String? desc, int? index}) =
       _CreateApp;
   const factory MenuEvent.moveApp(int fromIndex, int toIndex) = _MoveApp;

+ 16 - 1
frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart

@@ -1,6 +1,7 @@
 import 'package:appflowy/plugins/util.dart';
 import 'package:appflowy/startup/plugin/plugin.dart';
 import 'package:appflowy/startup/startup.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_backend/protobuf/flowy-folder2/view.pb.dart';
@@ -21,7 +22,9 @@ class TabsBloc extends Bloc<TabsEvent, TabsState> {
     on<TabsEvent>((event, emit) async {
       event.when(
         selectTab: (int index) {
-          if (index != state.currentIndex) {
+          if (index != state.currentIndex &&
+              index >= 0 &&
+              index < state.pages) {
             emit(state.copyWith(newIndex: index));
             _setLatestOpenView();
           }
@@ -31,6 +34,10 @@ class TabsBloc extends Bloc<TabsEvent, TabsState> {
           emit(state.closeView(pluginId));
           _setLatestOpenView();
         },
+        closeCurrentTab: () {
+          emit(state.closeView(state.currentPageManager.plugin.id));
+          _setLatestOpenView();
+        },
         openTab: (Plugin plugin, ViewPB view) {
           emit(state.openView(plugin, view));
           _setLatestOpenView(view);
@@ -54,4 +61,12 @@ class TabsBloc extends Bloc<TabsEvent, TabsState> {
       }
     }
   }
+
+  /// Adds a [TabsEvent.openTab] event for the provided [ViewPB]
+  void openTab(ViewPB view) =>
+      add(TabsEvent.openTab(plugin: view.plugin(), view: view));
+
+  /// Adds a [TabsEvent.openPlugin] event for the provided [ViewPB]
+  void openPlugin(ViewPB view) =>
+      add(TabsEvent.openPlugin(plugin: view.plugin(), view: view));
 }

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

@@ -4,6 +4,7 @@ part of 'tabs_bloc.dart';
 class TabsEvent with _$TabsEvent {
   const factory TabsEvent.moveTab() = _MoveTab;
   const factory TabsEvent.closeTab(String pluginId) = _CloseTab;
+  const factory TabsEvent.closeCurrentTab() = _CloseCurrentTab;
   const factory TabsEvent.selectTab(int index) = _SelectTab;
   const factory TabsEvent.openTab({
     required Plugin plugin,

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

@@ -31,6 +31,11 @@ class TabsState {
   }
 
   TabsState closeView(String pluginId) {
+    // Avoid closing the only open tab
+    if (_pageManagers.length == 1) {
+      return this;
+    }
+
     _pageManagers.removeWhere((pm) => pm.plugin.id == pluginId);
 
     /// If currentIndex is greater than the amount of allowed indices
@@ -79,6 +84,10 @@ class TabsState {
       return null;
     }
 
+    if (index == currentIndex) {
+      return this;
+    }
+
     return copyWith(newIndex: index);
   }
 

+ 7 - 1
frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart

@@ -112,9 +112,13 @@ class ViewBloc extends Bloc<ViewEvent, ViewState> {
             ext: {},
             openAfterCreate: e.openAfterCreated,
           );
+
           emit(
             result.fold(
-              (l) => state.copyWith(successOrFailure: left(unit)),
+              (view) => state.copyWith(
+                lastCreatedView: view,
+                successOrFailure: left(unit),
+              ),
               (error) => state.copyWith(successOrFailure: right(error)),
             ),
           );
@@ -218,6 +222,7 @@ class ViewState with _$ViewState {
     required bool isEditing,
     required bool isExpanded,
     required Either<Unit, FlowyError> successOrFailure,
+    @Default(null) ViewPB? lastCreatedView,
   }) = _ViewState;
 
   factory ViewState.init(ViewPB view) => ViewState(
@@ -226,5 +231,6 @@ class ViewState with _$ViewState {
         isExpanded: false,
         isEditing: false,
         successOrFailure: left(unit),
+        lastCreatedView: null,
       );
 }

+ 86 - 29
frontend/appflowy_flutter/lib/workspace/presentation/home/hotkeys.dart

@@ -2,45 +2,102 @@ import 'dart:io';
 
 import 'package:appflowy/workspace/application/appearance.dart';
 import 'package:appflowy/workspace/application/home/home_setting_bloc.dart';
+import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
 import 'package:flutter/material.dart';
 import 'package:hotkey_manager/hotkey_manager.dart';
 import 'package:provider/provider.dart';
 
+typedef KeyDownHandler = void Function(HotKey hotKey);
+
+/// Helper class that utilizes the global [HotKeyManager] to easily
+/// add a [HotKey] with different handlers.
+///
+/// Makes registration of a [HotKey] simple and easy to read, and makes
+/// sure the [KeyDownHandler], and other handlers, are grouped with the
+/// relevant [HotKey].
+///
+class HotKeyItem {
+  final HotKey hotKey;
+  final KeyDownHandler? keyDownHandler;
+
+  HotKeyItem({
+    required this.hotKey,
+    this.keyDownHandler,
+  });
+
+  void register() =>
+      hotKeyManager.register(hotKey, keyDownHandler: keyDownHandler);
+}
+
 class HomeHotKeys extends StatelessWidget {
   final Widget child;
   const HomeHotKeys({required this.child, Key? key}) : super(key: key);
 
   @override
   Widget build(BuildContext context) {
-    final HotKey hotKey = HotKey(
-      KeyCode.backslash,
-      modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control],
-      // Set hotkey scope (default is HotKeyScope.system)
-      scope: HotKeyScope.inapp, // Set as inapp-wide hotkey.
-    );
-    hotKeyManager.register(
-      hotKey,
-      keyDownHandler: (hotKey) {
-        context
-            .read<HomeSettingBloc>()
-            .add(const HomeSettingEvent.collapseMenu());
-      },
-    );
-
-    final HotKey hotKeyForToggleThemeMode = HotKey(
-      KeyCode.keyL,
-      modifiers: [
-        Platform.isMacOS ? KeyModifier.meta : KeyModifier.control,
-        KeyModifier.shift,
-      ],
-      scope: HotKeyScope.inapp,
-    );
-    hotKeyManager.register(
-      hotKeyForToggleThemeMode,
-      keyDownHandler: (_) {
-        context.read<AppearanceSettingsCubit>().toggleThemeMode();
-      },
-    );
+    // Collapse sidebar menu
+    HotKeyItem(
+      hotKey: HotKey(
+        KeyCode.backslash,
+        modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control],
+        // Set hotkey scope (default is HotKeyScope.system)
+        scope: HotKeyScope.inapp, // Set as inapp-wide hotkey.
+      ),
+      keyDownHandler: (_) => context
+          .read<HomeSettingBloc>()
+          .add(const HomeSettingEvent.collapseMenu()),
+    ).register();
+
+    // Toggle theme mode light/dark
+    HotKeyItem(
+      hotKey: HotKey(
+        KeyCode.keyL,
+        modifiers: [
+          Platform.isMacOS ? KeyModifier.meta : KeyModifier.control,
+          KeyModifier.shift,
+        ],
+        scope: HotKeyScope.inapp,
+      ),
+      keyDownHandler: (_) =>
+          context.read<AppearanceSettingsCubit>().toggleThemeMode(),
+    ).register();
+
+    // Close current tab
+    HotKeyItem(
+      hotKey: HotKey(
+        KeyCode.keyW,
+        modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control],
+        scope: HotKeyScope.inapp,
+      ),
+      keyDownHandler: (_) =>
+          context.read<TabsBloc>().add(const TabsEvent.closeCurrentTab()),
+    ).register();
+
+    // Go to previous tab
+    HotKeyItem(
+      hotKey: HotKey(
+        KeyCode.pageUp,
+        modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control],
+        scope: HotKeyScope.inapp,
+      ),
+      keyDownHandler: (_) => _selectTab(context, -1),
+    ).register();
+
+    // Go to next tab
+    HotKeyItem(
+      hotKey: HotKey(
+        KeyCode.pageDown,
+        modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control],
+        scope: HotKeyScope.inapp,
+      ),
+      keyDownHandler: (_) => _selectTab(context, 1),
+    ).register();
+
     return child;
   }
+
+  void _selectTab(BuildContext context, int change) {
+    final bloc = context.read<TabsBloc>();
+    bloc.add(TabsEvent.selectTab(bloc.state.currentIndex + change));
+  }
 }

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

@@ -123,12 +123,7 @@ class ViewSectionItem extends StatelessWidget {
                     .add(FavoriteEvent.toggle(view));
                 break;
               case ViewDisclosureAction.openInNewTab:
-                blocContext.read<TabsBloc>().add(
-                      TabsEvent.openTab(
-                        plugin: state.view.plugin(),
-                        view: blocContext.read<ViewBloc>().state.view,
-                      ),
-                    );
+                blocContext.read<TabsBloc>().openTab(state.view);
                 break;
             }
           },

+ 7 - 24
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu.dart

@@ -7,7 +7,6 @@ import 'package:appflowy/startup/startup.dart';
 import 'package:appflowy/workspace/application/favorite/favorite_bloc.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_backend/protobuf/flowy-folder2/view.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder2/workspace.pb.dart';
@@ -49,34 +48,18 @@ class HomeMenu extends StatelessWidget {
     return MultiBlocProvider(
       providers: [
         BlocProvider<MenuBloc>(
-          create: (context) {
-            final menuBloc = MenuBloc(
-              user: user,
-              workspace: workspaceSetting.workspace,
-            );
-            menuBloc.add(const MenuEvent.initial());
-            return menuBloc;
-          },
+          create: (context) => MenuBloc(
+            user: user,
+            workspace: workspaceSetting.workspace,
+          )..add(const MenuEvent.initial()),
         ),
         BlocProvider(
-          create: (ctx) =>
+          create: (context) =>
               getIt<FavoriteBloc>()..add(const FavoriteEvent.initial()),
         )
       ],
-      child: MultiBlocListener(
-        listeners: [
-          BlocListener<MenuBloc, MenuState>(
-            listenWhen: (p, c) => p.plugin.id != c.plugin.id,
-            listener: (context, state) {
-              getIt<TabsBloc>().add(
-                TabsEvent.openPlugin(plugin: state.plugin),
-              );
-            },
-          ),
-        ],
-        child: BlocBuilder<MenuBloc, MenuState>(
-          builder: (context, state) => _renderBody(context),
-        ),
+      child: BlocBuilder<MenuBloc, MenuState>(
+        builder: (context, state) => _renderBody(context),
       ),
     );
   }

+ 11 - 8
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/favorite_folder.dart

@@ -1,14 +1,13 @@
+import 'package:appflowy/core/raw_keyboard_extension.dart';
 import 'package:appflowy/generated/locale_keys.g.dart';
-import 'package:appflowy/startup/startup.dart';
-import 'package:appflowy/workspace/application/menu/menu_bloc.dart';
 import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
-import 'package:appflowy/workspace/application/view/view_ext.dart';
-import 'package:appflowy/workspace/presentation/home/menu/menu.dart';
+import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
 import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flowy_infra_ui/style_widget/button.dart';
 import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 
 class FavoriteFolder extends StatelessWidget {
@@ -24,6 +23,7 @@ class FavoriteFolder extends StatelessWidget {
     if (views.isEmpty) {
       return const SizedBox.shrink();
     }
+
     return BlocProvider<FolderBloc>(
       create: (context) => FolderBloc(type: FolderCategoryType.favorite)
         ..add(
@@ -54,11 +54,14 @@ class FavoriteFolder extends StatelessWidget {
                     view: view,
                     level: 0,
                     onSelected: (view) {
-                      getIt<MenuSharedState>().latestOpenView = view;
-                      context
-                          .read<MenuBloc>()
-                          .add(MenuEvent.openPage(view.plugin()));
+                      if (RawKeyboard.instance.isControlPressed) {
+                        context.read<TabsBloc>().openTab(view);
+                      }
+
+                      context.read<TabsBloc>().openPlugin(view);
                     },
+                    onTertiarySelected: (view) =>
+                        context.read<TabsBloc>().openTab(view),
                   ),
                 )
             ],

+ 9 - 8
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart

@@ -1,15 +1,15 @@
+import 'package:appflowy/core/raw_keyboard_extension.dart';
 import 'package:appflowy/generated/locale_keys.g.dart';
-import 'package:appflowy/startup/startup.dart';
 import 'package:appflowy/workspace/application/menu/menu_bloc.dart';
 import 'package:appflowy/workspace/application/sidebar/folder/folder_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/menu/view/view_item.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flowy_infra/image.dart';
 import 'package:flowy_infra_ui/flowy_infra_ui.dart';
 import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 
 class PersonalFolder extends StatelessWidget {
@@ -52,13 +52,14 @@ class PersonalFolder extends StatelessWidget {
                     leftPadding: 16,
                     isFeedback: false,
                     onSelected: (view) {
-                      getIt<TabsBloc>().add(
-                        TabsEvent.openPlugin(
-                          plugin: view.plugin(),
-                          view: view,
-                        ),
-                      );
+                      if (RawKeyboard.instance.isControlPressed) {
+                        context.read<TabsBloc>().openTab(view);
+                      }
+
+                      context.read<TabsBloc>().openPlugin(view);
                     },
+                    onTertiarySelected: (view) =>
+                        context.read<TabsBloc>().openTab(view),
                   ),
                 )
             ],

+ 11 - 11
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart

@@ -1,4 +1,3 @@
-import 'package:appflowy/startup/startup.dart';
 import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
 import 'package:appflowy/workspace/application/menu/menu_bloc.dart';
 import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
@@ -7,6 +6,7 @@ import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_new_pa
 import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_top_menu.dart';
 import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_trash.dart';
 import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_user.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;
@@ -48,17 +48,18 @@ class HomeSideBar extends StatelessWidget {
       ],
       child: BlocListener<MenuBloc, MenuState>(
         listenWhen: (p, c) => p.plugin.id != c.plugin.id,
-        listener: (context, state) => getIt<TabsBloc>().add(
-          TabsEvent.openPlugin(plugin: state.plugin),
-        ),
+        listener: (context, state) => context
+            .read<TabsBloc>()
+            .add(TabsEvent.openPlugin(plugin: state.plugin)),
         child: Builder(
           builder: (context) {
             final menuState = context.watch<MenuBloc>().state;
             final favoriteState = context.watch<FavoriteBloc>().state;
+
             return _buildSidebar(
               context,
-              menuState,
-              favoriteState,
+              menuState.views,
+              favoriteState.views,
             );
           },
         ),
@@ -68,11 +69,10 @@ class HomeSideBar extends StatelessWidget {
 
   Widget _buildSidebar(
     BuildContext context,
-    MenuState state,
-    FavoriteState favoriteState,
+    List<ViewPB> views,
+    List<ViewPB> favoriteViews,
   ) {
-    final views = state.views;
-    return Container(
+    return DecoratedBox(
       decoration: BoxDecoration(
         color: Theme.of(context).colorScheme.surfaceVariant,
         border: Border(
@@ -95,7 +95,7 @@ class HomeSideBar extends StatelessWidget {
               child: SingleChildScrollView(
                 child: SidebarFolder(
                   views: views,
-                  favoriteViews: favoriteState.views,
+                  favoriteViews: favoriteViews,
                 ),
               ),
             ),

+ 1 - 6
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart

@@ -48,12 +48,7 @@ class SidebarNewPageButton extends StatelessWidget {
       value: '',
       confirm: (value) {
         if (value.isNotEmpty) {
-          context.read<MenuBloc>().add(
-                MenuEvent.createApp(
-                  value,
-                  desc: '',
-                ),
-              );
+          context.read<MenuBloc>().add(MenuEvent.createApp(value));
         }
       },
     ).show(context);

+ 26 - 10
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart

@@ -19,6 +19,8 @@ import 'package:flowy_infra_ui/style_widget/hover.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 
+typedef ViewItemOnSelected = void Function(ViewPB);
+
 class ViewItem extends StatelessWidget {
   const ViewItem({
     super.key,
@@ -28,6 +30,7 @@ class ViewItem extends StatelessWidget {
     required this.level,
     this.leftPadding = 10,
     required this.onSelected,
+    this.onTertiarySelected,
     this.isFirstChild = false,
     this.isDraggable = true,
     required this.isFeedback,
@@ -46,7 +49,11 @@ class ViewItem extends StatelessWidget {
   // the left padding of the each level = level * leftPadding
   final double leftPadding;
 
-  final void Function(ViewPB) onSelected;
+  // Selected by normal conventions
+  final ViewItemOnSelected onSelected;
+
+  // Selected by middle mouse button
+  final ViewItemOnSelected? onTertiarySelected;
 
   // used for indicating the first child of the parent view, so that we can
   // add top border to the first child
@@ -62,7 +69,12 @@ class ViewItem extends StatelessWidget {
   Widget build(BuildContext context) {
     return BlocProvider(
       create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()),
-      child: BlocBuilder<ViewBloc, ViewState>(
+      child: BlocConsumer<ViewBloc, ViewState>(
+        listenWhen: (p, c) =>
+            c.lastCreatedView != null &&
+            p.lastCreatedView?.id != c.lastCreatedView!.id,
+        listener: (context, state) =>
+            context.read<TabsBloc>().openPlugin(state.lastCreatedView!),
         builder: (context, state) {
           // don't remove this code. it's related to the backend service.
           view.childViews
@@ -78,6 +90,7 @@ class ViewItem extends StatelessWidget {
             showActions: state.isEditing,
             isExpanded: state.isExpanded,
             onSelected: onSelected,
+            onTertiarySelected: onTertiarySelected,
             isFirstChild: isFirstChild,
             isDraggable: isDraggable,
             isFeedback: isFeedback,
@@ -101,6 +114,7 @@ class InnerViewItem extends StatelessWidget {
     required this.leftPadding,
     required this.showActions,
     required this.onSelected,
+    this.onTertiarySelected,
     this.isFirstChild = false,
     required this.isFeedback,
   });
@@ -120,7 +134,8 @@ class InnerViewItem extends StatelessWidget {
   final double leftPadding;
 
   final bool showActions;
-  final void Function(ViewPB) onSelected;
+  final ViewItemOnSelected onSelected;
+  final ViewItemOnSelected? onTertiarySelected;
 
   @override
   Widget build(BuildContext context) {
@@ -131,6 +146,7 @@ class InnerViewItem extends StatelessWidget {
       showActions: showActions,
       categoryType: categoryType,
       onSelected: onSelected,
+      onTertiarySelected: onTertiarySelected,
       isExpanded: isExpanded,
       isDraggable: isDraggable,
       leftPadding: leftPadding,
@@ -148,6 +164,7 @@ class InnerViewItem extends StatelessWidget {
           view: childView,
           level: level + 1,
           onSelected: onSelected,
+          onTertiarySelected: onTertiarySelected,
           isDraggable: isDraggable,
           leftPadding: leftPadding,
           isFeedback: isFeedback,
@@ -176,6 +193,7 @@ class InnerViewItem extends StatelessWidget {
             categoryType: categoryType,
             level: level,
             onSelected: onSelected,
+            onTertiarySelected: onTertiarySelected,
             isDraggable: false,
             leftPadding: leftPadding,
             isFeedback: true,
@@ -206,6 +224,7 @@ class SingleInnerViewItem extends StatefulWidget {
     required this.categoryType,
     required this.showActions,
     required this.onSelected,
+    this.onTertiarySelected,
     required this.isFeedback,
   });
 
@@ -220,7 +239,8 @@ class SingleInnerViewItem extends StatefulWidget {
 
   final bool isDraggable;
   final bool showActions;
-  final void Function(ViewPB) onSelected;
+  final ViewItemOnSelected onSelected;
+  final ViewItemOnSelected? onTertiarySelected;
   final FolderCategoryType categoryType;
 
   @override
@@ -279,6 +299,7 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
     return GestureDetector(
       behavior: HitTestBehavior.translucent,
       onTap: () => widget.onSelected(widget.view),
+      onTertiaryTapDown: (_) => widget.onTertiarySelected?.call(widget.view),
       child: SizedBox(
         height: 26,
         child: Padding(
@@ -377,12 +398,7 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
               context.read<ViewBloc>().add(const ViewEvent.duplicate());
               break;
             case ViewMoreActionType.openInNewTab:
-              context.read<TabsBloc>().add(
-                    TabsEvent.openTab(
-                      plugin: widget.view.plugin(),
-                      view: widget.view,
-                    ),
-                  );
+              context.read<TabsBloc>().openTab(widget.view);
               break;
             default:
               throw UnsupportedError('$action is not supported');