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; + } + } + // ================= 무시 규칙 ================= ///