Files
clide/lib/builtin/editor/src/editor_controller.dart
T
jpmschweitzerandClaude Opus 4.8 e54d5263e0 feat(vim): ex command-line overlay (:w :q :wq :x :e :N, ZZ) (T-407)
Under the Vim preset, `:` opens a transient one-line ex overlay running a
fixed v1 table; ZZ runs :wq directly. Completes the last built child of the
T-403 cross-pane vim layer (T-405 part 2 gt/gT still open).

- ExLineController + parseExCommand grammar + editor-targeted executors
  (lib/kernel/src/ex_line.dart); the overlay (lib/widgets/src/ex_line_overlay
  .dart) reuses the quick-open chrome, mounts in the root_shell Stack, and
  publishes the exline.open scope flag. Unknown commands flash + stay open;
  with no active buffer every command no-ops (2026-06-13 decision).
- :q closes the active tab via editor.close on its id — the registry promotes
  the next buffer and the split self-collapses on the last (2026-06-12
  decision); :w/:wq/:x/ZZ save (+close) the active buffer.
- :e <path> seeds quick-open (new QuickOpenController.open(seed:)); :N adds the
  editor.goto-line IPC/CLI verb (reuses _offsetForLine). Goto needs caret sync:
  EditorController now handles editor.selection-changed and the editor view
  moves the caret on a selection-only change.
- `:` and ZZ are typed intents; the editor matcher and PaneKeyNav now bubble
  unhandled typed intents to the app-root Actions, so they fire from any focus.
  vim.yaml binds `:`, ZZ (shift+z shift+z), and Esc-dismiss.

Tests: parser/controller/executors, editor.goto-line daemon tests,
selection-changed (controller + view), full overlay widget test. make test
green; analyze + format clean.

Also files T-441 (drop bold from the ticket-id card label) and T-442
(sub-agent renders as 3 cards instead of one bundle) under the T-276 UI epic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:44:48 +02:00

255 lines
8.4 KiB
Dart

