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:
2026-07-02 21:42:50 +02:00
co-authored by Claude Opus 4.8
parent f859173b98
commit aabec87d9a
7 changed files with 376 additions and 70 deletions
+81 -21
View File
@@ -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<DaemonEvent>().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<DaemonEvent>? _eventSub;
Timer? _debounce;
VaultGraph _graph = const VaultGraph([], []);
VaultGraph get graph => _graph;
Map<String, Set<String>> _tagsByPath = const {};
/// Every tag present in the loaded vault, sorted — what the filter UI offers.
List<String> get availableTags {
final all = <String>{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<void> 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 = <String, List<String>>{};
final tags = <String, Set<String>>{};
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<Map<String, Object?>> _castList(Object? raw) {
if (raw is! List) return const [];
return [for (final e in raw) (e as Map).cast<String, Object?>()];
+140 -12
View File
@@ -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<GraphPanel> {
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<GraphPanel> {
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<GraphPanel> {
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),
),
);
}
}