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:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user