From aabec87d9aeb7c6e9201eff30ac7ebe322ce54c1 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 2 Jul 2026 21:42:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(graph):=20filter=20bar=20=E2=80=94=20path?= =?UTF-8?q?=20glob,=20tag=20include/exclude,=20depth=20(T-323)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 4 +- assets/i18n/en_us/builtin.graph.json | 7 +- assets/i18n/nl_nl/builtin.graph.json | 7 +- lib/builtin/graph/src/graph_controller.dart | 102 +++++++++--- lib/builtin/graph/src/graph_panel.dart | 152 ++++++++++++++++-- test/builtin/graph/graph_controller_test.dart | 117 +++++++++++--- test/builtin/graph/graph_panel_test.dart | 57 +++++-- 7 files changed, 376 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9a08c54..fa88967a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,8 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. - **Vault graph view.** A force-directed link graph of the whole vault in the context panel — notes are nodes, wikilinks edges. Hover highlights a note's - neighbourhood; click opens it in the editor. Scroll to zoom, drag to pan. - (T-323) + neighbourhood; click opens it. Scroll to zoom, drag to pan; filter by path + glob, tag include/exclude, or depth from the active note. (T-323) ### Changed diff --git a/assets/i18n/en_us/builtin.graph.json b/assets/i18n/en_us/builtin.graph.json index aa3bc6d7..66b10d1d 100644 --- a/assets/i18n/en_us/builtin.graph.json +++ b/assets/i18n/en_us/builtin.graph.json @@ -1,4 +1,9 @@ { "tab.graph.title": { "translation": "Graph" }, - "graph.empty": { "translation": "No linked notes in this vault." } + "graph.empty": { "translation": "No linked notes in this vault." }, + "graph.empty.filtered": { "translation": "No notes match the filter." }, + "graph.empty.nolocal": { "translation": "Open a note to see its local graph." }, + "graph.filter.glob": { "translation": "Path glob, e.g. notes/**" }, + "graph.filter.depth": { "translation": "Local" }, + "graph.filter.all": { "translation": "All" } } diff --git a/assets/i18n/nl_nl/builtin.graph.json b/assets/i18n/nl_nl/builtin.graph.json index a6ec2737..4d215995 100644 --- a/assets/i18n/nl_nl/builtin.graph.json +++ b/assets/i18n/nl_nl/builtin.graph.json @@ -1,4 +1,9 @@ { "tab.graph.title": { "translation": "Grafiek" }, - "graph.empty": { "translation": "Geen gekoppelde notities in deze vault." } + "graph.empty": { "translation": "Geen gekoppelde notities in deze vault." }, + "graph.empty.filtered": { "translation": "Geen notities voldoen aan het filter." }, + "graph.empty.nolocal": { "translation": "Open een notitie om de lokale grafiek te zien." }, + "graph.filter.glob": { "translation": "Padpatroon, bijv. notes/**" }, + "graph.filter.depth": { "translation": "Lokaal" }, + "graph.filter.all": { "translation": "Alles" } } diff --git a/lib/builtin/graph/src/graph_controller.dart b/lib/builtin/graph/src/graph_controller.dart index 415a206a..741a430a 100644 --- a/lib/builtin/graph/src/graph_controller.dart +++ b/lib/builtin/graph/src/graph_controller.dart @@ -1,57 +1,77 @@ /// Loads the whole vault's link graph from pql (T-323): lists every markdown -/// file (nodes), fetches each file's outlinks (edges), and assembles a -/// [VaultGraph]. Exposes loading/error state and coalesces file-change bursts -/// into one refresh. +/// file (nodes), then fetches each file's `pql.meta` for its outlinks (edges) +/// and tags, assembling a [VaultGraph] plus a per-file tag map. Holds the file +/// glob + a client-side [GraphFilter] (tag include/exclude, depth-from-active) +/// and exposes the filtered [visibleGraph] the pane draws. /// -/// pql has no bulk-outlinks query, so this is 1 `pql.files` + N `pql.outlinks` -/// calls — acceptable for an explicitly-opened, spinner-backed view. Batching -/// is a later optimisation, not a correctness concern. +/// One `pql.meta` per file gives both outlinks and tags in a single call. Link +/// targets carry `#heading` fragments (`foo.md#bar`); those are stripped to the +/// file (`foo.md`) so a heading link still connects the two notes. library; import 'dart:async'; import 'package:clide/kernel/kernel.dart'; +import 'package:clide/src/graph/graph_filter.dart'; import 'package:clide/src/graph/vault_graph.dart'; import 'package:flutter/foundation.dart'; class GraphController extends ChangeNotifier { - GraphController({required this.ipc, required this.events, this.glob = '**/*.md', this.refreshDebounce = const Duration(milliseconds: 400)}) { + GraphController({required this.ipc, required this.events, String glob = '**/*.md', this.refreshDebounce = const Duration(milliseconds: 400)}) : _glob = glob { _eventSub = events.on().listen(_onEvent); } final DaemonClient ipc; final DaemonBus events; - /// The file set the graph spans; the default is every markdown note. - final String glob; - /// A save touches several `files.changed` events in a burst — coalesce them /// into one full reload rather than rebuilding the graph per file. final Duration refreshDebounce; + String _glob; + String get glob => _glob; + StreamSubscription? _eventSub; Timer? _debounce; VaultGraph _graph = const VaultGraph([], []); VaultGraph get graph => _graph; + Map> _tagsByPath = const {}; + + /// Every tag present in the loaded vault, sorted — what the filter UI offers. + List get availableTags { + final all = {for (final s in _tagsByPath.values) ...s}; + return all.toList()..sort(); + } + + String? _activePath; + String? get activePath => _activePath; + + GraphFilter _filter = const GraphFilter(); + GraphFilter get filter => _filter; + bool _loading = false; bool get loading => _loading; String? _error; String? get error => _error; - /// List every in-scope file, fetch each one's outlinks, and rebuild the - /// graph. A failed `pql.files` clears the graph and surfaces the error; a - /// failed per-file `pql.outlinks` just contributes no edges for that file. + /// The graph after the active [filter] — what the pane draws. + VaultGraph get visibleGraph => _filter.apply(_graph, tagsByPath: _tagsByPath, activePath: _activePath); + + /// List every in-scope file, fetch each one's meta (outlinks + tags), and + /// rebuild the graph. A failed `pql.files` clears everything and surfaces the + /// error; a failed per-file `pql.meta` just contributes no edges/tags for it. Future load() async { _loading = true; _error = null; notifyListeners(); - final filesResp = await ipc.request('pql.files', args: {'glob': glob}); + final filesResp = await ipc.request('pql.files', args: {'glob': _glob}); if (!filesResp.ok) { _graph = const VaultGraph([], []); + _tagsByPath = const {}; _error = filesResp.error?.message ?? 'pql.files failed'; _loading = false; notifyListeners(); @@ -64,27 +84,67 @@ class GraphController extends ChangeNotifier { ]; final outlinks = >{}; + final tags = >{}; for (final path in paths) { - final resp = await ipc.request('pql.outlinks', args: {'path': path}); - outlinks[path] = resp.ok - ? [ - for (final l in _castList(resp.data['links'])) - if (l['target'] is String) l['target'] as String, - ] - : const []; + final resp = await ipc.request('pql.meta', args: {'path': path}); + if (!resp.ok) { + outlinks[path] = const []; + continue; + } + outlinks[path] = [ + for (final l in _castList(resp.data['outlinks'])) + if (l['target'] is String) _stripFragment(l['target'] as String), + ].where((t) => t.isNotEmpty).toList(); + final t = resp.data['tags']; + if (t is List) { + final set = { + for (final e in t) + if (e is String) e, + }; + if (set.isNotEmpty) tags[path] = set; + } } _graph = VaultGraph.fromOutlinks(outlinks); + _tagsByPath = tags; _loading = false; notifyListeners(); } + /// Change the file set the graph spans. An empty glob resets to all markdown. + /// A real change re-queries pql; the same glob is a no-op. + void setGlob(String glob) { + final g = glob.trim().isEmpty ? '**/*.md' : glob.trim(); + if (g == _glob) return; + _glob = g; + unawaited(load()); + } + + /// Replace the client-side filter. No reload — [visibleGraph] recomputes. + void setFilter(GraphFilter filter) { + _filter = filter; + notifyListeners(); + } + void _onEvent(DaemonEvent e) { + if (e.subsystem == 'editor' && e.kind == 'editor.active-changed') { + final p = e.data['path'] as String?; + if (p != _activePath) { + _activePath = p; + notifyListeners(); // a depth filter re-centres on the new active note + } + return; + } if (e.subsystem != 'files') return; _debounce?.cancel(); _debounce = Timer(refreshDebounce, () => unawaited(load())); } + static String _stripFragment(String target) { + final hash = target.indexOf('#'); + return hash < 0 ? target : target.substring(0, hash); + } + static List> _castList(Object? raw) { if (raw is! List) return const []; return [for (final e in raw) (e as Map).cast()]; diff --git a/lib/builtin/graph/src/graph_panel.dart b/lib/builtin/graph/src/graph_panel.dart index 4ef40ed7..34365ff9 100644 --- a/lib/builtin/graph/src/graph_panel.dart +++ b/lib/builtin/graph/src/graph_panel.dart @@ -1,8 +1,12 @@ /// The vault link-graph context panel (T-323): loads the whole vault's link -/// graph from pql via [GraphController] and renders it with [GraphView], -/// wiring a node click to open that note in the editor. Shows loading / error -/// / empty states until there's a graph to draw; a debounced refresh keeps the -/// existing graph on screen rather than flashing back to the spinner. +/// graph from pql via [GraphController] and renders the filtered [GraphView], +/// wiring a node click to open that note in the editor. +/// +/// Above the graph sits a filter bar — a path glob (re-queries pql on submit), +/// a depth-from-active selector (the local graph around the open note), and +/// tri-state tag pills (neutral → include → exclude). Loading / error / empty +/// states show until there's a graph; a debounced refresh keeps the existing +/// graph on screen rather than flashing back to the spinner. library; import 'dart:async'; @@ -10,6 +14,7 @@ import 'dart:async'; import 'package:clide/builtin/graph/src/graph_controller.dart'; import 'package:clide/builtin/graph/src/graph_view.dart'; import 'package:clide/kernel/kernel.dart'; +import 'package:clide/src/graph/graph_filter.dart'; import 'package:clide/widgets/widgets.dart'; import 'package:flutter/widgets.dart'; @@ -42,6 +47,8 @@ class _GraphPanelState extends State { void _open(String nodeId) => unawaited(_ipc?.request('editor.open', args: {'path': nodeId})); + String _t(String key, String fallback) => ClideSettings.i18n.string(context, key, namespace: 'builtin.graph', placeholder: fallback); + @override Widget build(BuildContext context) { final c = _controller; @@ -49,15 +56,29 @@ class _GraphPanelState extends State { return ListenableBuilder( listenable: c, builder: (context, _) { - // Once we have a graph, keep drawing it through a debounced refresh — - // only the very first load (or a load that cleared it) falls back to - // the spinner / empty / error states. - if (!c.graph.isEmpty) return GraphView(graph: c.graph, onOpen: _open); - if (c.loading) return const Center(child: ClideSpinner(size: 20, semanticLabel: 'Loading graph')); - if (c.error != null) { - return _message(ClideSettings.theme.of(context).surface.statusError, c.error!); + // Before we have any graph, the pre-load states own the whole panel. + if (c.graph.isEmpty) { + if (c.loading) return const Center(child: ClideSpinner(size: 20, semanticLabel: 'Loading graph')); + if (c.error != null) return _message(ClideSettings.theme.of(context).surface.statusError, c.error!); + return _message(null, _t('graph.empty', 'No linked notes in this vault.')); } - return _message(null, ClideSettings.i18n.string(context, 'graph.empty', namespace: 'builtin.graph', placeholder: 'No linked notes in this vault.')); + final visible = c.visibleGraph; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _GraphFilterBar(controller: c, t: _t), + Expanded( + child: visible.isEmpty + ? _message( + null, + c.filter.depth != null && c.activePath == null + ? _t('graph.empty.nolocal', 'Open a note to see its local graph.') + : _t('graph.empty.filtered', 'No notes match the filter.'), + ) + : GraphView(graph: visible, onOpen: _open), + ), + ], + ); }, ); } @@ -67,3 +88,110 @@ class _GraphPanelState extends State { child: ClideText(text, color: color, muted: color == null, fontSize: clideFontCaption, textAlign: TextAlign.center), ); } + +/// The glob + depth + tag controls above the graph. Reads/writes the +/// controller's glob and [GraphFilter]; keeps no state of its own. +class _GraphFilterBar extends StatelessWidget { + const _GraphFilterBar({required this.controller, required this.t}); + + final GraphController controller; + final String Function(String key, String fallback) t; + + static const _depths = <(String, int?)>[('All', null), ('1', 1), ('2', 2), ('3', 3)]; + + void _setDepth(int? depth) => controller.setFilter(controller.filter.copyWith(depth: depth, clearDepth: depth == null)); + + void _cycleTag(String tag) { + final f = controller.filter; + final inc = {...f.includeTags}, exc = {...f.excludeTags}; + if (inc.contains(tag)) { + inc.remove(tag); + exc.add(tag); // include → exclude + } else if (exc.contains(tag)) { + exc.remove(tag); // exclude → neutral + } else { + inc.add(tag); // neutral → include + } + controller.setFilter(f.copyWith(includeTags: inc, excludeTags: exc)); + } + + @override + Widget build(BuildContext context) { + final tokens = ClideSettings.theme.of(context).surface; + final f = controller.filter; + final tags = controller.availableTags; + return Container( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: tokens.dividerColor)), + ), + padding: const EdgeInsets.only(bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + ClideFilterBox( + hint: t('graph.filter.glob', 'Path glob, e.g. notes/**'), + showIcon: true, + icon: PhosphorIcons.byName('funnel'), + onChanged: (_) {}, // reload is heavy (1+N pql calls) — apply on submit only + onSubmitted: controller.setGlob, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + children: [ + ClideText(t('graph.filter.depth', 'Local'), fontSize: clideFontCaption, color: tokens.globalTextMuted), + const SizedBox(width: 8), + for (final (label, depth) in _depths) + Padding( + padding: const EdgeInsets.only(right: 4), + child: _pill( + tokens, + label: label == 'All' ? t('graph.filter.all', 'All') : label, + selected: f.depth == depth, + onTap: () => _setDepth(depth), + ), + ), + ], + ), + ), + if (tags.isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: 10, right: 10, top: 6), + child: Wrap( + spacing: 4, + runSpacing: 4, + children: [ + for (final tag in tags) + _pill( + tokens, + label: f.excludeTags.contains(tag) ? '−$tag' : (f.includeTags.contains(tag) ? '+$tag' : tag), + selected: f.includeTags.contains(tag), + danger: f.excludeTags.contains(tag), + onTap: () => _cycleTag(tag), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _pill(SurfaceTokens tokens, {required String label, required bool selected, bool danger = false, required VoidCallback onTap}) { + final accent = danger ? tokens.statusError : tokens.globalFocus; + final active = selected || danger; + return ClideTappable( + onTap: onTap, + builder: (context, hovered, _) => Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: active ? accent.withValues(alpha: 0.16) : (hovered ? tokens.sidebarItemHover : null), + border: Border.all(color: active ? accent : tokens.globalBorder), + borderRadius: BorderRadius.circular(10), + ), + child: ClideText(label, fontSize: clideFontCaption, color: active ? accent : tokens.globalForeground), + ), + ); + } +} diff --git a/test/builtin/graph/graph_controller_test.dart b/test/builtin/graph/graph_controller_test.dart index 72ee3bb8..e3c85258 100644 --- a/test/builtin/graph/graph_controller_test.dart +++ b/test/builtin/graph/graph_controller_test.dart @@ -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> vault) { + /// Stubs `pql.files` (from the map's keys) and `pql.meta` (per path): + /// outlinks from [vault], tags from [tags]. + void stubVault(Map> vault, {Map> 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 [], }); }); } - 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 = []; + 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.delayed(const Duration(milliseconds: 5)); + c.setGlob('notes/**'); // no-op + await Future.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 [], + }), + ); + 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.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.delayed(const Duration(milliseconds: 40)); - expect(files, 1); // five events, one reload + expect(files, 1); c.dispose(); }); }); diff --git a/test/builtin/graph/graph_panel_test.dart b/test/builtin/graph/graph_panel_test.dart index f297ec53..7c80da69 100644 --- a/test/builtin/graph/graph_panel_test.dart +++ b/test/builtin/graph/graph_panel_test.dart @@ -17,8 +17,8 @@ void main() { IpcResponse ok(Map data) => IpcResponse.ok(id: '1', data: data); - /// Stubs `pql.files` (from the map's keys) and `pql.outlinks` (per path). - void stubVault(Map> vault) { + /// Stubs `pql.files` (from the map's keys) and `pql.meta` (outlinks + tags). + void stubVault(Map> vault, {Map> 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 [], }); }); } 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().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); + }); }