feat(ui): inspect and restore nest history

This commit is contained in:
aj committed 2026-10-11 17:01:01 -04:00
1 parent 5f1eeb00eb
commit a7c8e8541e
8 files changed
+887 -12

No files matched your search

+182
View File
@@ -0,0 +1,182 @@
using System.Text.Json;
namespace OpenNest.Data;
/// <summary>
/// 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.
/// </summary>
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<bool> 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<byte[]?> 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<NestRecord> 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<NestRecord> 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<NestRecord> 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<NestRecord> MutateAsync(Func<Task<NestRecord>> 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('/');
/// <summary>Read an immutable current snapshot and ensure metadata did not change during download.</summary>
public static async Task<NestCurrentSnapshot> 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);
@@ -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<InvalidOperationException>(() => 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<IOException>(() => session.RestoreAsync(2, "http://shop"));
Assert.Same(page, session.Page);
Assert.Null(session.Current);
await Assert.ThrowsAsync<InvalidOperationException>(() => 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<InvalidOperationException>(() => session.DownloadAsync(2, "http://other"));
await Assert.ThrowsAsync<InvalidOperationException>(() => session.RestoreAsync(2, "http://other"));
await Assert.ThrowsAsync<InvalidOperationException>(() => 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<NestRevisionPage>(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<NestRevisionPage>(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<NotSupportedException>(() => 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<InvalidDataException>(() => 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<InvalidDataException>(() => 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<byte[]?>(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<NestRevisionPage>? History;
public Task<byte[]?>? 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<NestRecord?> GetMetadataAsync(Guid id, CancellationToken ct = default)
{
++MetadataReads;
return Task.FromResult<NestRecord?>(Record(id, WrongVerification && Writes > 0 ? 1 : Revision));
}
public Task<NestRecord?> GetDeletedMetadataAsync(Guid id, CancellationToken ct = default) => GetMetadataAsync(id, ct);
public Task<NestRevisionPage> 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<NestRevisionPage> GetDeletedRevisionsAsync(Guid id, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) => GetRevisionsAsync(id, offset, limit, cancellationToken);
public Task<byte[]?> GetRevisionFileAsync(Guid id, int revisionNumber, CancellationToken cancellationToken = default)
{
Downloads++;
DownloadRevision = revisionNumber;
if (ChangeDuringDownload) Revision++;
return File ?? Task.FromResult<byte[]?>(Bytes);
}
public Task<byte[]?> GetDeletedRevisionFileAsync(Guid id, int revisionNumber, CancellationToken cancellationToken = default)
{
DeletedDownloads++;
return GetRevisionFileAsync(id, revisionNumber, cancellationToken);
}
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken ct = default) { LegacyDownloads++; return Task.FromResult<byte[]?>(Bytes); }
public Task<NestRecord> 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<NestRecord> RecoverAsync(Guid id, int expectedRevision, CancellationToken ct = default) => RestoreAsync(id, 0, expectedRevision, ct);
public Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken ct = default) => throw new NotImplementedException();
public Task<NestPage> QueryAsync(NestQuery query, CancellationToken ct = default) => throw new NotImplementedException();
public Task<NestRecord> UploadAsync(byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException();
public Task<NestRecord> UpdateFileAsync(Guid id, byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException();
public Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException();
public Task DeleteAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException();
}
}
@@ -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<DataGridView>(form, "revisions");
Assert.Equal(2, grid.Rows.Count);
Assert.True(grid.ReadOnly);
Assert.Equal("Current", grid.Rows[0].Cells["State"].Value);
Assert.False(Field<Button>(form, "restore").Enabled);
grid.CurrentCell = grid.Rows[1].Cells[0];
Assert.Equal("Historical", grid.SelectedRows[0].Cells["State"].Value);
Assert.True(Field<Button>(form, "restore").Enabled);
Assert.True(Field<Button>(form, "download").Enabled);
Assert.Equal(0, repository.Writes);
Assert.False(form.Changed);
form.Close();
}, TimeSpan.FromSeconds(30), "History form did not complete.");
}
[Fact]
public void Recovery_IsSeparateViewWithExplicitRecoveryButton()
{
StaTestThread.Run(() =>
{
var repository = new Repository { Deleted = true };
using var form = new NestHistoryForm(repository, "http://shop");
form.Show();
Application.DoEvents();
Assert.Single(Field<DataGridView>(form, "records").Rows.Cast<DataGridViewRow>());
Assert.True(Field<Button>(form, "recover").Visible);
Assert.True(Field<Button>(form, "recover").Enabled);
Assert.False(Field<Button>(form, "restore").Visible);
Assert.Equal(0, repository.Writes);
form.Close();
}, TimeSpan.FromSeconds(30), "Recovery form did not complete.");
}
[Fact]
public void Document_BindsOpenedRevisionWithoutLegacyFallback()
{
var document = new Document { Nest = new Nest("Synthetic") };
var id = Guid.NewGuid();
document.BindRemote(id, "http://shop", 5);
Assert.Equal(id, document.RemoteId);
Assert.Equal(5, document.RemoteRevision);
document.BindRemote(id, "http://shop");
Assert.Equal(0, document.RemoteRevision);
}
private static T Field<T>(object target, string name) =>
(T)target.GetType().GetField(name, BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(target)!;
private sealed class Repository : INestRepository
{
public Guid Id = Guid.NewGuid();
public bool Deleted;
public int Writes;
private NestRecord Record(int revision) => new() { Id = Id, Revision = revision, Deleted = Deleted, Name = "Synthetic" };
public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken ct = default) => Task.FromResult<NestRecord?>(Record(5));
public Task<NestRecord?> GetDeletedMetadataAsync(Guid id, CancellationToken ct = default) => GetMetadataAsync(id, ct);
public Task<NestPage> QueryDeletedAsync(NestQuery query, CancellationToken ct = default) => Task.FromResult(new NestPage { Items = [Record(5)], Total = 1, Limit = query.Limit });
public Task<NestRevisionPage> GetRevisionsAsync(Guid id, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) => Task.FromResult(new NestRevisionPage { Items = [Record(5), Record(2)], Total = 2, Limit = limit });
public Task<NestRevisionPage> GetDeletedRevisionsAsync(Guid id, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) => GetRevisionsAsync(id, offset, limit, cancellationToken);
public Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken ct = default) => throw new NotImplementedException();
public Task<NestPage> QueryAsync(NestQuery query, CancellationToken ct = default) => throw new NotImplementedException();
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException();
public Task<NestRecord> UploadAsync(byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException();
public Task<NestRecord> UpdateFileAsync(Guid id, byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException();
public Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException();
public Task DeleteAsync(Guid id, CancellationToken ct = default) { Writes++; throw new NotImplementedException(); }
}
}
+4
View File
@@ -21,6 +21,10 @@ namespace OpenNest
public void BindRemote(Guid id, string serverUrl) => remoteSession.Bind(id, serverUrl);
public void BindRemote(Guid id, string serverUrl, int revision) => remoteSession.Bind(id, serverUrl, revision);
public int RemoteRevision => remoteSession.Revision;
public string Name => Nest?.Name;
public bool HasSavePath => File.Exists(LastSavePath);
+16 -9
View File
@@ -169,11 +169,11 @@ namespace OpenNest.Forms
}
private void LoadNest(Nest nest, FormWindowState windowState = FormWindowState.Maximized,
Guid remoteId = default, string serverUrl = null)
Guid remoteId = default, string serverUrl = null, int remoteRevision = 0)
{
var editForm = new EditNestForm(nest);
if (remoteId != Guid.Empty)
editForm.Document.BindRemote(remoteId, serverUrl);
editForm.Document.BindRemote(remoteId, serverUrl, remoteRevision);
editForm.MdiParent = this;
editForm.PlateView.FilesDropped += (sender, paths) => OpenDroppedNestFiles(paths);
editForm.PlateChanged += (sender, e) =>
@@ -637,21 +637,28 @@ namespace OpenNest.Forms
try
{
var repository = NestStorage.CreateRepository();
using var dlg = new SavedNestsForm(repository, NestStorage.Settings.ServerUrl);
var serverUrl = NestStorage.Settings.ServerUrl;
using var dlg = new SavedNestsForm(repository, serverUrl);
if (dlg.ShowDialog(this) != DialogResult.OK)
return;
var serverUrl = NestStorage.Settings.ServerUrl;
databaseOpenInProgress = true;
storageModeMenu.Enabled = false;
mnuToolsOptions.Enabled = false;
var file = await repository.GetFileAsync(dlg.SelectedId);
if (file == null)
throw new FileNotFoundException("This nest no longer exists on the server.");
using var stream = new MemoryStream(file);
var snapshot = await NestHistorySession.ReadCurrentAsync(repository, dlg.SelectedId, serverUrl);
using var stream = new MemoryStream(snapshot.Archive);
var reader = new NestReader(stream);
var nest = reader.Read();
LoadNest(nest, remoteId: dlg.SelectedId, serverUrl: serverUrl);
// Metadata edits create revisions without rewriting the archive. Use their
// current editable fields rather than silently reverting them on the next save.
nest.Name = snapshot.Metadata.Name;
nest.Customer = snapshot.Metadata.Customer;
nest.Notes = snapshot.Metadata.Comments;
nest.Status = snapshot.Metadata.Status;
nest.MadeBy = snapshot.Metadata.MadeBy;
nest.DateLastModified = snapshot.Metadata.DateModified;
LoadNest(nest, remoteId: snapshot.Metadata.Id, serverUrl: snapshot.ServerUrl,
remoteRevision: snapshot.Metadata.Revision);
ShowNestWarnings(reader);
}
catch (Exception ex)
+268
View File
@@ -0,0 +1,268 @@
using System;
using System.Drawing;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Data;
using OpenNest.IO;
namespace OpenNest.Forms;
/// <summary>Read-only revision metadata/downloads with separate, confirmed promotion and recovery.</summary>
public sealed class NestHistoryForm : Form
{
private readonly INestRepository repository;
private readonly string serverUrl;
private readonly NestHistorySession session;
private readonly bool recovery;
private readonly DataGridView records = Grid();
private readonly DataGridView revisions = Grid();
private readonly Label status = new() { AutoSize = true };
private readonly Button previous = new() { Text = "Previous versions", AutoSize = true };
private readonly Button next = new() { Text = "Next versions", AutoSize = true };
private readonly Button download = new() { Text = "Download selected version...", AutoSize = true };
private readonly Button inspect = new() { Text = "Open read-only...", AutoSize = true };
private readonly Button restore = new() { Text = "Restore selected version...", AutoSize = true };
private readonly Button recover = new() { Text = "Recover selected nest...", AutoSize = true };
private readonly Button deletedPrevious = new() { Text = "Previous nests", AutoSize = true };
private readonly Button deletedNext = new() { Text = "Next nests", AutoSize = true };
private readonly CancellationTokenSource lifetime = new();
private readonly TextBox search = new() { Width = 220, MaxLength = NestQuery.MaxSearchLength, PlaceholderText = "Find deleted nests" };
private Guid selectedId;
private int deletedOffset;
private int readGeneration;
private bool populating;
private bool busy;
public bool Changed { get; private set; }
public NestHistoryForm(INestRepository repository, string serverUrl, Guid id = default)
{
this.repository = repository;
this.serverUrl = serverUrl;
selectedId = id;
recovery = id == Guid.Empty;
session = new NestHistorySession(repository, serverUrl);
Text = recovery ? "Deleted Nests — Recovery" : "Nest Version History";
StartPosition = FormStartPosition.CenterParent;
Size = new Size(1050, 650);
MinimumSize = new Size(800, 480);
ShowInTaskbar = false;
records.Columns.Add("Name", "Deleted nest");
records.Columns.Add("Version", "Current version");
records.Columns.Add("Saved", "Saved");
foreach (var caption in new[] { "Version", "State", "Saved", "Name", "Status", "Comments", "Restored from", "Bytes" })
revisions.Columns.Add(caption, caption);
revisions.Columns["Comments"].Width = 220;
records.SelectionChanged += async (_, _) =>
{
if (!populating && !busy && records.SelectedRows.Count > 0
&& records.SelectedRows[0].Tag is NestRecord record)
{
selectedId = record.Id;
await ReadAsync(0);
}
};
revisions.SelectionChanged += (_, _) => UpdateButtons();
var refresh = new Button { Text = "Refresh", AutoSize = true };
refresh.Click += async (_, _) => { if (recovery) await ReadDeletedAsync(); else await ReadAsync(0); };
previous.Click += async (_, _) => await ReadAsync(System.Math.Max(0, session.Page.Offset - NestHistorySession.PageSize));
next.Click += async (_, _) => await ReadAsync(session.Page.Offset + NestHistorySession.PageSize);
download.Click += async (_, _) => await DownloadAsync();
inspect.Click += async (_, _) => await InspectAsync();
restore.Click += async (_, _) => await MutateAsync(false);
recover.Click += async (_, _) => await MutateAsync(true);
deletedPrevious.Click += async (_, _) => { deletedOffset = System.Math.Max(0, deletedOffset - 50); await ReadDeletedAsync(); };
deletedNext.Click += async (_, _) => { deletedOffset += 50; await ReadDeletedAsync(); };
var find = new Button { Text = "Find", AutoSize = true };
find.Click += async (_, _) => { deletedOffset = 0; await ReadDeletedAsync(); };
var close = new Button { Text = "Close", AutoSize = true, DialogResult = DialogResult.Cancel };
CancelButton = close;
var bar = new FlowLayoutPanel { Dock = DockStyle.Bottom, AutoSize = true };
bar.Controls.AddRange(new Control[] { refresh, previous, next, inspect, download, restore, recover, close });
recover.Visible = recovery;
restore.Visible = !recovery;
var heading = new Label
{
Text = "Historical versions are read-only. Download opens no editor and changes nothing on the server. Restore adds a new version to the same nest.",
Dock = DockStyle.Top,
Height = 42,
};
var statusPanel = new FlowLayoutPanel { Dock = DockStyle.Bottom, AutoSize = true };
statusPanel.Controls.Add(status);
Controls.Add(revisions);
if (recovery)
{
var top = new Panel { Dock = DockStyle.Top, Height = 180 };
var findBar = new FlowLayoutPanel { Dock = DockStyle.Bottom, AutoSize = true };
findBar.Controls.AddRange(new Control[] { search, find, deletedPrevious, deletedNext });
top.Controls.Add(records);
top.Controls.Add(findBar);
Controls.Add(top);
}
Controls.Add(heading);
Controls.Add(statusPanel);
Controls.Add(bar);
Shown += async (_, _) => { if (recovery) await ReadDeletedAsync(); else await ReadAsync(0); };
FormClosing += (_, _) => lifetime.Cancel();
UpdateButtons();
}
private static DataGridView Grid() => new()
{
Dock = DockStyle.Fill,
ReadOnly = true,
AllowUserToAddRows = false,
AllowUserToDeleteRows = false,
MultiSelect = false,
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
AutoGenerateColumns = false,
RowHeadersVisible = false,
};
private NestRecord SelectedRevision => revisions.SelectedRows.Count > 0 ? revisions.SelectedRows[0].Tag as NestRecord : null;
private void UpdateButtons()
{
var ready = !busy && session.Current != null;
previous.Enabled = ready && session.Page?.Offset > 0;
next.Enabled = ready && session.Page != null && session.Page.Offset + session.Page.Items.Count < session.Page.Total;
download.Enabled = ready && SelectedRevision != null;
inspect.Enabled = download.Enabled;
restore.Enabled = ready && SelectedRevision != null && SelectedRevision.Revision != session.Current.Revision;
recover.Enabled = ready && session.Deleted;
records.Enabled = !busy;
}
private async Task ReadDeletedAsync()
{
if (busy) return;
var run = ++readGeneration;
selectedId = Guid.Empty;
session.Clear();
revisions.Rows.Clear();
UpdateButtons();
try
{
var page = await repository.QueryDeletedAsync(new NestQuery { Search = search.Text, Offset = deletedOffset, Limit = 50 }, lifetime.Token);
if (IsDisposed || run != readGeneration) return;
populating = true;
records.Rows.Clear();
foreach (var record in page.Items)
{
var row = records.Rows.Add(record.Name, record.Revision, record.SavedAt.ToString("g"));
records.Rows[row].Tag = record;
}
populating = false;
deletedPrevious.Enabled = page.Offset > 0;
deletedNext.Enabled = page.Offset + page.Items.Count < page.Total;
status.Text = $"{page.Total} deleted nests; page begins at {page.Offset + 1}.";
if (records.SelectedRows.Count > 0 && records.SelectedRows[0].Tag is NestRecord first)
{
selectedId = first.Id;
await ReadAsync(0);
}
}
catch (OperationCanceledException) { }
catch (Exception ex) { if (!IsDisposed && run == readGeneration) status.Text = ex.Message; }
finally { populating = false; }
}
private async Task ReadAsync(int offset)
{
if (busy || selectedId == Guid.Empty) return;
var run = ++readGeneration;
revisions.Rows.Clear();
status.Text = "Loading version history...";
var task = session.LoadAsync(selectedId, recovery, offset, lifetime.Token);
UpdateButtons();
try
{
if (!await task || IsDisposed || run != readGeneration) return;
foreach (var record in session.Page.Items)
{
var row = revisions.Rows.Add(record.Revision,
(record.Revision == session.Current.Revision ? "Current" : "Historical") + (record.Deleted ? " (deleted)" : ""),
record.SavedAt.ToString("g"), record.Name, record.Status, record.Comments, record.RestoredFromRevision, record.FileSize);
revisions.Rows[row].Tag = record;
}
status.Text = $"{serverUrl} — Nest {selectedId} — Current version {session.Current.Revision}; {session.Page.Total} versions. Displaying {offset + 1}–{offset + session.Page.Items.Count}.";
}
catch (Exception ex) { if (!IsDisposed && run == readGeneration) status.Text = ex.Message; }
finally { if (!IsDisposed) UpdateButtons(); }
}
private async Task InspectAsync()
{
if (SelectedRevision is not NestRecord record || busy) return;
busy = true;
UpdateButtons();
try
{
var bytes = await session.DownloadAsync(record.Revision, serverUrl, lifetime.Token);
if (bytes == null || IsDisposed) return;
var nest = await Task.Run(() =>
{
using var stream = new MemoryStream(bytes);
var reader = new NestReader(stream);
var loaded = reader.Read();
if (reader.Warnings.Count > 0)
throw new InvalidDataException(string.Join(Environment.NewLine, reader.Warnings));
return loaded;
}, lifetime.Token);
if (IsDisposed || lifetime.IsCancellationRequested) return;
using var preview = new NestRevisionPreviewForm(nest, record);
preview.ShowDialog(this);
}
catch (OperationCanceledException) { }
catch (Exception ex) { if (!IsDisposed) status.Text = ex.Message; }
finally { busy = false; if (!IsDisposed) UpdateButtons(); }
}
private async Task DownloadAsync()
{
if (SelectedRevision is not NestRecord record || busy) return;
using var dialog = new SaveFileDialog { Filter = "Nest archive (*.nest)|*.nest", FileName = $"nest-{selectedId}-v{record.Revision}.nest" };
if (dialog.ShowDialog(this) != DialogResult.OK) return;
busy = true;
UpdateButtons();
try
{
var bytes = await session.DownloadAsync(record.Revision, serverUrl, lifetime.Token);
if (bytes != null && !IsDisposed) await File.WriteAllBytesAsync(dialog.FileName, bytes, lifetime.Token);
}
catch (OperationCanceledException) { }
catch (Exception ex) { if (!IsDisposed) status.Text = ex.Message; }
finally { busy = false; if (!IsDisposed) UpdateButtons(); }
}
private async Task MutateAsync(bool recoveryAction)
{
if (busy || session.Current == null || (!recoveryAction && SelectedRevision == null)) return;
var revision = SelectedRevision?.Revision ?? 0;
var current = session.Current.Revision;
var text = recoveryAction
? $"Recover nest {selectedId} at version {current}? This unhides the same nest and adds a new version; all history is retained."
: $"Restore version {revision} over current version {current} of nest {selectedId}? This adds a new current version with the selected bytes and metadata. No history is removed.";
if (MessageBox.Show(this, text, recoveryAction ? "Recover Nest" : "Restore Version", MessageBoxButtons.YesNo,
MessageBoxIcon.Warning, MessageBoxDefaultButton.Button2) != DialogResult.Yes) return;
busy = true;
UpdateButtons();
try
{
if (recoveryAction) await session.RecoverAsync(serverUrl, lifetime.Token);
else await session.RestoreAsync(revision, serverUrl, lifetime.Token);
Changed = true;
busy = false;
if (!IsDisposed) { if (recoveryAction) await ReadDeletedAsync(); else await ReadAsync(0); }
}
catch (Exception ex) { if (!IsDisposed) status.Text = $"No automatic retry. Refresh to reconcile before continuing. {ex.Message}"; }
finally { busy = false; if (!IsDisposed) UpdateButtons(); }
}
protected override void Dispose(bool disposing)
{
if (disposing) { lifetime.Cancel(); session.Dispose(); }
base.Dispose(disposing);
}
}
+67
View File
@@ -0,0 +1,67 @@
using System.Drawing;
using System.Windows.Forms;
using OpenNest.Controls;
using OpenNest.Data;
namespace OpenNest.Forms;
/// <summary>A detached historical archive viewer; it has no Document or save/post actions.</summary>
public sealed class NestRevisionPreviewForm : Form
{
private readonly PlateView preview;
private readonly Label position = new() { AutoSize = true, Margin = new Padding(10) };
private readonly Button previous = new() { Text = "Previous plate", AutoSize = true };
private readonly Button next = new() { Text = "Next plate", AutoSize = true };
private readonly Nest nest;
private int index = -1;
public NestRevisionPreviewForm(Nest nest, NestRecord metadata)
{
this.nest = nest;
Text = $"Read-only — {metadata.Name} — Version {metadata.Revision}";
StartPosition = FormStartPosition.CenterParent;
Size = new Size(1000, 700);
MinimumSize = new Size(640, 420);
ShowInTaskbar = false;
preview = new PlateView
{
Dock = DockStyle.Fill,
AllowSelect = false,
AllowDrop = false,
DrawOrigin = false,
Cursor = Cursors.Default,
TabStop = false
};
preview.SizeChanged += (_, _) => { if (index >= 0) preview.ZoomToFit(); };
var heading = new Label
{
Dock = DockStyle.Top,
Height = 60,
Text = $"Read-only archive: {metadata.Id}, version {metadata.Revision}. No remote document is bound; this window cannot Save, Restore or Post.\nCustomer: {metadata.Customer}; Status: {metadata.Status}; Made by: {metadata.MadeBy}\nComments: {metadata.Comments}",
AutoEllipsis = true
};
previous.Click += (_, _) => ShowPlate(index - 1);
next.Click += (_, _) => ShowPlate(index + 1);
var close = new Button { Text = "Close", DialogResult = DialogResult.Cancel, AutoSize = true };
CancelButton = close;
var bar = new FlowLayoutPanel { Dock = DockStyle.Bottom, AutoSize = true };
bar.Controls.AddRange(new Control[] { previous, position, next, close });
Controls.Add(preview);
Controls.Add(heading);
Controls.Add(bar);
Shown += (_, _) => ShowPlate(0);
previous.Enabled = next.Enabled = false;
if (nest.Plates.Count == 0) position.Text = "No archived plates";
}
private void ShowPlate(int selected)
{
if (selected < 0 || selected >= nest.Plates.Count) return;
index = selected;
preview.Plate = nest.Plates[index];
preview.ZoomToFit();
position.Text = $"Plate {index + 1} of {nest.Plates.Count}";
previous.Enabled = index > 0;
next.Enabled = index + 1 < nest.Plates.Count;
}
}
+32 -3
View File
@@ -39,7 +39,7 @@ public sealed class SavedNestsForm : Form
private readonly INestRepository repository;
private readonly string serverUrl;
private readonly NestBrowseSession session;
private readonly NestDetailsSession details;
private NestDetailsSession details;
private readonly SplitContainer split;
private readonly DataGridView nestGrid;
private readonly DataGridView platesGrid;
@@ -141,6 +141,7 @@ public sealed class SavedNestsForm : Form
deleteMenuItem = new ToolStripMenuItem("Delete...", null, async (_, _) => await DeleteSelectedAsync());
var refreshMenuItem = new ToolStripMenuItem("Refresh", null, async (_, _) => await RunAsync(session.RefreshAsync));
menu.Items.AddRange(new ToolStripItem[] { openMenuItem, deleteMenuItem, new ToolStripSeparator(), refreshMenuItem });
menu.Items.Add(new ToolStripMenuItem("Version history...", null, async (_, _) => await ShowHistoryAsync(false)));
nestGrid.ContextMenuStrip = menu;
// Details of the highlighted nest.
@@ -273,6 +274,12 @@ public sealed class SavedNestsForm : Form
findBar.Controls.Add(new Label { Text = "Find:", AutoSize = true, Margin = new Padding(3, 8, 0, 0) });
findBar.Controls.Add(searchBox);
findBar.Controls.Add(refreshButton);
var historyButton = new Button { Text = "Version history...", AutoSize = true };
historyButton.Click += async (_, _) => await ShowHistoryAsync(false);
var recoveryButton = new Button { Text = "Deleted nests...", AutoSize = true };
recoveryButton.Click += async (_, _) => await ShowHistoryAsync(true);
findBar.Controls.Add(historyButton);
findBar.Controls.Add(recoveryButton);
findBar.Controls.Add(previousButton);
findBar.Controls.Add(nextButton);
findBar.Controls.Add(summaryLabel);
@@ -517,6 +524,7 @@ public sealed class SavedNestsForm : Form
record.Id
);
// Row numbers count through the whole filtered list, not just this page.
nestGrid.Rows[index].Tag = record;
nestGrid.Rows[index].HeaderCell.Value = (page.Offset + i + 1).ToString("N0", CultureInfo.CurrentCulture);
}
@@ -748,15 +756,30 @@ public sealed class SavedNestsForm : Form
DialogResult = DialogResult.OK;
}
private async Task ShowHistoryAsync(bool recovery)
{
if (!recovery && SelectedNestId() == null) return;
using var dialog = new NestHistoryForm(repository, serverUrl, recovery ? Guid.Empty : SelectedNestId().Value);
dialog.ShowDialog(this);
if (dialog.Changed && !IsDisposed)
{
details.Dispose();
details = new NestDetailsSession(LoadDetailsAsync);
ShowDetailsMessage("");
await RunAsync(session.RefreshAsync);
}
}
private async Task DeleteSelectedAsync()
{
if (deleting || SelectedNestId() is not Guid id)
return;
var name = nestGrid.SelectedRows[0].Cells[0].Value?.ToString() ?? "";
var selected = (NestRecord)nestGrid.SelectedRows[0].Tag;
var confirm = MessageBox.Show(
this,
$"Delete '{name}' from the server? This cannot be undone.",
$"Hide '{name}' from the current list? All versions are retained. Use Deleted nests to recover it.",
"Delete Nest",
MessageBoxButtons.YesNo,
MessageBoxIcon.Warning
@@ -768,7 +791,13 @@ public sealed class SavedNestsForm : Form
UpdateCommands();
try
{
await repository.DeleteAsync(id);
if (selected.Revision < 1)
throw new NotSupportedException("Upgrade the nest server and refresh before deleting a nest.");
var hidden = await repository.DeleteAsync(id, selected.Revision);
var verified = await repository.GetDeletedMetadataAsync(id);
if (hidden.Id != id || !hidden.Deleted || verified == null || verified.Id != id
|| !verified.Deleted || verified.Revision < hidden.Revision)
throw new InvalidDataException("Could not verify deletion. Refresh to reconcile; do not retry automatically.");
if (!IsDisposed)
await RunAsync(session.RefreshAsync);
}