diff --git a/assets/i18n/en_us/builtin.tools-settings.json b/assets/i18n/en_us/builtin.tools-settings.json index c4e71379..af3a7b24 100644 --- a/assets/i18n/en_us/builtin.tools-settings.json +++ b/assets/i18n/en_us/builtin.tools-settings.json @@ -7,5 +7,20 @@ "settings.field.d2.label": { "translation": "d2" }, "settings.field.d2.help": { "translation": "Absolute path to the d2 diagram compiler; blank to auto-resolve." }, "settings.field.detect.label": { "translation": "Re-detect" }, - "settings.field.detect.help": { "translation": "Re-scan PATH and the common install dirs, overwriting the paths above." } + "settings.field.detect.help": { "translation": "Re-scan PATH and the common install dirs, overwriting the paths above." }, + "settings.section.path": { "translation": "Workspace PATH" }, + "settings.field.pathPreset.label": { "translation": "Prepend to PATH" }, + "settings.field.pathPreset.help": { "translation": "Directories put ahead of PATH in Claude sessions and terminal panes spawned for this repo; worktrees share the repo's preset. Applies to new shells." }, + "path.noWorkspace": { "translation": "Open a workspace to set its PATH preset." }, + "path.sharedRoot": { "translation": "Worktree — preset shared with" }, + "path.empty": { "translation": "No preset entries — spawned shells get the resolved login-shell PATH as-is." }, + "path.add": { "translation": "Add entry" }, + "path.invalid": { "translation": "Enter an absolute path (or ~/…)." }, + "path.missing": { "translation": "missing" }, + "path.moveUp": { "translation": "Move up" }, + "path.moveDown": { "translation": "Move down" }, + "path.remove": { "translation": "Remove" }, + "path.capture": { "translation": "Suggest from login shell" }, + "path.captureNone": { "translation": "Nothing to suggest — the login-shell PATH is already covered." }, + "path.addSuggestion": { "translation": "Add suggested entry" } } diff --git a/assets/i18n/nl_nl/builtin.tools-settings.json b/assets/i18n/nl_nl/builtin.tools-settings.json index 12f1ad9a..8fa089bb 100644 --- a/assets/i18n/nl_nl/builtin.tools-settings.json +++ b/assets/i18n/nl_nl/builtin.tools-settings.json @@ -7,5 +7,20 @@ "settings.field.d2.label": { "translation": "d2" }, "settings.field.d2.help": { "translation": "Absoluut pad naar de d2-diagramcompiler; leeg om automatisch te bepalen." }, "settings.field.detect.label": { "translation": "Opnieuw detecteren" }, - "settings.field.detect.help": { "translation": "Scan PATH en de gangbare installatiemappen opnieuw; overschrijft de paden hierboven." } + "settings.field.detect.help": { "translation": "Scan PATH en de gangbare installatiemappen opnieuw; overschrijft de paden hierboven." }, + "settings.section.path": { "translation": "Workspace-PATH" }, + "settings.field.pathPreset.label": { "translation": "Vooraan aan PATH toevoegen" }, + "settings.field.pathPreset.help": { "translation": "Mappen die vóór PATH komen in Claude-sessies en terminalvensters voor deze repo; worktrees delen de preset van de repo. Geldt voor nieuwe shells." }, + "path.noWorkspace": { "translation": "Open een workspace om de PATH-preset in te stellen." }, + "path.sharedRoot": { "translation": "Worktree — preset gedeeld met" }, + "path.empty": { "translation": "Geen preset-items — nieuwe shells krijgen het opgeloste login-shell-PATH ongewijzigd." }, + "path.add": { "translation": "Item toevoegen" }, + "path.invalid": { "translation": "Voer een absoluut pad in (of ~/…)." }, + "path.missing": { "translation": "ontbreekt" }, + "path.moveUp": { "translation": "Omhoog" }, + "path.moveDown": { "translation": "Omlaag" }, + "path.remove": { "translation": "Verwijderen" }, + "path.capture": { "translation": "Voorstellen uit login-shell" }, + "path.captureNone": { "translation": "Niets voor te stellen — het login-shell-PATH is al gedekt." }, + "path.addSuggestion": { "translation": "Voorgesteld item toevoegen" } } diff --git a/lib/builtin/tools_settings/src/extension.dart b/lib/builtin/tools_settings/src/extension.dart index 5017e263..baed60fc 100644 --- a/lib/builtin/tools_settings/src/extension.dart +++ b/lib/builtin/tools_settings/src/extension.dart @@ -1,3 +1,4 @@ +import 'package:clide/builtin/tools_settings/src/path_preset_control.dart'; import 'package:clide/clide.dart'; import 'package:clide/extension/extension.dart'; import 'package:clide/kernel/kernel.dart'; @@ -7,6 +8,9 @@ import 'package:clide/src/env/supporter_binaries.dart'; /// claude, d2, … (D-104 / T-495). A path field per tool (`app.tools.`), /// auto-detected on first run, plus a Re-detect action. Editing a path rebuilds /// the live resolver so the change takes effect without a restart. +/// +/// Also hosts the per-workspace PATH preset editor (D-106 / T-511) — the UI +/// half of `clide env path …`. class ToolsSettingsExtension extends ClideExtension { @override String get id => 'builtin.tools-settings'; @@ -87,9 +91,31 @@ class ToolsSettingsExtension extends ClideExtension { ), ], ), + // Per-workspace PATH preset (D-106, T-511): dirs prepended to the + // PATH of every shell clide spawns for this repo. CLI parity: + // `clide env path list|set|add|remove|clear|capture`. + SettingsSection( + label: 'Workspace PATH', + labelKey: 'settings.section.path', + fields: [ + SettingsField( + // Placeholder key — the custom control persists to the + // worktree-aware app.env.pathPrepend. key itself. + key: 'app.env.pathPrepend', + kind: SettingsFieldKind.custom, + label: 'Prepend to PATH', + labelKey: 'settings.field.pathPreset.label', + help: + 'Directories put ahead of PATH in Claude sessions and terminal panes spawned for this repo; worktrees share the repo\'s preset. Applies to new shells.', + helpKey: 'settings.field.pathPreset.help', + customId: 'tools.path-preset', + ), + ], + ), ], ), ), + SettingsControlContribution(id: 'tools.path-preset', customId: 'tools.path-preset', builder: (_) => const PathPresetControl()), ]; Future _redetect(List args) async { diff --git a/lib/builtin/tools_settings/src/path_preset_control.dart b/lib/builtin/tools_settings/src/path_preset_control.dart new file mode 100644 index 00000000..994650e8 --- /dev/null +++ b/lib/builtin/tools_settings/src/path_preset_control.dart @@ -0,0 +1,330 @@ +import 'dart:io' show Directory, Platform; + +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/src/daemon/env_path_commands.dart' show envPathChannel; +import 'package:clide/src/env/path_preset.dart'; +import 'package:clide/src/env/shell_env.dart' show loginShellPathOrNull; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; + +const _ns = 'builtin.tools-settings'; + +String _s(BuildContext context, String key, String placeholder) => ClideSettings.i18n.string(context, key, namespace: _ns, placeholder: placeholder); + +/// Settings control for the per-workspace PATH preset (D-106, T-511): an +/// ordered list of directories prepended to the PATH of everything clide +/// spawns for this repo — hosted Claude sessions and terminal panes alike. +/// +/// The preset keys off the REPO identity ([presetRootFor]): opened from a +/// linked worktree (e.g. `.worktrees/`) the control edits the main +/// repo's preset and says so. Writes go to the user-scope settings key the +/// `clide env path` verbs use, published on [envPathChannel] (D-6 parity); +/// the store notifier keeps this control live for CLI edits. +class PathPresetControl extends StatefulWidget { + const PathPresetControl({super.key}); + + @override + State createState() => _PathPresetControlState(); +} + +class _PathPresetControlState extends State { + final TextEditingController _entry = TextEditingController(); + final FocusNode _focus = FocusNode(debugLabel: 'add-path-entry'); + SettingsStore? _settings; + + /// Capture-from-login-shell results; null until the button is pressed. + List? _suggestions; + bool _addRejected = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final settings = ClideKernel.maybeOf(context)?.settings; + if (identical(settings, _settings)) return; + _settings?.removeListener(_onChange); + _settings = settings; + _settings?.addListener(_onChange); + } + + void _onChange() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _settings?.removeListener(_onChange); + _entry.dispose(); + _focus.dispose(); + super.dispose(); + } + + List _dirs(KernelServices services, String cwd) => presetDirsFrom((k) => services.settings.get(k), cwd); + + Future _write(KernelServices services, String cwd, List dirs, String action) async { + final key = pathPresetKey(cwd); + final write = dirs.isEmpty ? services.settings.removeAt(SettingsScope.app, key) : services.settings.setAt(SettingsScope.app, key, dirs); + services.messages.publish('ui', envPathChannel, {'action': action, 'root': presetRootFor(cwd), 'dirs': dirs}); + await write; + } + + /// Expand a leading `~/`, require an absolute path (mirrors the `env path` + /// verb's guard), strip trailing slashes. Null = rejected. + String? _normalizeEntry(String raw) { + var d = raw.trim(); + if (d == '~' || d.startsWith('~/')) { + final home = Platform.environment['HOME']; + if (home == null || home.isEmpty) return null; + d = d == '~' ? home : '$home${d.substring(1)}'; + } + if (d.isEmpty || !(d.startsWith('/') || RegExp(r'^[A-Za-z]:[/\\]').hasMatch(d))) return null; + while (d.length > 1 && d.endsWith('/')) { + d = d.substring(0, d.length - 1); + } + return d; + } + + Future _add(KernelServices services, String cwd, [String? suggestion]) async { + final raw = suggestion ?? _entry.text; + if (raw.trim().isEmpty) return; + final d = _normalizeEntry(raw); + if (d == null) { + setState(() => _addRejected = true); + return; + } + final dirs = _dirs(services, cwd); + if (suggestion == null) _entry.clear(); + setState(() { + _addRejected = false; + _suggestions?.remove(raw); + _suggestions?.remove(d); + }); + if (dirs.contains(d)) return; + await _write(services, cwd, [...dirs, d], 'add'); + } + + Future _move(KernelServices services, String cwd, int index, int delta) async { + final dirs = _dirs(services, cwd); + final to = index + delta; + if (to < 0 || to >= dirs.length) return; + final out = [...dirs]; + final d = out.removeAt(index); + out.insert(to, d); + await _write(services, cwd, out, 'set'); + } + + Future _remove(KernelServices services, String cwd, String dir) async { + final kept = _dirs(services, cwd).where((d) => d != dir).toList(); + await _write(services, cwd, kept, 'remove'); + } + + void _capture(List current) { + final missing = missingLoginShellDirs(loginPath: loginShellPathOrNull(), processPath: Platform.environment['PATH'] ?? ''); + setState(() => _suggestions = missing.where((d) => !current.contains(d)).toList()); + } + + @override + Widget build(BuildContext context) { + final tokens = ClideSettings.theme.of(context).surface; + final services = ClideKernel.maybeOf(context); + final cwd = services?.settings.projectDir?.path; + if (services == null || cwd == null) { + return ClideText(_s(context, 'path.noWorkspace', 'Open a workspace to set its PATH preset.'), fontSize: clideFontCaption, color: tokens.globalTextMuted); + } + + final root = presetRootFor(cwd); + final dirs = _dirs(services, cwd); + final suggestions = _suggestions; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (root != cwd) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + ClideText(_s(context, 'path.sharedRoot', 'Worktree — preset shared with'), fontSize: clideFontCaption, color: tokens.globalTextMuted), + const SizedBox(width: 6), + Expanded( + child: ClideText( + root, + fontSize: clideFontCaption, + muted: true, + fontFamily: ClideSettings.fonts.monoOf(context), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + if (dirs.isEmpty) + ClideText( + _s(context, 'path.empty', 'No preset entries — spawned shells get the resolved login-shell PATH as-is.'), + fontSize: clideFontCaption, + color: tokens.globalTextMuted, + ) + else + for (var i = 0; i < dirs.length; i++) _row(context, services, cwd, tokens, dirs, i), + const SizedBox(height: 10), + _addRow(context, services, cwd, tokens), + if (_addRejected) + Padding( + padding: const EdgeInsets.only(top: 4), + child: ClideText(_s(context, 'path.invalid', 'Enter an absolute path (or ~/…).'), fontSize: clideFontCaption, color: tokens.statusError), + ), + const SizedBox(height: 10), + _captureButton(context, tokens, dirs), + if (suggestions != null && suggestions.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 6), + child: ClideText( + _s(context, 'path.captureNone', 'Nothing to suggest — the login-shell PATH is already covered.'), + fontSize: clideFontCaption, + color: tokens.globalTextMuted, + ), + ), + if (suggestions != null) + for (final d in suggestions) _suggestionRow(context, services, cwd, tokens, d), + ], + ); + } + + Widget _row(BuildContext context, KernelServices services, String cwd, SurfaceTokens tokens, List dirs, int index) { + final dir = dirs[index]; + final exists = Directory(dir).existsSync(); + final removeLabel = _s(context, 'path.remove', 'Remove'); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Expanded( + child: ClideText( + dir, + fontSize: clideFontMono, + fontFamily: ClideSettings.fonts.monoOf(context), + color: exists ? tokens.globalForeground : tokens.statusWarning, + overflow: TextOverflow.ellipsis, + ), + ), + if (!exists) ...[ + const SizedBox(width: 6), + ClideText(_s(context, 'path.missing', 'missing'), fontSize: clideFontCaption, color: tokens.statusWarning), + ], + const SizedBox(width: 8), + _iconButton(context, 'arrow-up', '${_s(context, 'path.moveUp', 'Move up')}: $dir', index == 0 ? null : () => _move(services, cwd, index, -1)), + const SizedBox(width: 6), + _iconButton( + context, + 'arrow-down', + '${_s(context, 'path.moveDown', 'Move down')}: $dir', + index == dirs.length - 1 ? null : () => _move(services, cwd, index, 1), + ), + const SizedBox(width: 6), + _iconButton(context, 'trash', '$removeLabel: $dir', () => _remove(services, cwd, dir), color: tokens.statusError), + ], + ), + ); + } + + Widget _suggestionRow(BuildContext context, KernelServices services, String cwd, SurfaceTokens tokens, String dir) { + final addLabel = _s(context, 'path.addSuggestion', 'Add suggested entry'); + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Row( + children: [ + _iconButton(context, 'plus-circle', '$addLabel: $dir', () => _add(services, cwd, dir), color: tokens.statusSuccess), + const SizedBox(width: 8), + Expanded( + child: ClideText(dir, fontSize: clideFontMono, fontFamily: ClideSettings.fonts.monoOf(context), muted: true, overflow: TextOverflow.ellipsis), + ), + ], + ), + ); + } + + Widget _iconButton(BuildContext context, String icon, String semantic, VoidCallback? onTap, {Color? color}) { + final tokens = ClideSettings.theme.of(context).surface; + final enabledColor = color ?? tokens.globalForeground; + return Semantics( + button: true, + enabled: onTap != null, + label: semantic, + excludeSemantics: true, + child: ClideTappable( + cursor: onTap != null ? SystemMouseCursors.click : SystemMouseCursors.basic, + onTap: onTap, + builder: (ctx, hovered, _) => + ClideIcon(PhosphorIcons.byName(icon), size: 14, color: onTap == null ? tokens.globalTextMuted : (hovered ? enabledColor : tokens.globalTextMuted)), + ), + ); + } + + Widget _addRow(BuildContext context, KernelServices services, String cwd, SurfaceTokens tokens) { + final addLabel = _s(context, 'path.add', 'Add entry'); + return Row( + children: [ + Expanded( + child: Container( + height: 26, + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: tokens.panelBackground, + border: Border.all(color: _focus.hasFocus ? tokens.panelActiveBorder : tokens.dividerColor), + borderRadius: BorderRadius.circular(4), + ), + child: EditableText( + controller: _entry, + focusNode: _focus, + style: TextStyle(fontFamily: ClideSettings.fonts.monoOf(context), fontSize: clideFontMono, color: tokens.globalForeground), + cursorColor: tokens.globalFocus, + backgroundCursorColor: tokens.globalTextMuted, + maxLines: 1, + onSubmitted: (_) => _add(services, cwd), + ), + ), + ), + const SizedBox(width: 8), + Semantics( + button: true, + label: addLabel, + excludeSemantics: true, + child: ClideTappable( + cursor: SystemMouseCursors.click, + onTap: () => _add(services, cwd), + builder: (ctx, hovered, _) => Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : tokens.buttonBackground, borderRadius: BorderRadius.circular(4)), + child: ClideText(addLabel, color: tokens.buttonForeground, fontSize: clideFontCaption), + ), + ), + ), + ], + ); + } + + Widget _captureButton(BuildContext context, SurfaceTokens tokens, List dirs) { + final label = _s(context, 'path.capture', 'Suggest from login shell'); + return Row( + children: [ + Semantics( + button: true, + label: label, + excludeSemantics: true, + child: ClideTappable( + cursor: SystemMouseCursors.click, + onTap: () => _capture(dirs), + builder: (ctx, hovered, _) => Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + decoration: BoxDecoration( + color: hovered ? tokens.listItemHoverBackground : tokens.panelBackground, + border: Border.all(color: tokens.dividerColor), + borderRadius: BorderRadius.circular(4), + ), + child: ClideText(label, color: tokens.globalForeground, fontSize: clideFontCaption), + ), + ), + ), + ], + ); + } +} diff --git a/test/builtin/tools_settings/path_preset_control_test.dart b/test/builtin/tools_settings/path_preset_control_test.dart new file mode 100644 index 00000000..3dc2fb14 --- /dev/null +++ b/test/builtin/tools_settings/path_preset_control_test.dart @@ -0,0 +1,177 @@ +/// D-106/T-511: the per-workspace PATH preset settings control. Covers the +/// no-workspace and empty states, add/remove/reorder against the real +/// settings store, worktree key sharing, live updates on a CLI-side write, +/// and the capture-from-login-shell suggestion flow. +/// +/// Settings writes are real file I/O, so seeding goes through +/// [WidgetTester.runAsync] — awaiting it inside the fake-async body would hang. +library; + +import 'dart:io'; + +import 'package:clide/builtin/tools_settings/src/path_preset_control.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/src/daemon/env_path_commands.dart' show envPathChannel; +import 'package:clide/src/env/path_preset.dart'; +import 'package:clide/src/env/shell_env.dart' show debugResetLoginShellPath, debugSetLoginShellPath; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() async => f.dispose()); + + List dirs(String root) => presetDirsFrom((k) => f.services.settings.get(k), root); + + Future pump(WidgetTester tester) => tester.pumpWidget( + harness( + f, + const Align( + alignment: Alignment.center, + child: SizedBox(width: 460, child: PathPresetControl()), + ), + ), + ); + + testWidgets('no workspace open → prompts to open one', (tester) async { + await pump(tester); + await tester.pump(); + expect(find.textContaining('Open a workspace'), findsOneWidget); + }); + + testWidgets('empty preset shows the hint + the add row', (tester) async { + await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir)); + await pump(tester); + await tester.pump(); + expect(find.textContaining('No preset entries'), findsOneWidget); + expect(find.text('Add entry'), findsOneWidget); + }); + + testWidgets('typing a dir + Add persists it, publishes on envPathChannel, and renders the row', (tester) async { + await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir)); + final published = >[]; + final sub = f.services.messages.subscribe(channel: envPathChannel).listen((m) => published.add(m.data)); + addTearDown(sub.cancel); + + await pump(tester); + await tester.pump(); + await tester.enterText(find.byType(EditableText), '/opt/go/bin'); + await tester.tap(find.text('Add entry')); + await tester.pump(); + + expect(dirs(f.tempDir.path), ['/opt/go/bin']); + expect(find.text('/opt/go/bin'), findsOneWidget); + expect(find.text('missing'), findsOneWidget, reason: 'the dir does not exist → warning tag'); + expect(published.single['action'], 'add'); + expect(published.single['dirs'], ['/opt/go/bin']); + }); + + testWidgets('a relative entry is rejected with a visible reason and nothing is written', (tester) async { + await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir)); + await pump(tester); + await tester.pump(); + await tester.enterText(find.byType(EditableText), 'go/bin'); + await tester.tap(find.text('Add entry')); + await tester.pump(); + expect(find.textContaining('absolute path'), findsOneWidget); + expect(dirs(f.tempDir.path), isEmpty); + }); + + testWidgets('an existing dir renders without the missing tag', (tester) async { + await tester.runAsync(() async { + await f.services.settings.setProjectDir(f.tempDir); + await f.services.settings.setAt(SettingsScope.app, pathPresetKey(f.tempDir.path), [f.tempDir.path]); + }); + await pump(tester); + await tester.pump(); + expect(find.text(f.tempDir.path), findsOneWidget); + expect(find.text('missing'), findsNothing); + }); + + testWidgets('remove and reorder rewrite the stored order', (tester) async { + await tester.runAsync(() async { + await f.services.settings.setProjectDir(f.tempDir); + await f.services.settings.setAt(SettingsScope.app, pathPresetKey(f.tempDir.path), ['/a', '/b', '/c']); + }); + await pump(tester); + await tester.pump(); + + await tester.tap(find.bySemanticsLabel('Move down: /a')); + await tester.pump(); + expect(dirs(f.tempDir.path), ['/b', '/a', '/c']); + + await tester.tap(find.bySemanticsLabel('Move up: /c')); + await tester.pump(); + expect(dirs(f.tempDir.path), ['/b', '/c', '/a']); + + await tester.tap(find.bySemanticsLabel('Remove: /b')); + await tester.pump(); + expect(dirs(f.tempDir.path), ['/c', '/a']); + }); + + testWidgets('live-updates when the preset changes from outside (CLI-side write)', (tester) async { + await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir)); + await pump(tester); + await tester.pump(); + expect(find.textContaining('No preset entries'), findsOneWidget); + + await tester.runAsync(() => f.services.settings.setAt(SettingsScope.app, pathPresetKey(f.tempDir.path), ['/from/cli'])); + await tester.pump(); + expect(find.text('/from/cli'), findsOneWidget); + }); + + testWidgets('opened from an in-repo worktree: says so and edits the main repo key (D-106)', (tester) async { + late Directory repo; + late Directory wt; + await tester.runAsync(() async { + repo = Directory('${f.tempDir.path}/repo')..createSync(); + Directory('${repo.path}/.git/worktrees/fix').createSync(recursive: true); + wt = Directory('${repo.path}/.worktrees/fix')..createSync(recursive: true); + File('${wt.path}/.git').writeAsStringSync('gitdir: ${repo.path}/.git/worktrees/fix\n'); + await f.services.settings.setProjectDir(wt); + }); + + await pump(tester); + await tester.pump(); + expect(find.textContaining('Worktree'), findsOneWidget); + expect(find.text(repo.path), findsOneWidget, reason: 'names the shared main repo root'); + + await tester.enterText(find.byType(EditableText), '/opt/go/bin'); + await tester.tap(find.text('Add entry')); + await tester.pump(); + expect(dirs(repo.path), ['/opt/go/bin'], reason: 'worktree writes land on the main repo key'); + expect(dirs(wt.path), ['/opt/go/bin'], reason: 'reading via the worktree resolves the same key'); + }); + + testWidgets('capture suggests login-shell dirs and a tap adopts one', (tester) async { + debugSetLoginShellPath('/cap-a:${Platform.environment['PATH'] ?? ''}'); + addTearDown(debugResetLoginShellPath); + await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir)); + + await pump(tester); + await tester.pump(); + await tester.tap(find.text('Suggest from login shell')); + await tester.pump(); + expect(find.text('/cap-a'), findsOneWidget); + + await tester.tap(find.bySemanticsLabel('Add suggested entry: /cap-a')); + await tester.pump(); + expect(dirs(f.tempDir.path), ['/cap-a']); + expect(find.bySemanticsLabel('Add suggested entry: /cap-a'), findsNothing, reason: 'adopted suggestion leaves the list'); + }); + + testWidgets('capture with no login-shell probe reports nothing to suggest', (tester) async { + debugResetLoginShellPath(); + addTearDown(debugResetLoginShellPath); + await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir)); + await pump(tester); + await tester.pump(); + await tester.tap(find.text('Suggest from login shell')); + await tester.pump(); + expect(find.textContaining('Nothing to suggest'), findsOneWidget); + }); +}