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 @@ + + + + + + + + + + + + + + + + +