From 3414db148a7dfdf38cc6be7d1956672a6744e6f7 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 9 Jun 2026 18:19:30 +0200 Subject: [PATCH] add status-bar collapse toggles for sidebar + context pane (T-294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed ~24px caret-line cells bookend the status bar (left=sidebar, right=context); chevron flips per arrangement.isCollapsed (inward=collapse, outward=expand) and fires the existing sidebar.collapse / context.collapse commands — the mouse affordance for the already keyboard/CLI-addressable action (D-6). Visual only; no new collapse logic. Status items shift 28px inward for the reserved cells. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 ++ lib/app.dart | 53 +++++++++++++++-- lib/widgets/src/icons/phosphor.dart | 3 + test/app_collapse_toggle_test.dart | 91 +++++++++++++++++++++++++++++ test/app_statusbar_test.dart | 11 ++-- 5 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 test/app_collapse_toggle_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 3256fc1d..b58b3c08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **Collapse toggles in the status bar.** A small caret-line button bookends + each end of the bottom status bar — left collapses/expands the sidebar, right + the context pane. The chevron points inward to collapse, outward to expand, + and fires the existing `sidebar.collapse` / `context.collapse` commands + (`Ctrl+Shift+1` / `Ctrl+Shift+3`), so it's the mouse affordance for an + already keyboard/CLI-addressable action. (T-294) - **Pasted images render inline in the Claude conversation.** A pasted-image `@` reference now shows as a bounded thumbnail in the message instead of the raw path; clicking it (or pressing Enter when focused) opens the full diff --git a/lib/app.dart b/lib/app.dart index 8b4c7f44..2c9346f4 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1091,6 +1091,46 @@ class _BottomRail extends StatelessWidget { } } +/// A fixed-position collapse/expand toggle bookending the status bar (T-294). +/// The left cell controls the sidebar, the right cell the context pane; both +/// fire the existing `sidebar.collapse` / `context.collapse` commands and flip a +/// caret-line chevron per `arrangement.isCollapsed` (outward = expand, inward = +/// collapse). The collapse behaviour itself lives in the commands (D-51/D-54); +/// this is the mouse affordance for the keyboard/CLI-addressable action (D-6). +class _CollapseToggle extends StatelessWidget { + const _CollapseToggle({required this.slot}); + + final SlotId slot; + + bool get _isSidebar => slot == Slots.sidebar; + + @override + Widget build(BuildContext context) { + final kernel = ClideKernel.of(context); + final tokens = ClideTheme.of(context).surface; + if (!kernel.arrangement.isVisible(slot)) return const SizedBox(width: 24); + final collapsed = kernel.arrangement.isCollapsed(slot); + // Mirror + flip: the chevron points OUT (toward the edge the pane lives on) + // to expand a collapsed pane, and IN to collapse an open one. + final icon = _isSidebar + ? (collapsed ? PhosphorIcons.caretLineRight : PhosphorIcons.caretLineLeft) + : (collapsed ? PhosphorIcons.caretLineLeft : PhosphorIcons.caretLineRight); + final what = _isSidebar ? 'sidebar' : 'context panel'; + return SizedBox( + width: 24, + child: ClideTappable( + onTap: () => kernel.commands.execute(_isSidebar ? 'sidebar.collapse' : 'context.collapse'), + tooltip: collapsed ? 'Show $what' : 'Hide $what', + builder: (ctx, hovered, focused) => Container( + alignment: Alignment.center, + color: (hovered || focused) ? tokens.listItemHoverBackground : null, + child: ClideIcon(icon, size: 13, color: tokens.statusBarForeground), + ), + ), + ); + } +} + class StatusbarHost extends StatelessWidget { const StatusbarHost({super.key}); @@ -1099,7 +1139,9 @@ class StatusbarHost extends StatelessWidget { final kernel = ClideKernel.of(context); final tokens = ClideTheme.of(context).surface; return ListenableBuilder( - listenable: kernel.panels, + // Also listen to the arrangement so the toggle chevrons flip with the + // collapsed state (T-294). + listenable: Listenable.merge([kernel.panels, kernel.arrangement]), builder: (ctx, _) { final items = kernel.panels.contributionsFor(Slots.statusbar).whereType().toList(); final left = items.where((i) => i.priority < 100).toList(); @@ -1110,13 +1152,15 @@ class StatusbarHost extends StatelessWidget { // trails it at intrinsic width — so it hugs the workspace block's // right edge by construction, no Spacer to fight a flex item (T-239). // Left items with flex > 0 wrap in Flexible(loose) so they yield width - // when tight (T-160). + // when tight (T-160). The collapse toggles bookend both ends at a fixed + // ~24px each; the status items sit between them (T-294). return Container( color: tokens.chromeBackground, - padding: const EdgeInsets.symmetric(horizontal: 8), + padding: const EdgeInsets.symmetric(horizontal: 4), child: Row( - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + const _CollapseToggle(slot: Slots.sidebar), Expanded( child: Row( crossAxisAlignment: CrossAxisAlignment.center, @@ -1127,6 +1171,7 @@ class StatusbarHost extends StatelessWidget { ), ), for (final item in right) item.build(ctx), + const _CollapseToggle(slot: Slots.contextPanel), ], ), ); diff --git a/lib/widgets/src/icons/phosphor.dart b/lib/widgets/src/icons/phosphor.dart index a10b5863..de5022ea 100644 --- a/lib/widgets/src/icons/phosphor.dart +++ b/lib/widgets/src/icons/phosphor.dart @@ -61,6 +61,9 @@ abstract class PhosphorIcons { static const caretRight = PhosphorIconPainter(0xe13a); static const caretDown = PhosphorIconPainter(0xe136); static const caretUp = PhosphorIconPainter(0xe13c); + // Chevron-with-edge-line: reads as "collapse to / expand from the edge" (T-294). + static const caretLineLeft = PhosphorIconPainter(0xe132); + static const caretLineRight = PhosphorIconPainter(0xe130); static const graph = PhosphorIconPainter(0xeb58); static const treeStructure = PhosphorIconPainter(0xe67c); static const image = PhosphorIconPainter(0xe2ca); diff --git a/test/app_collapse_toggle_test.dart b/test/app_collapse_toggle_test.dart new file mode 100644 index 00000000..f14d004d --- /dev/null +++ b/test/app_collapse_toggle_test.dart @@ -0,0 +1,91 @@ +/// Status-bar collapse toggles (T-294): a fixed cell bookends each end of the +/// status bar, flips a caret-line chevron per arrangement.isCollapsed, and fires +/// the existing sidebar.collapse / context.collapse commands. +library; + +import 'package:clide/app.dart'; +import 'package:clide/builtin/default_layout/default_layout.dart'; +import 'package:clide/extension/extension.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'helpers/kernel_fixture.dart'; + +Finder _icon(PhosphorIconPainter p) => find.byWidgetPredicate((w) => w is ClideIcon && w.painter == p); + +Widget _bar(KernelFixture f) => Directionality( + textDirection: TextDirection.ltr, + child: ClideKernel( + services: f.services, + child: ClideTheme( + controller: f.services.theme, + child: const MediaQuery( + data: MediaQueryData(size: Size(800, 200)), + child: Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 800, height: 26, child: StatusbarHost()), + ), + ), + ), + ), + ); + +void main() { + late KernelFixture f; + + setUp(() async { + f = await KernelFixture.create(); + f.services.arrangement.applyPreset(const LayoutPresetContribution( + id: 'test', + displayName: 'test', + slots: [ + LayoutSlot(slot: Slots.sidebar, position: SlotPosition.left, visible: true), + LayoutSlot(slot: Slots.contextPanel, position: SlotPosition.right, visible: true), + ], + )); + f.services.extensions.register(DefaultLayoutExtension()); + await f.services.extensions.activate('builtin.default-layout'); + }); + tearDown(() => f.dispose()); + + testWidgets('both toggles show, chevrons point inward when panes are open', (tester) async { + await tester.pumpWidget(_bar(f)); + await tester.pump(); + // Sidebar (left end) collapses leftward → caret-line-left; context (right + // end) collapses rightward → caret-line-right. + expect(_icon(PhosphorIcons.caretLineLeft), findsOneWidget); + expect(_icon(PhosphorIcons.caretLineRight), findsOneWidget); + }); + + testWidgets('a collapsed pane flips its chevron outward (expand affordance)', (tester) async { + f.services.arrangement.setCollapsed(Slots.sidebar, true); + await tester.pumpWidget(_bar(f)); + await tester.pump(); + // Sidebar now collapsed → its chevron points right (expand); context still + // open → right. So two right-pointing, none left. + expect(_icon(PhosphorIcons.caretLineRight), findsNWidgets(2)); + expect(_icon(PhosphorIcons.caretLineLeft), findsNothing); + }); + + testWidgets('tapping the sidebar toggle fires sidebar.collapse', (tester) async { + await tester.pumpWidget(_bar(f)); + await tester.pump(); + expect(f.services.arrangement.isCollapsed(Slots.sidebar), isFalse); + + await tester.tap(_icon(PhosphorIcons.caretLineLeft)); // the open-sidebar toggle + await tester.pump(); + + expect(f.services.arrangement.isCollapsed(Slots.sidebar), isTrue); + }); + + testWidgets('a hidden pane reserves the cell but shows no toggle', (tester) async { + f.services.arrangement.setVisible(Slots.contextPanel, false); + await tester.pumpWidget(_bar(f)); + await tester.pump(); + // Sidebar toggle present (open → left); context hidden → no right chevron. + expect(_icon(PhosphorIcons.caretLineLeft), findsOneWidget); + expect(_icon(PhosphorIcons.caretLineRight), findsNothing); + }); +} diff --git a/test/app_statusbar_test.dart b/test/app_statusbar_test.dart index 665bb800..dcdbfad3 100644 --- a/test/app_statusbar_test.dart +++ b/test/app_statusbar_test.dart @@ -69,9 +69,11 @@ void main() { for (final width in [600.0, 3440.0]) { await pumpAt(tester, width); expect(tester.takeException(), isNull, reason: 'width=$width'); - // StatusbarHost pads 8px each side → right item's right edge ≈ width - 8. - expect(tester.getTopRight(find.text('RIGHT')).dx, closeTo(width - 8, 1.0), reason: 'right group not at edge at width=$width'); - expect(tester.getTopLeft(find.text('LEFT')).dx, closeTo(8, 1.0), reason: 'left not at start at width=$width'); + // StatusbarHost pads 4px each side and reserves a fixed 24px collapse- + // toggle cell at each end (T-294), so status items sit 28px inside both + // edges: right item's right edge ≈ width - 28, left item's left ≈ 28. + expect(tester.getTopRight(find.text('RIGHT')).dx, closeTo(width - 28, 1.0), reason: 'right group not at edge at width=$width'); + expect(tester.getTopLeft(find.text('LEFT')).dx, closeTo(28, 1.0), reason: 'left not at start at width=$width'); } }); @@ -83,6 +85,7 @@ void main() { )); await pumpAt(tester, 3440.0); expect(tester.takeException(), isNull); - expect(tester.getTopRight(find.text('R2')).dx, closeTo(3440 - 8, 1.0)); + // 4px pad + 24px reserved toggle cell at the right end (T-294). + expect(tester.getTopRight(find.text('R2')).dx, closeTo(3440 - 28, 1.0)); }); }