Merge branch 'main' into feat/link_preview

This commit is contained in:
Morn 2025-04-10 00:29:19 +08:00
commit b844aebd00
71 changed files with 883 additions and 316 deletions

View file

@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
CARGO_MAKE_CRATE_NAME = "dart-ffi"
LIB_NAME = "dart_ffi"
APPFLOWY_VERSION = "0.8.8"
APPFLOWY_VERSION = "0.8.9"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"

View file

@ -1,5 +1,6 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@ -47,5 +48,41 @@ void main() {
expect(editorState.selection!.start.offset, 0);
});
testWidgets('select and delete text', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
/// create a new document
await tester.createNewPageWithNameUnderParent();
/// input text
final editor = tester.editor;
final editorState = editor.getCurrentEditorState();
const inputText = 'Test for text selection and deletion';
final texts = inputText.split(' ');
await editor.tapLineOfEditorAt(0);
await tester.ime.insertText(inputText);
/// selecte and delete
int index = 0;
while (texts.isNotEmpty) {
final text = texts.removeAt(0);
await tester.editor.updateSelection(
Selection(
start: Position(path: [0], offset: index),
end: Position(path: [0], offset: index + text.length),
),
);
await tester.simulateKeyEvent(LogicalKeyboardKey.delete);
index++;
}
/// excpete the text value is correct
final node = editorState.getNodeAtPath([0])!;
final nodeText = node.delta?.toPlainText() ?? '';
expect(nodeText, ' ' * (index - 1));
});
});
}

View file

