From b2ddbd62c0da8084c5bb4ae93796d8fa9a5c8ac8 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 4 Apr 2026 14:08:46 +0800 Subject: [PATCH] Align desktop ACP card actions --- lib/features/settings/settings_page_core.dart | 20 ++ .../settings/settings_page_gateway.dart | 179 ++++++++++++--- .../settings/settings_page_support.dart | 61 +++++ test/features/settings_page_suite.dart | 217 +++++++++--------- 4 files changed, 332 insertions(+), 145 deletions(-) diff --git a/lib/features/settings/settings_page_core.dart b/lib/features/settings/settings_page_core.dart index ff325aaf..b562ac6b 100644 --- a/lib/features/settings/settings_page_core.dart +++ b/lib/features/settings/settings_page_core.dart @@ -68,6 +68,14 @@ class SettingsPageStateInternal extends State { late final TextEditingController vaultTokenControllerInternal; late final TextEditingController ollamaApiKeyControllerInternal; late final TextEditingController runtimeLogFilterControllerInternal; + late final Map + externalAcpLabelControllersInternal; + late final Map + externalAcpEndpointControllersInternal; + late final Map externalAcpLabelSyncedValuesInternal; + late final Map externalAcpEndpointSyncedValuesInternal; + late final Map externalAcpMessageByProviderInternal; + late final Set externalAcpTestingProvidersInternal; bool gatewayTestingInternal = false; String gatewayTestStateInternal = 'idle'; String gatewayTestMessageInternal = ''; @@ -138,6 +146,12 @@ class SettingsPageStateInternal extends State { vaultTokenControllerInternal = TextEditingController(); ollamaApiKeyControllerInternal = TextEditingController(); runtimeLogFilterControllerInternal = TextEditingController(); + externalAcpLabelControllersInternal = {}; + externalAcpEndpointControllersInternal = {}; + externalAcpLabelSyncedValuesInternal = {}; + externalAcpEndpointSyncedValuesInternal = {}; + externalAcpMessageByProviderInternal = {}; + externalAcpTestingProvidersInternal = {}; } void setStateInternal(VoidCallback fn) => setState(fn); @@ -201,6 +215,12 @@ class SettingsPageStateInternal extends State { vaultTokenControllerInternal.dispose(); ollamaApiKeyControllerInternal.dispose(); runtimeLogFilterControllerInternal.dispose(); + for (final controller in externalAcpLabelControllersInternal.values) { + controller.dispose(); + } + for (final controller in externalAcpEndpointControllersInternal.values) { + controller.dispose(); + } super.dispose(); } diff --git a/lib/features/settings/settings_page_gateway.dart b/lib/features/settings/settings_page_gateway.dart index 5176199f..c29456fb 100644 --- a/lib/features/settings/settings_page_gateway.dart +++ b/lib/features/settings/settings_page_gateway.dart @@ -163,10 +163,7 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal { GatewayIntegrationSubTabInternal.skills => [ buildCollapsibleGatewaySectionInternal( context: context, - title: appText( - 'SKILLS 目录授权', - 'SKILLS Directory Authorization', - ), + title: appText('SKILLS 目录授权', 'SKILLS Directory Authorization'), expanded: skillsDirectoryAuthorizationExpandedInternal, onChanged: (value) => setStateInternal(() { skillsDirectoryAuthorizationExpandedInternal = value; @@ -248,10 +245,7 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal { const SizedBox(height: 16), buildCollapsibleGatewaySectionInternal( context: context, - title: appText( - 'SKILLS 目录授权', - 'SKILLS Directory Authorization', - ), + title: appText('SKILLS 目录授权', 'SKILLS Directory Authorization'), expanded: skillsDirectoryAuthorizationExpandedInternal, onChanged: (value) => setStateInternal(() { skillsDirectoryAuthorizationExpandedInternal = value; @@ -269,6 +263,7 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal { AppController controller, SettingsSnapshot settings, ) { + syncExternalAcpDraftControllersInternal(settings); final theme = Theme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -291,14 +286,13 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal { settings, ), icon: const Icon(Icons.add_rounded), - label: Text( - appText('添加更多自定义配置', 'Add more custom configurations'), - ), + label: Text(appText('添加更多自定义配置', 'Add more custom configurations')), ), ), const SizedBox(height: 16), ...settings.externalAcpEndpoints.map( (profile) => Padding( + key: ValueKey('external-acp-card-${profile.providerKey}'), padding: const EdgeInsets.only(bottom: 12), child: buildExternalAcpProviderCardInternal( context, @@ -319,8 +313,16 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal { ExternalAcpEndpointProfile profile, ) { final provider = profile.toProvider(); - final endpoint = profile.endpoint.trim(); - final configured = endpoint.isNotEmpty; + final labelController = + externalAcpLabelControllersInternal[profile.providerKey]!; + final endpointController = + externalAcpEndpointControllersInternal[profile.providerKey]!; + final message = + externalAcpMessageByProviderInternal[profile.providerKey] ?? ''; + final testing = externalAcpTestingProvidersInternal.contains( + profile.providerKey, + ); + final configured = endpointController.text.trim().isNotEmpty; return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -366,27 +368,21 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal { ], ), const SizedBox(height: 12), - EditableFieldInternal( - label: appText('显示名称', 'Display name'), - value: profile.label, - onSubmitted: (value) => saveSettingsInternal( - controller, - settings.copyWithExternalAcpEndpointForProvider( - provider, - profile.copyWith(label: value), - ), + TextField( + key: ValueKey('external-acp-label-${profile.providerKey}'), + controller: labelController, + decoration: InputDecoration( + labelText: appText('显示名称', 'Display name'), ), + onChanged: (_) => setStateInternal(() {}), ), - EditableFieldInternal( - label: appText('ACP Server Endpoint', 'ACP Server Endpoint'), - value: endpoint, - onSubmitted: (value) => saveSettingsInternal( - controller, - settings.copyWithExternalAcpEndpointForProvider( - provider, - profile.copyWith(endpoint: value), - ), + TextField( + key: ValueKey('external-acp-endpoint-${profile.providerKey}'), + controller: endpointController, + decoration: InputDecoration( + labelText: appText('ACP Server Endpoint', 'ACP Server Endpoint'), ), + onChanged: (_) => setStateInternal(() {}), ), Text( appText( @@ -395,11 +391,132 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal { ), style: Theme.of(context).textTheme.bodySmall, ), + const SizedBox(height: 12), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + OutlinedButton( + key: ValueKey('external-acp-test-${profile.providerKey}'), + onPressed: testing + ? null + : () => testExternalAcpEndpointInternal( + controller, + profile.providerKey, + ), + child: Text( + testing + ? appText('测试中...', 'Testing...') + : appText('测试连接', 'Test Connection'), + ), + ), + FilledButton( + key: ValueKey('external-acp-apply-${profile.providerKey}'), + onPressed: () => saveExternalAcpEndpointInternal( + controller, + settings, + provider, + profile, + ), + child: Text(appText('保存并生效', 'Save & apply')), + ), + ], + ), + if (message.trim().isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + message, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], ], ), ); } + Future saveExternalAcpEndpointInternal( + AppController controller, + SettingsSnapshot settings, + SingleAgentProvider provider, + ExternalAcpEndpointProfile profile, + ) async { + final label = + externalAcpLabelControllersInternal[profile.providerKey]?.text ?? + profile.label; + final endpoint = + externalAcpEndpointControllersInternal[profile.providerKey]?.text ?? + profile.endpoint; + final next = settings.copyWithExternalAcpEndpointForProvider( + provider, + profile.copyWith(label: label, endpoint: endpoint), + ); + await saveSettingsInternal(controller, next); + await handleTopLevelApplyInternal(controller); + if (!mounted) { + return; + } + setStateInternal(() { + externalAcpMessageByProviderInternal[profile.providerKey] = appText( + '配置已保存并生效。', + 'Configuration saved and applied.', + ); + }); + } + + Future testExternalAcpEndpointInternal( + AppController controller, + String providerKey, + ) async { + final endpointText = + externalAcpEndpointControllersInternal[providerKey]?.text.trim() ?? ''; + final endpoint = Uri.tryParse(endpointText); + if (endpoint == null || endpoint.host.trim().isEmpty) { + setStateInternal(() { + externalAcpMessageByProviderInternal[providerKey] = appText( + '请输入有效的 ACP Server Endpoint。', + 'Enter a valid ACP server endpoint.', + ); + }); + return; + } + setStateInternal(() { + externalAcpTestingProvidersInternal.add(providerKey); + externalAcpMessageByProviderInternal.remove(providerKey); + }); + try { + final capabilities = await controller.gatewayAcpClientInternal + .loadCapabilities(forceRefresh: true, endpointOverride: endpoint); + if (!mounted) { + return; + } + setStateInternal(() { + externalAcpMessageByProviderInternal[providerKey] = appText( + capabilities.providers.isEmpty + ? '连接成功。' + : '连接成功,可用 Provider: ${capabilities.providers.map((item) => item.label).join(' / ')}', + capabilities.providers.isEmpty + ? 'Connection succeeded.' + : 'Connection succeeded. Providers: ${capabilities.providers.map((item) => item.label).join(' / ')}', + ); + }); + } catch (error) { + if (!mounted) { + return; + } + setStateInternal(() { + externalAcpMessageByProviderInternal[providerKey] = '$error'; + }); + } finally { + if (mounted) { + setStateInternal(() { + externalAcpTestingProvidersInternal.remove(providerKey); + }); + } + } + } + Future showAddExternalAcpProviderWizardInternal( BuildContext context, AppController controller, diff --git a/lib/features/settings/settings_page_support.dart b/lib/features/settings/settings_page_support.dart index 0187f27d..6ad40f3e 100644 --- a/lib/features/settings/settings_page_support.dart +++ b/lib/features/settings/settings_page_support.dart @@ -307,6 +307,67 @@ XWorkmate Privacy Policy ); } + void syncExternalAcpDraftControllersInternal(SettingsSnapshot settings) { + final activeKeys = settings.externalAcpEndpoints + .map((item) => item.providerKey) + .toSet(); + for (final profile in settings.externalAcpEndpoints) { + final key = profile.providerKey; + final labelController = externalAcpLabelControllersInternal.putIfAbsent( + key, + () => TextEditingController(), + ); + final endpointController = externalAcpEndpointControllersInternal + .putIfAbsent(key, () => TextEditingController()); + syncDraftControllerValueInternal( + labelController, + profile.label, + syncedValue: externalAcpLabelSyncedValuesInternal[key] ?? '', + onSyncedValueChanged: (value) => + externalAcpLabelSyncedValuesInternal[key] = value, + ); + syncDraftControllerValueInternal( + endpointController, + profile.endpoint, + syncedValue: externalAcpEndpointSyncedValuesInternal[key] ?? '', + onSyncedValueChanged: (value) => + externalAcpEndpointSyncedValuesInternal[key] = value, + ); + } + disposeRemovedExternalAcpDraftsInternal( + externalAcpLabelControllersInternal, + activeKeys, + ); + disposeRemovedExternalAcpDraftsInternal( + externalAcpEndpointControllersInternal, + activeKeys, + ); + externalAcpLabelSyncedValuesInternal.removeWhere( + (key, _) => !activeKeys.contains(key), + ); + externalAcpEndpointSyncedValuesInternal.removeWhere( + (key, _) => !activeKeys.contains(key), + ); + externalAcpMessageByProviderInternal.removeWhere( + (key, _) => !activeKeys.contains(key), + ); + externalAcpTestingProvidersInternal.removeWhere( + (key) => !activeKeys.contains(key), + ); + } + + void disposeRemovedExternalAcpDraftsInternal( + Map controllers, + Set activeKeys, + ) { + final removedKeys = controllers.keys + .where((key) => !activeKeys.contains(key)) + .toList(growable: false); + for (final key in removedKeys) { + controllers.remove(key)?.dispose(); + } + } + GatewayConnectionProfile selectedGatewayProfileInternal( SettingsSnapshot settings, ) { diff --git a/test/features/settings_page_suite.dart b/test/features/settings_page_suite.dart index 2f88eef8..3c1605be 100644 --- a/test/features/settings_page_suite.dart +++ b/test/features/settings_page_suite.dart @@ -159,11 +159,7 @@ void main() { ) async { final controller = await createTestController(tester); - await _pumpSettingsPage( - tester, - controller, - tab: SettingsTab.appearance, - ); + await _pumpSettingsPage(tester, controller, tab: SettingsTab.appearance); await tester.tap(find.text('深色')); await tester.pumpAndSettle(); @@ -221,78 +217,68 @@ void main() { (WidgetTester tester) async { final controller = await createTestController(tester); - await _pumpSettingsPage( - tester, - controller, - tab: SettingsTab.workspace, - ); + await _pumpSettingsPage(tester, controller, tab: SettingsTab.workspace); expect(find.text('远程项目根目录'), findsNothing); expect(find.text('Remote Project Root'), findsNothing); }, ); - testWidgets('SettingsPage renders only the active section without internal tabs', ( - WidgetTester tester, - ) async { - final controller = await createTestController(tester); + testWidgets( + 'SettingsPage renders only the active section without internal tabs', + (WidgetTester tester) async { + final controller = await createTestController(tester); - await pumpPage( - tester, - child: SettingsPage( - controller: controller, - showSectionTabs: false, - ), - platform: TargetPlatform.macOS, - ); + await pumpPage( + tester, + child: SettingsPage(controller: controller, showSectionTabs: false), + platform: TargetPlatform.macOS, + ); - expect(find.byType(SectionTabs), findsNothing); - expect(find.text('Application'), findsOneWidget); - expect(find.text('OpenClaw Gateway'), findsNothing); - expect(find.text('LLM 接入点'), findsNothing); - expect(find.text('工作区路径'), findsNothing); - expect( - find.byKey(const ValueKey('external-acp-provider-add-button')), - findsNothing, - ); - }); + expect(find.byType(SectionTabs), findsNothing); + expect(find.text('Application'), findsOneWidget); + expect(find.text('OpenClaw Gateway'), findsNothing); + expect(find.text('LLM 接入点'), findsNothing); + expect(find.text('工作区路径'), findsNothing); + expect( + find.byKey(const ValueKey('external-acp-provider-add-button')), + findsNothing, + ); + }, + ); - testWidgets('SettingsPage workspace edits enable the top save-and-apply flow', ( - WidgetTester tester, - ) async { - final controller = await createTestController(tester); + testWidgets( + 'SettingsPage workspace edits enable the top save-and-apply flow', + (WidgetTester tester) async { + final controller = await createTestController(tester); - await _pumpSettingsPage( - tester, - controller, - tab: SettingsTab.workspace, - ); + await _pumpSettingsPage(tester, controller, tab: SettingsTab.workspace); - await tester.enterText( - find.byType(TextFormField).first, - '/tmp/xworkmate-workspace', - ); - await tester.pump(); + await tester.enterText( + find.byType(TextFormField).first, + '/tmp/xworkmate-workspace', + ); + await tester.pump(); - expect(controller.settingsDraft.workspacePath, '/tmp/xworkmate-workspace'); - expect(controller.hasSettingsDraftChanges, isTrue); + expect( + controller.settingsDraft.workspacePath, + '/tmp/xworkmate-workspace', + ); + expect(controller.hasSettingsDraftChanges, isTrue); - final applyButton = tester.widget( - find.byKey(const ValueKey('settings-global-apply-button')), - ); - expect(applyButton.onPressed, isNotNull); - }); + final applyButton = tester.widget( + find.byKey(const ValueKey('settings-global-apply-button')), + ); + expect(applyButton.onPressed, isNotNull); + }, + ); testWidgets('SettingsPage integration tab exposes unified gateway controls', ( WidgetTester tester, ) async { final controller = await createTestController(tester); - await _pumpSettingsPage( - tester, - controller, - tab: SettingsTab.gateway, - ); + await _pumpSettingsPage(tester, controller, tab: SettingsTab.gateway); expect(find.text('OpenClaw Gateway'), findsWidgets); expect(find.text('LLM 接入点'), findsOneWidget); @@ -348,7 +334,10 @@ void main() { find.byKey(const ValueKey('external-acp-provider-add-button')), findsOneWidget, ); - expect(find.byKey(const ValueKey('settings-global-apply-button')), findsNothing); + expect( + find.byKey(const ValueKey('settings-global-apply-button')), + findsNothing, + ); }); testWidgets('SettingsPage vault card exposes concrete K/V fields', ( @@ -356,11 +345,7 @@ void main() { ) async { final controller = await createTestController(tester); - await _pumpSettingsPage( - tester, - controller, - tab: SettingsTab.gateway, - ); + await _pumpSettingsPage(tester, controller, tab: SettingsTab.gateway); expect(find.text('Vault Server'), findsAtLeastNWidgets(1)); expect(find.text('VAULT_SERVER_URL'), findsOneWidget); @@ -377,11 +362,7 @@ void main() { ) async { final controller = await createTestController(tester); - await _pumpSettingsPage( - tester, - controller, - tab: SettingsTab.gateway, - ); + await _pumpSettingsPage(tester, controller, tab: SettingsTab.gateway); expect(find.text('外部 ACP Server Endpoint'), findsOneWidget); expect(find.textContaining('Codex'), findsWidgets); @@ -411,11 +392,7 @@ void main() { ) async { final controller = await createTestController(tester); - await _pumpSettingsPage( - tester, - controller, - tab: SettingsTab.gateway, - ); + await _pumpSettingsPage(tester, controller, tab: SettingsTab.gateway); await _ensureVisible( tester, @@ -451,11 +428,7 @@ void main() { _FakeSkillDirectoryAccessService(userHomeDirectory: '/Users/tester'), ); - await _pumpSettingsPage( - tester, - controller, - tab: SettingsTab.gateway, - ); + await _pumpSettingsPage(tester, controller, tab: SettingsTab.gateway); expect(find.text('~/.agents/skills'), findsOneWidget); expect(find.text('/Users/tester/.agents/skills'), findsOneWidget); @@ -485,11 +458,7 @@ void main() { ), ); - await _pumpSettingsPage( - tester, - controller, - tab: SettingsTab.gateway, - ); + await _pumpSettingsPage(tester, controller, tab: SettingsTab.gateway); await _ensureVisible( tester, find.byKey(const ValueKey('skill-directory-batch-add-button')), @@ -546,11 +515,7 @@ paths: ), ); - await _pumpSettingsPage( - tester, - controller, - tab: SettingsTab.gateway, - ); + await _pumpSettingsPage(tester, controller, tab: SettingsTab.gateway); await _ensureVisible( tester, find.byKey(const ValueKey('skill-directory-batch-add-button')), @@ -590,11 +555,7 @@ paths: _FakeSkillDirectoryAccessService(userHomeDirectory: '/Users/tester'), ); - await _pumpSettingsPage( - tester, - controller, - tab: SettingsTab.gateway, - ); + await _pumpSettingsPage(tester, controller, tab: SettingsTab.gateway); await _ensureVisible( tester, find.byKey(const ValueKey('skill-directory-batch-add-button')), @@ -635,11 +596,7 @@ paths: ) async { final controller = await createTestController(tester); - await _pumpSettingsPage( - tester, - controller, - tab: SettingsTab.gateway, - ); + await _pumpSettingsPage(tester, controller, tab: SettingsTab.gateway); await tester.tap(find.byTooltip('折叠').first); await tester.pumpAndSettle(); @@ -667,11 +624,7 @@ paths: ) async { final controller = await createTestController(tester); - await _pumpSettingsPage( - tester, - controller, - tab: SettingsTab.gateway, - ); + await _pumpSettingsPage(tester, controller, tab: SettingsTab.gateway); await _ensureVisible(tester, find.text('外部 ACP Server Endpoint')); await tester.tap(find.text('外部 ACP Server Endpoint').first); @@ -693,6 +646,50 @@ paths: expect(find.textContaining('OpenCode'), findsWidgets); }); + testWidgets('SettingsPage external ACP card supports continuous input', ( + WidgetTester tester, + ) async { + final controller = await createTestController(tester); + final customProfile = buildCustomExternalAcpEndpointProfile( + controller.settingsDraft.externalAcpEndpoints, + label: 'Initial Name', + endpoint: 'wss://initial.example.com/acp', + ); + await controller.saveSettingsDraft( + controller.settingsDraft.copyWith( + externalAcpEndpoints: [ + ...controller.settingsDraft.externalAcpEndpoints, + customProfile, + ], + ), + ); + + await _pumpSettingsPage(tester, controller, tab: SettingsTab.gateway); + + final labelField = find.byKey( + ValueKey('external-acp-label-${customProfile.providerKey}'), + ); + final testButton = find.byKey( + ValueKey('external-acp-test-${customProfile.providerKey}'), + ); + final applyButton = find.byKey( + ValueKey('external-acp-apply-${customProfile.providerKey}'), + ); + + expect(labelField, findsOneWidget); + expect(testButton, findsOneWidget); + expect(applyButton, findsOneWidget); + + await tester.enterText(labelField, 'A'); + await tester.pump(); + await tester.enterText(labelField, 'AB'); + await tester.pump(); + await tester.enterText(labelField, 'ABC'); + await tester.pump(); + + expect(find.text('ABC'), findsOneWidget); + }); + testWidgets('SettingsPage shows Linux desktop integration controls', ( WidgetTester tester, ) async { @@ -758,11 +755,7 @@ paths: ) async { final controller = await createTestController(tester); - await _pumpSettingsPage( - tester, - controller, - tab: SettingsTab.gateway, - ); + await _pumpSettingsPage(tester, controller, tab: SettingsTab.gateway); await tester.tap(find.byKey(const ValueKey('gateway-profile-chip-1'))); await tester.pumpAndSettle(); @@ -847,11 +840,7 @@ paths: (WidgetTester tester) async { final controller = await createTestController(tester); - await _pumpSettingsPage( - tester, - controller, - tab: SettingsTab.diagnostics, - ); + await _pumpSettingsPage(tester, controller, tab: SettingsTab.diagnostics); expect( find.byKey(const ValueKey('assistant-local-state-card')),