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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -28,14 +28,16 @@ typedef MessagePublisher = void Function(String publisher, String channel, Map<S
|
||||
|
||||
/// The readers `ui.open` can target → (bus publisher id, payload key the
|
||||
/// reader reads its entry from). Matches each reader's `ReaderNav` dataKey
|
||||
/// (tickets/decisions key on `id`; markdown on `path`). `diff` is not a
|
||||
/// ReaderNav reader — it keys on `path` and its extension subscribes to the
|
||||
/// same `selection` channel to reveal its tab + focus the file (T-233).
|
||||
/// (tickets/decisions key on `id`; markdown on `path`). `diff` and `canvas`
|
||||
/// are not ReaderNav readers — they key on `path` and their extensions
|
||||
/// subscribe to the same `selection` channel to reveal their tab + focus
|
||||
/// the file (T-233, T-322).
|
||||
const Map<String, ({String publisher, String dataKey})> _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
|
||||
|
||||
Reference in New Issue
Block a user