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
+15 -1
View File
@@ -16,6 +16,7 @@ class MarkdownExtension extends ClideExtension {
List<String> get dependsOn => const [];
StreamSubscription<Message>? _sub;
StreamSubscription<DaemonEvent>? _editorSub;
@override
List<ContributionPoint> 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<DaemonEvent>().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<void> deactivate() async => _sub?.cancel();
Future<void> deactivate() async {
await _sub?.cancel();
await _editorSub?.cancel();
}
}
+78 -2
View File
@@ -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<MarkdownViewer> {
String? _content;
String? _error;
StreamSubscription<Message>? _selectionSub;
StreamSubscription<DaemonEvent>? _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<MarkdownViewer> {
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<DaemonEvent>().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<void> _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<void> _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<MarkdownViewer> {
@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<void> _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<MarkdownViewer> {
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<MarkdownViewer> {
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(