최초 커밋: 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:
commit
7719094edf
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal 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
5
MergeHub.slnx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/MergeHub/MergeHub.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
263
doc/작업지시-머지툴보강.md
Normal file
263
doc/작업지시-머지툴보강.md
Normal 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만 줄)로 바꿈 — 히스토그램은 표를 만들지 않으므로 예전보다 훨씬 큰 구간도 정확히 정렬한다. |
|
||||||
176
doc/작업지시-사용성보강.md
Normal file
176
doc/작업지시-사용성보강.md
Normal 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
59
src/MergeHub/AppSetup.cs
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/MergeHub/MergeHub.csproj
Normal file
76
src/MergeHub/MergeHub.csproj
Normal 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>
|
||||||
91
src/MergeHub/MergeHubSettings.cs
Normal file
91
src/MergeHub/MergeHubSettings.cs
Normal 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;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/MergeHub/Models/CodeProblemItem.cs
Normal file
24
src/MergeHub/Models/CodeProblemItem.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/MergeHub/Models/CommitGraph.cs
Normal file
62
src/MergeHub/Models/CommitGraph.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
273
src/MergeHub/Models/GitModels.cs
Normal file
273
src/MergeHub/Models/GitModels.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
439
src/MergeHub/Models/MergeLine.cs
Normal file
439
src/MergeHub/Models/MergeLine.cs
Normal 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>[>] 또는 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>
|
||||||
|
/// [>] 버튼 표시 조건. 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
230
src/MergeHub/Services/CodeSanityChecker.cs
Normal file
230
src/MergeHub/Services/CodeSanityChecker.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
181
src/MergeHub/Services/CodeTokenizer.cs
Normal file
181
src/MergeHub/Services/CodeTokenizer.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
133
src/MergeHub/Services/CommitGraphBuilder.cs
Normal file
133
src/MergeHub/Services/CommitGraphBuilder.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
166
src/MergeHub/Services/CompareRules.cs
Normal file
166
src/MergeHub/Services/CompareRules.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
105
src/MergeHub/Services/EditorLayout.cs
Normal file
105
src/MergeHub/Services/EditorLayout.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
361
src/MergeHub/Services/FolderComparer.cs
Normal file
361
src/MergeHub/Services/FolderComparer.cs
Normal 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>(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1665
src/MergeHub/Services/GitService.cs
Normal file
1665
src/MergeHub/Services/GitService.cs
Normal file
File diff suppressed because it is too large
Load Diff
394
src/MergeHub/Services/GitWorkspace.cs
Normal file
394
src/MergeHub/Services/GitWorkspace.cs
Normal 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 설정 > 플랫폼에 등록된 사용자 > git config user.name > 윈도우 계정 순으로 고른다.
|
||||||
|
///
|
||||||
|
/// 플랫폼 사용자를 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
171
src/MergeHub/Services/IGitService.cs
Normal file
171
src/MergeHub/Services/IGitService.cs
Normal 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 -- <paths>. 워킹트리 수정을 버리고 특정 커밋 상태로 되돌린다.</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 <upstream>. 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
237
src/MergeHub/Services/LanguageProfile.cs
Normal file
237
src/MergeHub/Services/LanguageProfile.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
310
src/MergeHub/Services/LineAligner.cs
Normal file
310
src/MergeHub/Services/LineAligner.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/MergeHub/Services/MergeNote.cs
Normal file
90
src/MergeHub/Services/MergeNote.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
197
src/MergeHub/Services/MoveDetector.cs
Normal file
197
src/MergeHub/Services/MoveDetector.cs
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
156
src/MergeHub/Services/ShortcutMap.cs
Normal file
156
src/MergeHub/Services/ShortcutMap.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
179
src/MergeHub/Services/TextFile.cs
Normal file
179
src/MergeHub/Services/TextFile.cs
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
253
src/MergeHub/Services/ThreeWayAligner.cs
Normal file
253
src/MergeHub/Services/ThreeWayAligner.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
376
src/MergeHub/ViewModels/FolderViewModel.cs
Normal file
376
src/MergeHub/ViewModels/FolderViewModel.cs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
443
src/MergeHub/ViewModels/GitViewModel.cs
Normal file
443
src/MergeHub/ViewModels/GitViewModel.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2067
src/MergeHub/ViewModels/HomeViewModel.cs
Normal file
2067
src/MergeHub/ViewModels/HomeViewModel.cs
Normal file
File diff suppressed because it is too large
Load Diff
50
src/MergeHub/Views/Controls/AppStyles.xaml
Normal file
50
src/MergeHub/Views/Controls/AppStyles.xaml
Normal 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>
|
||||||
315
src/MergeHub/Views/Controls/CSharpTokenizer.cs
Normal file
315
src/MergeHub/Views/Controls/CSharpTokenizer.cs
Normal 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>+ - = == && 같은 연산자.</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<int>(...) 의 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
169
src/MergeHub/Views/Controls/ChangeMiniMap.cs
Normal file
169
src/MergeHub/Views/Controls/ChangeMiniMap.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
270
src/MergeHub/Views/Controls/CodeTextBlock.cs
Normal file
270
src/MergeHub/Views/Controls/CodeTextBlock.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/MergeHub/Views/Controls/CommitGraphCell.cs
Normal file
121
src/MergeHub/Views/Controls/CommitGraphCell.cs
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
124
src/MergeHub/Views/Controls/LineHighlightBrushes.cs
Normal file
124
src/MergeHub/Views/Controls/LineHighlightBrushes.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
219
src/MergeHub/Views/FolderView.xaml
Normal file
219
src/MergeHub/Views/FolderView.xaml
Normal 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 를 누르면 "파일 비교" 탭에서 줄 단위로 엽니다. 한쪽에만 있는 파일도 열립니다."
|
||||||
|
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>
|
||||||
69
src/MergeHub/Views/FolderView.xaml.cs
Normal file
69
src/MergeHub/Views/FolderView.xaml.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
419
src/MergeHub/Views/GitView.xaml
Normal file
419
src/MergeHub/Views/GitView.xaml
Normal 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 -- <file>"/>
|
||||||
|
<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>
|
||||||
51
src/MergeHub/Views/GitView.xaml.cs
Normal file
51
src/MergeHub/Views/GitView.xaml.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1329
src/MergeHub/Views/HomeView.xaml
Normal file
1329
src/MergeHub/Views/HomeView.xaml
Normal file
File diff suppressed because it is too large
Load Diff
1031
src/MergeHub/Views/HomeView.xaml.cs
Normal file
1031
src/MergeHub/Views/HomeView.xaml.cs
Normal file
File diff suppressed because it is too large
Load Diff
105
src/MergeHub/Views/ShortcutHelpWindow.xaml
Normal file
105
src/MergeHub/Views/ShortcutHelpWindow.xaml
Normal 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>
|
||||||
54
src/MergeHub/Views/ShortcutHelpWindow.xaml.cs
Normal file
54
src/MergeHub/Views/ShortcutHelpWindow.xaml.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user