머지 주석 · 타이핑 속도 · 탭 알림 (사용자 요청 · 보고)

Accept 뒤 Alt+Down 을 두 번 눌러야 다음 차이로 가던 것 - 반영하며 캐럿 위에
주석 줄이 끼면 글자 수로 지킨 캐럿 자리가 그 주석 줄이 됐다. 주석을 끼우거나
빼기 직전 캐럿 행을 기억했다가, 글을 갈아 끼운 뒤 그 행의 편집기 줄에 캐럿을
둔다. 임시 기록으로 확인한 것: 글을 막 갈아 끼운 TextBox 의 줄 정보는 아직
옛 글 기준이다 - 줄 시작 자리는 새 글에서 직접 센다.

줄을 지우는 반영(BASE 에 없는 줄)에도 주석을 남긴다. 한 줄이면 "삭제",
구간 · 메서드로 여러 줄이면 한 번만 "삭제 N줄"(사용자 선택). 왼쪽으로 반영 ·
3-Way 저쪽 반영에도 같은 규칙.

색 구분 "줄 안에서 바뀐 부분" 에 줄 수를 붙였다(이 칸만 없었다).

타이핑: 1,806줄에서 재 보니 엔터마다 도는 재정렬 0.4초의 대부분이 목록에
1,800줄을 한 줄씩 넣으며 알림이 1,800번 가는 것이었다(파일 비교 자체 0.012초).
MergeLineCollection 으로 한 번에 넣고(ReplaceAll), 재정렬에서는 번호 말고 모든
값이 같은 줄은 기존 객체를 두고 번호만 고쳐 가운데만 빼고 넣는다(Patch,
MergeLine.SameStateExceptNumbers). 엔터 한 번 약 0.55초 -> 약 0.05초.
화면 요소 재활용(Recycling)은 효과가 없어 넣지 않았다.

탭이 20개를 넘어 오래 안 본 탭이 조용히 닫히면 알림을 띄운다.

시험: 빌드 경고 0 · 오류 0. 하네스 NoteTest 106 · PatchTest(새) 16.
화면 g12-note(주석 · 캐럿 · 되돌리기) · g14-inline · g15-typing-tabs(새 줄
제자리 · 번호 896 -> 897 -> 898, 21번째 탭에서 알림 · 20개 유지) 통과.
기존 회귀는 사용자 지시("방금 요청한 것만")로 돌리지 않았다.

