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:
@@ -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" }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user