diff --git a/CHANGELOG.md b/CHANGELOG.md index fa88967a..d24025b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **Canvas pane.** `.canvas` (Obsidian JSONCanvas) files now open in an + interactive workspace pane — each document a sub-tab, with pan, zoom, and + node selection. Routed from the file tree, quick-open, and + `clide ui open canvas `. (T-322) - **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 neighbourhood; click opens it. Scroll to zoom, drag to pan; filter by path diff --git a/assets/i18n/en_us/builtin.canvas.json b/assets/i18n/en_us/builtin.canvas.json new file mode 100644 index 00000000..56fc0bd2 --- /dev/null +++ b/assets/i18n/en_us/builtin.canvas.json @@ -0,0 +1,5 @@ +{ + "tab.title": { "translation": "Canvas" }, + "empty": { "translation": "Open a .canvas file to view it here." }, + "status.loading": { "translation": "Loading…" } +} diff --git a/assets/i18n/nl_nl/builtin.canvas.json b/assets/i18n/nl_nl/builtin.canvas.json new file mode 100644 index 00000000..695cb516 --- /dev/null +++ b/assets/i18n/nl_nl/builtin.canvas.json @@ -0,0 +1,5 @@ +{ + "tab.title": { "translation": "Canvas" }, + "empty": { "translation": "Open een .canvas-bestand om het hier te bekijken." }, + "status.loading": { "translation": "Laden…" } +} diff --git a/lib/builtin/canvas/canvas.dart b/lib/builtin/canvas/canvas.dart index b968b883..107fb150 100644 --- a/lib/builtin/canvas/canvas.dart +++ b/lib/builtin/canvas/canvas.dart @@ -1 +1,2 @@ +export 'src/canvas_pane_host.dart'; export 'src/extension.dart'; diff --git a/lib/builtin/canvas/src/canvas_pane_host.dart b/lib/builtin/canvas/src/canvas_pane_host.dart new file mode 100644 index 00000000..8d23aca1 --- /dev/null +++ b/lib/builtin/canvas/src/canvas_pane_host.dart @@ -0,0 +1,111 @@ +/// The canvas workspace pane body (T-322): renders the extension-owned +/// [MultitabController] of open `.canvas` documents as real sub-tabs. +/// The controller lives app-scoped on [CanvasExtension] (the diff/T-233 +/// pattern) so open documents survive the pane being torn down while the +/// user works in another workspace tab. +library; + +import 'dart:async'; + +import 'package:clide/builtin/canvas/src/canvas_view.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/src/canvas/json_canvas.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; + +class CanvasPaneHost extends StatelessWidget { + const CanvasPaneHost({super.key, this.tabs}); + + /// Open documents, keyed by workspace path. Null until the extension + /// activates (the contribution builder captures the field lazily). + final MultitabController? tabs; + + @override + Widget build(BuildContext context) { + final controller = tabs; + if (controller == null) return const _EmptyHint(); + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + if (controller.isEmpty) return const _EmptyHint(); + return MultitabPane( + controller: controller, + // Keep every document's State (parsed doc, pan/zoom, selection) + // alive across sub-tab switches. + keepAlive: true, + bodyBuilder: (_, entry) => CanvasDocumentTab(path: entry.payload), + ); + }, + ); + } +} + +class _EmptyHint extends StatelessWidget { + const _EmptyHint(); + + @override + Widget build(BuildContext context) { + return Center( + child: ClideText( + ClideSettings.i18n.string(context, 'empty', namespace: 'builtin.canvas', placeholder: 'Open a .canvas file to view it here.'), + muted: true, + ), + ); + } +} + +/// One open `.canvas` document: fetches [path] through `files.read`, parses +/// the JSONCanvas model, and hands it to the interactive [CanvasView]. +class CanvasDocumentTab extends StatefulWidget { + const CanvasDocumentTab({super.key, required this.path}); + + final String path; + + @override + State createState() => _CanvasDocumentTabState(); +} + +class _CanvasDocumentTabState extends State { + CanvasDoc? _doc; + String? _error; + bool _requested = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_requested) return; + _requested = true; + unawaited(_load(ClideKernel.of(context))); + } + + Future _load(KernelServices kernel) async { + final resp = await kernel.ipc.request('files.read', args: {'path': widget.path}); + if (!mounted) return; + if (!resp.ok) { + setState(() => _error = resp.error?.message ?? widget.path); + return; + } + try { + final doc = CanvasDoc.parse(resp.data['content'] as String? ?? ''); + setState(() => _doc = doc); + } on FormatException catch (e) { + setState(() => _error = e.message); + } + } + + @override + Widget build(BuildContext context) { + final error = _error; + if (error != null) { + return Padding(padding: const EdgeInsets.all(12), child: ClideText(error, muted: true)); + } + final doc = _doc; + if (doc == null) { + return Padding( + padding: const EdgeInsets.all(12), + child: ClideText(ClideSettings.i18n.string(context, 'status.loading', namespace: 'builtin.canvas', placeholder: 'Loading…'), muted: true), + ); + } + return CanvasView(doc: doc); + } +} diff --git a/lib/builtin/canvas/src/extension.dart b/lib/builtin/canvas/src/extension.dart index acaf98e8..69a48ab2 100644 --- a/lib/builtin/canvas/src/extension.dart +++ b/lib/builtin/canvas/src/extension.dart @@ -1,17 +1,98 @@ -import 'package:clide/extension/extension.dart'; +import 'dart:async'; -/// Tier-0 stub. Real implementation lands in a later tier; the extension -/// is registered so the extensions-ui surface can list it as "installed, -/// not yet implemented" and its id is reserved. +import 'package:clide/builtin/canvas/src/canvas_pane_host.dart'; +import 'package:clide/extension/extension.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/widgets/widgets.dart'; + +/// Tier-5 interactive `.canvas` pane (T-322). Renders Obsidian JSONCanvas +/// documents in a workspace tab; each open document is a real sub-tab +/// (MultitabPane). Opens route in as `selection` messages — from +/// `openWorkspaceFile` (file tree, quick-open) and from +/// `clide ui open canvas [path]` (D-6 parity, the diff/T-233 pattern). class CanvasExtension extends ClideExtension { @override String get id => 'builtin.canvas'; @override String get title => 'Canvas'; @override - String get version => '0.0.0-stub'; + String get version => '0.1.0'; @override List get dependsOn => const []; + + /// App-scoped so open documents survive the pane view being (re)built + /// while another workspace tab is active. Built in [activate]; the + /// contribution's build closure reads the field at widget-build time. + MultitabController? _tabs; + StreamSubscription? _selectionSub; + StreamSubscription? _projectSub; + String? _projectRoot; + @override - List get contributions => const []; + List get contributions => [ + TabContribution( + id: 'canvas.view', + slot: Slots.workspace, + title: 'Canvas', + titleKey: 'tab.title', + i18nNamespace: id, + priority: -60, // below the readers' home surfaces, near diff (-70) + build: (_) => CanvasPaneHost(tabs: _tabs), + ), + ]; + + @override + Future activate(ClideExtensionContext ctx) async { + _tabs = MultitabController(); + _selectionSub = ctx.messages.subscribe(publisher: id, channel: 'selection').listen((msg) { + final path = msg.data['path']; + if (path is! String || path.isEmpty) return; + openPath(path); + ctx.panels.activateTab(Slots.workspace, 'canvas.view'); + }); + _projectSub = ctx.events.on().listen(_onProjectChanged); + } + + /// Focus the sub-tab for [path], opening one when the document isn't + /// open yet. Tab id is the path itself — one tab per document. + void openPath(String path) { + final tabs = _tabs; + if (tabs == null) return; + if (tabs.entries.any((e) => e.id == path)) { + tabs.activate(path); + } else { + tabs.add(MultitabEntry(id: path, title: _basename(path), payload: path)); + } + } + + /// Paths of the open documents, oldest-first. + List get openPaths => _tabs?.entries.map((e) => e.id).toList() ?? const []; + + /// Drop every open document when the workspace switches in place + /// (T-269): the old repo's paths don't resolve in the new one. + void _onProjectChanged(ProjectOpened e) { + final prev = _projectRoot; + _projectRoot = e.path; + if (prev == null || prev == e.path) return; + final tabs = _tabs; + if (tabs == null) return; + for (final id in tabs.entries.map((x) => x.id).toList()) { + tabs.remove(id); + } + } + + static String _basename(String path) { + final i = path.lastIndexOf('/'); + return i < 0 ? path : path.substring(i + 1); + } + + @override + Future deactivate() async { + await _selectionSub?.cancel(); + _selectionSub = null; + await _projectSub?.cancel(); + _projectSub = null; + _tabs?.dispose(); + _tabs = null; + } } diff --git a/lib/kernel/src/file_open.dart b/lib/kernel/src/file_open.dart index 39dce2cd..68a7e27f 100644 --- a/lib/kernel/src/file_open.dart +++ b/lib/kernel/src/file_open.dart @@ -1,6 +1,7 @@ /// The single dispatch point for opening a workspace file the way /// clide routes file activations (T-51 / T-187): /// * `.md` paths → the markdown reader, via the kernel MessageBus; +/// * `.canvas` paths → the canvas pane, via the kernel MessageBus (T-322); /// * every other path → the editor, via the `editor.open` IPC verb. /// /// Records the open in [KernelServices.recentFiles] so the quick-open @@ -15,8 +16,11 @@ import 'package:clide/kernel/src/facade.dart'; void openWorkspaceFile(KernelServices services, String path) { if (path.isEmpty) return; services.recentFiles.push(path); - if (path.toLowerCase().endsWith('.md')) { + final lower = path.toLowerCase(); + if (lower.endsWith('.md')) { services.messages.publish('builtin.markdown', 'selection', {'path': path}); + } else if (lower.endsWith('.canvas')) { + services.messages.publish('builtin.canvas', 'selection', {'path': path}); } else { unawaited(services.ipc.request('editor.open', args: {'path': path})); } diff --git a/lib/src/canvas/json_canvas.dart b/lib/src/canvas/json_canvas.dart index a7bedb12..31348946 100644 --- a/lib/src/canvas/json_canvas.dart +++ b/lib/src/canvas/json_canvas.dart @@ -3,9 +3,11 @@ /// back, so the canvas pane can load, edit, and persist a `.canvas`. /// /// Per D-91 `.canvas` is an import format, not clide's native schema — this -/// model is the faithful parse; the pane lowers it onto the SVG substrate for -/// rendering. Pure data — no Flutter, no I/O here — so it runs under -/// `dart test`. Spec: https://jsoncanvas.org/spec/1.0/ +/// model is the faithful parse. The interactive pane paints this model +/// directly via `CanvasPainter` (the D-103 live-widget exception, like the +/// graph); only the display-only drawing card lowers to the SVG substrate. +/// Pure data — no Flutter, no I/O here — so it runs under `dart test`. +/// Spec: https://jsoncanvas.org/spec/1.0/ library; import 'dart:convert'; diff --git a/lib/src/daemon/ui_command.dart b/lib/src/daemon/ui_command.dart index 16b76fb6..72ef5614 100644 --- a/lib/src/daemon/ui_command.dart +++ b/lib/src/daemon/ui_command.dart @@ -28,14 +28,16 @@ typedef MessagePublisher = void Function(String publisher, String channel, Map _readers = { 'tickets': (publisher: 'builtin.tickets', dataKey: 'id'), 'decisions': (publisher: 'builtin.decisions', dataKey: 'id'), 'markdown': (publisher: 'builtin.markdown', dataKey: 'path'), 'diff': (publisher: 'builtin.diff', dataKey: 'path'), + 'canvas': (publisher: 'builtin.canvas', dataKey: 'path'), }; /// Severities the toast verb accepts — mirrors `ToastSeverity` (kept as a diff --git a/test/builtin/canvas/canvas_extension_test.dart b/test/builtin/canvas/canvas_extension_test.dart new file mode 100644 index 00000000..c94a821b --- /dev/null +++ b/test/builtin/canvas/canvas_extension_test.dart @@ -0,0 +1,110 @@ +/// CanvasExtension (T-322) contributes the canvas pane into the workspace +/// slot and routes `selection` messages (openWorkspaceFile, +/// `clide ui open canvas [path]`) into app-scoped document tabs — the +/// diff/T-233 pattern. +library; + +import 'package:clide/builtin/canvas/canvas.dart'; +import 'package:clide/extension/extension.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; + +/// Drain the broadcast-stream microtask hop between a bus publish/emit and +/// the extension's listener. Plain `test` body — no fake-async zone, so a +/// zero-duration timer fires normally. +Future deliver() => Future.delayed(Duration.zero); + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + test('declares one workspace tab with the canvas.view identity', () { + final tabs = CanvasExtension().contributions.whereType().toList(); + expect(tabs, hasLength(1)); + final t = tabs.single; + expect(t.id, 'canvas.view'); + expect(t.slot, Slots.workspace); + expect(t.title, 'Canvas'); + expect(t.titleKey, 'tab.title'); + expect(t.i18nNamespace, 'builtin.canvas'); + }); + + test('registers into the workspace slot once activated', () async { + f.services.extensions.register(CanvasExtension()); + await f.services.extensions.activate('builtin.canvas'); + expect(f.services.panels.tabsFor(Slots.workspace).any((t) => t.id == 'canvas.view'), isTrue); + }); + + test('a selection message opens a document tab and reveals the pane', () async { + final ext = CanvasExtension(); + f.services.extensions.register(ext); + await f.services.extensions.activate('builtin.canvas'); + + f.services.messages.publish('builtin.canvas', 'selection', {'path': 'notes/board.canvas'}); + await deliver(); + + expect(ext.openPaths, ['notes/board.canvas']); + expect(f.services.panels.activeTabIn(Slots.workspace), 'canvas.view'); + }); + + test('re-selecting an open document focuses it instead of duplicating', () async { + final ext = CanvasExtension(); + f.services.extensions.register(ext); + await f.services.extensions.activate('builtin.canvas'); + + f.services.messages.publish('builtin.canvas', 'selection', {'path': 'a.canvas'}); + f.services.messages.publish('builtin.canvas', 'selection', {'path': 'b.canvas'}); + f.services.messages.publish('builtin.canvas', 'selection', {'path': 'a.canvas'}); + await deliver(); + + expect(ext.openPaths, ['a.canvas', 'b.canvas']); + }); + + test('a malformed selection payload is ignored', () async { + final ext = CanvasExtension(); + f.services.extensions.register(ext); + await f.services.extensions.activate('builtin.canvas'); + + f.services.messages.publish('builtin.canvas', 'selection', {'path': ''}); + f.services.messages.publish('builtin.canvas', 'selection', {'nope': 1}); + await deliver(); + + expect(ext.openPaths, isEmpty); + }); + + test('an in-place workspace switch drops the open documents (T-269)', () async { + final ext = CanvasExtension(); + f.services.extensions.register(ext); + await f.services.extensions.activate('builtin.canvas'); + + f.services.events.emit(const ProjectOpened(path: '/repo/a')); + await deliver(); + f.services.messages.publish('builtin.canvas', 'selection', {'path': 'a.canvas'}); + await deliver(); + expect(ext.openPaths, ['a.canvas']); + + // Same root again — documents stay. + f.services.events.emit(const ProjectOpened(path: '/repo/a')); + await deliver(); + expect(ext.openPaths, ['a.canvas']); + + // Different root — documents dropped. + f.services.events.emit(const ProjectOpened(path: '/repo/b')); + await deliver(); + expect(ext.openPaths, isEmpty); + }); + + test('deactivate cancels the subscription and disposes the tabs', () async { + final ext = CanvasExtension(); + f.services.extensions.register(ext); + await f.services.extensions.activate('builtin.canvas'); + await ext.deactivate(); + + f.services.messages.publish('builtin.canvas', 'selection', {'path': 'a.canvas'}); + await deliver(); + expect(ext.openPaths, isEmpty); + }); +} diff --git a/test/builtin/canvas/canvas_pane_host_test.dart b/test/builtin/canvas/canvas_pane_host_test.dart new file mode 100644 index 00000000..f09455d1 --- /dev/null +++ b/test/builtin/canvas/canvas_pane_host_test.dart @@ -0,0 +1,119 @@ +/// CanvasPaneHost (T-322): renders the extension-owned document tabs — +/// empty hint without documents, per-tab load through `files.read`, parse +/// errors surfaced as muted text, valid documents as an interactive +/// [CanvasView]. +library; + +import 'package:clide/builtin/canvas/canvas.dart'; +import 'package:clide/builtin/canvas/src/canvas_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'; + +const _validCanvas = '{"nodes":[{"id":"n1","type":"text","text":"hi","x":0,"y":0,"width":100,"height":50}],"edges":[]}'; + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + // The shared harness's Overlay hands unbounded constraints; the pane is a + // Column with an Expanded body, so give it a tight box. + Widget host(MultitabController? tabs) { + return harness(f, SizedBox(width: 800, height: 600, child: CanvasPaneHost(tabs: tabs))); + } + + testWidgets('shows the empty hint before activation (null controller)', (tester) async { + await tester.pumpWidget(host(null)); + expect(find.text('Open a .canvas file to view it here.'), findsOneWidget); + }); + + testWidgets('shows the empty hint when no document is open', (tester) async { + final tabs = MultitabController(); + addTearDown(tabs.dispose); + await tester.pumpWidget(host(tabs)); + expect(find.text('Open a .canvas file to view it here.'), findsOneWidget); + }); + + testWidgets('loads a document through files.read and renders a CanvasView', (tester) async { + final reads = []; + f.ipc.stub('files.read', (args) async { + reads.add(args['path']); + return IpcResponse.ok(id: '1', data: {'content': _validCanvas}); + }); + final tabs = MultitabController( + initial: [const MultitabEntry(id: 'a.canvas', title: 'a.canvas', payload: 'a.canvas')], + ); + addTearDown(tabs.dispose); + + await tester.pumpWidget(host(tabs)); + await pumpAsync(tester); + + expect(reads, ['a.canvas']); + expect(find.byType(CanvasView), findsOneWidget); + expect(find.text('a.canvas'), findsOneWidget); // the sub-tab label + }); + + testWidgets('a failed read surfaces the IPC error as muted text', (tester) async { + f.ipc.stub('files.read', (args) async { + return IpcResponse.err( + id: '1', + error: IpcError(code: IpcExitCode.notFound, kind: IpcErrorKind.notFound, message: 'no such file: gone.canvas'), + ); + }); + final tabs = MultitabController( + initial: [const MultitabEntry(id: 'gone.canvas', title: 'gone.canvas', payload: 'gone.canvas')], + ); + addTearDown(tabs.dispose); + + await tester.pumpWidget(host(tabs)); + await pumpAsync(tester); + + expect(find.text('no such file: gone.canvas'), findsOneWidget); + expect(find.byType(CanvasView), findsNothing); + }); + + testWidgets('a non-object top level surfaces the parse error', (tester) async { + f.ipc.stub('files.read', (args) async => IpcResponse.ok(id: '1', data: {'content': '[1,2,3]'})); + final tabs = MultitabController( + initial: [const MultitabEntry(id: 'bad.canvas', title: 'bad.canvas', payload: 'bad.canvas')], + ); + addTearDown(tabs.dispose); + + await tester.pumpWidget(host(tabs)); + await pumpAsync(tester); + + expect(find.text('canvas: top level must be a JSON object'), findsOneWidget); + expect(find.byType(CanvasView), findsNothing); + }); + + testWidgets('two documents render as two sub-tabs, keep-alive across switches', (tester) async { + f.ipc.stub('files.read', (args) async => IpcResponse.ok(id: '1', data: {'content': _validCanvas})); + final tabs = MultitabController( + initial: [ + const MultitabEntry(id: 'notes/a.canvas', title: 'a.canvas', payload: 'notes/a.canvas'), + const MultitabEntry(id: 'notes/b.canvas', title: 'b.canvas', payload: 'notes/b.canvas'), + ], + ); + addTearDown(tabs.dispose); + + await tester.pumpWidget(host(tabs)); + await pumpAsync(tester); + + expect(find.text('a.canvas'), findsOneWidget); + expect(find.text('b.canvas'), findsOneWidget); + // keepAlive keeps both bodies mounted — the inactive one offstage in + // the IndexedStack — so both must be found with skipOffstage off. + expect(find.byType(CanvasView, skipOffstage: false), findsNWidgets(2)); + expect(find.byType(CanvasView), findsOneWidget); + + tabs.activate('notes/b.canvas'); + await pumpAsync(tester); + expect(find.byType(CanvasView, skipOffstage: false), findsNWidgets(2)); + expect(find.byType(CanvasView), findsOneWidget); + }); +} diff --git a/test/daemon/ui_command_test.dart b/test/daemon/ui_command_test.dart index 83452766..95b77012 100644 --- a/test/daemon/ui_command_test.dart +++ b/test/daemon/ui_command_test.dart @@ -59,6 +59,16 @@ void main() { expect(published.single.data, {'path': 'lib/main.dart'}); }); + test('canvas keys on path and publishes a builtin.canvas selection (T-322)', () async { + wire(); + final r = await open(['canvas', 'notes/board.canvas']); + expect(r.ok, isTrue, reason: r.error?.message); + expect(r.data['opened'], isTrue); + expect(published.single.publisher, 'builtin.canvas'); + expect(published.single.channel, 'selection'); + expect(published.single.data, {'path': 'notes/board.canvas'}); + }); + test('named args work too (reader/ref)', () async { wire(); final r = await d.dispatch(IpcRequest(id: '1', cmd: 'ui.open', args: {'reader': 'tickets', 'ref': 'T-7'})); @@ -68,7 +78,7 @@ void main() { test('unknown reader → userError, nothing published', () async { wire(); - final r = await open(['canvas', 'foo']); + final r = await open(['bogus', 'foo']); expect(r.ok, isFalse); expect(r.error?.kind, IpcErrorKind.userError); expect(published, isEmpty); diff --git a/test/kernel/file_open_test.dart b/test/kernel/file_open_test.dart new file mode 100644 index 00000000..c19008c8 --- /dev/null +++ b/test/kernel/file_open_test.dart @@ -0,0 +1,70 @@ +/// openWorkspaceFile (T-51/T-187) — the single dispatch point for file +/// activations: `.md` → markdown reader, `.canvas` → canvas pane (T-322), +/// everything else → the editor via IPC. Also records recents. +library; + +import 'package:clide/clide.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/kernel_fixture.dart'; + +void main() { + late KernelFixture f; + late List published; + late List editorOpens; + + setUp(() async { + f = await KernelFixture.create(); + published = []; + editorOpens = []; + f.services.messages.subscribe().listen(published.add); + f.ipc.stub('editor.open', (args) async { + editorOpens.add(args['path']); + return IpcResponse.ok(id: '1', data: const {}); + }); + }); + tearDown(() => f.dispose()); + + Future deliver() => Future.delayed(Duration.zero); + + test('.md routes to the markdown reader via the bus', () async { + openWorkspaceFile(f.services, 'docs/plan.md'); + await deliver(); + expect(published, hasLength(1)); + expect(published.single.publisher, 'builtin.markdown'); + expect(published.single.channel, 'selection'); + expect(published.single.data, {'path': 'docs/plan.md'}); + expect(editorOpens, isEmpty); + }); + + test('.canvas routes to the canvas pane via the bus (T-322)', () async { + openWorkspaceFile(f.services, 'notes/board.canvas'); + await deliver(); + expect(published, hasLength(1)); + expect(published.single.publisher, 'builtin.canvas'); + expect(published.single.channel, 'selection'); + expect(published.single.data, {'path': 'notes/board.canvas'}); + expect(editorOpens, isEmpty); + }); + + test('extension match is case-insensitive', () async { + openWorkspaceFile(f.services, 'BOARD.CANVAS'); + await deliver(); + expect(published.single.publisher, 'builtin.canvas'); + }); + + test('everything else opens in the editor over IPC', () async { + openWorkspaceFile(f.services, 'lib/main.dart'); + await deliver(); + expect(published, isEmpty); + expect(editorOpens, ['lib/main.dart']); + }); + + test('records the open in recent files; empty path is a no-op', () async { + openWorkspaceFile(f.services, 'notes/board.canvas'); + openWorkspaceFile(f.services, ''); + await deliver(); + expect(f.services.recentFiles.paths, ['notes/board.canvas']); + }); +}