feat(canvas): open .canvas files in a workspace pane (T-322)
Makes the canvas foundation (parser/renderer/view) reachable. The extension owns an app-scoped MultitabController (the diff/T-233 pattern) so open documents survive the pane being rebuilt; each document is a real sub-tab per the refinement decision, kept alive across switches. Routing goes through the existing seams instead of the dead TabContribution.fileGlobs field: openWorkspaceFile gains a .canvas branch mirroring .md, and ui.open gains a canvas reader for D-6 parity (clide ui open canvas <path>). Also corrects the json_canvas doc header that claimed SVG-lowering — the interactive pane paints the model directly (D-103 live-widget exception). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<void> deliver() => Future<void>.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<TabContribution>().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);
|
||||
});
|
||||
}
|
||||
@@ -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<String>? 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<String>();
|
||||
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 = <Object?>[];
|
||||
f.ipc.stub('files.read', (args) async {
|
||||
reads.add(args['path']);
|
||||
return IpcResponse.ok(id: '1', data: {'content': _validCanvas});
|
||||
});
|
||||
final tabs = MultitabController<String>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Message> published;
|
||||
late List<Object?> 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<void> deliver() => Future<void>.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']);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user