mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-13 00:11:55 -04:00
feat(ui): inspect and restore nest history
This commit is contained in:
1 parent
5f1eeb00eb
commit
a7c8e8541e
8 files changed
+887
-12
No files matched your search
@@ -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(); }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user