feat(settings): Workspace PATH section in the Tools category (T-511)

The UI half of D-106 (D-6 parity with `clide env path`): an ordered
preset editor — add/remove/reorder, a missing-dir warning, a worktree
note naming the shared main repo, and capture-from-login-shell
suggestions adopted with one click. Writes land on the same user-scope
key the CLI verbs use and publish on the same channel, so both
surfaces stay live off the store notifier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 17:41:06 +02:00
co-authored by Claude Fable 5
parent e1fea83868
commit 1308cb3d23
5 changed files with 565 additions and 2 deletions
+16 -1
View File
@@ -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" }
}
+16 -1
View File
@@ -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" }
}
@@ -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.<name>`),
/// 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.<hash> 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<IpcResponse> _redetect(List<String> args) async {
@@ -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/<name>`) 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<PathPresetControl> createState() => _PathPresetControlState();
}
class _PathPresetControlState extends State<PathPresetControl> {
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<String>? _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<String> _dirs(KernelServices services, String cwd) => presetDirsFrom((k) => services.settings.get<Object>(k), cwd);
Future<void> _write(KernelServices services, String cwd, List<String> 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<void> _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<void> _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<void> _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<String> 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<String> 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<String> 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),
),
),
),
],
);
}
}
@@ -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<String> dirs(String root) => presetDirsFrom((k) => f.services.settings.get<Object>(k), root);
Future<void> 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 = <Map<String, Object?>>[];
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);
});
}