From 6572e9e8999c52dcad10482ae67943755b8b0ae2 Mon Sep 17 00:00:00 2001 From: Levi Date: Sun, 13 Sep 2026 15:01:50 +0800 Subject: [PATCH] fix: automatically submit scheme extension installs --- .../presentation/pages/extensions_page.dart | 25 ++++++++-- ui/flutter/test/widget_test.dart | 47 +++++++++++++------ 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart index fb82f64f..909ed9e4 100644 --- a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart +++ b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart @@ -46,6 +46,7 @@ class _ExtensionsPageState extends ConsumerState { final _searchController = TextEditingController(); final _installController = TextEditingController(); final _installAnchorKey = GlobalKey(); + final _installPopoverKey = GlobalKey<_InstallPopoverState>(); bool _installDevMode = false; final _listScrollController = ScrollController(); final Map _settingControllers = {}; @@ -84,12 +85,20 @@ class _ExtensionsPageState extends ConsumerState { // after the toolbar is laid out, so cold and warm links share the same UI. WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !identical(ref.read(pendingExtensionInstallProvider), pendingInstall)) return; + if (_installPopoverKey.currentState?._installing == true) return; final anchorContext = _installAnchorKey.currentContext; if (anchorContext == null) return; _installController.text = pendingInstall.url; _installDevMode = pendingInstall.devMode; _showInstallPopover(anchorContext); - ref.read(pendingExtensionInstallProvider.notifier).clear(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !identical(ref.read(pendingExtensionInstallProvider), pendingInstall)) return; + final popover = _installPopoverKey.currentState; + if (popover == null || popover._installing) return; + ref.read(pendingExtensionInstallProvider.notifier).clear(); + unawaited(popover._install()); + }); + WidgetsBinding.instance.ensureVisualUpdate(); }); } final stateAsync = ref.watch(extensionsControllerProvider); @@ -214,7 +223,14 @@ class _ExtensionsPageState extends ConsumerState { offset: const Offset(0, 8), modal: false, consumeOutsideTaps: false, - builder: (context) => _InstallPopover(controller: _installController, onInstallUrl: _installFromUrl), + builder: (context) => _InstallPopover( + key: _installPopoverKey, + controller: _installController, + onInstallUrl: _installFromUrl, + onFinished: () { + if (mounted && ref.read(pendingExtensionInstallProvider) != null) setState(() {}); + }, + ), ); _installPopover = overlay; unawaited( @@ -815,10 +831,11 @@ class _OutlineToolbarIconButton extends StatelessWidget { } class _InstallPopover extends StatefulWidget { - const _InstallPopover({required this.controller, required this.onInstallUrl}); + const _InstallPopover({super.key, required this.controller, required this.onInstallUrl, required this.onFinished}); final TextEditingController controller; final Future Function() onInstallUrl; + final VoidCallback onFinished; @override State<_InstallPopover> createState() => _InstallPopoverState(); @@ -880,8 +897,8 @@ class _InstallPopoverState extends State<_InstallPopover> { setState(() => _installing = true); await widget.onInstallUrl(); if (!mounted) return; - setState(() => _installing = false); await shad.closeOverlay(context); + widget.onFinished(); } } diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index 4606fe80..16b1c2d2 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -889,10 +889,10 @@ void main() { (1100.0, TargetPlatform.macOS), ]) { testWidgets( - 'scheme install prefills URL before and after mounting at width $width', + 'scheme install automatically runs before and after mounting at width $width', (tester) async { await _setTestSize(tester, Size(width, 900)); - final controller = FakeExtensionsController(); + final controller = FakeExtensionsController()..installGate = Completer(); final container = ProviderContainer(overrides: [extensionsControllerProvider.overrideWith(() => controller)]); addTearDown(container.dispose); const firstUrl = 'https://github.com/author/扩展'; @@ -912,31 +912,48 @@ void main() { final input = find.byKey(const ValueKey('extension-install-url-input')); expect(tester.widget(input).controller!.text, firstUrl); expect(container.read(pendingExtensionInstallProvider), isNull); - expect(controller.installCalls, 0); + expect(controller.installCalls, 1); + expect(controller.lastInstallUrl, firstUrl); final popoverRect = tester.getRect(find.byKey(const ValueKey('extension-install-popover'))); expect(popoverRect.left, greaterThanOrEqualTo(0)); expect(popoverRect.right, lessThanOrEqualTo(width)); + expect(find.descendant(of: input, matching: find.byType(shad.CircularProgressIndicator)), findsOneWidget); - // A second link updates the already-open input without installing twice. + // Rebuilds and manual submission must not duplicate an active install. + await tester.tap(find.descendant(of: input, matching: find.byType(shad.IconButton))); + await tester.pump(); + expect(controller.installCalls, 1); + controller.installGate!.complete(); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('extension-install-popover')), findsNothing); + + // A warm link automatically installs from an already-open form too. + await tester.tap(find.byKey(const ValueKey('install-extension-button'))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); const secondUrl = 'https://github.com/author/second'; + controller.installGate = Completer(); container.read(pendingExtensionInstallProvider.notifier).set(InstallExtension(url: secondUrl)); await tester.pump(); + await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(tester.widget(input).controller!.text, secondUrl); - expect(find.byKey(const ValueKey('extension-install-popover')), findsOneWidget); - expect(controller.installCalls, 0); - await tester.tap(find.descendant(of: input, matching: find.byType(shad.IconButton))); - await tester.pump(); - await tester.pump(const Duration(seconds: 1)); - expect(controller.installCalls, 1); + expect(controller.installCalls, 2); expect(controller.lastInstallUrl, secondUrl); - // A warm link also reopens a dismissed form. + // A request arriving during installation waits until the active form closes. container.read(pendingExtensionInstallProvider.notifier).set(InstallExtension(url: firstUrl)); await tester.pump(); - await tester.pump(const Duration(seconds: 1)); - expect(tester.widget(input).controller!.text, firstUrl); - expect(controller.installCalls, 1); + expect(controller.installCalls, 2); + controller.installGate!.complete(); + controller.installGate = Completer(); + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 200)); + } + expect(controller.installCalls, 3); + expect(controller.lastInstallUrl, firstUrl); + controller.installGate!.complete(); + await tester.pumpAndSettle(); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); @@ -5050,11 +5067,13 @@ class FailingTasksController extends TasksController { class FakeExtensionsController extends ExtensionsController { int installCalls = 0; String? lastInstallUrl; + Completer? installGate; @override Future installFromUrl(String url, {bool devInstall = false}) async { installCalls++; lastInstallUrl = url; + await installGate?.future; } int removeCalls = 0;