diff --git a/.claude/skills/clide/SKILL.md b/.claude/skills/clide/SKILL.md index ac5fa415..8907e6a0 100644 --- a/.claude/skills/clide/SKILL.md +++ b/.claude/skills/clide/SKILL.md @@ -44,7 +44,8 @@ one). `clide ` with no verb, or an unknown command, prints usage. - `clide pane list`, `clide editor active` — narrower snapshots. - **Drive** — make the UI do something: - `clide ui open ` — open a doc in a GUI reader - (`tickets`/`decisions` by id, `markdown` by path): "look at this with me." + (`tickets`/`decisions` by id, `markdown`/`diff` by path): "look at this + with me." `diff ` reveals the diff tab and scrolls to that file. - `clide ui toast "message" [--severity success|warning|error|info]` — raise a toast on the user's screen: "tests green", "push failed". - pane/editor/files/git verbs — see `clide capabilities` for the current set diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 81e71094..0d9faf0b 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -400,3 +400,4 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-249', 'status', 'ready', 'done', NULL, '2026-06-06 08:21:40', '2026-06-06 08:21:40', '2026-06-06 08:21:40', NULL, '996d52afaf650a7d5021184a673d09a3', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-248', 'status', 'review', 'done', NULL, '2026-06-06 08:31:25', '2026-06-06 08:31:25', '2026-06-06 08:31:25', NULL, '231d1bb74f6f154f7e888760f1c4e3a6', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-233', 'status', 'backlog', 'in_progress', NULL, '2026-06-06 08:33:42', '2026-06-06 08:33:42', '2026-06-06 08:33:42', NULL, '1ce52a9aabbedd3f4164095e1a0a291e', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-233', 'status', 'in_progress', 'done', NULL, '2026-06-06 08:50:12', '2026-06-06 08:50:12', '2026-06-06 08:50:12', NULL, 'ec94ca747fc852db6bf514b4d5079085', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 46fd6642..0b94ce57 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -920,3 +920,4 @@ New scope for this ticket: 2. Reconcile with the already-shipped `/clide` skill rather than authoring anew — confirm it points at the working verb and covers the conventions. Net: this is now a verify-and-reconcile task, not a build-from-scratch one. Related: T-249, T-208.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-06 07:21:17', '2026-06-06 08:31:25', NULL, 'd2d8d30c578bce8229924619542d5aa7', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-233', 'task', 'T-208', 'Open a file in the diff panel for review via ui.open', 'Follow-up from T-231 (2026-06-03). T-231 added ''clide ui open '' for the ReaderNav-backed readers (tickets/decisions/markdown), which open by subscribing to a MessageBus ''selection''. The DIFF panel is not covered: DiffController (lib/builtin/diff/src/diff_controller.dart) loads the git working-tree diff via the git.diff IPC verb and refreshes on git.changed events; it has no ''selection''/''focus'' bus channel, so ui.open has no entry point to target a file. Note: because the diff panel shows the working tree, an agent''s uncommitted edits already APPEAR there on the next git.changed refresh -- what''s missing is (a) bringing the diff tab to the front and (b) scrolling/filtering to a specific file. Proposed: give the diff view a bus subscription (publisher ''builtin.diff'', channel ''selection''/''focus'', {path}) that reveals its tab and focuses/filters that file''s diff (DiffController.load already accepts a paths filter), then add ''diff'' as a 4th ui.open target. Related gap: pane.focus is advisory-only (pane_commands.dart) so ''bring tab to front'' isn''t truly wired -- the reveal likely needs the same mechanism the readers use to reveal their tab on selection. Depends in spirit on T-232 (CLI args) only if a non-bus path is chosen; the bus path works regardless.', 'in_progress', 'medium', NULL, NULL, 'D-6', '2026-06-03 13:29:59', '2026-06-06 08:33:42', NULL, '41f3cdaedfb436b6102ce6aebbbc0161', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-233', 'task', 'T-208', 'Open a file in the diff panel for review via ui.open', 'Follow-up from T-231 (2026-06-03). T-231 added ''clide ui open '' for the ReaderNav-backed readers (tickets/decisions/markdown), which open by subscribing to a MessageBus ''selection''. The DIFF panel is not covered: DiffController (lib/builtin/diff/src/diff_controller.dart) loads the git working-tree diff via the git.diff IPC verb and refreshes on git.changed events; it has no ''selection''/''focus'' bus channel, so ui.open has no entry point to target a file. Note: because the diff panel shows the working tree, an agent''s uncommitted edits already APPEAR there on the next git.changed refresh -- what''s missing is (a) bringing the diff tab to the front and (b) scrolling/filtering to a specific file. Proposed: give the diff view a bus subscription (publisher ''builtin.diff'', channel ''selection''/''focus'', {path}) that reveals its tab and focuses/filters that file''s diff (DiffController.load already accepts a paths filter), then add ''diff'' as a 4th ui.open target. Related gap: pane.focus is advisory-only (pane_commands.dart) so ''bring tab to front'' isn''t truly wired -- the reveal likely needs the same mechanism the readers use to reveal their tab on selection. Depends in spirit on T-232 (CLI args) only if a non-bus path is chosen; the bus path works regardless.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-03 13:29:59', '2026-06-06 08:50:12', NULL, 'f2e24a5a804b0d3b95bef0be2f8327b7', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dd353bd..a55e349b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- `clide ui open diff ` reveals the diff tab and scrolls to that file's + diff, highlighting its header — the diff-panel arm of `ui open` (T-233). - Image cards in the Claude conversation log: `clide image show [--caption …]` renders an image inline (PNG/JPG/JPEG/GIF/WebP/BMP), clide-owned and display-only (D-78). The path is resolved workspace-relative diff --git a/lib/builtin/diff/src/diff_controller.dart b/lib/builtin/diff/src/diff_controller.dart index 5b5a632b..5cb71d4a 100644 --- a/lib/builtin/diff/src/diff_controller.dart +++ b/lib/builtin/diff/src/diff_controller.dart @@ -31,6 +31,24 @@ class DiffController extends ChangeNotifier { bool _loading = false; bool get loading => _loading; + String? _focusPath; + + /// The file the view should scroll into view + highlight (T-233), set by + /// [focus] when `clide ui open diff ` (or a UI reveal) targets a file. + /// Null until something focuses a path; cleared when that file leaves the + /// diff (e.g. its changes are reverted). + String? get focusPath => _focusPath; + + /// Focus [path] within the working-tree diff (T-233): record it so the view + /// scrolls it into view + highlights it, and reload so the latest edits to + /// that file are present even if the `git.changed` refresh hasn't landed yet. + /// Revealing the diff tab itself is the caller's job (the diff extension). + void focus(String path) { + _focusPath = path; + notifyListeners(); + unawaited(load(staged: _staged)); + } + /// Load diffs. Optionally filter to [paths] and toggle [staged]. Future load({ bool staged = false, @@ -54,6 +72,11 @@ class DiffController extends ChangeNotifier { _error = null; _diffs = _castList(r.data['diffs']); + // Drop a focus whose file no longer has changes, so the view doesn't keep + // a highlight on something that's gone. + if (_focusPath != null && !_diffs.any((d) => d['path'] == _focusPath)) { + _focusPath = null; + } notifyListeners(); } diff --git a/lib/builtin/diff/src/diff_view.dart b/lib/builtin/diff/src/diff_view.dart index cad9d69c..bc2d47ed 100644 --- a/lib/builtin/diff/src/diff_view.dart +++ b/lib/builtin/diff/src/diff_view.dart @@ -11,7 +11,13 @@ import 'package:flutter/widgets.dart'; import 'diff_controller.dart'; class DiffView extends StatefulWidget { - const DiffView({super.key}); + const DiffView({super.key, this.controller}); + + /// When supplied, render this controller instead of creating one. The diff + /// extension passes an app-scoped controller it retains so a `ui open diff` + /// focus survives the tab being revealed/remounted (T-233); the view then + /// neither owns nor disposes it. Null → self-owned, as before. + final DiffController? controller; @override State createState() => _DiffViewState(); @@ -19,19 +25,51 @@ class DiffView extends StatefulWidget { class _DiffViewState extends State { DiffController? _controller; + bool _ownsController = false; + final ScrollController _scroll = ScrollController(); + + /// One key per file path in the current diff, so [focus] can scroll the + /// matching section into view. Rebuilt lazily as paths appear. + final Map _fileKeys = {}; + + /// The focus we last scrolled to, so a repeat build doesn't re-scroll. + String? _scrolledTo; @override void didChangeDependencies() { super.didChangeDependencies(); if (_controller != null) return; - final kernel = ClideKernel.of(context); - _controller = DiffController(ipc: kernel.ipc, events: kernel.events); - unawaited(_controller!.load()); + final injected = widget.controller; + if (injected != null) { + _controller = injected; + _ownsController = false; + } else { + final kernel = ClideKernel.of(context); + _controller = DiffController(ipc: kernel.ipc, events: kernel.events); + _ownsController = true; + unawaited(_controller!.load()); + } + _controller!.addListener(_onControllerChanged); + } + + /// When the controller's focus changes, scroll that file into view after the + /// frame it lays out in. Keeps the highlight (paint) to [build]. + void _onControllerChanged() { + final path = _controller?.focusPath; + if (path == null || path == _scrolledTo) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + final ctx = _fileKeys[path]?.currentContext; + if (ctx == null) return; // file not in the diff (no changes) → nothing to scroll to + _scrolledTo = path; + unawaited(Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 200), alignment: 0.05)); + }); } @override void dispose() { - _controller?.dispose(); + _controller?.removeListener(_onControllerChanged); + if (_ownsController) _controller?.dispose(); + _scroll.dispose(); super.dispose(); } @@ -39,6 +77,12 @@ class _DiffViewState extends State { Widget build(BuildContext context) { final c = _controller; if (c == null) return const SizedBox.shrink(); + // Forget keys for files no longer in the diff so the map can't grow without + // bound across reloads. + _fileKeys.removeWhere((path, _) => !c.diffs.any((d) => d['path'] == path)); + if (c.focusPath != _scrolledTo && !c.diffs.any((d) => d['path'] == c.focusPath)) { + _scrolledTo = null; // focus left the diff; allow re-scroll if it returns + } return ListenableBuilder( listenable: c, builder: (context, _) { @@ -74,12 +118,19 @@ class _DiffViewState extends State { ), Expanded( child: SingleChildScrollView( + controller: _scroll, padding: const EdgeInsets.symmetric(vertical: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - for (final diff in c.diffs) _FileDiff(diff: diff, controller: c), + for (final diff in c.diffs) + _FileDiff( + key: _fileKeys[diff['path'] as String? ?? ''] ??= GlobalKey(), + diff: diff, + controller: c, + focused: (diff['path'] as String?) == c.focusPath, + ), ], ), ), @@ -140,10 +191,14 @@ class _DiffToolbar extends StatelessWidget { } class _FileDiff extends StatelessWidget { - const _FileDiff({required this.diff, required this.controller}); + const _FileDiff({super.key, required this.diff, required this.controller, this.focused = false}); final Map diff; final DiffController controller; + /// This file is the current focus target (T-233) — its header gets a focus + /// accent so the eye lands on it after the scroll. + final bool focused; + @override Widget build(BuildContext context) { final tokens = ClideTheme.of(context).surface; @@ -171,14 +226,17 @@ class _FileDiff extends StatelessWidget { children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - color: tokens.panelHeader, + decoration: BoxDecoration( + color: tokens.panelHeader, + border: focused ? Border(left: BorderSide(color: tokens.globalFocus, width: 2)) : null, + ), child: Row( children: [ Expanded( child: ClideText( path, fontSize: clideFontCaption, - color: tokens.panelHeaderForeground, + color: focused ? tokens.globalFocus : tokens.panelHeaderForeground, ), ), if (additions > 0) ClideText('+$additions ', fontSize: clideFontCaption, color: tokens.statusSuccess), diff --git a/lib/builtin/diff/src/extension.dart b/lib/builtin/diff/src/extension.dart index 03cd8a39..d17d9e5a 100644 --- a/lib/builtin/diff/src/extension.dart +++ b/lib/builtin/diff/src/extension.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:clide/builtin/diff/src/diff_controller.dart'; import 'package:clide/builtin/diff/src/diff_view.dart'; import 'package:clide/extension/extension.dart'; import 'package:clide/kernel/kernel.dart'; @@ -8,10 +11,17 @@ class DiffExtension extends ClideExtension { @override String get title => 'Diff'; @override - String get version => '0.1.0'; + String get version => '0.2.0'; @override List get dependsOn => const []; + /// App-scoped controller retained across tab reveal/remount so a + /// `ui open diff ` focus survives the view being (re)built (T-233). + /// Built in [activate] where the kernel ipc/events are in scope; the view + /// renders it but does not own it. + DiffController? _controller; + StreamSubscription? _sub; + @override List get contributions => [ TabContribution( @@ -21,7 +31,29 @@ class DiffExtension extends ClideExtension { titleKey: 'tab.title', i18nNamespace: id, priority: -70, - build: (_) => const DiffView(), + build: (_) => DiffView(controller: _controller), ), ]; + + @override + Future activate(ClideExtensionContext ctx) async { + _controller = DiffController(ipc: ctx.ipc, events: ctx.events)..load(); + // `clide ui open diff ` publishes a 'selection' here (T-233, the + // diff-panel arm of D-6 parity). Reveal the diff tab and focus the file — + // the same reveal mechanism the ReaderNav viewers use for their tabs. + _sub = ctx.messages.subscribe(publisher: id, channel: 'selection').listen((msg) { + final path = msg.data['path']; + if (path is! String || path.isEmpty) return; + ctx.panels.activateTab(Slots.workspace, 'diff.view'); + _controller?.focus(path); + }); + } + + @override + Future deactivate() async { + await _sub?.cancel(); + _sub = null; + _controller?.dispose(); + _controller = null; + } } diff --git a/lib/src/daemon/ui_command.dart b/lib/src/daemon/ui_command.dart index e4f276aa..16f07556 100644 --- a/lib/src/daemon/ui_command.dart +++ b/lib/src/daemon/ui_command.dart @@ -26,11 +26,14 @@ typedef MessagePublisher = void Function(String publisher, String channel, Map _readers = { 'tickets': (publisher: 'builtin.tickets', dataKey: 'id'), 'decisions': (publisher: 'builtin.decisions', dataKey: 'id'), 'markdown': (publisher: 'builtin.markdown', dataKey: 'path'), + 'diff': (publisher: 'builtin.diff', dataKey: 'path'), }; /// Severities the toast verb accepts — mirrors `ToastSeverity` (kept as a diff --git a/test/builtin/diff/diff_controller_test.dart b/test/builtin/diff/diff_controller_test.dart new file mode 100644 index 00000000..02490746 --- /dev/null +++ b/test/builtin/diff/diff_controller_test.dart @@ -0,0 +1,88 @@ +/// Tests DiffController's load + focus behaviour (T-233): loading hydrates the +/// diff list and clears stale focus; focus() records a path, notifies, and +/// reloads; git.changed refreshes. +library; + +import 'package:clide/builtin/diff/src/diff_controller.dart'; +import 'package:clide/clide.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/fake_ipc.dart'; + +IpcResponse _ok(Map data) => IpcResponse.ok(id: '', data: data); + +Map _file(String path) => {'path': path, 'hunks': const []}; + +void main() { + late DaemonBus bus; + late FakeDaemonClient ipc; + late DiffController c; + late int diffCalls; + + setUp(() { + bus = DaemonBus(); + ipc = FakeDaemonClient(log: Logger(), events: bus); + diffCalls = 0; + ipc.stub('git.diff', (_) async { + diffCalls++; + return _ok({ + 'diffs': [_file('lib/a.dart'), _file('lib/b.dart')], + }); + }); + c = DiffController(ipc: ipc, events: bus); + }); + + tearDown(() async { + c.dispose(); + await bus.dispose(); + }); + + test('load hydrates the diff list', () async { + await c.load(); + expect(c.diffs.map((d) => d['path']), ['lib/a.dart', 'lib/b.dart']); + expect(c.error, isNull); + }); + + test('focus records the path, notifies, and reloads', () async { + await c.load(); + final before = diffCalls; + var notified = 0; + c.addListener(() => notified++); + + c.focus('lib/b.dart'); + expect(c.focusPath, 'lib/b.dart'); + expect(notified, greaterThan(0)); + // focus() reloads so the latest edits to that file are present. + await Future.delayed(Duration.zero); + expect(diffCalls, greaterThan(before)); + }); + + test('a focus whose file leaves the diff is dropped on reload', () async { + c.focus('lib/gone.dart'); + expect(c.focusPath, 'lib/gone.dart'); + // The reload triggered by focus() returns a list without that file. + await Future.delayed(Duration.zero); + expect(c.focusPath, isNull); + }); + + test('a focus that stays in the diff survives reload', () async { + c.focus('lib/a.dart'); + await Future.delayed(Duration.zero); + expect(c.focusPath, 'lib/a.dart'); + }); + + test('git.changed refreshes the diff', () async { + await c.load(); + final before = diffCalls; + bus.emit(DaemonEvent(subsystem: 'git', kind: 'git.changed', data: const {}, ts: DateTime.now().toUtc())); + await Future.delayed(Duration.zero); + expect(diffCalls, greaterThan(before)); + }); + + test('a git.diff error surfaces on the controller', () async { + ipc.stub('git.diff', (_) async => IpcResponse.err(id: '', error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'boom'))); + await c.load(); + expect(c.error, 'boom'); + }); +} diff --git a/test/builtin/diff/diff_view_test.dart b/test/builtin/diff/diff_view_test.dart new file mode 100644 index 00000000..d60004d5 --- /dev/null +++ b/test/builtin/diff/diff_view_test.dart @@ -0,0 +1,71 @@ +/// Widget tests for DiffView focus (T-233): an injected controller renders its +/// diffs, and focusing a file highlights its header with the focus accent. +library; + +import 'package:clide/builtin/diff/src/diff_controller.dart'; +import 'package:clide/builtin/diff/src/diff_view.dart'; +import 'package:clide/clide.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/fake_ipc.dart'; +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +IpcResponse _ok(Map data) => IpcResponse.ok(id: '', data: data); +Map _file(String path) => {'path': path, 'hunks': const []}; + +ClideText _header(WidgetTester tester, String path) => tester.widget(find.byWidgetPredicate((w) => w is ClideText && w.data == path)); + +void main() { + late KernelFixture f; + late DaemonBus bus; + late FakeDaemonClient ipc; + late DiffController c; + + setUp(() async { + f = await KernelFixture.create(); + bus = DaemonBus(); + ipc = FakeDaemonClient(log: Logger(), events: bus); + ipc.stub( + 'git.diff', + (_) async => _ok({ + 'diffs': [_file('lib/a.dart'), _file('lib/b.dart')] + })); + c = DiffController(ipc: ipc, events: bus); + }); + + tearDown(() async { + c.dispose(); + await bus.dispose(); + f.dispose(); + }); + + testWidgets('renders the injected controller\'s diffs', (tester) async { + await c.load(); + await tester.pumpWidget(harness(f, DiffView(controller: c))); + await tester.pumpAndSettle(); + expect(find.byWidgetPredicate((w) => w is ClideText && w.data == 'lib/a.dart'), findsOneWidget); + expect(find.byWidgetPredicate((w) => w is ClideText && w.data == 'lib/b.dart'), findsOneWidget); + }); + + testWidgets('focusing a file highlights its header with the focus accent', (tester) async { + await c.load(); + await tester.pumpWidget(harness(f, DiffView(controller: c))); + await tester.pumpAndSettle(); + + final ctx = tester.element(find.byType(DiffView)); + final tokens = ClideTheme.of(ctx).surface; + // Before focus: header uses the plain panel-header foreground. + expect(_header(tester, 'lib/b.dart').color, tokens.panelHeaderForeground); + + c.focus('lib/b.dart'); + await tester.pumpAndSettle(); + + // After focus: the targeted file's header switches to the focus accent. + expect(_header(tester, 'lib/b.dart').color, tokens.globalFocus); + // The other file stays unhighlighted. + expect(_header(tester, 'lib/a.dart').color, tokens.panelHeaderForeground); + }); +} diff --git a/test/daemon/ui_command_test.dart b/test/daemon/ui_command_test.dart index b11e7e94..1ca60a4e 100644 --- a/test/daemon/ui_command_test.dart +++ b/test/daemon/ui_command_test.dart @@ -46,6 +46,16 @@ void main() { expect(published[1].data, {'path': 'docs/initial-plan.md'}); }); + test('diff keys on path and publishes a builtin.diff selection (T-233)', () async { + wire(); + final r = await open(['diff', 'lib/main.dart']); + expect(r.ok, isTrue, reason: r.error?.message); + expect(r.data['opened'], isTrue); + expect(published.single.publisher, 'builtin.diff'); + expect(published.single.channel, 'selection'); + expect(published.single.data, {'path': 'lib/main.dart'}); + }); + test('named args work too (reader/ref)', () async { wire(); final r = await d.dispatch(IpcRequest(id: '1', cmd: 'ui.open', args: {'reader': 'tickets', 'ref': 'T-7'}));