최초 커밋: MergeHub (사내 Git Merge/Diff 도구)

UbisamBase 플랫폼 위에서 도는 플러그인. 지금까지 만든 것을 그대로 담는다.
- 폴더 비교 / 파일 비교(부분 수용·워드랩·찾기) / 3-Way 병합 / Git 조작
- 비교 엔진: histogram 정렬 · 이동 블록 감지 · 무시 규칙 · 13개 언어 토크나이저
- doc/ 에 작업 지시서와 진행 기록

빌드 산출물(bin/obj)은 추적하지 않는다 - 플랫폼 런타임이 빌드 때마다
D:\UbisamPlatform\bin 에서 복사되어 들어오므로 저장소에 담을 이유가 없다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
김승준 2026-09-08 10:13:27 +09:00
commit 7719094edf
44 changed files with 13523 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
# 빌드 산출물
bin/
obj/
# Visual Studio / Rider
.vs/
.idea/
*.user
*.suo
# NuGet
packages/
# 플랫폼 런타임 복사본 (빌드할 때 D:\UbisamPlatform\bin 에서 자동으로 채워진다)
# → src/MergeHub/bin 아래로 들어가므로 위 bin/ 규칙에 이미 걸린다

5
MergeHub.slnx Normal file
View File

@ -0,0 +1,5 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/MergeHub/MergeHub.csproj" />
</Folder>
</Solution>

View File

@ -0,0 +1,263 @@
# MergeHub 확장 — 머지툴 기본기 보강
> 작성 2026-09-07 · 상태: 착수 전 · 선행 확인 3건 있음(맨 아래)
>
> 새 세션에서 이어서 작업할 때는 이 문서를 읽고 시작하면 된다.
## 배경
MergeHub는 사내 Git 머지/비교 도구다. 지금은 **Git 저장소 안의 파일만**, **C# 소스만**,
**BASE ↔ LOCAL 2분할로만** 비교할 수 있다. WinMerge와 비교하면 머지툴이면 당연히 있어야 할
조작(차이 탐색·무시 규칙·폴더 비교)이 빠져 있어 실무에서 쓰기 어렵다.
**방향을 다시 잡는다: 머지툴이 본체이고 Git은 그 위에 얹는 기능이다.**
Git 저장소가 아니어도, 소스 코드가 아니어도 비교가 되어야 한다.
## 프로젝트 현황
| 항목 | 내용 |
|---|---|
| 소비 프로젝트 | `D:\Sorce\MergeTool\MergeHub\src\MergeHub` (net48, OutputType=Library) |
| 플랫폼 | UbisamBase 호스트+플러그인. `D:\UbisamPlatform\bin` 을 `Reference`+`Private=false` 로 참조, `CopyPlatformRuntime` 타겟이 런타임 복사 |
| 플랫폼 소스 | `D:\Sorce\MergeTool\UbisamPlatform` — 가이드는 `doc\platform-guide.md` |
| 실행 | `bin\Release\net48\UbisamBase.Shell.exe` |
| MVVM | CommunityToolkit.Mvvm 8.3.2 (`[ObservableProperty]`, `[RelayCommand]`) |
| Git | LibGit2Sharp 0.32.0 |
### 관련 파일
```
Views/HomeView.xaml(.cs) 머지 화면. 현재 [커밋트리 | BASE | LOCAL | ACTIONS] 4열
ViewModels/HomeViewModel.cs ~900줄. 정렬·적용·Undo/Redo·전체편집
Views/GitView.xaml(.cs) Git 조작 탭
Services/LineAligner.cs LCS 줄 정렬. Align(baseLines, localLines) → AlignedRow 목록
Services/GitService.cs Git API 60여 개. GetThreeWayContent(path) 이미 있음
Services/GitWorkspace.cs DI 싱글턴. 저장소 상태·세마포어 게이트
Services/CodeSanityChecker.cs 코드 이상 검사
Services/MergeNote.cs 머지 주석 생성
Views/Controls/CSharpTokenizer.cs C# 전용 토크나이저. CodeTokenKind 15종
Views/Controls/CodeTextBlock.cs TextBlock 파생. Code/Words/InBlockComment/HighlightSyntax DP
Views/Controls/ChangeMiniMap.cs 미니맵
Models/MergeLine.cs 한 행. LineHighlight { None,Changed,Applied,Edited,Added,Removed }
MergeHubSettings.cs IgnoreWhitespace, WordLevelDiff, SyntaxHighlight 등
```
---
## 참고자료와 라이선스 — 먼저 읽을 것
**WinMerge 소스 코드를 받거나 읽지 말 것.** GPLv2+ 라 코드를 참고해 옮기면
MergeHub 전체가 GPL 의무에 걸린다. C++/MFC 라 어차피 C#/WPF 로 옮길 수도 없다.
대신 이것들을 쓴다.
| 목적 | 참고할 것 |
|---|---|
| 조작 감각·UI 관례·단축키 | **WinMerge 설치본을 직접 써 볼 것** (UI 관례를 따르는 건 자유) |
| 기능 정의·필터 파일 형식 | https://manual.winmerge.org/en/ |
| 줄 정렬 알고리즘 | **Histogram diff** (git 기본 알고리즘) · Myers diff 논문 *An O(ND) Difference Algorithm* |
| 단어 단위 diff | **DiffPlex** (MIT) — 필요하면 참조 가능 |
---
## 작업 1. 머지툴 기본 조작 · UI
WinMerge 화면에 가깝게 만든다. 세 갈래다.
### 1-1. 파일 비교
- **차이 탐색** — 첫 / 이전 / 다음 / 마지막 차이로 점프.
단축키 `Alt+↑` `Alt+↓` `Ctrl+Home` `Ctrl+End`, 툴바 버튼도 같이.
현재 몇 번째 차이인지 상태바에 `차이 3 / 15` 로 표시.
- **Go to** — 줄 번호 또는 차이 번호로 이동 (`Ctrl+G`).
- **워드랩 토글** — 긴 줄을 접어서 표시. 현재는 가로 스크롤뿐.
접힌 줄은 여백에 화살표 표시. BASE/LOCAL 양쪽 줄 높이가 어긋나지 않아야 한다.
> **완료 (2026-09-07).** 여백(줄번호·`[>]`)을 행 안쪽으로 옮겨 높이가 저절로 맞게 했고,
> 가로 스크롤 때는 글자만 `TranslateTransform` 으로 밀어 여백을 고정한다.
> 접히는 폭을 맞추려고 편집기 세로 스크롤바를 항상 띄우고 표시층에 같은 폭의 여백을 줬다.
>
> 한 가지 더 필요했다 - 한쪽만 접힌 행에서 아래가 통째로 밀리는 문제.
> `MergeLine.RowHeight` 에 두 패널이 각자 글 높이를 알려 주고 **큰 쪽을 함께 쓰도록** 해서 맞췄다.
- **상태바** — 줄/열 위치, 감지된 **인코딩**, **줄바꿈 형식(CRLF/LF)** 표시.
- **북마크** — 여백 더블클릭으로 표시/해제, `F2` 로 다음 북마크.
- **읽기 전용 토글** — LOCAL 편집 잠금.
#### 1-1-A. 정렬 알고리즘 교체 — LCS → Histogram diff
현재 `LineAligner` 는 순수 LCS 다. LCS 는 `{`, `}`, 빈 줄처럼 **짧고 자주 반복되는 줄**을
엉뚱하게 짝지어 정렬이 지저분해진다. git 이 LCS 대신 histogram 을 기본으로 쓰는 이유가 그것이다.
- **Histogram diff 로 교체한다.** 원리: 양쪽에서 **드물게 나오는 줄**(빈도가 낮은 줄)을
먼저 앵커로 잡고, 그 앵커를 기준으로 앞뒤 구간을 재귀적으로 다시 정렬한다.
흔한 줄로 짝을 맞추지 않으므로 사람이 보기에 자연스러운 정렬이 나온다.
- 기존 최적화는 유지한다 — 앞뒤 공통 구간 잘라내기, 크기 상한(`MaxCells`) 넘으면 포기.
- **기존 `AlignTest` 26건이 전부 통과해야 한다.** 실패하면 정렬이 나빠진 것이므로 원인을 밝힌다.
- 새 알고리즘 전용 테스트를 추가한다. 최소한 이 두 가지:
- 같은 코드 블록이 여러 번 반복되는 파일에서 올바른 쪽에 붙는지
- 중괄호·빈 줄만 있는 구간이 엉키지 않는지
#### 1-1-B. 이동된 블록 감지
- 정렬 결과에서 **삭제 구간과 추가 구간의 내용이 같으면**
"삭제+추가" 가 아니라 **"이동"** 으로 표시한다 (`LineHighlight` 에 `Moved` 추가, 별도 색).
- 메서드 순서만 바꾼 경우가 흔한데, 지금은 전부 삭제+추가로 보인다.
- 양쪽 블록을 선으로 잇거나 "→ 42행으로 이동" 같은 힌트를 준다.
- 미니맵과 범례에도 반영한다.
### 1-2. 무시 규칙
지금 `MergeHubSettings.IgnoreWhitespace` 는 **`GitService` 의 patch 파싱에만** 걸려 있고
홈 화면의 `LineAligner` 비교에는 적용되지 않는다. 이걸 바로잡고 확장한다.
- **공백 3단계** — 전부 비교 / 개수 변화만 무시 / 완전 무시
- **대소문자 무시**
- **줄 필터** — 정규식에 맞는 줄은 차이로 치지 않는다.
타임스탬프·빌드번호·`$Id$` 같은 것. 규칙 여러 개를 이름 붙여 저장/재사용.
- 무시 규칙은 **툴바에서 즉시 토글**되고, 바꾸면 화면이 바로 다시 계산되어야 한다.
현재 켜진 규칙을 상태바에 요약 표시.
- 무시로 처리된 줄은 "차이 없음" 과 구분되는 옅은 색으로 표시.
- 구현 위치 주의 — 무시 규칙은 **정렬 전에 비교용 키를 만드는 단계**에 넣는다.
화면에 보이는 원문은 그대로 두고, 비교할 때만 정규화한 문자열을 쓴다.
### 1-3. 폴더 비교
새 화면. 파일 하나가 아니라 **폴더 두 개**를 비교한다.
- 비교 방식: **전체 내용 / 빠른 내용(크기+수정일)** 선택. 큰 파일은 자동으로 빠른 비교.
- **트리 뷰** (하위 폴더 재귀) + 목록 뷰
- 각 항목 상태: 같음 / 다름 / 왼쪽만 / 오른쪽만 / 이진파일
- 파일 필터 — 정규식 포함·제외. 규칙 세트 저장
- 항목 더블클릭 → 기존 파일 비교 화면으로
- 좌↔우 복사, 삭제. **파괴적 동작은 확인 대화상자 필수**
- 결과 요약: 전체 N개 · 다름 N · 왼쪽만 N · 오른쪽만 N
---
## 작업 2. Git 유무에 따라 화면 바꾸기
- 연 경로에서 **`.git` 을 상위로 거슬러 탐색**한다 (`GitService.Discover` 이미 있음).
- **있으면** — 지금처럼 커밋 이력 패널을 보여주고, BASE를 커밋에서 불러온다.
- **없으면** — 커밋 이력 패널을 **숨기고**, Git 탭도 숨기거나 비활성화한다.
대신 **"파일 두 개를 직접 골라 비교"** 모드로 동작한다 (왼쪽/오른쪽 파일 선택).
- 전환은 자동이며, 사용자에게 현재 어느 모드인지 명확히 보여야 한다.
- 폴더 비교도 마찬가지 — Git 없이 동작해야 한다.
**판정 기준**: Git이 아닌 아무 폴더의 텍스트 파일 두 개를 열어 비교·편집·저장까지 된다.
---
## 작업 3. 진짜 3-Way 비교
기획안에는 "3-Way Merge" 라고 적혀 있으나 **실제는 2분할**이다.
1번 칸은 커밋 트리이고 BASE ↔ LOCAL만 비교한다.
- 패널을 **BASE(공통 조상) / LOCAL(내 작업) / REMOTE(상대 변경)** 3분할로 만든다.
- `GitService.GetThreeWayContent(path)` 가 이미 세 버전을 돌려준다 — 이걸 쓴다.
- `LineAligner` 를 **3열 정렬**로 확장한다. 지금은 2열이다.
권장 방식: **BASE 를 축으로 BASE↔LOCAL, BASE↔REMOTE 를 각각 정렬한 뒤 BASE 행 기준으로 합친다.**
세 쪽 모두에 대응되는 행, 두 쪽만 있는 행, 한 쪽만 있는 행이 한 행에 정렬되어야 한다.
- 각 줄에서 **LOCAL로 가져오기 / REMOTE로 가져오기 / 양쪽 다** 를 고를 수 있어야 한다.
- **충돌 판정**: BASE 대비 LOCAL과 REMOTE가 서로 다르게 바뀐 줄을 별도 색으로.
- 커밋 트리 패널은 접거나 폭을 줄일 수 있게 한다 — 3분할이면 가로가 부족하다.
- Git이 없거나 3-way 대상이 아니면 **2분할로 자동 전환**.
---
## 작업 4. 소스 아닌 파일 · 다른 언어
지금 구문 강조는 `CSharpTokenizer` 하나뿐이고, `CodeSanityChecker.AppliesTo` 도
`.cs .cpp .c .h .hpp .java .js .ts` 만 받는다.
- 토크나이저를 **언어별로 갈아끼울 수 있는 구조**로 바꾼다.
`CSharpTokenizer` 를 `ITokenizer` 뒤로 넣고 확장자로 고른다.
기존 `Tokenize(line, inBlockComment, out stillInBlockComment)` 시그니처(줄 넘어가는 블록 주석 상태)는 유지한다.
- 최소 지원: **C 계열**(C/C++/C#/Java/JS/TS) · **XML/HTML** · **JSON** · **SQL** ·
**Python** · **INI/설정** · **Markdown** · **일반 텍스트(강조 없음)**
- **일반 텍스트가 기본값**이다. 모르는 확장자는 강조 없이 비교만 되면 된다.
`.txt .md .csv .log .xml .json .yml` 등 문서 파일이 정상 동작해야 한다.
- **이진 파일 감지** — NUL 바이트가 있으면 텍스트로 열지 않고
"이진 파일 · 같음/다름" 만 알린다. 열어서 깨뜨리면 안 된다.
- `CodeSanityChecker` 는 **코드 파일에서만** 돌린다. 문서에서는 조용해야 한다.
- 인코딩 판별(BOM → UTF-8 엄격 → CP949)은 지금 로직을 그대로 쓴다.
- 기존 `TokenTest` 63건은 C# 토크나이저용이다 — **그대로 통과해야 한다.**
---
## 지켜야 할 것
- **주석은 한글**로, 기존 코드와 같은 밀도로. "무엇을" 이 아니라 **"왜"** 를 적는다.
- **빌드 경고 0 · 오류 0** 을 유지한다.
- 새 알고리즘(histogram 정렬, 3열 정렬, 이동 블록 감지, 무시 필터)은
**스크래치패드에 테스트 하네스**를 만들어 검증하고 결과 건수를 보고한다.
기존 하네스 방식(`AlignTest`, `SanityTest`, `TokenTest`)을 따른다.
- **기존 테스트를 깨뜨리지 말 것.** 깨지면 원인을 밝히고 보고한다.
- **PowerShell `Get-Content`/`Set-Content` 로 프로젝트 파일을 읽고 쓰지 말 것.**
PS 5.1이 UTF-8을 ANSI로 읽어 한글 주석을 손상시킨다. Read/Write/Edit 도구를 쓴다.
- 플랫폼(`UbisamPlatform`)을 고쳐야 하면 **먼저 알리고**, 고친 뒤에는
Core → Shell 빌드 → `scripts\publish-platform.ps1` → MergeHub 재빌드 순서를 지키고
`doc\history\changelog.md` 에 기록한다.
- **커밋·푸시하지 않는다.** 필요하면 먼저 확인받는다.
## 하지 말 것
- **WinMerge 소스 코드를 받거나 읽지 말 것** (GPLv2+ — 위 라이선스 항목 참조).
- WinMerge를 따라가되 **우리 목적과 무관한 것은 넣지 않는다** —
이미지 비교, CSV 표 형식 비교, 압축파일 내부 비교, 플러그인 시스템(ActiveX).
## 진행 방식
권장 순서:
```
1-1-A 정렬 교체(histogram) ← 다른 모든 것의 품질이 여기에 달림
1-1 차이 탐색 · Go to · 워드랩 · 상태바 · 북마크
1-1-B 이동 블록 감지
1-2 무시 규칙
2 Git 유무 분기
4 다국어 · 문서 · 이진파일
3 3-Way
1-3 폴더 비교
```
정렬 교체를 맨 앞에 두는 이유 — 이동 블록 감지와 3-Way 정렬이 모두 그 위에 얹히므로
나중에 바꾸면 두 번 일한다.
**각 항목을 끝낼 때마다 빌드하고 실제로 실행해 화면을 확인한 뒤 보고할 것.**
전부 끝내고 한 번에 보고하지 말 것.
---
## 시작 전에 확인할 것 (미해결)
1. **3-Way의 REMOTE는 무엇인가** — 병합 충돌 중일 때의 `MERGE_HEAD` 인가,
아니면 사용자가 고른 임의의 브랜치·커밋인가? 후자면 브랜치 선택 UI가 필요하다.
2. **폴더 비교에서 Git 커밋 두 개 사이 비교**도 필요한가, 아니면 파일시스템 폴더 두 개만인가?
3. **가로 공간** — 3분할 + 커밋트리 + ACTIONS 를 한 화면에 넣을지,
커밋 트리를 접는 방식으로 갈지.
---
## 진행 기록
| 날짜 | 항목 | 결과 |
|---|---|---|
| 2026-09-07 | 문서 작성 | 착수 전 |
| 2026-09-07 | **패널 이름·머리 정렬 · C#/C++ 데모 · 탭 인디케이터** | **완료.** ① 패널 이름을 모드와 상관없이 **BASE / LOCAL** 로 통일(`기준 파일`·`내 파일` 삭제). ② **경로 줄 높이를 맞췄다** — LOCAL 머리에만 있던 `전체 반영`·`전체 되돌리기` 를 ACTIONS 로, BASE 머리에만 있던 파일 목록 콤보를 상단 툴바로 옮겨 두 머리를 `제목 + 부제 + 경로` 로 똑같이 만들었다. 옮긴 버튼들은 줄 단위 반영과 성격이 같아 ACTIONS 가 원래 자리이기도 하다. ③ **C# · C++ 데모 파일** 을 모두 만들었다 — 파일 비교 기본값은 `WeldController_기준.cs ↔ _내작업.cs`, 같은 폴더에 `.cpp` 쌍도 있고, 폴더 비교 데모(`folder-demo`)에는 `.cs`·`.cpp`·`.h`·문서가 섞여 있다. Git 데모 저장소에는 `WeldController.cpp` 를 새 커밋으로 넣고 거기서 갈라지는 `feature/cpp-tuning` 을 만들어, **C++ 파일로도 제대로 된 3-Way**(merge base 가 그 파일을 갖고 있음)가 된다. ④ **탭 인디케이터 결함**: 폴더 비교에서 파일을 더블클릭하면 내용과 강조는 파일 비교로 넘어가는데 **아래 파란 막대만 폴더 비교에 남았다.** 코드로 탭을 옮기면 그 탭 화면이 그 자리에서 만들어지느라 배치가 밀리는데, `MoveTabIndicator` 는 그때 막대를 0 으로 만들려 했고 **진행 중인 애니메이션이 값을 붙들고 있어 그 지정이 무시**됐다 — 결국 옛 자리 그대로였다. 배치가 끝난 뒤 한 번 다시 재도록 고치고(무한 재시도 방지 포함), 선택이 아예 없을 때는 애니메이션을 먼저 끊고 지우게 했다. **곁들여 고친 것**: 파일 두 개를 직접 고른 모드에서 **코드 검사가 늘 "검사 대상 아님"** 이었다 — `SelectedFile` 은 Git 모드에서만 채워지는데 그걸 보고 있었다. `CurrentFilePath` 로 바꿔 `.cs`·`.cpp` 모두 검사된다(확인: `이상 없음`). `전체 되돌리기` 버튼 글자가 두 칸 배치에서 잘리던 것도 한 줄에 하나씩으로 폄. |
| 2026-09-07 | **폴더 비교 → 파일 비교 더블클릭이 안 먹던 것** | **완료.** 기능은 이미 있었는데 **탭 순서를 바꾸면서 깨졌다**. 탭 내용은 그 탭을 <b>처음 열 때</b> 만들어진다. 폴더 비교가 첫 화면이 되면서, 파일을 처음 더블클릭하는 순간에는 파일 비교 화면이 아직 없어 요청 이벤트를 받을 사람이 없었다. 요청은 허공에 사라지고, 뒤이어 만들어진 화면은 기본 데모 파일을 열었다 - 겉보기에 "더블클릭이 안 먹는다". `GitWorkspace` 가 요청을 **남겨 두고**, 화면이 만들어질 때 스스로 집어 가도록 고쳤다(`HasPendingPlainCompare` / `DeliverPendingPlainCompare`). **곁들여 고친 것**: ① 한쪽에만 있는 파일(왼쪽만·오른쪽만)은 열리지 않았다 — 오히려 그런 파일이야말로 "뭐가 들었길래 새로 생겼나"를 보고 싶은 대상이다. 없는 쪽을 빈 내용으로 열어 전부 추가(또는 삭제)로 보이게 했다. ② 열 수 없는 항목(폴더·이진 파일)을 눌러도 **아무 일도 일어나지 않아** 기능이 없는 줄 알게 됐다. 왜 못 여는지 토스트로 알린다. ③ Enter 로도 열린다. ④ 클릭한 자리에서 항목을 직접 집는다 — 펼침 화살표처럼 선택을 바꾸지 않는 자리를 두 번 누르면 엉뚱한 항목이 열렸다. ⑤ 안내 문구가 없어진 탭 이름("홈 화면")을 가리키고 있던 것을 고쳤다. **실행 확인**: 프로그램을 새로 켠 뒤 **첫 더블클릭**부터 바로 열림 — 양쪽 다 있는 `Weld.cs`(총 5 라인·차이 2 라인), 왼쪽에만 있는 `Legacy.cs`(총 1 라인·차이 1 라인) 모두 정상. 폴더는 접기/펼치기만 된다. |
| 2026-09-07 | **탭 순서·용어 정리 · 데모 전환 · 모니터 2 확인** | **완료.** ① **탭 순서를 실제 작업 순서로**: `폴더 비교 → 파일 비교 → Git`. 사용자가 짚은 대로 "폴더를 먼저 보고 고칠 파일을 골라 들어가는" 흐름이 맞다. `홈` 이라는 이름도 하는 일과 맞지 않아 `파일 비교` 로 바꿨다. 폴더 비교에서 더블클릭하면 파일 비교 탭으로 넘어가므로 흐름이 이어진다. ② **용어를 머지툴 표준으로**: `LOCAL (Mine)` · `BASE` · `REMOTE (Theirs)` · `MERGED`, 버튼은 `Take Mine` / `Take Theirs`, 범례는 `Theirs only` / `Conflict`. 한글 풀이(내 작업 원본·공통 조상·고른 브랜치·최종 결과)와 곁들인 설명 줄은 지웠다 — 이 용어는 Git 과 다른 머지툴에서 그대로 쓰이므로 여기서만 다르게 부르면 옮겨 다닐 때 오히려 헷갈린다. ③ **데모 전환 버튼**(발표용): 상단의 `데모 → .git 있음/없음` 하나로 Git 저장소 화면과 파일 비교 화면을 즉시 오간다. 설정에 `DemoGitPath`·`DemoPlainPath` 를 넣었고 둘 다 실재할 때만 버튼이 뜬다. **여기서 결함 둘을 잡았다** — (1) 전환 중 3-Way 를 끄면서 부른 재로드가 **작업 게이트를 먼저 잡아** 뒤따르는 저장소 닫기·파일 비교가 "다른 작업 진행 중"으로 통째로 건너뛰어져 화면이 빈 채 남았다(`_suspendReload` 로 차단). (2) 늦게 끝난 Git 본문 로드가 이미 바뀐 화면을 덮어써 **경로는 새 파일인데 내용은 옛 파일**이 됐다(양쪽 로드의 UI 반영 직전에 모드가 그대로인지 확인). **실행 확인**: 21줄 레시피 ↔ 34줄 StartWeld.cs 를 세 번 왕복해도 매번 정확. ④ **3-Way 워드랩 재검증**: LOCAL 4줄 접힘 · REMOTE 3줄 접힘 · BASE 빈칸인데도 `void StartWeld()` 가 세 칸 **정확히 같은 y**. 아래 MERGED 는 폭이 넓어 같은 줄이 한 줄에 들어가고 빈칸이 안 생긴다. ⑤ **모니터 2 확인**(1920×1080 @100%, 모니터 1 은 2560×1600 @150%): 4분할·워드랩·용어 모두 정상, 배율 때문에 깨지는 곳 없음. 상단 툴바만 폭에 맞춰 두 줄로 접힌다(정상 동작). |
| 2026-09-07 | **워드랩 4분할 대응 · 전체화면 점검** | **완료.** ① 3-Way 로 패널이 넷이 되면서 행 높이 계산이 어긋나 있었다 — `MergeLine.ReportTextHeight` 가 BASE·LOCAL **둘만** 보고 있어 REMOTE·LOCAL원본이 접혀도 반영되지 않았다. 더 중요한 것은 **맞춰야 할 묶음이 하나가 아니라는 점**이다. 3-Way 에서 위 원본 세 칸은 폭이 같아 서로 맞춰야 하지만, 아래 MERGED 는 폭이 가로 전체라 접히는 자리가 애초에 다르다. 같은 높이를 강요하면 MERGED 는 안 접힌 줄에 빈칸이 생기고 그 위에 겹친 편집기와도 어긋난다. 그래서 높이를 **`RowHeight`(MERGED·편집기)** 와 **`SourceRowHeight`(원본 세 칸)** 둘로 나눴다(2-Way 에서는 같은 값). ② 같은 이유로 글자 폭 보정(`MatchTextWidths`)도 모드를 타게 했다 — 2-Way 는 BASE 가 미니맵 폭만큼 물러나야 LOCAL 과 맞지만, 3-Way 에서 그러면 **BASE 만 좁아져** 위 세 칸의 접히는 자리가 어긋난다. 모드가 바뀌면 다시 재도록 `IsThreeWay` 변경을 구독한다. ③ 폴더 비교의 줄 배경이 **글자 끝에서 끊겨** 넓은 화면에서 색 띠가 중간에 잘려 보였다. TreeViewItem 기본 템플릿이 머리를 `Auto` 폭으로 잡아 `HorizontalContentAlignment=Stretch` 가 듣지 않는다 — 템플릿을 갈아끼우면 플랫폼 테마를 잃으므로 배경 띠에 트리 폭만큼 `MinWidth` 를 주고 넘치는 부분은 잘리게 뒀다. ④ `줄바꿈 끄기` 버튼 글자가 칸을 넘어 잘리던 것을 `줄바꿈 켬/끔` 으로 맞춤(`필터 켬/끔` 과 같은 꼴). **실행 확인**(2560×1600 최대화): 2-Way 는 BASE 의 빈 블록이 LOCAL 의 접힌 높이를 그대로 따라가 `void StartWeld()` 가 같은 눈높이. 3-Way 는 LOCAL 4줄 · REMOTE 3줄로 접힘이 다른데도 위 세 칸이 같은 높이로 맞물리고, 아래 MERGED 는 폭이 넓어 **한 줄에 다 들어가며 빈칸이 안 생긴다**. 접힌 줄에서 캐럿도 논리 행/열을 정확히 보고한다(`End` → `5행 67열`, 시각 줄이 아니라 실제 줄 기준). 폴더 비교도 실제 프로젝트 폴더(`Views` ↔ `Services`, 27개)로 전체화면 확인. |
| 2026-09-07 | **1-3 폴더 비교 (별도 탭)** | **완료.** 새 파일 `Services/FolderComparer.cs` · `ViewModels/FolderViewModel.cs` · `Views/FolderView.xaml` · 테스트 **27/27**. 홈에 넣지 않고 **탭을 따로** 뒀다 — 홈은 3-Way 에서 이미 네 칸을 쓰고 있어 트리를 얹을 자리가 없다. 담은 것: 트리 뷰(하위 폴더 재귀) · 상태 5종(같음 / 다름 / 왼쪽만 / 오른쪽만 / **다름(이진)**) · **전체 내용 ↔ 빠른 비교(크기+수정일)** 전환 · 한도(기본 8MB)를 넘는 파일은 전체 비교를 골라도 자동으로 빠른 비교 · 정규식 **포함·제외** 필터(기본 제외에 `.git` · `bin` · `obj` · `.dll` · `.pdb`) · 같은 파일 숨기기 · 요약(`전체 N개 · 다름 N · 왼쪽만 N · 오른쪽만 N · 같음 N`) · **좌↔우 복사와 삭제(원본·대상 경로를 그대로 보여 주는 확인 대화상자 필수, 기본 선택은 취소)** · 항목 더블클릭 → 홈 탭에서 줄 단위 비교. 비용이 드는 것은 파일을 여는 일뿐이라 **열기 전에 거를 수 있는 것을 먼저 거른다** — 크기가 다르면 내용을 안 보고, 내용 비교도 64KB 씩 읽다가 다른 곳을 만나면 즉시 멈춘다. 실수로 `C:\` 를 고른 경우를 대비해 5만 항목에서 끊고 요약에 그렇다고 적는다. **실행 확인**: 데모 폴더 두 개 → `절차서.txt`·`Weld.cs` 다름, `Legacy.cs` 왼쪽만, `New.cs` 오른쪽만, `bin/tool.dll` 은 기본 제외 규칙으로 빠짐(`전체 5개 · 다름 2 · 왼쪽만 1 · 오른쪽만 1 · 같음 1`). `Weld.cs` 를 더블클릭하니 홈 탭으로 넘어가 두 파일이 줄 단위로 열렸다(`1800→2000`, `Emit→EmitFast` 단어 단위 강조). **곁들여 고친 플랫폼 결함**: 코드로 탭을 옮기면(새 API `TabManager.Activate`) **내용만 바뀌고 네비게이션 강조는 그대로 남아** 두 곳이 어긋났고, 그 상태에서 강조된 탭을 눌러도 이미 선택된 항목이라 아무 일도 일어나지 않았다. |
| 2026-09-07 | **3-Way 를 4분할로 재구성 · 캐럿 가로 어긋남** | **완료.** ① 사용자 지적대로 **일반적인 3-Way 도구 구성(원본 3 + 결과 1)**으로 바꿨다. 위에 **LOCAL(내 작업 원본) · BASE(공통 조상) · REMOTE(고른 브랜치)** 를 읽기 전용으로 나란히, 아래에 **MERGED(최종 결과)** 를 폭 전체로 두고 여기서만 고친다. 원본을 넷째 칸으로 붙이지 않은 이유 — 한 칸이 40자도 안 돼 코드를 읽을 수 없다. 세로를 쓰면 위는 68자, 아래는 200자 넘게 나온다. 2-Way 는 아래 두 줄이 높이 0 이라 지금까지의 좌우 배치 그대로다. `MergeLine.OriginalLocalText` 를 새로 붙들어 둔다 — **이게 없으면 REMOTE 를 한 번 가져오는 순간 내 원래 내용이 화면에서 사라져** 정작 충돌을 판단할 근거를 잃는다. LOCAL 패널의 `[<]` 와 ACTIONS 의 `내 것으로` 가 Accept Mine, REMOTE 쪽 `[<]` 와 `저쪽 것으로` 가 Accept Theirs 다. 위 세 칸은 3-Way 에서 머리를 74px 로 줄이고 바닥을 감춰(같은 정보가 아래 상태바에 있다) 코드를 더 보여 준다. ② **캐럿이 글자 가운데에 박히던 것**: LOCAL 표시층의 본문 테두리(문제 줄 표시용 2px)가 레이아웃 폭을 먹어 **BASE·REMOTE 및 그 위에 겹친 편집기보다 글자가 2 DIP 오른쪽에서 시작**하고 있었다. 테두리와 안여백의 합을 세 패널 모두 2 로 맞춰 해결. ③ **상태바 열 번호가 틀리던 것**: `TextBox.GetLineIndexFromCharacterIndex`·`GetCharacterIndexFromLineIndex` 는 "화면에 보이는 줄" 기준이라 글을 갈아 끼운 직후 `CaretIndex` 와 어긋난다(11글자 줄에서 Home 을 눌렀는데 `10열`). 글에서 직접 세도록 바꿔 Home=1열·End=길이+1 로 정확해졌다. |
| 2026-09-07 | **작업 4 다른 언어·문서 지원** | **완료.** 새 파일 `Services/LanguageProfile.cs`(언어 표 + `LanguageCatalog`) · `Services/CodeTokenizer.cs`(표를 보고 도는 범용 토크나이저) · 테스트 **40/40**, 기존 하네스 전부 유지(Align 37 · Move 11 · Editor 14 · Rules 21 · Sanity 20 · Token 15 · Note 16 · ThreeWay 20). 언어마다 토크나이저를 만들지 않았다 — 색칠에 필요한 것은 "주석은 어디서 시작하나 · 문자열은 무엇으로 묶나 · 예약어는 무엇인가" 셋뿐이고 나머지는 어느 언어나 같아서, **표 하나에 적고 토크나이저 하나가 그 표를 본다**. C# 만 전용 토크나이저를 그대로 쓴다(축자·보간 문자열 `@"" $""` 과 접근제한자 구분은 표로 담기지 않고 이미 테스트로 굳어 있다). 담은 것: C# · C/C++ · Java · JS/TS · Python · SQL(대소문자 무시) · 셸/배치(`rem` 포함) · XML/HTML/XAML · JSON · YAML · INI/설정 · Markdown · **평문(기본값)**. 모르는 확장자는 평문이라 **색을 잘못 입히는 일이 없다**. `CodeSanityChecker.AppliesTo` 의 확장자 switch 를 걷어내고 언어 표의 `HasBraces` 하나로 판단하게 바꿔, 괄호로 블록을 여닫지 않는 형식(YAML·INI·평문)에는 검사를 걸지 않는다. 상태바에 `UTF-8 · LF · Python` 처럼 고른 언어를 띄운다. **이진 파일**은 앞 8KB 에 NUL 이 있으면 이진으로 보고(UTF-16 BOM 은 예외) 비교하지 않고 그렇다고 알린다 — 깨진 글자로 화면을 채우지 않기 위해서다. **실행 확인**: `.py` 두 개를 비교하니 `#` 주석·`def`/`if`·독스트링·숫자가 파이썬 규칙대로 칠해지고, 상태바가 `Python`, 코드 검사는 `검사 대상 아님`(파이썬은 중괄호를 쓰지 않는다)으로 나왔다. |
| 2026-09-07 | **작업 3 3-Way 비교** | **완료.** REMOTE 는 **사용자가 고르는 브랜치**다(상단 `3-Way 상대` 드롭다운). 고르면 BASE 가 `merge-base(HEAD, 고른 브랜치)`, LOCAL 은 워킹트리, REMOTE 는 그 브랜치의 파일이 된다 — BASE 를 HEAD 로 두면 "저쪽이 고친 것"과 "내가 고친 것"을 가를 수 없어서 반드시 공통 조상이어야 한다. 병합 중이면 브랜치를 고를 것도 없이 Git 이 들고 있는 세 벌(`GetThreeWayContent`)을 그대로 쓴다. 새 파일 `Services/ThreeWayAligner.cs` · 테스트 **20/20**. 세 글을 한꺼번에 정렬하지 않고 **BASE↔LOCAL · BASE↔REMOTE 두 번 정렬한 뒤 BASE 줄 번호로 맞물린다** — 검증된 2-way histogram 정렬기를 그대로 쓰기 위해서다. 양쪽이 같은 자리에 넣은 줄은 한 번 더 정렬해 짝지어 한 행으로 합친다(안 합치면 "둘 다 같은 줄 추가"가 충돌로 보인다). 판정은 `MergeLine.ThreeWay` — 같음 / 나만 고침 / 저쪽만 고침 / 둘 다 같게 / **충돌**. 색은 저쪽만 고침=남색, 충돌=자홍(가장 눈에 띄게, 이동·단순 차이보다 앞세움), 범례에도 3-Way 일 때만 두 항목이 붙는다. REMOTE 줄의 `[<]` 버튼과 ACTIONS 의 `저쪽 것으로` 로 그 줄을 가져오고, 되돌리기 스택은 BASE 반영과 같은 것을 쓴다. 3-Way 를 안 쓰면 REMOTE 칸은 **폭 0 으로 접힌다**(Visibility 만 숨기면 star 열이 자리를 그대로 차지한다). **실행 확인**: 데모 저장소에 공통 조상에서 갈라진 `feature/cooling` 을 만들어 붙였더니 `laser.Power`·`Wait()` 두 줄이 **충돌 2**, `fan.Speed = 70;` 이 **저쪽만 고침 1** 로 잡히고, `[<]` 로 가져오니 머지 주석까지 붙어 LOCAL 에 들어왔다(편집 1건). 세 패널의 같은 행이 정확히 같은 눈높이. **곁들여 고친 것**: `LoadFileAsync` 도 `RunAsync` 실패 시 화면을 갈아엎지 않게 막았다(파일 비교에서 겪은 것과 같은 결함). |
| 2026-09-07 | **캐럿 어긋남 · 입력 지연 · 화면 배율 · 데모 기본값** | **완료.** ① **캐럿이 글자에 안 맞던 원인**: 편집기와 표시층을 맞출 때 기준이 서로 다른 두 좌표를 견줬다. 별도 WPF 프로브(`scratchpad/LineProbe`)로 직접 재 보니 Consolas 12·줄높이 20 에서 `TextBox.GetRectFromCharacterIndex(0).Top = 4.67`(줄상자 위 여백을 지난 **글자 상자** 위쪽), `TextBlock` 의 `(0,0) = 0`(**줄상자** 위쪽). 이 여백만큼 표시층이 아래로 밀려 선택 영역이 글자보다 반 줄 위에 있는 것처럼 보였다. 여백을 `BaselineOffset − FontFamily.Baseline×FontSize` 로 글꼴에서 직접 구해 빼도록 고침(상수를 안 써서 배율이 바뀌어도 따라간다). 덧붙여 **BASE 패널에도 같은 이동값**을 걸었다 — LOCAL 만 밀면 두 패널의 줄이 6px 어긋난다(UIA 로 확인: 어긋남 0). ② **입력 지연의 남은 절반**: 화면 글자는 편집기가 아니라 표시층이 그리는데(편집기 글자는 투명) 표시층을 600ms 재정렬 타이머에만 맡겨 **친 글자가 그때까지 안 보였다**. `ShowTypedLineNow` 로 캐럿이 있는 줄만 즉시 반영(줄 수가 달라지면 재정렬에 맡김). **실행 확인**: 타이핑 120ms 뒤 촬영해 `-RC7` 이 이미 보이고 캐럿이 글자 끝에 정확히 붙음. ③ **화면 배율**: 지금까지 상·하단 바만 커지고 정작 작업 화면은 그대로였다. 플랫폼 `ShellWindow` 의 콘텐츠에 `LayoutTransform` 을 걸고 `UiSettingsService.ContentScale`(= ChromeScale ÷ 기준 1.2, 즉 "100% = 지금 그대로") 추가. **실행 확인**: 80% 에서 코드 행 높이 30px → 24px(UIA 측정), 같은 화면에 더 많은 줄. ④ **데모를 .git 없는 폴더로**: `plain-compare` 의 용접 레시피 두 개(`WeldRecipe_기준.txt` ↔ `WeldRecipe_내작업.txt`)를 기본으로 열게 함(`DefaultLeftFile`/`DefaultRightFile` 설정 추가). **곁들여 고친 실제 결함**: 저장소가 아닌 폴더에 자동 열기를 걸면 실패할 뿐 아니라 **작업 게이트를 쥐고 있어 뒤따르는 파일 비교가 통째로 취소**돼 화면이 `차이 없음` 으로 비었다. `GitWorkspace.LooksLikeRepository` 로 저장소일 때만 자동 열기를 시도하고, `LoadPlainFilesAsync` 도 `RunAsync` 실패 시 화면을 갈아엎지 않게 함. |
| 2026-09-07 | **작업 2 Git 유무 분기** | **완료.** `.git` 이 없으면 커밋 이력 칸을 접고(폭 0) Git 상태·새로고침·파일 목록·커밋 상세·커밋 메시지를 숨긴다. 대신 상단에 **기준 파일 / 내 파일** 고르기가 나오고, 패널 제목이 `기준 파일 / 내 파일` 로 바뀌며(Git 용어 안 씀), 저장 버튼이 `Save & Commit` → `저장`(커밋 없이 오른쪽 파일에 쓰기)이 된다. 모드는 상단 배지로 항상 보인다. 새 파일 `Services/TextFile.cs` — 인코딩·줄바꿈 판별 규칙을 `GitService` 와 동일하게 맞춰, 같은 파일을 어느 경로로 열든 결과가 같고 저장할 때 형식이 바뀌지 않는다. **실행 확인**: Git 아닌 폴더의 `left.txt`↔`right.txt` 비교 → 다름 1 · 추가 1, 전체 반영 후 저장하니 파일이 정확히 갱신됨(머지 주석 포함). Git 모드 복귀도 이상 없음. |
| 2026-09-07 | **설정 반영·패널 높이·ACTIONS 잘림 수정** | 플랫폼 `SettingsService` 인스턴스 분리 버그를 고쳐(아래 플랫폼 변경 이력) **설정 → 저장 → 홈 복귀만으로 재비교**되게 함(`RecompareIfSettingsChanged`, 홈이 다시 보일 때 설정 서명이 바뀌었으면 한 번만). BASE/LOCAL 하단 요약 칸을 공통 높이(`PaneFooterHeight` 54)로 맞춰 두 패널의 코드 영역 크기를 같게 함 — LOCAL 쪽 3줄을 2줄로 합침. ACTIONS 패널에 스크롤을 주고 폭을 196 → 214 로 넓혀 잘리지 않게 함. |
| 2026-09-07 | **타이핑 지연 해소 · 버튼 재배치 · 검사 팝업** | **완료.** ① **지연의 진짜 원인**은 겹친 레이어가 아니라 글자마다 도는 두 가지였다 — 문서 전체를 문자열로 복사(`TextBox.Text` 양방향 바인딩)와 **전체 줄을 훑는 차이 판정**(무시 규칙이 켜져 있으면 양쪽 줄을 정규화). 15,005줄 파일 기준 각각 0.12ms / **2.19ms** 한 타. 바인딩을 걷어내 편집기가 글을 직접 들고(ViewModel 은 필요할 때만 읽음), 차이 덩어리 목록을 캐시하고, 캐럿이 같은 줄 안에서 움직이면 명령 상태 갱신을 건너뛰게 했다. 캐시 조회는 0.000ms. ② 차이 탐색·비교 규칙 버튼을 코드 패널 위에서 **ACTIONS 의 커밋 메시지 아래**로 옮김(읽는 자리와 조작하는 자리를 분리). 머리 높이도 150 → 124 로 복귀. ③ 코드 검사 요약을 눌러 **팝업으로 문제 목록**을 보고, 항목을 누르면 그 줄로 이동. **곁들여**: 화살표 글리프(⤒↑↓⤓)가 이 서체에 없어 빈 버튼으로 나오던 것을 글자로 교체, 파일 로드 시 차이 캐시가 갱신되지 않아 `차이 없음` 으로 잘못 나오던 것 수정. |
| 2026-09-07 | **1-2 무시 규칙** | **완료.** `Services/CompareRules.cs` · 테스트 21/21, 기존 정렬 37/37 유지. 공백 3단계 · 대소문자 · 정규식 줄 필터. 툴바에서 즉시 토글되고 상태바에 `무시: 공백 개수 · 대소문자 · 줄필터 1` 로 요약된다. **구현 갈래가 둘**이다 — 공백·대소문자는 정렬 단계의 *비교용 키*(`LineAligner.Align` 에 keys 인자 추가, 원문은 보존), 줄 필터는 정렬 뒤 행 표시(`MergeLine.IsIgnored`). 필터를 정렬 키에 섞으면 걸린 줄끼리 같은 것으로 묶여 엉뚱하게 짝지어진다. **곁들여 고친 것**: ① `MergeLine.State` 가 원문끼리만 비교해 규칙이 안 먹던 문제 → 정규화 함수를 넘겨 해결 ② 무시된 줄이 차이 탐색에 걸리던 문제. **실행 확인**: 다름 3 → (공백) 1 → (대소문자) 0, 추가 3 → (줄필터) 1 + 무시 2, `차이 - / 2` → `- / 1`. |
| 2026-09-07 | **1-1-B 이동 블록 감지** | **완료.** `Services/MoveDetector.cs` · 테스트 11/11. 지워진 구간과 추가된 구간에서 **줄이 이어지는 최장 공통 부분**을 찾아 이동으로 묶는다(구간 전체를 견주면 정렬기가 `}`·빈 줄을 한쪽에 붙여 놓아 어긋난다). 2줄 미만이거나 중괄호·빈 줄만인 구간은 제외. 색은 청록(삭제/추가와 계열 구분), 범례에 `자리 옮김` 추가, 툴팁에 `24행으로 옮김`. **실행 확인**: 데모 파일의 `Cooldown()` 을 옮겨 두니 범례가 `자리 옮김 6` 으로 잡힘. |
| 2026-09-07 | **LOCAL 행 구조 개편 (워드랩 준비)** | 여백(줄번호·`[>]`)을 별도 컨트롤에서 **행 안쪽으로** 옮겨 높이가 저절로 맞게 함. 가로 스크롤 때는 글자만 `TranslateTransform` 으로 밀고 열에서 클리핑. BASE 여백도 54로 맞추고, 세 곳의 글자 폭을 스크롤바·미니맵 폭만큼 보정해 접히는 자리를 일치시킴. 워드랩 토글 버튼 추가. **화면 확인 완료** — 가로 스크롤 시 여백 고정, 줄 번호가 두 패널에서 정확히 같은 눈높이. 워드랩도 양쪽이 같은 자리에서 접히고 행이 맞물림(`MergeLine.RowHeight` 로 큰 쪽 높이를 공유). |
| 2026-09-07 | **1-1 파일 비교 (워드랩 제외)** | **완료.** 차이 탐색(첫/이전/다음/마지막 · `Alt+↑↓` `Ctrl+Home/End`, 끝에서 순환) · 상태바(`12행 5열` · `차이 2 / 3` · 인코딩 · 줄바꿈) · `Ctrl+G` 줄 이동 · 북마크(여백 더블클릭, `F2`) · 편집 잠금. 차이는 **덩어리 단위**로 센다(12줄 삭제 = 차이 1개). 인코딩·줄바꿈 판별은 `GitService.DescribeWorkingFile` 로 노출. **워드랩은 남김** — 아래 참고. |
| 2026-09-07 | **LOCAL 패널을 상시 편집기로 전환** | **완료.** "전체 편집" 모드 버튼 삭제. LOCAL 칸이 항상 편집 가능(여러 줄 선택·붙여넣기·삭제). 화면은 세 겹 — 왼쪽 여백(줄번호·`[>]`) / 색·문법 표시층 / 투명 편집기. BASE 와 눈높이를 맞추려고 지워진 줄 자리에 빈 줄(필러)을 넣고, 저장·재정렬 전에 `EditorLayout.StripFillers` 로 걷어낸다(테스트 14/14). 타이핑이 멈추면(600ms) 다시 정렬. **곁들여 고친 것**: 편집 후 원래부터 BASE 와 달랐던 줄까지 전부 "직접 수정"으로 칠해지던 문제 → 파일을 연 시점 LOCAL 과 견줘 실제로 손댄 줄만 표시. |
| 2026-09-07 | **1-1-A 정렬 교체 (LCS → Histogram)** | **완료.** `AlignTest` 26건 유지 + 신규 11건 = **37/37 통과**. 빌드 경고 0. 데모 파일에서 중복된 `fan.Off();` 가 엉뚱한 짝에 붙던 문제가 실제로 해소됨. 안전선을 표 크기(`MaxCells` 400만 칸)에서 줄 수(`MaxRegionLines` 2만 줄)로 바꿈 — 히스토그램은 표를 만들지 않으므로 예전보다 훨씬 큰 구간도 정확히 정렬한다. |

View File

@ -0,0 +1,176 @@
# 작업 지시 — MergeHub 사용성 보강 (2차)
대상: `D:\Sorce\MergeTool\MergeHub` (플러그인) · `D:\Sorce\MergeTool\UbisamPlatform` (셸)
전제: 1차 지시서(`작업지시-머지툴보강.md`)의 작업 1~4 · 1-1~1-3 은 모두 끝난 상태다.
---
## 공통 규칙
- **항목 하나를 끝낼 때마다 빌드하고 실제로 실행해 화면을 확인한 뒤 보고할 것.** 전부 끝내고 한 번에 보고하지 말 것.
- 기존 테스트 하네스(Align 37 · Move 11 · Editor 14 · Rules 21 · Sanity 20 · Token 15 · Note 16 · ThreeWay 20 · Lang 40 · Folder 27 = **221건**)는 계속 통과해야 한다. 로직을 건드리면 테스트를 먼저 늘린다.
- 빌드 경고 0 · 오류 0 을 유지한다.
- 플랫폼(`UbisamPlatform`)을 고치면 **다른 소비 프로그램에 미치는 영향**을 함께 적고 `doc/history/changelog.md` 에 남긴다.
---
## 1. 사용자 이름 등록
### 1-1. 첫 실행 시 이름 입력
- 프로그램을 켰을 때 **등록된 사용자 이름이 없으면** 이름을 묻는 창을 띄운다.
- 입력값은 `D:\UbisamConfig` 아래에 저장한다.
- 이미 등록돼 있으면 창을 띄우지 않는다. **매번 뜨면 안 된다.**
- 설정 화면에서 언제든 바꿀 수 있어야 한다(지금의 `MergeHub > 머지 주석 > 작성자` 칸이 그 자리다).
- 빈 값으로 넘어가려 하면 막거나, 넘어가더라도 다음 실행에 다시 묻는다.
### 1-2. 머지 주석이 이 이름을 쓴다
현재 `GitWorkspace.ResolveAuthorName()` 이 **설정값 → `git config user.name` → 윈도우 계정** 순으로 고른다.
1-1 에서 등록한 이름이 이 "설정값" 자리에 들어가면 된다 — **순서 자체는 그대로 두는 것이 맞다**(설정을 비워 두면 Git 설정을 따라가는 지금 동작이 쓸모 있다).
**판정 기준**: 이름을 처음 등록하고 아무 줄이나 반영하면 주석에 그 이름이 나온다. 프로그램을 다시 켜도 묻지 않는다.
---
## 2. 머지 주석을 "쌓이게" 바꾸기
### 2-1. 지금 동작
`Services/MergeNote.cs` 가 **줄 끝에** 주석을 붙인다(`Append`). 그 파일 주석에 적힌 이유는:
> 줄 "위"가 아니라 "끝"에 붙인다 - BASE/LOCAL 을 같은 줄 번호로 나란히 보여주는 구조라
> 줄을 새로 끼워 넣으면 두 패널의 정렬이 통째로 어긋난다.
**이 제약이 지금도 유효한지 먼저 확인하라.** 그동안 정렬기(histogram)와 필러 처리가 바뀌었으므로, 줄을 끼워 넣어도 정렬이 스스로 맞을 수 있다. 유효하다면 어떻게 우회할지 설계부터 적고 시작한다.
### 2-2. 바꿀 동작
- 주석을 **그 줄 위에 별도 줄로** 넣는다(줄 끝 X).
- 같은 줄을 다시 반영하면 **기존 주석을 지우지 않고 새 주석을 추가**한다.
- 쌓이는 순서는 **새 주석이 코드 줄 바로 위**, 오래된 주석일수록 위로 밀린다.
```
// [hong] 2026-09-01 1차 반영 ← 가장 오래된 것이 맨 위
// [kim] 2026-09-05 재확인
// [lee] 2026-09-07 최종 ← 가장 최근이 코드 바로 위
laser.SetPower(2000);
```
- 주석 기호는 지금처럼 언어를 따른다(`MergeNote.CommentTokensFor`). **`LanguageProfile` 이 이미 언어별 주석 기호를 들고 있으므로 둘 중 하나로 합치는 것을 검토하라** — 같은 지식이 두 곳에 있으면 언젠가 어긋난다.
### 2-3. 반드시 확인할 것
- 주석 줄이 늘어나도 **BASE ↔ LOCAL(↔ REMOTE ↔ MERGED) 행이 어긋나지 않아야 한다.**
- 저장했다가 다시 열었을 때 주석이 **중복으로 다시 붙지 않아야 한다.**
- 되돌리기(Undo · Revert · 전체 되돌리기)로 주석도 함께 사라져야 한다.
- `NoteTest` 하네스(16건)를 이 동작에 맞게 늘린다.
---
## 3. 차이 이동에서 행이 안 맞는 문제 (결함)
`이전 차이` · `다음 차이` · `처음` · `마지막` 을 누르면 **행 위치가 어긋난다.**
- 재현 조건부터 정확히 적어라 — 워드랩 켬/끔, 2-Way/3-Way, 필러 줄이 있는 경우 각각.
- 관련 코드: `HomeViewModel` 의 `_diffStarts` · `ComputeDiffBlockStarts` · `GoToRow`,
`HomeView.OnScrollToRowRequested`(현재 `LocalEditor.ScrollToLine(row - 3)` 으로 위 여유 3줄을 둔다).
- **의심 지점**: `ScrollToRowRequested` 는 편집기의 **줄 인덱스**로 스크롤하는데, 표시층은 ListBox 의 **픽셀**로 따라간다. 워드랩이 켜져 있으면 한 행이 여러 시각 줄이라 둘이 어긋난다.
- 고친 뒤에는 **선택된 행이 화면 안에 들어오고**, 세 패널(또는 네 패널)이 같은 행에 서 있어야 한다.
**판정 기준**: 워드랩을 켠 3-Way 상태에서 `처음`→`다음 차이`를 끝까지 눌러도 매번 그 차이 줄이 화면에 보이고 네 패널이 같은 줄을 가리킨다.
---
## 4. WinMerge 단축키
### 4-1. 적용
지금 있는 것: `Alt+↑/↓`(이전/다음 차이) · `Ctrl+Home/End`(처음/마지막) · `Ctrl+G`(줄 이동) · `F2`(북마크).
WinMerge 기준으로 맞추고 빠진 것을 채운다. 최소한 아래는 넣는다.
| 키 | 동작 |
|---|---|
| `Alt+↓` / `Alt+↑` | 다음 / 이전 차이 |
| `Alt+Home` / `Alt+End` | 첫 / 마지막 차이 |
| `Alt+←` / `Alt+→` | 왼쪽→오른쪽 / 오른쪽→왼쪽 반영 (3-Way 에서는 Take Mine / Take Theirs) |
| `Ctrl+S` | 저장 |
| `F5` | 다시 비교 |
| `F7` / `Shift+F7` | 다음 / 이전 차이(대체 키) |
| `Ctrl+G` | 줄 이동 |
| `F2` | 다음 북마크 |
| `Ctrl+F` | 찾기 (없으면 **이 항목은 범위 밖으로 빼고 그렇다고 보고**할 것) |
- 편집기가 먼저 삼키는 키가 있으므로 `PreviewKeyDown` 에서 가로챈다(지금 방식 유지).
- **기존 편집 동작을 뺏으면 안 된다** — 예: `Ctrl+A`, `Ctrl+Z` 는 편집기 것이다.
- 실제로 WinMerge 와 다르게 정한 것이 있으면 **왜 다른지 적어라.**
### 4-2. 단축키 설명 창
- **우측 상단에 버튼**을 두고, 누르면 단축키 목록 창이 뜬다.
- "우측 상단"이 어디인지 확인할 것 — 지금 화면에서 그 자리는 **ACTIONS 패널 위쪽**이거나 **상단 툴바 오른쪽 끝**이다. 둘 중 눈에 띄고 다른 것을 밀어내지 않는 자리를 고르고 이유를 적는다.
- 목록은 **코드에 박은 문자열이 아니라 실제 키 처리와 한 곳에서 나와야 한다** — 둘이 갈라지면 설명이 거짓말이 된다.
---
## 5. 소스가 더 잘 보이게 — 상·하단 줄이기 (플랫폼)
**이건 플랫폼 변경이라 다른 소비 프로그램에 모두 영향이 간다. 먼저 영향 범위를 적고 시작할 것.**
### 5-1. 상단 브랜딩 바
`ShellWindow.xaml` — `Padding="26,14"`, 로고 `Height="30"`, 장비명 `FontSize="18"`, 시계 `FontSize="25"`.
### 5-2. 하단 탭 바
`ShellStyles.xaml` 의 `Ubisam.Style.SidebarItem.Horizontal` — `Padding="30,13,30,14"`, 아이콘 `22x22`, 아이콘 아래 여백 `7`, 글자 `13.5`.
### 5-3. 방향
- 두 줄을 합쳐 **세로로 최소 40~60px 은 벌어야** 코드가 눈에 띄게 더 보인다.
- 다만 **터치로도 누르는 장비**라는 점을 잊지 말 것. 탭은 손가락으로 누를 수 있는 크기를 유지해야 한다 — 무작정 줄이면 안 된다.
- 가능하면 **크기를 상수로 박지 말고** 지금의 화면 배율(`UiSettingsService.ContentScale`)처럼 한 곳에서 정하도록 빼라. 장비마다 다르게 쓸 여지가 생긴다.
- 줄이기 전후의 **코드 표시 줄 수를 실제로 세어** 얼마나 늘었는지 숫자로 보고할 것.
---
## 진행 순서
```
1 사용자 이름 등록 (독립적, 먼저 끝내면 다른 작업의 주석 확인이 쉬워진다)
3 차이 이동 행 어긋남 (결함 — 다른 작업 확인에 계속 걸린다)
2 머지 주석 쌓기 (정렬에 영향이 크므로 3 이 끝난 뒤)
4 단축키 + 설명 창
5 상·하단 줄이기 (플랫폼)
```
2번을 3번 뒤로 미룬 이유 — 주석 줄이 늘어나면 행 정렬이 흔들리는데, 그 상태에서 차이 이동 결함까지 겹치면 무엇이 원인인지 가릴 수 없다.
---
## 결정 사항 (2026-09-07, 사용자 확인 완료)
1. **주석은 고친 줄 위에 올린다.** 새 주석이 코드 줄 바로 위에 붙고 기존 주석은 위로 밀린다.
정렬 제약은 우회해서라도 이 동작으로 간다.
2. **사용자 이름은 플랫폼의 "현재 사용자"로 둔다.** 다른 소비 프로그램도 쓸 수 있어야 한다.
3. **찾기(`Ctrl+F`) 를 포함해 머지툴에 맞는 단축키를 전부 넣는다.** 찾기 기능 자체도 만든다.
4. **터치는 필수가 아니다.** 상·하단을 줄인다.
---
## 진행 기록
| 날짜 | 항목 | 결과 |
|---|---|---|
| 2026-09-07 | 문서 작성 | 착수 전 |
| 2026-09-07 | **1. 사용자 이름 등록** | **완료.** 플랫폼에 `CurrentUserService` 신설(`D:\UbisamConfig\Settings\CurrentUserService.json`). 이름이 없으면 첫 실행에 `UserNameDialog` 로 한 번만 묻는다(취소 버튼 없음 - 넘어가면 다음 실행에 또 물어야 하고, 그러면 "매번 뜨는 성가신 창"이 된다). 소비 프로그램은 DI 로 주입받아 쓴다. `GitWorkspace.ResolveAuthorName()` 순서는 **MergeHub 설정 → 플랫폼 사용자 → git config → 윈도우 계정**. 플랫폼 사용자를 git config 보다 앞에 둔 이유 - 사람이 직접 적어 넣은 이름이고, 장비를 여럿이 쓰는 현장에서는 그게 실제 작업자다. **곁들여 고친 것**: 작성자 이름을 저장소를 읽을 때만 정하고 있어서, **Git 없이 파일 두 개를 비교하면 이름이 비고 주석에 윈도우 계정이 찍혔다.** 생성 시점에 한 번 정하고 설정에서 바꾸면 따라가게 했다. **실행 확인**: 이름 없이 켜면 창이 뜨고, `김상준` 등록 뒤 다시 켜면 안 뜬다. 주석 미리보기가 `// [김상준] 2026-09-07`. |
| 2026-09-07 | **3. 차이 이동 행 어긋남** | **완료.** 원인 둘. ① `OnScrollToRowRequested` 가 **논리 행 번호를 "화면에 보이는 줄" API**(`GetCharacterIndexFromLineIndex`)에 그대로 넘기고 있었다. 워드랩이 켜지면 한 행이 여러 줄로 접히므로 캐럿이 딴 줄로 갔다 - 실제로 상태바는 `6행`인데 화면에서 선택된 줄은 8행이었고 **아래로 갈수록 더 벌어졌다**(9행에서는 2줄 차이). 글에서 직접 세어 논리 행 → 글자 위치로 바꾸도록 고쳤다. ② 그렇게 고쳐도 **화면이 안 굴러갔다** - `TextBox.ScrollToLine` 이 이 화면(편집기가 세 겹의 스크롤 주인)에서는 듣지 않아, `마지막`을 눌러도 맨 위에 남았다. 캐럿의 실제 좌표를 재서 스크롤뷰어를 직접 옮기도록 바꿨다(워드랩이든 아니든 같은 방식). **실행 확인**: 워드랩 켠 상태로 `처음`→`다음 차이`를 끝까지, `마지막`, `이전 차이`까지 눌러도 상태바 행과 화면에서 선택된 행이 매번 일치(5↔4, 8↔7, 10↔9, 27↔26, 38↔37). |
| 2026-09-07 | **2. 머지 주석을 위로 쌓기** | **완료.** 주석을 줄 끝이 아니라 **고친 줄 바로 위에 한 줄로** 넣는다. 코드 줄의 들여쓰기를 그대로 따라간다. 같은 줄을 다시 반영하면 새 주석이 코드 바로 위에 들어가고 **앞서 남긴 주석은 위로 밀린다**(지우지 않는다). 정렬은 저절로 맞는다 - 끼워 넣은 줄은 "LOCAL 에만 있는 줄"이라 이미 있던 추가 줄 처리대로 BASE 쪽에 빈 자리가 생긴다. `MergeNote.cs` 에 적혀 있던 "줄을 끼워 넣으면 정렬이 어긋난다"는 제약은 그동안 정렬기와 필러 처리가 바뀌어 **더는 유효하지 않았다.** **여기서 결함 하나를 잡았다**: 주석 줄이 BASE 에 없는 줄이라 차이로 잡혔고, 그래서 **다음 Accept 나 전체 반영이 방금 남긴 주석을 지워 버렸다**("BASE 를 따른다 = BASE 에 없는 줄은 없앤다" 규칙 때문). `MergeLine.IsMergeNote` 를 두어 주석 줄은 차이로 세지도, 반영 대상이 되지도 않게 했다. Undo/Redo/Revert 는 `LineEdit.InsertedNote` 로 주석 줄까지 함께 넣고 뺀다 - 본문만 되돌리고 주석을 남기면 파일에 유령 주석이 쌓인다. **실행 확인**: 전체 되돌리기 43줄·주석 0 → 1회 반영 44줄·주석 1(반영한 줄 바로 위) → Undo 43줄·주석 0 → Redo 44줄·주석 1. 전체 반영은 반영한 줄마다 각자 위에 주석이 붙는다. |
| 2026-09-07 | **4. WinMerge 단축키 + 설명 창** | **완료.** 키 표를 `Services/ShortcutMap.cs` 한 곳으로 모았다 — 키 처리(`HomeView.View_PreviewKeyDown` → `RunShortcut`)와 설명 창(`Views/ShortcutHelpWindow`)이 **같은 목록**을 읽으므로 설명이 실제 동작과 갈라질 수 없다. 넣은 키: `Alt+↓/↑`·`F4`/`Shift+F4`(다음·이전 차이), `Alt+Home/End`·`Ctrl+Home/End`(첫·마지막 차이), `Alt+→/←`(왼→오른·오른→왼 반영), `Ctrl+Z`/`Ctrl+Y`, `Ctrl+M`(북마크 설정/해제)·`Alt+PageDown`·`F2`(다음 북마크), `Ctrl+G`, `Ctrl+D`(폴더 비교), `F5`·`Ctrl+R`, `Ctrl+S`, `Ctrl+F`·`F3`/`Shift+F3`·`Esc`, `F1`(이 목록). **찾기 기능 자체도 만들었다** — 편집기 위에 떠 있는 찾기 칸(패널 머리·바닥에 끼우면 열 때마다 BASE 와 높이가 어긋난다), 대소문자 구분, 앞뒤로 한 바퀴 돌기, 몇 개인지 표시. 처리는 편집기가 아니라 **화면 전체(`UserControl.PreviewKeyDown`)** 에 건다 — 편집기에만 걸면 파일 목록이나 찾기 칸에 초점이 있을 때 단축키가 죽는다. **WinMerge 와 다르게 정한 것 3가지**: ① `Ctrl+W`(탭 닫기)는 넣지 않았다 — 탭이 고정이라 닫을 것이 없고, 아무 일도 않는 키는 없느니만 못하다. ② `Ctrl+D` 는 새 창이 아니라 폴더 비교 **탭**으로 간다 — 창을 여러 개 띄우는 도구가 아니다. ③ `Ctrl+Z` 는 머지 반영을 되돌리되, **되돌릴 머지 작업이 없으면 키를 먹지 않아** 편집기의 글자 되돌리기가 그대로 산다(`Run()` 이 `CanExecute` 를 보고 판단). `Alt` 를 누르면 WPF 가 `e.Key` 에 `Key.System` 을 넣고 진짜 키를 `e.SystemKey` 로 보내므로 그것부터 풀어 준다. 버튼은 **상단 상태 바 오른쪽 끝**에 뒀다 — 그 줄이 내용에 따라 늘었다 줄었다 하는 `WrapPanel` 이라 그대로 넣으면 자리가 떠다녀서, `DockPanel` 로 감싸 오른쪽에 붙박았다. `ShortcutTest`(58건) 신설: 키 중복·풀림·편집기 몫 침범 여부·표기·묶음 순서·WinMerge 핵심 키 존재를 본다. **실행 확인**: `Alt+Home`→첫 차이(5행), `Alt+↓`→다음 차이(8행), `Alt+→`→그 줄 반영(주석 포함, 머지 반영함 1), `Ctrl+Z`→되돌림(머지 반영함 0·Redo 켜짐). `Ctrl+F` 로 "Polarize" 찾기 3개, 27행 20열로 이동. `F1`/버튼 둘 다 목록 창이 뜬다. |
| 2026-09-07 | **5. 상·하단 줄이기 (플랫폼)** | **완료.** 크기를 XAML 안에 박아 두지 않고 `ShellStyles.xaml` 에 `Ubisam.Metric.BrandingBar.*` · `Ubisam.Metric.NavItem.*` 로 빼서, 소비 프로그램이 `Application.Current.Resources` 에서 같은 키를 덮어쓰면 장비마다 다르게 쓸 수 있다(기존 `Ubisam.Metric.ContentPadding` 과 같은 방식). 값: 브랜딩 바 여백 `26,14`→`26,7`, 로고 30→24, 구분선 24→18, 장비명 18→15, 시계 25→19 / 탭 여백 `30,13,30,14`→`30,6,30,7`, 아이콘 22→19, 아이콘 아래 여백 7→3, 글자 13.5→12.5. **줄 수 확인(2560×1600 @150%, 최대화)**: 하단 탭 바 135px→95px, **코드 편집 영역 958px→1038px(+80px)**, 코드 표시 줄 **31줄 → 34줄(+3줄)**. 화면 배율 100% 기준 약 53 DIP 를 벌었다(목표 40~60). **다른 소비 프로그램 영향**: 플랫폼 전체에 걸리므로 모든 프로그램의 상·하단 띠가 같이 얇아진다. 터치 장비라면 위 세 값(`NavItem.Padding`·`IconSize`·`FontSize`)만 되돌리면 원래 크기다. |
| 2026-09-07 | **곁들여 고친 것 3가지** | ① **켤 때마다 이름 확인**: `UserNameDialog.AskIfMissing` → `Ask`. 한 장비를 여러 사람이 쓰므로 앞사람 이름으로 기록이 남으면 안 된다. **지난번 이름이 미리 채워지고 전체 선택돼 있어 같은 사람이면 Enter 한 번**이다. ② **마우스 가로 휠**: WPF 에는 가로 휠 이벤트가 아예 없어(`MouseWheel` 은 세로뿐) 창 메시지 `WM_MOUSEHWHEEL`(0x020E) 을 직접 후킹했다. **여기서 결함 하나를 더 잡았다** — 가로로 밀 때 **BASE·REMOTE·LOCAL 원본 패널이 따라오지 않았다.** 표시층 ListBox 를 가로로 굴리려 했는데, 행이 칸 너비에 맞춰 늘어나 굴릴 여지(`ScrollableWidth`)가 0 이라 아무 일도 일어나지 않았던 것. LOCAL 처럼 **글자만 `TextShiftX` 만큼 미는 방식**으로 네 패널을 통일했다(여백·줄번호는 제자리). ③ **아래로 스크롤한 상태에서 전체 되돌리기를 하면 글자가 위로 밀리던 것**: `AlignDecorationToEditor` 가 편집기의 **0번째 글자**를 표시층의 **처음 그려진 행**과 견주고 있었다. 가상화 때문에 스크롤한 뒤에는 그 행이 0행이 아니라서, 스크롤한 거리만큼 어긋난 보정값이 들어갔다. 컨테이너에서 실제 행 번호를 얻어 같은 줄끼리 견주도록 고쳤다. **실행 확인**: 전체 반영 후 아래로 스크롤 → 전체 되돌리기 → 줄 번호와 코드가 모든 행에서 일치. |
| 2026-09-07 | **가로 휠 — 3-Way 확인 중 결함 발견·수정** | 3-Way 에서 가로 휠이 **아무 일도 하지 않았다.** 스크롤 주인인 MERGED 는 폭 전체를 쓰므로 밀 여지가 없는데(`ScrollableWidth` 0), 정작 잘려 보이는 것은 위의 좁은 원본 세 칸(LOCAL(Mine)·BASE·REMOTE)이었기 때문이다. 게다가 그 세 칸에는 **각자 따로 노는 가로 스크롤 막대**가 달려 있어, 끌면 줄 번호까지 같이 밀려 나가고 세 칸이 서로 어긋났다. **바로잡은 방식**: 가로 스크롤 그룹을 둘로 나눴다 — 폭이 같은 것끼리만 묶는다. ① 위 원본 세 칸(3-Way) = 한 덩어리, ② MERGED(2-Way 에서는 BASE+LOCAL) = 한 덩어리. 원본 칸용으로 `SourceShiftX` 를 새로 두고 세 템플릿이 그 값만큼 **글자만** 민다(줄 번호·`[<]` 는 제자리). 2-Way 에서는 BASE 와 LOCAL 의 폭이 같으므로 `SourceShiftX` 가 `TextShiftX` 를 그대로 따라간다 — 지금까지의 동작 그대로다. 휠은 **마우스가 놓인 칸**을 민다(`WM_MOUSEHWHEEL` 의 좌표 대신 `Mouse.GetPosition` 을 쓴다 — DPI 가 섞인 화면에서 창 메시지의 픽셀 좌표를 직접 환산하면 어긋난다). 원본 칸의 제 스크롤 막대는 `Hidden` 으로 껐다 - 두 방식이 겹치면 같은 것을 두 번 미는 꼴이 된다. **실행 확인**(mergehub-demo, 3-Way = feature/cooling): BASE 위에서 오른쪽으로 6칸 → 세 칸이 **똑같이** 밀리고 줄 번호는 제자리, MERGED 는 그대로. 왼쪽으로 10칸 → 처음 자리에서 정확히 멈춤(과도 스크롤 없음). 2-Way 재확인: 100%→39.5%, BASE 동반 이동. |
| 2026-09-07 | **3-Way 확인 중 결함 둘 더 잡음 (원본 칸이 비어 버림 · 워드랩이 안 접힘)** | ① **MERGED 에 글자를 하나만 쳐도 LOCAL(Mine)·REMOTE 칸이 통째로 비었다.** 사용자는 "가로 휠을 하면 글이 사라진다"고 보았지만, 실제 원인은 휠이 아니라 **편집 후 재정렬**이었다 - `ReloadLinesFrom` 이 3-Way 여부와 무관하게 늘 `BuildLines`(2-Way)로 다시 만들고 있었다. 칸(레이아웃)은 `IsThreeWay` 를 따라 그대로 남고 내용만 빠지니 "글자가 사라진" 것처럼 보인다. REMOTE 전문을 `_remoteTextSnapshot` 으로 붙들어 두고, 3-Way 면 `BuildThreeWayLines` 로 다시 맞추게 고쳤다. **실행 확인**: MERGED 에 `abc` 한 번, 150자짜리 긴 줄 한 번 - 두 경우 모두 세 칸의 내용·Conflict 3·Theirs only 1 이 그대로 남는다. ② **워드랩을 켜도 원본 세 칸이 접히지 않았다.** 가로 이동량을 재려고 그 칸들의 가로 스크롤을 `Hidden`(막대만 감춤, 굴리기는 가능)으로 둔 것이 원인 - 굴릴 수 있으면 WPF 는 칸 너비를 <b>무한</b>으로 잡아 재므로 접힐 자리가 없다. 워드랩을 켜면 `Disabled`(너비 고정), 끄면 `Hidden`(폭 측정 가능)로 바꾸는 `SourceBars` 를 두어 해결. **실행 확인**: 워드랩 켠 3-Way 에서 LOCAL(Mine) 07행이 4줄, REMOTE 05행이 3줄로 접히고 BASE 쪽 빈자리 높이가 같이 늘어 **세 칸의 같은 행이 같은 눈높이**에 온다(09/05/06행 · 11/07/08행 · 19/15/16행 확인). 덤으로 표시층 ListBox 가 **스스로 굴러가지 못하게** 막았다(`BlockSelfScroll`) - ListBox 가 "고른 줄을 보이게" 하려고 제멋대로 굴리면 여백까지 밀려 나간다. 미니맵 이동도 편집기 캐럿 기준으로 바꿨다(표시층을 직접 굴리지 않는다). |
| 2026-09-07 | **남은 것 2가지 (확인됨, 미수정)** | ① **3-Way + 워드랩에서 위 세 칸과 아래 MERGED 의 세로 위치가 어긋난다.** 두 무리의 칸 너비가 달라 접히는 자리가 다르고, 따라서 같은 행이라도 높이가 다르다. 지금은 <b>픽셀 오프셋</b>으로 맞추므로 접힌 줄을 지날 때마다 조금씩 벌어진다(4칸 굴린 뒤 2행 차이). 제대로 맞추려면 픽셀이 아니라 **행 번호로** 맞춰야 하고, 그러려면 양쪽 모두 "N행의 픽셀 위치"를 되돌려 주는 길이 필요하다 - 스크롤 동기화를 다시 짜는 일이라 따로 잡는 편이 안전하다. 워드랩을 끄면 모든 칸의 행 높이가 같아 어긋나지 않는다. ② **MERGED 를 직접 편집하면 LOCAL(Mine) 칸도 그 편집을 따라간다.** MERGED 는 Mine 의 복사본에서 출발하고, 편집 후 재정렬은 "지금 글"로 다시 맞추기 때문이다. "파일을 연 시점의 내 줄"로 얼어붙게 하려면 편집 전 LOCAL 을 따로 정렬해 넣어야 하는데, 사용자가 줄을 더하거나 지우면 행이 어긋나 4-way 정렬이 된다. 반영(Accept·Take Mine/Theirs) 으로 고치는 경우에는 지금도 Mine 칸이 그대로 남는다. |
| 2026-09-07 | **남은 것 2가지 — 둘 다 완료** | ① **3-Way + 워드랩 세로 어긋남.** 위 원본 세 칸과 아래 MERGED 는 칸 너비가 달라 접히는 자리가 다르고, 따라서 <b>같은 행이라도 높이가 다르다.</b> 지금까지 픽셀 오프셋을 그대로 넘겨 맞추던 것을 **행 기준 변환**으로 바꿨다 — 편집기의 세로 위치 → 그 자리의 논리 행 + 행 안으로 들어간 비율 → 원본 칸의 행 높이표(`MergeLine.SourceRowHeight`)로 다시 픽셀. 반대 방향(원본 칸을 굴렸을 때)도 같은 식의 역변환을 넣었다. 변환은 <b>워드랩을 켠 3-Way 에서만</b> 탄다(`SourceRowsDifferFromEditor`) - 그 밖에는 모든 칸의 행 높이가 같아 픽셀 그대로가 정확하다. **실행 확인**: 워드랩 켠 3-Way 에서 아래로 굴리면 네 칸이 모두 05행에서 시작하고, BASE 칸에서 굴려도(역방향) 마찬가지다. 고치기 전에는 MERGED 10행 ↔ 원본 08행으로 벌어졌다. ② **MERGED 를 고치면 LOCAL(Mine) 칸도 따라가던 것.** 편집 후 재정렬이 "지금 글"을 MINE 자리에도 그대로 쓰고 있었다. **BASE 를 축으로 MINE·REMOTE·MERGED 세 쪽을 세우는 `ThreeWayAligner.AlignFour` 를 새로 만들어** 두 열을 갈랐다(`MergeLine.SetThreeWay` 로 MINE 을 따로 받는다). 방식은 기존 3-Way 정렬 그대로다 - BASE 와 2-way 정렬을 세 번 한 뒤 BASE 줄 번호로 맞물리고, BASE 줄이 없는 추가분은 **MINE↔MERGED 를 먼저 짝지은 뒤** 그 묶음을 REMOTE 와 짝짓는다(결과는 내 것의 복사본에서 출발하므로 이 순서라야 "내가 넣은 줄"이 편집 뒤에도 제자리에 남는다). **MERGED == MINE 이면 결과가 기존 3-Way 정렬과 글자 하나 다르지 않다** - 파일을 연 직후 화면은 그대로라는 뜻이고, 테스트로도 못 박았다. `ThreeWayTest` 20건 → **31건**(4-Way 11건 추가: 3-Way 와 동일함 2건, 결과만 고침·줄 추가·줄 삭제·내가 넣은 줄·저쪽 줄 가져오기·양쪽 추가·번호 갈라짐·빈 글 2건). **실행 확인**: MERGED 1행에 `abc` 를 쳐도 LOCAL(Mine) 1행은 `using System;` 그대로이고, 그 행에 Take Mine(`<`) 버튼이 생긴다. 네 칸의 행 정렬도 그대로다. 전체 **290건 통과**. |

59
src/MergeHub/AppSetup.cs Normal file
View File

@ -0,0 +1,59 @@
using System.Windows;
using Microsoft.Extensions.DependencyInjection;
using MergeHub.Services;
using MergeHub.ViewModels;
using MergeHub.Views;
using UbisamBase.Core.Modules;
using UbisamBase.Core.Setup;
namespace MergeHub
{
/// <summary>
/// UbisamBase.Shell.exe 가 리플렉션으로 찾아 로드하는 단일 진입점.
/// 이 dll 자체는 실행 파일이 아니다.
/// </summary>
public class AppSetup : IAppSetup
{
public string GetAppName() => "MergeHub - 사내 Git Merge/Diff";
public void RegisterViews(ITabManager manager)
{
// 머지툴은 3분할 비교라 가로 공간이 곧 성능이다.
// 셸 기본 여백(26,22)을 없애고 창 전체를 쓴다 - 안쪽 여백은 HomeView가 직접 준다.
// 이 키는 테마 전환에도 유지되므로 한 번만 덮어쓰면 되고,
// ShellWindow가 창 생성 시점에 읽어가므로 RegisterViews 안에서 정해야 한다.
if (Application.Current is { } app)
app.Resources["Ubisam.Metric.ContentPadding"] = new Thickness(0);
// 탭 순서는 실제 작업 순서를 따른다.
// 폴더 비교로 "어느 파일이 다른가"를 먼저 보고 → 그 파일을 골라 파일 비교로 들어가고
// → 필요하면 Git 으로 커밋한다.
// 폴더 비교에서 항목을 더블클릭하면 파일 비교 탭으로 넘어가므로 흐름이 이어진다.
manager.AddMain<FolderView, FolderViewModel>(Icon.Folder, ViewIds.Folder, "폴더 비교");
manager.AddMain<HomeView, HomeViewModel>(Icon.Document, ViewIds.Home, "파일 비교");
manager.AddMain<GitView, GitViewModel>(Icon.Link, ViewIds.Git, "Git");
}
public void RegisterSettings(ITabManager manager)
{
manager.AddSettingsSub<MergeHubSettings>(TabManager.SettingsTabKey, "MergeHub");
}
public void RegisterServices(IServiceCollection services)
{
// Git 조작 계층. Repository 핸들 하나를 화면 전체가 공유하므로 싱글턴.
services.AddSingleton<IGitService, GitService>();
// 홈(머지툴) 탭과 Git 탭이 함께 보는 저장소 상태. 호출 직렬화도 여기서 한다.
services.AddSingleton<GitWorkspace>();
}
}
/// <summary>AddMain/AddSub 에서 탭을 다시 찾을 때 쓰는 키.</summary>
public static class ViewIds
{
public const string Home = "MergeHub.Home";
public const string Folder = "MergeHub.Folder";
public const string Git = "MergeHub.Git";
}
}

View File

@ -0,0 +1,76 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net48</TargetFramework>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>MergeHub</RootNamespace>
<AssemblyName>MergeHub</AssemblyName>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<!-- 플랫폼 배포 폴더 (platform-guide 10-2 (B) 배포용) -->
<UbisamPlatformDir>D:\UbisamPlatform\bin\</UbisamPlatformDir>
<!-- 이 프로젝트는 dll이라 직접 실행 불가 - F5 시 Shell.exe가 뜨게 한다 -->
<StartAction>Program</StartAction>
<StartProgram>$(TargetDir)UbisamBase.Shell.exe</StartProgram>
<StartWorkingDirectory>$(TargetDir)</StartWorkingDirectory>
<DisableFastUpToDateCheck>true</DisableFastUpToDateCheck>
</PropertyGroup>
<ItemGroup>
<Reference Include="UbisamBase.Core">
<HintPath>$(UbisamPlatformDir)UbisamBase.Core.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Microsoft.Extensions.DependencyInjection.Abstractions">
<HintPath>$(UbisamPlatformDir)Microsoft.Extensions.DependencyInjection.Abstractions.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<!-- MVVM. 반드시 PackageReference 로 받아야 한다 - [ObservableProperty]/[RelayCommand] 는
패키지 안의 소스 제너레이터가 만들어내므로 dll 파일 참조만으로는 동작하지 않는다.
버전은 플랫폼 배포본(8.3.2.1)과 맞춘다. 다르면 CopyPlatformRuntime 이 복사한 뒤
우리 쪽 복사본이 덮어써서 Core 와 버전이 어긋난다. -->
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.3.2" />
<!-- Git 조작 본체. net472 타깃이 있어 net48에서 그대로 쓸 수 있다. -->
<PackageReference Include="LibGit2Sharp" Version="0.32.0" />
<!-- CP949 등 레거시 코드페이지 판별용 (net48에서는 기본 제공이지만 API 통일을 위해 유지) -->
<PackageReference Include="System.Text.Encoding.CodePages" Version="8.0.0" />
<!-- record / 범위 연산자(x[..7]) 등 최신 C# 문법을 net48에서 쓰기 위한 폴리필 (런타임 의존성 없음) -->
<PackageReference Include="PolySharp" Version="1.15.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<!-- 플랫폼 런타임 전체를 이 프로젝트 출력 폴더로 복사 -->
<Target Name="CopyPlatformRuntime" BeforeTargets="Build">
<ItemGroup>
<_PlatformFiles Include="$(UbisamPlatformDir)**\*.*" />
</ItemGroup>
<MakeDir Directories="$(TargetDir)" Condition="!Exists('$(TargetDir)')" />
<Copy SourceFiles="@(_PlatformFiles)" DestinationFiles="@(_PlatformFiles->'$(TargetDir)%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
</Target>
<!-- LibGit2Sharp 네이티브 바이너리(git2-*.dll)를 출력 폴더로 복사.
.NET Framework 프로젝트는 runtimes\ 폴더를 자동으로 풀지 않는다. -->
<Target Name="CopyLibGit2Native" AfterTargets="Build">
<ItemGroup>
<_Git2NativeX64 Include="$(NuGetPackageRoot)libgit2sharp.nativebinaries\**\runtimes\win-x64\native\*.dll" />
<_Git2NativeX86 Include="$(NuGetPackageRoot)libgit2sharp.nativebinaries\**\runtimes\win-x86\native\*.dll" />
</ItemGroup>
<!-- LibGit2Sharp는 .NET Framework에서 lib\win32\{x64|x86} 를 탐색한다.
Shell.exe가 AnyCPU라 실행 시 비트수가 정해지므로 둘 다 깔아둔다. -->
<Copy SourceFiles="@(_Git2NativeX64)" DestinationFolder="$(TargetDir)lib\win32\x64" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(_Git2NativeX86)" DestinationFolder="$(TargetDir)lib\win32\x86" SkipUnchangedFiles="true" />
</Target>
</Project>

View File

@ -0,0 +1,91 @@
using System.ComponentModel;
namespace MergeHub
{
/// <summary>
/// 설정 탭에 자동 생성되는 편집 화면의 원본.
/// D:\UbisamConfig\Settings\MergeHubSettings.json 으로 저장된다.
/// </summary>
public class MergeHubSettings
{
[Category("저장소")]
[DisplayName("기본 저장소 경로")]
public string DefaultRepositoryPath { get; set; } = string.Empty;
[Category("저장소")]
[DisplayName("기본 원격 이름")]
public string DefaultRemoteName { get; set; } = "origin";
// Git 저장소가 아닐 때 처음부터 비교해 둘 두 파일. 비워 두면 아무것도 열지 않는다.
[Category("파일 비교 (Git 아님)")]
[DisplayName("기본 왼쪽 파일")]
public string DefaultLeftFile { get; set; } = string.Empty;
[Category("파일 비교 (Git 아님)")]
[DisplayName("기본 오른쪽 파일")]
public string DefaultRightFile { get; set; } = string.Empty;
// 발표·교육용. 상단 "데모" 버튼이 이 두 경로를 오가며 Git 있는 화면과 없는 화면을 바로 보여 준다.
// 비워 두면 버튼이 나오지 않는다.
[Category("데모")]
[DisplayName("데모: .git 있는 폴더")]
public string DemoGitPath { get; set; } = string.Empty;
[Category("데모")]
[DisplayName("데모: .git 없는 폴더")]
public string DemoPlainPath { get; set; } = string.Empty;
[Category("사내 Gitea")]
[DisplayName("서버 주소")]
public string GiteaUrl { get; set; } = string.Empty;
[Category("사내 Gitea")]
[DisplayName("계정")]
public string GiteaUserName { get; set; } = string.Empty;
[Category("비교 옵션")]
[DisplayName("공백 변경 무시")]
public bool IgnoreWhitespace { get; set; }
[Category("비교 옵션")]
[DisplayName("공백 무시 단계 (0=전부 비교, 1=개수만, 2=전부)")]
public int WhitespaceMode { get; set; }
[Category("비교 옵션")]
[DisplayName("대소문자 무시")]
public bool IgnoreCase { get; set; }
[Category("비교 옵션")]
[DisplayName("줄 필터 사용")]
public bool UseLineFilters { get; set; } = true;
/// <summary>
/// 차이로 세지 않을 줄의 정규식. 줄바꿈으로 구분해 여러 개를 적는다.
/// 매번 바뀌는 타임스탬프·빌드번호처럼 내용이 아닌 것을 걸러내는 용도다.
/// </summary>
[Category("비교 옵션")]
[DisplayName("줄 필터 정규식 (한 줄에 하나)")]
public string LineFilters { get; set; } = string.Empty;
[Category("비교 옵션")]
[DisplayName("단어 단위 diff")]
public bool WordLevelDiff { get; set; } = true;
[Category("비교 옵션")]
[DisplayName("문법 강조 (C# 키워드·접근제한자·문자열)")]
public bool SyntaxHighlight { get; set; } = true;
[Category("비교 옵션")]
[DisplayName("커밋 조회 개수")]
public int CommitPageSize { get; set; } = 200;
[Category("머지 주석")]
[DisplayName("반영한 줄에 주석 남기기")]
public bool InsertMergeComment { get; set; } = true;
[Category("머지 주석")]
[DisplayName("작성자 (비우면 git config user.name)")]
public string MergeCommentAuthor { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,24 @@
namespace MergeHub.Models
{
/// <summary>
/// 코드 검사에서 걸린 항목 하나. 목록에서 눌러 그 줄로 갈 수 있어야 하므로
/// 문장만이 아니라 화면 행 번호까지 함께 들고 있는다.
/// </summary>
public sealed class CodeProblemItem
{
public CodeProblemItem(int row, string where, string message)
{
Row = row;
Where = where;
Message = message;
}
/// <summary>화면 행 번호(0부터). 특정 줄이 아니면 -1.</summary>
public int Row { get; }
/// <summary>"15행" 처럼 어디인지.</summary>
public string Where { get; }
public string Message { get; }
}
}

View File

@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
namespace MergeHub.Models
{
/// <summary>
/// 한 행 안에서 그려질 선 하나.
/// 행의 위쪽 경계 FromLane 에서 들어와 아래쪽 경계 ToLane 으로 나간다.
/// FromLane == ToLane 이면 수직선(그 레인을 그냥 통과), 다르면 합류/분기 사선이다.
/// </summary>
public sealed class GraphEdge
{
public GraphEdge(int fromLane, int toLane, int colorIndex)
{
FromLane = fromLane;
ToLane = toLane;
ColorIndex = colorIndex;
}
public int FromLane { get; }
public int ToLane { get; }
public int ColorIndex { get; }
}
/// <summary>커밋 한 개가 차지하는 그래프 행.</summary>
public sealed class GraphRow
{
public GraphRow(int lane, int colorIndex, IReadOnlyList<GraphEdge> edges, bool isMerge)
{
Lane = lane;
ColorIndex = colorIndex;
Edges = edges;
IsMerge = isMerge;
}
/// <summary>점(커밋)이 찍히는 레인.</summary>
public int Lane { get; }
public int ColorIndex { get; }
public IReadOnlyList<GraphEdge> Edges { get; }
/// <summary>부모가 둘 이상 - 속을 비운 점으로 그린다.</summary>
public bool IsMerge { get; }
/// <summary>목록 전체에서 쓰인 최대 레인 수. 열 너비를 맞추는 데 쓴다.</summary>
public int LaneCount { get; set; } = 1;
}
/// <summary>커밋 목록에 바인딩되는 항목 - 커밋 정보 + 그 커밋이 그려질 그래프 행.</summary>
public sealed class CommitNode
{
public CommitNode(CommitInfo commit, GraphRow graph)
{
Commit = commit;
Graph = graph;
}
public CommitInfo Commit { get; }
public GraphRow Graph { get; }
}
}

View File

@ -0,0 +1,273 @@
using System;
using System.Collections.Generic;
namespace MergeHub.Models
{
/// <summary>원격 저장소(사내 Gitea) 인증 정보. Password 자리에 개인 액세스 토큰을 넣어도 된다.</summary>
public sealed record GitCredentials(string Username, string Password);
/// <summary>브랜치 한 개의 표시용 정보.</summary>
public sealed record BranchInfo(
string FriendlyName,
string CanonicalName,
bool IsRemote,
bool IsCurrentHead,
string? TrackedBranch,
int? AheadBy,
int? BehindBy,
string TipSha,
string TipMessageShort);
/// <summary>커밋 한 개. ParentShas 를 이용해 히스토리 그래프를 그린다.</summary>
public sealed record CommitInfo(
string Sha,
string ShortSha,
string MessageShort,
string Message,
string AuthorName,
string AuthorEmail,
DateTimeOffset When,
IReadOnlyList<string> ParentShas,
IReadOnlyList<string> Refs)
{
public bool IsMerge => ParentShas.Count > 1;
}
/// <summary>워킹트리/인덱스의 파일 상태.</summary>
public enum WorkingFileState
{
Unmodified,
Added,
Modified,
Deleted,
Renamed,
TypeChanged,
Untracked,
Ignored,
Conflicted
}
public sealed record FileStatusInfo(
string Path,
WorkingFileState StagedState,
WorkingFileState WorkingDirState,
bool IsConflicted)
{
public bool IsStaged => StagedState is not (WorkingFileState.Unmodified or WorkingFileState.Untracked or WorkingFileState.Ignored);
}
/// <summary>저장소 전체 상태 요약. (상단 Working Tree 배지용)</summary>
public sealed record RepositoryStatusInfo(
bool IsClean,
int StagedCount,
int ModifiedCount,
int UntrackedCount,
int ConflictedCount,
IReadOnlyList<FileStatusInfo> Files);
/// <summary>충돌 파일 한 개. Base 가 없는 경우(양쪽에서 새로 추가) HasBase=false.</summary>
public sealed record ConflictInfo(
string Path,
bool HasBase,
bool HasOurs,
bool HasTheirs,
bool IsBinary);
/// <summary>3-way 병합 뷰에 그대로 바인딩되는 3분할 본문.</summary>
public sealed record ThreeWayContent(
string Path,
string BaseText,
string OursText,
string TheirsText,
bool IsBinary);
/// <summary>충돌 해결 방식. 목업의 Accept Mine / Theirs / Both 버튼에 대응.</summary>
public enum ConflictResolution
{
/// <summary>내 작업(HEAD, LOCAL)을 채택.</summary>
Mine,
/// <summary>상대 브랜치(MERGE_HEAD, REMOTE)를 채택.</summary>
Theirs,
/// <summary>공통 조상(BASE)으로 되돌림.</summary>
Base,
/// <summary>Mine 다음에 Theirs 를 이어붙임.</summary>
Both
}
public enum MergeOutcome
{
/// <summary>이미 최신 - 병합할 것이 없음.</summary>
UpToDate,
/// <summary>Fast-forward 로 HEAD 만 이동.</summary>
FastForward,
/// <summary>병합 성공 - 커밋 대기(CommitOnSuccess=false) 또는 병합 커밋 생성.</summary>
Merged,
/// <summary>충돌 발생 - Conflicts 목록을 해결해야 함.</summary>
Conflicts,
/// <summary>실패/중단.</summary>
Failed
}
public sealed record MergeResultInfo(
MergeOutcome Outcome,
string? CommitSha,
IReadOnlyList<ConflictInfo> Conflicts,
string Message)
{
public bool HasConflicts => Outcome == MergeOutcome.Conflicts;
}
/// <summary>저장소 열림 상태 요약.</summary>
public sealed record RepositoryInfo(
string WorkingDirectory,
string GitDirectory,
string? CurrentBranchName,
string? HeadSha,
bool IsDetachedHead,
string PendingOperation,
IReadOnlyList<string> RemoteNames);
public enum DiffLineKind
{
Context,
Added,
Deleted,
HunkHeader
}
public enum WordDiffKind
{
Unchanged,
Added,
Deleted
}
/// <summary>한 줄 안에서 실제로 바뀐 구간. (기획안 4p "한 줄 내 변경 부분까지 하이라이트")</summary>
public sealed record WordDiffSegment(WordDiffKind Kind, string Text);
public sealed record DiffLine(
DiffLineKind Kind,
int? OldLineNumber,
int? NewLineNumber,
string Text,
IReadOnlyList<WordDiffSegment>? Words = null);
public sealed record FileDiff(
string Path,
string? OldPath,
string ChangeKind,
bool IsBinary,
int LinesAdded,
int LinesDeleted,
string Patch,
IReadOnlyList<DiffLine> Lines);
public sealed record BlameLineInfo(
int LineNumber,
string Text,
string CommitSha,
string ShortSha,
string AuthorName,
DateTimeOffset When,
string MessageShort);
public sealed record RemoteInfo(string Name, string Url, string PushUrl);
/// <summary>git reset 의 세 가지 모드.</summary>
public enum ResetKind
{
/// <summary>HEAD 만 이동. 인덱스·워킹트리 유지 (커밋만 취소).</summary>
Soft,
/// <summary>HEAD + 인덱스 되돌림. 워킹트리 수정은 유지 (git reset 기본값).</summary>
Mixed,
/// <summary>전부 되돌림. 수정 내용이 사라진다.</summary>
Hard
}
public enum PatchApplyOutcome
{
/// <summary>적용 성공.</summary>
Applied,
/// <summary>충돌 발생 - GetConflicts() 로 해결 후 Commit().</summary>
Conflicts,
/// <summary>적용할 변경이 없음.</summary>
NothingToDo,
Failed
}
/// <summary>cherry-pick / revert 결과.</summary>
public sealed record PatchApplyResult(
PatchApplyOutcome Outcome,
string? CommitSha,
IReadOnlyList<ConflictInfo> Conflicts,
string Message)
{
public bool HasConflicts => Outcome == PatchApplyOutcome.Conflicts;
}
public enum RebaseOutcome
{
/// <summary>모든 단계 완료.</summary>
Completed,
/// <summary>충돌로 중단 - 해결 후 RebaseContinue().</summary>
Conflicts,
/// <summary>사용자 확인을 위해 정지.</summary>
Stopped,
/// <summary>재배치할 커밋이 없음.</summary>
NothingToDo,
Failed
}
public sealed record RebaseResultInfo(
RebaseOutcome Outcome,
int CompletedSteps,
int TotalSteps,
IReadOnlyList<ConflictInfo> Conflicts,
string Message);
public sealed record RebaseStatusInfo(
bool IsInProgress,
int CurrentStep,
int TotalSteps,
string? CurrentCommitSha,
string? CurrentMessage);
public sealed record TagInfo(
string Name,
string TargetSha,
bool IsAnnotated,
string? Message,
string? TaggerName,
DateTimeOffset? When);
public sealed record SubmoduleInfo(
string Name,
string Path,
string Url,
string? HeadSha,
string? IndexSha,
string? WorkDirSha,
string Status)
{
/// <summary>아직 clone 되지 않아 update 가 필요한 상태.</summary>
public bool NeedsUpdate => WorkDirSha is null || WorkDirSha != IndexSha;
}
public sealed record StashInfo(int Index, string Message, string Sha);
/// <summary>텍스트 파일의 저장 형식. 편집 화면 상태바에 그대로 보여 준다.</summary>
public sealed class TextFileInfo
{
public TextFileInfo(string encoding, string newLine)
{
Encoding = encoding;
NewLine = newLine;
}
/// <summary>UTF-8 / UTF-8 (BOM) / KS_C_5601-1987 처럼 사람이 읽는 이름.</summary>
public string Encoding { get; }
/// <summary>CRLF / LF / CR / 혼합 / 없음.</summary>
public string NewLine { get; }
}
}

View File

@ -0,0 +1,439 @@
using System;
using System.Collections.Generic;
using CommunityToolkit.Mvvm.ComponentModel;
namespace MergeHub.Models
{
public enum MergeLineState
{
/// <summary>BASE 와 LOCAL 이 같다.</summary>
Same,
/// <summary>양쪽에 있으나 내용이 다르다.</summary>
Changed,
/// <summary>LOCAL 에만 있다(추가됨).</summary>
LocalOnly,
/// <summary>BASE 에만 있다(삭제됨).</summary>
BaseOnly
}
/// <summary>
/// 화면에서 줄 배경을 칠하는 기준. 범례(색 구분 안내)와 1:1로 대응한다.
/// </summary>
public enum LineHighlight
{
/// <summary>BASE 와 같음 - 칠하지 않음.</summary>
None,
/// <summary>BASE 와 다름, 아직 손대지 않음.</summary>
Changed,
/// <summary>BASE 내용을 가져와 반영함.</summary>
Applied,
/// <summary>내가 직접 타이핑해서 고침.</summary>
Edited,
/// <summary>LOCAL 에만 있는 줄(추가).</summary>
Added,
/// <summary>BASE 에만 있는 줄(삭제).</summary>
Removed,
/// <summary>지운 것이 아니라 자리만 옮긴 줄.</summary>
Moved,
/// <summary>무시 규칙에 걸려 차이로 세지 않는 줄.</summary>
Ignored,
/// <summary>3-Way: 저쪽(REMOTE)만 고친 줄. 받아들이기만 하면 되는 변경.</summary>
RemoteChange,
/// <summary>3-Way: 양쪽이 서로 다르게 고친 줄. 사람이 골라야 한다.</summary>
Conflict
}
/// <summary>
/// 3-Way 에서 이 줄이 어떤 상태인지. BASE(공통 조상)를 기준으로 양쪽이 무엇을 했는지로 정한다.
/// "다르다"가 아니라 "누가 고쳤나"가 판단의 근거라, 2-Way 의 MergeLineState 와 섞을 수 없다.
/// </summary>
public enum ThreeWayState
{
/// <summary>아무도 안 고쳤다.</summary>
Same,
/// <summary>나만 고쳤다. 그대로 두면 된다.</summary>
LocalChanged,
/// <summary>저쪽만 고쳤다. 받아들이면 된다.</summary>
RemoteChanged,
/// <summary>둘 다 고쳤는데 결과가 같다. 충돌이 아니다.</summary>
BothSame,
/// <summary>둘 다 다르게 고쳤다. 사람이 골라야 한다.</summary>
Conflict
}
/// <summary>편집이 어디서 왔는지. 색과 범례를 나누는 기준이라 섞으면 안 된다.</summary>
public enum EditKind
{
/// <summary>[&gt;] 또는 Accept 로 BASE 를 가져옴.</summary>
TakeBase,
/// <summary>사용자가 직접 타이핑함.</summary>
Manual
}
/// <summary>
/// BASE / LOCAL 을 "내용이 같은 줄끼리" 맞춘 한 행.
/// 줄 번호는 양쪽이 다를 수 있고, 한쪽에만 있는 줄은 반대쪽 번호가 없다.
/// </summary>
public sealed partial class MergeLine : ObservableObject
{
/// <param name="normalize">
/// 두 쪽이 "같은 줄인가"를 따질 때 쓸 변환. 공백·대소문자 무시 규칙이 여기로 들어온다.
/// null 이면 원문끼리 그대로 비교한다. 화면에 보이는 글자는 어느 쪽이든 원문 그대로다.
/// </param>
public MergeLine(string? baseText, int? baseNumber, string? localText, int? localNumber,
Func<string, string>? normalize = null)
{
_normalize = normalize;
_baseText = baseText ?? string.Empty;
_localText = localText ?? string.Empty;
BaseNumber = baseNumber;
_localNumber = localNumber;
HasBase = baseText is not null;
_hasLocal = localText is not null;
}
private readonly Func<string, string>? _normalize;
/// <summary>
/// 무시 규칙까지 감안해 두 줄이 같은지. 사용자가 LOCAL 을 고쳐도 그때그때 다시 따진다.
/// </summary>
private bool SameText(string a, string b) =>
_normalize is null
? string.Equals(a, b, StringComparison.Ordinal)
: string.Equals(_normalize(a), _normalize(b), StringComparison.Ordinal);
/// <summary>BASE 쪽 줄 번호. LOCAL 에만 있는 줄이면 없다.</summary>
public int? BaseNumber { get; }
public string BaseNumberText => BaseNumber?.ToString("00") ?? string.Empty;
/// <summary>LOCAL 쪽 줄 번호. 줄을 지우면 사라진다.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(LocalNumberText))]
private int? _localNumber;
public string LocalNumberText => LocalNumber?.ToString("00") ?? string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(State))]
[NotifyPropertyChangedFor(nameof(IsDifferent))]
[NotifyPropertyChangedFor(nameof(CanTakeBase))]
[NotifyPropertyChangedFor(nameof(Highlight))]
private string _baseText;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(State))]
[NotifyPropertyChangedFor(nameof(IsDifferent))]
[NotifyPropertyChangedFor(nameof(CanTakeBase))]
[NotifyPropertyChangedFor(nameof(Highlight))]
[NotifyPropertyChangedFor(nameof(ThreeWay))]
[NotifyPropertyChangedFor(nameof(IsConflict))]
[NotifyPropertyChangedFor(nameof(CanTakeRemote))]
[NotifyPropertyChangedFor(nameof(CanTakeLocal))]
private string _localText;
/// <summary>한 줄 안에서 바뀐 구간. 단어 단위 diff 가 켜져 있을 때만 채워진다.</summary>
[ObservableProperty] private IReadOnlyList<WordDiffSegment>? _baseWords;
[ObservableProperty] private IReadOnlyList<WordDiffSegment>? _localWords;
/// <summary>BASE 내용을 가져와 반영한 줄인지. 되돌리면 풀린다.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Highlight))]
private bool _isApplied;
/// <summary>사용자가 직접 타이핑해 고친 줄인지. "머지 반영"과 구분해서 표시한다.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Highlight))]
private bool _isManuallyEdited;
/// <summary>반영하면서 줄 끝에 붙인 주석(툴팁으로 보여준다).</summary>
[ObservableProperty] private string? _appliedNote;
/// <summary>이 줄에서 발견한 문법 이상. 없으면 null.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasProblem))]
private string? _problem;
public bool HasProblem => !string.IsNullOrEmpty(Problem);
// ---- 워드랩에서 패널들의 행 높이 맞추기 ----
//
// 워드랩을 켜면 줄마다 접히는 횟수가 달라 높이가 제각각이 된다. 패널이 각자 자기 글
// 높이만 쓰면, 한쪽만 접힌 행에서 그 아래가 통째로 밀린다. 그래서 "같은 눈높이에
// 있어야 하는 패널끼리" 높이 중 큰 값을 함께 쓴다.
//
// 여기서 중요한 것은 <b>같이 맞춰야 하는 묶음이 하나가 아니라는 점</b>이다.
// 2-Way : BASE ↔ LOCAL(편집기) 이 나란히 있으므로 넷이 한 묶음이다.
// 3-Way : 위의 원본 세 칸은 폭이 같아 서로 맞춰야 하지만,
// 아래 MERGED 는 폭이 전혀 달라(가로 전체) 접히는 자리가 애초에 다르다.
// 같은 높이를 강요하면 MERGED 는 안 접힌 줄에 빈칸이 생기고,
// 그 위에 겹친 편집기와도 어긋난다.
// 그래서 묶음을 둘로 나눈다 - RowHeight(MERGED·편집기) 와 SourceRowHeight(원본 칸들).
private double _baseTextHeight;
private double _localTextHeight;
private double _remoteTextHeight;
private double _originalLocalTextHeight;
/// <summary>글 높이를 알려 오는 패널.</summary>
public enum TextPane
{
Base,
/// <summary>2-Way 의 LOCAL, 3-Way 의 MERGED. 편집기가 겹쳐 있는 칸이다.</summary>
Local,
Remote,
OriginalLocal
}
/// <summary>편집기가 겹친 칸(2-Way LOCAL · 3-Way MERGED)의 행 높이.</summary>
[ObservableProperty] private double _rowHeight = MinimumRowHeight;
/// <summary>읽기 전용 원본 칸들의 행 높이. 2-Way 에서는 RowHeight 와 같다.</summary>
[ObservableProperty] private double _sourceRowHeight = MinimumRowHeight;
/// <summary>접히지 않은 보통 줄의 높이. HomeView 의 CodeLineHeight 와 같은 값이어야 한다.</summary>
private const double MinimumRowHeight = 20;
/// <summary>각 패널이 자기 글이 실제로 얼마나 높은지 알려 준다.</summary>
public void ReportTextHeight(TextPane pane, double height)
{
if (height <= 0) return;
switch (pane)
{
case TextPane.Base: _baseTextHeight = height; break;
case TextPane.Local: _localTextHeight = height; break;
case TextPane.Remote: _remoteTextHeight = height; break;
case TextPane.OriginalLocal: _originalLocalTextHeight = height; break;
}
if (IsThreeWay)
{
RowHeight = Math.Max(MinimumRowHeight, _localTextHeight);
SourceRowHeight = Math.Max(MinimumRowHeight,
Math.Max(_originalLocalTextHeight, Math.Max(_baseTextHeight, _remoteTextHeight)));
return;
}
var shared = Math.Max(MinimumRowHeight, Math.Max(_baseTextHeight, _localTextHeight));
RowHeight = shared;
SourceRowHeight = shared;
}
/// <summary>무시 규칙(줄 필터)에 걸린 줄. 차이로 세지 않고 옅게만 표시한다.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsDifferent))]
[NotifyPropertyChangedFor(nameof(CanTakeBase))]
[NotifyPropertyChangedFor(nameof(Highlight))]
private bool _isIgnored;
/// <summary>자리만 옮겨진 줄이면 어디로/어디서 옮겨졌는지. 없으면 이동이 아니다.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsMoved))]
[NotifyPropertyChangedFor(nameof(Highlight))]
private string? _moveHint;
public bool IsMoved => !string.IsNullOrEmpty(MoveHint);
/// <summary>사용자가 여백을 눌러 표시해 둔 줄. F2 로 옮겨 다닌다.</summary>
[ObservableProperty] private bool _isBookmarked;
/// <summary>
/// 머지하면서 끼워 넣은 주석 줄인지.
///
/// 이 표시가 필요한 이유 - 주석 줄은 BASE 에 없는 줄이라, 그냥 두면 "LOCAL 에만 있는 줄"로
/// 차이에 잡힌다. 그러면 <b>다음 Accept 나 전체 반영이 그 주석을 지워 버린다</b>
/// ("BASE 를 따른다 = BASE 에 없는 줄은 없앤다"는 규칙 때문에). 실제로 그렇게 사라졌다.
/// 방금 내가 남긴 기록이 다음 클릭에 지워지면 안 된다.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsDifferent))]
[NotifyPropertyChangedFor(nameof(CanTakeBase))]
[NotifyPropertyChangedFor(nameof(CanTakeRemote))]
[NotifyPropertyChangedFor(nameof(CanTakeLocal))]
private bool _isMergeNote;
/// <summary>앞 줄에서 블록 주석이 열린 채 넘어왔는지 (BASE / LOCAL 각각).</summary>
[ObservableProperty] private bool _baseInComment;
[ObservableProperty] private bool _localInComment;
/// <summary>BASE 쪽에 이 줄이 있는지. 정렬 결과라 바뀌지 않는다.</summary>
public bool HasBase { get; }
/// <summary>LOCAL 쪽에 줄이 있는지. 줄을 지우거나 되살리면 바뀐다.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(State))]
[NotifyPropertyChangedFor(nameof(IsDifferent))]
[NotifyPropertyChangedFor(nameof(CanTakeBase))]
[NotifyPropertyChangedFor(nameof(Highlight))]
private bool _hasLocal;
// ---- 3-Way (REMOTE = 사용자가 고른 브랜치) ----
/// <summary>REMOTE 쪽 줄 번호. 그쪽에 없는 줄이면 없다.</summary>
public int? RemoteNumber { get; private set; }
public string RemoteNumberText => RemoteNumber?.ToString("00") ?? string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ThreeWay))]
[NotifyPropertyChangedFor(nameof(Highlight))]
[NotifyPropertyChangedFor(nameof(IsConflict))]
[NotifyPropertyChangedFor(nameof(CanTakeRemote))]
[NotifyPropertyChangedFor(nameof(CanTakeLocal))]
private string _remoteText = string.Empty;
/// <summary>REMOTE 쪽에 이 줄이 있는지. 3-Way 로 읽어 오지 않았으면 false 다.</summary>
public bool HasRemote { get; private set; }
/// <summary>이 행이 3-Way 로 만들어졌는지. false 면 REMOTE 칸은 아예 없는 것으로 본다.</summary>
public bool IsThreeWay { get; private set; }
/// <summary>REMOTE 쪽 한 줄 안에서 바뀐 구간.</summary>
[ObservableProperty] private IReadOnlyList<WordDiffSegment>? _remoteWords;
[ObservableProperty] private bool _remoteInComment;
// ---- 3-Way 에서의 LOCAL 원본 ----
//
// 3-Way 에서는 편집 대상이 MERGED(결과) 이고, LocalText 가 곧 그 결과다.
// 그래서 "내가 원래 갖고 있던 줄"을 따로 붙들어 둔다 - 이게 없으면
// REMOTE 를 한 번 가져오는 순간 내 원래 내용이 화면에서 사라져, 정작 충돌을
// 판단할 근거를 잃는다.
/// <summary>파일을 연 시점의 LOCAL 줄. 3-Way 의 LOCAL 패널이 이걸 보여 준다.</summary>
public string OriginalLocalText { get; private set; } = string.Empty;
/// <summary>파일을 연 시점에 LOCAL 쪽에 이 줄이 있었는지.</summary>
public bool HasOriginalLocal { get; private set; }
/// <summary>내 원본 쪽 줄 번호. MERGED 를 고치면 두 번호가 서로 달라진다.</summary>
public int? OriginalLocalNumber { get; private set; }
public string OriginalLocalNumberText =>
OriginalLocalNumber is int n ? n.ToString("00") : string.Empty;
/// <summary>3-Way 값을 채운다. 정렬 결과라 만든 뒤에는 바뀌지 않는다.</summary>
public void SetRemote(string? remoteText, int? remoteNumber) =>
SetThreeWay(remoteText, remoteNumber, HasLocal ? LocalText : null, LocalNumber);
/// <summary>
/// 3-Way 값을 채운다. 내 원본(MINE)을 따로 받는다.
///
/// MERGED 를 직접 고치고 나면 결과와 내 원본이 갈라지므로 한 열로 묶을 수 없다 -
/// 묶어 두면 글자를 하나만 쳐도 MINE 칸이 그 편집을 따라가 판단 근거가 사라진다.
/// </summary>
public void SetThreeWay(string? remoteText, int? remoteNumber, string? mineText, int? mineNumber)
{
OriginalLocalText = mineText ?? string.Empty;
HasOriginalLocal = mineText is not null;
OriginalLocalNumber = mineText is not null ? mineNumber : null;
IsThreeWay = true;
HasRemote = remoteText is not null;
RemoteNumber = remoteNumber;
RemoteText = remoteText ?? string.Empty;
}
/// <summary>
/// BASE 기준으로 양쪽이 무엇을 했는지. 3-Way 가 아니면 늘 Same 이다.
///
/// "한쪽에만 있다"도 변경으로 친다 - 지우거나 새로 넣은 것도 사람이 판단할 변경이다.
/// </summary>
public ThreeWayState ThreeWay
{
get
{
if (!IsThreeWay) return ThreeWayState.Same;
var localChanged = HasBase != HasLocal || (HasLocal && !SameText(BaseText, LocalText));
var remoteChanged = HasBase != HasRemote || (HasRemote && !SameText(BaseText, RemoteText));
if (!localChanged && !remoteChanged) return ThreeWayState.Same;
if (localChanged && !remoteChanged) return ThreeWayState.LocalChanged;
if (!localChanged && remoteChanged) return ThreeWayState.RemoteChanged;
return HasLocal == HasRemote && SameText(LocalText, RemoteText)
? ThreeWayState.BothSame
: ThreeWayState.Conflict;
}
}
public bool IsConflict => ThreeWay == ThreeWayState.Conflict;
/// <summary>REMOTE 내용을 가져올 수 있는 줄인지. 이미 같으면 가져올 것이 없다.</summary>
public bool CanTakeRemote =>
IsThreeWay && !IsMergeNote && ThreeWay is ThreeWayState.RemoteChanged or ThreeWayState.Conflict;
/// <summary>
/// 내 원본을 결과로 되돌릴 수 있는 줄인지.
/// 충돌 줄에서는 결과가 이미 내 원본과 같아도 보여 준다 - "내 것으로 정한다"는 결정 자체가 필요하다.
/// </summary>
public bool CanTakeLocal =>
IsThreeWay && !IsMergeNote &&
(IsConflict || HasOriginalLocal != HasLocal || !SameText(OriginalLocalText, LocalText));
public MergeLineState State =>
!HasBase ? MergeLineState.LocalOnly :
!HasLocal ? MergeLineState.BaseOnly :
SameText(BaseText, LocalText) ? MergeLineState.Same :
MergeLineState.Changed;
/// <summary>
/// 무시 규칙에 걸린 줄은 내용이 달라도 차이로 세지 않는다.
/// 3-Way 에서는 저쪽만 고친 줄도 사람이 봐야 할 차이다 - BASE 와 LOCAL 이 같아도 센다.
/// </summary>
public bool IsDifferent =>
!IsIgnored && !IsMergeNote && (State != MergeLineState.Same ||
(IsThreeWay && ThreeWay != ThreeWayState.Same));
/// <summary>
/// [&gt;] 버튼 표시 조건. BASE 가 없는 줄은 "가져올 것"이 없으니 지우기가 된다.
/// IsDifferent 와 따로 두는 이유 - 3-Way 에서 저쪽만 고친 줄은 BASE 와 LOCAL 이 같아서
/// BASE 를 가져와 봐야 아무 일도 일어나지 않는다.
/// </summary>
public bool CanTakeBase => !IsIgnored && !IsMergeNote && State != MergeLineState.Same;
/// <summary>
/// 줄 배경색 기준.
/// 내가 손댄 결과(반영/직접수정)가 단순 차이보다 우선한다 - 무엇을 이미 처리했는지가 더 중요하다.
/// </summary>
public LineHighlight Highlight =>
IsIgnored ? LineHighlight.Ignored :
IsManuallyEdited ? LineHighlight.Edited :
IsApplied ? LineHighlight.Applied :
// 충돌은 반드시 눈에 띄어야 한다. 이동·단순 차이보다 앞세운다.
IsThreeWay && ThreeWay == ThreeWayState.Conflict ? LineHighlight.Conflict :
IsThreeWay && ThreeWay == ThreeWayState.RemoteChanged ? LineHighlight.RemoteChange :
IsMoved ? LineHighlight.Moved :
State switch
{
MergeLineState.LocalOnly => LineHighlight.Added,
MergeLineState.BaseOnly => LineHighlight.Removed,
MergeLineState.Changed => LineHighlight.Changed,
_ => LineHighlight.None
};
}
/// <summary>
/// Undo/Redo 스택에 쌓이는 한 번의 줄 편집.
/// 줄을 지우거나 되살리는 것도 편집이라 HasLocal 도 함께 기억한다.
/// </summary>
public sealed record LineEdit(
MergeLine Line,
string Before, bool BeforeHasLocal,
string After, bool AfterHasLocal,
EditKind Kind,
string Description)
{
/// <summary>
/// 이 편집이 끼워 넣은 주석 줄. 되돌리면 함께 사라져야 한다.
///
/// 주석을 줄 끝이 아니라 <b>줄 위에 따로</b> 넣기로 하면서 생겼다 - 주석이 진짜 한 줄이므로
/// 되돌릴 때 본문만 되돌리고 주석을 남겨 두면 파일에 유령 주석이 쌓인다.
/// </summary>
public MergeLine? InsertedNote { get; init; }
}
}

View File

@ -0,0 +1,230 @@
using System;
using System.Collections.Generic;
using System.Text;
using MergeHub.Views.Controls;
namespace MergeHub.Services
{
/// <summary>줄 하나에서 발견한 문제.</summary>
public sealed class CodeProblem
{
public CodeProblem(int lineIndex, string message)
{
LineIndex = lineIndex;
Message = message;
}
/// <summary>Lines 컬렉션에서의 위치(0부터).</summary>
public int LineIndex { get; }
public string Message { get; }
}
/// <summary>
/// 편집한 내용이 "일단 컴파일은 될 모양인지"를 훑어보는 가벼운 검사기.
///
/// 컴파일러가 아니다. Roslyn 없이 토크나이저만으로 잡을 수 있는,
/// 그러나 실제로 자주 저지르는 실수만 본다:
/// 1. 주석·문자열 밖에 한글 등 식별자로 쓸 수 없는 글자 (주석 기호를 빼먹은 경우)
/// 2. 닫히지 않은 따옴표
/// 3. 파일 전체의 괄호 짝 ( ) { } [ ]
///
/// 놓치는 것이 많으므로 "빌드가 된다"는 보증이 아니라 "눈에 띄는 실수 알림"으로 쓴다.
/// </summary>
public static class CodeSanityChecker
{
/// <summary>이 종류의 토큰 안에서는 어떤 글자가 나와도 문제가 아니다.</summary>
private static bool IsFreeText(CodeTokenKind kind) =>
kind == CodeTokenKind.Text ||
kind == CodeTokenKind.Comment ||
kind == CodeTokenKind.XmlDoc ||
kind == CodeTokenKind.Preprocessor;
/// <summary>
/// 코드로 다룰 파일인지. .txt·.md 같은 문서나 YAML·INI 처럼 괄호로 블록을 여닫지 않는
/// 형식에는 이 검사(괄호 짝·식별자 글자)가 뜻이 없다. 판단은 언어 표 한 곳에서만 한다.
/// </summary>
public static bool AppliesTo(string? path) => LanguageCatalog.ForPath(path).HasBraces;
/// <summary>언어를 따로 주지 않으면 C# 으로 본다(이 도구가 처음부터 다루던 언어).</summary>
public static IReadOnlyList<CodeProblem> Check(IReadOnlyList<string> lines) =>
Check(lines, LanguageCatalog.CSharp);
public static IReadOnlyList<CodeProblem> Check(IReadOnlyList<string> lines, LanguageProfile profile)
{
var problems = new List<CodeProblem>();
var inBlockComment = false;
int round = 0, curly = 0, square = 0;
for (var i = 0; i < lines.Count; i++)
{
var line = lines[i];
var tokens = CodeTokenizer.Tokenize(line, profile, inBlockComment, out inBlockComment);
// 각 글자가 어떤 종류 안에 있는지 표시해 둔다(토큰이 안 잡힌 자리는 코드로 본다).
var free = new bool[line.Length];
foreach (var token in tokens)
{
if (!IsFreeText(token.Kind)) continue;
var end = Math.Min(line.Length, token.Start + token.Length);
for (var k = token.Start; k < end; k++) free[k] = true;
}
var illegal = CollectIllegalSymbols(line, free);
if (illegal.Length > 0)
{
problems.Add(new CodeProblem(i,
$"코드 자리에 쓸 수 없는 문자가 있습니다: {illegal}"));
}
else if (LooksLikeProse(line, tokens))
{
problems.Add(new CodeProblem(i,
"주석 기호 없이 문장을 쓴 것 같습니다. // 를 앞에 붙이거나 문자열로 감싸세요."));
}
if (HasUnclosedQuote(line, tokens))
problems.Add(new CodeProblem(i, "따옴표가 닫히지 않았습니다."));
CountBrackets(line, free, tokens, ref round, ref curly, ref square);
}
if (inBlockComment)
problems.Add(new CodeProblem(Math.Max(0, lines.Count - 1), "블록 주석 /* 가 닫히지 않았습니다."));
AddBalanceProblem(problems, lines.Count, round, '(', ')');
AddBalanceProblem(problems, lines.Count, curly, '{', '}');
AddBalanceProblem(problems, lines.Count, square, '[', ']');
return problems;
}
/// <summary>
/// 코드 자리에 있으면 무조건 컴파일이 깨지는 문자만 모은다.
/// 전각 기호(? : ,), 전각 공백 같은 것들이다.
/// 한글·한자 "문자"는 C# 식별자로 쓸 수 있으므로 여기서 걸지 않는다.
/// </summary>
private static string CollectIllegalSymbols(string line, bool[] free)
{
var found = new StringBuilder();
var seen = new HashSet<char>();
for (var i = 0; i < line.Length; i++)
{
if (free[i]) continue;
var c = line[i];
if (c < 128) continue; // ASCII 는 문제없다
if (char.IsLetter(c)) continue; // 한글 변수명 등 - 합법
if (c == '_') continue;
if (seen.Add(c)) found.Append(c);
if (found.Length >= 12) break;
}
return found.ToString();
}
/// <summary>
/// "주석 기호를 빼먹고 문장을 그대로 쓴" 줄인지 본다.
///
/// 한글 낱말 하나는 변수명일 수 있으므로(= var 메시지) 걸지 않는다.
/// 대신 한글 낱말이 연산자·구두점 없이 공백만 두고 둘 이상 이어지면
/// (= 여기 한글을 그냥 씀) 문장으로 본다. 코드에서는 거의 나오지 않는 배치다.
/// </summary>
private static bool LooksLikeProse(string line, IReadOnlyList<CodeToken> tokens)
{
var run = 0;
var previousEnd = -1;
foreach (var token in tokens)
{
if (IsFreeText(token.Kind)) { run = 0; previousEnd = -1; continue; }
var text = line.Substring(token.Start, token.Length);
var isWord = token.Kind is CodeTokenKind.Default or CodeTokenKind.Type or CodeTokenKind.Method;
if (!isWord || !ContainsNonAsciiLetter(text))
{
run = 0;
previousEnd = -1;
continue;
}
// 앞 낱말과 사이에 공백만 있었는지 확인한다.
var onlySpaceBetween = previousEnd >= 0 && IsAllWhitespace(line, previousEnd, token.Start);
run = onlySpaceBetween ? run + 1 : 1;
previousEnd = token.Start + token.Length;
if (run >= 2) return true;
}
return false;
}
private static bool ContainsNonAsciiLetter(string text)
{
foreach (var c in text)
if (c >= 128 && char.IsLetter(c)) return true;
return false;
}
private static bool IsAllWhitespace(string line, int start, int end)
{
for (var i = start; i < end; i++)
if (!char.IsWhiteSpace(line[i])) return false;
return true;
}
/// <summary>따옴표가 줄 끝까지 안 닫혔는지. 토크나이저가 만든 Text 토큰의 끝으로 판단한다.</summary>
private static bool HasUnclosedQuote(string line, IReadOnlyList<CodeToken> tokens)
{
foreach (var token in tokens)
{
if (token.Kind != CodeTokenKind.Text) continue;
if (token.Start + token.Length < line.Length) continue;
// 줄 끝까지 이어진 문자열 - 마지막 글자가 여는 따옴표와 같아야 닫힌 것이다.
var text = line.Substring(token.Start, token.Length);
var quote = text.IndexOf('"') >= 0 ? '"' : '\'';
if (text.Length < 2 || text[text.Length - 1] != quote) return true;
}
return false;
}
private static void CountBrackets(string line, bool[] free, IReadOnlyList<CodeToken> tokens,
ref int round, ref int curly, ref int square)
{
for (var i = 0; i < line.Length; i++)
{
if (free[i]) continue;
switch (line[i])
{
case '(': round++; break;
case ')': round--; break;
case '{': curly++; break;
case '}': curly--; break;
case '[': square++; break;
case ']': square--; break;
}
}
}
private static void AddBalanceProblem(List<CodeProblem> problems, int lineCount,
int balance, char open, char close)
{
if (balance == 0) return;
var message = balance > 0
? $"{open} 가 {balance}개 더 많습니다 ({close} 를 빠뜨렸나요?)"
: $"{close} 가 {-balance}개 더 많습니다.";
problems.Add(new CodeProblem(Math.Max(0, lineCount - 1), message));
}
}
}

View File

@ -0,0 +1,181 @@
using System;
using System.Collections.Generic;
using MergeHub.Views.Controls;
namespace MergeHub.Services
{
/// <summary>
/// 언어 표(<see cref="LanguageProfile"/>)를 보고 한 줄을 토큰으로 쪼갠다.
///
/// C# 은 전용 토크나이저로 넘긴다 - 축자·보간 문자열과 접근제한자 구분이 표로 담기지 않고,
/// 이미 테스트로 굳어 있어 굳이 다시 만들 이유가 없다.
/// 평문·Markdown 처럼 코드가 아닌 형식은 토큰을 만들지 않는다(그냥 검은 글씨로 나온다).
/// </summary>
public static class CodeTokenizer
{
private const string OperatorChars = "=+-*/%!<>&|^~?:";
private const string PunctuationChars = "(){}[];,.";
private static readonly IReadOnlyList<CodeToken> Empty = Array.Empty<CodeToken>();
public static IReadOnlyList<CodeToken> Tokenize(string line, LanguageProfile profile,
bool inBlockComment, out bool stillInBlockComment)
{
if (profile.UseCSharpTokenizer)
return CSharpTokenizer.Tokenize(line, inBlockComment, out stillInBlockComment);
stillInBlockComment = inBlockComment;
if (!profile.IsCode || string.IsNullOrEmpty(line)) return Empty;
var tokens = new List<CodeToken>();
var i = 0;
// 앞 줄에서 넘어온 블록 주석을 먼저 닫는다.
if (inBlockComment && profile.HasBlockComment)
{
var close = line.IndexOf(profile.BlockEnd!, StringComparison.Ordinal);
if (close < 0)
{
tokens.Add(new CodeToken(0, line.Length, CodeTokenKind.Comment));
return tokens;
}
i = close + profile.BlockEnd!.Length;
tokens.Add(new CodeToken(0, i, CodeTokenKind.Comment));
stillInBlockComment = false;
}
while (i < line.Length)
{
var start = i;
var c = line[i];
// ---- 한 줄 주석 ----
if (TryMatchLineComment(line, i, profile))
{
tokens.Add(new CodeToken(start, line.Length - start, CodeTokenKind.Comment));
break;
}
// ---- 블록 주석 ----
if (profile.HasBlockComment && Matches(line, i, profile.BlockStart!))
{
var end = line.IndexOf(profile.BlockEnd!, i + profile.BlockStart!.Length, StringComparison.Ordinal);
if (end < 0)
{
tokens.Add(new CodeToken(start, line.Length - start, CodeTokenKind.Comment));
stillInBlockComment = true;
break;
}
i = end + profile.BlockEnd!.Length;
tokens.Add(new CodeToken(start, i - start, CodeTokenKind.Comment));
continue;
}
// ---- 문자열 ----
if (Array.IndexOf(profile.Quotes, c) >= 0)
{
i += ReadQuoted(line, i, c);
tokens.Add(new CodeToken(start, i - start, CodeTokenKind.Text));
continue;
}
// ---- 숫자 ----
if (char.IsDigit(c))
{
i++;
while (i < line.Length && (char.IsLetterOrDigit(line[i]) || line[i] == '.' || line[i] == '_'))
{
if (line[i] == '.' && i + 1 < line.Length && line[i + 1] == '.') break;
i++;
}
tokens.Add(new CodeToken(start, i - start, CodeTokenKind.Number));
continue;
}
// ---- 식별자 / 예약어 ----
if (char.IsLetter(c) || c == '_')
{
i++;
while (i < line.Length && (char.IsLetterOrDigit(line[i]) || line[i] == '_')) i++;
var word = line.Substring(start, i - start);
tokens.Add(new CodeToken(start, i - start, Classify(word, line, i, profile)));
continue;
}
if (OperatorChars.IndexOf(c) >= 0)
{
i++;
while (i < line.Length && OperatorChars.IndexOf(line[i]) >= 0) i++;
tokens.Add(new CodeToken(start, i - start, CodeTokenKind.Operator));
continue;
}
if (PunctuationChars.IndexOf(c) >= 0)
{
tokens.Add(new CodeToken(start, 1, CodeTokenKind.Punctuation));
i++;
continue;
}
i++; // 공백 등 나머지
}
return tokens;
}
/// <summary>
/// 한 줄 주석 표시가 여기서 시작하는지.
/// 배치 파일의 <c>rem </c> 처럼 글자로 된 표시가 있어 대소문자 규칙을 따른다.
/// </summary>
private static bool TryMatchLineComment(string line, int index, LanguageProfile profile)
{
foreach (var marker in profile.LineComments)
{
if (marker.Length == 0) continue;
if (Matches(line, index, marker, profile.CaseSensitive)) return true;
}
return false;
}
private static bool Matches(string line, int index, string token, bool caseSensitive = true)
{
if (index + token.Length > line.Length) return false;
return string.Compare(line, index, token, 0, token.Length,
caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase) == 0;
}
private static CodeTokenKind Classify(string word, string line, int afterIndex, LanguageProfile profile)
{
if (profile.ControlKeywords.Contains(word)) return CodeTokenKind.ControlKeyword;
if (profile.TypeKeywords.Contains(word)) return CodeTokenKind.TypeKeyword;
if (profile.Keywords.Contains(word)) return CodeTokenKind.Keyword;
var j = afterIndex;
while (j < line.Length && char.IsWhiteSpace(line[j])) j++;
if (j < line.Length && line[j] == '(') return CodeTokenKind.Method;
return char.IsUpper(word[0]) ? CodeTokenKind.Type : CodeTokenKind.Default;
}
/// <summary>여는 따옴표부터 닫는 따옴표까지의 길이. 닫히지 않으면 줄 끝까지.</summary>
private static int ReadQuoted(string line, int index, char quote)
{
var i = index + 1;
while (i < line.Length)
{
if (line[i] == '\\') { i += 2; continue; }
if (line[i] == quote) return i + 1 - index;
i++;
}
return line.Length - index;
}
}
}

View File

@ -0,0 +1,133 @@
using System;
using System.Collections.Generic;
using MergeHub.Models;
namespace MergeHub.Services
{
/// <summary>
/// 커밋 목록에 레인을 배정해 브랜치 그래프를 만든다.
///
/// 원리: "레인"은 아직 그려지지 않은 부모 커밋 하나를 기다리는 세로줄이다.
/// 목록을 위에서 아래로 훑으면서
/// 1) 지금 커밋을 기다리던 레인들을 찾아 그 중 왼쪽 것을 이 커밋의 레인으로 삼고,
/// 2) 나머지는 그 레인으로 합류시키고(사선),
/// 3) 첫 부모는 같은 레인이 이어받고, 두 번째 부모부터는 새 레인으로 분기시킨다.
/// 커밋은 이미 시간순(topological)으로 정렬되어 들어온다는 전제다.
/// </summary>
public static class CommitGraphBuilder
{
/// <summary>레인 색은 이 개수로 순환한다. 실제 색상값은 화면(CommitGraphCell)이 정한다.</summary>
public const int ColorCount = 6;
public static IReadOnlyList<CommitNode> Build(IReadOnlyList<CommitInfo> commits)
{
var nodes = new List<CommitNode>(commits.Count);
// lanes[i] = 그 레인이 기다리는 커밋 SHA (없으면 null)
var lanes = new List<string?>();
var laneColors = new List<int>();
var maxLanes = 1;
var nextColor = 0;
foreach (var commit in commits)
{
// 이 행에 "위에서 내려온" 상태를 먼저 찍어둔다.
var top = new List<string?>(lanes);
var myLanes = new List<int>();
for (var i = 0; i < lanes.Count; i++)
if (lanes[i] == commit.Sha) myLanes.Add(i);
int lane;
if (myLanes.Count == 0)
{
// 아무도 기다리지 않던 커밋 = 새 가지의 시작(브랜치 팁)
lane = TakeFreeLane(lanes, laneColors, ref nextColor);
lanes[lane] = commit.Sha;
myLanes.Add(lane);
}
else
{
lane = myLanes[0];
}
var colorIndex = laneColors[lane];
// 이 커밋을 기다리던 레인은 전부 비운다.
foreach (var i in myLanes) lanes[i] = null;
// 첫 부모는 같은 레인이 이어받고, 나머지는 새 레인으로 분기한다.
for (var p = 0; p < commit.ParentShas.Count; p++)
{
var parent = commit.ParentShas[p];
if (p == 0)
{
lanes[lane] = parent;
continue;
}
// 이미 그 부모를 기다리는 레인이 있으면 그리로 합친다.
var existing = lanes.IndexOf(parent);
if (existing >= 0) continue;
var branchLane = TakeFreeLane(lanes, laneColors, ref nextColor);
lanes[branchLane] = parent;
}
var edges = new List<GraphEdge>();
// (1) 위에서 내려온 선들
for (var i = 0; i < top.Count; i++)
{
if (top[i] is null) continue;
if (myLanes.Contains(i))
edges.Add(new GraphEdge(i, lane, laneColors[i])); // 이 커밋으로 합류
else if (i < lanes.Count && lanes[i] is not null)
edges.Add(new GraphEdge(i, i, laneColors[i])); // 그냥 통과
}
// (2) 이 커밋에서 새로 시작하는 선들 (첫 부모의 수직 연장 + 분기)
for (var i = 0; i < lanes.Count; i++)
{
if (lanes[i] is null) continue;
// 위에서 이미 내려온 레인은 (1)에서 그렸다.
if (i < top.Count && top[i] is not null && !myLanes.Contains(i)) continue;
// 이 커밋의 레인이 위에서도 이어져 왔다면 (1)의 i→lane 선과 겹친다.
if (i == lane && lane < top.Count && top[lane] is not null) continue;
edges.Add(new GraphEdge(lane, i, laneColors[i]));
}
if (lanes.Count > maxLanes) maxLanes = lanes.Count;
if (top.Count > maxLanes) maxLanes = top.Count;
nodes.Add(new CommitNode(commit, new GraphRow(lane, colorIndex, edges, commit.IsMerge)));
}
// 열 너비를 전체에서 가장 넓은 행에 맞춘다.
foreach (var node in nodes) node.Graph.LaneCount = maxLanes;
return nodes;
}
/// <summary>비어 있는 레인을 재사용하고, 없으면 하나 늘린다.</summary>
private static int TakeFreeLane(List<string?> lanes, List<int> laneColors, ref int nextColor)
{
for (var i = 0; i < lanes.Count; i++)
{
if (lanes[i] is not null) continue;
laneColors[i] = nextColor++ % ColorCount;
return i;
}
lanes.Add(null);
laneColors.Add(nextColor++ % ColorCount);
return lanes.Count - 1;
}
}
}

View File

@ -0,0 +1,166 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;
namespace MergeHub.Services
{
/// <summary>공백을 얼마나 따질지.</summary>
public enum WhitespaceMode
{
/// <summary>공백 하나까지 그대로 비교한다.</summary>
Exact,
/// <summary>공백의 개수만 무시한다. 들여쓰기 폭을 바꿔도 같은 줄로 본다.</summary>
IgnoreAmount,
/// <summary>공백을 아예 없는 셈 친다. 탭/스페이스 정리를 무시할 때.</summary>
IgnoreAll
}
/// <summary>
/// "무엇을 차이로 볼 것인가"를 정하는 규칙.
///
/// 실무에서 diff 를 어지럽히는 것은 대개 내용이 아니라 형식이다 - 들여쓰기 정리,
/// 대소문자, 매번 바뀌는 타임스탬프 줄. 이런 것을 걸러야 진짜 변경이 눈에 들어온다.
///
/// 규칙은 두 갈래로 나뉘어 쓰인다.
/// · <see cref="KeyOf"/> — 정렬할 때 "같은 줄인가"를 판단하는 비교용 문자열.
/// 화면에 보이는 원문은 건드리지 않는다.
/// · <see cref="IsFiltered"/> — 줄 자체를 차이 계산에서 빼는 정규식 필터.
/// 정렬을 마친 뒤에 적용한다. 필터에 걸린 줄까지 같은 것으로 묶어 정렬하면
/// 엉뚱한 줄끼리 짝지어지기 때문이다.
/// </summary>
public sealed class CompareRules
{
public WhitespaceMode Whitespace { get; set; } = WhitespaceMode.Exact;
public bool IgnoreCase { get; set; }
/// <summary>이 정규식에 걸리는 줄은 차이로 세지 않는다. 잘못된 식은 조용히 버린다.</summary>
public IReadOnlyList<string> LinePatterns { get; set; } = Array.Empty<string>();
/// <summary>줄 필터를 켤지. 식은 그대로 두고 잠시 끄고 싶을 때가 있다.</summary>
public bool LineFilterEnabled { get; set; } = true;
private Regex[]? _compiled;
private string? _compiledFrom;
/// <summary>어떤 규칙도 켜져 있지 않으면 비교용 변환을 건너뛸 수 있다.</summary>
public bool IsPlain =>
Whitespace == WhitespaceMode.Exact && !IgnoreCase;
/// <summary>
/// 정렬에서 "같은 줄인가"를 따질 때 쓰는 문자열.
/// 원문은 그대로 두고 이 값만 비교하므로 화면 표시는 달라지지 않는다.
/// </summary>
public string KeyOf(string line)
{
if (line.Length == 0) return line;
var text = Whitespace switch
{
WhitespaceMode.IgnoreAll => StripSpaces(line),
WhitespaceMode.IgnoreAmount => CollapseSpaces(line),
_ => line
};
return IgnoreCase ? text.ToUpperInvariant() : text;
}
/// <summary>줄 필터에 걸리는 줄인지. 걸리면 차이로 세지 않는다.</summary>
public bool IsFiltered(string? line)
{
if (!LineFilterEnabled || line is null) return false;
foreach (var rule in Compiled())
{
if (rule.IsMatch(line)) return true;
}
return false;
}
/// <summary>상태바에 한 줄로 요약한다. 켜진 게 없으면 null.</summary>
public string? Describe()
{
var parts = new List<string>(3);
if (Whitespace == WhitespaceMode.IgnoreAmount) parts.Add("공백 개수");
else if (Whitespace == WhitespaceMode.IgnoreAll) parts.Add("공백 전부");
if (IgnoreCase) parts.Add("대소문자");
var rules = Compiled().Length;
if (rules > 0) parts.Add($"줄필터 {rules}");
return parts.Count == 0 ? null : "무시: " + string.Join(" · ", parts);
}
/// <summary>
/// 정규식은 쓸 때마다 다시 만들면 느리므로 캐시한다.
/// 사용자가 식을 고치면 목록 문자열이 달라지므로 그때만 다시 만든다.
/// </summary>
private Regex[] Compiled()
{
if (!LineFilterEnabled) return Array.Empty<Regex>();
var signature = string.Join("", LinePatterns);
if (_compiled is not null && _compiledFrom == signature) return _compiled;
var list = new List<Regex>(LinePatterns.Count);
foreach (var pattern in LinePatterns)
{
if (string.IsNullOrWhiteSpace(pattern)) continue;
try
{
list.Add(new Regex(pattern, RegexOptions.CultureInvariant));
}
catch (ArgumentException)
{
// 사용자가 식을 고치는 중일 수 있다. 잘못된 식 하나 때문에 비교가 멈추면 안 된다.
}
}
_compiledFrom = signature;
_compiled = list.ToArray();
return _compiled;
}
/// <summary>연속된 공백을 하나로 줄이고 앞뒤를 다듬는다.</summary>
private static string CollapseSpaces(string line)
{
var sb = new StringBuilder(line.Length);
var pendingSpace = false;
foreach (var c in line)
{
if (char.IsWhiteSpace(c))
{
if (sb.Length > 0) pendingSpace = true;
continue;
}
if (pendingSpace)
{
sb.Append(' ');
pendingSpace = false;
}
sb.Append(c);
}
return sb.ToString();
}
private static string StripSpaces(string line)
{
var sb = new StringBuilder(line.Length);
foreach (var c in line)
if (!char.IsWhiteSpace(c)) sb.Append(c);
return sb.ToString();
}
}
}

View File

@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
namespace MergeHub.Services
{
/// <summary>편집기에 그려 넣을 줄들과, 그중 어느 줄이 필러인지.</summary>
public sealed class EditorSnapshot
{
public EditorSnapshot(string[] lines, bool[] filler)
{
Lines = lines;
Filler = filler;
}
/// <summary>편집기 본문. 필러 자리는 빈 문자열이다.</summary>
public string[] Lines { get; }
/// <summary>같은 인덱스의 줄이 필러(= LOCAL 에 없는 줄)인지.</summary>
public bool[] Filler { get; }
}
/// <summary>
/// LOCAL 패널을 "항상 고칠 수 있는 편집기"로 쓰기 위한 줄 계산.
///
/// 화면에서 BASE 와 LOCAL 은 같은 내용이 같은 눈높이에 와야 한다. 그래서 BASE 에만 있는
/// 줄(= LOCAL 에서 지워진 줄) 자리에는 LOCAL 쪽에 <b>빈 줄(필러)</b>을 끼워 넣는다.
/// 필러는 화면을 맞추려고 넣은 것일 뿐 파일 내용이 아니므로, 저장하거나 다시 정렬하기 전에
/// 걷어내야 한다.
///
/// 문제는 사용자가 편집기에서 줄을 넣고 지운 뒤라 필러 위치가 밀려 있다는 점이다.
/// 그래서 "고치기 전 편집기 내용"과 "지금 편집기 내용"을 정렬해 보고,
/// <b>예전 필러와 그대로 대응되면서 여전히 비어 있는 줄</b>만 필러로 판정해 걷어낸다.
/// 필러 자리에 사용자가 뭔가 써 넣었다면 그건 새로 추가한 줄이므로 그대로 살린다.
/// </summary>
public static class EditorLayout
{
/// <summary>정렬 결과를 편집기에 그릴 줄들로 바꾼다.</summary>
public static EditorSnapshot Build(IReadOnlyList<(string Text, bool HasLocal)> rows)
{
var lines = new string[rows.Count];
var filler = new bool[rows.Count];
for (var i = 0; i < rows.Count; i++)
{
filler[i] = !rows[i].HasLocal;
lines[i] = rows[i].HasLocal ? rows[i].Text : string.Empty;
}
return new EditorSnapshot(lines, filler);
}
/// <summary>
/// 사용자가 고친 편집기 내용에서 필러를 걷어내 실제 LOCAL 줄만 남긴다.
/// </summary>
/// <param name="previous">고치기 전 편집기 줄(필러 포함)</param>
/// <param name="wasFiller">그때 어느 줄이 필러였는지</param>
/// <param name="current">지금 편집기 줄</param>
public static List<string> StripFillers(IReadOnlyList<string> previous,
IReadOnlyList<bool> wasFiller,
IReadOnlyList<string> current)
{
var kept = new List<string>(current.Count);
// 고치기 전 내용이 없으면 걷어낼 필러도 없다.
if (previous.Count == 0)
{
foreach (var line in current) kept.Add(line);
return kept;
}
// 어느 줄이 그대로 남았는지 짚어 준다. 빈 줄은 흔해서 앵커가 되지 못하므로
// 주변의 진짜 코드 줄에 딸려 자리를 잡는다 - 그래서 정렬기를 쓰는 것이다.
var map = LineAligner.Align(previous, current);
foreach (var row in map)
{
if (row.LocalNumber is null) continue; // 사용자가 지운 줄
var text = row.LocalText ?? string.Empty;
var cameFromFiller = row.BaseNumber is int oldNumber
&& oldNumber >= 1 && oldNumber <= wasFiller.Count
&& wasFiller[oldNumber - 1];
// 필러였고 여전히 비어 있으면 화면용 빈 줄일 뿐이다.
if (cameFromFiller && text.Length == 0) continue;
kept.Add(text);
}
return kept;
}
/// <summary>두 줄 목록이 같은지. 편집기 글을 다시 써야 하는지 판단할 때 쓴다.</summary>
public static bool SameLines(IReadOnlyList<string> a, IReadOnlyList<string> b)
{
if (a.Count != b.Count) return false;
for (var i = 0; i < a.Count; i++)
if (!string.Equals(a[i], b[i], StringComparison.Ordinal)) return false;
return true;
}
}
}

View File

@ -0,0 +1,361 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
namespace MergeHub.Services
{
/// <summary>폴더 비교에서 한 항목이 어떤 상태인지.</summary>
public enum FolderEntryStatus
{
/// <summary>양쪽이 같다.</summary>
Same,
/// <summary>양쪽에 있고 내용이 다르다.</summary>
Different,
/// <summary>왼쪽에만 있다.</summary>
LeftOnly,
/// <summary>오른쪽에만 있다.</summary>
RightOnly,
/// <summary>내용이 다르지만 이진 파일이라 줄 단위로 볼 수 없다.</summary>
Binary
}
/// <summary>폴더 비교 규칙.</summary>
public sealed class FolderCompareOptions
{
/// <summary>
/// 빠른 비교. 내용을 읽지 않고 <b>크기와 수정 시각</b>만 본다.
/// 네트워크 드라이브나 수만 개짜리 폴더에서 몇 분이 몇 초로 줄어든다.
/// </summary>
public bool QuickCompare { get; set; }
/// <summary>
/// 이 크기를 넘는 파일은 빠른 비교로 돌린다.
/// 전체 비교를 골랐더라도 수백 MB 짜리를 바이트로 훑는 것은 득보다 실이 크다.
/// </summary>
public long FullCompareLimitBytes { get; set; } = 8L * 1024 * 1024;
/// <summary>포함할 파일 정규식. 비우면 전부 포함.</summary>
public string IncludePattern { get; set; } = string.Empty;
/// <summary>제외할 파일·폴더 정규식. 비우면 제외 없음. 포함 규칙보다 우선한다.</summary>
public string ExcludePattern { get; set; } = string.Empty;
/// <summary>하위 폴더까지 훑을지.</summary>
public bool IncludeSubfolders { get; set; } = true;
/// <summary>
/// 수정 시각을 같다고 볼 오차. 파일 시스템마다 정밀도가 달라(FAT 는 2초) 정확히 같기를
/// 기대하면 안 된다.
/// </summary>
public TimeSpan TimeTolerance { get; set; } = TimeSpan.FromSeconds(2);
}
/// <summary>비교 결과 한 항목. 폴더면 Children 이 채워진다.</summary>
public sealed class FolderCompareItem
{
public FolderCompareItem(string name, string relativePath, bool isDirectory)
{
Name = name;
RelativePath = relativePath;
IsDirectory = isDirectory;
}
public string Name { get; }
/// <summary>두 뿌리 폴더 기준의 상대 경로. 양쪽 실제 경로를 만들 때 쓴다.</summary>
public string RelativePath { get; }
public bool IsDirectory { get; }
public FolderEntryStatus Status { get; set; }
public bool LeftExists { get; set; }
public bool RightExists { get; set; }
public long LeftSize { get; set; }
public long RightSize { get; set; }
public DateTime? LeftModified { get; set; }
public DateTime? RightModified { get; set; }
public List<FolderCompareItem> Children { get; } = new List<FolderCompareItem>();
public bool IsDifferent => Status != FolderEntryStatus.Same;
}
public sealed class FolderCompareResult
{
public List<FolderCompareItem> Items { get; } = new List<FolderCompareItem>();
public int TotalFiles { get; set; }
public int SameCount { get; set; }
public int DifferentCount { get; set; }
public int LeftOnlyCount { get; set; }
public int RightOnlyCount { get; set; }
/// <summary>항목 상한에 걸려 도중에 멈췄는지.</summary>
public bool Truncated { get; set; }
public string Summary =>
$"전체 {TotalFiles}개 · 다름 {DifferentCount} · 왼쪽만 {LeftOnlyCount} · 오른쪽만 {RightOnlyCount} · 같음 {SameCount}"
+ (Truncated ? " (너무 많아 일부만 봄)" : string.Empty);
}
/// <summary>
/// 폴더 두 개를 재귀로 견준다.
///
/// 파일 하나하나를 여는 것이 비용의 전부라서, 열기 전에 걸러낼 수 있는 것은 모두 먼저 거른다 -
/// 크기가 다르면 내용을 볼 필요가 없고, 빠른 비교면 아예 열지 않는다.
/// </summary>
public static class FolderComparer
{
/// <summary>이 개수를 넘으면 멈춘다. 실수로 C:\ 를 고른 경우에 화면이 굳는 것을 막는다.</summary>
private const int MaxEntries = 50_000;
public static FolderCompareResult Compare(string leftRoot, string rightRoot, FolderCompareOptions options)
{
var result = new FolderCompareResult();
if (string.IsNullOrWhiteSpace(leftRoot) || string.IsNullOrWhiteSpace(rightRoot)) return result;
if (!Directory.Exists(leftRoot) && !Directory.Exists(rightRoot)) return result;
var include = Compile(options.IncludePattern);
var exclude = Compile(options.ExcludePattern);
var count = 0;
Walk(leftRoot, rightRoot, string.Empty, options, include, exclude, result, result.Items, ref count);
result.Truncated = count >= MaxEntries;
return result;
}
private static void Walk(string leftRoot, string rightRoot, string relative,
FolderCompareOptions options, Regex? include, Regex? exclude,
FolderCompareResult result, List<FolderCompareItem> into, ref int count)
{
if (count >= MaxEntries) return;
var leftDir = Path.Combine(leftRoot, relative);
var rightDir = Path.Combine(rightRoot, relative);
// ---- 파일 ----
var names = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var path in SafeFiles(leftDir)) names.Add(Path.GetFileName(path));
foreach (var path in SafeFiles(rightDir)) names.Add(Path.GetFileName(path));
foreach (var name in names)
{
if (count >= MaxEntries) return;
var childRelative = string.IsNullOrEmpty(relative) ? name : Path.Combine(relative, name);
if (IsFilteredOut(name, childRelative, include, exclude)) continue;
var leftPath = Path.Combine(leftDir, name);
var rightPath = Path.Combine(rightDir, name);
var item = new FolderCompareItem(name, childRelative, isDirectory: false)
{
LeftExists = File.Exists(leftPath),
RightExists = File.Exists(rightPath),
};
if (item.LeftExists)
{
var info = new FileInfo(leftPath);
item.LeftSize = info.Length;
item.LeftModified = info.LastWriteTime;
}
if (item.RightExists)
{
var info = new FileInfo(rightPath);
item.RightSize = info.Length;
item.RightModified = info.LastWriteTime;
}
item.Status = Classify(item, leftPath, rightPath, options);
into.Add(item);
count++;
result.TotalFiles++;
switch (item.Status)
{
case FolderEntryStatus.Same: result.SameCount++; break;
case FolderEntryStatus.LeftOnly: result.LeftOnlyCount++; break;
case FolderEntryStatus.RightOnly: result.RightOnlyCount++; break;
default: result.DifferentCount++; break;
}
}
if (!options.IncludeSubfolders) return;
// ---- 하위 폴더 ----
var dirNames = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var path in SafeDirs(leftDir)) dirNames.Add(Path.GetFileName(path));
foreach (var path in SafeDirs(rightDir)) dirNames.Add(Path.GetFileName(path));
foreach (var name in dirNames)
{
if (count >= MaxEntries) return;
var childRelative = string.IsNullOrEmpty(relative) ? name : Path.Combine(relative, name);
// 폴더는 제외 규칙만 본다. 포함 규칙은 파일 이름에 거는 것이라 폴더에 걸면 트리가 통째로 사라진다.
if (exclude is not null && (exclude.IsMatch(name) || exclude.IsMatch(childRelative))) continue;
var item = new FolderCompareItem(name, childRelative, isDirectory: true)
{
LeftExists = Directory.Exists(Path.Combine(leftDir, name)),
RightExists = Directory.Exists(Path.Combine(rightDir, name)),
};
Walk(leftRoot, rightRoot, childRelative, options, include, exclude, result, item.Children, ref count);
item.Status = FolderStatus(item);
// 알맹이가 하나도 없는 폴더는 목록만 어지럽힌다. 한쪽에만 있는 폴더는 남긴다.
if (item.Children.Count == 0 && item.LeftExists && item.RightExists) continue;
into.Add(item);
count++;
}
}
/// <summary>폴더 상태는 아래에 담긴 것들로 정한다.</summary>
private static FolderEntryStatus FolderStatus(FolderCompareItem folder)
{
if (!folder.RightExists) return FolderEntryStatus.LeftOnly;
if (!folder.LeftExists) return FolderEntryStatus.RightOnly;
foreach (var child in folder.Children)
{
if (child.Status != FolderEntryStatus.Same) return FolderEntryStatus.Different;
}
return FolderEntryStatus.Same;
}
private static FolderEntryStatus Classify(FolderCompareItem item, string leftPath, string rightPath,
FolderCompareOptions options)
{
if (!item.RightExists) return FolderEntryStatus.LeftOnly;
if (!item.LeftExists) return FolderEntryStatus.RightOnly;
// 크기가 다르면 내용을 볼 필요가 없다.
if (item.LeftSize != item.RightSize) return DifferentKind(leftPath, rightPath);
var quick = options.QuickCompare || item.LeftSize > options.FullCompareLimitBytes;
if (quick)
{
var left = item.LeftModified ?? DateTime.MinValue;
var right = item.RightModified ?? DateTime.MinValue;
var gap = left > right ? left - right : right - left;
return gap <= options.TimeTolerance
? FolderEntryStatus.Same
: DifferentKind(leftPath, rightPath);
}
return SameContent(leftPath, rightPath)
? FolderEntryStatus.Same
: DifferentKind(leftPath, rightPath);
}
/// <summary>다른 파일이 이진이면 따로 표시한다 - 줄 단위 비교 화면을 열어도 볼 것이 없다.</summary>
private static FolderEntryStatus DifferentKind(string leftPath, string rightPath) =>
TextFile.IsBinary(leftPath) || TextFile.IsBinary(rightPath)
? FolderEntryStatus.Binary
: FolderEntryStatus.Different;
/// <summary>내용을 앞에서부터 덩어리째 견준다. 다른 곳을 만나면 바로 멈춘다.</summary>
private static bool SameContent(string leftPath, string rightPath)
{
const int BufferSize = 64 * 1024;
try
{
using var left = File.OpenRead(leftPath);
using var right = File.OpenRead(rightPath);
if (left.Length != right.Length) return false;
var a = new byte[BufferSize];
var b = new byte[BufferSize];
while (true)
{
var readA = ReadBlock(left, a);
var readB = ReadBlock(right, b);
if (readA != readB) return false;
if (readA == 0) return true;
for (var i = 0; i < readA; i++)
{
if (a[i] != b[i]) return false;
}
}
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
}
private static int ReadBlock(Stream stream, byte[] buffer)
{
var total = 0;
while (total < buffer.Length)
{
var read = stream.Read(buffer, total, buffer.Length - total);
if (read == 0) break;
total += read;
}
return total;
}
private static bool IsFilteredOut(string name, string relative, Regex? include, Regex? exclude)
{
if (exclude is not null && (exclude.IsMatch(name) || exclude.IsMatch(relative))) return true;
if (include is not null && !include.IsMatch(name) && !include.IsMatch(relative)) return true;
return false;
}
/// <summary>규칙이 깨져 있으면 규칙이 없는 것으로 본다. 화면이 예외로 죽는 것보다 낫다.</summary>
private static Regex? Compile(string pattern)
{
if (string.IsNullOrWhiteSpace(pattern)) return null;
try
{
return new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
}
catch (ArgumentException)
{
return null;
}
}
private static IEnumerable<string> SafeFiles(string dir)
{
if (!Directory.Exists(dir)) return Array.Empty<string>();
try { return Directory.GetFiles(dir); }
catch (UnauthorizedAccessException) { return Array.Empty<string>(); }
catch (IOException) { return Array.Empty<string>(); }
}
private static IEnumerable<string> SafeDirs(string dir)
{
if (!Directory.Exists(dir)) return Array.Empty<string>();
try { return Directory.GetDirectories(dir); }
catch (UnauthorizedAccessException) { return Array.Empty<string>(); }
catch (IOException) { return Array.Empty<string>(); }
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,394 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using MergeHub.Models;
using UbisamBase.Core.Logging;
using UbisamBase.Core.Messaging;
using UbisamBase.Core.SettingItems;
namespace MergeHub.Services
{
/// <summary>
/// 홈(머지툴) 탭과 Git 탭이 함께 쓰는 저장소 상태.
/// IGitService 는 스레드 안전하지 않으므로 모든 호출을 여기서 한 줄로 직렬화한다.
/// DI 싱글턴으로 등록되어 두 화면이 같은 인스턴스를 본다.
/// </summary>
public sealed partial class GitWorkspace : ObservableObject
{
private readonly SemaphoreSlim _gate = new SemaphoreSlim(1, 1);
private readonly SynchronizationContext? _uiContext;
private readonly Logger _logger = new Logger("MergeHub");
/// <summary>플랫폼에 등록된 사용자. 머지 주석에 남길 이름을 여기서 가져온다.</summary>
private readonly UbisamBase.Core.Settings.CurrentUserService? _currentUser;
public GitWorkspace(IGitService git, ISettingsService settingsService,
UbisamBase.Core.Settings.CurrentUserService currentUser)
{
Git = git;
_currentUser = currentUser;
_uiContext = SynchronizationContext.Current;
Settings = settingsService.RegisterSettings(nameof(MergeHubSettings), new MergeHubSettings());
RepositoryPath = Settings.DefaultRepositoryPath;
RemoteName = string.IsNullOrWhiteSpace(Settings.DefaultRemoteName) ? "origin" : Settings.DefaultRemoteName;
RemoteUrl = Settings.GiteaUrl;
UserName = Settings.GiteaUserName;
Git.IgnoreWhitespace = Settings.IgnoreWhitespace;
Git.WordLevelDiff = Settings.WordLevelDiff;
// 작성자 이름은 여기서 한 번 정해 둔다.
// 저장소를 읽을 때(ReadState)만 정하면 Git 없이 파일 두 개를 비교할 때 이름이 비고,
// 그러면 주석에 등록한 이름 대신 윈도우 계정이 찍힌다.
AuthorName = ResolveAuthorName();
// 설정 화면에서 이름을 바꾸면 바로 따라간다.
if (_currentUser is not null)
{
_currentUser.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(_currentUser.UserName)) AuthorName = ResolveAuthorName();
};
}
Git.Progress += (_, message) =>
{
_logger.I(message);
OnUi(() => AppendLog(message));
};
}
public IGitService Git { get; }
public MergeHubSettings Settings { get; }
/// <summary>저장소 상태가 다시 읽힐 때마다 발생. 화면들이 자기 패널을 갱신한다.</summary>
public event Action? Refreshed;
/// <summary>
/// 작업이 끝나 커맨드의 실행 가능 여부가 달라졌을 수 있음을 알린다.
/// CommunityToolkit 의 RelayCommand 는 CommandManager 를 구독하지 않아
/// 각 ViewModel 이 직접 NotifyCanExecuteChanged 를 불러야 한다.
/// </summary>
public event Action? StateChanged;
// ================= 공유 목록 =================
public ObservableCollection<BranchInfo> Branches { get; } = new ObservableCollection<BranchInfo>();
public ObservableCollection<CommitInfo> Commits { get; } = new ObservableCollection<CommitInfo>();
/// <summary>커밋 + 브랜치 그래프 레인. 홈 화면의 커밋 트리가 이걸 바인딩한다.</summary>
public ObservableCollection<CommitNode> CommitNodes { get; } = new ObservableCollection<CommitNode>();
public ObservableCollection<FileStatusInfo> StatusFiles { get; } = new ObservableCollection<FileStatusInfo>();
public ObservableCollection<ConflictInfo> Conflicts { get; } = new ObservableCollection<ConflictInfo>();
public ObservableCollection<TagInfo> Tags { get; } = new ObservableCollection<TagInfo>();
public ObservableCollection<SubmoduleInfo> Submodules { get; } = new ObservableCollection<SubmoduleInfo>();
// ================= 공유 상태 =================
[ObservableProperty] private string _repositoryPath = string.Empty;
[ObservableProperty] private string _remoteUrl = string.Empty;
[ObservableProperty] private string _remoteName = "origin";
[ObservableProperty] private string _userName = string.Empty;
[ObservableProperty] private string _password = string.Empty;
[ObservableProperty] private string _repositoryName = "-";
[ObservableProperty] private string _currentBranch = "-";
[ObservableProperty] private string _headSha = "-";
[ObservableProperty] private string _workingTreeSummary = "저장소 없음";
[ObservableProperty] private bool _isWorkingTreeClean = true;
[ObservableProperty] private bool _isRepositoryOpen;
[ObservableProperty] private bool _isRemoteConnected;
/// <summary>원격 등록 여부만 나타낸다 - 서버 접속 확인은 Fetch 를 눌러야 이뤄진다.</summary>
[ObservableProperty] private string _remoteStatusText = "원격 없음";
/// <summary>현재 원격의 주소(clone/push 대상).</summary>
[ObservableProperty] private string _remoteUrlText = "-";
/// <summary>추적 브랜치와 비교한 앞섬/뒤처짐. 마지막 fetch 시점 기준이다.</summary>
[ObservableProperty] private string _syncStatusText = "-";
/// <summary>추적 브랜치와 완전히 같은 상태인지.</summary>
[ObservableProperty] private bool _isUpToDate;
[ObservableProperty] private string _lastFetchText = "fetch 이력 없음";
/// <summary>머지 주석에 남길 작성자. 설정값 > git config user.name > 윈도우 계정 순.</summary>
[ObservableProperty] private string _authorName = Environment.UserName;
[ObservableProperty] private bool _isMergeInProgress;
[ObservableProperty] private bool _isRebaseInProgress;
[ObservableProperty] private string _pendingOperationText = string.Empty;
[ObservableProperty] private CommitInfo? _selectedCommit;
[ObservableProperty] private BranchInfo? _selectedBranch;
[ObservableProperty] private string _log = string.Empty;
// ================= 실행 =================
/// <summary>
/// Git 호출 하나를 백그라운드에서 직렬 실행한다.
/// 실패하면 로그 + Error 토스트를 띄우고 false 를 돌려준다.
/// </summary>
public async Task<bool> RunAsync(string operation, Action action)
{
// 이미 다른 Git 작업이 도는 중이면 겹쳐 실행하지 않는다.
// 조용히 사라지면 원인을 못 찾으니 반드시 로그를 남긴다.
if (!await _gate.WaitAsync(0))
{
_logger.W($"[{operation}] 다른 작업 진행 중이라 건너뜀");
return false;
}
try
{
await Task.Run(action);
return true;
}
catch (Exception ex)
{
_logger.E($"[{operation}] 실패", ex);
OnUi(() =>
{
AppendLog($"[{operation}] 실패: {ex.Message}");
MessageUtil.ShowErrorToast($"{operation} 실패: {ex.Message}");
});
return false;
}
finally
{
_gate.Release();
OnUi(() => StateChanged?.Invoke());
}
}
/// <summary>작업 하나를 실행하고 성공하면 저장소 상태를 다시 읽는다.</summary>
public async Task<bool> RunAndRefreshAsync(string operation, Action action)
{
var ok = await RunAsync(operation, action);
if (ok) await RefreshAsync();
return ok;
}
/// <summary>
/// 기본 경로가 Git 저장소로 보이는지. 워크트리는 .git 이 파일이라 둘 다 본다.
///
/// 저장소가 아닌 폴더에 Open 을 걸면 실패 알림이 뜰 뿐 아니라, 그동안 작업 게이트를
/// 쥐고 있어서 뒤이어 시작하는 "파일 두 개 비교"까지 통째로 취소된다.
/// </summary>
public bool LooksLikeRepository =>
!string.IsNullOrWhiteSpace(RepositoryPath) &&
(Directory.Exists(Path.Combine(RepositoryPath, ".git")) ||
File.Exists(Path.Combine(RepositoryPath, ".git")));
/// <summary>설정에 기본 저장소 경로가 있으면 시작하자마자 연다.</summary>
public Task TryAutoOpenAsync()
{
if (IsRepositoryOpen || string.IsNullOrWhiteSpace(RepositoryPath) || !Directory.Exists(RepositoryPath))
return Task.CompletedTask;
if (!LooksLikeRepository) return Task.CompletedTask;
return RunAndRefreshAsync("저장소 자동 열기", () => Git.Open(RepositoryPath));
}
/// <summary>
/// 상태를 다시 읽고, **게이트를 놓은 뒤에** Refreshed 를 발생시킨다.
/// 게이트를 쥔 채로 알리면 구독자가 거는 Git 호출이 전부 버려진다.
/// </summary>
public async Task RefreshAsync()
{
if (!await RunAsync("새로고침", ReadState)) return;
OnUi(() =>
{
Refreshed?.Invoke();
StateChanged?.Invoke();
});
}
private void ReadState()
{
if (!Git.IsOpen) return;
var info = Git.GetRepositoryInfo();
var branches = Git.GetBranches();
var commits = Git.GetCommits(maxCount: Settings.CommitPageSize <= 0 ? 200 : Settings.CommitPageSize);
var nodes = CommitGraphBuilder.Build(commits);
var status = Git.GetStatus();
var conflicts = Git.GetConflicts();
var tags = Git.GetTags();
var submodules = Git.GetSubmodules();
var rebase = Git.GetRebaseStatus();
var remotes = Git.GetRemotes();
var mergeInProgress = Git.IsMergeInProgress;
var remote = remotes.FirstOrDefault(r => r.Name == RemoteName) ?? remotes.FirstOrDefault();
var head = branches.FirstOrDefault(b => b.IsCurrentHead);
var author = ResolveAuthorName();
OnUi(() =>
{
RepositoryPath = info.WorkingDirectory;
RepositoryName = GetRepositoryName(info.WorkingDirectory);
CurrentBranch = info.CurrentBranchName ?? "(detached)";
HeadSha = string.IsNullOrEmpty(info.HeadSha) ? "-" : info.HeadSha!.Substring(0, 7);
IsRepositoryOpen = true;
IsRemoteConnected = remotes.Count > 0;
RemoteStatusText = remotes.Count > 0 ? $"원격 {remotes.Count}개" : "원격 없음";
RemoteUrlText = remote?.Url ?? "-";
AuthorName = author;
(SyncStatusText, IsUpToDate) = DescribeSync(head);
IsMergeInProgress = mergeInProgress;
IsRebaseInProgress = rebase.IsInProgress;
PendingOperationText =
rebase.IsInProgress ? $"rebase {rebase.CurrentStep}/{rebase.TotalSteps}" :
mergeInProgress ? "병합 진행 중" : string.Empty;
IsWorkingTreeClean = status.IsClean;
WorkingTreeSummary = status.IsClean ? "Clean" : $"{status.Files.Count} files";
Replace(Branches, branches);
Replace(Commits, commits);
Replace(CommitNodes, nodes);
Replace(StatusFiles, status.Files);
Replace(Conflicts, conflicts);
Replace(Tags, tags);
Replace(Submodules, submodules);
if (SelectedCommit is null && Commits.Count > 0) SelectedCommit = Commits[0];
});
}
/// <summary>
/// 추적 브랜치와 몇 개 차이 나는지 문장으로 만든다.
/// 이 수치는 **마지막 fetch 시점** 기준이라, fetch 전에는 서버가 앞서 있어도 "최신"으로 보인다.
/// </summary>
private static (string Text, bool UpToDate) DescribeSync(BranchInfo? head)
{
if (head is null) return ("브랜치 없음", false);
if (head.TrackedBranch is null) return ("추적 브랜치 없음", false);
var ahead = head.AheadBy ?? 0;
var behind = head.BehindBy ?? 0;
if (ahead == 0 && behind == 0) return ($"최신 ({head.TrackedBranch})", true);
if (ahead > 0 && behind > 0) return ($"갈라짐 ↑{ahead} ↓{behind}", false);
if (behind > 0) return ($"뒤처짐 ↓{behind} - pull 필요", false);
return ($"앞섬 ↑{ahead} - push 필요", false);
}
/// <summary>
/// MergeHub 설정 &gt; 플랫폼에 등록된 사용자 &gt; git config user.name &gt; 윈도우 계정 순으로 고른다.
///
/// 플랫폼 사용자를 Git 설정보다 앞에 두는 이유 - 처음 켤 때 사람이 직접 적어 넣은 이름이고,
/// 장비를 여럿이 쓰는 현장에서는 그 이름이 실제로 작업한 사람이다.
/// MergeHub 설정을 더 앞에 두는 것은 "이 도구에서만 다르게 남기고 싶다"는 뜻이라 존중한다.
/// </summary>
private string ResolveAuthorName()
{
if (!string.IsNullOrWhiteSpace(Settings.MergeCommentAuthor))
return Settings.MergeCommentAuthor.Trim();
if (_currentUser?.HasName == true) return _currentUser.UserName.Trim();
try
{
var configured = Git.GetConfig("user.name");
if (!string.IsNullOrWhiteSpace(configured)) return configured!.Trim();
}
catch (Exception)
{
// 설정을 못 읽는 건 치명적이지 않다 - 윈도우 계정으로 넘어간다.
}
return Environment.UserName;
}
/// <summary>fetch 가 실제로 성공한 시각을 기록한다. 동기화 표시가 언제 기준인지 알리기 위함.</summary>
public void MarkFetched()
{
var now = DateTime.Now;
OnUi(() => LastFetchText = $"마지막 fetch {now:HH:mm:ss}");
}
private static string GetRepositoryName(string workingDirectory)
{
if (string.IsNullOrWhiteSpace(workingDirectory)) return "-";
var trimmed = workingDirectory.TrimEnd('\\', '/');
var slash = trimmed.LastIndexOfAny(new[] { '\\', '/' });
return slash >= 0 ? trimmed.Substring(slash + 1) : trimmed;
}
public void ApplyCredentials()
{
Git.Credentials = string.IsNullOrWhiteSpace(UserName)
? null
: new GitCredentials(UserName, Password);
}
public void AppendLog(string message) =>
Log += $"[{DateTime.Now:HH:mm:ss}] {message}{Environment.NewLine}";
/// <summary>
/// "이 두 파일을 줄 단위로 비교해 달라" - 폴더 비교 탭이 보내고 파일 비교 탭이 받는다.
///
/// 두 화면이 서로를 직접 알지 않도록 이미 둘 다 들고 있는 이 싱글턴을 통로로 쓴다.
/// </summary>
public event Action<string, string>? PlainCompareRequested;
/// <summary>아직 아무도 받지 않은 요청. 아래 설명 참고.</summary>
private (string Left, string Right)? _pendingPlainCompare;
public bool HasPendingPlainCompare => _pendingPlainCompare is not null;
/// <summary>
/// 요청을 남겨 두고 받을 사람이 있으면 바로 넘긴다.
///
/// 남겨 두는 것이 핵심이다. 탭 내용은 <b>그 탭을 처음 열 때</b> 만들어지므로,
/// 폴더 비교에서 파일을 처음 더블클릭하는 순간에는 파일 비교 화면이 아직 없어
/// 이벤트를 받을 사람이 없다. 그냥 발생시키면 요청이 허공에 사라지고,
/// 새로 만들어진 화면은 기본 파일을 연다 - "더블클릭이 안 먹는다"로 보이는 이유다.
/// </summary>
public void RequestPlainCompare(string leftFile, string rightFile)
{
_pendingPlainCompare = (leftFile, rightFile);
OnUi(DeliverPendingPlainCompare);
}
/// <summary>남아 있는 요청을 넘긴다. 화면이 새로 만들어지면 스스로 한 번 불러 준다.</summary>
public void DeliverPendingPlainCompare()
{
if (_pendingPlainCompare is not { } pending) return;
if (PlainCompareRequested is null) return;
_pendingPlainCompare = null;
PlainCompareRequested.Invoke(pending.Left, pending.Right);
}
/// <summary>ObservableCollection 변경은 UI 스레드에서만 허용된다.</summary>
public void OnUi(Action action)
{
if (_uiContext is null || SynchronizationContext.Current == _uiContext)
{
action();
return;
}
_uiContext.Send(_ => action(), null);
}
private static void Replace<T>(ObservableCollection<T> target, IEnumerable<T> items)
{
target.Clear();
foreach (var item in items) target.Add(item);
}
}
}

View File

@ -0,0 +1,171 @@
using System;
using System.Collections.Generic;
using MergeHub.Models;
namespace MergeHub.Services
{
/// <summary>
/// 기획안 9p "Git 연동 방식 - 로컬 Git 조작" 계층.
/// git.exe 프로세스를 띄우지 않고 LibGit2Sharp 로 .git 을 직접 조작한다.
/// 모든 메서드는 동기이며 스레드 안전하지 않다. UI 에서는 Task.Run 으로 감싸 호출할 것.
/// </summary>
public interface IGitService : IDisposable
{
bool IsOpen { get; }
/// <summary>원격 작업(fetch/pull/push/clone)에 사용할 인증 정보. 사내 Gitea 계정 또는 토큰.</summary>
GitCredentials? Credentials { get; set; }
/// <summary>diff 계산 시 공백만 다른 줄을 변경으로 보지 않음. (기획안 4p "공백 변경 무시")</summary>
bool IgnoreWhitespace { get; set; }
/// <summary>진행 상황 로그 (clone/fetch 진행률 등).</summary>
event EventHandler<string>? Progress;
// ---- 저장소 열기 --------------------------------------------------
RepositoryInfo Open(string path);
RepositoryInfo Clone(string remoteUrl, string localPath, string? branch = null);
void Close();
RepositoryInfo GetRepositoryInfo();
// ---- 브랜치 -------------------------------------------------------
IReadOnlyList<BranchInfo> GetBranches(bool includeRemote = true);
void Checkout(string branchName, bool force = false);
BranchInfo CreateBranch(string name, string? startPointCommittish = null, bool checkout = true);
void DeleteBranch(string name);
/// <summary>원격 추적 브랜치(origin/xxx)를 로컬로 만들고 체크아웃.</summary>
BranchInfo CheckoutRemoteBranch(string remoteBranchName);
// ---- 원격 ---------------------------------------------------------
IReadOnlyList<RemoteInfo> GetRemotes();
/// <summary>다른 저장소의 소스를 끌어와 병합할 때 사용. 이미 있으면 URL 을 갱신한다.</summary>
RemoteInfo AddRemote(string name, string url);
void RemoveRemote(string name);
void Fetch(string remoteName = "origin", bool prune = true);
MergeResultInfo Pull(string remoteName = "origin");
void Push(string? branchName = null, string remoteName = "origin");
// ---- 히스토리 / 조회 ----------------------------------------------
IReadOnlyList<CommitInfo> GetCommits(string? committish = null, int maxCount = 200);
CommitInfo? GetCommit(string committish);
string? GetMergeBaseSha(string committishA, string committishB);
/// <summary>특정 커밋 시점의 파일 본문. (목업 1번 패널: 커밋 클릭 -> 본문 로드)</summary>
string GetFileTextAtCommit(string committish, string path);
IReadOnlyList<string> GetFilesAtCommit(string committish);
// ---- 상태 / 스테이징 ----------------------------------------------
RepositoryStatusInfo GetStatus(bool includeUntracked = true);
void Stage(params string[] paths);
void StageAll();
void Unstage(params string[] paths);
string Commit(string message, bool amend = false);
// ---- 병합 ---------------------------------------------------------
/// <summary>
/// 브랜치를 현재 HEAD 로 병합. 기획안 6p 비목표("사람 확인 없는 자동 커밋 금지")에 따라
/// 기본값은 자동 커밋하지 않고 인덱스까지만 병합한다.
/// </summary>
MergeResultInfo Merge(string branchName, bool commitOnSuccess = false, bool allowFastForward = true);
/// <summary>병합 진행 중 취소. reset --hard HEAD 와 동일.</summary>
void AbortMerge();
bool IsMergeInProgress { get; }
IReadOnlyList<ConflictInfo> GetConflicts();
/// <summary>충돌 파일의 Base/Mine/Theirs 3분할 본문.</summary>
ThreeWayContent GetThreeWayContent(string path);
/// <summary>Accept Mine / Theirs / Both 버튼 처리. 파일에 기록하고 인덱스에서 충돌 해제.</summary>
void ResolveConflict(string path, ConflictResolution resolution);
/// <summary>워킹트리 파일을 인코딩 자동 판별로 읽는다. (충돌 마커가 들어간 상태 그대로)</summary>
string ReadWorkingFileText(string path);
/// <summary>워킹트리 파일의 인코딩·줄바꿈 방식(상태바 표시용).</summary>
TextFileInfo DescribeWorkingFile(string path);
/// <summary>편집기에서 직접 수정한 본문으로 해결.</summary>
void ResolveConflictWithContent(string path, string content);
/// <summary>충돌을 모두 해결한 뒤 병합 커밋 생성. 메시지 생략 시 기본 메시지 사용.</summary>
string CommitMerge(string? message = null);
/// <summary>병합/cherry-pick/revert/rebase 중 무엇이든 진행 중인 작업을 취소한다.</summary>
void AbortOperation();
// ---- 되돌리기 -----------------------------------------------------
/// <summary>git reset --soft/--mixed/--hard.</summary>
void Reset(ResetKind kind, string committish = "HEAD");
/// <summary>git checkout -- &lt;paths&gt;. 워킹트리 수정을 버리고 특정 커밋 상태로 되돌린다.</summary>
void RestoreFiles(IEnumerable<string> paths, string committish = "HEAD");
/// <summary>git revert. 지정 커밋의 변경을 되돌리는 새 커밋을 만든다.</summary>
PatchApplyResult Revert(string committish, bool commitOnSuccess = false, int mainline = 0);
/// <summary>git cherry-pick. 다른 브랜치의 커밋 하나만 현재 브랜치로 가져온다.</summary>
PatchApplyResult CherryPick(string committish, bool commitOnSuccess = false, int mainline = 0);
// ---- rebase -------------------------------------------------------
/// <summary>
/// git rebase &lt;upstream&gt;. branch 를 생략하면 현재 HEAD 를 재배치한다.
/// libgit2 는 interactive rebase 를 지원하지 않는다.
/// </summary>
RebaseResultInfo RebaseStart(string upstreamBranch, string? branch = null, string? onto = null);
/// <summary>충돌을 해결하고 스테이징한 뒤 다음 단계로 진행. (git rebase --continue)</summary>
RebaseResultInfo RebaseContinue();
/// <summary>git rebase --abort.</summary>
void RebaseAbort();
RebaseStatusInfo GetRebaseStatus();
// ---- 태그 ---------------------------------------------------------
IReadOnlyList<TagInfo> GetTags();
/// <summary>message 를 주면 annotated tag, 생략하면 lightweight tag.</summary>
TagInfo CreateTag(string name, string? committish = null, string? message = null);
void DeleteTag(string name);
// ---- 서브모듈 -----------------------------------------------------
IReadOnlyList<SubmoduleInfo> GetSubmodules();
/// <summary>git submodule update --init. 아직 받지 않은 서브모듈을 clone/체크아웃한다.</summary>
void UpdateSubmodule(string name, bool init = true);
void UpdateAllSubmodules(bool init = true);
// ---- Diff ---------------------------------------------------------
/// <summary>diff 결과의 각 줄에 단어 단위 변경 구간(DiffLine.Words)을 채운다. (기획안 4p)</summary>
bool WordLevelDiff { get; set; }
IReadOnlyList<FileDiff> DiffCommits(string oldCommittish, string newCommittish, params string[] paths);
IReadOnlyList<FileDiff> DiffCommitWithParent(string committish, params string[] paths);
IReadOnlyList<FileDiff> DiffWorkingDirectory(bool stagedOnly = false, params string[] paths);
/// <summary>두 줄을 비교해 바뀐 구간만 뽑는다. (3-way 뷰에서 줄 단위로 호출)</summary>
(IReadOnlyList<WordDiffSegment> Old, IReadOnlyList<WordDiffSegment> New) DiffWords(string oldLine, string newLine);
// ---- 확장 ---------------------------------------------------------
IReadOnlyList<BlameLineInfo> Blame(string path, string? committish = null);
IReadOnlyList<StashInfo> GetStashes();
void StashSave(string message);
void StashPop(int index = 0);
void StashDrop(int index);
/// <summary>기획안 4p "mergetool 등록" - git config 에 merge.tool / difftool 등록.</summary>
void RegisterAsMergeTool(string toolName, string executablePath, bool global = true);
string? GetConfig(string key);
void SetConfig(string key, string value, bool global = false);
}
}

View File

@ -0,0 +1,237 @@
using System;
using System.Collections.Generic;
using System.IO;
namespace MergeHub.Services
{
/// <summary>
/// 한 가지 언어(또는 문서 형식)를 어떻게 읽을지 적어 둔 표.
///
/// 언어마다 토크나이저를 따로 만들지 않는 이유가 있다. 색칠에 필요한 것은
/// "주석은 어디서 시작하나 · 문자열은 무엇으로 묶나 · 예약어는 무엇인가" 이 셋뿐이고,
/// 나머지(숫자·식별자·연산자)는 어느 언어나 거의 같다. 그래서 <see cref="CodeTokenizer"/>
/// 하나가 이 표를 보고 동작한다.
///
/// C# 만 예외로 전용 토크나이저를 그대로 쓴다 - 축자/보간 문자열(@"" $"")과
/// 접근제한자 구분처럼 표로 적기 어려운 규칙이 있고, 이미 테스트로 굳어 있다.
/// </summary>
public sealed class LanguageProfile
{
public LanguageProfile(string name,
string[] extensions,
string[]? lineComments = null,
string? blockStart = null,
string? blockEnd = null,
char[]? quotes = null,
IEnumerable<string>? keywords = null,
IEnumerable<string>? controlKeywords = null,
IEnumerable<string>? typeKeywords = null,
bool caseSensitive = true,
bool isCode = true,
bool hasBraces = false,
bool useCSharpTokenizer = false)
{
Name = name;
Extensions = extensions;
LineComments = lineComments ?? Array.Empty<string>();
BlockStart = blockStart;
BlockEnd = blockEnd;
Quotes = quotes ?? Array.Empty<char>();
CaseSensitive = caseSensitive;
IsCode = isCode;
HasBraces = hasBraces;
UseCSharpTokenizer = useCSharpTokenizer;
var comparer = caseSensitive ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase;
Keywords = new HashSet<string>(keywords ?? Array.Empty<string>(), comparer);
ControlKeywords = new HashSet<string>(controlKeywords ?? Array.Empty<string>(), comparer);
TypeKeywords = new HashSet<string>(typeKeywords ?? Array.Empty<string>(), comparer);
}
public string Name { get; }
public string[] Extensions { get; }
public string[] LineComments { get; }
public string? BlockStart { get; }
public string? BlockEnd { get; }
public char[] Quotes { get; }
public bool CaseSensitive { get; }
/// <summary>문법 색을 입힐 대상인지. 평문·표 형식 문서는 false 다.</summary>
public bool IsCode { get; }
/// <summary>중괄호로 블록을 여닫는지. 코드 검사(짝 맞추기)를 걸 수 있는 조건이다.</summary>
public bool HasBraces { get; }
/// <summary>C# 전용 토크나이저를 쓸지.</summary>
public bool UseCSharpTokenizer { get; }
public HashSet<string> Keywords { get; }
public HashSet<string> ControlKeywords { get; }
public HashSet<string> TypeKeywords { get; }
public bool HasBlockComment => BlockStart is { Length: > 0 } && BlockEnd is { Length: > 0 };
}
/// <summary>
/// 확장자로 언어를 고른다. 모르는 확장자는 평문으로 본다 -
/// 이 도구는 소스만이 아니라 설정 파일·레시피·문서도 비교해야 한다.
/// </summary>
public static class LanguageCatalog
{
// 여러 언어가 나눠 쓰는 예약어 묶음. 표를 짧게 유지하려고 따로 뺐다.
private static readonly string[] CFamilyControl =
{
"break", "case", "catch", "continue", "default", "do", "else", "finally",
"for", "goto", "if", "return", "switch", "throw", "try", "while"
};
private static readonly string[] CFamilyTypes =
{
"auto", "bool", "char", "const", "double", "float", "int", "long",
"short", "signed", "unsigned", "void", "size_t", "wchar_t"
};
public static readonly LanguageProfile Plain = new LanguageProfile(
"평문", new[] { ".txt", ".log", ".csv", ".text" }, isCode: false);
public static readonly LanguageProfile CSharp = new LanguageProfile(
"C#", new[] { ".cs", ".csx" },
lineComments: new[] { "//" }, blockStart: "/*", blockEnd: "*/",
quotes: new[] { '"', '\'' },
hasBraces: true, useCSharpTokenizer: true);
public static readonly LanguageProfile CFamily = new LanguageProfile(
"C / C++", new[] { ".c", ".h", ".cpp", ".hpp", ".cc", ".hh", ".cxx", ".hxx", ".inl" },
lineComments: new[] { "//" }, blockStart: "/*", blockEnd: "*/",
quotes: new[] { '"', '\'' },
keywords: new[]
{
"class", "struct", "enum", "union", "namespace", "template", "typename",
"public", "private", "protected", "virtual", "override", "static", "extern",
"inline", "operator", "new", "delete", "this", "nullptr", "true", "false",
"using", "typedef", "sizeof", "friend", "explicit", "mutable", "constexpr"
},
controlKeywords: CFamilyControl, typeKeywords: CFamilyTypes, hasBraces: true);
public static readonly LanguageProfile Java = new LanguageProfile(
"Java", new[] { ".java" },
lineComments: new[] { "//" }, blockStart: "/*", blockEnd: "*/",
quotes: new[] { '"', '\'' },
keywords: new[]
{
"abstract", "class", "extends", "final", "implements", "import", "instanceof",
"interface", "native", "new", "null", "package", "private", "protected",
"public", "static", "super", "synchronized", "this", "throws", "transient",
"true", "false", "volatile", "enum", "record", "var"
},
controlKeywords: CFamilyControl, typeKeywords: CFamilyTypes, hasBraces: true);
public static readonly LanguageProfile JavaScript = new LanguageProfile(
"JavaScript / TypeScript", new[] { ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs" },
lineComments: new[] { "//" }, blockStart: "/*", blockEnd: "*/",
quotes: new[] { '"', '\'', '`' },
keywords: new[]
{
"class", "const", "let", "var", "function", "new", "this", "super", "extends",
"import", "export", "from", "as", "default", "async", "await", "null",
"undefined", "true", "false", "typeof", "instanceof", "in", "of", "delete",
"interface", "type", "enum", "implements", "public", "private", "readonly"
},
controlKeywords: CFamilyControl, typeKeywords: new[] { "number", "string", "boolean", "any", "void", "never", "unknown" },
hasBraces: true);
public static readonly LanguageProfile Python = new LanguageProfile(
"Python", new[] { ".py", ".pyw", ".pyi" },
lineComments: new[] { "#" },
quotes: new[] { '"', '\'' },
keywords: new[]
{
"and", "as", "assert", "async", "await", "class", "def", "del", "from",
"global", "import", "in", "is", "lambda", "None", "nonlocal", "not", "or",
"pass", "True", "False", "with", "yield", "self"
},
controlKeywords: new[] { "break", "continue", "elif", "else", "except", "finally", "for", "if", "raise", "return", "try", "while" },
typeKeywords: new[] { "bool", "bytes", "dict", "float", "int", "list", "set", "str", "tuple" });
public static readonly LanguageProfile Sql = new LanguageProfile(
"SQL", new[] { ".sql" },
lineComments: new[] { "--" }, blockStart: "/*", blockEnd: "*/",
quotes: new[] { '\'', '"' },
keywords: new[]
{
"select", "from", "where", "insert", "into", "values", "update", "set",
"delete", "create", "alter", "drop", "table", "view", "index", "join",
"inner", "left", "right", "outer", "on", "group", "order", "by", "having",
"union", "distinct", "as", "and", "or", "not", "null", "primary", "key",
"foreign", "references", "default", "constraint"
},
controlKeywords: new[] { "begin", "end", "if", "else", "while", "case", "when", "then", "return" },
typeKeywords: new[] { "int", "bigint", "smallint", "varchar", "nvarchar", "char", "nchar", "text", "date", "datetime", "decimal", "float", "bit" },
caseSensitive: false);
public static readonly LanguageProfile Shell = new LanguageProfile(
"셸 / 배치", new[] { ".sh", ".bash", ".zsh", ".bat", ".cmd", ".ps1", ".psm1" },
lineComments: new[] { "#", "::", "rem " },
quotes: new[] { '"', '\'' },
keywords: new[] { "echo", "set", "export", "function", "local", "source", "param", "return" },
controlKeywords: new[] { "if", "then", "else", "elif", "fi", "for", "while", "do", "done", "case", "esac", "goto", "exit" },
caseSensitive: false);
public static readonly LanguageProfile Markup = new LanguageProfile(
"XML / HTML", new[] { ".xml", ".xaml", ".html", ".htm", ".svg", ".xsd", ".xsl", ".csproj", ".vbproj", ".config", ".props", ".targets", ".resx" },
blockStart: "<!--", blockEnd: "-->",
quotes: new[] { '"', '\'' },
caseSensitive: false);
public static readonly LanguageProfile Json = new LanguageProfile(
"JSON", new[] { ".json", ".jsonc" },
lineComments: new[] { "//" }, blockStart: "/*", blockEnd: "*/",
quotes: new[] { '"' },
keywords: new[] { "true", "false", "null" });
public static readonly LanguageProfile Yaml = new LanguageProfile(
"YAML", new[] { ".yml", ".yaml" },
lineComments: new[] { "#" },
quotes: new[] { '"', '\'' },
keywords: new[] { "true", "false", "null", "yes", "no" },
caseSensitive: false);
public static readonly LanguageProfile Ini = new LanguageProfile(
"설정 / INI", new[] { ".ini", ".cfg", ".conf", ".properties", ".toml", ".editorconfig", ".gitconfig" },
lineComments: new[] { ";", "#" },
quotes: new[] { '"', '\'' },
keywords: new[] { "true", "false" },
caseSensitive: false);
public static readonly LanguageProfile Markdown = new LanguageProfile(
"Markdown", new[] { ".md", ".markdown" }, isCode: false);
/// <summary>화면에 고를 수 있게 늘어놓는 순서. 평문이 맨 앞이다(가장 넓게 쓰인다).</summary>
public static readonly IReadOnlyList<LanguageProfile> All = new[]
{
Plain, CSharp, CFamily, Java, JavaScript, Python, Sql, Shell, Markup, Json, Yaml, Ini, Markdown
};
/// <summary>경로의 확장자로 언어를 고른다. 모르면 평문.</summary>
public static LanguageProfile ForPath(string? path)
{
if (string.IsNullOrWhiteSpace(path)) return Plain;
string extension;
try { extension = Path.GetExtension(path)!.ToLowerInvariant(); }
catch (ArgumentException) { return Plain; }
if (string.IsNullOrEmpty(extension)) return Plain;
foreach (var profile in All)
{
foreach (var candidate in profile.Extensions)
{
if (string.Equals(candidate, extension, StringComparison.OrdinalIgnoreCase)) return profile;
}
}
return Plain;
}
}
}

View File

@ -0,0 +1,310 @@
using System;
using System.Collections.Generic;
namespace MergeHub.Services
{
/// <summary>정렬된 한 행. 한쪽에만 있는 줄은 반대쪽이 null 이다.</summary>
public sealed class AlignedRow
{
public AlignedRow(string? baseText, int? baseNumber, string? localText, int? localNumber)
{
BaseText = baseText;
BaseNumber = baseNumber;
LocalText = localText;
LocalNumber = localNumber;
}
public string? BaseText { get; }
public int? BaseNumber { get; }
public string? LocalText { get; }
public int? LocalNumber { get; }
}
/// <summary>
/// 두 줄 목록을 "내용이 같은 줄끼리" 맞춰 나란히 놓는다.
///
/// 줄 번호로 짝지으면 한쪽에 줄이 하나만 늘어나도 그 아래가 통째로 밀려
/// 똑같은 코드가 서로 다른 줄과 비교된다. 그래서 공통 줄을 먼저 고정하고
/// 그 사이에 낀 것만 추가/삭제/수정으로 본다.
///
/// 공통 줄을 고르는 방식이 정렬 품질을 좌우한다. 예전에는 최장 공통 부분수열(LCS)을
/// 썼는데, LCS 는 "가장 많이 겹치는" 답을 고르다 보니 <c>{</c>, <c>}</c>, 빈 줄처럼
/// 짧고 자주 나오는 줄을 엉뚱한 짝에 붙여 정렬이 지저분해진다.
///
/// 지금은 <b>히스토그램 방식</b>이다. 원리는 이렇다.
/// 1. BASE 구간에 어떤 줄이 몇 번 나오는지 센다(= 히스토그램).
/// 2. 양쪽에 다 있으면서 <b>드물게 나오는 줄</b>을 앵커로 삼는다.
/// 드문 줄일수록 "그 줄이 맞다"는 확신이 크기 때문이다.
/// 3. 앵커에서 위아래로 같은 줄이 이어지는 만큼 구간을 넓힌다.
/// 4. 가장 긴(같으면 더 드문) 구간을 확정하고, 그 앞뒤를 같은 방법으로 다시 나눈다.
///
/// git 이 기본으로 쓰는 방식이며, LCS 와 달리 표를 만들지 않아 큰 파일에서도 가볍다.
/// </summary>
public static class LineAligner
{
/// <summary>
/// 한 줄이 이보다 자주 나오면 앵커로 쓰지 않는다.
/// 여닫는 중괄호나 빈 줄처럼 어디에나 있는 줄은 위치를 특정해 주지 못한다.
/// git 이 쓰는 값과 같다.
/// </summary>
private const int MaxOccurrences = 64;
/// <summary>
/// 한 번에 정렬을 시도할 구간의 최대 줄 수. 넘으면 정확한 정렬을 포기하고
/// 통째로 수정 취급한다 - 화면은 뜨게 하려는 안전선이다.
///
/// LCS 때는 표 크기(가로×세로)로 막아야 했지만 히스토그램은 표를 만들지 않는다.
/// 중복이 극심한 입력에서 구간 확장 비용이 커지는 것만 막으면 되므로 줄 수로 센다.
/// </summary>
private const int MaxRegionLines = 20_000;
/// <summary>
/// 두 줄 목록을 맞춰 놓는다.
///
/// <paramref name="baseKeys"/> / <paramref name="localKeys"/> 를 주면 "같은 줄인가"를
/// 그쪽으로 판단한다. 공백·대소문자를 무시할 때 쓴다 - 원문은 그대로 돌려주고
/// 비교만 다르게 하려는 것이다. 안 주면 원문끼리 비교한다.
/// </summary>
public static IReadOnlyList<AlignedRow> Align(IReadOnlyList<string> baseLines,
IReadOnlyList<string> localLines,
IReadOnlyList<string>? baseKeys = null,
IReadOnlyList<string>? localKeys = null)
{
var a = baseKeys ?? baseLines;
var b = localKeys ?? localLines;
var rows = new List<AlignedRow>(Math.Max(baseLines.Count, localLines.Count));
// 1) 앞에서부터 같은 줄은 그대로 붙인다.
var prefix = 0;
while (prefix < baseLines.Count && prefix < localLines.Count &&
string.Equals(a[prefix], b[prefix], StringComparison.Ordinal))
{
rows.Add(new AlignedRow(baseLines[prefix], prefix + 1, localLines[prefix], prefix + 1));
prefix++;
}
// 2) 뒤에서부터 같은 줄도 찾아둔다(앞 구간과 겹치지 않게).
var suffix = 0;
while (suffix < baseLines.Count - prefix && suffix < localLines.Count - prefix &&
string.Equals(a[baseLines.Count - 1 - suffix],
b[localLines.Count - 1 - suffix], StringComparison.Ordinal))
{
suffix++;
}
// 3) 가운데만 히스토그램으로 정렬한다.
AlignRegion(baseLines, localLines, a, b,
prefix, baseLines.Count - suffix,
prefix, localLines.Count - suffix,
rows);
// 4) 뒤쪽 공통 구간.
for (var k = suffix; k > 0; k--)
{
var bi = baseLines.Count - k;
var li = localLines.Count - k;
rows.Add(new AlignedRow(baseLines[bi], bi + 1, localLines[li], li + 1));
}
return rows;
}
/// <summary>정렬할 구간 하나. IsCommon 이면 이미 짝이 확정된 공통 구간이다.</summary>
private readonly struct Region
{
public Region(int aStart, int aEnd, int bStart, int bEnd, bool isCommon)
{
AStart = aStart; AEnd = aEnd;
BStart = bStart; BEnd = bEnd;
IsCommon = isCommon;
}
public int AStart { get; }
public int AEnd { get; }
public int BStart { get; }
public int BEnd { get; }
public bool IsCommon { get; }
}
/// <summary>
/// 앵커를 찾아 구간을 셋(앞 / 앵커 / 뒤)으로 쪼개기를 반복한다.
///
/// 재귀 대신 스택을 쓴다. 변경 덩어리가 많은 파일은 쪼개는 횟수가 줄 수에 비례해
/// 재귀로 짜면 깊이가 수천이 되어 스택이 넘칠 수 있다.
/// </summary>
private static void AlignRegion(IReadOnlyList<string> baseText, IReadOnlyList<string> localText,
IReadOnlyList<string> a, IReadOnlyList<string> b,
int aStart, int aEnd, int bStart, int bEnd,
List<AlignedRow> rows)
{
var work = new Stack<Region>();
work.Push(new Region(aStart, aEnd, bStart, bEnd, false));
while (work.Count > 0)
{
var r = work.Pop();
if (r.IsCommon)
{
for (var k = 0; k < r.AEnd - r.AStart; k++)
{
rows.Add(new AlignedRow(baseText[r.AStart + k], r.AStart + k + 1,
localText[r.BStart + k], r.BStart + k + 1));
}
continue;
}
if (r.AStart >= r.AEnd && r.BStart >= r.BEnd) continue;
var tooBig = r.AEnd - r.AStart > MaxRegionLines || r.BEnd - r.BStart > MaxRegionLines;
// 한쪽이 비었거나, 너무 크거나, 공통 줄이 하나도 없으면 통째로 짝짓는다.
if (r.AStart >= r.AEnd || r.BStart >= r.BEnd || tooBig ||
!TryFindAnchor(a, b, r, out var aBegin, out var bBegin, out var length))
{
rows.AddRange(PairBlocks(baseText, localText, r.AStart, r.AEnd, r.BStart, r.BEnd));
continue;
}
// 꺼내는 순서가 앞 → 앵커 → 뒤 가 되도록 거꾸로 넣는다.
work.Push(new Region(aBegin + length, r.AEnd, bBegin + length, r.BEnd, false));
work.Push(new Region(aBegin, aBegin + length, bBegin, bBegin + length, true));
work.Push(new Region(r.AStart, aBegin, r.BStart, bBegin, false));
}
}
/// <summary>
/// 구간 안에서 가장 믿을 만한 공통 구간(앵커)을 찾는다.
/// 길수록 좋고, 길이가 같으면 그 안에 흔한 줄이 적은 쪽이 낫다.
/// </summary>
private static bool TryFindAnchor(IReadOnlyList<string> a, IReadOnlyList<string> b, Region r,
out int aBegin, out int bBegin, out int length)
{
aBegin = 0; bBegin = 0; length = 0;
var index = BuildHistogram(a, r.AStart, r.AEnd);
var found = false;
var bestCommonness = int.MaxValue;
var bs = r.BStart;
while (bs < r.BEnd)
{
if (!index.TryGetValue(b[bs], out var slots) || slots.Count > MaxOccurrences)
{
// BASE 에 없는 줄이거나, 너무 흔해서 위치를 특정 못 하는 줄이다.
bs++;
continue;
}
// 이 위치에서 찾아낸 가장 긴 구간의 끝 다음 - 같은 자리를 다시 훑지 않는다.
var next = bs + 1;
foreach (var candidate in slots)
{
var ai = candidate;
var bi = bs;
while (ai > r.AStart && bi > r.BStart &&
string.Equals(a[ai - 1], b[bi - 1], StringComparison.Ordinal))
{
ai--; bi--;
}
var aj = candidate;
var bj = bs;
while (aj + 1 < r.AEnd && bj + 1 < r.BEnd &&
string.Equals(a[aj + 1], b[bj + 1], StringComparison.Ordinal))
{
aj++; bj++;
}
if (bj + 1 > next) next = bj + 1;
var span = aj - ai + 1;
var commonness = MaxOccurrenceIn(index, a, ai, aj);
found = true;
if (span > length || (span == length && commonness < bestCommonness))
{
aBegin = ai;
bBegin = bi;
length = span;
bestCommonness = commonness;
}
}
bs = next;
}
return found;
}
/// <summary>
/// 구간 안 줄별 등장 위치. MaxOccurrences 를 넘으면 더 담지 않는다 -
/// 흔한 줄은 어차피 앵커로 안 쓰므로 위치를 다 기억할 이유가 없다.
/// (담기를 멈춰도 Count 가 상한을 한 칸 넘으므로 "흔하다"는 판정은 그대로 된다.)
/// </summary>
private static Dictionary<string, List<int>> BuildHistogram(IReadOnlyList<string> a, int start, int end)
{
var index = new Dictionary<string, List<int>>(StringComparer.Ordinal);
for (var i = start; i < end; i++)
{
if (!index.TryGetValue(a[i], out var slots))
{
slots = new List<int>(1);
index[a[i]] = slots;
}
if (slots.Count <= MaxOccurrences) slots.Add(i);
}
return index;
}
/// <summary>구간 a[from..to] 안에서 가장 흔한 줄의 등장 횟수. 낮을수록 좋은 앵커다.</summary>
private static int MaxOccurrenceIn(Dictionary<string, List<int>> index,
IReadOnlyList<string> a, int from, int to)
{
var worst = 0;
for (var k = from; k <= to; k++)
{
if (index.TryGetValue(a[k], out var slots) && slots.Count > worst)
worst = slots.Count;
}
return worst;
}
/// <summary>
/// 짝이 안 맞는 삭제·추가 덩어리를 위에서부터 1:1로 붙여 "수정된 줄"로 보여준다.
/// 남는 쪽은 한쪽만 있는 행이 된다.
/// </summary>
private static List<AlignedRow> PairBlocks(IReadOnlyList<string> a, IReadOnlyList<string> b,
int aStart, int aEnd, int bStart, int bEnd)
{
var removed = Math.Max(0, aEnd - aStart);
var added = Math.Max(0, bEnd - bStart);
var rows = new List<AlignedRow>(Math.Max(removed, added));
var pairs = Math.Min(removed, added);
for (var k = 0; k < pairs; k++)
{
rows.Add(new AlignedRow(a[aStart + k], aStart + k + 1,
b[bStart + k], bStart + k + 1));
}
for (var k = pairs; k < removed; k++)
rows.Add(new AlignedRow(a[aStart + k], aStart + k + 1, null, null));
for (var k = pairs; k < added; k++)
rows.Add(new AlignedRow(null, null, b[bStart + k], bStart + k + 1));
return rows;
}
}
}

View File

@ -0,0 +1,90 @@
using System;
using System.IO;
namespace MergeHub.Services
{
/// <summary>
/// 머지로 반영한 줄에 남기는 주석을 만든다.
///
/// 원칙 두 가지:
/// 1. 설명(사용자 입력)은 선택이지만, **누가·언제 반영했는지는 항상 들어간다.**
/// 2. 줄 "위"가 아니라 "끝"에 붙인다 - BASE/LOCAL 을 같은 줄 번호로 나란히 보여주는 구조라
/// 줄을 새로 끼워 넣으면 두 패널의 정렬이 통째로 어긋난다.
/// </summary>
public static class MergeNote
{
/// <summary>코드와 주석 사이 간격.</summary>
private const string Gap = " ";
/// <summary>
/// 주석 한 조각을 만든다. 구성은 딱 세 가지 - [사용자] 날짜 설명.
/// 설명이 비어 있어도 [사용자]와 날짜는 항상 남는다.
/// </summary>
public static string Build(string? author, DateTime when, string? comment, string? path)
{
var who = string.IsNullOrWhiteSpace(author) ? Environment.UserName : author!.Trim();
var body = $"[{who}] {when:yyyy-MM-dd}";
var note = comment?.Trim();
if (!string.IsNullOrEmpty(note)) body += $" {note}";
var (open, close) = CommentTokensFor(path);
return close is null ? $"{open} {body}" : $"{open} {body} {close}";
}
/// <summary>코드 줄 끝에 주석을 붙인다. 줄 끝 공백은 정리한다.</summary>
public static string Append(string line, string note)
{
if (string.IsNullOrEmpty(note)) return line;
return line.TrimEnd() + Gap + note;
}
/// <summary>확장자에 맞는 주석 기호. 모르는 확장자는 C 계열(//)로 본다.</summary>
public static (string Open, string? Close) CommentTokensFor(string? path)
{
var ext = string.IsNullOrEmpty(path)
? string.Empty
: Path.GetExtension(path).ToLowerInvariant();
switch (ext)
{
case ".py":
case ".sh":
case ".ps1":
case ".psm1":
case ".yml":
case ".yaml":
case ".toml":
case ".ini":
return ("#", null);
case ".xml":
case ".xaml":
case ".html":
case ".htm":
case ".csproj":
case ".vbproj":
case ".config":
case ".svg":
case ".resx":
return ("<!--", "-->");
case ".sql":
case ".lua":
return ("--", null);
case ".vb":
return ("'", null);
case ".bat":
case ".cmd":
return ("REM", null);
default:
// .cs .cpp .h .hpp .c .java .js .ts .json5 등 C 계열
return ("//", null);
}
}
}
}

View File

@ -0,0 +1,197 @@
using System;
using System.Collections.Generic;
namespace MergeHub.Services
{
/// <summary>옮겨진 덩어리 하나. 지워진 자리와 새로 나타난 자리를 짝으로 가리킨다.</summary>
public sealed class MovedBlock
{
public MovedBlock(int removedStart, int addedStart, int length)
{
RemovedStart = removedStart;
AddedStart = addedStart;
Length = length;
}
/// <summary>정렬 결과에서 "지워진 것으로 보이던" 구간의 시작 행.</summary>
public int RemovedStart { get; }
/// <summary>같은 내용이 "새로 추가된 것으로 보이던" 구간의 시작 행.</summary>
public int AddedStart { get; }
public int Length { get; }
}
/// <summary>
/// 정렬 결과에서 <b>자리만 옮긴 덩어리</b>를 찾아낸다.
///
/// 메서드 순서만 바꿔도 정렬기는 "여기서 12줄 삭제 · 저기서 12줄 추가"로 본다.
/// 사람 눈에는 옮긴 것 하나인데 차이가 둘로 보이니 검토가 번거롭다.
/// 지워진 구간과 추가된 구간의 내용이 똑같으면 <b>이동</b>으로 묶어 표시한다.
///
/// 한 줄짜리 <c>{</c>, <c>}</c>, 빈 줄까지 이동으로 보면 화면이 온통 이동 표시가 되므로
/// "알맹이가 있는 줄"이 하나라도 있어야 이동으로 친다.
/// </summary>
public static class MoveDetector
{
/// <summary>이 길이 이상 이어져야 이동으로 본다. 한 줄짜리는 우연히 같을 때가 많다.</summary>
private const int MinimumLines = 2;
/// <summary>두 구간을 견줄 때 만드는 표의 최대 칸 수. 넘으면 이동 찾기를 건너뛴다.</summary>
private const long MaxCompareCells = 250_000;
public static IReadOnlyList<MovedBlock> Find(IReadOnlyList<AlignedRow> rows)
{
// 한쪽에만 있는 구간을 각각 모은다.
var removed = CollectRuns(rows, deleted: true);
var added = CollectRuns(rows, deleted: false);
var moves = new List<MovedBlock>();
var usedAdded = new bool[added.Count];
foreach (var take in removed)
{
var bestGive = -1;
var bestTakeAt = 0;
var bestGiveAt = 0;
var bestLength = 0;
for (var i = 0; i < added.Count; i++)
{
if (usedAdded[i]) continue;
var (takeAt, giveAt, length) = LongestCommonRun(rows, take, added[i]);
if (length <= bestLength) continue;
bestGive = i;
bestTakeAt = takeAt;
bestGiveAt = giveAt;
bestLength = length;
}
if (bestGive < 0 || bestLength < MinimumLines) continue;
var removedStart = take.Start + bestTakeAt;
var addedStart = added[bestGive].Start + bestGiveAt;
if (!HasSubstance(rows, removedStart, bestLength, deleted: true)) continue;
usedAdded[bestGive] = true;
moves.Add(new MovedBlock(removedStart, addedStart, bestLength));
}
return moves;
}
/// <summary>
/// 두 구간에서 <b>줄이 이어지는 채로</b> 똑같은 가장 긴 부분을 찾는다.
///
/// 구간 전체를 통째로 견주지 않는 이유가 있다. 정렬기는 <c>}</c> 나 빈 줄 같은 공통 줄을
/// 한쪽에 붙여 놓기 때문에, 같은 메서드를 옮겼어도 구간의 시작과 끝이 한두 줄씩 어긋난다.
/// 그래서 겹치는 알맹이만 골라낸다.
/// </summary>
private static (int TakeAt, int GiveAt, int Length) LongestCommonRun(
IReadOnlyList<AlignedRow> rows, Run take, Run give)
{
// 구간이 지나치게 크면 굳이 따지지 않는다 - 이동 표시는 편의 기능이다.
if ((long)take.Length * give.Length > MaxCompareCells) return (0, 0, 0);
var table = new int[take.Length + 1, give.Length + 1];
var best = 0;
var bestTakeAt = 0;
var bestGiveAt = 0;
for (var i = 1; i <= take.Length; i++)
{
for (var j = 1; j <= give.Length; j++)
{
var before = TextOf(rows[take.Start + i - 1], deleted: true);
var after = TextOf(rows[give.Start + j - 1], deleted: false);
if (!string.Equals(before, after, StringComparison.Ordinal)) continue;
table[i, j] = table[i - 1, j - 1] + 1;
if (table[i, j] > best)
{
best = table[i, j];
bestTakeAt = i - best;
bestGiveAt = j - best;
}
}
}
return (bestTakeAt, bestGiveAt, best);
}
private readonly struct Run
{
public Run(int start, int length)
{
Start = start;
Length = length;
}
public int Start { get; }
public int Length { get; }
}
/// <summary>한쪽에만 있는 줄이 이어지는 구간들. 알맹이가 없는 구간은 걸러낸다.</summary>
private static List<Run> CollectRuns(IReadOnlyList<AlignedRow> rows, bool deleted)
{
var runs = new List<Run>();
var start = -1;
for (var i = 0; i <= rows.Count; i++)
{
var isTarget = i < rows.Count && IsOneSided(rows[i], deleted);
if (isTarget)
{
if (start < 0) start = i;
continue;
}
if (start >= 0)
{
var length = i - start;
if (length >= MinimumLines && HasSubstance(rows, start, length, deleted))
runs.Add(new Run(start, length));
start = -1;
}
}
return runs;
}
private static bool IsOneSided(AlignedRow row, bool deleted) =>
deleted ? row.BaseText is not null && row.LocalText is null
: row.BaseText is null && row.LocalText is not null;
private static string TextOf(AlignedRow row, bool deleted) =>
(deleted ? row.BaseText : row.LocalText) ?? string.Empty;
/// <summary>
/// 중괄호·빈 줄만 있는 구간은 이동으로 치지 않는다.
/// 어디에나 있는 모양이라 우연히 겹칠 뿐, 옮겼다고 알려 줄 값어치가 없다.
/// </summary>
private static bool HasSubstance(IReadOnlyList<AlignedRow> rows, int start, int length, bool deleted)
{
for (var i = start; i < start + length; i++)
{
var meaningful = 0;
foreach (var c in TextOf(rows[i], deleted))
{
if (char.IsLetterOrDigit(c) || c == '_' || c > 127) meaningful++;
if (meaningful >= 3) return true;
}
}
return false;
}
}
}

View File

@ -0,0 +1,156 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows.Input;
namespace MergeHub.Services
{
/// <summary>머지툴이 알아듣는 단축키 하나가 시키는 일.</summary>
public enum ShortcutAction
{
NextDiff,
PrevDiff,
FirstDiff,
LastDiff,
CopyLeftToRight,
CopyRightToLeft,
Undo,
Redo,
Save,
Recompare,
GoToLine,
NextBookmark,
ToggleBookmark,
OpenFolderCompare,
Find,
FindNext,
FindPrev,
CloseFind,
ShowHelp,
}
/// <summary>단축키 한 줄. 키 처리와 설명 창이 <b>둘 다 이 목록만</b> 본다.</summary>
public sealed class ShortcutEntry
{
public ShortcutEntry(ShortcutAction action, Key key, ModifierKeys modifiers, string group, string what, string? note = null)
{
Action = action;
Key = key;
Modifiers = modifiers;
Group = group;
What = what;
Note = note;
}
public ShortcutAction Action { get; }
public Key Key { get; }
public ModifierKeys Modifiers { get; }
/// <summary>설명 창에서 묶어 보여 줄 이름.</summary>
public string Group { get; }
/// <summary>이 키가 하는 일.</summary>
public string What { get; }
/// <summary>WinMerge 와 다르게 정한 것 등, 덧붙일 말.</summary>
public string? Note { get; }
/// <summary>사람이 읽는 키 표기(예: <c>Alt+↓</c>).</summary>
public string Gesture
{
get
{
var parts = new List<string>();
if ((Modifiers & ModifierKeys.Control) != 0) parts.Add("Ctrl");
if ((Modifiers & ModifierKeys.Alt) != 0) parts.Add("Alt");
if ((Modifiers & ModifierKeys.Shift) != 0) parts.Add("Shift");
parts.Add(KeyName(Key));
return string.Join("+", parts);
}
}
private static string KeyName(Key key) => key switch
{
Key.Up => "↑",
Key.Down => "↓",
Key.Left => "←",
Key.Right => "→",
Key.Escape => "Esc",
Key.PageDown => "PageDown",
Key.PageUp => "PageUp",
Key.Return => "Enter",
_ => key.ToString(),
};
}
/// <summary>
/// 단축키 표. WinMerge 를 기준으로 맞췄다.
///
/// 목록을 한 곳에 모아 둔 이유 - 키 처리와 설명 창이 따로 놀면 설명이 곧 거짓말이 된다.
/// 키를 더할 때는 여기에만 줄을 넣고, 그 <see cref="ShortcutAction"/> 을 처리부에 이어 준다.
/// </summary>
public static class ShortcutMap
{
public static IReadOnlyList<ShortcutEntry> All { get; } = new[]
{
new ShortcutEntry(ShortcutAction.NextDiff, Key.Down, ModifierKeys.Alt, "차이 이동", "다음 차이"),
new ShortcutEntry(ShortcutAction.PrevDiff, Key.Up, ModifierKeys.Alt, "차이 이동", "이전 차이"),
new ShortcutEntry(ShortcutAction.NextDiff, Key.F4, ModifierKeys.None, "차이 이동", "다음 차이 (대체 키)"),
new ShortcutEntry(ShortcutAction.PrevDiff, Key.F4, ModifierKeys.Shift, "차이 이동", "이전 차이 (대체 키)"),
new ShortcutEntry(ShortcutAction.FirstDiff, Key.Home, ModifierKeys.Alt, "차이 이동", "파일의 첫 차이"),
new ShortcutEntry(ShortcutAction.LastDiff, Key.End, ModifierKeys.Alt, "차이 이동", "파일의 마지막 차이"),
new ShortcutEntry(ShortcutAction.FirstDiff, Key.Home, ModifierKeys.Control, "차이 이동", "첫 차이 (대체 키)"),
new ShortcutEntry(ShortcutAction.LastDiff, Key.End, ModifierKeys.Control, "차이 이동", "마지막 차이 (대체 키)"),
new ShortcutEntry(ShortcutAction.CopyLeftToRight, Key.Right, ModifierKeys.Alt, "병합·편집",
"왼쪽 → 오른쪽 반영 (2-Way: BASE 반영 / 3-Way: Take Mine)",
"화살표는 WinMerge 와 같이 '어느 쪽으로 복사하는가'를 가리킨다. 3-Way 에서 고칠 수 있는 칸은 " +
"아래 MERGED 하나뿐이므로, 왼쪽 끝인 LOCAL(Mine) 을 결과로 가져오는 것이 여기서의 '오른쪽으로'다."),
new ShortcutEntry(ShortcutAction.CopyRightToLeft, Key.Left, ModifierKeys.Alt, "병합·편집",
"오른쪽 → 왼쪽 반영 (2-Way: 이 줄 되돌리기 / 3-Way: Take Theirs)"),
new ShortcutEntry(ShortcutAction.Undo, Key.Z, ModifierKeys.Control, "병합·편집", "실행 취소",
"머지 반영을 되돌린다. 되돌릴 머지 작업이 없으면 편집기의 글자 되돌리기가 그대로 동작한다."),
new ShortcutEntry(ShortcutAction.Redo, Key.Y, ModifierKeys.Control, "병합·편집", "다시 실행"),
new ShortcutEntry(ShortcutAction.ToggleBookmark, Key.M, ModifierKeys.Control, "이동·북마크", "지금 줄 북마크 설정/해제"),
new ShortcutEntry(ShortcutAction.NextBookmark, Key.PageDown, ModifierKeys.Alt, "이동·북마크", "다음 북마크로 이동"),
new ShortcutEntry(ShortcutAction.NextBookmark, Key.F2, ModifierKeys.None, "이동·북마크", "다음 북마크 (대체 키)"),
new ShortcutEntry(ShortcutAction.GoToLine, Key.G, ModifierKeys.Control, "이동·북마크", "줄 번호로 이동"),
new ShortcutEntry(ShortcutAction.OpenFolderCompare, Key.D, ModifierKeys.Control, "파일·보기", "폴더 비교 열기",
"WinMerge 는 새 비교 창을 띄우지만 여기서는 탭 하나로 돌아간다 - 창을 여러 개 띄우는 도구가 아니다."),
new ShortcutEntry(ShortcutAction.Recompare, Key.F5, ModifierKeys.None, "파일·보기", "다시 비교(새로고침)"),
new ShortcutEntry(ShortcutAction.Recompare, Key.R, ModifierKeys.Control, "파일·보기", "다시 비교 (대체 키)"),
new ShortcutEntry(ShortcutAction.Save, Key.S, ModifierKeys.Control, "파일·보기", "저장"),
new ShortcutEntry(ShortcutAction.Find, Key.F, ModifierKeys.Control, "찾기", "텍스트 찾기"),
new ShortcutEntry(ShortcutAction.FindNext, Key.F3, ModifierKeys.None, "찾기", "다음 찾기"),
new ShortcutEntry(ShortcutAction.FindPrev, Key.F3, ModifierKeys.Shift, "찾기", "이전 찾기"),
new ShortcutEntry(ShortcutAction.CloseFind, Key.Escape, ModifierKeys.None, "찾기", "찾기 칸 닫기"),
new ShortcutEntry(ShortcutAction.ShowHelp, Key.F1, ModifierKeys.None, "도움말", "이 단축키 목록"),
};
/// <summary>
/// 눌린 키에 걸린 동작을 찾는다. 없으면 <c>null</c> — 편집기가 그대로 받는다.
/// <para>
/// <c>Ctrl+A</c>·<c>Ctrl+Z</c>·<c>Ctrl+C</c> 처럼 편집기 것인 키는 이 표에 없으므로
/// 저절로 편집기로 간다. 표에 키를 더할 때 편집 기본 동작을 빼앗지 않는지 확인할 것.
/// </para>
/// </summary>
public static ShortcutAction? Resolve(Key key, ModifierKeys modifiers)
{
// Windows 키·CapsLock 같은 것은 무시하고 Ctrl/Alt/Shift 만 견준다.
var mods = modifiers & (ModifierKeys.Control | ModifierKeys.Alt | ModifierKeys.Shift);
foreach (var entry in All)
if (entry.Key == key && entry.Modifiers == mods) return entry.Action;
return null;
}
/// <summary>설명 창이 쓰는 묶음별 목록. 표에 적은 순서를 그대로 지킨다.</summary>
public static IEnumerable<IGrouping<string, ShortcutEntry>> ByGroup() => All.GroupBy(e => e.Group);
}
}

View File

@ -0,0 +1,179 @@
using System;
using System.IO;
using System.Linq;
using System.Text;
using MergeHub.Models;
namespace MergeHub.Services
{
/// <summary>
/// Git 없이 파일을 직접 읽고 쓸 때 쓰는 최소한의 도구.
///
/// 인코딩·줄바꿈을 함부로 바꾸지 않는 것이 요점이다. 사내 C/C++ 소스는 CP949 로 저장된
/// 것이 많아 UTF-8 로 강제 해석하면 한글이 깨지고, 저장할 때 줄바꿈을 바꾸면 실제로는
/// 한 줄도 안 고쳤는데 파일 전체가 변경된 것으로 보인다.
///
/// 판별 규칙은 GitService 와 같다 - 같은 파일을 어느 경로로 열든 결과가 같아야 한다.
/// </summary>
public static class TextFile
{
private static readonly Encoding Utf8Strict = new UTF8Encoding(false, throwOnInvalidBytes: true);
private static readonly Encoding Ansi;
static TextFile()
{
Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
try
{
Ansi = Encoding.GetEncoding(System.Globalization.CultureInfo.CurrentCulture.TextInfo.ANSICodePage);
}
catch (ArgumentException)
{
Ansi = Encoding.UTF8;
}
}
public static string Read(string path)
{
if (!File.Exists(path)) return string.Empty;
var bytes = File.ReadAllBytes(path);
if (bytes.Length == 0) return string.Empty;
var encoding = Detect(bytes);
var preamble = encoding.GetPreamble();
var offset = preamble.Length > 0 && bytes.Length >= preamble.Length
&& bytes.Take(preamble.Length).SequenceEqual(preamble)
? preamble.Length
: 0;
return encoding.GetString(bytes, offset, bytes.Length - offset);
}
/// <summary>
/// 원래 파일의 인코딩과 줄바꿈을 그대로 유지해 덮어쓴다.
/// 새 파일이면 UTF-8(BOM 없음) · CRLF 로 쓴다.
/// </summary>
public static void Write(string path, string content)
{
var encoding = File.Exists(path)
? Detect(File.ReadAllBytes(path))
: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
var newLine = File.Exists(path) ? Describe(path).NewLine : "CRLF";
var text = content.Replace("\r\n", "\n");
if (newLine != "LF") text = text.Replace("\n", "\r\n");
File.WriteAllText(path, text, encoding);
}
/// <summary>
/// 글이 아니라 이진 파일인지. 앞부분에 NUL 바이트가 있으면 이진으로 본다 -
/// Git 을 비롯한 대부분의 도구가 쓰는 판별이고, 실제 텍스트에는 NUL 이 들어가지 않는다.
///
/// 이진 파일을 줄 단위로 비교하면 화면이 깨진 글자로 가득 찰 뿐 아무 정보도 못 준다.
/// </summary>
public static bool IsBinary(string path)
{
if (!File.Exists(path)) return false;
try
{
using var stream = File.OpenRead(path);
var head = new byte[Math.Min(8192, stream.Length)];
var read = stream.Read(head, 0, head.Length);
// UTF-16 은 ASCII 문자마다 NUL 이 끼므로 BOM 이 있으면 글로 본다.
if (read >= 2 && ((head[0] == 0xFF && head[1] == 0xFE) || (head[0] == 0xFE && head[1] == 0xFF)))
return false;
for (var i = 0; i < read; i++)
{
if (head[i] == 0) return true;
}
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
return false;
}
/// <summary>상태바에 띄울 인코딩·줄바꿈 이름.</summary>
public static TextFileInfo Describe(string path)
{
if (!File.Exists(path)) return new TextFileInfo("-", "-");
var bytes = File.ReadAllBytes(path);
return new TextFileInfo(NameOf(bytes), NewLineOf(Read(path)));
}
private static Encoding Detect(byte[] bytes)
{
if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF)
return new UTF8Encoding(encoderShouldEmitUTF8Identifier: true);
if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE) return Encoding.Unicode;
if (bytes.Length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF) return Encoding.BigEndianUnicode;
try
{
Utf8Strict.GetString(bytes);
return new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
}
catch (DecoderFallbackException)
{
return Ansi;
}
}
private static string NameOf(byte[] bytes)
{
if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) return "UTF-8 (BOM)";
if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE) return "UTF-16 LE";
if (bytes.Length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF) return "UTF-16 BE";
try
{
Utf8Strict.GetString(bytes);
return "UTF-8";
}
catch (DecoderFallbackException)
{
return Ansi.WebName.ToUpperInvariant();
}
}
/// <summary>줄바꿈이 섞여 있으면 감추지 않고 "혼합"으로 알린다.</summary>
private static string NewLineOf(string text)
{
if (text.Length == 0) return "-";
int crlf = 0, lf = 0, cr = 0;
for (var i = 0; i < text.Length; i++)
{
if (text[i] == '\r')
{
if (i + 1 < text.Length && text[i + 1] == '\n') { crlf++; i++; }
else cr++;
}
else if (text[i] == '\n') lf++;
}
var kinds = (crlf > 0 ? 1 : 0) + (lf > 0 ? 1 : 0) + (cr > 0 ? 1 : 0);
if (kinds == 0) return "없음";
if (kinds > 1) return "혼합";
return crlf > 0 ? "CRLF" : lf > 0 ? "LF" : "CR";
}
}
}

View File

@ -0,0 +1,253 @@
using System.Collections.Generic;
namespace MergeHub.Services
{
/// <summary>세 쪽(BASE · LOCAL · REMOTE)을 같은 눈높이로 묶은 한 행.</summary>
public sealed class ThreeWayRow
{
public ThreeWayRow(string? baseText, int? baseNumber,
string? localText, int? localNumber,
string? remoteText, int? remoteNumber)
{
BaseText = baseText;
BaseNumber = baseNumber;
LocalText = localText;
LocalNumber = localNumber;
RemoteText = remoteText;
RemoteNumber = remoteNumber;
}
public string? BaseText { get; }
public int? BaseNumber { get; }
public string? LocalText { get; }
public int? LocalNumber { get; }
public string? RemoteText { get; }
public int? RemoteNumber { get; }
}
/// <summary>BASE·MINE(내 원본)·REMOTE·MERGED(결과)를 같은 눈높이로 묶은 한 행.</summary>
public sealed class FourWayRow
{
public FourWayRow(string? baseText, int? baseNumber,
string? mineText, int? mineNumber,
string? remoteText, int? remoteNumber,
string? mergedText, int? mergedNumber)
{
BaseText = baseText;
BaseNumber = baseNumber;
MineText = mineText;
MineNumber = mineNumber;
RemoteText = remoteText;
RemoteNumber = remoteNumber;
MergedText = mergedText;
MergedNumber = mergedNumber;
}
public string? BaseText { get; }
public int? BaseNumber { get; }
public string? MineText { get; }
public int? MineNumber { get; }
public string? RemoteText { get; }
public int? RemoteNumber { get; }
public string? MergedText { get; }
public int? MergedNumber { get; }
}
/// <summary>
/// 3-Way 정렬. BASE 를 축으로 LOCAL 과 REMOTE 를 나란히 세운다.
///
/// 세 글을 한꺼번에 정렬하지 않고 <b>BASE↔LOCAL</b>, <b>BASE↔REMOTE</b> 두 번 정렬한 뒤
/// BASE 줄 번호로 다시 맞물린다. 이유가 둘이다.
/// - 이미 검증된 2-way 정렬기(histogram)를 그대로 쓴다. 세 갈래 정렬을 새로 만들면
/// 기존 테스트가 지켜 주던 성질(중복 줄 오짝짓기 방지 등)을 처음부터 다시 증명해야 한다.
/// - 실제 머지의 뜻과 맞는다. "BASE 대비 내가 뭘 고쳤나 / 저쪽이 뭘 고쳤나"가 판단의 근거다.
///
/// 어느 쪽에도 BASE 줄이 없는 자리(한쪽에서만 새로 넣은 줄)는 같은 자리끼리 한 번 더
/// 정렬해 짝을 짓는다. 양쪽이 똑같은 줄을 넣었으면 한 행으로 합쳐진다 - 그래야
/// "둘 다 같은 내용을 추가함"이 충돌로 보이지 않는다.
/// </summary>
public static class ThreeWayAligner
{
public static IReadOnlyList<ThreeWayRow> Align(IReadOnlyList<string> baseLines,
IReadOnlyList<string> localLines,
IReadOnlyList<string> remoteLines,
IReadOnlyList<string>? baseKeys = null,
IReadOnlyList<string>? localKeys = null,
IReadOnlyList<string>? remoteKeys = null)
{
var left = LineAligner.Align(baseLines, localLines, baseKeys, localKeys);
var right = LineAligner.Align(baseLines, remoteLines, baseKeys, remoteKeys);
var rows = new List<ThreeWayRow>(left.Count + right.Count);
int i = 0, j = 0;
while (i < left.Count || j < right.Count)
{
var before = i + j;
// 1) BASE 줄이 없는 행 = 그 자리에 새로 넣은 줄. 양쪽에서 한 덩어리씩 모아 짝짓는다.
var addedLocal = new List<AlignedRow>();
while (i < left.Count && left[i].BaseNumber is null) addedLocal.Add(left[i++]);
var addedRemote = new List<AlignedRow>();
while (j < right.Count && right[j].BaseNumber is null) addedRemote.Add(right[j++]);
EmitInsertions(addedLocal, addedRemote, rows);
// 2) BASE 줄이 있는 행. 두 목록 모두 같은 BASE 를 순서대로 훑으므로 여기서 만난다.
if (i < left.Count && j < right.Count)
{
var l = left[i++];
var r = right[j++];
rows.Add(new ThreeWayRow(l.BaseText, l.BaseNumber,
l.LocalText, l.LocalNumber,
r.LocalText, r.LocalNumber));
}
// 어느 쪽도 줄지 않았으면 더 진행할 수 없다. 무한 반복을 막는다.
if (i + j == before) break;
}
return rows;
}
/// <summary>
/// 네 쪽 정렬. BASE 를 축으로 <b>MINE(내 원본) · REMOTE · MERGED(결과)</b> 를 나란히 세운다.
///
/// 3-Way 인데 왜 넷이냐 - 3-Way 에서 <b>고칠 수 있는 칸은 MERGED 하나</b>이고, 위의
/// MINE 칸은 "파일을 연 시점의 내 줄"을 그대로 붙들어 보여 주는 참고용이다. 둘을 한 열로
/// 묶어 두면 MERGED 에 글자를 하나만 쳐도 MINE 칸이 그 편집을 따라가 버려, 정작 무엇과
/// 견주어 판단하는지를 잃는다.
///
/// 방식은 <see cref="Align"/> 와 같다 - BASE 와 두 번이 아니라 <b>세 번</b> 2-way 정렬한 뒤
/// BASE 줄 번호로 맞물린다. BASE 줄이 없는 자리(새로 넣은 줄)는
/// <see cref="EmitInsertions4"/> 가 짝짓는다.
///
/// <para>MERGED 가 MINE 과 똑같으면(= 아직 아무것도 안 고쳤으면) 결과는
/// <see cref="Align"/> 와 정확히 같다. 파일을 연 직후의 화면이 달라지지 않는다는 뜻이다.</para>
/// </summary>
public static IReadOnlyList<FourWayRow> AlignFour(IReadOnlyList<string> baseLines,
IReadOnlyList<string> mineLines,
IReadOnlyList<string> remoteLines,
IReadOnlyList<string> mergedLines,
IReadOnlyList<string>? baseKeys = null,
IReadOnlyList<string>? mineKeys = null,
IReadOnlyList<string>? remoteKeys = null,
IReadOnlyList<string>? mergedKeys = null)
{
var mine = LineAligner.Align(baseLines, mineLines, baseKeys, mineKeys);
var remote = LineAligner.Align(baseLines, remoteLines, baseKeys, remoteKeys);
var merged = LineAligner.Align(baseLines, mergedLines, baseKeys, mergedKeys);
var rows = new List<FourWayRow>(merged.Count + mine.Count);
int i = 0, j = 0, k = 0;
while (i < mine.Count || j < remote.Count || k < merged.Count)
{
var before = i + j + k;
var addedMine = new List<AlignedRow>();
while (i < mine.Count && mine[i].BaseNumber is null) addedMine.Add(mine[i++]);
var addedRemote = new List<AlignedRow>();
while (j < remote.Count && remote[j].BaseNumber is null) addedRemote.Add(remote[j++]);
var addedMerged = new List<AlignedRow>();
while (k < merged.Count && merged[k].BaseNumber is null) addedMerged.Add(merged[k++]);
EmitInsertions4(addedMine, addedRemote, addedMerged, rows);
if (i < mine.Count && j < remote.Count && k < merged.Count)
{
var a = mine[i++];
var b = remote[j++];
var c = merged[k++];
rows.Add(new FourWayRow(a.BaseText, a.BaseNumber,
a.LocalText, a.LocalNumber,
b.LocalText, b.LocalNumber,
c.LocalText, c.LocalNumber));
}
if (i + j + k == before) break;
}
return rows;
}
/// <summary>
/// 같은 자리에 들어온 세 쪽의 추가분을 짝짓는다.
///
/// 두 걸음으로 나눈다. 먼저 <b>MINE ↔ MERGED</b> - 결과는 내 것의 복사본에서 출발하므로
/// 거의 그대로 겹치고, 여기서 짝이 맞아야 "내가 넣은 줄"이 편집 뒤에도 제자리에 남는다.
/// 그 다음 그 묶음을 <b>REMOTE</b> 와 짝짓는다 - 기준 글자는 내 줄, 내 줄이 없으면 결과 줄이다.
/// </summary>
private static void EmitInsertions4(List<AlignedRow> mine,
List<AlignedRow> remote,
List<AlignedRow> merged,
List<FourWayRow> rows)
{
if (mine.Count == 0 && remote.Count == 0 && merged.Count == 0) return;
// 1) 내 추가분 ↔ 결과 추가분
var pairs = new List<(AlignedRow? Mine, AlignedRow? Merged)>(mine.Count + merged.Count);
foreach (var pair in LineAligner.Align(TextsOf(mine), TextsOf(merged)))
{
pairs.Add((pair.BaseNumber is int a ? mine[a - 1] : null,
pair.LocalNumber is int b ? merged[b - 1] : null));
}
// 2) 그 묶음 ↔ 저쪽 추가분
var keys = new List<string>(pairs.Count);
foreach (var pair in pairs) keys.Add(pair.Mine?.LocalText ?? pair.Merged?.LocalText ?? string.Empty);
foreach (var pair in LineAligner.Align(keys, TextsOf(remote)))
{
var both = pair.BaseNumber is int a ? pairs[a - 1] : (null, null);
var r = pair.LocalNumber is int b ? remote[b - 1] : null;
rows.Add(new FourWayRow(null, null,
both.Mine?.LocalText, both.Mine?.LocalNumber,
r?.LocalText, r?.LocalNumber,
both.Merged?.LocalText, both.Merged?.LocalNumber));
}
}
private static List<string> TextsOf(List<AlignedRow> source)
{
var texts = new List<string>(source.Count);
foreach (var row in source) texts.Add(row.LocalText ?? string.Empty);
return texts;
}
/// <summary>
/// 같은 자리에 들어온 LOCAL 추가분과 REMOTE 추가분을 한 번 더 정렬해 짝짓는다.
/// 2-way 정렬기에 그대로 넘기므로 같은 줄은 같은 행에 모인다.
/// </summary>
private static void EmitInsertions(List<AlignedRow> local, List<AlignedRow> remote, List<ThreeWayRow> rows)
{
if (local.Count == 0 && remote.Count == 0) return;
var localTexts = new List<string>(local.Count);
foreach (var row in local) localTexts.Add(row.LocalText ?? string.Empty);
var remoteTexts = new List<string>(remote.Count);
foreach (var row in remote) remoteTexts.Add(row.LocalText ?? string.Empty);
// 여기서의 "base" 자리는 LOCAL 추가분, "local" 자리는 REMOTE 추가분이다.
foreach (var pair in LineAligner.Align(localTexts, remoteTexts))
{
var l = pair.BaseNumber is int a ? local[a - 1] : null;
var r = pair.LocalNumber is int b ? remote[b - 1] : null;
rows.Add(new ThreeWayRow(null, null,
l?.LocalText, l?.LocalNumber,
r?.LocalText, r?.LocalNumber));
}
}
}
}

View File

@ -0,0 +1,376 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MergeHub.Services;
using UbisamBase.Core.Messaging;
namespace MergeHub.ViewModels
{
/// <summary>
/// 화면에 뿌리는 폴더 비교 항목. 비교 결과(FolderCompareItem)를 그대로 감싸고
/// 트리에 필요한 것(펼침 상태·자식)만 얹는다.
/// </summary>
public sealed partial class FolderNode : ObservableObject
{
public FolderNode(FolderCompareItem item, string leftRoot, string rightRoot)
{
Item = item;
LeftPath = Path.Combine(leftRoot, item.RelativePath);
RightPath = Path.Combine(rightRoot, item.RelativePath);
foreach (var child in item.Children)
{
Children.Add(new FolderNode(child, leftRoot, rightRoot));
}
}
public FolderCompareItem Item { get; }
public ObservableCollection<FolderNode> Children { get; } = new ObservableCollection<FolderNode>();
public string LeftPath { get; }
public string RightPath { get; }
public string Name => Item.Name;
public string RelativePath => Item.RelativePath;
public bool IsDirectory => Item.IsDirectory;
public FolderEntryStatus Status => Item.Status;
/// <summary>다른 것부터 눈에 띄도록 처음부터 펼쳐 둔다. 같기만 한 폴더는 접어 둔다.</summary>
[ObservableProperty] private bool _isExpanded;
[ObservableProperty] private bool _isSelected;
public string StatusText => Status switch
{
FolderEntryStatus.Same => "같음",
FolderEntryStatus.Different => "다름",
FolderEntryStatus.LeftOnly => "왼쪽만",
FolderEntryStatus.RightOnly => "오른쪽만",
FolderEntryStatus.Binary => "다름(이진)",
_ => string.Empty
};
public string LeftInfo => Describe(Item.LeftExists, Item.LeftSize, Item.LeftModified);
public string RightInfo => Describe(Item.RightExists, Item.RightSize, Item.RightModified);
/// <summary>
/// 줄 단위 비교 화면으로 넘길 수 있는 항목인지.
///
/// 한쪽에만 있는 파일도 연다 - 오히려 그런 파일이야말로 "뭐가 들었길래 새로 생겼나"를
/// 보고 싶은 대상이다. 없는 쪽은 빈 내용으로 열려 전부 추가(또는 삭제)로 보인다.
/// 폴더와 이진 파일만 뺀다.
/// </summary>
public bool CanOpenInDiff =>
!IsDirectory && (Item.LeftExists || Item.RightExists) && Status != FolderEntryStatus.Binary;
/// <summary>열 수 없을 때 왜 그런지. 조용히 아무 일도 안 일어나는 것이 제일 나쁘다.</summary>
public string? OpenBlockedReason =>
IsDirectory ? "폴더입니다. 더블클릭하면 펼치고 접습니다." :
Status == FolderEntryStatus.Binary ? "이진 파일이라 줄 단위로 비교할 수 없습니다." :
!Item.LeftExists && !Item.RightExists ? "양쪽 모두 파일이 없습니다." :
null;
private static string Describe(bool exists, long size, DateTime? modified)
{
if (!exists) return "-";
var when = modified?.ToString("yyyy-MM-dd HH:mm") ?? string.Empty;
return $"{size:N0} B · {when}";
}
}
/// <summary>
/// 폴더 비교 탭.
///
/// 홈(파일 비교)과는 다른 일이라 탭을 따로 뒀다 - 홈은 이미 네 칸을 쓰고 있어서
/// 트리까지 얹을 자리가 없고, 하는 일도 "두 폴더 중 무엇이 다른가"로 성격이 다르다.
/// 여기서 파일을 고르면 홈 화면으로 넘겨 줄 단위로 본다.
/// </summary>
public sealed partial class FolderViewModel : ObservableObject
{
private readonly GitWorkspace _workspace;
public FolderViewModel(GitWorkspace workspace)
{
_workspace = workspace;
// 저장소를 열어 뒀으면 왼쪽 기본값으로 삼는다 - 대개 그 폴더를 견주게 된다.
if (!string.IsNullOrWhiteSpace(_workspace.RepositoryPath) &&
Directory.Exists(_workspace.RepositoryPath))
{
LeftFolder = _workspace.RepositoryPath;
}
}
/// <summary>홈 탭에서 이 두 파일을 비교해 달라고 알린다.</summary>
public event EventHandler<(string Left, string Right)>? OpenInDiffRequested;
public ObservableCollection<FolderNode> Nodes { get; } = new ObservableCollection<FolderNode>();
[ObservableProperty] private string _leftFolder = string.Empty;
[ObservableProperty] private string _rightFolder = string.Empty;
[ObservableProperty] private FolderNode? _selectedNode;
[ObservableProperty] private string _summary = "폴더 두 개를 고르고 비교를 누르세요.";
[ObservableProperty] private bool _isBusy;
/// <summary>내용을 다 읽지 않고 크기·수정 시각만 본다.</summary>
[ObservableProperty] private bool _quickCompare;
[ObservableProperty] private bool _includeSubfolders = true;
/// <summary>같은 파일도 목록에 남길지. 꺼 두면 다른 것만 본다.</summary>
[ObservableProperty] private bool _showSame;
[ObservableProperty] private string _includePattern = string.Empty;
[ObservableProperty] private string _excludePattern = @"\\\.git\\|^\.git$|\\bin\\|\\obj\\|\.dll$|\.pdb$";
partial void OnSelectedNodeChanged(FolderNode? value) => RaiseCanExecuteChanged();
private void RaiseCanExecuteChanged()
{
OpenInDiffCommand.NotifyCanExecuteChanged();
CopyToRightCommand.NotifyCanExecuteChanged();
CopyToLeftCommand.NotifyCanExecuteChanged();
DeleteLeftCommand.NotifyCanExecuteChanged();
DeleteRightCommand.NotifyCanExecuteChanged();
}
[RelayCommand]
private void PickLeftFolder()
{
if (AskForFolder("왼쪽(기준) 폴더 고르기", LeftFolder) is { } picked) LeftFolder = picked;
}
[RelayCommand]
private void PickRightFolder()
{
if (AskForFolder("오른쪽(비교 대상) 폴더 고르기", RightFolder) is { } picked) RightFolder = picked;
}
private static string? AskForFolder(string title, string current)
{
// 파일 열기 대화상자를 폴더 고르기로 쓴다. net48 WPF 에는 폴더 전용 대화상자가 없고,
// 셸 COM 을 직접 부르는 것보다 이 편이 유지보수가 쉽다.
var dialog = new Microsoft.Win32.OpenFileDialog
{
Title = title + " (폴더 안에서 아무 파일이나 고르거나, 파일 이름 칸에 폴더명만 두고 열기)",
CheckFileExists = false,
CheckPathExists = true,
FileName = "이 폴더 선택",
Filter = "폴더 (*.*)|*.*"
};
if (!string.IsNullOrWhiteSpace(current) && Directory.Exists(current))
{
dialog.InitialDirectory = current;
}
if (dialog.ShowDialog() != true) return null;
var path = Path.GetDirectoryName(dialog.FileName);
return string.IsNullOrEmpty(path) ? null : path;
}
private bool CanCompare() => !IsBusy;
[RelayCommand(CanExecute = nameof(CanCompare))]
private async Task CompareAsync()
{
if (!Directory.Exists(LeftFolder) || !Directory.Exists(RightFolder))
{
Summary = "양쪽 폴더가 모두 있어야 합니다.";
return;
}
var options = new FolderCompareOptions
{
QuickCompare = QuickCompare,
IncludeSubfolders = IncludeSubfolders,
IncludePattern = IncludePattern,
ExcludePattern = ExcludePattern,
};
var left = LeftFolder;
var right = RightFolder;
IsBusy = true;
CompareCommand.NotifyCanExecuteChanged();
Summary = "비교하는 중...";
try
{
var result = await Task.Run(() => FolderComparer.Compare(left, right, options));
Nodes.Clear();
foreach (var item in result.Items)
{
var node = new FolderNode(item, left, right);
if (!ShowSame && !Keep(node)) continue;
Prune(node);
node.IsExpanded = node.Status != FolderEntryStatus.Same;
Nodes.Add(node);
}
Summary = result.Summary;
_workspace.AppendLog($"폴더 비교: {left} ↔ {right} · {result.Summary}");
}
catch (Exception ex)
{
Summary = $"비교 실패: {ex.Message}";
MessageUtil.ShowErrorToast($"폴더 비교 실패: {ex.Message}");
}
finally
{
IsBusy = false;
CompareCommand.NotifyCanExecuteChanged();
}
}
/// <summary>"같은 것 숨기기"가 켜져 있을 때 남길 항목인지.</summary>
private static bool Keep(FolderNode node)
{
if (node.Status != FolderEntryStatus.Same) return true;
return node.Children.Any(Keep);
}
/// <summary>같기만 한 가지를 잘라낸다. 트리에서 다른 것만 남기려는 것이다.</summary>
private void Prune(FolderNode node)
{
if (ShowSame) return;
for (var i = node.Children.Count - 1; i >= 0; i--)
{
var child = node.Children[i];
if (!Keep(child)) node.Children.RemoveAt(i);
else Prune(child);
}
}
private bool CanOpenInDiff() => SelectedNode?.CanOpenInDiff == true;
/// <summary>고른 파일을 "파일 비교" 탭으로 넘기고 그 탭으로 옮긴다.</summary>
[RelayCommand(CanExecute = nameof(CanOpenInDiff))]
private void OpenInDiff() => OpenInDiffCore(SelectedNode);
/// <summary>
/// 더블클릭·Enter·버튼이 모두 여기로 들어온다.
///
/// 열 수 없는 항목이면 <b>왜 못 여는지 알려 준다</b> - 눌렀는데 아무 일도 안 일어나면
/// 기능이 없는 줄 알게 된다(폴더나 한쪽에만 있는 파일을 눌렀을 때 실제로 그랬다).
/// </summary>
public void OpenInDiffCore(FolderNode? node)
{
if (node is null) return;
if (!node.CanOpenInDiff)
{
if (node.OpenBlockedReason is { } reason) MessageUtil.ShowWarningToast(reason);
return;
}
OpenInDiffRequested?.Invoke(this, (node.LeftPath, node.RightPath));
// 요청을 먼저 걸고 탭을 연다. 파일 비교 화면이 아직 없으면 그 화면이 만들어지면서
// 남아 있는 요청을 직접 집어 간다(GitWorkspace.RequestPlainCompare 설명 참고).
_workspace.RequestPlainCompare(node.LeftPath, node.RightPath);
UbisamBase.Core.Modules.TabManager.Activate(ViewIds.Home);
}
private bool CanCopyToRight() => SelectedNode is { IsDirectory: false } node && node.Item.LeftExists;
[RelayCommand(CanExecute = nameof(CanCopyToRight))]
private void CopyToRight() => Copy(SelectedNode, toRight: true);
private bool CanCopyToLeft() => SelectedNode is { IsDirectory: false } node && node.Item.RightExists;
[RelayCommand(CanExecute = nameof(CanCopyToLeft))]
private void CopyToLeft() => Copy(SelectedNode, toRight: false);
/// <summary>
/// 한쪽 파일로 다른 쪽을 덮어쓴다.
/// 덮어쓰기는 되돌릴 수 없으므로 <b>무엇을 어디로 쓰는지</b>를 그대로 보여 주고 확인을 받는다.
/// </summary>
private void Copy(FolderNode? node, bool toRight)
{
if (node is null || node.IsDirectory) return;
var from = toRight ? node.LeftPath : node.RightPath;
var to = toRight ? node.RightPath : node.LeftPath;
if (!File.Exists(from)) return;
var exists = File.Exists(to);
var question =
$"{(toRight ? "왼쪽 → 오른쪽" : "오른쪽 → 왼쪽")} 복사합니다.\n\n" +
$"원본: {from}\n대상: {to}\n\n" +
(exists ? "대상 파일을 덮어씁니다. 되돌릴 수 없습니다." : "대상에 새로 만듭니다.");
if (MessageBox.Show(question, "파일 복사", MessageBoxButton.OKCancel,
MessageBoxImage.Warning, MessageBoxResult.Cancel) != MessageBoxResult.OK)
{
return;
}
try
{
var folder = Path.GetDirectoryName(to);
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
File.Copy(from, to, overwrite: true);
_workspace.AppendLog($"복사: {from} → {to}");
MessageUtil.ShowSuccessToast("복사했습니다. 다시 비교하면 결과가 갱신됩니다.");
}
catch (Exception ex)
{
MessageUtil.ShowErrorToast($"복사 실패: {ex.Message}");
}
}
private bool CanDeleteLeft() => SelectedNode is { IsDirectory: false } node && node.Item.LeftExists;
[RelayCommand(CanExecute = nameof(CanDeleteLeft))]
private void DeleteLeft() => Delete(SelectedNode, left: true);
private bool CanDeleteRight() => SelectedNode is { IsDirectory: false } node && node.Item.RightExists;
[RelayCommand(CanExecute = nameof(CanDeleteRight))]
private void DeleteRight() => Delete(SelectedNode, left: false);
/// <summary>파일 삭제. 폴더는 지우지 않는다 - 한 번에 너무 많은 것이 사라진다.</summary>
private void Delete(FolderNode? node, bool left)
{
if (node is null || node.IsDirectory) return;
var path = left ? node.LeftPath : node.RightPath;
if (!File.Exists(path)) return;
var question = $"파일을 지웁니다.\n\n{path}\n\n되돌릴 수 없습니다. 지울까요?";
if (MessageBox.Show(question, "파일 삭제", MessageBoxButton.OKCancel,
MessageBoxImage.Warning, MessageBoxResult.Cancel) != MessageBoxResult.OK)
{
return;
}
try
{
File.Delete(path);
_workspace.AppendLog($"삭제: {path}");
MessageUtil.ShowSuccessToast("지웠습니다. 다시 비교하면 결과가 갱신됩니다.");
}
catch (Exception ex)
{
MessageUtil.ShowErrorToast($"삭제 실패: {ex.Message}");
}
}
}
}

View File

@ -0,0 +1,443 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MergeHub.Models;
using MergeHub.Services;
using UbisamBase.Core.Messaging;
namespace MergeHub.ViewModels
{
/// <summary>
/// Git 탭 - 저장소 조작 전부.
/// 열기/클론 · 원격 · 브랜치 · 병합 · 이력 조작(revert/cherry-pick/rebase/reset) ·
/// 스테이징/커밋 · 태그 · 서브모듈 · mergetool 등록.
/// </summary>
public sealed partial class GitViewModel : ObservableObject
{
private readonly GitWorkspace _workspace;
public GitViewModel(GitWorkspace workspace)
{
_workspace = workspace;
_workspace.Refreshed += () => OnPropertyChanged(nameof(SelectedCommit));
_workspace.StateChanged += RaiseCanExecuteChanged;
}
public GitWorkspace Workspace => _workspace;
private bool IsOpen() => _workspace.IsRepositoryOpen;
// ================= 입력 =================
public IReadOnlyList<ResetKind> ResetKinds { get; } =
new[] { ResetKind.Soft, ResetKind.Mixed, ResetKind.Hard };
[ObservableProperty] private ResetKind _selectedResetKind = ResetKind.Mixed;
[ObservableProperty] private string _newBranchName = string.Empty;
[ObservableProperty] private string _tagName = string.Empty;
[ObservableProperty] private string _tagMessage = string.Empty;
[ObservableProperty] private string _commitMessage = string.Empty;
[ObservableProperty] private FileStatusInfo? _selectedStatusFile;
[ObservableProperty] private ConflictInfo? _selectedConflict;
[ObservableProperty] private TagInfo? _selectedTag;
[ObservableProperty] private string _diffText = string.Empty;
partial void OnNewBranchNameChanged(string value) => RaiseCanExecuteChanged();
partial void OnTagNameChanged(string value) => RaiseCanExecuteChanged();
partial void OnCommitMessageChanged(string value) => RaiseCanExecuteChanged();
partial void OnSelectedStatusFileChanged(FileStatusInfo? value) => RaiseCanExecuteChanged();
partial void OnSelectedConflictChanged(ConflictInfo? value) => RaiseCanExecuteChanged();
partial void OnSelectedTagChanged(TagInfo? value) => RaiseCanExecuteChanged();
public BranchInfo? SelectedBranch
{
get => _workspace.SelectedBranch;
set
{
if (ReferenceEquals(_workspace.SelectedBranch, value)) return;
_workspace.SelectedBranch = value;
OnPropertyChanged();
RaiseCanExecuteChanged();
}
}
public CommitInfo? SelectedCommit
{
get => _workspace.SelectedCommit;
set
{
if (ReferenceEquals(_workspace.SelectedCommit, value)) return;
_workspace.SelectedCommit = value;
OnPropertyChanged();
_ = ShowDiffAsync(value);
RaiseCanExecuteChanged();
}
}
// ================= 저장소 =================
[RelayCommand]
private Task OpenAsync() => _workspace.RunAndRefreshAsync("저장소 열기",
() => _workspace.Git.Open(_workspace.RepositoryPath));
private bool CanClone() => !string.IsNullOrWhiteSpace(_workspace.RemoteUrl);
[RelayCommand(CanExecute = nameof(CanClone))]
private Task CloneAsync() => _workspace.RunAndRefreshAsync("클론", () =>
{
_workspace.ApplyCredentials();
_workspace.Git.Clone(_workspace.RemoteUrl, _workspace.RepositoryPath);
});
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task AddRemoteAsync() => _workspace.RunAndRefreshAsync("원격 추가", () =>
{
if (string.IsNullOrWhiteSpace(_workspace.RemoteUrl))
throw new InvalidOperationException("원격 URL 을 입력하세요.");
_workspace.ApplyCredentials();
_workspace.Git.AddRemote(_workspace.RemoteName, _workspace.RemoteUrl);
_workspace.Git.Fetch(_workspace.RemoteName);
_workspace.MarkFetched();
});
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task RemoveRemoteAsync() => _workspace.RunAndRefreshAsync("원격 삭제",
() => _workspace.Git.RemoveRemote(_workspace.RemoteName));
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task FetchAsync() => _workspace.RunAndRefreshAsync("fetch", () =>
{
_workspace.ApplyCredentials();
_workspace.Git.Fetch(_workspace.RemoteName);
_workspace.MarkFetched();
});
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task PullAsync() => _workspace.RunAndRefreshAsync("pull", () =>
{
_workspace.ApplyCredentials();
var result = _workspace.Git.Pull(_workspace.RemoteName);
_workspace.MarkFetched();
_workspace.OnUi(() => _workspace.AppendLog(result.Message));
});
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task PushAsync() => _workspace.RunAndRefreshAsync("push", () =>
{
_workspace.ApplyCredentials();
_workspace.Git.Push(remoteName: _workspace.RemoteName);
});
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task RefreshAsync() => _workspace.RefreshAsync();
// ================= 브랜치 =================
private bool HasBranch() => SelectedBranch is not null;
[RelayCommand(CanExecute = nameof(HasBranch))]
private Task CheckoutAsync() => _workspace.RunAndRefreshAsync("체크아웃",
() => _workspace.Git.Checkout(SelectedBranch!.FriendlyName));
private bool CanCreateBranch() => IsOpen() && !string.IsNullOrWhiteSpace(NewBranchName);
[RelayCommand(CanExecute = nameof(CanCreateBranch))]
private Task CreateBranchAsync() => _workspace.RunAndRefreshAsync("브랜치 생성", () =>
{
_workspace.Git.CreateBranch(NewBranchName, SelectedCommit?.Sha, checkout: true);
_workspace.OnUi(() => NewBranchName = string.Empty);
});
private bool CanDeleteBranch() => SelectedBranch is { IsRemote: false, IsCurrentHead: false };
[RelayCommand(CanExecute = nameof(CanDeleteBranch))]
private Task DeleteBranchAsync() => _workspace.RunAndRefreshAsync("브랜치 삭제",
() => _workspace.Git.DeleteBranch(SelectedBranch!.FriendlyName));
// ================= 병합 =================
private bool CanMerge() => SelectedBranch is not null && !SelectedBranch.IsCurrentHead;
[RelayCommand(CanExecute = nameof(CanMerge))]
private Task MergeAsync() => _workspace.RunAndRefreshAsync("병합", () =>
{
var branch = SelectedBranch!.FriendlyName;
var result = _workspace.Git.Merge(branch, commitOnSuccess: false);
_workspace.OnUi(() =>
{
_workspace.AppendLog(result.Message);
if (string.IsNullOrWhiteSpace(CommitMessage))
CommitMessage = $"Merge branch '{branch}' into {_workspace.CurrentBranch}";
if (result.HasConflicts)
MessageUtil.ShowWarningToast($"충돌 {result.Conflicts.Count}건 - 홈 탭에서 해결하세요.");
else
MessageUtil.ShowToast(result.Message);
});
});
private bool CanAbort() => _workspace.IsMergeInProgress || _workspace.IsRebaseInProgress;
[RelayCommand(CanExecute = nameof(CanAbort))]
private Task AbortAsync() => _workspace.RunAndRefreshAsync("작업 취소",
() => _workspace.Git.AbortOperation());
private bool CanCommitMerge() => _workspace.IsMergeInProgress && _workspace.Conflicts.Count == 0;
[RelayCommand(CanExecute = nameof(CanCommitMerge))]
private Task CommitMergeAsync() => _workspace.RunAndRefreshAsync("병합 커밋", () =>
{
var message = string.IsNullOrWhiteSpace(CommitMessage) ? null : CommitMessage;
var sha = _workspace.Git.CommitMerge(message);
_workspace.OnUi(() =>
{
CommitMessage = string.Empty;
MessageUtil.ShowSuccessToast($"병합 커밋 완료 ({sha.Substring(0, 7)})");
});
});
private bool HasConflict() => SelectedConflict is not null;
[RelayCommand(CanExecute = nameof(HasConflict))]
private Task AcceptMineAsync() => ResolveAsync(ConflictResolution.Mine);
[RelayCommand(CanExecute = nameof(HasConflict))]
private Task AcceptTheirsAsync() => ResolveAsync(ConflictResolution.Theirs);
[RelayCommand(CanExecute = nameof(HasConflict))]
private Task AcceptBothAsync() => ResolveAsync(ConflictResolution.Both);
private Task ResolveAsync(ConflictResolution resolution) =>
_workspace.RunAndRefreshAsync($"충돌 해결({resolution})",
() => _workspace.Git.ResolveConflict(SelectedConflict!.Path, resolution));
// ================= 이력 조작 =================
private bool HasCommit() => SelectedCommit is not null;
[RelayCommand(CanExecute = nameof(HasCommit))]
private Task RevertAsync() => _workspace.RunAndRefreshAsync("revert", () =>
{
var result = _workspace.Git.Revert(SelectedCommit!.Sha);
_workspace.OnUi(() => _workspace.AppendLog(result.Message));
});
[RelayCommand(CanExecute = nameof(HasCommit))]
private Task CherryPickAsync() => _workspace.RunAndRefreshAsync("cherry-pick", () =>
{
var result = _workspace.Git.CherryPick(SelectedCommit!.Sha);
_workspace.OnUi(() => _workspace.AppendLog(result.Message));
});
[RelayCommand(CanExecute = nameof(HasCommit))]
private Task ResetAsync() => _workspace.RunAndRefreshAsync($"reset --{SelectedResetKind}",
() => _workspace.Git.Reset(SelectedResetKind, SelectedCommit!.Sha));
private bool CanRebase() =>
SelectedBranch is not null && !SelectedBranch.IsCurrentHead && !_workspace.IsRebaseInProgress;
[RelayCommand(CanExecute = nameof(CanRebase))]
private Task RebaseAsync() => _workspace.RunAndRefreshAsync("rebase", () =>
{
var result = _workspace.Git.RebaseStart(SelectedBranch!.FriendlyName);
_workspace.OnUi(() => _workspace.AppendLog(result.Message));
});
private bool IsRebasing() => _workspace.IsRebaseInProgress;
[RelayCommand(CanExecute = nameof(IsRebasing))]
private Task RebaseContinueAsync() => _workspace.RunAndRefreshAsync("rebase continue", () =>
{
var result = _workspace.Git.RebaseContinue();
_workspace.OnUi(() => _workspace.AppendLog(result.Message));
});
[RelayCommand(CanExecute = nameof(IsRebasing))]
private Task RebaseAbortAsync() => _workspace.RunAndRefreshAsync("rebase abort",
() => _workspace.Git.RebaseAbort());
// ================= 스테이징 / 커밋 =================
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task StageAllAsync() => _workspace.RunAndRefreshAsync("전체 스테이징",
() => _workspace.Git.StageAll());
private bool HasStatusFile() => SelectedStatusFile is not null;
[RelayCommand(CanExecute = nameof(HasStatusFile))]
private Task StageSelectedAsync() => _workspace.RunAndRefreshAsync("스테이징",
() => _workspace.Git.Stage(SelectedStatusFile!.Path));
[RelayCommand(CanExecute = nameof(HasStatusFile))]
private Task UnstageSelectedAsync() => _workspace.RunAndRefreshAsync("언스테이징",
() => _workspace.Git.Unstage(SelectedStatusFile!.Path));
[RelayCommand(CanExecute = nameof(HasStatusFile))]
private Task RestoreSelectedAsync() => _workspace.RunAndRefreshAsync("변경 취소",
() => _workspace.Git.RestoreFiles(new[] { SelectedStatusFile!.Path }));
private bool CanCommit() => IsOpen() && !string.IsNullOrWhiteSpace(CommitMessage);
[RelayCommand(CanExecute = nameof(CanCommit))]
private Task CommitAsync() => _workspace.RunAndRefreshAsync("커밋", () =>
{
var sha = _workspace.Git.Commit(CommitMessage);
_workspace.OnUi(() =>
{
CommitMessage = string.Empty;
MessageUtil.ShowSuccessToast($"커밋 완료 ({sha.Substring(0, 7)})");
});
});
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task StashSaveAsync() => _workspace.RunAndRefreshAsync("stash 저장",
() => _workspace.Git.StashSave($"MergeHub {DateTime.Now:yyyy-MM-dd HH:mm}"));
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task StashPopAsync() => _workspace.RunAndRefreshAsync("stash 적용",
() => _workspace.Git.StashPop());
// ================= 태그 / 서브모듈 / 설정 =================
private bool CanCreateTag() => IsOpen() && !string.IsNullOrWhiteSpace(TagName);
[RelayCommand(CanExecute = nameof(CanCreateTag))]
private Task CreateTagAsync() => _workspace.RunAndRefreshAsync("태그 생성", () =>
{
var message = string.IsNullOrWhiteSpace(TagMessage) ? null : TagMessage;
_workspace.Git.CreateTag(TagName, SelectedCommit?.Sha ?? "HEAD", message);
_workspace.OnUi(() => { TagName = string.Empty; TagMessage = string.Empty; });
});
private bool HasTag() => SelectedTag is not null;
[RelayCommand(CanExecute = nameof(HasTag))]
private Task DeleteTagAsync() => _workspace.RunAndRefreshAsync("태그 삭제",
() => _workspace.Git.DeleteTag(SelectedTag!.Name));
private bool CanUpdateSubmodules() => IsOpen() && _workspace.Submodules.Count > 0;
[RelayCommand(CanExecute = nameof(CanUpdateSubmodules))]
private Task UpdateSubmodulesAsync() => _workspace.RunAndRefreshAsync("서브모듈 업데이트", () =>
{
_workspace.ApplyCredentials();
_workspace.Git.UpdateAllSubmodules();
});
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task RegisterMergeToolAsync() => _workspace.RunAsync("mergetool 등록", () =>
{
// net48 에는 Environment.ProcessPath 가 없다. 호스트는 UbisamBase.Shell.exe.
var exe = Process.GetCurrentProcess().MainModule?.FileName
?? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "UbisamBase.Shell.exe");
_workspace.Git.RegisterAsMergeTool("mergehub", exe, global: true);
_workspace.OnUi(() => MessageUtil.ShowSuccessToast("git config 에 mergehub 등록 완료"));
});
/// <summary>CommunityToolkit 의 커맨드는 CommandManager 를 구독하지 않아 직접 알려야 한다.</summary>
private void RaiseCanExecuteChanged()
{
OpenCommand.NotifyCanExecuteChanged();
CloneCommand.NotifyCanExecuteChanged();
AddRemoteCommand.NotifyCanExecuteChanged();
RemoveRemoteCommand.NotifyCanExecuteChanged();
FetchCommand.NotifyCanExecuteChanged();
PullCommand.NotifyCanExecuteChanged();
PushCommand.NotifyCanExecuteChanged();
RefreshCommand.NotifyCanExecuteChanged();
CheckoutCommand.NotifyCanExecuteChanged();
CreateBranchCommand.NotifyCanExecuteChanged();
DeleteBranchCommand.NotifyCanExecuteChanged();
MergeCommand.NotifyCanExecuteChanged();
AbortCommand.NotifyCanExecuteChanged();
CommitMergeCommand.NotifyCanExecuteChanged();
AcceptMineCommand.NotifyCanExecuteChanged();
AcceptTheirsCommand.NotifyCanExecuteChanged();
AcceptBothCommand.NotifyCanExecuteChanged();
RevertCommand.NotifyCanExecuteChanged();
CherryPickCommand.NotifyCanExecuteChanged();
ResetCommand.NotifyCanExecuteChanged();
RebaseCommand.NotifyCanExecuteChanged();
RebaseContinueCommand.NotifyCanExecuteChanged();
RebaseAbortCommand.NotifyCanExecuteChanged();
StageAllCommand.NotifyCanExecuteChanged();
StageSelectedCommand.NotifyCanExecuteChanged();
UnstageSelectedCommand.NotifyCanExecuteChanged();
RestoreSelectedCommand.NotifyCanExecuteChanged();
CommitCommand.NotifyCanExecuteChanged();
StashSaveCommand.NotifyCanExecuteChanged();
StashPopCommand.NotifyCanExecuteChanged();
CreateTagCommand.NotifyCanExecuteChanged();
DeleteTagCommand.NotifyCanExecuteChanged();
UpdateSubmodulesCommand.NotifyCanExecuteChanged();
RegisterMergeToolCommand.NotifyCanExecuteChanged();
}
// ================= diff =================
private Task ShowDiffAsync(CommitInfo? commit)
{
if (commit is null) return Task.CompletedTask;
return _workspace.RunAsync("diff", () =>
{
var diffs = _workspace.Git.DiffCommitWithParent(commit.Sha);
var sb = new StringBuilder();
sb.AppendLine($"{commit.ShortSha} {commit.MessageShort}");
sb.AppendLine($"{commit.AuthorName} <{commit.AuthorEmail}> {commit.When:yyyy-MM-dd HH:mm}");
sb.AppendLine(new string('-', 60));
foreach (var d in diffs)
{
sb.AppendLine($"[{d.ChangeKind}] {d.Path} (+{d.LinesAdded} / -{d.LinesDeleted})");
if (d.IsBinary) { sb.AppendLine(" (binary)"); continue; }
foreach (var line in d.Lines)
{
var marker = line.Kind switch
{
DiffLineKind.Added => '+',
DiffLineKind.Deleted => '-',
DiffLineKind.HunkHeader => '@',
_ => ' '
};
sb.Append(marker).Append(' ').AppendLine(RenderLine(line));
}
sb.AppendLine();
}
var text = sb.ToString();
_workspace.OnUi(() => DiffText = text);
});
}
/// <summary>단어 단위 diff 가 켜져 있으면 바뀐 구간을 〔〕로 감싼다.</summary>
private static string RenderLine(DiffLine line)
{
if (line.Words is null || line.Words.Count == 0) return line.Text;
var sb = new StringBuilder();
foreach (var word in line.Words)
{
if (word.Kind == WordDiffKind.Unchanged) sb.Append(word.Text);
else sb.Append('〔').Append(word.Text).Append('〕');
}
return sb.ToString();
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,50 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!--
플랫폼 기본 Button 템플릿은 Background 를 고정값으로 쓰기 때문에
Button 에 Background 를 지정해도 반영되지 않고, Foreground 만 적용되어
밝은 테마에서 "흰 배경 + 흰 글자"가 되어 버린다.
강조 버튼(Merge, Accept, Save & Commit 등)은 이 스타일을 써서
지정한 Background/Foreground 가 실제로 적용되게 한다.
-->
<Style x:Key="FilledButton" TargetType="Button">
<Setter Property="MinHeight" Value="28"/>
<Setter Property="Padding" Value="10,4"/>
<Setter Property="Margin" Value="2,0"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Background" Value="{DynamicResource Ubisam.Brush.Accent.Fill}"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Accent.OnFill}"/>
<Setter Property="BorderBrush" Value="Transparent"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Padding="{TemplateBinding Padding}"
CornerRadius="3"
SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"
TextElement.Foreground="{TemplateBinding Foreground}"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Opacity" Value="0.85"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Opacity" Value="0.70"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Bd" Property="Opacity" Value="0.35"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

View File

@ -0,0 +1,315 @@
using System;
using System.Collections.Generic;
namespace MergeHub.Views.Controls
{
/// <summary>
/// 코드 한 조각의 종류. 색을 나누는 단위라 "무엇인지"보다 "어떻게 보여야 하는지"에 맞춰 쪼갰다.
/// </summary>
public enum CodeTokenKind
{
/// <summary>지역 변수·매개변수·필드 등 그냥 이름.</summary>
Default,
/// <summary>class, new, null, is 처럼 선언·식에 쓰는 예약어.</summary>
Keyword,
/// <summary>if, for, return, throw 처럼 흐름을 바꾸는 예약어.</summary>
ControlKeyword,
/// <summary>int, string, void, var 같은 내장 타입 예약어.</summary>
TypeKeyword,
/// <summary>public, private, protected, internal.</summary>
AccessModifier,
/// <summary>static, readonly, async, override 같은 한정자.</summary>
Modifier,
/// <summary>대문자로 시작하는 이름 - 타입으로 본다.</summary>
Type,
/// <summary>뒤에 여는 괄호가 오는 이름 - 메서드 호출/선언으로 본다.</summary>
Method,
/// <summary>문자열·문자 리터럴.</summary>
Text,
/// <summary>일반 주석.</summary>
Comment,
/// <summary>/// XML 문서 주석.</summary>
XmlDoc,
/// <summary>숫자 리터럴.</summary>
Number,
/// <summary>#region, #if 같은 전처리 지시문.</summary>
Preprocessor,
/// <summary>+ - = == &amp;&amp; 같은 연산자.</summary>
Operator,
/// <summary>( ) { } [ ] ; , . 같은 구두점.</summary>
Punctuation
}
public readonly struct CodeToken
{
public CodeToken(int start, int length, CodeTokenKind kind)
{
Start = start;
Length = length;
Kind = kind;
}
public int Start { get; }
public int Length { get; }
public CodeTokenKind Kind { get; }
}
/// <summary>
/// 화면 표시용 C# 토크나이저 - 한 줄씩 독립적으로 훑는다.
/// 컴파일러가 아니라 "색을 칠하기 위한" 수준이라 다음은 다루지 않는다:
/// - 여러 줄에 걸친 블록 주석은 시작한 줄만 주석으로 칠해진다
/// - 제네릭 메서드 호출 Foo&lt;int&gt;(...) 의 Foo 는 타입으로 잡힌다
/// </summary>
public static class CSharpTokenizer
{
private static readonly HashSet<string> AccessModifiers = new HashSet<string>(StringComparer.Ordinal)
{
"public", "private", "protected", "internal", "file"
};
private static readonly HashSet<string> Modifiers = new HashSet<string>(StringComparer.Ordinal)
{
"abstract", "async", "const", "extern", "override", "partial", "readonly",
"required", "sealed", "static", "unsafe", "virtual", "volatile", "init"
};
private static readonly HashSet<string> ControlKeywords = new HashSet<string>(StringComparer.Ordinal)
{
"await", "break", "case", "catch", "continue", "default", "do", "else", "finally",
"for", "foreach", "goto", "if", "lock", "return", "switch", "throw", "try",
"when", "while", "yield"
};
private static readonly HashSet<string> TypeKeywords = new HashSet<string>(StringComparer.Ordinal)
{
"bool", "byte", "char", "decimal", "double", "dynamic", "float", "int", "long",
"nint", "nuint", "object", "sbyte", "short", "string", "uint", "ulong", "ushort",
"var", "void"
};
private static readonly HashSet<string> Keywords = new HashSet<string>(StringComparer.Ordinal)
{
"add", "and", "as", "ascending", "base", "by", "checked", "class", "delegate",
"descending", "enum", "equals", "event", "explicit", "false", "fixed", "from",
"get", "global", "group", "implicit", "in", "interface", "into", "is", "join",
"let", "namespace", "nameof", "new", "not", "null", "on", "operator", "or",
"orderby", "out", "params", "record", "ref", "remove", "select", "set", "sizeof",
"stackalloc", "struct", "this", "true", "typeof", "unchecked", "using", "value",
"where", "with"
};
private const string OperatorChars = "=+-*/%!<>&|^~?:";
private const string PunctuationChars = "(){}[];,.";
public static IReadOnlyList<CodeToken> Tokenize(string line) => Tokenize(line, false, out _);
/// <summary>
/// 앞 줄에서 블록 주석(/* )이 열린 채 넘어왔는지를 받아 처리하고,
/// 이 줄이 끝난 뒤에도 열려 있는지를 돌려준다.
/// 이렇게 해야 여러 줄에 걸친 /* ... */ 가 전부 주석색으로 칠해진다.
/// </summary>
public static IReadOnlyList<CodeToken> Tokenize(string line, bool inBlockComment, out bool stillInBlockComment)
{
var tokens = new List<CodeToken>();
stillInBlockComment = inBlockComment;
if (string.IsNullOrEmpty(line)) return tokens;
var i = 0;
// 앞 줄에서 넘어온 블록 주석 먼저 닫는다.
if (inBlockComment)
{
var close = line.IndexOf("*/", StringComparison.Ordinal);
if (close < 0)
{
tokens.Add(new CodeToken(0, line.Length, CodeTokenKind.Comment));
return tokens;
}
tokens.Add(new CodeToken(0, close + 2, CodeTokenKind.Comment));
stillInBlockComment = false;
i = close + 2;
}
// 전처리 지시문은 줄 전체가 하나다. (블록 주석이 열린 줄에서는 해당 없음)
if (i == 0)
{
var firstVisible = FirstNonWhitespace(line);
if (firstVisible >= 0 && line[firstVisible] == '#')
{
tokens.Add(new CodeToken(firstVisible, line.Length - firstVisible, CodeTokenKind.Preprocessor));
return tokens;
}
}
while (i < line.Length)
{
var start = i;
var c = line[i];
// ---- 주석 ----
if (c == '/' && i + 1 < line.Length && line[i + 1] == '/')
{
var isXmlDoc = i + 2 < line.Length && line[i + 2] == '/';
tokens.Add(new CodeToken(start, line.Length - start,
isXmlDoc ? CodeTokenKind.XmlDoc : CodeTokenKind.Comment));
break;
}
if (c == '/' && i + 1 < line.Length && line[i + 1] == '*')
{
var end = line.IndexOf("*/", i + 2, StringComparison.Ordinal);
// 이 줄에서 안 닫히면 다음 줄로 넘긴다.
if (end < 0)
{
tokens.Add(new CodeToken(start, line.Length - start, CodeTokenKind.Comment));
stillInBlockComment = true;
break;
}
tokens.Add(new CodeToken(start, end + 2 - start, CodeTokenKind.Comment));
i = end + 2;
continue;
}
// ---- 문자열 / 문자 ----
if ((c == '@' || c == '$') && TryReadPrefixedString(line, i, out var prefixed))
{
tokens.Add(new CodeToken(start, prefixed, CodeTokenKind.Text));
i += prefixed;
continue;
}
if (c == '"' || c == '\'')
{
var length = ReadQuoted(line, i, c, verbatim: false);
tokens.Add(new CodeToken(start, length, CodeTokenKind.Text));
i += length;
continue;
}
// ---- 숫자 ----
if (char.IsDigit(c))
{
i++;
while (i < line.Length && (char.IsLetterOrDigit(line[i]) || line[i] == '.' || line[i] == '_'))
{
// 1..2 같은 범위 연산자는 숫자에 포함하지 않는다
if (line[i] == '.' && i + 1 < line.Length && line[i + 1] == '.') break;
i++;
}
tokens.Add(new CodeToken(start, i - start, CodeTokenKind.Number));
continue;
}
// ---- 식별자 / 예약어 ----
if (char.IsLetter(c) || c == '_')
{
i++;
while (i < line.Length && (char.IsLetterOrDigit(line[i]) || line[i] == '_')) i++;
var word = line.Substring(start, i - start);
tokens.Add(new CodeToken(start, i - start, ClassifyWord(word, line, i)));
continue;
}
// ---- 연산자 / 구두점 ----
if (OperatorChars.IndexOf(c) >= 0)
{
i++;
while (i < line.Length && OperatorChars.IndexOf(line[i]) >= 0) i++;
tokens.Add(new CodeToken(start, i - start, CodeTokenKind.Operator));
continue;
}
if (PunctuationChars.IndexOf(c) >= 0)
{
i++;
tokens.Add(new CodeToken(start, 1, CodeTokenKind.Punctuation));
continue;
}
// 공백 등 나머지
i++;
}
return tokens;
}
/// <summary>예약어 목록을 먼저 보고, 아니면 뒤에 여는 괄호가 오는지로 메서드를 가린다.</summary>
private static CodeTokenKind ClassifyWord(string word, string line, int afterIndex)
{
if (AccessModifiers.Contains(word)) return CodeTokenKind.AccessModifier;
if (Modifiers.Contains(word)) return CodeTokenKind.Modifier;
if (ControlKeywords.Contains(word)) return CodeTokenKind.ControlKeyword;
if (TypeKeywords.Contains(word)) return CodeTokenKind.TypeKeyword;
if (Keywords.Contains(word)) return CodeTokenKind.Keyword;
// 이름 뒤에 ( 가 오면 메서드로 본다. if/for 는 위에서 이미 걸러졌다.
var j = afterIndex;
while (j < line.Length && char.IsWhiteSpace(line[j])) j++;
if (j < line.Length && line[j] == '(') return CodeTokenKind.Method;
return char.IsUpper(word[0]) ? CodeTokenKind.Type : CodeTokenKind.Default;
}
private static int FirstNonWhitespace(string line)
{
for (var i = 0; i < line.Length; i++)
if (!char.IsWhiteSpace(line[i])) return i;
return -1;
}
/// <summary>@"..." / $"..." / $@"..." / @$"..." 를 통째로 읽는다.</summary>
private static bool TryReadPrefixedString(string line, int index, out int length)
{
length = 0;
var i = index;
var verbatim = false;
while (i < line.Length && (line[i] == '@' || line[i] == '$'))
{
if (line[i] == '@') verbatim = true;
i++;
}
if (i >= line.Length || line[i] != '"') return false;
var quoted = ReadQuoted(line, i, '"', verbatim);
length = i - index + quoted;
return true;
}
/// <summary>여는 따옴표부터 닫는 따옴표까지의 길이. 닫히지 않으면 줄 끝까지.</summary>
private static int ReadQuoted(string line, int index, char quote, bool verbatim)
{
var i = index + 1;
while (i < line.Length)
{
var c = line[i];
if (verbatim)
{
// 축자 문자열에서는 "" 가 따옴표 하나를 뜻한다
if (c == quote)
{
if (i + 1 < line.Length && line[i + 1] == quote) { i += 2; continue; }
return i + 1 - index;
}
i++;
continue;
}
if (c == '\\') { i += 2; continue; }
if (c == quote) return i + 1 - index;
i++;
}
return line.Length - index;
}
}
}

View File

@ -0,0 +1,169 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using System.Windows;
using System.Windows.Input;
using System.Windows.Media;
using MergeHub.Models;
namespace MergeHub.Views.Controls
{
/// <summary>
/// 파일 전체의 변경 분포를 세로 막대로 축소해 보여준다(목업 ③ 코드 미니맵).
/// 클릭하면 그 위치의 줄을 요청한다 - 실제 스크롤은 화면 쪽에서 처리한다.
/// </summary>
public sealed class ChangeMiniMap : FrameworkElement
{
private const double MinTickHeight = 2.0;
private static readonly Brush TrackBrush = Freeze(new SolidColorBrush(Color.FromArgb(0x22, 0x80, 0x80, 0x80)));
private static readonly Pen SelectionPen = Freeze(new Pen(new SolidColorBrush(Color.FromRgb(0x2E, 0x86, 0xDE)), 1.5));
/// <summary>
/// 범례·코드 패널과 같은 색을 쓰되, 미니맵은 얇아서 옅은 알파로는 안 보인다.
/// 그래서 같은 색상값을 불투명하게 올려 쓴다.
/// </summary>
private static readonly Brush ChangedBrush = Freeze(new SolidColorBrush(Color.FromRgb(0xE0, 0xA0, 0x20)));
private static readonly Brush AppliedBrush = Freeze(new SolidColorBrush(Color.FromRgb(0x2E, 0xA0, 0x43)));
private static readonly Brush EditedBrush = Freeze(new SolidColorBrush(Color.FromRgb(0x9B, 0x59, 0xB6)));
private static readonly Brush AddedBrush = Freeze(new SolidColorBrush(Color.FromRgb(0x2E, 0x86, 0xDE)));
private static readonly Brush RemovedBrush = Freeze(new SolidColorBrush(Color.FromRgb(0xD1, 0x4B, 0x4B)));
private static readonly Brush MovedBrush = Freeze(new SolidColorBrush(Color.FromRgb(0x00, 0xA0, 0xA8)));
private static Brush BrushFor(LineHighlight highlight) => highlight switch
{
LineHighlight.Applied => AppliedBrush,
LineHighlight.Edited => EditedBrush,
LineHighlight.Added => AddedBrush,
LineHighlight.Removed => RemovedBrush,
LineHighlight.Moved => MovedBrush,
_ => ChangedBrush
};
private INotifyCollectionChanged? _observed;
public ChangeMiniMap()
{
Cursor = Cursors.Hand;
Width = 14;
ToolTip = "변경 분포 - 클릭하면 해당 줄로 이동";
}
/// <summary>클릭한 지점에 해당하는 줄이 선택되었을 때 발생.</summary>
public event EventHandler<MergeLine>? LineRequested;
public static readonly DependencyProperty SourceProperty = DependencyProperty.Register(
nameof(Source), typeof(IEnumerable), typeof(ChangeMiniMap),
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnSourceChanged));
public IEnumerable? Source
{
get => (IEnumerable?)GetValue(SourceProperty);
set => SetValue(SourceProperty, value);
}
/// <summary>
/// 줄 내용이 바뀌어도 컬렉션 자체는 그대로라 다시 그릴 계기가 없다.
/// ViewModel 이 편집할 때마다 이 값을 올려서 갱신을 알린다.
/// </summary>
public static readonly DependencyProperty VersionProperty = DependencyProperty.Register(
nameof(Version), typeof(int), typeof(ChangeMiniMap),
new FrameworkPropertyMetadata(0, FrameworkPropertyMetadataOptions.AffectsRender));
public int Version
{
get => (int)GetValue(VersionProperty);
set => SetValue(VersionProperty, value);
}
public static readonly DependencyProperty SelectedLineProperty = DependencyProperty.Register(
nameof(SelectedLine), typeof(MergeLine), typeof(ChangeMiniMap),
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender));
public MergeLine? SelectedLine
{
get => (MergeLine?)GetValue(SelectedLineProperty);
set => SetValue(SelectedLineProperty, value);
}
private static void OnSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not ChangeMiniMap map) return;
if (map._observed is not null)
map._observed.CollectionChanged -= map.OnCollectionChanged;
map._observed = e.NewValue as INotifyCollectionChanged;
if (map._observed is not null)
map._observed.CollectionChanged += map.OnCollectionChanged;
map.InvalidateVisual();
}
private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) => InvalidateVisual();
protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
{
base.OnMouseLeftButtonDown(e);
var lines = Snapshot();
if (lines.Count == 0 || ActualHeight <= 0) return;
var ratio = Math.Min(1, Math.Max(0, e.GetPosition(this).Y / ActualHeight));
var index = Math.Min(lines.Count - 1, (int)(ratio * lines.Count));
LineRequested?.Invoke(this, lines[index]);
e.Handled = true;
}
protected override void OnRender(DrawingContext dc)
{
var w = ActualWidth;
var h = ActualHeight;
if (w <= 0 || h <= 0) return;
dc.DrawRectangle(TrackBrush, null, new Rect(0, 0, w, h));
var lines = Snapshot();
if (lines.Count == 0) return;
var step = h / lines.Count;
var tick = Math.Max(MinTickHeight, step);
for (var i = 0; i < lines.Count; i++)
{
var highlight = lines[i].Highlight;
if (highlight == LineHighlight.None || highlight == LineHighlight.Ignored) continue;
// 코드 패널의 줄 배경과 같은 기준을 쓴다.
// 반영한 줄은 그 즉시 초록으로 바뀌어야 하므로 IsDifferent 가 아니라 Highlight 를 본다.
dc.DrawRectangle(BrushFor(highlight), null, new Rect(2, i * step, w - 4, tick));
}
// 지금 보고 있는 줄 위치
var selected = SelectedLine;
if (selected is null) return;
var selectedIndex = lines.IndexOf(selected);
if (selectedIndex < 0) return;
var y = selectedIndex * step + tick / 2;
dc.DrawLine(SelectionPen, new Point(0, y), new Point(w, y));
}
private List<MergeLine> Snapshot()
{
if (Source is null) return new List<MergeLine>();
return Source.OfType<MergeLine>().ToList();
}
private static T Freeze<T>(T freezable) where T : Freezable
{
freezable.Freeze();
return freezable;
}
}
}

View File

@ -0,0 +1,270 @@
using System;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Media;
using MergeHub.Models;
namespace MergeHub.Views.Controls
{
/// <summary>
/// 코드 한 줄을 문법 색과 함께 그린다.
/// - 글자색 = C# 토큰 종류 (접근제한자는 따로 구분해 굵게)
/// - 배경색 = 단어 단위 diff 로 잡힌 "이 줄에서 실제로 바뀐 구간"
/// 두 축이 겹치므로 TextBlock 의 Inlines 를 직접 구성한다.
/// </summary>
public sealed class CodeTextBlock : TextBlock
{
/// <summary>이보다 긴 줄은 색칠을 포기하고 평문으로 그린다(목록 성능 보호).</summary>
private const int MaxHighlightLength = 1500;
// 밝은 배경 / 어두운 배경 각각의 팔레트. 실제 선택은 상속받은 Foreground 밝기로 판단한다.
public static readonly Palette Light = new Palette(
keyword: "#0000C8", control: "#8F08C4", typeKeyword: "#0F7B7B",
access: "#B4009E", modifier: "#0F72C4", type: "#2B91AF", method: "#795E26",
text: "#A31515", comment: "#2E8B2E", xmlDoc: "#5F9E5F", number: "#098658",
preprocessor: "#808080", op: "#555555", punctuation: "#777777",
change: "#66E0A020");
public static readonly Palette Dark = new Palette(
keyword: "#569CD6", control: "#C586C0", typeKeyword: "#4EC9B0",
access: "#E06BC8", modifier: "#4FA6E8", type: "#4EC9B0", method: "#DCDCAA",
text: "#CE9178", comment: "#6A9955", xmlDoc: "#7FA87F", number: "#B5CEA8",
preprocessor: "#9B9B9B", op: "#D4D4D4", punctuation: "#BFBFBF",
change: "#66E0A020");
public static readonly DependencyProperty CodeProperty = DependencyProperty.Register(
nameof(Code), typeof(string), typeof(CodeTextBlock),
new FrameworkPropertyMetadata(string.Empty, OnVisualInputChanged));
/// <summary>표시할 코드 한 줄.</summary>
public string Code
{
get => (string)GetValue(CodeProperty);
set => SetValue(CodeProperty, value);
}
public static readonly DependencyProperty WordsProperty = DependencyProperty.Register(
nameof(Words), typeof(IReadOnlyList<WordDiffSegment>), typeof(CodeTextBlock),
new FrameworkPropertyMetadata(null, OnVisualInputChanged));
/// <summary>단어 단위 diff 결과. 없으면 배경 강조를 하지 않는다.</summary>
public IReadOnlyList<WordDiffSegment>? Words
{
get => (IReadOnlyList<WordDiffSegment>?)GetValue(WordsProperty);
set => SetValue(WordsProperty, value);
}
public static readonly DependencyProperty InBlockCommentProperty = DependencyProperty.Register(
nameof(InBlockComment), typeof(bool), typeof(CodeTextBlock),
new FrameworkPropertyMetadata(false, OnVisualInputChanged));
/// <summary>앞 줄에서 /* 가 열린 채 넘어왔는지. 여러 줄 블록 주석을 이어서 칠하기 위한 값.</summary>
public bool InBlockComment
{
get => (bool)GetValue(InBlockCommentProperty);
set => SetValue(InBlockCommentProperty, value);
}
public static readonly DependencyProperty CodeLanguageProperty = DependencyProperty.Register(
nameof(CodeLanguage), typeof(Services.LanguageProfile), typeof(CodeTextBlock),
new FrameworkPropertyMetadata(null, OnVisualInputChanged));
/// <summary>
/// 어떤 언어로 읽을지. 비워 두면 평문으로 본다 - 색을 잘못 입히느니 안 입히는 편이 낫다.
/// </summary>
public Services.LanguageProfile? CodeLanguage
{
get => (Services.LanguageProfile?)GetValue(CodeLanguageProperty);
set => SetValue(CodeLanguageProperty, value);
}
public static readonly DependencyProperty HighlightSyntaxProperty = DependencyProperty.Register(
nameof(HighlightSyntax), typeof(bool), typeof(CodeTextBlock),
new FrameworkPropertyMetadata(true, OnVisualInputChanged));
public bool HighlightSyntax
{
get => (bool)GetValue(HighlightSyntaxProperty);
set => SetValue(HighlightSyntaxProperty, value);
}
static CodeTextBlock()
{
// 테마가 바뀌면 상속받는 Foreground 가 바뀐다. 그때 팔레트를 다시 고르도록
// 메타데이터에 콜백을 얹는다(TextBlock.OnPropertyChanged 는 sealed 라 재정의할 수 없다).
var inherited = (FrameworkPropertyMetadata)ForegroundProperty.GetMetadata(typeof(TextBlock));
ForegroundProperty.OverrideMetadata(typeof(CodeTextBlock),
new FrameworkPropertyMetadata(
inherited.DefaultValue,
FrameworkPropertyMetadataOptions.Inherits | FrameworkPropertyMetadataOptions.AffectsRender,
OnVisualInputChanged));
}
private static void OnVisualInputChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) =>
((CodeTextBlock)d).Rebuild();
private void Rebuild()
{
Inlines.Clear();
var code = Code ?? string.Empty;
if (code.Length == 0) return;
if (code.Length > MaxHighlightLength)
{
Inlines.Add(new Run(code));
return;
}
var palette = IsOnDarkBackground() ? Dark : Light;
var kinds = new CodeTokenKind[code.Length];
if (HighlightSyntax)
{
var profile = CodeLanguage ?? Services.LanguageCatalog.Plain;
foreach (var token in Services.CodeTokenizer.Tokenize(code, profile, InBlockComment, out _))
{
var end = Math.Min(code.Length, token.Start + token.Length);
for (var i = token.Start; i < end; i++) kinds[i] = token.Kind;
}
}
var changed = BuildChangeMask(code.Length, Words);
// 같은 (종류, 변경여부) 가 이어지는 동안 한 Run 으로 묶는다.
var runStart = 0;
for (var i = 1; i <= code.Length; i++)
{
var boundary = i == code.Length ||
kinds[i] != kinds[runStart] ||
changed[i] != changed[runStart];
if (!boundary) continue;
Inlines.Add(CreateRun(code.Substring(runStart, i - runStart),
kinds[runStart], changed[runStart], palette));
runStart = i;
}
}
private static Run CreateRun(string text, CodeTokenKind kind, bool isChanged, Palette palette)
{
var run = new Run(text);
var brush = palette.For(kind);
if (brush is not null) run.Foreground = brush;
if (kind == CodeTokenKind.AccessModifier) run.FontWeight = FontWeights.Bold;
if (isChanged) run.Background = palette.Change;
return run;
}
/// <summary>
/// 단어 단위 diff 세그먼트를 문자 위치 마스크로 바꾼다.
/// 세그먼트 길이 합이 본문 길이와 다르면(계산이 어긋난 경우) 강조를 포기한다.
/// </summary>
private static bool[] BuildChangeMask(int length, IReadOnlyList<WordDiffSegment>? words)
{
var mask = new bool[length];
if (words is null || words.Count == 0) return mask;
var total = 0;
foreach (var w in words) total += w.Text.Length;
if (total != length) return mask;
var pos = 0;
foreach (var w in words)
{
if (w.Kind != WordDiffKind.Unchanged)
{
for (var i = pos; i < pos + w.Text.Length; i++) mask[i] = true;
}
pos += w.Text.Length;
}
return mask;
}
/// <summary>글자색이 밝으면 어두운 배경 위에 있다고 본다.</summary>
private bool IsOnDarkBackground()
{
if (Foreground is not SolidColorBrush brush) return false;
var c = brush.Color;
var luma = (0.299 * c.R + 0.587 * c.G + 0.114 * c.B) / 255.0;
return luma > 0.5;
}
public sealed class Palette
{
public Palette(string keyword, string control, string typeKeyword, string access,
string modifier, string type, string method, string text,
string comment, string xmlDoc, string number, string preprocessor,
string op, string punctuation, string change)
{
Keyword = Freeze(keyword);
Control = Freeze(control);
TypeKeyword = Freeze(typeKeyword);
Access = Freeze(access);
Modifier = Freeze(modifier);
Type = Freeze(type);
Method = Freeze(method);
Text = Freeze(text);
Comment = Freeze(comment);
XmlDoc = Freeze(xmlDoc);
Number = Freeze(number);
Preprocessor = Freeze(preprocessor);
Operator = Freeze(op);
Punctuation = Freeze(punctuation);
Change = Freeze(change);
}
public Brush Keyword { get; }
public Brush Control { get; }
public Brush TypeKeyword { get; }
public Brush Access { get; }
public Brush Modifier { get; }
public Brush Type { get; }
public Brush Method { get; }
public Brush Text { get; }
public Brush Comment { get; }
public Brush XmlDoc { get; }
public Brush Number { get; }
public Brush Preprocessor { get; }
public Brush Operator { get; }
public Brush Punctuation { get; }
public Brush Change { get; }
/// <summary>Default 는 null - 상속받은 Foreground 를 그대로 쓴다.</summary>
public Brush? For(CodeTokenKind kind) => kind switch
{
CodeTokenKind.Keyword => Keyword,
CodeTokenKind.ControlKeyword => Control,
CodeTokenKind.TypeKeyword => TypeKeyword,
CodeTokenKind.AccessModifier => Access,
CodeTokenKind.Modifier => Modifier,
CodeTokenKind.Type => Type,
CodeTokenKind.Method => Method,
CodeTokenKind.Text => Text,
CodeTokenKind.Comment => Comment,
CodeTokenKind.XmlDoc => XmlDoc,
CodeTokenKind.Number => Number,
CodeTokenKind.Preprocessor => Preprocessor,
CodeTokenKind.Operator => Operator,
CodeTokenKind.Punctuation => Punctuation,
_ => null
};
private static Brush Freeze(string hex)
{
var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex));
brush.Freeze();
return brush;
}
}
}
}

View File

@ -0,0 +1,121 @@
using System;
using System.Windows;
using System.Windows.Media;
using MergeHub.Models;
namespace MergeHub.Views.Controls
{
/// <summary>
/// 커밋 목록 한 행의 왼쪽에 브랜치 그래프를 직접 그린다.
/// 도형을 XAML 로 조립하면 행마다 수십 개 요소가 생겨 목록이 무거워지므로 OnRender 로 그린다.
/// </summary>
public sealed class CommitGraphCell : FrameworkElement
{
private const double LaneWidth = 14;
private const double DotRadius = 4.5;
private const double StrokeThickness = 1.8;
/// <summary>레인 색. 플랫폼 테마와 무관하게 그래프끼리 구분되는 색을 쓴다.</summary>
private static readonly Color[] LaneColors =
{
Color.FromRgb(0x2E, 0x86, 0xDE), // 파랑
Color.FromRgb(0x2E, 0xA0, 0x43), // 초록
Color.FromRgb(0xE0, 0x7B, 0x1C), // 주황
Color.FromRgb(0x9B, 0x59, 0xB6), // 보라
Color.FromRgb(0xD1, 0x4B, 0x4B), // 빨강
Color.FromRgb(0x17, 0xA2, 0xA8), // 청록
};
private static readonly Pen[] Pens;
private static readonly Brush[] Brushes;
static CommitGraphCell()
{
Pens = new Pen[LaneColors.Length];
Brushes = new Brush[LaneColors.Length];
for (var i = 0; i < LaneColors.Length; i++)
{
var brush = new SolidColorBrush(LaneColors[i]);
brush.Freeze();
Brushes[i] = brush;
var pen = new Pen(brush, StrokeThickness);
pen.Freeze();
Pens[i] = pen;
}
}
public static readonly DependencyProperty RowProperty = DependencyProperty.Register(
nameof(Row), typeof(GraphRow), typeof(CommitGraphCell),
new FrameworkPropertyMetadata(null,
FrameworkPropertyMetadataOptions.AffectsRender | FrameworkPropertyMetadataOptions.AffectsMeasure));
public GraphRow? Row
{
get => (GraphRow?)GetValue(RowProperty);
set => SetValue(RowProperty, value);
}
protected override Size MeasureOverride(Size availableSize)
{
var lanes = Row?.LaneCount ?? 1;
return new Size(Math.Max(1, lanes) * LaneWidth, 0);
}
protected override void OnRender(DrawingContext dc)
{
var row = Row;
if (row is null) return;
var h = ActualHeight;
if (h <= 0) return;
// 선: 행 위쪽 경계에서 들어와 아래쪽 경계로 나간다.
// 사선이 급해 보이지 않도록 가운데 높이까지는 수직으로 내린 뒤 꺾는다.
foreach (var edge in row.Edges)
{
var pen = Pen(edge.ColorIndex);
var x1 = X(edge.FromLane);
var x2 = X(edge.ToLane);
if (Math.Abs(x1 - x2) < 0.1)
{
dc.DrawLine(pen, new Point(x1, 0), new Point(x1, h));
continue;
}
var geometry = new StreamGeometry();
using (var ctx = geometry.Open())
{
ctx.BeginFigure(new Point(x1, 0), false, false);
ctx.LineTo(new Point(x1, h * 0.5), true, true);
ctx.LineTo(new Point(x2, h), true, true);
}
geometry.Freeze();
dc.DrawGeometry(null, pen, geometry);
}
// 점: 병합 커밋은 속을 비워 구분한다.
var dotX = X(row.Lane);
var dotY = h * 0.5;
var brush = Brush(row.ColorIndex);
if (row.IsMerge)
{
dc.DrawEllipse(System.Windows.Media.Brushes.White, Pen(row.ColorIndex),
new Point(dotX, dotY), DotRadius, DotRadius);
}
else
{
dc.DrawEllipse(brush, null, new Point(dotX, dotY), DotRadius, DotRadius);
}
}
private static double X(int lane) => lane * LaneWidth + LaneWidth / 2;
private static Pen Pen(int colorIndex) => Pens[((colorIndex % Pens.Length) + Pens.Length) % Pens.Length];
private static Brush Brush(int colorIndex) => Brushes[((colorIndex % Brushes.Length) + Brushes.Length) % Brushes.Length];
}
}

View File

@ -0,0 +1,124 @@
using System;
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;
using MergeHub.Models;
namespace MergeHub.Views.Controls
{
/// <summary>
/// 줄 배경색의 단일 정의. 코드 패널과 범례(색 구분 안내)가 같은 값을 쓰도록 여기 한 곳에 둔다.
/// 알파를 낮게 잡아 밝은 테마·어두운 테마 어디에 얹어도 글자가 묻히지 않게 했다.
/// </summary>
public static class LineHighlightBrushes
{
/// <summary>BASE 와 다름 (아직 반영 안 함) - 주황.</summary>
public static readonly Brush Changed = Freeze("#33E0A020");
/// <summary>BASE 를 반영함 - 초록.</summary>
public static readonly Brush Applied = Freeze("#332EA043");
/// <summary>내가 직접 고침 - 보라. 반영과 확실히 구분되어야 한다.</summary>
public static readonly Brush Edited = Freeze("#339B59B6");
/// <summary>LOCAL 에만 있는 줄(추가) - 파랑.</summary>
public static readonly Brush Added = Freeze("#332E86DE");
/// <summary>BASE 에만 있는 줄(삭제) - 빨강.</summary>
public static readonly Brush Removed = Freeze("#33D14B4B");
/// <summary>지운 것이 아니라 자리만 옮긴 줄 - 청록. 삭제/추가와 헷갈리면 안 되므로 계열을 달리했다.</summary>
public static readonly Brush Moved = Freeze("#3300A0A8");
/// <summary>무시 규칙에 걸린 줄 - 아주 옅은 회색. "차이 없음"과 구분은 되되 눈에 띄지 않아야 한다.</summary>
public static readonly Brush Ignored = Freeze("#18808080");
/// <summary>3-Way: 저쪽만 고친 줄 - 남색. 내가 고친 것(파랑 계열)과 계열은 같되 더 짙다.</summary>
public static readonly Brush RemoteChange = Freeze("#333B5BA5");
/// <summary>3-Way: 양쪽이 다르게 고친 줄(충돌) - 진한 자홍. 가장 눈에 띄어야 한다.</summary>
public static readonly Brush Conflict = Freeze("#4DE0347B");
public static readonly Brush None = Brushes.Transparent;
public static Brush For(LineHighlight highlight) => highlight switch
{
LineHighlight.Changed => Changed,
LineHighlight.Applied => Applied,
LineHighlight.Edited => Edited,
LineHighlight.Added => Added,
LineHighlight.Removed => Removed,
LineHighlight.Moved => Moved,
LineHighlight.Ignored => Ignored,
LineHighlight.RemoteChange => RemoteChange,
LineHighlight.Conflict => Conflict,
_ => None
};
private static Brush Freeze(string hex)
{
var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex));
brush.Freeze();
return brush;
}
}
/// <summary>MergeLine.Highlight -> 배경 Brush.</summary>
public sealed class LineHighlightBrushConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
LineHighlightBrushes.For(value is LineHighlight h ? h : LineHighlight.None);
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
/// <summary>범례에서 쓰는 이름 기반 조회. ConverterParameter 로 "Changed" 같은 이름을 넘긴다.</summary>
public sealed class HighlightNameToBrushConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var name = parameter as string ?? value as string ?? string.Empty;
return Enum.TryParse<LineHighlight>(name, out var highlight)
? LineHighlightBrushes.For(highlight)
: LineHighlightBrushes.None;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
/// <summary>
/// 폴더 비교 목록의 줄 배경. 코드 패널과 같은 색 언어를 쓴다 -
/// 다름=주황, 왼쪽만=빨강(없어짐), 오른쪽만=파랑(새로 생김), 이진=청록.
/// </summary>
public sealed class FolderStatusBrushConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
value is Services.FolderEntryStatus status
? status switch
{
Services.FolderEntryStatus.Different => LineHighlightBrushes.Changed,
Services.FolderEntryStatus.Binary => LineHighlightBrushes.Moved,
Services.FolderEntryStatus.LeftOnly => LineHighlightBrushes.Removed,
Services.FolderEntryStatus.RightOnly => LineHighlightBrushes.Added,
_ => LineHighlightBrushes.None
}
: LineHighlightBrushes.None;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
/// <summary>빈 문자열이면 숨긴다. 상태바에서 켠 규칙이 없을 때 구분선까지 감추려고 쓴다.</summary>
public sealed class TextToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
string.IsNullOrWhiteSpace(value as string)
? System.Windows.Visibility.Collapsed
: System.Windows.Visibility.Visible;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
}

View File

@ -0,0 +1,219 @@
<UserControl x:Class="MergeHub.Views.FolderView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:MergeHub.ViewModels"
xmlns:ctl="clr-namespace:MergeHub.Views.Controls">
<UserControl.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="/MergeHub;component/Views/Controls/AppStyles.xaml"/>
</ResourceDictionary.MergedDictionaries>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
<ctl:FolderStatusBrushConverter x:Key="StatusBrush"/>
<!-- 파일 비교 화면과 같은 글자 스타일. 두 화면이 따로 노는 것처럼 보이지 않게 값을 맞춰 둔다. -->
<Style x:Key="Muted" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Muted}"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<!--
트리 한 줄. 이름 / 상태 / 왼쪽 정보 / 오른쪽 정보 를 고정 폭으로 늘어놓아
깊이가 달라도 상태 칸이 같은 자리에 오게 한다.
-->
<HierarchicalDataTemplate x:Key="NodeTemplate" ItemsSource="{Binding Children}">
<!--
줄 배경을 오른쪽 끝까지 채운다.
TreeViewItem 의 기본 템플릿은 머리 부분을 Auto 폭으로 잡아서
HorizontalContentAlignment 를 Stretch 로 줘도 늘어나지 않는다.
템플릿을 통째로 갈아끼우면 플랫폼 테마(펼침 표시·선택 색)를 잃으므로,
배경 띠 자체에 트리 폭만큼 최소 폭을 주고 넘치는 부분은 잘리게 둔다.
-->
<Border Padding="2,1" Background="{Binding Status, Converter={StaticResource StatusBrush}}"
MinWidth="{Binding ActualWidth, RelativeSource={RelativeSource AncestorType=TreeView}}">
<!--
칸 폭을 고정한다. TreeView 는 깊이마다 왼쪽으로 들여쓰기 때문에 star 폭을 쓰면
줄마다 상태 칸이 다른 자리에 오고 머리글과도 어긋난다. 고정 폭이면
들여쓰기만큼(한 단계 약 19px)만 밀려서 눈으로 읽을 수 있다.
-->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="420"/>
<ColumnDefinition Width="90"/>
<ColumnDefinition Width="190"/>
<ColumnDefinition Width="190"/>
<!-- 남는 폭을 먹는 빈 칸. 이게 없으면 줄 배경이 글자 끝에서 잘려
넓은 화면에서 색 띠가 중간에 끊겨 보인다. -->
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal">
<TextBlock Text="📁" FontSize="11" Margin="0,0,4,0"
Visibility="{Binding IsDirectory, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="{Binding Name}" FontFamily="Consolas" FontSize="12"
ToolTip="{Binding RelativePath}"/>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding StatusText}" FontSize="11" FontWeight="Bold"/>
<TextBlock Grid.Column="2" Text="{Binding LeftInfo}" FontSize="11"
Style="{StaticResource Muted}"/>
<TextBlock Grid.Column="3" Text="{Binding RightInfo}" FontSize="11"
Style="{StaticResource Muted}"/>
</Grid>
</Border>
</HierarchicalDataTemplate>
<Style x:Key="NodeItemStyle" TargetType="TreeViewItem">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}"/>
<Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}"/>
<!-- 줄 배경이 오른쪽 끝까지 가도록 항목을 가로로 늘린다. -->
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
</Style>
</ResourceDictionary>
</UserControl.Resources>
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- ① 폴더 두 개 -->
<Border Grid.Row="0" Padding="8" BorderThickness="1" Margin="0,0,0,6"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Button Grid.Column="0" Content="왼쪽 폴더..." Command="{Binding PickLeftFolderCommand}"
Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0"/>
<TextBox Grid.Column="1" Text="{Binding LeftFolder, UpdateSourceTrigger=PropertyChanged}"
FontFamily="Consolas" FontSize="11" Height="28" VerticalContentAlignment="Center"
Margin="0,0,14,0" ToolTip="비교 기준이 되는 폴더"/>
<Button Grid.Column="2" Content="오른쪽 폴더..." Command="{Binding PickRightFolderCommand}"
Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0"/>
<TextBox Grid.Column="3" Text="{Binding RightFolder, UpdateSourceTrigger=PropertyChanged}"
FontFamily="Consolas" FontSize="11" Height="28" VerticalContentAlignment="Center"
ToolTip="견줄 폴더"/>
</Grid>
</Border>
<!-- ② 비교 방식 · 필터 -->
<Border Grid.Row="1" Padding="8" BorderThickness="1" Margin="0,0,0,6"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<WrapPanel Orientation="Horizontal">
<CheckBox Content="빠른 비교 (크기·수정일만)" IsChecked="{Binding QuickCompare}"
VerticalAlignment="Center" Margin="0,0,16,0"
ToolTip="내용을 읽지 않습니다. 파일이 아주 많거나 네트워크 드라이브일 때 씁니다"/>
<CheckBox Content="하위 폴더 포함" IsChecked="{Binding IncludeSubfolders}"
VerticalAlignment="Center" Margin="0,0,16,0"/>
<CheckBox Content="같은 파일도 보기" IsChecked="{Binding ShowSame}"
VerticalAlignment="Center" Margin="0,0,16,0"
ToolTip="꺼 두면 다른 것만 남깁니다"/>
<TextBlock Text="포함" VerticalAlignment="Center" Style="{StaticResource Muted}"
FontSize="11" Margin="0,0,4,0"/>
<TextBox Text="{Binding IncludePattern, UpdateSourceTrigger=PropertyChanged}"
Width="150" Height="26" FontFamily="Consolas" FontSize="11"
VerticalContentAlignment="Center" Margin="0,0,12,0"
ToolTip="정규식. 비우면 전부 포함합니다 (예: \.cs$)"/>
<TextBlock Text="제외" VerticalAlignment="Center" Style="{StaticResource Muted}"
FontSize="11" Margin="0,0,4,0"/>
<TextBox Text="{Binding ExcludePattern, UpdateSourceTrigger=PropertyChanged}"
Width="260" Height="26" FontFamily="Consolas" FontSize="11"
VerticalContentAlignment="Center" Margin="0,0,16,0"
ToolTip="정규식. 포함 규칙보다 우선합니다"/>
<Button Content="비교" Command="{Binding CompareCommand}" Height="28" MinWidth="80"
Style="{StaticResource FilledButton}"
Background="{DynamicResource Ubisam.Brush.Accent.Fill}"
Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}" FontWeight="Bold"/>
</WrapPanel>
</Border>
<!-- ③ 목록 머리글. 트리 항목의 칸 폭과 같은 값을 쓴다. -->
<Border Grid.Row="2" Padding="8,4" BorderThickness="1,1,1,0"
Background="{DynamicResource Ubisam.Brush.Sidebar.Background}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<Grid Margin="26,0,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="420"/>
<ColumnDefinition Width="90"/>
<ColumnDefinition Width="190"/>
<ColumnDefinition Width="190"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="이름" FontSize="11" FontWeight="Bold"/>
<TextBlock Grid.Column="1" Text="상태" FontSize="11" FontWeight="Bold"/>
<TextBlock Grid.Column="2" Text="왼쪽 (크기 · 수정일)" FontSize="11" FontWeight="Bold"/>
<TextBlock Grid.Column="3" Text="오른쪽 (크기 · 수정일)" FontSize="11" FontWeight="Bold"/>
</Grid>
</Border>
<!--
④ 트리.
가로 스크롤을 켜 두면 항목이 "보이는 폭"이 아니라 "내용 폭"으로 측정돼
채움 칸(*)이 0 이 되고 줄 배경이 글자 끝에서 끊긴다. 칸 폭이 고정이라
가로로 넘칠 일이 없으므로 꺼 두는 편이 맞다.
-->
<TreeView Grid.Row="3" x:Name="Tree" BorderThickness="1"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
ItemsSource="{Binding Nodes}"
ItemTemplate="{StaticResource NodeTemplate}"
ItemContainerStyle="{StaticResource NodeItemStyle}"
SelectedItemChanged="Tree_SelectedItemChanged"
MouseDoubleClick="Tree_MouseDoubleClick"
KeyDown="Tree_KeyDown"
HorizontalContentAlignment="Stretch"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"/>
<!-- ⑤ 요약 · 동작 -->
<Border Grid.Row="4" Padding="8" BorderThickness="1,0,1,1" Margin="0,0,0,0"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" VerticalAlignment="Center">
<TextBlock Text="{Binding Summary}" FontSize="12" FontWeight="Bold"/>
<TextBlock Text="파일을 더블클릭하거나 Enter 를 누르면 &quot;파일 비교&quot; 탭에서 줄 단위로 엽니다. 한쪽에만 있는 파일도 열립니다."
Style="{StaticResource Muted}" FontSize="11"/>
</StackPanel>
<WrapPanel Grid.Column="1" Orientation="Horizontal">
<Button Content="줄 단위 비교" Command="{Binding OpenInDiffCommand}"
Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0"/>
<Button Content="→ 오른쪽으로 복사" Command="{Binding CopyToRightCommand}"
Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0"
ToolTip="왼쪽 파일로 오른쪽을 덮어씁니다 (확인을 묻습니다)"/>
<Button Content="← 왼쪽으로 복사" Command="{Binding CopyToLeftCommand}"
Height="28" Padding="10,2" FontSize="11" Margin="0,0,14,0"
ToolTip="오른쪽 파일로 왼쪽을 덮어씁니다 (확인을 묻습니다)"/>
<Button Content="왼쪽 삭제" Command="{Binding DeleteLeftCommand}"
Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0"
Foreground="{DynamicResource Ubisam.Brush.Danger}"
ToolTip="되돌릴 수 없습니다 (확인을 묻습니다)"/>
<Button Content="오른쪽 삭제" Command="{Binding DeleteRightCommand}"
Height="28" Padding="10,2" FontSize="11"
Foreground="{DynamicResource Ubisam.Brush.Danger}"
ToolTip="되돌릴 수 없습니다 (확인을 묻습니다)"/>
</WrapPanel>
</Grid>
</Border>
</Grid>
</UserControl>

View File

@ -0,0 +1,69 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using MergeHub.ViewModels;
namespace MergeHub.Views
{
/// <summary>
/// 폴더 비교 탭. 트리 자체는 XAML 이 다 하고, 여기서는 TreeView 가
/// 바인딩으로 못 넘겨 주는 것(선택 항목·더블클릭·Enter)만 이어 준다.
/// </summary>
public partial class FolderView : UserControl
{
public FolderView()
{
InitializeComponent();
}
/// <summary>TreeView.SelectedItem 은 읽기 전용이라 바인딩할 수 없다. 여기서 옮겨 준다.</summary>
private void Tree_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e)
{
if (DataContext is FolderViewModel vm) vm.SelectedNode = e.NewValue as FolderNode;
}
/// <summary>
/// 항목을 더블클릭하면 그 파일을 "파일 비교" 탭에서 연다.
///
/// 클릭한 자리에서 항목을 직접 집는다 - TreeView 의 선택은 첫 클릭에 따라오지만,
/// 펼침 화살표처럼 선택을 바꾸지 않는 자리를 두 번 누르면 <b>엉뚱한 항목</b>이 열린다.
/// </summary>
private void Tree_MouseDoubleClick(object sender, MouseButtonEventArgs e)
{
if (DataContext is not FolderViewModel vm) return;
if (NodeUnder(e.OriginalSource as DependencyObject) is not { } node) return;
// 폴더는 펼치고 접는 것이 기본 동작이다. 그대로 둔다.
if (node.IsDirectory) return;
vm.SelectedNode = node;
vm.OpenInDiffCore(node);
e.Handled = true;
}
/// <summary>Enter 로도 연다. 목록을 키보드로 훑는 사람에게는 이쪽이 빠르다.</summary>
private void Tree_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key != Key.Enter) return;
if (DataContext is not FolderViewModel vm || vm.SelectedNode is not { } node) return;
if (node.IsDirectory) return;
vm.OpenInDiffCore(node);
e.Handled = true;
}
/// <summary>클릭이 떨어진 시각 요소에서 위로 올라가며 그 줄의 항목을 찾는다.</summary>
private static FolderNode? NodeUnder(DependencyObject? source)
{
while (source is not null)
{
if (source is TreeViewItem item) return item.DataContext as FolderNode;
if (source is FrameworkElement { DataContext: FolderNode node }) return node;
source = System.Windows.Media.VisualTreeHelper.GetParent(source);
}
return null;
}
}
}

View File

@ -0,0 +1,419 @@
<UserControl x:Class="MergeHub.Views.GitView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:MergeHub.Views"
mc:Ignorable="d">
<UserControl.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="/MergeHub;component/Views/Controls/AppStyles.xaml"/>
</ResourceDictionary.MergedDictionaries>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
<Style TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Margin" Value="2"/>
<Setter Property="MinWidth" Value="76"/>
</Style>
<Style x:Key="Muted" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Muted}"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<Style x:Key="GroupTitle" TargetType="TextBlock">
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Accent}"/>
<Setter Property="Margin" Value="0,0,0,4"/>
</Style>
<Style x:Key="Code" TargetType="TextBox" BasedOn="{StaticResource {x:Type TextBox}}">
<Setter Property="FontFamily" Value="Consolas"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="IsReadOnly" Value="True"/>
<Setter Property="AcceptsReturn" Value="True"/>
<Setter Property="TextWrapping" Value="NoWrap"/>
<Setter Property="VerticalScrollBarVisibility" Value="Auto"/>
<Setter Property="HorizontalScrollBarVisibility" Value="Auto"/>
<Setter Property="VerticalContentAlignment" Value="Top"/>
</Style>
</ResourceDictionary>
</UserControl.Resources>
<Grid Margin="8" TextElement.Foreground="{DynamicResource Ubisam.Brush.ContentForeground}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="120"/>
</Grid.RowDefinitions>
<!-- ===== 저장소 연결 ===== -->
<Border Grid.Row="0" Padding="10,8" Margin="0,0,0,6"
Background="{DynamicResource Ubisam.Brush.Input.Background}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}" BorderThickness="1">
<StackPanel>
<TextBlock Text="저장소 연결" Style="{StaticResource GroupTitle}"/>
<Grid Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="로컬 경로" Style="{StaticResource Muted}" Margin="0,0,6,0"/>
<TextBox Grid.Column="1" Text="{Binding Workspace.RepositoryPath, UpdateSourceTrigger=PropertyChanged}"/>
<Button Grid.Column="2" Content="폴더..." Click="BrowseFolder_Click"/>
<Button Grid.Column="3" Content="열기" Command="{Binding OpenCommand}"/>
</Grid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="110"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="원격 URL" Style="{StaticResource Muted}" Margin="0,0,6,0"/>
<TextBox Grid.Column="1" Text="{Binding Workspace.RemoteUrl, UpdateSourceTrigger=PropertyChanged}"
ToolTip="예) https://gitea.내부도메인/team/repo.git"/>
<TextBlock Grid.Column="2" Text="이름" Style="{StaticResource Muted}" Margin="8,0,6,0"/>
<TextBox Grid.Column="3" Text="{Binding Workspace.RemoteName, UpdateSourceTrigger=PropertyChanged}"/>
<Button Grid.Column="4" Content="클론" Command="{Binding CloneCommand}"/>
<Button Grid.Column="5" Content="원격 추가" Command="{Binding AddRemoteCommand}"
ToolTip="다른 저장소를 원격으로 등록하고 바로 fetch"/>
<Button Grid.Column="6" Content="원격 삭제" Command="{Binding RemoveRemoteCommand}"/>
</Grid>
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="150"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="150"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Gitea 계정" Style="{StaticResource Muted}" Margin="0,0,6,0"/>
<TextBox Grid.Column="1" Text="{Binding Workspace.UserName, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Grid.Column="2" Text="토큰/암호" Style="{StaticResource Muted}" Margin="8,0,6,0"/>
<PasswordBox Grid.Column="3" PasswordChanged="Password_Changed"
Background="{DynamicResource Ubisam.Brush.Input.Background}"
Foreground="{DynamicResource Ubisam.Brush.ContentForeground}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}" Padding="4,3"/>
<StackPanel Grid.Column="4" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="Fetch" Command="{Binding FetchCommand}"/>
<Button Content="Pull" Command="{Binding PullCommand}"/>
<Button Content="Push" Command="{Binding PushCommand}"/>
<Button Content="새로고침" Command="{Binding RefreshCommand}"/>
</StackPanel>
</Grid>
</StackPanel>
</Border>
<!-- ===== 상태 배지 ===== -->
<WrapPanel Grid.Row="1" Margin="2,0,0,6" ItemHeight="24">
<TextBlock Text="HEAD" Style="{StaticResource Muted}" Margin="0,0,6,0"/>
<TextBlock Text="{Binding Workspace.CurrentBranch}" FontWeight="Bold" Margin="0,0,6,0"
Foreground="{DynamicResource Ubisam.Brush.Accent}" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Workspace.HeadSha}" FontFamily="Consolas" Margin="0,0,16,0"
Foreground="{DynamicResource Ubisam.Brush.Warning}" VerticalAlignment="Center"/>
<TextBlock Text="Working Tree" Style="{StaticResource Muted}" Margin="0,0,6,0"/>
<TextBlock Text="{Binding Workspace.WorkingTreeSummary}" Margin="0,0,16,0" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Workspace.PendingOperationText}" FontWeight="Bold" Margin="0,0,16,0"
Foreground="{DynamicResource Ubisam.Brush.Danger}" VerticalAlignment="Center"/>
<Button Content="작업 취소" Command="{Binding AbortCommand}"
ToolTip="진행 중인 merge / cherry-pick / revert / rebase 를 취소"/>
<Button Content="mergetool 등록" Command="{Binding RegisterMergeToolCommand}" MinWidth="110"
ToolTip="git config 의 merge.tool 로 이 프로그램을 등록 (전역 .gitconfig 수정)"/>
</WrapPanel>
<!-- ===== 작업 영역 ===== -->
<TabControl Grid.Row="2" Background="Transparent"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<!-- 브랜치 / 병합 -->
<TabItem Header="브랜치 · 병합">
<Grid Margin="6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="260"/>
</Grid.ColumnDefinitions>
<ListBox Grid.Column="0" ItemsSource="{Binding Workspace.Branches}"
SelectedItem="{Binding SelectedBranch}" HorizontalContentAlignment="Stretch">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid Margin="0,2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="★ " FontSize="11"
Foreground="{DynamicResource Ubisam.Brush.Accent}"
Visibility="{Binding IsCurrentHead, Converter={StaticResource BoolToVis}}"/>
<StackPanel Grid.Column="1">
<TextBlock Text="{Binding FriendlyName}"/>
<TextBlock Text="{Binding TipMessageShort}" FontSize="11"
Foreground="{DynamicResource Ubisam.Brush.Muted}"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal">
<TextBlock Text="↑" FontSize="11" Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
<TextBlock Text="{Binding AheadBy}" FontSize="11" Margin="0,0,6,0"
Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
<TextBlock Text="↓" FontSize="11" Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
<TextBlock Text="{Binding BehindBy}" FontSize="11"
Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Grid.Column="1" Margin="8,0,0,0">
<TextBlock Text="선택 브랜치" Style="{StaticResource GroupTitle}"/>
<Button Content="체크아웃" Command="{Binding CheckoutCommand}" HorizontalAlignment="Stretch"/>
<Button Content="Merge → 현재 브랜치로" Command="{Binding MergeCommand}"
HorizontalAlignment="Stretch" MinHeight="32"
Style="{StaticResource FilledButton}"
Background="{DynamicResource Ubisam.Brush.Danger.Fill}"
Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}"/>
<Button Content="브랜치 삭제" Command="{Binding DeleteBranchCommand}" HorizontalAlignment="Stretch"/>
<TextBlock Text="새 브랜치" Style="{StaticResource GroupTitle}" Margin="0,12,0,4"/>
<TextBox Text="{Binding NewBranchName, UpdateSourceTrigger=PropertyChanged}"
ToolTip="선택 커밋에서 분기. 선택이 없으면 HEAD"/>
<Button Content="생성 + 체크아웃" Command="{Binding CreateBranchCommand}"
HorizontalAlignment="Stretch" Margin="2,4,2,0"/>
<TextBlock Text="rebase" Style="{StaticResource GroupTitle}" Margin="0,12,0,4"/>
<Button Content="선택 브랜치 위로 rebase" Command="{Binding RebaseCommand}" HorizontalAlignment="Stretch"/>
<UniformGrid Columns="2">
<Button Content="계속" Command="{Binding RebaseContinueCommand}"/>
<Button Content="중단" Command="{Binding RebaseAbortCommand}"/>
</UniformGrid>
</StackPanel>
</Grid>
</TabItem>
<!-- 충돌 -->
<TabItem Header="충돌">
<Grid Margin="6">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Style="{StaticResource Muted}" Margin="0,0,0,6" TextWrapping="Wrap"
Text="파일 전체를 한쪽으로 채택하려면 아래 버튼을 쓰고, 줄 단위로 고르려면 홈 탭의 3분할 화면에서 처리하세요."/>
<ListBox Grid.Row="1" ItemsSource="{Binding Workspace.Conflicts}"
SelectedItem="{Binding SelectedConflict}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Path}"/>
<TextBlock Text=" (binary)" FontSize="11"
Foreground="{DynamicResource Ubisam.Brush.Danger}"
Visibility="{Binding IsBinary, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text=" · 상대쪽 삭제" FontSize="11"
Foreground="{DynamicResource Ubisam.Brush.Warning}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding HasTheirs}" Value="False">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<WrapPanel Grid.Row="2" Margin="0,6,0,0">
<Button Content="Accept Mine" Command="{Binding AcceptMineCommand}" MinWidth="100" Style="{StaticResource FilledButton}"
Background="{DynamicResource Ubisam.Brush.Success}"
Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}"/>
<Button Content="Accept Theirs" Command="{Binding AcceptTheirsCommand}" MinWidth="100" Style="{StaticResource FilledButton}"
Background="{DynamicResource Ubisam.Brush.Accent.Fill}"
Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}"/>
<Button Content="Accept Both" Command="{Binding AcceptBothCommand}" MinWidth="100" Style="{StaticResource FilledButton}"
Background="{DynamicResource Ubisam.Brush.Warning}"
Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}"/>
<Button Content="병합 커밋" Command="{Binding CommitMergeCommand}" MinWidth="100"/>
</WrapPanel>
</Grid>
</TabItem>
<!-- 변경 / 커밋 -->
<TabItem Header="변경 · 커밋">
<Grid Margin="6">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<ListBox Grid.Row="0" ItemsSource="{Binding Workspace.StatusFiles}"
SelectedItem="{Binding SelectedStatusFile}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding StagedState}" Width="80" FontSize="11"
Foreground="{DynamicResource Ubisam.Brush.Success}"/>
<TextBlock Text="{Binding WorkingDirState}" Width="80" FontSize="11"
Foreground="{DynamicResource Ubisam.Brush.Warning}"/>
<TextBlock Text="{Binding Path}" FontSize="11"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Grid.Row="1" Margin="0,6,0,0">
<WrapPanel>
<Button Content="스테이징" Command="{Binding StageSelectedCommand}"/>
<Button Content="언스테이징" Command="{Binding UnstageSelectedCommand}"/>
<Button Content="변경 취소" Command="{Binding RestoreSelectedCommand}"
ToolTip="git checkout -- &lt;file&gt;"/>
<Button Content="전체 스테이징" Command="{Binding StageAllCommand}" MinWidth="96"/>
<Button Content="stash 저장" Command="{Binding StashSaveCommand}"/>
<Button Content="stash 적용" Command="{Binding StashPopCommand}"/>
</WrapPanel>
<Grid Margin="0,6,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="커밋 메시지" Style="{StaticResource Muted}" Margin="0,0,6,0"/>
<TextBox Grid.Column="1" Text="{Binding CommitMessage, UpdateSourceTrigger=PropertyChanged}"/>
<Button Grid.Column="2" Content="커밋" Command="{Binding CommitCommand}"/>
</Grid>
</StackPanel>
</Grid>
</TabItem>
<!-- 이력 -->
<TabItem Header="이력">
<Grid Margin="6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="300"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<ListBox Grid.Row="0" Grid.Column="0" ItemsSource="{Binding Workspace.Commits}"
SelectedItem="{Binding SelectedCommit}" HorizontalContentAlignment="Stretch">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Margin="0,2">
<TextBlock Text="{Binding MessageShort}" TextTrimming="CharacterEllipsis"/>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding ShortSha}" FontFamily="Consolas" FontSize="11"
Foreground="{DynamicResource Ubisam.Brush.Warning}"/>
<TextBlock Text=" · " FontSize="11" Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
<TextBlock Text="{Binding AuthorName}" FontSize="11"
Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
</StackPanel>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBox Grid.Row="0" Grid.Column="1" Style="{StaticResource Code}" Margin="6,0,0,0"
Text="{Binding DiffText, Mode=OneWay}"/>
<WrapPanel Grid.Row="1" Grid.ColumnSpan="2" Margin="0,6,0,0">
<TextBlock Text="선택 커밋 →" Style="{StaticResource Muted}" Margin="0,0,6,0"/>
<Button Content="Revert" Command="{Binding RevertCommand}"
ToolTip="선택 커밋의 변경을 되돌리는 커밋 생성"/>
<Button Content="Cherry-pick" Command="{Binding CherryPickCommand}" MinWidth="90"
ToolTip="선택 커밋만 현재 브랜치로 가져오기"/>
<ComboBox ItemsSource="{Binding ResetKinds}" SelectedItem="{Binding SelectedResetKind}"
Width="80" Margin="8,2,2,2"/>
<Button Content="Reset" Command="{Binding ResetCommand}"
ToolTip="선택 커밋으로 HEAD 이동"/>
</WrapPanel>
</Grid>
</TabItem>
<!-- 태그 / 서브모듈 -->
<TabItem Header="태그 · 서브모듈">
<Grid Margin="6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="태그" Style="{StaticResource GroupTitle}"/>
<ListBox Grid.Row="1" Grid.Column="0" Margin="0,0,6,0"
ItemsSource="{Binding Workspace.Tags}" SelectedItem="{Binding SelectedTag}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Name}" FontWeight="Bold"/>
<TextBlock Text=" · " Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
<TextBlock Text="{Binding Message}" FontSize="11"
Foreground="{DynamicResource Ubisam.Brush.Muted}"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Grid.Row="2" Grid.Column="0" Margin="0,6,6,0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="110"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0" Text="{Binding TagName, UpdateSourceTrigger=PropertyChanged}"
ToolTip="예) v1.0.0"/>
<TextBox Grid.Column="1" Margin="4,0,0,0"
Text="{Binding TagMessage, UpdateSourceTrigger=PropertyChanged}"
ToolTip="메시지를 넣으면 annotated tag"/>
<Button Grid.Column="2" Content="생성" Command="{Binding CreateTagCommand}"/>
</Grid>
<Button Content="선택 태그 삭제" Command="{Binding DeleteTagCommand}" HorizontalAlignment="Left" Margin="2,4,2,0"/>
</StackPanel>
<TextBlock Grid.Row="0" Grid.Column="1" Text="서브모듈" Style="{StaticResource GroupTitle}"/>
<ListBox Grid.Row="1" Grid.Column="1" ItemsSource="{Binding Workspace.Submodules}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel>
<TextBlock Text="{Binding Path}"/>
<TextBlock Text="{Binding Status}" FontSize="11"
Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Button Grid.Row="2" Grid.Column="1" Content="서브모듈 업데이트 (--init)"
Command="{Binding UpdateSubmodulesCommand}" HorizontalAlignment="Left" Margin="2,6,2,0"
MinWidth="180"/>
</Grid>
</TabItem>
</TabControl>
<!-- ===== 로그 ===== -->
<TextBox Grid.Row="3" Style="{StaticResource Code}" Margin="0,6,0,0"
Text="{Binding Workspace.Log, Mode=OneWay}" TextChanged="Log_TextChanged"/>
</Grid>
</UserControl>

View File

@ -0,0 +1,51 @@
using System.Windows;
using System.Windows.Controls;
using MergeHub.ViewModels;
namespace MergeHub.Views
{
/// <summary>
/// Git 탭. DataContext(GitViewModel)는 플랫폼 TabManager 가 DI 로 만들어 붙여준다.
/// </summary>
public partial class GitView : UserControl
{
public GitView()
{
InitializeComponent();
}
private GitViewModel? ViewModel => DataContext as GitViewModel;
private void BrowseFolder_Click(object sender, RoutedEventArgs e)
{
var vm = ViewModel;
if (vm is null) return;
// net48 WPF 에는 OpenFolderDialog 가 없어 WinForms 대화상자를 쓴다.
using var dialog = new System.Windows.Forms.FolderBrowserDialog
{
Description = "Git 저장소 폴더 선택",
ShowNewFolderButton = false
};
if (!string.IsNullOrWhiteSpace(vm.Workspace.RepositoryPath))
dialog.SelectedPath = vm.Workspace.RepositoryPath;
if (dialog.ShowDialog() != System.Windows.Forms.DialogResult.OK) return;
vm.Workspace.RepositoryPath = dialog.SelectedPath;
if (vm.OpenCommand.CanExecute(null)) vm.OpenCommand.Execute(null);
}
// PasswordBox 는 보안상 Password 를 바인딩할 수 없어 코드 비하인드로 전달한다.
private void Password_Changed(object sender, RoutedEventArgs e)
{
if (sender is PasswordBox box && ViewModel is { } vm) vm.Workspace.Password = box.Password;
}
private void Log_TextChanged(object sender, TextChangedEventArgs e)
{
if (sender is TextBox box) box.ScrollToEnd();
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,105 @@
<Window x:Class="MergeHub.Views.ShortcutHelpWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Width="560" Height="620"
WindowStyle="None"
ResizeMode="NoResize"
ShowInTaskbar="False"
Background="{DynamicResource Ubisam.Brush.ContentBackground}">
<!-- 별도 창이라 셸의 리소스를 자동 상속받지 못한다 - 플랫폼 대화상자들과 같은 패턴. -->
<Window.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/UbisamBase.Core;component/Themes/ShellStyles.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Window.Resources>
<Border BorderBrush="{DynamicResource Ubisam.Brush.SubTabBar.Border}" BorderThickness="1">
<DockPanel TextElement.Foreground="{DynamicResource Ubisam.Brush.ContentForeground}">
<Border x:Name="TitleBar" DockPanel.Dock="Top"
Background="{DynamicResource Ubisam.Brush.Sidebar.Background}"
BorderBrush="{DynamicResource Ubisam.Brush.SubTabBar.Border}"
BorderThickness="0,0,0,1" Padding="16,10"
MouseLeftButtonDown="TitleBar_MouseLeftButtonDown">
<DockPanel>
<Button DockPanel.Dock="Right" Content="✕" Click="Close_Click"
Background="Transparent" BorderThickness="0" Padding="8,0"
MinHeight="20" FontSize="13"
Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
<TextBlock Text="단축키" FontWeight="SemiBold"
Foreground="{DynamicResource Ubisam.Brush.ContentForeground}"/>
</DockPanel>
</Border>
<Border DockPanel.Dock="Bottom" Padding="16,12"
BorderBrush="{DynamicResource Ubisam.Brush.SubTabBar.Border}" BorderThickness="0,1,0,0">
<DockPanel>
<Button DockPanel.Dock="Right" Content="닫기" MinWidth="84" Padding="14,7"
Style="{StaticResource Ubisam.Style.Button.Primary}"
IsDefault="True" IsCancel="True" Click="Close_Click"/>
<TextBlock VerticalAlignment="Center" FontSize="11.5"
Foreground="{DynamicResource Ubisam.Brush.Muted}"
Text="이 목록은 실제 키 처리와 같은 표에서 나옵니다."/>
</DockPanel>
</Border>
<!-- 묶음 → 키 두 겹. 묶음 이름과 순서 모두 ShortcutMap 이 정한 그대로다. -->
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="16,12">
<ItemsControl x:Name="GroupList">
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Margin="0,0,0,14">
<TextBlock Text="{Binding Key}" FontWeight="Bold" FontSize="12.5"
Foreground="{DynamicResource Ubisam.Brush.Accent}"
Margin="0,0,0,5"/>
<ItemsControl ItemsSource="{Binding}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Margin="0,2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="132"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" CornerRadius="3" Padding="6,2"
Margin="0,0,10,0" HorizontalAlignment="Left"
VerticalAlignment="Top"
Background="{DynamicResource Ubisam.Brush.Input.Background}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
BorderThickness="1">
<TextBlock Text="{Binding Gesture}" FontFamily="Consolas"
FontSize="11.5" FontWeight="Bold"/>
</Border>
<StackPanel Grid.Column="1">
<TextBlock Text="{Binding What}" FontSize="12"
TextWrapping="Wrap" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Note}" FontSize="11" TextWrapping="Wrap" Margin="0,2,0,0">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Muted}"/>
<Style.Triggers>
<!-- 덧붙일 말이 없는 줄은 자리를 비운다 -->
<DataTrigger Binding="{Binding Note}" Value="{x:Null}">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Border>
</Window>

View File

@ -0,0 +1,54 @@
using System.Linq;
using System.Windows;
using System.Windows.Input;
using MergeHub.Services;
namespace MergeHub.Views
{
/// <summary>
/// 단축키 목록 창.
///
/// 목록을 여기에 다시 적지 않는다 - <see cref="ShortcutMap"/> 하나에서 읽어 온다.
/// 설명과 실제 키 처리가 갈라지면 설명이 곧 거짓말이 되기 때문이다.
/// </summary>
public partial class ShortcutHelpWindow : Window
{
public ShortcutHelpWindow()
{
InitializeComponent();
GroupList.ItemsSource = ShortcutMap.ByGroup().ToList();
if (Application.Current?.MainWindow is { IsLoaded: true } main && !ReferenceEquals(main, this))
{
Owner = main;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
}
else
{
WindowStartupLocation = WindowStartupLocation.CenterScreen;
}
}
private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) => DragMove();
private void Close_Click(object sender, RoutedEventArgs e) => Close();
/// <summary>이미 떠 있으면 앞으로 가져온다. 누를 때마다 창이 쌓이면 성가시다.</summary>
private static ShortcutHelpWindow? open;
public static void ShowOrActivate(Window? owner = null)
{
if (open is { IsLoaded: true })
{
open.Activate();
return;
}
open = new ShortcutHelpWindow();
if (owner is not null) open.Owner = owner;
open.Closed += (_, _) => open = null;
((Window)open).Show();
}
}
}