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
34 changes: 31 additions & 3 deletions claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ flowchart LR
Owner{{"inline queue owner: whoever bound 3493<br/>first — the tray at login, else a viewer"}}
Files[("source files and<br/>staged patch files")]

Engine -->|"3492 moves, deletes (one way)"| Tray
Engine -->|"3493 inline, settle"| Owner
Engine -.->|"launch with patch on stdin,<br/>when nothing owns 3493"| Window
Engine -->|"3492 moves, deletes (one way),<br/>when a tray is running"| Tray
Engine -->|"3493 inline, settle, and<br/>moves and deletes with no tray"| Owner
Engine -.->|"launch with patch on stdin, or<br/>with a delete, when nothing owns 3493"| Window
Tray <-->|"3493 list, accept, focus"| Owner
Window <-->|"3493 listfull, accept, discard"| Owner
Plugin -->|"3493 settle, after accepting"| Owner
Expand Down Expand Up @@ -139,6 +139,11 @@ keeps offering a snapshot that is already in the source.
- Does **not** reference DiffEngine. It links `Inline/*.cs`, `Protocol/*.cs` and
`Tray/TrayDetector.cs` as source, because DiffEngine publishes and embeds the heads and a
reference back would be a cycle.
- Holds pending moves and deletes itself when it owns the queue, which is what happens with no
tray installed. They are ordinary `QueueEntryKind.Move`/`Delete` entries — the same ones an
attached viewer draws for the tray's — so nothing about how they look or what their menu offers
is per arrangement. Only who applies them differs: `ViewerActions.MoveFile`/`DeleteFile` here,
a forwarded key there.
- Single instance by socket bind on 3493 (`DiffEngine_ViewerPort`): whoever binds owns the queue,
and a process that fails to bind talks to the owner instead. A viewer that does not own one runs
with `--attach`: it polls `listfull`, derives every pane from the patches that come back, and
Expand Down Expand Up @@ -187,6 +192,29 @@ keeps offering a snapshot that is already in the source.
- Either host runs the same `InlineQueue` from DiffEngine, so the two cannot differ on what
accepting or settling means. Owning it means accepting runs on a listener thread rather than on
a render loop, which is where `InlineApplier`'s ten second mutex wait used to sit.
- Which arrangement is live decides *which process applies an accept*, so both are pinned by
`TrayViewerSyncTest` — a real tray and a real `SessionState` over a real socket, asserting that
an accept, discard, sweep or settle from either surface leaves the other showing the same thing.
It sits in DiffEngineTray.Tests because that is the only project that can reference both halves
(the viewer aliased, since it links DiffEngine's sources and so declares the same type names).
The wire carries `ok` and a message, not an apply status, so `RemoteInlineHost` decides applied
versus failed by re-reading the listing: an owner keeps a failed entry pending, and taking `ok`
at face value used to report it as accepted while the viewer was still showing it.
- A viewer that owns the queue answers about the tray's snapshots and about its **own** pending
files. Moves and deletes go to the tray when one is running and to the queue owner when one is
not (`PendingFiles`), because the alternative was that with no tray they went nowhere at all —
the send was skipped and the file was pending in nothing. A tray that owns the queue answers
those verbs too, routing them into the same tracked files the piper port fills, which is
load bearing rather than defensive: `DiffEngineTray.IsRunning` is cached at type init, so a test
process that started before the tray addresses the queue owner for the rest of its life.
- A delete starts a viewer when nothing owns the queue; a move does not. A move already has a
window — the diff tool DiffRunner just launched for that pair — and a delete has no second file
to compare against, so no tool ever opens for it. `--delete <file>` is the launch, on the command
line rather than stdin because a path fits where snapshot content does not.
- The catch that shape creates: every inline transition rebuilds its half of the queue from
`InlineQueue`, so `ViewerSession.Rebuild` carries the tracked entries across it. Without that,
accepting one snapshot silently drops the files pending beside it. `Sync` is the one caller that
must not, since it is replacing them with what the owner just reported.
- `DebugReport` / `DebugForm` - the menu's "Debug view": every field of every tracked move, delete
and snapshot as text, plus the queued patches when this tray owns the queue. The report is a
string so it can be copied into an issue and snapshot tested without rendering a window.
Expand Down
14 changes: 10 additions & 4 deletions docs/inline.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ flowchart LR
Owner{{"inline queue owner: whoever bound 3493<br/>first — the tray at login, else a viewer"}}
Files[("source files and<br/>staged patch files")]

Engine -->|"3492 moves, deletes (one way)"| Tray
Engine -->|"3493 inline, settle"| Owner
Engine -.->|"launch with patch on stdin,<br/>when nothing owns 3493"| Window
Engine -->|"3492 moves, deletes (one way),<br/>when a tray is running"| Tray
Engine -->|"3493 inline, settle, and<br/>moves and deletes with no tray"| Owner
Engine -.->|"launch with patch on stdin, or with<br/>a delete, when nothing owns 3493"| Window
Tray <-->|"3493 list, accept, focus"| Owner
Window <-->|"3493 listfull (with the tray's moves<br/>and deletes), accept, discard"| Owner
Window <-->|"3493 listfull (with the owner's moves<br/>and deletes), accept, discard"| Owner
Plugin -->|"3493 settle, after accepting"| Owner
Owner -->|"InlineApplier"| Files
Plugin -->|"InlineApplier"| Files
Expand All @@ -47,6 +47,12 @@ verbs. Either way both hosts run the same `InlineQueue` implementation, so they
on what accepting or settling means. [DiffEngineViewer](/docs/viewer.md) and
[DiffEngineTray](/docs/tray.md) cover the two arrangements in detail.

Pending file moves and deletes follow the same rule. They go to the tray when one is running,
over the port they have always used, and to the queue owner when one is not — so with no tray
installed they are reviewed in the viewer rather than going nowhere. A delete starts a viewer if
nothing owns the queue, because it has no second file to compare against and so no diff tool ever
opens for it. A move does not: DiffEngine has already opened a diff tool for that file pair.


## When a test fails

Expand Down
14 changes: 10 additions & 4 deletions docs/mdsource/inline.source.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ flowchart LR
Owner{{"inline queue owner: whoever bound 3493<br/>first — the tray at login, else a viewer"}}
Files[("source files and<br/>staged patch files")]

Engine -->|"3492 moves, deletes (one way)"| Tray
Engine -->|"3493 inline, settle"| Owner
Engine -.->|"launch with patch on stdin,<br/>when nothing owns 3493"| Window
Engine -->|"3492 moves, deletes (one way),<br/>when a tray is running"| Tray
Engine -->|"3493 inline, settle, and<br/>moves and deletes with no tray"| Owner
Engine -.->|"launch with patch on stdin, or with<br/>a delete, when nothing owns 3493"| Window
Tray <-->|"3493 list, accept, focus"| Owner
Window <-->|"3493 listfull (with the tray's moves<br/>and deletes), accept, discard"| Owner
Window <-->|"3493 listfull (with the owner's moves<br/>and deletes), accept, discard"| Owner
Plugin -->|"3493 settle, after accepting"| Owner
Owner -->|"InlineApplier"| Files
Plugin -->|"InlineApplier"| Files
Expand All @@ -40,6 +40,12 @@ verbs. Either way both hosts run the same `InlineQueue` implementation, so they
on what accepting or settling means. [DiffEngineViewer](/docs/viewer.md) and
[DiffEngineTray](/docs/tray.md) cover the two arrangements in detail.

Pending file moves and deletes follow the same rule. They go to the tray when one is running,
over the port they have always used, and to the queue owner when one is not — so with no tray
installed they are reviewed in the viewer rather than going nowhere. A delete starts a viewer if
nothing owns the queue, because it has no second file to compare against and so no diff tool ever
opens for it. A move does not: DiffEngine has already opened a diff tool for that file pair.


## When a test fails

Expand Down
4 changes: 3 additions & 1 deletion docs/mdsource/tray.source.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ If accepting a move fails because the files are locked by another process (for e

### Discard

Discard will clear all currently tracked items.
Discard will clear all currently tracked items. It is the same discard as the per item menu and as
the one in [DiffEngineViewer](/docs/viewer.md): a move loses its received file, a pending delete
keeps its file and is untracked, and every pending inline snapshot is dropped.


### Purge verified files
Expand Down
22 changes: 22 additions & 0 deletions docs/mdsource/viewer.source.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ Reviewing an inline snapshot, where the patch payload arrives on stdin:
DiffEngineViewer --inline --source <file.cs> --line <number>
```

Reviewing a file a passing test no longer produces, which DiffEngine sends when no tray is running:

```
DiffEngineViewer --delete <file>
```

Displaying a queue held by another process, which is how [DiffEngineTray](/docs/tray.md) opens one:

```
Expand Down Expand Up @@ -225,6 +231,22 @@ a particular snapshot, and close the viewer.
A tray restart loses the queue, as it loses pending file moves and deletes. Re-run the tests.


## With no tray

Pending file moves and deletes go to the tray when one is running. When one is not, they go to the
viewer, which holds and applies them itself — so a received file waiting to be promoted, or a
verified file a passing test no longer produces, is reviewable rather than invisible.

A pending delete starts a viewer if none is running. It is the one change with no second file to
compare against, so no diff tool ever opens for it, and a window is the only surface it can have. A pending move does not start one: DiffEngine has already opened a diff tool for that file
pair, and a second window competing with it is not an improvement. A move joins a window that is
already open.

Both look and behave exactly as they do when the tray owns them — same rows, same context menu,
same **Accept all** — because which process is holding a pending file depends only on whether a
tray happened to be running.


## Disabling

Set `DiffEngine_InlineViewer` to `false` to stop inline snapshots opening a window. The viewer also
Expand Down
4 changes: 3 additions & 1 deletion docs/tray.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ If accepting a move fails because the files are locked by another process (for e

### Discard

Discard will clear all currently tracked items.
Discard will clear all currently tracked items. It is the same discard as the per item menu and as
the one in [DiffEngineViewer](/docs/viewer.md): a move loses its received file, a pending delete
keeps its file and is untracked, and every pending inline snapshot is dropped.


### Purge verified files
Expand Down
22 changes: 22 additions & 0 deletions docs/viewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ Reviewing an inline snapshot, where the patch payload arrives on stdin:
DiffEngineViewer --inline --source <file.cs> --line <number>
```

Reviewing a file a passing test no longer produces, which DiffEngine sends when no tray is running:

```
DiffEngineViewer --delete <file>
```

Displaying a queue held by another process, which is how [DiffEngineTray](/docs/tray.md) opens one:

```
Expand Down Expand Up @@ -232,6 +238,22 @@ a particular snapshot, and close the viewer.
A tray restart loses the queue, as it loses pending file moves and deletes. Re-run the tests.


## With no tray

Pending file moves and deletes go to the tray when one is running. When one is not, they go to the
viewer, which holds and applies them itself — so a received file waiting to be promoted, or a
verified file a passing test no longer produces, is reviewable rather than invisible.

A pending delete starts a viewer if none is running. It is the one change with no second file to
compare against, so no diff tool ever opens for it, and a window is the only surface it can have. A pending move does not start one: DiffEngine has already opened a diff tool for that file
pair, and a second window competing with it is not an improvement. A move joins a window that is
already open.

Both look and behave exactly as they do when the tray owns them — same rows, same context menu,
same **Accept all** — because which process is holding a pending file depends only on whether a
tray happened to be running.


## Disabling

Set `DiffEngine_InlineViewer` to `false` to stop inline snapshots opening a window. The viewer also
Expand Down
44 changes: 44 additions & 0 deletions src/DiffEngine.Tests/ViewerProtocolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,42 @@ public async Task ARefusedAcceptGoesOnTheWireAsAnError()
await Assert.That(done.Message).IsEqualTo("Applied Tests.cs:42");
}

/// <summary>
/// A pending file with no tray running. The paths ride key and body rather than an encoded
/// payload, because that is all a tracked move or delete is.
/// </summary>
[Test]
public async Task MoveAndDeleteReachTheOwner()
{
var owner = new FakeOwner((true, null));

var move = ViewerMessageHandler.Handle(owner, new(ViewerVerb.Move, @"c:\temp\a.received.txt", @"c:\code\a.verified.txt"));
var delete = ViewerMessageHandler.Handle(owner, new(ViewerVerb.Delete, @"c:\code\b.verified.txt"));

await Assert.That(move.Ok).IsTrue();
await Assert.That(delete.Ok).IsTrue();
await Assert.That(owner.Tracked).IsEquivalentTo(
[
@"move c:\temp\a.received.txt > c:\code\a.verified.txt",
@"delete c:\code\b.verified.txt"
]);
}

[Test]
public async Task AMoveWithoutBothPathsIsRefused()
{
var owner = new FakeOwner((true, null));

var noTarget = ViewerMessageHandler.Handle(owner, new(ViewerVerb.Move, @"c:\temp\a.received.txt"));
var noFile = ViewerMessageHandler.Handle(owner, new(ViewerVerb.Delete));

await Assert.That(noTarget.Ok).IsFalse();
await Assert.That(noTarget.Message).IsEqualTo("Move requires a key and a body");
await Assert.That(noFile.Ok).IsFalse();
await Assert.That(noFile.Message).IsEqualTo("Delete requires a key");
await Assert.That(owner.Tracked).IsEmpty();
}

/// <summary>
/// The accept body is the variant origin a reviewer picked, and it has to reach the owner.
/// </summary>
Expand All @@ -396,6 +432,14 @@ public void Settle(string key, string? origin)
{
}

public List<string> Tracked { get; } = [];

public void TrackMove(string temp, string target) =>
Tracked.Add($"move {temp} > {target}");

public void TrackDelete(string file) =>
Tracked.Add($"delete {file}");

public ViewerResponse Listing(bool withPatches) => ViewerResponse.Listing([]);

public bool Has(string key) => true;
Expand Down
21 changes: 18 additions & 3 deletions src/DiffEngine/Protocol/IQueueOwner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,24 @@ interface IQueueOwner
void Settle(string key, string? origin);

/// <summary>
/// The whole listing response rather than just its items, because the tray answers with the
/// window command it has stashed plus its tracked moves and deletes, and the viewer has
/// nothing to add to the items.
/// Track a pending file move, replacing the entry for the same received file — a re-run
/// produces the same pair, and a second entry for it is a duplicate rather than news.
/// <para>
/// Only reaches an owner when no tray was running in the sending process, so it is normally
/// the viewer that answers this. A tray owner routes it into the same tracked moves the piper
/// port fills, which is what a tray started after the test process needs: that process's
/// tray check is cached, so its moves come here for the rest of its life.
/// </para>
/// </summary>
void TrackMove(string temp, string target);

/// <inheritdoc cref="TrackMove"/>
void TrackDelete(string file);

/// <summary>
/// The whole listing response rather than just its items, because an owner answers with its
/// tracked moves and deletes beside the queue, and a tray adds the window command it has
/// stashed.
/// </summary>
ViewerResponse Listing(bool withPatches);

Expand Down
33 changes: 33 additions & 0 deletions src/DiffEngine/Protocol/ViewerMessageHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ public static ViewerResponse Handle(IQueueOwner owner, ViewerMessage message)
return Inline(owner, message.Body);
case ViewerVerb.Settle:
return Settle(owner, message.Key, message.Body);
case ViewerVerb.Move:
return Move(owner, message.Key, message.Body);
case ViewerVerb.Delete:
return Delete(owner, message.Key);
case ViewerVerb.List:
return owner.Listing(false);
case ViewerVerb.ListFull:
Expand Down Expand Up @@ -81,6 +85,35 @@ static ViewerResponse Settle(IQueueOwner owner, string? key, string? origin)
return ViewerResponse.Success();
}

/// <summary>
/// The paths ride key and body rather than an encoded payload, because that is all a tracked
/// move is. What DiffEngine knows beside them — the diff tool it launched and that tool's
/// process id — is the tray's kill machinery and means nothing to an owner that does not have
/// any, so it is not sent.
/// </summary>
static ViewerResponse Move(IQueueOwner owner, string? temp, string? target)
{
if (temp is null ||
target is null)
{
return ViewerResponse.Error("Move requires a key and a body");
}

owner.TrackMove(temp, target);
return ViewerResponse.Success();
}

static ViewerResponse Delete(IQueueOwner owner, string? file)
{
if (file is null)
{
return ViewerResponse.Error("Delete requires a key");
}

owner.TrackDelete(file);
return ViewerResponse.Success();
}

static ViewerResponse Act(IQueueOwner owner, string? key, string? body, ViewerVerb verb)
{
if (key is null)
Expand Down
19 changes: 19 additions & 0 deletions src/DiffEngine/Protocol/ViewerVerb.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,25 @@ enum ViewerVerb
/// </summary>
Settle,

/// <summary>
/// Track a pending file move: <c>key</c> is the received file, <c>body</c> the target it
/// belongs at. From DiffEngine when no tray is running, so the pair still has somewhere to be
/// pending rather than nowhere at all.
/// <para>
/// Never launches an owner. DiffRunner has already opened a diff tool for that file pair, and
/// a second window competing with it is not an improvement.
/// </para>
/// </summary>
Move,

/// <summary>
/// Track a pending file delete: <c>key</c> is the file. From DiffEngine when no tray is
/// running, and unlike <see cref="Move"/> this one does start a viewer when nothing owns the
/// queue — a delete has no second file to compare against and so no diff tool to open, which
/// left it with no surface whatsoever.
/// </summary>
Delete,

/// <summary>
/// Return the pending entries as key, name and status. Enough to build a menu. From the tray.
/// </summary>
Expand Down
Loading
Loading