mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 00:21:06 -04:00
Grids across the desktop app each set their own borders, header styles and selection colours, so tables looked different from form to form. The database nest browser's grid style moves into a shared GridStyle helper that the AutoNest, BOM import, options, nest history, cutting inspection and post settings grids now apply too. The helper also fixes row heights so users cannot resize rows; editing, selection and column sizing stay with each grid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
940 lines
36 KiB
C#
940 lines
36 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Drawing;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using System.Windows.Forms;
|
|
using OpenNest.Controls;
|
|
using OpenNest.Data;
|
|
using OpenNest.IO;
|
|
using Timer = System.Windows.Forms.Timer;
|
|
|
|
namespace OpenNest.Forms;
|
|
|
|
/// <summary>
|
|
/// File > 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 plates and drawings
|
|
/// from <see cref="NestDetailsSession"/> and on-demand version history beside a plate
|
|
/// preview. Nest Information edits a detached snapshot through guarded database saves.
|
|
/// </summary>
|
|
public sealed class SavedNestsForm : Form
|
|
{
|
|
// Rows this far before/after the highlight are warmed in the background once the
|
|
// highlighted nest's own details are showing, so arrowing through the page mostly
|
|
// hits the NestDetailsSession cache instead of downloading each nest on demand.
|
|
private const int PrefetchWindow = 5;
|
|
|
|
private const string IdColumn = "Id";
|
|
private const string PlateSizeColumn = "PlateSize";
|
|
private const string AreaColumn = "Area";
|
|
private const string LoadingText = "Loading...";
|
|
private const string LoadingDetailsText = "Loading details...";
|
|
private const string WindowTitle = "Open Nest — Database";
|
|
|
|
private static readonly Font SectionFont = new(SystemFonts.MessageBoxFont.FontFamily, 14f);
|
|
|
|
private readonly INestRepository repository;
|
|
private readonly string serverUrl;
|
|
private readonly NestBrowseSession session;
|
|
private NestDetailsSession details;
|
|
private readonly SplitContainer split;
|
|
private readonly DataGridView nestGrid;
|
|
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;
|
|
private readonly Button nextPlateButton;
|
|
private readonly Label plateLabel;
|
|
private readonly Label detailsStatus;
|
|
private readonly TextBox searchBox;
|
|
private readonly Timer searchTimer;
|
|
private readonly Timer detailsTimer;
|
|
private readonly Button previousButton;
|
|
private readonly Button nextButton;
|
|
private readonly Label summaryLabel;
|
|
private readonly ToolStripMenuItem openMenuItem;
|
|
private readonly ToolStripMenuItem deleteMenuItem;
|
|
private bool deleting;
|
|
private bool editingInfo;
|
|
private readonly Button infoButton;
|
|
private readonly ToolStripMenuItem infoMenuItem;
|
|
|
|
internal Func<EditNestInfoForm, DialogResult> ShowNestInfoDialog { get; set; }
|
|
private IReadOnlyList<Plate> previewPlates = Array.Empty<Plate>();
|
|
private int previewIndex = -1;
|
|
|
|
/// <summary>Set to the chosen record's id when the dialog closes with OK.</summary>
|
|
public Guid SelectedId { get; private set; }
|
|
|
|
public SavedNestsForm(INestRepository repository, string serverUrl = null)
|
|
{
|
|
this.repository = repository;
|
|
this.serverUrl = serverUrl ?? "";
|
|
var settings = Properties.Settings.Default;
|
|
var sort = Enum.TryParse<NestSortField>(settings.SavedNestsSortColumn, out var savedSort)
|
|
&& Enum.IsDefined(savedSort) ? savedSort : NestSortField.SavedAt;
|
|
session = new NestBrowseSession(repository, sort: sort, descending: settings.SavedNestsSortDescending);
|
|
details = new NestDetailsSession(LoadDetailsAsync);
|
|
FormClosed += (_, _) =>
|
|
{
|
|
settings.SavedNestsSortColumn = session.Sort.ToString();
|
|
settings.SavedNestsSortDescending = session.Descending;
|
|
settings.Save();
|
|
};
|
|
|
|
Text = WindowTitle;
|
|
// Placed on the owner's screen in Load; see FitToScreen.
|
|
StartPosition = FormStartPosition.Manual;
|
|
ClientSize = new Size(1280, 800);
|
|
MinimumSize = new Size(800, 560);
|
|
MinimizeBox = false;
|
|
ShowInTaskbar = false;
|
|
|
|
// Nests: one server page, sorted by the server when a header is clicked.
|
|
nestGrid = CreateGrid();
|
|
nestGrid.RowHeadersWidth = 64;
|
|
AddNestColumn("Nest name", NestSortField.Name, 170);
|
|
AddNestColumn("Status", NestSortField.Status, 100);
|
|
AddNestColumn("Modified", NestSortField.DateModified, 140);
|
|
AddNestColumn("Plates", NestSortField.PlateCount, 60, alignRight: true);
|
|
AddNestColumn("Parts", NestSortField.PartCount, 60, alignRight: true);
|
|
AddNestColumn("Comment", NestSortField.Comments, 260);
|
|
AddNestColumn("Customer", NestSortField.Customer, 160);
|
|
AddNestColumn("Made by", NestSortField.MadeBy, 90);
|
|
AddNestColumn("Material", NestSortField.Material, 150);
|
|
AddNestColumn("Thickness", NestSortField.Thickness, 80, alignRight: true);
|
|
AddNestColumn("Created", NestSortField.DateCreated, 140);
|
|
AddNestColumn("Saved", NestSortField.SavedAt, 140);
|
|
AddNestColumn("File size", NestSortField.FileSize, 80, alignRight: true);
|
|
nestGrid.Columns.Add(new DataGridViewTextBoxColumn
|
|
{
|
|
Name = IdColumn,
|
|
ValueType = typeof(Guid),
|
|
Visible = false,
|
|
SortMode = DataGridViewColumnSortMode.NotSortable,
|
|
});
|
|
// Sorting a page locally would misrepresent the archive; the server sorts all matches.
|
|
nestGrid.ColumnHeaderMouseClick += async (_, e) =>
|
|
{
|
|
if (e.Button == MouseButtons.Left && nestGrid.Columns[e.ColumnIndex].Tag is NestSortField field)
|
|
await RunAsync(() => session.SortByAsync(field));
|
|
};
|
|
nestGrid.CellDoubleClick += (_, e) =>
|
|
{
|
|
if (e.RowIndex >= 0)
|
|
OpenSelected();
|
|
};
|
|
nestGrid.CellMouseDown += (_, e) =>
|
|
{
|
|
// Right-click highlights the row under the pointer before its menu opens.
|
|
if (e.Button == MouseButtons.Right && e.RowIndex >= 0)
|
|
nestGrid.CurrentCell = nestGrid.Rows[e.RowIndex].Cells[0];
|
|
};
|
|
nestGrid.SelectionChanged += (_, _) =>
|
|
{
|
|
UpdateCommands();
|
|
ScheduleDetails();
|
|
};
|
|
|
|
var menu = new ContextMenuStrip();
|
|
openMenuItem = new ToolStripMenuItem("Open", null, (_, _) => OpenSelected()) { ShortcutKeyDisplayString = "Enter" };
|
|
deleteMenuItem = new ToolStripMenuItem("Delete...", null, async (_, _) => await DeleteSelectedAsync());
|
|
var refreshMenuItem = new ToolStripMenuItem("Refresh", null, async (_, _) => await RunAsync(session.RefreshAsync));
|
|
infoMenuItem = new ToolStripMenuItem("Nest Information...", null, async (_, _) => await EditSelectedInfoAsync());
|
|
menu.Items.AddRange(new ToolStripItem[] { openMenuItem, infoMenuItem, deleteMenuItem, new ToolStripSeparator(), refreshMenuItem });
|
|
nestGrid.ContextMenuStrip = menu;
|
|
|
|
// Details of the highlighted nest.
|
|
platesGrid = CreateGrid();
|
|
platesGrid.RowHeadersVisible = false;
|
|
AddColumn(platesGrid, "Number", "#", 50, typeof(int), alignRight: true);
|
|
AddColumn(platesGrid, "Duplicates", "Duplicates", 90, typeof(int), alignRight: true);
|
|
AddColumn(platesGrid, PlateSizeColumn, "Plate size", 140, typeof(string));
|
|
AddColumn(platesGrid, "Parts", "Parts", 70, typeof(int), alignRight: true);
|
|
AddColumn(platesGrid, "Drawings", "Drawings", 80, typeof(int), alignRight: true);
|
|
AddColumn(platesGrid, "Utilization", "Utilization", 90, typeof(double), alignRight: true, format: "P1");
|
|
platesGrid.SelectionChanged += (_, _) =>
|
|
{
|
|
// Read the selected row: while this event runs, CurrentRow still names the
|
|
// previously current row.
|
|
if (SelectedPlateRow() is var row and >= 0)
|
|
ShowPlate(row);
|
|
};
|
|
|
|
drawingsGrid = CreateGrid();
|
|
drawingsGrid.RowHeadersVisible = false;
|
|
AddColumn(drawingsGrid, "Drawing", "Drawing", 220, typeof(string));
|
|
AddColumn(drawingsGrid, "Customer", "Customer", 160, typeof(string));
|
|
AddColumn(drawingsGrid, "Required", "Required", 80, typeof(int), alignRight: true);
|
|
AddColumn(drawingsGrid, "Nested", "Nested", 80, typeof(int), alignRight: true);
|
|
AddColumn(drawingsGrid, "Remaining", "Remaining", 90, typeof(int), alignRight: true);
|
|
AddColumn(drawingsGrid, AreaColumn, "Area", 100, typeof(double), alignRight: true, format: "0.###");
|
|
|
|
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
|
|
{
|
|
Dock = DockStyle.Fill,
|
|
AllowSelect = false,
|
|
AllowDrop = false,
|
|
DrawOrigin = false,
|
|
Cursor = Cursors.Default,
|
|
TabStop = false,
|
|
Visible = false,
|
|
};
|
|
platePreview.SizeChanged += (_, _) =>
|
|
{
|
|
if (platePreview.Visible)
|
|
platePreview.ZoomToFit();
|
|
};
|
|
previousPlateButton = new Button { Text = "\u25C0", Width = 32, Enabled = false, AccessibleName = "Previous plate" };
|
|
previousPlateButton.Click += (_, _) => ShowPlate(previewIndex - 1);
|
|
nextPlateButton = new Button { Text = "\u25B6", Width = 32, Enabled = false, AccessibleName = "Next plate" };
|
|
nextPlateButton.Click += (_, _) => ShowPlate(previewIndex + 1);
|
|
plateLabel = new Label { Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleCenter, AutoEllipsis = true };
|
|
|
|
var plateBar = new TableLayoutPanel { Dock = DockStyle.Bottom, AutoSize = true, ColumnCount = 3, RowCount = 1 };
|
|
plateBar.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
|
plateBar.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
|
plateBar.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
|
plateBar.Controls.Add(previousPlateButton, 0, 0);
|
|
plateBar.Controls.Add(plateLabel, 1, 0);
|
|
plateBar.Controls.Add(nextPlateButton, 2, 0);
|
|
|
|
detailsSplit = new SplitContainer
|
|
{
|
|
Dock = DockStyle.Fill,
|
|
Orientation = Orientation.Vertical,
|
|
SplitterWidth = 6,
|
|
};
|
|
detailsSplit.Panel1.Controls.Add(detailsTabs);
|
|
detailsSplit.Panel2.Controls.Add(platePreview);
|
|
detailsSplit.Panel2.Controls.Add(plateBar);
|
|
|
|
detailsStatus = new Label
|
|
{
|
|
Dock = DockStyle.Fill,
|
|
TextAlign = ContentAlignment.MiddleLeft,
|
|
ForeColor = SystemColors.GrayText,
|
|
AutoEllipsis = true,
|
|
};
|
|
var detailsHeader = new Panel { Dock = DockStyle.Top, Height = 32 };
|
|
detailsHeader.Controls.Add(detailsStatus);
|
|
detailsHeader.Controls.Add(SectionLabel("Details", DockStyle.Left));
|
|
|
|
split = new SplitContainer
|
|
{
|
|
Dock = DockStyle.Fill,
|
|
Orientation = Orientation.Horizontal,
|
|
SplitterWidth = 6,
|
|
};
|
|
split.Panel1.Controls.Add(nestGrid);
|
|
infoButton = new Button { Text = "Nest Information...", AutoSize = true, Dock = DockStyle.Right };
|
|
infoButton.Click += async (_, _) => await EditSelectedInfoAsync();
|
|
var nestsHeader = new Panel { Dock = DockStyle.Top, Height = 36 };
|
|
nestsHeader.Controls.Add(infoButton);
|
|
nestsHeader.Controls.Add(SectionLabel("Nests", DockStyle.Left));
|
|
split.Panel1.Controls.Add(nestsHeader);
|
|
ShowNestInfoDialog = dialog => dialog.ShowDialog(this);
|
|
FormClosing += (_, e) => e.Cancel = editingInfo;
|
|
split.Panel2.Controls.Add(detailsSplit);
|
|
split.Panel2.Controls.Add(detailsHeader);
|
|
|
|
// Find bar and paging.
|
|
searchBox = new TextBox
|
|
{
|
|
Width = 320,
|
|
MaxLength = NestQuery.MaxSearchLength,
|
|
PlaceholderText = "Name, customer, material, made by, comment or status",
|
|
};
|
|
searchTimer = new Timer { Interval = 300 };
|
|
searchTimer.Tick += async (_, _) =>
|
|
{
|
|
searchTimer.Stop();
|
|
await RunAsync(() => session.SetSearchAsync(searchBox.Text));
|
|
};
|
|
searchBox.TextChanged += (_, _) =>
|
|
{
|
|
searchTimer.Stop();
|
|
searchTimer.Start();
|
|
};
|
|
detailsTimer = new Timer { Interval = 250 };
|
|
detailsTimer.Tick += async (_, _) =>
|
|
{
|
|
detailsTimer.Stop();
|
|
if (SelectedNestId() is Guid id)
|
|
await ShowDetailsAsync(id);
|
|
};
|
|
|
|
var refreshButton = new Button { Text = "Refresh", AutoSize = true };
|
|
refreshButton.Click += async (_, _) => await RunAsync(session.RefreshAsync);
|
|
previousButton = new Button { Text = "\u25C0", Width = 32, Enabled = false };
|
|
previousButton.Click += async (_, _) => await RunAsync(session.PreviousPageAsync);
|
|
nextButton = new Button { Text = "\u25B6", Width = 32, Enabled = false };
|
|
nextButton.Click += async (_, _) => await RunAsync(session.NextPageAsync);
|
|
summaryLabel = new Label { AutoSize = true, Margin = new Padding(12, 8, 3, 0) };
|
|
|
|
var findBar = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill, WrapContents = false };
|
|
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 recoveryButton = new Button { Text = "Deleted nests...", AutoSize = true };
|
|
recoveryButton.Click += async (_, _) => await ShowDeletedAsync();
|
|
findBar.Controls.Add(recoveryButton);
|
|
findBar.Controls.Add(previousButton);
|
|
findBar.Controls.Add(nextButton);
|
|
findBar.Controls.Add(summaryLabel);
|
|
|
|
var bottom = new TableLayoutPanel
|
|
{
|
|
Dock = DockStyle.Bottom,
|
|
AutoSize = true,
|
|
ColumnCount = 1,
|
|
Padding = new Padding(3, 3, 3, 6),
|
|
};
|
|
bottom.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
|
bottom.Controls.Add(findBar);
|
|
|
|
Controls.Add(split);
|
|
Controls.Add(bottom);
|
|
|
|
Load += (_, _) =>
|
|
{
|
|
FitToScreen();
|
|
|
|
// Minimum sizes are applied once the container has its real height; setting them
|
|
// on the default-sized container throws.
|
|
split.Panel1MinSize = 120;
|
|
split.Panel2MinSize = 120;
|
|
split.SplitterDistance = System.Math.Max(split.Panel1MinSize, split.Height * 3 / 5);
|
|
detailsSplit.Panel1MinSize = 240;
|
|
detailsSplit.Panel2MinSize = 160;
|
|
detailsSplit.SplitterDistance = System.Math.Max(detailsSplit.Panel1MinSize, detailsSplit.Width * 3 / 5);
|
|
};
|
|
Shown += async (_, _) =>
|
|
{
|
|
nestGrid.Focus();
|
|
await RunAsync(session.RefreshAsync);
|
|
};
|
|
UpdateCommands();
|
|
}
|
|
|
|
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
|
|
{
|
|
if (editingInfo)
|
|
return true;
|
|
switch (keyData)
|
|
{
|
|
case Keys.Enter when searchBox.Focused:
|
|
// Run the typed search now instead of opening a row from the previous results.
|
|
searchTimer.Stop();
|
|
_ = RunAsync(() => session.SetSearchAsync(searchBox.Text));
|
|
nestGrid.Focus();
|
|
return true;
|
|
case Keys.Enter when nestGrid.Focused:
|
|
OpenSelected();
|
|
return true;
|
|
case Keys.Escape:
|
|
DialogResult = DialogResult.Cancel;
|
|
return true;
|
|
}
|
|
|
|
return base.ProcessCmdKey(ref msg, keyData);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opens the dialog as a large normal window centered in the working area of the owner's
|
|
/// screen, so its bottom row stays clear of the taskbar. It is deliberately not maximized
|
|
/// with fixed <see cref="Form.MaximizedBounds"/>: Windows reads those relative to the
|
|
/// monitor, so bounds taken from a secondary screen moved the modal dialog off-screen there
|
|
/// and left the main window unusable.
|
|
/// </summary>
|
|
private void FitToScreen()
|
|
{
|
|
var area = Screen.FromControl(Owner ?? this).WorkingArea;
|
|
var width = System.Math.Max(MinimumSize.Width, area.Width * 9 / 10);
|
|
var height = System.Math.Max(MinimumSize.Height, area.Height * 9 / 10);
|
|
Bounds = new Rectangle(
|
|
System.Math.Max(area.Left, area.Left + (area.Width - width) / 2),
|
|
System.Math.Max(area.Top, area.Top + (area.Height - height) / 2),
|
|
width,
|
|
height);
|
|
}
|
|
|
|
private static DataGridView CreateGrid()
|
|
{
|
|
return GridStyle.Apply(new DataGridView
|
|
{
|
|
Dock = DockStyle.Fill,
|
|
ReadOnly = true,
|
|
AllowUserToAddRows = false,
|
|
AllowUserToDeleteRows = false,
|
|
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
|
|
MultiSelect = false,
|
|
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.None,
|
|
StandardTab = true,
|
|
});
|
|
}
|
|
|
|
private static DataGridViewTextBoxColumn AddColumn(
|
|
DataGridView grid,
|
|
string name,
|
|
string header,
|
|
int width,
|
|
Type valueType,
|
|
bool alignRight = false,
|
|
string format = null)
|
|
{
|
|
var column = new DataGridViewTextBoxColumn
|
|
{
|
|
Name = name,
|
|
HeaderText = header,
|
|
Width = width,
|
|
ValueType = valueType,
|
|
};
|
|
if (alignRight)
|
|
column.DefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleRight;
|
|
if (format != null)
|
|
column.DefaultCellStyle.Format = format;
|
|
grid.Columns.Add(column);
|
|
return column;
|
|
}
|
|
|
|
private void AddNestColumn(string header, NestSortField sortField, int width, bool alignRight = false)
|
|
{
|
|
// Cells hold display text: the server has already sorted the page.
|
|
var column = AddColumn(nestGrid, header, header, width, typeof(string), alignRight);
|
|
column.SortMode = DataGridViewColumnSortMode.Programmatic;
|
|
column.Tag = sortField;
|
|
}
|
|
|
|
private static TabPage CreateTab(string text, Control content)
|
|
{
|
|
var page = new TabPage(text) { Padding = new Padding(0) };
|
|
page.Controls.Add(content);
|
|
return page;
|
|
}
|
|
|
|
private static Label SectionLabel(string text, DockStyle dock) =>
|
|
new()
|
|
{
|
|
Text = text,
|
|
Font = SectionFont,
|
|
Dock = dock,
|
|
AutoSize = dock == DockStyle.Left,
|
|
Height = 32,
|
|
TextAlign = ContentAlignment.MiddleLeft,
|
|
Padding = new Padding(0, 0, 12, 0),
|
|
};
|
|
|
|
/// <summary>
|
|
/// Sends one browse request and renders its page. A request superseded by a newer one
|
|
/// renders nothing; a failure clears the rows and shows the error in the summary line.
|
|
/// Navigation follows only the latest request (<see cref="NestBrowseSession.IsLoading"/>).
|
|
/// </summary>
|
|
private async Task RunAsync(Func<Task<bool>> request)
|
|
{
|
|
summaryLabel.Text = LoadingText;
|
|
try
|
|
{
|
|
var started = request();
|
|
UpdateNavigation();
|
|
if (await started && !IsDisposed)
|
|
Populate(session.Page);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (!IsDisposed)
|
|
{
|
|
nestGrid.Rows.Clear();
|
|
summaryLabel.Text = $"Could not load nests from the server: {ex.Message}";
|
|
UpdateTitle("");
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (!IsDisposed)
|
|
{
|
|
UpdateNavigation();
|
|
// A request that was declined or superseded renders nothing of its own.
|
|
if (!session.IsLoading && summaryLabel.Text == LoadingText)
|
|
summaryLabel.Text = session.Summary;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void UpdateNavigation()
|
|
{
|
|
previousButton.Enabled = !session.IsLoading && session.CanGoPrevious;
|
|
nextButton.Enabled = !session.IsLoading && session.CanGoNext;
|
|
}
|
|
|
|
private void UpdateCommands()
|
|
{
|
|
var hasSelection = SelectedNestId() != null && !editingInfo && !deleting;
|
|
openMenuItem.Enabled = hasSelection;
|
|
infoButton.Enabled = hasSelection;
|
|
infoMenuItem.Enabled = hasSelection;
|
|
deleteMenuItem.Enabled = hasSelection;
|
|
}
|
|
|
|
private void UpdateTitle(string summary)
|
|
{
|
|
var parts = new[] { WindowTitle, serverUrl, summary };
|
|
Text = string.Join(" ", Array.FindAll(parts, part => part.Length > 0));
|
|
}
|
|
|
|
private void Populate(NestPage page)
|
|
{
|
|
nestGrid.Rows.Clear();
|
|
for (var i = 0; i < page.Items.Count; i++)
|
|
{
|
|
var record = page.Items[i];
|
|
var index = nestGrid.Rows.Add(
|
|
record.Name,
|
|
FormatStatus(record.Status),
|
|
FormatDate(record.DateModified),
|
|
record.PlateCount.ToString("N0", CultureInfo.CurrentCulture),
|
|
record.PartCount.ToString("N0", CultureInfo.CurrentCulture),
|
|
record.Comments,
|
|
record.Customer,
|
|
record.MadeBy,
|
|
record.Material,
|
|
record.Thickness.ToString("0.####", CultureInfo.CurrentCulture),
|
|
FormatDate(record.DateCreated),
|
|
FormatDate(record.SavedAt),
|
|
FormatFileSize(record.FileSize),
|
|
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);
|
|
}
|
|
|
|
foreach (DataGridViewColumn column in nestGrid.Columns)
|
|
{
|
|
column.HeaderCell.SortGlyphDirection = column.Tag is NestSortField field && field == session.Sort
|
|
? session.Descending ? SortOrder.Descending : SortOrder.Ascending
|
|
: SortOrder.None;
|
|
}
|
|
|
|
if (nestGrid.Rows.Count > 0 && nestGrid.CurrentCell == null)
|
|
nestGrid.CurrentCell = nestGrid.Rows[0].Cells[0];
|
|
|
|
summaryLabel.Text = session.Summary;
|
|
UpdateTitle(session.Summary);
|
|
}
|
|
|
|
private static string FormatStatus(NestStatus status) => status switch
|
|
{
|
|
NestStatus.Quote => "Quote",
|
|
NestStatus.ToBeCut => "To Be Cut",
|
|
NestStatus.HasBeenCut => "Has Been Cut",
|
|
_ => status.ToString(),
|
|
};
|
|
|
|
private static string FormatDate(DateTime value) =>
|
|
value == default ? "" : value.ToString("g", CultureInfo.CurrentCulture);
|
|
|
|
private static readonly string[] FileSizeUnits = { "B", "KB", "MB", "GB" };
|
|
|
|
private static string FormatFileSize(long bytes)
|
|
{
|
|
double size = bytes;
|
|
var unitIndex = 0;
|
|
while (size >= 1024 && unitIndex < FileSizeUnits.Length - 1)
|
|
{
|
|
size /= 1024;
|
|
unitIndex++;
|
|
}
|
|
|
|
return unitIndex == 0 ? $"{size:0} {FileSizeUnits[unitIndex]}" : $"{size:0.#} {FileSizeUnits[unitIndex]}";
|
|
}
|
|
|
|
private Guid? SelectedNestId() =>
|
|
nestGrid.SelectedRows.Count > 0 && nestGrid.SelectedRows[0].Cells[IdColumn].Value is Guid id ? id : null;
|
|
|
|
/// <summary>
|
|
/// Shows the highlighted nest's details after a short pause, so arrowing through the
|
|
/// list downloads only the nest the highlight settles on.
|
|
/// </summary>
|
|
private void ScheduleDetails()
|
|
{
|
|
detailsTimer.Stop();
|
|
var id = SelectedNestId();
|
|
if (historyId != id)
|
|
{
|
|
historyView?.Dispose();
|
|
historyView = null;
|
|
historyId = null;
|
|
}
|
|
UpdateHistoryView();
|
|
if (id == null)
|
|
{
|
|
details.Clear();
|
|
ShowDetailsMessage("");
|
|
return;
|
|
}
|
|
|
|
if (id == details.NestId)
|
|
{
|
|
// Back on the nest already shown or loading (for example after arrowing away and
|
|
// back before the pause ended): keep that result rather than downloading it again.
|
|
if (details.Details != null)
|
|
{
|
|
PopulateDetails(details.Details);
|
|
PrefetchNearby(id.Value);
|
|
return;
|
|
}
|
|
|
|
if (details.IsLoading)
|
|
{
|
|
ShowDetailsMessage(LoadingDetailsText);
|
|
return;
|
|
}
|
|
}
|
|
|
|
ShowDetailsMessage(LoadingDetailsText);
|
|
detailsTimer.Start();
|
|
}
|
|
|
|
private async Task ShowDetailsAsync(Guid id)
|
|
{
|
|
try
|
|
{
|
|
if (await details.LoadAsync(id) && !IsDisposed)
|
|
{
|
|
PopulateDetails(details.Details);
|
|
PrefetchNearby(id);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (!IsDisposed)
|
|
ShowDetailsMessage($"Could not load details: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Warms the cache for the rows around the highlighted one (<see cref="PrefetchWindow"/>
|
|
/// on each side) so arrowing to a nearby nest resolves from <see cref="NestDetailsSession"/>'s
|
|
/// cache instead of a fresh download. Only the current page's rows are candidates.
|
|
/// </summary>
|
|
private void PrefetchNearby(Guid id)
|
|
{
|
|
var index = -1;
|
|
for (var i = 0; i < nestGrid.Rows.Count; i++)
|
|
{
|
|
if (nestGrid.Rows[i].Cells[IdColumn].Value is Guid rowId && rowId == id)
|
|
{
|
|
index = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (index < 0)
|
|
return;
|
|
|
|
for (var offset = 1; offset <= PrefetchWindow; offset++)
|
|
{
|
|
PrefetchRow(index - offset);
|
|
PrefetchRow(index + offset);
|
|
}
|
|
}
|
|
|
|
private void PrefetchRow(int index)
|
|
{
|
|
if (index < 0 || index >= nestGrid.Rows.Count)
|
|
return;
|
|
if (nestGrid.Rows[index].Cells[IdColumn].Value is Guid id)
|
|
details.Prefetch(id);
|
|
}
|
|
|
|
/// <summary>Downloads one nest's archive and reads its plates and drawings off the UI thread.</summary>
|
|
private async Task<NestDetails> LoadDetailsAsync(Guid id, CancellationToken cancellationToken)
|
|
{
|
|
var file = await repository.GetFileAsync(id, cancellationToken)
|
|
?? throw new InvalidOperationException("This nest no longer exists on the server.");
|
|
return await Task.Run(
|
|
() =>
|
|
{
|
|
using var stream = new MemoryStream(file);
|
|
return NestDetails.FromNest(new NestReader(stream).Read());
|
|
},
|
|
cancellationToken);
|
|
}
|
|
|
|
private void ShowDetailsMessage(string message)
|
|
{
|
|
previewPlates = Array.Empty<Plate>();
|
|
previewIndex = -1;
|
|
platePreview.Visible = false;
|
|
plateLabel.Text = "";
|
|
previousPlateButton.Enabled = false;
|
|
nextPlateButton.Enabled = false;
|
|
|
|
platesGrid.Rows.Clear();
|
|
drawingsGrid.Rows.Clear();
|
|
detailsStatus.Text = message;
|
|
}
|
|
|
|
private void PopulateDetails(NestDetails nestDetails)
|
|
{
|
|
var unit = nestDetails.Units == Units.Millimeters ? "mm" : "in";
|
|
platesGrid.Columns[PlateSizeColumn].HeaderText = $"Plate size ({unit})";
|
|
drawingsGrid.Columns[AreaColumn].HeaderText = $"Area ({unit}\u00B2)";
|
|
|
|
ShowDetailsMessage(
|
|
$"{nestDetails.Plates.Count:N0} plate(s), {nestDetails.Drawings.Count:N0} drawing(s)");
|
|
previewPlates = nestDetails.PlateLayouts;
|
|
foreach (var plate in nestDetails.Plates)
|
|
{
|
|
platesGrid.Rows.Add(
|
|
plate.Number,
|
|
plate.Duplicates,
|
|
$"{plate.Width.ToString("0.###", CultureInfo.CurrentCulture)} x {plate.Length.ToString("0.###", CultureInfo.CurrentCulture)}",
|
|
plate.PartCount,
|
|
plate.DrawingCount,
|
|
plate.Utilization);
|
|
}
|
|
|
|
foreach (var drawing in nestDetails.Drawings)
|
|
{
|
|
drawingsGrid.Rows.Add(
|
|
drawing.Name,
|
|
drawing.Customer,
|
|
drawing.Required,
|
|
drawing.Nested,
|
|
drawing.Remaining,
|
|
drawing.Area);
|
|
}
|
|
|
|
ShowPlate(0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shows one plate of the highlighted nest in the preview and highlights its row in the
|
|
/// Plates table. Plate buttons and row selection both come through here.
|
|
/// </summary>
|
|
private void ShowPlate(int index)
|
|
{
|
|
if (index < 0 || index >= previewPlates.Count)
|
|
return;
|
|
|
|
if (index != previewIndex)
|
|
{
|
|
previewIndex = index;
|
|
platePreview.Plate = previewPlates[index];
|
|
platePreview.Visible = true;
|
|
platePreview.ZoomToFit();
|
|
}
|
|
|
|
plateLabel.Text = $"Plate {index + 1:N0} of {previewPlates.Count:N0}";
|
|
previousPlateButton.Enabled = index > 0;
|
|
nextPlateButton.Enabled = index < previewPlates.Count - 1;
|
|
if (index < platesGrid.Rows.Count && SelectedPlateRow() != index)
|
|
platesGrid.CurrentCell = platesGrid.Rows[index].Cells[0];
|
|
}
|
|
|
|
private int SelectedPlateRow() => platesGrid.SelectedRows.Count > 0 ? platesGrid.SelectedRows[0].Index : -1;
|
|
|
|
private void OpenSelected()
|
|
{
|
|
if (editingInfo || SelectedNestId() is not Guid id)
|
|
return;
|
|
SelectedId = id;
|
|
DialogResult = DialogResult.OK;
|
|
}
|
|
|
|
private void UpdateHistoryView()
|
|
{
|
|
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)
|
|
{
|
|
details.Dispose();
|
|
details = new NestDetailsSession(LoadDetailsAsync);
|
|
ShowDetailsMessage("");
|
|
await RunAsync(session.RefreshAsync);
|
|
}
|
|
}
|
|
|
|
/// <summary>Edits a detached current snapshot without opening an MDI document.</summary>
|
|
internal async Task EditSelectedInfoAsync()
|
|
{
|
|
if (editingInfo || deleting || SelectedNestId() is not Guid id)
|
|
return;
|
|
summaryLabel.Text = "Loading nest information...";
|
|
editingInfo = true;
|
|
Enabled = false;
|
|
UpdateCommands();
|
|
try
|
|
{
|
|
var snapshot = await NestHistorySession.ReadCurrentAsync(repository, id, serverUrl);
|
|
if (IsDisposed) return;
|
|
if (snapshot.Metadata.Revision < 1)
|
|
throw new NotSupportedException("Upgrade the nest server and refresh before editing nest information.");
|
|
var nest = await Task.Run(() =>
|
|
{
|
|
using var source = new MemoryStream(snapshot.Archive);
|
|
var loaded = new NestReader(source).Read();
|
|
// Metadata-only revisions can be newer than the archived fields.
|
|
NestRecordFactory.ApplyMetadata(loaded, snapshot.Metadata);
|
|
return loaded;
|
|
});
|
|
if (IsDisposed) return;
|
|
using var dialog = new EditNestInfoForm();
|
|
dialog.LoadNestInfo(nest);
|
|
if (ShowNestInfoDialog(dialog) != DialogResult.OK)
|
|
return;
|
|
// Same default propagation as Nest > Nest Info in the open editor.
|
|
using var manager = new PlateManager(nest);
|
|
manager.ChangePlateDefaults(() => dialog.SaveNestInfo(nest));
|
|
nest.DateLastModified = DateTime.Now;
|
|
summaryLabel.Text = "Saving nest information...";
|
|
var bytes = await Task.Run(() =>
|
|
{
|
|
using var output = new MemoryStream();
|
|
new NestWriter(nest).Write(output);
|
|
return output.ToArray();
|
|
});
|
|
var save = new NestSaveSession();
|
|
save.Bind(id, snapshot.ServerUrl, snapshot.Metadata.Revision);
|
|
summaryLabel.Text = "Sending nest information...";
|
|
var saved = await save.SaveAsync(repository, snapshot.ServerUrl, nest, bytes);
|
|
var verified = await NestHistorySession.ReadCurrentAsync(repository, id, snapshot.ServerUrl);
|
|
if (verified.Metadata.Revision != saved.Revision
|
|
|| !System.Linq.Enumerable.SequenceEqual(bytes, verified.Archive))
|
|
throw new InvalidDataException("Could not verify the saved nest information.");
|
|
if (IsDisposed) return;
|
|
// Drop archive previews and embedded history, including prefetched snapshots.
|
|
details.Dispose();
|
|
details = new NestDetailsSession(LoadDetailsAsync);
|
|
historyView?.Dispose();
|
|
historyView = null;
|
|
historyId = null;
|
|
ShowDetailsMessage("");
|
|
await RunAsync(session.RefreshAsync);
|
|
foreach (DataGridViewRow row in nestGrid.Rows)
|
|
if (row.Cells[IdColumn].Value is Guid rowId && rowId == id)
|
|
nestGrid.CurrentCell = row.Cells[0];
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (!IsDisposed)
|
|
summaryLabel.Text = $"Could not edit nest information: {ex.Message} Refresh and reopen Nest Information before trying again; the save may already have reached the server.";
|
|
}
|
|
finally
|
|
{
|
|
editingInfo = false;
|
|
if (!IsDisposed)
|
|
{
|
|
Enabled = true;
|
|
if (summaryLabel.Text == "Loading nest information...")
|
|
summaryLabel.Text = session.Summary;
|
|
UpdateCommands();
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
$"Hide '{name}' from the current list? All versions are retained. Use Deleted nests to recover it.",
|
|
"Delete Nest",
|
|
MessageBoxButtons.YesNo,
|
|
MessageBoxIcon.Warning
|
|
);
|
|
if (confirm != DialogResult.Yes)
|
|
return;
|
|
|
|
deleting = true;
|
|
UpdateCommands();
|
|
try
|
|
{
|
|
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);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (!IsDisposed)
|
|
MessageBox.Show(
|
|
this,
|
|
$"Could not delete the nest: {ex.Message}",
|
|
"Delete Nest",
|
|
MessageBoxButtons.OK,
|
|
MessageBoxIcon.Error
|
|
);
|
|
}
|
|
finally
|
|
{
|
|
deleting = false;
|
|
if (!IsDisposed)
|
|
UpdateCommands();
|
|
}
|
|
}
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (disposing)
|
|
{
|
|
searchTimer.Dispose();
|
|
detailsTimer.Dispose();
|
|
session.Dispose();
|
|
details.Dispose();
|
|
}
|
|
|
|
base.Dispose(disposing);
|
|
}
|
|
}
|