Browse Source

feat: support favorites folder

Mihir 1 year ago
parent
commit
a1143e24f3
50 changed files with 1268 additions and 172 deletions
  1. 0 3
      frontend/appflowy_flutter/assets/images/home/Favorite/active.svg
  2. 0 3
      frontend/appflowy_flutter/assets/images/home/Favorite/inactive.svg
  3. 2 2
      frontend/appflowy_flutter/assets/images/home/favorite.svg
  4. 3 0
      frontend/appflowy_flutter/assets/images/home/unfavorite.svg
  5. 48 0
      frontend/appflowy_flutter/integration_test/sidebar/sidebar_expand_test.dart
  6. 175 0
      frontend/appflowy_flutter/integration_test/sidebar/sidebar_favorites_test.dart
  7. 4 0
      frontend/appflowy_flutter/integration_test/sidebar/sidebar_test_runner.dart
  8. 43 1
      frontend/appflowy_flutter/integration_test/util/common_operations.dart
  9. 22 4
      frontend/appflowy_flutter/integration_test/util/expectation.dart
  10. 6 0
      frontend/appflowy_flutter/lib/core/config/kv_keys.dart
  11. 1 3
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart
  12. 1 2
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart
  13. 2 0
      frontend/appflowy_flutter/lib/startup/deps_resolver.dart
  14. 93 0
      frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_bloc.dart
  15. 65 0
      frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_listener.dart
  16. 18 0
      frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_service.dart
  17. 3 0
      frontend/appflowy_flutter/lib/workspace/application/favorite/prelude.dart
  18. 2 1
      frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart
  19. 83 0
      frontend/appflowy_flutter/lib/workspace/application/sidebar/folder/folder_bloc.dart
  20. 21 0
      frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart
  21. 10 1
      frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart
  22. 25 6
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/app/section/item.dart
  23. 0 1
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/favorite/favorite.dart
  24. 0 10
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/favorite/header.dart
  25. 0 1
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/favorite/section.dart
  26. 61 5
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu.dart
  27. 109 0
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/favorite_folder.dart
  28. 43 30
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart
  29. 31 9
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart
  30. 23 6
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_folder.dart
  31. 10 5
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_action_type.dart
  32. 30 7
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart
  33. 12 7
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart
  34. 2 5
      frontend/appflowy_flutter/test/bloc_test/home_test/menu_bloc_test.dart
  35. 6 7
      frontend/appflowy_tauri/src-tauri/Cargo.toml
  36. 3 0
      frontend/resources/translations/en.json
  37. 10 10
      frontend/rust-lib/Cargo.lock
  38. 6 7
      frontend/rust-lib/Cargo.toml
  39. 29 9
      frontend/rust-lib/flowy-folder2/src/entities/view.rs
  40. 35 3
      frontend/rust-lib/flowy-folder2/src/event_handler.rs
  41. 8 1
      frontend/rust-lib/flowy-folder2/src/event_map.rs
  42. 105 18
      frontend/rust-lib/flowy-folder2/src/manager.rs
  43. 5 0
      frontend/rust-lib/flowy-folder2/src/notification.rs
  44. 1 0
      frontend/rust-lib/flowy-folder2/src/test_helper.rs
  45. 5 1
      frontend/rust-lib/flowy-folder2/src/view_operation.rs
  46. 52 0
      frontend/rust-lib/flowy-folder2/tests/workspace/folder_test.rs
  47. 47 4
      frontend/rust-lib/flowy-folder2/tests/workspace/script.rs
  48. 1 0
      frontend/rust-lib/flowy-test/src/document/document_event.rs
  49. 2 0
      frontend/rust-lib/flowy-test/src/folder_event.rs
  50. 5 0
      frontend/rust-lib/flowy-test/src/lib.rs

+ 0 - 3
frontend/appflowy_flutter/assets/images/home/Favorite/active.svg

@@ -1,3 +0,0 @@
-<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M16 6L18.781 11.9243L25 12.8801L20.5 17.489L21.562 24L16 20.9243L10.438 24L11.5 17.489L7 12.8801L13.219 11.9243L16 6Z" fill="#FFD667" stroke="#FFD667" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>

+ 0 - 3
frontend/appflowy_flutter/assets/images/home/Favorite/inactive.svg

@@ -1,3 +0,0 @@
-<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M16 6L18.781 11.9243L25 12.8801L20.5 17.489L21.562 24L16 20.9243L10.438 24L11.5 17.489L7 12.8801L13.219 11.9243L16 6Z" stroke="#333333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>

+ 2 - 2
frontend/appflowy_flutter/assets/images/home/favorite.svg

@@ -1,3 +1,3 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M12 4.5L14.0858 8.94322L18.75 9.66009L15.375 13.1167L16.1715 18L12 15.6932L7.8285 18L8.625 13.1167L5.25 9.66009L9.91425 8.94322L12 4.5Z" stroke="#333333" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8 3L9.3905 5.96215L12.5 6.44006L10.25 8.74448L10.781 12L8 10.4621L5.219 12L5.75 8.74448L3.5 6.44006L6.6095 5.96215L8 3Z" fill="#FFD667" stroke="#FFD667" stroke-linecap="round" stroke-linejoin="round"/>
 </svg>

+ 3 - 0
frontend/appflowy_flutter/assets/images/home/unfavorite.svg

@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8 3L9.3905 5.96215L12.5 6.44006L10.25 8.74448L10.781 12L8 10.4621L5.219 12L5.75 8.74448L3.5 6.44006L6.6095 5.96215L8 3Z" fill="#none" stroke="#FFFFFF" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 48 - 0
frontend/appflowy_flutter/integration_test/sidebar/sidebar_expand_test.dart

@@ -0,0 +1,48 @@
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
+import 'package:appflowy/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart';
+import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_test.dart';
+
+import '../util/util.dart';
+
+void main() {
+  IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+  group('sidebar expand test', () {
+    bool isExpanded({required FolderCategoryType type}) {
+      if (type == FolderCategoryType.personal) {
+        return find
+            .descendant(
+              of: find.byType(PersonalFolder),
+              matching: find.byType(ViewItem),
+            )
+            .evaluate()
+            .isNotEmpty;
+      }
+      return false;
+    }
+
+    testWidgets('first time the personal folder is expanded', (tester) async {
+      await tester.initializeAppFlowy();
+      await tester.tapGoButton();
+
+      // first time is expanded
+      expect(isExpanded(type: FolderCategoryType.personal), true);
+
+      // collapse the personal folder
+      await tester.tapButton(
+        find.byTooltip(LocaleKeys.sideBar_clickToHidePersonal.tr()),
+      );
+      expect(isExpanded(type: FolderCategoryType.personal), false);
+
+      // expand the personal folder
+      await tester.tapButton(
+        find.byTooltip(LocaleKeys.sideBar_clickToHidePersonal.tr()),
+      );
+      expect(isExpanded(type: FolderCategoryType.personal), true);
+    });
+  });
+}

+ 175 - 0
frontend/appflowy_flutter/integration_test/sidebar/sidebar_favorites_test.dart

@@ -0,0 +1,175 @@
+import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
+import 'package:appflowy/workspace/presentation/home/menu/sidebar/folder/favorite_folder.dart';
+import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.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';
+
+void main() {
+  IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+  group('Favorites', () {
+    testWidgets(
+        'Toggle favorites for views creates / removes the favorite header along with favorite views',
+        (tester) async {
+      await tester.initializeAppFlowy();
+      await tester.tapGoButton();
+
+      // no favorite folder
+      expect(find.byType(FavoriteFolder), findsNothing);
+
+      // create the nested views
+      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];
+        await tester.createNewPageWithName(
+          name: names[i],
+          parentName: parentName,
+          layout: ViewLayoutPB.Document,
+        );
+        tester.expectToSeePageName(
+          names[i],
+          parentName: parentName,
+          layout: ViewLayoutPB.Document,
+          parentLayout: ViewLayoutPB.Document,
+        );
+      }
+
+      await tester.favoriteViewByName(gettingStated);
+      expect(
+        tester.findFavoritePageName(gettingStated),
+        findsOneWidget,
+      );
+
+      await tester.favoriteViewByName(names[1]);
+      expect(
+        tester.findFavoritePageName(names[1]),
+        findsNWidgets(2),
+      );
+
+      await tester.unfavoriteViewByName(gettingStated);
+      expect(
+        tester.findFavoritePageName(gettingStated),
+        findsNothing,
+      );
+      expect(
+        tester.findFavoritePageName(
+          names[1],
+        ),
+        findsOneWidget,
+      );
+
+      await tester.unfavoriteViewByName(names[1]);
+      expect(
+        tester.findFavoritePageName(
+          names[1],
+        ),
+        findsNothing,
+      );
+    });
+
+    testWidgets(
+      'renaming a favorite view updates name under favorite header',
+      (tester) async {
+        await tester.initializeAppFlowy();
+        await tester.tapGoButton();
+
+        const name = 'test';
+        await tester.favoriteViewByName(gettingStated);
+        await tester.hoverOnPageName(
+          gettingStated,
+          layout: ViewLayoutPB.Document,
+          onHover: () async {
+            await tester.renamePage(name);
+            await tester.pumpAndSettle();
+          },
+        );
+        expect(
+          tester.findPageName(name),
+          findsNWidgets(2),
+        );
+        expect(
+          tester.findFavoritePageName(name),
+          findsNothing,
+        );
+      },
+    );
+
+    testWidgets(
+      'deleting first level favorite view removes its instance from favorite header, deleting root level views leads to removal of all favorites that are its children',
+      (tester) async {
+        await tester.initializeAppFlowy();
+        await tester.tapGoButton();
+
+        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];
+          await tester.createNewPageWithName(
+            name: names[i],
+            parentName: parentName,
+            layout: ViewLayoutPB.Document,
+          );
+          tester.expectToSeePageName(names[i], parentName: parentName);
+        }
+        await tester.favoriteViewByName(gettingStated);
+        await tester.favoriteViewByName(names[0]);
+        await tester.favoriteViewByName(names[1]);
+
+        expect(
+          find.byWidgetPredicate(
+            (widget) =>
+                widget is ViewItem &&
+                widget.view.isFavorite &&
+                widget.categoryType == FolderCategoryType.favorite,
+          ),
+          findsNWidgets(6),
+        );
+
+        await tester.hoverOnPageName(
+          names[1],
+          layout: ViewLayoutPB.Document,
+          onHover: () async {
+            await tester.tapDeletePageButton();
+            await tester.pumpAndSettle();
+          },
+        );
+
+        expect(
+          find.byWidgetPredicate(
+            (widget) =>
+                widget is ViewItem &&
+                widget.view.isFavorite &&
+                widget.categoryType == FolderCategoryType.favorite,
+          ),
+          findsNWidgets(3),
+        );
+
+        await tester.hoverOnPageName(
+          gettingStated,
+          layout: ViewLayoutPB.Document,
+          onHover: () async {
+            await tester.tapDeletePageButton();
+            await tester.pumpAndSettle();
+          },
+        );
+
+        expect(
+          find.byWidgetPredicate(
+            (widget) =>
+                widget is ViewItem &&
+                widget.view.isFavorite &&
+                widget.categoryType == FolderCategoryType.favorite,
+          ),
+          findsNothing,
+        );
+      },
+    );
+  });
+}

