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:
+2
-2
@@ -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
|
- **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
|
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.
|
neighbourhood; click opens it. Scroll to zoom, drag to pan; filter by path
|
||||||
(T-323)
|
glob, tag include/exclude, or depth from the active note. (T-323)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
{
|
{
|
||||||
"tab.graph.title": { "translation": "Graph" },
|
"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" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
{
|
{
|
||||||
"tab.graph.title": { "translation": "Grafiek" },
|
"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" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,77 @@
|
|||||||
/// Loads the whole vault's link graph from pql (T-323): lists every markdown
|
/// 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
|
/// file (nodes), then fetches each file's `pql.meta` for its outlinks (edges)
|
||||||
/// [VaultGraph]. Exposes loading/error state and coalesces file-change bursts
|
/// and tags, assembling a [VaultGraph] plus a per-file tag map. Holds the file
|
||||||
/// into one refresh.
|
/// 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`
|
/// One `pql.meta` per file gives both outlinks and tags in a single call. Link
|
||||||
/// calls — acceptable for an explicitly-opened, spinner-backed view. Batching
|
/// targets carry `#heading` fragments (`foo.md#bar`); those are stripped to the
|
||||||
/// is a later optimisation, not a correctness concern.
|
/// file (`foo.md`) so a heading link still connects the two notes.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:clide/kernel/kernel.dart';
|
import 'package:clide/kernel/kernel.dart';
|
||||||
|
import 'package:clide/src/graph/graph_filter.dart';
|
||||||
import 'package:clide/src/graph/vault_graph.dart';
|
import 'package:clide/src/graph/vault_graph.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
class GraphController extends ChangeNotifier {
|
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);
|
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
final DaemonClient ipc;
|
final DaemonClient ipc;
|
||||||
final DaemonBus events;
|
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
|
/// A save touches several `files.changed` events in a burst — coalesce them
|
||||||
/// into one full reload rather than rebuilding the graph per file.
|
/// into one full reload rather than rebuilding the graph per file.
|
||||||
final Duration refreshDebounce;
|
final Duration refreshDebounce;
|
||||||
|
|
||||||
|
String _glob;
|
||||||
|
String get glob => _glob;
|
||||||
|
|
||||||
StreamSubscription<DaemonEvent>? _eventSub;
|
StreamSubscription<DaemonEvent>? _eventSub;
|
||||||
Timer? _debounce;
|
Timer? _debounce;
|
||||||
|
|
||||||
VaultGraph _graph = const VaultGraph([], []);
|
VaultGraph _graph = const VaultGraph([], []);
|
||||||
VaultGraph get graph => _graph;
|
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 _loading = false;
|
||||||
bool get loading => _loading;
|
bool get loading => _loading;
|
||||||
|
|
||||||
String? _error;
|
String? _error;
|
||||||
String? get error => _error;
|
String? get error => _error;
|
||||||
|
|
||||||
/// List every in-scope file, fetch each one's outlinks, and rebuild the
|
/// The graph after the active [filter] — what the pane draws.
|
||||||
/// graph. A failed `pql.files` clears the graph and surfaces the error; a
|
VaultGraph get visibleGraph => _filter.apply(_graph, tagsByPath: _tagsByPath, activePath: _activePath);
|
||||||
/// failed per-file `pql.outlinks` just contributes no edges for that file.
|
|
||||||
|
/// 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 {
|
Future<void> load() async {
|
||||||
_loading = true;
|
_loading = true;
|
||||||
_error = null;
|
_error = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
final filesResp = await ipc.request('pql.files', args: {'glob': glob});
|
final filesResp = await ipc.request('pql.files', args: {'glob': _glob});
|
||||||
if (!filesResp.ok) {
|
if (!filesResp.ok) {
|
||||||
_graph = const VaultGraph([], []);
|
_graph = const VaultGraph([], []);
|
||||||
|
_tagsByPath = const {};
|
||||||
_error = filesResp.error?.message ?? 'pql.files failed';
|
_error = filesResp.error?.message ?? 'pql.files failed';
|
||||||
_loading = false;
|
_loading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -64,27 +84,67 @@ class GraphController extends ChangeNotifier {
|
|||||||
];
|
];
|
||||||
|
|
||||||
final outlinks = <String, List<String>>{};
|
final outlinks = <String, List<String>>{};
|
||||||
|
final tags = <String, Set<String>>{};
|
||||||
for (final path in paths) {
|
for (final path in paths) {
|
||||||
final resp = await ipc.request('pql.outlinks', args: {'path': path});
|
final resp = await ipc.request('pql.meta', args: {'path': path});
|
||||||
outlinks[path] = resp.ok
|
if (!resp.ok) {
|
||||||
? [
|
outlinks[path] = const [];
|
||||||
for (final l in _castList(resp.data['links']))
|
continue;
|
||||||
if (l['target'] is String) l['target'] as String,
|
}
|
||||||
]
|
outlinks[path] = [
|
||||||
: const [];
|
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);
|
_graph = VaultGraph.fromOutlinks(outlinks);
|
||||||
|
_tagsByPath = tags;
|
||||||
_loading = false;
|
_loading = false;
|
||||||
notifyListeners();
|
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) {
|
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;
|
if (e.subsystem != 'files') return;
|
||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
_debounce = Timer(refreshDebounce, () => unawaited(load()));
|
_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) {
|
static List<Map<String, Object?>> _castList(Object? raw) {
|
||||||
if (raw is! List) return const [];
|
if (raw is! List) return const [];
|
||||||
return [for (final e in raw) (e as Map).cast<String, Object?>()];
|
return [for (final e in raw) (e as Map).cast<String, Object?>()];
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
/// The vault link-graph context panel (T-323): loads the whole vault's link
|
/// The vault link-graph context panel (T-323): loads the whole vault's link
|
||||||
/// graph from pql via [GraphController] and renders it with [GraphView],
|
/// graph from pql via [GraphController] and renders the filtered [GraphView],
|
||||||
/// wiring a node click to open that note in the editor. Shows loading / error
|
/// wiring a node click to open that note in the editor.
|
||||||
/// / 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.
|
/// 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;
|
library;
|
||||||
|
|
||||||
import 'dart:async';
|
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_controller.dart';
|
||||||
import 'package:clide/builtin/graph/src/graph_view.dart';
|
import 'package:clide/builtin/graph/src/graph_view.dart';
|
||||||
import 'package:clide/kernel/kernel.dart';
|
import 'package:clide/kernel/kernel.dart';
|
||||||
|
import 'package:clide/src/graph/graph_filter.dart';
|
||||||
import 'package:clide/widgets/widgets.dart';
|
import 'package:clide/widgets/widgets.dart';
|
||||||
import 'package:flutter/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}));
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final c = _controller;
|
final c = _controller;
|
||||||
@@ -49,15 +56,29 @@ class _GraphPanelState extends State<GraphPanel> {
|
|||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: c,
|
listenable: c,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
// Once we have a graph, keep drawing it through a debounced refresh —
|
// Before we have any graph, the pre-load states own the whole panel.
|
||||||
// only the very first load (or a load that cleared it) falls back to
|
if (c.graph.isEmpty) {
|
||||||
// the spinner / empty / error states.
|
if (c.loading) return const Center(child: ClideSpinner(size: 20, semanticLabel: 'Loading graph'));
|
||||||
if (!c.graph.isEmpty) return GraphView(graph: c.graph, onOpen: _open);
|
if (c.error != null) return _message(ClideSettings.theme.of(context).surface.statusError, c.error!);
|
||||||
if (c.loading) return const Center(child: ClideSpinner(size: 20, semanticLabel: 'Loading graph'));
|
return _message(null, _t('graph.empty', 'No linked notes in this vault.'));
|
||||||
if (c.error != null) {
|
|
||||||
return _message(ClideSettings.theme.of(context).surface.statusError, c.error!);
|
|
||||||
}
|
}
|
||||||
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),
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import 'package:clide/builtin/graph/src/graph_controller.dart';
|
import 'package:clide/builtin/graph/src/graph_controller.dart';
|
||||||
import 'package:clide/clide.dart';
|
import 'package:clide/clide.dart';
|
||||||
import 'package:clide/kernel/kernel.dart';
|
import 'package:clide/kernel/kernel.dart';
|
||||||
|
import 'package:clide/src/graph/graph_filter.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import '../../helpers/fake_ipc.dart';
|
import '../../helpers/fake_ipc.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('GraphController.load', () {
|
group('GraphController', () {
|
||||||
late DaemonBus bus;
|
late DaemonBus bus;
|
||||||
late FakeDaemonClient ipc;
|
late FakeDaemonClient ipc;
|
||||||
|
|
||||||
@@ -22,8 +23,9 @@ void main() {
|
|||||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: msg),
|
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: msg),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Stubs `pql.files` (from the map's keys) and `pql.outlinks` (per path).
|
/// Stubs `pql.files` (from the map's keys) and `pql.meta` (per path):
|
||||||
void stubVault(Map<String, List<String>> vault) {
|
/// outlinks from [vault], tags from [tags].
|
||||||
|
void stubVault(Map<String, List<String>> vault, {Map<String, List<String>> tags = const {}}) {
|
||||||
ipc.stub(
|
ipc.stub(
|
||||||
'pql.files',
|
'pql.files',
|
||||||
(_) async => ok({
|
(_) 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 path = args['path'] as String?;
|
||||||
final links = vault[path] ?? const [];
|
|
||||||
return ok({
|
return ok({
|
||||||
'links': [
|
'outlinks': [
|
||||||
for (final t in links) {'target': t},
|
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({
|
stubVault({
|
||||||
'a.md': ['b.md'],
|
'a.md': ['b.md'],
|
||||||
'b.md': [],
|
'b.md': [],
|
||||||
@@ -57,7 +59,30 @@ void main() {
|
|||||||
expect(c.graph.edgePairs, [('a.md', 'b.md')]);
|
expect(c.graph.edgePairs, [('a.md', 'b.md')]);
|
||||||
expect(c.loading, isFalse);
|
expect(c.loading, isFalse);
|
||||||
expect(c.error, isNull);
|
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 {
|
test('a pql.files failure clears the graph and surfaces the error', () async {
|
||||||
@@ -69,7 +94,7 @@ void main() {
|
|||||||
expect(c.loading, isFalse);
|
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(
|
ipc.stub(
|
||||||
'pql.files',
|
'pql.files',
|
||||||
(_) async => ok({
|
(_) 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');
|
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);
|
final c = GraphController(ipc: ipc, events: bus);
|
||||||
await c.load();
|
await c.load();
|
||||||
expect(c.graph.nodes.map((n) => n.id), containsAll(['a.md', 'b.md']));
|
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.graph.edgePairs, isEmpty);
|
||||||
expect(c.error, isNull); // a partial failure isn't a load failure
|
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({
|
stubVault({
|
||||||
'a.md': ['a.md', 'ghost.md', 'b.md'],
|
'a.md': ['b.md'],
|
||||||
'b.md': [],
|
'b.md': ['c.md'],
|
||||||
|
'c.md': [],
|
||||||
});
|
});
|
||||||
final c = GraphController(ipc: ipc, events: bus);
|
final c = GraphController(ipc: ipc, events: bus);
|
||||||
await c.load();
|
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 {
|
test('a files event triggers a debounced reload; non-files events do not', () async {
|
||||||
@@ -106,7 +181,7 @@ void main() {
|
|||||||
files++;
|
files++;
|
||||||
return ok({'files': const []});
|
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);
|
final c = GraphController(ipc: ipc, events: bus, refreshDebounce: Duration.zero);
|
||||||
expect(files, 0);
|
expect(files, 0);
|
||||||
|
|
||||||
@@ -126,14 +201,14 @@ void main() {
|
|||||||
files++;
|
files++;
|
||||||
return ok({'files': const []});
|
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));
|
final c = GraphController(ipc: ipc, events: bus, refreshDebounce: const Duration(milliseconds: 20));
|
||||||
|
|
||||||
for (var i = 0; i < 5; i++) {
|
for (var i = 0; i < 5; i++) {
|
||||||
bus.emit(DaemonEvent(subsystem: 'files', kind: 'files.changed', data: {'path': '$i.md'}, ts: DateTime.now()));
|
bus.emit(DaemonEvent(subsystem: 'files', kind: 'files.changed', data: {'path': '$i.md'}, ts: DateTime.now()));
|
||||||
}
|
}
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 40));
|
await Future<void>.delayed(const Duration(milliseconds: 40));
|
||||||
expect(files, 1); // five events, one reload
|
expect(files, 1);
|
||||||
c.dispose();
|
c.dispose();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ void main() {
|
|||||||
|
|
||||||
IpcResponse ok(Map<String, Object?> data) => IpcResponse.ok(id: '1', data: data);
|
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).
|
/// Stubs `pql.files` (from the map's keys) and `pql.meta` (outlinks + tags).
|
||||||
void stubVault(Map<String, List<String>> vault) {
|
void stubVault(Map<String, List<String>> vault, {Map<String, List<String>> tags = const {}}) {
|
||||||
f.ipc.stub(
|
f.ipc.stub(
|
||||||
'pql.files',
|
'pql.files',
|
||||||
(_) async => ok({
|
(_) async => ok({
|
||||||
@@ -27,28 +27,29 @@ void main() {
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
f.ipc.stub('pql.outlinks', (args) async {
|
f.ipc.stub('pql.meta', (args) async {
|
||||||
final links = vault[args['path'] as String?] ?? const [];
|
final path = args['path'] as String?;
|
||||||
return ok({
|
return ok({
|
||||||
'links': [
|
'outlinks': [
|
||||||
for (final t in links) {'target': t},
|
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()));
|
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 {
|
testWidgets('shows a spinner (no filter bar) while the first load is in flight', (tester) async {
|
||||||
// A never-completing pql.files keeps the panel in its loading state.
|
|
||||||
f.ipc.stub('pql.files', (_) => Completer<IpcResponse>().future);
|
f.ipc.stub('pql.files', (_) => Completer<IpcResponse>().future);
|
||||||
await tester.pumpWidget(panel());
|
await tester.pumpWidget(panel());
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.byType(ClideSpinner), findsOneWidget);
|
expect(find.byType(ClideSpinner), findsOneWidget);
|
||||||
|
expect(find.byType(ClideFilterBox), findsNothing);
|
||||||
expect(find.byType(GraphView), 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({
|
stubVault({
|
||||||
'a.md': ['b.md'],
|
'a.md': ['b.md'],
|
||||||
'b.md': [],
|
'b.md': [],
|
||||||
@@ -56,7 +57,7 @@ void main() {
|
|||||||
await tester.pumpWidget(panel());
|
await tester.pumpWidget(panel());
|
||||||
await pumpAsync(tester);
|
await pumpAsync(tester);
|
||||||
expect(find.byType(GraphView), findsOneWidget);
|
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 {
|
testWidgets('shows an empty message for a vault with no notes', (tester) async {
|
||||||
@@ -90,10 +91,42 @@ void main() {
|
|||||||
});
|
});
|
||||||
await tester.pumpWidget(panel());
|
await tester.pumpWidget(panel());
|
||||||
await pumpAsync(tester);
|
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 tester.tap(find.byType(GraphView));
|
||||||
await pumpAsync(tester);
|
await pumpAsync(tester);
|
||||||
expect(opened, 'only.md');
|
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