open a file in the diff panel via clide ui open diff

Adds diff as a fourth ui.open target. The diff extension now retains an
app-scoped DiffController and subscribes to its builtin.diff/selection
channel: a selection reveals the diff tab and focuses the file, which
the view scrolls into view and highlights. Retaining the controller in
the extension (not the view) lets a focus survive the tab being
revealed/remounted, mirroring the ReaderNav viewers.

Closes T-233.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 10:50:12 +02:00
co-authored by Claude Opus 4.8
parent ba230d4139
commit 870b773381
11 changed files with 303 additions and 13 deletions
+2 -1
View File
@@ -44,7 +44,8 @@ one). `clide <subsystem>` 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 <reader> <ref>` — 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 <path>` 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
@@ -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;
+1
View File
@@ -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 <reader> <id|path>'' 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 <reader> <id|path>'' 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);
+2
View File
@@ -18,6 +18,8 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
- `clide ui open diff <path>` 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 <path>
[--caption …]` renders an image inline (PNG/JPG/JPEG/GIF/WebP/BMP),
clide-owned and display-only (D-78). The path is resolved workspace-relative
+23
View File
@@ -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 <path>` (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<void> 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();
}
+67 -9
View File
@@ -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<DiffView> createState() => _DiffViewState();
@@ -19,19 +25,51 @@ class DiffView extends StatefulWidget {
class _DiffViewState extends State<DiffView> {
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<String, GlobalKey> _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<DiffView> {
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<DiffView> {
),
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<String, Object?> 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),
+34 -2
View File
@@ -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<String> get dependsOn => const [];
/// App-scoped controller retained across tab reveal/remount so a
/// `ui open diff <path>` 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<Message>? _sub;
@override
List<ContributionPoint> 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<void> activate(ClideExtensionContext ctx) async {
_controller = DiffController(ipc: ctx.ipc, events: ctx.events)..load();
// `clide ui open diff <path>` 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<void> deactivate() async {
await _sub?.cancel();
_sub = null;
_controller?.dispose();
_controller = null;
}
}
+4 -1
View File
@@ -26,11 +26,14 @@ typedef MessagePublisher = void Function(String publisher, String channel, Map<S
/// The readers `ui.open` can target → (bus publisher id, payload key the
/// reader reads its entry from). Matches each reader's `ReaderNav` dataKey
/// (tickets/decisions key on `id`; markdown on `path`).
/// (tickets/decisions key on `id`; markdown on `path`). `diff` is not a
/// ReaderNav reader — it keys on `path` and its extension subscribes to the
/// same `selection` channel to reveal its tab + focus the file (T-233).
const Map<String, ({String publisher, String dataKey})> _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
@@ -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<String, Object?> data) => IpcResponse.ok(id: '', data: data);
Map<String, Object?> _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<void>.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<void>.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<void>.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<void>.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');
});
}
+71
View File
@@ -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<String, Object?> data) => IpcResponse.ok(id: '', data: data);
Map<String, Object?> _file(String path) => {'path': path, 'hunks': const []};
ClideText _header(WidgetTester tester, String path) => tester.widget<ClideText>(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);
});
}
+10
View File
@@ -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'}));