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:
+20
-11
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user