build the Config sidebar tab
The Config sub-tab grows from a static settings table into a browser of the Claude environment: the pinned settings table stays, and below it expandable accordions list the full (never-truncated) sets of skills, agents, commands, hooks, permissions (grouped + colour-coded by allow/ ask/deny), and MCP servers. File-backed entries are clickable and open their .md via editor.open. ClaudeConfig gains agents, hooks, and mcpServers loaders plus path fields on skills/commands, kept live by the existing .claude file watcher. T-183. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -294,4 +294,91 @@ void main() {
|
||||
expect(versionCalls, 1, reason: 'refresh is disk-only');
|
||||
c.dispose();
|
||||
});
|
||||
|
||||
// T-183: path fields, agents, hooks, MCP servers ------------------------------
|
||||
|
||||
Future<void> writeAgent(Directory scope, String fileName) async {
|
||||
final d = Directory('${scope.path}/agents')..createSync(recursive: true);
|
||||
await File('${d.path}/$fileName').writeAsString('# agent');
|
||||
}
|
||||
|
||||
test('skill and command entries carry their absolute file path (T-183)', () async {
|
||||
await writeSkill(globalDir, 'my-skill', name: 'my-skill');
|
||||
await writeCommand(globalDir, 'deploy.md');
|
||||
final c = build();
|
||||
await c.load();
|
||||
|
||||
final skill = c.skills.first;
|
||||
expect(skill.path, isNotNull);
|
||||
expect(skill.path, endsWith('my-skill/SKILL.md'));
|
||||
|
||||
final cmd = c.commands.first;
|
||||
expect(cmd.path, isNotNull);
|
||||
expect(cmd.path, endsWith('deploy.md'));
|
||||
c.dispose();
|
||||
});
|
||||
|
||||
test('agents scanned from <scope>/agents/*.md (T-183)', () async {
|
||||
await writeAgent(globalDir, 'planner.md');
|
||||
await writeAgent(localDir, 'coder.md');
|
||||
final c = build();
|
||||
await c.load();
|
||||
|
||||
expect(c.agents.map((a) => a.name), containsAll(['planner', 'coder']));
|
||||
final planner = c.agents.firstWhere((a) => a.name == 'planner');
|
||||
expect(planner.scope, ConfigScope.global);
|
||||
expect(planner.path, isNotNull);
|
||||
expect(planner.path, endsWith('planner.md'));
|
||||
c.dispose();
|
||||
});
|
||||
|
||||
test('agents deduplicated local-over-global same as skills (T-183)', () async {
|
||||
await writeAgent(globalDir, 'shared.md');
|
||||
await writeAgent(localDir, 'shared.md');
|
||||
final c = build();
|
||||
await c.load();
|
||||
expect(c.agents.where((a) => a.name == 'shared'), hasLength(1));
|
||||
final shared = c.agents.firstWhere((a) => a.name == 'shared');
|
||||
expect(shared.scope, ConfigScope.local, reason: 'local wins on collision');
|
||||
c.dispose();
|
||||
});
|
||||
|
||||
test('hooks parsed from settings.json hooks key (T-183)', () async {
|
||||
await writeSettings(globalDir, {
|
||||
'hooks': {
|
||||
'PreToolUse': [
|
||||
{
|
||||
'hooks': [
|
||||
{'command': 'echo pre-tool'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
final c = build();
|
||||
await c.load();
|
||||
expect(c.hooks, hasLength(1));
|
||||
expect(c.hooks.first.event, 'PreToolUse');
|
||||
expect(c.hooks.first.commands, ['echo pre-tool']);
|
||||
c.dispose();
|
||||
});
|
||||
|
||||
test('mcpServers parsed from settings.json mcpServers key (T-183)', () async {
|
||||
await writeSettings(globalDir, {
|
||||
'mcpServers': {'brave': {}, 'clide': {}},
|
||||
});
|
||||
final c = build();
|
||||
await c.load();
|
||||
expect(c.mcpServers.map((s) => s.name), containsAll(['brave', 'clide']));
|
||||
c.dispose();
|
||||
});
|
||||
|
||||
test('empty settings produce empty hooks and mcpServers (T-183)', () async {
|
||||
final c = build();
|
||||
await c.load();
|
||||
expect(c.agents, isEmpty);
|
||||
expect(c.hooks, isEmpty);
|
||||
expect(c.mcpServers, isEmpty);
|
||||
c.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:clide/builtin/claude/src/session_orchestrator.dart';
|
||||
import 'package:clide/builtin/claude/src/stream_json_session.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_publisher.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/clide.dart' show IpcResponse;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/services.dart' show LogicalKeyboardKey;
|
||||
import 'package:flutter/widgets.dart' show EditableText, SizedBox, Semantics;
|
||||
@@ -705,4 +706,382 @@ void main() {
|
||||
orch.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// T-183: Config tab accordion browser ----------------------------------------
|
||||
|
||||
group('T-183 Config accordion browser', () {
|
||||
/// Build a fully-loaded [ClaudeConfig] via [tester.runAsync] so real I/O
|
||||
/// and async work run outside FakeAsync. Returns a config whose load() has
|
||||
/// completed; caller must call dispose() (via addTearDown).
|
||||
Future<ClaudeConfig> loadedConfig(
|
||||
WidgetTester tester,
|
||||
Directory root, {
|
||||
List<({String name, String dir})> skills = const [],
|
||||
List<String> commands = const [],
|
||||
List<String> agents = const [],
|
||||
Map<String, Object?> settings = const {},
|
||||
}) async {
|
||||
final result = await tester.runAsync(() async {
|
||||
final globalDir = Directory('${root.path}/global')..createSync(recursive: true);
|
||||
final cacheDir = Directory('${root.path}/cache')..createSync(recursive: true);
|
||||
|
||||
for (final s in skills) {
|
||||
final d = Directory('${globalDir.path}/skills/${s.dir}')..createSync(recursive: true);
|
||||
await File('${d.path}/SKILL.md').writeAsString('---\nname: ${s.name}\n---\nbody');
|
||||
}
|
||||
for (final cmd in commands) {
|
||||
final d = Directory('${globalDir.path}/commands')..createSync(recursive: true);
|
||||
await File('${d.path}/$cmd.md').writeAsString('# $cmd');
|
||||
}
|
||||
for (final agent in agents) {
|
||||
final d = Directory('${globalDir.path}/agents')..createSync(recursive: true);
|
||||
await File('${d.path}/$agent.md').writeAsString('# $agent');
|
||||
}
|
||||
if (settings.isNotEmpty) {
|
||||
await File('${globalDir.path}/settings.json').writeAsString(jsonEncode(settings));
|
||||
}
|
||||
|
||||
final c = ClaudeConfig(
|
||||
globalDir: globalDir,
|
||||
cacheDir: cacheDir,
|
||||
versionRunner: () async => '2.1.0 (Claude Code)\n',
|
||||
initProbe: () async => null,
|
||||
watch: (_) => const Stream<void>.empty(),
|
||||
debounce: Duration.zero,
|
||||
);
|
||||
await c.load();
|
||||
return c;
|
||||
});
|
||||
// tester.runAsync returns T? — the result is non-null here because the
|
||||
// body always returns successfully.
|
||||
return result!;
|
||||
}
|
||||
|
||||
testWidgets('pinned SETTINGS table renders with model / output style / permission mode / source', (tester) async {
|
||||
final dir = Directory.systemTemp.createTempSync('t183_settings');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
final config = await loadedConfig(tester, dir);
|
||||
addTearDown(config.dispose);
|
||||
|
||||
await tester.pumpWidget(harness(f, sidebar(config: config, initialTab: SidebarTab.config)));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('SETTINGS'), findsOneWidget);
|
||||
expect(find.text('model'), findsOneWidget);
|
||||
expect(find.text('output style'), findsOneWidget);
|
||||
expect(find.text('permission mode'), findsOneWidget);
|
||||
expect(find.text('source'), findsOneWidget);
|
||||
expect(find.text('~/.claude + .claude'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('accordion sections appear for each kind with correct counts', (tester) async {
|
||||
final dir = Directory.systemTemp.createTempSync('t183_counts');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
final config = await loadedConfig(
|
||||
tester,
|
||||
dir,
|
||||
skills: [
|
||||
(name: 'git-commit', dir: 'git-commit'),
|
||||
(name: 'pql', dir: 'pql'),
|
||||
],
|
||||
commands: ['deploy', 'test'],
|
||||
agents: ['planner'],
|
||||
settings: {
|
||||
'mcpServers': {'brave': {}, 'clide': {}},
|
||||
'permissions': {
|
||||
'allow': ['Bash(git *)'],
|
||||
'deny': ['Bash(rm -rf *)'],
|
||||
},
|
||||
},
|
||||
);
|
||||
addTearDown(config.dispose);
|
||||
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
|
||||
));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
// Section headers with counts are rendered by ClideAccordion as "$label · $count".
|
||||
expect(find.text('SKILLS · 2'), findsOneWidget);
|
||||
expect(find.text('AGENTS · 1'), findsOneWidget);
|
||||
expect(find.text('COMMANDS · 2'), findsOneWidget);
|
||||
expect(find.text('PERMISSIONS · 2'), findsOneWidget);
|
||||
expect(find.text('MCP SERVERS · 2'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('expanding SKILLS section shows all skill names (no ellipsis)', (tester) async {
|
||||
final dir = Directory.systemTemp.createTempSync('t183_skills');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
final config = await loadedConfig(
|
||||
tester,
|
||||
dir,
|
||||
skills: [
|
||||
(name: 'git-commit', dir: 'git-commit'),
|
||||
(name: 'pql', dir: 'pql'),
|
||||
(name: 'deep-research', dir: 'deep-research'),
|
||||
],
|
||||
);
|
||||
addTearDown(config.dispose);
|
||||
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
|
||||
));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
// Skills are hidden until expanded.
|
||||
expect(find.text('git-commit'), findsNothing);
|
||||
|
||||
// Tap the SKILLS accordion header.
|
||||
await tester.tap(find.text('SKILLS · 3'));
|
||||
await tester.pump();
|
||||
|
||||
// All three skills render — no truncation.
|
||||
expect(find.text('git-commit'), findsOneWidget);
|
||||
expect(find.text('pql'), findsOneWidget);
|
||||
expect(find.text('deep-research'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('expanding AGENTS section shows all agent names', (tester) async {
|
||||
final dir = Directory.systemTemp.createTempSync('t183_agents');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
final config = await loadedConfig(tester, dir, agents: ['planner', 'coder']);
|
||||
addTearDown(config.dispose);
|
||||
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
|
||||
));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('AGENTS · 2'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('planner'), findsOneWidget);
|
||||
expect(find.text('coder'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('expanding COMMANDS section shows all command names', (tester) async {
|
||||
final dir = Directory.systemTemp.createTempSync('t183_cmds');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
final config = await loadedConfig(tester, dir, commands: ['deploy', 'test', 'lint']);
|
||||
addTearDown(config.dispose);
|
||||
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
|
||||
));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('COMMANDS · 3'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('deploy'), findsOneWidget);
|
||||
expect(find.text('test'), findsOneWidget);
|
||||
expect(find.text('lint'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('expanding PERMISSIONS shows rules grouped by allow / ask / deny', (tester) async {
|
||||
final dir = Directory.systemTemp.createTempSync('t183_perms');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
final config = await loadedConfig(
|
||||
tester,
|
||||
dir,
|
||||
settings: {
|
||||
'permissions': {
|
||||
'allow': ['Bash(git *)'],
|
||||
'ask': ['Write'],
|
||||
'deny': ['Bash(rm -rf *)'],
|
||||
},
|
||||
},
|
||||
);
|
||||
addTearDown(config.dispose);
|
||||
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
|
||||
));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('PERMISSIONS · 3'));
|
||||
await tester.pump();
|
||||
|
||||
// Group labels and rules should all appear.
|
||||
expect(find.text('allow'), findsOneWidget);
|
||||
expect(find.text('ask'), findsOneWidget);
|
||||
expect(find.text('deny'), findsOneWidget);
|
||||
expect(find.text('Bash(git *)'), findsOneWidget);
|
||||
expect(find.text('Write'), findsOneWidget);
|
||||
expect(find.text('Bash(rm -rf *)'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('expanding MCP SERVERS shows all server names', (tester) async {
|
||||
final dir = Directory.systemTemp.createTempSync('t183_mcp');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
final config = await loadedConfig(
|
||||
tester,
|
||||
dir,
|
||||
settings: {
|
||||
'mcpServers': {'brave': {}, 'clide': {}, 'github': {}},
|
||||
},
|
||||
);
|
||||
addTearDown(config.dispose);
|
||||
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
|
||||
));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('MCP SERVERS · 3'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('brave'), findsOneWidget);
|
||||
expect(find.text('clide'), findsOneWidget);
|
||||
expect(find.text('github'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tapping a file-backed skill fires editor.open with its path', (tester) async {
|
||||
final dir = Directory.systemTemp.createTempSync('t183_click');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
final config = await loadedConfig(tester, dir, skills: [(name: 'my-skill', dir: 'my-skill')]);
|
||||
addTearDown(config.dispose);
|
||||
|
||||
// Capture editor.open calls through the fake IPC.
|
||||
final opened = <String>[];
|
||||
f.ipc.stub('editor.open', (args) async {
|
||||
opened.add(args['path'] as String? ?? '');
|
||||
return IpcResponse.ok(id: '', data: const {});
|
||||
});
|
||||
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
|
||||
));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
// Expand SKILLS, then tap the skill row.
|
||||
await tester.tap(find.text('SKILLS · 1'));
|
||||
await tester.pump();
|
||||
|
||||
// The skill name is the tappable label.
|
||||
await tester.tap(find.text('my-skill'));
|
||||
await tester.pump();
|
||||
|
||||
expect(opened, hasLength(1));
|
||||
expect(opened.first, endsWith('my-skill/SKILL.md'));
|
||||
});
|
||||
|
||||
testWidgets('tapping a file-backed command fires editor.open with its path', (tester) async {
|
||||
final dir = Directory.systemTemp.createTempSync('t183_cmd_click');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
final config = await loadedConfig(tester, dir, commands: ['deploy']);
|
||||
addTearDown(config.dispose);
|
||||
|
||||
final opened = <String>[];
|
||||
f.ipc.stub('editor.open', (args) async {
|
||||
opened.add(args['path'] as String? ?? '');
|
||||
return IpcResponse.ok(id: '', data: const {});
|
||||
});
|
||||
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
|
||||
));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('COMMANDS · 1'));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('deploy'));
|
||||
await tester.pump();
|
||||
|
||||
expect(opened, hasLength(1));
|
||||
expect(opened.first, endsWith('deploy.md'));
|
||||
});
|
||||
|
||||
testWidgets('tapping a file-backed agent fires editor.open with its path', (tester) async {
|
||||
final dir = Directory.systemTemp.createTempSync('t183_agent_click');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
final config = await loadedConfig(tester, dir, agents: ['planner']);
|
||||
addTearDown(config.dispose);
|
||||
|
||||
final opened = <String>[];
|
||||
f.ipc.stub('editor.open', (args) async {
|
||||
opened.add(args['path'] as String? ?? '');
|
||||
return IpcResponse.ok(id: '', data: const {});
|
||||
});
|
||||
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
|
||||
));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('AGENTS · 1'));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('planner'));
|
||||
await tester.pump();
|
||||
|
||||
expect(opened, hasLength(1));
|
||||
expect(opened.first, endsWith('planner.md'));
|
||||
});
|
||||
|
||||
testWidgets('accordion collapses when toggled a second time', (tester) async {
|
||||
final dir = Directory.systemTemp.createTempSync('t183_collapse');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
final config = await loadedConfig(tester, dir, skills: [(name: 'git-commit', dir: 'git-commit')]);
|
||||
addTearDown(config.dispose);
|
||||
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
|
||||
));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
// Expand.
|
||||
await tester.tap(find.text('SKILLS · 1'));
|
||||
await tester.pump();
|
||||
expect(find.text('git-commit'), findsOneWidget);
|
||||
|
||||
// Collapse.
|
||||
await tester.tap(find.text('SKILLS · 1'));
|
||||
await tester.pump();
|
||||
expect(find.text('git-commit'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Config tab with empty sections shows zero counts on all headers', (tester) async {
|
||||
final dir = Directory.systemTemp.createTempSync('t183_empty');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
final config = await loadedConfig(tester, dir);
|
||||
addTearDown(config.dispose);
|
||||
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
|
||||
));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('SKILLS · 0'), findsOneWidget);
|
||||
expect(find.text('AGENTS · 0'), findsOneWidget);
|
||||
expect(find.text('COMMANDS · 0'), findsOneWidget);
|
||||
expect(find.text('HOOKS · 0'), findsOneWidget);
|
||||
expect(find.text('PERMISSIONS · 0'), findsOneWidget);
|
||||
expect(find.text('MCP SERVERS · 0'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user