feat: Improve document management with Save/Save As and keyboard shortcuts

- Track file path after save/load so Save doesn't prompt again
- Add Save As (Ctrl+Shift+S) to always prompt for location
- Update window title to show current filename
- Generate incremental default filenames (CutList_1.json, etc.)
- Add keyboard shortcuts: Ctrl+S (Save), Ctrl+O (Open), Ctrl+N (New)
- Enter key in items grid moves to Length column on next row

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 16:20:59 -05:00
parent b19ecf3610
commit 6db8ab21f4
4 changed files with 122 additions and 3 deletions

View File

@@ -9,6 +9,7 @@ namespace CutList.Forms
public partial class MainForm : Form, IMainView public partial class MainForm : Form, IMainView
{ {
private static readonly Random random = new Random(); private static readonly Random random = new Random();
private const string BaseTitle = "Cut List";
private BindingList<PartInputItem> parts; private BindingList<PartInputItem> parts;
private BindingList<BinInputItem> bins; private BindingList<BinInputItem> bins;
@@ -32,9 +33,13 @@ namespace CutList.Forms
binInputItemBindingSource.DataSource = bins; binInputItemBindingSource.DataSource = bins;
binInputItemBindingSource.ListChanged += BinInputItemBindingSource_ListChanged; binInputItemBindingSource.ListChanged += BinInputItemBindingSource_ListChanged;
toolbox = new Toolbox(); toolbox = new Toolbox();
cutMethodComboBox.DataSource = toolbox.Tools; cutMethodComboBox.DataSource = toolbox.Tools;
// Enable keyboard shortcuts
KeyPreview = true;
#if DEBUG #if DEBUG
loadExampleDataButton.Visible = true; loadExampleDataButton.Visible = true;
#else #else
@@ -140,6 +145,13 @@ namespace CutList.Forms
LoadDocumentData(new List<PartInputItem>(), new List<BinInputItem>()); LoadDocumentData(new List<PartInputItem>(), new List<BinInputItem>());
} }
public void UpdateWindowTitle(string? fileName)
{
Text = string.IsNullOrEmpty(fileName)
? BaseTitle
: $"{fileName} - {BaseTitle}";
}
// Event handler delegates to presenter // Event handler delegates to presenter
private void Open() private void Open()
{ {
@@ -164,6 +176,61 @@ namespace CutList.Forms
presenter.SaveDocument(); presenter.SaveDocument();
} }
private void SaveAs()
{
FlushPendingEdits();
presenter.SaveDocumentAs();
}
protected override void OnKeyDown(KeyEventArgs e)
{
base.OnKeyDown(e);
if (e.Control && e.Shift && e.KeyCode == Keys.S)
{
SaveAs();
e.Handled = true;
}
else if (e.Control && e.KeyCode == Keys.S)
{
Save();
e.Handled = true;
}
else if (e.Control && e.KeyCode == Keys.O)
{
Open();
e.Handled = true;
}
else if (e.Control && e.KeyCode == Keys.N)
{
presenter.NewDocument();
e.Handled = true;
}
}
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
// Handle Enter key in items DataGridView to move to Length column
if (keyData == Keys.Enter && dataGridView1.CurrentCell != null &&
(dataGridView1.ContainsFocus || dataGridView1.IsCurrentCellInEditMode))
{
dataGridView1.EndEdit();
int currentRow = dataGridView1.CurrentCell.RowIndex;
int nextRow = currentRow + 1;
int lengthColumnIndex = lengthDataGridViewTextBoxColumn.Index;
if (nextRow < dataGridView1.RowCount)
{
dataGridView1.CurrentCell = dataGridView1[lengthColumnIndex, nextRow];
dataGridView1.BeginEdit(true);
return true; // Handled
}
}
return base.ProcessCmdKey(ref msg, keyData);
}
private void Run() private void Run()
{ {
FlushPendingEdits(); FlushPendingEdits();
@@ -281,6 +348,7 @@ namespace CutList.Forms
dataGridView1.Refresh(); dataGridView1.Refresh();
} }
private void dataGridView1_DataError(object sender, DataGridViewDataErrorEventArgs e) private void dataGridView1_DataError(object sender, DataGridViewDataErrorEventArgs e)
{ {
dataGridView1.Rows[e.RowIndex].ErrorText = e.Exception.InnerException?.Message; dataGridView1.Rows[e.RowIndex].ErrorText = e.Exception.InnerException?.Message;

View File

@@ -88,5 +88,11 @@ namespace CutList.Presenters
/// Clears all data in the view. /// Clears all data in the view.
/// </summary> /// </summary>
void ClearData(); void ClearData();
/// <summary>
/// Updates the window title to reflect the current document state.
/// </summary>
/// <param name="fileName">The file name to display, or null for a new document</param>
void UpdateWindowTitle(string? fileName);
} }
} }

