diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs index 79dcbec..3c98487 100644 --- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs @@ -425,7 +425,28 @@ public static class EditSmoke Check("상용구 모드: 제목에 '쿼리' 없음", !wordEditor.Title.Contains("쿼리"), wordEditor.Title); wordEditor.Close(); - // 24) 연속 Undo 로 빈 문서까지 + // 24) 더티 추적 — 종료 가드의 판단 근거(별도 문서로 격리해 위 단계와 간섭 없게) + var dirtyDoc = new DesignerViewModel(business.CreateNew()); + Check("더티: 초기 상태는 clean", !dirtyDoc.Undo.IsDirty); + dirtyDoc.AddControlAt("Label", new Point(50, 50)); + Check("더티: 편집 후 dirty", dirtyDoc.Undo.IsDirty); + dirtyDoc.Undo.MarkSaved(); + Check("더티: 저장 표시 후 clean", !dirtyDoc.Undo.IsDirty); + dirtyDoc.AddControlAt("Label", new Point(90, 90)); + Check("더티: 저장 후 편집하면 dirty", dirtyDoc.Undo.IsDirty); + dirtyDoc.Undo.Undo(); + Check("더티: 저장 지점으로 Undo 하면 clean", !dirtyDoc.Undo.IsDirty); + dirtyDoc.Undo.Undo(); + Check("더티: 저장 지점보다 더 되돌리면 dirty", dirtyDoc.Undo.IsDirty); + dirtyDoc.Undo.Redo(); + Check("더티: Redo 로 저장 지점 복귀하면 clean", !dirtyDoc.Undo.IsDirty); + // 저장 지점보다 얕은 상태에서 새 편집 → 그 지점은 되돌아갈 수 없으므로 영구 dirty + dirtyDoc.Undo.Undo(); + dirtyDoc.AddControlAt("CheckBox", new Point(120, 120)); + dirtyDoc.Undo.Undo(); + Check("더티: 분기 후에는 저장 지점 소실로 계속 dirty", dirtyDoc.Undo.IsDirty); + + // 25) 연속 Undo 로 빈 문서까지 var guard = 0; while (designer.Undo.CanUndo && guard++ < 80) { diff --git a/src/SheetMe.Designer/Services/UndoService.cs b/src/SheetMe.Designer/Services/UndoService.cs index ec85f42..f3cc2d7 100644 --- a/src/SheetMe.Designer/Services/UndoService.cs +++ b/src/SheetMe.Designer/Services/UndoService.cs @@ -16,6 +16,9 @@ public sealed class UndoService private readonly Func getDocument; private readonly Action restoreDocument; private DateTime lastNudgeAt = DateTime.MinValue; + + /// 마지막 저장 시점의 undo 깊이 — -1 은 저장 지점이 소실되어 항상 더티 + private int savedDepth; #endregion #region Properties @@ -24,6 +27,13 @@ public sealed class UndoService /// Redo 가능 여부 public bool CanRedo => redoStack.Count > 0; + + /// + /// 저장하지 않은 변경이 있는지. CanUndo 와 달리 저장 이후 편집이 없으면 false 다 + /// (CanUndo 만 쓰면 저장 직후에도 영구히 더티라 종료 확인이 무의미해진다). + /// Undo/Redo 로 저장 지점 깊이에 되돌아오면 자동으로 clean 이 된다. + /// + public bool IsDirty => savedDepth < 0 || undoStack.Count != savedDepth; #endregion #region Constructors @@ -44,14 +54,29 @@ public sealed class UndoService public void Snapshot() { undoStack.Add(getDocument().Clone()); + + // 저장 지점보다 얕은 상태에서 새 편집이 들어오면(=Undo 후 편집) 그 지점으로는 되돌아갈 수 없다 + if (savedDepth > undoStack.Count - 1) + { + savedDepth = -1; + } + if (undoStack.Count > Capacity) { - undoStack.RemoveRange(0, undoStack.Count - Capacity); + var trimmed = undoStack.Count - Capacity; + undoStack.RemoveRange(0, trimmed); + if (savedDepth >= 0) + { + savedDepth = savedDepth >= trimmed ? savedDepth - trimmed : -1; + } } redoStack.Clear(); lastNudgeAt = DateTime.MinValue; } + /// 저장 완료 시점 표시 — 이후 편집이 없으면 가 false + public void MarkSaved() => savedDepth = undoStack.Count; + /// 넛지(방향키) 스냅샷 — 400ms 이내 연속 입력은 1스텝으로 코얼레스 public void SnapshotForNudge() { diff --git a/src/SheetMe.Designer/ViewModels/MainViewModel.cs b/src/SheetMe.Designer/ViewModels/MainViewModel.cs index 25f4ee1..fec4429 100644 --- a/src/SheetMe.Designer/ViewModels/MainViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/MainViewModel.cs @@ -119,7 +119,16 @@ internal sealed class MainViewModel : ViewModelBase ZoomInCommand = new Command((sender, e) => CurrentDesigner?.ZoomIn()); ZoomOutCommand = new Command((sender, e) => CurrentDesigner?.ZoomOut()); ZoomResetCommand = new Command((sender, e) => CurrentDesigner?.ZoomReset()); - ExitCommand = new Command((sender, e) => Application.Current.Shutdown()); + // Application.Shutdown() 은 Window.Closing 의 e.Cancel 을 무시하므로 VM 에서 먼저 확인한다 + ExitCommand = new Command((sender, e) => + { + if (!ConfirmCloseAll()) + { + return; + } + IsShuttingDown = true; + Application.Current.Shutdown(); + }); SearchSheetsCommand = new Command(async (sender, e) => await LoadSheetListAsync()); OpenSheetCommand = new Command((object param) => OnOpenSheetFromList(param as SheetSummary)); CloseDocumentCommand = new Command((object param) => OnCloseDocument(param as DesignerViewModel)); @@ -207,8 +216,8 @@ internal sealed class MainViewModel : ViewModelBase { return; } - if (designer.Undo.CanUndo - && MessageBox.Show($"'{designer.DisplayName}' 문서에 저장하지 않은 변경이 있을 수 있습니다.\n닫을까요?", + if (designer.Undo.IsDirty + && MessageBox.Show($"'{designer.DisplayName}' 문서에 저장하지 않은 변경이 있습니다.\n닫을까요?", "문서 닫기", MessageBoxButton.YesNo, MessageBoxImage.Question) != MessageBoxResult.Yes) { return; @@ -223,6 +232,56 @@ internal sealed class MainViewModel : ViewModelBase } } + /// + /// 종료 전 미저장 문서 처리 — 진행 가능하면 true, 사용자가 한 곳에서라도 취소하면 false. + /// 문서별로 확인하며, 확인 직전 해당 문서를 활성 탭으로 올려 어느 문서를 묻는지 보이게 한다 + /// (저장 경로도 CurrentDesigner 를 대상으로 동작하므로 전환이 필수다). + /// + public bool ConfirmCloseAll() + { + foreach (var designer in OpenDesigners.ToList()) + { + if (!designer.Undo.IsDirty) + { + continue; + } + CurrentDesigner = designer; + + // 과거 버전 열람 탭은 저장하면 활성 디자인을 과거 내용으로 덮게 되므로 저장 선택지를 주지 않는다 + if (designer.HistorySdgKey is not null) + { + var discard = MessageBox.Show( + $"'{designer.DisplayName}' 은(는) 과거 버전 열람 탭이라 저장할 수 없습니다.\n변경을 버리고 종료할까요?", + "종료", MessageBoxButton.YesNo, MessageBoxImage.Warning); + if (discard != MessageBoxResult.Yes) + { + return false; + } + continue; + } + + var answer = MessageBox.Show( + $"'{designer.DisplayName}' 문서에 저장하지 않은 변경이 있습니다.\n저장할까요?", + "종료", MessageBoxButton.YesNoCancel, MessageBoxImage.Question); + if (answer == MessageBoxResult.Cancel) + { + return false; + } + if (answer == MessageBoxResult.No) + { + continue; + } + if (!(designer.IsFromDb ? OnSaveToDb() : OnSaveFile(saveAs: false))) + { + return false; // 저장 대화상자 취소 또는 오류 — 종료를 중단한다 + } + } + return true; + } + + /// 종료 확인을 이미 통과했는지 — MainView.Closing 이 재질문하지 않도록 + public bool IsShuttingDown { get; private set; } + /// 이미 열린 DB 서식이면 해당 탭 활성화 — 없으면 null private DesignerViewModel? FindOpenDbDocument(string shtCod) => OpenDesigners.FirstOrDefault(d => d.IsFromDb && d.Document.FormId == shtCod); @@ -270,14 +329,16 @@ internal sealed class MainViewModel : ViewModelBase } } - private void OnSaveFile(bool saveAs) + /// 파일 저장 — 성공 시 true. 대화상자 취소·오류는 false(종료 가드가 이 값으로 진행 여부를 판단한다) + private bool OnSaveFile(bool saveAs) { try { if (CurrentDesigner is null) { - return; + return false; } + CommitPendingEdits(); var path = CurrentDesigner.FilePath; if (saveAs || path is null) { @@ -285,20 +346,36 @@ internal sealed class MainViewModel : ViewModelBase CurrentDesigner.Document.FormId + ".xml"); if (path is null) { - return; + return false; } } dataBusiness.SaveXmlFile(CurrentDesigner.Document, path); CurrentDesigner.FilePath = path; + CurrentDesigner.Undo.MarkSaved(); CurrentDesigner.NotifyDisplayNameChanged(); Title = $"SheetMe 서식생성기 — {CurrentDesigner.DisplayName}"; StatusText = $"저장됨: {path}"; + return true; } catch (Exception ex) { - MessageBox.Show($"저장 중 오류가 발생했습니다.\n\n{ex}", "오류", + MessageBox.Show($"저장 중 오류가 발생했습니다.\n\n{ex.Message}", "오류", MessageBoxButton.OK, MessageBoxImage.Error); + return false; + } + } + + /// + /// 포커스가 머물러 있는 편집 중 값을 소스에 반영한다. + /// 인스펙터 바인딩이 UpdateSourceTrigger=LostFocus 라, 값을 고치다 바로 Ctrl+S 를 누르면 + /// 커밋되지 않은 채 저장되어 조용히 유실된다. + /// + private static void CommitPendingEdits() + { + if (System.Windows.Input.Keyboard.FocusedElement is System.Windows.Controls.TextBox box) + { + box.GetBindingExpression(System.Windows.Controls.TextBox.TextProperty)?.UpdateSource(); } } @@ -352,20 +429,22 @@ internal sealed class MainViewModel : ViewModelBase ShowReadWarnings(document.Meta.ReadWarnings, "DB 열기"); } - private void OnSaveToDb() + /// DB 저장 — 성공 시 true. 게이트 차단·취소·오류는 false + private bool OnSaveToDb() { try { if (CurrentDesigner is null) { - return; + return false; } if (!dataBusiness.CanSaveToDb) { MessageBox.Show("DB 저장이 비활성화되어 있습니다.\nappsettings.json 의 FormStore:SaveMode 를 'Db' 로 설정한 뒤 사용하세요.\n(운영 레거시 테이블에 기록되므로 명시적 활성화가 필요합니다)", "DB 저장", MessageBoxButton.OK, MessageBoxImage.Warning); - return; + return false; } + CommitPendingEdits(); var document = CurrentDesigner.Document; @@ -380,7 +459,7 @@ internal sealed class MainViewModel : ViewModelBase }; if (register.ShowDialog() != true) { - return; + return false; } dataBusiness.RegisterSheet(register.SheetCode, register.SheetName, register.ClassCode); document.FormId = register.SheetCode; @@ -394,20 +473,23 @@ internal sealed class MainViewModel : ViewModelBase "DB 저장 확인", MessageBoxButton.YesNo, MessageBoxImage.Question); if (confirm != MessageBoxResult.Yes) { - return; + return false; } var sdgKey = dataBusiness.SaveToDb(document); CurrentDesigner.IsFromDb = true; + CurrentDesigner.Undo.MarkSaved(); CurrentDesigner.NotifyDisplayNameChanged(); StatusText = $"DB 저장 완료: {document.FormId} → SdgKey {sdgKey}"; MessageBox.Show($"저장되었습니다. (SdgKey {sdgKey})\n레거시 뷰어/디자이너에서 열어 확인하세요.", "DB 저장", MessageBoxButton.OK, MessageBoxImage.Information); + return true; } catch (Exception ex) { MessageBox.Show($"DB 저장 중 오류가 발생했습니다. (트랜잭션 롤백됨)\n\n{ex.Message}", "오류", MessageBoxButton.OK, MessageBoxImage.Error); + return false; } } diff --git a/src/SheetMe.Designer/Views/MainView.xaml b/src/SheetMe.Designer/Views/MainView.xaml index 2196dac..70fe856 100644 --- a/src/SheetMe.Designer/Views/MainView.xaml +++ b/src/SheetMe.Designer/Views/MainView.xaml @@ -16,7 +16,20 @@ Width="1440" Height="920" MinWidth="940" MinHeight="640" WindowStyle="None" WindowStartupLocation="CenterScreen" Background="{DynamicResource B.AppBg}" FontFamily="Malgun Gothic" FontSize="13" - Loaded="OnWindowLoaded" StateChanged="OnWindowStateChanged"> + Loaded="OnWindowLoaded" StateChanged="OnWindowStateChanged" Closing="OnWindowClosing"> + + + + + + + + + + @@ -118,7 +131,7 @@ - + diff --git a/src/SheetMe.Designer/Views/MainView.xaml.cs b/src/SheetMe.Designer/Views/MainView.xaml.cs index df73666..f3209be 100644 --- a/src/SheetMe.Designer/Views/MainView.xaml.cs +++ b/src/SheetMe.Designer/Views/MainView.xaml.cs @@ -104,6 +104,19 @@ public partial class MainView : Window private void OnCloseWinClick(object sender, RoutedEventArgs e) => Close(); + /// + /// 창 닫기(타이틀바 X / Alt+F4) 전 미저장 확인 — 판단은 VM 에 위임하고 여기서는 취소만 반영한다. + /// 파일▸종료는 Application.Shutdown() 이라 이 이벤트의 e.Cancel 을 무시하므로 VM 이 먼저 확인하고 + /// IsShuttingDown 을 세운다(그 경로에서 여기가 다시 묻지 않도록). + /// + private void OnWindowClosing(object sender, System.ComponentModel.CancelEventArgs e) + { + if (DataContext is MainViewModel viewModel && !viewModel.IsShuttingDown && !viewModel.ConfirmCloseAll()) + { + e.Cancel = true; + } + } + /// 최대화/복원 글리프 전환(Segoe MDL2: E922=최대화, E923=복원) private void OnWindowStateChanged(object sender, EventArgs e) => MaxGlyph.Text = WindowState == WindowState.Maximized ? "" : "";