add toast notification system (T-50)

Non-modal operation-feedback toasts, bottom-right: a ClideToast card per
severity (success/warning/error/info), auto-dismiss (errors linger), queue
with a visible cap, slide+fade in, manual dismiss, live-region a11y.

ToastService is a MessageBus consumer — components raise a toast by publishing
to the 'toast' channel (publishToast helper), so emitters stay decoupled from
the UI. GitController's push/pull are the first emitters. ToastOverlay mounts
in the app-root Stack.

Also adds comprehensive GitController coverage: importing it for the toast
emitter test first pulled the whole file into the coverage denominator, so the
controller is now tested end to end (status/stage/commit/stash/push/pull).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 23:14:42 +02:00
co-authored by Claude Opus 4.8
parent 55ed3013e1
commit 1f09abcfd7
12 changed files with 747 additions and 4 deletions
+1
View File
@@ -166,6 +166,7 @@ class _RootShellState extends State<_RootShell> {
const ClidePalette(),
const QuickOpenOverlay(),
const Positioned.fill(child: _WelcomeOverlay()),
const ToastOverlay(),
],
),
),
+19 -3
View File
@@ -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<DaemonEvent>().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<DaemonEvent>? _eventSub;
String? _branch;
@@ -112,8 +122,11 @@ class GitController extends ChangeNotifier {
Future<bool> 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<bool> 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;
+1 -1
View File
@@ -35,7 +35,7 @@ class _GitPanelViewState extends State<GitPanelView> {
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());
}
+1
View File
@@ -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';
+6
View File
@@ -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<KernelServices> 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();
+140
View File
@@ -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<Message>? _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<ToastEntry> _entries = [];
final Map<int, Timer> _timers = {};
/// Live toasts, oldest first. Unmodifiable.
List<ToastEntry> 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();
}
}
+160
View File
@@ -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<ClideToast> createState() => _ClideToastState();
}
class _ClideToastState extends State<ClideToast> {
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<ToastOverlay> createState() => _ToastOverlayState();
}
class _ToastOverlayState extends State<ToastOverlay> {
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 <ToastEntry>[];
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),
],
],
),
);
}
}
+1
View File
@@ -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';