Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 28 additions & 8 deletions claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ dotnet run -c Release --project src/DiffEngineViewer.Windows.Benchmarks -- --fil

**Target Frameworks:**
- DiffEngine library: net462, net472, net48, net6.0, net7.0, net8.0, net9.0, net10.0 (Windows also includes .NET Framework targets)
- DiffEngineTray: net10.0 Windows Forms application, on DiffEngineTray.Core (net10.0, every platform)
- DiffEngineTray: net10.0 Windows Forms application, and a net10.0 Linux head, both on DiffEngineTray.Core (net10.0, every platform)
- Tests: net10.0 (net48 on Windows)

## Architecture Overview
Expand Down Expand Up @@ -615,11 +615,14 @@ the comment there about not caching "nothing staged" asks for.
**Native shim (`native/`), used by the Mac and Linux heads only:**
- The build and packaging of the heads is PlatformHeads' (`D:\Code\PlatformHeads`,
github.com/SimonCropp/PlatformHeads), which BuildMonitor shares: it was worked out here and
copied there, and fixes made here did not reach the copy. Six pins name one tag of it and move
together: the `uses:` of `.github/workflows/build-native.yml`, the `GIT_TAG` in
`native/CMakeLists.txt`, `PlatformHeads`, `PlatformHeads.Core` and
`PlatformHeads.WindowsForms` in `Directory.Packages.props`, and the `PlatformHeads` package
in `native/swift/Package.swift`. So is the native runtime under both heads' drawing: the C++
copied there, and fixes made here did not reach the copy. Seven pins name a tag of it: the
`uses:` of `.github/workflows/build-native.yml`, the `GIT_TAG` in
`native/CMakeLists.txt`, `PlatformHeads`, `PlatformHeads.Core`, `PlatformHeads.Linux` (the
Linux tray's) and `PlatformHeads.WindowsForms` in `Directory.Packages.props`, which move
together, and the `PlatformHeads` package
in `native/swift/Package.swift`. The packages are at 0.7.1 and the native three at 0.6.0,
since all that has changed natively since is `StatusItem`, which the viewer does not use; the
macOS tray will move them. So is the native runtime under both heads' drawing: the C++
one `deview.cpp` calls as `ph::` (the window, GLFW's input as it happened, which frames are
built and drawn, the renderer, the fonts and fontconfig, and the label helpers) and the Swift
`PlatformHeadsMac` the AppKit head imports (`HeadWindow`, `EventPump`, `TerminateGuard`,
Expand Down Expand Up @@ -701,6 +704,23 @@ the comment there about not caching "nothing staged" asks for.
`IssueLauncher.Declined` declines in the core, which has no toolkit to ask with, and the head
hands it `IssueQuestion`'s MessageBox first thing in Main. The core is signed and named in
DiffEngine's `InternalsVisibleTo`, and names the head and both test projects in its own
- `src/DiffEngineTray.Linux` is the Linux head, an `Exe` named `DiffEngineTray` as the Windows
head is, so the core's `InternalsVisibleTo` names it too. No window and no native library: its
icon and menu are PlatformHeads.Linux's `StatusNotifierTray`, which the core's `TrayLoop` hands
a model a tenth of a second and polls for what was chosen, since PlatformHeads answers a
panel's `AboutToShow` itself and the layout has to be current before anyone opens the menu.
Its one option is "Run at startup", a checked item over
PlatformHeads' `XdgAutostart` (`TrayActions.RunAtLogin`), and the settings file is not read: the
hot keys are Windows', and target on left and the launch limit are user environment variables,
which .NET sets only on Windows. No purge, which needs a folder picker. The debug view is
written beside the logs and opened (`DebugReportFile`). The icon is the Windows head's `.ico`,
whose one picture is a 48 pixel, 32 bit bitmap and needs no decoder (`IconPixels`). With no
StatusNotifierWatcher on the bus, as on a stock GNOME, it runs anyway, holding the queue and
tracking what test runs send it, and says so once. A session ending is SIGTERM or SIGHUP,
heard by PlatformHeads.Core's `SessionEndSignals` and handed `TraySessionEnd.For`, which the
Windows head hands its `SessionEndWindow`. Seen working in a `dotnet/sdk:10.0` container on a
private `dbus-daemon` with a stand-in watcher: the menu, run at startup, the debug view, exit
and SIGTERM. Not packed yet
- The core's tests are `DiffEngineTray.Core.Tests` (net10.0), which runs in the unix CI job as
well as on Windows. What the menu says and does is `TrayMenuTests` there, as text.
`DiffEngineTray.Tests` keeps what needs the head: how the menu is drawn, the forms, the
Expand Down Expand Up @@ -846,8 +866,8 @@ the comment there about not caching "nothing staged" asks for.
carried out marks it (`QueueEntry.Written`), a batch keeps a marked or awaited delete
(`ViewerSession.HeldReason`), a single accept still deletes, and `TrackedEntry.DeleteAgain`
carries the mark across the watch's re-read. "Discard (n)" runs wholly on a worker. A scan that fails three times running is one
balloon for the run. Every tray keeps the `SessionEndWindow` now, and `Program.SessionEnding`
stages the queue where it is held here and then removes the version marker.
balloon for the run. Every Windows tray keeps the `SessionEndWindow` now, and
`TraySessionEnd.For` stages the queue where it is held here and then removes the version marker.
- `ITrackedFiles.Version` is what `ListingTag` asks about the tracked files: the identity of the
tracked objects, so `TrackedMove` and `TrackedDelete` must stay immutable in everything a
listing carries. `TrackedDelete.Written` is the exception, and its changes are counted into the
Expand Down
4 changes: 4 additions & 0 deletions src/DiffEngine.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@
<Project Path="DiffEngineTray.Core/DiffEngineTray.Core.csproj">
<BuildType Solution="Release-NotWindows|*" Project="Release" />
</Project>
<!-- The Linux head, which is managed only and so builds everywhere too. -->
<Project Path="DiffEngineTray.Linux/DiffEngineTray.Linux.csproj">
<BuildType Solution="Release-NotWindows|*" Project="Release" />
</Project>
<!-- Its tests, which run everywhere too. -->
<Project Path="DiffEngineTray.Core.Tests/DiffEngineTray.Core.Tests.csproj">
<BuildType Solution="Release-NotWindows|*" Project="Release" />
Expand Down
28 changes: 28 additions & 0 deletions src/DiffEngineTray.Core.Tests/DebugReportFileTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
public class DebugReportFileTests :
IDisposable
{
/// <summary>
/// The report a head with no window opens, which is the one the Windows head shows.
/// </summary>
[Test]
public async Task TheReportIsWrittenWhole()
{
await using var tracker = new RecordingTracker();
tracker.AddDelete(file);
var now = new DateTime(2026, 10, 28, 12, 0, 0);
var path = Path.Combine(directory, "debug.txt");

DebugReportFile.Write(tracker, path, now);

await Assert.That(await File.ReadAllTextAsync(path)).IsEqualTo(DebugReport.Build(tracker, now));
}

string directory = Directory.CreateTempSubdirectory("DebugReportFileTests").FullName;
string file = Path.GetTempFileName();

public void Dispose()
{
File.Delete(file);
Directory.Delete(directory, true);
}
}
91 changes: 91 additions & 0 deletions src/DiffEngineTray.Core.Tests/IconPixelsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using System.Buffers.Binary;

public class IconPixelsTests
{
/// <summary>
/// A bitmap in an icon is stored from the bottom row up, blue first, and a panel takes the
/// pixels from the top row down, alpha first. Four pixels that all differ, so a row or a
/// channel in the wrong place shows. The tray's own icons are symmetrical, and could not.
/// </summary>
[Test]
public async Task RowsFromTheTopAndAlphaFirst()
{
byte[] stored =
[
// The bottom row: blue, then green
255, 0, 0, 255, 0, 255, 0, 255,
// The top row: red, then white half seen through
0, 0, 255, 255, 255, 255, 255, 128
];

byte[] expected =
[
// Red, then white half seen through
255, 255, 0, 0, 128, 255, 255, 255,
// Blue, then green
255, 0, 0, 255, 255, 0, 255, 0
];

var pixels = IconPixels.FromIco(Ico(2, stored), 2)!;

// As text, so the order counts and a failure says where
await Assert.That(Convert.ToHexString(pixels)).IsEqualTo(Convert.ToHexString(expected));
}

/// <summary>
/// The tray's two icons, each a 48 pixel bitmap, by the colour they are mostly: the active one
/// is red, which a red and blue swapped would make blue.
/// </summary>
[Test]
[Arguments("default.ico", "#FFFFFF")]
[Arguments("active.ico", "#CC0000")]
public async Task TheTraysIcons(string name, string mostSeen)
{
var pixels = IconPixels.FromIco(await File.ReadAllBytesAsync(Image(name)), 48)!;

await Assert.That(pixels.Length).IsEqualTo(48 * 48 * 4);
await Assert.That(MostSeen(pixels)).IsEqualTo(mostSeen);
}

/// <summary>
/// The icons have no other size, and a panel scales the one they have.
/// </summary>
[Test]
public async Task NoOtherSize() =>
await Assert.That(IconPixels.FromIco(await File.ReadAllBytesAsync(Image("default.ico")), 16)).IsNull();

static string MostSeen(byte[] pixels) =>
pixels
.Chunk(4)
.Where(_ => _[0] == 255)
.Select(_ => $"#{_[1]:X2}{_[2]:X2}{_[3]:X2}")
.GroupBy(_ => _)
.MaxBy(_ => _.Count())!
.Key;

/// <summary>
/// An icon of one 32 bit bitmap, with only what <see cref="IconPixels" /> reads filled in.
/// </summary>
static byte[] Ico(int size, byte[] bgra)
{
const int directory = 6 + 16;
const int header = 40;
var mask = (size + 31) / 32 * 4 * size;
var ico = new byte[directory + header + bgra.Length + mask];
BinaryPrimitives.WriteUInt16LittleEndian(ico.AsSpan(2), 1);
BinaryPrimitives.WriteUInt16LittleEndian(ico.AsSpan(4), 1);
ico[6] = (byte) size;
ico[7] = (byte) size;
BinaryPrimitives.WriteInt32LittleEndian(ico.AsSpan(6 + 12), directory);
BinaryPrimitives.WriteInt32LittleEndian(ico.AsSpan(directory), header);
BinaryPrimitives.WriteInt32LittleEndian(ico.AsSpan(directory + 4), size);
BinaryPrimitives.WriteInt32LittleEndian(ico.AsSpan(directory + 8), size * 2);
BinaryPrimitives.WriteUInt16LittleEndian(ico.AsSpan(directory + 12), 1);
BinaryPrimitives.WriteUInt16LittleEndian(ico.AsSpan(directory + 14), 32);
bgra.CopyTo(ico, directory + header);
return ico;
}

static string Image(string name, [CallerFilePath] string source = "") =>
Path.Combine(Path.GetDirectoryName(source)!, "..", "DiffEngineTray", "Images", name);
}
34 changes: 34 additions & 0 deletions src/DiffEngineTray.Core.Tests/StandInTray.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/// <summary>
/// A tray standing in for a panel: it keeps every model it is handed, and reports the items a test
/// chooses, one a poll, as a panel's clicks arrive.
/// </summary>
sealed class StandInTray : ITray<TrayIcon>
{
readonly ConcurrentQueue<string> chosen = new();
readonly TaskCompletionSource<TrayModel<TrayIcon>> first = new(TaskCreationOptions.RunContinuationsAsynchronously);

public ConcurrentQueue<TrayModel<TrayIcon>> Applied { get; } = new();

public Task<TrayModel<TrayIcon>> FirstApplied =>
first.Task;

public void Choose(string id) =>
chosen.Enqueue(id);

public void Apply(TrayModel<TrayIcon> model)
{
Applied.Enqueue(model);
first.TrySetResult(model);
}

public void Notify(TrayNotification notification)
{
}

public TrayInput Poll() =>
chosen.TryDequeue(out var id) ? new(ClickedItem: id) : new();

public void Dispose()
{
}
}
127 changes: 127 additions & 0 deletions src/DiffEngineTray.Core.Tests/TrayLoopTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/// <summary>
/// The loop a head with no window runs, with <see cref="StandInTray" /> for the panel: what the
/// tray is handed, and what is done when an item on it is chosen.
/// </summary>
public class TrayLoopTests :
IDisposable
{
[Test]
public async Task TheTrayIsHandedTheFixedItemsThenTheTracked()
{
await using var tracker = new RecordingTracker();
tracker.AddDelete(file);
using var cancel = new CancelSource();
var actions = Actions(cancel);
var tray = new StandInTray();

var loop = TrayLoop.Run(tray, tracker, actions, () => TrayIcon.Active, cancel.Token);
var model = await tray.FirstApplied.WaitAsync(wait);
await cancel.CancelAsync();
await loop;

await Assert.That(model.Icon).IsEqualTo(TrayIcon.Active);
await Assert.That(model.Tooltip).IsEqualTo("DiffEngineTray");
await Assert.That(model.Items).IsEquivalentTo(
[.. TrayMenu.Fixed(actions).Items, .. TrayMenu.Tracked(tracker, actions).Items]);
}

[Test]
public async Task AChosenItemIsDone()
{
await using var tracker = new RecordingTracker();
tracker.AddDelete(file);
using var cancel = new CancelSource();
var tray = new StandInTray();
var loop = TrayLoop.Run(tray, tracker, Actions(cancel), () => TrayIcon.Default, cancel.Token);
await tray.FirstApplied.WaitAsync(wait);

tray.Choose($"delete:{file}:accept");
await Until(() => tracker.Deletes.Count == 0);
await cancel.CancelAsync();
await loop;

await Assert.That(File.Exists(file)).IsFalse();
}

[Test]
public async Task ExitEndsTheLoop()
{
await using var tracker = new RecordingTracker();
using var cancel = new CancelSource();
var tray = new StandInTray();
var loop = TrayLoop.Run(tray, tracker, Actions(cancel), () => TrayIcon.Default, cancel.Token);
await tray.FirstApplied.WaitAsync(wait);

tray.Choose("exit");

await loop.WaitAsync(wait);
}

/// <summary>
/// Thrown out of the loop, a failing item would have ended the tray with it.
/// </summary>
[Test]
public async Task AnItemThatThrowsLeavesTheLoopRunning()
{
await using var tracker = new RecordingTracker();
using var cancel = new CancelSource();
var actions = Actions(cancel) with
{
DebugView = () => throw new("The report could not be written.")
};
var tray = new StandInTray();
var loop = TrayLoop.Run(tray, tracker, actions, () => TrayIcon.Default, cancel.Token);
await tray.FirstApplied.WaitAsync(wait);

tray.Choose("debug");
tray.Choose("exit");

await loop.WaitAsync(wait);
}

/// <summary>
/// Nowhere to show a menu, as on a GNOME without the AppIndicator extension: the tray holds
/// the queue until the session ends it.
/// </summary>
[Test]
public async Task WithNoTrayTheLoopWaitsForTheEnd()
{
await using var tracker = new RecordingTracker();
using var cancel = new CancelSource();
var loop = TrayLoop.Run(null, tracker, Actions(cancel), () => TrayIcon.Default, cancel.Token);

await Assert.That(loop.IsCompleted).IsFalse();
await cancel.CancelAsync();
await loop.WaitAsync(wait);
}

static TrayActions Actions(CancelSource cancel) =>
new(
Exit: cancel.Cancel,
Options: null,
DebugView: () => { },
Purge: null,
OpenDiffTool: _ => { });

static async Task Until(Func<bool> condition)
{
var until = DateTime.UtcNow + wait;
while (!condition())
{
if (DateTime.UtcNow > until)
{
throw new TimeoutException("The chosen item was not done.");
}

await Task.Delay(20);
}
}

// Long, since only a loop that never gets there takes it
static readonly TimeSpan wait = TimeSpan.FromSeconds(30);

string file = Path.GetTempFileName();

public void Dispose() =>
File.Delete(file);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Exit [glyph: exit]
Run at startup [unchecked]
Debug view [glyph: options]
Open logs [glyph: folder]
Raise issue [glyph: link]
Loading
Loading