diff --git a/docs/inline.md b/docs/inline.md
index 8ad14379..14d9b25d 100644
--- a/docs/inline.md
+++ b/docs/inline.md
@@ -30,9 +30,9 @@ flowchart LR
Engine -.->|"launch with patch on stdin, or with
a delete, when nothing owns 3493"| Window
Tray <-->|"3493 list, accept, focus"| Owner
Window <-->|"3493 listfull (with the owner's moves
and deletes), accept, discard"| Owner
- Plugin -->|"3493 settle, after accepting"| Owner
+ Plugin <-->|"3493 listfull, accept, discard,
focus, via InlineQueueClient"| Owner
Owner -->|"InlineApplier"| Files
- Plugin -->|"InlineApplier"| Files
+ Plugin -.->|"InlineApplier, for a staged
patch with no owner to ask"| Files
```
The queue of pending snapshots has exactly one owner per session: whichever process bound port 3493 first, decided once and never transferred. When the tray owns it, its edges to the owner above are in-process calls; when a viewer owns it, the tray drives that viewer over the same verbs. Either way both hosts run the same `InlineQueue` implementation, so they cannot disagree on what accepting or settling means. [DiffEngineViewer](/docs/viewer.md) and [DiffEngineTray](/docs/tray.md) cover the two arrangements in detail.
@@ -189,9 +189,22 @@ Locating the call is otherwise the same scan, taught F#'s lexis: `(* *)` comment
`FsStringLiteral.Render` takes the call site's indentation, like its C# peer, but means something else by it: not a prefix to write, since the content is verbatim, but the column the result has to clear. A surface rendering its own literal has to pass the indentation of the statement it is splicing into, or it will produce the form that does not compile there.
+## Reviewing from another surface
+
+A review surface that is neither the viewer nor the tray — the ReSharper / Rider plugin, or any other tool — reaches the pending snapshots through `InlineQueueClient`, and is then a peer of the tray rather than a fallback for when no viewer could be found. Same queue, same entries, one writer.
+
+* `TryList` returns every pending entry with the patch that produced it, so the snapshot and the text it replaces can be rendered without reading anything from disk. False means no owner answered, which is not the same as an empty queue: a surface that falls back to staged files has to tell those apart. `Find` is that listing narrowed to one call site, keyed by `InlineKey.For(sourceFile, line)`, and `TryListKeys` is the cheap half — which call sites are pending, for deciding whether to offer an action without the payload of every patch crossing the wire.
+* `Accept` asks the owner to apply the patch and drop the entry. Applying happens there rather than here, which is what keeps one writer per source file and leaves every display agreeing about what is still pending — and why there is no local apply to settle afterwards.
+* `Discard` drops an entry without applying it. `Focus` hands one to the viewer window instead of accepting it, starting a window when the owner has none.
+
+`Accept` returns `Accepted` (the entry has gone), `Failed` (still pending and retryable — a locked file, or a conflicted entry that has to be resolved in the viewer), or `Unknown` (nobody owns the queue, or it holds nothing for that call site). It hands back the owner's own message, which is what tells an applied patch from a stale one that was dropped: the wire carries whether the verb was carried out rather than an apply status, so from a client those are the same observation, and only the words separate them.
+
+Listing waits half a second, since an owner that cannot answer one in that time is wedged rather than slow, and a refused connection is immediate either way. Accepting waits fifteen seconds, because the owner applies through `InlineApplier` and that can sit on its cross process mutex for ten.
+
+
## Applying a patch from another surface
-The contract for a review surface of its own, which is what the ReSharper / Rider plugin is: read the staged patch with `InlinePatchFile.TryRead`, apply it with `InlineApplier.Apply`, and honour two rules.
+For the staging fallback, where no viewer could be resolved and the patch is a file on disk rather than an entry in a queue: read it with `InlinePatchFile.TryRead`, apply it with `InlineApplier.Apply`, and honour two rules.
* **InlineApplier owns all locking.** A per file cross process mutex (up to a ten second wait) plus an in process gate serialise every writer, so applying beside a concurrently accepting tray or viewer is safe, and callers must not add locking of their own. The file's encoding, BOM and line endings are preserved, and its extension picks the language.
* **Settle what was applied.** The same test run that staged the files may also have queued the patch with the port owner, and that queue outlives both the window and the run. After `Applied` or `AlreadyApplied`, call `DiffRunner.SettleInline(patch.SourceFile, patch.LineHint)` — otherwise the tray keeps offering a snapshot that is already in the source.
@@ -208,4 +221,4 @@ The contract for a review surface of its own, which is what the ReSharper / Ride
| 3492 | a tray is here | one way payloads: moves and deletes ([tray](/docs/tray.md#payloads)) |
| 3493 | the inline queue owner is here | request/response verbs, internal |
-Two ports because they answer different questions: the owner of 3493 is sometimes a viewer, and a late starting tray still receives every move on 3492 while it is. `DiffEngine_ViewerPort` overrides 3493, which test suites use to keep out of the way of a live tray. The 3493 protocol is internal and versioned; integrate through `DiffRunner` and `InlineApplier` rather than speaking it directly.
+Two ports because they answer different questions: the owner of 3493 is sometimes a viewer, and a late starting tray still receives every move on 3492 while it is. `DiffEngine_ViewerPort` overrides 3493, which test suites use to keep out of the way of a live tray. The 3493 protocol itself is internal and versioned; integrate through `DiffRunner` to produce, `InlineQueueClient` to review, and `InlineApplier` to write, rather than speaking it directly.
diff --git a/docs/mdsource/inline.source.md b/docs/mdsource/inline.source.md
index 9a9dc229..af23a08a 100644
--- a/docs/mdsource/inline.source.md
+++ b/docs/mdsource/inline.source.md
@@ -23,9 +23,9 @@ flowchart LR
Engine -.->|"launch with patch on stdin, or with
a delete, when nothing owns 3493"| Window
Tray <-->|"3493 list, accept, focus"| Owner
Window <-->|"3493 listfull (with the owner's moves
and deletes), accept, discard"| Owner
- Plugin -->|"3493 settle, after accepting"| Owner
+ Plugin <-->|"3493 listfull, accept, discard,
focus, via InlineQueueClient"| Owner
Owner -->|"InlineApplier"| Files
- Plugin -->|"InlineApplier"| Files
+ Plugin -.->|"InlineApplier, for a staged
patch with no owner to ask"| Files
```
The queue of pending snapshots has exactly one owner per session: whichever process bound port 3493 first, decided once and never transferred. When the tray owns it, its edges to the owner above are in-process calls; when a viewer owns it, the tray drives that viewer over the same verbs. Either way both hosts run the same `InlineQueue` implementation, so they cannot disagree on what accepting or settling means. [DiffEngineViewer](/docs/viewer.md) and [DiffEngineTray](/docs/tray.md) cover the two arrangements in detail.
@@ -182,9 +182,22 @@ Locating the call is otherwise the same scan, taught F#'s lexis: `(* *)` comment
`FsStringLiteral.Render` takes the call site's indentation, like its C# peer, but means something else by it: not a prefix to write, since the content is verbatim, but the column the result has to clear. A surface rendering its own literal has to pass the indentation of the statement it is splicing into, or it will produce the form that does not compile there.
+## Reviewing from another surface
+
+A review surface that is neither the viewer nor the tray — the ReSharper / Rider plugin, or any other tool — reaches the pending snapshots through `InlineQueueClient`, and is then a peer of the tray rather than a fallback for when no viewer could be found. Same queue, same entries, one writer.
+
+* `TryList` returns every pending entry with the patch that produced it, so the snapshot and the text it replaces can be rendered without reading anything from disk. False means no owner answered, which is not the same as an empty queue: a surface that falls back to staged files has to tell those apart. `Find` is that listing narrowed to one call site, keyed by `InlineKey.For(sourceFile, line)`, and `TryListKeys` is the cheap half — which call sites are pending, for deciding whether to offer an action without the payload of every patch crossing the wire.
+* `Accept` asks the owner to apply the patch and drop the entry. Applying happens there rather than here, which is what keeps one writer per source file and leaves every display agreeing about what is still pending — and why there is no local apply to settle afterwards.
+* `Discard` drops an entry without applying it. `Focus` hands one to the viewer window instead of accepting it, starting a window when the owner has none.
+
+`Accept` returns `Accepted` (the entry has gone), `Failed` (still pending and retryable — a locked file, or a conflicted entry that has to be resolved in the viewer), or `Unknown` (nobody owns the queue, or it holds nothing for that call site). It hands back the owner's own message, which is what tells an applied patch from a stale one that was dropped: the wire carries whether the verb was carried out rather than an apply status, so from a client those are the same observation, and only the words separate them.
+
+Listing waits half a second, since an owner that cannot answer one in that time is wedged rather than slow, and a refused connection is immediate either way. Accepting waits fifteen seconds, because the owner applies through `InlineApplier` and that can sit on its cross process mutex for ten.
+
+
## Applying a patch from another surface
-The contract for a review surface of its own, which is what the ReSharper / Rider plugin is: read the staged patch with `InlinePatchFile.TryRead`, apply it with `InlineApplier.Apply`, and honour two rules.
+For the staging fallback, where no viewer could be resolved and the patch is a file on disk rather than an entry in a queue: read it with `InlinePatchFile.TryRead`, apply it with `InlineApplier.Apply`, and honour two rules.
* **InlineApplier owns all locking.** A per file cross process mutex (up to a ten second wait) plus an in process gate serialise every writer, so applying beside a concurrently accepting tray or viewer is safe, and callers must not add locking of their own. The file's encoding, BOM and line endings are preserved, and its extension picks the language.
* **Settle what was applied.** The same test run that staged the files may also have queued the patch with the port owner, and that queue outlives both the window and the run. After `Applied` or `AlreadyApplied`, call `DiffRunner.SettleInline(patch.SourceFile, patch.LineHint)` — otherwise the tray keeps offering a snapshot that is already in the source.
@@ -201,4 +214,4 @@ The contract for a review surface of its own, which is what the ReSharper / Ride
| 3492 | a tray is here | one way payloads: moves and deletes ([tray](/docs/tray.md#payloads)) |
| 3493 | the inline queue owner is here | request/response verbs, internal |
-Two ports because they answer different questions: the owner of 3493 is sometimes a viewer, and a late starting tray still receives every move on 3492 while it is. `DiffEngine_ViewerPort` overrides 3493, which test suites use to keep out of the way of a live tray. The 3493 protocol is internal and versioned; integrate through `DiffRunner` and `InlineApplier` rather than speaking it directly.
+Two ports because they answer different questions: the owner of 3493 is sometimes a viewer, and a late starting tray still receives every move on 3492 while it is. `DiffEngine_ViewerPort` overrides 3493, which test suites use to keep out of the way of a live tray. The 3493 protocol itself is internal and versioned; integrate through `DiffRunner` to produce, `InlineQueueClient` to review, and `InlineApplier` to write, rather than speaking it directly.
diff --git a/src/DiffEngine.Tests/InlineQueueClientTests.cs b/src/DiffEngine.Tests/InlineQueueClientTests.cs
new file mode 100644
index 00000000..d938a7b7
--- /dev/null
+++ b/src/DiffEngine.Tests/InlineQueueClientTests.cs
@@ -0,0 +1,413 @@
+///
+/// The review surface half of the queue protocol: what a tool that is neither the viewer nor the
+/// tray sees when it lists and accepts.
+///
+/// Against a real socket, a real and a real
+/// , because what is being pinned is that the three agree — a client
+/// tested against a hand written responder would pass while the owner said something else.
+///
+///
+[NotInParallel]
+public class InlineQueueClientTests
+{
+ static InlinePatch Patch(
+ string source = "Sample.cs",
+ int line = 42,
+ string content = "new content",
+ string? framework = null) =>
+ new(source, line, "\"old\"", content)
+ {
+ TestName = "Sample.Test",
+ Framework = framework
+ };
+
+ [Test]
+ public async Task ListsWhatTheOwnerHolds()
+ {
+ using var owner = new Owner();
+ owner.Enqueue(Patch("A.cs", 1, "first"));
+ owner.Enqueue(Patch("B.cs", 2, "second"));
+
+ var listed = InlineQueueClient.TryList(out var pending);
+
+ await Assert.That(listed).IsTrue();
+ await Assert.That(pending.Select(_ => _.Name)).IsEquivalentTo(["A.cs:1", "B.cs:2"]);
+ // The patches ride the listing, which is what lets a surface render the snapshot without
+ // reading anything from disk.
+ await Assert.That(pending.Select(_ => _.Patch.NewContent)).IsEquivalentTo(["first", "second"]);
+ await Assert.That(pending[0].Patch.OriginalValue).IsNull();
+ await Assert.That(pending[0].Patch.OriginalExpression).IsEqualTo("\"old\"");
+ await Assert.That(pending[0].Patch.TestName).IsEqualTo("Sample.Test");
+ }
+
+ ///
+ /// Not the same answer as an empty queue: a surface that falls back to the files a test run
+ /// staged has to be able to tell "nobody is holding this" from "nothing is pending".
+ ///
+ [Test]
+ public async Task ReportsWhenNoOwnerAnswers()
+ {
+ using var nobody = new NoOwner();
+
+ var listed = InlineQueueClient.TryList(out var pending);
+
+ await Assert.That(listed).IsFalse();
+ await Assert.That(pending).IsEmpty();
+ }
+
+ ///
+ /// The cheap listing a surface builds a menu from: which call sites are pending, without the
+ /// patch payload of every one of them crossing the wire.
+ ///
+ [Test]
+ public async Task ListsKeysWithoutPatches()
+ {
+ using var owner = new Owner();
+ owner.Enqueue(Patch("A.cs", 1));
+ owner.Enqueue(Patch("B.cs", 2));
+
+ var listed = InlineQueueClient.TryListKeys(out var keys);
+
+ await Assert.That(listed).IsTrue();
+ await Assert.That(keys).IsEquivalentTo([InlineKey.For("A.cs", 1), InlineKey.For("B.cs", 2)]);
+ }
+
+ [Test]
+ public async Task ReportsWhenNoOwnerAnswersKeys()
+ {
+ using var nobody = new NoOwner();
+
+ var listed = InlineQueueClient.TryListKeys(out var keys);
+
+ await Assert.That(listed).IsFalse();
+ await Assert.That(keys).IsEmpty();
+ }
+
+ [Test]
+ public async Task FindsTheEntryForACallSite()
+ {
+ using var owner = new Owner();
+ owner.Enqueue(Patch("A.cs", 1));
+ owner.Enqueue(Patch("B.cs", 2));
+
+ var entry = InlineQueueClient.Find(InlineKey.For("B.cs", 2));
+
+ await Assert.That(entry!.Name).IsEqualTo("B.cs:2");
+ }
+
+ [Test]
+ public async Task AcceptAppliesInTheOwnerAndDropsTheEntry()
+ {
+ using var owner = new Owner();
+ owner.Enqueue(Patch());
+
+ var outcome = InlineQueueClient.Accept(InlineKey.For("Sample.cs", 42), out var message);
+
+ await Assert.That(outcome).IsEqualTo(InlineAcceptOutcome.Accepted);
+ await Assert.That(message).IsEqualTo("Applied Sample.cs:42");
+ // Applied where the queue is, not where the client is: one writer per source file.
+ await Assert.That(owner.Applied.Single().NewContent).IsEqualTo("new content");
+ await Assert.That(InlineQueueClient.TryList(out var pending)).IsTrue();
+ await Assert.That(pending).IsEmpty();
+ }
+
+ ///
+ /// An apply that could not write the file keeps its entry, so it can be retried once whatever
+ /// blocked it is gone. The wire says the verb was carried out either way, which is why the
+ /// client asks whether the entry survived rather than reading the answer off ok.
+ ///
+ [Test]
+ public async Task AFailedApplyStaysPending()
+ {
+ using var owner = new Owner
+ {
+ Apply = _ => InlineApplyResult.Failed("the file is locked")
+ };
+ owner.Enqueue(Patch());
+
+ var outcome = InlineQueueClient.Accept(InlineKey.For("Sample.cs", 42), out var message);
+
+ await Assert.That(outcome).IsEqualTo(InlineAcceptOutcome.Failed);
+ await Assert.That(message).IsEqualTo("the file is locked");
+ await Assert.That(InlineQueueClient.Find(InlineKey.For("Sample.cs", 42))).IsNotNull();
+ }
+
+ ///
+ /// A stale patch is dropped rather than kept, so it reads as accepted. The owner's message is
+ /// what carries the difference, which is why it is handed back rather than swallowed.
+ ///
+ [Test]
+ public async Task AStalePatchReportsWhatTheOwnerSaid()
+ {
+ using var owner = new Owner
+ {
+ Apply = _ => InlineApplyResult.NotFound("no call site")
+ };
+ owner.Enqueue(Patch());
+
+ var outcome = InlineQueueClient.Accept(InlineKey.For("Sample.cs", 42), out var message);
+
+ await Assert.That(outcome).IsEqualTo(InlineAcceptOutcome.Accepted);
+ await Assert.That(message).IsEqualTo("Sample.cs:42 source changed, re-run the test");
+ }
+
+ ///
+ /// Two frameworks disagreeing is refused rather than picked between, and nothing is applied.
+ ///
+ [Test]
+ public async Task AConflictIsRefusedWithItsReason()
+ {
+ using var owner = new Owner();
+ owner.Enqueue(Patch(content: "from net8", framework: "net8.0"));
+ owner.Enqueue(Patch(content: "from net9", framework: "net9.0"));
+
+ var outcome = InlineQueueClient.Accept(InlineKey.For("Sample.cs", 42), out var message);
+
+ await Assert.That(outcome).IsEqualTo(InlineAcceptOutcome.Failed);
+ await Assert.That(message).IsEqualTo("Conflicting snapshots (net8.0 / net9.0), resolve in the viewer");
+ await Assert.That(owner.Applied).IsEmpty();
+ }
+
+ [Test]
+ public async Task AcceptOfAnUnknownCallSiteDoesNothing()
+ {
+ using var owner = new Owner();
+ owner.Enqueue(Patch());
+
+ var outcome = InlineQueueClient.Accept(InlineKey.For("Other.cs", 1), out var message);
+
+ await Assert.That(outcome).IsEqualTo(InlineAcceptOutcome.Unknown);
+ await Assert.That(message).IsNull();
+ await Assert.That(owner.Applied).IsEmpty();
+ // The one it does hold is untouched.
+ await Assert.That(InlineQueueClient.Find(InlineKey.For("Sample.cs", 42))).IsNotNull();
+ }
+
+ [Test]
+ public async Task DiscardDropsWithoutApplying()
+ {
+ using var owner = new Owner();
+ owner.Enqueue(Patch());
+
+ var discarded = InlineQueueClient.Discard(InlineKey.For("Sample.cs", 42), out var message);
+
+ await Assert.That(discarded).IsTrue();
+ await Assert.That(message).IsEqualTo("Discarded Sample.cs:42");
+ await Assert.That(owner.Applied).IsEmpty();
+ await Assert.That(InlineQueueClient.Find(InlineKey.For("Sample.cs", 42))).IsNull();
+ }
+
+ [Test]
+ public async Task FocusAsksForTheWindow()
+ {
+ using var owner = new Owner();
+ owner.Enqueue(Patch());
+
+ var focused = InlineQueueClient.Focus(InlineKey.For("Sample.cs", 42));
+
+ await Assert.That(focused).IsTrue();
+ await Assert.That(owner.Window).IsEqualTo(WindowCommand.Focus);
+ }
+
+ ///
+ /// Points DiffEngine_ViewerPort at a port nothing is listening on, so what the client meets is
+ /// an absent owner rather than whichever viewer or tray happens to be running on this machine's
+ /// default port — which is what the assert would otherwise be talking to.
+ ///
+ sealed class NoOwner : IDisposable
+ {
+ readonly string? previousPort;
+
+ public NoOwner()
+ {
+ // Bound only to be given a port that is free, then released, so nothing can answer on it.
+ if (!ViewerServer.TryBind(0, out var bound))
+ {
+ throw new("Could not bind an ephemeral port.");
+ }
+
+ var port = bound.Port;
+ bound.Dispose();
+ previousPort = Environment.GetEnvironmentVariable(ViewerClient.PortVariable);
+ Environment.SetEnvironmentVariable(ViewerClient.PortVariable, port.ToString());
+ }
+
+ public void Dispose() =>
+ Environment.SetEnvironmentVariable(ViewerClient.PortVariable, previousPort);
+ }
+
+ ///
+ /// A queue owner on an ephemeral port, with DiffEngine_ViewerPort pointed at it so a viewer
+ /// or tray running on this machine is not the thing being talked to.
+ ///
+ /// Everything below the socket is the real thing: maps the
+ /// verbs and holds the entries. Only applying is substituted, so a
+ /// failure or a stale patch can be arranged without a locked file.
+ ///
+ ///
+ sealed class Owner : IQueueOwner, IDisposable
+ {
+ readonly ViewerServer server;
+ readonly CancelSource cancel = new();
+ readonly Task listening;
+ readonly string? previousPort;
+ readonly Lock gate = new();
+ InlineQueue queue = InlineQueue.Empty;
+
+ public Owner()
+ {
+ if (!ViewerServer.TryBind(0, out var bound))
+ {
+ throw new("Could not bind an ephemeral port.");
+ }
+
+ server = bound;
+ previousPort = Environment.GetEnvironmentVariable(ViewerClient.PortVariable);
+ Environment.SetEnvironmentVariable(ViewerClient.PortVariable, server.Port.ToString());
+ listening = server.Listen(_ => ViewerMessageHandler.Handle(this, _), cancel.Token);
+ }
+
+ public Func Apply { get; init; } = _ => InlineApplyResult.Applied;
+
+ public List Applied { get; } = [];
+
+ public WindowCommand? Window { get; private set; }
+
+ public void Enqueue(InlinePatch patch)
+ {
+ lock (gate)
+ {
+ queue = queue.Enqueue(patch);
+ }
+ }
+
+ InlineApplyResult Record(InlinePatch patch)
+ {
+ var result = Apply(patch);
+ if (result.Status is InlineApplyStatus.Applied or InlineApplyStatus.AlreadyApplied)
+ {
+ Applied.Add(patch);
+ }
+
+ return result;
+ }
+
+ int IQueueOwner.Enqueue(InlinePatch patch)
+ {
+ Enqueue(patch);
+ lock (gate)
+ {
+ return queue.Count;
+ }
+ }
+
+ void IQueueOwner.Settle(string key, string? origin)
+ {
+ lock (gate)
+ {
+ queue = queue.Settle(key, origin);
+ }
+ }
+
+ void IQueueOwner.TrackMove(string temp, string target)
+ {
+ }
+
+ void IQueueOwner.TrackDelete(string file)
+ {
+ }
+
+ ViewerResponse IQueueOwner.Listing(bool withPatches)
+ {
+ lock (gate)
+ {
+ return ViewerResponse.Listing(ViewerListing.Items(queue.Items, withPatches));
+ }
+ }
+
+ bool IQueueOwner.Has(string key)
+ {
+ lock (gate)
+ {
+ return queue.Find(key) is not null;
+ }
+ }
+
+ (bool ok, string? message) IQueueOwner.Accept(string key, string? origin)
+ {
+ lock (gate)
+ {
+ if (queue.Find(key) is null)
+ {
+ return (false, null);
+ }
+
+ var before = queue;
+ queue = origin is null
+ ? queue.Accept(key, Record, out var message)
+ : queue.Accept(key, origin, Record, out message);
+
+ // The queue refuses a conflict by returning itself with the reason, which is a
+ // refusal rather than an attempt and goes on the wire as an error.
+ if (ReferenceEquals(before, queue))
+ {
+ return (false, message);
+ }
+
+ return (true, message);
+ }
+ }
+
+ (bool ok, string? message) IQueueOwner.Discard(string key)
+ {
+ lock (gate)
+ {
+ if (queue.Find(key) is null)
+ {
+ return (false, null);
+ }
+
+ queue = queue.Discard(key, out var message);
+ return (true, message);
+ }
+ }
+
+ string IQueueOwner.AcceptAll()
+ {
+ lock (gate)
+ {
+ queue = queue.AcceptAll(Record, out var message);
+ return message;
+ }
+ }
+
+ string IQueueOwner.DiscardAll()
+ {
+ lock (gate)
+ {
+ queue = queue.DiscardAll(out var message);
+ return message;
+ }
+ }
+
+ void IQueueOwner.Window(WindowCommand command, string? key) =>
+ Window = command;
+
+ public void Dispose()
+ {
+ cancel.Cancel();
+ server.Dispose();
+ try
+ {
+ listening.Wait(TimeSpan.FromSeconds(5));
+ }
+ catch (AggregateException)
+ {
+ // Cancellation unwinds through the listener; nothing to report.
+ }
+
+ cancel.Dispose();
+ Environment.SetEnvironmentVariable(ViewerClient.PortVariable, previousPort);
+ }
+ }
+}
diff --git a/src/DiffEngine/Inline/InlineQueueClient.cs b/src/DiffEngine/Inline/InlineQueueClient.cs
new file mode 100644
index 00000000..3068094a
--- /dev/null
+++ b/src/DiffEngine/Inline/InlineQueueClient.cs
@@ -0,0 +1,192 @@
+namespace DiffEngine;
+
+///
+/// What became of an accept sent to the queue owner.
+///
+public enum InlineAcceptOutcome
+{
+ ///
+ /// Nothing happened: no owner answered, or the one that did holds no entry for that key —
+ /// it settled, or another surface got to it first.
+ ///
+ Unknown,
+
+ ///
+ /// The entry is no longer pending, which is almost always because the snapshot is in the
+ /// source file now.
+ ///
+ /// It also covers a patch the owner dropped as stale, because the wire carries whether the
+ /// verb was carried out rather than an apply status, and from here the two are the same
+ /// observation: the entry has gone. hands back the
+ /// owner's own message, which distinguishes them in words - "Applied Sample.cs:42" against
+ /// "Sample.cs:42 source changed, re-run the test" - so a surface that shows it is telling the
+ /// truth either way.
+ ///
+ ///
+ Accepted,
+
+ ///
+ /// Still pending, and the message says why: an apply that could not write the file, or a
+ /// conflicted entry that a reviewer has to resolve before it can be accepted at all.
+ /// Retryable once whatever blocked it is gone.
+ ///
+ Failed
+}
+
+///
+/// The pending inline snapshots, for a review surface that is neither DiffEngineViewer nor
+/// DiffEngineTray — an IDE plugin, or any other tool that wants to show what a test run left
+/// pending and accept it.
+///
+/// Everything here is a short loopback exchange with whichever process owns the queue, so a
+/// surface built on this is a peer of the tray rather than a fallback for when no viewer could be
+/// found. Accepting runs in the owner, which is what keeps one writer per source file and leaves
+/// every display agreeing about what is still pending; there is no local apply to settle
+/// afterwards.
+///
+///
+/// A refused connection means nobody owns the queue: no test run has queued anything, or the
+/// process that held it has gone. That is reported rather than thrown, since it is the ordinary
+/// state of a machine with no failing snapshots.
+///
+///
+public static class InlineQueueClient
+{
+ ///
+ /// Sized for the accept, which is the one verb that can legitimately take this long: the owner
+ /// applies the patch through , which waits up to ten seconds on its
+ /// cross process mutex. A shorter wait reads a busy owner as an absent one.
+ ///
+ static readonly TimeSpan acceptWait = TimeSpan.FromSeconds(15);
+
+ ///
+ /// Every pending inline snapshot the owner holds, with the patches that produced them, so a
+ /// caller can render the snapshot and the text it replaces without reading anything from disk.
+ ///
+ /// False when no owner answered, which is not the same as an empty queue: a surface that wants
+ /// to fall back to the files a test run staged needs to tell those apart.
+ ///
+ ///
+ /// Uses the short wait, because this is what an interactive surface calls to decide whether to
+ /// offer an action, and an owner that cannot answer a listing in half a second is wedged rather
+ /// than slow.
+ ///
+ ///
+ public static bool TryList(out IReadOnlyList pending)
+ {
+ if (!Exchange(new(ViewerVerb.ListFull), ViewerClient.ShortTimeout, out var response))
+ {
+ pending = [];
+ return false;
+ }
+
+ pending = ViewerListing.Pending(response.Items);
+ return true;
+ }
+
+ ///
+ /// Which call sites are pending, and nothing else, over the listing that carries no patches.
+ ///
+ /// For a surface deciding whether to offer an action rather than one about to render a
+ /// snapshot — an IDE building a context menu, say, where the payload of every queued patch is
+ /// not worth the round trip. builds a key from a source file and
+ /// a line, so a caller can ask about its own call sites without matching on anything else.
+ ///
+ ///
+ public static bool TryListKeys(out IReadOnlyList keys)
+ {
+ if (!Exchange(new(ViewerVerb.List), ViewerClient.ShortTimeout, out var response))
+ {
+ keys = [];
+ return false;
+ }
+
+ keys = response.Items.Select(_ => _.Key).ToList();
+ return true;
+ }
+
+ ///
+ /// The entry for a call site, or null when nothing is pending for it.
+ /// builds the key from a source file and a line.
+ ///
+ public static PendingInline? Find(string key) =>
+ TryList(out var pending)
+ ? pending.FirstOrDefault(_ => _.Key == key)
+ : null;
+
+ ///
+ /// Asks the owner to apply the patch for a call site and drop it from the queue.
+ /// is the owner's own account of what happened, suitable to show a
+ /// user as it stands, and null when it had nothing to say — always so for
+ /// , where nothing happened.
+ ///
+ public static InlineAcceptOutcome Accept(string key, out string? message)
+ {
+ message = null;
+ if (!Exchange(new(ViewerVerb.Accept, key), acceptWait, out var response))
+ {
+ return InlineAcceptOutcome.Unknown;
+ }
+
+ message = Text(response.Message);
+ if (!response.Ok)
+ {
+ // One error shape covers both "no entry for that key" and a refusal on a live one — a
+ // conflicted entry — so which it was is asked rather than read out of the text.
+ if (StillPending(key))
+ {
+ return InlineAcceptOutcome.Failed;
+ }
+
+ // The owner's phrasing here names a key rather than a snapshot, so it says nothing a
+ // caller would want to show.
+ message = null;
+ return InlineAcceptOutcome.Unknown;
+ }
+
+ // Attempted, but attempted is not applied: an owner keeps an entry that failed to write so
+ // it can be retried. Whether the entry survived is the answer, and it has to be asked for
+ // rather than read off `ok`.
+ return StillPending(key)
+ ? InlineAcceptOutcome.Failed
+ : InlineAcceptOutcome.Accepted;
+ }
+
+ ///
+ /// Drops a pending snapshot without applying it. False when no owner answered, or it held
+ /// nothing for that key.
+ ///
+ public static bool Discard(string key, out string? message)
+ {
+ message = null;
+ if (!Exchange(new(ViewerVerb.Discard, key), ViewerClient.ShortTimeout, out var response))
+ {
+ return false;
+ }
+
+ message = Text(response.Message);
+ return response.Ok;
+ }
+
+ ///
+ /// Brings the viewer window forward on an entry, starting one when the owner has no window of
+ /// its own. For a surface that wants to hand a snapshot over to be reviewed rather than
+ /// accepting it outright.
+ ///
+ public static bool Focus(string key) =>
+ Exchange(new(ViewerVerb.Focus, key), ViewerClient.ShortTimeout, out var response) &&
+ response.Ok;
+
+ static bool StillPending(string key) =>
+ TryListKeys(out var keys) &&
+ keys.Contains(key);
+
+ static string? Text(string? message) =>
+ message is { Length: > 0 } ? message : null;
+
+ static bool Exchange(
+ ViewerMessage message,
+ TimeSpan wait,
+ [NotNullWhen(true)] out ViewerResponse? response) =>
+ ViewerClient.TrySend(message, out response, wait: wait);
+}
diff --git a/src/DiffEngine/Protocol/ViewerListing.cs b/src/DiffEngine/Protocol/ViewerListing.cs
index bc25a8b9..19ae086a 100644
--- a/src/DiffEngine/Protocol/ViewerListing.cs
+++ b/src/DiffEngine/Protocol/ViewerListing.cs
@@ -35,4 +35,45 @@ public static List Items(IEnumerable entries,
return items;
}
+
+ ///
+ /// The reverse of : a full listing read back into the entries it was
+ /// projected from, for a process that displays or reviews a queue it does not own.
+ ///
+ /// Here beside the projection rather than beside either reader, because the two are one format
+ /// and a change to how an entry is written is a change to how it is read.
+ ///
+ ///
+ public static List Pending(IEnumerable items) =>
+ items
+ .Select(Read)
+ .OfType()
+ .ToList();
+
+ ///
+ /// An item with no patch, or whose payload does not parse, is dropped rather than surfaced as
+ /// an entry with nothing in it.
+ ///
+ static PendingInline? Read(ViewerResponseItem item)
+ {
+ if (item.Patch is null ||
+ !InlinePatchFile.TryParse(item.Patch, out var patch))
+ {
+ return null;
+ }
+
+ var variants = new List
+ {
+ new(patch, item.Origins)
+ };
+ foreach (var variant in item.Variants)
+ {
+ if (InlinePatchFile.TryParse(variant.Patch, out var extra))
+ {
+ variants.Add(new(extra, variant.Origins));
+ }
+ }
+
+ return new(variants, item.Status);
+ }
}
diff --git a/src/DiffEngineViewer/Ipc/OwnerLink.cs b/src/DiffEngineViewer/Ipc/OwnerLink.cs
index 9ec9f817..02929d14 100644
--- a/src/DiffEngineViewer/Ipc/OwnerLink.cs
+++ b/src/DiffEngineViewer/Ipc/OwnerLink.cs
@@ -67,7 +67,7 @@ public bool Pump(out bool sent)
return false;
}
- var pending = InlineQueue.From(response.Items.Select(Read).OfType());
+ var pending = InlineQueue.From(ViewerListing.Pending(response.Items));
var changes = ReadChanges(response);
host.Mutate(_ => ViewerSession.Sync(_, pending, changes, message));
@@ -124,33 +124,6 @@ string Send(Outbound command)
return response.Message ?? (response.Ok ? "" : $"{command.Verb} was refused.");
}
- ///
- /// An item with no patch, or whose payload does not parse, is dropped rather than shown as a
- /// blank pane.
- ///
- static PendingInline? Read(ViewerResponseItem item)
- {
- if (item.Patch is null ||
- !InlinePatchFile.TryParse(item.Patch, out var patch))
- {
- return null;
- }
-
- var variants = new List
- {
- new(patch, item.Origins)
- };
- foreach (var variant in item.Variants)
- {
- if (InlinePatchFile.TryParse(variant.Patch, out var extra))
- {
- variants.Add(new(extra, variant.Origins));
- }
- }
-
- return new(variants, item.Status);
- }
-
///
/// Materializes the owner's tracked moves and deletes into displayable entries, reading the
/// files here on the polling thread — the read seam, keeping the session IO free the way