feat(graph): filter bar — path glob, tag include/exclude, depth (T-323)
Switches the controller from pql.outlinks to pql.meta (outlinks + tags in one call per file) and strips #heading fragments from link targets, so a heading link now connects the two notes. Adds a filter bar above the graph: a path glob that re-queries pql on submit, a depth-from-active selector for the local graph around the open note, and tri-state tag pills (neutral / include / exclude). The pane draws the filtered visibleGraph. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import 'package:clide/builtin/graph/src/graph_controller.dart';
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/graph/graph_filter.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/fake_ipc.dart';
|
||||
|
||||
void main() {
|
||||
group('GraphController.load', () {
|
||||
group('GraphController', () {
|
||||
late DaemonBus bus;
|
||||
late FakeDaemonClient ipc;
|
||||
|
||||
@@ -22,8 +23,9 @@ void main() {
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: msg),
|
||||
);
|
||||
|
||||
/// Stubs `pql.files` (from the map's keys) and `pql.outlinks` (per path).
|
||||
void stubVault(Map<String, List<String>> vault) {
|
||||
/// Stubs `pql.files` (from the map's keys) and `pql.meta` (per path):
|
||||
/// outlinks from [vault], tags from [tags].
|
||||
void stubVault(Map<String, List<String>> vault, {Map<String, List<String>> tags = const {}}) {
|
||||
ipc.stub(
|
||||
'pql.files',
|
||||
(_) async => ok({
|
||||
@@ -32,18 +34,18 @@ void main() {
|
||||
],
|
||||
}),
|
||||
);
|
||||
ipc.stub('pql.outlinks', (args) async {
|
||||
ipc.stub('pql.meta', (args) async {
|
||||
final path = args['path'] as String?;
|
||||
final links = vault[path] ?? const [];
|
||||
return ok({
|
||||
'links': [
|
||||
for (final t in links) {'target': t},
|
||||
'outlinks': [
|
||||
for (final t in vault[path] ?? const []) {'target': t},
|
||||
],
|
||||
'tags': tags[path] ?? const <String>[],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('assembles a graph from files + their outlinks', () async {
|
||||
test('assembles a graph from files + their meta outlinks', () async {
|
||||
stubVault({
|
||||
'a.md': ['b.md'],
|
||||
'b.md': [],
|
||||
@@ -57,7 +59,30 @@ void main() {
|
||||
expect(c.graph.edgePairs, [('a.md', 'b.md')]);
|
||||
expect(c.loading, isFalse);
|
||||
expect(c.error, isNull);
|
||||
expect(notified, greaterThan(0)); // loading toggle + final
|
||||
expect(notified, greaterThan(0));
|
||||
});
|
||||
|
||||
test('strips #heading fragments so a heading link still connects the notes', () async {
|
||||
stubVault({
|
||||
'a.md': ['b.md#a-heading'],
|
||||
'b.md': [],
|
||||
});
|
||||
final c = GraphController(ipc: ipc, events: bus);
|
||||
await c.load();
|
||||
expect(c.graph.edgePairs, [('a.md', 'b.md')]); // fragment stripped → real edge
|
||||
});
|
||||
|
||||
test('collects tags into a sorted availableTags union', () async {
|
||||
stubVault(
|
||||
{'a.md': const [], 'b.md': const []},
|
||||
tags: {
|
||||
'a.md': ['project', 'note'],
|
||||
'b.md': ['note'],
|
||||
},
|
||||
);
|
||||
final c = GraphController(ipc: ipc, events: bus);
|
||||
await c.load();
|
||||
expect(c.availableTags, ['note', 'project']);
|
||||
});
|
||||
|
||||
test('a pql.files failure clears the graph and surfaces the error', () async {
|
||||
@@ -69,7 +94,7 @@ void main() {
|
||||
expect(c.loading, isFalse);
|
||||
});
|
||||
|
||||
test('a per-file outlinks failure drops that file\'s edges, keeps the node', () async {
|
||||
test('a per-file meta failure drops that file\'s edges, keeps the node', () async {
|
||||
ipc.stub(
|
||||
'pql.files',
|
||||
(_) async => ok({
|
||||
@@ -79,25 +104,75 @@ void main() {
|
||||
],
|
||||
}),
|
||||
);
|
||||
ipc.stub('pql.outlinks', (args) async {
|
||||
ipc.stub('pql.meta', (args) async {
|
||||
if (args['path'] == 'a.md') return err('boom');
|
||||
return ok({'links': const []});
|
||||
return ok({'outlinks': const [], 'tags': const []});
|
||||
});
|
||||
final c = GraphController(ipc: ipc, events: bus);
|
||||
await c.load();
|
||||
expect(c.graph.nodes.map((n) => n.id), containsAll(['a.md', 'b.md']));
|
||||
expect(c.graph.edgePairs, isEmpty); // a.md's edges were lost, no crash
|
||||
expect(c.error, isNull); // a partial failure isn't a load failure
|
||||
expect(c.graph.edgePairs, isEmpty);
|
||||
expect(c.error, isNull);
|
||||
});
|
||||
|
||||
test('dangling + self links are dropped by the model', () async {
|
||||
test('setGlob reloads with the new glob; the same glob is a no-op', () async {
|
||||
final globs = <String>[];
|
||||
ipc.stub('pql.files', (args) async {
|
||||
globs.add(args['glob'] as String);
|
||||
return ok({'files': const []});
|
||||
});
|
||||
ipc.stub('pql.meta', (_) async => ok({'outlinks': const [], 'tags': const []}));
|
||||
final c = GraphController(ipc: ipc, events: bus);
|
||||
await c.load();
|
||||
c.setGlob('notes/**');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
c.setGlob('notes/**'); // no-op
|
||||
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
expect(globs, ['**/*.md', 'notes/**']);
|
||||
expect(c.glob, 'notes/**');
|
||||
});
|
||||
|
||||
test('setFilter narrows visibleGraph without reloading', () async {
|
||||
var files = 0;
|
||||
ipc.stub('pql.files', (_) async {
|
||||
files++;
|
||||
return ok({
|
||||
'files': [
|
||||
{'path': 'a.md'},
|
||||
{'path': 'b.md'},
|
||||
],
|
||||
});
|
||||
});
|
||||
ipc.stub(
|
||||
'pql.meta',
|
||||
(args) async => ok({
|
||||
'outlinks': const [],
|
||||
'tags': args['path'] == 'a.md' ? ['project'] : const <String>[],
|
||||
}),
|
||||
);
|
||||
final c = GraphController(ipc: ipc, events: bus);
|
||||
await c.load();
|
||||
expect(files, 1);
|
||||
expect(c.visibleGraph.nodes.map((n) => n.id), unorderedEquals(['a.md', 'b.md']));
|
||||
c.setFilter(const GraphFilter(includeTags: {'project'}));
|
||||
expect(c.visibleGraph.nodes.map((n) => n.id), ['a.md']);
|
||||
expect(files, 1); // client-side — no reload
|
||||
});
|
||||
|
||||
test('a depth filter re-centres on the active editor file', () async {
|
||||
stubVault({
|
||||
'a.md': ['a.md', 'ghost.md', 'b.md'],
|
||||
'b.md': [],
|
||||
'a.md': ['b.md'],
|
||||
'b.md': ['c.md'],
|
||||
'c.md': [],
|
||||
});
|
||||
final c = GraphController(ipc: ipc, events: bus);
|
||||
await c.load();
|
||||
expect(c.graph.edgePairs, [('a.md', 'b.md')]);
|
||||
c.setFilter(const GraphFilter(depth: 1));
|
||||
expect(c.visibleGraph.isEmpty, isTrue); // no active file yet
|
||||
bus.emit(DaemonEvent(subsystem: 'editor', kind: 'editor.active-changed', data: const {'path': 'b.md'}, ts: DateTime.now()));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
expect(c.activePath, 'b.md');
|
||||
expect(c.visibleGraph.nodes.map((n) => n.id), unorderedEquals(['a.md', 'b.md', 'c.md']));
|
||||
});
|
||||
|
||||
test('a files event triggers a debounced reload; non-files events do not', () async {
|
||||
@@ -106,7 +181,7 @@ void main() {
|
||||
files++;
|
||||
return ok({'files': const []});
|
||||
});
|
||||
ipc.stub('pql.outlinks', (_) async => ok({'links': const []}));
|
||||
ipc.stub('pql.meta', (_) async => ok({'outlinks': const [], 'tags': const []}));
|
||||
final c = GraphController(ipc: ipc, events: bus, refreshDebounce: Duration.zero);
|
||||
expect(files, 0);
|
||||
|
||||
@@ -126,14 +201,14 @@ void main() {
|
||||
files++;
|
||||
return ok({'files': const []});
|
||||
});
|
||||
ipc.stub('pql.outlinks', (_) async => ok({'links': const []}));
|
||||
ipc.stub('pql.meta', (_) async => ok({'outlinks': const [], 'tags': const []}));
|
||||
final c = GraphController(ipc: ipc, events: bus, refreshDebounce: const Duration(milliseconds: 20));
|
||||
|
||||
for (var i = 0; i < 5; i++) {
|
||||
bus.emit(DaemonEvent(subsystem: 'files', kind: 'files.changed', data: {'path': '$i.md'}, ts: DateTime.now()));
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 40));
|
||||
expect(files, 1); // five events, one reload
|
||||
expect(files, 1);
|
||||
c.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,8 +17,8 @@ void main() {
|
||||
|
||||
IpcResponse ok(Map<String, Object?> data) => IpcResponse.ok(id: '1', data: data);
|
||||
|
||||
/// Stubs `pql.files` (from the map's keys) and `pql.outlinks` (per path).
|
||||
void stubVault(Map<String, List<String>> vault) {
|
||||
/// Stubs `pql.files` (from the map's keys) and `pql.meta` (outlinks + tags).
|
||||
void stubVault(Map<String, List<String>> vault, {Map<String, List<String>> tags = const {}}) {
|
||||
f.ipc.stub(
|
||||
'pql.files',
|
||||
(_) async => ok({
|
||||
@@ -27,28 +27,29 @@ void main() {
|
||||
],
|
||||
}),
|
||||
);
|
||||
f.ipc.stub('pql.outlinks', (args) async {
|
||||
final links = vault[args['path'] as String?] ?? const [];
|
||||
f.ipc.stub('pql.meta', (args) async {
|
||||
final path = args['path'] as String?;
|
||||
return ok({
|
||||
'links': [
|
||||
for (final t in links) {'target': t},
|
||||
'outlinks': [
|
||||
for (final t in vault[path] ?? const []) {'target': t},
|
||||
],
|
||||
'tags': tags[path] ?? const <String>[],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Widget panel([Size size = const Size(400, 400)]) => anchoredHarness(f, SizedBox(width: size.width, height: size.height, child: const GraphPanel()));
|
||||
|
||||
testWidgets('shows a spinner while the first load is in flight', (tester) async {
|
||||
// A never-completing pql.files keeps the panel in its loading state.
|
||||
testWidgets('shows a spinner (no filter bar) while the first load is in flight', (tester) async {
|
||||
f.ipc.stub('pql.files', (_) => Completer<IpcResponse>().future);
|
||||
await tester.pumpWidget(panel());
|
||||
await tester.pump();
|
||||
expect(find.byType(ClideSpinner), findsOneWidget);
|
||||
expect(find.byType(ClideFilterBox), findsNothing);
|
||||
expect(find.byType(GraphView), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('renders the graph once loaded', (tester) async {
|
||||
testWidgets('renders the graph and the filter bar once loaded', (tester) async {
|
||||
stubVault({
|
||||
'a.md': ['b.md'],
|
||||
'b.md': [],
|
||||
@@ -56,7 +57,7 @@ void main() {
|
||||
await tester.pumpWidget(panel());
|
||||
await pumpAsync(tester);
|
||||
expect(find.byType(GraphView), findsOneWidget);
|
||||
expect(find.byType(ClideSpinner), findsNothing);
|
||||
expect(find.byType(ClideFilterBox), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows an empty message for a vault with no notes', (tester) async {
|
||||
@@ -90,10 +91,42 @@ void main() {
|
||||
});
|
||||
await tester.pumpWidget(panel());
|
||||
await pumpAsync(tester);
|
||||
// A single node lays out at the layout centre → a square canvas maps it to
|
||||
// the view's centre, which is where a byType tap lands.
|
||||
await tester.tap(find.byType(GraphView));
|
||||
await pumpAsync(tester);
|
||||
expect(opened, 'only.md');
|
||||
});
|
||||
|
||||
testWidgets('a depth pill with no active note shows the local-graph hint', (tester) async {
|
||||
stubVault({
|
||||
'a.md': ['b.md'],
|
||||
'b.md': [],
|
||||
});
|
||||
await tester.pumpWidget(panel());
|
||||
await pumpAsync(tester);
|
||||
await tester.tap(find.text('1')); // depth-1 from the (absent) active note
|
||||
await pumpAsync(tester);
|
||||
expect(find.text('Open a note to see its local graph.'), findsOneWidget);
|
||||
expect(find.byType(GraphView), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a tag pill cycles include → exclude and filters the graph', (tester) async {
|
||||
stubVault(
|
||||
{'only.md': const []},
|
||||
tags: {
|
||||
'only.md': ['x'],
|
||||
},
|
||||
);
|
||||
await tester.pumpWidget(panel());
|
||||
await pumpAsync(tester);
|
||||
expect(find.text('x'), findsOneWidget); // neutral tag pill
|
||||
|
||||
await tester.tap(find.text('x')); // → include; the note carries x, so it stays
|
||||
await pumpAsync(tester);
|
||||
expect(find.byType(GraphView), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('+x')); // → exclude; the note carries x, so it drops
|
||||
await pumpAsync(tester);
|
||||
expect(find.text('No notes match the filter.'), findsOneWidget);
|
||||
expect(find.byType(GraphView), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user