/// Flutter-side mirror of the daemon's active-editor state.
///
/// Listens to `editor.*` events over IPC and tracks: the active
/// buffer's id/path/content/selection, and whether the buffer is
/// dirty. The widget layer consumes this via [ListenableBuilder].
///
/// User edits flow the other way — the widget calls into the
/// controller, which calls `editor.set-content` / `editor.save` on
/// the daemon. The daemon is the source of truth; the widget is a
/// reconciled view.
library;
import 'dart:async';
import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/src/editor/editor_settings.dart';
import 'package:flutter/foundation.dart';
/// Lightweight view of one open buffer for the tab strip — the
/// daemon's authoritative content lives behind [EditorController];
/// this is just what the tabs need to render.
typedef OpenBuffer = ({String id, String path, bool dirty});
class EditorController extends ChangeNotifier {
EditorController({required this.ipc, required DaemonBus events}) {
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
}
final DaemonClient ipc;
StreamSubscription<DaemonEvent>? _eventSub;
String? _activeId;
String? _activePath;
String _content = '';
Selection _selection = const Selection.collapsed(0);
bool _dirty = false;
String? _error;
EditorSettings _settings = EditorSettings.empty;
/// All open buffers, in daemon order, for the tab strip.
List<OpenBuffer> _buffers = const [];
bool _suppressNextRemoteEdit = false;
int _pendingLocalEdits = 0;
String? get activeId => _activeId;
String? get activePath => _activePath;
String get content => _content;
Selection get selection => _selection;
bool get dirty => _dirty;
String? get error => _error;
List<OpenBuffer> get buffers => _buffers;
/// Effective editor settings for the active buffer (T-29).
EditorSettings get settings => _settings;
/// On first mount we don't know what's already open. Ask the daemon
/// for the buffer list and the active buffer.
Future<void> hydrate() async {
await _refreshList();
final r = await ipc.request('editor.active');
if (!r.ok) {
_error = r.error?.message;
notifyListeners();
return;
}
final active = r.data['active'];
if (active is! Map) {
_activeId = null;
_activePath = null;
_content = '';
notifyListeners();
return;
}
final id = active['id']! as String;
await _loadBuffer(id);
}
/// Make [id] the active buffer. The daemon echoes an
/// `editor.active-changed` event which loads its content.
Future<void> activate(String id) async {
if (id == _activeId) return;
await ipc.request('editor.activate', args: {'id': id});
}
/// Close the buffer [id]. The daemon emits `editor.closed` (and an
/// `editor.active-changed` if it was the active one).
Future<void> closeBuffer(String id) async {
await ipc.request('editor.close', args: {'id': id});
}
/// Re-fetch the open-buffer list from the daemon (authoritative).
Future<void> _refreshList() async {
final r = await ipc.request('editor.list');
if (!r.ok) return;
final raw = r.data['buffers'];
if (raw is! List) return;
_buffers = [
for (final b in raw)
if (b is Map) (id: b['id']! as String, path: b['path']! as String, dirty: (b['dirty'] as bool?) ?? false),
];
notifyListeners();
}
void _markDirty(String id, bool dirty) {
var changed = false;
_buffers = [
for (final b in _buffers)
if (b.id == id && b.dirty != dirty)
(() {
changed = true;
return (id: b.id, path: b.path, dirty: dirty);
})()
else
b,
];
if (changed) notifyListeners();
}
Future<void> _loadBuffer(String id) async {
final r = await ipc.request('editor.read', args: {'id': id});
if (!r.ok) {
_error = r.error?.message;
notifyListeners();
return;
}
_activeId = r.data['id']! as String;
_activePath = r.data['path']! as String;
_settings = EditorSettings.fromJson(r.data['editorSettings']);
_content = (r.data['content'] as String?) ?? '';
final sel = r.data['selection'];
_selection = sel is Map ? Selection.fromJson(sel.cast<String, Object?>()) : const Selection.collapsed(0);
_dirty = (r.data['dirty'] as bool?) ?? false;
_error = null;
notifyListeners();
}
/// Called by the widget on every local text edit.
void pushLocalEdit({required String newContent, required Selection newSelection}) {
final id = _activeId;
if (id == null) return;
_content = newContent;
_selection = newSelection;
_dirty = true;
_markDirty(id, true);
notifyListeners();
// Mirror to daemon. Use editor.set-content for the first cut —
// it's coarse but simple and avoids diff computation. Future
// tuning: diff + editor.insert / editor.replace-selection for
// large buffers, so event broadcasts stay small.
_pendingLocalEdits++;
_suppressNextRemoteEdit = true;
ipc.request('editor.set-content', args: {'id': id, 'text': newContent, 'selection': newSelection.toJson()}).whenComplete(() => _pendingLocalEdits--);
}
Future<void> save() async {
final id = _activeId;
if (id == null) return;
await ipc.request('editor.save', args: {'id': id});
}
void _onEvent(DaemonEvent e) {
if (e.subsystem != 'editor') return;
switch (e.kind) {
case 'editor.opened':
// A new buffer joined the set — refresh the tab list, then
// load whatever the daemon now considers active.
unawaited(_refreshList());
final id = e.data['id'] as String?;
if (id == null) {
_clearActive();
} else if (id != _activeId) {
_loadBuffer(id);
}
case 'editor.active-changed':
final id = e.data['id'] as String?;
if (id == null) {
_clearActive();
} else if (id != _activeId) {
_loadBuffer(id);
}
case 'editor.edited':
// Our own set-content echoes back as editor.edited. Skip one
// bounce so we don't clobber the caret the user just moved.
if (_suppressNextRemoteEdit) {
_suppressNextRemoteEdit = false;
return;
}
// Remote edit (another client, or the CLI inserting bytes).
// Reload the authoritative buffer.
final id = e.data['id'] as String?;
if (id != null) _markDirty(id, true);
if (id != null && id == _activeId && _pendingLocalEdits == 0) {
_loadBuffer(id);
}
case 'editor.saved':
final id = e.data['id'] as String?;
if (id != null) _markDirty(id, false);
if (id == _activeId) {
_dirty = false;
notifyListeners();
}
case 'editor.selection-changed':
// An external setSelection moved the caret server-side (find-in-files
// line jump, ex-line `:N` goto — T-407). Mirror it onto the active
// buffer so the view's caret follows. Skipped while our own local edits
// are in flight — their echo already carries the authoritative caret.
final id = e.data['id'] as String?;
if (id != null && id == _activeId && _pendingLocalEdits == 0) {
final sel = e.data['selection'];
if (sel is Map) {
_selection = Selection.fromJson(sel.cast<String, Object?>());
notifyListeners();
}
}
case 'editor.settings-changed':
// A source (e.g. a saved .editorconfig) re-resolved the buffer's
// settings. Refresh the active buffer's copy so indent/ruler update.
if (e.data['id'] == _activeId) {
_settings = EditorSettings.fromJson(e.data['editorSettings']);
notifyListeners();
}
case 'editor.closed':
// A buffer left the set — refresh the tab list. If it was the
// active one the daemon promotes another and emits
// active-changed; reflect the cleared state in the meantime.
unawaited(_refreshList());
if (e.data['id'] == _activeId) {
_clearActive();
}
}
}
void _clearActive() {
_activeId = null;
_activePath = null;
_content = '';
_selection = const Selection.collapsed(0);
_dirty = false;
_settings = EditorSettings.empty;
notifyListeners();
}
@override
void dispose() {
_eventSub?.cancel();
_eventSub = null;
super.dispose();
}
}