feat(markdown): live-sync read-mirror of the open editor buffer (T-36)

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 11:08:31 +02:00
co-authored by Claude Opus 4.8
parent 84f4b20cb8
commit 095c45a023
7 changed files with 207 additions and 3 deletions
@@ -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');
});
}
@@ -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');
});
});
}