diff --git a/frontend/appflowy_flutter/integration_test/desktop/command_palette/recent_history_test.dart b/frontend/appflowy_flutter/integration_test/desktop/command_palette/recent_history_test.dart index 277ae8f21e..b9495ae0e7 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/command_palette/recent_history_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/command_palette/recent_history_test.dart @@ -1,5 +1,5 @@ import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart'; -import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_view_tile.dart'; +import 'package:appflowy/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_views_list.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -27,11 +27,12 @@ void main() { expect(find.byType(RecentViewsList), findsOneWidget); // Expect three recent history items - expect(find.byType(RecentViewTile), findsNWidgets(3)); + expect(find.byType(SearchRecentViewCell), findsNWidgets(3)); // Expect the first item to be the last viewed document final firstDocumentWidget = - tester.widget(find.byType(RecentViewTile).first) as RecentViewTile; + tester.widget(find.byType(SearchRecentViewCell).first) + as SearchRecentViewCell; expect(firstDocumentWidget.view.name, secondDocument); }); }); diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart index b01402c868..b53f3f8c23 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart @@ -11,10 +11,25 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'command_palette_bloc.freezed.dart'; +class Debouncer { + Debouncer({required this.delay}); + + final Duration delay; + Timer? _timer; + + void run(void Function() action) { + _timer?.cancel(); + _timer = Timer(delay, action); + } + + void dispose() { + _timer?.cancel(); + } +} + class CommandPaletteBloc extends Bloc { CommandPaletteBloc() : super(CommandPaletteState.initial()) { - // Register event handlers on<_SearchChanged>(_onSearchChanged); on<_PerformSearch>(_onPerformSearch); on<_NewSearchStream>(_onNewSearchStream); @@ -26,38 +41,35 @@ class CommandPaletteBloc _initTrash(); } - Timer? _debounceOnChanged; + final Debouncer _searchDebouncer = Debouncer( + delay: const Duration(milliseconds: 300), + ); final TrashService _trashService = TrashService(); final TrashListener _trashListener = TrashListener(); - String? _oldQuery; + String? _activeQuery; String? _workspaceId; @override Future close() { _trashListener.close(); - _debounceOnChanged?.cancel(); + _searchDebouncer.dispose(); state.searchResponseStream?.dispose(); return super.close(); } Future _initTrash() async { - // Start listening for trash updates _trashListener.start( - trashUpdated: (trashOrFailed) { - add( - CommandPaletteEvent.trashChanged( - trash: trashOrFailed.toNullable(), - ), - ); - }, + trashUpdated: (trashOrFailed) => add( + CommandPaletteEvent.trashChanged( + trash: trashOrFailed.toNullable(), + ), + ), ); - // Read initial trash state and forward results final trashOrFailure = await _trashService.readTrash(); - add( - CommandPaletteEvent.trashChanged( - trash: trashOrFailure.toNullable()?.items, - ), + trashOrFailure.fold( + (trash) => add(CommandPaletteEvent.trashChanged(trash: trash.items)), + (error) => debugPrint('Failed to load trash: $error'), ); } @@ -65,9 +77,7 @@ class CommandPaletteBloc _SearchChanged event, Emitter emit, ) { - _debounceOnChanged?.cancel(); - _debounceOnChanged = Timer( - const Duration(milliseconds: 300), + _searchDebouncer.run( () { if (!isClosed) { add(CommandPaletteEvent.performSearch(search: event.search)); @@ -80,31 +90,44 @@ class CommandPaletteBloc _PerformSearch event, Emitter emit, ) async { - if (event.search.isNotEmpty && event.search != state.query) { - _oldQuery = state.query; + if (event.search.isEmpty && event.search != state.query) { + emit( + state.copyWith( + query: null, + isLoading: false, + serverResponseItems: [], + localResponseItems: [], + combinedResponseItems: {}, + resultSummaries: [], + ), + ); + } else { emit(state.copyWith(query: event.search, isLoading: true)); + _activeQuery = event.search; - // Fire off search asynchronously (fire and forget) unawaited( SearchBackendService.performSearch( event.search, workspaceId: _workspaceId, ).then( - (result) => result.onSuccess((stream) { - if (!isClosed) { - add(CommandPaletteEvent.newSearchStream(stream: stream)); - } - }), - ), - ); - } else { - // Clear state if search is empty or unchanged - emit( - state.copyWith( - query: null, - isLoading: false, - resultItems: [], - resultSummaries: [], + (result) => result.fold( + (stream) { + if (!isClosed && _activeQuery == event.search) { + add(CommandPaletteEvent.newSearchStream(stream: stream)); + } + }, + (error) { + debugPrint('Search error: $error'); + if (!isClosed) { + add( + CommandPaletteEvent.resultsChanged( + searchId: '', + isLoading: false, + ), + ); + } + }, + ), ), ); } @@ -123,83 +146,88 @@ class CommandPaletteBloc ); event.stream.listen( - onItems: ( - List items, - String searchId, - bool isLoading, - ) { - if (_isActiveSearch(searchId)) { - add( - CommandPaletteEvent.resultsChanged( - items: items, - searchId: searchId, - isLoading: isLoading, - ), - ); - } - }, - onSummaries: ( - List summaries, - String searchId, - bool isLoading, - ) { - if (_isActiveSearch(searchId)) { - add( - CommandPaletteEvent.resultsChanged( - summaries: summaries, - searchId: searchId, - isLoading: isLoading, - ), - ); - } - }, - onFinished: (String searchId) { - if (_isActiveSearch(searchId)) { - add( - CommandPaletteEvent.resultsChanged( - searchId: searchId, - isLoading: false, - ), - ); - } - }, + onLocalItems: (items, searchId) => _handleResultsUpdate( + searchId: searchId, + localItems: items, + ), + onServerItems: (items, searchId, isLoading) => _handleResultsUpdate( + searchId: searchId, + serverItems: items, + isLoading: isLoading, + ), + onSummaries: (summaries, searchId, isLoading) => _handleResultsUpdate( + searchId: searchId, + summaries: summaries, + isLoading: isLoading, + ), + onFinished: (searchId) => _handleResultsUpdate( + searchId: searchId, + isLoading: false, + ), ); } + void _handleResultsUpdate({ + required String searchId, + List? serverItems, + List? localItems, + List? summaries, + bool isLoading = true, + }) { + if (_isActiveSearch(searchId)) { + add( + CommandPaletteEvent.resultsChanged( + searchId: searchId, + serverItems: serverItems, + localItems: localItems, + summaries: summaries, + isLoading: isLoading, + ), + ); + } + } + FutureOr _onResultsChanged( _ResultsChanged event, Emitter emit, ) async { - // If query was updated since last emission, clear previous results. - if (state.query != _oldQuery) { - emit( - state.copyWith( - resultItems: [], - resultSummaries: [], - isLoading: event.isLoading, - ), - ); - _oldQuery = state.query; - } - - // Check for outdated search streams if (state.searchId != event.searchId) return; - final updatedItems = - event.items ?? List.from(state.resultItems); - final updatedSummaries = - event.summaries ?? List.from(state.resultSummaries); + final combinedItems = {}; + for (final item in event.serverItems ?? state.serverResponseItems) { + combinedItems[item.id] = SearchResultItem( + id: item.id, + icon: item.icon, + displayName: item.displayName, + content: item.content, + workspaceId: item.workspaceId, + ); + } + + for (final item in event.localItems ?? state.localResponseItems) { + combinedItems.putIfAbsent( + item.id, + () => SearchResultItem( + id: item.id, + icon: item.icon, + displayName: item.displayName, + content: '', + workspaceId: item.workspaceId, + ), + ); + } emit( state.copyWith( - resultItems: updatedItems, - resultSummaries: updatedSummaries, + serverResponseItems: event.serverItems ?? state.serverResponseItems, + localResponseItems: event.localItems ?? state.localResponseItems, + resultSummaries: event.summaries ?? state.resultSummaries, + combinedResponseItems: combinedItems, isLoading: event.isLoading, ), ); } - // Update trash state and, in case of null, retry reading trash from the service FutureOr _onTrashChanged( _TrashChanged event, Emitter emit, @@ -216,7 +244,6 @@ class CommandPaletteBloc } } - // Update the workspace and clear current search results and query FutureOr _onWorkspaceChanged( _WorkspaceChanged event, Emitter emit, @@ -225,27 +252,20 @@ class CommandPaletteBloc emit( state.copyWith( query: '', - resultItems: [], + serverResponseItems: [], + localResponseItems: [], + combinedResponseItems: {}, resultSummaries: [], isLoading: false, ), ); } - // Clear search state FutureOr _onClearSearch( _ClearSearch event, Emitter emit, ) { - emit( - state.copyWith( - query: '', - resultItems: [], - resultSummaries: [], - isLoading: false, - searchId: null, - ), - ); + emit(CommandPaletteState.initial().copyWith(trash: state.trash)); } bool _isActiveSearch(String searchId) => @@ -264,7 +284,8 @@ class CommandPaletteEvent with _$CommandPaletteEvent { const factory CommandPaletteEvent.resultsChanged({ required String searchId, required bool isLoading, - List? items, + List? serverItems, + List? localItems, List? summaries, }) = _ResultsChanged; @@ -277,12 +298,30 @@ class CommandPaletteEvent with _$CommandPaletteEvent { const factory CommandPaletteEvent.clearSearch() = _ClearSearch; } +class SearchResultItem { + const SearchResultItem({ + required this.id, + required this.icon, + required this.content, + required this.displayName, + this.workspaceId, + }); + + final String id; + final String content; + final ResultIconPB icon; + final String displayName; + final String? workspaceId; +} + @freezed class CommandPaletteState with _$CommandPaletteState { const CommandPaletteState._(); const factory CommandPaletteState({ @Default(null) String? query, - @Default([]) List resultItems, + @Default([]) List serverResponseItems, + @Default([]) List localResponseItems, + @Default({}) Map combinedResponseItems, @Default([]) List resultSummaries, @Default(null) SearchResponseStream? searchResponseStream, required bool isLoading, @@ -290,6 +329,7 @@ class CommandPaletteState with _$CommandPaletteState { @Default(null) String? searchId, }) = _CommandPaletteState; - factory CommandPaletteState.initial() => - const CommandPaletteState(isLoading: false); + factory CommandPaletteState.initial() => const CommandPaletteState( + isLoading: false, + ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_list_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_list_bloc.dart index c77530ba77..58e5a951da 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_list_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_list_bloc.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:bloc/bloc.dart'; import 'package:flutter/foundation.dart'; @@ -13,6 +14,7 @@ class SearchResultListBloc // Register event handlers on<_OnHoverSummary>(_onHoverSummary); on<_OnHoverResult>(_onHoverResult); + on<_OpenPage>(_onOpenPage); } FutureOr _onHoverSummary( @@ -23,6 +25,7 @@ class SearchResultListBloc state.copyWith( hoveredSummary: event.summary, hoveredResult: null, + openPageId: null, ), ); } @@ -35,9 +38,17 @@ class SearchResultListBloc state.copyWith( hoveredSummary: null, hoveredResult: event.item, + openPageId: null, ), ); } + + FutureOr _onOpenPage( + _OpenPage event, + Emitter emit, + ) { + emit(state.copyWith(openPageId: event.pageId)); + } } @freezed @@ -46,8 +57,12 @@ class SearchResultListEvent with _$SearchResultListEvent { required SearchSummaryPB summary, }) = _OnHoverSummary; const factory SearchResultListEvent.onHoverResult({ - required SearchResponseItemPB item, + required SearchResultItem item, }) = _OnHoverResult; + + const factory SearchResultListEvent.openPage({ + required String pageId, + }) = _OpenPage; } @freezed @@ -55,7 +70,8 @@ class SearchResultListState with _$SearchResultListState { const SearchResultListState._(); const factory SearchResultListState({ @Default(null) SearchSummaryPB? hoveredSummary, - @Default(null) SearchResponseItemPB? hoveredResult, + @Default(null) SearchResultItem? hoveredResult, + @Default(null) String? openPageId, }) = _SearchResultListState; factory SearchResultListState.initial() => const SearchResultListState(); diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart index 6b862bc9ab..6f05b88081 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart @@ -50,12 +50,18 @@ class SearchResponseStream { List items, String searchId, bool isLoading, - )? _onItems; + )? _onServerItems; void Function( List summaries, String searchId, bool isLoading, )? _onSummaries; + + void Function( + List items, + String searchId, + )? _onLocalItems; + void Function(String searchId)? _onFinished; int get nativePort => _port.sendPort.nativePort; @@ -65,21 +71,28 @@ class SearchResponseStream { } void _onResultsChanged(Uint8List data) { - final response = SearchResponsePB.fromBuffer(data); + final searchState = SearchStatePB.fromBuffer(data); - if (response.hasResult()) { - if (response.result.hasSearchResult()) { - _onItems?.call( - response.result.searchResult.items, + if (searchState.hasResponse()) { + if (searchState.response.hasSearchResult()) { + _onServerItems?.call( + searchState.response.searchResult.items, searchId, - response.isLoading, + searchState.isLoading, ); } - if (response.result.hasSearchSummary()) { + if (searchState.response.hasSearchSummary()) { _onSummaries?.call( - response.result.searchSummary.items, + searchState.response.searchSummary.items, + searchId, + searchState.isLoading, + ); + } + + if (searchState.response.hasLocalSearchResult()) { + _onLocalItems?.call( + searchState.response.localSearchResult.items, searchId, - response.isLoading, ); } } else { @@ -92,16 +105,21 @@ class SearchResponseStream { List items, String searchId, bool isLoading, - )? onItems, + )? onServerItems, required void Function( List summaries, String searchId, bool isLoading, )? onSummaries, + required void Function( + List items, + String searchId, + )? onLocalItems, required void Function(String searchId)? onFinished, }) { - _onItems = onItems; + _onServerItems = onServerItems; _onSummaries = onSummaries; + _onLocalItems = onLocalItems; _onFinished = onFinished; } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart index f54fbebe14..a08c46679c 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart @@ -153,13 +153,13 @@ class CommandPaletteModal extends StatelessWidget { ), ), ], - if (state.resultItems.isNotEmpty && + if (state.combinedResponseItems.isNotEmpty && (state.query?.isNotEmpty ?? false)) ...[ const Divider(height: 0), Flexible( child: SearchResultList( trash: state.trash, - resultItems: state.resultItems, + resultItems: state.combinedResponseItems.values.toList(), resultSummaries: state.resultSummaries, ), ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_views_list.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_views_list.dart index b0f87005d2..3bc160ee81 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_views_list.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_views_list.dart @@ -4,7 +4,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emo import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; -import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_view_tile.dart'; +import 'package:appflowy/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; @@ -52,7 +52,7 @@ class RecentViewsList extends StatelessWidget { ) : FlowySvg(view.iconData, size: const Size.square(20)); - return RecentViewTile( + return SearchRecentViewCell( icon: SizedBox(width: 24, child: icon), view: view, onSelected: onSelected, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_view_tile.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart similarity index 94% rename from frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_view_tile.dart rename to frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart index 645b9696c8..a803f9b44c 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_view_tile.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart @@ -7,8 +7,8 @@ import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; -class RecentViewTile extends StatelessWidget { - const RecentViewTile({ +class SearchRecentViewCell extends StatelessWidget { + const SearchRecentViewCell({ super.key, required this.icon, required this.view, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_cell.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_cell.dart index 1bd3d0b03a..7034b79821 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_cell.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_cell.dart @@ -1,11 +1,8 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/string_extension.dart'; -import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; -import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; +import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart'; import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.dart'; -import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; @@ -19,12 +16,10 @@ class SearchResultCell extends StatefulWidget { const SearchResultCell({ super.key, required this.item, - required this.onSelected, this.isTrashed = false, }); - final SearchResponseItemPB item; - final VoidCallback onSelected; + final SearchResultItem item; final bool isTrashed; @override @@ -43,12 +38,9 @@ class _SearchResultCellState extends State { /// Helper to handle the selection action. void _handleSelection() { - widget.onSelected(); - getIt().add( - ActionNavigationEvent.performAction( - action: NavigationAction(objectId: widget.item.id), - ), - ); + context.read().add( + SearchResultListEvent.openPage(pageId: widget.item.id), + ); } /// Helper to clean up preview text. @@ -62,7 +54,7 @@ class _SearchResultCellState extends State { LocaleKeys.menuAppHeader_defaultNewPageName.tr(), ); final icon = widget.item.icon.getIcon(); - final cleanedPreview = _cleanPreview(widget.item.preview); + final cleanedPreview = _cleanPreview(widget.item.content); final hasPreview = cleanedPreview.isNotEmpty; final trashHintText = widget.isTrashed ? LocaleKeys.commandPalette_fromTrashHint.tr() : null; @@ -208,7 +200,7 @@ class SearchResultPreview extends StatelessWidget { required this.data, }); - final SearchResponseItemPB data; + final SearchResultItem data; @override Widget build(BuildContext context) { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart index 5d2e305c15..af2944d09c 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart @@ -1,3 +1,7 @@ +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; +import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; +import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.dart'; import 'package:flutter/material.dart'; @@ -20,9 +24,8 @@ class SearchResultList extends StatelessWidget { }); final List trash; - final List resultItems; + final List resultItems; final List resultSummaries; - Widget _buildSectionHeader(String title) => Padding( padding: const EdgeInsets.symmetric(vertical: 8) + const EdgeInsets.only(left: 8), @@ -65,7 +68,6 @@ class SearchResultList extends StatelessWidget { final item = resultItems[index]; return SearchResultCell( item: item, - onSelected: () => FlowyOverlay.pop(context), isTrashed: trash.any((t) => t.id == item.id), ); }, @@ -80,33 +82,46 @@ class SearchResultList extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 6), child: BlocProvider( create: (context) => SearchResultListBloc(), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Flexible( - flex: 7, - child: ListView( - shrinkWrap: true, - physics: const ClampingScrollPhysics(), - children: [ - if (resultSummaries.isNotEmpty) _buildSummariesSection(), - const VSpace(10), - if (resultItems.isNotEmpty) _buildResultsSection(context), - ], - ), - ), - const HSpace(10), - Flexible( - flex: 3, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 16, + child: BlocListener( + listener: (context, state) { + if (state.openPageId != null) { + FlowyOverlay.pop(context); + getIt().add( + ActionNavigationEvent.performAction( + action: NavigationAction(objectId: state.openPageId!), + ), + ); + } + }, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + flex: 7, + child: ListView( + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + children: [ + if (resultSummaries.isNotEmpty) _buildSummariesSection(), + const VSpace(10), + if (resultItems.isNotEmpty) _buildResultsSection(context), + ], ), - child: const SearchCellPreview(), ), - ), - ], + const HSpace(10), + if (resultItems.any((item) => item.content.isNotEmpty)) + Flexible( + flex: 3, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 16, + ), + child: const SearchCellPreview(), + ), + ), + ], + ), ), ), ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart index c911c46735..15e8bb18f7 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart @@ -36,7 +36,7 @@ class SearchSummaryCell extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: FlowyText( summary.content, - maxLines: 3, + maxLines: 20, ), ), ); @@ -78,14 +78,19 @@ class SearchSummarySource extends StatelessWidget { @override Widget build(BuildContext context) { final icon = source.icon.getIcon(); - return Row( - children: [ - if (icon != null) ...[ - SizedBox(width: 24, child: icon), - const HSpace(6), - ], - FlowyText(source.displayName), - ], + return SizedBox( + height: 30, + child: FlowyButton( + leftIcon: icon, + hoverColor: + Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + text: FlowyText(source.displayName), + onTap: () { + context.read().add( + SearchResultListEvent.openPage(pageId: source.id), + ); + }, + ), ); } } diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 4279f95425..21c39c63f5 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -2694,8 +2694,8 @@ "placeholder": "Search or ask a question...", "bestMatches": "Best matches", "aiOverview": "AI overview", - "aiOverviewSource": "Sources", - "pagePreview": "Preview", + "aiOverviewSource": "Reference sources", + "pagePreview": "Content preview", "recentHistory": "Recent history", "navigateHint": "to navigate", "loadingTooltip": "We are looking for results...", diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs index 2fc6fad2cc..172204746a 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs @@ -71,10 +71,10 @@ impl LocalAIResourceController { ) -> Self { let (resource_notify, _) = tokio::sync::broadcast::channel(1); let (app_state_sender, _) = tokio::sync::broadcast::channel(1); - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] let mut offline_app_disk_watch: Option = None; - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] { match watch_offline_app() { Ok((new_watcher, mut rx)) => { diff --git a/frontend/rust-lib/flowy-search/src/document/handler.rs b/frontend/rust-lib/flowy-search/src/document/handler.rs index 9077829c07..fc68f850e5 100644 --- a/frontend/rust-lib/flowy-search/src/document/handler.rs +++ b/frontend/rust-lib/flowy-search/src/document/handler.rs @@ -1,9 +1,9 @@ use crate::entities::{ - CreateSearchResultPBArgs, RepeatedSearchResponseItemPB, RepeatedSearchSummaryPB, SearchResultPB, - SearchSourcePB, SearchSummaryPB, + CreateSearchResultPBArgs, RepeatedSearchResponseItemPB, RepeatedSearchSummaryPB, + SearchResponsePB, SearchSourcePB, SearchSummaryPB, }; use crate::{ - entities::{IndexTypePB, ResultIconPB, ResultIconTypePB, SearchFilterPB, SearchResponseItemPB}, + entities::{ResultIconPB, ResultIconTypePB, SearchFilterPB, SearchResponseItemPB}, services::manager::{SearchHandler, SearchType}, }; use async_stream::stream; @@ -45,7 +45,7 @@ impl SearchHandler for DocumentSearchHandler { &self, query: String, filter: Option, - ) -> Pin> + Send + 'static>> { + ) -> Pin> + Send + 'static>> { let cloud_service = self.cloud_service.clone(); let folder_manager = self.folder_manager.clone(); @@ -99,13 +99,10 @@ impl SearchHandler for DocumentSearchHandler { for item in &result_items { if let Some(view) = views.iter().find(|v| v.id == item.object_id.to_string()) { items.push(SearchResponseItemPB { - index_type: IndexTypePB::Document, id: item.object_id.to_string(), display_name: view.name.clone(), icon: extract_icon(view), - score: item.score, workspace_id: item.workspace_id.to_string(), - preview: item.preview.clone(), content: item.content.clone()} ); } else { @@ -133,15 +130,11 @@ impl SearchHandler for DocumentSearchHandler { let sources: Vec = v.sources .iter() .flat_map(|id| { - if let Some(view) = views.iter().find(|v| v.id == id.to_string()) { - Some(SearchSourcePB { + views.iter().find(|v| v.id == id.to_string()).map(|view| SearchSourcePB { id: id.to_string(), display_name: view.name.clone(), icon: extract_icon(view), }) - } else { - None - } }) .collect(); diff --git a/frontend/rust-lib/flowy-search/src/entities/index_type.rs b/frontend/rust-lib/flowy-search/src/entities/index_type.rs deleted file mode 100644 index 77adc76a97..0000000000 --- a/frontend/rust-lib/flowy-search/src/entities/index_type.rs +++ /dev/null @@ -1,31 +0,0 @@ -use flowy_derive::ProtoBuf_Enum; - -#[derive(ProtoBuf_Enum, Eq, PartialEq, Debug, Clone)] -pub enum IndexTypePB { - View = 0, - Document = 1, - DocumentBlock = 2, - DatabaseRow = 3, -} - -impl Default for IndexTypePB { - fn default() -> Self { - Self::View - } -} - -impl std::convert::From for i32 { - fn from(notification: IndexTypePB) -> Self { - notification as i32 - } -} - -impl std::convert::From for IndexTypePB { - fn from(notification: i32) -> Self { - match notification { - 1 => IndexTypePB::View, - 2 => IndexTypePB::DocumentBlock, - _ => IndexTypePB::DatabaseRow, - } - } -} diff --git a/frontend/rust-lib/flowy-search/src/entities/mod.rs b/frontend/rust-lib/flowy-search/src/entities/mod.rs index b4d7c682b9..dc6aaace08 100644 --- a/frontend/rust-lib/flowy-search/src/entities/mod.rs +++ b/frontend/rust-lib/flowy-search/src/entities/mod.rs @@ -1,10 +1,8 @@ -mod index_type; mod notification; mod query; mod result; mod search_filter; -pub use index_type::*; pub use notification::*; pub use query::*; pub use result::*; diff --git a/frontend/rust-lib/flowy-search/src/entities/notification.rs b/frontend/rust-lib/flowy-search/src/entities/notification.rs index 0a51eb85d3..3f1cdab67a 100644 --- a/frontend/rust-lib/flowy-search/src/entities/notification.rs +++ b/frontend/rust-lib/flowy-search/src/entities/notification.rs @@ -1,10 +1,10 @@ -use super::SearchResultPB; +use super::SearchResponsePB; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; #[derive(ProtoBuf, Default, Debug, Clone)] -pub struct SearchResponsePB { +pub struct SearchStatePB { #[pb(index = 1, one_of)] - pub result: Option, + pub response: Option, #[pb(index = 2)] pub search_id: String, diff --git a/frontend/rust-lib/flowy-search/src/entities/result.rs b/frontend/rust-lib/flowy-search/src/entities/result.rs index 28f4c0111f..8f5ba11ded 100644 --- a/frontend/rust-lib/flowy-search/src/entities/result.rs +++ b/frontend/rust-lib/flowy-search/src/entities/result.rs @@ -1,4 +1,3 @@ -use super::IndexTypePB; use collab_folder::{IconType, ViewIcon}; use derive_builder::Builder; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; @@ -7,7 +6,7 @@ use flowy_folder::entities::ViewIconPB; #[derive(Debug, Default, ProtoBuf, Builder, Clone)] #[builder(name = "CreateSearchResultPBArgs")] #[builder(pattern = "mutable")] -pub struct SearchResultPB { +pub struct SearchResponsePB { #[pb(index = 1, one_of)] #[builder(default)] pub search_result: Option, @@ -15,6 +14,10 @@ pub struct SearchResultPB { #[pb(index = 2, one_of)] #[builder(default)] pub search_summary: Option, + + #[pb(index = 3, one_of)] + #[builder(default)] + pub local_search_result: Option, } #[derive(ProtoBuf, Default, Debug, Clone)] @@ -53,43 +56,40 @@ pub struct RepeatedSearchResponseItemPB { #[derive(ProtoBuf, Default, Debug, Clone)] pub struct SearchResponseItemPB { #[pb(index = 1)] - pub index_type: IndexTypePB, - - #[pb(index = 2)] pub id: String, - #[pb(index = 3)] + #[pb(index = 2)] pub display_name: String, - #[pb(index = 4, one_of)] + #[pb(index = 3, one_of)] pub icon: Option, - #[pb(index = 5)] - pub score: f64, - - #[pb(index = 6)] + #[pb(index = 4)] pub workspace_id: String, - #[pb(index = 7, one_of)] - pub preview: Option, - - #[pb(index = 8)] + #[pb(index = 5)] pub content: String, } -impl SearchResponseItemPB { - pub fn with_score(&self, score: f64) -> Self { - SearchResponseItemPB { - index_type: self.index_type.clone(), - id: self.id.clone(), - display_name: self.display_name.clone(), - icon: self.icon.clone(), - score, - workspace_id: self.workspace_id.clone(), - preview: self.preview.clone(), - content: self.content.clone(), - } - } +#[derive(ProtoBuf, Default, Debug, Clone)] +pub struct RepeatedLocalSearchResponseItemPB { + #[pb(index = 1)] + pub items: Vec, +} + +#[derive(ProtoBuf, Default, Debug, Clone)] +pub struct LocalSearchResponseItemPB { + #[pb(index = 1)] + pub id: String, + + #[pb(index = 2)] + pub display_name: String, + + #[pb(index = 3, one_of)] + pub icon: Option, + + #[pb(index = 4)] + pub workspace_id: String, } #[derive(ProtoBuf_Enum, Clone, Debug, PartialEq, Eq, Default)] diff --git a/frontend/rust-lib/flowy-search/src/folder/entities.rs b/frontend/rust-lib/flowy-search/src/folder/entities.rs index 1e9fb1f0d9..1bb763b4a6 100644 --- a/frontend/rust-lib/flowy-search/src/folder/entities.rs +++ b/frontend/rust-lib/flowy-search/src/folder/entities.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::entities::{IndexTypePB, ResultIconPB, SearchResponseItemPB}; +use crate::entities::{LocalSearchResponseItemPB, ResultIconPB}; #[derive(Debug, Serialize, Deserialize)] pub struct FolderIndexData { @@ -11,7 +11,7 @@ pub struct FolderIndexData { pub workspace_id: String, } -impl From for SearchResponseItemPB { +impl From for LocalSearchResponseItemPB { fn from(data: FolderIndexData) -> Self { let icon = if data.icon.is_empty() { None @@ -23,14 +23,10 @@ impl From for SearchResponseItemPB { }; Self { - index_type: IndexTypePB::View, id: data.id, display_name: data.title, - score: 0.0, icon, workspace_id: data.workspace_id, - preview: None, - content: "".to_string(), } } } diff --git a/frontend/rust-lib/flowy-search/src/folder/handler.rs b/frontend/rust-lib/flowy-search/src/folder/handler.rs index 975609a227..e21ce1c98c 100644 --- a/frontend/rust-lib/flowy-search/src/folder/handler.rs +++ b/frontend/rust-lib/flowy-search/src/folder/handler.rs @@ -1,6 +1,6 @@ use super::indexer::FolderIndexManagerImpl; use crate::entities::{ - CreateSearchResultPBArgs, RepeatedSearchResponseItemPB, SearchFilterPB, SearchResultPB, + CreateSearchResultPBArgs, RepeatedLocalSearchResponseItemPB, SearchFilterPB, SearchResponsePB, }; use crate::services::manager::{SearchHandler, SearchType}; use async_stream::stream; @@ -30,7 +30,7 @@ impl SearchHandler for FolderSearchHandler { &self, query: String, filter: Option, - ) -> Pin> + Send + 'static>> { + ) -> Pin> + Send + 'static>> { let index_manager = self.index_manager.clone(); Box::pin(stream! { @@ -48,8 +48,8 @@ impl SearchHandler for FolderSearchHandler { } // Build the search result. - let search_result = RepeatedSearchResponseItemPB {items}; - yield Ok(CreateSearchResultPBArgs::default().search_result(Some(search_result)).build().unwrap()) + let search_result = RepeatedLocalSearchResponseItemPB {items}; + yield Ok(CreateSearchResultPBArgs::default().local_search_result(Some(search_result)).build().unwrap()) }) } } diff --git a/frontend/rust-lib/flowy-search/src/folder/indexer.rs b/frontend/rust-lib/flowy-search/src/folder/indexer.rs index 9875c02465..641c1f5f96 100644 --- a/frontend/rust-lib/flowy-search/src/folder/indexer.rs +++ b/frontend/rust-lib/flowy-search/src/folder/indexer.rs @@ -1,9 +1,6 @@ -use crate::{ - entities::SearchResponseItemPB, - folder::schema::{ - FolderSchema, FOLDER_ICON_FIELD_NAME, FOLDER_ICON_TY_FIELD_NAME, FOLDER_ID_FIELD_NAME, - FOLDER_TITLE_FIELD_NAME, FOLDER_WORKSPACE_ID_FIELD_NAME, - }, +use crate::folder::schema::{ + FolderSchema, FOLDER_ICON_FIELD_NAME, FOLDER_ICON_TY_FIELD_NAME, FOLDER_ID_FIELD_NAME, + FOLDER_TITLE_FIELD_NAME, FOLDER_WORKSPACE_ID_FIELD_NAME, }; use collab::core::collab::{IndexContent, IndexContentReceiver}; use collab_folder::{folder_diff::FolderViewChange, View, ViewIcon, ViewIndexContent, ViewLayout}; @@ -14,9 +11,8 @@ use std::sync::{Arc, Weak}; use std::{collections::HashMap, fs}; use super::entities::FolderIndexData; -use crate::entities::ResultIconTypePB; +use crate::entities::{LocalSearchResponseItemPB, ResultIconTypePB}; use lib_infra::async_trait::async_trait; -use strsim::levenshtein; use tantivy::{ collector::TopDocs, directory::MmapDirectory, doc, query::QueryParser, schema::Field, Document, Index, IndexReader, IndexWriter, TantivyDocument, Term, @@ -113,11 +109,6 @@ impl FolderIndexManagerImpl { (icon, icon_ty) } - fn score_result(&self, query: &str, term: &str) -> f64 { - let distance = levenshtein(query, term) as f64; - 1.0 / (distance + 1.0) - } - /// Simple implementation to index all given data by spawning async tasks. fn index_all(&self, data_vec: Vec) -> Result<(), FlowyError> { for data in data_vec { @@ -130,7 +121,7 @@ impl FolderIndexManagerImpl { } /// Searches the index using the given query string. - pub async fn search(&self, query: String) -> Result, FlowyError> { + pub async fn search(&self, query: String) -> Result, FlowyError> { let lock = self.state.read().await; let state = lock .as_ref() @@ -157,8 +148,8 @@ impl FolderIndexManagerImpl { } if !content.is_empty() { let s = serde_json::to_string(&content)?; - let result: SearchResponseItemPB = serde_json::from_str::(&s)?.into(); - results.push(result.with_score(self.score_result(&query, &result.display_name))); + let result: LocalSearchResponseItemPB = serde_json::from_str::(&s)?.into(); + results.push(result); } } @@ -200,11 +191,11 @@ impl IndexManager for FolderIndexManagerImpl { }) .await; }, - Err(err) => tracing::error!("FolderIndexManager error deserialize (update): {:?}", err), + Err(err) => error!("FolderIndexManager error deserialize (update): {:?}", err), }, IndexContent::Delete(ids) => { if let Err(e) = indexer.remove_indices(ids).await { - tracing::error!("FolderIndexManager error (delete): {:?}", e); + error!("FolderIndexManager error (delete): {:?}", e); } }, } diff --git a/frontend/rust-lib/flowy-search/src/services/manager.rs b/frontend/rust-lib/flowy-search/src/services/manager.rs index 157781eceb..72a3c12793 100644 --- a/frontend/rust-lib/flowy-search/src/services/manager.rs +++ b/frontend/rust-lib/flowy-search/src/services/manager.rs @@ -1,4 +1,4 @@ -use crate::entities::{SearchFilterPB, SearchResponsePB, SearchResultPB}; +use crate::entities::{SearchFilterPB, SearchResponsePB, SearchStatePB}; use allo_isolate::Isolate; use flowy_error::FlowyResult; use lib_infra::async_trait::async_trait; @@ -25,7 +25,7 @@ pub trait SearchHandler: Send + Sync + 'static { &self, query: String, filter: Option, - ) -> Pin> + Send + 'static>>; + ) -> Pin> + Send + 'static>>; } /// The [SearchManager] is used to inject multiple [SearchHandler]'s @@ -34,7 +34,7 @@ pub trait SearchHandler: Send + Sync + 'static { /// pub struct SearchManager { pub handlers: HashMap>, - current_search: Arc>>, // Track current search + current_search: Arc>>, } impl SearchManager { @@ -84,23 +84,21 @@ impl SearchManager { } let mut stream = handler.perform_search(query.clone(), filter).await; - while let Some(result) = stream.next().await { + while let Some(Ok(search_result)) = stream.next().await { if !is_current_search(¤t_search, &search_id).await { trace!("[Search] discard search stream: {}", query); return; } - if let Ok(result) = result { - let resp = SearchResponsePB { - result: Some(result), - search_id: search_id.clone(), - is_loading: true, - }; - if let Ok::, _>(data) = resp.try_into() { - if let Err(err) = clone_sink.send(data).await { - error!("Failed to send search result: {}", err); - break; - } + let resp = SearchStatePB { + response: Some(search_result), + search_id: search_id.clone(), + is_loading: true, + }; + if let Ok::, _>(data) = resp.try_into() { + if let Err(err) = clone_sink.send(data).await { + error!("Failed to send search result: {}", err); + break; } } } @@ -110,8 +108,8 @@ impl SearchManager { return; } - let resp = SearchResponsePB { - result: None, + let resp = SearchStatePB { + response: None, search_id: search_id.clone(), is_loading: true, };