fix(editor): collapse the workspace split when the last buffer closes (T-459)

EditorRegistry.close() guarded its active-changed emit on `_activeId !=
null`, so closing the LAST buffer (active clears to null) emitted only
editor.closed — never the active-changed(id:null) the editor extension
listens for to call closeEditor(). editorOpen stayed true and the top
split sat orphaned over the Claude pane. Always emit active-changed when
the active buffer is removed, including the cleared-to-null case; the
slot renderer already collapses correctly once editorOpen flips false.

The existing extension test fabricated the null active-changed event, so
it passed despite the registry never emitting it — that gap is why the
bug shipped. Add a registry test that drives the real close() path, plus
a slot_host widget test asserting the split (drag handle) drops out and
the primary pane fills the column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 17:49:12 +02:00
co-authored by Claude Opus 4.8
parent 8c9c881d81
commit 1e49f3e1d9
6 changed files with 188 additions and 1 deletions
+17
View File
@@ -89,6 +89,23 @@ void main() {
expect(sink.ofKind('editor.active-changed').length, greaterThanOrEqualTo(2));
});
test('closing the last buffer clears active + emits active-changed with id=null (T-459)', () async {
final buf = await reg.open('README.md');
expect(reg.active, same(buf));
sink.events.clear();
reg.close(buf.id);
// No buffer left — active clears to null.
expect(reg.active, isNull);
// The collapse signal: active-changed must still fire, carrying a null id,
// so the editor split drops out. The bug (T-459) was this emit being
// guarded away on the last close, leaving editorOpen stuck true.
final activeChanged = sink.ofKind('editor.active-changed');
expect(activeChanged, hasLength(1));
expect(activeChanged.single.data['id'], isNull);
expect(activeChanged.single.data['path'], isNull);
expect(sink.ofKind('editor.closed'), hasLength(1));
});
test('opening a non-existent path creates an empty buffer', () async {
final buf = await reg.open('NEW.md');
expect(buf.content, isEmpty);
+81
View File
@@ -0,0 +1,81 @@
/// T-459: the workspace editor top split must collapse cleanly when the
/// editor closes — no orphaned drag handle or empty region left over the
/// primary (Claude) pane.
///
/// This covers the render seam: given `arrangement.editorOpen`, the
/// `_WorkspaceSlot` either shows the split (editor body + drag handle over the
/// primary pane) or collapses to just the primary pane. The event seam that
/// flips `editorOpen` on the last buffer close is covered by the registry +
/// editor-extension tests.
library;
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/src/shell/slot_host.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
void main() {
late KernelFixture f;
setUp(() async {
f = await KernelFixture.create();
// Stand-in workspace tabs: the real Claude pane / EditorView pull in PTY +
// tree-sitter FFI, which the split-collapse contract doesn't need.
f.services.panels.contribute(
TabContribution(
id: 'claude.primary',
slot: Slots.workspace,
title: 'Claude',
priority: 90,
build: (_) => const SizedBox(key: ValueKey('claude-body')),
),
);
f.services.panels.contribute(
TabContribution(
id: 'editor.active',
slot: Slots.workspace,
title: 'Editor',
priority: 80,
build: (_) => const SizedBox(key: ValueKey('editor-body')),
),
);
});
tearDown(() => f.dispose());
Finder dragHandle() => find.bySemanticsLabel('Editor split');
testWidgets('editor top split collapses to the primary pane when the editor closes (T-459)', (tester) async {
// Editor open + active: the split shows the editor body, the drag handle,
// and the primary pane below.
f.services.panels.activateTab(Slots.workspace, 'editor.active');
f.services.arrangement.openEditor();
await tester.pumpWidget(harness(f, const SlotHost(slot: Slots.workspace)));
await tester.pump();
expect(dragHandle(), findsOneWidget, reason: 'split should render its resize handle while the editor is open');
expect(find.byKey(const ValueKey('editor-body')), findsOneWidget);
expect(find.byKey(const ValueKey('claude-body')), findsOneWidget);
// Close the editor → the top split must drop out entirely, leaving only the
// primary pane (no leftover drag handle / empty region).
f.services.arrangement.closeEditor();
await tester.pump();
expect(dragHandle(), findsNothing, reason: 'collapsed split must not leave a drag handle behind');
expect(find.byKey(const ValueKey('editor-body')), findsNothing, reason: 'closed editor body must not linger');
expect(find.byKey(const ValueKey('claude-body')), findsOneWidget, reason: 'primary pane fills the column');
});
testWidgets('no split when the editor was never opened — primary pane only', (tester) async {
f.services.panels.activateTab(Slots.workspace, 'claude.primary');
await tester.pumpWidget(harness(f, const SlotHost(slot: Slots.workspace)));
await tester.pump();
expect(dragHandle(), findsNothing);
expect(find.byKey(const ValueKey('claude-body')), findsOneWidget);
});
}