From 5f8fa6af10f4246d2f228fe911aa0c7514890e8e Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 29 Jun 2026 12:08:33 +0200 Subject: [PATCH] feat(draw): fold the d2 source into a "view source" disclosure (T-494) The rendered diagram leads; the d2 source carries through the draw bus and folds into a collapsed ClideCollapserCard beneath the card (D-78 display- only). en+nl catalogs. Also switches the d2 test to a null-aware element. Co-Authored-By: Claude Opus 4.8 (1M context) --- assets/i18n/en_us/builtin.claude.json | 1 + assets/i18n/nl_nl/builtin.claude.json | 1 + lib/builtin/claude/src/conversation_view.dart | 4 +++ lib/builtin/claude/src/extension.dart | 1 + lib/builtin/claude/src/transcript_reader.dart | 14 +++++++- lib/src/daemon/draw_commands.dart | 8 ++++- lib/widgets/src/draw/drawing_card.dart | 32 ++++++++++++++++++- test/draw/d2_template_test.dart | 2 +- test/widgets/draw/drawing_card_test.dart | 23 +++++++++++++ 9 files changed, 82 insertions(+), 4 deletions(-) diff --git a/assets/i18n/en_us/builtin.claude.json b/assets/i18n/en_us/builtin.claude.json index b2ad80d9..ee3d6077 100644 --- a/assets/i18n/en_us/builtin.claude.json +++ b/assets/i18n/en_us/builtin.claude.json @@ -48,6 +48,7 @@ "conversation.label.agentThinking": { "translation": "agent thinking" }, "conversation.label.image": { "translation": "image" }, "conversation.label.drawing": { "translation": "drawing" }, + "conversation.draw.viewSource": { "translation": "view d2 source" }, "conversation.label.agentRun": { "translation": "agent run" }, "conversation.label.workflow": { "translation": "workflow" }, "conversation.label.error": { "translation": "error" }, diff --git a/assets/i18n/nl_nl/builtin.claude.json b/assets/i18n/nl_nl/builtin.claude.json index 8718f993..8ad51fc7 100644 --- a/assets/i18n/nl_nl/builtin.claude.json +++ b/assets/i18n/nl_nl/builtin.claude.json @@ -48,6 +48,7 @@ "conversation.label.agentThinking": { "translation": "agent denkt na" }, "conversation.label.image": { "translation": "afbeelding" }, "conversation.label.drawing": { "translation": "tekening" }, + "conversation.draw.viewSource": { "translation": "d2-bron tonen" }, "conversation.label.agentRun": { "translation": "agent-uitvoering" }, "conversation.label.workflow": { "translation": "workflow" }, "conversation.label.error": { "translation": "fout" }, diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index 5965e35e..23f79bcd 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -720,6 +720,10 @@ class _ConversationTurn extends StatelessWidget { document: doc, label: m.label, description: m.description, + source: m.source, + sourceLabel: m.source == null + ? null + : ClideSettings.i18n.string(context, 'conversation.draw.viewSource', namespace: 'builtin.claude', placeholder: 'view d2 source'), // A data-lightbox element opens the whole drawing, zoomable (T-318). onLightbox: () => ClideKernel.of(context).dialog.show( (ctx, dismiss) => ClideLightbox( diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index a76c62fa..68ff99de 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -661,6 +661,7 @@ class ClaudeExtension extends ClideExtension { svg: svg, label: m.data['label'] as String?, description: m.data['description'] as String?, + source: m.data['source'] as String?, ), ); } diff --git a/lib/builtin/claude/src/transcript_reader.dart b/lib/builtin/claude/src/transcript_reader.dart index 54a24bf8..b9e92aa1 100644 --- a/lib/builtin/claude/src/transcript_reader.dart +++ b/lib/builtin/claude/src/transcript_reader.dart @@ -196,7 +196,15 @@ final class ImageMessage extends ConversationItem { /// paints (already lowered from the doc's template / primitive source); /// [label] / [description] are the optional card caption. final class DrawingMessage extends ConversationItem { - const DrawingMessage({required super.uuid, required super.timestamp, required super.isSidechain, required this.svg, this.label, this.description}); + const DrawingMessage({ + required super.uuid, + required super.timestamp, + required super.isSidechain, + required this.svg, + this.label, + this.description, + this.source, + }); /// The SVG document source the renderer paints. final String svg; @@ -204,6 +212,10 @@ final class DrawingMessage extends ConversationItem { /// Optional card-level caption (label + supporting description). final String? label, description; + /// Optional template source (e.g. the d2 diagram text) — shown in a collapsed + /// "view source" disclosure on the card when present (T-494). + final String? source; + @override String toString() => 'DrawingMessage(${label ?? ''})'; } diff --git a/lib/src/daemon/draw_commands.dart b/lib/src/daemon/draw_commands.dart index f3a34313..372ddd62 100644 --- a/lib/src/daemon/draw_commands.dart +++ b/lib/src/daemon/draw_commands.dart @@ -98,6 +98,12 @@ Future _draw(IpcRequest req, MessagePublisher? Function() publisher ); } - publish('cli', drawShowChannel, {'svg': svg, if (doc.label != null) 'label': doc.label, if (doc.description != null) 'description': doc.description}); + publish('cli', drawShowChannel, { + 'svg': svg, + if (doc.label != null) 'label': doc.label, + if (doc.description != null) 'description': doc.description, + // d2 cards carry their source so the card can offer a "view source" peek. + if (doc.template == 'd2' && doc.fields['source'] is String) 'source': doc.fields['source'], + }); return IpcResponse.ok(id: req.id, data: {'shown': true, if (doc.template != null) 'template': doc.template}); } diff --git a/lib/widgets/src/draw/drawing_card.dart b/lib/widgets/src/draw/drawing_card.dart index 891a1f0b..98ab0a1e 100644 --- a/lib/widgets/src/draw/drawing_card.dart +++ b/lib/widgets/src/draw/drawing_card.dart @@ -14,6 +14,7 @@ library; import 'package:clide/kernel/src/theme/tokens.dart'; import 'package:clide/widgets/src/clide_card_metrics.dart'; +import 'package:clide/widgets/src/clide_collapser_card.dart'; import 'package:clide/widgets/src/clide_settings.dart'; import 'package:clide/widgets/src/clide_text.dart'; import 'package:clide/widgets/src/svg/svg_painter.dart'; @@ -22,12 +23,27 @@ import 'package:clide/src/svg/svg_node.dart'; import 'package:flutter/widgets.dart'; class DrawingCard extends StatelessWidget { - const DrawingCard({super.key, required this.document, this.label, this.description, this.images, this.onLightbox, this.maxHeight = 360}); + const DrawingCard({ + super.key, + required this.document, + this.label, + this.description, + this.images, + this.onLightbox, + this.source, + this.sourceLabel, + this.maxHeight = 360, + }); final SvgDocument document; final String? label, description; final SvgImageResolver? images; + /// The template source (e.g. d2 diagram text) and its disclosure title. When + /// [source] is present, a collapsed "view source" disclosure renders beneath + /// the drawing (T-494) — the rendered diagram leads; the code folds away. + final String? source, sourceLabel; + /// Called when a `data-lightbox` element is tapped (the caller opens the /// zoom view). Null ⇒ no lightbox affordance. final VoidCallback? onLightbox; @@ -51,6 +67,20 @@ class DrawingCard extends StatelessWidget { padding: const EdgeInsets.only(top: kClideCardHeaderPadV), child: ClideText(description!, fontSize: clideFontCaption, color: tokens.globalTextMuted), ), + if (_has(source)) + Padding( + padding: const EdgeInsets.only(top: kClideCardHeaderPadV), + child: ClideCollapserCard( + label: sourceLabel ?? 'view source', + color: tokens.globalTextMuted, + children: [ + Padding( + padding: const EdgeInsets.all(kClideCardHeaderPadH), + child: ClideText(source!, fontSize: clideFontCaption, fontFamily: ClideSettings.fonts.monoOf(context), color: tokens.globalForeground), + ), + ], + ), + ), ], ); } diff --git a/test/draw/d2_template_test.dart b/test/draw/d2_template_test.dart index 1651605d..55e131fe 100644 --- a/test/draw/d2_template_test.dart +++ b/test/draw/d2_template_test.dart @@ -4,7 +4,7 @@ import 'package:clide/src/draw/draw_doc.dart'; import 'package:test/test.dart'; void main() { - DrawingCardDoc d2doc(Object? source) => DrawingCardDoc(template: 'd2', fields: {'template': 'd2', if (source != null) 'source': source}); + DrawingCardDoc d2doc(Object? source) => DrawingCardDoc(template: 'd2', fields: {'template': 'd2', 'source': ?source}); group('d2TemplateHandler', () { test('compiles the source field via the injected compiler', () async { diff --git a/test/widgets/draw/drawing_card_test.dart b/test/widgets/draw/drawing_card_test.dart index 625b9cfb..8b0f2572 100644 --- a/test/widgets/draw/drawing_card_test.dart +++ b/test/widgets/draw/drawing_card_test.dart @@ -44,6 +44,29 @@ void main() { expect(find.text('the entry point'), findsOneWidget); }); + testWidgets('a source disclosure folds the d2 source under a collapser (T-494)', (tester) async { + await tester.pumpWidget( + anchoredHarness( + f, + SizedBox( + width: 400, + child: DrawingCard( + document: buildSvgDocument(''), + source: 'a -> b: hello', + sourceLabel: 'view d2 source', + ), + ), + ), + ); + await tester.pump(); + // The disclosure header shows; the source is folded away until expanded. + expect(find.text('view d2 source'), findsOneWidget); + expect(find.text('a -> b: hello'), findsNothing); + await tester.tap(find.text('view d2 source')); + await tester.pumpAndSettle(); + expect(find.text('a -> b: hello'), findsOneWidget); + }); + testWidgets('a data-lightbox element fires onLightbox when tapped (T-318)', (tester) async { var tapped = false; final doc = buildSvgDocument('');