6차 B-4 발견 두 건: 멀리 건너뛰어도 네 칸이 안 밀리고 mergetool 세 벌을 네 칸으로 연다

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
김승준 2026-09-14 13:07:46 +09:00
parent ddce51d375
commit de097da2ff
7 changed files with 324 additions and 9 deletions

View File

@ -515,7 +515,72 @@ Consolas 12 는 한 글자 약 6.6px 라 20px 에는 세 자리만 들어간다.
빌드 경고 0 · 오류 0. 고친 파일은 XAML 뿐이라 검사 하네스는 영향이 없다. 빌드 경고 0 · 오류 0. 고친 파일은 XAML 뿐이라 검사 하네스는 영향이 없다.
### 확인하다 본 것 (고치지 않음) ### 확인하다 본 것 두 가지 — 아래 "B-4 확인 중 발견 두 건" 에서 고쳤다
- 3분할에서 Ctrl+End 를 누르면 캐럿이 파일 끝이 아니라 **마지막 충돌 줄**(2951 · 7470)에 서고, 네 칸 모두 **그 아래가 빈 채**로 그려진다. 고치기 전 화면에서도 같았다 — B-4 와 무관하다. - 3분할에서 Ctrl+End 를 누르면 네 칸 모두 몇 줄만 보이고 **그 아래가 빈 채**로 그려진다. 고치기 전 화면에서도 같았다.
- 저장소 밖 파일을 `--merge --base --local --remote --merged` 로 넘기면 네 칸이 아니라 BASE · LOCAL 두 칸으로 열린다. - 저장소 밖 파일을 `--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.

View File

@ -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-4** — 3분할 MERGED 칸 줄 번호 자리가 20px 라 2949 가 "294" 로 잘리던 것을 고침. 네 칸 여백을 54 → 66px 로 맞춰 넓혀 다섯 자리까지 보인다 | 아래 |
| 2026-09-14 | **6차 B-3** — 병합 커밋 기본 메시지를 `Merge branch '상대' into 내 브랜치` 로. 첫 파일 이름이 굳어 남던 것, 커밋에 `#Conflicts:` 줄 24개와 해시 첫 줄이 들어가던 것을 고침. 병합이 끝나면 스스로 채운 글만 지운다 | 아래 | | 2026-09-14 | **6차 B-3** — 병합 커밋 기본 메시지를 `Merge branch '상대' into 내 브랜치` 로. 첫 파일 이름이 굳어 남던 것, 커밋에 `#Conflicts:` 줄 24개와 해시 첫 줄이 들어가던 것을 고침. 병합이 끝나면 스스로 채운 글만 지운다 | 아래 |
| 2026-09-14 | **6차 B-2** — 파일을 바꿀 때 확인 창이 고친 파일 이름을 대고, [저장하고 계속] 이 엉뚱한 파일(새로 고른 파일)에 쓰던 것을 막음. [취소] 뒤 콤보가 제자리로. 저장소를 먼저 열면 목록이 0개이던 B-1 빈틈도 | 아래 | | 2026-09-14 | **6차 B-2** — 파일을 바꿀 때 확인 창이 고친 파일 이름을 대고, [저장하고 계속] 이 엉뚱한 파일(새로 고른 파일)에 쓰던 것을 막음. [취소] 뒤 콤보가 제자리로. 저장소를 먼저 열면 목록이 0개이던 B-1 빈틈도 | 아래 |

View File

@ -107,6 +107,7 @@
| **완료** | B-2 확인 창이 엉뚱한 파일 이름을 댄다 — 이름·[저장하고 계속] 모두 실린 파일 기준, [취소] 뒤 콤보도 돌아온다 | | **완료** | B-2 확인 창이 엉뚱한 파일 이름을 댄다 — 이름·[저장하고 계속] 모두 실린 파일 기준, [취소] 뒤 콤보도 돌아온다 |
| **완료** | B-3 커밋 메시지가 엉뚱하게 남는다 — 기본값 `Merge branch '상대' into 내 브랜치`, `#` 줄 뺌, 병합이 끝나면 칸을 비움 | | **완료** | B-3 커밋 메시지가 엉뚱하게 남는다 — 기본값 `Merge branch '상대' into 내 브랜치`, `#` 줄 뺌, 병합이 끝나면 칸을 비움 |
| **완료** | B-4 MERGED 칸 줄 번호가 네 자리에서 잘린다 — 네 칸 여백을 66px 로, MERGED 번호 자리 20 → 36px(다섯 자리) | | **완료** | B-4 MERGED 칸 줄 번호가 네 자리에서 잘린다 — 네 칸 여백을 66px 로, MERGED 번호 자리 20 → 36px(다섯 자리) |
| **완료** | B-4 확인 중 발견 두 건 — 멀리 건너뛰면 네 칸이 위로 밀려 아래가 빔 · `git mergetool` 세 벌이 두 칸으로 열림 |
| 대기 | B-5 머지 주석은 소스에 넣고, 병합 뒤에는 그 줄을 무시한다 | | 대기 | B-5 머지 주석은 소스에 넣고, 병합 뒤에는 그 줄을 무시한다 |
| 대기 | B-6 충돌 탭에서 하나씩 누를 때마다 오래 기다린다 | | 대기 | B-6 충돌 탭에서 하나씩 누를 때마다 오래 기다린다 |
| 대기 | B-7 상태바가 병합 중에 믿을 수 없는 값을 보인다 | | 대기 | B-7 상태바가 병합 중에 믿을 수 없는 값을 보인다 |

