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

메뉴에는 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
+22 -1
View File
@@ -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)
{
+26 -1
View File
@@ -16,6 +16,9 @@ public sealed class UndoService
private readonly Func<FormDocument> getDocument;
private readonly Action<FormDocument> restoreDocument;
private DateTime lastNudgeAt = DateTime.MinValue;
/// <summary>마지막 저장 시점의 undo 깊이 — -1 은 저장 지점이 소실되어 항상 더티</summary>
private int savedDepth;
#endregion
#region Properties
@@ -24,6 +27,13 @@ public sealed class UndoService
/// <summary>Redo 가능 여부</summary>
public bool CanRedo => redoStack.Count > 0;
/// <summary>
/// 저장하지 않은 변경이 있는지. CanUndo 와 달리 저장 이후 편집이 없으면 false 다
/// (CanUndo 만 쓰면 저장 직후에도 영구히 더티라 종료 확인이 무의미해진다).
/// Undo/Redo 로 저장 지점 깊이에 되돌아오면 자동으로 clean 이 된다.
/// </summary>
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;
}
/// <summary>저장 완료 시점 표시 — 이후 편집이 없으면 <see cref="IsDirty"/> 가 false</summary>
public void MarkSaved() => savedDepth = undoStack.Count;
/// <summary>넛지(방향키) 스냅샷 — 400ms 이내 연속 입력은 1스텝으로 코얼레스</summary>
public void SnapshotForNudge()
{
@@ -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;
}
}
+15 -2
View File
@@ -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">
<!--
파일/앱 계열만 창 단위로 바인딩한다. 편집 계열(Ctrl+Z/C/V/X/D/A/G)은 CanvasKeyboardBehavior 가
처리하며, 여기 올리면 인스펙터·검색 상자 등 TextBox 의 표준 편집 키와 충돌한다.
-->
<Window.InputBindings>
<KeyBinding Key="N" Modifiers="Control" Command="{Binding NewFileCommand}"/>
<KeyBinding Key="O" Modifiers="Control" Command="{Binding OpenFileCommand}"/>
<KeyBinding Key="S" Modifiers="Control" Command="{Binding SaveFileCommand}"/>
<KeyBinding Key="S" Modifiers="Control+Shift" Command="{Binding SaveAsFileCommand}"/>
<KeyBinding Key="P" Modifiers="Control" Command="{Binding PrintCommand}"/>
<KeyBinding Key="W" Modifiers="Control" Command="{Binding CloseDocumentCommand}"/>
</Window.InputBindings>
<!-- GlassFrameThickness 0,0,0,1: DWM 창 그림자 활성화([200]SheetMe 크롬과 동일) -->
<shell:WindowChrome.WindowChrome>
@@ -118,7 +131,7 @@
<MenuItem Header="열기(_O)..." Command="{Binding OpenFileCommand}" InputGestureText="Ctrl+O"/>
<Separator/>
<MenuItem Header="저장(_S)" Command="{Binding SaveFileCommand}" InputGestureText="Ctrl+S"/>
<MenuItem Header="다른 이름으로 저장(_A)..." Command="{Binding SaveAsFileCommand}"/>
<MenuItem Header="다른 이름으로 저장(_A)..." Command="{Binding SaveAsFileCommand}" InputGestureText="Ctrl+Shift+S"/>
<Separator/>
<MenuItem Header="DB에서 열기(_D)..." Command="{Binding OpenFromDbCommand}"/>
<MenuItem Header="DB에 저장(_B)..." Command="{Binding SaveToDbCommand}"/>
@@ -104,6 +104,19 @@ public partial class MainView : Window
private void OnCloseWinClick(object sender, RoutedEventArgs e) => Close();
/// <summary>
/// 창 닫기(타이틀바 X / Alt+F4) 전 미저장 확인 — 판단은 VM 에 위임하고 여기서는 취소만 반영한다.
/// 파일▸종료는 Application.Shutdown() 이라 이 이벤트의 e.Cancel 을 무시하므로 VM 이 먼저 확인하고
/// IsShuttingDown 을 세운다(그 경로에서 여기가 다시 묻지 않도록).
/// </summary>
private void OnWindowClosing(object sender, System.ComponentModel.CancelEventArgs e)
{
if (DataContext is MainViewModel viewModel && !viewModel.IsShuttingDown && !viewModel.ConfirmCloseAll())
{
e.Cancel = true;
}
}
/// <summary>최대화/복원 글리프 전환(Segoe MDL2: E922=최대화, E923=복원)</summary>
private void OnWindowStateChanged(object sender, EventArgs e) =>
MaxGlyph.Text = WindowState == WindowState.Maximized ? "" : "";