feat(graph): pql-backed GraphController — files + outlinks to VaultGraph (T-323)
Lists every markdown note (nodes) and fetches each one's outlinks (edges) via the pql.files / pql.outlinks IPC commands, assembling the file->outlinks map that VaultGraph consumes. Coalesces file-change bursts into one debounced reload. 1+N calls, since pql exposes no bulk-outlinks query; acceptable for a spinner-backed, explicitly-opened view. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'description', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.
|
||||
|
||||
PROGRESS (2026-06-29): force-directed layout SOLVER built + tested (lib/src/graph/force_layout.dart, 6 dart-test cases, commit bcef3a6f) — deterministic Fruchterman-Reingold, Flutter-free. REMAINING (the bulk, multi-session): graph data model from pql backlinks/outlinks over the vault; CustomPaint rendering (nodes+edges); pan/zoom; hover-highlight-subgraph; click-opens-note; filter pane (tag/glob/depth); MultitabPane + context-panel slot (D-47) wiring; widget/golden tests. This is a full interactive Tier-5 pane, a fresh-session undertaking — only its solver core is done.', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.
|
||||
|
||||
PROGRESS (2026-06-29): force-directed layout SOLVER built + tested (lib/src/graph/force_layout.dart, 6 dart-test cases, commit bcef3a6f) — deterministic Fruchterman-Reingold, Flutter-free. REMAINING (the bulk, multi-session): graph data model from pql backlinks/outlinks over the vault; CustomPaint rendering (nodes+edges); pan/zoom; hover-highlight-subgraph; click-opens-note; filter pane (tag/glob/depth); MultitabPane + context-panel slot (D-47) wiring; widget/golden tests. This is a full interactive Tier-5 pane, a fresh-session undertaking — only its solver core is done.
|
||||
|
||||
PROGRESS (2026-07-02): the graph rendering + interaction foundation is BUILT + tested (4 slices, all committed). (1) force-directed solver — force_layout.dart (bcef3a6f). (2) link-graph model — vault_graph.dart: VaultGraph.fromOutlinks, neighborhood, edgePairs (084bf842). (3) CustomPaint renderer — graph_painter.dart: edges/nodes/labels, hover-dim, fit transform (6bea1723). (4) interactive GraphView — graph_view.dart: hover-highlight + click-to-open, shared GraphViewport hit-testing (c529703b). The graph is a fully working WIDGET (renders + hover + click). REMAINING = the app-integration phase: pql link-data wiring (pql outlinks over the vault -> the file->outlinks map -> VaultGraph), the pane shell (async load + filter: tag/glob/depth + pan/zoom), and MultitabPane + context-panel slot (D-47) + GraphExtension registration.', NULL, '2026-07-02 06:35:03', '2026-07-02 06:35:03.555', '2026-07-02 06:35:03.555', NULL, '33a6fd1c81f2e1142d15ce5791ce3b24', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
@@ -0,0 +1,5 @@
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'story', '06FHAX7FV5KWGZQ31617R63W94', 'Tier-5 graph view (builtin.graph)', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.
|
||||
|
||||
PROGRESS (2026-06-29): force-directed layout SOLVER built + tested (lib/src/graph/force_layout.dart, 6 dart-test cases, commit bcef3a6f) — deterministic Fruchterman-Reingold, Flutter-free. REMAINING (the bulk, multi-session): graph data model from pql backlinks/outlinks over the vault; CustomPaint rendering (nodes+edges); pan/zoom; hover-highlight-subgraph; click-opens-note; filter pane (tag/glob/depth); MultitabPane + context-panel slot (D-47) wiring; widget/golden tests. This is a full interactive Tier-5 pane, a fresh-session undertaking — only its solver core is done.
|
||||
|
||||
PROGRESS (2026-07-02): the graph rendering + interaction foundation is BUILT + tested (4 slices, all committed). (1) force-directed solver — force_layout.dart (bcef3a6f). (2) link-graph model — vault_graph.dart: VaultGraph.fromOutlinks, neighborhood, edgePairs (084bf842). (3) CustomPaint renderer — graph_painter.dart: edges/nodes/labels, hover-dim, fit transform (6bea1723). (4) interactive GraphView — graph_view.dart: hover-highlight + click-to-open, shared GraphViewport hit-testing (c529703b). The graph is a fully working WIDGET (renders + hover + click). REMAINING = the app-integration phase: pql link-data wiring (pql outlinks over the vault -> the file->outlinks map -> VaultGraph), the pane shell (async load + filter: tag/glob/depth + pan/zoom), and MultitabPane + context-panel slot (D-47) + GraphExtension registration.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:23', '2026-07-02 06:35:03.555', NULL, 'a46ad64b03bc1b4126e9882ed7b81a3a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
@@ -0,0 +1,100 @@
|
||||
/// 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.
|
||||
///
|
||||
/// 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.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/kernel.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)}) {
|
||||
_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;
|
||||
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
Timer? _debounce;
|
||||
|
||||
VaultGraph _graph = const VaultGraph([], []);
|
||||
VaultGraph get graph => _graph;
|
||||
|
||||
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.
|
||||
Future<void> load() async {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
final filesResp = await ipc.request('pql.files', args: {'glob': glob});
|
||||
if (!filesResp.ok) {
|
||||
_graph = const VaultGraph([], []);
|
||||
_error = filesResp.error?.message ?? 'pql.files failed';
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final paths = [
|
||||
for (final f in _castList(filesResp.data['files']))
|
||||
if (f['path'] is String) f['path'] as String,
|
||||
];
|
||||
|
||||
final outlinks = <String, List<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 [];
|
||||
}
|
||||
|
||||
_graph = VaultGraph.fromOutlinks(outlinks);
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _onEvent(DaemonEvent e) {
|
||||
if (e.subsystem != 'files') return;
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(refreshDebounce, () => unawaited(load()));
|
||||
}
|
||||
|
||||
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?>()];
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_eventSub?.cancel();
|
||||
_eventSub = null;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'package:clide/builtin/graph/src/graph_controller.dart';
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/fake_ipc.dart';
|
||||
|
||||
void main() {
|
||||
group('GraphController.load', () {
|
||||
late DaemonBus bus;
|
||||
late FakeDaemonClient ipc;
|
||||
|
||||
setUp(() {
|
||||
bus = DaemonBus();
|
||||
ipc = FakeDaemonClient(log: Logger(), events: bus);
|
||||
});
|
||||
tearDown(() => bus.dispose());
|
||||
|
||||
IpcResponse ok(Map<String, Object?> data) => IpcResponse.ok(id: '1', data: data);
|
||||
IpcResponse err(String msg) => IpcResponse.err(
|
||||
id: '1',
|
||||
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) {
|
||||
ipc.stub(
|
||||
'pql.files',
|
||||
(_) async => ok({
|
||||
'files': [
|
||||
for (final p in vault.keys) {'path': p},
|
||||
],
|
||||
}),
|
||||
);
|
||||
ipc.stub('pql.outlinks', (args) async {
|
||||
final path = args['path'] as String?;
|
||||
final links = vault[path] ?? const [];
|
||||
return ok({
|
||||
'links': [
|
||||
for (final t in links) {'target': t},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('assembles a graph from files + their outlinks', () async {
|
||||
stubVault({
|
||||
'a.md': ['b.md'],
|
||||
'b.md': [],
|
||||
});
|
||||
final c = GraphController(ipc: ipc, events: bus);
|
||||
var notified = 0;
|
||||
c.addListener(() => notified++);
|
||||
await c.load();
|
||||
|
||||
expect(c.graph.nodes.map((n) => n.id), containsAll(['a.md', 'b.md']));
|
||||
expect(c.graph.edgePairs, [('a.md', 'b.md')]);
|
||||
expect(c.loading, isFalse);
|
||||
expect(c.error, isNull);
|
||||
expect(notified, greaterThan(0)); // loading toggle + final
|
||||
});
|
||||
|
||||
test('a pql.files failure clears the graph and surfaces the error', () async {
|
||||
ipc.stub('pql.files', (_) async => err('index locked'));
|
||||
final c = GraphController(ipc: ipc, events: bus);
|
||||
await c.load();
|
||||
expect(c.graph.isEmpty, isTrue);
|
||||
expect(c.error, contains('index locked'));
|
||||
expect(c.loading, isFalse);
|
||||
});
|
||||
|
||||
test('a per-file outlinks failure drops that file\'s edges, keeps the node', () async {
|
||||
ipc.stub(
|
||||
'pql.files',
|
||||
(_) async => ok({
|
||||
'files': [
|
||||
{'path': 'a.md'},
|
||||
{'path': 'b.md'},
|
||||
],
|
||||
}),
|
||||
);
|
||||
ipc.stub('pql.outlinks', (args) async {
|
||||
if (args['path'] == 'a.md') return err('boom');
|
||||
return ok({'links': 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
|
||||
});
|
||||
|
||||
test('dangling + self links are dropped by the model', () async {
|
||||
stubVault({
|
||||
'a.md': ['a.md', 'ghost.md', 'b.md'],
|
||||
'b.md': [],
|
||||
});
|
||||
final c = GraphController(ipc: ipc, events: bus);
|
||||
await c.load();
|
||||
expect(c.graph.edgePairs, [('a.md', 'b.md')]);
|
||||
});
|
||||
|
||||
test('a files event triggers a debounced reload; non-files events do not', () async {
|
||||
var files = 0;
|
||||
ipc.stub('pql.files', (_) async {
|
||||
files++;
|
||||
return ok({'files': const []});
|
||||
});
|
||||
ipc.stub('pql.outlinks', (_) async => ok({'links': const []}));
|
||||
final c = GraphController(ipc: ipc, events: bus, refreshDebounce: Duration.zero);
|
||||
expect(files, 0);
|
||||
|
||||
bus.emit(DaemonEvent(subsystem: 'git', kind: 'git.status', data: const {}, ts: DateTime.now()));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
expect(files, 0); // non-files event ignored
|
||||
|
||||
bus.emit(DaemonEvent(subsystem: 'files', kind: 'files.changed', data: const {'path': 'x.md'}, ts: DateTime.now()));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
expect(files, 1); // reload fired
|
||||
c.dispose();
|
||||
});
|
||||
|
||||
test('a burst of files events coalesces into a single reload', () async {
|
||||
var files = 0;
|
||||
ipc.stub('pql.files', (_) async {
|
||||
files++;
|
||||
return ok({'files': const []});
|
||||
});
|
||||
ipc.stub('pql.outlinks', (_) async => ok({'links': 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
|
||||
c.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user