diff --git a/doc/history/2026-09-10-4차.md b/doc/history/2026-09-10-4차.md
index 72e6c91..dbad39d 100644
--- a/doc/history/2026-09-10-4차.md
+++ b/doc/history/2026-09-10-4차.md
@@ -65,4 +65,65 @@ doc/
MergeHub 에도 같은 이름의 파일이 생겨서 그냥 `doc/history/changelog.md` 라고 쓰면 어느 쪽인지 알 수 없다.
**두 파일은 기준이 다르다** - 플랫폼은 "다른 소비 프로그램에 미치는 영향", MergeHub 는 "무엇을 왜".
-**판정 기준**은 4차 A~C 를 진행하는 동안 이 규칙대로 쌓이는지로 본다. 지금부터 적용한다.
\ No newline at end of file
+**판정 기준**은 4차 A~C 를 진행하는 동안 이 규칙대로 쌓이는지로 본다. 지금부터 적용한다.
+---
+
+## A-1. 저장하지 않은 편집이 말없이 사라지던 것 — **완료** (2026-09-10)
+
+### 먼저 조사한 것 — 어느 길로 들어오면 사라졌나
+
+`HomeViewModel` 에서 본문을 다시 읽는 자리를 전부 훑었다. 열한 군데였고 성격이 셋으로 갈렸다.
+
+| 길 | 어떻게 들어오나 | 판정 |
+|---|---|---|
+| `OnPlainCompareRequested` | 폴더 비교 더블클릭 · 폴더 검색 결과 · 끌어다 놓기 · 탐색기 우클릭 · `git mergetool` | **막음** |
+| `PickLeftFile` / `PickRightFile` | 기준 파일 · 내 파일 단추 | **막음** |
+| `DropFile` | 경로 칸에 파일을 놓기 | **막음** |
+| `OnSelectedFileChanged` | Git 탭 파일 목록에서 다른 파일 고르기 | **막음** (취소하면 목록 선택도 되돌린다) |
+| `OnSelectedNodeChanged` | 이력에서 다른 커밋 고르기 | **막음** (취소하면 커밋 선택도 되돌린다) |
+| `OnRemoteBranchChanged` | 3-Way 켜고 끄기 | **막음** (취소하면 브랜치도 되돌린다) |
+| `SwitchDemoAsync` | 데모 폴더 전환 | **막음** |
+| `RevertAll` | 전체 되돌리기 단추 | **막음** (되살릴 수 없어 한 번 묻는다) |
+| `ReloadLinesFrom(keepAsEdited: true)` | 비교 규칙 변경 · 무시 규칙 변경 | **안 막음** — 편집을 지키도록 이미 만들어져 있다 |
+| `LoadDefaultPlainFiles` | 켤 때 기본 파일 열기 | **안 막음** — 아직 고친 것이 있을 수 없다 |
+| `RefreshCommits` → `LoadCommitAsync` | 커밋·체크아웃 뒤 목록 다시 세우기 | **일부러 안 막음** (아래) |
+
+**비교 규칙을 바꾸는 길에 손대지 않은 것이 중요하다.** 지시서가 그렇게 못박았고, 실제로 그 길은
+`keepAsEdited: true` 로 편집을 지킨다. 여기까지 확인 창을 붙였으면 규칙 하나 바꿀 때마다 창이 떠서 못 쓴다.
+
+**`RefreshCommits` 는 일부러 두었다.** 사용자가 고른 것이 아니라 프로그램이 목록을 다시 세우는 자리다.
+그 한가운데 창을 띄우면 무엇에 답하는 창인지 알 수 없고, 취소해도 목록은 이미 새로 세워진 뒤라
+되돌릴 자리가 없다. `_suspendGuard` 로 그 구간만 묻지 않게 했다. **남은 구멍이라는 것은 인정한다** -
+커밋·체크아웃을 한 뒤에는 고치던 것이 다시 읽힌다. 다만 그 조작들은 대개 저장한 뒤에 하는 일이다.
+
+### 어떻게 막았나
+
+`ConfirmDiscard(무엇을 하려는지)` 하나로 모았다. 고친 것이 없으면 **묻지 않는다** -
+매번 뜨는 창은 읽지 않고 누르게 되고, 정작 위험할 때도 그냥 눌러 버린다.
+
+세 갈래로 묻는다. **예/아니오로는 "버리고 가기"와 "그만두기"가 구분되지 않는다** -
+아니오를 눌렀는데 그대로 열려 버리면 그것이 곧 잃는 것이다.
+플랫폼에 `MessageUtil.ShowChoice` 를 새로 넣었다(플랫폼 `ccfed6f`).
+
+`저장하고 계속` 은 **커밋하지 않고 파일에만 쓴다**(`SaveWorkingCopy`). Git 모드의 저장은 커밋 메시지가
+있어야 하는데 확인 창에서 메시지를 받을 수는 없다. 커밋은 사용자가 따로 할 일이다.
+작업 게이트를 타지 않는다 - 확인 창이 UI 스레드에서 답을 기다리는 중이라 게이트를 기다리면 서로 막힌다.
+**저장이 실패하면 넘어가지 않는다.** 여기서 넘어가면 저장한 줄 알고 잃는다.
+
+### 곁들여 잡은 것 — 고친 것이 없는데도 묻던 문제
+
+만들어 놓고 화면에서 확인하다 걸렸다. 파일을 새로 열어도 `HasChanges` 가 꺼지지 않아
+**앞 파일의 상태가 그대로 끌려왔다.** `LoadPlainFilesAsync` 에 그 줄이 빠져 있었다.
+지금까지는 저장 단추가 켜진 채 남는 정도라 눈에 안 띄었는데, A-1 이 그 값을 읽으면서 드러났다.
+
+### 화면에서 확인한 것
+
+| 한 일 | 결과 |
+|---|---|
+| 고친 뒤 다른 파일 열기 | `Other.cpp 에 저장하지 않은 편집이 있습니다 / 다른 파일을 엽니다: WeldController.cpp` |
+| [취소] | 편집기 내용 그대로, 파일도 그대로, 열린 파일도 그대로 |
+| [저장하고 계속] | `Other.cpp` 에 고친 내용이 쓰이고 `WeldController.cpp` 가 열렸다 |
+| [저장하지 않고 계속] | 파일은 손대지 않은 채 다음 파일이 열렸다 |
+| 고친 것 없이 두 번 연속 열기 | 창이 뜨지 않았다 |
+
+검사 514건 통과 · 빌드 경고 0 · 오류 0.
\ No newline at end of file
diff --git a/doc/history/changelog.md b/doc/history/changelog.md
index dfdf2fe..8ec1919 100644
--- a/doc/history/changelog.md
+++ b/doc/history/changelog.md
@@ -8,6 +8,7 @@
| 날짜 | 항목 | 커밋 |
|---|---|---|
+| 2026-09-10 | **4차 A-1** — 저장하지 않은 편집이 말없이 사라지던 길 여덟 군데를 막음. 플랫폼에 세 갈래 확인 창을 새로 넣었다 | 아래 |
| 2026-09-10 | **4차 D-2** — 항목을 끝내면 상세·상태·한 줄을 각각 어디에 적을지 공통 규칙으로 못박음 | 아래 |
| 2026-09-10 | **4차 D-1** — 721줄짜리 진행상황을 `history/` 의 날짜별 파일로 나누고 지시서를 `작업지시/` 로 모음 | 아래 |
| 2026-09-09 | **3차 A 묶음** — 폴더 고르기 다운 · 화면 이동 결함 3가지 · 저장소 최상위 열기 | `5e667af` `af27800` `93a507e` `b5bd06c` / 플랫폼 `595aa23` `f664c31` |
@@ -27,6 +28,7 @@
| 날짜 | 문서 | 내용 |
|---|---|---|
+| 2026-09-10 | **4차 A-1** — 저장하지 않은 편집이 말없이 사라지던 길 여덟 군데를 막음. 플랫폼에 세 갈래 확인 창을 새로 넣었다 | 아래 |
| 2026-09-10 | **4차 D-2** — 항목을 끝내면 상세·상태·한 줄을 각각 어디에 적을지 공통 규칙으로 못박음 | 아래 |
| 2026-09-10 | **4차 D-1** — 721줄짜리 진행상황을 `history/` 의 날짜별 파일로 나누고 지시서를 `작업지시/` 로 모음 | 아래 |
| 2026-09-10 | [2026-09-10-4차.md](2026-09-10-4차.md) | 4차 지시서 진행 중 |
diff --git a/doc/진행상황.md b/doc/진행상황.md
index f8b949e..c9a8a14 100644
--- a/doc/진행상황.md
+++ b/doc/진행상황.md
@@ -22,7 +22,7 @@
| 상태 | 항목 |
|---|---|
-| 대기 | A-1 저장하지 않은 편집이 말없이 사라진다 |
+| **완료** | A-1 저장하지 않은 편집이 말없이 사라진다 |
| 대기 | A-2 내보낸 보고서에 만든 사람이 없다 |
### B. 매일 쓰는 마찰 줄이기
diff --git a/src/MergeHub/ViewModels/HomeViewModel.cs b/src/MergeHub/ViewModels/HomeViewModel.cs
index 5bb71a6..5d99cf1 100644
--- a/src/MergeHub/ViewModels/HomeViewModel.cs
+++ b/src/MergeHub/ViewModels/HomeViewModel.cs
@@ -73,6 +73,10 @@ namespace MergeHub.ViewModels
// 폴더 비교에서 "왼쪽만 / 오른쪽만" 인 파일을 열어 보려는 것이 바로 이 경우다.
if (!File.Exists(leftFile) && !File.Exists(rightFile)) return;
+ // 폴더 비교 더블클릭 · 폴더 검색 결과 · 끌어다 놓기 · 탐색기 우클릭 · mergetool 이 모두 여기로 온다.
+ var opening = Path.GetFileName(File.Exists(rightFile) ? rightFile : leftFile);
+ if (!ConfirmDiscard($"다른 파일을 엽니다: {opening}")) return;
+
// 3-Way 를 끄되 그 때문에 본문을 다시 읽지는 않게 한다 - 그 재로드가 작업 게이트를
// 먼저 잡으면 바로 아래 파일 비교가 "다른 작업 진행 중"으로 건너뛰어진다.
_suspendReload = true;
@@ -114,6 +118,7 @@ namespace MergeHub.ViewModels
private async Task SwitchDemoAsync()
{
if (!CanSwitchDemo) return;
+ if (!ConfirmDiscard("데모 폴더를 바꿉니다.")) return;
var toGit = !IsGitMode;
var target = toGit ? _workspace.Settings.DemoGitPath : _workspace.Settings.DemoPlainPath;
@@ -200,13 +205,41 @@ namespace MergeHub.ViewModels
/// 머지한 줄에 붙일 사용자 설명. 비워도 작성자·날짜 정보는 항상 들어간다.
[ObservableProperty] private string _mergeComment = string.Empty;
+ /// 확인 창에서 [취소] 를 눌렀을 때 돌아갈 자리.
+ private string? _lastLoadedFile;
+
+ /// 되돌리는 중. 그 사이에는 다시 묻지도, 다시 읽지도 않는다.
+ private bool _revertingFile;
+
partial void OnSelectedFileChanged(string? value)
+ {
+ if (_revertingFile)
+ {
+ // 목록 선택만 제자리로 돌린 것이다. 화면 내용은 그대로 두면 된다.
+ RaiseSelectedFileNotices();
+ return;
+ }
+
+ if (!ConfirmDiscard($"다른 파일을 엽니다: {value}"))
+ {
+ var back = _lastLoadedFile;
+ _revertingFile = true;
+ try { SelectedFile = back; }
+ finally { _revertingFile = false; }
+ return;
+ }
+
+ _lastLoadedFile = value;
+ RaiseSelectedFileNotices();
+ _ = LoadFileAsync();
+ }
+
+ private void RaiseSelectedFileNotices()
{
OnPropertyChanged(nameof(SelectedFilePath));
OnPropertyChanged(nameof(SelectedFileFullPath));
OnPropertyChanged(nameof(BaseFileLocation));
OnPropertyChanged(nameof(MergeNotePreview));
- _ = LoadFileAsync();
}
partial void OnMergeCommentChanged(string value) => OnPropertyChanged(nameof(MergeNotePreview));
@@ -265,9 +298,25 @@ namespace MergeHub.ViewModels
/// 새로고침으로 목록을 다시 만들 때 선택을 잇는 중이면 재진입을 막는다.
private bool _syncingSelection;
+ /// 확인 창에서 [취소] 를 눌렀을 때 돌아갈 커밋.
+ private CommitNode? _lastLoadedNode;
+ private bool _revertingNode;
+
partial void OnSelectedNodeChanged(CommitNode? value)
{
- if (_syncingSelection) return;
+ if (_syncingSelection || _revertingNode) { _lastLoadedNode = value; return; }
+
+ // 커밋을 바꾸면 BASE 뿐 아니라 LOCAL 까지 파일에서 다시 읽는다. 고치던 것이 있으면 사라진다.
+ if (!ConfirmDiscard("다른 커밋을 봅니다."))
+ {
+ var back = _lastLoadedNode;
+ _revertingNode = true;
+ try { SelectedNode = back; }
+ finally { _revertingNode = false; }
+ return;
+ }
+
+ _lastLoadedNode = value;
SelectedCommit = value?.Commit;
}
@@ -383,10 +432,19 @@ namespace MergeHub.ViewModels
private bool CanRevertAll() => HasChanges;
- /// 파일을 연 시점의 LOCAL 로 통째로 되돌린다.
+ ///
+ /// 파일을 연 시점의 LOCAL 로 통째로 되돌린다.
+ ///
+ /// 되돌리기(Undo) 목록까지 비우므로 되살릴 방법이 없다. 그래서 한 번 묻는다 -
+ /// 이름이 분명한 단추라 해도 잘못 누르면 그때까지 고친 것이 통째로 사라진다.
+ ///
[RelayCommand(CanExecute = nameof(CanRevertAll))]
private void RevertAll()
{
+ if (!MessageUtil.ShowYesNo("전체 되돌리기",
+ "고친 내용을 모두 버리고 파일을 연 시점으로 돌아갑니다.\n\n되살릴 수 없습니다. 계속할까요?"))
+ return;
+
ReloadLinesFrom(_originalLocalText, keepAsEdited: false);
}
@@ -498,6 +556,7 @@ namespace MergeHub.ViewModels
OnPropertyChanged(nameof(SelectedCommit));
RaiseCommitDetail();
_ = LoadCommitAsync();
+ // 여기서 다시 세우는 것은 사용자가 고른 것이 아니다. LoadCommitAsync 안에서 묻지 않게 한다.
}
/// 선택 커밋이 담고 있는 파일 목록을 채우고, 가능하면 이전 선택을 유지한다.
@@ -538,10 +597,20 @@ namespace MergeHub.ViewModels
? previous
: changed.FirstOrDefault(files.Contains) ?? files.FirstOrDefault();
- if (!string.Equals(SelectedFile, next, StringComparison.Ordinal))
- SelectedFile = next; // setter 가 LoadFileAsync 를 부른다
- else
- _ = LoadFileAsync();
+ // 목록을 프로그램이 다시 세우는 중이다. 사용자가 고른 것이 아니므로 묻지 않는다 -
+ // 무엇에 답하는 창인지 알 수 없고, 취소해도 목록은 이미 새로 세워진 뒤다.
+ _suspendGuard = true;
+ try
+ {
+ if (!string.Equals(SelectedFile, next, StringComparison.Ordinal))
+ SelectedFile = next; // setter 가 LoadFileAsync 를 부른다
+ else
+ _ = LoadFileAsync();
+ }
+ finally
+ {
+ _suspendGuard = false;
+ }
});
}
@@ -967,8 +1036,24 @@ namespace MergeHub.ViewModels
/// 충돌 줄 수. 상태바와 요약에 쓴다.
[ObservableProperty] private int _conflictCount;
+ /// 확인 창에서 [취소] 를 눌렀을 때 돌아갈 브랜치.
+ private string _lastLoadedRemoteBranch = NoRemoteBranch;
+ private bool _revertingRemoteBranch;
+
partial void OnRemoteBranchChanged(string value)
{
+ // 3-Way 로 들고 나면 본문을 다시 읽는다. 고치던 것이 있으면 사라진다.
+ // _suspendReload 가 걸린 길(데모 전환·파일 비교 전환)은 다시 읽지 않으므로 묻지 않는다.
+ if (!_revertingRemoteBranch && IsGitMode && !_suspendReload && !ConfirmDiscard("비교 방식을 바꿉니다."))
+ {
+ var back = _lastLoadedRemoteBranch;
+ _revertingRemoteBranch = true;
+ try { RemoteBranch = back; }
+ finally { _revertingRemoteBranch = false; }
+ return;
+ }
+
+ _lastLoadedRemoteBranch = value;
OnPropertyChanged(nameof(IsThreeWay));
OnPropertyChanged(nameof(IsTwoWay));
@@ -1427,6 +1512,8 @@ namespace MergeHub.ViewModels
{
if (AskForFile("왼쪽(기준) 파일 고르기", LeftFile) is { } picked)
{
+ if (!ConfirmDiscard($"왼쪽 파일을 바꿉니다: {Path.GetFileName(picked)}")) return;
+
LeftFile = picked;
OnPropertyChanged(nameof(BaseFileLocation));
_ = LoadPlainFilesAsync();
@@ -1438,6 +1525,8 @@ namespace MergeHub.ViewModels
{
if (AskForFile("오른쪽(내 작업) 파일 고르기", RightFile) is { } picked)
{
+ if (!ConfirmDiscard($"오른쪽 파일을 바꿉니다: {Path.GetFileName(picked)}")) return;
+
RightFile = picked;
OnPropertyChanged(nameof(SelectedFileFullPath));
_ = LoadPlainFilesAsync();
@@ -1455,6 +1544,7 @@ namespace MergeHub.ViewModels
public void DropFile(string path, bool left)
{
if (string.IsNullOrWhiteSpace(path)) return;
+ if (!ConfirmDiscard($"놓은 파일을 엽니다: {Path.GetFileName(path)}")) return;
var newLeft = left ? path : LeftFile;
var newRight = left ? RightFile : path;
@@ -1618,6 +1708,11 @@ namespace MergeHub.ViewModels
_undo.Clear();
_redo.Clear();
+
+ // 새로 읽은 파일에는 고친 것이 없다. 이걸 안 끄면 앞 파일의 상태가 그대로 끌려와
+ // 저장 단추가 켜진 채로 남고, 다음에 파일을 열 때마다 "저장하지 않은 편집" 을 묻는다.
+ HasChanges = false;
+
CaretRow = 0;
InvalidateDiffBlocks();
@@ -1682,6 +1777,91 @@ namespace MergeHub.ViewModels
});
}
+ // ================= 저장 안 한 편집 지키기 =================
+
+ ///
+ /// 프로그램이 스스로 화면을 다시 그리는 중. 그때는 묻지 않는다.
+ /// 사용자가 고른 것이 아닌데 창이 뜨면 무엇에 답하는지 알 수 없다.
+ ///
+ private bool _suspendGuard;
+
+ ///
+ /// 고치던 것이 있으면 물어보고, 계속해도 되면 true 를 준다.
+ ///
+ /// 왜 세 갈래인가 - 예/아니오로는 "버리고 가기"와 "그만두기"가 구분되지 않는다.
+ /// 아니오를 눌렀을 때 그대로 열려 버리면 그것이 곧 잃는 것이다.
+ /// 그래서 플랫폼에 ShowChoice 를 새로 넣었다.
+ ///
+ /// 고친 것이 없으면 묻지 않는다. 넘어가도 되는 경우까지 묻는 것이 더 나쁘다 -
+ /// 매번 뜨는 창은 읽지 않고 누르게 되고, 정작 위험할 때도 그냥 눌러 버린다.
+ ///
+ /// 이제 무엇을 하려는지. 창에 그대로 적힌다.
+ private bool ConfirmDiscard(string what)
+ {
+ if (_suspendGuard) return true;
+ if (!HasChanges) return true;
+
+ var name = IsPlainMode
+ ? (string.IsNullOrWhiteSpace(RightFile) ? "지금 파일" : Path.GetFileName(RightFile))
+ : (string.IsNullOrWhiteSpace(SelectedFile) ? "지금 파일" : Path.GetFileName(SelectedFile));
+
+ var choice = MessageUtil.ShowChoice(
+ "저장하지 않은 편집",
+ $"{name} 에 저장하지 않은 편집이 있습니다.\n\n{what}\n\n그대로 두면 고친 내용이 사라집니다.",
+ "저장하고 계속", "저장하지 않고 계속");
+
+ return choice switch
+ {
+ // 저장이 실패하면 넘어가지 않는다. 여기서 넘어가면 저장한 줄 알고 잃는다.
+ DialogChoice.Primary => SaveWorkingCopy(),
+ DialogChoice.Secondary => true,
+ _ => false
+ };
+ }
+
+ ///
+ /// 고친 내용을 지금 파일에 쓴다. 커밋은 하지 않는다.
+ ///
+ /// Git 모드의 "저장 후 커밋"은 커밋 메시지가 있어야 한다. 확인 창에서 메시지를 받을 수는 없으므로
+ /// 여기서는 워킹트리 파일에 쓰는 데까지만 한다 - 커밋은 사용자가 따로 할 일이다.
+ /// 작업 게이트를 타지 않는다. 파일 하나를 쓰는 일이라 Git 작업과 줄 세울 까닭이 없고,
+ /// 확인 창은 UI 스레드에서 답을 기다리는 중이라 게이트를 기다리면 서로 막힌다.
+ ///
+ private bool SaveWorkingCopy()
+ {
+ var target = IsPlainMode
+ ? RightFile
+ : string.IsNullOrEmpty(SelectedFile)
+ ? string.Empty
+ : Path.Combine(_workspace.RepositoryPath,
+ SelectedFile!.Replace('/', Path.DirectorySeparatorChar));
+
+ if (string.IsNullOrWhiteSpace(target))
+ {
+ MessageUtil.ShowErrorToast("저장할 파일을 알 수 없습니다.");
+ return false;
+ }
+
+ try
+ {
+ var content = BuildLocalText();
+ TextFile.Write(target, content, SaveEncoding, SaveNewLine);
+
+ _originalLocalText = content;
+ _undo.Clear();
+ _redo.Clear();
+ HasChanges = false;
+
+ MessageUtil.ShowSuccessToast($"저장 완료 · {Path.GetFileName(target)}");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ MessageUtil.ShowErrorToast($"저장하지 못했습니다: {ex.Message}");
+ return false;
+ }
+ }
+
// ================= 무시 규칙 =================
///