chore: local and server result

This commit is contained in:
Nathan 2025-04-14 22:05:21 +08:00
parent a44ad63230
commit 35bc095760
21 changed files with 346 additions and 314 deletions

View File

@ -1,5 +1,5 @@
import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart'; 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:appflowy/workspace/presentation/command_palette/widgets/recent_views_list.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart'; import 'package:integration_test/integration_test.dart';
@ -27,11 +27,12 @@ void main() {
expect(find.byType(RecentViewsList), findsOneWidget); expect(find.byType(RecentViewsList), findsOneWidget);
// Expect three recent history items // 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 // Expect the first item to be the last viewed document
final firstDocumentWidget = final firstDocumentWidget =
tester.widget(find.byType(RecentViewTile).first) as RecentViewTile; tester.widget(find.byType(SearchRecentViewCell).first)
as SearchRecentViewCell;
expect(firstDocumentWidget.view.name, secondDocument); expect(firstDocumentWidget.view.name, secondDocument);
}); });
}); });

View File

@ -11,10 +11,25 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'command_palette_bloc.freezed.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 class CommandPaletteBloc
extends Bloc<CommandPaletteEvent, CommandPaletteState> { extends Bloc<CommandPaletteEvent, CommandPaletteState> {
CommandPaletteBloc() : super(CommandPaletteState.initial()) { CommandPaletteBloc() : super(CommandPaletteState.initial()) {
// Register event handlers
on<_SearchChanged>(_onSearchChanged); on<_SearchChanged>(_onSearchChanged);
on<_PerformSearch>(_onPerformSearch); on<_PerformSearch>(_onPerformSearch);
on<_NewSearchStream>(_onNewSearchStream); on<_NewSearchStream>(_onNewSearchStream);
@ -26,38 +41,35 @@ class CommandPaletteBloc
_initTrash(); _initTrash();
} }
Timer? _debounceOnChanged; final Debouncer _searchDebouncer = Debouncer(
delay: const Duration(milliseconds: 300),
);
final TrashService _trashService = TrashService(); final TrashService _trashService = TrashService();
final TrashListener _trashListener = TrashListener(); final TrashListener _trashListener = TrashListener();
String? _oldQuery; String? _activeQuery;
String? _workspaceId; String? _workspaceId;
@override @override
Future<void> close() { Future<void> close() {
_trashListener.close(); _trashListener.close();
_debounceOnChanged?.cancel(); _searchDebouncer.dispose();
state.searchResponseStream?.dispose(); state.searchResponseStream?.dispose();
return super.close(); return super.close();
} }
Future<void> _initTrash() async { Future<void> _initTrash() async {
// Start listening for trash updates
_trashListener.start( _trashListener.start(
trashUpdated: (trashOrFailed) { trashUpdated: (trashOrFailed) => add(
add(
CommandPaletteEvent.trashChanged( CommandPaletteEvent.trashChanged(
trash: trashOrFailed.toNullable(), trash: trashOrFailed.toNullable(),
), ),
); ),
},
); );
// Read initial trash state and forward results
final trashOrFailure = await _trashService.readTrash(); final trashOrFailure = await _trashService.readTrash();
add( trashOrFailure.fold(
CommandPaletteEvent.trashChanged( (trash) => add(CommandPaletteEvent.trashChanged(trash: trash.items)),
trash: trashOrFailure.toNullable()?.items, (error) => debugPrint('Failed to load trash: $error'),
),
); );
} }
@ -65,9 +77,7 @@ class CommandPaletteBloc
_SearchChanged event, _SearchChanged event,
Emitter<CommandPaletteState> emit, Emitter<CommandPaletteState> emit,
) { ) {
_debounceOnChanged?.cancel(); _searchDebouncer.run(
_debounceOnChanged = Timer(
const Duration(milliseconds: 300),
() { () {
if (!isClosed) { if (!isClosed) {
add(CommandPaletteEvent.performSearch(search: event.search)); add(CommandPaletteEvent.performSearch(search: event.search));
@ -80,31 +90,44 @@ class CommandPaletteBloc
_PerformSearch event, _PerformSearch event,
Emitter<CommandPaletteState> emit, Emitter<CommandPaletteState> emit,
) async { ) async {
if (event.search.isNotEmpty && event.search != state.query) { if (event.search.isEmpty && event.search != state.query) {
_oldQuery = state.query; emit(
state.copyWith(
query: null,
isLoading: false,
serverResponseItems: [],
localResponseItems: [],
combinedResponseItems: {},
resultSummaries: [],
),
);
} else {
emit(state.copyWith(query: event.search, isLoading: true)); emit(state.copyWith(query: event.search, isLoading: true));
_activeQuery = event.search;
// Fire off search asynchronously (fire and forget)
unawaited( unawaited(
SearchBackendService.performSearch( SearchBackendService.performSearch(
event.search, event.search,
workspaceId: _workspaceId, workspaceId: _workspaceId,
).then( ).then(
(result) => result.onSuccess((stream) { (result) => result.fold(
if (!isClosed) { (stream) {
if (!isClosed && _activeQuery == event.search) {
add(CommandPaletteEvent.newSearchStream(stream: stream)); add(CommandPaletteEvent.newSearchStream(stream: stream));
} }
}), },
(error) {
debugPrint('Search error: $error');
if (!isClosed) {
add(
CommandPaletteEvent.resultsChanged(
searchId: '',
isLoading: false,
), ),
); );
} else { }
// Clear state if search is empty or unchanged },
emit( ),
state.copyWith(
query: null,
isLoading: false,
resultItems: [],
resultSummaries: [],
), ),
); );
} }
@ -123,83 +146,88 @@ class CommandPaletteBloc
); );
event.stream.listen( event.stream.listen(
onItems: ( onLocalItems: (items, searchId) => _handleResultsUpdate(
List<SearchResponseItemPB> items,
String searchId,
bool isLoading,
) {
if (_isActiveSearch(searchId)) {
add(
CommandPaletteEvent.resultsChanged(
items: items,
searchId: searchId, searchId: searchId,
localItems: items,
),
onServerItems: (items, searchId, isLoading) => _handleResultsUpdate(
searchId: searchId,
serverItems: items,
isLoading: isLoading, isLoading: isLoading,
), ),
); onSummaries: (summaries, searchId, isLoading) => _handleResultsUpdate(
} searchId: searchId,
},
onSummaries: (
List<SearchSummaryPB> summaries,
String searchId,
bool isLoading,
) {
if (_isActiveSearch(searchId)) {
add(
CommandPaletteEvent.resultsChanged(
summaries: summaries, summaries: summaries,
searchId: searchId,
isLoading: isLoading, isLoading: isLoading,
), ),
); onFinished: (searchId) => _handleResultsUpdate(
}
},
onFinished: (String searchId) {
if (_isActiveSearch(searchId)) {
add(
CommandPaletteEvent.resultsChanged(
searchId: searchId, searchId: searchId,
isLoading: false, isLoading: false,
), ),
); );
} }
},
void _handleResultsUpdate({
required String searchId,
List<SearchResponseItemPB>? serverItems,
List<LocalSearchResponseItemPB>? localItems,
List<SearchSummaryPB>? summaries,
bool isLoading = true,
}) {
if (_isActiveSearch(searchId)) {
add(
CommandPaletteEvent.resultsChanged(
searchId: searchId,
serverItems: serverItems,
localItems: localItems,
summaries: summaries,
isLoading: isLoading,
),
); );
} }
}
FutureOr<void> _onResultsChanged( FutureOr<void> _onResultsChanged(
_ResultsChanged event, _ResultsChanged event,
Emitter<CommandPaletteState> emit, Emitter<CommandPaletteState> emit,
) async { ) 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; if (state.searchId != event.searchId) return;
final updatedItems = final combinedItems = <String, SearchResultItem>{};
event.items ?? List<SearchResponseItemPB>.from(state.resultItems); for (final item in event.serverItems ?? state.serverResponseItems) {
final updatedSummaries = combinedItems[item.id] = SearchResultItem(
event.summaries ?? List<SearchSummaryPB>.from(state.resultSummaries); 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( emit(
state.copyWith( state.copyWith(
resultItems: updatedItems, serverResponseItems: event.serverItems ?? state.serverResponseItems,
resultSummaries: updatedSummaries, localResponseItems: event.localItems ?? state.localResponseItems,
resultSummaries: event.summaries ?? state.resultSummaries,
combinedResponseItems: combinedItems,
isLoading: event.isLoading, isLoading: event.isLoading,
), ),
); );
} }
// Update trash state and, in case of null, retry reading trash from the service
FutureOr<void> _onTrashChanged( FutureOr<void> _onTrashChanged(
_TrashChanged event, _TrashChanged event,
Emitter<CommandPaletteState> emit, Emitter<CommandPaletteState> emit,
@ -216,7 +244,6 @@ class CommandPaletteBloc
} }
} }
// Update the workspace and clear current search results and query
FutureOr<void> _onWorkspaceChanged( FutureOr<void> _onWorkspaceChanged(
_WorkspaceChanged event, _WorkspaceChanged event,
Emitter<CommandPaletteState> emit, Emitter<CommandPaletteState> emit,
@ -225,27 +252,20 @@ class CommandPaletteBloc
emit( emit(
state.copyWith( state.copyWith(
query: '', query: '',
resultItems: [], serverResponseItems: [],
localResponseItems: [],
combinedResponseItems: {},
resultSummaries: [], resultSummaries: [],
isLoading: false, isLoading: false,
), ),
); );
} }
// Clear search state
FutureOr<void> _onClearSearch( FutureOr<void> _onClearSearch(
_ClearSearch event, _ClearSearch event,
Emitter<CommandPaletteState> emit, Emitter<CommandPaletteState> emit,
) { ) {
emit( emit(CommandPaletteState.initial().copyWith(trash: state.trash));
state.copyWith(
query: '',
resultItems: [],
resultSummaries: [],
isLoading: false,
searchId: null,
),
);
} }
bool _isActiveSearch(String searchId) => bool _isActiveSearch(String searchId) =>
@ -264,7 +284,8 @@ class CommandPaletteEvent with _$CommandPaletteEvent {
const factory CommandPaletteEvent.resultsChanged({ const factory CommandPaletteEvent.resultsChanged({
required String searchId, required String searchId,
required bool isLoading, required bool isLoading,
List<SearchResponseItemPB>? items, List<SearchResponseItemPB>? serverItems,
List<LocalSearchResponseItemPB>? localItems,
List<SearchSummaryPB>? summaries, List<SearchSummaryPB>? summaries,
}) = _ResultsChanged; }) = _ResultsChanged;
@ -277,12 +298,30 @@ class CommandPaletteEvent with _$CommandPaletteEvent {
const factory CommandPaletteEvent.clearSearch() = _ClearSearch; 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 @freezed
class CommandPaletteState with _$CommandPaletteState { class CommandPaletteState with _$CommandPaletteState {
const CommandPaletteState._(); const CommandPaletteState._();
const factory CommandPaletteState({ const factory CommandPaletteState({
@Default(null) String? query, @Default(null) String? query,
@Default([]) List<SearchResponseItemPB> resultItems, @Default([]) List<SearchResponseItemPB> serverResponseItems,
@Default([]) List<LocalSearchResponseItemPB> localResponseItems,
@Default({}) Map<String, SearchResultItem> combinedResponseItems,
@Default([]) List<SearchSummaryPB> resultSummaries, @Default([]) List<SearchSummaryPB> resultSummaries,
@Default(null) SearchResponseStream? searchResponseStream, @Default(null) SearchResponseStream? searchResponseStream,
required bool isLoading, required bool isLoading,
@ -290,6 +329,7 @@ class CommandPaletteState with _$CommandPaletteState {
@Default(null) String? searchId, @Default(null) String? searchId,
}) = _CommandPaletteState; }) = _CommandPaletteState;
factory CommandPaletteState.initial() => factory CommandPaletteState.initial() => const CommandPaletteState(
const CommandPaletteState(isLoading: false); isLoading: false,
);
} }

View File

@ -1,5 +1,6 @@
import 'dart:async'; 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:appflowy_backend/protobuf/flowy-search/result.pb.dart';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -13,6 +14,7 @@ class SearchResultListBloc
// Register event handlers // Register event handlers
on<_OnHoverSummary>(_onHoverSummary); on<_OnHoverSummary>(_onHoverSummary);
on<_OnHoverResult>(_onHoverResult); on<_OnHoverResult>(_onHoverResult);
on<_OpenPage>(_onOpenPage);
} }
FutureOr<void> _onHoverSummary( FutureOr<void> _onHoverSummary(
@ -23,6 +25,7 @@ class SearchResultListBloc
state.copyWith( state.copyWith(
hoveredSummary: event.summary, hoveredSummary: event.summary,
hoveredResult: null, hoveredResult: null,
openPageId: null,
), ),
); );
} }
@ -35,9 +38,17 @@ class SearchResultListBloc
state.copyWith( state.copyWith(
hoveredSummary: null, hoveredSummary: null,
hoveredResult: event.item, hoveredResult: event.item,
openPageId: null,
), ),
); );
} }
FutureOr<void> _onOpenPage(
_OpenPage event,
Emitter<SearchResultListState> emit,
) {
emit(state.copyWith(openPageId: event.pageId));
}
} }
@freezed @freezed
@ -46,8 +57,12 @@ class SearchResultListEvent with _$SearchResultListEvent {
required SearchSummaryPB summary, required SearchSummaryPB summary,
}) = _OnHoverSummary; }) = _OnHoverSummary;
const factory SearchResultListEvent.onHoverResult({ const factory SearchResultListEvent.onHoverResult({
required SearchResponseItemPB item, required SearchResultItem item,
}) = _OnHoverResult; }) = _OnHoverResult;
const factory SearchResultListEvent.openPage({
required String pageId,
}) = _OpenPage;
} }
@freezed @freezed
@ -55,7 +70,8 @@ class SearchResultListState with _$SearchResultListState {
const SearchResultListState._(); const SearchResultListState._();
const factory SearchResultListState({ const factory SearchResultListState({
@Default(null) SearchSummaryPB? hoveredSummary, @Default(null) SearchSummaryPB? hoveredSummary,
@Default(null) SearchResponseItemPB? hoveredResult, @Default(null) SearchResultItem? hoveredResult,
@Default(null) String? openPageId,
}) = _SearchResultListState; }) = _SearchResultListState;
factory SearchResultListState.initial() => const SearchResultListState(); factory SearchResultListState.initial() => const SearchResultListState();

View File

@ -50,12 +50,18 @@ class SearchResponseStream {
List<SearchResponseItemPB> items, List<SearchResponseItemPB> items,
String searchId, String searchId,
bool isLoading, bool isLoading,
)? _onItems; )? _onServerItems;
void Function( void Function(
List<SearchSummaryPB> summaries, List<SearchSummaryPB> summaries,
String searchId, String searchId,
bool isLoading, bool isLoading,
)? _onSummaries; )? _onSummaries;
void Function(
List<LocalSearchResponseItemPB> items,
String searchId,
)? _onLocalItems;
void Function(String searchId)? _onFinished; void Function(String searchId)? _onFinished;
int get nativePort => _port.sendPort.nativePort; int get nativePort => _port.sendPort.nativePort;
@ -65,21 +71,28 @@ class SearchResponseStream {
} }
void _onResultsChanged(Uint8List data) { void _onResultsChanged(Uint8List data) {
final response = SearchResponsePB.fromBuffer(data); final searchState = SearchStatePB.fromBuffer(data);
if (response.hasResult()) { if (searchState.hasResponse()) {
if (response.result.hasSearchResult()) { if (searchState.response.hasSearchResult()) {
_onItems?.call( _onServerItems?.call(
response.result.searchResult.items, searchState.response.searchResult.items,
searchId, searchId,
response.isLoading, searchState.isLoading,
); );
} }
if (response.result.hasSearchSummary()) { if (searchState.response.hasSearchSummary()) {
_onSummaries?.call( _onSummaries?.call(
response.result.searchSummary.items, searchState.response.searchSummary.items,
searchId,
searchState.isLoading,
);
}
if (searchState.response.hasLocalSearchResult()) {
_onLocalItems?.call(
searchState.response.localSearchResult.items,
searchId, searchId,
response.isLoading,
); );
} }
} else { } else {
@ -92,16 +105,21 @@ class SearchResponseStream {
List<SearchResponseItemPB> items, List<SearchResponseItemPB> items,
String searchId, String searchId,
bool isLoading, bool isLoading,
)? onItems, )? onServerItems,
required void Function( required void Function(
List<SearchSummaryPB> summaries, List<SearchSummaryPB> summaries,
String searchId, String searchId,
bool isLoading, bool isLoading,
)? onSummaries, )? onSummaries,
required void Function(
List<LocalSearchResponseItemPB> items,
String searchId,
)? onLocalItems,
required void Function(String searchId)? onFinished, required void Function(String searchId)? onFinished,
}) { }) {
_onItems = onItems; _onServerItems = onServerItems;
_onSummaries = onSummaries; _onSummaries = onSummaries;
_onLocalItems = onLocalItems;
_onFinished = onFinished; _onFinished = onFinished;
} }
} }

