파일 계열 키바인딩 배선 + 미저장 종료 가드

메뉴에는 Ctrl+N/O/S/P 가 표시되지만 Window.InputBindings 가 아예 없어 실제로는 동작하지
않는 잘못된 어포던스였다. 그리고 MainView 에 Closing 핸들러가 없어 타이틀바 X·Alt+F4·
파일 종료 어느 경로로도 미저장 확인이 뜨지 않아, 미저장 다중 문서가 확인 없이 버려졌다.

- UndoService 에 savedDepth 기반 IsDirty/MarkSaved 추가. 기존 CanUndo 는 저장해도 영구
  더티라 종료마다 무의미한 확인이 떠서 가드가 존재 가치를 잃는다. Undo/Redo 로 저장 지점
  깊이에 되돌아오면 자동 clean 이 되고, 저장 지점보다 얕은 상태에서 새 편집이 들어오면
  (분기) 되돌아갈 수 없으므로 savedDepth=-1 로 영구 더티 처리한다. 용량 트림 시에도 보정.
- OnSaveFile/OnSaveToDb 를 bool 반환으로. 기존 void 는 저장 대화상자 취소를 감지할 수 없어
  종료 가드에서 저장 선택지를 줄 수 없었다. 성공 시 MarkSaved() 호출.
- ConfirmCloseAll() — 문서별 YesNoCancel 확인. 확인 직전 해당 문서를 활성 탭으로 올려
  어느 문서를 묻는지 보이게 한다(저장 경로가 CurrentDesigner 대상이라 전환이 필수다).
  과거 버전 열람 탭은 저장하면 활성 디자인을 과거 내용으로 덮으므로 저장 선택지를 주지 않는다.
- 종료 경로 두 개를 모두 막는다: MainView.Closing 에서 e.Cancel, 그리고 파일 종료의
  Application.Shutdown() 은 e.Cancel 을 무시하므로 VM 이 먼저 확인하고 IsShuttingDown 을
  세워 Closing 이 재질문하지 않게 한다.
- Window.InputBindings 에는 파일/앱 계열만(Ctrl+N/O/S/Shift+S/P/W). 편집 계열은
  CanvasKeyboardBehavior 가 계속 담당한다 — Ctrl+Z/C/V/X/A/D/G 를 창에 올리면 인스펙터·
  검색 상자 TextBox 의 표준 편집 키와 충돌하고 Spread 붙여넣기 가드도 우회할 여지가 생긴다.
- 저장 진입부에서 포커스가 머문 TextBox 의 BindingExpression.UpdateSource() 호출.
  인스펙터 바인딩이 UpdateSourceTrigger=LostFocus 라 값을 고치다 바로 Ctrl+S 를 누르면
  커밋되지 않은 채 저장되어 조용히 유실됐다.

검증: edit-smoke 에 더티 추적 8건 추가(초기 clean / 편집 후 dirty / MarkSaved 후 clean /
저장 지점 Undo 복귀 시 clean / 더 되돌리면 dirty / Redo 복귀 시 clean / 분기 후 영구 dirty).
1단계 전체 회귀 통과 — 테스트 70/70, edit-smoke 실패 0, 왕복 1,271건 diff 0/예외 0,
db-save-smoke 제자리 갱신(P163)·버저닝(S999) 양쪽, db-word-smoke 통과.

주의: 키바인딩과 종료 대화상자의 실 UI 조작 확인은 아직 하지 않았다(무인 검증 불가 구간).
병행 운영 전 수동 확인 항목으로 남긴다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-11 18:24:10 +09:00
co-authored by Claude Fable 5
parent 3dcac31a5b
commit 5c97bf1440
5 changed files with 170 additions and 16 deletions
@@ -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
}
}
/// <summary>
/// 종료 전 미저장 문서 처리 — 진행 가능하면 true, 사용자가 한 곳에서라도 취소하면 false.
/// 문서별로 확인하며, 확인 직전 해당 문서를 활성 탭으로 올려 어느 문서를 묻는지 보이게 한다
/// (저장 경로도 CurrentDesigner 를 대상으로 동작하므로 전환이 필수다).
/// </summary>
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;
}
/// <summary>종료 확인을 이미 통과했는지 — MainView.Closing 이 재질문하지 않도록</summary>
public bool IsShuttingDown { get; private set; }
/// <summary>이미 열린 DB 서식이면 해당 탭 활성화 — 없으면 null</summary>
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)
/// <summary>파일 저장 — 성공 시 true. 대화상자 취소·오류는 false(종료 가드가 이 값으로 진행 여부를 판단한다)</summary>
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;
}
}
/// <summary>
/// 포커스가 머물러 있는 편집 중 값을 소스에 반영한다.
/// 인스펙터 바인딩이 UpdateSourceTrigger=LostFocus 라, 값을 고치다 바로 Ctrl+S 를 누르면
/// 커밋되지 않은 채 저장되어 조용히 유실된다.
/// </summary>
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()
/// <summary>DB 저장 — 성공 시 true. 게이트 차단·취소·오류는 false</summary>
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;
}
}