diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json
index 5aa6b39c..41797703 100644
--- a/src/ClaudeDo.Localization/locales/de.json
+++ b/src/ClaudeDo.Localization/locales/de.json
@@ -358,8 +358,11 @@
"filesHeader": "Dateien",
"binary": "Binärdatei — kein Text-Diff",
"empty": "Kein Inhalt",
+ "unifiedView": "Zusammen",
"splitView": "Nebeneinander",
- "wrapLines": "Zeilenumbruch"
+ "wrapLines": "Zeilenumbruch",
+ "paneBase": "BASIS",
+ "paneWorktree": "WORKTREE"
},
"worktreesOverview": {
"refresh": "Aktualisieren",
diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json
index 41213be1..c8f18116 100644
--- a/src/ClaudeDo.Localization/locales/en.json
+++ b/src/ClaudeDo.Localization/locales/en.json
@@ -358,8 +358,11 @@
"filesHeader": "Files",
"binary": "Binary file — no text diff",
"empty": "No content",
+ "unifiedView": "Unified",
"splitView": "Side by side",
- "wrapLines": "Wrap lines"
+ "wrapLines": "Wrap lines",
+ "paneBase": "BASE",
+ "paneWorktree": "WORKTREE"
},
"worktreesOverview": {
"refresh": "Refresh",
diff --git a/src/ClaudeDo.Ui/Design/IslandStyles.axaml b/src/ClaudeDo.Ui/Design/IslandStyles.axaml
index 41d37329..c6870765 100644
--- a/src/ClaudeDo.Ui/Design/IslandStyles.axaml
+++ b/src/ClaudeDo.Ui/Design/IslandStyles.axaml
@@ -108,6 +108,9 @@
M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.03 7.03 0 0 0-1.69-.98l-.38-2.65a.5.5 0 0 0-.5-.42h-4a.5.5 0 0 0-.5.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.5.5 0 0 0 .12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65a.5.5 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65a.5.5 0 0 0 .5.42h4a.5.5 0 0 0 .5-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.5.5 0 0 0-.12-.64l-2.11-1.65z
+
+ M3 5 H21 V7 H3 Z M3 11 H17 A4 4 0 0 1 17 19 H12 V21 L7 18 L12 15 V17 H17 A2 2 0 0 0 17 13 H3 Z
+
F0 M12 2 C7 2 4 5.5 4 10 C4 13.5 6 16 8 17.5 L8 19 C8 20 8.9 21 10 21 L10 18.5 L14 18.5 L14 21 C15.1 21 16 20 16 19 L16 17.5 C18 16 20 13.5 20 10 C20 5.5 17 2 12 2 Z M8.5 8 L8.5 12 L11 12 L11 8 Z M13 8 L13 12 L15.5 12 L15.5 8 Z
@@ -323,8 +326,9 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs b/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs
index 21838697..b6670ebc 100644
--- a/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs
+++ b/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs
@@ -1,19 +1,14 @@
using System;
using System.Collections.Generic;
using System.Globalization;
-using System.IO;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
-using Avalonia.Threading;
-using Avalonia.VisualTree;
-using AvaloniaEdit;
-using AvaloniaEdit.Document;
using AvaloniaEdit.Editing;
using AvaloniaEdit.Rendering;
using AvaloniaEdit.TextMate;
using ClaudeDo.Ui.ViewModels.Modals;
-using TextMateSharp.Grammars;
+using Seg = ClaudeDo.Ui.Views.Controls.DiffEditorSetup.Seg;
namespace ClaudeDo.Ui.Views.Controls;
@@ -52,9 +47,6 @@ public partial class DiffTextView : UserControl
set => SetValue(WrapLinesProperty, value);
}
- // Grammars and themes are process-wide; loading the registry per control would be wasteful.
- private static readonly RegistryOptions Registry = new(ThemeName.DarkPlus);
-
private TextMate.Installation? _leftTm, _rightTm;
// Row lookup per editor, indexed by document line number (1-based). Populated on rebuild
@@ -66,18 +58,16 @@ public partial class DiffTextView : UserControl
private DiffLineNumberMargin? _leftMargin, _rightMargin;
- private ScrollViewer? _leftScroll, _rightScroll;
- private bool _scrollHooked;
- private bool _syncing;
private bool _renderersInstalled;
public DiffTextView()
{
InitializeComponent();
- _leftTm = LeftEditor.InstallTextMate(Registry);
- _rightTm = RightEditor.InstallTextMate(Registry);
+ _leftTm = DiffEditorSetup.InstallHighlighting(LeftEditor);
+ _rightTm = DiffEditorSetup.InstallHighlighting(RightEditor);
ReloadFile();
- HookScrollSync();
+ new VerticalScrollSync(new[] { LeftEditor, RightEditor },
+ isActive: () => IsSplit, anchorByLine: () => WrapLines);
}
/// Brushes and the mono typeface only resolve once the control is in the visual tree —
@@ -121,6 +111,7 @@ public partial class DiffTextView : UserControl
var split = IsSplit;
RightEditor.IsVisible = split;
PaneDivider.IsVisible = split;
+ PaneHeader.IsVisible = split;
Grid.SetColumnSpan(LeftEditor, split ? 1 : 3);
if (split)
@@ -146,7 +137,7 @@ public partial class DiffTextView : UserControl
RebuildMargins();
ApplyWrap();
- ApplyGrammar(File?.Path);
+ DiffEditorSetup.ApplyGrammar(File?.Path, _leftTm, _rightTm);
InvalidateRenderers();
}
@@ -171,21 +162,6 @@ public partial class DiffTextView : UserControl
RightEditor.WordWrap = WrapLines;
}
- /// Only hunks are in the document, not whole files, so TextMate's line-by-line state can
- /// be wrong at a fragment boundary (a line inside a block comment may highlight as code).
- /// Accepted — every fragment-based diff viewer has this.
- private void ApplyGrammar(string? path)
- {
- if (string.IsNullOrEmpty(path)) return;
- var ext = Path.GetExtension(path);
- if (string.IsNullOrEmpty(ext)) return;
- var language = Registry.GetLanguageByExtension(ext);
- if (language is null) return;
- var scope = Registry.GetScopeByLanguageId(language.Id);
- _leftTm?.SetGrammar(scope);
- _rightTm?.SetGrammar(scope);
- }
-
/// The margin's column layout depends on split vs unified, so it is rebuilt rather than
/// reconfigured whenever the layout changes.
private void RebuildMargins()
@@ -205,10 +181,7 @@ public partial class DiffTextView : UserControl
RightEditor.TextArea.LeftMargins.Insert(0, _rightMargin);
}
- private IBrush Brush(string key, Color fallback) =>
- this.TryFindResource(key, out var value) && value is IBrush brush
- ? brush
- : new SolidColorBrush(fallback);
+ private IBrush Brush(string key, Color fallback) => DiffEditorSetup.Brush(this, key, fallback);
private void InstallRenderers()
{
@@ -231,58 +204,6 @@ public partial class DiffTextView : UserControl
RightEditor.TextArea.TextView.InvalidateVisual();
}
- /// The editors' ScrollViewers only exist once the template has been applied.
- private void HookScrollSync()
- {
- if (_scrollHooked) return;
- _scrollHooked = true;
- Dispatcher.UIThread.Post(() =>
- {
- _leftScroll = LeftEditor.FindDescendantOfType();
- _rightScroll = RightEditor.FindDescendantOfType();
- if (_leftScroll is not null) _leftScroll.ScrollChanged += OnPaneScroll;
- if (_rightScroll is not null) _rightScroll.ScrollChanged += OnPaneScroll;
- }, DispatcherPriority.Loaded);
- }
-
- private void OnPaneScroll(object? sender, ScrollChangedEventArgs e)
- {
- if (_syncing || !IsSplit || _leftScroll is null || _rightScroll is null) return;
- var fromLeft = ReferenceEquals(sender, _leftScroll);
- var source = fromLeft ? _leftScroll : _rightScroll;
- var target = fromLeft ? _rightScroll : _leftScroll;
- var targetEditor = fromLeft ? RightEditor : LeftEditor;
- var sourceEditor = fromLeft ? LeftEditor : RightEditor;
-
- _syncing = true;
- try
- {
- if (WrapLines)
- {
- // Line heights differ once lines wrap, so anchor on the top visible line.
- // ScrollToLine/ScrollTo are "bring into view" primitives (they park the line
- // near mid-viewport past a hysteresis threshold) — top-align by computing the
- // target's own vertical offset for that document line and assigning it
- // directly, the same way the non-wrap branch assigns Offset below. That is
- // synchronous, so the same offset-comparison guard makes the echo self-terminate.
- var topLine = sourceEditor.TextArea.TextView.VisualLines is { Count: > 0 } visualLines
- ? visualLines[0].FirstDocumentLine.LineNumber
- : (int?)null;
- if (topLine is { } line && line >= 1 && line <= targetEditor.Document.LineCount)
- {
- var targetTop = targetEditor.TextArea.TextView.GetVisualTopByDocumentLine(line);
- if (Math.Abs(target.Offset.Y - targetTop) > 0.5)
- target.Offset = new Vector(target.Offset.X, targetTop);
- }
- }
- else if (Math.Abs(target.Offset.Y - source.Offset.Y) > 0.5)
- {
- target.Offset = new Vector(target.Offset.X, source.Offset.Y);
- }
- }
- finally { _syncing = false; }
- }
-
/// What one document line represents, for the margin and the background renderers.
internal sealed record RowInfo(
AlignedSide Kind, int? OldNo, int? NewNo, IReadOnlyList Spans);
@@ -428,13 +349,4 @@ public partial class DiffTextView : UserControl
}
}
}
-
- /// A minimal for geometry queries.
- private readonly struct Seg : ISegment
- {
- public Seg(int offset, int length) { Offset = offset; Length = length; }
- public int Offset { get; }
- public int Length { get; }
- public int EndOffset => Offset + Length;
- }
}
diff --git a/src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml b/src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml
index 003cdaf2..89fbd4e1 100644
--- a/src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml
+++ b/src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml
@@ -30,10 +30,24 @@
-
+
-
-
+
+
+
+
+
+
+
+
+