From 095c45a0239b4fe19ad6c66941490189b2dd9169 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 28 Jun 2026 11:08:31 +0200 Subject: [PATCH] feat(markdown): live-sync read-mirror of the open editor buffer (T-36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D-50 behavior 4: opening a renderable .md in the editor now auto-reveals the context-panel reader, which mirrors the buffer read-only and re-renders as the user types — rather than a one-shot disk read. The extension reveals the tab on editor.opened (renderable only — D-50 behavior 5 leaves non-.md files alone). The viewer owns the mirror: on mount it picks up the active buffer (editor.read, no id), enters mirror mode on editor.opened / active-changed for a renderable file, re-reads the in-memory buffer on editor.edited, and drops the mirror (with its edit affordance back) on a disk load or a switch to a non-renderable buffer. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 3 + .pql/changelog/tickets/2026-06.sql | 15 ++++ CHANGELOG.md | 3 + lib/builtin/markdown/src/extension.dart | 16 +++- lib/builtin/markdown/src/markdown_viewer.dart | 80 ++++++++++++++++++- .../markdown/markdown_extension_test.dart | 53 ++++++++++++ .../markdown/markdown_viewer_test.dart | 40 ++++++++++ 7 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 test/builtin/markdown/markdown_extension_test.dart diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 9d5af6ab..2c1ee8d2 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -7267,3 +7267,6 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSQP1YPG70RPQJ4XZDH2T1M', 'status', 'backlog', 'in_progress', NULL, '2026-06-28 08:32:16', '2026-06-28 08:32:16.329', '2026-06-28 08:32:16.329', NULL, 'b212ea2e61acbb81bd5d089e97240c97', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSQP1YPG70RPQJ4XZDH2T1M', 'status', 'in_progress', 'in_progress', NULL, '2026-06-28 08:32:36', '2026-06-28 08:32:36.557', '2026-06-28 08:32:36.557', NULL, '09e582bb30f33f9c7330e041067a48b6', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSQP1YPG70RPQJ4XZDH2T1M', 'status', 'in_progress', 'done', NULL, '2026-06-28 08:53:08', '2026-06-28 08:53:08.149', '2026-06-28 08:53:08.149', NULL, 'fbe0fcce351e196779c912689805075f', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JK0RHMRAH47K8ND4', 'status', 'backlog', 'in_progress', NULL, '2026-06-28 08:55:04', '2026-06-28 08:55:04.660', '2026-06-28 08:55:04.660', NULL, 'e5c075285fc2422545f90d372c175e28', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JK0RHMRAH47K8ND4', 'status', 'in_progress', 'in_progress', NULL, '2026-06-28 08:55:15', '2026-06-28 08:55:15.737', '2026-06-28 08:55:15.737', NULL, '793c4f2984d01abfb3a52d860e60f6d7', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JK0RHMRAH47K8ND4', 'status', 'in_progress', 'done', NULL, '2026-06-28 09:08:31', '2026-06-28 09:08:31.757', '2026-06-28 09:08:31.757', NULL, '168bec5a771d23ab7f0d850a8202ef0c', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index b7fa338e..707e533f 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -9446,3 +9446,18 @@ Considerations: 2. The post-/clear respawn also gets it (it spawns through the same path). 3. A --resume of an existing session does NOT get re-injected. 4. The text is short and does not crowd out clideContextNote.', 'done', 'medium', NULL, NULL, NULL, '2026-06-28 06:15:53.077', '2026-06-28 08:53:08.149', NULL, '1c8842605724aab0461805297ad03ac7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JK0RHMRAH47K8ND4', 'story', '06FB0TNQM7RQMN1GKEZ5JQKJT0', 'Live-sync read-mirror: context panel tracks the open editor buffer', 'When an editor is open in edit mode on a renderable doc (.md), the right-hand context panel auto-opens a read-only view that mirrors the buffer and updates live as the user types (D-50 behavior 4). If the editor is on a non-renderable file, no auto-viewer (D-50 behavior 5, already holds). + +Wiring sketch: EditorRegistry already emits editor.opened / editor.active-changed / editor.edited (lib/src/editor/registry.dart:85). The markdown reader (lib/builtin/markdown/src/markdown_viewer.dart) currently pulls content once via files.read with no subscription. The live-sync work is: (1) on editor.opened for a renderable file, auto-activate the markdown reader in the context panel; (2) subscribe the reader to editor.edited and re-render from the in-memory buffer rather than re-reading disk; (3) read-only — no edit affordances in the mirror. + +HISTORY: T-36 originally bundled four D-50 clauses (parse Claude''s output for file references, swap the open viewer, badge the spine when collapsed, live-sync). The give-clide-hands push (T-208) superseded the first three: instead of clide scraping the terminal for references, the agent explicitly drives the reader via `clide ui open markdown ` (T-231) and ui.open -> diff (T-233). The spine badge was dropped (the open is now an intentional agent act, not a passive notification). Re-scoped 2026-06-06 to the one UI-owned piece that give-clide-hands did not deliver: the live-sync read-mirror. Re-homed from T-7 (Tier 5 canvas/graph, a mis-parent) to T-259 (interaction model). See the D-50 amendment.', 'in_progress', 'low', NULL, NULL, 'D-50', '2026-04-22 20:34:09', '2026-06-28 08:55:04.660', NULL, '4efae1f339b66f4ee9ff7aa14581867e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JK0RHMRAH47K8ND4', 'story', '06FB0TNQM7RQMN1GKEZ5JQKJT0', 'Live-sync read-mirror: context panel tracks the open editor buffer', 'When an editor is open in edit mode on a renderable doc (.md), the right-hand context panel auto-opens a read-only view that mirrors the buffer and updates live as the user types (D-50 behavior 4). If the editor is on a non-renderable file, no auto-viewer (D-50 behavior 5, already holds). + +Wiring sketch: EditorRegistry already emits editor.opened / editor.active-changed / editor.edited (lib/src/editor/registry.dart:85). The markdown reader (lib/builtin/markdown/src/markdown_viewer.dart) currently pulls content once via files.read with no subscription. The live-sync work is: (1) on editor.opened for a renderable file, auto-activate the markdown reader in the context panel; (2) subscribe the reader to editor.edited and re-render from the in-memory buffer rather than re-reading disk; (3) read-only — no edit affordances in the mirror. + +HISTORY: T-36 originally bundled four D-50 clauses (parse Claude''s output for file references, swap the open viewer, badge the spine when collapsed, live-sync). The give-clide-hands push (T-208) superseded the first three: instead of clide scraping the terminal for references, the agent explicitly drives the reader via `clide ui open markdown ` (T-231) and ui.open -> diff (T-233). The spine badge was dropped (the open is now an intentional agent act, not a passive notification). Re-scoped 2026-06-06 to the one UI-owned piece that give-clide-hands did not deliver: the live-sync read-mirror. Re-homed from T-7 (Tier 5 canvas/graph, a mis-parent) to T-259 (interaction model). See the D-50 amendment.', 'in_progress', 'low', NULL, NULL, 'D-50', '2026-04-22 20:34:09', '2026-06-28 08:55:15.737', NULL, 'eb473131afb64548a097a45d74f3637c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JK0RHMRAH47K8ND4', 'story', '06FB0TNQM7RQMN1GKEZ5JQKJT0', 'Live-sync read-mirror: context panel tracks the open editor buffer', 'When an editor is open in edit mode on a renderable doc (.md), the right-hand context panel auto-opens a read-only view that mirrors the buffer and updates live as the user types (D-50 behavior 4). If the editor is on a non-renderable file, no auto-viewer (D-50 behavior 5, already holds). + +Wiring sketch: EditorRegistry already emits editor.opened / editor.active-changed / editor.edited (lib/src/editor/registry.dart:85). The markdown reader (lib/builtin/markdown/src/markdown_viewer.dart) currently pulls content once via files.read with no subscription. The live-sync work is: (1) on editor.opened for a renderable file, auto-activate the markdown reader in the context panel; (2) subscribe the reader to editor.edited and re-render from the in-memory buffer rather than re-reading disk; (3) read-only — no edit affordances in the mirror. + +HISTORY: T-36 originally bundled four D-50 clauses (parse Claude''s output for file references, swap the open viewer, badge the spine when collapsed, live-sync). The give-clide-hands push (T-208) superseded the first three: instead of clide scraping the terminal for references, the agent explicitly drives the reader via `clide ui open markdown ` (T-231) and ui.open -> diff (T-233). The spine badge was dropped (the open is now an intentional agent act, not a passive notification). Re-scoped 2026-06-06 to the one UI-owned piece that give-clide-hands did not deliver: the live-sync read-mirror. Re-homed from T-7 (Tier 5 canvas/graph, a mis-parent) to T-259 (interaction model). See the D-50 amendment.', 'done', 'low', NULL, NULL, 'D-50', '2026-04-22 20:34:09', '2026-06-28 09:08:31.757', NULL, 'cb89bbf9b488b2611acbe49cc3d59a6a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index b96fca46..7c240661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **Live-sync markdown read-mirror.** Editing a `.md` in the editor auto-opens a + read-only preview in the context panel that mirrors the buffer and re-renders + as you type; non-renderable files get no auto-viewer. (T-36, D-50) - **New project flow.** A "New project…" action in the welcome view creates + opens a project, then prompts for the Claude account to bind it to — the per-repo account roadblock fires only for freshly-created projects. (T-488, diff --git a/lib/builtin/markdown/src/extension.dart b/lib/builtin/markdown/src/extension.dart index 66824981..f8f31cb0 100644 --- a/lib/builtin/markdown/src/extension.dart +++ b/lib/builtin/markdown/src/extension.dart @@ -16,6 +16,7 @@ class MarkdownExtension extends ClideExtension { List get dependsOn => const []; StreamSubscription? _sub; + StreamSubscription? _editorSub; @override List get contributions => [ @@ -38,8 +39,21 @@ class MarkdownExtension extends ClideExtension { if (msg.data['path'] is! String) return; ctx.panels.activateTab(Slots.contextPanel, 'markdown.viewer'); }); + // Live-sync read-mirror (T-36, D-50 behavior 4): opening a renderable .md in + // the editor auto-reveals the reader, which then mirrors the buffer live. + // Non-renderable files reveal nothing (D-50 behavior 5). + _editorSub = ctx.events.on().listen((e) { + if (e.subsystem != 'editor' || e.kind != 'editor.opened') return; + final path = e.data['path'] as String?; + if (path != null && isRenderableMarkdownPath(path)) { + ctx.panels.activateTab(Slots.contextPanel, 'markdown.viewer'); + } + }); } @override - Future deactivate() async => _sub?.cancel(); + Future deactivate() async { + await _sub?.cancel(); + await _editorSub?.cancel(); + } } diff --git a/lib/builtin/markdown/src/markdown_viewer.dart b/lib/builtin/markdown/src/markdown_viewer.dart index 0cac1f1b..0ac024e1 100644 --- a/lib/builtin/markdown/src/markdown_viewer.dart +++ b/lib/builtin/markdown/src/markdown_viewer.dart @@ -5,6 +5,13 @@ import 'package:clide/kernel/kernel.dart'; import 'package:clide/widgets/widgets.dart'; import 'package:flutter/widgets.dart'; +/// Whether [path] is a markdown file the reader can mirror live (T-36, D-50). +/// Non-renderable files get no auto-viewer (D-50 behavior 5). +bool isRenderableMarkdownPath(String path) { + final p = path.toLowerCase(); + return p.endsWith('.md') || p.endsWith('.markdown'); +} + class MarkdownViewer extends StatefulWidget { const MarkdownViewer({super.key}); @@ -17,8 +24,15 @@ class _MarkdownViewerState extends State { String? _content; String? _error; StreamSubscription? _selectionSub; + StreamSubscription? _editorSub; ReaderNav? _nav; + /// Live-sync mirror state (T-36, D-50 behavior 4): when [_mirror] is true the + /// view is a read-only reflection of the open editor buffer [_mirrorId] and + /// re-reads it on every edit, rather than a disk snapshot. + bool _mirror = false; + String? _mirrorId; + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -31,12 +45,68 @@ class _MarkdownViewerState extends State { final path = msg.data['path'] as String?; if (path != null) _loadFile(path); }); + // Live-sync: mirror the editor buffer as it's typed (T-36). + _editorSub = kernel.events.on().listen(_onEditorEvent); + // The editor may have opened before this viewer mounted (the extension + // reveals the tab on editor.opened) — pick up the active buffer now. + unawaited(_mirrorActiveIfRenderable(kernel)); // Grab the latest entry the nav already holds (a selection that // revealed this tab before we subscribed). final current = _nav!.current; if (current != null) _loadFile(current); } + /// On mount, mirror the active editor buffer if it's a renderable file — + /// `editor.read` with no id resolves to the active buffer. + Future _mirrorActiveIfRenderable(KernelServices kernel) async { + final resp = await kernel.ipc.request('editor.read', args: const {}); + if (!mounted || !resp.ok) return; + final path = resp.data['path'] as String?; + final id = resp.data['id'] as String?; + if (path != null && id != null && isRenderableMarkdownPath(path)) { + _enterMirror(id, path, resp.data['content'] as String? ?? ''); + } + } + + void _onEditorEvent(DaemonEvent e) { + if (e.subsystem != 'editor') return; + final id = e.data['id'] as String?; + final path = e.data['path'] as String?; + switch (e.kind) { + case 'editor.opened': + if (id != null && path != null && isRenderableMarkdownPath(path)) _enterMirror(id, path, e.data['content'] as String? ?? ''); + case 'editor.active-changed': + // Followed the active buffer: mirror a renderable one, drop the mirror + // for a non-renderable / no buffer (D-50 behavior 5 — no auto-viewer). + if (id != null && path != null && isRenderableMarkdownPath(path)) { + unawaited(_reread(id, path)); + } else { + if (_mirror) setState(() => _mirror = false); + } + case 'editor.edited': + if (_mirror && id != null && id == _mirrorId && _path != null) unawaited(_reread(id, _path!)); + case 'editor.closed': + if (id == _mirrorId && _mirror) setState(() => _mirror = false); + } + } + + /// Re-read the in-memory buffer [id] and refresh the mirror (live edits). + Future _reread(String id, String path) async { + final resp = await ClideKernel.of(context).ipc.request('editor.read', args: {'id': id}); + if (!mounted || !resp.ok) return; + _enterMirror(id, path, resp.data['content'] as String? ?? ''); + } + + void _enterMirror(String id, String path, String content) { + setState(() { + _mirror = true; + _mirrorId = id; + _path = path; + _content = content; + _error = null; + }); + } + void _onNavChanged() { if (mounted) setState(() {}); // refresh action-bar button state } @@ -44,11 +114,14 @@ class _MarkdownViewerState extends State { @override void dispose() { _selectionSub?.cancel(); + _editorSub?.cancel(); _nav?.removeListener(_onNavChanged); super.dispose(); } - /// Fetch + display [path]. History lives in [ReaderNav]; never pushes. + /// Fetch + display [path] from disk. An explicit (agent-driven) selection, so + /// it leaves mirror mode — the edit affordance returns. History lives in + /// [ReaderNav]; never pushes. Future _loadFile(String path) async { final kernel = ClideKernel.of(context); final resp = await kernel.ipc.request('files.read', args: {'path': path}); @@ -56,6 +129,7 @@ class _MarkdownViewerState extends State { if (resp.ok) { kernel.messages.publish('builtin.markdown', 'focus', {'path': path}); setState(() { + _mirror = false; _path = path; _content = resp.data['content'] as String? ?? ''; _error = null; @@ -121,7 +195,9 @@ class _MarkdownViewerState extends State { onBack: (_nav?.canGoBack ?? false) ? _onBack : null, onForward: (_nav?.canGoForward ?? false) ? _onForward : null, onJumpToPin: (_nav?.hasPinned ?? false) ? _onJumpToPin : null, - onEdit: _path != null ? _onEdit : null, + // Read-only while mirroring the live buffer (T-36): the file is already + // open in the editor, so no edit affordance here. + onEdit: (!_mirror && _path != null) ? _onEdit : null, ), ], child: SingleChildScrollView( diff --git a/test/builtin/markdown/markdown_extension_test.dart b/test/builtin/markdown/markdown_extension_test.dart new file mode 100644 index 00000000..cd81b66f --- /dev/null +++ b/test/builtin/markdown/markdown_extension_test.dart @@ -0,0 +1,53 @@ +/// T-36 / D-50 behavior 4+5: MarkdownExtension reveals the context-panel reader +/// when a renderable .md opens in the editor, and leaves a non-renderable file +/// alone. (The viewer then mirrors the buffer live — see markdown_viewer_test.) +library; + +import 'package:clide/builtin/markdown/src/extension.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; + +void main() { + late KernelFixture f; + + setUp(() async { + f = await KernelFixture.create(); + f.services.extensions.register(MarkdownExtension()); + await f.services.extensions.activate('builtin.markdown'); + }); + tearDown(() => f.dispose()); + + void open(String path) { + f.services.events.emit(DaemonEvent(subsystem: 'editor', kind: 'editor.opened', data: {'id': 'b1', 'path': path}, ts: DateTime.now().toUtc())); + } + + test('contributes the markdown.viewer context-panel tab', () { + expect(f.services.panels.tabsFor(Slots.contextPanel).any((t) => t.id == 'markdown.viewer'), isTrue); + }); + + test('opening a renderable .md reveals the markdown viewer', () async { + // markdown.viewer is the default contextPanel tab; flip to a sentinel first + // so the reveal is observable. + f.services.panels.activateTab(Slots.contextPanel, 'sentinel'); + open('docs/notes.md'); + await pumpEventQueue(); + expect(f.services.panels.activeTabIn(Slots.contextPanel), 'markdown.viewer'); + }); + + test('opening a non-renderable file does not reveal it (D-50 behavior 5)', () async { + f.services.panels.activateTab(Slots.contextPanel, 'sentinel'); + open('lib/main.dart'); + await pumpEventQueue(); + expect(f.services.panels.activeTabIn(Slots.contextPanel), 'sentinel'); + }); + + test('after deactivate, editor.opened no longer reveals the viewer', () async { + await f.services.extensions.deactivate('builtin.markdown'); + f.services.panels.activateTab(Slots.contextPanel, 'sentinel'); + open('notes.md'); + await pumpEventQueue(); + expect(f.services.panels.activeTabIn(Slots.contextPanel), 'sentinel'); + }); +} diff --git a/test/builtin/markdown/markdown_viewer_test.dart b/test/builtin/markdown/markdown_viewer_test.dart index e38ad4a0..7bbfe3a4 100644 --- a/test/builtin/markdown/markdown_viewer_test.dart +++ b/test/builtin/markdown/markdown_viewer_test.dart @@ -391,4 +391,44 @@ void main() { expect(editorOpenArgs.first['path'], path); }); }); + + // T-36 / D-50 behavior 4: live-sync read-mirror of the open editor buffer. + group('MarkdownViewer — live-sync mirror (T-36)', () { + DaemonEvent opened(String id, String path, String content) => + DaemonEvent(subsystem: 'editor', kind: 'editor.opened', data: {'id': id, 'path': path, 'content': content}, ts: DateTime.now().toUtc()); + DaemonEvent edited(String id) => + DaemonEvent(subsystem: 'editor', kind: 'editor.edited', data: {'id': id, 'kind': 'replace', 'length': 0}, ts: DateTime.now().toUtc()); + + bool editVisible() => find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Edit in editor').evaluate().isNotEmpty; + + testWidgets('mirrors the buffer on editor.opened, and the mirror is read-only', (tester) async { + await pumpView(tester, f); + f.services.events.emit(opened('b1', 'notes.md', '# Live mirror\n')); + await pumpAsync(tester); + expect(find.textContaining('Live mirror'), findsWidgets); + expect(editVisible(), isFalse, reason: 'no edit affordance while mirroring the live buffer'); + }); + + testWidgets('re-reads the buffer on editor.edited and re-renders', (tester) async { + var content = '# First\n'; + f.ipc.stub('editor.read', (args) async => _ok({'id': 'b1', 'path': 'notes.md', 'content': content})); + await pumpView(tester, f); + f.services.events.emit(opened('b1', 'notes.md', content)); + await pumpAsync(tester); + expect(find.textContaining('First'), findsWidgets); + + content = '# Second\n'; // the user typed in the editor + f.services.events.emit(edited('b1')); + await pumpAsync(tester); + expect(find.textContaining('Second'), findsWidgets); + expect(find.textContaining('First'), findsNothing); + }); + + testWidgets('a non-renderable editor.opened does not mirror (D-50 behavior 5)', (tester) async { + await pumpView(tester, f); + f.services.events.emit(opened('b9', 'main.py', 'print(1)')); + await pumpAsync(tester); + expect(find.textContaining('print'), findsNothing, reason: 'non-renderable file gets no auto-mirror'); + }); + }); }