diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d06bb60..423510d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/builtin/editor/src/editor_controller.dart b/lib/builtin/editor/src/editor_controller.dart index 03241d80..c6ecbb81 100644 --- a/lib/builtin/editor/src/editor_controller.dart +++ b/lib/builtin/editor/src/editor_controller.dart @@ -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().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 _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 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 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 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 closeBuffer(String id) async { + await ipc.request('editor.close', args: {'id': id}); + } + + /// Re-fetch the open-buffer list from the daemon (authoritative). + Future _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 _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(); diff --git a/lib/builtin/editor/src/editor_view.dart b/lib/builtin/editor/src/editor_view.dart index 4af14d74..5df0a4ec 100644 --- a/lib/builtin/editor/src/editor_view.dart +++ b/lib/builtin/editor/src/editor_view.dart @@ -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 { EditorController? _controller; + final MultitabController _tabs = MultitabController(); 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 { _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 { 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 { _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(id: b.id, title: title, payload: b.id), activate: false); + } else if (existing.first.title != title) { + _tabs.replace(b.id, MultitabEntry(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 { return ListenableBuilder( listenable: c, builder: (context, _) { - final title = c.activePath ?? 'editor'; - final subtitle = c.activeId == null - ? 'no buffer · use `clide open ` 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 ` or pick a file in the tree', + child: const Center( + child: ClideText('Open a file to begin editing.', muted: true), + ), + ); + } + return MultitabPane( + 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, + ), + ), ); }, ); diff --git a/test/builtin/editor/editor_controller_test.dart b/test/builtin/editor/editor_controller_test.dart new file mode 100644 index 00000000..c4a6cf63 --- /dev/null +++ b/test/builtin/editor/editor_controller_test.dart @@ -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 data) => IpcResponse.ok(id: '', data: data); + +Map _buf(String id, String path, {bool dirty = false}) => {'id': id, 'path': path, 'dirty': dirty}; + +Map _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 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? 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'); + }); + }); +} diff --git a/test/builtin/editor/editor_view_test.dart b/test/builtin/editor/editor_view_test.dart new file mode 100644 index 00000000..b8941064 --- /dev/null +++ b/test/builtin/editor/editor_view_test.dart @@ -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 data) => IpcResponse.ok(id: '', data: data); + +Map _buf(String id, String path, {bool dirty = false}) => {'id': id, 'path': path, 'dirty': dirty}; + +Map _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> 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'); + }); + }); +}