add editor subsystem — daemon state + editor.* IPC
EditorBuffer + Selection + EditorRegistry hold the daemon-side active-file model (D-006 subsystem 'editor'). Active buffer tracking means `clide insert "…"` and `clide replace-selection "…"` target the UI's focused file without the caller supplying an id. Mutations mark buffers dirty; editor.save writes back to disk through the workspace root; events fire on every state change so subscribers can mirror. IPC surface matches CLAUDE.md's tier-2 list + the natural extras (list, read, activate, set-selection, set-content, close). Tests cover open-idempotence, insert at caret, replace-selection range swap, dirty→save→clean round-trip, close picks a new active buffer, out-of-range selection clamping. 69 core tests pass. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,18 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
### Added
|
### 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
|
- `builtin.claude` — Tier-1 stub upgraded to the real Claude pane per
|
||||||
D-041. Contributes a primary `Claude` tab in the workspace slot that
|
D-041. Contributes a primary `Claude` tab in the workspace slot that
|
||||||
spawns `tmux new-session -A -s clide-claude-<hash> -- claude` via
|
spawns `tmux new-session -A -s clide-claude-<hash> -- claude` via
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ import 'package:clide/clide.dart';
|
|||||||
// Daemon-only deep imports — these pull in dart:ffi (PTY) and
|
// Daemon-only deep imports — these pull in dart:ffi (PTY) and
|
||||||
// daemon-subsystem wiring that the Flutter app doesn't need 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.
|
// 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/files_commands.dart';
|
||||||
import 'package:clide/src/daemon/pane_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';
|
import 'package:clide/src/panes/registry.dart';
|
||||||
|
|
||||||
Future<void> main(List<String> argv) async {
|
Future<void> main(List<String> argv) async {
|
||||||
@@ -85,6 +87,9 @@ Future<void> _runDaemon(List<String> args) async {
|
|||||||
final files = FilesService.atCwd(events: events);
|
final files = FilesService.atCwd(events: events);
|
||||||
registerFilesCommands(dispatcher, files);
|
registerFilesCommands(dispatcher, files);
|
||||||
|
|
||||||
|
final editor = EditorRegistry(events: events, workspaceRoot: files.root);
|
||||||
|
registerEditorCommands(dispatcher, editor);
|
||||||
|
|
||||||
final stopping = Completer<void>();
|
final stopping = Completer<void>();
|
||||||
void shutdown(ProcessSignal sig) {
|
void shutdown(ProcessSignal sig) {
|
||||||
if (!stopping.isCompleted) {
|
if (!stopping.isCompleted) {
|
||||||
@@ -99,6 +104,7 @@ Future<void> _runDaemon(List<String> args) async {
|
|||||||
await server.start();
|
await server.start();
|
||||||
await stopping.future;
|
await stopping.future;
|
||||||
await registry.shutdown();
|
await registry.shutdown();
|
||||||
|
await editor.shutdown();
|
||||||
await files.shutdown();
|
await files.shutdown();
|
||||||
await server.stop();
|
await server.stop();
|
||||||
exit(0);
|
exit(0);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ library;
|
|||||||
// they're pure data types that both the app and the daemon reference.
|
// they're pure data types that both the app and the daemon reference.
|
||||||
|
|
||||||
export 'src/daemon/dispatcher.dart';
|
export 'src/daemon/dispatcher.dart';
|
||||||
|
export 'src/editor/buffer.dart';
|
||||||
export 'src/files/ignore.dart';
|
export 'src/files/ignore.dart';
|
||||||
export 'src/files/listing.dart' show FileEntry, listDir;
|
export 'src/files/listing.dart' show FileEntry, listDir;
|
||||||
export 'src/ipc/envelope.dart';
|
export 'src/ipc/envelope.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<IpcResponse> _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<IpcResponse> _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<IpcResponse> _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<IpcResponse> _list(IpcRequest req, EditorRegistry r) async {
|
||||||
|
return IpcResponse.ok(
|
||||||
|
id: req.id,
|
||||||
|
data: {'buffers': [for (final b in r.buffers) b.toJson()]},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<IpcResponse> _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<IpcResponse> _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<IpcResponse> _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<IpcResponse> _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<IpcResponse> _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<IpcResponse> _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<IpcResponse> _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});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<String, Object?> toJson() => {'start': start, 'end': end};
|
||||||
|
|
||||||
|
factory Selection.fromJson(Map<String, Object?> 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<String, Object?> 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<String, Object?> toFullJson() => {
|
||||||
|
...toJson(),
|
||||||
|
'content': content,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<String, EditorBuffer> _buffers = {};
|
||||||
|
final Map<String, String> _pathToId = {}; // repo-rel path → id
|
||||||
|
int _nextId = 1;
|
||||||
|
String? _activeId;
|
||||||
|
|
||||||
|
Iterable<EditorBuffer> 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<EditorBuffer> 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<bool> 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<void> 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<String, Object?> 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<String, Object?>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Support JSON decode of content payloads (base64 for binary safety
|
||||||
|
// or plain text).
|
||||||
|
static String contentFromArgs(Map<String, Object?> 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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IpcResponse> call(String cmd, [Map<String, Object?> 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<Map>()) 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user