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