diff --git a/doc/history/2026-09-14-6차.md b/doc/history/2026-09-14-6차.md
index 9ac9541..1bc7294 100644
--- a/doc/history/2026-09-14-6차.md
+++ b/doc/history/2026-09-14-6차.md
@@ -515,7 +515,72 @@ Consolas 12 는 한 글자 약 6.6px 라 20px 에는 세 자리만 들어간다.
빌드 경고 0 · 오류 0. 고친 파일은 XAML 뿐이라 검사 하네스는 영향이 없다.
-### 확인하다 본 것 (고치지 않음)
+### 확인하다 본 것 두 가지 — 아래 "B-4 확인 중 발견 두 건" 에서 고쳤다
-- 3분할에서 Ctrl+End 를 누르면 캐럿이 파일 끝이 아니라 **마지막 충돌 줄**(2951 · 7470)에 서고, 네 칸 모두 **그 아래가 빈 채**로 그려진다. 고치기 전 화면에서도 같았다 — B-4 와 무관하다.
+- 3분할에서 Ctrl+End 를 누르면 네 칸 모두 몇 줄만 보이고 **그 아래가 빈 채**로 그려진다. 고치기 전 화면에서도 같았다.
- 저장소 밖 파일을 `--merge --base --local --remote --merged` 로 넘기면 네 칸이 아니라 BASE · LOCAL 두 칸으로 열린다.
+
+## B-4 확인 중 발견 두 건 (2026-09-14, 완료)
+
+사용자가 "확인하다 본 것도 고쳐 달라" 고 했다.
+
+### ① 멀리 건너뛰면 네 칸이 위로 밀려 아래가 빈다
+
+**다시 났다.** 만든 저장소 `Big7479.cs`(7,479줄) 네 칸에서 Ctrl+End. 네 칸 모두 `7462` ~ `7470` 아홉 줄만 보이고 아래가 비었다. 휠을 한 칸 굴리면 멀쩡해졌다.
+
+Ctrl+End 는 원래 "마지막 차이" 단축키(4차)라 마지막 충돌 줄로 가는 것은 설계대로다. 문제는 빈 화면이다.
+
+**원인을 가른 과정.**
+
+| 해 본 것 | 본 것 |
+|---|---|
+| UIA 로 MERGED 목록의 행 | 행은 만들어져 있다(7446 ~ 7471). 비어서 빈 것이 아니다 |
+| 1px 굴렸다 되돌리는 코드를 넣음 | **그대로** — 이 길이 아니다(걷어냄) |
+| 목록 칸과 편집기의 화면 좌표 | **MERGED 목록 칸이 편집기보다 279px(약 9줄) 위에 있었다.** 목록 위쪽은 머리글에 가리고 그만큼 아래가 빈다 |
+
+표시층(색 · 줄 번호) 목록 넷은 편집기 글자에 맞추려고 **같은 세로 이동값**(`_decorationShift`)을 공유한다. 이 값은 배치가 끝날 때마다 "편집기의 N행 글자 위치" 와 "표시층 N행 위치" 를 견줘 정한다.
+그런데 N 을 **처음 만들어진 목록 행**에서 골랐다. 멀리 건너뛴 직후 그 행은 화면 밖에 남은 행이고, TextBox 는 화면 밖 글자의 좌표를 **어림으로** 준다. 그 어림이 9줄쯤 틀려 네 칸이 한꺼번에 밀렸다. 휠을 굴리면 기준 행이 바뀌어 다시 맞았다.
+
+**고친 것** (`Views/HomeView.xaml.cs`)
+
+| 자리 | 무엇 |
+|---|---|
+| `AlignDecorationToEditor` | 기준 행을 **목록 칸 안에 위가 안 잘리고 보이는 첫 행**으로 고른다(`FirstVisibleContainer` - 행 판의 자식만 훑는다). 편집기 글자 좌표가 **편집기 화면 밖이면 맞추지 않고** 다음 배치 때 다시 본다 |
+
+**확인** (`Big7479.cs` 네 칸)
+
+| 동작 | MERGED 목록 칸 y − 편집기 y | 화면 |
+|---|---|---|
+| 처음 연 뒤 | −7px (고치기 전과 같은 값 - 칸 테두리 차) | 정상 |
+| Ctrl+End | **−7px** (고치기 전 −279px) | 네 칸 `7453` ~ `7470` 끝까지 채워짐 |
+| Ctrl+Home(첫 차이 2950) · 끝에서 Alt+↑ | 행 번호와 편집기 줄이 같은 눈높이 | 정상 |
+
+### ② `git mergetool` 세 벌이 두 칸으로 열린다
+
+**다시 났다.** 7,479줄 파일 셋을 `--merge --base --local --remote --merged` 로 넘기자 BASE · LOCAL 두 칸, "차이 없음".
+
+**원인.** 켤 때 인자를 `DiffPair`(BASE, MERGED) 한 쌍으로만 넘겼다. 세 벌을 받는 길이 없었고, 네 칸(3-Way)은 `IsGitMode` 일 때만 켜졌다.
+MERGED 는 git 이 충돌 표시를 넣어 둔 파일이라, 두 칸으로는 상대가 무엇을 바꿨는지 볼 수 없고 충돌 표시를 손으로 지워 가며 풀어야 했다.
+
+**고친 것**
+
+| 파일 | 무엇 |
+|---|---|
+| `Services/StartupArgs.cs` | `ThreeWayMerge` — `--merge` 에 LOCAL · REMOTE 가 다 있으면 `MergeToolRequest(Base, Local, Remote, Merged)`. BASE 는 없어도 되고(양쪽 새 파일), MERGED 가 없으면 LOCAL 에 쓴다 |
+| `Services/GitWorkspace.cs` | 켤 때 세 벌이면 `RequestMergeTool` — 다른 요청처럼 화면이 생길 때까지 남겨 둔다 |
+| `ViewModels/HomeViewModel.cs` | `OnMergeToolRequested` · `LoadMergeToolAsync` — 파일 비교 모드에서 네 칸으로 연다. MERGED 칸의 처음 글은 충돌 표시가 아니라 **LOCAL**. `IsThreeWay` 가 Git 이 아니면 mergetool 네 칸일 때 참. 파일 두 개 비교를 열면 접는다 |
+| 같은 파일 | [저장] 은 안 고쳤어도 **충돌이 다 풀리면** 켜진다. MERGED 파일에 쓸 때 형식(인코딩 · 줄끝)은 **LOCAL 파일**에서 읽는다 - MERGED 는 충돌 표시 줄이 LF 라 섞여 보인다(A-1 과 같은 까닭) |
+
+`ArgsTest` 에 10건을 넣어 **36건 통과**.
+
+**확인** (지어낸 세 벌: 40줄 CRLF · BOM, 10줄은 양쪽이 다르게 고침, 30줄은 상대만 고침. MERGED 는 git 모양대로 충돌 표시를 넣고 LF)
+
+| 확인 | 결과 |
+|---|---|
+| 인자로 켬 | **네 칸**. BASE `Weld_BASE_1234.cs`, REMOTE `REMOTE · Weld_REMOTE_1234.cs`, 범례 Conflict 1 · Theirs only 1, MERGED 는 내 쪽 글(`= 111; // 내 쪽`), [저장] 꺼짐 |
+| 10줄 Take Theirs → [자동 병합] | "1줄 자동 병합 · 남은 충돌 없음", [저장] 켜짐 |
+| [저장] 뒤 `Weld.cs` | BOM 있음 · CRLF 42 · 홀로 LF 0 · 충돌 표시 없음. 10줄 머지 주석, 11줄 `= 222; // 상대`, 31줄 머지 주석, 32줄 `= 3030; // 상대만 고침` |
+
+위쪽 LOCAL(Mine) 칸 제목은 MERGED 칸과 같은 값을 쓴다(Git 모드에서는 둘 다 브랜치 이름). mergetool 에서는 결과 파일 이름 `Weld.cs` 가 보인다. 손대지 않았다.
+
+빌드 경고 0 · 오류 0.
diff --git a/doc/history/changelog.md b/doc/history/changelog.md
index c82132e..917f879 100644
--- a/doc/history/changelog.md
+++ b/doc/history/changelog.md
@@ -8,6 +8,7 @@
| 날짜 | 항목 | 커밋 |
|---|---|---|
+| 2026-09-14 | **6차 B-4 확인 중 발견 두 건** — "마지막 차이" 로 멀리 건너뛰면 표시층 보정이 화면 밖 행으로 재져 네 칸이 9줄 위로 밀리던 것을 고침. `git mergetool` 이 넘긴 세 벌을 두 칸이 아닌 네 칸으로 열고 결과를 MERGED 파일에 쓴다 | 아래 |
| 2026-09-14 | **6차 B-4** — 3분할 MERGED 칸 줄 번호 자리가 20px 라 2949 가 "294" 로 잘리던 것을 고침. 네 칸 여백을 54 → 66px 로 맞춰 넓혀 다섯 자리까지 보인다 | 아래 |
| 2026-09-14 | **6차 B-3** — 병합 커밋 기본 메시지를 `Merge branch '상대' into 내 브랜치` 로. 첫 파일 이름이 굳어 남던 것, 커밋에 `#Conflicts:` 줄 24개와 해시 첫 줄이 들어가던 것을 고침. 병합이 끝나면 스스로 채운 글만 지운다 | 아래 |
| 2026-09-14 | **6차 B-2** — 파일을 바꿀 때 확인 창이 고친 파일 이름을 대고, [저장하고 계속] 이 엉뚱한 파일(새로 고른 파일)에 쓰던 것을 막음. [취소] 뒤 콤보가 제자리로. 저장소를 먼저 열면 목록이 0개이던 B-1 빈틈도 | 아래 |
diff --git a/doc/진행상황.md b/doc/진행상황.md
index 28c1766..6cf7f26 100644
--- a/doc/진행상황.md
+++ b/doc/진행상황.md
@@ -107,6 +107,7 @@
| **완료** | B-2 확인 창이 엉뚱한 파일 이름을 댄다 — 이름·[저장하고 계속] 모두 실린 파일 기준, [취소] 뒤 콤보도 돌아온다 |
| **완료** | B-3 커밋 메시지가 엉뚱하게 남는다 — 기본값 `Merge branch '상대' into 내 브랜치`, `#` 줄 뺌, 병합이 끝나면 칸을 비움 |
| **완료** | B-4 MERGED 칸 줄 번호가 네 자리에서 잘린다 — 네 칸 여백을 66px 로, MERGED 번호 자리 20 → 36px(다섯 자리) |
+| **완료** | B-4 확인 중 발견 두 건 — 멀리 건너뛰면 네 칸이 위로 밀려 아래가 빔 · `git mergetool` 세 벌이 두 칸으로 열림 |
| 대기 | B-5 머지 주석은 소스에 넣고, 병합 뒤에는 그 줄을 무시한다 |
| 대기 | B-6 충돌 탭에서 하나씩 누를 때마다 오래 기다린다 |
| 대기 | B-7 상태바가 병합 중에 믿을 수 없는 값을 보인다 |
diff --git a/src/MergeHub/Services/GitWorkspace.cs b/src/MergeHub/Services/GitWorkspace.cs
index e251075..716f4fd 100644
--- a/src/MergeHub/Services/GitWorkspace.cs
+++ b/src/MergeHub/Services/GitWorkspace.cs
@@ -557,6 +557,34 @@ namespace MergeHub.Services
OpenConflictRequested.Invoke(path);
}
+ // ================= mergetool 세 벌을 네 칸으로 열기 =================
+
+ ///
+ /// "`git mergetool` 이 넘긴 세 벌을 네 칸으로 열어 달라". 켤 때 넘어온 인자가 보내고 파일 비교 탭이 받는다.
+ /// 파일 비교 화면은 탭을 처음 열 때 만들어지므로 다른 요청들처럼 남겨 둔다.
+ ///
+ public event Action? MergeToolRequested;
+
+ private MergeToolRequest? _pendingMergeTool;
+
+ public bool HasPendingMergeTool => _pendingMergeTool is not null;
+
+ public void RequestMergeTool(MergeToolRequest request)
+ {
+ _pendingMergeTool = request;
+ OnUi(DeliverPendingMergeTool);
+ }
+
+ /// 남아 있는 요청을 넘긴다. 파일 비교 화면이 새로 만들어지면 스스로 한 번 불러 준다.
+ public void DeliverPendingMergeTool()
+ {
+ if (_pendingMergeTool is not { } request) return;
+ if (MergeToolRequested is null) return;
+
+ _pendingMergeTool = null;
+ MergeToolRequested.Invoke(request);
+ }
+
// ================= 켤 때 넘어온 인자 =================
/// 폴더 비교 화면이 아직 없을 때 남겨 두는 요청. 파일 비교 쪽과 같은 방식이다.
@@ -597,6 +625,15 @@ namespace MergeHub.Services
return;
}
+ // mergetool 이 세 벌을 넘겼으면 네 칸으로 연다(6차 B-4 확인 중 발견 - 예전에는 BASE · MERGED 두 칸이었다).
+ if (args.ThreeWayMerge is { } merge)
+ {
+ RequestMergeTool(merge);
+ ActivateWhenReady(MergeHub.ViewIds.Home);
+ AppendLog($"mergetool 로 호출됨(네 칸): {merge.Merged}");
+ return;
+ }
+
if (!args.WantsFiles) return;
var (left, right) = args.DiffPair;
diff --git a/src/MergeHub/Services/StartupArgs.cs b/src/MergeHub/Services/StartupArgs.cs
index e8d6360..cf42535 100644
--- a/src/MergeHub/Services/StartupArgs.cs
+++ b/src/MergeHub/Services/StartupArgs.cs
@@ -159,9 +159,25 @@ namespace MergeHub.Services
}
}
+ ///
+ /// `git mergetool` 이 세 벌(BASE · LOCAL · REMOTE)을 넘겼으면 네 칸으로 열 요청. 아니면 null.
+ ///
+ /// 예전에는 로 BASE 와 MERGED 두 칸만 열었다(6차 B-4 확인 중 발견).
+ /// MERGED 는 git 이 충돌 표시를 넣어 둔 파일이라 두 칸으로는 상대가 무엇을 바꿨는지 볼 수 없었다.
+ /// REMOTE 가 없으면 풀 것이 없으므로 세 벌로 보지 않는다. BASE 는 없어도 된다(양쪽이 새로 만든 파일).
+ /// MERGED 가 없으면 LOCAL 에 쓴다.
+ ///
+ public MergeToolRequest? ThreeWayMerge =>
+ Mode == StartupMode.Merge && LooseLeft.Length == 0 && Local.Length > 0 && Remote.Length > 0
+ ? new MergeToolRequest(Base, Local, Remote, Merged.Length > 0 ? Merged : Local)
+ : null;
+
public bool WantsFolder => Mode == StartupMode.Folder && LeftFolder.Length > 0;
public bool WantsFiles =>
Mode is StartupMode.Diff or StartupMode.Merge && (DiffPair.Left.Length > 0 || DiffPair.Right.Length > 0);
}
+
+ /// `git mergetool` 이 넘긴 세 벌과 결과를 쓸 파일.
+ public sealed record MergeToolRequest(string Base, string Local, string Remote, string Merged);
}
diff --git a/src/MergeHub/ViewModels/HomeViewModel.cs b/src/MergeHub/ViewModels/HomeViewModel.cs
index d2951c2..010eb19 100644
--- a/src/MergeHub/ViewModels/HomeViewModel.cs
+++ b/src/MergeHub/ViewModels/HomeViewModel.cs
@@ -37,6 +37,7 @@ namespace MergeHub.ViewModels
_workspace.StateChanged += RaiseCanExecuteChanged;
_workspace.PlainCompareRequested += OnPlainCompareRequested;
_workspace.OpenConflictRequested += OnOpenConflictRequested;
+ _workspace.MergeToolRequested += OnMergeToolRequested;
_workspace.Recent.Changed += (_, _) => _workspace.OnUi(RefreshRecent);
_workspace.QueueChanged += (_, _) => RefreshQueue();
@@ -61,6 +62,13 @@ namespace MergeHub.ViewModels
return;
}
+ // git mergetool 이 세 벌을 넘겨 켠 경우. 두 칸 비교 요청보다 먼저 본다.
+ if (_workspace.HasPendingMergeTool)
+ {
+ _workspace.DeliverPendingMergeTool();
+ return;
+ }
+
// 폴더 비교에서 파일을 골라 이 화면이 만들어진 경우라면 그 파일을 먼저 연다.
// 이 화면은 탭을 처음 열 때 만들어지므로, 요청이 우리보다 먼저 도착해 있을 수 있다.
if (_workspace.HasPendingPlainCompare)
@@ -125,6 +133,135 @@ namespace MergeHub.ViewModels
_ = LoadPlainFilesAsync();
}
+ // ================= git mergetool 세 벌 =================
+
+ /// git mergetool 이 넘긴 세 벌. 네 칸으로 열려 있는 동안만 있다.
+ private MergeToolRequest? _mergeTool;
+
+ ///
+ /// `git mergetool` 이 넘긴 BASE · LOCAL · REMOTE 를 네 칸으로 연다(6차 B-4 확인 중 발견).
+ ///
+ /// 예전에는 BASE 와 MERGED 두 칸으로만 열었다. MERGED 는 git 이 충돌 표시를 넣어 둔 파일이라
+ /// 상대(REMOTE)가 무엇을 바꿨는지 볼 수 없고, 충돌 표시를 손으로 지워 가며 풀어야 했다.
+ /// 이제는 저장소 안 충돌 파일과 같은 네 칸으로 열고, 결과(MERGED 칸)는 [저장] 으로 MERGED 파일에 쓴다.
+ /// MERGED 칸의 처음 내용은 git 이 넣은 충돌 표시가 아니라 LOCAL(내 쪽)이다 - 저장소 충돌 파일과 같다.
+ ///
+ private void OnMergeToolRequested(MergeToolRequest request)
+ {
+ if (!File.Exists(request.Local) && !File.Exists(request.Remote)) return;
+ if (!ConfirmDiscard($"병합할 파일을 엽니다: {Path.GetFileName(request.Merged)}")) return;
+
+ _suspendReload = true;
+ try
+ {
+ IsGitMode = false;
+ RemoteBranch = NoRemoteBranch;
+ }
+ finally
+ {
+ _suspendReload = false;
+ }
+
+ _mergeTool = request;
+ LeftFile = request.Base;
+ RightFile = request.Merged;
+ OnPropertyChanged(nameof(BaseFileLocation));
+ OnPropertyChanged(nameof(SelectedFileFullPath));
+
+ _ = LoadMergeToolAsync(request);
+ }
+
+ private async Task LoadMergeToolAsync(MergeToolRequest request)
+ {
+ string baseText = string.Empty, localText = string.Empty, remoteText = string.Empty;
+
+ if (!await _workspace.RunAsync("mergetool 파일 읽기", () =>
+ {
+ baseText = ReadIfExists(request.Base);
+ localText = ReadIfExists(request.Local);
+ remoteText = ReadIfExists(request.Remote);
+ }))
+ {
+ return;
+ }
+
+ _workspace.OnUi(() =>
+ {
+ // 읽는 사이에 다른 파일이나 저장소로 넘어갔으면 버린다.
+ if (IsGitMode || !ReferenceEquals(_mergeTool, request)) return;
+
+ IsBinaryFile = false;
+ IsImageFile = false;
+ HexRows.Clear();
+ BinarySummary = string.Empty;
+
+ // 행을 싣기 전에 틀부터 네 칸으로 바꾼다(LoadFileAsync 와 같은 까닭).
+ SetToolThreeWay(true);
+
+ _baseTextSnapshot = baseText;
+ _mineTextSnapshot = localText;
+ _remoteTextSnapshot = remoteText;
+ _appliedBaseNumbers.Clear();
+ _appliedMineNumbers.Clear();
+
+ RefreshLanguage();
+ var rows = BuildThreeWayLines(baseText, localText, remoteText);
+ Lines.Clear();
+ foreach (var row in rows) Lines.Add(row);
+
+ BaseHeaderLead = string.Empty;
+ LocalHeaderLead = string.Empty;
+ BaseHeader = string.IsNullOrEmpty(baseText)
+ ? "(BASE 없음) 양쪽에서 새로 만든 파일입니다"
+ : Path.GetFileName(request.Base);
+ LocalHeader = Path.GetFileName(request.Merged);
+ RemoteHeader = $"REMOTE · {Path.GetFileName(request.Remote)}";
+
+ _originalLocalText = BuildLocalText();
+ SyncEditorFromLines();
+
+ var info = TextFile.Describe(File.Exists(request.Local) ? request.Local : request.Remote);
+ FileEncoding = info.Encoding;
+ FileNewLine = info.NewLine;
+
+ _undo.Clear();
+ _redo.Clear();
+ HasChanges = false;
+ CaretRow = 0;
+ InvalidateDiffBlocks();
+
+ SelectedLine = Lines.FirstOrDefault(l => l.NeedsDecision)
+ ?? Lines.FirstOrDefault(l => l.IsDifferent)
+ ?? Lines.FirstOrDefault();
+
+ UpdateSummaries();
+ RefreshDiffPosition();
+ RaiseCanExecuteChanged();
+
+ _workspace.AppendLog($"mergetool 네 칸: 남은 충돌 {UnresolvedConflictCount}곳 · 결과는 {request.Merged}");
+ });
+ }
+
+ /// 없는 파일은 빈 글로 본다. git mergetool 은 조상이 없으면 BASE 에 빈 파일이나 없는 경로를 준다.
+ private static string ReadIfExists(string path) =>
+ !string.IsNullOrWhiteSpace(path) && File.Exists(path) ? TextFile.Read(path) : string.Empty;
+
+ ///
+ /// 파일 비교에서 쓰는 저장. mergetool 의 MERGED 파일이면 형식(인코딩 · 줄끝)을 LOCAL 파일에서 읽는다.
+ /// MERGED 는 git 이 충돌 표시를 넣어 둔 파일이라 거기서 읽으면 줄끝이 섞여 보인다(6차 A-1 과 같은 까닭).
+ ///
+ private void WritePlainFile(string target, string content, string? encoding, string? newLine)
+ {
+ if (_mergeTool is { } tool && File.Exists(tool.Local) &&
+ string.Equals(Path.GetFullPath(target), Path.GetFullPath(tool.Merged), StringComparison.OrdinalIgnoreCase))
+ {
+ File.WriteAllBytes(target, TextFile.Encode(content, encoding, newLine, File.ReadAllBytes(tool.Local)));
+ return;
+ }
+
+ TextFile.Write(target, content, encoding, newLine);
+ }
+
// ================= 데모 전환 (발표·교육용) =================
/// 설정에 데모 경로가 둘 다 적혀 있을 때만 전환 버튼을 띄운다.
@@ -1489,7 +1626,18 @@ namespace MergeHub.ViewModels
/// - 병합 중인 충돌 파일을 열었다(6차 A-3). 콤보를 안 건드려도 세 칸이 뜬다.
/// 예전에는 충돌 파일을 열어도 BASE 와 내 것 두 칸뿐이라, 상대가 무엇을 바꿨는지 모른 채 풀게 됐다.
///
- public bool IsThreeWay => IsGitMode && (_conflictThreeWay || IsBranchThreeWay);
+ /// Git 저장소가 아니면 git mergetool 이 세 벌을 넘겨 연 경우에만 참이다().
+ public bool IsThreeWay => IsGitMode ? _conflictThreeWay || IsBranchThreeWay : _toolThreeWay;
+
+ /// git mergetool 세 벌이라 켠 네 칸. 저장소 밖 파일이라 Git 쪽 두 까닭과 따로 둔다.
+ private bool _toolThreeWay;
+
+ private void SetToolThreeWay(bool on)
+ {
+ if (_toolThreeWay == on) return;
+ _toolThreeWay = on;
+ RaiseThreeWayShapeChanged();
+ }
/// 콤보에서 브랜치를 골라 켠 3-Way 인지. 충돌 때문에 켠 것은 뺀다.
private bool IsBranchThreeWay =>
@@ -1505,7 +1653,11 @@ namespace MergeHub.ViewModels
{
if (_conflictThreeWay == on) return;
_conflictThreeWay = on;
+ RaiseThreeWayShapeChanged();
+ }
+ private void RaiseThreeWayShapeChanged()
+ {
// 콤보로 바꿀 때(OnRemoteBranchChanged)와 같은 것들을 알린다 - 화면이 4분할로 바뀌고 제목·머리 높이도 바뀐다.
OnPropertyChanged(nameof(IsThreeWay));
OnPropertyChanged(nameof(IsTwoWay));
@@ -2188,6 +2340,10 @@ namespace MergeHub.ViewModels
// 없는 쪽은 빈 내용이 되어 전부 추가(또는 삭제)로 보인다.
if (string.IsNullOrWhiteSpace(LeftFile) && string.IsNullOrWhiteSpace(RightFile)) return;
+ // 파일 두 개 비교는 늘 두 칸이다. mergetool 네 칸에서 넘어왔으면 접는다.
+ _mergeTool = null;
+ SetToolThreeWay(false);
+
// 이진 파일은 줄 단위로 견줄 것이 없다. 깨진 글자로 화면을 채우느니 그렇다고 알린다.
if (TextFile.IsBinary(LeftFile) || TextFile.IsBinary(RightFile))
{
@@ -2297,7 +2453,12 @@ namespace MergeHub.ViewModels
return -1;
}
- private bool CanSavePlain() => IsPlainMode && HasChanges && !string.IsNullOrWhiteSpace(RightFile);
+ ///
+ /// 고친 것이 있으면 저장할 수 있다. mergetool 네 칸은 안 고쳤어도 충돌이 다 풀렸으면 저장할 수 있다 -
+ /// 상대 쪽이 고친 줄이 저절로 들어가 LOCAL 과 같은 글이 되는 경우가 있고, 그래도 MERGED 파일에는 써야 한다.
+ ///
+ private bool CanSavePlain() => IsPlainMode && !string.IsNullOrWhiteSpace(RightFile) &&
+ (HasChanges || (_toolThreeWay && UnresolvedConflictCount == 0));
/// Git 없이 쓸 때는 커밋 없이 오른쪽 파일에 그대로 쓴다.
[RelayCommand(CanExecute = nameof(CanSavePlain))]
@@ -2310,7 +2471,7 @@ namespace MergeHub.ViewModels
var newLine = SaveNewLine;
var ok = await _workspace.RunAsync("파일 저장",
- () => TextFile.Write(target, content, encoding, newLine));
+ () => WritePlainFile(target, content, encoding, newLine));
if (!ok) return;
_workspace.OnUi(() =>
@@ -2456,7 +2617,7 @@ namespace MergeHub.ViewModels
}
else
{
- TextFile.Write(target, content, SaveEncoding, SaveNewLine);
+ WritePlainFile(target, content, SaveEncoding, SaveNewLine);
}
_originalLocalText = content;
diff --git a/src/MergeHub/Views/HomeView.xaml.cs b/src/MergeHub/Views/HomeView.xaml.cs
index 3a25e29..35ba6a5 100644
--- a/src/MergeHub/Views/HomeView.xaml.cs
+++ b/src/MergeHub/Views/HomeView.xaml.cs
@@ -532,7 +532,11 @@ namespace MergeHub.Views
// 스크롤을 내리면 처음 그려진 행이 0행이 아니다. 그러면 "0번 글자(화면 위로 한참 올라간 자리)"와
// "화면 맨 위 행"을 비교하게 돼 이동값이 스크롤한 거리만큼 커지고, 표시층 글자가 통째로
// 위로 밀려 보인다 - 아래로 스크롤한 뒤 전체 되돌리기를 하면 실제로 그렇게 됐다.
- if (FindFirst(LocalLines) is not { } container) return;
+ //
+ // 또 칸 안에 보이는 행이어야 한다(6차 B-4 확인 중 발견). "처음 만들어진 행" 은 멀리 건너뛴 직후에
+ // 화면 밖에 남은 행일 수 있고, 편집기는 화면 밖 글자의 좌표를 어림으로 준다. 7,479줄 파일에서
+ // "마지막 차이" 로 가자 그 어림이 9줄쯤 틀려 네 칸이 통째로 위로 밀리고 아래가 비었다.
+ if (FirstVisibleContainer(LocalLines) is not { } container) return;
if (FindFirst(container) is not { } sample) return;
var row = LocalLines.ItemContainerGenerator.IndexFromContainer(container);
@@ -543,8 +547,12 @@ namespace MergeHub.Views
try
{
- var editorPoint = LocalEditor.TransformToVisual(this)
- .Transform(LocalEditor.GetRectFromCharacterIndex(charIndex).TopLeft);
+ var charRect = LocalEditor.GetRectFromCharacterIndex(charIndex);
+
+ // 편집기 화면 밖이면 어림값이다. 맞추지 않고 다음 배치 때 다시 본다.
+ if (charRect.IsEmpty || charRect.Top < -1 || charRect.Top > LocalEditor.ActualHeight) return;
+
+ var editorPoint = LocalEditor.TransformToVisual(this).Transform(charRect.TopLeft);
var decorationPoint = sample.TransformToVisual(this).Transform(new Point(0, 0));
@@ -574,6 +582,32 @@ namespace MergeHub.Views
}
}
+ ///
+ /// 목록 칸 안에서 위가 잘리지 않고 보이는 첫 행. 없으면 null.
+ /// 행 판(가상화 패널)의 자식만 훑는다 - 배치가 끝날 때마다 불리므로 시각 트리 전체를 뒤지면 무겁다.
+ /// 목록 자신에 걸린 이동값()은 목록 기준 좌표에 들어가지 않는다.
+ ///
+ private static ListBoxItem? FirstVisibleContainer(ListBox list)
+ {
+ if (FindFirst(list) is not { } panel) return null;
+
+ ListBoxItem? best = null;
+ var bestTop = double.MaxValue;
+
+ foreach (UIElement child in panel.Children)
+ {
+ if (child is not ListBoxItem item || !item.IsVisible) continue;
+
+ var top = item.TransformToAncestor(list).Transform(new Point(0, 0)).Y;
+ if (top < -0.5 || top >= list.ActualHeight || top >= bestTop) continue;
+
+ best = item;
+ bestTop = top;
+ }
+
+ return best;
+ }
+
private static T? FindFirst(DependencyObject root) where T : DependencyObject
{
if (root is T hit) return hit;