diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index 1283e93d..9d26b8b7 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -71,16 +71,21 @@ const int _singleAgentLocalSkillsCacheSchemaVersion = 4; class AppController extends ChangeNotifier { static const List<_SingleAgentSkillScanRoot> _defaultSingleAgentGlobalSkillScanRoots = <_SingleAgentSkillScanRoot>[ - _SingleAgentSkillScanRoot( - path: '/etc/skills', - source: 'system', - scope: 'system', - ), _SingleAgentSkillScanRoot( path: '~/.agents/skills', source: 'agents', scope: 'user', ), + _SingleAgentSkillScanRoot( + path: '~/.codex/skills', + source: 'codex', + scope: 'user', + ), + _SingleAgentSkillScanRoot( + path: '~/.workbuddy/skills', + source: 'workbuddy', + scope: 'user', + ), ]; static const List<_SingleAgentSkillScanRoot> _defaultSingleAgentWorkspaceSkillScanRoots = <_SingleAgentSkillScanRoot>[ @@ -4428,6 +4433,12 @@ class AppController extends ChangeNotifier { if (path == '~/.agents/skills' || path.endsWith('/.agents/skills')) { return 'agents'; } + if (path == '~/.codex/skills' || path.endsWith('/.codex/skills')) { + return 'codex'; + } + if (path == '~/.workbuddy/skills' || path.endsWith('/.workbuddy/skills')) { + return 'workbuddy'; + } return 'custom'; } diff --git a/lib/app/app_controller_web.dart b/lib/app/app_controller_web.dart index 568b0601..b59a2e18 100644 --- a/lib/app/app_controller_web.dart +++ b/lib/app/app_controller_web.dart @@ -110,8 +110,9 @@ class AppController extends ChangeNotifier { List get authorizedSkillDirectories => _settings.authorizedSkillDirectories; List get recommendedAuthorizedSkillDirectoryPaths => const [ - '/etc/skills', '~/.agents/skills', + '~/.codex/skills', + '~/.workbuddy/skills', ]; String get userHomeDirectory => ''; String get settingsYamlPath => ''; diff --git a/lib/features/settings/skill_directory_authorization_card.dart b/lib/features/settings/skill_directory_authorization_card.dart index ec1b2e38..d79e3364 100644 --- a/lib/features/settings/skill_directory_authorization_card.dart +++ b/lib/features/settings/skill_directory_authorization_card.dart @@ -6,6 +6,8 @@ import '../../runtime/runtime_models.dart'; import '../../theme/app_palette.dart'; import '../../widgets/surface_card.dart'; +enum _SkillDirectoryAuthorizationMode { direct, picker } + class SkillDirectoryAuthorizationCard extends StatefulWidget { const SkillDirectoryAuthorizationCard({super.key, required this.controller}); @@ -53,8 +55,8 @@ class _SkillDirectoryAuthorizationCardState const SizedBox(height: 8), Text( appText( - '预设扫描目录固定为 /etc/skills 和 ~/.agents/skills;其他目录可批量添加为自定义目录扩展扫描列表。设置中心修改会写入 settings.yaml。', - 'Preset scan roots are fixed to /etc/skills and ~/.agents/skills. Other locations can be added in batches as custom directories to extend the scan list. Settings Center writes changes back to settings.yaml.', + '预设目录支持直接按路径加入;也可以把终端输出里的路径直接贴进来批量导入。系统目录选择器保留在同行旁侧,作为可选授权方式。设置中心修改会写入 settings.yaml。', + 'Preset roots can be added directly by path, and terminal output paths can be pasted for batch import. The system directory picker remains available as an optional side action. Settings Center writes changes back to settings.yaml.', ), style: theme.textTheme.bodyMedium, ), @@ -91,7 +93,13 @@ class _SkillDirectoryAuthorizationCardState presetPath, homeDirectory: homeDirectory, ), - onAuthorize: () => _authorizeDirectory( + onDirectAuthorize: () => _saveDirectoriesFromPaths([ + _resolvePathForDisplay( + presetPath, + homeDirectory: homeDirectory, + ), + ]), + onPickerAuthorize: () => _authorizeDirectory( suggestedPath: _resolvePathForDisplay( presetPath, homeDirectory: homeDirectory, @@ -112,7 +120,9 @@ class _SkillDirectoryAuthorizationCardState title: _displayNameForPath(directory.path), subtitle: directory.path, directory: directory, - onAuthorize: () => + onDirectAuthorize: () => + _saveDirectoriesFromPaths([directory.path]), + onPickerAuthorize: () => _authorizeDirectory(suggestedPath: directory.path), ), const SizedBox(height: 10), @@ -121,14 +131,15 @@ class _SkillDirectoryAuthorizationCardState Align( alignment: Alignment.centerLeft, child: FilledButton.tonalIcon( - onPressed: _busy ? null : _authorizeDirectories, + key: const ValueKey('skill-directory-batch-add-button'), + onPressed: _busy ? null : _showDirectoryAuthorizationDialog, icon: _busy ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Icon(Icons.create_new_folder_outlined), + : const Icon(Icons.playlist_add_rounded), label: Text(appText('批量添加自定义目录', 'Add Custom Directories')), ), ), @@ -146,8 +157,8 @@ class _SkillDirectoryAuthorizationCardState const SizedBox(height: 12), Text( appText( - 'macOS 会通过目录选择器显式授予只读访问,并持久化授权 bookmark;移除目录会立即停止扫描该目录。', - 'On macOS the directory picker grants explicit read-only access and persists the authorization bookmark. Removing a directory stops scanning it immediately.', + '按路径添加会立即写入扫描列表;如果 macOS 对某个目录仍缺系统级访问权限,可使用旁侧目录向导补授 bookmark。移除目录会立即停止扫描该目录。', + 'Direct path add updates the scan list immediately. If macOS still needs system-level access for a directory, use the adjacent picker flow to grant a bookmark. Removing a directory stops scanning it immediately.', ), style: theme.textTheme.bodySmall?.copyWith( color: palette.textSecondary, @@ -163,7 +174,8 @@ class _SkillDirectoryAuthorizationCardState required String title, required String subtitle, required AuthorizedSkillDirectory? directory, - required Future Function() onAuthorize, + required Future Function() onDirectAuthorize, + required Future Function() onPickerAuthorize, }) { final theme = Theme.of(context); final palette = context.palette; @@ -196,18 +208,23 @@ class _SkillDirectoryAuthorizationCardState runSpacing: 8, children: [ FilledButton.tonalIcon( - onPressed: _busy ? null : onAuthorize, + onPressed: _busy ? null : onDirectAuthorize, icon: Icon( directory == null - ? Icons.folder_open_rounded + ? Icons.playlist_add_rounded : Icons.refresh_rounded, ), label: Text( directory == null - ? appText('授权目录', 'Authorize') - : appText('重新授权', 'Re-authorize'), + ? appText('按路径授权', 'Authorize by Path') + : appText('重新同步', 'Resync Path'), ), ), + OutlinedButton.icon( + onPressed: _busy ? null : onPickerAuthorize, + icon: const Icon(Icons.folder_open_rounded), + label: Text(appText('目录向导', 'Directory Picker')), + ), if (directory != null) OutlinedButton.icon( onPressed: _busy ? null : () => _removeDirectory(directory), @@ -238,6 +255,30 @@ class _SkillDirectoryAuthorizationCardState return null; } + Future _showDirectoryAuthorizationDialog() async { + final result = await showDialog<_SkillDirectoryAuthorizationDialogResult>( + context: context, + builder: (context) => _SkillDirectoryAuthorizationDialog( + presetPaths: widget.controller.recommendedAuthorizedSkillDirectoryPaths + .map( + (path) => _resolvePathForDisplay( + path, + homeDirectory: widget.controller.userHomeDirectory, + ), + ) + .toList(growable: false), + ), + ); + if (!mounted || result == null) { + return; + } + if (result.mode == _SkillDirectoryAuthorizationMode.picker) { + await _authorizeDirectories(suggestedPaths: result.paths); + return; + } + await _saveDirectoriesFromPaths(result.paths); + } + Future _authorizeDirectory({String suggestedPath = ''}) async { setState(() { _busy = true; @@ -286,14 +327,18 @@ class _SkillDirectoryAuthorizationCardState } } - Future _authorizeDirectories() async { + Future _authorizeDirectories({ + List suggestedPaths = const [], + }) async { setState(() { _busy = true; _statusMessage = null; _errorMessage = null; }); try { - final granted = await widget.controller.authorizeSkillDirectories(); + final granted = await widget.controller.authorizeSkillDirectories( + suggestedPaths: suggestedPaths, + ); if (granted.isEmpty) { if (!mounted) { return; @@ -331,6 +376,49 @@ class _SkillDirectoryAuthorizationCardState } } + Future _saveDirectoriesFromPaths(List rawPaths) async { + final paths = _extractAuthorizedPathCandidates(rawPaths.join('\n')); + if (paths.isEmpty) { + setState(() { + _statusMessage = null; + _errorMessage = appText( + '没有识别到可用目录路径。请每行提供一个以 / 或 ~/ 开头的目录。', + 'No usable directory paths were detected. Provide one directory per line starting with / or ~/.', + ); + }); + return; + } + setState(() { + _busy = true; + _statusMessage = null; + _errorMessage = null; + }); + try { + final next = _mergedAuthorizedDirectories( + paths.map(_authorizedDirectoryForPath).toList(growable: false), + ); + await widget.controller.saveAuthorizedSkillDirectories(next); + if (!mounted) { + return; + } + setState(() { + _busy = false; + _statusMessage = appText( + '已同步 ${paths.length} 个目录到 settings.yaml;如 macOS 仍无法读取,可再使用目录向导补授权。', + 'Synced ${paths.length} directories to settings.yaml. If macOS still cannot read one, use the picker flow to grant access.', + ); + }); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _busy = false; + _errorMessage = error.toString(); + }); + } + } + Future _removeDirectory(AuthorizedSkillDirectory directory) async { setState(() { _busy = true; @@ -411,6 +499,210 @@ class _SkillDirectoryAuthorizationCardState final segments = normalized.split(RegExp(r'[\\/]')); return segments.isEmpty ? normalized : segments.last; } + + AuthorizedSkillDirectory _authorizedDirectoryForPath(String path) { + final normalized = normalizeAuthorizedSkillDirectoryPath(path); + final existing = _findAuthorizedDirectory( + widget.controller.authorizedSkillDirectories, + normalized, + homeDirectory: widget.controller.userHomeDirectory, + ); + return AuthorizedSkillDirectory( + path: normalized, + bookmark: existing?.bookmark ?? '', + ); + } +} + +List _extractAuthorizedPathCandidates(String rawInput) { + final extracted = []; + final seen = {}; + for (final line in rawInput.split(RegExp(r'[\r\n]+'))) { + for (final candidate in _extractAuthorizedPathCandidatesFromLine(line)) { + final normalized = normalizeAuthorizedSkillDirectoryPath(candidate); + if (normalized.isNotEmpty && seen.add(normalized)) { + extracted.add(normalized); + } + } + } + return extracted; +} + +Iterable _extractAuthorizedPathCandidatesFromLine(String line) sync* { + var normalizedLine = line.trim(); + if (normalizedLine.isEmpty) { + return; + } + normalizedLine = normalizedLine + .replaceFirst(RegExp(r'^[\-\*\u2022]\s*'), '') + .replaceFirst(RegExp(r'^\d+[.)]\s*'), '') + .replaceFirst( + RegExp(r'^(?:path|paths|dir|directory|路径|目录)\s*[:=]\s*'), + '', + ); + final matches = RegExp( + r"""["']((?:~/|/)[^"']+)["']|`((?:~/|/)[^`]+)`|((?:~/|/)[^,\s;]+)""", + ).allMatches(normalizedLine); + if (matches.isNotEmpty) { + for (final match in matches) { + final candidate = + match.group(1) ?? match.group(2) ?? match.group(3) ?? ''; + if (candidate.isNotEmpty) { + yield candidate; + } + } + return; + } + final unwrapped = normalizedLine.replaceAll( + RegExp(r"""^[\[\(\{<"'`]+|[\]\)\}>,"';`]+$"""), + '', + ); + if (unwrapped.startsWith('/') || unwrapped.startsWith('~/')) { + yield unwrapped; + } +} + +class _SkillDirectoryAuthorizationDialogResult { + const _SkillDirectoryAuthorizationDialogResult({ + required this.mode, + required this.paths, + }); + + final _SkillDirectoryAuthorizationMode mode; + final List paths; +} + +class _SkillDirectoryAuthorizationDialog extends StatefulWidget { + const _SkillDirectoryAuthorizationDialog({required this.presetPaths}); + + final List presetPaths; + + @override + State<_SkillDirectoryAuthorizationDialog> createState() => + _SkillDirectoryAuthorizationDialogState(); +} + +class _SkillDirectoryAuthorizationDialogState + extends State<_SkillDirectoryAuthorizationDialog> { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final parsedPaths = _extractAuthorizedPathCandidates(_controller.text); + return AlertDialog( + title: Text(appText('批量添加自定义目录', 'Add Custom Directories')), + content: SizedBox( + width: 640, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText( + '默认直接贴路径即可添加;系统目录选择器放在旁侧作为可选动作。支持一行一个目录,也支持直接粘贴终端输出。', + 'Paste paths directly to add them by default. The system directory picker stays beside it as an optional action. Supports one directory per line or pasted terminal output.', + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final path in widget.presetPaths) + ActionChip( + label: Text(path), + onPressed: () { + final current = _controller.text.trim(); + _controller.text = current.isEmpty + ? path + : '$current\n$path'; + _controller.selection = TextSelection.collapsed( + offset: _controller.text.length, + ); + setState(() {}); + }, + ), + ], + ), + const SizedBox(height: 12), + TextField( + key: const ValueKey('skill-directory-path-input'), + controller: _controller, + minLines: 5, + maxLines: 8, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + hintText: appText( + '一行一个目录,或直接粘贴命令输出\n~/.agents/skills\n~/.codex/skills\n/Users/shenlan/.workbuddy/skills', + 'One directory per line, or paste command output\n~/.agents/skills\n~/.codex/skills\n/Users/shenlan/.workbuddy/skills', + ), + ), + ), + const SizedBox(height: 10), + Text( + appText( + '已识别 ${parsedPaths.length} 个路径', + 'Detected ${parsedPaths.length} paths', + ), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(appText('取消', 'Cancel')), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + FilledButton.icon( + key: const ValueKey('skill-directory-direct-add-button'), + onPressed: parsedPaths.isEmpty + ? null + : () { + Navigator.of(context).pop( + _SkillDirectoryAuthorizationDialogResult( + mode: _SkillDirectoryAuthorizationMode.direct, + paths: parsedPaths, + ), + ); + }, + icon: const Icon(Icons.playlist_add_rounded), + label: Text(appText('按路径添加', 'Add by Path')), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + key: const ValueKey('skill-directory-picker-button'), + onPressed: () { + Navigator.of(context).pop( + _SkillDirectoryAuthorizationDialogResult( + mode: _SkillDirectoryAuthorizationMode.picker, + paths: parsedPaths, + ), + ); + }, + icon: const Icon(Icons.folder_open_rounded), + label: Text(appText('目录向导', 'Directory Picker')), + ), + ], + ), + ], + ); + } } class _StatusChip extends StatelessWidget { diff --git a/test/features/settings_page_suite.dart b/test/features/settings_page_suite.dart index 8a5ec9c7..7db8fc0f 100644 --- a/test/features/settings_page_suite.dart +++ b/test/features/settings_page_suite.dart @@ -342,11 +342,12 @@ void main() { await tester.tap(find.text('SKILLS 目录授权')); await tester.pumpAndSettle(); - expect(find.text('/etc/skills'), findsWidgets); expect(find.text('~/.agents/skills'), findsOneWidget); expect(find.text('/Users/tester/.agents/skills'), findsOneWidget); - expect(find.text('~/.codex/skills'), findsNothing); - expect(find.text('~/.workbuddy/skills'), findsNothing); + expect(find.text('~/.codex/skills'), findsOneWidget); + expect(find.text('/Users/tester/.codex/skills'), findsOneWidget); + expect(find.text('~/.workbuddy/skills'), findsOneWidget); + expect(find.text('/Users/tester/.workbuddy/skills'), findsOneWidget); }); testWidgets('SettingsPage can batch add custom skills directories', ( @@ -379,7 +380,22 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.text('SKILLS 目录授权')); await tester.pumpAndSettle(); - await tester.tap(find.text('批量添加自定义目录')); + await tester.tap( + find.byKey(const ValueKey('skill-directory-batch-add-button')), + ); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('skill-directory-path-input')), + ''' +paths: + - /Users/tester/custom-a + - "/Users/tester/custom-b" +''', + ); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('skill-directory-direct-add-button')), + ); await tester.pump(); for ( var attempt = 0; @@ -400,6 +416,59 @@ void main() { expect(find.text('custom-b'), findsOneWidget); }); + testWidgets('SettingsPage skills authorization dialog can use picker flow', ( + WidgetTester tester, + ) async { + final controller = await _createControllerWithSkillAccessService( + tester, + _FakeSkillDirectoryAccessService( + userHomeDirectory: '/Users/tester', + multiDirectoryResponse: const [ + AuthorizedSkillDirectory( + path: '/Users/tester/custom-picker', + bookmark: 'bookmark-picker', + ), + ], + ), + ); + + await pumpPage( + tester, + child: SettingsPage(controller: controller), + platform: TargetPlatform.macOS, + ); + + await tester.tap(find.text('集成')); + await tester.pumpAndSettle(); + await tester.tap(find.text('SKILLS 目录授权')); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('skill-directory-batch-add-button')), + ); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('skill-directory-path-input')), + '/Users/tester/custom-picker', + ); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('skill-directory-picker-button')), + ); + await tester.pump(); + for ( + var attempt = 0; + attempt < 10 && controller.authorizedSkillDirectories.isEmpty; + attempt += 1 + ) { + await tester.pump(const Duration(milliseconds: 100)); + } + + expect( + controller.authorizedSkillDirectories.map((item) => item.path), + contains('/Users/tester/custom-picker'), + ); + }); + testWidgets('SettingsPage gateway sections can collapse individually', ( WidgetTester tester, ) async {