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;