Files
clide/lib/src/editor/registry.dart
T
jpmschweitzerandClaude Opus 4.8 d4b39e430a route editor config through a source-agnostic EditorSettings model (T-29)
Introduce EditorSettings as the effective, source-agnostic editor configuration
the editor and save path obey. .editorconfig is demoted to one *source*
(editorconfig.dart now returns EditorSettings); editor_settings_resolver.dart is
the single composition seam where future sources — a settings panel, a clide
settings file — layer in via merge() without the editor changing.

The registry resolves settings on buffer load and, when a .editorconfig is saved
in-app, re-resolves every open buffer and emits editor.settings-changed (a hook
in save, not a filesystem watcher — the realistic case, cheaply). Buffer JSON
carries editorSettings. 100% line coverage on the new model + resolver.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 09:15:49 +02:00

282 lines
8.7 KiB
Dart

/// [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';
import 'editor_settings_resolver.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,
settings: resolveEditorSettings(workspaceRoot, path),
);
_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. Applies the buffer's on-save settings (EOL,
/// trailing-whitespace, final-newline) first, and — when those changed the
/// text — reconciles the in-memory buffer + UI so disk and buffer agree.
/// Clears the dirty flag on success. Saving a `.editorconfig` re-resolves the
/// settings of every open buffer (its rules just changed).
Future<bool> save(String id) async {
final buf = _buffers[id];
if (buf == null) return false;
final normalized = buf.settings.applyOnSave(buf.content);
final changed = normalized != buf.content;
final absolute = _absolutePathOf(buf.path);
await File(absolute).writeAsString(normalized);
if (changed) {
buf.content = normalized;
buf.selection = Selection(
start: buf.selection.start.clamp(0, normalized.length),
end: buf.selection.end.clamp(0, normalized.length),
);
// Re-broadcast so the UI reloads the normalized text (the editor.edited
// handler re-reads the buffer); emitted before editor.saved clears dirty.
buf.dirty = false;
_emit('editor.edited', {
'id': id,
'kind': 'replace',
'length': normalized.length,
'selection': buf.selection.toJson(),
});
}
buf.dirty = false;
_emit('editor.saved', {'id': id, 'path': buf.path});
if (_isEditorConfigPath(buf.path)) _reresolveSettings();
return true;
}
/// Recompute every open buffer's effective settings from its sources and tell
/// the UI about the ones that changed. Called when a `.editorconfig` is saved
/// in-app (the file's rules changed under the open buffers).
void _reresolveSettings() {
for (final buf in _buffers.values) {
final next = resolveEditorSettings(workspaceRoot, buf.path);
if (next.toJson().toString() == buf.settings.toJson().toString()) continue;
buf.settings = next;
_emit('editor.settings-changed', {
'id': buf.id,
'path': buf.path,
'editorSettings': next.toJson(),
});
}
}
bool _isEditorConfigPath(String path) => path == '.editorconfig' || path.endsWith('/.editorconfig');
/// 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 '';
}
}