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
+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;
}
}