Compare commits
2
Commits
fd72940d2d
...
203f8e7681
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
203f8e7681 | ||
|
|
f1f12e7d79 |
@@ -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,432 @@
|
||||
/// 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 right buffer dirty, leaving siblings alone', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [_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 => _ok(_read(a['id'] as String, 'a.dart', 'x')));
|
||||
await c.hydrate();
|
||||
expect(c.buffers.every((b) => !b.dirty), isTrue);
|
||||
|
||||
// A remote edit of b_2 (not our own). b_1 stays clean — exercises
|
||||
// the untouched-sibling branch of the dirty marker.
|
||||
emitEditor(bus, 'editor.edited', {'id': 'b_2'});
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(c.buffers.firstWhere((b) => b.id == 'b_2').dirty, isTrue);
|
||||
expect(c.buffers.firstWhere((b) => b.id == 'b_1').dirty, isFalse);
|
||||
});
|
||||
|
||||
test('editor.active-changed to a different buffer loads its content', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [_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', 'body-$id'));
|
||||
});
|
||||
await c.hydrate();
|
||||
expect(c.activeId, 'b_1');
|
||||
|
||||
emitEditor(bus, 'editor.active-changed', {'id': 'b_2'});
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(c.activeId, 'b_2');
|
||||
expect(c.content, 'body-b_2');
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
test('save() issues editor.save for the active buffer', () 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();
|
||||
|
||||
String? saved;
|
||||
ipc.stub('editor.save', (a) async {
|
||||
saved = a['id'] as String?;
|
||||
return _ok(const {});
|
||||
});
|
||||
await c.save();
|
||||
expect(saved, 'b_1');
|
||||
});
|
||||
|
||||
test('save() is a no-op with no active buffer', () async {
|
||||
var calls = 0;
|
||||
ipc.stub('editor.save', (_) async {
|
||||
calls++;
|
||||
return _ok(const {});
|
||||
});
|
||||
await c.save(); // never hydrated → no active
|
||||
expect(calls, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('edge cases', () {
|
||||
test('editor.active-changed to null clears the active buffer', () 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.activeId, 'b_1');
|
||||
|
||||
emitEditor(bus, 'editor.active-changed', const {}); // no id
|
||||
await pumpEventQueue();
|
||||
expect(c.activeId, isNull);
|
||||
expect(c.content, '');
|
||||
});
|
||||
|
||||
test('hydrate surfaces an error when editor.active fails', () async {
|
||||
ipc.stub('editor.list', (_) async => _ok({'buffers': const []}));
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'boom'),
|
||||
),
|
||||
);
|
||||
await c.hydrate();
|
||||
expect(c.error, 'boom');
|
||||
});
|
||||
|
||||
test('a missing-stub editor.list (not ok) leaves the buffer list empty', () async {
|
||||
// No editor.list stub registered → FakeDaemonClient returns a
|
||||
// notFound error; _refreshList bails without crashing.
|
||||
ipc.stub('editor.active', (_) async => _ok(const {}));
|
||||
await c.hydrate();
|
||||
expect(c.buffers, isEmpty);
|
||||
});
|
||||
|
||||
test('editor.opened with no id clears the active buffer', () 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.activeId, 'b_1');
|
||||
|
||||
emitEditor(bus, 'editor.opened', const {}); // no id
|
||||
await pumpEventQueue();
|
||||
expect(c.activeId, isNull);
|
||||
});
|
||||
|
||||
test('a failing editor.read surfaces the error', () 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 => IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'read failed'),
|
||||
),
|
||||
);
|
||||
await c.hydrate();
|
||||
expect(c.error, 'read failed');
|
||||
});
|
||||
|
||||
test('our own edit echo (editor.edited) is suppressed once, not reloaded', () 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', 'original')));
|
||||
ipc.stub('editor.set-content', (_) async => _ok(const {}));
|
||||
await c.hydrate();
|
||||
|
||||
// Local edit arms _suppressNextRemoteEdit.
|
||||
c.pushLocalEdit(newContent: 'local', newSelection: const Selection.collapsed(5));
|
||||
var reads = 0;
|
||||
ipc.stub('editor.read', (_) async {
|
||||
reads++;
|
||||
return _ok(_read('b_1', 'a.dart', 'reloaded'));
|
||||
});
|
||||
// The echo of our own set-content comes back as editor.edited.
|
||||
emitEditor(bus, 'editor.edited', {'id': 'b_1'});
|
||||
await pumpEventQueue();
|
||||
|
||||
// Suppressed: no reload, local content preserved.
|
||||
expect(reads, 0);
|
||||
expect(c.content, 'local');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/// 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:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
Finder _closeIcons() => find.byWidgetPredicate((w) => w is ClideIcon && w.painter is CloseIcon);
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
testWidgets('closing a tab routes to editor.close', (tester) async {
|
||||
stubBuffers([_buf('b_1', 'lib/a.dart')], active: 'b_1');
|
||||
String? closed;
|
||||
f.ipc.stub('editor.close', (a) async {
|
||||
closed = a['id'] as String?;
|
||||
return _ok(const {});
|
||||
});
|
||||
await tester.pumpWidget(harness(f, const EditorView()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_closeIcons(), findsWidgets);
|
||||
await tester.tap(_closeIcons().first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(closed, 'b_1');
|
||||
});
|
||||
|
||||
testWidgets('typing in the editor mirrors to editor.set-content', (tester) async {
|
||||
stubBuffers([_buf('b_1', 'lib/a.dart')], active: 'b_1');
|
||||
Map<String, Object?>? setArgs;
|
||||
f.ipc.stub('editor.set-content', (a) async {
|
||||
setArgs = a;
|
||||
return _ok(const {});
|
||||
});
|
||||
await tester.pumpWidget(harness(f, const EditorView()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(EditableText), 'edited body');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(setArgs?['id'], 'b_1');
|
||||
expect(setArgs?['text'], 'edited body');
|
||||
});
|
||||
|
||||
testWidgets('Ctrl+S in the editor triggers editor.save', (tester) async {
|
||||
stubBuffers([_buf('b_1', 'lib/a.dart')], active: 'b_1');
|
||||
String? saved;
|
||||
f.ipc.stub('editor.save', (a) async {
|
||||
saved = a['id'] as String?;
|
||||
return _ok(const {});
|
||||
});
|
||||
await tester.pumpWidget(harness(f, const EditorView()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(EditableText));
|
||||
await tester.pump();
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyS);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(saved, 'b_1');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/// Tests SyntaxTextController — the editor's TextEditingController that
|
||||
/// turns tree-sitter spans into styled TextSpans. A fake
|
||||
/// TreeSitterService supplies canned spans so the byte→char mapping and
|
||||
/// span-rendering logic can be exercised without the native grammar.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/editor/src/syntax_text_controller.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/kernel/src/syntax/tree_sitter_service.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
/// TreeSitterService stub returning canned spans (constructor does no
|
||||
/// FFI work — `_init` is lazy and never reached here).
|
||||
class _FakeSyntax extends TreeSitterService {
|
||||
_FakeSyntax(this.spans);
|
||||
final List<SyntaxSpan> spans;
|
||||
@override
|
||||
Future<SyntaxResult> highlight(String path, String source) async => SyntaxResult(spans);
|
||||
}
|
||||
|
||||
/// Counts highlight invocations so a test can assert a same-path
|
||||
/// updatePath doesn't kick off a redundant highlight.
|
||||
class _CountingSyntax extends TreeSitterService {
|
||||
int calls = 0;
|
||||
@override
|
||||
Future<SyntaxResult> highlight(String path, String source) async {
|
||||
calls++;
|
||||
return const SyntaxResult([]);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('SyntaxTextController', () {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
/// Pump a themed context, run [body] with a controller already
|
||||
/// given tokens from the active theme.
|
||||
Future<void> withController(
|
||||
WidgetTester tester,
|
||||
SyntaxTextController c,
|
||||
Future<void> Function(BuildContext ctx) body,
|
||||
) async {
|
||||
late BuildContext ctx;
|
||||
await tester.pumpWidget(harness(f, Builder(builder: (context) {
|
||||
ctx = context;
|
||||
return const SizedBox();
|
||||
})));
|
||||
c.tokens = ClideTheme.of(ctx).surface;
|
||||
await body(ctx);
|
||||
}
|
||||
|
||||
testWidgets('renders highlighted spans as styled TextSpan children', (tester) async {
|
||||
final c = SyntaxTextController(
|
||||
syntax: _FakeSyntax(const [
|
||||
SyntaxSpan(start: 0, end: 5, role: 'keyword'), // "class"
|
||||
SyntaxSpan(start: 6, end: 9, role: 'type'), // "Foo"
|
||||
]));
|
||||
await withController(tester, c, (ctx) async {
|
||||
c.text = 'class Foo {}';
|
||||
c.updatePath('a.dart');
|
||||
await tester.pumpAndSettle();
|
||||
final span = c.buildTextSpan(context: ctx, withComposing: false);
|
||||
expect(span.children, isNotNull);
|
||||
// keyword + gap + type + trailing → several children.
|
||||
expect(span.children!.length, greaterThan(2));
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('maps byte offsets across multi-byte (surrogate) characters', (tester) async {
|
||||
// '😀' is a surrogate pair (4 UTF-8 bytes). A span after it must
|
||||
// still land on the right character offset.
|
||||
final c = SyntaxTextController(
|
||||
syntax: _FakeSyntax(const [
|
||||
SyntaxSpan(start: 5, end: 8, role: 'type'), // "Foo" after "😀 "
|
||||
]));
|
||||
await withController(tester, c, (ctx) async {
|
||||
c.text = '😀 Foo';
|
||||
c.updatePath('a.dart');
|
||||
await tester.pumpAndSettle();
|
||||
final span = c.buildTextSpan(context: ctx, withComposing: false);
|
||||
expect(span.children, isNotNull);
|
||||
expect(span.toPlainText(), '😀 Foo');
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('with no spans falls back to a plain TextSpan', (tester) async {
|
||||
final c = SyntaxTextController(syntax: _FakeSyntax(const []));
|
||||
await withController(tester, c, (ctx) async {
|
||||
c.text = 'plain text';
|
||||
final span = c.buildTextSpan(context: ctx, withComposing: false);
|
||||
expect(span.children, isNull);
|
||||
expect(span.text, 'plain text');
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('updatePath to the same path is a no-op', (tester) async {
|
||||
final syntax = _CountingSyntax();
|
||||
final c = SyntaxTextController(syntax: syntax);
|
||||
await withController(tester, c, (ctx) async {
|
||||
c.text = 'x';
|
||||
c.updatePath('a.dart');
|
||||
await tester.pumpAndSettle();
|
||||
final first = syntax.calls;
|
||||
expect(first, greaterThan(0));
|
||||
c.updatePath('a.dart'); // same path → early return
|
||||
await tester.pumpAndSettle();
|
||||
expect(syntax.calls, first); // no new highlight request
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('a path with no known grammar skips highlighting', (tester) async {
|
||||
final c = SyntaxTextController(syntax: _FakeSyntax(const [SyntaxSpan(start: 0, end: 1, role: 'x')]));
|
||||
await withController(tester, c, (ctx) async {
|
||||
c.text = 'data';
|
||||
c.updatePath('notes.unknownext');
|
||||
await tester.pumpAndSettle();
|
||||
final span = c.buildTextSpan(context: ctx, withComposing: false);
|
||||
// No grammar → no spans applied → plain text.
|
||||
expect(span.children, isNull);
|
||||
expect(span.text, 'data');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user