MultitabPane: keepAlive mode and tab strip polish

Adds keepAlive: when true, all entry bodies stay mounted via
IndexedStack so switching tabs doesn't tear down their state.
Hosts that own PTY-backed sessions or any long-lived widget
state opt in; callers that want fresh state on each switch use
the default single-body mode.

Polishes the tab strip itself for production use:
- bottom divider so the strip visually anchors to the body below
- Column.crossAxisAlignment.stretch so the strip fills the pane
  width instead of sizing to its content
- close button: replace the text × glyph with the CloseIcon
  painter (clean cross strokes, font-independent)
- two-column tab layout — Expanded title on the left, fixed
  16x16 close button on the right; uniform 12px left padding,
  6px right padding to match the 6px top/bottom breathing room
  around the close button

Two new widget tests cover keepAlive (state preserved across
switches) and default mode (inactive bodies disposed).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-06 15:20:57 +02:00
co-authored by Claude
parent aa742eed79
commit 96c6cfc6c5
3 changed files with 159 additions and 27 deletions
+20 -11
View File
@@ -1,5 +1,5 @@
{ {
"exported_at": "2026-05-06T12:17:16Z", "exported_at": "2026-05-06T13:20:57Z",
"decisions": [ "decisions": [
{ {
"id": "D-1", "id": "D-1",
@@ -2073,11 +2073,11 @@
"type": "task", "type": "task",
"parent_id": "T-3", "parent_id": "T-3",
"title": "secondary Claude pane UI wiring", "title": "secondary Claude pane UI wiring",
"status": "ready", "status": "in_progress",
"priority": "medium", "priority": "medium",
"decision_ref": "D-41", "decision_ref": "D-41",
"created_at": "2026-04-22 14:08:40", "created_at": "2026-04-22 14:08:40",
"updated_at": "2026-05-03 20:36:31" "updated_at": "2026-05-06 12:20:58"
}, },
{ {
"id": "T-25", "id": "T-25",
@@ -2687,18 +2687,13 @@
"id": "T-84", "id": "T-84",
"type": "task", "type": "task",
"title": "MultitabPane: drag-to-reorder gesture wiring", "title": "MultitabPane: drag-to-reorder gesture wiring",
"status": "in_progress", "status": "done",
"priority": "medium", "priority": "medium",
"created_at": "2026-05-06 10:16:02", "created_at": "2026-05-06 10:16:02",
"updated_at": "2026-05-06 10:17:57" "updated_at": "2026-05-06 12:17:27"
}
],
"ticket_deps": [
{
"blocker_id": "T-84",
"blocked_id": "T-24"
} }
], ],
"ticket_deps": null,
"ticket_labels": null, "ticket_labels": null,
"history": [ "history": [
{ {
@@ -4056,6 +4051,20 @@
"old_value": "backlog", "old_value": "backlog",
"new_value": "in_progress", "new_value": "in_progress",
"changed_at": "2026-05-06 10:17:57" "changed_at": "2026-05-06 10:17:57"
},
{
"ticket_id": "T-84",
"field": "status",
"old_value": "in_progress",
"new_value": "done",
"changed_at": "2026-05-06 12:17:27"
},
{
"ticket_id": "T-24",
"field": "status",
"old_value": "ready",
"new_value": "in_progress",
"changed_at": "2026-05-06 12:20:58"
} }
] ]
} }
+60 -16
View File
@@ -1,6 +1,8 @@
import 'package:clide/kernel/src/theme/controller.dart'; import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/widgets/src/clide_icon.dart';
import 'package:clide/widgets/src/clide_tappable.dart'; import 'package:clide/widgets/src/clide_tappable.dart';
import 'package:clide/widgets/src/clide_text.dart'; import 'package:clide/widgets/src/clide_text.dart';
import 'package:clide/widgets/src/icons/x.dart';
import 'package:clide/widgets/src/multitab_controller.dart'; import 'package:clide/widgets/src/multitab_controller.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@@ -24,6 +26,7 @@ class MultitabPane<T> extends StatelessWidget {
this.onCloseRequested, this.onCloseRequested,
this.onAddRequested, this.onAddRequested,
this.allowReorder = true, this.allowReorder = true,
this.keepAlive = false,
this.tabHeight = 28, this.tabHeight = 28,
}); });
@@ -32,6 +35,15 @@ class MultitabPane<T> extends StatelessWidget {
final MultitabEntryCallback<T>? onCloseRequested; final MultitabEntryCallback<T>? onCloseRequested;
final VoidCallback? onAddRequested; final VoidCallback? onAddRequested;
final bool allowReorder; final bool allowReorder;
/// When true, all entry bodies stay mounted across tab switches
/// (via [IndexedStack]). Use this for tabs that own long-lived
/// state — PTY-backed sessions, editor buffers, anything where
/// rebuilding from scratch on every switch loses state. Default
/// is false: only the active body builds, switching disposes the
/// old body and rebuilds the new one.
final bool keepAlive;
final double tabHeight; final double tabHeight;
@override @override
@@ -39,8 +51,10 @@ class MultitabPane<T> extends StatelessWidget {
return ListenableBuilder( return ListenableBuilder(
listenable: controller, listenable: controller,
builder: (context, _) { builder: (context, _) {
final entries = controller.entries;
final active = controller.active; final active = controller.active;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_TabStrip<T>( _TabStrip<T>(
controller: controller, controller: controller,
@@ -49,21 +63,37 @@ class MultitabPane<T> extends StatelessWidget {
allowReorder: allowReorder, allowReorder: allowReorder,
tabHeight: tabHeight, tabHeight: tabHeight,
), ),
Expanded( Expanded(child: _body(context, entries, active)),
child: active == null
? const SizedBox.expand()
: KeyedSubtree(
// Key by id so swapping the active tab rebuilds
// body state cleanly instead of mutating in place.
key: ValueKey('multitab-body-${active.id}'),
child: bodyBuilder(context, active),
),
),
], ],
); );
}, },
); );
} }
Widget _body(BuildContext context, List<MultitabEntry<T>> entries, MultitabEntry<T>? active) {
if (active == null || entries.isEmpty) return const SizedBox.expand();
if (!keepAlive) {
// Single-body mode: rebuild on every active change. Key by id
// so a stable body widget tree gets a fresh State on switch.
return KeyedSubtree(
key: ValueKey('multitab-body-${active.id}'),
child: bodyBuilder(context, active),
);
}
// Keep-alive mode: every body stays mounted; switching is just
// an IndexedStack index change. Bodies preserve their State.
final activeIndex = entries.indexWhere((e) => e.id == active.id);
return IndexedStack(
index: activeIndex < 0 ? 0 : activeIndex,
children: [
for (final entry in entries)
KeyedSubtree(
key: ValueKey('multitab-body-${entry.id}'),
child: bodyBuilder(context, entry),
),
],
);
}
} }
class _TabStrip<T> extends StatelessWidget { class _TabStrip<T> extends StatelessWidget {
@@ -89,7 +119,10 @@ class _TabStrip<T> extends StatelessWidget {
return Container( return Container(
height: tabHeight, height: tabHeight,
color: tokens.tabBarBackground, decoration: BoxDecoration(
color: tokens.tabBarBackground,
border: Border(bottom: BorderSide(color: tokens.dividerColor)),
),
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: Row( child: Row(
@@ -288,7 +321,14 @@ class _TabState<T> extends State<_Tab<T>> {
builder: (context, _, __) => Container( builder: (context, _, __) => Container(
constraints: BoxConstraints(minWidth: 96, maxWidth: 200), constraints: BoxConstraints(minWidth: 96, maxWidth: 200),
height: widget.tabHeight, height: widget.tabHeight,
padding: const EdgeInsets.symmetric(horizontal: 12), // Left margin stays at 12 (text breathing room).
// Right margin matches the close button's vertical
// breathing room ((tabHeight − iconSize) / 2 ≈ 6) so the
// gap around the icon is uniform on top, bottom, and right.
padding: EdgeInsets.only(
left: 12,
right: widget.onClose != null ? 6 : 12,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: bg, color: bg,
border: Border( border: Border(
@@ -298,9 +338,9 @@ class _TabState<T> extends State<_Tab<T>> {
), ),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Flexible( // Left column: title, takes all remaining space.
Expanded(
child: ClideText( child: ClideText(
widget.entry.title, widget.entry.title,
fontSize: 12, fontSize: 12,
@@ -309,6 +349,7 @@ class _TabState<T> extends State<_Tab<T>> {
maxLines: 1, maxLines: 1,
), ),
), ),
// Right column: close icon, fixed natural width.
if (widget.onClose != null) ...[ if (widget.onClose != null) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
Opacity( Opacity(
@@ -323,8 +364,11 @@ class _TabState<T> extends State<_Tab<T>> {
color: hovered ? tokens.listItemHoverBackground : null, color: hovered ? tokens.listItemHoverBackground : null,
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
child: ClideText('×', child: ClideIcon(
fontSize: 14, color: tokens.globalTextMuted), const CloseIcon(),
size: 10,
color: hovered ? tokens.globalForeground : tokens.globalTextMuted,
),
), ),
), ),
), ),
+79
View File
@@ -18,6 +18,33 @@ MultitabEntry<String> entry(String id, {bool closeable = true, bool reorderable
Widget body(BuildContext _, MultitabEntry<String> e) => Widget body(BuildContext _, MultitabEntry<String> e) =>
SizedBox(key: ValueKey('body-${e.id}'), child: Text('body:${e.payload}')); SizedBox(key: ValueKey('body-${e.id}'), child: Text('body:${e.payload}'));
/// Stateful tap-counter body. Preserves a per-id count across rebuilds
/// in a static map so the test can assert state survival across tab
/// switches.
class _CountingBody extends StatefulWidget {
const _CountingBody({required this.id});
final String id;
@override
State<_CountingBody> createState() => _CountingBodyState();
}
class _CountingBodyState extends State<_CountingBody> {
static final Map<String, int> counts = {};
void _bump() => setState(() => counts[widget.id] = (counts[widget.id] ?? 0) + 1);
@override
Widget build(BuildContext context) {
return GestureDetector(
key: ValueKey('counting-${widget.id}'),
behavior: HitTestBehavior.opaque,
onTap: _bump,
child: const SizedBox.expand(),
);
}
}
void main() { void main() {
group('MultitabPane', () { group('MultitabPane', () {
late KernelFixture f; late KernelFixture f;
@@ -218,6 +245,58 @@ void main() {
expect(c.entries.map((e) => e.id), ['p', 'a']); expect(c.entries.map((e) => e.id), ['p', 'a']);
}); });
testWidgets('keepAlive: inactive bodies stay mounted (state preserved)', (tester) async {
final c = MultitabController<String>(initial: [entry('a'), entry('b')]);
await tester.pumpWidget(
harness(
f,
MultitabPane<String>(
controller: c,
bodyBuilder: (ctx, e) => _CountingBody(id: e.id),
keepAlive: true,
),
),
);
// Both bodies are in the tree (b is offstage in IndexedStack).
expect(find.byKey(const ValueKey('counting-a')), findsOneWidget);
expect(find.byKey(const ValueKey('counting-b'), skipOffstage: false), findsOneWidget);
// Increment counter on body 'a' (it's the active one, visible).
await tester.tap(find.byKey(const ValueKey('counting-a')));
await tester.pumpAndSettle();
expect(_CountingBodyState.counts['a'], 1);
// Switch to b. Body 'a' stays mounted; switch back and the
// counter is preserved (would be 0 if 'a' had been rebuilt).
await tester.tap(find.text('b'));
await tester.pumpAndSettle();
await tester.tap(find.text('a'));
await tester.pumpAndSettle();
expect(_CountingBodyState.counts['a'], 1);
});
testWidgets('default mode disposes inactive bodies', (tester) async {
final c = MultitabController<String>(initial: [entry('a'), entry('b')]);
await tester.pumpWidget(
harness(
f,
MultitabPane<String>(
controller: c,
bodyBuilder: (ctx, e) => _CountingBody(id: 'd-${e.id}'),
),
),
);
expect(find.byKey(const ValueKey('counting-d-a')), findsOneWidget);
expect(find.byKey(const ValueKey('counting-d-b')), findsNothing);
await tester.tap(find.text('b'));
await tester.pumpAndSettle();
// a's body is gone from the tree; b's is in.
expect(find.byKey(const ValueKey('counting-d-a')), findsNothing);
expect(find.byKey(const ValueKey('counting-d-b')), findsOneWidget);
});
testWidgets('allowReorder=false disables drag entirely', (tester) async { testWidgets('allowReorder=false disables drag entirely', (tester) async {
final c = MultitabController<String>(initial: [entry('a'), entry('b')]); final c = MultitabController<String>(initial: [entry('a'), entry('b')]);
await tester.pumpWidget( await tester.pumpWidget(