View File

@ -557,6 +557,34 @@ namespace MergeHub.Services
OpenConflictRequested.Invoke(path); OpenConflictRequested.Invoke(path);
} }
// ================= mergetool 세 벌을 네 칸으로 열기 =================
/// <summary>
/// "`git mergetool` 이 넘긴 세 벌을 네 칸으로 열어 달라". 켤 때 넘어온 인자가 보내고 파일 비교 탭이 받는다.
/// 파일 비교 화면은 탭을 처음 열 때 만들어지므로 다른 요청들처럼 남겨 둔다.
/// </summary>
public event Action<MergeToolRequest>? MergeToolRequested;
private MergeToolRequest? _pendingMergeTool;
public bool HasPendingMergeTool => _pendingMergeTool is not null;
public void RequestMergeTool(MergeToolRequest request)
{
_pendingMergeTool = request;
OnUi(DeliverPendingMergeTool);
}
/// <summary>남아 있는 요청을 넘긴다. 파일 비교 화면이 새로 만들어지면 스스로 한 번 불러 준다.</summary>
public void DeliverPendingMergeTool()
{
if (_pendingMergeTool is not { } request) return;
if (MergeToolRequested is null) return;
_pendingMergeTool = null;
MergeToolRequested.Invoke(request);
}
// ================= 켤 때 넘어온 인자 ================= // ================= 켤 때 넘어온 인자 =================
/// <summary>폴더 비교 화면이 아직 없을 때 남겨 두는 요청. 파일 비교 쪽과 같은 방식이다.</summary> /// <summary>폴더 비교 화면이 아직 없을 때 남겨 두는 요청. 파일 비교 쪽과 같은 방식이다.</summary>
@ -597,6 +625,15 @@ namespace MergeHub.Services
return; 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; if (!args.WantsFiles) return;
var (left, right) = args.DiffPair; var (left, right) = args.DiffPair;

View File

@ -159,9 +159,25 @@ namespace MergeHub.Services
} }
} }
/// <summary>
/// `git mergetool` 이 세 벌(BASE · LOCAL · REMOTE)을 넘겼으면 네 칸으로 열 요청. 아니면 null.
///
/// 예전에는 <see cref="DiffPair"/> 로 BASE 와 MERGED 두 칸만 열었다(6차 B-4 확인 중 발견).
/// MERGED 는 git 이 충돌 표시를 넣어 둔 파일이라 두 칸으로는 상대가 무엇을 바꿨는지 볼 수 없었다.
/// REMOTE 가 없으면 풀 것이 없으므로 세 벌로 보지 않는다. BASE 는 없어도 된다(양쪽이 새로 만든 파일).
/// MERGED 가 없으면 LOCAL 에 쓴다.
/// </summary>
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 WantsFolder => Mode == StartupMode.Folder && LeftFolder.Length > 0;
public bool WantsFiles => public bool WantsFiles =>
Mode is StartupMode.Diff or StartupMode.Merge && (DiffPair.Left.Length > 0 || DiffPair.Right.Length > 0); Mode is StartupMode.Diff or StartupMode.Merge && (DiffPair.Left.Length > 0 || DiffPair.Right.Length > 0);
} }
/// <summary>`git mergetool` 이 넘긴 세 벌과 결과를 쓸 파일.</summary>
public sealed record MergeToolRequest(string Base, string Local, string Remote, string Merged);
} }

View File