@ -133,7 +133,6 @@ Future<bool> _afLaunchLocalUri(
};
if (context != null && context.mounted) {
showToastNotification(
context,
message: message,
type: result.type == ResultType.done
? ToastificationType.success

View file

@ -336,7 +336,6 @@ class _MobileViewPageState extends State<MobileViewPage> {
listener: (context, state) {
if (state.isLocked) {
showToastNotification(
context,
message: LocaleKeys.lockPage_pageLockedToast.tr(),
);
@ -366,7 +365,6 @@ class _MobileViewPageState extends State<MobileViewPage> {
listener: (context, state) {
if (state.isLocked) {
showToastNotification(
context,
message: LocaleKeys.lockPage_pageLockedToast.tr(),
);
}

View file

@ -161,7 +161,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
context.pop();
showToastNotification(
context,
message: LocaleKeys.button_duplicateSuccessfully.tr(),
);
}
@ -170,7 +169,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
_toggleFavorite(context);
showToastNotification(
context,
message: LocaleKeys.button_favoriteSuccessfully.tr(),
);
}
@ -179,7 +177,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
_toggleFavorite(context);
showToastNotification(
context,
message: LocaleKeys.button_unfavoriteSuccessfully.tr(),
);
}
@ -202,7 +199,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
),
);
showToastNotification(
context,
message: LocaleKeys.message_copy_success.tr(),
);
}
@ -234,12 +230,10 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
),
);
showToastNotification(
context,
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
);
} else {
showToastNotification(
context,
message: LocaleKeys.shareAction_copyLinkToBlockFailed.tr(),
type: ToastificationType.error,
);
@ -323,11 +317,9 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
if (state.publishResult != null) {
state.publishResult!.fold(
(value) => showToastNotification(
context,
message: LocaleKeys.publish_publishSuccessfully.tr(),
),
(error) => showToastNotification(
context,
message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}',
type: ToastificationType.error,
),
@ -335,11 +327,9 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
} else if (state.unpublishResult != null) {
state.unpublishResult!.fold(
(value) => showToastNotification(
context,
message: LocaleKeys.publish_unpublishSuccessfully.tr(),
),
(error) => showToastNotification(
context,
message: LocaleKeys.publish_unpublishFailed.tr(),
description: error.msg,
type: ToastificationType.error,
@ -349,7 +339,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
state.updatePathNameResult!.onSuccess(
(value) {
showToastNotification(
context,
message:
LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(),
);

View file

@ -65,7 +65,6 @@ class _MobileViewItemBottomSheetState extends State<MobileViewItemBottomSheet> {
Navigator.pop(context);
context.read<ViewBloc>().add(const ViewEvent.duplicate());
showToastNotification(
context,
message: LocaleKeys.button_duplicateSuccessfully.tr(),
);
break;
@ -84,7 +83,6 @@ class _MobileViewItemBottomSheetState extends State<MobileViewItemBottomSheet> {
.read<FavoriteBloc>()
.add(FavoriteEvent.toggle(widget.view));
showToastNotification(
context,
message: !widget.view.isFavorite
? LocaleKeys.button_favoriteSuccessfully.tr()
: LocaleKeys.button_unfavoriteSuccessfully.tr(),
@ -146,7 +144,6 @@ class _MobileViewItemBottomSheetState extends State<MobileViewItemBottomSheet> {
Navigator.pop(context);
showToastNotification(
context,
message: LocaleKeys.sideBar_removeSuccess.tr(),
);
},

View file

@ -45,7 +45,6 @@ enum MobilePaneActionType {
size: 24.0,
onPressed: (context) {
showToastNotification(
context,
message: LocaleKeys.button_unfavoriteSuccessfully.tr(),
);
@ -61,7 +60,6 @@ enum MobilePaneActionType {
size: 24.0,
onPressed: (context) {
showToastNotification(
context,
message: LocaleKeys.button_favoriteSuccessfully.tr(),
);

View file

@ -103,7 +103,7 @@ class _OpenRowPageButtonState extends State<OpenRowPageButton> {
Log.info('Open row page(${widget.documentId})');
if (view == null) {
showToastNotification(context, message: 'Failed to open row page');
showToastNotification(message: 'Failed to open row page');
// reload the view again
unawaited(_preloadView(context));
Log.error('Failed to open row page(${widget.documentId})');

View file

@ -329,7 +329,7 @@ class _HomePageState extends State<_HomePage> {
}
if (message != null) {
showToastNotification(context, message: message, type: toastType);
showToastNotification(message: message, type: toastType);
}
}
}

View file

@ -339,7 +339,6 @@ class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
context.read<SpaceBloc>().add(const SpaceEvent.duplicate());
showToastNotification(
context,
message: LocaleKeys.space_success_duplicateSpace.tr(),
);
@ -374,7 +373,6 @@ class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
.add(SpaceEvent.rename(space: widget.space, name: name));
showToastNotification(
context,
message: LocaleKeys.space_success_renameSpace.tr(),
);
},
@ -424,7 +422,6 @@ class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
);
showToastNotification(
context,
message: LocaleKeys.space_success_updateSpace.tr(),
);
@ -457,7 +454,6 @@ class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
context.read<SpaceBloc>().add(SpaceEvent.delete(widget.space));
showToastNotification(
context,
message: LocaleKeys.space_success_deleteSpace.tr(),
);

View file

@ -332,7 +332,6 @@ class _NotificationNavigationBar extends StatelessWidget {
}
showToastNotification(
context,
message: LocaleKeys
.settings_notifications_markAsReadNotifications_allSuccess
.tr(),
@ -350,7 +349,6 @@ class _NotificationNavigationBar extends StatelessWidget {
}
showToastNotification(
context,
message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess
.tr(),
);

View file

@ -108,7 +108,6 @@ class NotificationSettingsPopupMenu extends StatelessWidget {
void _onMarkAllAsRead(BuildContext context) {
showToastNotification(
context,
message: LocaleKeys
.settings_notifications_markAsReadNotifications_allSuccess
.tr(),
@ -119,7 +118,6 @@ class NotificationSettingsPopupMenu extends StatelessWidget {
void _onArchiveAll(BuildContext context) {
showToastNotification(
context,
message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess
.tr(),
);
@ -133,7 +131,6 @@ class NotificationSettingsPopupMenu extends StatelessWidget {
}
showToastNotification(
context,
message: 'Unarchive all success (Debug Mode)',
);

View file

@ -31,7 +31,6 @@ enum NotificationPaneActionType {
size: 24.0,
onPressed: (context) {
showToastNotification(
context,
message: LocaleKeys
.settings_notifications_markAsReadNotifications_success
.tr(),
@ -55,7 +54,6 @@ enum NotificationPaneActionType {
size: 24.0,
onPressed: (context) {
showToastNotification(
context,
message: 'Unarchive notification success',
);
@ -168,7 +166,6 @@ class _NotificationMoreActions extends StatelessWidget {
Navigator.of(context).pop();
showToastNotification(
context,
message: LocaleKeys.settings_notifications_markAsReadNotifications_success
.tr(),
);
@ -191,7 +188,6 @@ class _NotificationMoreActions extends StatelessWidget {
void _onArchive(BuildContext context) {
showToastNotification(
context,
message: LocaleKeys.settings_notifications_archiveNotifications_success
.tr()
.tr(),

View file

@ -74,7 +74,6 @@ class _NotificationTabState extends State<NotificationTab>
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.settings_notifications_refreshSuccess.tr(),
);
}

View file

@ -81,7 +81,6 @@ class SupportSettingGroup extends StatelessWidget {
);
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.settings_files_clearCacheSuccess.tr(),
);
}

View file

@ -201,7 +201,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
result.fold(
(s) {
showToastNotification(
context,
message:
LocaleKeys.settings_appearance_members_addMemberSuccess.tr(),
bottomPadding: keyboardHeight,
@ -218,7 +217,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
});
showToastNotification(
context,
type: ToastificationType.error,
bottomPadding: keyboardHeight,
message: message,
@ -229,7 +227,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
result.fold(
(s) {
showToastNotification(
context,
message:
LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(),
bottomPadding: keyboardHeight,
@ -247,7 +244,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
});
showToastNotification(
context,
type: ToastificationType.error,
message: message,
bottomPadding: keyboardHeight,
@ -258,7 +254,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
result.fold(
(s) {
showToastNotification(
context,
message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceSuccess
.tr(),
@ -267,7 +262,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
},
(f) {
showToastNotification(
context,
type: ToastificationType.error,
message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceFailed
@ -283,7 +277,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
final email = emailController.text;
if (!isEmail(email)) {
return showToastNotification(
context,
type: ToastificationType.error,
message: LocaleKeys.settings_appearance_members_emailInvalidError.tr(),
);

View file

@ -184,7 +184,6 @@ class CopyButton extends StatelessWidget {
);
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.message_copy_success.tr(),
);
}

View file

@ -376,7 +376,6 @@ class ChatAIMessagePopup extends StatelessWidget {
}
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.message_copy_success.tr(),
);
}

View file

@ -14,7 +14,6 @@ import 'package:universal_platform/universal_platform.dart';
void openPageFromMessage(BuildContext context, ViewPB? view) {
if (view == null) {
showToastNotification(
context,
message: LocaleKeys.chat_openPagePreviewFailedToast.tr(),
type: ToastificationType.error,
);
@ -36,7 +35,6 @@ void showSaveMessageSuccessToast(BuildContext context, ViewPB? view) {
return;
}
showToastNotification(
context,
richMessage: TextSpan(
children: [
TextSpan(

View file

@ -442,7 +442,6 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
final context = AppGlobals.rootNavKey.currentContext;
if (context != null && context.mounted) {
showToastNotification(
context,
message: 'document integrity check failed',
type: ToastificationType.error,
);

View file

@ -150,7 +150,6 @@ class _AiWriterToolbarActionListState extends State<AiWriterToolbarActionList> {
});
} else {
showToastNotification(
context,
message: LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(),
);
}
@ -196,7 +195,6 @@ class ImproveWritingButton extends StatelessWidget {
_insertAiNode(editorState, AiWriterCommand.improveWriting);
} else {
showToastNotification(
context,
message: LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(),
);
}

View file

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_result/appflowy_result.dart';
@ -15,6 +16,11 @@ import 'ai_writer_block_operations.dart';
import 'ai_writer_entities.dart';
import 'ai_writer_node_extension.dart';
/// Enable the debug log for the AiWriterCubit.
///
/// This is useful for debugging the AI writer cubit.
const _aiWriterCubitDebugLog = false;
class AiWriterCubit extends Cubit<AiWriterState> {
AiWriterCubit({
required this.documentId,
@ -95,6 +101,10 @@ class AiWriterCubit extends Cubit<AiWriterState> {
final command = node.aiWriterCommand;
final (run, prompt) = await _addSelectionTextToRecords(command);
_aiWriterCubitLog(
'command: $command, run: $run, prompt: $prompt',
);
if (!run) {
await exit();
return;
@ -211,20 +221,26 @@ class AiWriterCubit extends Cubit<AiWriterState> {
return;
}
// Accept
//
// If the user clicks accept, we need to replace the selection with the AI's response
if (action case SuggestionAction.accept) {
await _textRobot.persist();
await formatSelection(
editorState,
selection,
ApplySuggestionFormatType.clear,
// trim the markdown text to avoid extra new lines
final trimmedMarkdownText = _textRobot.markdownText.trim();
_aiWriterCubitLog(
'trigger accept action, markdown text: $trimmedMarkdownText',
);
final nodes = editorState.getNodesInSelection(selection);
final transaction = editorState.transaction..deleteNodes(nodes);
await editorState.apply(
transaction,
withUpdateSelection: false,
await _textRobot.deleteAINodes();
await _textRobot.replace(
selection: selection,
markdownText: trimmedMarkdownText,
);
await exit(withDiscard: false, withUnformat: false);
return;
}
@ -276,17 +292,24 @@ class AiWriterCubit extends Cubit<AiWriterState> {
AiWriterCommand command,
) async {
final node = aiWriterNode;
// check the node is registered
if (node == null) {
return (false, '');
}
// check the selection is valid
final selection = node.aiWriterSelection?.normalized;
if (selection == null) {
return (false, '');
}
// if the command is continue writing, we don't need to get the selection text
if (command == AiWriterCommand.continueWriting) {
return (true, '');
}
// if the selection is collapsed, we don't need to get the selection text
if (selection.isCollapsed) {
return (true, '');
}
@ -297,6 +320,7 @@ class AiWriterCubit extends Cubit<AiWriterState> {
records.add(
AiWriterRecord.user(content: selectionText, format: null),
);
return (true, '');
} else {
return (true, selectionText);
@ -540,6 +564,10 @@ class AiWriterCubit extends Cubit<AiWriterState> {
attributes: ApplySuggestionFormatType.replace.attributes,
);
onAppendToDocument?.call();
_aiWriterCubitLog(
'received message: $text',
);
},
processAssistMessage: (text) async {
if (state case final GeneratingAiWriterState generatingState) {
@ -551,6 +579,10 @@ class AiWriterCubit extends Cubit<AiWriterState> {
),
);
}
_aiWriterCubitLog(
'received assist message: $text',
);
},
onEnd: () async {
if (state case final GeneratingAiWriterState generatingState) {
@ -567,6 +599,10 @@ class AiWriterCubit extends Cubit<AiWriterState> {
records.add(
AiWriterRecord.ai(content: _textRobot.markdownText),
);
_aiWriterCubitLog(
'returned response: ${_textRobot.markdownText}',
);
}
},
onError: (error) async {
@ -658,6 +694,12 @@ class AiWriterCubit extends Cubit<AiWriterState> {
);
}
}
void _aiWriterCubitLog(String message) {
if (_aiWriterCubitDebugLog) {
Log.debug('[AiWriterCubit] $message');
}
}
}
mixin RegisteredAiWriter {

View file

@ -57,11 +57,30 @@ extension AiWriterNodeExtension on EditorState {
slicedNodes.add(copiedNode);
}
for (final (i, node) in slicedNodes.indexed) {
final childNodesShouldBeDeleted = <Node>[];
for (final child in node.children) {
if (!child.path.inSelection(selection)) {
childNodesShouldBeDeleted.add(child);
}
}
for (final child in childNodesShouldBeDeleted) {
slicedNodes[i] = node.copyWith(
children: node.children.where((e) => e.id != child.id).toList(),
type: selection.startIndex != 0 ? ParagraphBlockKeys.type : node.type,
);
}
}
// use \n\n as line break to improve the ai response
// using \n will cause the ai response treat the text as a single line
final markdown = await customDocumentToMarkdown(
Document.blank()..insert([0], slicedNodes),
lineBreak: '\n\n',
);
return markdown;
// trim the last \n if it exists
return markdown.trimRight();
}
List<String> getPlainTextInSelection(Selection? selection) {

View file

@ -110,10 +110,13 @@ class MarkdownTextRobot {
}
/// Persist the text into the document
Future<void> persist({String? markdownText}) async {
Future<void> persist({
String? markdownText,
}) async {
if (markdownText != null) {
_markdownText = markdownText;
}
await _lock.synchronized(() async {
await _refresh(inMemoryUpdate: false);
});
@ -124,6 +127,34 @@ class MarkdownTextRobot {
}
}
/// Replace the selected content with the AI's response
Future<void> replace({
required Selection selection,
required String markdownText,
}) async {
if (selection.isSingle) {
await _replaceInSameLine(
selection: selection,
markdownText: markdownText,
);
} else {
await _replaceInMultiLines(
selection: selection,
markdownText: markdownText,
);
}
}
/// Delete the temporary inserted AI nodes
Future<void> deleteAINodes() async {
final nodes = getInsertedNodes();
final transaction = editorState.transaction..deleteNodes(nodes);
await editorState.apply(
transaction,
options: const ApplyOptions(recordUndo: false),
);
}
/// Discard the inserted content
Future<void> discard() async {
final start = _insertPosition;
@ -282,6 +313,161 @@ class MarkdownTextRobot {
children: children,
);
}
/// If the selected content is in the same line,
/// keep the selected node and replace the delta.
Future<void> _replaceInSameLine({
required Selection selection,
required String markdownText,
}) async {
selection = selection.normalized;
// If the selection is not a single node, do nothing.
if (!selection.isSingle) {
assert(false, 'Expected single node selection');
Log.error('Expected single node selection');
return;
}
final startIndex = selection.startIndex;
final endIndex = selection.endIndex;
final length = endIndex - startIndex;
// Get the selected node.
final node = editorState.getNodeAtPath(selection.start.path);
final delta = node?.delta;
if (node == null || delta == null) {
assert(false, 'Expected non-null node and delta');
Log.error('Expected non-null node and delta');
return;
}
// Convert the markdown text to delta.
// Question: Why we need to convert the markdown to document first?
// Answer: Because the markdown text may contain the list item,
// if we convert the markdown to delta directly, the list item will be
// treated as a normal text node, and the delta will be incorrect.
// For example, the markdown text is:
// ```
// 1. item1
// ```
// if we convert the markdown to delta directly, the delta will be:
// ```
// [
// {
// "insert": "1. item1"
// }
// ]
// ```
// if we convert the markdown to document first, the document will be:
// ```
// [
// {
// "type": "numbered_list",
// "children": [
// {
// "insert": "item1"
// }
// ]
// }
// ]
final document = customMarkdownToDocument(markdownText);
final decoder = DeltaMarkdownDecoder();
final markdownDelta =
document.nodeAtPath([0])?.delta ?? decoder.convert(markdownText);
// Replace the delta of the selected node.
final transaction = editorState.transaction;
transaction
..deleteText(node, startIndex, length)
..insertTextDelta(node, startIndex, markdownDelta);
await editorState.apply(transaction);
}
/// If the selected content is in multiple lines
Future<void> _replaceInMultiLines({
required Selection selection,
required String markdownText,
}) async {
selection = selection.normalized;
// If the selection is a single node, do nothing.
if (selection.isSingle) {
assert(false, 'Expected multi-line selection');
Log.error('Expected multi-line selection');
return;
}
final markdownNodes = customMarkdownToDocument(
markdownText,
tableWidth: 250.0,
).root.children;
// Get the selected nodes.
final nodes = editorState.getNodesInSelection(selection);
// Note: Don't change its order, otherwise the delta will be incorrect.
// step 1. merge the first selected node and the first node from the ai response
// step 2. merge the last selected node and the last node from the ai response
// step 3. insert the middle nodes from the ai response
// step 4. delete the middle nodes
final transaction = editorState.transaction;
// step 1
final firstNode = nodes.firstOrNull;
final delta = firstNode?.delta;
final firstMarkdownNode = markdownNodes.firstOrNull;
final firstMarkdownDelta = firstMarkdownNode?.delta;
if (firstNode != null &&
delta != null &&
firstMarkdownNode != null &&
firstMarkdownDelta != null) {
final startIndex = selection.startIndex;
final length = delta.length - startIndex;
transaction
..deleteText(firstNode, startIndex, length)
..insertTextDelta(firstNode, startIndex, firstMarkdownDelta);
}
// step 2
final lastNode = nodes.lastOrNull;
final lastDelta = lastNode?.delta;
final lastMarkdownNode = markdownNodes.lastOrNull;
final lastMarkdownDelta = lastMarkdownNode?.delta;
if (lastNode != null &&
lastDelta != null &&
lastMarkdownNode != null &&
lastMarkdownDelta != null) {
final endIndex = selection.endIndex;
transaction.deleteText(lastNode, 0, endIndex);
// if the last node is same as the first node, it means we have replaced the
// selected text in the first node.
if (lastMarkdownNode.id != firstMarkdownNode?.id) {
transaction.insertTextDelta(lastNode, 0, lastMarkdownDelta);
}
}
// step 3
final insertedPath = selection.start.path.nextNPath(1);
if (markdownNodes.length > 2) {
transaction.insertNodes(
insertedPath,
markdownNodes.skip(1).take(markdownNodes.length - 2).toList(),
);
}
// step 4
final length = nodes.length - 2;
if (length > 0) {
final middleNodes = nodes.skip(1).take(length).toList();
transaction.deleteNodes(middleNodes);
}
await editorState.apply(transaction);
}
}
class AINodeExternalValues extends NodeExternalValues {

View file

@ -47,7 +47,6 @@ class _CopyButton extends StatelessWidget {
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.document_codeBlock_codeCopiedSnackbar.tr(),
);
}

View file

@ -73,7 +73,6 @@ extension PasteFromImage on EditorState {
Log.info('unsupported format: $format');
if (UniversalPlatform.isMobile) {
showToastNotification(
context,
message: LocaleKeys.document_imageBlock_error_invalidImageFormat.tr(),
);
}
@ -112,7 +111,6 @@ extension PasteFromImage on EditorState {
if (errorMessage != null && context.mounted) {
showToastNotification(
context,
message: errorMessage,
);
return false;
@ -131,7 +129,6 @@ extension PasteFromImage on EditorState {
Log.error('cannot copy image file', e);
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.document_imageBlock_error_invalidImage.tr(),
);
}

View file

@ -154,7 +154,6 @@ class _ErrorBlockComponentWidgetState extends State<ErrorBlockComponentWidget>
void _copyBlockContent() {
showToastNotification(
context,
message: LocaleKeys.document_errorBlock_blockContentHasBeenCopied.tr(),
);

View file

@ -105,7 +105,6 @@ Future<void> downloadMediaFile(
} else {
if (userProfile == null) {
return showToastNotification(
context,
message: LocaleKeys.grid_media_downloadFailedToken.tr(),
);
}
@ -128,14 +127,12 @@ Future<void> downloadMediaFile(
if (result != null && context.mounted) {
showToastNotification(
context,
type: ToastificationType.error,
message: LocaleKeys.grid_media_downloadSuccess.tr(),
);
}
} else if (context.mounted) {
showToastNotification(
context,
type: ToastificationType.error,
message: LocaleKeys.document_plugins_image_imageDownloadFailed.tr(),
);
@ -159,13 +156,11 @@ Future<void> downloadMediaFile(
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.grid_media_downloadSuccess.tr(),
);
}
} else if (context.mounted) {
showToastNotification(
context,
type: ToastificationType.error,
message: LocaleKeys.document_plugins_image_imageDownloadFailed.tr(),
);

View file

@ -378,7 +378,6 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
onTap: () async {
context.pop();
showToastNotification(
context,
message: LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(),
);
await getIt<ClipboardService>().setPlainText(url);
@ -431,7 +430,6 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
);
if (mounted) {
showToastNotification(
context,
message: result.isSuccess
? LocaleKeys.document_imageBlock_successToAddImageToGallery.tr()
: LocaleKeys.document_imageBlock_failedToAddImageToGallery.tr(),

View file

@ -117,14 +117,12 @@ class _ImageMenuState extends State<ImageMenu> {
if (mounted) {
showToastNotification(
context,
message: LocaleKeys.message_copy_success.tr(),
);
}
} catch (e) {
if (mounted) {
showToastNotification(
context,
message: LocaleKeys.message_copy_fail.tr(),
type: ToastificationType.error,
);

View file

@ -218,7 +218,6 @@ class _MultiImageMenuState extends State<MultiImageMenu> {
ClipboardData(text: images[widget.indexNotifier.value].url),
);
showToastNotification(
context,
message: LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(),
);
}

View file

@ -1,16 +1,15 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
class CustomLinkPreviewMenu extends StatefulWidget {
@ -122,14 +121,7 @@ class _CustomLinkPreviewMenuState extends State<CustomLinkPreviewMenu> {
break;
case LinkPreviewMenuCommand.copyLink:
if (url != null) {
await Clipboard.setData(ClipboardData(text: url));
if (context.mounted) {
showToastNotification(
// ignore: use_build_context_synchronously
context,
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
);
}
await context.copyLink(url);
}
break;
case LinkPreviewMenuCommand.replace:

View file

@ -100,7 +100,6 @@ class ChildPageTransactionHandler extends MentionTransactionHandler {
Log.error(error);
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.document_plugins_subPage_errors_failedDeletePage
.tr(),
);

View file

@ -38,10 +38,6 @@ Future<bool> emojiCommandHandler(
return false;
}
if (!selection.isCollapsed) {
await editorState.deleteSelection(selection);
}
final node = editorState.getNodeAtPath(selection.end.path);
final delta = node?.delta;
if (node == null ||
@ -58,6 +54,8 @@ Future<bool> emojiCommandHandler(
if (previousCharacter != _emojiCharacter) return false;
if (!context.mounted) return false;
if (!selection.isCollapsed) return false;
await editorState.insertTextAtPosition(
character,
position: selection.start,

View file

@ -174,11 +174,10 @@ class ExportTab extends StatelessWidget {
ClipboardServiceData(plainText: markdown),
);
showToastNotification(
context,
message: LocaleKeys.message_copy_success.tr(),
);
},
(error) => showToastNotification(context, message: error.msg),
(error) => showToastNotification(message: error.msg),
);
}
}

View file

@ -85,11 +85,9 @@ class PublishTab extends StatelessWidget {
if (state.publishResult != null) {
state.publishResult!.fold(
(value) => showToastNotification(
context,
message: LocaleKeys.publish_publishSuccessfully.tr(),
),
(error) => showToastNotification(
context,
message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}',
type: ToastificationType.error,
),
@ -97,11 +95,9 @@ class PublishTab extends StatelessWidget {
} else if (state.unpublishResult != null) {
state.unpublishResult!.fold(
(value) => showToastNotification(
context,
message: LocaleKeys.publish_unpublishSuccessfully.tr(),
),
(error) => showToastNotification(
context,
message: LocaleKeys.publish_unpublishFailed.tr(),
description: error.msg,
type: ToastificationType.error,
@ -110,14 +106,12 @@ class PublishTab extends StatelessWidget {
} else if (state.updatePathNameResult != null) {
state.updatePathNameResult!.fold(
(value) => showToastNotification(
context,
message: LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(),
),
(error) {
Log.error('update path name failed: $error');
showToastNotification(
context,
message: LocaleKeys.settings_sites_error_updatePathNameFailed.tr(),
type: ToastificationType.error,
description: error.code.publishErrorMessage,
@ -182,7 +176,6 @@ class _PublishedWidgetState extends State<_PublishedWidget> {
);
showToastNotification(
context,
message: LocaleKeys.message_copy_success.tr(),
);
},
@ -292,7 +285,6 @@ class _PublishWidgetState extends State<_PublishWidget> {
// check if any database is selected
if (_selectedViews.isEmpty) {
showToastNotification(
context,
message: LocaleKeys.publish_noDatabaseSelected.tr(),
);
return;
@ -611,7 +603,6 @@ class _PublishDatabaseSelectorState extends State<_PublishDatabaseSelector> {
// unable to deselect the primary database
if (isPrimaryDatabase) {
showToastNotification(
context,
message:
LocaleKeys.publish_unableToDeselectPrimaryDatabase.tr(),
);

View file

@ -70,7 +70,6 @@ class ShareButton extends StatelessWidget {
case ShareType.html:
case ShareType.csv:
showToastNotification(
context,
message: LocaleKeys.settings_files_exportFileSuccess.tr(),
);
break;
@ -81,7 +80,6 @@ class ShareButton extends StatelessWidget {
void _handleExportError(BuildContext context, FlowyError error) {
showToastNotification(
context,
message:
'${LocaleKeys.settings_files_exportFileFail.tr()}: ${error.code}',
);

View file

@ -117,7 +117,6 @@ class _ShareTabContent extends StatelessWidget {
);
showToastNotification(
context,
message: LocaleKeys.message_copy_success.tr(),
);
}

View file

@ -45,7 +45,6 @@ class _MobileSyncErrorPage extends StatelessWidget {
onTapUp: () {
getIt<ClipboardService>().setPlainText(error.toString());
showToastNotification(
context,
message: LocaleKeys.message_copy_success.tr(),
bottomPadding: 0,
);
@ -101,7 +100,7 @@ class _DesktopSyncErrorPage extends StatelessWidget {
onTapUp: () {
getIt<ClipboardService>().setPlainText(error.toString());
showToastNotification(
context,
message: LocaleKeys.message_copy_success.tr(),
bottomPadding: 0,
);

View file

@ -27,6 +27,7 @@ Future<String> customDocumentToMarkdown(
Document document, {
String path = '',
AsyncValueSetter<Archive>? onArchive,
String lineBreak = '',
}) async {
final List<Future<ArchiveFile>> fileFutures = [];
@ -41,6 +42,7 @@ Future<String> customDocumentToMarkdown(
try {
markdown = documentToMarkdown(
document,
lineBreak: lineBreak,
customParsers: [
const MathEquationNodeParser(),
const CalloutNodeParser(),

View file

@ -129,7 +129,7 @@ class AppFlowyCloudDeepLink {
final context = AppGlobals.rootNavKey.currentState?.context;
if (context != null) {
showToastNotification(
context,
message: err.msg,
);
}

View file

@ -17,14 +17,14 @@ void handleOpenWorkspaceError(BuildContext context, FlowyError error) {
case ErrorCode.InvalidEncryptSecret:
case ErrorCode.NetworkError:
showToastNotification(
context,
message: error.msg,
type: ToastificationType.error,
);
break;
default:
showToastNotification(
context,
message: error.msg,
type: ToastificationType.error,
callbacks: ToastificationCallbacks(

View file

@ -65,7 +65,7 @@ class _SignInWithMagicLinkButtonsState
void _sendMagicLink(BuildContext context, String email) {
if (!isEmail(email)) {
return showToastNotification(
context,
message: LocaleKeys.signIn_invalidEmail.tr(),
type: ToastificationType.error,
);

View file

@ -25,7 +25,7 @@ Future<void> shareLogFiles(BuildContext? context) async {
if (archiveLogFiles.isEmpty) {
if (context != null && context.mounted) {
showToastNotification(
context,
message: LocaleKeys.noLogFiles.tr(),
type: ToastificationType.error,
);
@ -42,7 +42,7 @@ Future<void> shareLogFiles(BuildContext? context) async {
if (zip == null) {
if (context != null && context.mounted) {
showToastNotification(
context,
message: LocaleKeys.noLogFiles.tr(),
type: ToastificationType.error,
);
@ -72,7 +72,7 @@ Future<void> shareLogFiles(BuildContext? context) async {
} catch (e) {
if (context != null && context.mounted) {
showToastNotification(
context,
message: e.toString(),
type: ToastificationType.error,
);

View file

@ -2,11 +2,13 @@ import 'dart:async';
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/user_settings_service.dart';
import 'package:appflowy/util/color_to_hex_string.dart';
import 'package:appflowy/workspace/application/appearance_defaults.dart';
import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart';
@ -17,6 +19,7 @@ import 'package:flowy_infra/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:universal_platform/universal_platform.dart';
part 'appearance_cubit.freezed.dart';
@ -97,7 +100,19 @@ class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
Future<void> setTheme(String themeName) async {
_appearanceSettings.theme = themeName;
unawaited(_saveAppearanceSettings());
emit(state.copyWith(appTheme: await AppTheme.fromName(themeName)));
try {
final theme = await AppTheme.fromName(themeName);
emit(state.copyWith(appTheme: theme));
} catch (e) {
Log.error("Error setting theme: $e");
if (UniversalPlatform.isMacOS) {
showToastNotification(
message:
LocaleKeys.settings_workspacePage_theme_failedToLoadThemes.tr(),
type: ToastificationType.error,
);
}
}
}
/// Reset the current user selected theme back to the default

View file

@ -169,7 +169,7 @@ class _SidebarWorkspaceState extends State<SidebarWorkspace> {
if (message != null) {
showToastNotification(
context,
message: message,
type: result.fold(
(_) => ToastificationType.success,

View file

@ -193,7 +193,7 @@ Future<void> deleteMyAccount(
if (!isChecked) {
showToastNotification(
context,
type: ToastificationType.warning,
bottomPadding: bottomPadding,
message: LocaleKeys
@ -208,7 +208,7 @@ Future<void> deleteMyAccount(
if (confirmText.isEmpty || !_isConfirmTextValid(confirmText)) {
showToastNotification(
context,
type: ToastificationType.warning,
bottomPadding: bottomPadding,
message: LocaleKeys
@ -226,7 +226,7 @@ Future<void> deleteMyAccount(
loading.stop();
showToastNotification(
context,
message: LocaleKeys
.newSettings_myAccount_deleteAccount_deleteAccountSuccess
.tr(),
@ -245,7 +245,7 @@ Future<void> deleteMyAccount(
loading.stop();
showToastNotification(
context,
type: ToastificationType.error,
bottomPadding: bottomPadding,
message: f.msg,

View file

@ -140,10 +140,8 @@ class LocalAISettingPanel extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const LocalAIStatusIndicator(),
if (state.showSettings) ...[
const VSpace(10),
OllamaSettingPage(),
],
const VSpace(10),
OllamaSettingPage(),
],
);
},

View file

@ -288,6 +288,10 @@ class _LackOfResource extends StatelessWidget {
text: LocaleKeys.settings_aiPage_keys_modelsMissing.tr(),
style: textStyle,
),
TextSpan(
text: modelNames.join(', '),
style: textStyle,
),
TextSpan(
text: ' ',
style: textStyle,

View file

@ -157,7 +157,7 @@ class SettingsManageDataView extends StatelessWidget {
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys
.settings_manageDataPage_cache_dialog_successHint
.tr(),

View file

@ -53,7 +53,6 @@ class SettingsPageSitesEvent {
);
getIt<ClipboardService>().setData(ClipboardServiceData(plainText: url));
showToastNotification(
context,
message: LocaleKeys.message_copy_success.tr(),
);
}

View file

@ -253,7 +253,6 @@ class _FreePlanUpgradeButton extends StatelessWidget {
onTap: () {
if (isOwner) {
showToastNotification(
context,
message:
LocaleKeys.settings_sites_namespace_redirectToPayment.tr(),
type: ToastificationType.info,
@ -264,7 +263,6 @@ class _FreePlanUpgradeButton extends StatelessWidget {
);
} else {
showToastNotification(
context,
message: LocaleKeys
.settings_sites_namespace_pleaseAskOwnerToSetHomePage
.tr(),

View file

@ -216,7 +216,6 @@ class _DomainSettingsDialogState extends State<DomainSettingsDialog> {
result.fold(
(s) {
showToastNotification(
context,
message: LocaleKeys.settings_sites_success_namespaceUpdated.tr(),
);
@ -234,7 +233,6 @@ class _DomainSettingsDialogState extends State<DomainSettingsDialog> {
Log.error('Failed to update namespace: $f');
showToastNotification(
context,
message: basicErrorMessage,
type: ToastificationType.error,
description: errorMessage,

View file

@ -203,7 +203,6 @@ class _PublishedViewSettingsDialogState
result.fold(
(s) {
showToastNotification(
context,
message: LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(),
);
Navigator.of(context).pop();
@ -212,7 +211,6 @@ class _PublishedViewSettingsDialogState
Log.error('update path name failed: $f');
showToastNotification(
context,
message: LocaleKeys.settings_sites_error_updatePathNameFailed.tr(),
type: ToastificationType.error,
description: f.code.publishErrorMessage,

View file

@ -178,7 +178,6 @@ class _SettingsSitesPageView extends StatelessWidget {
Log.error('Failed to generate payment link for Pro Plan: ${f.msg}');
showToastNotification(
context,
message:
LocaleKeys.settings_sites_error_failedToGeneratePaymentLink.tr(),
type: ToastificationType.error,
@ -188,14 +187,12 @@ class _SettingsSitesPageView extends StatelessWidget {
result != null) {
result.fold((_) {
showToastNotification(
context,
message: LocaleKeys.publish_unpublishSuccessfully.tr(),
);
}, (f) {
Log.error('Failed to unpublish view: ${f.msg}');
showToastNotification(
context,
message: LocaleKeys.publish_unpublishFailed.tr(),
type: ToastificationType.error,
description: f.msg,
@ -204,14 +201,12 @@ class _SettingsSitesPageView extends StatelessWidget {
} else if (type == SettingsSitesActionType.setHomePage && result != null) {
result.fold((s) {
showToastNotification(
context,
message: LocaleKeys.settings_sites_success_setHomepageSuccess.tr(),
);
}, (f) {
Log.error('Failed to set homepage: ${f.msg}');
showToastNotification(
context,
message: LocaleKeys.settings_sites_error_setHomepageFailed.tr(),
type: ToastificationType.error,
);
@ -220,14 +215,12 @@ class _SettingsSitesPageView extends StatelessWidget {
result != null) {
result.fold((s) {
showToastNotification(
context,
message: LocaleKeys.settings_sites_success_removeHomePageSuccess.tr(),
);
}, (f) {
Log.error('Failed to remove homepage: ${f.msg}');
showToastNotification(
context,
message: LocaleKeys.settings_sites_error_removeHomePageFailed.tr(),
type: ToastificationType.error,
);

View file

@ -363,7 +363,6 @@ class _SelfHostSettingsState extends State<_SelfHostSettings> {
}) async {
if (cloudUrl.isEmpty || webUrl.isEmpty) {
showToastNotification(
context,
message: LocaleKeys.settings_menu_pleaseInputValidURL.tr(),
type: ToastificationType.error,
);
@ -375,7 +374,6 @@ class _SelfHostSettingsState extends State<_SelfHostSettings> {
if (mounted) {
if (isValid) {
showToastNotification(
context,
message: LocaleKeys.settings_menu_changeUrl.tr(args: [cloudUrl]),
);
@ -387,7 +385,6 @@ class _SelfHostSettingsState extends State<_SelfHostSettings> {
await runAppFlowy();
} else {
showToastNotification(
context,
message: LocaleKeys.settings_menu_pleaseInputValidURL.tr(),
type: ToastificationType.error,
);
@ -522,7 +519,6 @@ class _SupportSettings extends StatelessWidget {
await getIt<FlowyCacheManager>().clearAllCache();
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys
.settings_manageDataPage_cache_dialog_successHint
.tr(),

View file

@ -71,7 +71,7 @@ class AppFlowyCloudViewSetting extends StatelessWidget {
const VSpace(8),
const AppFlowyCloudEnableSync(),
const VSpace(6),
const AppFlowyCloudSyncLogEnabled(),
// const AppFlowyCloudSyncLogEnabled(),
const VSpace(12),
RestartButton(
onClick: () {

View file

@ -157,7 +157,6 @@ class _NavigatorTextFieldDialogState extends State<NavigatorTextFieldDialog> {
onOkPressed: () {
if (newValue.isEmpty) {
showToastNotification(
context,
message: LocaleKeys.space_spaceNameCannotBeEmpty.tr(),
);
return;
@ -363,8 +362,7 @@ class OkCancelButton extends StatelessWidget {
}
}
void showToastNotification(
BuildContext context, {
void showToastNotification({
String? message,
TextSpan? richMessage,
String? description,

View file

@ -51,7 +51,6 @@ class FlowyVersionSection extends CustomActionCell {
}
enableDocumentInternalLog = !enableDocumentInternalLog;
showToastNotification(
context,
message: enableDocumentInternalLog
? 'Enabled Internal Log'
: 'Disabled Internal Log',

View file

@ -74,7 +74,6 @@ class ViewTitleBar extends StatelessWidget {
listener: (context, state) {
if (state.isLocked) {
showToastNotification(
context,
message: LocaleKeys.lockPage_pageLockedToast.tr(),
);
}

View file

@ -98,8 +98,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "552f95f"
resolved-ref: "552f95fd15627e10a138c6db2a6d0a8089bc9a25"
ref: "361b99c38370abeeb19656f89e8c31cb3666623b"
resolved-ref: "361b99c38370abeeb19656f89e8c31cb3666623b"
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git
version: "5.1.0"

View file

@ -2,13 +2,13 @@ name: appflowy
description: Bring projects, wikis, and teams together with AI. AppFlowy is an
AI collaborative workspace where you achieve more without losing control of
your data. The best open source alternative to Notion.
publish_to: "none"
publish_to: 'none'
version: 0.8.8
version: 0.8.9
environment:
flutter: ">=3.27.4"
sdk: ">=3.3.0 <4.0.0"
flutter: '>=3.27.4'
sdk: '>=3.3.0 <4.0.0'
dependencies:
any_date: ^1.0.4
@ -41,7 +41,7 @@ dependencies:
calendar_view:
git:
url: https://github.com/Xazin/flutter_calendar_view
ref: "6fe0c98"
ref: '6fe0c98'
collection: ^1.17.1
connectivity_plus: ^5.0.2
cross_file: ^0.3.4+1
@ -77,7 +77,7 @@ dependencies:
flutter_emoji_mart:
git:
url: https://github.com/LucasXu0/emoji_mart.git
ref: "355aa56"
ref: '355aa56'
flutter_math_fork: ^0.7.3
flutter_slidable: ^3.0.0
@ -187,13 +187,13 @@ dependency_overrides:
appflowy_editor:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: "552f95f"
ref: '361b99c38370abeeb19656f89e8c31cb3666623b'
appflowy_editor_plugins:
git:
url: https://github.com/AppFlowy-IO/AppFlowy-plugins.git
path: "packages/appflowy_editor_plugins"
ref: "4efcff7"
path: 'packages/appflowy_editor_plugins'
ref: '4efcff7'
sheet:
git:

View file

@ -375,7 +375,7 @@ void main() {
await blocResponseFuture();
bloc.runResponseAction(SuggestionAction.accept);
await blocResponseFuture();
expect(editorState.document.root.children.length, 1);
expect(editorState.document.root.children.length, 2);
expect(
editorState.getNodeAtPath([0])!.delta!.toPlainText(),
'Hello World',

View file

@ -294,6 +294,514 @@ void main() {
);
});
});
group('markdown text robot - replace in same line:', () {
final text1 =
'''The introduction of the World Wide Web in the early 1990s marked a turning point. ''';
final text2 =
'''Tim Berners-Lee's invention made the internet accessible to non-technical users, opening the floodgates for mass adoption. ''';
final text3 =
'''Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity, allowing for real-time text communication.''';
Document buildTestDocument() {
return Document(
root: pageNode(
children: [
paragraphNode(delta: Delta()..insert(text1 + text2 + text3)),
],
),
);
}
// 1. create a document with a paragraph node
// 2. use the text robot to replace the selected content in the same line
// 3. check the document
test('the selection is in the middle of the text', () async {
final document = buildTestDocument();
final editorState = EditorState(document: document);
editorState.selection = Selection(
start: Position(
path: [0],
offset: text1.length,
),
end: Position(
path: [0],
offset: text1.length + text2.length,
),
);
final markdownText =
'''Tim Berners-Lee's invention of the **World Wide Web** transformed the internet, making it accessible to _non-technical users_ and opening the floodgates for global mass adoption.''';
final markdownTextRobot = MarkdownTextRobot(
editorState: editorState,
);
await markdownTextRobot.replace(
selection: editorState.selection!,
markdownText: markdownText,
);
final afterDelta = editorState.document.root.children[0].delta!.toList();
expect(afterDelta.length, 5);
final d1 = afterDelta[0] as TextInsert;
expect(d1.text, '${text1}Tim Berners-Lee\'s invention of the ');
expect(d1.attributes, null);
final d2 = afterDelta[1] as TextInsert;
expect(d2.text, 'World Wide Web');
expect(d2.attributes, {AppFlowyRichTextKeys.bold: true});
final d3 = afterDelta[2] as TextInsert;
expect(d3.text, ' transformed the internet, making it accessible to ');
expect(d3.attributes, null);
final d4 = afterDelta[3] as TextInsert;
expect(d4.text, 'non-technical users');
expect(d4.attributes, {AppFlowyRichTextKeys.italic: true});
final d5 = afterDelta[4] as TextInsert;
expect(
d5.text,
' and opening the floodgates for global mass adoption.$text3',
);
expect(d5.attributes, null);
});
test('replace markdown text with selection from start to middle', () async {
final document = buildTestDocument();
final editorState = EditorState(document: document);
editorState.selection = Selection(
start: Position(
path: [0],
),
end: Position(
path: [0],
offset: text1.length,
),
);
final markdownText =
'''The **invention** of the _World Wide Web_ by Tim Berners-Lee transformed how we access information.''';
final markdownTextRobot = MarkdownTextRobot(
editorState: editorState,
);
await markdownTextRobot.replace(
selection: editorState.selection!,
markdownText: markdownText,
);
final afterDelta = editorState.document.root.children[0].delta!.toList();
expect(afterDelta.length, 5);
final d1 = afterDelta[0] as TextInsert;
expect(d1.text, 'The ');
expect(d1.attributes, null);
final d2 = afterDelta[1] as TextInsert;
expect(d2.text, 'invention');
expect(d2.attributes, {AppFlowyRichTextKeys.bold: true});
final d3 = afterDelta[2] as TextInsert;
expect(d3.text, ' of the ');
expect(d3.attributes, null);
final d4 = afterDelta[3] as TextInsert;
expect(d4.text, 'World Wide Web');
expect(d4.attributes, {AppFlowyRichTextKeys.italic: true});
final d5 = afterDelta[4] as TextInsert;
expect(
d5.text,
' by Tim Berners-Lee transformed how we access information.$text2$text3',
);
expect(d5.attributes, null);
});
test('replace markdown text with selection from middle to end', () async {
final document = buildTestDocument();
final editorState = EditorState(document: document);
editorState.selection = Selection(
start: Position(
path: [0],
offset: text1.length + text2.length,
),
end: Position(
path: [0],
offset: text1.length + text2.length + text3.length,
),
);
final markdownText =
'''**Email** became widespread, and instant messaging services like *ICQ* and **AOL Instant Messenger** gained tremendous popularity, allowing for seamless real-time text communication across the globe.''';
final markdownTextRobot = MarkdownTextRobot(
editorState: editorState,
);
await markdownTextRobot.replace(
selection: editorState.selection!,
markdownText: markdownText,
);
final afterDelta = editorState.document.root.children[0].delta!.toList();
expect(afterDelta.length, 7);
final d1 = afterDelta[0] as TextInsert;
expect(
d1.text,
text1 + text2,
);
expect(d1.attributes, null);
final d2 = afterDelta[1] as TextInsert;
expect(d2.text, 'Email');
expect(d2.attributes, {AppFlowyRichTextKeys.bold: true});
final d3 = afterDelta[2] as TextInsert;
expect(
d3.text,
' became widespread, and instant messaging services like ',
);
expect(d3.attributes, null);
final d4 = afterDelta[3] as TextInsert;
expect(d4.text, 'ICQ');
expect(d4.attributes, {AppFlowyRichTextKeys.italic: true});
final d5 = afterDelta[4] as TextInsert;
expect(d5.text, ' and ');
expect(d5.attributes, null);
final d6 = afterDelta[5] as TextInsert;
expect(
d6.text,
'AOL Instant Messenger',
);
expect(d6.attributes, {AppFlowyRichTextKeys.bold: true});
final d7 = afterDelta[6] as TextInsert;
expect(
d7.text,
' gained tremendous popularity, allowing for seamless real-time text communication across the globe.',
);
expect(d7.attributes, null);
});
});
group('markdown text robot - replace in multiple lines:', () {
final text1 =
'''The introduction of the World Wide Web in the early 1990s marked a turning point. ''';
final text2 =
'''Tim Berners-Lee's invention made the internet accessible to non-technical users, opening the floodgates for mass adoption. ''';
final text3 =
'''Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity, allowing for real-time text communication.''';
Document buildTestDocument() {
return Document(
root: pageNode(
children: [
paragraphNode(delta: Delta()..insert(text1)),
paragraphNode(delta: Delta()..insert(text2)),
paragraphNode(delta: Delta()..insert(text3)),
],
),
);
}
// 1. create a document with 3 paragraph nodes
// 2. use the text robot to replace the selected content in the multiple lines
// 3. check the document
test(
'the selection starts with the first paragraph and ends with the middle of second paragraph',
() async {
final document = buildTestDocument();
final editorState = EditorState(document: document);
editorState.selection = Selection(
start: Position(
path: [0],
),
end: Position(
path: [1],
offset: text2.length -
', opening the floodgates for mass adoption. '.length,
),
);
final markdownText =
'''The **introduction** of the World Wide Web in the *early 1990s* marked a significant turning point.
Tim Berners-Lee's **revolutionary invention** made the internet accessible to non-technical users''';
final markdownTextRobot = MarkdownTextRobot(
editorState: editorState,
);
await markdownTextRobot.replace(
selection: editorState.selection!,
markdownText: markdownText,
);
final afterNodes = editorState.document.root.children;
expect(afterNodes.length, 3);
{
// first paragraph
final delta1 = afterNodes[0].delta!.toList();
expect(delta1.length, 5);
final d1 = delta1[0] as TextInsert;
expect(d1.text, 'The ');
expect(d1.attributes, null);
final d2 = delta1[1] as TextInsert;
expect(d2.text, 'introduction');
expect(d2.attributes, {AppFlowyRichTextKeys.bold: true});
final d3 = delta1[2] as TextInsert;
expect(d3.text, ' of the World Wide Web in the ');
expect(d3.attributes, null);
final d4 = delta1[3] as TextInsert;
expect(d4.text, 'early 1990s');
expect(d4.attributes, {AppFlowyRichTextKeys.italic: true});
final d5 = delta1[4] as TextInsert;
expect(d5.text, ' marked a significant turning point.');
expect(d5.attributes, null);
}
{
// second paragraph
final delta2 = afterNodes[1].delta!.toList();
expect(delta2.length, 3);
final d1 = delta2[0] as TextInsert;
expect(d1.text, "Tim Berners-Lee's ");
expect(d1.attributes, null);
final d2 = delta2[1] as TextInsert;
expect(d2.text, "revolutionary invention");
expect(d2.attributes, {AppFlowyRichTextKeys.bold: true});
final d3 = delta2[2] as TextInsert;
expect(
d3.text,
" made the internet accessible to non-technical users, opening the floodgates for mass adoption. ",
);
expect(d3.attributes, null);
}
{
// third paragraph
final delta3 = afterNodes[2].delta!.toList();
expect(delta3.length, 1);
final d1 = delta3[0] as TextInsert;
expect(d1.text, text3);
expect(d1.attributes, null);
}
});
test(
'the selection starts with the middle of the first paragraph and ends with the middle of last paragraph',
() async {
final document = buildTestDocument();
final editorState = EditorState(document: document);
editorState.selection = Selection(
start: Position(
path: [0],
offset: 'The introduction of the World Wide Web'.length,
),
end: Position(
path: [2],
offset:
'Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity'
.length,
),
);
final markdownText =
''' in the **early 1990s** marked a *significant turning point* in technological history.
Tim Berners-Lee's **revolutionary invention** made the internet accessible to non-technical users, opening the floodgates for *unprecedented mass adoption*.
Email became **widely prevalent**, and instant messaging services like *ICQ* and *AOL Instant Messenger* gained tremendous popularity
''';
final markdownTextRobot = MarkdownTextRobot(
editorState: editorState,
);
await markdownTextRobot.replace(
selection: editorState.selection!,
markdownText: markdownText,
);
final afterNodes = editorState.document.root.children;
expect(afterNodes.length, 3);
{
// first paragraph
final delta1 = afterNodes[0].delta!.toList();
expect(delta1.length, 5);
final d1 = delta1[0] as TextInsert;
expect(d1.text, 'The introduction of the World Wide Web in the ');
expect(d1.attributes, null);
final d2 = delta1[1] as TextInsert;
expect(d2.text, 'early 1990s');
expect(d2.attributes, {AppFlowyRichTextKeys.bold: true});
final d3 = delta1[2] as TextInsert;
expect(d3.text, ' marked a ');
expect(d3.attributes, null);
final d4 = delta1[3] as TextInsert;
expect(d4.text, 'significant turning point');
expect(d4.attributes, {AppFlowyRichTextKeys.italic: true});
final d5 = delta1[4] as TextInsert;
expect(d5.text, ' in technological history.');
expect(d5.attributes, null);
}
{
// second paragraph
final delta2 = afterNodes[1].delta!.toList();
expect(delta2.length, 5);
final d1 = delta2[0] as TextInsert;
expect(d1.text, "Tim Berners-Lee's ");
expect(d1.attributes, null);
final d2 = delta2[1] as TextInsert;
expect(d2.text, "revolutionary invention");
expect(d2.attributes, {AppFlowyRichTextKeys.bold: true});
final d3 = delta2[2] as TextInsert;
expect(
d3.text,
" made the internet accessible to non-technical users, opening the floodgates for ",
);
expect(d3.attributes, null);
final d4 = delta2[3] as TextInsert;
expect(d4.text, "unprecedented mass adoption");
expect(d4.attributes, {AppFlowyRichTextKeys.italic: true});
final d5 = delta2[4] as TextInsert;
expect(d5.text, ".");
expect(d5.attributes, null);
}
{
// third paragraph
// third paragraph
final delta3 = afterNodes[2].delta!.toList();
expect(delta3.length, 7);
final d1 = delta3[0] as TextInsert;
expect(d1.text, "Email became ");
expect(d1.attributes, null);
final d2 = delta3[1] as TextInsert;
expect(d2.text, "widely prevalent");
expect(d2.attributes, {AppFlowyRichTextKeys.bold: true});
final d3 = delta3[2] as TextInsert;
expect(d3.text, ", and instant messaging services like ");
expect(d3.attributes, null);
final d4 = delta3[3] as TextInsert;
expect(d4.text, "ICQ");
expect(d4.attributes, {AppFlowyRichTextKeys.italic: true});
final d5 = delta3[4] as TextInsert;
expect(d5.text, " and ");
expect(d5.attributes, null);
final d6 = delta3[5] as TextInsert;
expect(d6.text, "AOL Instant Messenger");
expect(d6.attributes, {AppFlowyRichTextKeys.italic: true});
final d7 = delta3[6] as TextInsert;
expect(
d7.text,
" gained tremendous popularity, allowing for real-time text communication.",
);
expect(d7.attributes, null);
}
});
test(
'the length of the returned response less than the length of the selected text',
() async {
final document = buildTestDocument();
final editorState = EditorState(document: document);
editorState.selection = Selection(
start: Position(
path: [0],
offset: 'The introduction of the World Wide Web'.length,
),
end: Position(
path: [2],
offset:
'Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity'
.length,
),
);
final markdownText =
''' in the **early 1990s** marked a *significant turning point* in technological history.''';
final markdownTextRobot = MarkdownTextRobot(
editorState: editorState,
);
await markdownTextRobot.replace(
selection: editorState.selection!,
markdownText: markdownText,
);
final afterNodes = editorState.document.root.children;
expect(afterNodes.length, 2);
{
// first paragraph
final delta1 = afterNodes[0].delta!.toList();
expect(delta1.length, 5);
final d1 = delta1[0] as TextInsert;
expect(d1.text, "The introduction of the World Wide Web in the ");
expect(d1.attributes, null);
final d2 = delta1[1] as TextInsert;
expect(d2.text, "early 1990s");
expect(d2.attributes, {AppFlowyRichTextKeys.bold: true});
final d3 = delta1[2] as TextInsert;
expect(d3.text, " marked a ");
expect(d3.attributes, null);
final d4 = delta1[3] as TextInsert;
expect(d4.text, "significant turning point");
expect(d4.attributes, {AppFlowyRichTextKeys.italic: true});
final d5 = delta1[4] as TextInsert;
expect(d5.text, " in technological history.");
expect(d5.attributes, null);
}
{
// second paragraph
final delta2 = afterNodes[1].delta!.toList();
expect(delta2.length, 1);
final d1 = delta2[0] as TextInsert;
expect(d1.text, ", allowing for real-time text communication.");
expect(d1.attributes, null);
}
});
});
}
const _sample1 = '''# The Curious Cat

View file

@ -628,7 +628,8 @@
"theme": {
"title": "Theme",
"description": "Select a preset theme, or upload your own custom theme.",
"uploadCustomThemeTooltip": "Upload a custom theme"
"uploadCustomThemeTooltip": "Upload a custom theme",
"failedToLoadThemes": "Failed to load themes, please check your permission settings in System Settings > Privacy and Security > Files and Folders > @:appName"
},
"workspaceFont": {
"title": "Workspace font",
@ -884,7 +885,7 @@
"pleaseFollowThese": "Please follow these",
"instructions": "instructions",
"installOllamaLai": "to set up Ollama and AppFlowy Local AI.",
"modelsMissing": "Cannot find the required models.",
"modelsMissing": "Cannot find the required models: ",
"downloadModel": "to download them."
}
},

View file

@ -345,12 +345,11 @@ dependencies = [
[[package]]
name = "af-local-ai"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=4b3d50cbec2f58be2ac385231b8f585f1555e282#4b3d50cbec2f58be2ac385231b8f585f1555e282"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=9d731d89ea2e0fd764da2effa8a456210c5a39c3#9d731d89ea2e0fd764da2effa8a456210c5a39c3"
dependencies = [
"af-plugin",
"anyhow",
"bytes",
"futures",
"reqwest 0.11.27",
"serde",
"serde_json",
@ -358,14 +357,12 @@ dependencies = [
"tokio-stream",
"tokio-util",
"tracing",
"zip 2.2.0",
"zip-extensions",
]
[[package]]
name = "af-mcp"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=4b3d50cbec2f58be2ac385231b8f585f1555e282#4b3d50cbec2f58be2ac385231b8f585f1555e282"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=9d731d89ea2e0fd764da2effa8a456210c5a39c3#9d731d89ea2e0fd764da2effa8a456210c5a39c3"
dependencies = [
"anyhow",
"futures-util",
@ -379,7 +376,7 @@ dependencies = [
[[package]]
name = "af-plugin"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=4b3d50cbec2f58be2ac385231b8f585f1555e282#4b3d50cbec2f58be2ac385231b8f585f1555e282"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=9d731d89ea2e0fd764da2effa8a456210c5a39c3#9d731d89ea2e0fd764da2effa8a456210c5a39c3"
dependencies = [
"anyhow",
"cfg-if",
@ -2504,7 +2501,6 @@ dependencies = [
"tracing-subscriber",
"uuid",
"validator 0.18.1",
"winreg 0.55.0",
"zip 2.2.0",
"zip-extensions",
]

View file

@ -152,6 +152,6 @@ collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFl
# To update the commit ID, run:
# scripts/tool/update_local_ai_rev.sh new_rev_id
# ⚠️⚠️⚠️️
af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "4b3d50cbec2f58be2ac385231b8f585f1555e282" }
af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "4b3d50cbec2f58be2ac385231b8f585f1555e282" }
af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "4b3d50cbec2f58be2ac385231b8f585f1555e282" }
af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "9d731d89ea2e0fd764da2effa8a456210c5a39c3" }
af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "9d731d89ea2e0fd764da2effa8a456210c5a39c3" }
af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "9d731d89ea2e0fd764da2effa8a456210c5a39c3" }

View file

@ -53,11 +53,6 @@ collab-integrate.workspace = true
notify = "6.1.1"
af-mcp = { version = "0.1.0" }
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.55"
#cmd_lib = { version = "1.9.5" }
[dev-dependencies]
dotenv = "0.15.0"
uuid.workspace = true

View file

@ -13,9 +13,9 @@ use futures::Sink;
use lib_infra::async_trait::async_trait;
use std::collections::HashMap;
use crate::local_ai::watch::is_plugin_ready;
use crate::stream_message::StreamMessage;
use af_local_ai::ollama_plugin::OllamaAIPlugin;
use af_plugin::core::path::is_plugin_ready;
use af_plugin::core::plugin::RunningState;
use arc_swap::ArcSwapOption;
use futures_util::SinkExt;

View file

@ -5,13 +5,13 @@ use flowy_error::{ErrorCode, FlowyError, FlowyResult};
use lib_infra::async_trait::async_trait;
use crate::entities::LackOfAIResourcePB;
use crate::local_ai::watch::{is_plugin_ready, ollama_plugin_path};
#[cfg(target_os = "macos")]
use crate::local_ai::watch::{watch_offline_app, WatchContext};
use crate::notification::{
chat_notification_builder, ChatNotification, APPFLOWY_AI_NOTIFICATION_KEY,
};
use af_local_ai::ollama_plugin::OllamaPluginConfig;
use af_plugin::core::path::{is_plugin_ready, ollama_plugin_path};
use lib_infra::util::{get_operating_system, OperatingSystem};
use reqwest::Client;
use serde::Deserialize;
@ -195,9 +195,14 @@ impl LocalAIResourceController {
let tags: TagsResponse = resp.json().await.inspect_err(|e| {
log::error!("[LLM Resource] Failed to parse /api/tags JSON response: {e:?}")
})?;
// Check each required model is present in the response.
// Check if each of our required models exists in the list of available models
trace!("[LLM Resource] ollama available models: {:?}", tags.models);
for required in &required_models {
if !tags.models.iter().any(|m| m.name.contains(required)) {
if !tags
.models
.iter()
.any(|m| m.name == *required || m.name == format!("{}:latest", required))
{
log::trace!(
"[LLM Resource] required model '{}' not found in API response",
required

View file

@ -1,13 +1,10 @@
use crate::local_ai::resource::WatchDiskEvent;
use af_plugin::core::path::{install_path, ollama_plugin_path};
use flowy_error::{FlowyError, FlowyResult};
use std::path::PathBuf;
use std::process::Command;
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver};
use tracing::{error, trace};
#[cfg(windows)]
use winreg::{enums::*, RegKey};
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
#[allow(dead_code)]
pub struct WatchContext {
@ -61,131 +58,3 @@ pub fn watch_offline_app() -> FlowyResult<(WatchContext, UnboundedReceiver<Watch
rx,
))
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
pub(crate) fn install_path() -> Option<PathBuf> {
None
}
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
pub(crate) fn install_path() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
return None;
#[cfg(target_os = "macos")]
return Some(PathBuf::from("/usr/local/bin"));
#[cfg(target_os = "linux")]
return None;
}
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
pub fn is_plugin_ready() -> bool {
ollama_plugin_path().exists() || ollama_plugin_command_available()
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
pub fn is_plugin_ready() -> bool {
false
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
pub(crate) fn ollama_plugin_path() -> PathBuf {
PathBuf::new()
}
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
pub(crate) fn ollama_plugin_path() -> std::path::PathBuf {
#[cfg(target_os = "windows")]
{
// Use LOCALAPPDATA for a user-specific installation path on Windows.
let local_appdata =
std::env::var("LOCALAPPDATA").unwrap_or_else(|_| "C:\\Program Files".to_string());
std::path::PathBuf::from(local_appdata).join("Programs\\appflowy_plugin\\af_ollama_plugin.exe")
}
#[cfg(target_os = "macos")]
{
let offline_app = "af_ollama_plugin";
std::path::PathBuf::from(format!("/usr/local/bin/{}", offline_app))
}
#[cfg(target_os = "linux")]
{
let offline_app = "af_ollama_plugin";
std::path::PathBuf::from(format!("/usr/local/bin/{}", offline_app))
}
}
pub(crate) fn ollama_plugin_command_available() -> bool {
if cfg!(windows) {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let output = Command::new("cmd")
.args(&["/C", "where", "af_ollama_plugin"])
.creation_flags(CREATE_NO_WINDOW)
.output();
if let Ok(output) = output {
if !output.stdout.is_empty() {
return true;
}
}
// 2. Fallback: Check registry PATH for the executable
let path_dirs = get_windows_path_dirs();
let plugin_exe = "af_ollama_plugin.exe"; // Adjust name if needed
path_dirs.iter().any(|dir| {
let full_path = std::path::Path::new(dir).join(plugin_exe);
full_path.exists()
})
}
#[cfg(not(windows))]
false
} else {
let output = Command::new("command")
.args(["-v", "af_ollama_plugin"])
.output();
match output {
Ok(o) => !o.stdout.is_empty(),
_ => false,
}
}
}
#[cfg(windows)]
fn get_windows_path_dirs() -> Vec<String> {
let mut paths = Vec::new();
// Check HKEY_CURRENT_USER\Environment
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
if let Ok(env) = hkcu.open_subkey("Environment") {
if let Ok(path) = env.get_value::<String, _>("Path") {
paths.extend(path.split(';').map(|s| s.trim().to_string()));
}
}
// Check HKEY_LOCAL_MACHINE\SYSTEM\...\Environment
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
if let Ok(env) = hklm.open_subkey(r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment")
{
if let Ok(path) = env.get_value::<String, _>("Path") {
paths.extend(path.split(';').map(|s| s.trim().to_string()));
}
}
paths
}
#[cfg(test)]
mod tests {
use crate::local_ai::watch::ollama_plugin_command_available;
#[test]
fn test_command_import() {
let result = ollama_plugin_command_available();
println!("ollama plugin exist: {:?}", result);
}
}