From 6fae1bc46aeefb7836c75029c36933bb2c4a1965 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 2 Jul 2026 09:43:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(graph):=20graph=20context-panel=20shell=20?= =?UTF-8?q?=E2=80=94=20states=20+=20click-to-open=20(T-323)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps GraphController + GraphView into the panel widget: loads the vault graph on mount, shows a spinner / empty / error state until it is ready, then draws the graph and opens a note on node click via editor.open. A debounced refresh keeps the current graph on screen instead of flashing back to the spinner. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/builtin/graph/src/graph_panel.dart | 69 +++++++++++++++++ test/builtin/graph/graph_panel_test.dart | 99 ++++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 lib/builtin/graph/src/graph_panel.dart create mode 100644 test/builtin/graph/graph_panel_test.dart diff --git a/lib/builtin/graph/src/graph_panel.dart b/lib/builtin/graph/src/graph_panel.dart new file mode 100644 index 00000000..4ef40ed7 --- /dev/null +++ b/lib/builtin/graph/src/graph_panel.dart @@ -0,0 +1,69 @@ +/// 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. +library; + +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/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; + +class GraphPanel extends StatefulWidget { + const GraphPanel({super.key}); + + @override + State createState() => _GraphPanelState(); +} + +class _GraphPanelState extends State { + GraphController? _controller; + DaemonClient? _ipc; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_controller != null) return; + final kernel = ClideKernel.of(context); + _ipc = kernel.ipc; + _controller = GraphController(ipc: kernel.ipc, events: kernel.events); + unawaited(_controller!.load()); + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + void _open(String nodeId) => unawaited(_ipc?.request('editor.open', args: {'path': nodeId})); + + @override + Widget build(BuildContext context) { + final c = _controller; + if (c == null) return const SizedBox.shrink(); + 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!); + } + return _message(null, ClideSettings.i18n.string(context, 'graph.empty', namespace: 'builtin.graph', placeholder: 'No linked notes in this vault.')); + }, + ); + } + + Widget _message(Color? color, String text) => Padding( + padding: const EdgeInsets.all(16), + child: ClideText(text, color: color, muted: color == null, fontSize: clideFontCaption, textAlign: TextAlign.center), + ); +} diff --git a/test/builtin/graph/graph_panel_test.dart b/test/builtin/graph/graph_panel_test.dart new file mode 100644 index 00000000..f297ec53 --- /dev/null +++ b/test/builtin/graph/graph_panel_test.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:clide/builtin/graph/src/graph_panel.dart'; +import 'package:clide/builtin/graph/src/graph_view.dart'; +import 'package:clide/clide.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + 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) { + f.ipc.stub( + 'pql.files', + (_) async => ok({ + 'files': [ + for (final p in vault.keys) {'path': p}, + ], + }), + ); + f.ipc.stub('pql.outlinks', (args) async { + final links = vault[args['path'] as String?] ?? const []; + return ok({ + 'links': [ + for (final t in links) {'target': t}, + ], + }); + }); + } + + 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. + f.ipc.stub('pql.files', (_) => Completer().future); + await tester.pumpWidget(panel()); + await tester.pump(); + expect(find.byType(ClideSpinner), findsOneWidget); + expect(find.byType(GraphView), findsNothing); + }); + + testWidgets('renders the graph once loaded', (tester) async { + stubVault({ + 'a.md': ['b.md'], + 'b.md': [], + }); + await tester.pumpWidget(panel()); + await pumpAsync(tester); + expect(find.byType(GraphView), findsOneWidget); + expect(find.byType(ClideSpinner), findsNothing); + }); + + testWidgets('shows an empty message for a vault with no notes', (tester) async { + stubVault(const {}); + await tester.pumpWidget(panel()); + await pumpAsync(tester); + expect(find.text('No linked notes in this vault.'), findsOneWidget); + expect(find.byType(GraphView), findsNothing); + }); + + testWidgets('surfaces a load error', (tester) async { + f.ipc.stub( + 'pql.files', + (_) async => IpcResponse.err( + id: '1', + error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'index locked'), + ), + ); + await tester.pumpWidget(panel()); + await pumpAsync(tester); + expect(find.text('index locked'), findsOneWidget); + expect(find.byType(GraphView), findsNothing); + }); + + testWidgets('clicking a node opens it in the editor', (tester) async { + stubVault({'only.md': const []}); + String? opened; + f.ipc.stub('editor.open', (args) async { + opened = args['path'] as String?; + return ok(const {}); + }); + 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'); + }); +}