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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user