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:
@@ -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
|
||||
|
||||
@@ -166,6 +166,7 @@ class _RootShellState extends State<_RootShell> {
|
||||
const ClidePalette(),
|
||||
const QuickOpenOverlay(),
|
||||
const Positioned.fill(child: _WelcomeOverlay()),
|
||||
const ToastOverlay(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<String, Object?> 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<void> settle() => Future<void>.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
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<void>.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<void>.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<void>.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
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user