View File

@ -153,13 +153,13 @@ class CommandPaletteModal extends StatelessWidget {
), ),
), ),
], ],
if (state.resultItems.isNotEmpty && if (state.combinedResponseItems.isNotEmpty &&
(state.query?.isNotEmpty ?? false)) ...[ (state.query?.isNotEmpty ?? false)) ...[
const Divider(height: 0), const Divider(height: 0),
Flexible( Flexible(
child: SearchResultList( child: SearchResultList(
trash: state.trash, trash: state.trash,
resultItems: state.resultItems, resultItems: state.combinedResponseItems.values.toList(),
resultSummaries: state.resultSummaries, resultSummaries: state.resultSummaries,
), ),
), ),

View File

@ -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/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart'; import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.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:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.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)); : FlowySvg(view.iconData, size: const Size.square(20));
return RecentViewTile( return SearchRecentViewCell(
icon: SizedBox(width: 24, child: icon), icon: SizedBox(width: 24, child: icon),
view: view, view: view,
onSelected: onSelected, onSelected: onSelected,

View File

@ -7,8 +7,8 @@ import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class RecentViewTile extends StatelessWidget { class SearchRecentViewCell extends StatelessWidget {
const RecentViewTile({ const SearchRecentViewCell({
super.key, super.key,
required this.icon, required this.icon,
required this.view, required this.view,

View File

@ -1,11 +1,8 @@
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart';
import 'package:appflowy/workspace/application/command_palette/search_result_ext.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/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:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart';
@ -19,12 +16,10 @@ class SearchResultCell extends StatefulWidget {
const SearchResultCell({ const SearchResultCell({
super.key, super.key,
required this.item, required this.item,
required this.onSelected,
this.isTrashed = false, this.isTrashed = false,
}); });
final SearchResponseItemPB item; final SearchResultItem item;
final VoidCallback onSelected;
final bool isTrashed; final bool isTrashed;
@override @override
@ -43,11 +38,8 @@ class _SearchResultCellState extends State<SearchResultCell> {
/// Helper to handle the selection action. /// Helper to handle the selection action.
void _handleSelection() { void _handleSelection() {
widget.onSelected(); context.read<SearchResultListBloc>().add(
getIt<ActionNavigationBloc>().add( SearchResultListEvent.openPage(pageId: widget.item.id),
ActionNavigationEvent.performAction(
action: NavigationAction(objectId: widget.item.id),
),
); );
} }
@ -62,7 +54,7 @@ class _SearchResultCellState extends State<SearchResultCell> {
LocaleKeys.menuAppHeader_defaultNewPageName.tr(), LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
); );
final icon = widget.item.icon.getIcon(); final icon = widget.item.icon.getIcon();
final cleanedPreview = _cleanPreview(widget.item.preview); final cleanedPreview = _cleanPreview(widget.item.content);
final hasPreview = cleanedPreview.isNotEmpty; final hasPreview = cleanedPreview.isNotEmpty;
final trashHintText = final trashHintText =
widget.isTrashed ? LocaleKeys.commandPalette_fromTrashHint.tr() : null; widget.isTrashed ? LocaleKeys.commandPalette_fromTrashHint.tr() : null;
@ -208,7 +200,7 @@ class SearchResultPreview extends StatelessWidget {
required this.data, required this.data,
}); });
final SearchResponseItemPB data; final SearchResultItem data;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -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:appflowy/workspace/application/command_palette/search_result_list_bloc.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -20,9 +24,8 @@ class SearchResultList extends StatelessWidget {
}); });
final List<TrashPB> trash; final List<TrashPB> trash;
final List<SearchResponseItemPB> resultItems; final List<SearchResultItem> resultItems;
final List<SearchSummaryPB> resultSummaries; final List<SearchSummaryPB> resultSummaries;
Widget _buildSectionHeader(String title) => Padding( Widget _buildSectionHeader(String title) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8) + padding: const EdgeInsets.symmetric(vertical: 8) +
const EdgeInsets.only(left: 8), const EdgeInsets.only(left: 8),
@ -65,7 +68,6 @@ class SearchResultList extends StatelessWidget {
final item = resultItems[index]; final item = resultItems[index];
return SearchResultCell( return SearchResultCell(
item: item, item: item,
onSelected: () => FlowyOverlay.pop(context),
isTrashed: trash.any((t) => t.id == item.id), isTrashed: trash.any((t) => t.id == item.id),
); );
}, },
@ -80,6 +82,17 @@ class SearchResultList extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 6), padding: const EdgeInsets.symmetric(horizontal: 6),
child: BlocProvider( child: BlocProvider(
create: (context) => SearchResultListBloc(), create: (context) => SearchResultListBloc(),
child: BlocListener<SearchResultListBloc, SearchResultListState>(
listener: (context, state) {
if (state.openPageId != null) {
FlowyOverlay.pop(context);
getIt<ActionNavigationBloc>().add(
ActionNavigationEvent.performAction(
action: NavigationAction(objectId: state.openPageId!),
),
);
}
},
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -96,6 +109,7 @@ class SearchResultList extends StatelessWidget {
), ),
), ),
const HSpace(10), const HSpace(10),
if (resultItems.any((item) => item.content.isNotEmpty))
Flexible( Flexible(
flex: 3, flex: 3,
child: Padding( child: Padding(
@ -109,6 +123,7 @@ class SearchResultList extends StatelessWidget {
], ],
), ),
), ),
),
); );
} }
} }