View File

@@ -13,6 +13,7 @@ namespace CutList.Presenters
private readonly CutListService _cutListService; private readonly CutListService _cutListService;
private readonly DocumentService _documentService; private readonly DocumentService _documentService;
private Document _currentDocument; private Document _currentDocument;
private int _documentCounter = 0;
public MainFormPresenter(IMainView view, CutListService cutListService, DocumentService documentService) public MainFormPresenter(IMainView view, CutListService cutListService, DocumentService documentService)
{ {
@@ -40,11 +41,13 @@ namespace CutList.Presenters
_currentDocument = loadResult.Value; _currentDocument = loadResult.Value;
_view.LoadDocumentData(_currentDocument.PartsToNest, _currentDocument.StockBins); _view.LoadDocumentData(_currentDocument.PartsToNest, _currentDocument.StockBins);
_view.UpdateWindowTitle(Path.GetFileName(filePath));
UpdateRunButtonState(); UpdateRunButtonState();
} }
/// <summary> /// <summary>
/// Handles the "Save" operation to save the current document to file. /// Handles the "Save" operation. If the document has a known path, saves directly.
/// Otherwise, prompts for a file location (same as Save As).
/// </summary> /// </summary>
public void SaveDocument() public void SaveDocument()
{ {
@@ -57,19 +60,59 @@ namespace CutList.Presenters
return; return;
} }
var defaultFileName = _currentDocument.LastFilePath == null // If we have a known path, save directly without prompting
? "NewDocument.json" if (!string.IsNullOrEmpty(_currentDocument.LastFilePath))
{
SaveToPath(_currentDocument.LastFilePath);
return;
}
// No known path - prompt for location (same as Save As)
SaveDocumentAs();
}
/// <summary>
/// Handles the "Save As" operation. Always prompts for a file location.
/// </summary>
public void SaveDocumentAs()
{
SyncDocumentFromView();
var validationResult = _documentService.Validate(_currentDocument);
if (validationResult.IsFailure)
{
_view.ShowWarning(validationResult.Error);
return;
}
var defaultFileName = string.IsNullOrEmpty(_currentDocument.LastFilePath)
? GenerateDefaultFileName()
: Path.GetFileName(_currentDocument.LastFilePath); : Path.GetFileName(_currentDocument.LastFilePath);
if (!_view.PromptSaveFile("Json File|*.json", defaultFileName, out string filePath)) if (!_view.PromptSaveFile("Json File|*.json", defaultFileName, out string filePath))
return; return;
SaveToPath(filePath);
}
private void SaveToPath(string filePath)
{
var saveResult = _documentService.Save(_currentDocument, filePath); var saveResult = _documentService.Save(_currentDocument, filePath);
if (saveResult.IsFailure) if (saveResult.IsFailure)
{ {
_view.ShowError(saveResult.Error); _view.ShowError(saveResult.Error);
return;
} }
_currentDocument.LastFilePath = filePath;
_view.UpdateWindowTitle(Path.GetFileName(filePath));
}
private string GenerateDefaultFileName()
{
_documentCounter++;
return $"CutList_{_documentCounter}.json";
} }
/// <summary> /// <summary>
@@ -100,6 +143,7 @@ namespace CutList.Presenters
{ {
_currentDocument = new Document(); _currentDocument = new Document();
_view.ClearData(); _view.ClearData();
_view.UpdateWindowTitle(null);
UpdateRunButtonState(); UpdateRunButtonState();
} }

View File

@@ -41,6 +41,7 @@ namespace CutList.Services
{ {
var json = File.ReadAllText(filePath); var json = File.ReadAllText(filePath);
var document = JsonConvert.DeserializeObject<Document>(json); var document = JsonConvert.DeserializeObject<Document>(json);
document.LastFilePath = filePath;
return Result<Document>.Success(document); return Result<Document>.Success(document);
} }
catch (Exception ex) catch (Exception ex)