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
+5
View File
@@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
- Multi-file editor tabs — the editor pane now shows one tab per open
buffer (filename + a dot when unsaved) via the shared tab strip;
opening a second file no longer replaces the first. Click a tab to
switch, × to close. Backed by the daemon's existing multi-buffer
model.
- Typed IPC command-schema framework (T-119/T-120, D-74) — commands
register an argument schema beside their handler; the dispatcher
normalises argv into named args, coerces types, and validates
+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();
+80 -31
View File
@@ -10,10 +10,13 @@ import 'package:flutter/widgets.dart';
import 'editor_controller.dart';
import 'syntax_text_controller.dart';
/// Tier-2 editor tab. One tab — the content reflects the daemon's
/// active buffer. Multi-file tabs live in the workspace-slot plan but
/// aren't in Tier 2's scope; opening a new file swaps this view's
/// content.
/// Tier-2 editor pane. Shows one tab per open buffer via the shared
/// [MultitabPane] (the same strip the Claude pane uses); the body
/// reflects the daemon's active buffer. The daemon ([EditorRegistry])
/// is the source of truth for which buffers are open and which is
/// active — the local [MultitabController] is reconciled from it, and
/// tab gestures (select / close) are routed back as `editor.activate`
/// / `editor.close`.
///
/// Uses Flutter's low-level `EditableText` so we stay off Material
/// per D-007. Owning more of the editor stack (line numbers, gutter,
@@ -27,17 +30,24 @@ class EditorView extends StatefulWidget {
class _EditorViewState extends State<EditorView> {
EditorController? _controller;
final MultitabController<String> _tabs = MultitabController<String>();
final TreeSitterService _syntax = TreeSitterService.shared;
late final SyntaxTextController _text;
late final FocusNode _focus;
String? _lastRemoteContent;
/// Guards the controller→tabstrip reconcile so the tabstrip's own
/// change notifications (from us mutating it) don't bounce back as
/// daemon calls.
bool _applyingRemote = false;
@override
void initState() {
super.initState();
_text = SyntaxTextController(syntax: _syntax);
_focus = FocusNode();
_text.addListener(_onTextChanged);
_tabs.addListener(_onTabsChanged);
}
@override
@@ -54,6 +64,8 @@ class _EditorViewState extends State<EditorView> {
_text.removeListener(_onTextChanged);
_text.dispose();
_focus.dispose();
_tabs.removeListener(_onTabsChanged);
_tabs.dispose();
_controller?.removeListener(_onControllerChanged);
_controller?.dispose();
super.dispose();
@@ -61,6 +73,7 @@ class _EditorViewState extends State<EditorView> {
void _onControllerChanged() {
final c = _controller!;
_syncTabs(c);
_text.updatePath(c.activePath);
if (c.content != _lastRemoteContent) {
_lastRemoteContent = c.content;
@@ -72,7 +85,46 @@ class _EditorViewState extends State<EditorView> {
_text.value = TextEditingValue(text: c.content, selection: sel);
_text.addListener(_onTextChanged);
}
setState(() {}); // subtitle refresh
setState(() {}); // tab/title refresh
}
/// Reconcile the local tab strip to match the daemon's open-buffer
/// list + active selection. Membership and order follow the daemon;
/// titles carry a dirty marker.
void _syncTabs(EditorController c) {
_applyingRemote = true;
final bufs = c.buffers;
final liveIds = {for (final b in bufs) b.id};
for (final e in _tabs.entries) {
if (!liveIds.contains(e.id)) _tabs.remove(e.id);
}
for (final b in bufs) {
final title = _tabTitle(b);
final existing = _tabs.entries.where((e) => e.id == b.id).toList();
if (existing.isEmpty) {
_tabs.add(MultitabEntry<String>(id: b.id, title: title, payload: b.id), activate: false);
} else if (existing.first.title != title) {
_tabs.replace(b.id, MultitabEntry<String>(id: b.id, title: title, payload: b.id));
}
}
final act = c.activeId;
if (act != null && _tabs.activeId != act) _tabs.activate(act);
_applyingRemote = false;
}
/// User tapped a tab. The tab strip already updated its local active
/// selection; mirror that choice to the daemon.
void _onTabsChanged() {
if (_applyingRemote) return;
final id = _tabs.activeId;
if (id != null && id != _controller?.activeId) {
unawaited(_controller?.activate(id) ?? Future.value());
}
}
String _tabTitle(OpenBuffer b) {
final name = b.path.split('/').last;
return b.dirty ? '$name' : name;
}
void _onTextChanged() {
@@ -112,32 +164,29 @@ class _EditorViewState extends State<EditorView> {
return ListenableBuilder(
listenable: c,
builder: (context, _) {
final title = c.activePath ?? 'editor';
final subtitle = c.activeId == null
? 'no buffer · use `clide open <path>` or pick a file in the tree'
: '${c.activeId} · ${c.dirty ? 'modified' : 'saved'}'
'${c.error == null ? '' : ' · ${c.error}'}';
return ClidePaneChrome(
title: title,
subtitle: subtitle,
child: c.activeId == null
? const Center(
child: ClideText(
'Open a file to begin editing.',
muted: true,
),
)
: Focus(
onKeyEvent: _onKey,
child: _TextBody(
controller: _text,
focus: _focus,
background: tokens.panelBackground,
foreground: tokens.globalForeground,
accent: tokens.globalFocus,
),
),
if (c.buffers.isEmpty) {
return ClidePaneChrome(
title: 'editor',
subtitle: 'no buffer · use `clide open <path>` or pick a file in the tree',
child: const Center(
child: ClideText('Open a file to begin editing.', muted: true),
),
);
}
return MultitabPane<String>(
controller: _tabs,
allowReorder: false,
onCloseRequested: (entry) => unawaited(c.closeBuffer(entry.id)),
bodyBuilder: (context, _) => Focus(
onKeyEvent: _onKey,
child: _TextBody(
controller: _text,
focus: _focus,
background: tokens.panelBackground,
foreground: tokens.globalForeground,
accent: tokens.globalFocus,
),
),
);
},
);
@@ -0,0 +1,255 @@
/// Tests the multi-buffer tracking in EditorController (the UI mirror
/// of the daemon's editor model): hydrate populates the open-buffer
/// list, events keep it in sync, and activate/close route to IPC.
library;
import 'package:clide/builtin/editor/src/editor_controller.dart';
import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/fake_ipc.dart';
IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data);
Map<String, Object?> _buf(String id, String path, {bool dirty = false}) => {'id': id, 'path': path, 'dirty': dirty};
Map<String, Object?> _read(String id, String path, String content, {bool dirty = false}) => {
'id': id,
'path': path,
'content': content,
'selection': {'start': 0, 'end': 0},
'dirty': dirty,
};
void emitEditor(DaemonBus bus, String kind, Map<String, Object?> data) {
bus.emit(DaemonEvent(subsystem: 'editor', kind: kind, data: data, ts: DateTime.now().toUtc()));
}
void main() {
late DaemonBus bus;
late FakeDaemonClient ipc;
late EditorController c;
setUp(() {
bus = DaemonBus();
ipc = FakeDaemonClient(log: Logger(), events: bus);
c = EditorController(ipc: ipc, events: bus);
});
tearDown(() async {
c.dispose();
await bus.dispose();
});
group('hydrate', () {
test('populates the open-buffer list and loads the active buffer', () async {
ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'lib/a.dart'), _buf('b_2', 'lib/b.dart', dirty: true)]
}));
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'}
}));
ipc.stub('editor.read', (a) async => _ok(_read('b_1', 'lib/a.dart', 'hello')));
await c.hydrate();
expect(c.buffers.map((b) => b.id).toList(), ['b_1', 'b_2']);
expect(c.buffers[1].dirty, isTrue);
expect(c.activeId, 'b_1');
expect(c.content, 'hello');
});
test('with no active buffer leaves the list but no active content', () async {
ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'lib/a.dart')]
}));
ipc.stub('editor.active', (_) async => _ok(const {})); // no `active` key
await c.hydrate();
expect(c.buffers, hasLength(1));
expect(c.activeId, isNull);
expect(c.content, '');
});
});
group('routing tab gestures to IPC', () {
test('activate(id) issues editor.activate', () async {
String? activated;
ipc.stub('editor.activate', (a) async {
activated = a['id'] as String?;
return _ok({'active': a['id']});
});
await c.activate('b_7');
expect(activated, 'b_7');
});
test('activate is a no-op when the id is already active', () async {
ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart')]
}));
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'}
}));
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'x')));
await c.hydrate();
var calls = 0;
ipc.stub('editor.activate', (a) async {
calls++;
return _ok({'active': a['id']});
});
await c.activate('b_1'); // already active
expect(calls, 0);
});
test('closeBuffer(id) issues editor.close', () async {
String? closed;
ipc.stub('editor.close', (a) async {
closed = a['id'] as String?;
return _ok(const {});
});
await c.closeBuffer('b_3');
expect(closed, 'b_3');
});
});
group('event sync', () {
test('editor.opened refreshes the list and loads the new active buffer', () async {
var listVersion = 1;
ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': listVersion == 1 ? [_buf('b_1', 'a.dart')] : [_buf('b_1', 'a.dart'), _buf('b_2', 'b.dart')]
}));
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'}
}));
ipc.stub('editor.read', (a) async {
final id = a['id'] as String;
return _ok(_read(id, id == 'b_1' ? 'a.dart' : 'b.dart', 'content-$id'));
});
await c.hydrate();
expect(c.buffers, hasLength(1));
listVersion = 2;
emitEditor(bus, 'editor.opened', {'id': 'b_2'});
await pumpEventQueue();
expect(c.buffers.map((b) => b.id).toList(), ['b_1', 'b_2']);
expect(c.activeId, 'b_2');
expect(c.content, 'content-b_2');
});
test('editor.closed refreshes the list and clears active when it was active', () async {
var listVersion = 1;
ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': listVersion == 1 ? [_buf('b_1', 'a.dart'), _buf('b_2', 'b.dart')] : [_buf('b_2', 'b.dart')]
}));
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'}
}));
ipc.stub('editor.read', (a) async => _ok(_read(a['id'] as String, 'a.dart', 'x')));
await c.hydrate();
expect(c.buffers, hasLength(2));
listVersion = 2;
emitEditor(bus, 'editor.closed', {'id': 'b_1'});
await pumpEventQueue();
expect(c.buffers.map((b) => b.id).toList(), ['b_2']);
expect(c.activeId, isNull); // cleared until an active-changed arrives
});
test('editor.saved clears the dirty marker on the buffer', () async {
ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart', dirty: true)]
}));
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'}
}));
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'x', dirty: true)));
await c.hydrate();
expect(c.buffers.single.dirty, isTrue);
emitEditor(bus, 'editor.saved', {'id': 'b_1'});
await pumpEventQueue();
expect(c.buffers.single.dirty, isFalse);
expect(c.dirty, isFalse);
});
test('editor.edited marks the buffer dirty', () async {
ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart')]
}));
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'}
}));
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'x')));
await c.hydrate();
expect(c.buffers.single.dirty, isFalse);
// A remote edit (not our own — no set-content was issued).
emitEditor(bus, 'editor.edited', {'id': 'b_1'});
await pumpEventQueue();
expect(c.buffers.single.dirty, isTrue);
});
});
group('local edits', () {
test('pushLocalEdit marks the active buffer dirty and mirrors to IPC', () async {
ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart')]
}));
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'}
}));
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'x')));
await c.hydrate();
Map<String, Object?>? setArgs;
ipc.stub('editor.set-content', (a) async {
setArgs = a;
return _ok(const {});
});
c.pushLocalEdit(newContent: 'xy', newSelection: const Selection.collapsed(2));
await pumpEventQueue();
expect(c.dirty, isTrue);
expect(c.buffers.single.dirty, isTrue);
expect(setArgs?['id'], 'b_1');
expect(setArgs?['text'], 'xy');
});
});
}
+86
View File
@@ -0,0 +1,86 @@
/// Widget tests for the multi-tab editor view: a tab per open buffer
/// (filename + dirty marker), the empty-state hint, and tab taps
/// routing to `editor.activate`. Buffer-list logic itself is covered
/// in editor_controller_test.dart.
library;
import 'package:clide/builtin/editor/src/editor_view.dart';
import 'package:clide/clide.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data);
Map<String, Object?> _buf(String id, String path, {bool dirty = false}) => {'id': id, 'path': path, 'dirty': dirty};
Map<String, Object?> _read(String id, String path) => {
'id': id,
'path': path,
'content': 'content of $path',
'selection': {'start': 0, 'end': 0},
'dirty': false,
};
void main() {
group('EditorView tabs', () {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
void stubBuffers(List<Map<String, Object?>> buffers, {String? active}) {
f.ipc.stub('editor.list', (_) async => _ok({'buffers': buffers}));
f.ipc.stub(
'editor.active',
(_) async => active == null
? _ok(const {})
: _ok({
'active': {'id': active}
}));
f.ipc.stub('editor.read', (a) async {
final id = a['id'] as String;
final b = buffers.firstWhere((b) => b['id'] == id);
return _ok(_read(id, b['path'] as String));
});
}
testWidgets('renders one tab per open buffer, by filename', (tester) async {
stubBuffers([_buf('b_1', 'lib/a.dart'), _buf('b_2', 'src/b.dart')], active: 'b_1');
await tester.pumpWidget(harness(f, const EditorView()));
await tester.pumpAndSettle();
expect(find.text('a.dart'), findsOneWidget);
expect(find.text('b.dart'), findsOneWidget);
});
testWidgets('a dirty buffer carries a marker in its tab title', (tester) async {
stubBuffers([_buf('b_1', 'lib/a.dart', dirty: true)], active: 'b_1');
await tester.pumpWidget(harness(f, const EditorView()));
await tester.pumpAndSettle();
expect(find.text('a.dart •'), findsOneWidget);
});
testWidgets('no open buffers shows the open-a-file hint', (tester) async {
stubBuffers(const [], active: null);
await tester.pumpWidget(harness(f, const EditorView()));
await tester.pumpAndSettle();
expect(find.text('Open a file to begin editing.'), findsOneWidget);
});
testWidgets('tapping an inactive tab routes to editor.activate', (tester) async {
stubBuffers([_buf('b_1', 'lib/a.dart'), _buf('b_2', 'src/b.dart')], active: 'b_1');
String? activated;
f.ipc.stub('editor.activate', (a) async {
activated = a['id'] as String?;
return _ok({'active': a['id']});
});
await tester.pumpWidget(harness(f, const EditorView()));
await tester.pumpAndSettle();
await tester.tap(find.text('b.dart'));
await tester.pumpAndSettle();
expect(activated, 'b_2');
});
});
}