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
@@ -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),
),
),
),
],
);
}
}