Agent: main (claude-opus-5-5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
김승준 2026-09-30 18:27:47 +09:00
parent 4d0bc5c7a7
commit 0530832e79
10 changed files with 331 additions and 40 deletions

View File

@ -2,6 +2,54 @@
지시서: [../작업지시/12차.md](../작업지시/12차.md) 지시서: [../작업지시/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 · 완료) ## 커밋 뒤 사용자 요청 셋 (2026-09-30 · 완료)
### 폴더 비교 → 두 번 눌러 파일 비교로 가도 하단 탭이 "폴더 비교" 에 남는다 (사용자 보고 · 플랫폼에서 고침) ### 폴더 비교 → 두 번 눌러 파일 비교로 가도 하단 탭이 "폴더 비교" 에 남는다 (사용자 보고 · 플랫폼에서 고침)

View File

@ -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 | **커밋 뒤 사용자 요청 셋** — ① 폴더 비교에서 두 번 눌러 파일 비교로 가도 하단 탭이 옛 탭에 남던 것: 사용자가 쓰던 앱을 읽기만 해서 재현(화면 파일 비교 · 선택 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 묶음 마무리(알파 · 베타 전)** — ① 설정의 `프로그램 정보`(만든 사람 · 사내 문의처) 칸을 뺐다 — 개발자 정보가 담당을 보인다(사용자). [정보] 창은 실행 파일 값만 읽고(`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 `<Description>` · 개발자 정보 기본값 · 정보 창 받침 · 창 제목(`MergeHub - 사내 Merge/Diff + Git`) 네 곳을 맞춤 — 창 제목은 플랫폼이 표시에만 쓰는 것을 읽어 확인. 하네스 `OverviewTest` 기대값을 먼저 바꿔 4건 실패 → 15건 통과, `AboutTest` 30건, 화면 `g6-verify` · `g5-verify` 실패 0. 빌드 경고 0 · 오류 0 | 아래 | | 2026-09-30 | **12차 G-6 고침** — 사용자 지시로 개발자 정보 담당 카드를 갈래 둘(`Platform` 최민영 대리 · `MergeHub` 김승준 사원, 이름표 영문)로 바꾸고 사내 문의처는 **카드에서만** 뺐다([정보] 창과 G-5 구조는 그대로 — 사용자 선택). 사람은 `ProgramOverview` 상수 둘 — 설정의 "만든 사람" 은 정보 창의 회사 이름 받침이라 섞지 않는다. 소개 문구를 `사내 Git Merge/Diff 도구` → **`사내 Merge/Diff + Git 도구`**(Merge 가 먼저)로 csproj `<Description>` · 개발자 정보 기본값 · 정보 창 받침 · 창 제목(`MergeHub - 사내 Merge/Diff + Git`) 네 곳을 맞춤 — 창 제목은 플랫폼이 표시에만 쓰는 것을 읽어 확인. 하네스 `OverviewTest` 기대값을 먼저 바꿔 4건 실패 → 15건 통과, `AboutTest` 30건, 화면 `g6-verify` · `g5-verify` 실패 0. 빌드 경고 0 · 오류 0 | 아래 |

View File

@ -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] | | **완료** | **하단 탭이 따라가지 않는다**(사용자 보고) — 폴더 비교에서 두 번 눌러 파일 비교로 가도 하단 탭이 폴더 비교에 남았다. 원인은 공용 플랫폼 셸(`NavList` 가 `SelectedMainTab` 을 따라가지 않음) — 허락받아 플랫폼에서 고침. 화면 `g10-drop` 선택 [0] → [1] |
| **완료** | **Git 탭 끌어다 놓기**(사용자 요청) — 폴더 · 파일을 Git 탭 아무 데나 놓으면 그 저장소(최상위)를 연다. `[폴더...]` 와 같은 규칙(`PathUtil.RepositoryFolderFor`). 하네스 `PathTest` 62 → 68건. 흉내 낸 끌기는 도구 한계로 안 들어가 **사용자가 직접 확인**(로그의 저장소 열기 두 번) | | **완료** | **Git 탭 끌어다 놓기**(사용자 요청) — 폴더 · 파일을 Git 탭 아무 데나 놓으면 그 저장소(최상위)를 연다. `[폴더...]` 와 같은 규칙(`PathUtil.RepositoryFolderFor`). 하네스 `PathTest` 62 → 68건. 흉내 낸 끌기는 도구 한계로 안 들어가 **사용자가 직접 확인**(로그의 저장소 열기 두 번) |
| **완료** | **파일 비교 편집 칸 바탕**(사용자 요청) — 회색이던 칸을 밝음 #FAFAFA · 어두움 #252830 으로(후보를 보고 사용자가 고름). 화면 `g11-pane` 두 테마 화소로 확인 | | **완료** | **파일 비교 편집 칸 바탕**(사용자 요청) — 회색이던 칸을 밝음 #FAFAFA · 어두움 #252830 으로(후보를 보고 사용자가 고름). 화면 `g11-pane` 두 테마 화소로 확인 |

View File

@ -101,6 +101,42 @@ namespace MergeHub.Models
private readonly Func<string, string>? _normalize; private readonly Func<string, string>? _normalize;
/// <summary>
/// 재정렬 뒤 이 줄 객체를 그대로 다시 써도 되는가(<see cref="MergeLineCollection.Patch"/>, 2026-09-30).
/// <b>번호(BASE · LOCAL) 말고 모든 값</b>이 같아야 한다 - 글 · 있음/없음 · 단어 차이 · 반영/직접 수정/주석/무시 표시 ·
/// 옮김 · 책갈피 · 블록 주석 이어받기 · 3-Way 칸 · 비교 규칙(정규화). 행 높이는 글이 같으면 같으므로 옛 값을 쓴다.
/// </summary>
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);
/// <summary>번호만 새 정렬 결과로. 나머지는 <see cref="SameStateExceptNumbers"/> 로 같다는 것을 확인한 뒤에만 부른다.</summary>
public void TakeNumbersFrom(MergeLine o)
{
BaseNumber = o.BaseNumber;
LocalNumber = o.LocalNumber;
}
private static bool SameWords(IReadOnlyList<WordDiffSegment>? a, IReadOnlyList<WordDiffSegment>? 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;
}
/// <summary> /// <summary>
/// 무시 규칙까지 감안해 두 줄이 같은지. 사용자가 LOCAL 을 고쳐도 그때그때 다시 따진다. /// 무시 규칙까지 감안해 두 줄이 같은지. 사용자가 LOCAL 을 고쳐도 그때그때 다시 따진다.
/// </summary> /// </summary>

View File

@ -0,0 +1,70 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
namespace MergeHub.Models
{
/// <summary>
/// 비교 화면의 정렬 행 목록. <see cref="ReplaceAll"/> 로 <b>통째로 갈아 끼우고 알림은 한 번</b>만 보낸다.
///
/// <para>
/// 편집기에서 엔터를 치거나 손을 멈출 때마다 줄을 다시 정렬하는데, 예전에는 <c>Clear</c> 뒤 한 줄씩 <c>Add</c> 해서
/// 1,800줄이면 "하나 추가됨" 알림이 1,800번 가고 칸 목록 넷이 알림마다 반응했다. 재서 보니 재정렬 한 번 0.37초 가운데
/// <b>그 알림들이 0.33초</b>였다(파일 비교 자체는 0.01초) - 엔터마다 화면이 0.4초씩 멈춰 "글자가 뒤따라오는" 까닭(2026-09-30).
/// </para>
/// </summary>
public sealed class MergeLineCollection : ObservableCollection<MergeLine>
{
/// <summary>가운데(바뀐 줄) 이보다 많으면 줄마다 빼고 넣지 않고 통째로 갈아 끼운다 - 알림이 줄 수만큼 가면 도로 느려진다.</summary>
public const int PatchLimit = 200;
/// <summary>
/// 새 정렬 결과를 넣되, <b>번호 말고는 모든 값이 같은 줄</b>은 기존 객체를 그대로 두고 번호만 고친다(2026-09-30).
///
/// <para>
/// 통째로 갈아 끼우면(<see cref="ReplaceAll"/>) 줄 객체가 모두 새것이라 칸 목록이 보이는 줄 요소를 전부 다시 만든다 -
/// 재정렬 뒤 화면이 다시 그려지기까지 0.15초 가운데 0.12초가 그것이었다(색칠은 0.02초). 엔터를 치면 위 · 아래 줄은
/// 그대로이고 끼운 자리 아래는 번호만 밀리므로, 앞 · 뒤로 같은 줄을 걸러 내고 <b>가운데만</b> 빼고 넣는다.
/// 값이 하나라도 다른 줄(반영 표시 · 단어 차이 · 규칙)은 새 객체로 바꾼다 - 옛 객체에 새 값을 옮겨 적다 하나라도 빠뜨리면 틀린 색이 남는다.
/// </para>
/// </summary>
public void Patch(IList<MergeLine> 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<MergeLine> 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));
}
}
}

View File

@ -31,6 +31,20 @@ namespace MergeHub.Services
public static string AuthorOf(string? author) => public static string AuthorOf(string? author) =>
string.IsNullOrWhiteSpace(author) ? Environment.UserName : author!.Trim(); string.IsNullOrWhiteSpace(author) ? Environment.UserName : author!.Trim();
/// <summary>
/// 줄을 지우는 반영의 표시(사용자 결정 2026-09-30) - 한 줄이면 "삭제", 여러 줄이면 한 번만 "삭제 N줄".
/// 지운 자리에 남는 주석이 보통 반영과 똑같으면 무엇이 지워졌는지 모른다. 지운 게 없으면 빈 글.
/// </summary>
public static string DeletionTag(int deletedLines) =>
deletedLines <= 0 ? string.Empty : deletedLines == 1 ? "삭제" : $"삭제 {deletedLines}줄";
/// <summary>출처 뒤에 삭제 표시를 붙인다. 주석 모양은 <c>[사람] 날짜 출처 삭제 [설명]</c>.</summary>
public static string WithDeletion(string source, int deletedLines)
{
var tag = DeletionTag(deletedLines);
return tag.Length == 0 ? source : $"{source} {tag}";
}
/// <summary> /// <summary>
/// 반영하기 전에 막아야 하는가 - 주석을 남기게 해 두었는데 설정의 작성자가 비었다(사용자 결정 2026-09-30). /// 반영하기 전에 막아야 하는가 - 주석을 남기게 해 두었는데 설정의 작성자가 비었다(사용자 결정 2026-09-30).
/// 비었을 때 git 이름 · 윈도우 계정으로 조용히 채우면, 장비를 여럿이 쓰는 현장에서 누가 반영했는지 주석이 틀리게 남는다. /// 비었을 때 git 이름 · 윈도우 계정으로 조용히 채우면, 장비를 여럿이 쓰는 현장에서 누가 반영했는지 주석이 틀리게 남는다.

View File

@ -212,7 +212,11 @@ namespace MergeHub.ViewModels
SyncTabItems(); SyncTabItems();
if (Tabs.LastEvicted is { } gone) if (Tabs.LastEvicted is { } gone)
{
_workspace.AppendLog($"탭이 {DocTabs.Soft}개를 넘어 오래 안 본 탭을 닫음: {gone.Title}"); _workspace.AppendLog($"탭이 {DocTabs.Soft}개를 넘어 오래 안 본 탭을 닫음: {gone.Title}");
// 로그 한 줄로는 모르고 지나간다(사용자 2026-09-30). 닫히는 탭은 늘 저장할 것이 없는 탭이다 - 그것도 함께 알린다.
MessageUtil.ShowWarningToast($"탭이 {DocTabs.Soft}개를 넘어 오래 안 본 '{gone.Title}' 탭을 닫았습니다 (고친 것이 없던 탭)");
}
SaveSession(); // 12차 E-1 SaveSession(); // 12차 E-1
} }

View File

@ -235,8 +235,7 @@ namespace MergeHub.ViewModels
RefreshLanguage(); RefreshLanguage();
var rows = BuildThreeWayLines(baseText, localText, remoteText); var rows = BuildThreeWayLines(baseText, localText, remoteText);
Lines.Clear(); Lines.ReplaceAll(rows); // 알림 한 번(MergeLineCollection 참고)
foreach (var row in rows) Lines.Add(row);
BaseHeaderLead = string.Empty; BaseHeaderLead = string.Empty;
LocalHeaderLead = string.Empty; LocalHeaderLead = string.Empty;
@ -321,7 +320,7 @@ namespace MergeHub.ViewModels
// ================= 3분할 상태 ================= // ================= 3분할 상태 =================
/// <summary>BASE 와 LOCAL 을 같은 줄 번호로 묶은 행 목록.</summary> /// <summary>BASE 와 LOCAL 을 같은 줄 번호로 묶은 행 목록.</summary>
public ObservableCollection<MergeLine> Lines { get; } = new ObservableCollection<MergeLine>(); public MergeLineCollection Lines { get; } = new MergeLineCollection();
/// <summary>비교 대상 파일 목록(충돌 중이면 충돌 파일, 아니면 선택 커밋의 파일).</summary> /// <summary>비교 대상 파일 목록(충돌 중이면 충돌 파일, 아니면 선택 커밋의 파일).</summary>
public ObservableCollection<string> Files { get; } = new ObservableCollection<string>(); public ObservableCollection<string> Files { get; } = new ObservableCollection<string>();
@ -836,13 +835,11 @@ namespace MergeHub.ViewModels
bool afterHasBase; bool afterHasBase;
string? note = null; string? note = null;
if (line.HasLocal) // LOCAL 에 없던 줄(BASE 에서 지우는 반영)에도 주석을 남긴다 - 오른쪽 반영(Apply)과 같은 규칙(사용자 2026-09-30).
{
note = _workspace.Settings.InsertMergeComment && group == 0
? BuildMergeNote(MergeNames.LocalSource(LocalSourceName)) : null;
}
(after, afterHasBase) = PaneText.Plan(line, MergeLine.TextPane.Base); (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)) if (beforeHasBase == afterHasBase && string.Equals(before, after, StringComparison.Ordinal))
return false; return false;
@ -857,7 +854,7 @@ namespace MergeHub.ViewModels
EditKind.TakeBase, description) EditKind.TakeBase, description)
{ {
Side = MergeLine.TextPane.Base, Side = MergeLine.TextPane.Base,
InsertedNote = InsertBaseNoteAbove(line, note), InsertedNote = InsertBaseNoteAbove(line, note, indentFrom: before),
Group = group, Group = group,
}); });
@ -897,15 +894,18 @@ namespace MergeHub.ViewModels
{ {
var group = ++_lastGroup; var group = ++_lastGroup;
var changed = 0; var changed = 0;
var deleted = 0; // 주석에 "삭제 N줄" 로 한 번만 적는다(사용자 2026-09-30)
int? first = null, last = null; int? first = null, last = null;
foreach (var line in rows.ToList()) foreach (var line in rows.ToList())
{ {
if (line.IsMergeNote) continue; if (line.IsMergeNote) continue;
var number = line.HasLocal ? line.LocalNumber : line.BaseNumber; var number = line.HasLocal ? line.LocalNumber : line.BaseNumber;
var hadBase = line.HasBase;
// group 이 0 이 아니므로 ApplyToBase 는 줄마다 주석을 달지 않는다 - 아래에서 한 줄만 단다. // group 이 0 이 아니므로 ApplyToBase 는 줄마다 주석을 달지 않는다 - 아래에서 한 줄만 단다.
if (!line.CanTakeBase || !ApplyToBase(line, requery: false, group: group)) continue; if (!line.CanTakeBase || !ApplyToBase(line, requery: false, group: group)) continue;
if (hadBase && !line.HasBase) deleted++;
changed++; changed++;
if (number is int n) { first ??= n; last = n; } 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 where = first is null ? string.Empty : first == last ? $"{first}행 " : $"{first}~{last}행 ";
var description = $"{where}{what} 왼쪽에 반영 ({changed}줄)"; 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) 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, _undo.Push(new LineEdit(at, at.BaseText, at.HasBase, at.BaseText, at.HasBase,
EditKind.NoteOnly, description) EditKind.NoteOnly, description)
{ {
@ -951,14 +953,15 @@ namespace MergeHub.ViewModels
/// 왼쪽으로 반영한 줄 <b>위에</b> 넣을 머지 주석 한 줄. 오른쪽 것(<see cref="InsertNoteAbove"/>)의 거울이라 /// 왼쪽으로 반영한 줄 <b>위에</b> 넣을 머지 주석 한 줄. 오른쪽 것(<see cref="InsertNoteAbove"/>)의 거울이라
/// <b>왼쪽에만 있는 줄</b>로 넣는다 - 오른쪽 파일에는 들어가지 않는다. /// <b>왼쪽에만 있는 줄</b>로 넣는다 - 오른쪽 파일에는 들어가지 않는다.
/// </summary> /// </summary>
private MergeLine? InsertBaseNoteAbove(MergeLine line, string? note) private MergeLine? InsertBaseNoteAbove(MergeLine line, string? note, string? indentFrom = null)
{ {
if (string.IsNullOrWhiteSpace(note)) return null; if (string.IsNullOrWhiteSpace(note)) return null;
var at = Lines.IndexOf(line); var at = Lines.IndexOf(line);
if (at < 0) return null; 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) 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); Lines.Insert(at, row);
NoteRowMoved(at, +1);
return row; return row;
} }
@ -1671,8 +1675,7 @@ namespace MergeHub.ViewModels
SetDeletion(deletion, theirsName); SetDeletion(deletion, theirsName);
OnPropertyChanged(nameof(ConflictSideText)); OnPropertyChanged(nameof(ConflictSideText));
Lines.Clear(); Lines.ReplaceAll(rows); // 알림 한 번(MergeLineCollection 참고)
foreach (var row in rows) Lines.Add(row);
// BASE 가 비어 있으면 <b>그 파일이 공통 조상에 없었다</b>는 뜻이다 - 양쪽 브랜치가 // BASE 가 비어 있으면 <b>그 파일이 공통 조상에 없었다</b>는 뜻이다 - 양쪽 브랜치가
// 각자 새로 만든 파일이라 견줄 조상이 없다. 그냥 두면 빈 칸이 고장으로 보인다. // 각자 새로 만든 파일이라 견줄 조상이 없다. 그냥 두면 빈 칸이 고장으로 보인다.
@ -2027,16 +2030,17 @@ namespace MergeHub.ViewModels
bool afterHasLocal; bool afterHasLocal;
string? note = null; string? note = null;
if (line.HasBase) // 반영한 줄에는 누가·언제 반영했는지 주석을 남긴다.
{ // 주석은 줄 끝이 아니라 그 줄 "위"에 따로 들어간다 - 아래 InsertNoteAbove 참고.
// 반영한 줄에는 누가·언제 반영했는지 주석을 남긴다. // BASE 에 없던 줄(LOCAL 에서 지우는 반영)에도 남긴다 - 지운 자리에 누가 지웠는지가 남아야 한다(사용자 2026-09-30).
// 주석은 줄 끝이 아니라 그 줄 "위"에 따로 들어간다 - 아래 InsertNoteAbove 참고. // 예전에는 BASE 에 줄이 있을 때만 남겨, 지운 반영은 흔적이 없었다. 지운 반영이면 출처 뒤에 "삭제".
note = _workspace.Settings.InsertMergeComment && group == 0 ? BuildMergeNote(MergeNames.BaseSource(_baseSourceName)) : null;
}
// 반영하면 그 칸이 어떻게 되는지는 양쪽이 같은 규칙을 쓴다(12차 B-1, PaneText). // 반영하면 그 칸이 어떻게 되는지는 양쪽이 같은 규칙을 쓴다(12차 B-1, PaneText).
// BASE 에 없던 줄이면 LOCAL 에서도 지운다. // BASE 에 없던 줄이면 LOCAL 에서도 지운다.
(after, afterHasLocal) = PaneText.Plan(line, MergeLine.TextPane.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)) if (beforeHasLocal == afterHasLocal && string.Equals(before, after, StringComparison.Ordinal))
return false; return false;
@ -2051,7 +2055,7 @@ namespace MergeHub.ViewModels
_undo.Push(new LineEdit(line, before, beforeHasLocal, after, afterHasLocal, _undo.Push(new LineEdit(line, before, beforeHasLocal, after, afterHasLocal,
EditKind.TakeBase, description) EditKind.TakeBase, description)
{ {
InsertedNote = InsertNoteAbove(line, note), InsertedNote = InsertNoteAbove(line, note, indentFrom: before),
Group = group, Group = group,
}); });
@ -2354,11 +2358,13 @@ namespace MergeHub.ViewModels
bool afterHasLocal; bool afterHasLocal;
string? note = null; 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) if (line.HasRemote)
{ {
note = _workspace.Settings.InsertMergeComment && group == 0
? BuildMergeNote(MergeNames.TheirsSource(TheirsSideName, TakesMergeSides))
: null;
after = line.RemoteText; after = line.RemoteText;
afterHasLocal = true; afterHasLocal = true;
} }
@ -2382,7 +2388,7 @@ namespace MergeHub.ViewModels
_undo.Push(new LineEdit(line, before, beforeHasLocal, after, afterHasLocal, _undo.Push(new LineEdit(line, before, beforeHasLocal, after, afterHasLocal,
EditKind.TakeBase, description) EditKind.TakeBase, description)
{ {
InsertedNote = InsertNoteAbove(line, note), InsertedNote = InsertNoteAbove(line, note, indentFrom: before),
Group = group, Group = group,
}); });
@ -2406,7 +2412,7 @@ namespace MergeHub.ViewModels
/// 정렬은 저절로 맞는다. 끼워 넣은 줄은 BASE 에 없는 "LOCAL 에만 있는 줄"이라, /// 정렬은 저절로 맞는다. 끼워 넣은 줄은 BASE 에 없는 "LOCAL 에만 있는 줄"이라,
/// 이미 추가된 줄을 다루던 방식 그대로 BASE 쪽에 빈 자리(필러)가 생긴다. /// 이미 추가된 줄을 다루던 방식 그대로 BASE 쪽에 빈 자리(필러)가 생긴다.
/// </summary> /// </summary>
private MergeLine? InsertNoteAbove(MergeLine line, string? note) private MergeLine? InsertNoteAbove(MergeLine line, string? note, string? indentFrom = null)
{ {
if (string.IsNullOrWhiteSpace(note)) return null; if (string.IsNullOrWhiteSpace(note)) return null;
@ -2414,7 +2420,8 @@ namespace MergeHub.ViewModels
if (at < 0) return null; 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, var row = new MergeLine(null, null, indent + note, null,
Rules.IsPlain ? null : Rules.KeyOf) Rules.IsPlain ? null : Rules.KeyOf)
@ -2432,9 +2439,53 @@ namespace MergeHub.ViewModels
if (line.IsThreeWay) row.SetThreeWay(null, null, null, null); if (line.IsThreeWay) row.SetThreeWay(null, null, null, null);
Lines.Insert(at, row); Lines.Insert(at, row);
NoteRowMoved(at, +1);
return row; return row;
} }
/// <summary>처음으로 글이 있는 쪽의 앞 공백. 모두 비었으면 들여쓰지 않는다.</summary>
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;
}
/// <summary>
/// 주석 줄을 끼우거나 빼기 <b>직전</b> 캐럿이 있던 행(2026-09-30 사용자 보고 "Accept 뒤 Alt+↓ 를 두 번 눌러야 한다").
///
/// 편집기는 글을 갈아 끼울 때 캐럿을 <b>글자 수 자리</b>로 지킨다. 반영하면서 캐럿 위에 주석 한 줄이 끼면
/// 같은 글자 수 자리는 그 주석 줄이 되고, 그러면 "다음 차이" 가 방금 반영한 줄을 다음 차이로 잡았다.
/// 끼운 줄 수만큼 옮기는 셈은 지운 줄(편집기에 줄이 없는 행)에서 어긋나서, <b>행 자체</b>를 기억했다가
/// 글을 갈아 끼운 뒤 그 행이 몇 번째 편집기 줄이 됐는지(<see cref="EditorLayout.LineOfRow"/>)로 캐럿을 둔다.
/// </summary>
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];
}
/// <summary>
/// 편집기가 글을 갈아 끼운 뒤 캐럿을 둘 편집기 줄. 기억한 행이 없으면 -1(글자 수 자리를 그대로 지킨다).
/// 그 행이 LOCAL 에 없으면(지운 줄) 바로 다음 줄이다. 한 번 넘기면 비운다.
/// </summary>
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);
}
/// <summary>반영한 줄 위에 넣을 주석 한 줄. [사용자] 날짜 출처 설명(출처는 11차 F-1).</summary> /// <summary>반영한 줄 위에 넣을 주석 한 줄. [사용자] 날짜 출처 설명(출처는 11차 F-1).</summary>
private string BuildMergeNote(string? source) => MergeNote.Build( private string BuildMergeNote(string? source) => MergeNote.Build(
author: _workspace.AuthorName, author: _workspace.AuthorName,
@ -2689,7 +2740,6 @@ namespace MergeHub.ViewModels
? ChangedLocalNumbers(_originalLocalText, localText) ? ChangedLocalNumbers(_originalLocalText, localText)
: new HashSet<int>(); : new HashSet<int>();
Lines.Clear();
foreach (var row in rows) foreach (var row in rows)
{ {
// 반영한 줄인지를 <b>먼저</b> 본다. // 반영한 줄인지를 <b>먼저</b> 본다.
@ -2708,10 +2758,12 @@ namespace MergeHub.ViewModels
if (!row.IsApplied && !row.IsMergeNote && row.LocalNumber is int number && touched.Contains(number)) if (!row.IsApplied && !row.IsMergeNote && row.LocalNumber is int number && touched.Contains(number))
row.IsManuallyEdited = true; row.IsManuallyEdited = true;
Lines.Add(row);
} }
// 줄마다 넣으면 알림이 줄 수만큼 간다 - 표시를 다 매긴 뒤 한 번에 넣는다. 위 · 아래로 그대로인 줄은 기존 객체를 두고
// 번호만 고치고, 바뀐 가운데만 빼고 넣는다 - 칸 목록이 보이는 줄 요소를 전부 다시 만들지 않게(MergeLineCollection.Patch).
Lines.Patch(rows);
if (keepAsEdited) { _undo.Clear(); _redo.Clear(); } // 위에서 글로 옮겨 이미 비었다 if (keepAsEdited) { _undo.Clear(); _redo.Clear(); } // 위에서 글로 옮겨 이미 비었다
else ClearHistory(); else ClearHistory();
@ -3013,8 +3065,7 @@ namespace MergeHub.ViewModels
RefreshLanguage(); RefreshLanguage();
var rows = BuildLines(baseText, localText); var rows = BuildLines(baseText, localText);
Lines.Clear(); Lines.ReplaceAll(rows); // 알림 한 번(MergeLineCollection 참고)
foreach (var row in rows) Lines.Add(row);
SetPlainHeaders(); SetPlainHeaders();
@ -4015,14 +4066,21 @@ namespace MergeHub.ViewModels
if (toBefore) if (toBefore)
{ {
Lines.Remove(note); var was = Lines.IndexOf(note);
if (was >= 0)
{
Lines.RemoveAt(was);
NoteRowMoved(was, -1); // 되돌리기로 주석이 빠지면 캐럿도 한 줄 올라와야 같은 줄에 남는다
}
return; return;
} }
if (Lines.Contains(note)) return; if (Lines.Contains(note)) return;
var at = Lines.IndexOf(edit.Line); 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);
} }
/// <summary> /// <summary>
@ -4295,17 +4353,20 @@ namespace MergeHub.ViewModels
{ {
var group = ++_lastGroup; var group = ++_lastGroup;
var changed = 0; var changed = 0;
var deleted = 0; // 주석에 "삭제 N줄" 로 한 번만 적는다(사용자 2026-09-30)
int? first = null, last = null; int? first = null, last = null;
foreach (var line in rows.ToList()) foreach (var line in rows.ToList())
{ {
if (line.IsMergeNote) continue; if (line.IsMergeNote) continue;
var number = IsThreeWay ? line.RemoteNumber : line.BaseNumber; var number = IsThreeWay ? line.RemoteNumber : line.BaseNumber;
var hadLocal = line.HasLocal;
var done = IsThreeWay var done = IsThreeWay
? line.CanTakeRemote && ApplyRemote(line, requery: false, group: group) ? line.CanTakeRemote && ApplyRemote(line, requery: false, group: group)
: line.CanTakeBase && Apply(line, requery: false, group: group); : line.CanTakeBase && Apply(line, requery: false, group: group);
if (!done) continue; if (!done) continue;
if (hadLocal && !line.HasLocal) deleted++;
changed++; changed++;
if (number is int n) { first ??= n; last = n; } 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 where = first is null ? string.Empty : first == last ? $"{first}행 " : $"{first}~{last}행 ";
var description = $"{where}{what} {source} 반영 ({changed}줄)"; var description = $"{where}{what} {source} 반영 ({changed}줄)";
// 묶음이 모두 지우는 반영이면 글이 남은 줄이 없다 - 그래도 지운 자리(첫 줄)에 주석을 남긴다(사용자 2026-09-30).
var at = anchor is { HasLocal: true } && Lines.Contains(anchor) var at = anchor is { HasLocal: true } && Lines.Contains(anchor)
? 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) 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, _undo.Push(new LineEdit(at, at.LocalText, at.HasLocal, at.LocalText, at.HasLocal,
EditKind.NoteOnly, description) EditKind.NoteOnly, description)
{ {
@ -4619,11 +4682,12 @@ namespace MergeHub.ViewModels
private void UpdateLegendCounts() private void UpdateLegendCounts()
{ {
int changed = 0, applied = 0, edited = 0, added = 0, removed = 0, moved = 0, ignored = 0, same = 0; 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) foreach (var line in Lines)
{ {
if (line.NeedsDecision) needsDecision++; if (line.NeedsDecision) needsDecision++;
if (HasInlineChange(line)) inline++;
switch (line.Highlight) switch (line.Highlight)
{ {
@ -4651,9 +4715,24 @@ namespace MergeHub.ViewModels
RemoteChangeCount = remote; RemoteChangeCount = remote;
ConflictCount = conflict; ConflictCount = conflict;
UnresolvedConflictCount = needsDecision; UnresolvedConflictCount = needsDecision;
InlineChangeCount = inline;
SaveResolvedCommand.NotifyCanExecuteChanged(); SaveResolvedCommand.NotifyCanExecuteChanged();
} }
/// <summary>
/// 색 구분의 "줄 안에서 바뀐 부분" 줄 수(사용자 요청 2026-09-30 - 이 칸만 개수가 없었다).
/// 지금도 BASE 와 다른(<see cref="LineHighlight.Changed"/>) 줄 가운데 단어 단위로 달라진 구간이 있는 줄 - 화면에서 그 색이 칠해지는 줄이다.
/// 반영 · 무시로 색이 바뀐 줄은 세지 않는다.
/// </summary>
[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<WordDiffSegment>? words) =>
words is not null && words.Any(w => w.Kind != WordDiffKind.Unchanged);
/// <summary> /// <summary>
/// MERGED 칸 번호를 <b>결과 파일 번호</b>로 다시 매긴다(8차 B-3). /// MERGED 칸 번호를 <b>결과 파일 번호</b>로 다시 매긴다(8차 B-3).
/// ///

View File

@ -899,7 +899,9 @@
</Button> </Button>
<Border Style="{StaticResource LegendChip}" Background="#66E0A020"/> <Border Style="{StaticResource LegendChip}" Background="#66E0A020"/>
<TextBlock Text="줄 안에서 바뀐 부분" Style="{StaticResource LegendText}" Margin="0,0,14,0"/> <TextBlock Text="줄 안에서 바뀐 부분" Style="{StaticResource LegendText}"/>
<!-- 2026-09-30 사용자 요청: 이 칸만 개수가 없었다. 단어 단위로 달라진 곳이 있는 "BASE와 다름" 줄 수. -->
<TextBlock x:Name="InlineChangeCountText" Text="{Binding InlineChangeCount}" Style="{StaticResource LegendCount}" Margin="0,0,14,0"/>
</WrapPanel> </WrapPanel>
</Border> </Border>

View File

@ -977,6 +977,12 @@ namespace MergeHub.Views
{ {
var caret = LocalEditor.CaretIndex; var caret = LocalEditor.CaretIndex;
// 머지 주석 줄이 끼거나 빠졌으면 캐럿을 <b>누르기 전과 같은 행</b>에 둔다(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 가 도는데, 그걸 사용자의 편집으로 오해하면 // 글을 갈아 끼우면 TextChanged 가 도는데, 그걸 사용자의 편집으로 오해하면
// 다시 정렬이 끝없이 되풀이된다. // 다시 정렬이 끝없이 되풀이된다.
_replacingText = true; _replacingText = true;
@ -989,9 +995,37 @@ namespace MergeHub.Views
_replacingText = false; _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); LocalEditor.CaretIndex = Math.Min(caret, text.Length);
} }
/// <summary>
/// 글 안 <paramref name="line"/> 번째 줄(0부터)의 시작 자리와 길이(줄끝 글자 빼고). 줄이 모자라면 마지막 줄.
/// 편집기에 묻지 않고 글에서 직접 센다 - 글을 막 갈아 끼운 순간 TextBox 의 줄 정보는 아직 <b>옛 글</b> 기준이다
/// (기록으로 확인: 새 글 7번 줄을 물었더니 옛 글의 주석 줄 자리 · 길이가 돌아왔다).
/// </summary>
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);
}
/// <summary>사용자가 글자를 고쳤다. 무거운 일은 ViewModel 이 잠시 뒤에 한다.</summary> /// <summary>사용자가 글자를 고쳤다. 무거운 일은 ViewModel 이 잠시 뒤에 한다.</summary>
private void LocalEditor_TextChanged(object sender, TextChangedEventArgs e) private void LocalEditor_TextChanged(object sender, TextChangedEventArgs e)
{ {