diff --git a/doc/history/2026-09-23-12차.md b/doc/history/2026-09-23-12차.md index 2c03df1..56b5ef9 100644 --- a/doc/history/2026-09-23-12차.md +++ b/doc/history/2026-09-23-12차.md @@ -2,6 +2,54 @@ 지시서: [../작업지시/12차.md](../작업지시/12차.md) +## 머지 주석 · 타이핑 속도 · 탭 알림 (2026-09-30 저녁, 사용자 요청 · 완료) + +### Accept 뒤 Alt+↓ 를 두 번 눌러야 다음 차이로 간다 (사용자 보고) + +재현(`g12-note`): Accept 직후 캐럿이 **새로 끼운 주석 줄**에 서서 첫 Alt+↓ 는 방금 반영한 줄로 갔다. 편집기는 글을 갈아 끼울 때 +캐럿을 글자 수 자리로 지키는데, 캐럿 위에 주석 한 줄이 끼면 그 자리가 주석 줄이 된다. +고침: 주석 줄을 끼우거나 빼기 **직전 캐럿 행(MergeLine)** 을 기억했다가(`NoteRowMoved` → `_caretAnchor`), 글을 갈아 끼운 뒤 +그 행이 몇 번째 편집기 줄이 됐는지(`EditorLayout.LineOfRow`)로 캐럿을 둔다(`TakeCaretLine`). 처음엔 "끼운 줄 수만큼 옮기기" 로 했는데 +지운 줄(편집기에 줄이 없는 행)에서 어긋났다. 또 **임시 기록으로 확인한 것**: 글을 막 갈아 끼운 순간 TextBox 의 줄 정보는 아직 **옛 글 기준**이다 +(새 글 7번 줄을 물으니 옛 글 주석 줄의 자리 · 길이가 왔다) → 줄 시작 자리는 새 글 문자열에서 직접 센다(`LineSpan`). 기록 코드는 뺐다. + +### 줄을 지우는 반영에도 주석 · 삭제 표시 (사용자 요청 · 표시 모양은 사용자 선택) + +BASE 에 없는 줄을 반영하면 LOCAL 에서 지우는데 주석이 남지 않았다(`Apply` 가 BASE 에 줄이 있을 때만 주석을 만들었다). 이제 지운 자리에도 남긴다 — +한 줄이면 `… BASE(a.cs) 삭제`, 구간 · 메서드로 여러 줄이면 한 번만 `… 삭제 N줄`(`MergeNote.DeletionTag` · `WithDeletion`). +왼쪽으로 반영(BASE 에서 지움) · 3-Way 저쪽 반영 · 구간/메서드 반영 모두. 묶음이 전부 지우는 반영이면 첫 줄 자리에. 들여쓰기는 지우기 전 글에서. +하네스 `NoteTest` 99 → 106건. `g12-note`: 삭제 주석 · 구간 반영(바뀐 1 + 지운 2) → 주석 한 줄에 `삭제 2줄` · Ctrl+Z 로 지운 줄과 주석이 함께 돌아감. + +### 색 구분 "줄 안에서 바뀐 부분" 에 줄 수 (사용자 요청) + +이 칸만 개수가 없었다. "BASE와 다름" 줄 가운데 단어 단위로 달라진 구간이 있는 줄 수(`InlineChangeCount`). `g14-inline`: 재료에서 1 → 전체 반영 뒤 0. + +### 파일 비교 타이핑이 느리다 · 엔터 · 들여쓰기는 더 느리다 (사용자 보고) + +임시 기록(`MERGEHUB_PERF_TRACE`, 끝나고 뺌)으로 1,806줄 C# 에서 사람 속도로 쳐서 쟀다(`g13-perf`). +글자 하나 처리는 1.4ms 로 빨랐고, **엔터 · 줄 합치는 지우기마다 도는 재정렬이 0.37~0.42초** 였다. 단계별로 쪼개 보니 +파일 비교(`BuildLines`)는 0.012초, **1,800줄을 목록에 한 줄씩 넣으며 알림이 1,800번 가는 것이 0.29~0.31초**, 비우기 0.04초. +1) `MergeLineCollection.ReplaceAll` — 한 번에 넣고 알림은 Reset 한 번 → 재정렬 0.10~0.14초. 그래도 재정렬 뒤 화면 다시 그리기가 0.15~0.18초 — + 다시 재 보니 색칠(CodeTextBlock) 0.02~0.04초 · 선 맞추기 0.0002초이고 **나머지는 칸 목록이 줄 요소를 새로 만드는 비용**(줄 객체가 모두 새것). + `VirtualizationMode=Recycling` 은 효과가 없어 되돌렸다. +2) `MergeLineCollection.Patch` — **번호 말고 모든 값이 같은 줄**(`MergeLine.SameStateExceptNumbers`: 글 · 있음/없음 · 단어 차이 · 반영/직접 수정/주석/무시 · + 옮김 · 책갈피 · 블록 주석 · 3-Way 칸 · 비교 규칙)은 기존 객체를 두고 번호만 고치고(`TakeNumbersFrom`), 가운데만 빼고 넣는다. 가운데가 200줄을 넘으면 통째로. + 값이 하나라도 다르면 새 객체 — 옛 객체에 값을 옮겨 적다 빠뜨리는 일이 없게. 재정렬 자리(`ReloadLinesFrom`)에만 쓰고, 파일을 여는 자리는 통째로. +결과(엔터 한 번): 재정렬 0.37~0.42초 → **0.037~0.05초**, 뒤 다시 그리기 0.15~0.2초 → **0.011~0.018초**, 다시 칠한 줄 약 290 → 0~8. +하네스 `PatchTest`(새) 16건 — 먼저 실패 확인. 화면 `g15-typing-tabs` ①: 엔터 · 공백 4 · 123 → 새 줄 제자리, LOCAL 번호 896 → 897 → 898, BASE 그대로. +**기존 회귀(22벌)는 사용자 지시로 돌리지 않았다** — "방금 요청한 것만". ReplaceAll 단계까지는 파일 비교 여덟 벌이 통과했다(Patch 전). + +### 탭이 20개를 넘어 조용히 닫힐 때 알림 (사용자 요청) + +로그 한 줄뿐이던 것에 알림(`ShowWarningToast`)을 더했다 — "탭이 20개를 넘어 오래 안 본 '…' 탭을 닫았습니다 (고친 것이 없던 탭)". +닫히는 규칙은 그대로(저장할 것 없는 탭 가운데 가장 오래 안 본 것, 고친 탭 · 보고 있는 탭은 안 닫음, 모두 고쳤으면 20을 넘겨서라도 연다). +`g15-typing-tabs` ②: t01~t21 을 차례로 열자 21번째에서 알림 · t01 이 닫히고 t02~t21 20개(사진). 처음 두 판은 대본이 1번을 열지 않아(켤 때 기본 짝이 탭이 되리라 가정) +20개에 그쳐 알림이 안 떴다 — 사용자가 화면을 보고 짚었다. 대본이 탭 줄 이름을 1개만 읽는 것은 읽기 한계라 사진으로 셌다. + +### 이 PC 메모 + +시험 중 앱이 빈 메모(`01.txt` 0바이트)와 `목록.txt` 를 지운 적이 있다 — 대본은 메모 폴더를 건드리지 않는다. 앱이 빈 메모를 정리한 것으로 보이나 값으로 확정하지 않았다. 끝에 기준본으로 되돌림. + ## 커밋 뒤 사용자 요청 셋 (2026-09-30 · 완료) ### 폴더 비교 → 두 번 눌러 파일 비교로 가도 하단 탭이 "폴더 비교" 에 남는다 (사용자 보고 · 플랫폼에서 고침) diff --git a/doc/history/changelog.md b/doc/history/changelog.md index b2767db..5141b38 100644 --- a/doc/history/changelog.md +++ b/doc/history/changelog.md @@ -8,6 +8,7 @@ | 날짜 | 항목 | 커밋 | |---|---|---| +| 2026-09-30 | **머지 주석 · 타이핑 속도 · 탭 알림(사용자 요청 · 보고)** — ① Accept 뒤 캐럿이 새 주석 줄에 서서 Alt+↓ 를 두 번 눌러야 했다 → 주석을 끼우기 직전 캐럿 행을 기억해 글을 갈아 끼운 뒤 그 행으로(글을 막 갈아 끼운 TextBox 의 줄 정보가 옛 글 기준이라는 것을 기록으로 확인 → 줄 자리는 글에서 직접 셈). ② 줄을 지우는 반영에도 주석 — 한 줄 `삭제`, 여러 줄 `삭제 N줄`(사용자 선택, 왼쪽 · 3-Way · 구간 · 메서드 모두). ③ 색 구분 "줄 안에서 바뀐 부분" 에 줄 수. ④ 타이핑: 1,806줄에서 재 보니 엔터마다 재정렬 0.4초의 대부분이 목록에 1,800줄을 한 줄씩 넣는 알림(파일 비교 자체는 0.012초) → `MergeLineCollection.ReplaceAll`(알림 한 번) + `Patch`(번호 말고 같은 줄은 기존 객체를 두고 번호만, 가운데만 빼고 넣기) → 엔터 한 번 약 0.55초 → 약 0.05초(뒤 다시 칠한 줄 290 → 0~8). ⑤ 탭이 20개를 넘어 조용히 닫히면 알림. 하네스 `NoteTest` 106 · `PatchTest`(새) 16, 화면 `g12-note` · `g14-inline` · `g15-typing-tabs` 통과. **기존 회귀는 사용자 지시로 돌리지 않음.** 빌드 경고 0 · 오류 0 | 아래 | | 2026-09-30 | **커밋 뒤 사용자 요청 셋** — ① 폴더 비교에서 두 번 눌러 파일 비교로 가도 하단 탭이 옛 탭에 남던 것: 사용자가 쓰던 앱을 읽기만 해서 재현(화면 파일 비교 · 선택 0번), 원인은 공용 플랫폼 셸의 `NavList` 가 `SelectedMainTab` 을 따라가지 않는 것 — 허락받아 `UbisamCommonPlatform` 에서 고침(MergeHub 코드 변경 없음), `g10-drop` 선택 [0] → [1]. ② Git 탭에 폴더 · 파일을 끌어다 놓으면 그 저장소 최상위를 연다(`PathUtil.RepositoryFolderFor`, `[폴더...]` 와 같은 규칙, 탭 전체 Preview 로 받아 TextBox 가 먹는 문제 회피). `PathTest` 62 → 68건(먼저 실패 확인). 흉내 낸 OLE 끌기는 이미 되는 폴더 비교 칸에도 안 들어가 도구 한계로 보고 사용자가 직접 확인(로그 저장소 열기 두 번). ③ 파일 비교 편집 칸 바탕을 회색(페이지 바탕이 비침) → 밝음 #FAFAFA · 어두움 #252830(순검정 · #1E1E1E 를 거쳐 후보를 보고 사용자가 고름, `PaneBackgroundConverter`). `g11-pane` 두 테마 화소로 확인. 빌드 경고 0 · 오류 0 | 아래 | | 2026-09-30 | **12차 G 묶음 마무리(알파 · 베타 전)** — ① 설정의 `프로그램 정보`(만든 사람 · 사내 문의처) 칸을 뺐다 — 개발자 정보가 담당을 보인다(사용자). [정보] 창은 실행 파일 값만 읽고(`ProgramInfoReader.For` 의 설정 인수를 걷음), 빈 칸 안내 글에서 없어진 설정 자리를 뺐다. ② 사용자 요청 "작성자를 입력하지 않으면 알람" — 주석 남기기가 켜져 있는데 작성자가 비면 **반영 명령 맨 앞**(여덟 곳)에서 알림 창을 띄우고 반영하지 않는다(사용자 선택, 줄마다가 아니라 명령마다 — [모두 반영] 이 줄 수만큼 알리지 않게). 예전에는 git 이름 · 윈도우 계정으로 조용히 채웠다. 미리보기도 그 이름을 안 보인다. 한때 "주석 늘 켬" 으로 바꿨다가 사용자 지시로 원복. ③ 데모(설정 두 칸 · [데모 →] 단추 · 전환 코드)를 걷어냄. 이 PC 의 시험 재료는 휴지통으로, 설정 · 최근 목록의 시험 경로를 비움. 하네스 `AboutTest` 30 · `NoteTest` 99 · `OverviewTest` 15건(둘은 먼저 고쳐 컴파일 실패 확인), 화면 `g5` · `g6` · `g9-author`(새) 실패 0. 빌드 경고 0 · 오류 0 | 아래 | | 2026-09-30 | **12차 G-6 고침** — 사용자 지시로 개발자 정보 담당 카드를 갈래 둘(`Platform` 최민영 대리 · `MergeHub` 김승준 사원, 이름표 영문)로 바꾸고 사내 문의처는 **카드에서만** 뺐다([정보] 창과 G-5 구조는 그대로 — 사용자 선택). 사람은 `ProgramOverview` 상수 둘 — 설정의 "만든 사람" 은 정보 창의 회사 이름 받침이라 섞지 않는다. 소개 문구를 `사내 Git Merge/Diff 도구` → **`사내 Merge/Diff + Git 도구`**(Merge 가 먼저)로 csproj `` · 개발자 정보 기본값 · 정보 창 받침 · 창 제목(`MergeHub - 사내 Merge/Diff + Git`) 네 곳을 맞춤 — 창 제목은 플랫폼이 표시에만 쓰는 것을 읽어 확인. 하네스 `OverviewTest` 기대값을 먼저 바꿔 4건 실패 → 15건 통과, `AboutTest` 30건, 화면 `g6-verify` · `g5-verify` 실패 0. 빌드 경고 0 · 오류 0 | 아래 | diff --git a/doc/진행상황.md b/doc/진행상황.md index 37580dd..d2d5883 100644 --- a/doc/진행상황.md +++ b/doc/진행상황.md @@ -446,6 +446,9 @@ | 상태 | 항목 | |---|---| +| **완료** | **파일 비교 타이핑 · 엔터 속도**(사용자 보고) — 재서 보니 엔터마다 도는 재정렬 0.4초의 대부분이 1,800줄을 목록에 한 줄씩 넣는 알림이었다. 한 번에 넣기(`ReplaceAll`) + 번호 말고 같은 줄은 기존 객체를 두고 가운데만 바꾸기(`Patch`)로 엔터 한 번 약 0.55초 → 약 0.05초. 하네스 `PatchTest` 16건, 화면 `g15` ① 새 줄 · 번호 제자리. 기존 회귀는 사용자 지시로 안 돌림 | +| **완료** | **머지 주석 두 가지**(사용자 보고) — Accept 뒤 캐럿이 새 주석 줄에 서서 Alt+↓ 를 두 번 눌러야 했던 것(캐럿을 누르기 전 행에 둔다), 줄을 지우는 반영에도 주석 + `삭제` · 여러 줄이면 `삭제 N줄`(사용자 선택). `NoteTest` 106건, 화면 `g12-note` 실패 0 | +| **완료** | **"줄 안에서 바뀐 부분" 줄 수 · 탭 20개 넘을 때 알림**(사용자 요청) — 색 구분에 개수(`g14-inline`), 오래 안 본 탭을 닫으면 알림(`g15` ②: 21번째에서 알림 · 20개 유지) | | **완료** | **하단 탭이 따라가지 않는다**(사용자 보고) — 폴더 비교에서 두 번 눌러 파일 비교로 가도 하단 탭이 폴더 비교에 남았다. 원인은 공용 플랫폼 셸(`NavList` 가 `SelectedMainTab` 을 따라가지 않음) — 허락받아 플랫폼에서 고침. 화면 `g10-drop` 선택 [0] → [1] | | **완료** | **Git 탭 끌어다 놓기**(사용자 요청) — 폴더 · 파일을 Git 탭 아무 데나 놓으면 그 저장소(최상위)를 연다. `[폴더...]` 와 같은 규칙(`PathUtil.RepositoryFolderFor`). 하네스 `PathTest` 62 → 68건. 흉내 낸 끌기는 도구 한계로 안 들어가 **사용자가 직접 확인**(로그의 저장소 열기 두 번) | | **완료** | **파일 비교 편집 칸 바탕**(사용자 요청) — 회색이던 칸을 밝음 #FAFAFA · 어두움 #252830 으로(후보를 보고 사용자가 고름). 화면 `g11-pane` 두 테마 화소로 확인 | diff --git a/src/MergeHub/Models/MergeLine.cs b/src/MergeHub/Models/MergeLine.cs index 9329c3d..75edee3 100644 --- a/src/MergeHub/Models/MergeLine.cs +++ b/src/MergeHub/Models/MergeLine.cs @@ -101,6 +101,42 @@ namespace MergeHub.Models private readonly Func? _normalize; + /// + /// 재정렬 뒤 이 줄 객체를 그대로 다시 써도 되는가(, 2026-09-30). + /// 번호(BASE · LOCAL) 말고 모든 값이 같아야 한다 - 글 · 있음/없음 · 단어 차이 · 반영/직접 수정/주석/무시 표시 · + /// 옮김 · 책갈피 · 블록 주석 이어받기 · 3-Way 칸 · 비교 규칙(정규화). 행 높이는 글이 같으면 같으므로 옛 값을 쓴다. + /// + public bool SameStateExceptNumbers(MergeLine o) => + Equals(_normalize, o._normalize) && + HasBase == o.HasBase && HasLocal == o.HasLocal && + string.Equals(BaseText, o.BaseText, StringComparison.Ordinal) && + string.Equals(LocalText, o.LocalText, StringComparison.Ordinal) && + SameWords(BaseWords, o.BaseWords) && SameWords(LocalWords, o.LocalWords) && SameWords(RemoteWords, o.RemoteWords) && + IsApplied == o.IsApplied && IsManuallyEdited == o.IsManuallyEdited && IsMergeNote == o.IsMergeNote && IsIgnored == o.IsIgnored && + AppliedNote == o.AppliedNote && Problem == o.Problem && + MoveHint == o.MoveHint && MoveGroup == o.MoveGroup && MoveFromBase == o.MoveFromBase && + IsBookmarked == o.IsBookmarked && + BaseInComment == o.BaseInComment && LocalInComment == o.LocalInComment && RemoteInComment == o.RemoteInComment && + IsThreeWay == o.IsThreeWay && HasRemote == o.HasRemote && RemoteNumber == o.RemoteNumber && + string.Equals(RemoteText, o.RemoteText, StringComparison.Ordinal) && + HasOriginalLocal == o.HasOriginalLocal && OriginalLocalNumber == o.OriginalLocalNumber && + string.Equals(OriginalLocalText, o.OriginalLocalText, StringComparison.Ordinal); + + /// 번호만 새 정렬 결과로. 나머지는 로 같다는 것을 확인한 뒤에만 부른다. + public void TakeNumbersFrom(MergeLine o) + { + BaseNumber = o.BaseNumber; + LocalNumber = o.LocalNumber; + } + + private static bool SameWords(IReadOnlyList? a, IReadOnlyList? b) + { + if (ReferenceEquals(a, b)) return true; + if (a is null || b is null || a.Count != b.Count) return false; + for (var i = 0; i < a.Count; i++) if (!Equals(a[i], b[i])) return false; + return true; + } + /// /// 무시 규칙까지 감안해 두 줄이 같은지. 사용자가 LOCAL 을 고쳐도 그때그때 다시 따진다. /// diff --git a/src/MergeHub/Models/MergeLineCollection.cs b/src/MergeHub/Models/MergeLineCollection.cs new file mode 100644 index 0000000..c766af5 --- /dev/null +++ b/src/MergeHub/Models/MergeLineCollection.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; + +namespace MergeHub.Models +{ + /// + /// 비교 화면의 정렬 행 목록. 로 통째로 갈아 끼우고 알림은 한 번만 보낸다. + /// + /// + /// 편집기에서 엔터를 치거나 손을 멈출 때마다 줄을 다시 정렬하는데, 예전에는 Clear 뒤 한 줄씩 Add 해서 + /// 1,800줄이면 "하나 추가됨" 알림이 1,800번 가고 칸 목록 넷이 알림마다 반응했다. 재서 보니 재정렬 한 번 0.37초 가운데 + /// 그 알림들이 0.33초였다(파일 비교 자체는 0.01초) - 엔터마다 화면이 0.4초씩 멈춰 "글자가 뒤따라오는" 까닭(2026-09-30). + /// + /// + public sealed class MergeLineCollection : ObservableCollection + { + /// 가운데(바뀐 줄) 이보다 많으면 줄마다 빼고 넣지 않고 통째로 갈아 끼운다 - 알림이 줄 수만큼 가면 도로 느려진다. + public const int PatchLimit = 200; + + /// + /// 새 정렬 결과를 넣되, 번호 말고는 모든 값이 같은 줄은 기존 객체를 그대로 두고 번호만 고친다(2026-09-30). + /// + /// + /// 통째로 갈아 끼우면() 줄 객체가 모두 새것이라 칸 목록이 보이는 줄 요소를 전부 다시 만든다 - + /// 재정렬 뒤 화면이 다시 그려지기까지 0.15초 가운데 0.12초가 그것이었다(색칠은 0.02초). 엔터를 치면 위 · 아래 줄은 + /// 그대로이고 끼운 자리 아래는 번호만 밀리므로, 앞 · 뒤로 같은 줄을 걸러 내고 가운데만 빼고 넣는다. + /// 값이 하나라도 다른 줄(반영 표시 · 단어 차이 · 규칙)은 새 객체로 바꾼다 - 옛 객체에 새 값을 옮겨 적다 하나라도 빠뜨리면 틀린 색이 남는다. + /// + /// + public void Patch(IList rows) + { + var oldCount = Count; + var newCount = rows.Count; + + var head = 0; + while (head < oldCount && head < newCount && this[head].SameStateExceptNumbers(rows[head])) head++; + + var tail = 0; + while (tail < oldCount - head && tail < newCount - head && + this[oldCount - 1 - tail].SameStateExceptNumbers(rows[newCount - 1 - tail])) tail++; + + var oldMiddle = oldCount - head - tail; + var newMiddle = newCount - head - tail; + if (oldMiddle + newMiddle > PatchLimit) + { + ReplaceAll(rows); + return; + } + + for (var i = 0; i < head; i++) this[i].TakeNumbersFrom(rows[i]); + for (var k = 0; k < tail; k++) this[oldCount - 1 - k].TakeNumbersFrom(rows[newCount - 1 - k]); + + for (var i = 0; i < oldMiddle; i++) RemoveAt(head); + for (var i = 0; i < newMiddle; i++) Insert(head + i, rows[head + i]); + } + + public void ReplaceAll(IEnumerable rows) + { + CheckReentrancy(); + Items.Clear(); + foreach (var row in rows) Items.Add(row); + + OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count))); + OnPropertyChanged(new PropertyChangedEventArgs("Item[]")); + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + } +} diff --git a/src/MergeHub/Services/MergeNote.cs b/src/MergeHub/Services/MergeNote.cs index c9ff00d..ae95717 100644 --- a/src/MergeHub/Services/MergeNote.cs +++ b/src/MergeHub/Services/MergeNote.cs @@ -31,6 +31,20 @@ namespace MergeHub.Services public static string AuthorOf(string? author) => string.IsNullOrWhiteSpace(author) ? Environment.UserName : author!.Trim(); + /// + /// 줄을 지우는 반영의 표시(사용자 결정 2026-09-30) - 한 줄이면 "삭제", 여러 줄이면 한 번만 "삭제 N줄". + /// 지운 자리에 남는 주석이 보통 반영과 똑같으면 무엇이 지워졌는지 모른다. 지운 게 없으면 빈 글. + /// + public static string DeletionTag(int deletedLines) => + deletedLines <= 0 ? string.Empty : deletedLines == 1 ? "삭제" : $"삭제 {deletedLines}줄"; + + /// 출처 뒤에 삭제 표시를 붙인다. 주석 모양은 [사람] 날짜 출처 삭제 [설명]. + public static string WithDeletion(string source, int deletedLines) + { + var tag = DeletionTag(deletedLines); + return tag.Length == 0 ? source : $"{source} {tag}"; + } + /// /// 반영하기 전에 막아야 하는가 - 주석을 남기게 해 두었는데 설정의 작성자가 비었다(사용자 결정 2026-09-30). /// 비었을 때 git 이름 · 윈도우 계정으로 조용히 채우면, 장비를 여럿이 쓰는 현장에서 누가 반영했는지 주석이 틀리게 남는다. diff --git a/src/MergeHub/ViewModels/HomeViewModel.Tabs.cs b/src/MergeHub/ViewModels/HomeViewModel.Tabs.cs index 4b49701..4c385b2 100644 --- a/src/MergeHub/ViewModels/HomeViewModel.Tabs.cs +++ b/src/MergeHub/ViewModels/HomeViewModel.Tabs.cs @@ -212,7 +212,11 @@ namespace MergeHub.ViewModels SyncTabItems(); if (Tabs.LastEvicted is { } gone) + { _workspace.AppendLog($"탭이 {DocTabs.Soft}개를 넘어 오래 안 본 탭을 닫음: {gone.Title}"); + // 로그 한 줄로는 모르고 지나간다(사용자 2026-09-30). 닫히는 탭은 늘 저장할 것이 없는 탭이다 - 그것도 함께 알린다. + MessageUtil.ShowWarningToast($"탭이 {DocTabs.Soft}개를 넘어 오래 안 본 '{gone.Title}' 탭을 닫았습니다 (고친 것이 없던 탭)"); + } SaveSession(); // 12차 E-1 } diff --git a/src/MergeHub/ViewModels/HomeViewModel.cs b/src/MergeHub/ViewModels/HomeViewModel.cs index 2dc2a9d..cf81de9 100644 --- a/src/MergeHub/ViewModels/HomeViewModel.cs +++ b/src/MergeHub/ViewModels/HomeViewModel.cs @@ -235,8 +235,7 @@ namespace MergeHub.ViewModels RefreshLanguage(); var rows = BuildThreeWayLines(baseText, localText, remoteText); - Lines.Clear(); - foreach (var row in rows) Lines.Add(row); + Lines.ReplaceAll(rows); // 알림 한 번(MergeLineCollection 참고) BaseHeaderLead = string.Empty; LocalHeaderLead = string.Empty; @@ -321,7 +320,7 @@ namespace MergeHub.ViewModels // ================= 3분할 상태 ================= /// BASE 와 LOCAL 을 같은 줄 번호로 묶은 행 목록. - public ObservableCollection Lines { get; } = new ObservableCollection(); + public MergeLineCollection Lines { get; } = new MergeLineCollection(); /// 비교 대상 파일 목록(충돌 중이면 충돌 파일, 아니면 선택 커밋의 파일). public ObservableCollection Files { get; } = new ObservableCollection(); @@ -836,13 +835,11 @@ namespace MergeHub.ViewModels bool afterHasBase; string? note = null; - if (line.HasLocal) - { - note = _workspace.Settings.InsertMergeComment && group == 0 - ? BuildMergeNote(MergeNames.LocalSource(LocalSourceName)) : null; - } - + // LOCAL 에 없던 줄(BASE 에서 지우는 반영)에도 주석을 남긴다 - 오른쪽 반영(Apply)과 같은 규칙(사용자 2026-09-30). (after, afterHasBase) = PaneText.Plan(line, MergeLine.TextPane.Base); + note = _workspace.Settings.InsertMergeComment && group == 0 + ? BuildMergeNote(MergeNote.WithDeletion(MergeNames.LocalSource(LocalSourceName), beforeHasBase && !afterHasBase ? 1 : 0)) + : null; if (beforeHasBase == afterHasBase && string.Equals(before, after, StringComparison.Ordinal)) return false; @@ -857,7 +854,7 @@ namespace MergeHub.ViewModels EditKind.TakeBase, description) { Side = MergeLine.TextPane.Base, - InsertedNote = InsertBaseNoteAbove(line, note), + InsertedNote = InsertBaseNoteAbove(line, note, indentFrom: before), Group = group, }); @@ -897,15 +894,18 @@ namespace MergeHub.ViewModels { var group = ++_lastGroup; var changed = 0; + var deleted = 0; // 주석에 "삭제 N줄" 로 한 번만 적는다(사용자 2026-09-30) int? first = null, last = null; foreach (var line in rows.ToList()) { if (line.IsMergeNote) continue; var number = line.HasLocal ? line.LocalNumber : line.BaseNumber; + var hadBase = line.HasBase; // group 이 0 이 아니므로 ApplyToBase 는 줄마다 주석을 달지 않는다 - 아래에서 한 줄만 단다. if (!line.CanTakeBase || !ApplyToBase(line, requery: false, group: group)) continue; + if (hadBase && !line.HasBase) deleted++; changed++; if (number is int n) { first ??= n; last = n; } } @@ -919,10 +919,12 @@ namespace MergeHub.ViewModels var where = first is null ? string.Empty : first == last ? $"{first}행 " : $"{first}~{last}행 "; var description = $"{where}{what} 왼쪽에 반영 ({changed}줄)"; - var at = rows.FirstOrDefault(r => r.HasLocal && !r.IsMergeNote && Lines.Contains(r)); + // 묶음이 모두 지우는 반영이면 글이 남은 줄이 없다 - 그래도 지운 자리(첫 줄)에 주석을 남긴다(사용자 2026-09-30). + var at = rows.FirstOrDefault(r => r.HasLocal && !r.IsMergeNote && Lines.Contains(r)) + ?? rows.FirstOrDefault(r => !r.IsMergeNote && Lines.Contains(r)); if (at is not null && _workspace.Settings.InsertMergeComment) { - var note = BuildMergeNote(MergeNames.LocalSource(LocalSourceName)); + var note = BuildMergeNote(MergeNote.WithDeletion(MergeNames.LocalSource(LocalSourceName), deleted)); _undo.Push(new LineEdit(at, at.BaseText, at.HasBase, at.BaseText, at.HasBase, EditKind.NoteOnly, description) { @@ -951,14 +953,15 @@ namespace MergeHub.ViewModels /// 왼쪽으로 반영한 줄 위에 넣을 머지 주석 한 줄. 오른쪽 것()의 거울이라 /// 왼쪽에만 있는 줄로 넣는다 - 오른쪽 파일에는 들어가지 않는다. /// - private MergeLine? InsertBaseNoteAbove(MergeLine line, string? note) + private MergeLine? InsertBaseNoteAbove(MergeLine line, string? note, string? indentFrom = null) { if (string.IsNullOrWhiteSpace(note)) return null; var at = Lines.IndexOf(line); if (at < 0) return null; - var indent = new string(' ', line.BaseText.Length - line.BaseText.TrimStart().Length); + // 지우는 반영이면 BASE 글이 이미 비었다 - 지우기 전 글이나 LOCAL 쪽 글에서 잰다. + var indent = IndentOf(line.BaseText, indentFrom, line.LocalText); var row = new MergeLine(indent + note, null, null, null, Rules.IsPlain ? null : Rules.KeyOf) { @@ -967,6 +970,7 @@ namespace MergeHub.ViewModels }; Lines.Insert(at, row); + NoteRowMoved(at, +1); return row; } @@ -1671,8 +1675,7 @@ namespace MergeHub.ViewModels SetDeletion(deletion, theirsName); OnPropertyChanged(nameof(ConflictSideText)); - Lines.Clear(); - foreach (var row in rows) Lines.Add(row); + Lines.ReplaceAll(rows); // 알림 한 번(MergeLineCollection 참고) // BASE 가 비어 있으면 그 파일이 공통 조상에 없었다는 뜻이다 - 양쪽 브랜치가 // 각자 새로 만든 파일이라 견줄 조상이 없다. 그냥 두면 빈 칸이 고장으로 보인다. @@ -2027,16 +2030,17 @@ namespace MergeHub.ViewModels bool afterHasLocal; string? note = null; - if (line.HasBase) - { - // 반영한 줄에는 누가·언제 반영했는지 주석을 남긴다. - // 주석은 줄 끝이 아니라 그 줄 "위"에 따로 들어간다 - 아래 InsertNoteAbove 참고. - note = _workspace.Settings.InsertMergeComment && group == 0 ? BuildMergeNote(MergeNames.BaseSource(_baseSourceName)) : null; - } + // 반영한 줄에는 누가·언제 반영했는지 주석을 남긴다. + // 주석은 줄 끝이 아니라 그 줄 "위"에 따로 들어간다 - 아래 InsertNoteAbove 참고. + // BASE 에 없던 줄(LOCAL 에서 지우는 반영)에도 남긴다 - 지운 자리에 누가 지웠는지가 남아야 한다(사용자 2026-09-30). + // 예전에는 BASE 에 줄이 있을 때만 남겨, 지운 반영은 흔적이 없었다. 지운 반영이면 출처 뒤에 "삭제". // 반영하면 그 칸이 어떻게 되는지는 양쪽이 같은 규칙을 쓴다(12차 B-1, PaneText). // BASE 에 없던 줄이면 LOCAL 에서도 지운다. (after, afterHasLocal) = PaneText.Plan(line, MergeLine.TextPane.Local); + note = _workspace.Settings.InsertMergeComment && group == 0 + ? BuildMergeNote(MergeNote.WithDeletion(MergeNames.BaseSource(_baseSourceName), beforeHasLocal && !afterHasLocal ? 1 : 0)) + : null; if (beforeHasLocal == afterHasLocal && string.Equals(before, after, StringComparison.Ordinal)) return false; @@ -2051,7 +2055,7 @@ namespace MergeHub.ViewModels _undo.Push(new LineEdit(line, before, beforeHasLocal, after, afterHasLocal, EditKind.TakeBase, description) { - InsertedNote = InsertNoteAbove(line, note), + InsertedNote = InsertNoteAbove(line, note, indentFrom: before), Group = group, }); @@ -2354,11 +2358,13 @@ namespace MergeHub.ViewModels bool afterHasLocal; string? note = null; + // 저쪽에 없던 줄(내 쪽에서 지우는 반영)에도 주석을 남긴다 - Apply 와 같은 규칙(사용자 2026-09-30). + note = _workspace.Settings.InsertMergeComment && group == 0 + ? BuildMergeNote(MergeNote.WithDeletion(MergeNames.TheirsSource(TheirsSideName, TakesMergeSides), + beforeHasLocal && !line.HasRemote ? 1 : 0)) + : null; if (line.HasRemote) { - note = _workspace.Settings.InsertMergeComment && group == 0 - ? BuildMergeNote(MergeNames.TheirsSource(TheirsSideName, TakesMergeSides)) - : null; after = line.RemoteText; afterHasLocal = true; } @@ -2382,7 +2388,7 @@ namespace MergeHub.ViewModels _undo.Push(new LineEdit(line, before, beforeHasLocal, after, afterHasLocal, EditKind.TakeBase, description) { - InsertedNote = InsertNoteAbove(line, note), + InsertedNote = InsertNoteAbove(line, note, indentFrom: before), Group = group, }); @@ -2406,7 +2412,7 @@ namespace MergeHub.ViewModels /// 정렬은 저절로 맞는다. 끼워 넣은 줄은 BASE 에 없는 "LOCAL 에만 있는 줄"이라, /// 이미 추가된 줄을 다루던 방식 그대로 BASE 쪽에 빈 자리(필러)가 생긴다. /// - private MergeLine? InsertNoteAbove(MergeLine line, string? note) + private MergeLine? InsertNoteAbove(MergeLine line, string? note, string? indentFrom = null) { if (string.IsNullOrWhiteSpace(note)) return null; @@ -2414,7 +2420,8 @@ namespace MergeHub.ViewModels if (at < 0) return null; // 코드 줄과 들여쓰기를 맞춘다. 주석만 왼쪽 끝에 붙어 있으면 눈에 거슬린다. - var indent = new string(' ', line.LocalText.Length - line.LocalText.TrimStart().Length); + // 지우는 반영이면 그 줄 글이 이미 비었으므로 지우기 전 글(indentFrom)이나 BASE 쪽 글에서 잰다. + var indent = IndentOf(line.LocalText, indentFrom, line.BaseText); var row = new MergeLine(null, null, indent + note, null, Rules.IsPlain ? null : Rules.KeyOf) @@ -2432,9 +2439,53 @@ namespace MergeHub.ViewModels if (line.IsThreeWay) row.SetThreeWay(null, null, null, null); Lines.Insert(at, row); + NoteRowMoved(at, +1); return row; } + /// 처음으로 글이 있는 쪽의 앞 공백. 모두 비었으면 들여쓰지 않는다. + private static string IndentOf(params string?[] candidates) + { + foreach (var text in candidates) + { + if (string.IsNullOrWhiteSpace(text)) continue; + return new string(' ', text!.Length - text.TrimStart().Length); + } + return string.Empty; + } + + /// + /// 주석 줄을 끼우거나 빼기 직전 캐럿이 있던 행(2026-09-30 사용자 보고 "Accept 뒤 Alt+↓ 를 두 번 눌러야 한다"). + /// + /// 편집기는 글을 갈아 끼울 때 캐럿을 글자 수 자리로 지킨다. 반영하면서 캐럿 위에 주석 한 줄이 끼면 + /// 같은 글자 수 자리는 그 주석 줄이 되고, 그러면 "다음 차이" 가 방금 반영한 줄을 다음 차이로 잡았다. + /// 끼운 줄 수만큼 옮기는 셈은 지운 줄(편집기에 줄이 없는 행)에서 어긋나서, 행 자체를 기억했다가 + /// 글을 갈아 끼운 뒤 그 행이 몇 번째 편집기 줄이 됐는지()로 캐럿을 둔다. + /// + private MergeLine? _caretAnchor; + + private void NoteRowMoved(int at, int delta) + { + // 처음 끼우거나 뺄 때만 잡는다 - 한 번 누른 반영이 주석을 여럿 넣어도 기준은 누르기 전 캐럿 행이다. + // 이미 바뀐 뒤라 캐럿 행 번호를 그만큼 되돌려 누르기 전 행을 찾는다. + if (_caretAnchor is not null) return; + var before = at <= CaretRow ? CaretRow + delta : CaretRow; + if (before >= 0 && before < Lines.Count) _caretAnchor = Lines[before]; + } + + /// + /// 편집기가 글을 갈아 끼운 뒤 캐럿을 둘 편집기 줄. 기억한 행이 없으면 -1(글자 수 자리를 그대로 지킨다). + /// 그 행이 LOCAL 에 없으면(지운 줄) 바로 다음 줄이다. 한 번 넘기면 비운다. + /// + public int TakeCaretLine() + { + var anchor = _caretAnchor; + _caretAnchor = null; + if (anchor is null) return -1; + var row = Lines.IndexOf(anchor); + return row < 0 ? -1 : EditorLayout.LineOfRow(_editorRows, row); + } + /// 반영한 줄 위에 넣을 주석 한 줄. [사용자] 날짜 출처 설명(출처는 11차 F-1). private string BuildMergeNote(string? source) => MergeNote.Build( author: _workspace.AuthorName, @@ -2689,7 +2740,6 @@ namespace MergeHub.ViewModels ? ChangedLocalNumbers(_originalLocalText, localText) : new HashSet(); - Lines.Clear(); foreach (var row in rows) { // 반영한 줄인지를 먼저 본다. @@ -2708,10 +2758,12 @@ namespace MergeHub.ViewModels if (!row.IsApplied && !row.IsMergeNote && row.LocalNumber is int number && touched.Contains(number)) row.IsManuallyEdited = true; - - Lines.Add(row); } + // 줄마다 넣으면 알림이 줄 수만큼 간다 - 표시를 다 매긴 뒤 한 번에 넣는다. 위 · 아래로 그대로인 줄은 기존 객체를 두고 + // 번호만 고치고, 바뀐 가운데만 빼고 넣는다 - 칸 목록이 보이는 줄 요소를 전부 다시 만들지 않게(MergeLineCollection.Patch). + Lines.Patch(rows); + if (keepAsEdited) { _undo.Clear(); _redo.Clear(); } // 위에서 글로 옮겨 이미 비었다 else ClearHistory(); @@ -3013,8 +3065,7 @@ namespace MergeHub.ViewModels RefreshLanguage(); var rows = BuildLines(baseText, localText); - Lines.Clear(); - foreach (var row in rows) Lines.Add(row); + Lines.ReplaceAll(rows); // 알림 한 번(MergeLineCollection 참고) SetPlainHeaders(); @@ -4015,14 +4066,21 @@ namespace MergeHub.ViewModels if (toBefore) { - Lines.Remove(note); + var was = Lines.IndexOf(note); + if (was >= 0) + { + Lines.RemoveAt(was); + NoteRowMoved(was, -1); // 되돌리기로 주석이 빠지면 캐럿도 한 줄 올라와야 같은 줄에 남는다 + } return; } if (Lines.Contains(note)) return; var at = Lines.IndexOf(edit.Line); - Lines.Insert(at < 0 ? Lines.Count : at, note); + if (at < 0) at = Lines.Count; + Lines.Insert(at, note); + NoteRowMoved(at, +1); } /// @@ -4295,17 +4353,20 @@ namespace MergeHub.ViewModels { var group = ++_lastGroup; var changed = 0; + var deleted = 0; // 주석에 "삭제 N줄" 로 한 번만 적는다(사용자 2026-09-30) int? first = null, last = null; foreach (var line in rows.ToList()) { if (line.IsMergeNote) continue; var number = IsThreeWay ? line.RemoteNumber : line.BaseNumber; + var hadLocal = line.HasLocal; var done = IsThreeWay ? line.CanTakeRemote && ApplyRemote(line, requery: false, group: group) : line.CanTakeBase && Apply(line, requery: false, group: group); if (!done) continue; + if (hadLocal && !line.HasLocal) deleted++; changed++; if (number is int n) { first ??= n; last = n; } } @@ -4322,12 +4383,14 @@ namespace MergeHub.ViewModels var where = first is null ? string.Empty : first == last ? $"{first}행 " : $"{first}~{last}행 "; var description = $"{where}{what} {source} 반영 ({changed}줄)"; + // 묶음이 모두 지우는 반영이면 글이 남은 줄이 없다 - 그래도 지운 자리(첫 줄)에 주석을 남긴다(사용자 2026-09-30). var at = anchor is { HasLocal: true } && Lines.Contains(anchor) ? anchor - : rows.FirstOrDefault(r => r.HasLocal && !r.IsMergeNote && Lines.Contains(r)); + : rows.FirstOrDefault(r => r.HasLocal && !r.IsMergeNote && Lines.Contains(r)) + ?? rows.FirstOrDefault(r => !r.IsMergeNote && Lines.Contains(r)); if (at is not null && _workspace.Settings.InsertMergeComment) { - var note = BuildMergeNote(source); + var note = BuildMergeNote(MergeNote.WithDeletion(source, deleted)); _undo.Push(new LineEdit(at, at.LocalText, at.HasLocal, at.LocalText, at.HasLocal, EditKind.NoteOnly, description) { @@ -4619,11 +4682,12 @@ namespace MergeHub.ViewModels private void UpdateLegendCounts() { int changed = 0, applied = 0, edited = 0, added = 0, removed = 0, moved = 0, ignored = 0, same = 0; - int remote = 0, conflict = 0, needsDecision = 0; + int remote = 0, conflict = 0, needsDecision = 0, inline = 0; foreach (var line in Lines) { if (line.NeedsDecision) needsDecision++; + if (HasInlineChange(line)) inline++; switch (line.Highlight) { @@ -4651,9 +4715,24 @@ namespace MergeHub.ViewModels RemoteChangeCount = remote; ConflictCount = conflict; UnresolvedConflictCount = needsDecision; + InlineChangeCount = inline; SaveResolvedCommand.NotifyCanExecuteChanged(); } + /// + /// 색 구분의 "줄 안에서 바뀐 부분" 줄 수(사용자 요청 2026-09-30 - 이 칸만 개수가 없었다). + /// 지금도 BASE 와 다른() 줄 가운데 단어 단위로 달라진 구간이 있는 줄 - 화면에서 그 색이 칠해지는 줄이다. + /// 반영 · 무시로 색이 바뀐 줄은 세지 않는다. + /// + [ObservableProperty] private int _inlineChangeCount; + + private static bool HasInlineChange(MergeLine line) => + line.Highlight == LineHighlight.Changed && + (HasChangedWord(line.BaseWords) || HasChangedWord(line.LocalWords)); + + private static bool HasChangedWord(IReadOnlyList? words) => + words is not null && words.Any(w => w.Kind != WordDiffKind.Unchanged); + /// /// MERGED 칸 번호를 결과 파일 번호로 다시 매긴다(8차 B-3). /// diff --git a/src/MergeHub/Views/HomeView.xaml b/src/MergeHub/Views/HomeView.xaml index b95ab40..a161fd5 100644 --- a/src/MergeHub/Views/HomeView.xaml +++ b/src/MergeHub/Views/HomeView.xaml @@ -899,7 +899,9 @@ - + + + diff --git a/src/MergeHub/Views/HomeView.xaml.cs b/src/MergeHub/Views/HomeView.xaml.cs index 96c5d83..6ca4149 100644 --- a/src/MergeHub/Views/HomeView.xaml.cs +++ b/src/MergeHub/Views/HomeView.xaml.cs @@ -977,6 +977,12 @@ namespace MergeHub.Views { var caret = LocalEditor.CaretIndex; + // 머지 주석 줄이 끼거나 빠졌으면 캐럿을 누르기 전과 같은 행에 둔다(2026-09-30). + // 글자 수로만 지키면 Accept 뒤 캐럿이 새 주석 줄에 서서, Alt+↓ 가 방금 반영한 줄을 "다음 차이" 로 잡았다. + var targetLine = (DataContext as HomeViewModel)?.TakeCaretLine() ?? -1; + var oldLine = targetLine >= 0 ? LocalEditor.GetLineIndexFromCharacterIndex(caret) : -1; + var caretColumn = oldLine >= 0 ? caret - LocalEditor.GetCharacterIndexFromLineIndex(oldLine) : 0; + // 글을 갈아 끼우면 TextChanged 가 도는데, 그걸 사용자의 편집으로 오해하면 // 다시 정렬이 끝없이 되풀이된다. _replacingText = true; @@ -989,9 +995,37 @@ namespace MergeHub.Views _replacingText = false; } + if (targetLine >= 0 && LineSpan(text, targetLine) is { } span) + { + LocalEditor.CaretIndex = span.Start + Math.Min(caretColumn, span.Length); + return; + } + LocalEditor.CaretIndex = Math.Min(caret, text.Length); } + /// + /// 글 안 번째 줄(0부터)의 시작 자리와 길이(줄끝 글자 빼고). 줄이 모자라면 마지막 줄. + /// 편집기에 묻지 않고 글에서 직접 센다 - 글을 막 갈아 끼운 순간 TextBox 의 줄 정보는 아직 옛 글 기준이다 + /// (기록으로 확인: 새 글 7번 줄을 물었더니 옛 글의 주석 줄 자리 · 길이가 돌아왔다). + /// + private static (int Start, int Length)? LineSpan(string text, int line) + { + if (text is null) return null; + var start = 0; + for (var i = 0; i < line; i++) + { + var next = text.IndexOf('\n', start); + if (next < 0) break; + start = next + 1; + } + var end = text.IndexOf('\n', start); + if (end < 0) end = text.Length; + var length = end - start; + if (length > 0 && text[end - 1] == '\r') length--; + return (start, length); + } + /// 사용자가 글자를 고쳤다. 무거운 일은 ViewModel 이 잠시 뒤에 한다. private void LocalEditor_TextChanged(object sender, TextChangedEventArgs e) {