@ -37,6 +37,7 @@ namespace MergeHub.ViewModels
_workspace.StateChanged += RaiseCanExecuteChanged; _workspace.StateChanged += RaiseCanExecuteChanged;
_workspace.PlainCompareRequested += OnPlainCompareRequested; _workspace.PlainCompareRequested += OnPlainCompareRequested;
_workspace.OpenConflictRequested += OnOpenConflictRequested; _workspace.OpenConflictRequested += OnOpenConflictRequested;
_workspace.MergeToolRequested += OnMergeToolRequested;
_workspace.Recent.Changed += (_, _) => _workspace.OnUi(RefreshRecent); _workspace.Recent.Changed += (_, _) => _workspace.OnUi(RefreshRecent);
_workspace.QueueChanged += (_, _) => RefreshQueue(); _workspace.QueueChanged += (_, _) => RefreshQueue();
@ -61,6 +62,13 @@ namespace MergeHub.ViewModels
return; return;
} }
// git mergetool 이 세 벌을 넘겨 켠 경우. 두 칸 비교 요청보다 먼저 본다.
if (_workspace.HasPendingMergeTool)
{
_workspace.DeliverPendingMergeTool();
return;
}
// 폴더 비교에서 파일을 골라 이 화면이 만들어진 경우라면 그 파일을 먼저 연다. // 폴더 비교에서 파일을 골라 이 화면이 만들어진 경우라면 그 파일을 먼저 연다.
// 이 화면은 탭을 처음 열 때 만들어지므로, 요청이 우리보다 먼저 도착해 있을 수 있다. // 이 화면은 탭을 처음 열 때 만들어지므로, 요청이 우리보다 먼저 도착해 있을 수 있다.
if (_workspace.HasPendingPlainCompare) if (_workspace.HasPendingPlainCompare)
@ -125,6 +133,135 @@ namespace MergeHub.ViewModels
_ = LoadPlainFilesAsync(); _ = LoadPlainFilesAsync();
} }
// ================= git mergetool 세 벌 =================
/// <summary>git mergetool 이 넘긴 세 벌. 네 칸으로 열려 있는 동안만 있다.</summary>
private MergeToolRequest? _mergeTool;
/// <summary>
/// `git mergetool` 이 넘긴 BASE · LOCAL · REMOTE 를 네 칸으로 연다(6차 B-4 확인 중 발견).
///
/// 예전에는 BASE 와 MERGED 두 칸으로만 열었다. MERGED 는 git 이 충돌 표시를 넣어 둔 파일이라
/// 상대(REMOTE)가 무엇을 바꿨는지 볼 수 없고, 충돌 표시를 손으로 지워 가며 풀어야 했다.
/// 이제는 저장소 안 충돌 파일과 같은 네 칸으로 열고, 결과(MERGED 칸)는 [저장] 으로 MERGED 파일에 쓴다.
/// MERGED 칸의 처음 내용은 git 이 넣은 충돌 표시가 아니라 <b>LOCAL(내 쪽)</b>이다 - 저장소 충돌 파일과 같다.
/// </summary>
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}");
});
}
/// <summary>없는 파일은 빈 글로 본다. git mergetool 은 조상이 없으면 BASE 에 빈 파일이나 없는 경로를 준다.</summary>
private static string ReadIfExists(string path) =>
!string.IsNullOrWhiteSpace(path) && File.Exists(path) ? TextFile.Read(path) : string.Empty;
/// <summary>
/// 파일 비교에서 쓰는 저장. mergetool 의 MERGED 파일이면 <b>형식(인코딩 · 줄끝)을 LOCAL 파일에서</b> 읽는다.
/// MERGED 는 git 이 충돌 표시를 넣어 둔 파일이라 거기서 읽으면 줄끝이 섞여 보인다(6차 A-1 과 같은 까닭).
/// </summary>
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);
}
// ================= 데모 전환 (발표·교육용) ================= // ================= 데모 전환 (발표·교육용) =================
/// <summary>설정에 데모 경로가 둘 다 적혀 있을 때만 전환 버튼을 띄운다.</summary> /// <summary>설정에 데모 경로가 둘 다 적혀 있을 때만 전환 버튼을 띄운다.</summary>
@ -1489,7 +1626,18 @@ namespace MergeHub.ViewModels
/// - <b>병합 중인 충돌 파일을 열었다</b>(6차 A-3). 콤보를 안 건드려도 세 칸이 뜬다. /// - <b>병합 중인 충돌 파일을 열었다</b>(6차 A-3). 콤보를 안 건드려도 세 칸이 뜬다.
/// 예전에는 충돌 파일을 열어도 BASE 와 내 것 두 칸뿐이라, 상대가 무엇을 바꿨는지 모른 채 풀게 됐다. /// 예전에는 충돌 파일을 열어도 BASE 와 내 것 두 칸뿐이라, 상대가 무엇을 바꿨는지 모른 채 풀게 됐다.
/// </summary> /// </summary>
public bool IsThreeWay => IsGitMode && (_conflictThreeWay || IsBranchThreeWay); /// Git 저장소가 아니면 <b>git mergetool 이 세 벌을 넘겨 연 경우</b>에만 참이다(<see cref="_toolThreeWay"/>).
public bool IsThreeWay => IsGitMode ? _conflictThreeWay || IsBranchThreeWay : _toolThreeWay;
/// <summary>git mergetool 세 벌이라 켠 네 칸. 저장소 밖 파일이라 Git 쪽 두 까닭과 따로 둔다.</summary>
private bool _toolThreeWay;
private void SetToolThreeWay(bool on)
{
if (_toolThreeWay == on) return;
_toolThreeWay = on;
RaiseThreeWayShapeChanged();
}
/// <summary>콤보에서 브랜치를 골라 켠 3-Way 인지. 충돌 때문에 켠 것은 뺀다.</summary> /// <summary>콤보에서 브랜치를 골라 켠 3-Way 인지. 충돌 때문에 켠 것은 뺀다.</summary>
private bool IsBranchThreeWay => private bool IsBranchThreeWay =>
@ -1505,7 +1653,11 @@ namespace MergeHub.ViewModels
{ {
if (_conflictThreeWay == on) return; if (_conflictThreeWay == on) return;
_conflictThreeWay = on; _conflictThreeWay = on;
RaiseThreeWayShapeChanged();
}
private void RaiseThreeWayShapeChanged()
{
// 콤보로 바꿀 때(OnRemoteBranchChanged)와 같은 것들을 알린다 - 화면이 4분할로 바뀌고 제목·머리 높이도 바뀐다. // 콤보로 바꿀 때(OnRemoteBranchChanged)와 같은 것들을 알린다 - 화면이 4분할로 바뀌고 제목·머리 높이도 바뀐다.
OnPropertyChanged(nameof(IsThreeWay)); OnPropertyChanged(nameof(IsThreeWay));
OnPropertyChanged(nameof(IsTwoWay)); OnPropertyChanged(nameof(IsTwoWay));
@ -2188,6 +2340,10 @@ namespace MergeHub.ViewModels
// 없는 쪽은 빈 내용이 되어 전부 추가(또는 삭제)로 보인다. // 없는 쪽은 빈 내용이 되어 전부 추가(또는 삭제)로 보인다.
if (string.IsNullOrWhiteSpace(LeftFile) && string.IsNullOrWhiteSpace(RightFile)) return; if (string.IsNullOrWhiteSpace(LeftFile) && string.IsNullOrWhiteSpace(RightFile)) return;
// 파일 두 개 비교는 늘 두 칸이다. mergetool 네 칸에서 넘어왔으면 접는다.
_mergeTool = null;
SetToolThreeWay(false);
// 이진 파일은 줄 단위로 견줄 것이 없다. 깨진 글자로 화면을 채우느니 그렇다고 알린다. // 이진 파일은 줄 단위로 견줄 것이 없다. 깨진 글자로 화면을 채우느니 그렇다고 알린다.
if (TextFile.IsBinary(LeftFile) || TextFile.IsBinary(RightFile)) if (TextFile.IsBinary(LeftFile) || TextFile.IsBinary(RightFile))
{ {
@ -2297,7 +2453,12 @@ namespace MergeHub.ViewModels
return -1; return -1;
} }
private bool CanSavePlain() => IsPlainMode && HasChanges && !string.IsNullOrWhiteSpace(RightFile); /// <summary>
/// 고친 것이 있으면 저장할 수 있다. mergetool 네 칸은 <b>안 고쳤어도 충돌이 다 풀렸으면</b> 저장할 수 있다 -
/// 상대 쪽이 고친 줄이 저절로 들어가 LOCAL 과 같은 글이 되는 경우가 있고, 그래도 MERGED 파일에는 써야 한다.
/// </summary>
private bool CanSavePlain() => IsPlainMode && !string.IsNullOrWhiteSpace(RightFile) &&
(HasChanges || (_toolThreeWay && UnresolvedConflictCount == 0));
/// <summary>Git 없이 쓸 때는 커밋 없이 오른쪽 파일에 그대로 쓴다.</summary> /// <summary>Git 없이 쓸 때는 커밋 없이 오른쪽 파일에 그대로 쓴다.</summary>
[RelayCommand(CanExecute = nameof(CanSavePlain))] [RelayCommand(CanExecute = nameof(CanSavePlain))]
@ -2310,7 +2471,7 @@ namespace MergeHub.ViewModels
var newLine = SaveNewLine; var newLine = SaveNewLine;
var ok = await _workspace.RunAsync("파일 저장", var ok = await _workspace.RunAsync("파일 저장",
() => TextFile.Write(target, content, encoding, newLine)); () => WritePlainFile(target, content, encoding, newLine));
if (!ok) return; if (!ok) return;
_workspace.OnUi(() => _workspace.OnUi(() =>
@ -2456,7 +2617,7 @@ namespace MergeHub.ViewModels
} }
else else
{ {
TextFile.Write(target, content, SaveEncoding, SaveNewLine); WritePlainFile(target, content, SaveEncoding, SaveNewLine);
} }
_originalLocalText = content; _originalLocalText = content;

View File

@ -532,7 +532,11 @@ namespace MergeHub.Views
// 스크롤을 내리면 처음 그려진 행이 0행이 아니다. 그러면 "0번 글자(화면 위로 한참 올라간 자리)"와 // 스크롤을 내리면 처음 그려진 행이 0행이 아니다. 그러면 "0번 글자(화면 위로 한참 올라간 자리)"와
// "화면 맨 위 행"을 비교하게 돼 이동값이 스크롤한 거리만큼 커지고, 표시층 글자가 통째로 // "화면 맨 위 행"을 비교하게 돼 이동값이 스크롤한 거리만큼 커지고, 표시층 글자가 통째로
// 위로 밀려 보인다 - 아래로 스크롤한 뒤 전체 되돌리기를 하면 실제로 그렇게 됐다. // 위로 밀려 보인다 - 아래로 스크롤한 뒤 전체 되돌리기를 하면 실제로 그렇게 됐다.
if (FindFirst<ListBoxItem>(LocalLines) is not { } container) return; //
// 또 <b>칸 안에 보이는 행</b>이어야 한다(6차 B-4 확인 중 발견). "처음 만들어진 행" 은 멀리 건너뛴 직후에
// 화면 밖에 남은 행일 수 있고, 편집기는 화면 밖 글자의 좌표를 어림으로 준다. 7,479줄 파일에서
// "마지막 차이" 로 가자 그 어림이 9줄쯤 틀려 네 칸이 통째로 위로 밀리고 아래가 비었다.
if (FirstVisibleContainer(LocalLines) is not { } container) return;
if (FindFirst<Controls.CodeTextBlock>(container) is not { } sample) return; if (FindFirst<Controls.CodeTextBlock>(container) is not { } sample) return;
var row = LocalLines.ItemContainerGenerator.IndexFromContainer(container); var row = LocalLines.ItemContainerGenerator.IndexFromContainer(container);
@ -543,8 +547,12 @@ namespace MergeHub.Views
try try
{ {
var editorPoint = LocalEditor.TransformToVisual(this) var charRect = LocalEditor.GetRectFromCharacterIndex(charIndex);
.Transform(LocalEditor.GetRectFromCharacterIndex(charIndex).TopLeft);
// 편집기 화면 밖이면 어림값이다. 맞추지 않고 다음 배치 때 다시 본다.
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)); var decorationPoint = sample.TransformToVisual(this).Transform(new Point(0, 0));
@ -574,6 +582,32 @@ namespace MergeHub.Views
} }
} }
/// <summary>
/// 목록 칸 안에서 위가 잘리지 않고 보이는 첫 행. 없으면 null.
/// 행 판(가상화 패널)의 자식만 훑는다 - 배치가 끝날 때마다 불리므로 시각 트리 전체를 뒤지면 무겁다.
/// 목록 자신에 걸린 이동값(<see cref="_decorationShift"/>)은 목록 기준 좌표에 들어가지 않는다.
/// </summary>
private static ListBoxItem? FirstVisibleContainer(ListBox list)
{
if (FindFirst<VirtualizingStackPanel>(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<T>(DependencyObject root) where T : DependencyObject private static T? FindFirst<T>(DependencyObject root) where T : DependencyObject
{ {
if (root is T hit) return hit; if (root is T hit) return hit;