diff --git a/CHANGELOG.md b/CHANGELOG.md index 769c24b6..7605e14b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- Toast notifications for operation feedback: non-modal cards slide in + bottom-right, auto-dismiss (errors linger), stack, and are manually + dismissable, with success/warning/error/info severities. Components raise + them by publishing to the kernel MessageBus — git push/pull show the first + ones. (T-50) - Claude pane folds runs of tool calls/results into a collapsible "activity card" so prose isn't buried: collapsed by default with a live one-line ticker + step count, click/Enter to expand. Claude prose, user messages, and failed diff --git a/lib/app.dart b/lib/app.dart index add3de52..513f59cc 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -166,6 +166,7 @@ class _RootShellState extends State<_RootShell> { const ClidePalette(), const QuickOpenOverlay(), const Positioned.fill(child: _WelcomeOverlay()), + const ToastOverlay(), ], ), ), diff --git a/lib/builtin/git/src/git_controller.dart b/lib/builtin/git/src/git_controller.dart index ac9fba10..0a7872e3 100644 --- a/lib/builtin/git/src/git_controller.dart +++ b/lib/builtin/git/src/git_controller.dart @@ -12,13 +12,23 @@ import 'package:clide/kernel/kernel.dart'; import 'package:flutter/foundation.dart'; class GitController extends ChangeNotifier { - GitController({required this.ipc, required this.events}) { + GitController({required this.ipc, required this.events, this.messages}) { _eventSub = events.on().listen(_onEvent); } final DaemonClient ipc; final DaemonBus events; + /// Optional — the kernel MessageBus, used to publish operation-feedback + /// toasts (push/pull) without depending on the toast service. Null in + /// headless/unit contexts that don't wire it. + final MessageBus? messages; + + void _toast(String message, ToastSeverity severity) { + final m = messages; + if (m != null) publishToast(m, 'builtin.git', message, severity: severity); + } + StreamSubscription? _eventSub; String? _branch; @@ -112,8 +122,11 @@ class GitController extends ChangeNotifier { Future pull() async { final r = await ipc.request('git.pull'); - if (!r.ok) { + if (r.ok) { + _toast('Pulled${_upstream != null ? ' from $_upstream' : ''}', ToastSeverity.success); + } else { _error = r.error?.message; + _toast('Pull failed: ${r.error?.message ?? 'unknown error'}', ToastSeverity.error); notifyListeners(); } return r.ok; @@ -121,8 +134,11 @@ class GitController extends ChangeNotifier { Future push() async { final r = await ipc.request('git.push'); - if (!r.ok) { + if (r.ok) { + _toast('Pushed${_upstream != null ? ' to $_upstream' : ''}', ToastSeverity.success); + } else { _error = r.error?.message; + _toast('Push failed: ${r.error?.message ?? 'unknown error'}', ToastSeverity.error); notifyListeners(); } return r.ok; diff --git a/lib/builtin/git/src/git_panel_view.dart b/lib/builtin/git/src/git_panel_view.dart index 7ffe3ec1..f8f97e88 100644 --- a/lib/builtin/git/src/git_panel_view.dart +++ b/lib/builtin/git/src/git_panel_view.dart @@ -35,7 +35,7 @@ class _GitPanelViewState extends State { super.didChangeDependencies(); if (_controller != null) return; final kernel = ClideKernel.of(context); - _controller = GitController(ipc: kernel.ipc, events: kernel.events); + _controller = GitController(ipc: kernel.ipc, events: kernel.events, messages: kernel.messages); unawaited(_controller!.load()); } diff --git a/lib/kernel/kernel.dart b/lib/kernel/kernel.dart index c728827f..6dd1657f 100644 --- a/lib/kernel/kernel.dart +++ b/lib/kernel/kernel.dart @@ -46,6 +46,7 @@ export 'src/reader_nav.dart'; export 'src/recent_files.dart'; export 'src/scheduler.dart'; export 'src/text_zoom.dart'; +export 'src/toast.dart'; export 'src/secrets.dart'; export 'src/tray.dart'; export 'src/panels/drag_resize.dart'; diff --git a/lib/kernel/src/facade.dart b/lib/kernel/src/facade.dart index 8a346cc2..a32f2fab 100644 --- a/lib/kernel/src/facade.dart +++ b/lib/kernel/src/facade.dart @@ -5,6 +5,7 @@ import 'package:clide/kernel/src/clipboard.dart'; import 'package:clide/kernel/src/commands/keybindings.dart'; import 'package:clide/kernel/src/keymap/keymap_service.dart'; import 'package:clide/kernel/src/text_zoom.dart'; +import 'package:clide/kernel/src/toast.dart'; import 'package:clide/kernel/src/commands/palette.dart'; import 'package:clide/kernel/src/commands/registry.dart'; import 'package:clide/kernel/src/dialog.dart'; @@ -72,6 +73,7 @@ class KernelServices { required this.scheduler, required this.keymap, required this.textZoom, + required this.toast, }); final Logger log; @@ -105,6 +107,7 @@ class KernelServices { final SchedulerService scheduler; final KeymapService keymap; final TextZoom textZoom; + final ToastService toast; static Future boot({ required Directory appDir, @@ -166,6 +169,7 @@ class KernelServices { final scheduler = SchedulerService(events); scheduler.start(); final textZoom = TextZoom(); + final toast = ToastService(messages: messages); final project = ProjectManager( log: log, events: events, @@ -252,6 +256,7 @@ class KernelServices { scheduler: scheduler, keymap: keymap, textZoom: textZoom, + toast: toast, ); } @@ -265,6 +270,7 @@ class KernelServices { commands.dispose(); palette.dispose(); quickOpen.dispose(); + toast.dispose(); recentFiles.dispose(); readerNav.dispose(); i18n.dispose(); diff --git a/lib/kernel/src/toast.dart b/lib/kernel/src/toast.dart new file mode 100644 index 00000000..6a34ef72 --- /dev/null +++ b/lib/kernel/src/toast.dart @@ -0,0 +1,140 @@ +import 'dart:async'; + +import 'package:clide/kernel/src/events/message_bus.dart'; +import 'package:flutter/foundation.dart'; + +/// Severity of a toast. Maps to the theme's status tokens at render time +/// (success/warning/error/info) — see `ClideToast`. +enum ToastSeverity { success, warning, error, info } + +/// MessageBus channel the [ToastService] consumes. Any component raises a +/// toast by publishing here — it needs no reference to the service. See +/// [publishToast]. +const String toastChannel = 'toast'; + +ToastSeverity _severityFromName(Object? name) => switch (name) { + 'success' => ToastSeverity.success, + 'warning' => ToastSeverity.warning, + 'error' => ToastSeverity.error, + _ => ToastSeverity.info, + }; + +/// Raise a toast by publishing to the MessageBus — the decoupled path: +/// emitters depend only on the bus, never on the [ToastService]. [publisher] +/// is the emitter id (e.g. `builtin.git`), kept for provenance/filtering. +void publishToast( + MessageBus messages, + String publisher, + String message, { + ToastSeverity severity = ToastSeverity.info, + Duration? duration, +}) { + messages.publish(publisher, toastChannel, { + 'message': message, + 'severity': severity.name, + if (duration != null) 'durationMs': duration.inMilliseconds, + }); +} + +/// One live toast. Immutable; the [ToastService] owns the list. +@immutable +class ToastEntry { + const ToastEntry({required this.id, required this.message, required this.severity}); + + /// Monotonic id, unique within a [ToastService] lifetime. Used as the + /// widget key and the [ToastService.dismiss] handle. + final int id; + final String message; + final ToastSeverity severity; +} + +/// Non-modal toast notifications for operation feedback (T-50). +/// +/// A MessageBus consumer: it subscribes to the [toastChannel] and turns each +/// published message into a queued toast, so emitters (git, extensions, …) +/// stay decoupled — they publish, they don't hold a reference here. Queues +/// multiple (newest last), auto-dismisses each after a per-severity timeout +/// (errors linger), supports manual dismissal, and caps the visible count. +/// UI-only kernel service (a [ChangeNotifier], like the palette/dialog +/// controllers); the `ToastOverlay` widget renders [entries] bottom-right. +class ToastService extends ChangeNotifier { + ToastService({required MessageBus messages, this.maxVisible = 4}) : _messages = messages { + _sub = _messages.subscribe(channel: toastChannel).listen(_onMessage); + } + + final MessageBus _messages; + StreamSubscription? _sub; + + /// Most toasts shown at once; older ones are dropped past this. + final int maxVisible; + + static const Duration defaultDuration = Duration(seconds: 4); + + /// Errors linger longer — they're more likely to matter and to be missed. + static const Duration errorDuration = Duration(seconds: 8); + + int _nextId = 0; + final List _entries = []; + final Map _timers = {}; + + /// Live toasts, oldest first. Unmodifiable. + List get entries => List.unmodifiable(_entries); + + void _onMessage(Message m) { + final msg = m.data['message']; + if (msg is! String) return; + final ms = m.data['durationMs']; + show( + msg, + severity: _severityFromName(m.data['severity']), + duration: ms is int ? Duration(milliseconds: ms) : null, + ); + } + + /// Show a toast directly (the queue API the bus handler also calls). + /// Returns its id (for [dismiss]). A non-positive [duration] (or + /// `Duration.zero`) makes it sticky — no auto-dismiss. + int show(String message, {ToastSeverity severity = ToastSeverity.info, Duration? duration}) { + final id = _nextId++; + _entries.add(ToastEntry(id: id, message: message, severity: severity)); + while (_entries.length > maxVisible) { + final dropped = _entries.removeAt(0); + _timers.remove(dropped.id)?.cancel(); + } + final d = duration ?? (severity == ToastSeverity.error ? errorDuration : defaultDuration); + if (d > Duration.zero) { + _timers[id] = Timer(d, () => dismiss(id)); + } + notifyListeners(); + return id; + } + + /// Remove a toast (manual dismiss or auto-dismiss). No-op if already gone. + void dismiss(int id) { + _timers.remove(id)?.cancel(); + final before = _entries.length; + _entries.removeWhere((e) => e.id == id); + if (_entries.length != before) notifyListeners(); + } + + /// Drop every toast (e.g. on project close). + void clear() { + for (final t in _timers.values) { + t.cancel(); + } + _timers.clear(); + if (_entries.isEmpty) return; + _entries.clear(); + notifyListeners(); + } + + @override + void dispose() { + _sub?.cancel(); + for (final t in _timers.values) { + t.cancel(); + } + _timers.clear(); + super.dispose(); + } +} diff --git a/lib/widgets/src/clide_toast.dart b/lib/widgets/src/clide_toast.dart new file mode 100644 index 00000000..999cd2d7 --- /dev/null +++ b/lib/widgets/src/clide_toast.dart @@ -0,0 +1,160 @@ +import 'package:clide/kernel/kernel.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/phosphor.dart'; +import 'package:clide/widgets/src/spacing.dart'; +import 'package:clide/widgets/src/typography.dart'; +import 'package:flutter/widgets.dart'; + +/// Maps a [ToastSeverity] to its status token + leading glyph. Kept here +/// (render layer) so the [ToastService] stays theme-free. +({Color color, ClideIconPainter icon}) _styleFor(ToastSeverity s, SurfaceTokens t) { + switch (s) { + case ToastSeverity.success: + return (color: t.statusSuccess, icon: PhosphorIcons.checkCircle); + case ToastSeverity.warning: + return (color: t.statusWarning, icon: PhosphorIcons.warningCircle); + case ToastSeverity.error: + return (color: t.statusError, icon: PhosphorIcons.warningCircle); + case ToastSeverity.info: + return (color: t.statusInfo, icon: PhosphorIcons.circlesFour); + } +} + +/// A single toast card (T-50). Severity-colored leading accent + glyph, the +/// message, and a dismiss affordance. Slides + fades in on mount; clipped to +/// a bounded width. No Material — a plain themed container (D-7). +class ClideToast extends StatefulWidget { + const ClideToast({super.key, required this.entry, required this.onDismiss}); + + final ToastEntry entry; + final VoidCallback onDismiss; + + @override + State createState() => _ClideToastState(); +} + +class _ClideToastState extends State { + bool _shown = false; + + @override + void initState() { + super.initState(); + // Flip after the first frame so the implicit animations run once, then + // settle (no perpetual ticker — keeps widget tests deterministic). + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _shown = true); + }); + } + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final style = _styleFor(widget.entry.severity, tokens); + return AnimatedSlide( + offset: _shown ? Offset.zero : const Offset(0.25, 0), + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + child: AnimatedOpacity( + opacity: _shown ? 1 : 0, + duration: const Duration(milliseconds: 200), + child: Semantics( + container: true, + liveRegion: true, + label: widget.entry.message, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: Container( + decoration: BoxDecoration( + color: tokens.dropdownBackground, + border: Border(left: BorderSide(color: style.color, width: 3)), + borderRadius: BorderRadius.circular(4), + boxShadow: [BoxShadow(color: tokens.shadowAmbient, blurRadius: 8, offset: const Offset(0, 2))], + ), + padding: const EdgeInsets.fromLTRB(clideInsetText, clideInsetStandard, clideInsetStandard, clideInsetStandard), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClideIcon(style.icon, size: clideIconStandard, color: style.color), + const SizedBox(width: clideGapStandard), + Flexible(child: ClideText(widget.entry.message, fontSize: clideFontBody, maxLines: 3, overflow: TextOverflow.ellipsis)), + const SizedBox(width: clideGapStandard), + Semantics( + button: true, + label: 'Dismiss notification', + child: ClideTappable( + onTap: widget.onDismiss, + builder: (ctx, hovered, _) => ClideIcon( + PhosphorIcons.xMark, + size: clideIconStandard, + color: hovered ? tokens.globalForeground : tokens.globalTextMuted, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +/// Renders the live [ToastService] queue anchored bottom-right (T-50). +/// Mounted as a child of the app-root [Stack] (alongside the palette / +/// quick-open overlays). Occupies only its corner — non-toast space stays +/// interactive. Newest toast sits at the bottom. +class ToastOverlay extends StatefulWidget { + const ToastOverlay({super.key}); + + @override + State createState() => _ToastOverlayState(); +} + +class _ToastOverlayState extends State { + ToastService? _toast; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final next = ClideKernel.of(context).toast; + if (!identical(_toast, next)) { + _toast?.removeListener(_onChanged); + _toast = next; + _toast!.addListener(_onChanged); + } + } + + void _onChanged() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _toast?.removeListener(_onChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final entries = _toast?.entries ?? const []; + if (entries.isEmpty) return const SizedBox.shrink(); + return Positioned( + right: clideGapMajor, + bottom: clideGapMajor, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (final e in entries) ...[ + ClideToast(key: ValueKey('toast.${e.id}'), entry: e, onDismiss: () => _toast?.dismiss(e.id)), + const SizedBox(height: clideGapStandard), + ], + ], + ), + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 68c6ce0a..de2c9c55 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -14,6 +14,7 @@ export 'src/clide_filter_box.dart'; export 'src/clide_markdown.dart'; export 'src/clide_marquee.dart'; export 'src/clide_svg_view.dart'; +export 'src/clide_toast.dart'; export 'src/clide_icon.dart'; export 'src/clide_icon_rail.dart'; export 'src/clide_palette.dart'; diff --git a/test/builtin/git/git_controller_test.dart b/test/builtin/git/git_controller_test.dart new file mode 100644 index 00000000..91ed5efe --- /dev/null +++ b/test/builtin/git/git_controller_test.dart @@ -0,0 +1,201 @@ +/// Tests for GitController — the git sidebar's state model. Drives every +/// action against a stubbed DaemonClient (no real git), covering status +/// hydration + parsing, the stage/unstage/discard/commit/stash verbs, the +/// event-driven refresh, and the push/pull MessageBus toast emitters (T-50). +/// +/// The KernelFixture is built in setUp (real file I/O — kept out of any +/// fake-async zone); these are plain async tests, so the toast auto-dismiss +/// Timers are real and cancelled by fixture dispose in tearDown. +library; + +import 'package:clide/builtin/git/src/git_controller.dart'; +import 'package:clide/clide.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() async => f.dispose()); + + GitController controller() { + final c = GitController(ipc: f.ipc, events: f.services.events, messages: f.services.messages); + addTearDown(c.dispose); + return c; + } + + IpcResponse ok([Map data = const {}]) => IpcResponse.ok(id: '', data: data); + IpcResponse err(String message) => IpcResponse.err( + id: '', + error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: message), + ); + + // Let the broadcast streams (bus / events) deliver. + Future settle() => Future.delayed(Duration.zero); + + group('load + status parsing', () { + test('hydrates branch / counts / file lists from git.status', () async { + f.ipc.stub( + 'git.status', + (_) async => ok({ + 'branch': 'main', + 'upstream': 'origin/main', + 'ahead': 2, + 'behind': 1, + 'clean': false, + 'hasConflicts': true, + 'staged': [ + {'path': 'a.dart'}, + ], + 'unstaged': [ + {'path': 'b.dart'}, + ], + 'untracked': [ + {'path': 'c.dart'}, + ], + 'conflicted': [ + {'path': 'd.dart'}, + ], + })); + final c = controller(); + await c.load(); + expect(c.loading, isFalse); + expect(c.branch, 'main'); + expect(c.upstream, 'origin/main'); + expect(c.ahead, 2); + expect(c.behind, 1); + expect(c.isClean, isFalse); + expect(c.hasConflicts, isTrue); + expect(c.staged.single['path'], 'a.dart'); + expect(c.unstaged.single['path'], 'b.dart'); + expect(c.untracked.single['path'], 'c.dart'); + expect(c.conflicted.single['path'], 'd.dart'); + }); + + test('defaults missing fields and tolerates a non-list payload', () async { + f.ipc.stub('git.status', (_) async => ok({'branch': 'dev', 'staged': 'not-a-list'})); + final c = controller(); + await c.load(); + expect(c.branch, 'dev'); + expect(c.ahead, 0); + expect(c.behind, 0); + expect(c.isClean, isTrue); + expect(c.hasConflicts, isFalse); + expect(c.staged, isEmpty); + }); + + test('records an error when git.status fails', () async { + f.ipc.stub('git.status', (_) async => err('not a repo')); + final c = controller(); + await c.load(); + expect(c.loading, isFalse); + expect(c.error, 'not a repo'); + }); + }); + + group('staging verbs pass through ok', () { + test('stage / stageAll / unstage / discard', () async { + for (final cmd in ['git.stage', 'git.stage-all', 'git.unstage', 'git.discard']) { + f.ipc.stub(cmd, (_) async => ok()); + } + final c = controller(); + expect(await c.stage(['a']), isTrue); + expect(await c.stageAll(), isTrue); + expect(await c.unstage(['a']), isTrue); + expect(await c.discard(['a']), isTrue); + }); + + test('a failing verb returns false', () async { + f.ipc.stub('git.stage', (_) async => err('locked')); + expect(await controller().stage(['a']), isFalse); + }); + }); + + group('commit', () { + test('returns the new hash on success', () async { + f.ipc.stub('git.commit', (args) async { + expect(args['message'], 'msg'); + return ok({'hash': 'abc123'}); + }); + expect(await controller().commit('msg'), 'abc123'); + }); + + test('returns null and records the error on failure', () async { + f.ipc.stub('git.commit', (_) async => err('nothing staged')); + final c = controller(); + expect(await c.commit('msg'), isNull); + expect(c.error, 'nothing staged'); + c.clearError(); + expect(c.error, isNull); + }); + }); + + group('stash', () { + test('omits the message arg when none is given', () async { + f.ipc.stub('git.stash', (args) async { + expect(args.containsKey('message'), isFalse); + return ok(); + }); + expect(await controller().stash(), isTrue); + }); + + test('passes the message arg when given', () async { + f.ipc.stub('git.stash', (args) async { + expect(args['message'], 'wip'); + return ok(); + }); + expect(await controller().stash(message: 'wip'), isTrue); + }); + }); + + group('push / pull raise toasts on the bus', () { + test('push success → success toast', () async { + f.ipc.stub('git.status', (_) async => ok({'upstream': 'origin/main'})); + f.ipc.stub('git.push', (_) async => ok()); + final c = controller(); + await c.load(); // sets _upstream so the message includes it + expect(await c.push(), isTrue); + await settle(); + expect(f.services.toast.entries.any((e) => e.severity == ToastSeverity.success && e.message == 'Pushed to origin/main'), isTrue); + }); + + test('push failure → error toast + error state', () async { + f.ipc.stub('git.push', (_) async => err('rejected')); + final c = controller(); + expect(await c.push(), isFalse); + expect(c.error, 'rejected'); + await settle(); + expect(f.services.toast.entries.any((e) => e.severity == ToastSeverity.error && e.message.contains('rejected')), isTrue); + }); + + test('pull success + failure raise toasts', () async { + f.ipc.stub('git.pull', (_) async => ok()); + final c = controller(); + expect(await c.pull(), isTrue); + await settle(); + expect(f.services.toast.entries.any((e) => e.severity == ToastSeverity.success && e.message.startsWith('Pulled')), isTrue); + f.services.toast.clear(); + f.ipc.stub('git.pull', (_) async => err('diverged')); + expect(await c.pull(), isFalse); + await settle(); + expect(f.services.toast.entries.any((e) => e.severity == ToastSeverity.error && e.message.contains('diverged')), isTrue); + }); + }); + + group('event-driven refresh', () { + test('a git.changed event triggers a reload; non-git events are ignored', () async { + var statusCalls = 0; + f.ipc.stub('git.status', (_) async { + statusCalls++; + return ok({'branch': 'main'}); + }); + controller(); + f.services.events.emit(DaemonEvent(subsystem: 'pty', kind: 'output', data: const {}, ts: DateTime.utc(2026))); + f.services.events.emit(DaemonEvent(subsystem: 'git', kind: 'git.changed', data: const {}, ts: DateTime.utc(2026))); + await settle(); + expect(statusCalls, 1); // only the git.changed reload + }); + }); +} diff --git a/test/kernel/src/toast_test.dart b/test/kernel/src/toast_test.dart new file mode 100644 index 00000000..9e60954b --- /dev/null +++ b/test/kernel/src/toast_test.dart @@ -0,0 +1,127 @@ +/// Unit tests for ToastService (T-50): MessageBus consumption, queueing, +/// the visible cap, manual dismiss, clear, and per-severity auto-dismiss. +/// +/// Timer-driven cases run under testWidgets so flutter_test's fake clock +/// fires the Timers on `tester.pump(duration)` — no `fake_async` dependency. +library; + +import 'package:clide/kernel/src/events/message_bus.dart'; +import 'package:clide/kernel/src/toast.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + // A service wired to a fresh bus; both torn down. + (ToastService, MessageBus) make({int maxVisible = 4}) { + final bus = MessageBus(); + final t = ToastService(messages: bus, maxVisible: maxVisible); + addTearDown(t.dispose); + addTearDown(bus.dispose); + return (t, bus); + } + + group('ToastService — MessageBus consumption', () { + test('shows a toast for each message published to the toast channel', () async { + final (t, bus) = make(); + publishToast(bus, 'builtin.git', 'Pushed to origin/main', severity: ToastSeverity.success, duration: Duration.zero); + await Future.delayed(Duration.zero); // let the broadcast stream deliver + expect(t.entries.single.message, 'Pushed to origin/main'); + expect(t.entries.single.severity, ToastSeverity.success); + }); + + test('ignores messages without a string "message" payload', () async { + final (t, bus) = make(); + bus.publish('x', toastChannel, {'severity': 'error'}); // no message + bus.publish('x', 'other-channel', {'message': 'nope'}); // wrong channel + await Future.delayed(Duration.zero); + expect(t.entries, isEmpty); + }); + + test('parses severity by name and defaults unknown to info', () async { + final (t, bus) = make(); + bus.publish('x', toastChannel, {'message': 'a', 'severity': 'warning', 'durationMs': 0}); + bus.publish('x', toastChannel, {'message': 'b', 'severity': 'bogus', 'durationMs': 0}); + await Future.delayed(Duration.zero); + expect(t.entries.map((e) => e.severity), [ToastSeverity.warning, ToastSeverity.info]); + }); + }); + + group('ToastService — queue', () { + test('show appends entries with monotonic ids and the given severity', () { + final (t, _) = make(); + final a = t.show('hello', duration: Duration.zero); + final b = t.show('there', severity: ToastSeverity.error, duration: Duration.zero); + expect(a, isNot(b)); + expect(t.entries.map((e) => e.message), ['hello', 'there']); + expect(t.entries.first.severity, ToastSeverity.info); // default + expect(t.entries.last.severity, ToastSeverity.error); + }); + + test('caps the visible count, dropping the oldest', () { + final (t, _) = make(maxVisible: 2); + t.show('1', duration: Duration.zero); + t.show('2', duration: Duration.zero); + t.show('3', duration: Duration.zero); + expect(t.entries.map((e) => e.message), ['2', '3']); + }); + + test('dismiss removes by id and is a no-op for unknown ids', () { + final (t, _) = make(); + final id = t.show('x', duration: Duration.zero); + var fired = 0; + t.addListener(() => fired++); + t.dismiss(99999); // unknown → no notify + expect(fired, 0); + t.dismiss(id); + expect(t.entries, isEmpty); + expect(fired, 1); + }); + + test('clear drops everything (and only notifies when non-empty)', () { + final (t, _) = make(); + var fired = 0; + t.addListener(() => fired++); + t.clear(); // already empty → no notify + expect(fired, 0); + t.show('a', duration: Duration.zero); + t.show('b', duration: Duration.zero); + fired = 0; + t.clear(); + expect(t.entries, isEmpty); + expect(fired, 1); + }); + }); + + group('ToastService — auto-dismiss timers', () { + testWidgets('fires after the default duration; errors linger longer', (tester) async { + final (t, _) = make(); + await tester.pumpWidget(const SizedBox()); + t.show('info'); + t.show('boom', severity: ToastSeverity.error); + await tester.pump(const Duration(seconds: 3, milliseconds: 900)); + expect(t.entries.length, 2); + await tester.pump(const Duration(milliseconds: 200)); // past 4s + expect(t.entries.map((e) => e.message), ['boom']); + await tester.pump(const Duration(seconds: 4)); // past 8s + expect(t.entries, isEmpty); + }); + + testWidgets('a zero/sticky duration never auto-dismisses', (tester) async { + final (t, _) = make(); + await tester.pumpWidget(const SizedBox()); + t.show('sticky', duration: Duration.zero); + await tester.pump(const Duration(minutes: 5)); + expect(t.entries.length, 1); + }); + + testWidgets('dispose cancels pending timers (no lingering callbacks)', (tester) async { + final bus = MessageBus(); + addTearDown(bus.dispose); + final t = ToastService(messages: bus); + await tester.pumpWidget(const SizedBox()); + t.show('x'); + t.dispose(); + await tester.pump(const Duration(seconds: 10)); // cancelled timer must not fire + }); + }); +} diff --git a/test/widgets/src/clide_toast_test.dart b/test/widgets/src/clide_toast_test.dart new file mode 100644 index 00000000..0c4619b5 --- /dev/null +++ b/test/widgets/src/clide_toast_test.dart @@ -0,0 +1,85 @@ +/// Widget tests for ClideToast + ToastOverlay (T-50): render per severity, +/// manual dismiss, the live-region a11y contract, and the overlay reflecting +/// the ToastService queue. Toasts are shown sticky (Duration.zero) so no +/// auto-dismiss Timer is left pending at teardown. +library; + +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'; + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() async => f.dispose()); + + Widget host(Widget child) => Directionality( + textDirection: TextDirection.ltr, + child: ClideKernel( + services: f.services, + child: ClideTheme( + controller: f.services.theme, + child: Align(alignment: Alignment.topLeft, child: child), + ), + ), + ); + + testWidgets('renders the message and calls onDismiss when the × is tapped', (tester) async { + var dismissed = false; + await tester.pumpWidget(host(ClideToast( + entry: const ToastEntry(id: 1, message: 'Pushed to origin/main', severity: ToastSeverity.success), + onDismiss: () => dismissed = true, + ))); + await tester.pump(const Duration(milliseconds: 300)); // settle entrance + + expect(find.text('Pushed to origin/main'), findsOneWidget); + // The dismiss × is the toast's only ClideTappable. + await tester.tap(find.byType(ClideTappable)); + await tester.pump(); + expect(dismissed, isTrue); + }); + + testWidgets('exposes the message as a live region (a11y)', (tester) async { + await tester.pumpWidget(host(ClideToast( + entry: const ToastEntry(id: 1, message: 'Heads up', severity: ToastSeverity.warning), + onDismiss: () {}, + ))); + await tester.pump(const Duration(milliseconds: 300)); + + // The message is wrapped in a live-region Semantics so screen readers + // announce it when it appears. + expect( + find.byWidgetPredicate((w) => w is Semantics && w.properties.liveRegion == true && w.properties.label == 'Heads up'), + findsOneWidget, + ); + }); + + testWidgets('overlay renders a card per queued toast and dismiss removes one', (tester) async { + await tester.pumpWidget(host(const SizedBox( + width: 800, + height: 600, + child: Stack(children: [ToastOverlay()]), + ))); + await tester.pump(); + expect(find.byType(ClideToast), findsNothing); + + f.services.toast.show('one', duration: Duration.zero); + f.services.toast.show('two', severity: ToastSeverity.error, duration: Duration.zero); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byType(ClideToast), findsNWidgets(2)); + expect(find.text('one'), findsOneWidget); + expect(find.text('two'), findsOneWidget); + + // Dismissing via the service updates the overlay (tap-to-dismiss is + // covered by the ClideToast test above; here we assert reactivity). + f.services.toast.dismiss(f.services.toast.entries.first.id); + await tester.pump(); + expect(find.byType(ClideToast), findsNWidgets(1)); + expect(find.text('one'), findsNothing); + expect(find.text('two'), findsOneWidget); + }); +}