Files
clide/lib/builtin/diff/src/diff_view.dart
T
jpmschweitzerandClaude Opus 4.8 6d0ebab721 chore: adopt Dart 3.9 toolchain — honest floor + tall-style reformat (T-353)
Raise the declared minimums in pubspec.yaml to what our deps already
require: Flutter >=3.35.0 / Dart >=3.9.0 (was 3.19.0 / 3.5.0). alchemist
0.12 needs Flutter 3.32; Dart 3.9 first ships in Flutter 3.35, so 3.35 is
the binding floor. Pin the exact build toolchain in .fvmrc (Flutter
3.44.1).

Moving to the Dart 3.9 language level switches `dart format` to the new
"tall" style and enables two new lints. This commit is the resulting
mechanical churn, isolated from any behaviour change:
  - whole-tree `dart format` reformat (tall style)
  - `dart fix` for unnecessary_underscores + use_null_aware_elements

No runtime behaviour change; `make test` green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:11:53 +02:00

323 lines
11 KiB
Dart

/// Workspace tab rendering unified diffs with hunk-level
/// stage/unstage actions.
library;
import 'dart:async';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'diff_controller.dart';
class DiffView extends StatefulWidget {
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();
}
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 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?.removeListener(_onControllerChanged);
if (_ownsController) _controller?.dispose();
_scroll.dispose();
super.dispose();
}
@override
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, _) {
final tokens = ClideTheme.of(context).surface;
return Semantics(
label: 'diff view',
container: true,
explicitChildNodes: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_DiffToolbar(controller: c),
if (c.error != null)
Padding(
padding: const EdgeInsets.all(12),
child: ClideText(c.error!, color: tokens.statusError),
),
if (c.loading && c.diffs.isEmpty) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
if (!c.loading && c.diffs.isEmpty && c.error == null)
Padding(padding: const EdgeInsets.all(12), child: ClideText(c.showStaged ? 'No staged changes.' : 'No unstaged changes.', muted: true)),
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(
key: _fileKeys[diff['path'] as String? ?? ''] ??= GlobalKey(),
diff: diff,
controller: c,
focused: (diff['path'] as String?) == c.focusPath,
),
],
),
),
),
],
),
);
},
);
}
}
class _DiffToolbar extends StatelessWidget {
const _DiffToolbar({required this.controller});
final DiffController controller;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: tokens.panelBorder)),
),
child: Row(
children: [
Semantics(
button: true,
toggled: !controller.showStaged,
label: 'show unstaged changes',
child: GestureDetector(
onTap: controller.showStaged ? controller.toggleStaged : null,
child: ClideText('Unstaged', fontSize: clideFontCaption, color: controller.showStaged ? tokens.globalTextMuted : tokens.globalForeground),
),
),
const SizedBox(width: 12),
Semantics(
button: true,
toggled: controller.showStaged,
label: 'show staged changes',
child: GestureDetector(
onTap: controller.showStaged ? null : controller.toggleStaged,
child: ClideText('Staged', fontSize: clideFontCaption, color: controller.showStaged ? tokens.globalForeground : tokens.globalTextMuted),
),
),
],
),
);
}
}
class _FileDiff extends StatelessWidget {
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;
final path = diff['path'] as String? ?? '';
final isBinary = diff['binary'] as bool? ?? false;
final isNew = diff['new'] as bool? ?? false;
final isDeleted = diff['deleted'] as bool? ?? false;
final isRenamed = diff['renamed'] as bool? ?? false;
final additions = (diff['additions'] as num?)?.toInt() ?? 0;
final removals = (diff['removals'] as num?)?.toInt() ?? 0;
final hunks = (diff['hunks'] as List?) ?? const [];
final meta = <String>[];
if (isNew) meta.add('new file');
if (isDeleted) meta.add('deleted');
if (isRenamed) {
final oldPath = diff['oldPath'] as String?;
if (oldPath != null) meta.add('renamed from $oldPath');
}
if (isBinary) meta.add('binary');
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
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: focused ? tokens.globalFocus : tokens.panelHeaderForeground),
),
if (additions > 0) ClideText('+$additions ', fontSize: clideFontCaption, color: tokens.statusSuccess),
if (removals > 0) ClideText('-$removals', fontSize: clideFontCaption, color: tokens.statusError),
],
),
),
if (meta.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
child: ClideText(meta.join(' · '), fontSize: clideFontCaption, muted: true),
),
if (!isBinary)
for (final hunk in hunks) _HunkView(hunk: (hunk as Map).cast<String, Object?>(), filePath: path, controller: controller),
const SizedBox(height: 8),
],
);
}
}
class _HunkView extends StatelessWidget {
const _HunkView({required this.hunk, required this.filePath, required this.controller});
final Map<String, Object?> hunk;
final String filePath;
final DiffController controller;
@override
Widget build(BuildContext context) {
final header = hunk['header'] as String? ?? '';
final lines = (hunk['lines'] as List?) ?? const [];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
child: ClideText(header, fontSize: clideFontMono, muted: true, fontFamily: clideMonoFamily),
),
for (final lineObj in lines) _DiffLineRow(line: (lineObj as Map).cast<String, Object?>()),
],
);
}
}
class _DiffLineRow extends StatelessWidget {
const _DiffLineRow({required this.line});
final Map<String, Object?> line;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final kind = line['kind'] as String? ?? 'context';
final text = line['text'] as String? ?? '';
final oldLineNo = line['oldLineNo'] as num?;
final newLineNo = line['newLineNo'] as num?;
final (Color bg, Color fg) = switch (kind) {
'addition' => (tokens.statusSuccess.withValues(alpha: 0.15), tokens.statusSuccess),
'removal' => (tokens.statusError.withValues(alpha: 0.15), tokens.statusError),
_ => (const Color(0x00000000), tokens.globalForeground),
};
final prefix = switch (kind) {
'addition' => '+',
'removal' => '-',
'header' => '',
_ => ' ',
};
return Container(
color: bg,
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
children: [
SizedBox(
width: 36,
child: ClideText(
oldLineNo != null ? '${oldLineNo.toInt()}' : '',
fontSize: clideFontMono,
muted: true,
fontFamily: clideMonoFamily,
textAlign: TextAlign.right,
),
),
const SizedBox(width: 2),
SizedBox(
width: 36,
child: ClideText(
newLineNo != null ? '${newLineNo.toInt()}' : '',
fontSize: clideFontMono,
muted: true,
fontFamily: clideMonoFamily,
textAlign: TextAlign.right,
),
),
const SizedBox(width: 4),
ClideText(prefix, fontSize: clideFontMono, color: fg, fontFamily: clideMonoFamily),
const SizedBox(width: 2),
Expanded(
child: ClideText(text, fontSize: clideFontMono, color: fg, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.clip),
),
],
),
);
}
}