diff --git a/frontend/appflowy_flutter/integration_test/database_calendar_test.dart b/frontend/appflowy_flutter/integration_test/database_calendar_test.dart index 18efc129f5..dfd42fed19 100644 --- a/frontend/appflowy_flutter/integration_test/database_calendar_test.dart +++ b/frontend/appflowy_flutter/integration_test/database_calendar_test.dart @@ -50,7 +50,7 @@ void main() { await tester.tapFirstDayOfWeekStartFromMonday(); // Open the other page and open the new calendar page again - await tester.openPage(gettingStated); + await tester.openPage(gettingStarted); await tester.pumpAndSettle(const Duration(milliseconds: 300)); await tester.openPage(name, layout: ViewLayoutPB.Calendar); diff --git a/frontend/appflowy_flutter/integration_test/document/document_create_and_delete_test.dart b/frontend/appflowy_flutter/integration_test/document/document_create_and_delete_test.dart index 922207c2ae..c0f9a9af2a 100644 --- a/frontend/appflowy_flutter/integration_test/document/document_create_and_delete_test.dart +++ b/frontend/appflowy_flutter/integration_test/document/document_create_and_delete_test.dart @@ -34,20 +34,20 @@ void main() { // delete the readme page await tester.hoverOnPageName( - gettingStated, + gettingStarted, onHover: () async => await tester.tapDeletePageButton(), ); // the banner should show up and the readme page should be gone tester.expectToSeeDocumentBanner(); - tester.expectNotToSeePageName(gettingStated); + tester.expectNotToSeePageName(gettingStarted); // restore the readme page await tester.tapRestoreButton(); // the banner should be gone and the readme page should be back tester.expectNotToSeeDocumentBanner(); - tester.expectToSeePageName(gettingStated); + tester.expectToSeePageName(gettingStarted); }); testWidgets('delete the readme page and delete it permanently', @@ -58,20 +58,20 @@ void main() { // delete the readme page await tester.hoverOnPageName( - gettingStated, + gettingStarted, onHover: () async => await tester.tapDeletePageButton(), ); // the banner should show up and the readme page should be gone tester.expectToSeeDocumentBanner(); - tester.expectNotToSeePageName(gettingStated); + tester.expectNotToSeePageName(gettingStarted); // delete the page permanently await tester.tapDeletePermanentlyButton(); // the banner should be gone and the readme page should be gone tester.expectNotToSeeDocumentBanner(); - tester.expectNotToSeePageName(gettingStated); + tester.expectNotToSeePageName(gettingStarted); }); }); } diff --git a/frontend/appflowy_flutter/integration_test/document/edit_document_test.dart b/frontend/appflowy_flutter/integration_test/document/edit_document_test.dart index 7050778b3a..b120b5e332 100644 --- a/frontend/appflowy_flutter/integration_test/document/edit_document_test.dart +++ b/frontend/appflowy_flutter/integration_test/document/edit_document_test.dart @@ -59,7 +59,7 @@ void main() { ); // switch to other page and switch back - await tester.openPage(gettingStated); + await tester.openPage(gettingStarted); await tester.openPage(pageName); // the numbered list should be kept @@ -91,7 +91,7 @@ void main() { } // switch to other page and switch back - await tester.openPage(gettingStated); + await tester.openPage(gettingStarted); await tester.openPage(pageName); // this screenshots are different on different platform, so comment it out temporarily. diff --git a/frontend/appflowy_flutter/integration_test/import_files_test.dart b/frontend/appflowy_flutter/integration_test/import_files_test.dart index 27e448474c..3611f0d43a 100644 --- a/frontend/appflowy_flutter/integration_test/import_files_test.dart +++ b/frontend/appflowy_flutter/integration_test/import_files_test.dart @@ -17,7 +17,7 @@ void main() { await tester.tapGoButton(); // expect to see a getting started page - tester.expectToSeePageName(gettingStated); + tester.expectToSeePageName(gettingStarted); await tester.tapAddViewButton(); await tester.tapImportButton(); diff --git a/frontend/appflowy_flutter/integration_test/runner.dart b/frontend/appflowy_flutter/integration_test/runner.dart index 6969eddb86..0ed7e3976c 100644 --- a/frontend/appflowy_flutter/integration_test/runner.dart +++ b/frontend/appflowy_flutter/integration_test/runner.dart @@ -16,6 +16,7 @@ import 'share_markdown_test.dart' as share_markdown_test; import 'switch_folder_test.dart' as switch_folder_test; import 'sidebar/sidebar_test_runner.dart' as sidebar_test_runner; import 'board/board_test_runner.dart' as board_test_runner; +import 'tabs_test.dart' as tabs_test; /// The main task runner for all integration tests in AppFlowy. /// @@ -51,6 +52,9 @@ void main() { database_view_test.main(); database_calendar_test.main(); + // Tabs + tabs_test.main(); + // board_test.main(); // empty_document_test.main(); // smart_menu_test.main(); diff --git a/frontend/appflowy_flutter/integration_test/share_markdown_test.dart b/frontend/appflowy_flutter/integration_test/share_markdown_test.dart index 97426aa3c1..60b430613e 100644 --- a/frontend/appflowy_flutter/integration_test/share_markdown_test.dart +++ b/frontend/appflowy_flutter/integration_test/share_markdown_test.dart @@ -16,7 +16,7 @@ void main() { await tester.tapGoButton(); // expect to see a readme page - tester.expectToSeePageName(gettingStated); + tester.expectToSeePageName(gettingStarted); // mock the file picker final path = await mockSaveFilePath( @@ -43,11 +43,11 @@ void main() { await tester.tapGoButton(); // expect to see a getting started page - tester.expectToSeePageName(gettingStated); + tester.expectToSeePageName(gettingStarted); // rename the document await tester.hoverOnPageName( - gettingStated, + gettingStarted, onHover: () async { await tester.renamePage('example'); }, diff --git a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_favorites_test.dart b/frontend/appflowy_flutter/integration_test/sidebar/sidebar_favorites_test.dart index 963cd7e455..c0842b487b 100644 --- a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_favorites_test.dart +++ b/frontend/appflowy_flutter/integration_test/sidebar/sidebar_favorites_test.dart @@ -30,7 +30,7 @@ void main() { 2, ].map((e) => 'document_$e').toList(); for (var i = 0; i < names.length; i++) { - final parentName = i == 0 ? gettingStated : names[i - 1]; + final parentName = i == 0 ? gettingStarted : names[i - 1]; await tester.createNewPageWithName( name: names[i], parentName: parentName, @@ -44,9 +44,9 @@ void main() { ); } - await tester.favoriteViewByName(gettingStated); + await tester.favoriteViewByName(gettingStarted); expect( - tester.findFavoritePageName(gettingStated), + tester.findFavoritePageName(gettingStarted), findsOneWidget, ); @@ -56,9 +56,9 @@ void main() { findsNWidgets(2), ); - await tester.unfavoriteViewByName(gettingStated); + await tester.unfavoriteViewByName(gettingStarted); expect( - tester.findFavoritePageName(gettingStated), + tester.findFavoritePageName(gettingStarted), findsNothing, ); expect( @@ -84,9 +84,9 @@ void main() { await tester.tapGoButton(); const name = 'test'; - await tester.favoriteViewByName(gettingStated); + await tester.favoriteViewByName(gettingStarted); await tester.hoverOnPageName( - gettingStated, + gettingStarted, layout: ViewLayoutPB.Document, onHover: () async { await tester.renamePage(name); @@ -112,7 +112,7 @@ void main() { final names = [1, 2].map((e) => 'document_$e').toList(); for (var i = 0; i < names.length; i++) { - final parentName = i == 0 ? gettingStated : names[i - 1]; + final parentName = i == 0 ? gettingStarted : names[i - 1]; await tester.createNewPageWithName( name: names[i], parentName: parentName, @@ -120,7 +120,7 @@ void main() { ); tester.expectToSeePageName(names[i], parentName: parentName); } - await tester.favoriteViewByName(gettingStated); + await tester.favoriteViewByName(gettingStarted); await tester.favoriteViewByName(names[0]); await tester.favoriteViewByName(names[1]); @@ -154,7 +154,7 @@ void main() { ); await tester.hoverOnPageName( - gettingStated, + gettingStarted, layout: ViewLayoutPB.Document, onHover: () async { await tester.tapDeletePageButton(); @@ -181,7 +181,7 @@ void main() { await tester.tapGoButton(); await tester.createNewPageWithName(); - await tester.favoriteViewByName(gettingStated); + await tester.favoriteViewByName(gettingStarted); expect( find.byWidgetPredicate( (widget) => @@ -201,9 +201,9 @@ void main() { await tester.tapGoButton(); await tester.createNewPageWithName(); - await tester.favoriteViewByName(gettingStated); + await tester.favoriteViewByName(gettingStarted); await tester.hoverOnPageName( - gettingStated, + gettingStarted, layout: ViewLayoutPB.Document, useLast: false, onHover: () async { diff --git a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_test.dart b/frontend/appflowy_flutter/integration_test/sidebar/sidebar_test.dart index af612f8ee7..bbad6d833b 100644 --- a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_test.dart +++ b/frontend/appflowy_flutter/integration_test/sidebar/sidebar_test.dart @@ -70,7 +70,7 @@ void main() { break; } - await tester.openPage(gettingStated); + await tester.openPage(gettingStarted); } }); @@ -80,7 +80,7 @@ void main() { final names = [1, 2, 3, 4].map((e) => 'document_$e').toList(); for (var i = 0; i < names.length; i++) { - final parentName = i == 0 ? gettingStated : names[i - 1]; + final parentName = i == 0 ? gettingStarted : names[i - 1]; await tester.createNewPageWithName( name: names[i], parentName: parentName, @@ -92,7 +92,7 @@ void main() { // move the document_3 to the getting started page await tester.movePageToOtherPage( name: names[3], - parentName: gettingStated, + parentName: gettingStarted, layout: ViewLayoutPB.Document, parentLayout: ViewLayoutPB.Document, ); @@ -101,7 +101,7 @@ void main() { .view .parentViewId; final toId = tester - .widget(tester.findPageName(gettingStated)) + .widget(tester.findPageName(gettingStarted)) .view .id; expect(fromId, toId); @@ -109,13 +109,13 @@ void main() { // move the document_2 before document_1 await tester.movePageToOtherPage( name: names[2], - parentName: gettingStated, + parentName: gettingStarted, layout: ViewLayoutPB.Document, parentLayout: ViewLayoutPB.Document, position: DraggableHoverPosition.bottom, ); final childViews = tester - .widget(tester.findPageName(gettingStated)) + .widget(tester.findPageName(gettingStarted)) .view .childViews; expect( @@ -170,7 +170,7 @@ void main() { // it should not be moved final childViews = tester - .widget(tester.findPageName(gettingStated)) + .widget(tester.findPageName(gettingStarted)) .view .childViews; expect( diff --git a/frontend/appflowy_flutter/integration_test/tabs_test.dart b/frontend/appflowy_flutter/integration_test/tabs_test.dart index 333f9a5f29..47c58fbe82 100644 --- a/frontend/appflowy_flutter/integration_test/tabs_test.dart +++ b/frontend/appflowy_flutter/integration_test/tabs_test.dart @@ -1,23 +1,26 @@ +import 'dart:io'; + import 'package:appflowy/workspace/presentation/home/tabs/flowy_tab.dart'; import 'package:appflowy/workspace/presentation/home/tabs/tabs_manager.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'util/base.dart'; import 'util/common_operations.dart'; +import 'util/expectation.dart'; +import 'util/keyboard.dart'; -const _readmeName = 'Read me'; -const _documentName = 'Document'; -const _calendarName = 'Calendar'; +const _documentName = 'First Doc'; +const _documentTwoName = 'Second Doc'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('Tabs', () { - testWidgets('Open AppFlowy and open/navigate multiple tabs', - (tester) async { + testWidgets('Open AppFlowy and open/navigate/close tabs', (tester) async { await tester.initializeAppFlowy(); await tester.tapGoButton(); @@ -29,31 +32,21 @@ void main() { findsNothing, ); - await tester.createNewPageWithName( - name: _calendarName, - layout: ViewLayoutPB.Calendar, - ); await tester.createNewPageWithName( name: _documentName, layout: ViewLayoutPB.Document, ); - // Navigate current view to "Read me" document again - await tester.tapButtonWithName(_readmeName); + await tester.createNewPageWithName( + name: _documentTwoName, + layout: ViewLayoutPB.Document, + ); /// Open second menu item in a new tab - await tester.openAppInNewTab(_calendarName); + await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document); /// Open third menu item in a new tab - await tester.openAppInNewTab(_documentName); - - expect( - find.descendant( - of: find.byType(TabsManager), - matching: find.byType(TabBar), - ), - findsOneWidget, - ); + await tester.openAppInNewTab(_documentName, ViewLayoutPB.Document); expect( find.descendant( @@ -63,13 +56,32 @@ void main() { findsNWidgets(3), ); - /// Navigate to the first tab + /// Navigate to the second tab await tester.tap( find.descendant( of: find.byType(FlowyTab), - matching: find.text(_readmeName), + matching: find.text(gettingStarted), ), ); + + /// Close tab by shortcut + await FlowyTestKeyboard.simulateKeyDownEvent( + [ + Platform.isMacOS + ? LogicalKeyboardKey.meta + : LogicalKeyboardKey.control, + LogicalKeyboardKey.keyW, + ], + tester: tester, + ); + + expect( + find.descendant( + of: find.byType(TabBar), + matching: find.byType(FlowyTab), + ), + findsNWidgets(2), + ); }); }); } diff --git a/frontend/appflowy_flutter/integration_test/util/common_operations.dart b/frontend/appflowy_flutter/integration_test/util/common_operations.dart index fc00b9b3ef..3e91ea7b61 100644 --- a/frontend/appflowy_flutter/integration_test/util/common_operations.dart +++ b/frontend/appflowy_flutter/integration_test/util/common_operations.dart @@ -8,7 +8,6 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/share/share_button.dart'; import 'package:appflowy/user/presentation/skip_log_in_screen.dart'; -import 'package:appflowy/workspace/presentation/home/menu/app/section/item.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -29,7 +28,7 @@ extension CommonOperations on WidgetTester { /// Tap the + button on the home page. Future tapAddViewButton({ - String name = gettingStated, + String name = gettingStarted, }) async { await hoverOnPageName( name, @@ -211,6 +210,12 @@ extension CommonOperations on WidgetTester { await tapButtonWithName(ViewMoreActionType.unFavorite.name); } + /// Tap the Open in a new tab button + Future tapOpenInTabButton() async { + await tapPageOptionButton(); + await tapButtonWithName(ViewMoreActionType.openInNewTab.name); + } + /// Rename the page. Future renamePage(String name) async { await tapRenamePageButton(); @@ -272,7 +277,7 @@ extension CommonOperations on WidgetTester { bool openAfterCreated = true, }) async { // create a new page - await tapAddViewButton(name: parentName ?? gettingStated); + await tapAddViewButton(name: parentName ?? gettingStarted); await tapButtonWithName(layout.menuName); await pumpAndSettle(); @@ -336,11 +341,14 @@ extension CommonOperations on WidgetTester { await pumpAndSettle(); } - Future openAppInNewTab(String name) async { - await hoverOnPageName(name); - await tap(find.byType(ViewDisclosureButton)); - await pumpAndSettle(); - await tap(find.text(LocaleKeys.disclosureAction_openNewTab.tr())); + Future openAppInNewTab(String name, ViewLayoutPB layout) async { + await hoverOnPageName( + name, + onHover: () async { + await tapOpenInTabButton(); + await pumpAndSettle(); + }, + ); await pumpAndSettle(); } diff --git a/frontend/appflowy_flutter/integration_test/util/database_test_op.dart b/frontend/appflowy_flutter/integration_test/util/database_test_op.dart index c7880945a1..ce9eb37896 100644 --- a/frontend/appflowy_flutter/integration_test/util/database_test_op.dart +++ b/frontend/appflowy_flutter/integration_test/util/database_test_op.dart @@ -76,7 +76,7 @@ extension AppFlowyDatabaseTest on WidgetTester { await tapGoButton(); // expect to see a readme page - expectToSeePageName(gettingStated); + expectToSeePageName(gettingStarted); await tapAddViewButton(); await tapImportButton(); diff --git a/frontend/appflowy_flutter/integration_test/util/expectation.dart b/frontend/appflowy_flutter/integration_test/util/expectation.dart index fb5c528663..56bd2c6804 100644 --- a/frontend/appflowy_flutter/integration_test/util/expectation.dart +++ b/frontend/appflowy_flutter/integration_test/util/expectation.dart @@ -11,13 +11,13 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter_test/flutter_test.dart'; // const String readme = 'Read me'; -const String gettingStated = '⭐️ Getting started'; +const String gettingStarted = '⭐️ Getting started'; extension Expectation on WidgetTester { /// Expect to see the home page and with a default read me page. void expectToSeeHomePage() { expect(find.byType(HomeStack), findsOneWidget); - expect(find.textContaining(gettingStated), findsWidgets); + expect(find.textContaining(gettingStarted), findsWidgets); } /// Expect to see the page name on the home page. diff --git a/frontend/appflowy_flutter/lib/core/raw_keyboard_extension.dart b/frontend/appflowy_flutter/lib/core/raw_keyboard_extension.dart index c37c992421..368b61389d 100644 --- a/frontend/appflowy_flutter/lib/core/raw_keyboard_extension.dart +++ b/frontend/appflowy_flutter/lib/core/raw_keyboard_extension.dart @@ -8,4 +8,12 @@ extension RawKeyboardExtension on RawKeyboard { LogicalKeyboardKey.altRight, ].contains(key), ); + + bool get isControlPressed => keysPressed.any( + (key) => [ + LogicalKeyboardKey.control, + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlRight, + ].contains(key), + ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart index 84371be2d7..3ad37b7500 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart @@ -35,9 +35,6 @@ class MenuBloc extends Bloc { _listener.start(appsChanged: _handleAppsOrFail); await _fetchApps(emit); }, - openPage: (e) async { - emit(state.copyWith(plugin: e.plugin)); - }, createApp: (_CreateApp event) async { final result = await _workspaceService.createApp( name: event.name, @@ -110,7 +107,6 @@ class MenuBloc extends Bloc { @freezed class MenuEvent with _$MenuEvent { const factory MenuEvent.initial() = _Initial; - const factory MenuEvent.openPage(Plugin plugin) = _OpenPage; const factory MenuEvent.createApp(String name, {String? desc, int? index}) = _CreateApp; const factory MenuEvent.moveApp(int fromIndex, int toIndex) = _MoveApp; diff --git a/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart index 51e2d63476..8c4df4bf5d 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart @@ -1,6 +1,7 @@ import 'package:appflowy/plugins/util.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; @@ -21,7 +22,9 @@ class TabsBloc extends Bloc { on((event, emit) async { event.when( selectTab: (int index) { - if (index != state.currentIndex) { + if (index != state.currentIndex && + index >= 0 && + index < state.pages) { emit(state.copyWith(newIndex: index)); _setLatestOpenView(); } @@ -31,6 +34,10 @@ class TabsBloc extends Bloc { emit(state.closeView(pluginId)); _setLatestOpenView(); }, + closeCurrentTab: () { + emit(state.closeView(state.currentPageManager.plugin.id)); + _setLatestOpenView(); + }, openTab: (Plugin plugin, ViewPB view) { emit(state.openView(plugin, view)); _setLatestOpenView(view); @@ -54,4 +61,12 @@ class TabsBloc extends Bloc { } } } + + /// Adds a [TabsEvent.openTab] event for the provided [ViewPB] + void openTab(ViewPB view) => + add(TabsEvent.openTab(plugin: view.plugin(), view: view)); + + /// Adds a [TabsEvent.openPlugin] event for the provided [ViewPB] + void openPlugin(ViewPB view) => + add(TabsEvent.openPlugin(plugin: view.plugin(), view: view)); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_event.dart b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_event.dart index b3e6dcc39f..8e344e361f 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_event.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_event.dart @@ -4,6 +4,7 @@ part of 'tabs_bloc.dart'; class TabsEvent with _$TabsEvent { const factory TabsEvent.moveTab() = _MoveTab; const factory TabsEvent.closeTab(String pluginId) = _CloseTab; + const factory TabsEvent.closeCurrentTab() = _CloseCurrentTab; const factory TabsEvent.selectTab(int index) = _SelectTab; const factory TabsEvent.openTab({ required Plugin plugin, diff --git a/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_state.dart b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_state.dart index 5b34b3bac0..34d8dd69b1 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_state.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_state.dart @@ -31,6 +31,11 @@ class TabsState { } TabsState closeView(String pluginId) { + // Avoid closing the only open tab + if (_pageManagers.length == 1) { + return this; + } + _pageManagers.removeWhere((pm) => pm.plugin.id == pluginId); /// If currentIndex is greater than the amount of allowed indices @@ -79,6 +84,10 @@ class TabsState { return null; } + if (index == currentIndex) { + return this; + } + return copyWith(newIndex: index); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart index 9a1bd29ef8..1063566ab0 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart @@ -112,9 +112,13 @@ class ViewBloc extends Bloc { ext: {}, openAfterCreate: e.openAfterCreated, ); + emit( result.fold( - (l) => state.copyWith(successOrFailure: left(unit)), + (view) => state.copyWith( + lastCreatedView: view, + successOrFailure: left(unit), + ), (error) => state.copyWith(successOrFailure: right(error)), ), ); @@ -218,6 +222,7 @@ class ViewState with _$ViewState { required bool isEditing, required bool isExpanded, required Either successOrFailure, + @Default(null) ViewPB? lastCreatedView, }) = _ViewState; factory ViewState.init(ViewPB view) => ViewState( @@ -226,5 +231,6 @@ class ViewState with _$ViewState { isExpanded: false, isEditing: false, successOrFailure: left(unit), + lastCreatedView: null, ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/hotkeys.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/hotkeys.dart index 0ba7a76be9..1a6ba9b6f8 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/hotkeys.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/hotkeys.dart @@ -2,45 +2,102 @@ import 'dart:io'; import 'package:appflowy/workspace/application/appearance.dart'; import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:flutter/material.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:provider/provider.dart'; +typedef KeyDownHandler = void Function(HotKey hotKey); + +/// Helper class that utilizes the global [HotKeyManager] to easily +/// add a [HotKey] with different handlers. +/// +/// Makes registration of a [HotKey] simple and easy to read, and makes +/// sure the [KeyDownHandler], and other handlers, are grouped with the +/// relevant [HotKey]. +/// +class HotKeyItem { + final HotKey hotKey; + final KeyDownHandler? keyDownHandler; + + HotKeyItem({ + required this.hotKey, + this.keyDownHandler, + }); + + void register() => + hotKeyManager.register(hotKey, keyDownHandler: keyDownHandler); +} + class HomeHotKeys extends StatelessWidget { final Widget child; const HomeHotKeys({required this.child, Key? key}) : super(key: key); @override Widget build(BuildContext context) { - final HotKey hotKey = HotKey( - KeyCode.backslash, - modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control], - // Set hotkey scope (default is HotKeyScope.system) - scope: HotKeyScope.inapp, // Set as inapp-wide hotkey. - ); - hotKeyManager.register( - hotKey, - keyDownHandler: (hotKey) { - context - .read() - .add(const HomeSettingEvent.collapseMenu()); - }, - ); + // Collapse sidebar menu + HotKeyItem( + hotKey: HotKey( + KeyCode.backslash, + modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control], + // Set hotkey scope (default is HotKeyScope.system) + scope: HotKeyScope.inapp, // Set as inapp-wide hotkey. + ), + keyDownHandler: (_) => context + .read() + .add(const HomeSettingEvent.collapseMenu()), + ).register(); + + // Toggle theme mode light/dark + HotKeyItem( + hotKey: HotKey( + KeyCode.keyL, + modifiers: [ + Platform.isMacOS ? KeyModifier.meta : KeyModifier.control, + KeyModifier.shift, + ], + scope: HotKeyScope.inapp, + ), + keyDownHandler: (_) => + context.read().toggleThemeMode(), + ).register(); + + // Close current tab + HotKeyItem( + hotKey: HotKey( + KeyCode.keyW, + modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control], + scope: HotKeyScope.inapp, + ), + keyDownHandler: (_) => + context.read().add(const TabsEvent.closeCurrentTab()), + ).register(); + + // Go to previous tab + HotKeyItem( + hotKey: HotKey( + KeyCode.pageUp, + modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control], + scope: HotKeyScope.inapp, + ), + keyDownHandler: (_) => _selectTab(context, -1), + ).register(); + + // Go to next tab + HotKeyItem( + hotKey: HotKey( + KeyCode.pageDown, + modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control], + scope: HotKeyScope.inapp, + ), + keyDownHandler: (_) => _selectTab(context, 1), + ).register(); - final HotKey hotKeyForToggleThemeMode = HotKey( - KeyCode.keyL, - modifiers: [ - Platform.isMacOS ? KeyModifier.meta : KeyModifier.control, - KeyModifier.shift, - ], - scope: HotKeyScope.inapp, - ); - hotKeyManager.register( - hotKeyForToggleThemeMode, - keyDownHandler: (_) { - context.read().toggleThemeMode(); - }, - ); return child; } + + void _selectTab(BuildContext context, int change) { + final bloc = context.read(); + bloc.add(TabsEvent.selectTab(bloc.state.currentIndex + change)); + } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/app/section/item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/app/section/item.dart index 8c33303b96..1f4f4b6f08 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/app/section/item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/app/section/item.dart @@ -123,12 +123,7 @@ class ViewSectionItem extends StatelessWidget { .add(FavoriteEvent.toggle(view)); break; case ViewDisclosureAction.openInNewTab: - blocContext.read().add( - TabsEvent.openTab( - plugin: state.view.plugin(), - view: blocContext.read().state.view, - ), - ); + blocContext.read().openTab(state.view); break; } }, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu.dart index ffed3f0d05..0cda26cf87 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu.dart @@ -7,7 +7,6 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; -import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/workspace.pb.dart'; @@ -49,34 +48,18 @@ class HomeMenu extends StatelessWidget { return MultiBlocProvider( providers: [ BlocProvider( - create: (context) { - final menuBloc = MenuBloc( - user: user, - workspace: workspaceSetting.workspace, - ); - menuBloc.add(const MenuEvent.initial()); - return menuBloc; - }, + create: (context) => MenuBloc( + user: user, + workspace: workspaceSetting.workspace, + )..add(const MenuEvent.initial()), ), BlocProvider( - create: (ctx) => + create: (context) => getIt()..add(const FavoriteEvent.initial()), ) ], - child: MultiBlocListener( - listeners: [ - BlocListener( - listenWhen: (p, c) => p.plugin.id != c.plugin.id, - listener: (context, state) { - getIt().add( - TabsEvent.openPlugin(plugin: state.plugin), - ); - }, - ), - ], - child: BlocBuilder( - builder: (context, state) => _renderBody(context), - ), + child: BlocBuilder( + builder: (context, state) => _renderBody(context), ), ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/favorite_folder.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/favorite_folder.dart index d854022f48..a064f56700 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/favorite_folder.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/favorite_folder.dart @@ -1,14 +1,13 @@ +import 'package:appflowy/core/raw_keyboard_extension.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; -import 'package:appflowy/workspace/application/view/view_ext.dart'; -import 'package:appflowy/workspace/presentation/home/menu/menu.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class FavoriteFolder extends StatelessWidget { @@ -24,6 +23,7 @@ class FavoriteFolder extends StatelessWidget { if (views.isEmpty) { return const SizedBox.shrink(); } + return BlocProvider( create: (context) => FolderBloc(type: FolderCategoryType.favorite) ..add( @@ -54,11 +54,14 @@ class FavoriteFolder extends StatelessWidget { view: view, level: 0, onSelected: (view) { - getIt().latestOpenView = view; - context - .read() - .add(MenuEvent.openPage(view.plugin())); + if (RawKeyboard.instance.isControlPressed) { + context.read().openTab(view); + } + + context.read().openPlugin(view); }, + onTertiarySelected: (view) => + context.read().openTab(view), ), ) ], diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart index 86e060b7dd..3e98b81cab 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart @@ -1,15 +1,15 @@ +import 'package:appflowy/core/raw_keyboard_extension.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; -import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/image.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class PersonalFolder extends StatelessWidget { @@ -52,13 +52,14 @@ class PersonalFolder extends StatelessWidget { leftPadding: 16, isFeedback: false, onSelected: (view) { - getIt().add( - TabsEvent.openPlugin( - plugin: view.plugin(), - view: view, - ), - ); + if (RawKeyboard.instance.isControlPressed) { + context.read().openTab(view); + } + + context.read().openPlugin(view); }, + onTertiarySelected: (view) => + context.read().openTab(view), ), ) ], diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart index 8045fe08b4..0c4dcddcd4 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart @@ -1,4 +1,3 @@ -import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; @@ -7,6 +6,7 @@ import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_new_pa import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_top_menu.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_trash.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_user.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show UserProfilePB; @@ -48,17 +48,18 @@ class HomeSideBar extends StatelessWidget { ], child: BlocListener( listenWhen: (p, c) => p.plugin.id != c.plugin.id, - listener: (context, state) => getIt().add( - TabsEvent.openPlugin(plugin: state.plugin), - ), + listener: (context, state) => context + .read() + .add(TabsEvent.openPlugin(plugin: state.plugin)), child: Builder( builder: (context) { final menuState = context.watch().state; final favoriteState = context.watch().state; + return _buildSidebar( context, - menuState, - favoriteState, + menuState.views, + favoriteState.views, ); }, ), @@ -68,11 +69,10 @@ class HomeSideBar extends StatelessWidget { Widget _buildSidebar( BuildContext context, - MenuState state, - FavoriteState favoriteState, + List views, + List favoriteViews, ) { - final views = state.views; - return Container( + return DecoratedBox( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceVariant, border: Border( @@ -95,7 +95,7 @@ class HomeSideBar extends StatelessWidget { child: SingleChildScrollView( child: SidebarFolder( views: views, - favoriteViews: favoriteState.views, + favoriteViews: favoriteViews, ), ), ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart index a5bcfede65..a94575cfcd 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart @@ -48,12 +48,7 @@ class SidebarNewPageButton extends StatelessWidget { value: '', confirm: (value) { if (value.isNotEmpty) { - context.read().add( - MenuEvent.createApp( - value, - desc: '', - ), - ); + context.read().add(MenuEvent.createApp(value)); } }, ).show(context); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart index 49b577459e..b7ef4c2382 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart @@ -19,6 +19,8 @@ import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +typedef ViewItemOnSelected = void Function(ViewPB); + class ViewItem extends StatelessWidget { const ViewItem({ super.key, @@ -28,6 +30,7 @@ class ViewItem extends StatelessWidget { required this.level, this.leftPadding = 10, required this.onSelected, + this.onTertiarySelected, this.isFirstChild = false, this.isDraggable = true, required this.isFeedback, @@ -46,7 +49,11 @@ class ViewItem extends StatelessWidget { // the left padding of the each level = level * leftPadding final double leftPadding; - final void Function(ViewPB) onSelected; + // Selected by normal conventions + final ViewItemOnSelected onSelected; + + // Selected by middle mouse button + final ViewItemOnSelected? onTertiarySelected; // used for indicating the first child of the parent view, so that we can // add top border to the first child @@ -62,7 +69,12 @@ class ViewItem extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()), - child: BlocBuilder( + child: BlocConsumer( + listenWhen: (p, c) => + c.lastCreatedView != null && + p.lastCreatedView?.id != c.lastCreatedView!.id, + listener: (context, state) => + context.read().openPlugin(state.lastCreatedView!), builder: (context, state) { // don't remove this code. it's related to the backend service. view.childViews @@ -78,6 +90,7 @@ class ViewItem extends StatelessWidget { showActions: state.isEditing, isExpanded: state.isExpanded, onSelected: onSelected, + onTertiarySelected: onTertiarySelected, isFirstChild: isFirstChild, isDraggable: isDraggable, isFeedback: isFeedback, @@ -101,6 +114,7 @@ class InnerViewItem extends StatelessWidget { required this.leftPadding, required this.showActions, required this.onSelected, + this.onTertiarySelected, this.isFirstChild = false, required this.isFeedback, }); @@ -120,7 +134,8 @@ class InnerViewItem extends StatelessWidget { final double leftPadding; final bool showActions; - final void Function(ViewPB) onSelected; + final ViewItemOnSelected onSelected; + final ViewItemOnSelected? onTertiarySelected; @override Widget build(BuildContext context) { @@ -131,6 +146,7 @@ class InnerViewItem extends StatelessWidget { showActions: showActions, categoryType: categoryType, onSelected: onSelected, + onTertiarySelected: onTertiarySelected, isExpanded: isExpanded, isDraggable: isDraggable, leftPadding: leftPadding, @@ -148,6 +164,7 @@ class InnerViewItem extends StatelessWidget { view: childView, level: level + 1, onSelected: onSelected, + onTertiarySelected: onTertiarySelected, isDraggable: isDraggable, leftPadding: leftPadding, isFeedback: isFeedback, @@ -176,6 +193,7 @@ class InnerViewItem extends StatelessWidget { categoryType: categoryType, level: level, onSelected: onSelected, + onTertiarySelected: onTertiarySelected, isDraggable: false, leftPadding: leftPadding, isFeedback: true, @@ -206,6 +224,7 @@ class SingleInnerViewItem extends StatefulWidget { required this.categoryType, required this.showActions, required this.onSelected, + this.onTertiarySelected, required this.isFeedback, }); @@ -220,7 +239,8 @@ class SingleInnerViewItem extends StatefulWidget { final bool isDraggable; final bool showActions; - final void Function(ViewPB) onSelected; + final ViewItemOnSelected onSelected; + final ViewItemOnSelected? onTertiarySelected; final FolderCategoryType categoryType; @override @@ -279,6 +299,7 @@ class _SingleInnerViewItemState extends State { return GestureDetector( behavior: HitTestBehavior.translucent, onTap: () => widget.onSelected(widget.view), + onTertiaryTapDown: (_) => widget.onTertiarySelected?.call(widget.view), child: SizedBox( height: 26, child: Padding( @@ -377,12 +398,7 @@ class _SingleInnerViewItemState extends State { context.read().add(const ViewEvent.duplicate()); break; case ViewMoreActionType.openInNewTab: - context.read().add( - TabsEvent.openTab( - plugin: widget.view.plugin(), - view: widget.view, - ), - ); + context.read().openTab(widget.view); break; default: throw UnsupportedError('$action is not supported');