From 96c6cfc6c52282ad43ce7e1dc894d748e18ecf4d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 6 May 2026 15:20:57 +0200 Subject: [PATCH] MultitabPane: keepAlive mode and tab strip polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .pql/pql-plan.json | 31 +++++++---- lib/widgets/src/multitab_pane.dart | 76 ++++++++++++++++++++------ test/widgets/multitab_pane_test.dart | 79 ++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 27 deletions(-) diff --git a/.pql/pql-plan.json b/.pql/pql-plan.json index d5df68b6..a7152cba 100644 --- a/.pql/pql-plan.json +++ b/.pql/pql-plan.json @@ -1,5 +1,5 @@ { - "exported_at": "2026-05-06T12:17:16Z", + "exported_at": "2026-05-06T13:20:57Z", "decisions": [ { "id": "D-1", @@ -2073,11 +2073,11 @@ "type": "task", "parent_id": "T-3", "title": "secondary Claude pane UI wiring", - "status": "ready", + "status": "in_progress", "priority": "medium", "decision_ref": "D-41", "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", @@ -2687,18 +2687,13 @@ "id": "T-84", "type": "task", "title": "MultitabPane: drag-to-reorder gesture wiring", - "status": "in_progress", + "status": "done", "priority": "medium", "created_at": "2026-05-06 10:16:02", - "updated_at": "2026-05-06 10:17:57" - } - ], - "ticket_deps": [ - { - "blocker_id": "T-84", - "blocked_id": "T-24" + "updated_at": "2026-05-06 12:17:27" } ], + "ticket_deps": null, "ticket_labels": null, "history": [ { @@ -4056,6 +4051,20 @@ "old_value": "backlog", "new_value": "in_progress", "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" } ] } diff --git a/lib/widgets/src/multitab_pane.dart b/lib/widgets/src/multitab_pane.dart index e42122f4..c0a24b2a 100644 --- a/lib/widgets/src/multitab_pane.dart +++ b/lib/widgets/src/multitab_pane.dart @@ -1,6 +1,8 @@ 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_text.dart'; +import 'package:clide/widgets/src/icons/x.dart'; import 'package:clide/widgets/src/multitab_controller.dart'; import 'package:flutter/widgets.dart'; @@ -24,6 +26,7 @@ class MultitabPane extends StatelessWidget { this.onCloseRequested, this.onAddRequested, this.allowReorder = true, + this.keepAlive = false, this.tabHeight = 28, }); @@ -32,6 +35,15 @@ class MultitabPane extends StatelessWidget { final MultitabEntryCallback? onCloseRequested; final VoidCallback? onAddRequested; 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; @override @@ -39,8 +51,10 @@ class MultitabPane extends StatelessWidget { return ListenableBuilder( listenable: controller, builder: (context, _) { + final entries = controller.entries; final active = controller.active; return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _TabStrip( controller: controller, @@ -49,21 +63,37 @@ class MultitabPane extends StatelessWidget { allowReorder: allowReorder, tabHeight: tabHeight, ), - Expanded( - 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), - ), - ), + Expanded(child: _body(context, entries, active)), ], ); }, ); } + + Widget _body(BuildContext context, List> entries, MultitabEntry? 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 extends StatelessWidget { @@ -89,7 +119,10 @@ class _TabStrip extends StatelessWidget { return Container( height: tabHeight, - color: tokens.tabBarBackground, + decoration: BoxDecoration( + color: tokens.tabBarBackground, + border: Border(bottom: BorderSide(color: tokens.dividerColor)), + ), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( @@ -288,7 +321,14 @@ class _TabState extends State<_Tab> { builder: (context, _, __) => Container( constraints: BoxConstraints(minWidth: 96, maxWidth: 200), 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( color: bg, border: Border( @@ -298,9 +338,9 @@ class _TabState extends State<_Tab> { ), ), child: Row( - mainAxisSize: MainAxisSize.min, children: [ - Flexible( + // Left column: title, takes all remaining space. + Expanded( child: ClideText( widget.entry.title, fontSize: 12, @@ -309,6 +349,7 @@ class _TabState extends State<_Tab> { maxLines: 1, ), ), + // Right column: close icon, fixed natural width. if (widget.onClose != null) ...[ const SizedBox(width: 8), Opacity( @@ -323,8 +364,11 @@ class _TabState extends State<_Tab> { color: hovered ? tokens.listItemHoverBackground : null, borderRadius: BorderRadius.circular(2), ), - child: ClideText('×', - fontSize: 14, color: tokens.globalTextMuted), + child: ClideIcon( + const CloseIcon(), + size: 10, + color: hovered ? tokens.globalForeground : tokens.globalTextMuted, + ), ), ), ), diff --git a/test/widgets/multitab_pane_test.dart b/test/widgets/multitab_pane_test.dart index fb3adfee..cb9ddfcd 100644 --- a/test/widgets/multitab_pane_test.dart +++ b/test/widgets/multitab_pane_test.dart @@ -18,6 +18,33 @@ MultitabEntry entry(String id, {bool closeable = true, bool reorderable Widget body(BuildContext _, MultitabEntry e) => 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 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() { group('MultitabPane', () { late KernelFixture f; @@ -218,6 +245,58 @@ void main() { expect(c.entries.map((e) => e.id), ['p', 'a']); }); + testWidgets('keepAlive: inactive bodies stay mounted (state preserved)', (tester) async { + final c = MultitabController(initial: [entry('a'), entry('b')]); + await tester.pumpWidget( + harness( + f, + MultitabPane( + 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(initial: [entry('a'), entry('b')]); + await tester.pumpWidget( + harness( + f, + MultitabPane( + 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 { final c = MultitabController(initial: [entry('a'), entry('b')]); await tester.pumpWidget(