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:
2026-07-05 08:56:09 +02:00
co-authored by Claude Fable 5
parent 8d7d886bea
commit e2e305dea6
13 changed files with 538 additions and 14 deletions
+1
View File
@@ -1 +1,2 @@
export 'src/canvas_pane_host.dart';
export 'src/extension.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<String>? 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<String>(
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<CanvasDocumentTab> createState() => _CanvasDocumentTabState();
}
class _CanvasDocumentTabState extends State<CanvasDocumentTab> {
CanvasDoc? _doc;
String? _error;
bool _requested = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_requested) return;
_requested = true;
unawaited(_load(ClideKernel.of(context)));
}
Future<void> _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);
}
}
+87 -6
View File
@@ -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<String> 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<String>? _tabs;
StreamSubscription<Message>? _selectionSub;
StreamSubscription<ProjectOpened>? _projectSub;
String? _projectRoot;
@override
List<ContributionPoint> get contributions => const [];
List<ContributionPoint> 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<void> activate(ClideExtensionContext ctx) async {
_tabs = MultitabController<String>();
_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<ProjectOpened>().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<String>(id: path, title: _basename(path), payload: path));
}
}
/// Paths of the open documents, oldest-first.
List<String> 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<void> deactivate() async {
await _selectionSub?.cancel();
_selectionSub = null;
await _projectSub?.cancel();
_projectSub = null;
_tabs?.dispose();
_tabs = null;
}
}