+ 4 - 0
frontend/appflowy_flutter/integration_test/sidebar/sidebar_test_runner.dart

@@ -1,10 +1,14 @@
 import 'package:integration_test/integration_test.dart';
 
 import 'sidebar_test.dart' as sidebar_test;
+import 'sidebar_expand_test.dart' as sidebar_expanded_test;
+import 'sidebar_favorites_test.dart' as sidebar_favorite_test;
 
 void startTesting() {
   IntegrationTestWidgetsFlutterBinding.ensureInitialized();
 
   // Sidebar integration tests
   sidebar_test.main();
+  sidebar_expanded_test.main();
+  sidebar_favorite_test.main();
 }

+ 43 - 1
frontend/appflowy_flutter/integration_test/util/common_operations.dart

@@ -1,4 +1,3 @@
-import 'dart:ui';
 import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart';
 import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart';
 import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart';
@@ -14,6 +13,7 @@ import 'package:appflowy/workspace/presentation/settings/widgets/settings_langua
 import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
+import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_test/flutter_test.dart';
@@ -199,6 +199,18 @@ extension CommonOperations on WidgetTester {
     await tapButtonWithName(ViewMoreActionType.rename.name);
   }
 
+  /// Tap the favorite page button
+  Future<void> tapFavoritePageButton() async {
+    await tapPageOptionButton();
+    await tapButtonWithName(ViewMoreActionType.favorite.name);
+  }
+
+  /// Tap the unfavorite page button
+  Future<void> tapUnfavoritePageButton() async {
+    await tapPageOptionButton();
+    await tapButtonWithName(ViewMoreActionType.unFavorite.name);
+  }
+
   /// Rename the page.
   Future<void> renamePage(String name) async {
     await tapRenamePageButton();
@@ -332,6 +344,36 @@ extension CommonOperations on WidgetTester {
     await pumpAndSettle();
   }
 
+  Future<void> favoriteViewByName(
+    String name, {
+    ViewLayoutPB layout = ViewLayoutPB.Document,
+  }) async {
+    await hoverOnPageName(
+      name,
+      layout: layout,
+      useLast: false,
+      onHover: () async {
+        await tapFavoritePageButton();
+        await pumpAndSettle();
+      },
+    );
+  }
+
+  Future<void> unfavoriteViewByName(
+    String name, {
+    ViewLayoutPB layout = ViewLayoutPB.Document,
+  }) async {
+    await hoverOnPageName(
+      name,
+      layout: layout,
+      useLast: false,
+      onHover: () async {
+        await tapUnfavoritePageButton();
+        await pumpAndSettle();
+      },
+    );
+  }
+
   Future<void> movePageToOtherPage({
     required String name,
     required String parentName,

+ 22 - 4
frontend/appflowy_flutter/integration_test/util/expectation.dart

@@ -2,6 +2,7 @@ import 'package:appflowy/generated/locale_keys.g.dart';
 import 'package:appflowy/plugins/document/presentation/banner.dart';
 import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
 import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
+import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
 import 'package:appflowy/workspace/presentation/home/home_stack.dart';
 import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
@@ -147,7 +148,24 @@ extension Expectation on WidgetTester {
     expect(textWidget, findsOneWidget);
   }
 
-  /// Find the page name on the home page.
+  /// Find if the page is favorite
+  Finder findFavoritePageName(
+    String name, {
+    ViewLayoutPB layout = ViewLayoutPB.Document,
+    String? parentName,
+    ViewLayoutPB parentLayout = ViewLayoutPB.Document,
+  }) {
+    return find.byWidgetPredicate(
+      (widget) =>
+          widget is ViewItem &&
+          widget.view.isFavorite &&
+          widget.categoryType == FolderCategoryType.favorite &&
+          widget.view.name == name &&
+          widget.view.layout == layout,
+      skipOffstage: false,
+    );
+  }
+
   Finder findPageName(
     String name, {
     ViewLayoutPB layout = ViewLayoutPB.Document,
@@ -168,11 +186,11 @@ extension Expectation on WidgetTester {
       of: find.byWidgetPredicate(
         (widget) =>
             widget is ViewItem &&
-            widget.view.name == name &&
-            widget.view.layout == layout,
+            widget.view.name == parentName &&
+            widget.view.layout == parentLayout,
         skipOffstage: false,
       ),
-      matching: findPageName(name),
+      matching: findPageName(name, layout: layout),
     );
   }
 }

+ 6 - 0
frontend/appflowy_flutter/lib/core/config/kv_keys.dart

@@ -35,4 +35,10 @@ class KVKeys {
   /// The value is a json string with the following format:
   ///  {'viewId': true, 'viewId2': false}
   static const String expandedViews = 'expandedViews';
+
+  /// The key for saving the expanded folder
+  ///
+  /// The value is a json string with the following format:
+  ///  {'SidebarFolderCategoryType.value': true}
+  static const String expandedFolders = 'expandedFolders';
 }

+ 1 - 3
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart

@@ -15,7 +15,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/menu/menu.dart';
 import 'package:appflowy_popover/appflowy_popover.dart';
 import 'package:flowy_infra_ui/style_widget/icon_button.dart';
 
@@ -155,11 +154,10 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
           onSelected: (action, controller) async {
             switch (action.inner) {
               case _ActionType.viewDatabase:
-                getIt<MenuSharedState>().latestOpenView = viewPB;
-
                 getIt<TabsBloc>().add(
                   TabsEvent.openPlugin(
                     plugin: viewPB.plugin(),
+                    view: viewPB,
                   ),
                 );
                 break;

+ 1 - 2
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart

@@ -4,7 +4,6 @@ import 'package:appflowy/startup/startup.dart';
 import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
 import 'package:appflowy/workspace/application/view/prelude.dart';
 import 'package:appflowy/workspace/application/view/view_ext.dart';
-import 'package:appflowy/workspace/presentation/home/menu/menu.dart';
 import 'package:appflowy_backend/log.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
 import 'package:appflowy_editor/appflowy_editor.dart'
@@ -109,10 +108,10 @@ class _MentionPageBlockState extends State<MentionPageBlock> {
       Log.error('Page($pageId) not found');
       return;
     }
-    getIt<MenuSharedState>().latestOpenView = view;
     getIt<TabsBloc>().add(
       TabsEvent.openPlugin(
         plugin: view.plugin(),
+        view: view,
       ),
     );
   }

+ 2 - 0
frontend/appflowy_flutter/lib/startup/deps_resolver.dart

@@ -15,6 +15,7 @@ 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';
+import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
 import 'package:appflowy/workspace/application/user/prelude.dart';
 import 'package:appflowy/workspace/application/workspace/prelude.dart';
 import 'package:appflowy/workspace/application/edit_panel/edit_panel_bloc.dart';
@@ -156,6 +157,7 @@ void _resolveFolderDeps(GetIt getIt) {
   getIt.registerFactory<TrashBloc>(
     () => TrashBloc(),
   );
+  getIt.registerFactory<FavoriteBloc>(() => FavoriteBloc());
 }
 
 void _resolveDocDeps(GetIt getIt) {

+ 93 - 0
frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_bloc.dart

@@ -0,0 +1,93 @@
+import 'package:appflowy/workspace/application/favorite/favorite_service.dart';
+import 'package:appflowy_backend/log.dart';
+import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
+import 'package:dartz/dartz.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+import 'favorite_listener.dart';
+
+part 'favorite_bloc.freezed.dart';
+
+class FavoriteBloc extends Bloc<FavoriteEvent, FavoriteState> {
+  final _service = FavoriteService();
+  final _listener = FavoriteListener();
+
+  FavoriteBloc() : super(FavoriteState.initial()) {
+    on<FavoriteEvent>(
+      (event, emit) async {
+        await event.map(
+          initial: (e) async {
+            _listener.start(
+              favoritesUpdated: _onFavoritesUpdated,
+            );
+            final result = await _service.readFavorites();
+            emit(
+              result.fold(
+                (view) => state.copyWith(
+                  views: view.items,
+                ),
+                (error) => state.copyWith(
+                  views: [],
+                ),
+              ),
+            );
+          },
+          didFavorite: (e) {
+            emit(
+              state.copyWith(views: [...state.views, ...e.favorite.items]),
+            );
+          },
+          didUnfavorite: (e) {
+            final views = [...state.views]..removeWhere(
+                (view) => e.favorite.items.any((item) => item.id == view.id),
+              );
+            emit(
+              state.copyWith(views: views),
+            );
+          },
+          toggle: (e) async {
+            await _service.toggleFavorite(
+              e.view.id,
+              !e.view.isFavorite,
+            );
+          },
+        );
+      },
+    );
+  }
+
+  void _onFavoritesUpdated(
+    Either<FlowyError, RepeatedViewPB> favoriteOrFailed,
+    bool didFavorite,
+  ) {
+    favoriteOrFailed.fold(
+      (error) => Log.error(error),
+      (favorite) => didFavorite
+          ? add(FavoriteEvent.didFavorite(favorite))
+          : add(FavoriteEvent.didUnfavorite(favorite)),
+    );
+  }
+}
+
+@freezed
+class FavoriteEvent with _$FavoriteEvent {
+  const factory FavoriteEvent.initial() = Initial;
+  const factory FavoriteEvent.didFavorite(RepeatedViewPB favorite) =
+      DidFavorite;
+  const factory FavoriteEvent.didUnfavorite(RepeatedViewPB favorite) =
+      DidUnfavorite;
+  const factory FavoriteEvent.toggle(ViewPB view) = ToggleFavorite;
+}
+
+@freezed
+class FavoriteState with _$FavoriteState {
+  const factory FavoriteState({
+    required List<ViewPB> views,
+  }) = _FavoriteState;
+
+  factory FavoriteState.initial() => const FavoriteState(
+        views: [],
+      );
+}

+ 65 - 0
frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_listener.dart

@@ -0,0 +1,65 @@
+import 'dart:async';
+
+import 'package:appflowy/core/notification/folder_notification.dart';
+import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder2/notification.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart';
+import 'package:appflowy_backend/rust_stream.dart';
+import 'package:dartz/dartz.dart';
+import 'package:flutter/foundation.dart';
+
+typedef FavoriteUpdated = void Function(
+  Either<FlowyError, RepeatedViewPB> result,
+  bool isFavorite,
+);
+
+class FavoriteListener {
+  StreamSubscription<SubscribeObject>? _streamSubscription;
+  FolderNotificationParser? _parser;
+
+  FavoriteUpdated? _favoriteUpdated;
+
+  void start({
+    FavoriteUpdated? favoritesUpdated,
+  }) {
+    _favoriteUpdated = favoritesUpdated;
+    _parser = FolderNotificationParser(
+      id: 'favorite',
+      callback: _observableCallback,
+    );
+    _streamSubscription = RustStreamReceiver.listen(
+      (observable) => _parser?.parse(observable),
+    );
+  }
+
+  void _observableCallback(
+    FolderNotification ty,
+    Either<Uint8List, FlowyError> result,
+  ) {
+    if (_favoriteUpdated == null) {
+      return;
+    }
+
+    final isFavorite = ty == FolderNotification.DidFavoriteView;
+    result.fold(
+      (payload) {
+        final view = RepeatedViewPB.fromBuffer(payload);
+        _favoriteUpdated!(
+          right(view),
+          isFavorite,
+        );
+      },
+      (error) => _favoriteUpdated!(
+        left(error),
+        isFavorite,
+      ),
+    );
+  }
+
+  Future<void> stop() async {
+    _parser = null;
+    await _streamSubscription?.cancel();
+    _favoriteUpdated = null;
+  }
+}

+ 18 - 0
frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_service.dart

@@ -0,0 +1,18 @@
+import 'package:appflowy_backend/dispatch/dispatch.dart';
+import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
+import 'package:dartz/dartz.dart';
+
+class FavoriteService {
+  Future<Either<RepeatedViewPB, FlowyError>> readFavorites() {
+    return FolderEventReadFavorites().send();
+  }
+
+  Future<Either<Unit, FlowyError>> toggleFavorite(
+    String viewId,
+    bool favoriteStatus,
+  ) async {
+    final id = RepeatedViewIdPB.create()..items.add(viewId);
+    return FolderEventToggleFavorite(id).send();
+  }
+}

+ 3 - 0
frontend/appflowy_flutter/lib/workspace/application/favorite/prelude.dart

@@ -0,0 +1,3 @@
+export 'favorite_bloc.dart';
+export 'favorite_listener.dart';
+export 'favorite_service.dart';

+ 2 - 1
frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart

@@ -41,7 +41,8 @@ class MenuBloc extends Bloc<MenuEvent, MenuState> {
         createApp: (_CreateApp event) async {
           final result = await _workspaceService.createApp(
             name: event.name,
-            desc: event.desc ?? "",
+            desc: event.desc,
+            index: 0, // default to the first index
           );
           result.fold(
             (app) => emit(state.copyWith(plugin: app.plugin())),

+ 83 - 0
frontend/appflowy_flutter/lib/workspace/application/sidebar/folder/folder_bloc.dart

@@ -0,0 +1,83 @@
+import 'dart:convert';
+
+import 'package:appflowy/core/config/kv.dart';
+import 'package:appflowy/core/config/kv_keys.dart';
+import 'package:appflowy/startup/startup.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+part 'folder_bloc.freezed.dart';
+
+enum FolderCategoryType {
+  favorite,
+  personal,
+}
+
+class FolderBloc extends Bloc<FolderEvent, FolderState> {
+  FolderBloc({
+    required FolderCategoryType type,
+  }) : super(FolderState.initial(type)) {
+    on<FolderEvent>((event, emit) async {
+      await event.map(
+        initial: (e) async {
+          // fetch the expand status
+          final isExpanded = await _getFolderExpandStatus();
+          emit(state.copyWith(isExpanded: isExpanded));
+        },
+        expandOrUnExpand: (e) async {
+          final isExpanded = e.isExpanded ?? !state.isExpanded;
+          await _setFolderExpandStatus(e.isExpanded ?? !state.isExpanded);
+          emit(state.copyWith(isExpanded: isExpanded));
+        },
+      );
+    });
+  }
+
+  Future<void> _setFolderExpandStatus(bool isExpanded) async {
+    final result = await getIt<KeyValueStorage>().get(KVKeys.expandedViews);
+    final map = result.fold(
+      (l) => {},
+      (r) => jsonDecode(r),
+    );
+    if (isExpanded) {
+      // set expand status to true if it's not expanded
+      map[state.type.name] = true;
+    } else {
+      // remove the expand status if it's expanded
+      map.remove(state.type.name);
+    }
+    await getIt<KeyValueStorage>().set(KVKeys.expandedViews, jsonEncode(map));
+  }
+
+  Future<bool> _getFolderExpandStatus() async {
+    return getIt<KeyValueStorage>().get(KVKeys.expandedViews).then((result) {
+      return result.fold((l) => true, (r) {
+        final map = jsonDecode(r);
+        return map[state.type.name] ?? true;
+      });
+    });
+  }
+}
+
+@freezed
+class FolderEvent with _$FolderEvent {
+  const factory FolderEvent.initial() = Initial;
+  const factory FolderEvent.expandOrUnExpand({
+    bool? isExpanded,
+  }) = ExpandOrUnExpand;
+}
+
+@freezed
+class FolderState with _$FolderState {
+  const factory FolderState({
+    required FolderCategoryType type,
+    required bool isExpanded,
+  }) = _FolderState;
+
+  factory FolderState.initial(
+    FolderCategoryType type,
+  ) =>
+      FolderState(
+        type: type,
+        isExpanded: true,
+      );
+}

+ 21 - 0
frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart

@@ -34,6 +34,10 @@ class ViewBackendService {
     /// the database id. For example: "database_id": "xxx"
     ///
     Map<String, String> ext = const {},
+
+    /// The [index] is the index of the view in the parent view.
+    /// If the index is null, the view will be added to the end of the list.
+    int? index,
   }) {
     final payload = CreateViewPayloadPB.create()
       ..parentViewId = parentViewId
@@ -47,6 +51,14 @@ class ViewBackendService {
       payload.meta.addAll(ext);
     }
 
+    if (desc != null) {
+      payload.desc = desc;
+    }
+
+    if (index != null) {
+      payload.index = index;
+    }
+
     return FolderEventCreateView(payload).send();
   }
 
@@ -118,11 +130,17 @@ class ViewBackendService {
     return FolderEventDuplicateView(view).send();
   }
 
+  static Future<Either<Unit, FlowyError>> favorite({required String viewId}) {
+    final request = RepeatedViewIdPB.create()..items.add(viewId);
+    return FolderEventToggleFavorite(request).send();
+  }
+
   static Future<Either<ViewPB, FlowyError>> updateView({
     required String viewId,
     String? name,
     String? iconURL,
     String? coverURL,
+    bool? isFavorite,
   }) {
     final payload = UpdateViewPayloadPB.create()..viewId = viewId;
 
@@ -138,6 +156,9 @@ class ViewBackendService {
       payload.coverUrl = coverURL;
     }
 
+    if (isFavorite != null) {
+      payload.isFavorite = isFavorite;
+    }
     return FolderEventUpdateView(payload).send();
   }
 

+ 10 - 1
frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart

@@ -15,16 +15,25 @@ class WorkspaceService {
   WorkspaceService({
     required this.workspaceId,
   });
+
   Future<Either<ViewPB, FlowyError>> createApp({
     required String name,
     String? desc,
+    int? index,
   }) {
     final payload = CreateViewPayloadPB.create()
       ..parentViewId = workspaceId
       ..name = name
-      ..desc = desc ?? ""
       ..layout = ViewLayoutPB.Document;
 
+    if (desc != null) {
+      payload.desc = desc;
+    }
+
+    if (index != null) {
+      payload.index = index;
+    }
+
     return FolderEventCreateView(payload).send();
   }
 

+ 25 - 6
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/favorite/favorite_bloc.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';
@@ -92,6 +93,7 @@ class ViewSectionItem extends StatelessWidget {
     if (onHover || state.isEditing) {
       children.add(
         ViewDisclosureButton(
+          state: state,
           onEdit: (isEdit) =>
               blocContext.read<ViewBloc>().add(ViewEvent.setIsEditing(isEdit)),
           onAction: (action) {
@@ -115,6 +117,11 @@ class ViewSectionItem extends StatelessWidget {
               case ViewDisclosureAction.duplicate:
                 blocContext.read<ViewBloc>().add(const ViewEvent.duplicate());
                 break;
+              case ViewDisclosureAction.favorite:
+                blocContext
+                    .read<FavoriteBloc>()
+                    .add(FavoriteEvent.toggle(view));
+                break;
               case ViewDisclosureAction.openInNewTab:
                 blocContext.read<TabsBloc>().add(
                       TabsEvent.openTab(
@@ -143,11 +150,12 @@ enum ViewDisclosureAction {
   rename,
   delete,
   duplicate,
+  favorite,
   openInNewTab,
 }
 
 extension ViewDisclosureExtension on ViewDisclosureAction {
-  String get name {
+  String name({ViewState? state}) {
     switch (this) {
       case ViewDisclosureAction.rename:
         return LocaleKeys.disclosureAction_rename.tr();
@@ -155,12 +163,16 @@ extension ViewDisclosureExtension on ViewDisclosureAction {
         return LocaleKeys.disclosureAction_delete.tr();
       case ViewDisclosureAction.duplicate:
         return LocaleKeys.disclosureAction_duplicate.tr();
+      case ViewDisclosureAction.favorite:
+        return state!.view.isFavorite
+            ? LocaleKeys.disclosureAction_unfavorite.tr()
+            : LocaleKeys.disclosureAction_favorite.tr();
       case ViewDisclosureAction.openInNewTab:
         return LocaleKeys.disclosureAction_openNewTab.tr();
     }
   }
 
-  Widget icon(Color iconColor) {
+  Widget icon(Color iconColor, {ViewState? state}) {
     switch (this) {
       case ViewDisclosureAction.rename:
         return const FlowySvg(name: 'editor/edit');
@@ -168,6 +180,10 @@ extension ViewDisclosureExtension on ViewDisclosureAction {
         return const FlowySvg(name: 'editor/delete');
       case ViewDisclosureAction.duplicate:
         return const FlowySvg(name: 'editor/copy');
+      case ViewDisclosureAction.favorite:
+        return state!.view.isFavorite
+            ? const FlowySvg(name: 'home/favorite')
+            : const FlowySvg(name: 'home/unfavorite');
       case ViewDisclosureAction.openInNewTab:
         return const FlowySvg(name: 'grid/expander');
     }
@@ -177,9 +193,11 @@ extension ViewDisclosureExtension on ViewDisclosureAction {
 class ViewDisclosureButton extends StatelessWidget {
   final Function(bool) onEdit;
   final Function(ViewDisclosureAction) onAction;
+  final ViewState state;
   const ViewDisclosureButton({
     required this.onEdit,
     required this.onAction,
+    required this.state,
     Key? key,
   }) : super(key: key);
 
@@ -188,7 +206,7 @@ class ViewDisclosureButton extends StatelessWidget {
     return PopoverActionList<ViewDisclosureActionWrapper>(
       direction: PopoverDirection.bottomWithCenterAligned,
       actions: ViewDisclosureAction.values
-          .map((action) => ViewDisclosureActionWrapper(action))
+          .map((action) => ViewDisclosureActionWrapper(action, state))
           .toList(),
       buildChild: (controller) {
         return FlowyIconButton(
@@ -219,11 +237,12 @@ class ViewDisclosureButton extends StatelessWidget {
 
 class ViewDisclosureActionWrapper extends ActionCell {
   final ViewDisclosureAction inner;
+  final ViewState? state;
 
-  ViewDisclosureActionWrapper(this.inner);
+  ViewDisclosureActionWrapper(this.inner, [this.state]);
   @override
-  Widget? leftIcon(Color iconColor) => inner.icon(iconColor);
+  Widget? leftIcon(Color iconColor) => inner.icon(iconColor, state: state);
 
   @override
-  String get name => inner.name;
+  String get name => inner.name(state: state);
 }

+ 0 - 1
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/favorite/favorite.dart

@@ -1 +0,0 @@
-

+ 0 - 10
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/favorite/header.dart

@@ -1,10 +0,0 @@
-import 'package:flutter/material.dart';
-
-class FavoriteHeader extends StatelessWidget {
-  const FavoriteHeader({Key? key}) : super(key: key);
-
-  @override
-  Widget build(BuildContext context) {
-    throw UnimplementedError();
-  }
-}

+ 0 - 1
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/favorite/section.dart

@@ -1 +0,0 @@
-

+ 61 - 5
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu.dart

@@ -4,6 +4,7 @@ import 'package:appflowy/core/frameless_window.dart';
 import 'package:appflowy/generated/locale_keys.g.dart';
 import 'package:appflowy/plugins/trash/menu.dart';
 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';
@@ -27,6 +28,7 @@ import 'package:styled_widget/styled_widget.dart';
 import '../navigation.dart';
 import 'app/create_button.dart';
 import 'app/menu_app.dart';
+import 'app/section/item.dart';
 import 'menu_user.dart';
 
 export './app/header/header.dart';
@@ -56,6 +58,10 @@ class HomeMenu extends StatelessWidget {
             return menuBloc;
           },
         ),
+        BlocProvider(
+          create: (ctx) =>
+              getIt<FavoriteBloc>()..add(const FavoriteEvent.initial()),
+        )
       ],
       child: MultiBlocListener(
         listeners: [
@@ -105,6 +111,50 @@ class HomeMenu extends StatelessWidget {
     );
   }
 
+  Widget _renderFavorites(BuildContext context) {
+    return BlocBuilder<FavoriteBloc, FavoriteState>(
+      builder: (context, state) {
+        return state.views.isNotEmpty
+            ? ExpandableTheme(
+                data: ExpandableThemeData(
+                  useInkWell: true,
+                  animationDuration: Durations.medium,
+                ),
+                child: ExpandablePanel(
+                  theme: const ExpandableThemeData(
+                    headerAlignment: ExpandablePanelHeaderAlignment.center,
+                    tapBodyToExpand: false,
+                    tapBodyToCollapse: false,
+                    tapHeaderToExpand: false,
+                    iconPadding: EdgeInsets.zero,
+                    hasIcon: false,
+                  ),
+                  // header: const FavoriteHeader(),
+                  expanded: ScrollConfiguration(
+                    behavior:
+                        const ScrollBehavior().copyWith(scrollbars: false),
+                    child: Column(
+                      children: state.views
+                          .map(
+                            (e) => ViewSectionItem(
+                              key: ValueKey(e.id),
+                              isSelected: false,
+                              onSelected: (view) => getIt<MenuSharedState>()
+                                  .latestOpenView = view,
+                              view: e,
+                            ),
+                          )
+                          .toList(),
+                    ),
+                  ),
+                  collapsed: const SizedBox.shrink(),
+                ),
+              )
+            : const SizedBox.shrink();
+      },
+    );
+  }
+
   Widget _renderApps(BuildContext context) {
     return ExpandableTheme(
       data: ExpandableThemeData(
@@ -122,10 +172,16 @@ class HomeMenu extends StatelessWidget {
               return ReorderableListView.builder(
                 itemCount: menuItems.length,
                 buildDefaultDragHandles: false,
-                header: Padding(
-                  padding:
-                      EdgeInsets.only(bottom: 20.0 - MenuAppSizes.appVPadding),
-                  child: MenuUser(user),
+                header: Column(
+                  children: [
+                    Padding(
+                      padding: EdgeInsets.only(
+                        bottom: MenuAppSizes.appVPadding,
+                      ),
+                      child: MenuUser(user),
+                    ),
+                    _renderFavorites(context),
+                  ],
                 ),
                 onReorder: (oldIndex, newIndex) {
                   // Moving item1 from index 0 to index 1
@@ -180,7 +236,7 @@ class MenuSharedState {
   ValueNotifier<ViewPB?> get notifier => _latestOpenView;
 
   set latestOpenView(ViewPB? view) {
-    if (_latestOpenView.value != view) {
+    if (_latestOpenView.value?.id != view?.id) {
       _latestOpenView.value = view;
     }
   }

+ 109 - 0
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/favorite_folder.dart

@@ -0,0 +1,109 @@
+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/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_bloc/flutter_bloc.dart';
+
+class FavoriteFolder extends StatelessWidget {
+  const FavoriteFolder({
+    super.key,
+    required this.views,
+  });
+
+  final List<ViewPB> views;
+
+  @override
+  Widget build(BuildContext context) {
+    if (views.isEmpty) {
+      return const SizedBox.shrink();
+    }
+    return BlocProvider<FolderBloc>(
+      create: (context) => FolderBloc(type: FolderCategoryType.favorite)
+        ..add(
+          const FolderEvent.initial(),
+        ),
+      child: BlocBuilder<FolderBloc, FolderState>(
+        builder: (context, state) {
+          return Column(
+            children: [
+              FavoriteHeader(
+                onPressed: () => context
+                    .read<FolderBloc>()
+                    .add(const FolderEvent.expandOrUnExpand()),
+                onAdded: () => context
+                    .read<FolderBloc>()
+                    .add(const FolderEvent.expandOrUnExpand(isExpanded: true)),
+              ),
+              if (state.isExpanded)
+                ...views.map(
+                  (view) => ViewItem(
+                    key: ValueKey(
+                      '${FolderCategoryType.favorite.name} ${view.id}',
+                    ),
+                    categoryType: FolderCategoryType.favorite,
+                    isDraggable: false,
+                    isFirstChild: view.id == views.first.id,
+                    view: view,
+                    level: 0,
+                    onSelected: (view) {
+                      getIt<MenuSharedState>().latestOpenView = view;
+                      context
+                          .read<MenuBloc>()
+                          .add(MenuEvent.openPage(view.plugin()));
+                    },
+                  ),
+                )
+            ],
+          );
+        },
+      ),
+    );
+  }
+}
+
+class FavoriteHeader extends StatefulWidget {
+  const FavoriteHeader({
+    super.key,
+    required this.onPressed,
+    required this.onAdded,
+  });
+
+  final VoidCallback onPressed;
+  final VoidCallback onAdded;
+
+  @override
+  State<FavoriteHeader> createState() => _FavoriteHeaderState();
+}
+
+class _FavoriteHeaderState extends State<FavoriteHeader> {
+  bool onHover = false;
+
+  @override
+  Widget build(BuildContext context) {
+    const iconSize = 26.0;
+    return MouseRegion(
+      onEnter: (event) => setState(() => onHover = true),
+      onExit: (event) => setState(() => onHover = false),
+      child: Row(
+        crossAxisAlignment: CrossAxisAlignment.center,
+        children: [
+          FlowyTextButton(
+            LocaleKeys.sideBar_favorites.tr(),
+            tooltip: LocaleKeys.sideBar_clickToHideFavorites.tr(),
+            constraints: const BoxConstraints(maxHeight: iconSize),
+            padding: const EdgeInsets.all(4),
+            fillColor: Colors.transparent,
+            onPressed: widget.onPressed,
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 43 - 30
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart

@@ -1,8 +1,9 @@
 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/menu.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';
@@ -11,7 +12,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 
-class PersonalFolder extends StatefulWidget {
+class PersonalFolder extends StatelessWidget {
   const PersonalFolder({
     super.key,
     required this.views,
@@ -19,37 +20,49 @@ class PersonalFolder extends StatefulWidget {
 
   final List<ViewPB> views;
 
-  @override
-  State<PersonalFolder> createState() => _PersonalFolderState();
-}
-
-class _PersonalFolderState extends State<PersonalFolder> {
-  bool isExpanded = true;
-
   @override
   Widget build(BuildContext context) {
-    return Column(
-      children: [
-        PersonalFolderHeader(
-          onPressed: () => setState(
-            () => isExpanded = !isExpanded,
-          ),
-          onAdded: () => setState(() => isExpanded = true),
+    return BlocProvider<FolderBloc>(
+      create: (context) => FolderBloc(type: FolderCategoryType.personal)
+        ..add(
+          const FolderEvent.initial(),
         ),
-        if (isExpanded)
-          ...widget.views.map(
-            (view) => ViewItem(
-              key: ValueKey(view.id),
-              isFirstChild: view.id == widget.views.first.id,
-              view: view,
-              level: 0,
-              onSelected: (view) {
-                getIt<MenuSharedState>().latestOpenView = view;
-                context.read<MenuBloc>().add(MenuEvent.openPage(view.plugin()));
-              },
-            ),
-          )
-      ],
+      child: BlocBuilder<FolderBloc, FolderState>(
+        builder: (context, state) {
+          return Column(
+            children: [
+              PersonalFolderHeader(
+                onPressed: () => context
+                    .read<FolderBloc>()
+                    .add(const FolderEvent.expandOrUnExpand()),
+                onAdded: () => context
+                    .read<FolderBloc>()
+                    .add(const FolderEvent.expandOrUnExpand(isExpanded: true)),
+              ),
+              if (state.isExpanded)
+                ...views.map(
+                  (view) => ViewItem(
+                    key: ValueKey(
+                      '${FolderCategoryType.personal.name} ${view.id}',
+                    ),
+                    categoryType: FolderCategoryType.personal,
+                    isFirstChild: view.id == views.first.id,
+                    view: view,
+                    level: 0,
+                    onSelected: (view) {
+                      getIt<TabsBloc>().add(
+                        TabsEvent.openPlugin(
+                          plugin: view.plugin(),
+                          view: view,
+                        ),
+                      );
+                    },
+                  ),
+                )
+            ],
+          );
+        },
+      ),
     );
   }
 }

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

@@ -1,4 +1,5 @@
 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';
 import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_folder.dart';
@@ -33,22 +34,43 @@ class HomeSideBar extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
-    return BlocProvider(
-      create: (_) => MenuBloc(
-        user: user,
-        workspace: workspaceSetting.workspace,
-      )..add(const MenuEvent.initial()),
-      child: BlocConsumer<MenuBloc, MenuState>(
-        builder: (context, state) => _buildSidebar(context, state),
+    return MultiBlocProvider(
+      providers: [
+        BlocProvider(
+          create: (_) => MenuBloc(
+            user: user,
+            workspace: workspaceSetting.workspace,
+          )..add(const MenuEvent.initial()),
+        ),
+        BlocProvider(
+          create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()),
+        )
+      ],
+      child: BlocListener<MenuBloc, MenuState>(
         listenWhen: (p, c) => p.plugin.id != c.plugin.id,
         listener: (context, state) => getIt<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,
+            );
+          },
+        ),
       ),
     );
   }
 
-  Widget _buildSidebar(BuildContext context, MenuState state) {
+  Widget _buildSidebar(
+    BuildContext context,
+    MenuState state,
+    FavoriteState favoriteState,
+  ) {
     final views = state.views;
     return Container(
       decoration: BoxDecoration(
@@ -67,13 +89,13 @@ class HomeSideBar extends StatelessWidget {
             const SidebarTopMenu(),
             // user, setting
             SidebarUser(user: user),
-            // Favorite, Not supported yet
             const VSpace(20),
             // scrollable document list
             Expanded(
               child: SingleChildScrollView(
                 child: SidebarFolder(
                   views: views,
+                  favoriteViews: favoriteState.views,
                 ),
               ),
             ),

+ 23 - 6
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_folder.dart

@@ -1,23 +1,40 @@
+import 'package:appflowy/startup/startup.dart';
+import 'package:appflowy/workspace/presentation/home/menu/menu.dart';
+import 'package:appflowy/workspace/presentation/home/menu/sidebar/folder/favorite_folder.dart';
 import 'package:appflowy/workspace/presentation/home/menu/sidebar/folder/personal_folder.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 SidebarFolder extends StatelessWidget {
   const SidebarFolder({
     super.key,
     required this.views,
+    required this.favoriteViews,
   });
 
   final List<ViewPB> views;
+  final List<ViewPB> favoriteViews;
 
   @override
   Widget build(BuildContext context) {
-    return Column(
-      mainAxisAlignment: MainAxisAlignment.start,
-      children: [
-        // personal
-        PersonalFolder(views: views),
-      ],
+    return ValueListenableBuilder(
+      valueListenable: getIt<MenuSharedState>().notifier,
+      builder: (context, value, child) {
+        return Column(
+          mainAxisAlignment: MainAxisAlignment.start,
+          children: [
+            // favorite
+            if (favoriteViews.isNotEmpty)
+              FavoriteFolder(
+                views: favoriteViews,
+              ),
+            const VSpace(10),
+            // personal
+            PersonalFolder(views: views),
+          ],
+        );
+      },
     );
   }
 }

+ 10 - 5
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_action_type.dart

@@ -5,7 +5,8 @@ import 'package:flutter/material.dart';
 
 enum ViewMoreActionType {
   delete,
-  addToFavorites, // not supported yet.
+  favorite,
+  unFavorite,
   duplicate,
   copyLink, // not supported yet.
   rename,
@@ -18,8 +19,10 @@ extension ViewMoreActionTypeExtension on ViewMoreActionType {
     switch (this) {
       case ViewMoreActionType.delete:
         return LocaleKeys.disclosureAction_delete.tr();
-      case ViewMoreActionType.addToFavorites:
-        return LocaleKeys.disclosureAction_addToFavorites.tr();
+      case ViewMoreActionType.favorite:
+        return LocaleKeys.disclosureAction_favorite.tr();
+      case ViewMoreActionType.unFavorite:
+        return LocaleKeys.disclosureAction_unfavorite.tr();
       case ViewMoreActionType.duplicate:
         return LocaleKeys.disclosureAction_duplicate.tr();
       case ViewMoreActionType.copyLink:
@@ -37,8 +40,10 @@ extension ViewMoreActionTypeExtension on ViewMoreActionType {
     switch (this) {
       case ViewMoreActionType.delete:
         return const FlowySvg(name: 'editor/delete');
-      case ViewMoreActionType.addToFavorites:
-        return const Icon(Icons.favorite);
+      case ViewMoreActionType.favorite:
+        return const FlowySvg(name: 'home/unfavorite');
+      case ViewMoreActionType.unFavorite:
+        return const FlowySvg(name: 'home/favorite');
       case ViewMoreActionType.duplicate:
         return const FlowySvg(name: 'editor/copy');
       case ViewMoreActionType.copyLink:

+ 30 - 7
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart

@@ -1,5 +1,7 @@
 import 'package:appflowy/generated/locale_keys.g.dart';
 import 'package:appflowy/startup/startup.dart';
+import 'package:appflowy/workspace/application/favorite/favorite_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_bloc.dart';
 import 'package:appflowy/workspace/application/view/view_ext.dart';
@@ -21,6 +23,7 @@ class ViewItem extends StatelessWidget {
   const ViewItem({
     super.key,
     required this.view,
+    required this.categoryType,
     required this.level,
     this.leftPadding = 10,
     required this.onSelected,
@@ -30,6 +33,8 @@ class ViewItem extends StatelessWidget {
 
   final ViewPB view;
 
+  final FolderCategoryType categoryType;
+
   // indicate the level of the view item
   // used to calculate the left padding
   final int level;
@@ -53,11 +58,10 @@ class ViewItem extends StatelessWidget {
       create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()),
       child: BlocBuilder<ViewBloc, ViewState>(
         builder: (context, state) {
-          view.childViews
-            ..clear()
-            ..addAll(state.childViews);
           return InnerViewItem(
-            view: view,
+            view: state.view,
+            childViews: state.childViews,
+            categoryType: categoryType,
             level: level,
             leftPadding: leftPadding,
             showActions: state.isEditing,
@@ -76,6 +80,8 @@ class InnerViewItem extends StatelessWidget {
   const InnerViewItem({
     super.key,
     required this.view,
+    required this.childViews,
+    required this.categoryType,
     this.isDraggable = true,
     this.isExpanded = true,
     required this.level,
@@ -86,6 +92,8 @@ class InnerViewItem extends StatelessWidget {
   });
 
   final ViewPB view;
+  final List<ViewPB> childViews;
+  final FolderCategoryType categoryType;
 
   final bool isDraggable;
   final bool isExpanded;
@@ -108,11 +116,11 @@ class InnerViewItem extends StatelessWidget {
     );
 
     // if the view is expanded and has child views, render its child views
-    final childViews = view.childViews;
     if (isExpanded && childViews.isNotEmpty) {
       final children = childViews.map((childView) {
         return ViewItem(
-          key: ValueKey(childView.id),
+          key: ValueKey('${categoryType.name} ${childView.id}'),
+          categoryType: categoryType,
           isFirstChild: childView.id == childViews.first.id,
           view: childView,
           level: level + 1,
@@ -139,12 +147,19 @@ class InnerViewItem extends StatelessWidget {
         feedback: (context) {
           return ViewItem(
             view: view,
+            categoryType: categoryType,
             level: level,
             onSelected: onSelected,
             isDraggable: false,
           );
         },
       );
+    } else {
+      // keep the same height of the DraggableItem
+      child = Padding(
+        padding: const EdgeInsets.only(top: 2.0),
+        child: child,
+      );
     }
 
     return child;
@@ -218,7 +233,8 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
       children.add(_buildViewAddButton(context));
     }
 
-    return GestureDetector(
+    // Don't use GestureDetector here, because it doesn't response to the tap event sometimes.
+    return InkWell(
       onTap: () => widget.onSelected(widget.view),
       child: SizedBox(
         height: 26,
@@ -284,10 +300,17 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
     return Tooltip(
       message: LocaleKeys.menuAppHeader_moreButtonToolTip.tr(),
       child: ViewMoreActionButton(
+        view: widget.view,
         onEditing: (value) =>
             context.read<ViewBloc>().add(ViewEvent.setIsEditing(value)),
         onAction: (action) {
           switch (action) {
+            case ViewMoreActionType.favorite:
+            case ViewMoreActionType.unFavorite:
+              context
+                  .read<FavoriteBloc>()
+                  .add(FavoriteEvent.toggle(widget.view));
+              break;
             case ViewMoreActionType.rename:
               NavigatorTextFieldDialog(
                 title: LocaleKeys.disclosureAction_rename.tr(),

+ 12 - 7
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart

@@ -1,4 +1,5 @@
 import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
 import 'package:flutter/material.dart';
 import 'package:flowy_infra/image.dart';
 
@@ -6,26 +7,30 @@ 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';
 
-const supportedActionTypes = [
-  ViewMoreActionType.rename,
-  ViewMoreActionType.delete,
-  ViewMoreActionType.duplicate,
-  ViewMoreActionType.openInNewTab,
-];
-
 /// ··· button beside the view name
 class ViewMoreActionButton extends StatelessWidget {
   const ViewMoreActionButton({
     super.key,
+    required this.view,
     required this.onEditing,
     required this.onAction,
   });
 
+  final ViewPB view;
   final void Function(bool value) onEditing;
   final void Function(ViewMoreActionType) onAction;
 
   @override
   Widget build(BuildContext context) {
+    final supportedActionTypes = [
+      ViewMoreActionType.rename,
+      ViewMoreActionType.delete,
+      ViewMoreActionType.duplicate,
+      ViewMoreActionType.openInNewTab,
+      view.isFavorite
+          ? ViewMoreActionType.unFavorite
+          : ViewMoreActionType.favorite,
+    ];
     return PopoverActionList<ViewMoreActionTypeWrapper>(
       direction: PopoverDirection.bottomWithCenterAligned,
       offset: const Offset(0, 8),

+ 2 - 5
frontend/appflowy_flutter/test/bloc_test/home_test/menu_bloc_test.dart

@@ -32,11 +32,8 @@ void main() {
     menuBloc.add(const MenuEvent.createApp("App 3"));
     await blocResponseFuture();
 
-    menuBloc.add(const MenuEvent.moveApp(1, 3));
-    await blocResponseFuture();
-
+    assert(menuBloc.state.views[0].name == 'App 3');
     assert(menuBloc.state.views[1].name == 'App 2');
-    assert(menuBloc.state.views[2].name == 'App 3');
-    assert(menuBloc.state.views[3].name == 'App 1');
+    assert(menuBloc.state.views[2].name == 'App 1');
   });
 }

+ 6 - 7
frontend/appflowy_tauri/src-tauri/Cargo.toml

@@ -34,13 +34,12 @@ default = ["custom-protocol"]
 custom-protocol = ["tauri/custom-protocol"]
 
 [patch.crates-io]
-collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" }
-collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" }
-collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" }
-collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" }
-collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" }
-appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" }
-collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" }
+appflowy-integrate = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"}
+collab = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"}
+collab-database = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"}
+collab-document = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"}
+collab-folder = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"}
+collab-plugins = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"}
 
 #collab = { path = "../../AppFlowy-Collab/collab" }
 #collab-folder = { path = "../../AppFlowy-Collab/collab-folder" }

+ 3 - 0
frontend/resources/translations/en.json

@@ -70,6 +70,8 @@
     "rename": "Rename",
     "delete": "Delete",
     "duplicate": "Duplicate",
+    "unfavorite": "Remove from favorites",
+    "favorite": "Add to favorites",
     "openNewTab": "Open in a new tab",
     "moveTo": "Move to",
     "addToFavorites": "Add to Favorites",
@@ -154,6 +156,7 @@
     "personal": "Personal",
     "favorites": "Favorites",
     "clickToHidePersonal": "Click to hide personal section",
+    "clickToHideFavorites": "Click to hide favorite section",
     "addAPage": "Add a page"
   },
   "notifications": {

+ 10 - 10
frontend/rust-lib/Cargo.lock

@@ -85,7 +85,7 @@ checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8"
 [[package]]
 name = "appflowy-integrate"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5"
 dependencies = [
  "anyhow",
  "collab",
@@ -888,7 +888,7 @@ dependencies = [
 [[package]]
 name = "collab"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5"
 dependencies = [
  "anyhow",
  "bytes",
@@ -906,7 +906,7 @@ dependencies = [
 [[package]]
 name = "collab-client-ws"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5"
 dependencies = [
  "bytes",
  "collab-sync",
@@ -924,7 +924,7 @@ dependencies = [
 [[package]]
 name = "collab-database"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -951,7 +951,7 @@ dependencies = [
 [[package]]
 name = "collab-derive"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -963,7 +963,7 @@ dependencies = [
 [[package]]
 name = "collab-document"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5"
 dependencies = [
  "anyhow",
  "collab",
@@ -982,7 +982,7 @@ dependencies = [
 [[package]]
 name = "collab-folder"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5"
 dependencies = [
  "anyhow",
  "chrono",
@@ -1002,7 +1002,7 @@ dependencies = [
 [[package]]
 name = "collab-persistence"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5"
 dependencies = [
  "bincode",
  "chrono",
@@ -1022,7 +1022,7 @@ dependencies = [
 [[package]]
 name = "collab-plugins"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -1052,7 +1052,7 @@ dependencies = [
 [[package]]
 name = "collab-sync"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5"
 dependencies = [
  "bytes",
  "collab",

+ 6 - 7
frontend/rust-lib/Cargo.toml

@@ -38,12 +38,12 @@ opt-level = 3
 incremental = false
 
 [patch.crates-io]
-collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" }
-collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" }
-collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" }
-collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" }
-appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" }
-collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" }
+appflowy-integrate = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"}
+collab = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"}
+collab-database = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"}
+collab-document = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"}
+collab-folder = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"}
+collab-plugins = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"}
 
 #collab = { path = "../AppFlowy-Collab/collab" }
 #collab-folder = { path = "../AppFlowy-Collab/collab-folder" }
@@ -51,4 +51,3 @@ collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev =
 #collab-document = { path = "../AppFlowy-Collab/collab-document" }
 #collab-plugins = { path = "../AppFlowy-Collab/collab-plugins" }
 #appflowy-integrate = { path = "../AppFlowy-Collab/appflowy-integrate" }
-

+ 29 - 9
frontend/rust-lib/flowy-folder2/src/entities/view.rs

@@ -58,6 +58,9 @@ pub struct ViewPB {
   /// The cover url of the view.
   #[pb(index = 8, one_of)]
   pub cover_url: Option<String>,
+
+  #[pb(index = 9)]
+  pub is_favorite: bool,
 }
 
 pub fn view_pb_without_child_views(view: Arc<View>) -> ViewPB {
@@ -70,6 +73,7 @@ pub fn view_pb_without_child_views(view: Arc<View>) -> ViewPB {
     layout: view.layout.clone().into(),
     icon_url: view.icon_url.clone(),
     cover_url: view.cover_url.clone(),
+    is_favorite: view.is_favorite.clone(),
   }
 }
 
@@ -87,6 +91,7 @@ pub fn view_pb_with_child_views(view: Arc<View>, child_views: Vec<Arc<View>>) ->
     layout: view.layout.clone().into(),
     icon_url: view.icon_url.clone(),
     cover_url: view.cover_url.clone(),
+    is_favorite: view.is_favorite.clone(),
   }
 }
 
@@ -174,9 +179,14 @@ pub struct CreateViewPayloadPB {
   #[pb(index = 7)]
   pub meta: HashMap<String, String>,
 
-  /// Mark the view as current view after creation.
+  // Mark the view as current view after creation.
   #[pb(index = 8)]
   pub set_as_current: bool,
+
+  // The index of the view in the parent view.
+  // If the index is None or the index is out of range, the view will be appended to the end of the parent view.
+  #[pb(index = 9, one_of)]
+  pub index: Option<u32>,
 }
 
 /// The orphan view is meant to be a view that is not attached to any parent view. By default, this
@@ -209,8 +219,11 @@ pub struct CreateViewParams {
   pub view_id: String,
   pub initial_data: Vec<u8>,
   pub meta: HashMap<String, String>,
-  /// Mark the view as current view after creation.
+  // Mark the view as current view after creation.
   pub set_as_current: bool,
+  // The index of the view in the parent view.
+  // If the index is None or the index is out of range, the view will be appended to the end of the parent view.
+  pub index: Option<u32>,
 }
 
 impl TryInto<CreateViewParams> for CreateViewPayloadPB {
@@ -230,6 +243,7 @@ impl TryInto<CreateViewParams> for CreateViewPayloadPB {
       initial_data: self.initial_data,
       meta: self.meta,
       set_as_current: self.set_as_current,
+      index: self.index,
     })
   }
 }
@@ -250,6 +264,7 @@ impl TryInto<CreateViewParams> for CreateOrphanViewPayloadPB {
       initial_data: self.initial_data,
       meta: Default::default(),
       set_as_current: false,
+      index: None,
     })
   }
 }
@@ -307,6 +322,9 @@ pub struct UpdateViewPayloadPB {
 
   #[pb(index = 7, one_of)]
   pub cover_url: Option<String>,
+
+  #[pb(index = 8, one_of)]
+  pub is_favorite: Option<bool>,
 }
 
 #[derive(Clone, Debug)]
@@ -315,13 +333,10 @@ pub struct UpdateViewParams {
   pub name: Option<String>,
   pub desc: Option<String>,
   pub thumbnail: Option<String>,
-  pub layout: Option<ViewLayout>,
-
-  /// The icon url can be empty, which means the view has no icon.
   pub icon_url: Option<String>,
-
-  /// The cover url can be empty, which means the view has no icon.
   pub cover_url: Option<String>,
+  pub is_favorite: Option<bool>,
+  pub layout: Option<ViewLayout>,
 }
 
 impl TryInto<UpdateViewParams> for UpdateViewPayloadPB {
@@ -345,14 +360,19 @@ impl TryInto<UpdateViewParams> for UpdateViewPayloadPB {
       Some(thumbnail) => Some(ViewThumbnail::parse(thumbnail)?.0),
     };
 
+    let cover_url = self.cover_url;
+    let icon_url = self.icon_url;
+    let is_favorite = self.is_favorite;
+
     Ok(UpdateViewParams {
       view_id,
       name,
       desc,
       thumbnail,
+      cover_url,
+      icon_url,
+      is_favorite,
       layout: self.layout.map(|ty| ty.into()),
-      icon_url: self.icon_url,
-      cover_url: self.cover_url,
     })
   }
 }

+ 35 - 3
frontend/rust-lib/flowy-folder2/src/event_handler.rs

@@ -1,11 +1,10 @@
 use std::sync::{Arc, Weak};
 
-use flowy_error::{FlowyError, FlowyResult};
-use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult};
-
 use crate::entities::*;
 use crate::manager::FolderManager;
 use crate::share::ImportParams;
+use flowy_error::{FlowyError, FlowyResult};
+use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult};
 
 fn upgrade_folder(
   folder_manager: AFPluginState<Weak<FolderManager>>,
@@ -152,6 +151,18 @@ pub(crate) async fn delete_view_handler(
   Ok(())
 }
 
+pub(crate) async fn toggle_favorites_handler(
+  data: AFPluginData<RepeatedViewIdPB>,
+  folder: AFPluginState<Weak<FolderManager>>,
+) -> Result<(), FlowyError> {
+  let params: RepeatedViewIdPB = data.into_inner();
+  let folder = upgrade_folder(folder)?;
+  for view_id in &params.items {
+    let _ = folder.toggle_favorites(view_id).await;
+  }
+  Ok(())
+}
+
 pub(crate) async fn set_latest_view_handler(
   data: AFPluginData<ViewIdPB>,
   folder: AFPluginState<Weak<FolderManager>>,
@@ -208,6 +219,27 @@ pub(crate) async fn duplicate_view_handler(
   Ok(())
 }
 
+#[tracing::instrument(level = "debug", skip(folder), err)]
+pub(crate) async fn read_favorites_handler(
+  folder: AFPluginState<Weak<FolderManager>>,
+) -> DataResult<RepeatedViewPB, FlowyError> {
+  let folder = upgrade_folder(folder)?;
+  let favorites = folder.get_all_favorites().await;
+  let mut views = vec![];
+  for info in favorites {
+    let view = folder.get_view(&info.id).await;
+    match view {
+      Ok(view) => {
+        views.push(view);
+      },
+      Err(err) => {
+        return Err(err.into());
+      },
+    }
+  }
+
+  data_result_ok(RepeatedViewPB { items: views })
+}
 #[tracing::instrument(level = "debug", skip(folder), err)]
 pub(crate) async fn read_trash_handler(
   folder: AFPluginState<Weak<FolderManager>>,

+ 8 - 1
frontend/rust-lib/flowy-folder2/src/event_map.rs

@@ -38,6 +38,8 @@ pub fn init(folder: Weak<FolderManager>) -> AFPlugin {
     .event(FolderEvent::DeleteAllTrash, delete_all_trash_handler)
     .event(FolderEvent::ImportData, import_data_handler)
       .event(FolderEvent::GetFolderSnapshots, get_folder_snapshots_handler)
+    .event(FolderEvent::ReadFavorites, read_favorites_handler)
+    .event(FolderEvent::ToggleFavorite, toggle_favorites_handler)
 }
 
 #[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)]
@@ -133,7 +135,6 @@ pub enum FolderEvent {
 
   #[event()]
   GetFolderSnapshots = 31,
-
   /// Moves a nested view to a new location in the hierarchy.
   ///
   /// This function takes the `view_id` of the view to be moved,
@@ -142,4 +143,10 @@ pub enum FolderEvent {
   /// this specific view.
   #[event(input = "MoveNestedViewPayloadPB")]
   MoveNestedView = 32,
+
+  #[event(output = "RepeatedViewPB")]
+  ReadFavorites = 33,
+
+  #[event(input = "RepeatedViewIdPB")]
+  ToggleFavorite = 34,
 }

+ 105 - 18
frontend/rust-lib/flowy-folder2/src/manager.rs

@@ -7,8 +7,8 @@ use appflowy_integrate::{CollabPersistenceConfig, CollabType, RocksCollabDB};
 use collab::core::collab::{CollabRawData, MutexCollab};
 use collab::core::collab_state::SyncState;
 use collab_folder::core::{
-  Folder, FolderData, FolderNotify, TrashChange, TrashChangeReceiver, TrashInfo, View, ViewChange,
-  ViewChangeReceiver, ViewLayout, Workspace,
+  FavoritesInfo, Folder, FolderData, FolderNotify, TrashChange, TrashChangeReceiver, TrashInfo,
+  View, ViewChange, ViewChangeReceiver, ViewLayout, Workspace,
 };
 use parking_lot::Mutex;
 use tokio_stream::wrappers::WatchStream;
@@ -370,9 +370,10 @@ impl FolderManager {
         .await?;
     }
 
+    let index = params.index;
     let view = create_view(params, view_layout);
     self.with_folder((), |folder| {
-      folder.insert_view(view.clone());
+      folder.insert_view(view.clone(), index);
     });
 
     Ok(view)
@@ -393,7 +394,7 @@ impl FolderManager {
       .await?;
     let view = create_view(params, view_layout);
     self.with_folder((), |folder| {
-      folder.insert_view(view.clone());
+      folder.insert_view(view.clone(), None);
     });
     Ok(view)
   }
@@ -442,21 +443,21 @@ impl FolderManager {
 
   /// Move the view to trash. If the view is the current view, then set the current view to empty.
   /// When the view is moved to trash, all the child views will be moved to trash as well.
+  /// All the favorite views being trashed will be unfavorited first to remove it from favorites list as well. The process of unfavoriting concerned view is handled by `unfavorite_view_and_decendants()`
   #[tracing::instrument(level = "debug", skip(self), err)]
   pub async fn move_view_to_trash(&self, view_id: &str) -> FlowyResult<()> {
     self.with_folder((), |folder| {
-      let view = folder.views.get_view(view_id);
-      folder.add_trash(vec![view_id.to_string()]);
-
-      // notify the parent view that the view is moved to trash
-      send_notification(view_id, FolderNotification::DidMoveViewToTrash)
-        .payload(DeletedViewPB {
-          view_id: view_id.to_string(),
-          index: None,
-        })
-        .send();
+      if let Some(view) = folder.views.get_view(view_id) {
+        self.unfavorite_view_and_decendants(view.clone(), &folder);
+        folder.add_trash(vec![view_id.to_string()]);
+        // notify the parent view that the view is moved to trash
+        send_notification(view_id, FolderNotification::DidMoveViewToTrash)
+          .payload(DeletedViewPB {
+            view_id: view_id.to_string(),
+            index: None,
+          })
+          .send();
 
-      if let Some(view) = view {
         notify_child_views_changed(
           view_pb_without_child_views(view),
           ChildViewChangeReason::DidDeleteView,
@@ -467,6 +468,31 @@ impl FolderManager {
     Ok(())
   }
 
+  fn unfavorite_view_and_decendants(&self, view: Arc<View>, folder: &Folder) {
+    let mut all_descendant_views: Vec<Arc<View>> = vec![view.clone()];
+    all_descendant_views.extend(folder.views.get_views_belong_to(&view.id));
+
+    let favorite_descendant_views: Vec<ViewPB> = all_descendant_views
+      .iter()
+      .filter(|view| view.is_favorite)
+      .map(|view| view_pb_without_child_views(view.clone()))
+      .collect();
+
+    if !favorite_descendant_views.is_empty() {
+      folder.delete_favorites(
+        favorite_descendant_views
+          .iter()
+          .map(|v| v.id.clone())
+          .collect(),
+      );
+      send_notification("favorite", FolderNotification::DidUnfavoriteView)
+        .payload(RepeatedViewPB {
+          items: favorite_descendant_views,
+        })
+        .send();
+    }
+  }
+
   /// Moves a nested view to a new location in the hierarchy.
   ///
   /// This function takes the `view_id` of the view to be moved,
@@ -570,6 +596,7 @@ impl FolderManager {
           .set_layout_if_not_none(params.layout)
           .set_icon_url_if_not_none(params.icon_url)
           .set_cover_url_if_not_none(params.cover_url)
+          .set_favorite_if_not_none(params.is_favorite)
           .done()
       });
 
@@ -599,6 +626,14 @@ impl FolderManager {
 
     let handler = self.get_handler(&view.layout)?;
     let view_data = handler.duplicate_view(&view.id).await?;
+
+    // get the current view index in the parent view, because we need to insert the duplicated view below the current view.
+    let index = if let Some((_, __, views)) = self.get_view_relation(&view.parent_view_id).await {
+      views.iter().position(|id| id == view_id).map(|i| i as u32)
+    } else {
+      None
+    };
+
     let duplicate_params = CreateViewParams {
       parent_view_id: view.parent_view_id.clone(),
       name: format!("{} (copy)", &view.name),
@@ -608,9 +643,10 @@ impl FolderManager {
       view_id: gen_view_id(),
       meta: Default::default(),
       set_as_current: true,
+      index,
     };
 
-    let _ = self.create_view_with_params(duplicate_params).await?;
+    self.create_view_with_params(duplicate_params).await?;
     Ok(())
   }
 
@@ -634,6 +670,57 @@ impl FolderManager {
     self.get_view(&view_id).await.ok()
   }
 
+  /// Toggles the favorite status of a view identified by `view_id`If the view is not a favorite, it will be added to the favorites list; otherwise, it will be removed from the list.
+  #[tracing::instrument(level = "debug", skip(self), err)]
+  pub async fn toggle_favorites(&self, view_id: &str) -> FlowyResult<()> {
+    self.with_folder((), |folder| {
+      if let Some(old_view) = folder.views.get_view(view_id) {
+        if old_view.is_favorite {
+          folder.delete_favorites(vec![view_id.to_string()]);
+        } else {
+          folder.add_favorites(vec![view_id.to_string()]);
+        }
+      }
+    });
+    self.send_toggle_favorite_notification(view_id).await;
+    Ok(())
+  }
+
+  // Used by toggle_favorites to send notification to frontend, after the favorite status of view has been changed.It sends two distinct notifications: one to correctly update the concerned view's is_favorite status, and another to update the list of favorites that is to be displayed.
+  async fn send_toggle_favorite_notification(&self, view_id: &str) {
+    if let Ok(view) = self.get_view(view_id).await {
+      let notification_type = if view.is_favorite {
+        FolderNotification::DidFavoriteView
+      } else {
+        FolderNotification::DidUnfavoriteView
+      };
+      send_notification("favorite", notification_type)
+        .payload(RepeatedViewPB {
+          items: vec![view.clone()],
+        })
+        .send();
+
+      send_notification(&view.id, FolderNotification::DidUpdateView)
+        .payload(view)
+        .send()
+    }
+  }
+
+  #[tracing::instrument(level = "trace", skip(self))]
+  pub(crate) async fn get_all_favorites(&self) -> Vec<FavoritesInfo> {
+    self.with_folder(vec![], |folder| {
+      let trash_ids = folder
+        .get_all_trash()
+        .into_iter()
+        .map(|trash| trash.id)
+        .collect::<Vec<String>>();
+
+      let mut views = folder.get_all_favorites();
+      views.retain(|view| !trash_ids.contains(&view.id));
+      views
+    })
+  }
+
   #[tracing::instrument(level = "trace", skip(self))]
   pub(crate) async fn get_all_trash(&self) -> Vec<TrashInfo> {
     self.with_folder(vec![], |folder| folder.get_all_trash())
@@ -644,7 +731,6 @@ impl FolderManager {
     self.with_folder((), |folder| {
       folder.remote_all_trash();
     });
-
     send_notification("trash", FolderNotification::DidUpdateTrash)
       .payload(RepeatedTrashPB { items: vec![] })
       .send();
@@ -718,11 +804,12 @@ impl FolderManager {
       view_id,
       meta: Default::default(),
       set_as_current: false,
+      index: None,
     };
 
     let view = create_view(params, import_data.view_layout);
     self.with_folder((), |folder| {
-      folder.insert_view(view.clone());
+      folder.insert_view(view.clone(), None);
     });
     notify_parent_view_did_change(self.mutex_folder.clone(), vec![view.parent_view_id.clone()]);
     Ok(view)

+ 5 - 0
frontend/rust-lib/flowy-folder2/src/notification.rs

@@ -34,6 +34,9 @@ pub enum FolderNotification {
   DidUpdateTrash = 15,
   DidUpdateFolderSnapshotState = 16,
   DidUpdateFolderSyncUpdate = 17,
+
+  DidFavoriteView = 36,
+  DidUnfavoriteView = 37,
 }
 
 impl std::convert::From<FolderNotification> for i32 {
@@ -57,6 +60,8 @@ impl std::convert::From<i32> for FolderNotification {
       15 => FolderNotification::DidUpdateTrash,
       16 => FolderNotification::DidUpdateFolderSnapshotState,
       17 => FolderNotification::DidUpdateFolderSyncUpdate,
+      36 => FolderNotification::DidFavoriteView,
+      37 => FolderNotification::DidUnfavoriteView,
       _ => FolderNotification::Unknown,
     }
   }

+ 1 - 0
frontend/rust-lib/flowy-folder2/src/test_helper.rs

@@ -44,6 +44,7 @@ impl FolderManager {
       initial_data: vec![],
       meta: ext,
       set_as_current: true,
+      index: None,
     };
     self.create_view_with_params(params).await.unwrap();
     view_id

+ 5 - 1
frontend/rust-lib/flowy-folder2/src/view_operation.rs

@@ -54,6 +54,7 @@ pub struct ViewBuilder {
   desc: String,
   layout: ViewLayout,
   child_views: Vec<ParentChildViews>,
+  is_favorite: bool,
   icon_url: Option<String>,
   cover_url: Option<String>,
 }
@@ -67,6 +68,7 @@ impl ViewBuilder {
       desc: Default::default(),
       layout: ViewLayout::Document,
       child_views: vec![],
+      is_favorite: false,
       icon_url: None,
       cover_url: None,
     }
@@ -110,6 +112,7 @@ impl ViewBuilder {
       name: self.name,
       desc: self.desc,
       created_at: timestamp(),
+      is_favorite: self.is_favorite,
       layout: self.layout,
       icon_url: self.icon_url,
       cover_url: self.cover_url,
@@ -252,9 +255,10 @@ pub(crate) fn create_view(params: CreateViewParams, layout: ViewLayout) -> View
     desc: params.desc,
     children: Default::default(),
     created_at: time,
+    is_favorite: false,
     layout,
-    icon_url: None,
     cover_url: None,
+    icon_url: None,
   }
 }
 

+ 52 - 0
frontend/rust-lib/flowy-folder2/tests/workspace/folder_test.rs

@@ -98,6 +98,7 @@ async fn update_parent_view_test() {
       UpdateParentView {
         name: Some(new_name.clone()),
         desc: None,
+        is_favorite: None,
       },
       ReloadParentView(parent_view.id),
     ])
@@ -143,6 +144,7 @@ async fn view_update() {
       UpdateView {
         name: Some(new_name.clone()),
         desc: None,
+        is_favorite: None,
       },
       ReadView(view.id),
     ])
@@ -249,6 +251,56 @@ async fn view_delete_all_permanent() {
   assert_eq!(test.trash.len(), 0);
 }
 
+#[tokio::test]
+async fn toggle_favorites() {
+  let mut test = FolderTest::new().await;
+  let view = test.child_view.clone();
+  test
+    .run_scripts(vec![
+      ReadView(view.id.clone()),
+      ToggleFavorite,
+      ReadFavorites,
+      ReadView(view.id.clone()),
+    ])
+    .await;
+  assert_eq!(test.child_view.is_favorite, true);
+  assert!(test.favorites.len() != 0);
+  assert_eq!(test.favorites[0].id, view.id);
+
+  let view = test.child_view.clone();
+  test
+    .run_scripts(vec![
+      ReadView(view.id.clone()),
+      ToggleFavorite,
+      ReadFavorites,
+      ReadView(view.id.clone()),
+    ])
+    .await;
+
+  assert!(!test.child_view.is_favorite);
+  assert!(test.favorites.is_empty());
+}
+
+#[tokio::test]
+async fn delete_favorites() {
+  let mut test = FolderTest::new().await;
+  let view = test.child_view.clone();
+  test
+    .run_scripts(vec![
+      ReadView(view.id.clone()),
+      ToggleFavorite,
+      ReadFavorites,
+      ReadView(view.id.clone()),
+    ])
+    .await;
+  assert_eq!(test.child_view.is_favorite, true);
+  assert!(test.favorites.len() != 0);
+  assert_eq!(test.favorites[0].id, view.id);
+
+  test.run_scripts(vec![DeleteView, ReadFavorites]).await;
+  assert!(test.favorites.len() == 0);
+}
+
 #[tokio::test]
 async fn move_view_event_test() {
   let mut test = FolderTest::new().await;

+ 47 - 4
frontend/rust-lib/flowy-folder2/tests/workspace/script.rs

@@ -25,6 +25,7 @@ pub enum FolderScript {
   UpdateParentView {
     name: Option<String>,
     desc: Option<String>,
+    is_favorite: Option<bool>,
   },
   DeleteParentView,
 
@@ -39,6 +40,7 @@ pub enum FolderScript {
   UpdateView {
     name: Option<String>,
     desc: Option<String>,
+    is_favorite: Option<bool>,
   },
   DeleteView,
   DeleteViews(Vec<String>),
@@ -53,6 +55,8 @@ pub enum FolderScript {
   RestoreViewFromTrash,
   ReadTrash,
   DeleteAllTrash,
+  ToggleFavorite,
+  ReadFavorites,
 }
 
 pub struct FolderTest {
@@ -62,6 +66,7 @@ pub struct FolderTest {
   pub parent_view: ViewPB,
   pub child_view: ViewPB,
   pub trash: Vec<TrashPB>,
+  pub favorites: Vec<ViewPB>,
 }
 
 impl FolderTest {
@@ -85,6 +90,7 @@ impl FolderTest {
       parent_view,
       child_view: view,
       trash: vec![],
+      favorites: vec![],
     }
   }
 
@@ -123,8 +129,12 @@ impl FolderTest {
         let parent_view = read_view(sdk, &parent_view_id).await;
         self.parent_view = parent_view;
       },
-      FolderScript::UpdateParentView { name, desc } => {
-        update_view(sdk, &self.parent_view.id, name, desc).await;
+      FolderScript::UpdateParentView {
+        name,
+        desc,
+        is_favorite,
+      } => {
+        update_view(sdk, &self.parent_view.id, name, desc, is_favorite).await;
       },
       FolderScript::DeleteParentView => {
         delete_view(sdk, vec![self.parent_view.id.clone()]).await;
@@ -147,8 +157,12 @@ impl FolderTest {
         let view = read_view(sdk, &view_id).await;
         self.child_view = view;
       },
-      FolderScript::UpdateView { name, desc } => {
-        update_view(sdk, &self.child_view.id, name, desc).await;
+      FolderScript::UpdateView {
+        name,
+        desc,
+        is_favorite,
+      } => {
+        update_view(sdk, &self.child_view.id, name, desc, is_favorite).await;
       },
       FolderScript::DeleteView => {
         delete_view(sdk, vec![self.child_view.id.clone()]).await;
@@ -170,6 +184,13 @@ impl FolderTest {
         delete_all_trash(sdk).await;
         self.trash = vec![];
       },
+      FolderScript::ToggleFavorite => {
+        toggle_favorites(sdk, vec![self.child_view.id.clone()]).await;
+      },
+      FolderScript::ReadFavorites => {
+        let favorites = read_favorites(sdk).await;
+        self.favorites = favorites.to_vec();
+      },
     }
   }
 }
@@ -223,6 +244,7 @@ pub async fn create_app(sdk: &FlowyCoreTest, workspace_id: &str, name: &str, des
     initial_data: vec![],
     meta: Default::default(),
     set_as_current: true,
+    index: None,
   };
 
   EventBuilder::new(sdk.clone())
@@ -249,6 +271,7 @@ pub async fn create_view(
     initial_data: vec![],
     meta: Default::default(),
     set_as_current: true,
+    index: None,
   };
   EventBuilder::new(sdk.clone())
     .event(CreateView)
@@ -293,11 +316,14 @@ pub async fn update_view(
   view_id: &str,
   name: Option<String>,
   desc: Option<String>,
+  is_favorite: Option<bool>,
 ) {
+  println!("Toggling update view {:?}", is_favorite);
   let request = UpdateViewPayloadPB {
     view_id: view_id.to_string(),
     name,
     desc,
+    is_favorite,
     ..Default::default()
   };
   EventBuilder::new(sdk.clone())
@@ -352,3 +378,20 @@ pub async fn delete_all_trash(sdk: &FlowyCoreTest) {
     .async_send()
     .await;
 }
+
+pub async fn toggle_favorites(sdk: &FlowyCoreTest, view_id: Vec<String>) {
+  let request = RepeatedViewIdPB { items: view_id };
+  EventBuilder::new(sdk.clone())
+    .event(ToggleFavorite)
+    .payload(request)
+    .async_send()
+    .await;
+}
+
+pub async fn read_favorites(sdk: &FlowyCoreTest) -> RepeatedViewPB {
+  EventBuilder::new(sdk.clone())
+    .event(ReadFavorites)
+    .async_send()
+    .await
+    .parse::<RepeatedViewPB>()
+}

+ 1 - 0
frontend/rust-lib/flowy-test/src/document/document_event.rs

@@ -41,6 +41,7 @@ impl DocumentEventTest {
       initial_data: vec![],
       meta: Default::default(),
       set_as_current: true,
+      index: None,
     };
     EventBuilder::new(core.clone())
       .event(FolderEvent::CreateView)

+ 2 - 0
frontend/rust-lib/flowy-test/src/folder_event.rs

@@ -77,6 +77,7 @@ async fn create_app(sdk: &FlowyCoreTest, name: &str, desc: &str, workspace_id: &
     initial_data: vec![],
     meta: Default::default(),
     set_as_current: true,
+    index: None,
   };
 
   EventBuilder::new(sdk.clone())
@@ -102,6 +103,7 @@ async fn create_view(
     initial_data: data,
     meta: Default::default(),
     set_as_current: true,
+    index: None,
   };
 
   EventBuilder::new(sdk.clone())

+ 5 - 0
frontend/rust-lib/flowy-test/src/lib.rs

@@ -190,6 +190,7 @@ impl FlowyCoreTest {
       initial_data: vec![],
       meta: Default::default(),
       set_as_current: false,
+      index: None,
     };
     EventBuilder::new(self.clone())
       .event(FolderEvent::CreateView)
@@ -214,6 +215,7 @@ impl FlowyCoreTest {
       initial_data,
       meta: Default::default(),
       set_as_current: true,
+      index: None,
     };
     let view = EventBuilder::new(self.clone())
       .event(FolderEvent::CreateView)
@@ -246,6 +248,7 @@ impl FlowyCoreTest {
       initial_data,
       meta: Default::default(),
       set_as_current: true,
+      index: None,
     };
     EventBuilder::new(self.clone())
       .event(FolderEvent::CreateView)
@@ -275,6 +278,7 @@ impl FlowyCoreTest {
       initial_data,
       meta: Default::default(),
       set_as_current: true,
+      index: None,
     };
     EventBuilder::new(self.clone())
       .event(FolderEvent::CreateView)
@@ -299,6 +303,7 @@ impl FlowyCoreTest {
       initial_data,
       meta: Default::default(),
       set_as_current: true,
+      index: None,
     };
     EventBuilder::new(self.clone())
       .event(FolderEvent::CreateView)