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>
323 lines
11 KiB
Dart
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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|