diff --git a/doc/history/2026-09-18-11차.md b/doc/history/2026-09-18-11차.md
index 59aa094..d47c1db 100644
--- a/doc/history/2026-09-18-11차.md
+++ b/doc/history/2026-09-18-11차.md
@@ -165,6 +165,7 @@ A-1 에서 값으로 확인한 것 — 지운 파일은 "삭제되어 건너뜀
- F-4: 매뉴얼 파일 비교 화면 — [ACTIONS 감추기] · F9, 아래 탭 줄 [탭 접기] · 띠 · F11(플랫폼), 단축키 표에 F9 · F11. 숫자 23벌 1,414건 · 플랫폼 31건.
- F-5: 매뉴얼 설정 표 "보기 > 코드 글자 크기", 파일 비교 화면 Ctrl+휠 · Ctrl++/- · Ctrl+0 · 상태 줄 `글자 N`, 단축키 표. 숫자 24벌 1,538건.
- F-6: 줄 간격을 좁혀 1920 × 1080 두 칸 40 → 50줄(매뉴얼의 화면 설명 · 그림이 있으면 새로). 숫자 24벌 1,572건.
+- F-8: 매뉴얼 되돌리기 설명 — 저장 · 저장 후 커밋 뒤에도 Undo/Redo(커밋은 그대로, 화면만), 편집기에 친 것 · 규칙 바꾸기도 이어짐, 요약 `· 저장됨`, [전체 되돌리기] 는 파일을 연 시점(저장했으면 화면만). Git 탭 충돌 화면 "이번 병합에서 푼 파일" + [다시 충돌로](git.exe 필요 — 알아 둘 한계에도). 숫자 24벌 1,593건.
## B-1. 배포 묶음을 다시 만들고 꼭 필요한 파일을 가림 (2026-09-22, 완료)
@@ -800,6 +801,48 @@ libgit2 의 다듬기(`PrettifyMessages`)를 켜면 **손대지 않은 뒤 커
- ~~플랫폼 배포~~ — 사용자 "배포해줘" 뒤 `publish-platform.ps1`(경고 0 · 오류 0, Core · Shell · README 셋 바뀜). MergeHub Debug 다시 빌드 → 두 파일 해시가 배포본과 같음, 배포본으로 같은 재현 → 창 360~2560 · 종료 2560 · Accept 2526 · 넘침 0.
- 배율이 **서로 다른** 모니터 사이를 옮길 때는 이 PC(두 모니터 모두 150%)에서 재현할 수 없다. 대형 모니터에서 같은 증상이 또 나면 그 모니터의 해상도 · 배율 · 창 모드였는지를 알려 받는다(창 자리 파일 `ShellWindowPlacement.json` 한 벌이면 그대로 재현할 수 있다).
+## F-8. 저장한 뒤에도 되돌리기 · 푼 파일을 다시 충돌로 (2026-09-22, 완료)
+
+사용자 결정: "되돌리면 다시 충돌로", "커밋은 두고 화면만 되돌림", [전체 되돌리기] 는 "파일을 연 시점", 병합 중 [Save] 는 지금 흐름 그대로 두고 **Git 탭 충돌 목록에 [다시 충돌로]**("1번으로 — UNDO, REDO 기능이 중요해"). [다시 충돌로] 는 LibGit2Sharp 에 길이 없어 **이 단추만 git.exe 를 부름**(사용자 결정).
+
+### 고치기 전 (재현 `f8-probe.ps1` · `f8-git.ps1`)
+
+| 경우 | 고치기 전 |
+|---|---|
+| 두 파일 비교: 반영 → [저장] | Undo · Redo **꺼짐** — 저장하는 네 길(저장 · 저장 후 커밋 · 해결로 저장 · 파일 바꿀 때 "저장하고 계속")이 모두 목록을 비웠다 |
+| Git: 반영 → [Save & Commit] | Undo 꺼짐 — 저장 때 비우고, 이어지는 새로고침이 **새 HEAD 기준으로 줄을 다시 세우며** 또 비움(9차 A-2 로 BASE 가 새 HEAD 를 따라감) |
+| 편집기에 글자를 치거나 비교 규칙(Aa · 공백 · 필터)을 바꿈 | 재정렬이 목록을 통째로 비워 그 전 Accept 들을 Undo 할 수 없었다 |
+| "저장할 것 있음" | 되돌리기 목록이 비었는지로 판단 — 저장 뒤 목록을 두면 저장 단추가 켜진 채로 남으므로 비울 수밖에 없었다 |
+
+### 고친 것
+
+- `SaveBaseline`(새) — 편집 칸의 두 기준 글: **파일을 연 시점**(전체 되돌리기가 가는 곳 · 직접 고친 줄을 가리는 기준)과 **마지막 저장**. "저장할 것 있음" = 지금 글 ≠ 마지막 저장(또는 스타일 맞춤 대기). 저장 뒤 Undo 하면 "있음", Redo 로 저장 지점에 오면 "없음".
+- 저장 · 저장 후 커밋 · "저장하고 계속" 은 목록을 **두고** 저장 지점만 옮긴다(`MarkSaved`). 병합 중 [Save](해결로 저장)는 다음 충돌로 넘어가므로 예전처럼 비움.
+- **글 단위 이력** — 줄 단위 목록은 줄 객체를 가리켜 줄을 다시 세우면 못 쓴다. 다시 세우기 **전에** 각 지점의 전문을 적어 옮긴다(`FlattenHistory` — 한 묶음이 한 걸음, 덩어리 · 메서드 반영은 11차 F-2 대로 한 번). 쓰는 곳: 커밋 뒤 같은 파일 · 같은 글을 다시 읽을 때, 비교 규칙을 바꿀 때, 편집기에서 친 뒤 재정렬할 때. 글 단위 걸음은 줄을 다시 세워 되돌린다. 걸음마다 파일 전문 한 벌이라 한쪽에 300걸음까지 둔다.
+- **편집기에서 친 것도 한 걸음** — 치는 동안 화면을 바로 보이려고 줄 객체를 먼저 고치는데(되돌리기 목록 밖) 그 줄의 원래 글을 적어 두었다가 재정렬 때 한 걸음으로 올린다.
+- 커밋 뒤 새로고침은 한 번에 **두 번 읽고 그 사이 화면을 잠깐 비운다**(고른 커밋 바뀜 · 목록 다시 세우기). 한 번 쓰고 버리는 표시로 했더니 두 번째가 비웠다 → 조건만 본다(같은 파일 · 두 칸 · 충돌 아님 · 읽은 글이 화면 글과 같음 — 파일의 CRLF · 끝 줄바꿈은 같은 글로 봄). 화면을 비울 때는 이력 · 두 기준을 맡겨 두고, 같은 파일 · 같은 글을 다시 실으면 되찾는다.
+- [전체 되돌리기] — 저장한 뒤에도 켜짐(연 시점과 다르면), 파일을 연 시점으로. 이미 저장했으면 확인 창이 "저장한 파일 · 커밋은 그대로, 화면만 돌아감 — 넣으려면 다시 저장, 되돌리기 목록은 비워짐" 이라고 알린다.
+- 요약 줄 — 저장한 지점이면 `· 저장됨` 을 붙인다(`남은 차이 1줄 · 편집 1건 · 되돌리기 0건 · 저장됨`).
+- **Git 탭 충돌 목록 아래 "이번 병합에서 푼 파일 N개" + [다시 충돌로]** — 병합 중이고 푼 파일이 있을 때만 보인다. 목록 = 처음 충돌 목록(`.git/MERGEHUB_CONFLICTS`) ∩ git 의 해결 기록(REUC) − 지금 충돌(`MergeConflictRecord.Resolved` — REUC 만 보면 저절로 병합된 파일도 들어온다, 6차 C-1). 누르면 먼저 묻고(저장해 둔 해결 내용이 충돌 표시 글로 바뀜), `git checkout -m -- 파일` 로 세 벌 · 충돌 표시를 되살린 뒤 저장소를 다시 열고(열어 둔 객체는 인덱스를 다시 안 읽어 충돌이 0 으로 보였다 — `UnresolveProbe` 로 확인), 해결 기록(8차 F-1)에서 그 파일을 뺀다(`MergeResolutionLog.Forget`). git.exe 가 없으면 "Git 이 설치되어 PATH 에 있어야" 로 알린다.
+
+### 확인
+
+| 경우 | 결과 |
+|---|---|
+| 두 파일: 반영 → 저장 → Undo → Redo (`f8-probe.ps1`) | 저장 뒤 Undo 켜짐 · 저장 꺼짐 · `저장됨` → Undo: 저장 켜짐 · 파일은 그대로 → Redo: 저장 꺼짐 · `저장됨`. 전체 되돌리기 켜짐 |
+| Git: 반영 → Save & Commit → Undo → Redo (`f8-git.ps1`) | 커밋됨(워킹 변경 0) · 요약 `차이 없음 · 편집 1건 · 되돌리기 0건 · 저장됨` · Undo 켜짐 → Undo: 새 HEAD 대비 `남은 차이 1줄` · 커밋 · 파일 그대로 → Redo: `차이 없음 · 저장됨` |
+| 반영 → 편집기에 침 → 규칙(Aa) 바꿈 → Undo ×3 → Redo ×3 (`f8-typing.ps1`) | 친 것이 한 걸음(편집 2건), 규칙을 바꿔도 이력 그대로, Undo 가 친 것 → Accept 차례로, Redo 가 거꾸로 |
+| 병합: Accept Theirs 로 푼 파일 · 3분할 [Save] 로 푼 파일을 [다시 충돌로] (`f8-reopen.ps1`, C1Repo) | 푼 파일 목록에 나옴 → 확인 창 → git `UU` · 충돌 표시 줄 되살아남 · 충돌 23 → 24 · 충돌 목록에 다시 · 푼 파일 목록에서 빠짐 · 해결 기록에서 빠짐. 병합 직후 머지 주석만 달라 저절로 푼 2개도 목록에 나옴(MergeHub 가 푼 것이라 맞음) |
+| 병합 한 길 회귀(`e11-e1-merge.ps1`) | 충돌 24 · 풀기 26 · 누른 Take 18 = 로그 18 · 병합 커밋 부모 둘 · 줄끝/BOM/인코딩 다른 파일 0 · 충돌 표식 0 · 305초 |
+
+- 하네스 MergeSaveTest **+21건(296 → 317)** — 두 기준 글(연 직후 · 고침 · 저장 · 저장 뒤 Undo/Redo · 전체 되돌리기 대상 · 스타일 대기 · 다시 열기 · null), 줄바꿈만 다른 글, 맡겨 둔 기준 되찾기, 푼 파일 목록(지금 충돌 · 저절로 병합 · 해결 기록 없는 파일 · 겹친 이름), 해결 기록에서 한 파일 빼기. 먼저 넣고 시작. Debug 빌드 경고 0 · 오류 0.
+- 시험 도구: `d7lib` 의 머지 주석 줄 패턴이 F-1 뒤 주석 모양(날짜 뒤 출처)을 못 알아봐 "기준 대조 → 다름" 이 F-2 부터 나고 있었다 — 패턴을 고침(앱 문제 아님).
+
+### 남은 것
+
+- 세 칸(3-Way) 화면에서 규칙을 바꾸거나 치면 이력은 지키지만, 커밋 뒤 다시 읽기처럼 **파일에서 새로 읽는** 경우는 두 칸일 때만 지킨다(충돌 · 3-Way 는 저장이 곧 다음 충돌로 넘어감).
+- [다시 충돌로] 는 MergeHub 로 시작한 병합만(처음 충돌 목록이 있어야). rebase · cherry-pick · revert 중에는 보이지 않는다.
+
## F-12. C# 코드 스타일 맞춤의 기본값을 AURORA 로 (2026-09-18, 완료)
사용자: "기본값은 원본형태를 따르고 C#인 경우에 AURORA 스타일로 하자" → 물어서 정한 것은 **제품 기본값 AURORA · C# 파일만 · 저장 동작은 지금 그대로**(켜져 있으면 저장도 그 스타일로 쓴다).
diff --git a/doc/history/changelog.md b/doc/history/changelog.md
index f3cbe5f..77e14a9 100644
--- a/doc/history/changelog.md
+++ b/doc/history/changelog.md
@@ -8,6 +8,7 @@
| 날짜 | 항목 | 커밋 |
|---|---|---|
+| 2026-09-22 | **11차 F-8** — 저장한 뒤에도 Undo/Redo: 저장하는 길마다 되돌리기 목록을 비우던 것을 두고, "저장할 것 있음" 을 목록이 아니라 "지금 글이 마지막 저장과 다른가" 로(`SaveBaseline`). 커밋 뒤 새 HEAD 로 다시 읽기 · 비교 규칙 바꾸기 · 편집기에 친 뒤 재정렬처럼 줄을 다시 세울 때 이력을 글 단위로 옮겨 지킴(친 것도 한 걸음, 300걸음까지). 전체 되돌리기는 파일을 연 시점. 병합 중 푼 파일은 Git 탭 [다시 충돌로] 로 되돌림 — LibGit2Sharp 에 길이 없어 이 단추만 `git checkout -m`(사용자 결정), 해결 기록에서도 뺌 | 아래 |
| 2026-09-22 | **11차 F-7 (추가 요청)** — "대형 모니터에서 오른쪽이 잘려 종료 · ACTIONS 가 안 보임": 창이 모니터 밖으로 나간 것. 켤 때 창 모드로 기억한 자리를 모니터에 조금만 걸쳐도 그대로 놓았다 — 해상도 · 모니터 배치가 바뀐 뒤 오른쪽으로 넘침(재현 840px). 플랫폼이 가장 많이 겹치는 모니터 안으로 밀게 고침(플랫폼 changelog, 사용자 확인 뒤 배포 09-22). 가장 좁은 창 × 화면 배율 80~133% 에서 안쪽 화면은 원래 넘치지 않음 | 아래 |
| 2026-09-22 | **11차 F-6 (추가 요청)** — 코드 칸 줄 간격을 좁힘: 줄 높이 글자의 1.67배 → 1.33배(12 → 16, 사용자 결정 "선택 사항은 만들지 마" — 켜고 끄는 단추 · 설정 없음). 1920 × 1080 두 칸 40 → 50줄, 네 칸 위 17 → 22 · 아래 18 → 23줄. 북마크 표식 높이도 줄에 맞춤. 방법별 줄 수(탭 줄 접기 +2 · 글자 10 +7 · 칸 제목 한 줄 · 범례 감추기 +1 안팎)는 기록에 | 아래 |
| 2026-09-22 | **11차 F-5 뒤 (사용자 "응 고쳐줘")** — 켜자마자(또는 탭을 눌러 돌아오면) 키보드 포커스가 없거나 탭 줄에 있어, 코드 칸을 한 번 누르기 전까지 **모든 단축키**(Ctrl+S · F9 · Ctrl++ …)가 듣지 않았다. 파일 비교 화면이 뜰 때 · 돌아올 때 · 창이 활성이 될 때 포커스가 비었거나 화면 밖이면 편집기로 준다(화면 안 · 다른 창의 포커스는 그대로). E-1 병합 길 회귀 없음 | 아래 |
diff --git a/doc/진행상황.md b/doc/진행상황.md
index bffdf91..2b30944 100644
--- a/doc/진행상황.md
+++ b/doc/진행상황.md
@@ -377,7 +377,7 @@
| **완료** | F-5 코드 글자 크기(8~24, 기본 12) — 한 값을 모든 모니터에(모니터 배율은 플랫폼), 코드 칸만, Ctrl+휠 · Ctrl++/- · Ctrl+0 + 설정 "보기 > 코드 글자 크기"(사용자 결정). 줄 높이 · 번호 칸 · 여백 단추가 같은 비율로(`CodeFontMetrics`, 12 에서 지금 값 그대로), 줄 번호 기준선을 코드와 맞춤. 8 · 12 · 24 에서 네 칸 행 높이 같음 · 단추 행 안. FontTest 110 · 1,538건. Ctrl+휠 · 키 · 껐다 켜도 남음 확인. 켜자마자는 포커스가 없어 단축키가 모두 안 듣던 것(원래 있던 것)도 고침 — 포커스가 비었거나 화면 밖이면 편집기로 |
| **완료** | F-6 글자를 되도록 많이 보이게 — 방법마다 1920×1080 줄 수를 재 드린 뒤 사용자 결정 "줄 간격 줄여주고 선택 사항은 만들지 마": 줄 높이 글자의 1.67배 → 1.33배(12 → 16). 두 칸 40 → 50줄(탭 줄 접으면 53), 네 칸 17 · 18 → 22 · 23줄. 한글 잘림 · 겹침 없음, E-1 회귀 없음. FontTest 144 · 1,572건 |
| **완료** | F-7 대형 모니터에서 오른쪽(종료 · ACTIONS)이 잘림 — 창이 모니터 밖으로 나간 것. 켤 때 창 모드로 기억한 자리를 모니터에 조금만 걸쳐도 그대로 놓던 것을 가장 많이 겹치는 모니터 안으로 밀게(플랫폼 `FitToMonitors`). 재현 840px 넘침 → 0. 좁은 창 × 화면 배율 80~133% 는 원래 넘친 것 없음. PlacementTest +8. 플랫폼 배포함(09-22) |
-| 대기 | F-8 저장한 뒤에도 되돌리기 (지금은 저장 네 길이 스택을 비움) |
+| **완료** | F-8 저장한 뒤에도 되돌리기 — 저장 · 저장 후 커밋 뒤 Undo/Redo 유지(저장할 것 있음은 "마지막 저장과 다른가" 로), 줄을 다시 세울 때(커밋 뒤 새 HEAD · 규칙 바꾸기 · 친 뒤 재정렬) 이력을 글 단위로 옮겨 지킴, 친 것도 한 걸음. 전체 되돌리기는 파일을 연 시점. Git 탭 "푼 파일" + [다시 충돌로](이 단추만 git.exe, 사용자 결정). MergeSaveTest +21 |
| 대기 | F-9 백업본으로 저장 · 원본을 건드릴지 고르게 |
| 대기 | F-10 덩어리가 어디로 갔는지 UI 로 잇기 (지금은 "자리 옮김" 색만) |
| 대기 | F-11 Git 계정 · URL · 암호 저장 확인 (지금은 주소 · 계정만 저장, 토큰은 메모리) |
diff --git a/src/MergeHub/Services/GitService.cs b/src/MergeHub/Services/GitService.cs
index 58dfff4..4dbb4ec 100644
--- a/src/MergeHub/Services/GitService.cs
+++ b/src/MergeHub/Services/GitService.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
@@ -1002,6 +1003,106 @@ namespace MergeHub.Services
return lines.Skip(1).Count(l => l.Trim().Length > 0);
}
+ ///
+ /// 이번 병합에서 처음 충돌 났다가 지금은 푼 파일(11차 F-8). [다시 충돌로] 로 되돌릴 수 있는 것만 준다 -
+ /// git 이 해결 전 세 벌(REUC)을 기억하는 파일. 병합 중이 아니거나 MergeHub 밖에서 시작한 병합이면 빈 목록.
+ ///
+ public IReadOnlyList GetResolvedMergeConflicts()
+ {
+ var path = Path.Combine(Repo.Info.Path, MergeConflictListFile);
+ var head = ReadMergeHeadSha();
+ if (string.IsNullOrEmpty(head) || !File.Exists(path)) return Array.Empty();
+
+ var lines = File.ReadAllLines(path);
+ if (lines.Length == 0 || !string.Equals(lines[0].Trim(), head, StringComparison.OrdinalIgnoreCase)) return Array.Empty();
+
+ return MergeConflictRecord.Resolved(
+ lines.Skip(1),
+ GetConflicts().Select(c => c.Path),
+ Repo.Index.Conflicts.ResolvedConflicts.Select(r => r.Path));
+ }
+
+ ///
+ /// 푼 파일을 다시 충돌로 되돌린다(11차 F-8, 사용자 결정 "되돌리면 다시 충돌로").
+ ///
+ /// LibGit2Sharp 에는 인덱스에 충돌 세 벌을 다시 넣는 길이 없다. 이 한 곳만 git.exe 를 부른다(사용자 결정) -
+ /// git checkout -m -- 파일 은 해결 기록(REUC)으로 세 벌을 되살리고 워킹트리에 충돌 표시를 다시 쓴다.
+ /// 워킹트리에 저장해 둔 해결 내용은 충돌 표시 글로 바뀐다(부르는 쪽이 먼저 묻는다).
+ /// 끝나면 저장소를 다시 연다 - 열어 둔 객체는 인덱스를 다시 읽지 않아 충돌 목록이 비어 보였다(시험으로 확인).
+ ///
+ public void ReopenConflict(string path)
+ {
+ var normalized = NormalizePath(path);
+ var head = ReadMergeHeadSha();
+ if (string.IsNullOrEmpty(head)) throw new InvalidOperationException("병합 중이 아니라 다시 충돌로 되돌릴 수 없습니다.");
+
+ var workdir = Repo.Info.WorkingDirectory ?? throw new InvalidOperationException("워킹트리가 없는 저장소입니다.");
+ var gitDir = Repo.Info.Path;
+
+ RunGitExe(workdir, "-c", "core.quotepath=false", "checkout", "-m", "--", normalized);
+
+ _repo?.Dispose();
+ _repo = new Repository(gitDir);
+
+ // 해결 기록(8차 F-1)에서 뺀다 - 다시 풀면 그때 적는다.
+ try
+ {
+ var file = Path.Combine(gitDir, MergeResolutionLog.FileName);
+ if (File.Exists(file))
+ File.WriteAllText(file, MergeResolutionLog.Forget(File.ReadAllText(file), head!, normalized), new UTF8Encoding(false));
+ }
+ catch (IOException) { }
+ catch (UnauthorizedAccessException) { }
+
+ Report($"다시 충돌로: {normalized}");
+ }
+
+ /// git.exe 를 한 번 부른다(11차 F-8 [다시 충돌로] 만 쓴다). 실패하면 git 이 한 말을 담아 던진다.
+ private static void RunGitExe(string workdir, params string[] args)
+ {
+ var info = new ProcessStartInfo("git", string.Join(" ", args.Select(QuoteArg)))
+ {
+ WorkingDirectory = workdir,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ StandardOutputEncoding = Encoding.UTF8,
+ StandardErrorEncoding = Encoding.UTF8,
+ };
+
+ Process process;
+ try
+ {
+ process = Process.Start(info) ?? throw new InvalidOperationException("git.exe 를 실행하지 못했습니다.");
+ }
+ catch (System.ComponentModel.Win32Exception)
+ {
+ throw new InvalidOperationException("Git(git.exe)을 찾지 못했습니다. [다시 충돌로] 는 Git 이 설치되어 PATH 에 있어야 합니다.");
+ }
+
+ using (process)
+ {
+ var error = process.StandardError.ReadToEndAsync();
+ process.StandardOutput.ReadToEnd();
+ if (!process.WaitForExit(30000))
+ {
+ try { process.Kill(); } catch (InvalidOperationException) { }
+ throw new InvalidOperationException("git.exe 가 30초 안에 끝나지 않았습니다.");
+ }
+
+ if (process.ExitCode != 0)
+ throw new InvalidOperationException($"git 이 실패했습니다({process.ExitCode}): {error.Result.Trim()}");
+ }
+ }
+
+ /// Windows 명령줄 규칙으로 인자 하나를 감싼다(빈칸 · 따옴표가 든 경로).
+ private static string QuoteArg(string arg)
+ {
+ if (arg.Length > 0 && arg.IndexOfAny(new[] { ' ', '\t', '"' }) < 0) return arg;
+ return "\"" + arg.Replace("\"", "\\\"") + "\"";
+ }
+
///
/// 쓸모없어진 충돌 목록 파일을 지운다(7차 B-1). 지웠으면 true.
/// 병합 커밋 · 작업 취소 직후와 상태를 다시 읽을 때마다 부른다 - 터미널에서 병합을 끝낸 경우도 다음 새로고침에 치운다.
diff --git a/src/MergeHub/Services/GitWorkspace.cs b/src/MergeHub/Services/GitWorkspace.cs
index 5e27378..0d951e4 100644
--- a/src/MergeHub/Services/GitWorkspace.cs
+++ b/src/MergeHub/Services/GitWorkspace.cs
@@ -150,6 +150,9 @@ namespace MergeHub.Services
public ObservableCollection CommitNodes { get; } = new ObservableCollection();
public ObservableCollection StatusFiles { get; } = new ObservableCollection();
public ObservableCollection Conflicts { get; } = new ObservableCollection();
+
+ /// 이번 병합에서 푼 파일 - [다시 충돌로] 로 되돌릴 수 있는 것(11차 F-8). 병합 중이 아니면 빈다.
+ public ObservableCollection ResolvedConflicts { get; } = new ObservableCollection();
public ObservableCollection Tags { get; } = new ObservableCollection();
public ObservableCollection Submodules { get; } = new ObservableCollection();
@@ -408,6 +411,7 @@ namespace MergeHub.Services
var mergeInProgress = Git.IsMergeInProgress;
var pendingHasChanges = !operation.IsInProgress || Git.HasStagedChanges();
var conflictTotal = mergeInProgress ? Git.GetMergeConflictTotal() : null;
+ var resolvedConflicts = mergeInProgress ? Git.GetResolvedMergeConflicts() : Array.Empty();
var resolvedOperation = CommitSelection.IsResolvedOperation(operation.IsInProgress, conflicts.Count);
var remote = remotes.FirstOrDefault(r => r.Name == RemoteName) ?? remotes.FirstOrDefault();
@@ -453,6 +457,7 @@ namespace MergeHub.Services
Replace(CommitNodes, nodes);
Replace(StatusFiles, status.Files);
Replace(Conflicts, conflicts);
+ Replace(ResolvedConflicts, resolvedConflicts);
MergeConflictTotal = conflictTotal;
MergeResolutionBody = resolutionBody;
OnPropertyChanged(nameof(ConflictProgressText));
diff --git a/src/MergeHub/Services/IGitService.cs b/src/MergeHub/Services/IGitService.cs
index f7ff3af..c2f6000 100644
--- a/src/MergeHub/Services/IGitService.cs
+++ b/src/MergeHub/Services/IGitService.cs
@@ -87,6 +87,12 @@ namespace MergeHub.Services
/// 이번 병합에서 처음 난 충돌 파일 수. MergeHub 가 병합할 때 적어 둔 것, 모르면 null(6차 C-1).
int? GetMergeConflictTotal();
+ /// 이번 병합에서 처음 충돌 났다가 지금은 푼 파일 - [다시 충돌로] 로 되돌릴 수 있는 것(11차 F-8).
+ IReadOnlyList GetResolvedMergeConflicts();
+
+ /// 푼 파일을 다시 충돌로 되돌린다(11차 F-8). 워킹트리의 해결 내용은 충돌 표시 글로 바뀐다.
+ void ReopenConflict(string path);
+
/// 쓸모없어진 처음 충돌 목록(.git/MERGEHUB_CONFLICTS)을 지운다. 지웠으면 true(7차 B-1).
bool ForgetStaleMergeConflicts();
diff --git a/src/MergeHub/Services/MergeConflictRecord.cs b/src/MergeHub/Services/MergeConflictRecord.cs
index 1515862..30689ac 100644
--- a/src/MergeHub/Services/MergeConflictRecord.cs
+++ b/src/MergeHub/Services/MergeConflictRecord.cs
@@ -1,4 +1,6 @@
-using System;
+using System;
+using System.Collections.Generic;
+using System.Linq;
namespace MergeHub.Services
{
@@ -23,5 +25,21 @@ namespace MergeHub.Services
if (string.IsNullOrWhiteSpace(recordFirstLine)) return true;
return !string.Equals(recordFirstLine!.Trim(), mergeHeadSha!.Trim(), StringComparison.OrdinalIgnoreCase);
}
+
+ ///
+ /// [다시 충돌로] 로 되돌릴 수 있는 파일(11차 F-8) - 이번 병합에서 처음 충돌 났고(),
+ /// 지금은 충돌이 아니며( 에 없음), git 이 해결 전 세 벌을 기억하는(, REUC) 파일.
+ /// REUC 만 보면 안 된다 - libgit2 는 저절로 병합된 파일도 거기 남긴다(6차 C-1). 처음 목록 차례를 지킨다.
+ ///
+ public static IReadOnlyList Resolved(IEnumerable initial, IEnumerable current, IEnumerable resolveUndo)
+ {
+ static string N(string p) => (p ?? string.Empty).Trim().Replace('\\', '/');
+ var now = new HashSet(current.Select(N), StringComparer.Ordinal);
+ var undo = new HashSet(resolveUndo.Select(N), StringComparer.Ordinal);
+ var seen = new HashSet(StringComparer.Ordinal);
+ return initial.Select(N)
+ .Where(p => p.Length > 0 && !now.Contains(p) && undo.Contains(p) && seen.Add(p))
+ .ToList();
+ }
}
}
diff --git a/src/MergeHub/Services/MergeResolutionLog.cs b/src/MergeHub/Services/MergeResolutionLog.cs
index 3a4879b..092fb1f 100644
--- a/src/MergeHub/Services/MergeResolutionLog.cs
+++ b/src/MergeHub/Services/MergeResolutionLog.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
@@ -104,6 +104,21 @@ namespace MergeHub.Services
return sb.ToString();
}
+ ///
+ /// 한 파일의 기록을 뺀 글(11차 F-8 - [다시 충돌로] 한 파일은 아직 푼 것이 아니다).
+ /// 지난 병합의 기록이었으면 머리 줄만 남긴 새 글. 뺄 것이 없으면 같은 내용.
+ ///
+ public static string Forget(string? existing, string mergeHead, string path)
+ {
+ var normalized = Normalize(path);
+ var sb = new StringBuilder();
+ sb.Append(mergeHead.Trim()).Append('\n');
+ foreach (var entry in Parse(existing, mergeHead))
+ if (!string.Equals(entry.Path, normalized, StringComparison.Ordinal))
+ sb.Append(entry.Kind).Append('\t').Append(entry.Path).Append('\n');
+ return sb.ToString();
+ }
+
///
/// 병합 커밋 본문 블록. 기록이 없으면 null.
///
diff --git a/src/MergeHub/Services/SaveBaseline.cs b/src/MergeHub/Services/SaveBaseline.cs
new file mode 100644
index 0000000..25c09c2
--- /dev/null
+++ b/src/MergeHub/Services/SaveBaseline.cs
@@ -0,0 +1,50 @@
+using System;
+
+namespace MergeHub.Services
+{
+ ///
+ /// 편집 칸의 두 기준 글(11차 F-8) — 파일을 연 시점과 마지막으로 저장한 것.
+ ///
+ /// 예전에는 저장하는 길마다 되돌리기 목록을 비우고, "저장할 것 있음" 을 "되돌리기 목록이 비었는가" 로 봤다.
+ /// 그래서 저장한 뒤에는 Undo 가 꺼졌다. 이제 목록은 두고, 저장할 것이 있는지는 지금 글이 마지막 저장과 다른가로 본다 —
+ /// 저장 뒤 Undo 하면 달라져 "있음", Redo 로 저장 지점에 오면 같아져 "없음". 저장 표시를 따로 들고 다닐 필요가 없다.
+ /// [전체 되돌리기] 는 파일을 연 시점으로 간다(사용자 결정).
+ ///
+ public sealed class SaveBaseline
+ {
+ /// 파일을 연 시점의 글. [전체 되돌리기] 가 가는 곳.
+ public string Opened { get; private set; } = string.Empty;
+
+ /// 마지막으로 저장한 글(연 뒤 저장하지 않았으면 연 시점과 같다).
+ public string Saved { get; private set; } = string.Empty;
+
+ public string RevertTarget => Opened;
+
+ /// 파일을 새로 열었다.
+ public void Open(string text)
+ {
+ Opened = text ?? string.Empty;
+ Saved = Opened;
+ }
+
+ /// 화면을 잠깐 비웠다가 같은 파일 · 같은 글을 다시 실었다 - 맡겨 둔 두 기준을 되찾는다.
+ public void Resume(string opened, string saved)
+ {
+ Opened = opened ?? string.Empty;
+ Saved = saved ?? string.Empty;
+ }
+
+ /// 줄바꿈(CRLF · LF)과 끝 줄바꿈만 다르면 같은 글. 파일에서 읽은 글과 화면 글을 견줄 때 쓴다.
+ public static bool SameText(string? a, string? b) =>
+ string.Equals(Norm(a), Norm(b), StringComparison.Ordinal);
+
+ private static string Norm(string? s) => (s ?? string.Empty).Replace("\r\n", "\n").TrimEnd('\n');
+
+ /// 파일에 썼다.
+ public void MarkSaved(string text) => Saved = text ?? string.Empty;
+
+ /// C# 스타일 맞춤으로 연 글이 파일과 다르다(저장하면 반영된다).
+ public bool IsDirty(string current, bool stylePending) =>
+ stylePending || !string.Equals(current ?? string.Empty, Saved, StringComparison.Ordinal);
+ }
+}
diff --git a/src/MergeHub/ViewModels/GitViewModel.cs b/src/MergeHub/ViewModels/GitViewModel.cs
index e578820..ba7ea5d 100644
--- a/src/MergeHub/ViewModels/GitViewModel.cs
+++ b/src/MergeHub/ViewModels/GitViewModel.cs
@@ -97,6 +97,7 @@ namespace MergeHub.ViewModels
[ObservableProperty] private string _commitMessage = string.Empty;
[ObservableProperty] private FileStatusInfo? _selectedStatusFile;
[ObservableProperty] private ConflictInfo? _selectedConflict;
+ [ObservableProperty] private string? _selectedResolvedConflict;
[ObservableProperty] private TagInfo? _selectedTag;
[ObservableProperty] private string _diffText = string.Empty;
[ObservableProperty] private StashInfo? _selectedStash;
@@ -121,6 +122,7 @@ namespace MergeHub.ViewModels
partial void OnBlamePathChanged(string value) => RaiseCanExecuteChanged();
partial void OnSelectedStatusFileChanged(FileStatusInfo? value) => RaiseCanExecuteChanged();
partial void OnSelectedConflictChanged(ConflictInfo? value) => RaiseCanExecuteChanged();
+ partial void OnSelectedResolvedConflictChanged(string? value) => ReopenConflictCommand.NotifyCanExecuteChanged();
partial void OnSelectedTagChanged(TagInfo? value) => RaiseCanExecuteChanged();
partial void OnSelectedStashChanged(StashInfo? value) => RaiseCanExecuteChanged();
@@ -486,6 +488,28 @@ namespace MergeHub.ViewModels
return go ? ResolveAsync(ConflictResolution.Both) : Task.CompletedTask;
}
+ private bool CanReopenConflict() => _workspace.IsMergeInProgress && !string.IsNullOrEmpty(SelectedResolvedConflict);
+
+ ///
+ /// 푼 파일을 다시 충돌로 되돌린다(11차 F-8, 사용자 결정 "되돌리면 다시 충돌로").
+ /// 병합 중 [Save](해결로 저장)는 파일을 풀고 다음 충돌로 넘어가 편집기의 Undo 로는 돌아올 수 없다 - 여기서 되돌린다.
+ /// 워킹트리의 해결 내용이 충돌 표시 글로 바뀌므로 먼저 묻는다.
+ ///
+ [RelayCommand(CanExecute = nameof(CanReopenConflict))]
+ private async Task ReopenConflictAsync()
+ {
+ var path = SelectedResolvedConflict;
+ if (string.IsNullOrEmpty(path)) return;
+
+ if (!MessageUtil.ShowYesNo("다시 충돌로",
+ $"{path}\n\n이 파일을 푼 것을 되돌려 다시 충돌로 만듭니다.\n" +
+ "파일에 저장해 둔 해결 내용은 충돌 표시(<<<<<<< ======= >>>>>>>)가 든 글로 바뀌고, 다시 풀어야 병합 커밋을 할 수 있습니다.\n\n계속할까요?"))
+ return;
+
+ var ok = await _workspace.RunAndRefreshAsync("다시 충돌로", () => _workspace.Git.ReopenConflict(path!));
+ if (ok) MessageUtil.ShowSuccessToast($"다시 충돌로 · {path} - 충돌 목록에서 다시 풀 수 있습니다");
+ }
+
///
/// 고른 충돌 파일을 파일 비교 탭의 3분할로 연다(6차 B-1). 목록을 두 번 누르거나 Enter 로도 온다.
/// 켜짐/꺼짐을 따로 두지 않는다 - 고른 것이 없으면 아무 일도 안 한다. 이진 파일은 줄 단위로 볼 수 없어 알린다.
@@ -1068,6 +1092,7 @@ namespace MergeHub.ViewModels
/// CommunityToolkit 의 커맨드는 CommandManager 를 구독하지 않아 직접 알려야 한다.
private void RaiseCanExecuteChanged()
{
+ ReopenConflictCommand.NotifyCanExecuteChanged();
OpenCommand.NotifyCanExecuteChanged();
CloneCommand.NotifyCanExecuteChanged();
AddRemoteCommand.NotifyCanExecuteChanged();
diff --git a/src/MergeHub/ViewModels/HomeViewModel.cs b/src/MergeHub/ViewModels/HomeViewModel.cs
index 18f9a53..7ab069e 100644
--- a/src/MergeHub/ViewModels/HomeViewModel.cs
+++ b/src/MergeHub/ViewModels/HomeViewModel.cs
@@ -235,15 +235,14 @@ namespace MergeHub.ViewModels
_theirsSideName = Path.GetFileName(request.Remote);
_mineSideName = Path.GetFileName(request.Local);
- _originalLocalText = BuildLocalText();
+ _baseline.Open(BuildLocalText());
SyncEditorFromLines();
var info = TextFile.Describe(File.Exists(request.Local) ? request.Local : request.Remote);
FileEncoding = info.Encoding;
FileNewLine = info.NewLine;
- _undo.Clear();
- _redo.Clear();
+ ClearHistory();
MarkStyleChange(rawLocalText, localText);
CaretRow = 0;
InvalidateDiffBlocks();
@@ -811,7 +810,7 @@ namespace MergeHub.ViewModels
_undo.Clear();
foreach (var e in remaining) _undo.Push(e);
- _redo.Clear();
+ ClearRedo();
_workspace.AppendLog($"{line.LocalNumber ?? line.BaseNumber}행 되돌림");
AfterEdit();
@@ -819,12 +818,16 @@ namespace MergeHub.ViewModels
private bool HasEditFor(MergeLine line) => _undo.Any(e => ReferenceEquals(e.Line, line));
- private bool CanUndo() => _undo.Count > 0;
+ private bool CanUndo() => _undo.Count > 0 || _textUndo.Count > 0;
[RelayCommand(CanExecute = nameof(CanUndo))]
private void Undo()
{
- if (_undo.Count == 0) return;
+ if (_undo.Count == 0)
+ {
+ if (_textUndo.Count > 0) StepText(_textUndo, _textRedo);
+ return;
+ }
// 덩어리 · 메서드 반영은 여러 줄이 한 묶음이다 - 한 번에 되돌린다(11차 F-2).
var edit = _undo.Pop();
@@ -840,12 +843,16 @@ namespace MergeHub.ViewModels
AfterEdit();
}
- private bool CanRedo() => _redo.Count > 0;
+ private bool CanRedo() => _redo.Count > 0 || _textRedo.Count > 0;
[RelayCommand(CanExecute = nameof(CanRedo))]
private void Redo()
{
- if (_redo.Count == 0) return;
+ if (_redo.Count == 0)
+ {
+ if (_textRedo.Count > 0) StepText(_textRedo, _textUndo);
+ return;
+ }
var edit = _redo.Pop();
Restore(edit, toBefore: false);
@@ -871,7 +878,9 @@ namespace MergeHub.ViewModels
AfterEdit();
}
- private bool CanRevertAll() => HasChanges;
+ /// 저장할 것이 있거나, 저장은 했지만 연 시점과 달라졌다(11차 F-8 - 저장해도 연 시점으로 돌아갈 수 있다).
+ private bool CanRevertAll() =>
+ HasChanges || !string.Equals(_baseline.Saved, _baseline.Opened, StringComparison.Ordinal);
///
/// 파일을 연 시점의 LOCAL 로 통째로 되돌린다.
@@ -882,8 +891,14 @@ namespace MergeHub.ViewModels
[RelayCommand(CanExecute = nameof(CanRevertAll))]
private void RevertAll()
{
+ // 연 뒤 저장했으면 파일은 이미 바뀌어 있다 - 화면만 연 시점으로 가고, 파일에는 다시 저장해야 들어간다(11차 F-8).
+ var saved = !string.Equals(_baseline.Saved, _baseline.Opened, StringComparison.Ordinal);
if (!MessageUtil.ShowYesNo("전체 되돌리기",
- "고친 내용을 모두 버리고 파일을 연 시점으로 돌아갑니다.\n\n되살릴 수 없습니다. 계속할까요?"))
+ saved
+ ? "고친 내용을 모두 버리고 파일을 연 시점으로 돌아갑니다.\n\n" +
+ "이미 저장한 파일 · 커밋은 그대로입니다 - 화면만 돌아가고, 파일에 넣으려면 다시 저장하세요.\n" +
+ "되돌리기 목록은 비워집니다. 계속할까요?"
+ : "고친 내용을 모두 버리고 파일을 연 시점으로 돌아갑니다.\n\n되살릴 수 없습니다. 계속할까요?"))
return;
ReloadLinesFrom(_originalLocalText, keepAsEdited: false);
@@ -961,10 +976,7 @@ namespace MergeHub.ViewModels
_workspace.OnUi(() =>
{
- _originalLocalText = content;
- _undo.Clear();
- _redo.Clear();
- HasChanges = false;
+ MarkSaved(content);
CommitMessage = string.Empty;
_autoCommitMessage = string.Empty;
MessageUtil.ShowSuccessToast(
@@ -1042,9 +1054,9 @@ namespace MergeHub.ViewModels
{
// 저장한 내용이 이제 "원본" 이다. 이걸 안 하면 저장한 뒤에도 편집이 남은 것으로 보여
// 다른 파일을 고를 때마다 확인 창이 다시 뜬다(5차 C-1 에서 겪었다).
- _originalLocalText = content;
- _undo.Clear();
- _redo.Clear();
+ _baseline.Open(content);
+ _styleRawLocalText = null;
+ ClearHistory();
HasChanges = false;
RaiseCanExecuteChanged();
@@ -1127,8 +1139,7 @@ namespace MergeHub.ViewModels
_workspace.OnUi(() =>
{
- _undo.Clear();
- _redo.Clear();
+ ClearHistory();
HasChanges = false;
RaiseCanExecuteChanged();
@@ -1464,6 +1475,33 @@ namespace MergeHub.ViewModels
return;
}
+ // 새로고침이 화면에 있는 파일을 화면 그대로의 글로 다시 읽었다(11차 F-8) - [저장 후 커밋] 뒤가 그렇다.
+ // 커밋하면 BASE 가 새 HEAD 로 옮겨 가(9차 A-2) 줄을 다시 세울 수밖에 없는데, 그러면 되돌리기 목록이 비어
+ // 커밋한 뒤에는 Undo 가 꺼졌다. 글이 같으니 이력은 그대로 뜻이 있다 - 글 단위로 옮겨 지킨다(커밋은 두고 화면만 되돌림, 사용자 결정).
+ // 새로고침은 한 번에 두 번 읽기도 한다(고른 커밋이 바뀜 · 목록 다시 세우기). 표시를 한 번 쓰고 버리면 두 번째가 비웠다 - 조건만 본다.
+ // 파일에서 읽은 글은 CRLF · 끝 줄바꿈이 붙어 있다 - 줄바꿈 차이는 같은 글로 본다.
+ // 새로고침 사이에 화면이 잠깐 비워지기도 한다(목록을 다시 세우는 동안 고른 파일 · 커밋이 잠깐 빔) - 그때 맡겨 둔 이력을 되찾는다.
+ var parked = _parkedHistory;
+ _parkedHistory = null;
+ var keepHistory = false;
+ if (!threeWay && !isConflict && !IsThreeWay)
+ {
+ if (string.Equals(_shownFile, path, StringComparison.Ordinal) && SaveBaseline.SameText(localText, BuildLocalText()))
+ {
+ FlattenHistory();
+ keepHistory = true;
+ }
+ else if (_shownFile is null && parked is not null && string.Equals(parked.File, path, StringComparison.Ordinal) &&
+ SaveBaseline.SameText(localText, parked.Text))
+ {
+ ClearHistory();
+ for (var i = parked.Undo.Length - 1; i >= 0; i--) _textUndo.Push(parked.Undo[i]);
+ for (var i = parked.Redo.Length - 1; i >= 0; i--) _textRedo.Push(parked.Redo[i]);
+ _baseline.Resume(parked.Opened, parked.Saved);
+ keepHistory = true;
+ }
+ }
+
// 여기서부터 화면에 실리는 것은 이 파일의 내용이다. 저장은 이 이름으로만 쓴다.
_shownFile = path;
@@ -1516,7 +1554,7 @@ namespace MergeHub.ViewModels
_loadedAsConflict = isConflict;
_appliedBaseNumbers.Clear(); // 파일이 바뀌면 앞 파일의 결정은 뜻이 없다
_appliedMineNumbers.Clear();
- _originalLocalText = BuildLocalText();
+ if (!keepHistory) _baseline.Open(BuildLocalText()); // 이어서 읽은 것이면 연 시점 · 저장 지점을 그대로 둔다
SyncEditorFromLines();
// 충돌 파일은 충돌 표시가 든 워킹트리가 아니라 내 쪽 파일로 본다 - 저장도 그 형식을 따른다(6차 B-7).
@@ -1530,8 +1568,7 @@ namespace MergeHub.ViewModels
InvalidateDiffBlocks();
RefreshDiffPosition();
- _undo.Clear();
- _redo.Clear();
+ if (!keepHistory) ClearHistory();
SelectedLine = Lines.FirstOrDefault(l => l.IsDifferent) ?? Lines.FirstOrDefault();
// 병합 커밋 메시지 기본값은 병합 전체의 이름이다(6차 B-3). 예전에는 "Merge: 첫 충돌 파일 충돌 해결" 을
@@ -1555,14 +1592,25 @@ namespace MergeHub.ViewModels
private void ClearPanes()
{
+ // 비우기 전에 이력을 맡겨 둔다(11차 F-8). 새로고침 중에 잠깐 비웠다가 같은 파일 · 같은 글을 다시 싣는 일이 있다 -
+ // [저장 후 커밋] 뒤가 그랬다. 다른 파일이거나 글이 다르면 다음 불러오기가 버린다.
+ _parkedHistory = null;
+ if (_shownFile is not null && Lines.Count > 0)
+ {
+ FlattenHistory();
+ if (_textUndo.Count > 0 || _textRedo.Count > 0)
+ _parkedHistory = new ParkedHistory(_shownFile, BuildLocalText(), _textUndo.ToArray(), _textRedo.ToArray(),
+ _baseline.Opened, _baseline.Saved);
+ }
+
Lines.Clear();
Files.Clear();
- _undo.Clear();
- _redo.Clear();
+ ClearHistory();
_syncingEditor = true;
EditorTextReplaced?.Invoke(this, string.Empty);
_syncingEditor = false;
_editorLines = Array.Empty();
+ _typedOriginals.Clear(); // 편집기를 줄에서 다시 채웠다 - 친 것은 줄에 이미 들어갔다
_editorFiller = Array.Empty();
InvalidateDiffBlocks();
@@ -1573,6 +1621,7 @@ namespace MergeHub.ViewModels
_baseSourceName = null;
DiffSummary = "-";
EditSummary = "-";
+ _baseline.Open(string.Empty);
HasChanges = false;
_loadedAsConflict = false;
_shownFile = null;
@@ -1846,7 +1895,7 @@ namespace MergeHub.ViewModels
// 반영 표시는 되돌리기 스택 밖에도 남긴다 - 규칙을 바꾸면 스택이 비워지기 때문이다.
MarkApplied(line);
- _redo.Clear();
+ ClearRedo();
if (group == 0) _workspace.AppendLog(description);
if (requery) AfterEdit();
@@ -2069,7 +2118,7 @@ namespace MergeHub.ViewModels
{
InsertedNote = InsertNoteAbove(line, chosenNote),
});
- _redo.Clear();
+ ClearRedo();
}
_workspace.AppendLog(chosen);
@@ -2098,7 +2147,7 @@ namespace MergeHub.ViewModels
// 반영 표시는 되돌리기 스택 밖에도 남긴다 - 규칙을 바꾸면 스택이 비워지기 때문이다.
MarkApplied(line);
- _redo.Clear();
+ ClearRedo();
_workspace.AppendLog(description);
AfterEdit();
@@ -2172,7 +2221,7 @@ namespace MergeHub.ViewModels
// 반영 표시는 되돌리기 스택 밖에도 남긴다 - 규칙을 바꾸면 스택이 비워지기 때문이다.
MarkApplied(line);
- _redo.Clear();
+ ClearRedo();
if (group == 0) _workspace.AppendLog(description);
if (requery) AfterEdit();
@@ -2278,8 +2327,28 @@ namespace MergeHub.ViewModels
/// 편집기 글을 통째로 갈아 끼운다. 새 글을 함께 넘긴다.
public event EventHandler? EditorTextReplaced;
- /// 파일을 연 직후의 LOCAL 전문. "전체 되돌리기"의 기준점.
- private string _originalLocalText = string.Empty;
+ ///
+ /// 파일을 연 시점 · 마지막으로 저장한 글(11차 F-8). 저장할 것이 있는지는 되돌리기 목록이 아니라
+ /// "지금 글이 마지막 저장과 다른가" 로 본다 - 그래서 저장해도 되돌리기 목록을 비우지 않는다.
+ ///
+ private readonly SaveBaseline _baseline = new();
+
+ ///
+ /// 글 단위 되돌리기 · 다시하기(11차 F-8). 줄 단위 목록()은 줄 객체를 가리키므로
+ /// 줄을 다시 세우면(커밋 뒤 새 HEAD 로 다시 읽기 · 비교 규칙 바꾸기) 쓸 수 없다. 그 전에 각 지점의 전문을 여기로 옮긴다.
+ /// 여기 든 것은 줄 단위 목록보다 늘 먼 쪽이다(되돌리기는 더 옛날, 다시하기는 더 나중).
+ ///
+ private readonly Stack _textUndo = new();
+ private readonly Stack _textRedo = new();
+
+ /// 화면을 비울 때 맡겨 둔 이력(11차 F-8). 맨 위가 첫 칸(가장 가까운 것) - 차례.
+ private sealed record ParkedHistory(string File, string Text, string[] Undo, string[] Redo, string Opened, string Saved);
+
+ private ParkedHistory? _parkedHistory;
+
+
+ /// 파일을 연 직후의 LOCAL 전문. "전체 되돌리기"의 기준점 · 직접 고친 줄을 가리는 기준.
+ private string _originalLocalText => _baseline.Opened;
/// 정렬 기준이 되는 BASE 전문. 편집 후 다시 정렬할 때 쓴다.
private string _baseTextSnapshot = string.Empty;
@@ -2376,26 +2445,54 @@ namespace MergeHub.ViewModels
// 글자가 그대로면 그릴 것이 없다. 이것도 "맞춰 둔 것"이라 true 다.
if (string.Equals(_editorLines[line], value, StringComparison.Ordinal)) return true;
+ // 친 줄의 원래 글을 한 번만 적어 둔다 - 재정렬 때 되돌리기 한 걸음으로 삼는다(11차 F-8).
+ if (!_typedOriginals.ContainsKey(line)) _typedOriginals[line] = Lines[line].LocalText;
_editorLines[line] = value;
Lines[line].LocalText = value;
DiffVersion++;
return true;
}
+ ///
+ /// 편집기에서 친 줄의 원래 글(줄 번호 → 글, 11차 F-8). 치는 동안 화면을 바로 보이려고 줄 객체를 먼저 고치는데
+ /// 되돌리기 목록에는 안 들어간다. 재정렬할 때 이것으로 친 것 전의 글을 되살려 한 걸음으로 올린다.
+ ///
+ private readonly Dictionary _typedOriginals = new();
+
/// 타이핑이 멈췄다. 필러를 걷어내고 BASE 와 다시 맞춘다.
private void Reflow()
{
_reflowTimer.Stop();
var current = SplitLines(CurrentEditorText);
- var real = EditorLayout.StripFillers(_editorLines, _editorFiller, current);
+ var real = string.Join("\n", EditorLayout.StripFillers(_editorLines, _editorFiller, current));
- ReloadLinesFrom(string.Join("\n", real), keepAsEdited: true, moveSelection: false);
+ // 편집기에서 친 것도 되돌리기 한 걸음이다(11차 F-8). 예전에는 재정렬이 목록을 통째로 비워,
+ // 글자 하나만 쳐도 그 전의 Accept 들을 Undo 할 수 없었다. 친 줄을 원래 글로 돌려 친 것 전의 글을 얻고,
+ // 그 상태에서 목록을 글 단위로 옮긴 뒤 친 것 전의 글을 한 걸음으로 올린다.
+ foreach (var typed in _typedOriginals)
+ if (typed.Key < Lines.Count) Lines[typed.Key].LocalText = typed.Value;
+ _typedOriginals.Clear();
+
+ var before = BuildLocalText();
+ FlattenHistory();
+ if (!string.Equals(before, real, StringComparison.Ordinal))
+ {
+ _textUndo.Push(before);
+ KeepNewest(_textUndo);
+ ClearRedo();
+ }
+
+ ReloadLinesFrom(real, keepAsEdited: true, moveSelection: false);
}
/// 주어진 LOCAL 전문으로 비교 화면을 다시 만든다.
private void ReloadLinesFrom(string localText, bool keepAsEdited, bool moveSelection = true)
{
+ // 줄을 다시 세우면 되돌리기 목록이 가리키던 줄이 사라진다 - 그 전에 목록을 글 단위로 옮겨 둔다(11차 F-8).
+ // 예전에는 비교 규칙 하나만 바꿔도 목록이 비었다. [전체 되돌리기](keepAsEdited 아님)만 목록을 비운다.
+ if (keepAsEdited) FlattenHistory();
+
// 3-Way 면 3-Way 로 다시 맞춰야 한다. 2-Way 로 만들면 REMOTE·LOCAL 원본 칸의
// 내용이 통째로 사라진다(칸은 그대로 있고 글만 빈다).
var rows = IsThreeWay
@@ -2431,13 +2528,13 @@ namespace MergeHub.ViewModels
Lines.Add(row);
}
- _undo.Clear();
- _redo.Clear();
+ if (keepAsEdited) { _undo.Clear(); _redo.Clear(); } // 위에서 글로 옮겨 이미 비었다
+ else ClearHistory();
if (moveSelection)
SelectedLine = Lines.FirstOrDefault(l => l.IsDifferent) ?? Lines.FirstOrDefault();
- HasChanges = !string.Equals(localText, _originalLocalText, StringComparison.Ordinal) || _styleRawLocalText is not null;
+ HasChanges = _baseline.IsDirty(localText, _styleRawLocalText is not null);
InvalidateDiffBlocks();
SyncEditorFromLines();
@@ -2460,6 +2557,7 @@ namespace MergeHub.ViewModels
var snapshot = EditorLayout.Build(rows);
_editorLines = snapshot.Lines;
+ _typedOriginals.Clear(); // 편집기를 줄에서 다시 채웠다 - 친 것은 줄에 이미 들어갔다
_editorFiller = snapshot.Filler;
var text = string.Join(Environment.NewLine, snapshot.Lines);
@@ -2733,7 +2831,7 @@ namespace MergeHub.ViewModels
SetPlainHeaders();
- _originalLocalText = BuildLocalText();
+ _baseline.Open(BuildLocalText());
SyncEditorFromLines();
var info = TextFile.Describe(RightFile);
@@ -2741,8 +2839,7 @@ namespace MergeHub.ViewModels
FileEncoding = info.Encoding;
FileNewLine = info.NewLine;
- _undo.Clear();
- _redo.Clear();
+ ClearHistory();
// 새로 읽은 파일에는 고친 것이 없다. 이걸 안 끄면 앞 파일의 상태가 그대로 끌려와
// 저장 단추가 켜진 채로 남고, 다음에 파일을 열 때마다 "저장하지 않은 편집" 을 묻는다.
@@ -2814,10 +2911,7 @@ namespace MergeHub.ViewModels
_workspace.OnUi(() =>
{
- _originalLocalText = content;
- _undo.Clear();
- _redo.Clear();
- HasChanges = false;
+ MarkSaved(content);
MessageUtil.ShowSuccessToast($"저장 완료 · {Path.GetFileName(target)}");
});
}
@@ -2967,10 +3061,7 @@ namespace MergeHub.ViewModels
WritePlainFile(target, content, SaveEncoding, SaveNewLine);
}
- _originalLocalText = content;
- _undo.Clear();
- _redo.Clear();
- HasChanges = false;
+ MarkSaved(content);
MessageUtil.ShowSuccessToast($"저장 완료 · {Path.GetFileName(target)}");
return true;
@@ -3503,9 +3594,104 @@ namespace MergeHub.ViewModels
}
+ /// 새 편집을 했다 - 다시하기는 줄 단위 · 글 단위 모두 뜻을 잃는다.
+ private void ClearRedo()
+ {
+ _redo.Clear();
+ _textRedo.Clear();
+ }
+
+ /// 다른 파일을 열었거나 화면을 비웠다 - 이력을 모두 버린다.
+ private void ClearHistory()
+ {
+ _undo.Clear();
+ _redo.Clear();
+ _textUndo.Clear();
+ _textRedo.Clear();
+ }
+
+ /// 한 묶음(덩어리 · 메서드 반영은 여러 줄이 한 묶음, 11차 F-2)을 꺼낸다. 꺼낸 순서대로.
+ private static List PopGroup(Stack from)
+ {
+ var edit = from.Pop();
+ var group = new List { edit };
+ while (edit.Group != 0 && from.Count > 0 && from.Peek().Group == edit.Group) group.Add(from.Pop());
+ return group;
+ }
+
+ ///
+ /// 줄 단위 이력을 글 단위로 옮긴다(11차 F-8). 줄을 다시 세우기 전에 부른다.
+ /// 되돌리기 쪽은 한 묶음씩 거꾸로 밟아 가며 그 지점의 전문을 적고, 다시하기 쪽은 앞으로 밟아 적은 뒤
+ /// 화면을 지금 상태로 돌려 놓는다. 한 묶음이 한 걸음이다 - Undo 한 번에 돌아가던 만큼 그대로 돌아간다.
+ ///
+ private void FlattenHistory()
+ {
+ if (_undo.Count == 0 && _redo.Count == 0) return;
+
+ var undoTexts = new List(); // 가까운 것부터
+ var undone = new List();
+ while (_undo.Count > 0)
+ {
+ foreach (var e in PopGroup(_undo)) { Restore(e, toBefore: true); undone.Add(e); }
+ undoTexts.Add(BuildLocalText());
+ }
+ for (var i = undone.Count - 1; i >= 0; i--) Restore(undone[i], toBefore: false);
+
+ var redoTexts = new List(); // 가까운 것부터
+ var redone = new List();
+ while (_redo.Count > 0)
+ {
+ foreach (var e in PopGroup(_redo)) { Restore(e, toBefore: false); redone.Add(e); }
+ redoTexts.Add(BuildLocalText());
+ }
+ for (var i = redone.Count - 1; i >= 0; i--) Restore(redone[i], toBefore: true);
+
+ // 이미 든 글 단위 이력은 더 먼 쪽이다 - 그 위에 먼 것부터 쌓아 가장 가까운 것이 맨 위.
+ for (var i = undoTexts.Count - 1; i >= 0; i--) _textUndo.Push(undoTexts[i]);
+ for (var i = redoTexts.Count - 1; i >= 0; i--) _textRedo.Push(redoTexts[i]);
+ KeepNewest(_textUndo);
+ KeepNewest(_textRedo);
+ }
+
+ /// 글 단위 이력은 한 걸음이 파일 전문 한 벌이다 - 가장 먼 것부터 버려 이만큼만 둔다.
+ private const int TextHistoryLimit = 300;
+
+ private static void KeepNewest(Stack stack)
+ {
+ if (stack.Count <= TextHistoryLimit) return;
+ var keep = stack.Take(TextHistoryLimit).Reverse().ToList(); // 맨 위(가까운 것)부터 300개
+ stack.Clear();
+ foreach (var text in keep) stack.Push(text);
+ }
+
+ /// 글 단위로 한 걸음(되돌리기 또는 다시하기). 지금 글을 반대쪽에 올리고 꺼낸 글로 줄을 다시 세운다.
+ private void StepText(Stack from, Stack to)
+ {
+ // 줄 단위 다시하기가 남아 있으면(줄 편집을 되돌리다 글 단위로 넘어옴) 그것도 먼저 글로 옮긴다 - 안 그러면 다시 세우며 사라진다.
+ FlattenHistory();
+ var target = from.Pop();
+ to.Push(BuildLocalText());
+ ReloadLinesFrom(target, keepAsEdited: true, moveSelection: false);
+ RaiseCanExecuteChanged();
+ }
+
+ ///
+ /// 파일에 썼다(11차 F-8). 되돌리기 · 다시하기 목록은 두고 저장 지점만 옮긴다 -
+ /// 저장한 뒤에도 Undo 로 저장 전 글로 갈 수 있다. 스타일 맞춤은 파일에 들어갔으니 끝났다.
+ ///
+ private void MarkSaved(string content)
+ {
+ _baseline.MarkSaved(content);
+ _styleRawLocalText = null;
+ HasChanges = _baseline.IsDirty(BuildLocalText(), stylePending: false);
+ UpdateSummaries();
+ RaiseCanExecuteChanged();
+ }
+
private void AfterEdit()
{
- HasChanges = _undo.Count > 0 || _styleRawLocalText is not null;
+ // 저장한 뒤 Undo 하면 저장한 글과 달라져 "있음", Redo 로 저장 지점에 오면 "없음"(11차 F-8).
+ HasChanges = _baseline.IsDirty(BuildLocalText(), _styleRawLocalText is not null);
RefreshAppliedFlags();
RefreshBlockCommentState(Lines);
InvalidateDiffBlocks();
@@ -3654,11 +3840,12 @@ namespace MergeHub.ViewModels
}
// 주석만 든 편집(덩어리 · 메서드 반영의 주석 한 줄, 11차 F-2)은 세지 않는다 - 편집 수는 바뀐 줄 수다.
- var edits = _undo.Count(e => e.Kind != EditKind.NoteOnly);
- var redos = _redo.Count(e => e.Kind != EditKind.NoteOnly);
+ var edits = _undo.Count(e => e.Kind != EditKind.NoteOnly) + _textUndo.Count; // 글 단위는 한 걸음이 한 건(11차 F-8)
+ var redos = _redo.Count(e => e.Kind != EditKind.NoteOnly) + _textRedo.Count;
+ // 저장한 뒤에도 목록이 남으므로(11차 F-8) 저장한 지점인지를 같이 적는다.
EditSummary = edits == 0 && redos == 0
? work
- : $"{work} · 편집 {edits}건 · 되돌리기 {redos}건";
+ : $"{work} · 편집 {edits}건 · 되돌리기 {redos}건{(HasChanges || edits == 0 ? string.Empty : " · 저장됨")}";
RefreshProblems();
}
@@ -3804,7 +3991,7 @@ namespace MergeHub.ViewModels
});
}
- _redo.Clear();
+ ClearRedo();
_workspace.AppendLog(description);
AfterEdit();
return changed;
diff --git a/src/MergeHub/Views/GitView.xaml b/src/MergeHub/Views/GitView.xaml
index 50031e2..24a9811 100644
--- a/src/MergeHub/Views/GitView.xaml
+++ b/src/MergeHub/Views/GitView.xaml
@@ -237,6 +237,7 @@
+
@@ -304,6 +305,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+