4차 A-1: 저장하지 않은 편집이 말없이 사라지던 것

본문을 다시 읽는 열한 군데를 조사해 여덟 군데를 막았다.
비교 규칙 변경은 편집을 지키므로 손대지 않았고, 커밋 새로고침은 일부러 두었다.
파일을 새로 열어도 HasChanges 가 안 꺼지던 것을 함께 고쳤다.
This commit is contained in:
김승준 2026-09-10 20:29:52 +09:00
parent 0994d514fb
commit 7278829d1e
4 changed files with 252 additions and 9 deletions

View File

@ -66,3 +66,64 @@ doc/
**두 파일은 기준이 다르다** - 플랫폼은 "다른 소비 프로그램에 미치는 영향", MergeHub 는 "무엇을 왜".
**판정 기준**은 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.

View File

@ -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차 지시서 진행 중 |

View File

@ -22,7 +22,7 @@
| 상태 | 항목 |
|---|---|
| 대기 | A-1 저장하지 않은 편집이 말없이 사라진다 |
| **완료** | A-1 저장하지 않은 편집이 말없이 사라진다 |
| 대기 | A-2 내보낸 보고서에 만든 사람이 없다 |
### B. 매일 쓰는 마찰 줄이기

View File

@ -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
/// <summary>머지한 줄에 붙일 사용자 설명. 비워도 작성자·날짜 정보는 항상 들어간다.</summary>
[ObservableProperty] private string _mergeComment = string.Empty;
/// <summary>확인 창에서 [취소] 를 눌렀을 때 돌아갈 자리.</summary>
private string? _lastLoadedFile;
/// <summary>되돌리는 중. 그 사이에는 다시 묻지도, 다시 읽지도 않는다.</summary>
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
/// <summary>새로고침으로 목록을 다시 만들 때 선택을 잇는 중이면 재진입을 막는다.</summary>
private bool _syncingSelection;
/// <summary>확인 창에서 [취소] 를 눌렀을 때 돌아갈 커밋.</summary>
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;
/// <summary>파일을 연 시점의 LOCAL 로 통째로 되돌린다.</summary>
/// <summary>
/// 파일을 연 시점의 LOCAL 로 통째로 되돌린다.
///
/// 되돌리기(Undo) 목록까지 비우므로 <b>되살릴 방법이 없다.</b> 그래서 한 번 묻는다 -
/// 이름이 분명한 단추라 해도 잘못 누르면 그때까지 고친 것이 통째로 사라진다.
/// </summary>
[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 안에서 묻지 않게 한다.
}
/// <summary>선택 커밋이 담고 있는 파일 목록을 채우고, 가능하면 이전 선택을 유지한다.</summary>
@ -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
/// <summary>충돌 줄 수. 상태바와 요약에 쓴다.</summary>
[ObservableProperty] private int _conflictCount;
/// <summary>확인 창에서 [취소] 를 눌렀을 때 돌아갈 브랜치.</summary>
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
});
}
// ================= 저장 안 한 편집 지키기 =================
/// <summary>
/// 프로그램이 스스로 화면을 다시 그리는 중. 그때는 묻지 않는다.
/// 사용자가 고른 것이 아닌데 창이 뜨면 무엇에 답하는지 알 수 없다.
/// </summary>
private bool _suspendGuard;
/// <summary>
/// 고치던 것이 있으면 물어보고, 계속해도 되면 true 를 준다.
///
/// <b>왜 세 갈래인가</b> - 예/아니오로는 "버리고 가기"와 "그만두기"가 구분되지 않는다.
/// 아니오를 눌렀을 때 그대로 열려 버리면 그것이 곧 잃는 것이다.
/// 그래서 플랫폼에 <c>ShowChoice</c> 를 새로 넣었다.
///
/// 고친 것이 없으면 묻지 않는다. 넘어가도 되는 경우까지 묻는 것이 더 나쁘다 -
/// 매번 뜨는 창은 읽지 않고 누르게 되고, 정작 위험할 때도 그냥 눌러 버린다.
/// </summary>
/// <param name="what">이제 무엇을 하려는지. 창에 그대로 적힌다.</param>
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
};
}
/// <summary>
/// 고친 내용을 지금 파일에 쓴다. <b>커밋은 하지 않는다.</b>
///
/// Git 모드의 "저장 후 커밋"은 커밋 메시지가 있어야 한다. 확인 창에서 메시지를 받을 수는 없으므로
/// 여기서는 워킹트리 파일에 쓰는 데까지만 한다 - 커밋은 사용자가 따로 할 일이다.
/// 작업 게이트를 타지 않는다. 파일 하나를 쓰는 일이라 Git 작업과 줄 세울 까닭이 없고,
/// 확인 창은 UI 스레드에서 답을 기다리는 중이라 게이트를 기다리면 서로 막힌다.
/// </summary>
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;
}
}
// ================= 무시 규칙 =================
/// <summary>