From 2a1cbc7fc08c904271b9a42c493c2376841b2dea Mon Sep 17 00:00:00 2001 From: Alexandre Rose-Pizant Date: Wed, 13 May 2026 15:32:18 +0200 Subject: [PATCH 1/2] feat: add appflowy-flutter://new deep link to create notes from clipper Implements the `appflowy-flutter://new` deep link endpoint that allows external clippers to create documents inside AppFlowy. Supported query parameters: - workspace_id (optional) switch to target workspace first - parent_view_id (optional) target space / folder; falls back to current space - name (optional) document title; defaults to \"New Note\" - content (optional) URL-encoded Markdown body - clipboard (optional flag) read Markdown content from the system clipboard (takes precedence over &content when both are present) New files: - lib/startup/tasks/deeplink/new_note_deeplink_handler.dart DeepLinkHandler that parses the URI, optionally reads the clipboard, and signals note creation via createNoteNotifier. - lib/workspace/presentation/home/menu/sidebar/workspace/note_creation_notifier.dart Global ValueNotifier decoupling the handler (no BuildContext) from the sidebar (has Bloc context). Modified files: - lib/startup/tasks/appflowy_cloud_task.dart Registers NewNoteDeepLinkHandler in the handler registry. - lib/workspace/presentation/home/menu/sidebar/sidebar.dart _SidebarState listens to createNoteNotifier; handles workspace switching with retry logic, converts Markdown to DocumentDataPB, calls ViewBackendService.createView and opens the new view. - test/unit_test/deeplink/deeplink_test.dart 8 new unit tests covering canHandle, param mapping, name defaults, clipboard integration and priority rules." --- .../startup/tasks/appflowy_cloud_task.dart | 2 + .../deeplink/new_note_deeplink_handler.dart | 71 +++++++++++ .../home/menu/sidebar/sidebar.dart | 109 +++++++++++++++++ .../workspace/note_creation_notifier.dart | 28 +++++ .../unit_test/deeplink/deeplink_test.dart | 112 ++++++++++++++++++ 5 files changed, 322 insertions(+) create mode 100644 frontend/appflowy_flutter/lib/startup/tasks/deeplink/new_note_deeplink_handler.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/note_creation_notifier.dart diff --git a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart index 52ccbabbca5b8..78acd5dc7461b 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart @@ -9,6 +9,7 @@ import 'package:appflowy/startup/tasks/deeplink/deeplink_handler.dart'; import 'package:appflowy/startup/tasks/deeplink/expire_login_deeplink_handler.dart'; import 'package:appflowy/startup/tasks/deeplink/invitation_deeplink_handler.dart'; import 'package:appflowy/startup/tasks/deeplink/login_deeplink_handler.dart'; +import 'package:appflowy/startup/tasks/deeplink/new_note_deeplink_handler.dart'; import 'package:appflowy/startup/tasks/deeplink/open_app_deeplink_handler.dart'; import 'package:appflowy/startup/tasks/deeplink/payment_deeplink_handler.dart'; import 'package:appflowy/user/application/auth/auth_error.dart'; @@ -30,6 +31,7 @@ class AppFlowyCloudDeepLink { ..register(PaymentDeepLinkHandler()) ..register(InvitationDeepLinkHandler()) ..register(ExpireLoginDeepLinkHandler()) + ..register(NewNoteDeepLinkHandler()) ..register(OpenAppDeepLinkHandler()); _deepLinkSubscription = _AppLinkWrapper.instance.listen( diff --git a/frontend/appflowy_flutter/lib/startup/tasks/deeplink/new_note_deeplink_handler.dart b/frontend/appflowy_flutter/lib/startup/tasks/deeplink/new_note_deeplink_handler.dart new file mode 100644 index 0000000000000..05f5fb01e1286 --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/deeplink/new_note_deeplink_handler.dart @@ -0,0 +1,71 @@ +import 'dart:async'; + +import 'package:appflowy/startup/tasks/deeplink/deeplink_handler.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/note_creation_notifier.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter/services.dart'; + +/// Handles deep links of the form: +/// +/// ``` +/// appflowy-flutter://new +/// ?workspace_id= (optional – switches workspace first) +/// &parent_view_id= (optional – target space / folder) +/// &name= (optional – defaults to "New Note") +/// &content=<url-encoded-markdown> (optional) +/// &clipboard (optional – read content from clipboard) +/// ``` +/// +/// Either `content` or `clipboard` can supply the initial Markdown body. +/// If both are present, `clipboard` takes precedence. +class NewNoteDeepLinkHandler extends DeepLinkHandler<void> { + static const _host = 'new'; + static const _workspaceIdKey = 'workspace_id'; + static const _parentViewIdKey = 'parent_view_id'; + static const _nameKey = 'name'; + static const _contentKey = 'content'; + static const _clipboardKey = 'clipboard'; + + @override + bool canHandle(Uri uri) => uri.host == _host; + + @override + Future<FlowyResult<void, FlowyError>> handle({ + required Uri uri, + required DeepLinkStateHandler onStateChange, + }) async { + final name = uri.queryParameters[_nameKey]?.trim(); + final effectiveName = + (name == null || name.isEmpty) ? 'New Note' : name; + + String? content; + + // `clipboard` flag takes precedence over an explicit `content` value. + if (uri.queryParameters.containsKey(_clipboardKey)) { + final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); + content = clipboardData?.text; + if (content == null || content.isEmpty) { + Log.warn('NewNoteDeepLink: clipboard was empty'); + } + } else { + content = uri.queryParameters[_contentKey]; + } + + createNoteNotifier.value = CreateNoteParams( + workspaceId: uri.queryParameters[_workspaceIdKey], + parentViewId: uri.queryParameters[_parentViewIdKey], + name: effectiveName, + content: content, + ); + + Log.info( + 'NewNoteDeepLink: queued note creation ' + '"$effectiveName" (workspace=${uri.queryParameters[_workspaceIdKey]}, ' + 'parent=${uri.queryParameters[_parentViewIdKey]})', + ); + + return FlowyResult.success(null); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart index 9e46795f220e0..cd432939accd5 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart @@ -34,10 +34,14 @@ import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_new_page_button.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_migration.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/note_creation_notifier.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart'; import 'package:appflowy_backend/log.dart'; +import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; +import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; +import 'package:collection/collection.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show UserProfilePB; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -311,10 +315,14 @@ class _SidebarState extends State<_Sidebar> { // mute the update button during the current application lifecycle. final _muteUpdateButton = ValueNotifier(false); + int _noteCreationRetryCount = 0; + static const int _maxNoteCreationRetries = 5; + @override void initState() { super.initState(); _scrollController.addListener(_onScrollChanged); + createNoteNotifier.addListener(_handleCreateNoteDeepLink); } @override @@ -324,9 +332,110 @@ class _SidebarState extends State<_Sidebar> { _scrollController.dispose(); _scrollOffset.dispose(); _isHovered.dispose(); + createNoteNotifier.removeListener(_handleCreateNoteDeepLink); super.dispose(); } + // ── Deep-link: create new note ────────────────────────────────────────── + + void _handleCreateNoteDeepLink() { + final params = createNoteNotifier.value; + if (params == null) return; + + if (!mounted) return; + + final workspaceBloc = context.read<UserWorkspaceBloc>(); + final currentWorkspaceId = + workspaceBloc.state.currentWorkspace?.workspaceId; + + // If a specific workspace is requested and it isn't the current one, + // switch to it first and retry once it is ready. + if (params.workspaceId != null && + params.workspaceId != currentWorkspaceId) { + if (_noteCreationRetryCount >= _maxNoteCreationRetries) { + Log.error( + 'NewNoteDeepLink: giving up after $_maxNoteCreationRetries retries ' + '(workspace ${params.workspaceId} never became current)', + ); + createNoteNotifier.value = null; + _noteCreationRetryCount = 0; + return; + } + + final targetWorkspace = workspaceBloc.state.workspaces.firstWhereOrNull( + (w) => w.workspaceId == params.workspaceId, + ); + + if (targetWorkspace == null) { + workspaceBloc.add( + UserWorkspaceEvent.fetchWorkspaces( + initialWorkspaceId: params.workspaceId, + ), + ); + } else { + workspaceBloc.add( + UserWorkspaceEvent.openWorkspace( + workspaceId: params.workspaceId!, + workspaceType: targetWorkspace.workspaceType, + ), + ); + } + + _noteCreationRetryCount++; + Future.delayed( + Duration(milliseconds: 300 + _noteCreationRetryCount * 200), + _handleCreateNoteDeepLink, + ); + return; + } + + _noteCreationRetryCount = 0; + _createNoteFromDeepLink(params); + } + + Future<void> _createNoteFromDeepLink(CreateNoteParams params) async { + // Consume the notifier immediately to prevent duplicate creation. + createNoteNotifier.value = null; + + final parentViewId = params.parentViewId?.isNotEmpty == true + ? params.parentViewId! + : context.read<SpaceBloc>().state.currentSpace?.id ?? ''; + + if (parentViewId.isEmpty) { + Log.error('NewNoteDeepLink: no parentViewId available, aborting'); + return; + } + + // Convert Markdown to DocumentDataPB bytes if content is provided. + List<int>? initialDataBytes; + final content = params.content; + if (content != null && content.isNotEmpty) { + final document = customMarkdownToDocument(content); + initialDataBytes = + DocumentDataPBFromTo.fromDocument(document)?.writeToBuffer(); + } + + final result = await ViewBackendService.createView( + layoutType: ViewLayoutPB.Document, + parentViewId: parentViewId, + name: params.name, + openAfterCreate: true, + initialDataBytes: initialDataBytes, + ); + + result.fold( + (view) { + Log.info( + 'NewNoteDeepLink: created "${view.name}" (${view.id})', + ); + if (mounted) { + context.read<TabsBloc>().openPlugin(view); + } + }, + (error) => Log.error('NewNoteDeepLink: failed to create note – $error'), + ); + } + @override Widget build(BuildContext context) { const menuHorizontalInset = EdgeInsets.symmetric(horizontal: 8); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/note_creation_notifier.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/note_creation_notifier.dart new file mode 100644 index 0000000000000..dc1732ba374ed --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/note_creation_notifier.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +/// Global notifier used to pass note-creation parameters from a deep link +/// handler to the sidebar, which has the required bloc context. +final ValueNotifier<CreateNoteParams?> createNoteNotifier = + ValueNotifier(null); + +class CreateNoteParams { + CreateNoteParams({ + this.workspaceId, + this.parentViewId, + required this.name, + this.content, + }); + + /// Target workspace UUID. If null, uses the currently open workspace. + final String? workspaceId; + + /// Parent view (space / folder) UUID. If null, falls back to the current + /// default space. + final String? parentViewId; + + /// Title of the new document. + final String name; + + /// Markdown content to pre-fill the document. May be null for an empty note. + final String? content; +} diff --git a/frontend/appflowy_flutter/test/unit_test/deeplink/deeplink_test.dart b/frontend/appflowy_flutter/test/unit_test/deeplink/deeplink_test.dart index d197bd5b90a1f..4c91876a7d097 100644 --- a/frontend/appflowy_flutter/test/unit_test/deeplink/deeplink_test.dart +++ b/frontend/appflowy_flutter/test/unit_test/deeplink/deeplink_test.dart @@ -1,8 +1,11 @@ import 'package:appflowy/startup/tasks/deeplink/deeplink_handler.dart'; import 'package:appflowy/startup/tasks/deeplink/invitation_deeplink_handler.dart'; import 'package:appflowy/startup/tasks/deeplink/login_deeplink_handler.dart'; +import 'package:appflowy/startup/tasks/deeplink/new_note_deeplink_handler.dart'; import 'package:appflowy/startup/tasks/deeplink/open_app_deeplink_handler.dart'; import 'package:appflowy/startup/tasks/deeplink/payment_deeplink_handler.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/note_creation_notifier.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -11,6 +14,7 @@ void main() { ..register(LoginDeepLinkHandler()) ..register(PaymentDeepLinkHandler()) ..register(InvitationDeepLinkHandler()) + ..register(NewNoteDeepLinkHandler()) ..register(OpenAppDeepLinkHandler()); test('invitation deep link handler', () { @@ -61,4 +65,112 @@ void main() { expect(OpenAppDeepLinkHandler().canHandle(uri), true); }); }); + + // ───────────────────────────────────────────────────────────────────────── + // NewNoteDeepLinkHandler + // ───────────────────────────────────────────────────────────────────────── + group('NewNoteDeepLinkHandler: ', () { + // Initialise Flutter bindings so Clipboard platform channel is available. + setUpAll(TestWidgetsFlutterBinding.ensureInitialized); + + setUp(() => createNoteNotifier.value = null); + + final handler = NewNoteDeepLinkHandler(); + void noop(_, __) {} + + test('canHandle returns true for appflowy-flutter://new', () { + expect(handler.canHandle(Uri.parse('appflowy-flutter://new')), true); + }); + + test('canHandle returns false for other hosts', () { + expect( + handler.canHandle(Uri.parse('appflowy-flutter://invitation-callback')), + false, + ); + expect(handler.canHandle(Uri.parse('appflowy-flutter://')), false); + }); + + test('handle sets CreateNoteParams with all provided values', () async { + final uri = Uri.parse( + 'appflowy-flutter://new' + '?workspace_id=ws-123' + '&parent_view_id=pv-456' + '&name=My%20Clipping' + '&content=Hello%20**world**', + ); + + final result = await handler.handle(uri: uri, onStateChange: noop); + + expect(result.isSuccess, true); + final params = createNoteNotifier.value; + expect(params, isNotNull); + expect(params!.workspaceId, 'ws-123'); + expect(params.parentViewId, 'pv-456'); + expect(params.name, 'My Clipping'); + expect(params.content, 'Hello **world**'); + }); + + test('handle defaults name to "New Note" when absent', () async { + final uri = Uri.parse('appflowy-flutter://new'); + + await handler.handle(uri: uri, onStateChange: noop); + + expect(createNoteNotifier.value?.name, 'New Note'); + }); + + test('handle defaults name to "New Note" when blank', () async { + final uri = Uri.parse('appflowy-flutter://new?name=%20%20'); + + await handler.handle(uri: uri, onStateChange: noop); + + expect(createNoteNotifier.value?.name, 'New Note'); + }); + + test('handle uses clipboard content when &clipboard flag is present', + () async { + // Seed the clipboard. + await Clipboard.setData(const ClipboardData(text: '# From Clipboard')); + + final uri = Uri.parse('appflowy-flutter://new?name=Clip&clipboard'); + + await handler.handle(uri: uri, onStateChange: noop); + + expect(createNoteNotifier.value?.content, '# From Clipboard'); + }); + + test('clipboard flag takes precedence over &content', () async { + await Clipboard.setData(const ClipboardData(text: 'clipboard wins')); + + final uri = Uri.parse( + 'appflowy-flutter://new?content=ignored&clipboard', + ); + + await handler.handle(uri: uri, onStateChange: noop); + + expect(createNoteNotifier.value?.content, 'clipboard wins'); + }); + + test('handle succeeds when clipboard is empty', () async { + await Clipboard.setData(const ClipboardData(text: '')); + + final uri = Uri.parse('appflowy-flutter://new?clipboard'); + + final result = await handler.handle(uri: uri, onStateChange: noop); + + expect(result.isSuccess, true); + // content should be null or empty – note creation still proceeds + final content = createNoteNotifier.value?.content; + expect(content == null || content.isEmpty, true); + }); + + test('handle sets null workspaceId and parentViewId when absent', () async { + final uri = Uri.parse('appflowy-flutter://new?name=Minimal'); + + await handler.handle(uri: uri, onStateChange: noop); + + final params = createNoteNotifier.value; + expect(params?.workspaceId, isNull); + expect(params?.parentViewId, isNull); + }); + }); } From fd134d2a90848a791ee3bf4f4ff2c74b7aacaf5f Mon Sep 17 00:00:00 2001 From: Alexandre Rose-Pizant <alexandre.rosepizant@gmail.com> Date: Wed, 13 May 2026 16:05:39 +0200 Subject: [PATCH 2/2] refactor: address Sourcery review feedback on new-note deep link Three issues fixed: 1. Replace global ValueNotifier with a getIt-registered CreateNoteService The raw `createNoteNotifier` global is replaced by `CreateNoteService extends ChangeNotifier`, registered as a lazySingleton in DependencyResolver._resolveUserDeps. This makes the cross-layer dependency explicit and injectable for tests. 2. Replace polling retry with event-driven workspace-switch The shared `_noteCreationRetryCount` counter and recursive `Future.delayed` loop are removed. _SidebarState now subscribes to `UserWorkspaceBloc.stream`; when the workspace changes to the requested one the note is created automatically. A `_pendingWorkspaceSwitchId` guard prevents duplicate switch events from being dispatched while the switch is still in progress. 3. Wrap Markdown conversion in try/catch `customMarkdownToDocument` and `DocumentDataPBFromTo.fromDocument` are now wrapped in a try/catch block; a malformed `content` payload logs a warning and falls back to creating an empty note instead of crashing note creation. Handler testability: NewNoteDeepLinkHandler now accepts an optional `CreateNoteService` parameter for DI; tests inject a fresh instance directly (no getIt setup required)." --- .../lib/startup/deps_resolver.dart | 2 + .../deeplink/new_note_deeplink_handler.dart | 29 ++- .../home/menu/sidebar/sidebar.dart | 130 +++++++------ .../workspace/note_creation_notifier.dart | 34 +++- .../unit_test/deeplink/deeplink_test.dart | 176 +++++++++++++++++- 5 files changed, 296 insertions(+), 75 deletions(-) diff --git a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart index 631bed3a3baae..b7ca098735e70 100644 --- a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart +++ b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart @@ -27,6 +27,7 @@ import 'package:appflowy/workspace/application/settings/appearance/mobile_appear import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy/workspace/application/sidebar/rename_view/rename_view_bloc.dart'; import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/note_creation_notifier.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/user/prelude.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; @@ -137,6 +138,7 @@ void _resolveUserDeps(GetIt getIt, IntegrationMode mode) { getIt.registerLazySingleton<SubscriptionSuccessListenable>( () => SubscriptionSuccessListenable(), ); + getIt.registerLazySingleton<CreateNoteService>(() => CreateNoteService()); } void _resolveHomeDeps(GetIt getIt) { diff --git a/frontend/appflowy_flutter/lib/startup/tasks/deeplink/new_note_deeplink_handler.dart b/frontend/appflowy_flutter/lib/startup/tasks/deeplink/new_note_deeplink_handler.dart index 05f5fb01e1286..bc65665d9f184 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/deeplink/new_note_deeplink_handler.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/deeplink/new_note_deeplink_handler.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/deeplink/deeplink_handler.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/note_creation_notifier.dart'; import 'package:appflowy_backend/log.dart'; @@ -11,16 +12,22 @@ import 'package:flutter/services.dart'; /// /// ``` /// appflowy-flutter://new -/// ?workspace_id=<uuid> (optional – switches workspace first) -/// &parent_view_id=<uuid> (optional – target space / folder) -/// &name=<title> (optional – defaults to "New Note") +/// ?workspace_id=<uuid> (optional – switches workspace first) +/// &parent_view_id=<uuid> (optional – target space / folder) +/// &name=<title> (optional – defaults to "New Note") /// &content=<url-encoded-markdown> (optional) -/// &clipboard (optional – read content from clipboard) +/// &clipboard (optional – read content from clipboard) /// ``` /// /// Either `content` or `clipboard` can supply the initial Markdown body. -/// If both are present, `clipboard` takes precedence. +/// When both are present, `clipboard` takes precedence. class NewNoteDeepLinkHandler extends DeepLinkHandler<void> { + /// [service] is injected for testability; defaults to the [getIt] singleton. + NewNoteDeepLinkHandler({CreateNoteService? service}) + : _service = service ?? getIt<CreateNoteService>(); + + final CreateNoteService _service; + static const _host = 'new'; static const _workspaceIdKey = 'workspace_id'; static const _parentViewIdKey = 'parent_view_id'; @@ -53,11 +60,13 @@ class NewNoteDeepLinkHandler extends DeepLinkHandler<void> { content = uri.queryParameters[_contentKey]; } - createNoteNotifier.value = CreateNoteParams( - workspaceId: uri.queryParameters[_workspaceIdKey], - parentViewId: uri.queryParameters[_parentViewIdKey], - name: effectiveName, - content: content, + _service.request( + CreateNoteParams( + workspaceId: uri.queryParameters[_workspaceIdKey], + parentViewId: uri.queryParameters[_parentViewIdKey], + name: effectiveName, + content: content, + ), ); Log.info( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart index cd432939accd5..3ea22fabf6aab 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart @@ -315,87 +315,99 @@ class _SidebarState extends State<_Sidebar> { // mute the update button during the current application lifecycle. final _muteUpdateButton = ValueNotifier(false); - int _noteCreationRetryCount = 0; - static const int _maxNoteCreationRetries = 5; + /// Subscription to workspace bloc state changes used for the event-driven + /// workspace-switch step before note creation. + StreamSubscription<UserWorkspaceState>? _workspaceSubscription; + + /// Guards against dispatching the same workspace-switch event multiple times + /// while the switch is still in progress. + String? _pendingWorkspaceSwitchId; @override void initState() { super.initState(); _scrollController.addListener(_onScrollChanged); - createNoteNotifier.addListener(_handleCreateNoteDeepLink); + getIt<CreateNoteService>().addListener(_onCreateNoteRequested); + _workspaceSubscription = context + .read<UserWorkspaceBloc>() + .stream + .listen(_onWorkspaceStateChanged); } @override void dispose() { + _workspaceSubscription?.cancel(); + getIt<CreateNoteService>().removeListener(_onCreateNoteRequested); _scrollDebounce?.cancel(); _scrollController.removeListener(_onScrollChanged); _scrollController.dispose(); _scrollOffset.dispose(); _isHovered.dispose(); - createNoteNotifier.removeListener(_handleCreateNoteDeepLink); super.dispose(); } // ── Deep-link: create new note ────────────────────────────────────────── - void _handleCreateNoteDeepLink() { - final params = createNoteNotifier.value; + /// Called when [CreateNoteService] notifies a new pending request. + void _onCreateNoteRequested() { + if (!mounted) return; + final params = getIt<CreateNoteService>().pending; if (params == null) return; + _maybeCreateNote(params); + } + /// Called on every [UserWorkspaceBloc] state change so a pending + /// workspace-switch can be completed without any polling timer. + void _onWorkspaceStateChanged(UserWorkspaceState _) { if (!mounted) return; + final params = getIt<CreateNoteService>().pending; + if (params == null) return; + _maybeCreateNote(params); + } - final workspaceBloc = context.read<UserWorkspaceBloc>(); + /// Checks whether the correct workspace is active; if not, dispatches a + /// workspace switch exactly once and returns – [_onWorkspaceStateChanged] + /// will re-invoke this method when the switch completes. + void _maybeCreateNote(CreateNoteParams params) { final currentWorkspaceId = - workspaceBloc.state.currentWorkspace?.workspaceId; + context.read<UserWorkspaceBloc>().state.currentWorkspace?.workspaceId; - // If a specific workspace is requested and it isn't the current one, - // switch to it first and retry once it is ready. if (params.workspaceId != null && params.workspaceId != currentWorkspaceId) { - if (_noteCreationRetryCount >= _maxNoteCreationRetries) { - Log.error( - 'NewNoteDeepLink: giving up after $_maxNoteCreationRetries retries ' - '(workspace ${params.workspaceId} never became current)', - ); - createNoteNotifier.value = null; - _noteCreationRetryCount = 0; - return; + if (_pendingWorkspaceSwitchId != params.workspaceId) { + _pendingWorkspaceSwitchId = params.workspaceId; + _switchToWorkspace(params.workspaceId!); } + return; + } - final targetWorkspace = workspaceBloc.state.workspaces.firstWhereOrNull( - (w) => w.workspaceId == params.workspaceId, - ); - - if (targetWorkspace == null) { - workspaceBloc.add( - UserWorkspaceEvent.fetchWorkspaces( - initialWorkspaceId: params.workspaceId, - ), - ); - } else { - workspaceBloc.add( - UserWorkspaceEvent.openWorkspace( - workspaceId: params.workspaceId!, - workspaceType: targetWorkspace.workspaceType, - ), - ); - } + _pendingWorkspaceSwitchId = null; + _createNoteFromParams(params); + } - _noteCreationRetryCount++; - Future.delayed( - Duration(milliseconds: 300 + _noteCreationRetryCount * 200), - _handleCreateNoteDeepLink, + void _switchToWorkspace(String workspaceId) { + final workspaceBloc = context.read<UserWorkspaceBloc>(); + final target = workspaceBloc.state.workspaces.firstWhereOrNull( + (w) => w.workspaceId == workspaceId, + ); + if (target == null) { + workspaceBloc.add( + UserWorkspaceEvent.fetchWorkspaces(initialWorkspaceId: workspaceId), + ); + } else { + workspaceBloc.add( + UserWorkspaceEvent.openWorkspace( + workspaceId: workspaceId, + workspaceType: target.workspaceType, + ), ); - return; } - - _noteCreationRetryCount = 0; - _createNoteFromDeepLink(params); } - Future<void> _createNoteFromDeepLink(CreateNoteParams params) async { - // Consume the notifier immediately to prevent duplicate creation. - createNoteNotifier.value = null; + Future<void> _createNoteFromParams(CreateNoteParams params) async { + // Consume immediately to prevent duplicate creation if the bloc fires + // another state change before the async call completes. + getIt<CreateNoteService>().consume(); final parentViewId = params.parentViewId?.isNotEmpty == true ? params.parentViewId! @@ -406,13 +418,21 @@ class _SidebarState extends State<_Sidebar> { return; } - // Convert Markdown to DocumentDataPB bytes if content is provided. + // Convert Markdown → DocumentDataPB bytes. A malformed payload must not + // block note creation; fall back to an empty document instead. List<int>? initialDataBytes; final content = params.content; if (content != null && content.isNotEmpty) { - final document = customMarkdownToDocument(content); - initialDataBytes = - DocumentDataPBFromTo.fromDocument(document)?.writeToBuffer(); + try { + final document = customMarkdownToDocument(content); + initialDataBytes = + DocumentDataPBFromTo.fromDocument(document)?.writeToBuffer(); + } catch (e) { + Log.warn( + 'NewNoteDeepLink: Markdown conversion failed, ' + 'creating empty note – $e', + ); + } } final result = await ViewBackendService.createView( @@ -425,12 +445,8 @@ class _SidebarState extends State<_Sidebar> { result.fold( (view) { - Log.info( - 'NewNoteDeepLink: created "${view.name}" (${view.id})', - ); - if (mounted) { - context.read<TabsBloc>().openPlugin(view); - } + Log.info('NewNoteDeepLink: created "${view.name}" (${view.id})'); + if (mounted) context.read<TabsBloc>().openPlugin(view); }, (error) => Log.error('NewNoteDeepLink: failed to create note – $error'), ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/note_creation_notifier.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/note_creation_notifier.dart index dc1732ba374ed..d60a4fe0acf62 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/note_creation_notifier.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/note_creation_notifier.dart @@ -1,10 +1,6 @@ -import 'package:flutter/material.dart'; - -/// Global notifier used to pass note-creation parameters from a deep link -/// handler to the sidebar, which has the required bloc context. -final ValueNotifier<CreateNoteParams?> createNoteNotifier = - ValueNotifier(null); +import 'package:flutter/foundation.dart'; +/// Parameters for a note-creation deep-link request. class CreateNoteParams { CreateNoteParams({ this.workspaceId, @@ -13,7 +9,7 @@ class CreateNoteParams { this.content, }); - /// Target workspace UUID. If null, uses the currently open workspace. + /// Target workspace UUID. If null, the currently open workspace is used. final String? workspaceId; /// Parent view (space / folder) UUID. If null, falls back to the current @@ -26,3 +22,27 @@ class CreateNoteParams { /// Markdown content to pre-fill the document. May be null for an empty note. final String? content; } + +/// Service registered in [getIt] that carries a pending note-creation request +/// from the deep-link layer to the sidebar layer. +/// +/// Using a [ChangeNotifier]-based singleton registered via [getIt] avoids +/// leaking a hidden global mutable across layers and makes the dependency +/// explicit and injectable for tests. +class CreateNoteService extends ChangeNotifier { + CreateNoteParams? _pending; + + /// The pending note-creation request, or null when idle. + CreateNoteParams? get pending => _pending; + + /// Enqueue a new note-creation request and notify listeners. + void request(CreateNoteParams params) { + _pending = params; + notifyListeners(); + } + + /// Mark the pending request as handled (consume it). + void consume() { + _pending = null; + } +} diff --git a/frontend/appflowy_flutter/test/unit_test/deeplink/deeplink_test.dart b/frontend/appflowy_flutter/test/unit_test/deeplink/deeplink_test.dart index 4c91876a7d097..2c3560a8c58ff 100644 --- a/frontend/appflowy_flutter/test/unit_test/deeplink/deeplink_test.dart +++ b/frontend/appflowy_flutter/test/unit_test/deeplink/deeplink_test.dart @@ -14,7 +14,6 @@ void main() { ..register(LoginDeepLinkHandler()) ..register(PaymentDeepLinkHandler()) ..register(InvitationDeepLinkHandler()) - ..register(NewNoteDeepLinkHandler()) ..register(OpenAppDeepLinkHandler()); test('invitation deep link handler', () { @@ -66,6 +65,181 @@ void main() { }); }); + // ───────────────────────────────────────────────────────────────────────── + // NewNoteDeepLinkHandler + // ───────────────────────────────────────────────────────────────────────── + group('NewNoteDeepLinkHandler: ', () { + // Initialise Flutter bindings so Clipboard platform channel is available. + setUpAll(TestWidgetsFlutterBinding.ensureInitialized); + + late CreateNoteService service; + late NewNoteDeepLinkHandler handler; + + setUp(() { + service = CreateNoteService(); + handler = NewNoteDeepLinkHandler(service: service); + }); + + tearDown(() => service.dispose()); + + void noop(_, __) {} + + test('canHandle returns true for appflowy-flutter://new', () { + expect(handler.canHandle(Uri.parse('appflowy-flutter://new')), true); + }); + + test('canHandle returns false for other hosts', () { + expect( + handler.canHandle(Uri.parse('appflowy-flutter://invitation-callback')), + false, + ); + expect(handler.canHandle(Uri.parse('appflowy-flutter://')), false); + }); + + test('handle populates CreateNoteParams with all provided values', + () async { + final uri = Uri.parse( + 'appflowy-flutter://new' + '?workspace_id=ws-123' + '&parent_view_id=pv-456' + '&name=My%20Clipping' + '&content=Hello%20**world**', + ); + + final result = await handler.handle(uri: uri, onStateChange: noop); + + expect(result.isSuccess, true); + final params = service.pending; + expect(params, isNotNull); + expect(params!.workspaceId, 'ws-123'); + expect(params.parentViewId, 'pv-456'); + expect(params.name, 'My Clipping'); + expect(params.content, 'Hello **world**'); + }); + + test('handle defaults name to "New Note" when absent', () async { + await handler.handle( + uri: Uri.parse('appflowy-flutter://new'), + onStateChange: noop, + ); + expect(service.pending?.name, 'New Note'); + }); + + test('handle defaults name to "New Note" when blank', () async { + await handler.handle( + uri: Uri.parse('appflowy-flutter://new?name=%20%20'), + onStateChange: noop, + ); + expect(service.pending?.name, 'New Note'); + }); + + test('handle uses clipboard content when &clipboard flag is present', + () async { + await Clipboard.setData(const ClipboardData(text: '# From Clipboard')); + + await handler.handle( + uri: Uri.parse('appflowy-flutter://new?name=Clip&clipboard'), + onStateChange: noop, + ); + + expect(service.pending?.content, '# From Clipboard'); + }); + + test('clipboard flag takes precedence over &content', () async { + await Clipboard.setData(const ClipboardData(text: 'clipboard wins')); + + await handler.handle( + uri: Uri.parse('appflowy-flutter://new?content=ignored&clipboard'), + onStateChange: noop, + ); + + expect(service.pending?.content, 'clipboard wins'); + }); + + test('handle succeeds when clipboard is empty', () async { + await Clipboard.setData(const ClipboardData(text: '')); + + final result = await handler.handle( + uri: Uri.parse('appflowy-flutter://new?clipboard'), + onStateChange: noop, + ); + + expect(result.isSuccess, true); + final content = service.pending?.content; + expect(content == null || content.isEmpty, true); + }); + + test('handle leaves workspaceId and parentViewId null when absent', + () async { + await handler.handle( + uri: Uri.parse('appflowy-flutter://new?name=Minimal'), + onStateChange: noop, + ); + expect(service.pending?.workspaceId, isNull); + expect(service.pending?.parentViewId, isNull); + }); + + test('consume clears the pending request', () async { + await handler.handle( + uri: Uri.parse('appflowy-flutter://new?name=ToConsume'), + onStateChange: noop, + ); + expect(service.pending, isNotNull); + + service.consume(); + expect(service.pending, isNull); + }); + }); +} + + final uri = Uri.parse( + 'appflowy-flutter://invitation-callback?email=lucas@appflowy.com&workspace_id=123', + ); + deepLinkHandlerRegistry.processDeepLink( + uri: uri, + onStateChange: (handler, state) { + expect(handler, isA<InvitationDeepLinkHandler>()); + }, + onResult: (handler, result) { + expect(handler, isA<InvitationDeepLinkHandler>()); + expect(result.isSuccess, true); + }, + onError: (error) { + expect(error, isNull); + }, + ); + }); + + test('login deep link handler', () { + final uri = + Uri.parse('appflowy-flutter://login-callback#access_token=123'); + expect(LoginDeepLinkHandler().canHandle(uri), true); + }); + + test('payment deep link handler', () { + final uri = Uri.parse('appflowy-flutter://payment-success'); + expect(PaymentDeepLinkHandler().canHandle(uri), true); + }); + + test('unknown deep link handler', () { + final uri = + Uri.parse('appflowy-flutter://unknown-callback?workspace_id=123'); + deepLinkHandlerRegistry.processDeepLink( + uri: uri, + onStateChange: (handler, state) {}, + onResult: (handler, result) {}, + onError: (error) { + expect(error, isNotNull); + }, + ); + }); + + test('open app deep link handler', () { + final uri = Uri.parse('appflowy-flutter://'); + expect(OpenAppDeepLinkHandler().canHandle(uri), true); + }); + }); + // ───────────────────────────────────────────────────────────────────────── // NewNoteDeepLinkHandler // ─────────────────────────────────────────────────────────────────────────