View File

@ -36,7 +36,7 @@ class SearchSummaryCell extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: FlowyText( child: FlowyText(
summary.content, summary.content,
maxLines: 3, maxLines: 20,
), ),
), ),
); );
@ -78,14 +78,19 @@ class SearchSummarySource extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final icon = source.icon.getIcon(); final icon = source.icon.getIcon();
return Row( return SizedBox(
children: [ height: 30,
if (icon != null) ...[ child: FlowyButton(
SizedBox(width: 24, child: icon), leftIcon: icon,
const HSpace(6), hoverColor:
], Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
FlowyText(source.displayName), text: FlowyText(source.displayName),
], onTap: () {
context.read<SearchResultListBloc>().add(
SearchResultListEvent.openPage(pageId: source.id),
);
},
),
); );
} }
} }

View File

@ -2694,8 +2694,8 @@
"placeholder": "Search or ask a question...", "placeholder": "Search or ask a question...",
"bestMatches": "Best matches", "bestMatches": "Best matches",
"aiOverview": "AI overview", "aiOverview": "AI overview",
"aiOverviewSource": "Sources", "aiOverviewSource": "Reference sources",
"pagePreview": "Preview", "pagePreview": "Content preview",
"recentHistory": "Recent history", "recentHistory": "Recent history",
"navigateHint": "to navigate", "navigateHint": "to navigate",
"loadingTooltip": "We are looking for results...", "loadingTooltip": "We are looking for results...",

