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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user