add multi-file editor tabs

The editor pane showed a single buffer — opening a second file
replaced the first, even though the daemon's EditorRegistry has
always been multi-buffer (editor.list / activate / close). This wires
the UI up to that: EditorController now tracks the full open-buffer
list (via editor.list on hydrate, kept in sync by editor.opened /
closed / saved / edited events), and EditorView renders the buffers
as tabs through the shared MultitabPane — the same strip the Claude
pane uses. The daemon stays the source of truth: the local tab
controller is reconciled from it, and tab select / close route back
as editor.activate / editor.close. Reorder is disabled for now
(daemon order is authoritative).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-22 12:24:35 +02:00
co-authored by Claude
parent fd72940d2d
commit f1f12e7d79
5 changed files with 513 additions and 45 deletions
+87 -14
View File
@@ -16,6 +16,11 @@ import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.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);
@@ -32,6 +37,9 @@ class EditorController extends ChangeNotifier {
bool _dirty = false;
String? _error;
/// All open buffers, in daemon order, for the tab strip.
List<OpenBuffer> _buffers = const [];
bool _suppressNextRemoteEdit = false;
int _pendingLocalEdits = 0;
@@ -41,10 +49,12 @@ class EditorController extends ChangeNotifier {
Selection get selection => _selection;
bool get dirty => _dirty;
String? get error => _error;
List<OpenBuffer> get buffers => _buffers;
/// On first mount we don't know what (if anything) is already
/// active. Ask the daemon.
/// 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;
@@ -63,6 +73,52 @@ class EditorController extends ChangeNotifier {
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) {
@@ -91,6 +147,7 @@ class EditorController extends ChangeNotifier {
_content = newContent;
_selection = newSelection;
_dirty = true;
_markDirty(id, true);
notifyListeners();
// Mirror to daemon. Use editor.set-content for the first cut —
@@ -116,15 +173,19 @@ class EditorController extends ChangeNotifier {
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) {
_activeId = null;
_activePath = null;
_content = '';
_selection = const Selection.collapsed(0);
_dirty = false;
notifyListeners();
_clearActive();
} else if (id != _activeId) {
_loadBuffer(id);
}
@@ -138,25 +199,37 @@ class EditorController extends ChangeNotifier {
// 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':
if (e.data['id'] == _activeId) {
final id = e.data['id'] as String?;
if (id != null) _markDirty(id, false);
if (id == _activeId) {
_dirty = false;
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) {
_activeId = null;
_activePath = null;
_content = '';
_dirty = false;
notifyListeners();
_clearActive();
}
}
}
void _clearActive() {
_activeId = null;
_activePath = null;
_content = '';
_selection = const Selection.collapsed(0);
_dirty = false;
notifyListeners();
}
@override
void dispose() {
_eventSub?.cancel();