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:
Mathias Mogensen
2024-03-21 17:34:53 +01:00
committed by GitHub
parent 4e99952b0e
commit c1006c18c3
123 changed files with 4013 additions and 521 deletions

View File

@ -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);
}

View File

@ -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,

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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,
};
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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,
);
}

View File

@ -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());
},

View File

@ -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