diff --git a/OpenNest.Data/NestHistorySession.cs b/OpenNest.Data/NestHistorySession.cs
new file mode 100644
index 0000000..40291fd
--- /dev/null
+++ b/OpenNest.Data/NestHistorySession.cs
@@ -0,0 +1,182 @@
+using System.Text.Json;
+
+namespace OpenNest.Data;
+
+///
+/// URL-bound, bounded history browsing. Historical reads never bind an editable document.
+/// A selection change cancels and invalidates previous reads even if a backend ignores cancellation.
+/// Mutations use the displayed current revision; an uncertain failure requires an explicit refresh.
+///
+public sealed class NestHistorySession : IDisposable
+{
+ private readonly INestRepository repository;
+ private CancellationTokenSource? pending;
+ private int generation;
+ private bool disposed;
+ public string ServerUrl { get; }
+ public Guid NestId { get; private set; }
+ public bool Deleted { get; private set; }
+ public NestRecord? Current { get; private set; }
+ public NestRevisionPage? Page { get; private set; }
+ public bool IsMutating { get; private set; }
+ public const int PageSize = 50;
+
+ public NestHistorySession(INestRepository repository, string serverUrl)
+ {
+ this.repository = repository ?? throw new ArgumentNullException(nameof(repository));
+ ServerUrl = Normalize(serverUrl);
+ }
+
+ public async Task LoadAsync(Guid id, bool deleted = false, int offset = 0,
+ CancellationToken cancellationToken = default)
+ {
+ ObjectDisposedException.ThrowIf(disposed, this);
+ if (IsMutating) throw new InvalidOperationException("Wait for the current operation to finish.");
+ if (id == Guid.Empty || offset < 0) throw new ArgumentOutOfRangeException(nameof(id));
+ pending?.Cancel();
+ var run = ++generation;
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ pending = cts;
+ NestId = id;
+ Deleted = deleted;
+ Current = null;
+ Page = null;
+ try
+ {
+ var record = deleted ? await repository.GetDeletedMetadataAsync(id, cts.Token)
+ : await repository.GetMetadataAsync(id, cts.Token);
+ if (record == null) throw new FileNotFoundException("This nest is no longer in this view. Refresh the list.");
+ ValidateRecord(record, id, deleted);
+ if (record.Revision < 1) throw new NotSupportedException("Upgrade the nest server to browse or change version history. Current nests can still be opened read-only.");
+ var page = deleted ? await repository.GetDeletedRevisionsAsync(id, offset, PageSize, cts.Token)
+ : await repository.GetRevisionsAsync(id, offset, PageSize, cts.Token);
+ if (page.Offset != offset || page.Items.Count > PageSize || page.Total < offset + page.Items.Count
+ || page.Items.Any(r => r.Id != id || r.Revision < 1))
+ throw new InvalidDataException("The server returned invalid nest history.");
+ if (page.Items.Any(r => r.Revision > record.Revision))
+ throw new IOException("The current version changed while loading history. Refresh to review the new current version.");
+ if (run != generation || cts.IsCancellationRequested || disposed) return false;
+ Current = record;
+ Page = page;
+ return true;
+ }
+ catch (Exception) when (run != generation || cts.IsCancellationRequested || disposed) { return false; }
+ finally { if (ReferenceEquals(pending, cts)) pending = null; }
+ }
+
+ public async Task DownloadAsync(int revision, string serverUrl, CancellationToken ct = default)
+ {
+ EnsureReady(serverUrl);
+ var id = NestId;
+ var run = generation;
+ var snapshot = Page!.Items.SingleOrDefault(r => r.Revision == revision)
+ ?? throw new InvalidOperationException("Select a revision on the displayed page.");
+ var file = Deleted ? await repository.GetDeletedRevisionFileAsync(id, revision, ct)
+ : await repository.GetRevisionFileAsync(id, revision, ct);
+ ct.ThrowIfCancellationRequested();
+ if (disposed || run != generation) return null;
+ if (file == null || file.LongLength != snapshot.FileSize)
+ throw new InvalidDataException("The revision archive is missing or does not match its metadata. Refresh before trying again.");
+ return file;
+ }
+
+ public async Task RestoreAsync(int sourceRevision, string serverUrl, CancellationToken ct = default)
+ {
+ EnsureReady(serverUrl);
+ if (Deleted) throw new InvalidOperationException("Recover the deleted nest before restoring an earlier version.");
+ if (!Page!.Items.Any(r => r.Revision == sourceRevision)) throw new InvalidOperationException("Select a displayed revision.");
+ return await MutateAsync(() => repository.RestoreAsync(NestId, sourceRevision, Current!.Revision, ct), false, ct);
+ }
+
+ public async Task RecoverAsync(string serverUrl, CancellationToken ct = default)
+ {
+ EnsureReady(serverUrl);
+ if (!Deleted) throw new InvalidOperationException("This nest is not deleted.");
+ return await MutateAsync(() => repository.RecoverAsync(NestId, Current!.Revision, ct), false, ct);
+ }
+
+ public async Task DeleteAsync(string serverUrl, CancellationToken ct = default)
+ {
+ EnsureReady(serverUrl);
+ if (Deleted) throw new InvalidOperationException("This nest is already deleted.");
+ return await MutateAsync(() => repository.DeleteAsync(NestId, Current!.Revision, ct), true, ct);
+ }
+
+ private async Task MutateAsync(Func> write, bool deleted, CancellationToken ct)
+ {
+ IsMutating = true;
+ var expected = Current!.Revision;
+ try
+ {
+ var result = await write();
+ ValidateRecord(result, NestId, deleted);
+ if (result.Revision != expected + 1) throw new InvalidDataException("Invalid mutation revision. Refresh to reconcile.");
+ // Verify the exact target after the write; do not declare success from the response alone.
+ var verified = deleted ? await repository.GetDeletedMetadataAsync(NestId, ct)
+ : await repository.GetMetadataAsync(NestId, ct);
+ if (verified == null || verified.Id != NestId || verified.Revision < result.Revision || verified.Deleted != deleted)
+ throw new InvalidDataException("Could not verify the new current revision. Refresh to reconcile.");
+ ct.ThrowIfCancellationRequested();
+ if (disposed) throw new OperationCanceledException("The history browser was closed. Reopen to reconcile.");
+ Current = verified;
+ Deleted = deleted;
+ Page = null;
+ return result;
+ }
+ catch { Current = null; throw; }
+ finally { IsMutating = false; }
+ }
+
+ private void EnsureReady(string url)
+ {
+ ObjectDisposedException.ThrowIf(disposed, this);
+ if (Normalize(url) != ServerUrl) throw new InvalidOperationException("The nest server changed. Close this browser and reopen it on the selected server.");
+ if (Current == null || IsMutating) throw new InvalidOperationException("Refresh the current version before continuing.");
+ }
+
+ private static void ValidateRecord(NestRecord record, Guid id, bool deleted)
+ {
+ if (record.Id != id || record.Deleted != deleted) throw new InvalidDataException("The server returned a different nest identity or visibility.");
+ }
+
+ private static string Normalize(string? url) => (url ?? "").Trim().TrimEnd('/');
+
+ /// Read an immutable current snapshot and ensure metadata did not change during download.
+ public static async Task ReadCurrentAsync(INestRepository repository, Guid id,
+ string serverUrl, CancellationToken ct = default)
+ {
+ var before = await repository.GetMetadataAsync(id, ct)
+ ?? throw new FileNotFoundException("This nest no longer exists on the server.");
+ ValidateRecord(before, id, false);
+ var beforeMetadata = JsonSerializer.Serialize(before);
+ var file = before.Revision > 0 ? await repository.GetRevisionFileAsync(id, before.Revision, ct)
+ : await repository.GetFileAsync(id, ct);
+ var after = await repository.GetMetadataAsync(id, ct);
+ ct.ThrowIfCancellationRequested();
+ if (after == null || JsonSerializer.Serialize(after) != beforeMetadata
+ || file == null || file.LongLength != before.FileSize)
+ throw new InvalidDataException("The current nest changed or its archive does not match its metadata. Refresh and open it again.");
+ return new NestCurrentSnapshot(before, file, Normalize(serverUrl));
+ }
+
+ public void Clear()
+ {
+ if (IsMutating) throw new InvalidOperationException("Wait for the current operation to finish.");
+ ++generation;
+ pending?.Cancel();
+ NestId = Guid.Empty;
+ Current = null;
+ Page = null;
+ }
+
+ public void Dispose()
+ {
+ disposed = true;
+ ++generation;
+ pending?.Cancel();
+ Current = null;
+ Page = null;
+ }
+}
+
+public sealed record NestCurrentSnapshot(NestRecord Metadata, byte[] Archive, string ServerUrl);
diff --git a/OpenNest.Tests/Data/NestHistorySessionTests.cs b/OpenNest.Tests/Data/NestHistorySessionTests.cs
new file mode 100644
index 0000000..a93b905
--- /dev/null
+++ b/OpenNest.Tests/Data/NestHistorySessionTests.cs
@@ -0,0 +1,232 @@
+using OpenNest.Data;
+
+namespace OpenNest.Tests.Data;
+
+public class NestHistorySessionTests
+{
+ [Fact]
+ public async Task BoundedHistory_DownloadIsByteExactAndReadOnly()
+ {
+ var repository = new Repository();
+ using var session = new NestHistorySession(repository, "http://shop/");
+ Assert.True(await session.LoadAsync(repository.Id));
+ Assert.Equal(NestHistorySession.PageSize, repository.Limit);
+ Assert.Equal(repository.Bytes, await session.DownloadAsync(2, "http://shop"));
+ Assert.Equal(0, repository.Writes);
+ Assert.Equal(5, session.Current!.Revision);
+ Assert.Equal("Earlier notes", session.Page!.Items[0].Comments);
+ }
+
+ [Fact]
+ public async Task Restore_UsesDisplayedPreconditionSameIdAndVerifiesCurrent()
+ {
+ var repository = new Repository();
+ using var session = new NestHistorySession(repository, "http://shop");
+ await session.LoadAsync(repository.Id);
+ var restored = await session.RestoreAsync(2, "http://shop");
+ Assert.Equal(repository.Id, restored.Id);
+ Assert.Equal(6, restored.Revision);
+ Assert.Equal(5, repository.Expected);
+ Assert.Equal(2, repository.Source);
+ Assert.Equal(2, repository.MetadataReads);
+ Assert.Null(session.Page);
+ Assert.True(await session.LoadAsync(repository.Id));
+ Assert.Equal(6, session.Current!.Revision);
+ }
+
+ [Fact]
+ public async Task Recovery_IsExplicitAndUsesDeletedEndpoints()
+ {
+ var repository = new Repository { Deleted = true };
+ using var session = new NestHistorySession(repository, "http://shop");
+ await session.LoadAsync(repository.Id, true);
+ Assert.Equal(repository.Bytes, await session.DownloadAsync(2, "http://shop"));
+ Assert.Equal(1, repository.DeletedDownloads);
+ await Assert.ThrowsAsync(() => session.RestoreAsync(2, "http://shop"));
+ var recovered = await session.RecoverAsync("http://shop");
+ Assert.False(recovered.Deleted);
+ Assert.Equal(6, recovered.Revision);
+ Assert.Equal(5, repository.Expected);
+ }
+
+ [Fact]
+ public async Task FailedRestore_PreservesHistoryButDisablesBlindRetry()
+ {
+ var repository = new Repository { Fail = true };
+ using var session = new NestHistorySession(repository, "http://shop");
+ await session.LoadAsync(repository.Id);
+ var page = session.Page;
+ await Assert.ThrowsAsync(() => session.RestoreAsync(2, "http://shop"));
+ Assert.Same(page, session.Page);
+ Assert.Null(session.Current);
+ await Assert.ThrowsAsync(() => session.RestoreAsync(2, "http://shop"));
+ Assert.Equal(1, repository.Writes);
+ Assert.Equal(5, repository.Revision);
+ repository.Fail = false;
+ await session.LoadAsync(repository.Id);
+ Assert.NotNull(session.Current);
+ }
+
+ [Fact]
+ public async Task ServerSwitch_RefusesDownloadRestoreAndRecoveryWithoutRequests()
+ {
+ var repository = new Repository();
+ using var session = new NestHistorySession(repository, "http://shop");
+ await session.LoadAsync(repository.Id);
+ await Assert.ThrowsAsync(() => session.DownloadAsync(2, "http://other"));
+ await Assert.ThrowsAsync(() => session.RestoreAsync(2, "http://other"));
+ await Assert.ThrowsAsync(() => session.RecoverAsync("http://other"));
+ Assert.Equal(0, repository.Downloads);
+ Assert.Equal(0, repository.Writes);
+ }
+
+ [Fact]
+ public async Task StaleSelection_IgnoresBackendThatIgnoresCancellation()
+ {
+ var repository = new Repository();
+ var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ repository.History = held.Task;
+ using var session = new NestHistorySession(repository, "http://shop");
+ var first = session.LoadAsync(repository.Id);
+ repository.History = null;
+ var secondId = Guid.NewGuid();
+ await session.LoadAsync(secondId);
+ held.SetResult(new NestRevisionPage { Items = [repository.Record(repository.Id, 2)], Total = 1, Limit = 50 });
+ Assert.False(await first);
+ Assert.Equal(secondId, session.Current!.Id);
+ }
+
+ [Fact]
+ public async Task CancelAndClear_NeverPublishLateHistory()
+ {
+ var repository = new Repository();
+ var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ repository.History = held.Task;
+ using var session = new NestHistorySession(repository, "http://shop");
+ using var cancellation = new CancellationTokenSource();
+ var task = session.LoadAsync(repository.Id, cancellationToken: cancellation.Token);
+ cancellation.Cancel();
+ session.Clear();
+ held.SetResult(new NestRevisionPage { Items = [repository.Record(repository.Id, 2)], Total = 1, Limit = 50 });
+ Assert.False(await task);
+ Assert.Null(session.Current);
+ Assert.Null(session.Page);
+ }
+
+ [Fact]
+ public async Task LegacyServer_HistoryRefusesButCurrentReadRetainsZeroRevision()
+ {
+ var repository = new Repository { Revision = 0 };
+ using var session = new NestHistorySession(repository, "http://shop");
+ await Assert.ThrowsAsync(() => session.LoadAsync(repository.Id));
+ var snapshot = await NestHistorySession.ReadCurrentAsync(repository, repository.Id, "http://shop/");
+ Assert.Equal(0, snapshot.Metadata.Revision);
+ Assert.Equal("http://shop", snapshot.ServerUrl);
+ Assert.Equal(repository.Bytes, snapshot.Archive);
+ }
+
+ [Fact]
+ public async Task CurrentOpen_UsesImmutableRevisionNotUnversionedFile()
+ {
+ var repository = new Repository();
+ var snapshot = await NestHistorySession.ReadCurrentAsync(repository, repository.Id, "http://shop");
+ Assert.Equal(5, repository.DownloadRevision);
+ Assert.Equal(0, repository.LegacyDownloads);
+ var save = new NestSaveSession();
+ save.Bind(snapshot.Metadata.Id, snapshot.ServerUrl, snapshot.Metadata.Revision);
+ Assert.Equal(5, save.Revision);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task CurrentOpen_RejectsChangedMetadataOrMismatchingArchive(bool concurrent)
+ {
+ var repository = new Repository { ChangeDuringDownload = concurrent, WrongSize = !concurrent };
+ await Assert.ThrowsAsync(() => NestHistorySession.ReadCurrentAsync(repository, repository.Id, "http://shop"));
+ }
+
+ [Fact]
+ public async Task Mutation_VerificationFailureIsNotSuccess()
+ {
+ var repository = new Repository { WrongVerification = true };
+ using var session = new NestHistorySession(repository, "http://shop");
+ await session.LoadAsync(repository.Id);
+ await Assert.ThrowsAsync(() => session.RestoreAsync(2, "http://shop"));
+ Assert.Null(session.Current);
+ Assert.Equal(1, repository.Writes);
+ }
+
+ [Fact]
+ public async Task Download_SelectionChangedDuringReadDropsLateBytes()
+ {
+ var repository = new Repository();
+ using var session = new NestHistorySession(repository, "http://shop");
+ await session.LoadAsync(repository.Id);
+ var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ repository.File = held.Task;
+ var read = session.DownloadAsync(2, "http://shop");
+ session.Clear();
+ held.SetResult(repository.Bytes);
+ Assert.Null(await read);
+ }
+
+ private sealed class Repository : INestRepository
+ {
+ public Guid Id { get; } = Guid.NewGuid();
+ public byte[] Bytes { get; } = [1, 2, 3];
+ public int Revision = 5;
+ public bool Deleted, Fail, ChangeDuringDownload, WrongSize, WrongVerification;
+ public int Limit, Writes, Expected, Source, MetadataReads, Downloads, DeletedDownloads, LegacyDownloads, DownloadRevision;
+ public Task? History;
+ public Task? File;
+ public NestRecord Record(Guid id, int revision) => new()
+ {
+ Id = id,
+ Revision = revision,
+ Deleted = Deleted,
+ FileSize = WrongSize ? 100 : Bytes.Length,
+ Comments = "Earlier notes",
+ };
+ public Task GetMetadataAsync(Guid id, CancellationToken ct = default)
+ {
+ ++MetadataReads;
+ return Task.FromResult(Record(id, WrongVerification && Writes > 0 ? 1 : Revision));
+ }
+ public Task GetDeletedMetadataAsync(Guid id, CancellationToken ct = default) => GetMetadataAsync(id, ct);
+ public Task GetRevisionsAsync(Guid id, int offset = 0, int limit = 100, CancellationToken cancellationToken = default)
+ {
+ Limit = limit;
+ return History ?? Task.FromResult(new NestRevisionPage { Items = [Record(id, 2)], Total = offset + 1, Offset = offset, Limit = limit });
+ }
+ public Task GetDeletedRevisionsAsync(Guid id, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) => GetRevisionsAsync(id, offset, limit, cancellationToken);
+ public Task GetRevisionFileAsync(Guid id, int revisionNumber, CancellationToken cancellationToken = default)
+ {
+ Downloads++;
+ DownloadRevision = revisionNumber;
+ if (ChangeDuringDownload) Revision++;
+ return File ?? Task.FromResult(Bytes);
+ }
+ public Task GetDeletedRevisionFileAsync(Guid id, int revisionNumber, CancellationToken cancellationToken = default)
+ {
+ DeletedDownloads++;
+ return GetRevisionFileAsync(id, revisionNumber, cancellationToken);
+ }
+ public Task GetFileAsync(Guid id, CancellationToken ct = default) { LegacyDownloads++; return Task.FromResult(Bytes); }
+ public Task RestoreAsync(Guid id, int sourceRevision, int expectedRevision, CancellationToken ct = default)
+ {
+ Writes++; Expected = expectedRevision; Source = sourceRevision;
+ if (Fail) throw new IOException("Conflict: current version 6. Reload or Save As; no automatic retry.");
+ Deleted = false;
+ Revision++;
+ return Task.FromResult(Record(id, Revision));
+ }
+ public Task RecoverAsync(Guid id, int expectedRevision, CancellationToken ct = default) => RestoreAsync(id, 0, expectedRevision, ct);
+ public Task> ListAsync(CancellationToken ct = default) => throw new NotImplementedException();
+ public Task QueryAsync(NestQuery query, CancellationToken ct = default) => throw new NotImplementedException();
+ public Task UploadAsync(byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException();
+ public Task UpdateFileAsync(Guid id, byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException();
+ public Task UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException();
+ public Task DeleteAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException();
+ }
+}
diff --git a/OpenNest.WinForms.Tests/Forms/NestHistoryFormTests.cs b/OpenNest.WinForms.Tests/Forms/NestHistoryFormTests.cs
new file mode 100644
index 0000000..260f304
--- /dev/null
+++ b/OpenNest.WinForms.Tests/Forms/NestHistoryFormTests.cs
@@ -0,0 +1,86 @@
+using System.Reflection;
+using System.Windows.Forms;
+using OpenNest.Data;
+using OpenNest.Forms;
+
+namespace OpenNest.WinForms.Tests.Forms;
+
+public class NestHistoryFormTests
+{
+ [Fact]
+ public void HistoricalSelection_IsReadOnlyAndRestoreIsDistinctFromCurrent()
+ {
+ StaTestThread.Run(() =>
+ {
+ var repository = new Repository();
+ using var form = new NestHistoryForm(repository, "http://shop", repository.Id);
+ form.Show();
+ Application.DoEvents();
+ var grid = Field(form, "revisions");
+ Assert.Equal(2, grid.Rows.Count);
+ Assert.True(grid.ReadOnly);
+ Assert.Equal("Current", grid.Rows[0].Cells["State"].Value);
+ Assert.False(Field