feat(graph): graph context-panel shell — states + click-to-open (T-323)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<GraphPanel> createState() => _GraphPanelState();
|
||||
}
|
||||
|
||||
class _GraphPanelState extends State<GraphPanel> {
|
||||
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),
|
||||
);
|
||||
}
|
||||
@@ -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<String, Object?> data) => IpcResponse.ok(id: '1', data: data);
|
||||
|
||||
/// Stubs `pql.files` (from the map's keys) and `pql.outlinks` (per path).
|
||||
void stubVault(Map<String, List<String>> 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<IpcResponse>().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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user