View File

@ -71,10 +71,10 @@ impl LocalAIResourceController {
) -> Self { ) -> Self {
let (resource_notify, _) = tokio::sync::broadcast::channel(1); let (resource_notify, _) = tokio::sync::broadcast::channel(1);
let (app_state_sender, _) = 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<WatchContext> = None; let mut offline_app_disk_watch: Option<WatchContext> = None;
#[cfg(target_os = "macos")] #[cfg(any(target_os = "macos", target_os = "linux"))]
{ {
match watch_offline_app() { match watch_offline_app() {
Ok((new_watcher, mut rx)) => { Ok((new_watcher, mut rx)) => {

View File

@ -1,9 +1,9 @@
use crate::entities::{ use crate::entities::{
CreateSearchResultPBArgs, RepeatedSearchResponseItemPB, RepeatedSearchSummaryPB, SearchResultPB, CreateSearchResultPBArgs, RepeatedSearchResponseItemPB, RepeatedSearchSummaryPB,
SearchSourcePB, SearchSummaryPB, SearchResponsePB, SearchSourcePB, SearchSummaryPB,
}; };
use crate::{ use crate::{
entities::{IndexTypePB, ResultIconPB, ResultIconTypePB, SearchFilterPB, SearchResponseItemPB}, entities::{ResultIconPB, ResultIconTypePB, SearchFilterPB, SearchResponseItemPB},
services::manager::{SearchHandler, SearchType}, services::manager::{SearchHandler, SearchType},
}; };
use async_stream::stream; use async_stream::stream;
@ -45,7 +45,7 @@ impl SearchHandler for DocumentSearchHandler {
&self, &self,
query: String, query: String,
filter: Option<SearchFilterPB>, filter: Option<SearchFilterPB>,
) -> Pin<Box<dyn Stream<Item = FlowyResult<SearchResultPB>> + Send + 'static>> { ) -> Pin<Box<dyn Stream<Item = FlowyResult<SearchResponsePB>> + Send + 'static>> {
let cloud_service = self.cloud_service.clone(); let cloud_service = self.cloud_service.clone();
let folder_manager = self.folder_manager.clone(); let folder_manager = self.folder_manager.clone();
@ -99,13 +99,10 @@ impl SearchHandler for DocumentSearchHandler {
for item in &result_items { for item in &result_items {
if let Some(view) = views.iter().find(|v| v.id == item.object_id.to_string()) { if let Some(view) = views.iter().find(|v| v.id == item.object_id.to_string()) {
items.push(SearchResponseItemPB { items.push(SearchResponseItemPB {
index_type: IndexTypePB::Document,
id: item.object_id.to_string(), id: item.object_id.to_string(),
display_name: view.name.clone(), display_name: view.name.clone(),
icon: extract_icon(view), icon: extract_icon(view),
score: item.score,
workspace_id: item.workspace_id.to_string(), workspace_id: item.workspace_id.to_string(),
preview: item.preview.clone(),
content: item.content.clone()} content: item.content.clone()}
); );
} else { } else {
@ -133,15 +130,11 @@ impl SearchHandler for DocumentSearchHandler {
let sources: Vec<SearchSourcePB> = v.sources let sources: Vec<SearchSourcePB> = v.sources
.iter() .iter()
.flat_map(|id| { .flat_map(|id| {
if let Some(view) = views.iter().find(|v| v.id == id.to_string()) { views.iter().find(|v| v.id == id.to_string()).map(|view| SearchSourcePB {
Some(SearchSourcePB {
id: id.to_string(), id: id.to_string(),
display_name: view.name.clone(), display_name: view.name.clone(),
icon: extract_icon(view), icon: extract_icon(view),
}) })
} else {
None
}
}) })
.collect(); .collect();

View File

@ -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<IndexTypePB> for i32 {
fn from(notification: IndexTypePB) -> Self {
notification as i32
}
}
impl std::convert::From<i32> for IndexTypePB {
fn from(notification: i32) -> Self {
match notification {
1 => IndexTypePB::View,
2 => IndexTypePB::DocumentBlock,
_ => IndexTypePB::DatabaseRow,
}
}
}

View File

@ -1,10 +1,8 @@
mod index_type;
mod notification; mod notification;
mod query; mod query;
mod result; mod result;
mod search_filter; mod search_filter;
pub use index_type::*;
pub use notification::*; pub use notification::*;
pub use query::*; pub use query::*;
pub use result::*; pub use result::*;

View File

@ -1,10 +1,10 @@
use super::SearchResultPB; use super::SearchResponsePB;
use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_derive::{ProtoBuf, ProtoBuf_Enum};
#[derive(ProtoBuf, Default, Debug, Clone)] #[derive(ProtoBuf, Default, Debug, Clone)]
pub struct SearchResponsePB { pub struct SearchStatePB {
#[pb(index = 1, one_of)] #[pb(index = 1, one_of)]
pub result: Option<SearchResultPB>, pub response: Option<SearchResponsePB>,
#[pb(index = 2)] #[pb(index = 2)]
pub search_id: String, pub search_id: String,

View File

@ -1,4 +1,3 @@
use super::IndexTypePB;
use collab_folder::{IconType, ViewIcon}; use collab_folder::{IconType, ViewIcon};
use derive_builder::Builder; use derive_builder::Builder;
use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_derive::{ProtoBuf, ProtoBuf_Enum};
@ -7,7 +6,7 @@ use flowy_folder::entities::ViewIconPB;
#[derive(Debug, Default, ProtoBuf, Builder, Clone)] #[derive(Debug, Default, ProtoBuf, Builder, Clone)]
#[builder(name = "CreateSearchResultPBArgs")] #[builder(name = "CreateSearchResultPBArgs")]
#[builder(pattern = "mutable")] #[builder(pattern = "mutable")]
pub struct SearchResultPB { pub struct SearchResponsePB {
#[pb(index = 1, one_of)] #[pb(index = 1, one_of)]
#[builder(default)] #[builder(default)]
pub search_result: Option<RepeatedSearchResponseItemPB>, pub search_result: Option<RepeatedSearchResponseItemPB>,
@ -15,6 +14,10 @@ pub struct SearchResultPB {
#[pb(index = 2, one_of)] #[pb(index = 2, one_of)]
#[builder(default)] #[builder(default)]
pub search_summary: Option<RepeatedSearchSummaryPB>, pub search_summary: Option<RepeatedSearchSummaryPB>,
#[pb(index = 3, one_of)]
#[builder(default)]
pub local_search_result: Option<RepeatedLocalSearchResponseItemPB>,
} }
#[derive(ProtoBuf, Default, Debug, Clone)] #[derive(ProtoBuf, Default, Debug, Clone)]
@ -53,43 +56,40 @@ pub struct RepeatedSearchResponseItemPB {
#[derive(ProtoBuf, Default, Debug, Clone)] #[derive(ProtoBuf, Default, Debug, Clone)]
pub struct SearchResponseItemPB { pub struct SearchResponseItemPB {
#[pb(index = 1)] #[pb(index = 1)]
pub index_type: IndexTypePB,
#[pb(index = 2)]
pub id: String, pub id: String,
#[pb(index = 3)] #[pb(index = 2)]
pub display_name: String, pub display_name: String,
#[pb(index = 4, one_of)] #[pb(index = 3, one_of)]
pub icon: Option<ResultIconPB>, pub icon: Option<ResultIconPB>,
#[pb(index = 5)] #[pb(index = 4)]
pub score: f64,
#[pb(index = 6)]
pub workspace_id: String, pub workspace_id: String,
#[pb(index = 7, one_of)] #[pb(index = 5)]
pub preview: Option<String>,
#[pb(index = 8)]
pub content: String, pub content: String,
} }
impl SearchResponseItemPB { #[derive(ProtoBuf, Default, Debug, Clone)]
pub fn with_score(&self, score: f64) -> Self { pub struct RepeatedLocalSearchResponseItemPB {
SearchResponseItemPB { #[pb(index = 1)]
index_type: self.index_type.clone(), pub items: Vec<LocalSearchResponseItemPB>,
id: self.id.clone(), }
display_name: self.display_name.clone(),
icon: self.icon.clone(), #[derive(ProtoBuf, Default, Debug, Clone)]
score, pub struct LocalSearchResponseItemPB {
workspace_id: self.workspace_id.clone(), #[pb(index = 1)]
preview: self.preview.clone(), pub id: String,
content: self.content.clone(),
} #[pb(index = 2)]
} pub display_name: String,
#[pb(index = 3, one_of)]
pub icon: Option<ResultIconPB>,
#[pb(index = 4)]
pub workspace_id: String,
} }
#[derive(ProtoBuf_Enum, Clone, Debug, PartialEq, Eq, Default)] #[derive(ProtoBuf_Enum, Clone, Debug, PartialEq, Eq, Default)]

View File

@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::entities::{IndexTypePB, ResultIconPB, SearchResponseItemPB}; use crate::entities::{LocalSearchResponseItemPB, ResultIconPB};
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct FolderIndexData { pub struct FolderIndexData {
@ -11,7 +11,7 @@ pub struct FolderIndexData {
pub workspace_id: String, pub workspace_id: String,
} }
impl From<FolderIndexData> for SearchResponseItemPB { impl From<FolderIndexData> for LocalSearchResponseItemPB {
fn from(data: FolderIndexData) -> Self { fn from(data: FolderIndexData) -> Self {
let icon = if data.icon.is_empty() { let icon = if data.icon.is_empty() {
None None
@ -23,14 +23,10 @@ impl From<FolderIndexData> for SearchResponseItemPB {
}; };
Self { Self {
index_type: IndexTypePB::View,
id: data.id, id: data.id,
display_name: data.title, display_name: data.title,
score: 0.0,
icon, icon,
workspace_id: data.workspace_id, workspace_id: data.workspace_id,
preview: None,
content: "".to_string(),
} }
} }
} }

View File

@ -1,6 +1,6 @@
use super::indexer::FolderIndexManagerImpl; use super::indexer::FolderIndexManagerImpl;
use crate::entities::{ use crate::entities::{
CreateSearchResultPBArgs, RepeatedSearchResponseItemPB, SearchFilterPB, SearchResultPB, CreateSearchResultPBArgs, RepeatedLocalSearchResponseItemPB, SearchFilterPB, SearchResponsePB,
}; };
use crate::services::manager::{SearchHandler, SearchType}; use crate::services::manager::{SearchHandler, SearchType};
use async_stream::stream; use async_stream::stream;
@ -30,7 +30,7 @@ impl SearchHandler for FolderSearchHandler {
&self, &self,
query: String, query: String,
filter: Option<SearchFilterPB>, filter: Option<SearchFilterPB>,
) -> Pin<Box<dyn Stream<Item = FlowyResult<SearchResultPB>> + Send + 'static>> { ) -> Pin<Box<dyn Stream<Item = FlowyResult<SearchResponsePB>> + Send + 'static>> {
let index_manager = self.index_manager.clone(); let index_manager = self.index_manager.clone();
Box::pin(stream! { Box::pin(stream! {
@ -48,8 +48,8 @@ impl SearchHandler for FolderSearchHandler {
} }
// Build the search result. // Build the search result.
let search_result = RepeatedSearchResponseItemPB {items}; let search_result = RepeatedLocalSearchResponseItemPB {items};
yield Ok(CreateSearchResultPBArgs::default().search_result(Some(search_result)).build().unwrap()) yield Ok(CreateSearchResultPBArgs::default().local_search_result(Some(search_result)).build().unwrap())
}) })
} }
} }

View File

@ -1,9 +1,6 @@
use crate::{ use crate::folder::schema::{
entities::SearchResponseItemPB,
folder::schema::{
FolderSchema, FOLDER_ICON_FIELD_NAME, FOLDER_ICON_TY_FIELD_NAME, FOLDER_ID_FIELD_NAME, FolderSchema, FOLDER_ICON_FIELD_NAME, FOLDER_ICON_TY_FIELD_NAME, FOLDER_ID_FIELD_NAME,
FOLDER_TITLE_FIELD_NAME, FOLDER_WORKSPACE_ID_FIELD_NAME, FOLDER_TITLE_FIELD_NAME, FOLDER_WORKSPACE_ID_FIELD_NAME,
},
}; };
use collab::core::collab::{IndexContent, IndexContentReceiver}; use collab::core::collab::{IndexContent, IndexContentReceiver};
use collab_folder::{folder_diff::FolderViewChange, View, ViewIcon, ViewIndexContent, ViewLayout}; 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 std::{collections::HashMap, fs};
use super::entities::FolderIndexData; use super::entities::FolderIndexData;
use crate::entities::ResultIconTypePB; use crate::entities::{LocalSearchResponseItemPB, ResultIconTypePB};
use lib_infra::async_trait::async_trait; use lib_infra::async_trait::async_trait;
use strsim::levenshtein;
use tantivy::{ use tantivy::{
collector::TopDocs, directory::MmapDirectory, doc, query::QueryParser, schema::Field, Document, collector::TopDocs, directory::MmapDirectory, doc, query::QueryParser, schema::Field, Document,
Index, IndexReader, IndexWriter, TantivyDocument, Term, Index, IndexReader, IndexWriter, TantivyDocument, Term,
@ -113,11 +109,6 @@ impl FolderIndexManagerImpl {
(icon, icon_ty) (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. /// Simple implementation to index all given data by spawning async tasks.
fn index_all(&self, data_vec: Vec<IndexableData>) -> Result<(), FlowyError> { fn index_all(&self, data_vec: Vec<IndexableData>) -> Result<(), FlowyError> {
for data in data_vec { for data in data_vec {
@ -130,7 +121,7 @@ impl FolderIndexManagerImpl {
} }
/// Searches the index using the given query string. /// Searches the index using the given query string.
pub async fn search(&self, query: String) -> Result<Vec<SearchResponseItemPB>, FlowyError> { pub async fn search(&self, query: String) -> Result<Vec<LocalSearchResponseItemPB>, FlowyError> {
let lock = self.state.read().await; let lock = self.state.read().await;
let state = lock let state = lock
.as_ref() .as_ref()
@ -157,8 +148,8 @@ impl FolderIndexManagerImpl {
} }
if !content.is_empty() { if !content.is_empty() {
let s = serde_json::to_string(&content)?; let s = serde_json::to_string(&content)?;
let result: SearchResponseItemPB = serde_json::from_str::<FolderIndexData>(&s)?.into(); let result: LocalSearchResponseItemPB = serde_json::from_str::<FolderIndexData>(&s)?.into();
results.push(result.with_score(self.score_result(&query, &result.display_name))); results.push(result);
} }
} }
@ -200,11 +191,11 @@ impl IndexManager for FolderIndexManagerImpl {
}) })
.await; .await;
}, },
Err(err) => tracing::error!("FolderIndexManager error deserialize (update): {:?}", err), Err(err) => error!("FolderIndexManager error deserialize (update): {:?}", err),
}, },
IndexContent::Delete(ids) => { IndexContent::Delete(ids) => {
if let Err(e) = indexer.remove_indices(ids).await { if let Err(e) = indexer.remove_indices(ids).await {
tracing::error!("FolderIndexManager error (delete): {:?}", e); error!("FolderIndexManager error (delete): {:?}", e);
} }
}, },
} }

View File

@ -1,4 +1,4 @@
use crate::entities::{SearchFilterPB, SearchResponsePB, SearchResultPB}; use crate::entities::{SearchFilterPB, SearchResponsePB, SearchStatePB};
use allo_isolate::Isolate; use allo_isolate::Isolate;
use flowy_error::FlowyResult; use flowy_error::FlowyResult;
use lib_infra::async_trait::async_trait; use lib_infra::async_trait::async_trait;
@ -25,7 +25,7 @@ pub trait SearchHandler: Send + Sync + 'static {
&self, &self,
query: String, query: String,
filter: Option<SearchFilterPB>, filter: Option<SearchFilterPB>,
) -> Pin<Box<dyn Stream<Item = FlowyResult<SearchResultPB>> + Send + 'static>>; ) -> Pin<Box<dyn Stream<Item = FlowyResult<SearchResponsePB>> + Send + 'static>>;
} }
/// The [SearchManager] is used to inject multiple [SearchHandler]'s /// The [SearchManager] is used to inject multiple [SearchHandler]'s
@ -34,7 +34,7 @@ pub trait SearchHandler: Send + Sync + 'static {
/// ///
pub struct SearchManager { pub struct SearchManager {
pub handlers: HashMap<SearchType, Arc<dyn SearchHandler>>, pub handlers: HashMap<SearchType, Arc<dyn SearchHandler>>,
current_search: Arc<tokio::sync::Mutex<Option<String>>>, // Track current search current_search: Arc<tokio::sync::Mutex<Option<String>>>,
} }
impl SearchManager { impl SearchManager {
@ -84,15 +84,14 @@ impl SearchManager {
} }
let mut stream = handler.perform_search(query.clone(), filter).await; 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(&current_search, &search_id).await { if !is_current_search(&current_search, &search_id).await {
trace!("[Search] discard search stream: {}", query); trace!("[Search] discard search stream: {}", query);
return; return;
} }
if let Ok(result) = result { let resp = SearchStatePB {
let resp = SearchResponsePB { response: Some(search_result),
result: Some(result),
search_id: search_id.clone(), search_id: search_id.clone(),
is_loading: true, is_loading: true,
}; };
@ -103,15 +102,14 @@ impl SearchManager {
} }
} }
} }
}
if !is_current_search(&current_search, &search_id).await { if !is_current_search(&current_search, &search_id).await {
trace!("[Search] discard search result: {}", query); trace!("[Search] discard search result: {}", query);
return; return;
} }
let resp = SearchResponsePB { let resp = SearchStatePB {
result: None, response: None,
search_id: search_id.clone(), search_id: search_id.clone(),
is_loading: true, is_loading: true,
}; };