diff --git a/CHANGELOG.md b/CHANGELOG.md index 38e2d735..77d60f7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,18 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit. ### Added +- Editor subsystem in the daemon (`lib/src/editor/`). `EditorBuffer` + holds path + content + cursor/selection + dirty flag; + `EditorRegistry` owns the open-buffer set, active-buffer tracking, + and file I/O. IPC verbs land alongside (`editor.open | active | + activate | list | read | insert | replace-selection | set-selection + | set-content | save | close`) with matching events (`editor.opened + | active-changed | selection-changed | edited | saved | closed`). + Omitting `id` on mutating verbs targets the active buffer so the + tier-2 CLI shortcuts (`clide insert "…"`, `clide replace-selection + "…"`) read naturally. 16 new core tests cover the lifecycle + + dispatcher round-trips. + - `builtin.claude` — Tier-1 stub upgraded to the real Claude pane per D-041. Contributes a primary `Claude` tab in the workspace slot that spawns `tmux new-session -A -s clide-claude- -- claude` via diff --git a/bin/clide.dart b/bin/clide.dart index 8ea75dcc..3a9f83e0 100644 --- a/bin/clide.dart +++ b/bin/clide.dart @@ -16,8 +16,10 @@ import 'package:clide/clide.dart'; // Daemon-only deep imports — these pull in dart:ffi (PTY) and // daemon-subsystem wiring that the Flutter app doesn't need and // can't compile for web. See lib/clide.dart for the barrel split. +import 'package:clide/src/daemon/editor_commands.dart'; import 'package:clide/src/daemon/files_commands.dart'; import 'package:clide/src/daemon/pane_commands.dart'; +import 'package:clide/src/editor/registry.dart' show EditorRegistry; import 'package:clide/src/panes/registry.dart'; Future main(List argv) async { @@ -85,6 +87,9 @@ Future _runDaemon(List args) async { final files = FilesService.atCwd(events: events); registerFilesCommands(dispatcher, files); + final editor = EditorRegistry(events: events, workspaceRoot: files.root); + registerEditorCommands(dispatcher, editor); + final stopping = Completer(); void shutdown(ProcessSignal sig) { if (!stopping.isCompleted) { @@ -99,6 +104,7 @@ Future _runDaemon(List args) async { await server.start(); await stopping.future; await registry.shutdown(); + await editor.shutdown(); await files.shutdown(); await server.stop(); exit(0); diff --git a/lib/clide.dart b/lib/clide.dart index f1ba13ed..cfe5a20d 100644 --- a/lib/clide.dart +++ b/lib/clide.dart @@ -17,6 +17,7 @@ library; // they're pure data types that both the app and the daemon reference. export 'src/daemon/dispatcher.dart'; +export 'src/editor/buffer.dart'; export 'src/files/ignore.dart'; export 'src/files/listing.dart' show FileEntry, listDir; export 'src/ipc/envelope.dart'; diff --git a/lib/src/daemon/editor_commands.dart b/lib/src/daemon/editor_commands.dart new file mode 100644 index 00000000..b953be8c --- /dev/null +++ b/lib/src/daemon/editor_commands.dart @@ -0,0 +1,164 @@ +/// Registers `editor.*` command handlers on a [DaemonDispatcher]. +/// +/// Verb list matches CLAUDE.md's tier-2 surface: +/// editor.open editor.active editor.activate editor.insert +/// editor.replace-selection editor.save editor.close editor.list +/// editor.read editor.set-selection editor.set-content +/// +/// Single-word CLI shortcuts (`clide open`, `clide active`, …) map +/// one-to-one onto these in `bin/clide.dart`. +library; + +import '../editor/registry.dart'; +import '../ipc/envelope.dart'; +import '../ipc/schema_v1.dart'; +import 'dispatcher.dart'; + +export '../editor/buffer.dart' show Selection; + +void registerEditorCommands(DaemonDispatcher d, EditorRegistry registry) { + d.register('editor.open', (req) => _open(req, registry)); + d.register('editor.active', (req) => _active(req, registry)); + d.register('editor.activate', (req) => _activate(req, registry)); + d.register('editor.list', (req) => _list(req, registry)); + d.register('editor.read', (req) => _read(req, registry)); + d.register('editor.insert', (req) => _insert(req, registry)); + d.register('editor.replace-selection', (req) => _replace(req, registry)); + d.register('editor.set-selection', (req) => _setSelection(req, registry)); + d.register('editor.set-content', (req) => _setContent(req, registry)); + d.register('editor.save', (req) => _save(req, registry)); + d.register('editor.close', (req) => _close(req, registry)); +} + +IpcResponse _userErr(String id, String msg, {String? hint}) => IpcResponse.err( + id: id, + error: IpcError( + code: IpcExitCode.userError, + kind: IpcErrorKind.userError, + message: msg, + hint: hint, + ), + ); + +IpcResponse _notFound(String id, String msg) => IpcResponse.err( + id: id, + error: IpcError( + code: IpcExitCode.notFound, + kind: IpcErrorKind.notFound, + message: msg, + ), + ); + +String? _resolveId(IpcRequest req, EditorRegistry r) { + final id = req.args['id'] as String?; + if (id != null) return id; + // CLI shortcut: omitting `id` means the active buffer. + return r.active?.id; +} + +Future _open(IpcRequest req, EditorRegistry r) async { + final path = req.args['path'] as String?; + if (path == null || path.isEmpty) { + return _userErr(req.id, 'path is required'); + } + try { + final buf = await r.open(path); + return IpcResponse.ok(id: req.id, data: buf.toJson()); + } catch (e) { + return IpcResponse.err( + id: req.id, + error: IpcError( + code: IpcExitCode.toolError, + kind: IpcErrorKind.toolError, + message: 'editor.open failed: $e', + ), + ); + } +} + +Future _active(IpcRequest req, EditorRegistry r) async { + final buf = r.active; + if (buf == null) { + return IpcResponse.ok(id: req.id, data: const {'active': null}); + } + return IpcResponse.ok(id: req.id, data: {'active': buf.toJson()}); +} + +Future _activate(IpcRequest req, EditorRegistry r) async { + final id = req.args['id'] as String?; + if (id == null) return _userErr(req.id, 'id is required'); + if (r.get(id) == null) return _notFound(req.id, 'no such buffer: $id'); + r.activate(id); + return IpcResponse.ok(id: req.id, data: {'active': id}); +} + +Future _list(IpcRequest req, EditorRegistry r) async { + return IpcResponse.ok( + id: req.id, + data: {'buffers': [for (final b in r.buffers) b.toJson()]}, + ); +} + +Future _read(IpcRequest req, EditorRegistry r) async { + final id = _resolveId(req, r); + if (id == null) return _notFound(req.id, 'no active buffer'); + final buf = r.get(id); + if (buf == null) return _notFound(req.id, 'no such buffer: $id'); + return IpcResponse.ok(id: req.id, data: buf.toFullJson()); +} + +Future _insert(IpcRequest req, EditorRegistry r) async { + final id = _resolveId(req, r); + if (id == null) return _notFound(req.id, 'no active buffer'); + if (r.get(id) == null) return _notFound(req.id, 'no such buffer: $id'); + final text = EditorRegistry.contentFromArgs(req.args); + r.insert(id, text); + return IpcResponse.ok(id: req.id, data: {'id': id, 'inserted': text.length}); +} + +Future _replace(IpcRequest req, EditorRegistry r) async { + final id = _resolveId(req, r); + if (id == null) return _notFound(req.id, 'no active buffer'); + if (r.get(id) == null) return _notFound(req.id, 'no such buffer: $id'); + final text = EditorRegistry.contentFromArgs(req.args); + r.replaceSelection(id, text); + return IpcResponse.ok(id: req.id, data: {'id': id, 'length': text.length}); +} + +Future _setSelection(IpcRequest req, EditorRegistry r) async { + final id = _resolveId(req, r); + if (id == null) return _notFound(req.id, 'no active buffer'); + if (r.get(id) == null) return _notFound(req.id, 'no such buffer: $id'); + final sel = EditorRegistry.selectionFromArgs(req.args['selection']); + r.setSelection(id, sel); + return IpcResponse.ok(id: req.id, data: {'id': id}); +} + +Future _setContent(IpcRequest req, EditorRegistry r) async { + final id = _resolveId(req, r); + if (id == null) return _notFound(req.id, 'no active buffer'); + if (r.get(id) == null) return _notFound(req.id, 'no such buffer: $id'); + final content = EditorRegistry.contentFromArgs(req.args); + final sel = req.args['selection'] == null + ? null + : EditorRegistry.selectionFromArgs(req.args['selection']); + r.setContent(id, content, selection: sel); + return IpcResponse.ok(id: req.id, data: {'id': id, 'length': content.length}); +} + +Future _save(IpcRequest req, EditorRegistry r) async { + final id = _resolveId(req, r); + if (id == null) return _notFound(req.id, 'no active buffer'); + final ok = await r.save(id); + if (!ok) return _notFound(req.id, 'no such buffer: $id'); + return IpcResponse.ok(id: req.id, data: {'id': id, 'saved': true}); +} + +Future _close(IpcRequest req, EditorRegistry r) async { + final id = req.args['id'] as String?; + if (id == null) return _userErr(req.id, 'id is required'); + if (r.get(id) == null) return _notFound(req.id, 'no such buffer: $id'); + r.close(id); + return IpcResponse.ok(id: req.id, data: {'id': id}); +} + diff --git a/lib/src/editor/buffer.dart b/lib/src/editor/buffer.dart new file mode 100644 index 00000000..546ef34d --- /dev/null +++ b/lib/src/editor/buffer.dart @@ -0,0 +1,85 @@ +/// A buffer in the daemon's editor model. +/// +/// Represents an open file: its on-disk path, the authoritative text +/// content, the UI's current cursor/selection, and whether it has +/// unsaved changes. Thin data class — the [EditorRegistry] owns the +/// transitions. +library; + +class Selection { + const Selection({required this.start, required this.end}); + + const Selection.collapsed(int offset) + : start = offset, + end = offset; + + final int start; + final int end; + + bool get isCollapsed => start == end; + int get length => end - start; + + Map toJson() => {'start': start, 'end': end}; + + factory Selection.fromJson(Map j) => Selection( + start: (j['start'] as num).toInt(), + end: (j['end'] as num).toInt(), + ); + + @override + bool operator ==(Object other) => + other is Selection && other.start == start && other.end == end; + + @override + int get hashCode => Object.hash(start, end); + + @override + String toString() => 'Selection($start-$end)'; +} + +class EditorBuffer { + EditorBuffer({ + required this.id, + required this.path, + required this.content, + Selection? selection, + this.dirty = false, + }) : selection = selection ?? const Selection.collapsed(0); + + /// Stable daemon-local id (`b_1`, `b_2`, …). + final String id; + + /// Repo-relative path. Path is the identity key for "reopening the + /// same file" — opening an already-open path returns the existing + /// buffer. + final String path; + + /// Authoritative text content. UI edits mutate this via IPC + /// (`editor.insert`, `editor.replace-selection`); the UI's local + /// copy reconciles to match. + String content; + + /// Cursor / selection. Offsets are byte offsets into [content] — + /// utf-8 characters that span multiple bytes count as multiple + /// offsets, same convention Flutter's `TextEditingValue` uses. + Selection selection; + + /// True after an edit landed that hasn't been persisted via + /// `editor.save` (or `files.save` in future). + bool dirty; + + Map toJson() => { + 'id': id, + 'path': path, + 'length': content.length, + 'selection': selection.toJson(), + 'dirty': dirty, + }; + + /// Full snapshot including [content] — for `editor.read` / tests / + /// anything that needs the text explicitly. + Map toFullJson() => { + ...toJson(), + 'content': content, + }; +} diff --git a/lib/src/editor/registry.dart b/lib/src/editor/registry.dart new file mode 100644 index 00000000..d5078631 --- /dev/null +++ b/lib/src/editor/registry.dart @@ -0,0 +1,230 @@ +/// [EditorRegistry] — daemon-side state for open editor buffers. +/// +/// Owns a set of [EditorBuffer]s keyed by id. At most one buffer is +/// `active` at a time — the UI tells the daemon which one it's +/// focused on via `editor.activate`. All state transitions emit +/// events through the [DaemonEventSink]. +library; + +import 'dart:convert'; +import 'dart:io'; + +import '../ipc/envelope.dart'; +import '../panes/event_sink.dart'; +import 'buffer.dart'; + +class EditorRegistry { + EditorRegistry({ + required this.events, + required this.workspaceRoot, + }); + + final DaemonEventSink events; + + /// Workspace root used to resolve repo-relative paths to disk. + final Directory workspaceRoot; + + final Map _buffers = {}; + final Map _pathToId = {}; // repo-rel path → id + int _nextId = 1; + String? _activeId; + + Iterable get buffers => _buffers.values; + EditorBuffer? get(String id) => _buffers[id]; + EditorBuffer? get active => + _activeId == null ? null : _buffers[_activeId!]; + + /// Open a file. If [path] is already open, returns the existing + /// buffer (no re-read from disk — the in-memory content is the + /// source of truth between save points). + Future open(String path) async { + final existing = _pathToId[path]; + if (existing != null) { + final buf = _buffers[existing]!; + _setActive(buf.id); + return buf; + } + + final absolute = _absolutePathOf(path); + final file = File(absolute); + String content = ''; + if (await file.exists()) { + content = await file.readAsString(); + } + + final id = 'b_${_nextId++}'; + final buf = EditorBuffer(id: id, path: path, content: content); + _buffers[id] = buf; + _pathToId[path] = id; + + _emit('editor.opened', { + ...buf.toJson(), + 'content': buf.content, // snapshot at open time + }); + _setActive(id); + return buf; + } + + /// Mark [id] as the active buffer. Idempotent. + void activate(String id) { + if (!_buffers.containsKey(id)) return; + _setActive(id); + } + + /// Insert [text] at the buffer's cursor (or replace the selection + /// if one exists). Advances the cursor past the inserted text. + void insert(String id, String text) { + final buf = _buffers[id]; + if (buf == null) return; + final sel = buf.selection; + final before = buf.content.substring(0, sel.start); + final after = buf.content.substring(sel.end); + buf.content = '$before$text$after'; + final newCaret = sel.start + text.length; + buf.selection = Selection.collapsed(newCaret); + buf.dirty = true; + _emit('editor.edited', { + 'id': id, + 'kind': 'insert', + 'inserted': text, + 'at': sel.start, + 'replaced': sel.length, + 'length': buf.content.length, + 'selection': buf.selection.toJson(), + }); + _emitSelection(buf); + } + + /// Replace the current selection (or insert at cursor if no + /// selection) with [text]. Same mechanic as [insert] — kept as a + /// named verb because the CLI surface exposes it separately per + /// CLAUDE.md's tier-2 list. + void replaceSelection(String id, String text) => insert(id, text); + + /// Update the UI's cursor / selection for [id]. Broadcasts so other + /// subscribers can mirror it. + void setSelection(String id, Selection sel) { + final buf = _buffers[id]; + if (buf == null) return; + final clamped = Selection( + start: sel.start.clamp(0, buf.content.length), + end: sel.end.clamp(0, buf.content.length), + ); + if (clamped.start == buf.selection.start && clamped.end == buf.selection.end) { + return; + } + buf.selection = clamped; + _emitSelection(buf); + } + + /// Overwrite [id]'s content (used when the UI owns authoritative + /// text — diff-style editor, paste, etc.) and reconcile the + /// registry's view. Emits a single `editor.edited` event with + /// kind='replace' so subscribers don't need to diff. + void setContent(String id, String content, {Selection? selection}) { + final buf = _buffers[id]; + if (buf == null) return; + buf.content = content; + if (selection != null) { + buf.selection = Selection( + start: selection.start.clamp(0, content.length), + end: selection.end.clamp(0, content.length), + ); + } else { + buf.selection = Selection( + start: buf.selection.start.clamp(0, content.length), + end: buf.selection.end.clamp(0, content.length), + ); + } + buf.dirty = true; + _emit('editor.edited', { + 'id': id, + 'kind': 'replace', + 'length': content.length, + 'selection': buf.selection.toJson(), + }); + } + + /// Persist [id] to disk. Clears the dirty flag on success. + Future save(String id) async { + final buf = _buffers[id]; + if (buf == null) return false; + final absolute = _absolutePathOf(buf.path); + await File(absolute).writeAsString(buf.content); + buf.dirty = false; + _emit('editor.saved', {'id': id, 'path': buf.path}); + return true; + } + + /// Close a buffer. Idempotent. + void close(String id) { + final buf = _buffers.remove(id); + if (buf == null) return; + _pathToId.remove(buf.path); + if (_activeId == id) { + _activeId = _buffers.values.isEmpty ? null : _buffers.values.first.id; + if (_activeId != null) _emitActive(); + } + _emit('editor.closed', {'id': id, 'path': buf.path}); + } + + Future shutdown() async { + _buffers.clear(); + _pathToId.clear(); + _activeId = null; + } + + // ----------------------------------------------------------------- + + void _setActive(String id) { + if (_activeId == id) return; + _activeId = id; + _emitActive(); + } + + void _emitActive() { + final buf = active; + _emit('editor.active-changed', { + 'id': buf?.id, + 'path': buf?.path, + }); + } + + void _emitSelection(EditorBuffer buf) { + _emit('editor.selection-changed', { + 'id': buf.id, + 'selection': buf.selection.toJson(), + }); + } + + void _emit(String kind, Map data) { + events.emit(IpcEvent( + subsystem: 'editor', + kind: kind, + timestamp: DateTime.now().toUtc(), + data: data, + )); + } + + String _absolutePathOf(String repoRelative) { + if (repoRelative.startsWith('/')) return repoRelative; + final sep = Platform.pathSeparator; + return '${workspaceRoot.absolute.path}$sep${repoRelative.replaceAll('/', sep)}'; + } + + // Support JSON decode of Selection from IPC args. + static Selection selectionFromArgs(Object? raw) { + if (raw is! Map) return const Selection.collapsed(0); + return Selection.fromJson(raw.cast()); + } + + // Support JSON decode of content payloads (base64 for binary safety + // or plain text). + static String contentFromArgs(Map args) { + final text = args['text']; + if (text is String) return text; + final b64 = args['content_b64']; + if (b64 is String) return utf8.decode(base64Decode(b64)); + return ''; + } +} diff --git a/test/daemon/editor_commands_test.dart b/test/daemon/editor_commands_test.dart new file mode 100644 index 00000000..f3660ced --- /dev/null +++ b/test/daemon/editor_commands_test.dart @@ -0,0 +1,110 @@ +import 'dart:io'; + +import 'package:clide/clide.dart'; +import 'package:clide/src/daemon/editor_commands.dart'; +import 'package:clide/src/editor/registry.dart'; +import 'package:test/test.dart'; + +void main() { + late Directory sandbox; + late DaemonDispatcher dispatcher; + late EditorRegistry reg; + + setUp(() async { + sandbox = await Directory.systemTemp.createTemp('clide-ed-cmd-test-'); + await File('${sandbox.path}/doc.md').writeAsString('alpha beta'); + final sink = RecordingEventSink(); + reg = EditorRegistry(events: sink, workspaceRoot: sandbox); + dispatcher = DaemonDispatcher(); + registerEditorCommands(dispatcher, reg); + }); + + tearDown(() async { + await reg.shutdown(); + if (sandbox.existsSync()) sandbox.deleteSync(recursive: true); + }); + + Future call(String cmd, [Map args = const {}]) { + return dispatcher.dispatch(IpcRequest(id: '1', cmd: cmd, args: args)); + } + + test('editor.open requires a path', () async { + final r = await call('editor.open'); + expect(r.ok, isFalse); + expect(r.error!.kind, 'user_error'); + }); + + test('editor.open + editor.active round-trip', () async { + final open = await call('editor.open', {'path': 'doc.md'}); + expect(open.ok, isTrue); + final id = open.data['id']! as String; + expect(id, startsWith('b_')); + + final active = await call('editor.active'); + expect(active.ok, isTrue); + final act = active.data['active']! as Map; + expect(act['id'], id); + expect(act['path'], 'doc.md'); + }); + + test('editor.insert without id targets the active buffer', () async { + await call('editor.open', {'path': 'doc.md'}); + final r = await call('editor.insert', {'text': 'X '}); + expect(r.ok, isTrue); + expect(r.data['inserted'], 2); + + final read = await call('editor.read'); + expect((read.data['content'] as String).startsWith('X '), isTrue); + }); + + test('editor.replace-selection swaps selected range', () async { + final open = await call('editor.open', {'path': 'doc.md'}); + final id = open.data['id'] as String?; + + await call('editor.set-selection', { + 'id': id, + 'selection': {'start': 0, 'end': 5}, // 'alpha' + }); + final r = await call('editor.replace-selection', {'text': 'gamma'}); + expect(r.ok, isTrue); + + final read = await call('editor.read'); + expect(read.data['content'], 'gamma beta'); + }); + + test('editor.save persists to disk', () async { + await call('editor.open', {'path': 'doc.md'}); + await call('editor.insert', {'text': 'Z '}); + final save = await call('editor.save'); + expect(save.ok, isTrue); + final disk = await File('${sandbox.path}/doc.md').readAsString(); + expect(disk.startsWith('Z '), isTrue); + }); + + test('editor.close removes the buffer', () async { + final open = await call('editor.open', {'path': 'doc.md'}); + final id = open.data['id'] as String?; + final r = await call('editor.close', {'id': id}); + expect(r.ok, isTrue); + final list = await call('editor.list'); + expect((list.data['buffers'] as List), isEmpty); + }); + + test('editor.list includes all open buffers', () async { + await File('${sandbox.path}/a.md').writeAsString('a'); + await File('${sandbox.path}/b.md').writeAsString('b'); + await call('editor.open', {'path': 'a.md'}); + await call('editor.open', {'path': 'b.md'}); + final r = await call('editor.list'); + final names = [ + for (final b in (r.data['buffers'] as List).cast()) b['path'], + ]; + expect(names, containsAll(['a.md', 'b.md'])); + }); + + test('insert on unknown id returns not-found', () async { + final r = await call('editor.insert', {'id': 'b_404', 'text': 'x'}); + expect(r.ok, isFalse); + expect(r.error!.code, IpcExitCode.notFound); + }); +} diff --git a/test/editor/registry_test.dart b/test/editor/registry_test.dart new file mode 100644 index 00000000..1861ab6f --- /dev/null +++ b/test/editor/registry_test.dart @@ -0,0 +1,97 @@ +import 'dart:io'; + +import 'package:clide/clide.dart'; +import 'package:clide/src/editor/registry.dart'; +import 'package:test/test.dart'; + +void main() { + late Directory sandbox; + late RecordingEventSink sink; + late EditorRegistry reg; + + setUp(() async { + sandbox = await Directory.systemTemp.createTemp('clide-editor-test-'); + await File('${sandbox.path}/README.md').writeAsString('# Hello\n\nbody\n'); + sink = RecordingEventSink(); + reg = EditorRegistry(events: sink, workspaceRoot: sandbox); + }); + + tearDown(() async { + await reg.shutdown(); + if (sandbox.existsSync()) sandbox.deleteSync(recursive: true); + }); + + test('open loads file content + emits editor.opened + active-changed', () async { + final buf = await reg.open('README.md'); + expect(buf.content, contains('Hello')); + expect(buf.dirty, isFalse); + expect(reg.active, same(buf)); + expect(sink.ofKind('editor.opened'), hasLength(1)); + expect(sink.ofKind('editor.active-changed'), hasLength(1)); + }); + + test('opening the same path returns the existing buffer', () async { + final a = await reg.open('README.md'); + final b = await reg.open('README.md'); + expect(b.id, a.id); + // Still only one open event — re-open is an activate, not a reload. + expect(sink.ofKind('editor.opened'), hasLength(1)); + }); + + test('insert at caret appends + advances cursor', () async { + final buf = await reg.open('README.md'); + // caret at 0 + reg.insert(buf.id, 'PREFIX '); + expect(buf.content.startsWith('PREFIX '), isTrue); + expect(buf.selection.isCollapsed, isTrue); + expect(buf.selection.start, 'PREFIX '.length); + expect(buf.dirty, isTrue); + expect(sink.ofKind('editor.edited'), hasLength(1)); + }); + + test('replace-selection swaps selected text + resets cursor', () async { + final buf = await reg.open('README.md'); + reg.setSelection(buf.id, const Selection(start: 2, end: 7)); // 'Hello' + reg.replaceSelection(buf.id, 'WORLD'); + expect(buf.content.substring(2, 7), 'WORLD'); + expect(buf.selection, const Selection(start: 7, end: 7)); + }); + + test('set-selection clamps out-of-range offsets', () async { + final buf = await reg.open('README.md'); + reg.setSelection(buf.id, const Selection(start: -5, end: 99999)); + expect(buf.selection.start, 0); + expect(buf.selection.end, buf.content.length); + }); + + test('save writes the content back + clears dirty', () async { + final buf = await reg.open('README.md'); + reg.insert(buf.id, 'X'); + expect(buf.dirty, isTrue); + final ok = await reg.save(buf.id); + expect(ok, isTrue); + expect(buf.dirty, isFalse); + final onDisk = await File('${sandbox.path}/README.md').readAsString(); + expect(onDisk.startsWith('X'), isTrue); + expect(sink.ofKind('editor.saved'), hasLength(1)); + }); + + test('close picks a new active buffer when the active one closes', + () async { + final a = await reg.open('README.md'); + await File('${sandbox.path}/b.txt').writeAsString('two'); + final b = await reg.open('b.txt'); + expect(reg.active, same(b)); + reg.close(b.id); + expect(reg.active, same(a)); + expect(sink.ofKind('editor.closed'), hasLength(1)); + // Active changed at least twice: a→b (on open), b→a (after close) + expect(sink.ofKind('editor.active-changed').length, greaterThanOrEqualTo(2)); + }); + + test('opening a non-existent path creates an empty buffer', () async { + final buf = await reg.open('NEW.md'); + expect(buf.content, isEmpty); + expect(buf.dirty, isFalse); + }); +}