feat(ui): show nest versions in database details tab

This commit is contained in:
aj committed 2026-10-11 19:42:06 -04:00
1 parent fae37dd1d0
commit 09dd19ca66
4 files changed
+105 -27

No files matched your search

@@ -49,6 +49,39 @@ public class NestHistoryFormTests
}, TimeSpan.FromSeconds(30), "Recovery form did not complete.");
}
[Fact]
public void SavedNests_VersionsTabFollowsSelectedNestWithoutWriting()
{
StaTestThread.Run(() =>
{
var repository = new Repository();
using var browser = new SavedNestsForm(repository, "http://shop");
browser.Show();
Application.DoEvents();
var tabs = Field<TabControl>(browser, "detailsTabs");
Assert.Equal(new[] { "Plates", "Drawings", "Versions" },
tabs.TabPages.Cast<TabPage>().Select(page => page.Text));
tabs.SelectedIndex = 2;
Application.DoEvents();
var first = Field<NestHistoryForm>(browser, "historyView");
Assert.NotNull(first);
Assert.Same(tabs.SelectedTab, first.Parent);
Assert.True(first.Visible);
Assert.Equal(repository.Id, ((NestRecord)Field<DataGridView>(first, "revisions").Rows[0].Tag!).Id);
Assert.False(Field<Button>(first, "restore").Enabled);
var nests = Field<DataGridView>(browser, "nestGrid");
nests.CurrentCell = nests.Rows[1].Cells[0];
Application.DoEvents();
var second = Field<NestHistoryForm>(browser, "historyView");
Assert.True(first.IsDisposed);
Assert.NotSame(first, second);
Assert.Equal(repository.SecondId, ((NestRecord)Field<DataGridView>(second, "revisions").Rows[0].Tag!).Id);
Assert.Equal(0, repository.Writes);
browser.Close();
}, TimeSpan.FromSeconds(30), "Embedded history did not follow the selected nest.");
}
[Fact]
public void Document_BindsOpenedRevisionWithoutLegacyFallback()
{
@@ -67,16 +100,17 @@ public class NestHistoryFormTests
private sealed class Repository : INestRepository
{
public Guid Id = Guid.NewGuid();
public Guid SecondId = 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));
private NestRecord Record(int revision, Guid? id = null) => new() { Id = id ?? Id, Revision = revision, Deleted = Deleted, Name = "Synthetic" };
public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken ct = default) => Task.FromResult<NestRecord?>(Record(5, id));
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> GetRevisionsAsync(Guid id, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) => Task.FromResult(new NestRevisionPage { Items = [Record(5, id), Record(2, id)], 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<NestPage> QueryAsync(NestQuery query, CancellationToken ct = default) => Task.FromResult(new NestPage { Items = [Record(5), Record(5, SecondId)], Total = 2, Limit = query.Limit });
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();
+14 -3
View File
@@ -35,8 +35,9 @@ public sealed class NestHistoryForm : Form
private bool populating;
private bool busy;
public bool Changed { get; private set; }
public event EventHandler HistoryChanged;
public NestHistoryForm(INestRepository repository, string serverUrl, Guid id = default)
public NestHistoryForm(INestRepository repository, string serverUrl, Guid id = default, bool embedded = false)
{
this.repository = repository;
this.serverUrl = serverUrl;
@@ -48,6 +49,14 @@ public sealed class NestHistoryForm : Form
Size = new Size(1050, 650);
MinimumSize = new Size(800, 480);
ShowInTaskbar = false;
if (embedded)
{
TopLevel = false;
FormBorderStyle = FormBorderStyle.None;
Dock = DockStyle.Fill;
MinimumSize = Size.Empty;
CancelButton = null;
}
records.Columns.Add("Name", "Deleted nest");
records.Columns.Add("Version", "Current version");
records.Columns.Add("Saved", "Saved");
@@ -77,9 +86,10 @@ public sealed class NestHistoryForm : Form
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;
if (!embedded) CancelButton = close;
var bar = new FlowLayoutPanel { Dock = DockStyle.Bottom, AutoSize = true };
bar.Controls.AddRange(new Control[] { refresh, previous, next, inspect, download, restore, recover, close });
bar.Controls.AddRange(new Control[] { refresh, previous, next, inspect, download, restore, recover });
if (!embedded) bar.Controls.Add(close);
recover.Visible = recovery;
restore.Visible = !recovery;
var heading = new Label
@@ -253,6 +263,7 @@ public sealed class NestHistoryForm : Form
if (recoveryAction) await session.RecoverAsync(serverUrl, lifetime.Token);
else await session.RestoreAsync(revision, serverUrl, lifetime.Token);
Changed = true;
HistoryChanged?.Invoke(this, EventArgs.Empty);
busy = false;
if (!IsDisposed) { if (recoveryAction) await ReadDeletedAsync(); else await ReadAsync(0); }
}
+43 -12
View File
@@ -16,9 +16,9 @@ namespace OpenNest.Forms;
/// <summary>
/// File &gt; Open (Database mode). The upper list browses saved nests on the shared nest
/// server one bounded page at a time; filtering, sorting and paging run on the server
/// through <see cref="NestBrowseSession"/>. The lower tabs show the plates and drawings of
/// the highlighted nest, read from its archive through <see cref="NestDetailsSession"/>,
/// beside a preview of one of its plates. This form only renders their state.
/// through <see cref="NestBrowseSession"/>. The lower tabs show plates and drawings
/// from <see cref="NestDetailsSession"/> and on-demand version history beside a plate
/// preview. This form renders the selected nest without editing its archive.
/// </summary>
public sealed class SavedNestsForm : Form
{
@@ -45,6 +45,9 @@ public sealed class SavedNestsForm : Form
private readonly DataGridView platesGrid;
private readonly DataGridView drawingsGrid;
private readonly TabControl detailsTabs;
private readonly TabPage versionsTab;
private NestHistoryForm historyView;
private Guid? historyId;
private readonly SplitContainer detailsSplit;
private readonly PlateView platePreview;
private readonly Button previousPlateButton;
@@ -141,7 +144,6 @@ 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.
@@ -173,6 +175,9 @@ public sealed class SavedNestsForm : Form
detailsTabs = new TabControl { Dock = DockStyle.Fill };
detailsTabs.TabPages.Add(CreateTab("Plates", platesGrid));
detailsTabs.TabPages.Add(CreateTab("Drawings", drawingsGrid));
versionsTab = new TabPage("Versions");
detailsTabs.TabPages.Add(versionsTab);
detailsTabs.SelectedIndexChanged += (_, _) => UpdateHistoryView();
// Preview of one plate beside the tables; the buttons below it step through the plates.
platePreview = new PlateView
@@ -274,11 +279,8 @@ 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);
recoveryButton.Click += async (_, _) => await ShowDeletedAsync();
findBar.Controls.Add(recoveryButton);
findBar.Controls.Add(previousButton);
findBar.Controls.Add(nextButton);
@@ -328,7 +330,7 @@ public sealed class SavedNestsForm : Form
_ = RunAsync(() => session.SetSearchAsync(searchBox.Text));
nestGrid.Focus();
return true;
case Keys.Enter:
case Keys.Enter when nestGrid.Focused:
OpenSelected();
return true;
case Keys.Escape:
@@ -579,6 +581,13 @@ public sealed class SavedNestsForm : Form
{
detailsTimer.Stop();
var id = SelectedNestId();
if (historyId != id)
{
historyView?.Dispose();
historyView = null;
historyId = null;
}
UpdateHistoryView();
if (id == null)
{
details.Clear();
@@ -756,10 +765,32 @@ public sealed class SavedNestsForm : Form
DialogResult = DialogResult.OK;
}
private async Task ShowHistoryAsync(bool recovery)
private void UpdateHistoryView()
{
if (!recovery && SelectedNestId() == null) return;
using var dialog = new NestHistoryForm(repository, serverUrl, recovery ? Guid.Empty : SelectedNestId().Value);
if (detailsTabs.SelectedTab != versionsTab || SelectedNestId() is not Guid id || historyId == id)
return;
historyView?.Dispose();
historyView = new NestHistoryForm(repository, serverUrl, id, embedded: true);
historyId = id;
historyView.HistoryChanged += (_, _) =>
{
// Let the history operation finish before refreshing the browse selection.
BeginInvoke(new Action(async () =>
{
if (IsDisposed) return;
details.Dispose();
details = new NestDetailsSession(LoadDetailsAsync);
ShowDetailsMessage("");
await RunAsync(session.RefreshAsync);
}));
};
versionsTab.Controls.Add(historyView);
historyView.Show();
}
private async Task ShowDeletedAsync()
{
using var dialog = new NestHistoryForm(repository, serverUrl);
dialog.ShowDialog(this);
if (dialog.Changed && !IsDisposed)
{
+10 -8
View File
@@ -14,18 +14,20 @@ The desktop app can save nests two ways:
user when the browser closes, including across app restarts. Until a selection
is saved, nests appear newest-saved first.
Below the list, the **Details** tabs show the highlighted nest's plates
(duplicates, size, parts, drawings, utilization) and drawings (required, nested,
remaining, area); they are read from that nest's archive, which is downloaded
shortly after the highlight stops moving. Beside the tabs, a preview draws one
(duplicates, size, parts, drawings, utilization), drawings (required, nested,
remaining, area), and **Versions** (bounded current and earlier snapshots).
Plates and drawings come from the archive downloaded shortly after the
highlight stops moving; Versions loads on selection and follows the selected
nest without opening another window. Beside the tabs, a preview draws one
plate of the highlighted nest; the arrow buttons below it step through the
plates, and selecting a row on the Plates tab shows that plate. Enter or a
double-click opens the highlighted nest and Esc closes. Save creates a record on
plates, and selecting a row on the Plates tab shows that plate. Enter on the
nest list or a double-click opens the highlighted nest; Esc closes. Save creates a record on
first save and updates that same record afterward. Save As creates a new record
(a copy). A failed save keeps the previous record association; switching server
URLs creates a record on the new server rather than updating an id from the
previous one. **Version history...** on a selected row lists bounded current and
earlier snapshots; **Open read-only...** previews one without binding an editable
document, and **Download selected version...** saves its exact archived bytes.
previous one. On the Versions tab, **Open read-only...** previews one snapshot
without binding an editable document, and **Download selected version...** saves
its exact archived bytes.
A separate confirmed **Restore selected version...** creates a new current
revision of the same ID and keeps all intervening versions. **Delete...** hides
the record from ordinary browsing but retains every archive; **Deleted nests...**