mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: folder search mvp (#4665)
* feat: implement folder indexer * feat: sqlite search views using fts5 * feat: add view indexing to user manager * feat: implement folder indexer * feat: add sqlite search documents * feat: add document indexing to user manager * feat: add document indexing to folder indexer * chore: update collab rev * feat: search frontend integration * refactor: search index * test: add event test * chore: fix ci * feat: initial command palette overlay impl (#4619) * chore: test search engine * chore: initial structure * chore: replace old search request * chore: enable log for lib-dispatch * chore: move search manager to core * feat: move traits and responsibility to search crate * feat: move search to search crate * feat: replace sqlite with tantivy * feat: deserialize tantivy documents * chore: fixes after rebase * chore: clean code * feat: fetch and sort results * fix: code review + cleaning * feat: support custom icons * feat: support view layout icons * feat: rename bloc and fix indexing * fix: prettify dialog * feat: score results * chore: update collab rev * feat: add recent view history to command palette * test: add integration_tests * fix: clippy changes * fix: focus traversal in cmd palette * fix: remove file after merging main * chore: code review and panic-safe * feat: index all views if index does not exist * chore: improve logic with conditional * chore: add is_empty check * chore: abstract logic from folder manager init * chore: update collab rev * chore: code review * chore: fixes after merge + update lock file * chore: revert cargo lock * fix: set icon type when removing icon * fix: code review + dependency inversion * fix: remove icon fix for not persisting icon type * test: simple tests manipulating views * test: create 100 views * fix: tauri build * chore: create 1000 views * chore: create util methods * chore: test * chore: test * chore: remove logs * chore: fix build.rs * chore: export models * chore: enable clear cache on Rust-CI * fix: navigate to newly created views * fix: force disable setting workspace listener on rebuilds * fix: remove late final * fix: missing returns * fix: localization and minor fixes * test: add index assert to large test * fix: missing section param after merging main * chore: try fix unzip file error * chore: lower the test * feat: show hint when result is in trash --------- Co-authored-by: nathan <nathan@appflowy.io> Co-authored-by: Jiraffe7 <twajxjiraffe@gmail.com> Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
This commit is contained in:
@ -0,0 +1,134 @@
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy/workspace/application/workspace/workspace_listener.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'action_navigation_bloc.freezed.dart';
|
||||
|
||||
class ActionNavigationBloc
|
||||
extends Bloc<ActionNavigationEvent, ActionNavigationState> {
|
||||
ActionNavigationBloc() : super(const ActionNavigationState.initial()) {
|
||||
on<ActionNavigationEvent>((event, emit) async {
|
||||
await event.when(
|
||||
initialize: () async {
|
||||
final views = await ViewBackendService().fetchViews();
|
||||
emit(state.copyWith(views: views));
|
||||
await initializeListeners();
|
||||
},
|
||||
viewsChanged: (views) {
|
||||
emit(state.copyWith(views: views));
|
||||
},
|
||||
performAction: (action, nextActions) {
|
||||
emit(state.copyWith(action: action, nextActions: nextActions));
|
||||
|
||||
if (nextActions.isNotEmpty) {
|
||||
final newActions = [...nextActions];
|
||||
final next = newActions.removeAt(0);
|
||||
|
||||
add(
|
||||
ActionNavigationEvent.performAction(
|
||||
action: next,
|
||||
nextActions: newActions,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
emit(state.setNoAction());
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
WorkspaceListener? _workspaceListener;
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _workspaceListener?.stop();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
Future<void> initializeListeners() async {
|
||||
if (_workspaceListener != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final userOrFailure = await getIt<AuthService>().getUser();
|
||||
final user = userOrFailure.fold((s) => s, (f) => null);
|
||||
if (user == null) {
|
||||
_workspaceListener = null;
|
||||
return;
|
||||
}
|
||||
|
||||
final workspaceSettingsOrFailure =
|
||||
await FolderEventGetCurrentWorkspaceSetting().send();
|
||||
final workspaceId = workspaceSettingsOrFailure.fold(
|
||||
(s) => s.workspaceId,
|
||||
(f) => null,
|
||||
);
|
||||
if (workspaceId == null) {
|
||||
_workspaceListener = null;
|
||||
return;
|
||||
}
|
||||
|
||||
_workspaceListener = WorkspaceListener(
|
||||
user: user,
|
||||
workspaceId: workspaceId,
|
||||
);
|
||||
|
||||
_workspaceListener?.start(
|
||||
appsChanged: (_) async {
|
||||
final views = await ViewBackendService().fetchViews();
|
||||
add(ActionNavigationEvent.viewsChanged(views));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ActionNavigationEvent with _$ActionNavigationEvent {
|
||||
const factory ActionNavigationEvent.initialize() = _Initialize;
|
||||
|
||||
const factory ActionNavigationEvent.performAction({
|
||||
required NavigationAction action,
|
||||
@Default([]) List<NavigationAction> nextActions,
|
||||
}) = _PerformAction;
|
||||
|
||||
const factory ActionNavigationEvent.viewsChanged(List<ViewPB> views) =
|
||||
_ViewsChanged;
|
||||
}
|
||||
|
||||
class ActionNavigationState {
|
||||
const ActionNavigationState.initial()
|
||||
: action = null,
|
||||
nextActions = const [],
|
||||
views = const [];
|
||||
|
||||
const ActionNavigationState({
|
||||
required this.action,
|
||||
this.nextActions = const [],
|
||||
this.views = const [],
|
||||
});
|
||||
|
||||
final NavigationAction? action;
|
||||
final List<NavigationAction> nextActions;
|
||||
final List<ViewPB> views;
|
||||
|
||||
ActionNavigationState copyWith({
|
||||
NavigationAction? action,
|
||||
List<NavigationAction>? nextActions,
|
||||
List<ViewPB>? views,
|
||||
}) =>
|
||||
ActionNavigationState(
|
||||
action: action ?? this.action,
|
||||
nextActions: nextActions ?? this.nextActions,
|
||||
views: views ?? this.views,
|
||||
);
|
||||
|
||||
ActionNavigationState setNoAction() =>
|
||||
ActionNavigationState(action: null, nextActions: [], views: views);
|
||||
}
|
@ -10,13 +10,13 @@ class ActionArgumentKeys {
|
||||
static String rowId = "row_id";
|
||||
}
|
||||
|
||||
/// A [NotificationAction] is used to communicate with the
|
||||
/// [NotificationActionBloc] to perform actions based on an event
|
||||
/// A [NavigationAction] is used to communicate with the
|
||||
/// [ActionNavigationBloc] to perform actions based on an event
|
||||
/// triggered by pressing a notification, such as opening a specific
|
||||
/// view and jumping to a specific block.
|
||||
///
|
||||
class NotificationAction {
|
||||
const NotificationAction({
|
||||
class NavigationAction {
|
||||
const NavigationAction({
|
||||
this.type = ActionType.openView,
|
||||
this.arguments,
|
||||
required this.objectId,
|
||||
@ -27,12 +27,12 @@ class NotificationAction {
|
||||
final String objectId;
|
||||
final Map<String, dynamic>? arguments;
|
||||
|
||||
NotificationAction copyWith({
|
||||
NavigationAction copyWith({
|
||||
ActionType? type,
|
||||
String? objectId,
|
||||
Map<String, dynamic>? arguments,
|
||||
}) =>
|
||||
NotificationAction(
|
||||
NavigationAction(
|
||||
type: type ?? this.type,
|
||||
objectId: objectId ?? this.objectId,
|
||||
arguments: arguments ?? this.arguments,
|
@ -0,0 +1,181 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:appflowy/plugins/trash/application/trash_listener.dart';
|
||||
import 'package:appflowy/plugins/trash/application/trash_service.dart';
|
||||
import 'package:appflowy/workspace/application/command_palette/search_listener.dart';
|
||||
import 'package:appflowy/workspace/application/command_palette/search_service.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-search/entities.pb.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'command_palette_bloc.freezed.dart';
|
||||
|
||||
class CommandPaletteBloc
|
||||
extends Bloc<CommandPaletteEvent, CommandPaletteState> {
|
||||
CommandPaletteBloc() : super(CommandPaletteState.initial()) {
|
||||
_searchListener.start(
|
||||
onResultsChanged: _onResultsChanged,
|
||||
onResultsClosed: _onResultsClosed,
|
||||
);
|
||||
|
||||
_initTrash();
|
||||
|
||||
_dispatch();
|
||||
}
|
||||
|
||||
Timer? _debounceOnChanged;
|
||||
final TrashService _trashService = TrashService();
|
||||
final SearchListener _searchListener = SearchListener();
|
||||
final TrashListener _trashListener = TrashListener();
|
||||
String? _oldQuery;
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_trashListener.close();
|
||||
_searchListener.stop();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
void _dispatch() {
|
||||
on<CommandPaletteEvent>((event, emit) async {
|
||||
event.when(
|
||||
searchChanged: _debounceOnSearchChanged,
|
||||
trashChanged: (trash) async {
|
||||
if (trash != null) {
|
||||
emit(state.copyWith(trash: trash));
|
||||
return;
|
||||
}
|
||||
|
||||
final trashOrFailure = await _trashService.readTrash();
|
||||
final trashRes = trashOrFailure.fold(
|
||||
(trash) => trash,
|
||||
(error) => null,
|
||||
);
|
||||
|
||||
if (trashRes != null) {
|
||||
emit(state.copyWith(trash: trashRes.items));
|
||||
}
|
||||
},
|
||||
performSearch: (search) async {
|
||||
if (search.isNotEmpty) {
|
||||
_oldQuery = state.query;
|
||||
emit(state.copyWith(query: search, isLoading: true));
|
||||
await SearchBackendService.performSearch(search);
|
||||
} else {
|
||||
emit(state.copyWith(query: null, isLoading: false, results: []));
|
||||
}
|
||||
},
|
||||
resultsChanged: (results, didClose) {
|
||||
if (state.query != _oldQuery) {
|
||||
emit(state.copyWith(results: []));
|
||||
}
|
||||
|
||||
final searchResults = _filterDuplicates(results.items);
|
||||
searchResults.sort((a, b) => b.score.compareTo(a.score));
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
results: searchResults,
|
||||
isLoading: !didClose,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initTrash() async {
|
||||
_trashListener.start(
|
||||
trashUpdated: (trashOrFailed) {
|
||||
final trash = trashOrFailed.fold(
|
||||
(trash) => trash,
|
||||
(error) => null,
|
||||
);
|
||||
|
||||
add(CommandPaletteEvent.trashChanged(trash: trash));
|
||||
},
|
||||
);
|
||||
|
||||
final trashOrFailure = await _trashService.readTrash();
|
||||
final trashRes = trashOrFailure.fold(
|
||||
(trash) => trash,
|
||||
(error) => null,
|
||||
);
|
||||
|
||||
add(CommandPaletteEvent.trashChanged(trash: trashRes?.items));
|
||||
}
|
||||
|
||||
void _debounceOnSearchChanged(String value) {
|
||||
_debounceOnChanged?.cancel();
|
||||
_debounceOnChanged = Timer(
|
||||
const Duration(milliseconds: 300),
|
||||
() => _performSearch(value),
|
||||
);
|
||||
}
|
||||
|
||||
List<SearchResultPB> _filterDuplicates(List<SearchResultPB> results) {
|
||||
final currentItems = [...state.results];
|
||||
final res = [...results];
|
||||
|
||||
for (final item in results) {
|
||||
final duplicateIndex = currentItems.indexWhere((a) => a.id == item.id);
|
||||
if (duplicateIndex == -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final duplicate = currentItems[duplicateIndex];
|
||||
if (item.score < duplicate.score) {
|
||||
res.remove(item);
|
||||
} else {
|
||||
currentItems.remove(duplicate);
|
||||
}
|
||||
}
|
||||
|
||||
return res..addAll(currentItems);
|
||||
}
|
||||
|
||||
void _performSearch(String value) =>
|
||||
add(CommandPaletteEvent.performSearch(search: value));
|
||||
|
||||
void _onResultsChanged(RepeatedSearchResultPB results) =>
|
||||
add(CommandPaletteEvent.resultsChanged(results: results));
|
||||
|
||||
void _onResultsClosed(RepeatedSearchResultPB results) =>
|
||||
add(CommandPaletteEvent.resultsChanged(results: results, didClose: true));
|
||||
}
|
||||
|
||||
@freezed
|
||||
class CommandPaletteEvent with _$CommandPaletteEvent {
|
||||
const factory CommandPaletteEvent.searchChanged({required String search}) =
|
||||
_SearchChanged;
|
||||
|
||||
const factory CommandPaletteEvent.performSearch({required String search}) =
|
||||
_PerformSearch;
|
||||
|
||||
const factory CommandPaletteEvent.resultsChanged({
|
||||
required RepeatedSearchResultPB results,
|
||||
@Default(false) bool didClose,
|
||||
}) = _ResultsChanged;
|
||||
|
||||
const factory CommandPaletteEvent.trashChanged({
|
||||
@Default(null) List<TrashPB>? trash,
|
||||
}) = _TrashChanged;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class CommandPaletteState with _$CommandPaletteState {
|
||||
const CommandPaletteState._();
|
||||
|
||||
const factory CommandPaletteState({
|
||||
@Default(null) String? query,
|
||||
required List<SearchResultPB> results,
|
||||
required bool isLoading,
|
||||
@Default([]) List<TrashPB> trash,
|
||||
}) = _CommandPaletteState;
|
||||
|
||||
factory CommandPaletteState.initial() =>
|
||||
const CommandPaletteState(results: [], isLoading: false);
|
||||
}
|
@ -0,0 +1,65 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:appflowy/core/notification/search_notification.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-search/entities.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:flowy_infra/notifier.dart';
|
||||
|
||||
// Do not modify!
|
||||
const _searchObjectId = "SEARCH_IDENTIFIER";
|
||||
|
||||
class SearchListener {
|
||||
SearchListener();
|
||||
|
||||
PublishNotifier<RepeatedSearchResultPB>? _updateNotifier = PublishNotifier();
|
||||
PublishNotifier<RepeatedSearchResultPB>? _updateDidCloseNotifier =
|
||||
PublishNotifier();
|
||||
SearchNotificationListener? _listener;
|
||||
|
||||
void start({
|
||||
required void Function(RepeatedSearchResultPB) onResultsChanged,
|
||||
required void Function(RepeatedSearchResultPB) onResultsClosed,
|
||||
}) {
|
||||
_updateNotifier?.addPublishListener(onResultsChanged);
|
||||
_updateDidCloseNotifier?.addPublishListener(onResultsClosed);
|
||||
_listener = SearchNotificationListener(
|
||||
objectId: _searchObjectId,
|
||||
handler: _handler,
|
||||
);
|
||||
}
|
||||
|
||||
void _handler(
|
||||
SearchNotification ty,
|
||||
FlowyResult<Uint8List, FlowyError> result,
|
||||
) {
|
||||
switch (ty) {
|
||||
case SearchNotification.DidUpdateResults:
|
||||
result.fold(
|
||||
(payload) => _updateNotifier?.value =
|
||||
RepeatedSearchResultPB.fromBuffer(payload),
|
||||
(err) => Log.error(err),
|
||||
);
|
||||
break;
|
||||
case SearchNotification.DidCloseResults:
|
||||
result.fold(
|
||||
(payload) => _updateDidCloseNotifier?.value =
|
||||
RepeatedSearchResultPB.fromBuffer(payload),
|
||||
(err) => Log.error(err),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
await _listener?.stop();
|
||||
_updateNotifier?.dispose();
|
||||
_updateNotifier = null;
|
||||
_updateDidCloseNotifier?.dispose();
|
||||
_updateDidCloseNotifier = null;
|
||||
}
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-search/entities.pb.dart';
|
||||
|
||||
extension GetIcon on SearchResultPB {
|
||||
Widget? getIcon() {
|
||||
if (icon.ty == ResultIconTypePB.Emoji) {
|
||||
return icon.value.isNotEmpty
|
||||
? Text(
|
||||
icon.value,
|
||||
style: const TextStyle(fontSize: 18.0),
|
||||
)
|
||||
: null;
|
||||
} else if (icon.ty == ResultIconTypePB.Icon) {
|
||||
return FlowySvg(icon.getViewSvg(), size: const Size.square(20));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
extension _ToViewIcon on ResultIconPB {
|
||||
FlowySvgData getViewSvg() => switch (value) {
|
||||
"0" => FlowySvgs.document_s,
|
||||
"1" => FlowySvgs.grid_s,
|
||||
"2" => FlowySvgs.board_s,
|
||||
"3" => FlowySvgs.date_s,
|
||||
_ => FlowySvgs.document_s,
|
||||
};
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-search/entities.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
|
||||
class SearchBackendService {
|
||||
static Future<FlowyResult<void, FlowyError>> performSearch(
|
||||
String keyword,
|
||||
) async {
|
||||
final request = SearchQueryPB(search: keyword);
|
||||
|
||||
return SearchEventSearch(request).send();
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:local_notifier/local_notifier.dart';
|
||||
|
||||
const _appName = "AppFlowy";
|
||||
@ -12,9 +13,7 @@ const _appName = "AppFlowy";
|
||||
///
|
||||
class NotificationService {
|
||||
static Future<void> initialize() async {
|
||||
await localNotifier.setup(
|
||||
appName: _appName,
|
||||
);
|
||||
await localNotifier.setup(appName: _appName);
|
||||
}
|
||||
}
|
||||
|
@ -1,61 +0,0 @@
|
||||
import 'package:appflowy/workspace/application/notifications/notification_action.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'notification_action_bloc.freezed.dart';
|
||||
|
||||
class NotificationActionBloc
|
||||
extends Bloc<NotificationActionEvent, NotificationActionState> {
|
||||
NotificationActionBloc() : super(const NotificationActionState.initial()) {
|
||||
on<NotificationActionEvent>((event, emit) async {
|
||||
event.when(
|
||||
performAction: (action, nextActions) {
|
||||
emit(state.copyWith(action: action, nextActions: nextActions));
|
||||
|
||||
if (nextActions.isNotEmpty) {
|
||||
final newActions = [...nextActions];
|
||||
final next = newActions.removeAt(0);
|
||||
|
||||
add(
|
||||
NotificationActionEvent.performAction(
|
||||
action: next,
|
||||
nextActions: newActions,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class NotificationActionEvent with _$NotificationActionEvent {
|
||||
const factory NotificationActionEvent.performAction({
|
||||
required NotificationAction action,
|
||||
@Default([]) List<NotificationAction> nextActions,
|
||||
}) = _PerformAction;
|
||||
}
|
||||
|
||||
class NotificationActionState {
|
||||
const NotificationActionState.initial()
|
||||
: action = null,
|
||||
nextActions = const [];
|
||||
|
||||
const NotificationActionState({
|
||||
required this.action,
|
||||
this.nextActions = const [],
|
||||
});
|
||||
|
||||
final NotificationAction? action;
|
||||
final List<NotificationAction> nextActions;
|
||||
|
||||
NotificationActionState copyWith({
|
||||
NotificationAction? action,
|
||||
List<NotificationAction>? nextActions,
|
||||
}) =>
|
||||
NotificationActionState(
|
||||
action: action ?? this.action,
|
||||
nextActions: nextActions ?? this.nextActions,
|
||||
);
|
||||
}
|
@ -29,9 +29,7 @@ class RecentViewsBloc extends Bloc<RecentViewsEvent, RecentViewsState> {
|
||||
await event.map(
|
||||
initial: (e) async {
|
||||
_listener.start(
|
||||
recentViewsUpdated: (result) => _onRecentViewsUpdated(
|
||||
result,
|
||||
),
|
||||
recentViewsUpdated: (result) => _onRecentViewsUpdated(result),
|
||||
);
|
||||
add(const RecentViewsEvent.fetchRecentViews());
|
||||
},
|
||||
|
@ -167,9 +167,10 @@ class ViewBackendService {
|
||||
static Future<FlowyResult<void, FlowyError>> updateViewIcon({
|
||||
required String viewId,
|
||||
required String viewIcon,
|
||||
ViewIconTypePB iconType = ViewIconTypePB.Emoji,
|
||||
}) {
|
||||
final icon = ViewIconPB()
|
||||
..ty = ViewIconTypePB.Emoji
|
||||
..ty = iconType
|
||||
..value = viewIcon;
|
||||
final payload = UpdateViewIconPayloadPB.create()
|
||||
..viewId = viewId
|
||||
|
Reference in New Issue
Block a user