From 7719094edf616ecb6afc53a1d1449393f526d364 Mon Sep 17 00:00:00 2001 From: ksj09406 Date: Tue, 8 Sep 2026 10:13:27 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B5=9C=EC=B4=88=20=EC=BB=A4=EB=B0=8B:=20Merg?= =?UTF-8?q?eHub=20(=EC=82=AC=EB=82=B4=20Git=20Merge/Diff=20=EB=8F=84?= =?UTF-8?q?=EA=B5=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UbisamBase 플랫폼 위에서 도는 플러그인. 지금까지 만든 것을 그대로 담는다. - 폴더 비교 / 파일 비교(부분 수용·워드랩·찾기) / 3-Way 병합 / Git 조작 - 비교 엔진: histogram 정렬 · 이동 블록 감지 · 무시 규칙 · 13개 언어 토크나이저 - doc/ 에 작업 지시서와 진행 기록 빌드 산출물(bin/obj)은 추적하지 않는다 - 플랫폼 런타임이 빌드 때마다 D:\UbisamPlatform\bin 에서 복사되어 들어오므로 저장소에 담을 이유가 없다. Co-Authored-By: Claude Opus 5 --- .gitignore | 15 + MergeHub.slnx | 5 + doc/작업지시-머지툴보강.md | 263 +++ doc/작업지시-사용성보강.md | 176 ++ src/MergeHub/AppSetup.cs | 59 + src/MergeHub/MergeHub.csproj | 76 + src/MergeHub/MergeHubSettings.cs | 91 + src/MergeHub/Models/CodeProblemItem.cs | 24 + src/MergeHub/Models/CommitGraph.cs | 62 + src/MergeHub/Models/GitModels.cs | 273 +++ src/MergeHub/Models/MergeLine.cs | 439 ++++ src/MergeHub/Services/CodeSanityChecker.cs | 230 ++ src/MergeHub/Services/CodeTokenizer.cs | 181 ++ src/MergeHub/Services/CommitGraphBuilder.cs | 133 ++ src/MergeHub/Services/CompareRules.cs | 166 ++ src/MergeHub/Services/EditorLayout.cs | 105 + src/MergeHub/Services/FolderComparer.cs | 361 +++ src/MergeHub/Services/GitService.cs | 1665 +++++++++++++ src/MergeHub/Services/GitWorkspace.cs | 394 ++++ src/MergeHub/Services/IGitService.cs | 171 ++ src/MergeHub/Services/LanguageProfile.cs | 237 ++ src/MergeHub/Services/LineAligner.cs | 310 +++ src/MergeHub/Services/MergeNote.cs | 90 + src/MergeHub/Services/MoveDetector.cs | 197 ++ src/MergeHub/Services/ShortcutMap.cs | 156 ++ src/MergeHub/Services/TextFile.cs | 179 ++ src/MergeHub/Services/ThreeWayAligner.cs | 253 ++ src/MergeHub/ViewModels/FolderViewModel.cs | 376 +++ src/MergeHub/ViewModels/GitViewModel.cs | 443 ++++ src/MergeHub/ViewModels/HomeViewModel.cs | 2067 +++++++++++++++++ src/MergeHub/Views/Controls/AppStyles.xaml | 50 + .../Views/Controls/CSharpTokenizer.cs | 315 +++ src/MergeHub/Views/Controls/ChangeMiniMap.cs | 169 ++ src/MergeHub/Views/Controls/CodeTextBlock.cs | 270 +++ .../Views/Controls/CommitGraphCell.cs | 121 + .../Views/Controls/LineHighlightBrushes.cs | 124 + src/MergeHub/Views/FolderView.xaml | 219 ++ src/MergeHub/Views/FolderView.xaml.cs | 69 + src/MergeHub/Views/GitView.xaml | 419 ++++ src/MergeHub/Views/GitView.xaml.cs | 51 + src/MergeHub/Views/HomeView.xaml | 1329 +++++++++++ src/MergeHub/Views/HomeView.xaml.cs | 1031 ++++++++ src/MergeHub/Views/ShortcutHelpWindow.xaml | 105 + src/MergeHub/Views/ShortcutHelpWindow.xaml.cs | 54 + 44 files changed, 13523 insertions(+) create mode 100644 .gitignore create mode 100644 MergeHub.slnx create mode 100644 doc/작업지시-머지툴보강.md create mode 100644 doc/작업지시-사용성보강.md create mode 100644 src/MergeHub/AppSetup.cs create mode 100644 src/MergeHub/MergeHub.csproj create mode 100644 src/MergeHub/MergeHubSettings.cs create mode 100644 src/MergeHub/Models/CodeProblemItem.cs create mode 100644 src/MergeHub/Models/CommitGraph.cs create mode 100644 src/MergeHub/Models/GitModels.cs create mode 100644 src/MergeHub/Models/MergeLine.cs create mode 100644 src/MergeHub/Services/CodeSanityChecker.cs create mode 100644 src/MergeHub/Services/CodeTokenizer.cs create mode 100644 src/MergeHub/Services/CommitGraphBuilder.cs create mode 100644 src/MergeHub/Services/CompareRules.cs create mode 100644 src/MergeHub/Services/EditorLayout.cs create mode 100644 src/MergeHub/Services/FolderComparer.cs create mode 100644 src/MergeHub/Services/GitService.cs create mode 100644 src/MergeHub/Services/GitWorkspace.cs create mode 100644 src/MergeHub/Services/IGitService.cs create mode 100644 src/MergeHub/Services/LanguageProfile.cs create mode 100644 src/MergeHub/Services/LineAligner.cs create mode 100644 src/MergeHub/Services/MergeNote.cs create mode 100644 src/MergeHub/Services/MoveDetector.cs create mode 100644 src/MergeHub/Services/ShortcutMap.cs create mode 100644 src/MergeHub/Services/TextFile.cs create mode 100644 src/MergeHub/Services/ThreeWayAligner.cs create mode 100644 src/MergeHub/ViewModels/FolderViewModel.cs create mode 100644 src/MergeHub/ViewModels/GitViewModel.cs create mode 100644 src/MergeHub/ViewModels/HomeViewModel.cs create mode 100644 src/MergeHub/Views/Controls/AppStyles.xaml create mode 100644 src/MergeHub/Views/Controls/CSharpTokenizer.cs create mode 100644 src/MergeHub/Views/Controls/ChangeMiniMap.cs create mode 100644 src/MergeHub/Views/Controls/CodeTextBlock.cs create mode 100644 src/MergeHub/Views/Controls/CommitGraphCell.cs create mode 100644 src/MergeHub/Views/Controls/LineHighlightBrushes.cs create mode 100644 src/MergeHub/Views/FolderView.xaml create mode 100644 src/MergeHub/Views/FolderView.xaml.cs create mode 100644 src/MergeHub/Views/GitView.xaml create mode 100644 src/MergeHub/Views/GitView.xaml.cs create mode 100644 src/MergeHub/Views/HomeView.xaml create mode 100644 src/MergeHub/Views/HomeView.xaml.cs create mode 100644 src/MergeHub/Views/ShortcutHelpWindow.xaml create mode 100644 src/MergeHub/Views/ShortcutHelpWindow.xaml.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..175498e --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# 빌드 산출물 +bin/ +obj/ + +# Visual Studio / Rider +.vs/ +.idea/ +*.user +*.suo + +# NuGet +packages/ + +# 플랫폼 런타임 복사본 (빌드할 때 D:\UbisamPlatform\bin 에서 자동으로 채워진다) +# → src/MergeHub/bin 아래로 들어가므로 위 bin/ 규칙에 이미 걸린다 diff --git a/MergeHub.slnx b/MergeHub.slnx new file mode 100644 index 0000000..1e03219 --- /dev/null +++ b/MergeHub.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/doc/작업지시-머지툴보강.md b/doc/작업지시-머지툴보강.md new file mode 100644 index 0000000..cc4c0a0 --- /dev/null +++ b/doc/작업지시-머지툴보강.md @@ -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 | **폴더 비교 → 파일 비교 더블클릭이 안 먹던 것** | **완료.** 기능은 이미 있었는데 **탭 순서를 바꾸면서 깨졌다**. 탭 내용은 그 탭을 처음 열 때 만들어진다. 폴더 비교가 첫 화면이 되면서, 파일을 처음 더블클릭하는 순간에는 파일 비교 화면이 아직 없어 요청 이벤트를 받을 사람이 없었다. 요청은 허공에 사라지고, 뒤이어 만들어진 화면은 기본 데모 파일을 열었다 - 겉보기에 "더블클릭이 안 먹는다". `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만 줄)로 바꿈 — 히스토그램은 표를 만들지 않으므로 예전보다 훨씬 큰 구간도 정확히 정렬한다. | diff --git a/doc/작업지시-사용성보강.md b/doc/작업지시-사용성보강.md new file mode 100644 index 0000000..7a5fc24 --- /dev/null +++ b/doc/작업지시-사용성보강.md @@ -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 는 칸 너비를 무한으로 잡아 재므로 접힐 자리가 없다. 워드랩을 켜면 `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 의 세로 위치가 어긋난다.** 두 무리의 칸 너비가 달라 접히는 자리가 다르고, 따라서 같은 행이라도 높이가 다르다. 지금은 픽셀 오프셋으로 맞추므로 접힌 줄을 지날 때마다 조금씩 벌어진다(4칸 굴린 뒤 2행 차이). 제대로 맞추려면 픽셀이 아니라 **행 번호로** 맞춰야 하고, 그러려면 양쪽 모두 "N행의 픽셀 위치"를 되돌려 주는 길이 필요하다 - 스크롤 동기화를 다시 짜는 일이라 따로 잡는 편이 안전하다. 워드랩을 끄면 모든 칸의 행 높이가 같아 어긋나지 않는다. ② **MERGED 를 직접 편집하면 LOCAL(Mine) 칸도 그 편집을 따라간다.** MERGED 는 Mine 의 복사본에서 출발하고, 편집 후 재정렬은 "지금 글"로 다시 맞추기 때문이다. "파일을 연 시점의 내 줄"로 얼어붙게 하려면 편집 전 LOCAL 을 따로 정렬해 넣어야 하는데, 사용자가 줄을 더하거나 지우면 행이 어긋나 4-way 정렬이 된다. 반영(Accept·Take Mine/Theirs) 으로 고치는 경우에는 지금도 Mine 칸이 그대로 남는다. | +| 2026-09-07 | **남은 것 2가지 — 둘 다 완료** | ① **3-Way + 워드랩 세로 어긋남.** 위 원본 세 칸과 아래 MERGED 는 칸 너비가 달라 접히는 자리가 다르고, 따라서 같은 행이라도 높이가 다르다. 지금까지 픽셀 오프셋을 그대로 넘겨 맞추던 것을 **행 기준 변환**으로 바꿨다 — 편집기의 세로 위치 → 그 자리의 논리 행 + 행 안으로 들어간 비율 → 원본 칸의 행 높이표(`MergeLine.SourceRowHeight`)로 다시 픽셀. 반대 방향(원본 칸을 굴렸을 때)도 같은 식의 역변환을 넣었다. 변환은 워드랩을 켠 3-Way 에서만 탄다(`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건 통과**. | diff --git a/src/MergeHub/AppSetup.cs b/src/MergeHub/AppSetup.cs new file mode 100644 index 0000000..19680be --- /dev/null +++ b/src/MergeHub/AppSetup.cs @@ -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 +{ + /// + /// UbisamBase.Shell.exe 가 리플렉션으로 찾아 로드하는 단일 진입점. + /// 이 dll 자체는 실행 파일이 아니다. + /// + 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(Icon.Folder, ViewIds.Folder, "폴더 비교"); + manager.AddMain(Icon.Document, ViewIds.Home, "파일 비교"); + manager.AddMain(Icon.Link, ViewIds.Git, "Git"); + } + + public void RegisterSettings(ITabManager manager) + { + manager.AddSettingsSub(TabManager.SettingsTabKey, "MergeHub"); + } + + public void RegisterServices(IServiceCollection services) + { + // Git 조작 계층. Repository 핸들 하나를 화면 전체가 공유하므로 싱글턴. + services.AddSingleton(); + + // 홈(머지툴) 탭과 Git 탭이 함께 보는 저장소 상태. 호출 직렬화도 여기서 한다. + services.AddSingleton(); + } + } + + /// AddMain/AddSub 에서 탭을 다시 찾을 때 쓰는 키. + public static class ViewIds + { + public const string Home = "MergeHub.Home"; + public const string Folder = "MergeHub.Folder"; + public const string Git = "MergeHub.Git"; + } +} diff --git a/src/MergeHub/MergeHub.csproj b/src/MergeHub/MergeHub.csproj new file mode 100644 index 0000000..9a858c9 --- /dev/null +++ b/src/MergeHub/MergeHub.csproj @@ -0,0 +1,76 @@ + + + + Library + net48 + true + true + latest + enable + MergeHub + MergeHub + false + + + D:\UbisamPlatform\bin\ + + + Program + $(TargetDir)UbisamBase.Shell.exe + $(TargetDir) + + true + + + + + $(UbisamPlatformDir)UbisamBase.Core.dll + false + + + $(UbisamPlatformDir)Microsoft.Extensions.DependencyInjection.Abstractions.dll + false + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + <_PlatformFiles Include="$(UbisamPlatformDir)**\*.*" /> + + + + + + + + + <_Git2NativeX64 Include="$(NuGetPackageRoot)libgit2sharp.nativebinaries\**\runtimes\win-x64\native\*.dll" /> + <_Git2NativeX86 Include="$(NuGetPackageRoot)libgit2sharp.nativebinaries\**\runtimes\win-x86\native\*.dll" /> + + + + + + + diff --git a/src/MergeHub/MergeHubSettings.cs b/src/MergeHub/MergeHubSettings.cs new file mode 100644 index 0000000..d65eb24 --- /dev/null +++ b/src/MergeHub/MergeHubSettings.cs @@ -0,0 +1,91 @@ +using System.ComponentModel; + +namespace MergeHub +{ + /// + /// 설정 탭에 자동 생성되는 편집 화면의 원본. + /// D:\UbisamConfig\Settings\MergeHubSettings.json 으로 저장된다. + /// + 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; + + /// + /// 차이로 세지 않을 줄의 정규식. 줄바꿈으로 구분해 여러 개를 적는다. + /// 매번 바뀌는 타임스탬프·빌드번호처럼 내용이 아닌 것을 걸러내는 용도다. + /// + [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; + + } +} diff --git a/src/MergeHub/Models/CodeProblemItem.cs b/src/MergeHub/Models/CodeProblemItem.cs new file mode 100644 index 0000000..74cd905 --- /dev/null +++ b/src/MergeHub/Models/CodeProblemItem.cs @@ -0,0 +1,24 @@ +namespace MergeHub.Models +{ + /// + /// 코드 검사에서 걸린 항목 하나. 목록에서 눌러 그 줄로 갈 수 있어야 하므로 + /// 문장만이 아니라 화면 행 번호까지 함께 들고 있는다. + /// + public sealed class CodeProblemItem + { + public CodeProblemItem(int row, string where, string message) + { + Row = row; + Where = where; + Message = message; + } + + /// 화면 행 번호(0부터). 특정 줄이 아니면 -1. + public int Row { get; } + + /// "15행" 처럼 어디인지. + public string Where { get; } + + public string Message { get; } + } +} diff --git a/src/MergeHub/Models/CommitGraph.cs b/src/MergeHub/Models/CommitGraph.cs new file mode 100644 index 0000000..3d63759 --- /dev/null +++ b/src/MergeHub/Models/CommitGraph.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; + +namespace MergeHub.Models +{ + /// + /// 한 행 안에서 그려질 선 하나. + /// 행의 위쪽 경계 FromLane 에서 들어와 아래쪽 경계 ToLane 으로 나간다. + /// FromLane == ToLane 이면 수직선(그 레인을 그냥 통과), 다르면 합류/분기 사선이다. + /// + 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; } + } + + /// 커밋 한 개가 차지하는 그래프 행. + public sealed class GraphRow + { + public GraphRow(int lane, int colorIndex, IReadOnlyList edges, bool isMerge) + { + Lane = lane; + ColorIndex = colorIndex; + Edges = edges; + IsMerge = isMerge; + } + + /// 점(커밋)이 찍히는 레인. + public int Lane { get; } + + public int ColorIndex { get; } + + public IReadOnlyList Edges { get; } + + /// 부모가 둘 이상 - 속을 비운 점으로 그린다. + public bool IsMerge { get; } + + /// 목록 전체에서 쓰인 최대 레인 수. 열 너비를 맞추는 데 쓴다. + public int LaneCount { get; set; } = 1; + } + + /// 커밋 목록에 바인딩되는 항목 - 커밋 정보 + 그 커밋이 그려질 그래프 행. + public sealed class CommitNode + { + public CommitNode(CommitInfo commit, GraphRow graph) + { + Commit = commit; + Graph = graph; + } + + public CommitInfo Commit { get; } + public GraphRow Graph { get; } + } +} diff --git a/src/MergeHub/Models/GitModels.cs b/src/MergeHub/Models/GitModels.cs new file mode 100644 index 0000000..dac9009 --- /dev/null +++ b/src/MergeHub/Models/GitModels.cs @@ -0,0 +1,273 @@ +using System; +using System.Collections.Generic; + +namespace MergeHub.Models +{ + /// 원격 저장소(사내 Gitea) 인증 정보. Password 자리에 개인 액세스 토큰을 넣어도 된다. + public sealed record GitCredentials(string Username, string Password); + + /// 브랜치 한 개의 표시용 정보. + public sealed record BranchInfo( + string FriendlyName, + string CanonicalName, + bool IsRemote, + bool IsCurrentHead, + string? TrackedBranch, + int? AheadBy, + int? BehindBy, + string TipSha, + string TipMessageShort); + + /// 커밋 한 개. ParentShas 를 이용해 히스토리 그래프를 그린다. + public sealed record CommitInfo( + string Sha, + string ShortSha, + string MessageShort, + string Message, + string AuthorName, + string AuthorEmail, + DateTimeOffset When, + IReadOnlyList ParentShas, + IReadOnlyList Refs) + { + public bool IsMerge => ParentShas.Count > 1; + } + + /// 워킹트리/인덱스의 파일 상태. + 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); + } + + /// 저장소 전체 상태 요약. (상단 Working Tree 배지용) + public sealed record RepositoryStatusInfo( + bool IsClean, + int StagedCount, + int ModifiedCount, + int UntrackedCount, + int ConflictedCount, + IReadOnlyList Files); + + /// 충돌 파일 한 개. Base 가 없는 경우(양쪽에서 새로 추가) HasBase=false. + public sealed record ConflictInfo( + string Path, + bool HasBase, + bool HasOurs, + bool HasTheirs, + bool IsBinary); + + /// 3-way 병합 뷰에 그대로 바인딩되는 3분할 본문. + public sealed record ThreeWayContent( + string Path, + string BaseText, + string OursText, + string TheirsText, + bool IsBinary); + + /// 충돌 해결 방식. 목업의 Accept Mine / Theirs / Both 버튼에 대응. + public enum ConflictResolution + { + /// 내 작업(HEAD, LOCAL)을 채택. + Mine, + /// 상대 브랜치(MERGE_HEAD, REMOTE)를 채택. + Theirs, + /// 공통 조상(BASE)으로 되돌림. + Base, + /// Mine 다음에 Theirs 를 이어붙임. + Both + } + + public enum MergeOutcome + { + /// 이미 최신 - 병합할 것이 없음. + UpToDate, + /// Fast-forward 로 HEAD 만 이동. + FastForward, + /// 병합 성공 - 커밋 대기(CommitOnSuccess=false) 또는 병합 커밋 생성. + Merged, + /// 충돌 발생 - Conflicts 목록을 해결해야 함. + Conflicts, + /// 실패/중단. + Failed + } + + public sealed record MergeResultInfo( + MergeOutcome Outcome, + string? CommitSha, + IReadOnlyList Conflicts, + string Message) + { + public bool HasConflicts => Outcome == MergeOutcome.Conflicts; + } + + /// 저장소 열림 상태 요약. + public sealed record RepositoryInfo( + string WorkingDirectory, + string GitDirectory, + string? CurrentBranchName, + string? HeadSha, + bool IsDetachedHead, + string PendingOperation, + IReadOnlyList RemoteNames); + + public enum DiffLineKind + { + Context, + Added, + Deleted, + HunkHeader + } + + public enum WordDiffKind + { + Unchanged, + Added, + Deleted + } + + /// 한 줄 안에서 실제로 바뀐 구간. (기획안 4p "한 줄 내 변경 부분까지 하이라이트") + public sealed record WordDiffSegment(WordDiffKind Kind, string Text); + + public sealed record DiffLine( + DiffLineKind Kind, + int? OldLineNumber, + int? NewLineNumber, + string Text, + IReadOnlyList? Words = null); + + public sealed record FileDiff( + string Path, + string? OldPath, + string ChangeKind, + bool IsBinary, + int LinesAdded, + int LinesDeleted, + string Patch, + IReadOnlyList 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); + + /// git reset 의 세 가지 모드. + public enum ResetKind + { + /// HEAD 만 이동. 인덱스·워킹트리 유지 (커밋만 취소). + Soft, + /// HEAD + 인덱스 되돌림. 워킹트리 수정은 유지 (git reset 기본값). + Mixed, + /// 전부 되돌림. 수정 내용이 사라진다. + Hard + } + + public enum PatchApplyOutcome + { + /// 적용 성공. + Applied, + /// 충돌 발생 - GetConflicts() 로 해결 후 Commit(). + Conflicts, + /// 적용할 변경이 없음. + NothingToDo, + Failed + } + + /// cherry-pick / revert 결과. + public sealed record PatchApplyResult( + PatchApplyOutcome Outcome, + string? CommitSha, + IReadOnlyList Conflicts, + string Message) + { + public bool HasConflicts => Outcome == PatchApplyOutcome.Conflicts; + } + + public enum RebaseOutcome + { + /// 모든 단계 완료. + Completed, + /// 충돌로 중단 - 해결 후 RebaseContinue(). + Conflicts, + /// 사용자 확인을 위해 정지. + Stopped, + /// 재배치할 커밋이 없음. + NothingToDo, + Failed + } + + public sealed record RebaseResultInfo( + RebaseOutcome Outcome, + int CompletedSteps, + int TotalSteps, + IReadOnlyList 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) + { + /// 아직 clone 되지 않아 update 가 필요한 상태. + public bool NeedsUpdate => WorkDirSha is null || WorkDirSha != IndexSha; + } + + public sealed record StashInfo(int Index, string Message, string Sha); + + /// 텍스트 파일의 저장 형식. 편집 화면 상태바에 그대로 보여 준다. + public sealed class TextFileInfo + { + public TextFileInfo(string encoding, string newLine) + { + Encoding = encoding; + NewLine = newLine; + } + + /// UTF-8 / UTF-8 (BOM) / KS_C_5601-1987 처럼 사람이 읽는 이름. + public string Encoding { get; } + + /// CRLF / LF / CR / 혼합 / 없음. + public string NewLine { get; } + } +} diff --git a/src/MergeHub/Models/MergeLine.cs b/src/MergeHub/Models/MergeLine.cs new file mode 100644 index 0000000..96408d7 --- /dev/null +++ b/src/MergeHub/Models/MergeLine.cs @@ -0,0 +1,439 @@ +using System; +using System.Collections.Generic; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace MergeHub.Models +{ + public enum MergeLineState + { + /// BASE 와 LOCAL 이 같다. + Same, + /// 양쪽에 있으나 내용이 다르다. + Changed, + /// LOCAL 에만 있다(추가됨). + LocalOnly, + /// BASE 에만 있다(삭제됨). + BaseOnly + } + + /// + /// 화면에서 줄 배경을 칠하는 기준. 범례(색 구분 안내)와 1:1로 대응한다. + /// + public enum LineHighlight + { + /// BASE 와 같음 - 칠하지 않음. + None, + /// BASE 와 다름, 아직 손대지 않음. + Changed, + /// BASE 내용을 가져와 반영함. + Applied, + /// 내가 직접 타이핑해서 고침. + Edited, + /// LOCAL 에만 있는 줄(추가). + Added, + /// BASE 에만 있는 줄(삭제). + Removed, + /// 지운 것이 아니라 자리만 옮긴 줄. + Moved, + /// 무시 규칙에 걸려 차이로 세지 않는 줄. + Ignored, + /// 3-Way: 저쪽(REMOTE)만 고친 줄. 받아들이기만 하면 되는 변경. + RemoteChange, + /// 3-Way: 양쪽이 서로 다르게 고친 줄. 사람이 골라야 한다. + Conflict + } + + /// + /// 3-Way 에서 이 줄이 어떤 상태인지. BASE(공통 조상)를 기준으로 양쪽이 무엇을 했는지로 정한다. + /// "다르다"가 아니라 "누가 고쳤나"가 판단의 근거라, 2-Way 의 MergeLineState 와 섞을 수 없다. + /// + public enum ThreeWayState + { + /// 아무도 안 고쳤다. + Same, + /// 나만 고쳤다. 그대로 두면 된다. + LocalChanged, + /// 저쪽만 고쳤다. 받아들이면 된다. + RemoteChanged, + /// 둘 다 고쳤는데 결과가 같다. 충돌이 아니다. + BothSame, + /// 둘 다 다르게 고쳤다. 사람이 골라야 한다. + Conflict + } + + /// 편집이 어디서 왔는지. 색과 범례를 나누는 기준이라 섞으면 안 된다. + public enum EditKind + { + /// [>] 또는 Accept 로 BASE 를 가져옴. + TakeBase, + /// 사용자가 직접 타이핑함. + Manual + } + + /// + /// BASE / LOCAL 을 "내용이 같은 줄끼리" 맞춘 한 행. + /// 줄 번호는 양쪽이 다를 수 있고, 한쪽에만 있는 줄은 반대쪽 번호가 없다. + /// + public sealed partial class MergeLine : ObservableObject + { + /// + /// 두 쪽이 "같은 줄인가"를 따질 때 쓸 변환. 공백·대소문자 무시 규칙이 여기로 들어온다. + /// null 이면 원문끼리 그대로 비교한다. 화면에 보이는 글자는 어느 쪽이든 원문 그대로다. + /// + public MergeLine(string? baseText, int? baseNumber, string? localText, int? localNumber, + Func? 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? _normalize; + + /// + /// 무시 규칙까지 감안해 두 줄이 같은지. 사용자가 LOCAL 을 고쳐도 그때그때 다시 따진다. + /// + private bool SameText(string a, string b) => + _normalize is null + ? string.Equals(a, b, StringComparison.Ordinal) + : string.Equals(_normalize(a), _normalize(b), StringComparison.Ordinal); + + /// BASE 쪽 줄 번호. LOCAL 에만 있는 줄이면 없다. + public int? BaseNumber { get; } + + public string BaseNumberText => BaseNumber?.ToString("00") ?? string.Empty; + + /// LOCAL 쪽 줄 번호. 줄을 지우면 사라진다. + [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; + + /// 한 줄 안에서 바뀐 구간. 단어 단위 diff 가 켜져 있을 때만 채워진다. + [ObservableProperty] private IReadOnlyList? _baseWords; + [ObservableProperty] private IReadOnlyList? _localWords; + + /// BASE 내용을 가져와 반영한 줄인지. 되돌리면 풀린다. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Highlight))] + private bool _isApplied; + + /// 사용자가 직접 타이핑해 고친 줄인지. "머지 반영"과 구분해서 표시한다. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Highlight))] + private bool _isManuallyEdited; + + /// 반영하면서 줄 끝에 붙인 주석(툴팁으로 보여준다). + [ObservableProperty] private string? _appliedNote; + + /// 이 줄에서 발견한 문법 이상. 없으면 null. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasProblem))] + private string? _problem; + + public bool HasProblem => !string.IsNullOrEmpty(Problem); + + // ---- 워드랩에서 패널들의 행 높이 맞추기 ---- + // + // 워드랩을 켜면 줄마다 접히는 횟수가 달라 높이가 제각각이 된다. 패널이 각자 자기 글 + // 높이만 쓰면, 한쪽만 접힌 행에서 그 아래가 통째로 밀린다. 그래서 "같은 눈높이에 + // 있어야 하는 패널끼리" 높이 중 큰 값을 함께 쓴다. + // + // 여기서 중요한 것은 같이 맞춰야 하는 묶음이 하나가 아니라는 점이다. + // 2-Way : BASE ↔ LOCAL(편집기) 이 나란히 있으므로 넷이 한 묶음이다. + // 3-Way : 위의 원본 세 칸은 폭이 같아 서로 맞춰야 하지만, + // 아래 MERGED 는 폭이 전혀 달라(가로 전체) 접히는 자리가 애초에 다르다. + // 같은 높이를 강요하면 MERGED 는 안 접힌 줄에 빈칸이 생기고, + // 그 위에 겹친 편집기와도 어긋난다. + // 그래서 묶음을 둘로 나눈다 - RowHeight(MERGED·편집기) 와 SourceRowHeight(원본 칸들). + + private double _baseTextHeight; + private double _localTextHeight; + private double _remoteTextHeight; + private double _originalLocalTextHeight; + + /// 글 높이를 알려 오는 패널. + public enum TextPane + { + Base, + /// 2-Way 의 LOCAL, 3-Way 의 MERGED. 편집기가 겹쳐 있는 칸이다. + Local, + Remote, + OriginalLocal + } + + /// 편집기가 겹친 칸(2-Way LOCAL · 3-Way MERGED)의 행 높이. + [ObservableProperty] private double _rowHeight = MinimumRowHeight; + + /// 읽기 전용 원본 칸들의 행 높이. 2-Way 에서는 RowHeight 와 같다. + [ObservableProperty] private double _sourceRowHeight = MinimumRowHeight; + + /// 접히지 않은 보통 줄의 높이. HomeView 의 CodeLineHeight 와 같은 값이어야 한다. + private const double MinimumRowHeight = 20; + + /// 각 패널이 자기 글이 실제로 얼마나 높은지 알려 준다. + 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; + } + + /// 무시 규칙(줄 필터)에 걸린 줄. 차이로 세지 않고 옅게만 표시한다. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsDifferent))] + [NotifyPropertyChangedFor(nameof(CanTakeBase))] + [NotifyPropertyChangedFor(nameof(Highlight))] + private bool _isIgnored; + + /// 자리만 옮겨진 줄이면 어디로/어디서 옮겨졌는지. 없으면 이동이 아니다. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsMoved))] + [NotifyPropertyChangedFor(nameof(Highlight))] + private string? _moveHint; + + public bool IsMoved => !string.IsNullOrEmpty(MoveHint); + + /// 사용자가 여백을 눌러 표시해 둔 줄. F2 로 옮겨 다닌다. + [ObservableProperty] private bool _isBookmarked; + + /// + /// 머지하면서 끼워 넣은 주석 줄인지. + /// + /// 이 표시가 필요한 이유 - 주석 줄은 BASE 에 없는 줄이라, 그냥 두면 "LOCAL 에만 있는 줄"로 + /// 차이에 잡힌다. 그러면 다음 Accept 나 전체 반영이 그 주석을 지워 버린다 + /// ("BASE 를 따른다 = BASE 에 없는 줄은 없앤다"는 규칙 때문에). 실제로 그렇게 사라졌다. + /// 방금 내가 남긴 기록이 다음 클릭에 지워지면 안 된다. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsDifferent))] + [NotifyPropertyChangedFor(nameof(CanTakeBase))] + [NotifyPropertyChangedFor(nameof(CanTakeRemote))] + [NotifyPropertyChangedFor(nameof(CanTakeLocal))] + private bool _isMergeNote; + + /// 앞 줄에서 블록 주석이 열린 채 넘어왔는지 (BASE / LOCAL 각각). + [ObservableProperty] private bool _baseInComment; + [ObservableProperty] private bool _localInComment; + + /// BASE 쪽에 이 줄이 있는지. 정렬 결과라 바뀌지 않는다. + public bool HasBase { get; } + + /// LOCAL 쪽에 줄이 있는지. 줄을 지우거나 되살리면 바뀐다. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(State))] + [NotifyPropertyChangedFor(nameof(IsDifferent))] + [NotifyPropertyChangedFor(nameof(CanTakeBase))] + [NotifyPropertyChangedFor(nameof(Highlight))] + private bool _hasLocal; + + // ---- 3-Way (REMOTE = 사용자가 고른 브랜치) ---- + + /// REMOTE 쪽 줄 번호. 그쪽에 없는 줄이면 없다. + 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; + + /// REMOTE 쪽에 이 줄이 있는지. 3-Way 로 읽어 오지 않았으면 false 다. + public bool HasRemote { get; private set; } + + /// 이 행이 3-Way 로 만들어졌는지. false 면 REMOTE 칸은 아예 없는 것으로 본다. + public bool IsThreeWay { get; private set; } + + /// REMOTE 쪽 한 줄 안에서 바뀐 구간. + [ObservableProperty] private IReadOnlyList? _remoteWords; + + [ObservableProperty] private bool _remoteInComment; + + // ---- 3-Way 에서의 LOCAL 원본 ---- + // + // 3-Way 에서는 편집 대상이 MERGED(결과) 이고, LocalText 가 곧 그 결과다. + // 그래서 "내가 원래 갖고 있던 줄"을 따로 붙들어 둔다 - 이게 없으면 + // REMOTE 를 한 번 가져오는 순간 내 원래 내용이 화면에서 사라져, 정작 충돌을 + // 판단할 근거를 잃는다. + + /// 파일을 연 시점의 LOCAL 줄. 3-Way 의 LOCAL 패널이 이걸 보여 준다. + public string OriginalLocalText { get; private set; } = string.Empty; + + /// 파일을 연 시점에 LOCAL 쪽에 이 줄이 있었는지. + public bool HasOriginalLocal { get; private set; } + + /// 내 원본 쪽 줄 번호. MERGED 를 고치면 두 번호가 서로 달라진다. + public int? OriginalLocalNumber { get; private set; } + + public string OriginalLocalNumberText => + OriginalLocalNumber is int n ? n.ToString("00") : string.Empty; + + /// 3-Way 값을 채운다. 정렬 결과라 만든 뒤에는 바뀌지 않는다. + public void SetRemote(string? remoteText, int? remoteNumber) => + SetThreeWay(remoteText, remoteNumber, HasLocal ? LocalText : null, LocalNumber); + + /// + /// 3-Way 값을 채운다. 내 원본(MINE)을 따로 받는다. + /// + /// MERGED 를 직접 고치고 나면 결과와 내 원본이 갈라지므로 한 열로 묶을 수 없다 - + /// 묶어 두면 글자를 하나만 쳐도 MINE 칸이 그 편집을 따라가 판단 근거가 사라진다. + /// + 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; + } + + /// + /// BASE 기준으로 양쪽이 무엇을 했는지. 3-Way 가 아니면 늘 Same 이다. + /// + /// "한쪽에만 있다"도 변경으로 친다 - 지우거나 새로 넣은 것도 사람이 판단할 변경이다. + /// + 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; + + /// REMOTE 내용을 가져올 수 있는 줄인지. 이미 같으면 가져올 것이 없다. + public bool CanTakeRemote => + IsThreeWay && !IsMergeNote && ThreeWay is ThreeWayState.RemoteChanged or ThreeWayState.Conflict; + + /// + /// 내 원본을 결과로 되돌릴 수 있는 줄인지. + /// 충돌 줄에서는 결과가 이미 내 원본과 같아도 보여 준다 - "내 것으로 정한다"는 결정 자체가 필요하다. + /// + 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; + + /// + /// 무시 규칙에 걸린 줄은 내용이 달라도 차이로 세지 않는다. + /// 3-Way 에서는 저쪽만 고친 줄도 사람이 봐야 할 차이다 - BASE 와 LOCAL 이 같아도 센다. + /// + public bool IsDifferent => + !IsIgnored && !IsMergeNote && (State != MergeLineState.Same || + (IsThreeWay && ThreeWay != ThreeWayState.Same)); + + /// + /// [>] 버튼 표시 조건. BASE 가 없는 줄은 "가져올 것"이 없으니 지우기가 된다. + /// IsDifferent 와 따로 두는 이유 - 3-Way 에서 저쪽만 고친 줄은 BASE 와 LOCAL 이 같아서 + /// BASE 를 가져와 봐야 아무 일도 일어나지 않는다. + /// + public bool CanTakeBase => !IsIgnored && !IsMergeNote && State != MergeLineState.Same; + + /// + /// 줄 배경색 기준. + /// 내가 손댄 결과(반영/직접수정)가 단순 차이보다 우선한다 - 무엇을 이미 처리했는지가 더 중요하다. + /// + 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 + }; + } + + /// + /// Undo/Redo 스택에 쌓이는 한 번의 줄 편집. + /// 줄을 지우거나 되살리는 것도 편집이라 HasLocal 도 함께 기억한다. + /// + public sealed record LineEdit( + MergeLine Line, + string Before, bool BeforeHasLocal, + string After, bool AfterHasLocal, + EditKind Kind, + string Description) + { + /// + /// 이 편집이 끼워 넣은 주석 줄. 되돌리면 함께 사라져야 한다. + /// + /// 주석을 줄 끝이 아니라 줄 위에 따로 넣기로 하면서 생겼다 - 주석이 진짜 한 줄이므로 + /// 되돌릴 때 본문만 되돌리고 주석을 남겨 두면 파일에 유령 주석이 쌓인다. + /// + public MergeLine? InsertedNote { get; init; } + } +} diff --git a/src/MergeHub/Services/CodeSanityChecker.cs b/src/MergeHub/Services/CodeSanityChecker.cs new file mode 100644 index 0000000..e4c52f6 --- /dev/null +++ b/src/MergeHub/Services/CodeSanityChecker.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.Text; +using MergeHub.Views.Controls; + +namespace MergeHub.Services +{ + /// 줄 하나에서 발견한 문제. + public sealed class CodeProblem + { + public CodeProblem(int lineIndex, string message) + { + LineIndex = lineIndex; + Message = message; + } + + /// Lines 컬렉션에서의 위치(0부터). + public int LineIndex { get; } + + public string Message { get; } + } + + /// + /// 편집한 내용이 "일단 컴파일은 될 모양인지"를 훑어보는 가벼운 검사기. + /// + /// 컴파일러가 아니다. Roslyn 없이 토크나이저만으로 잡을 수 있는, + /// 그러나 실제로 자주 저지르는 실수만 본다: + /// 1. 주석·문자열 밖에 한글 등 식별자로 쓸 수 없는 글자 (주석 기호를 빼먹은 경우) + /// 2. 닫히지 않은 따옴표 + /// 3. 파일 전체의 괄호 짝 ( ) { } [ ] + /// + /// 놓치는 것이 많으므로 "빌드가 된다"는 보증이 아니라 "눈에 띄는 실수 알림"으로 쓴다. + /// + public static class CodeSanityChecker + { + /// 이 종류의 토큰 안에서는 어떤 글자가 나와도 문제가 아니다. + private static bool IsFreeText(CodeTokenKind kind) => + kind == CodeTokenKind.Text || + kind == CodeTokenKind.Comment || + kind == CodeTokenKind.XmlDoc || + kind == CodeTokenKind.Preprocessor; + + /// + /// 코드로 다룰 파일인지. .txt·.md 같은 문서나 YAML·INI 처럼 괄호로 블록을 여닫지 않는 + /// 형식에는 이 검사(괄호 짝·식별자 글자)가 뜻이 없다. 판단은 언어 표 한 곳에서만 한다. + /// + public static bool AppliesTo(string? path) => LanguageCatalog.ForPath(path).HasBraces; + + /// 언어를 따로 주지 않으면 C# 으로 본다(이 도구가 처음부터 다루던 언어). + public static IReadOnlyList Check(IReadOnlyList lines) => + Check(lines, LanguageCatalog.CSharp); + + public static IReadOnlyList Check(IReadOnlyList lines, LanguageProfile profile) + { + var problems = new List(); + + 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; + } + + /// + /// 코드 자리에 있으면 무조건 컴파일이 깨지는 문자만 모은다. + /// 전각 기호(? : ,), 전각 공백 같은 것들이다. + /// 한글·한자 "문자"는 C# 식별자로 쓸 수 있으므로 여기서 걸지 않는다. + /// + private static string CollectIllegalSymbols(string line, bool[] free) + { + var found = new StringBuilder(); + var seen = new HashSet(); + + 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(); + } + + /// + /// "주석 기호를 빼먹고 문장을 그대로 쓴" 줄인지 본다. + /// + /// 한글 낱말 하나는 변수명일 수 있으므로(= var 메시지) 걸지 않는다. + /// 대신 한글 낱말이 연산자·구두점 없이 공백만 두고 둘 이상 이어지면 + /// (= 여기 한글을 그냥 씀) 문장으로 본다. 코드에서는 거의 나오지 않는 배치다. + /// + private static bool LooksLikeProse(string line, IReadOnlyList 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; + } + + /// 따옴표가 줄 끝까지 안 닫혔는지. 토크나이저가 만든 Text 토큰의 끝으로 판단한다. + private static bool HasUnclosedQuote(string line, IReadOnlyList 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 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 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)); + } + } +} diff --git a/src/MergeHub/Services/CodeTokenizer.cs b/src/MergeHub/Services/CodeTokenizer.cs new file mode 100644 index 0000000..2f4b8e8 --- /dev/null +++ b/src/MergeHub/Services/CodeTokenizer.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using MergeHub.Views.Controls; + +namespace MergeHub.Services +{ + /// + /// 언어 표()를 보고 한 줄을 토큰으로 쪼갠다. + /// + /// C# 은 전용 토크나이저로 넘긴다 - 축자·보간 문자열과 접근제한자 구분이 표로 담기지 않고, + /// 이미 테스트로 굳어 있어 굳이 다시 만들 이유가 없다. + /// 평문·Markdown 처럼 코드가 아닌 형식은 토큰을 만들지 않는다(그냥 검은 글씨로 나온다). + /// + public static class CodeTokenizer + { + private const string OperatorChars = "=+-*/%!<>&|^~?:"; + private const string PunctuationChars = "(){}[];,."; + + private static readonly IReadOnlyList Empty = Array.Empty(); + + public static IReadOnlyList 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(); + 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; + } + + /// + /// 한 줄 주석 표시가 여기서 시작하는지. + /// 배치 파일의 rem 처럼 글자로 된 표시가 있어 대소문자 규칙을 따른다. + /// + 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; + } + + /// 여는 따옴표부터 닫는 따옴표까지의 길이. 닫히지 않으면 줄 끝까지. + 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; + } + } +} diff --git a/src/MergeHub/Services/CommitGraphBuilder.cs b/src/MergeHub/Services/CommitGraphBuilder.cs new file mode 100644 index 0000000..ac95f3b --- /dev/null +++ b/src/MergeHub/Services/CommitGraphBuilder.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using MergeHub.Models; + +namespace MergeHub.Services +{ + /// + /// 커밋 목록에 레인을 배정해 브랜치 그래프를 만든다. + /// + /// 원리: "레인"은 아직 그려지지 않은 부모 커밋 하나를 기다리는 세로줄이다. + /// 목록을 위에서 아래로 훑으면서 + /// 1) 지금 커밋을 기다리던 레인들을 찾아 그 중 왼쪽 것을 이 커밋의 레인으로 삼고, + /// 2) 나머지는 그 레인으로 합류시키고(사선), + /// 3) 첫 부모는 같은 레인이 이어받고, 두 번째 부모부터는 새 레인으로 분기시킨다. + /// 커밋은 이미 시간순(topological)으로 정렬되어 들어온다는 전제다. + /// + public static class CommitGraphBuilder + { + /// 레인 색은 이 개수로 순환한다. 실제 색상값은 화면(CommitGraphCell)이 정한다. + public const int ColorCount = 6; + + public static IReadOnlyList Build(IReadOnlyList commits) + { + var nodes = new List(commits.Count); + + // lanes[i] = 그 레인이 기다리는 커밋 SHA (없으면 null) + var lanes = new List(); + var laneColors = new List(); + var maxLanes = 1; + var nextColor = 0; + + foreach (var commit in commits) + { + // 이 행에 "위에서 내려온" 상태를 먼저 찍어둔다. + var top = new List(lanes); + + var myLanes = new List(); + 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(); + + // (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; + } + + /// 비어 있는 레인을 재사용하고, 없으면 하나 늘린다. + private static int TakeFreeLane(List lanes, List 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; + } + } +} diff --git a/src/MergeHub/Services/CompareRules.cs b/src/MergeHub/Services/CompareRules.cs new file mode 100644 index 0000000..1f421d8 --- /dev/null +++ b/src/MergeHub/Services/CompareRules.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; + +namespace MergeHub.Services +{ + /// 공백을 얼마나 따질지. + public enum WhitespaceMode + { + /// 공백 하나까지 그대로 비교한다. + Exact, + /// 공백의 개수만 무시한다. 들여쓰기 폭을 바꿔도 같은 줄로 본다. + IgnoreAmount, + /// 공백을 아예 없는 셈 친다. 탭/스페이스 정리를 무시할 때. + IgnoreAll + } + + /// + /// "무엇을 차이로 볼 것인가"를 정하는 규칙. + /// + /// 실무에서 diff 를 어지럽히는 것은 대개 내용이 아니라 형식이다 - 들여쓰기 정리, + /// 대소문자, 매번 바뀌는 타임스탬프 줄. 이런 것을 걸러야 진짜 변경이 눈에 들어온다. + /// + /// 규칙은 두 갈래로 나뉘어 쓰인다. + /// · — 정렬할 때 "같은 줄인가"를 판단하는 비교용 문자열. + /// 화면에 보이는 원문은 건드리지 않는다. + /// · — 줄 자체를 차이 계산에서 빼는 정규식 필터. + /// 정렬을 마친 뒤에 적용한다. 필터에 걸린 줄까지 같은 것으로 묶어 정렬하면 + /// 엉뚱한 줄끼리 짝지어지기 때문이다. + /// + public sealed class CompareRules + { + public WhitespaceMode Whitespace { get; set; } = WhitespaceMode.Exact; + + public bool IgnoreCase { get; set; } + + /// 이 정규식에 걸리는 줄은 차이로 세지 않는다. 잘못된 식은 조용히 버린다. + public IReadOnlyList LinePatterns { get; set; } = Array.Empty(); + + /// 줄 필터를 켤지. 식은 그대로 두고 잠시 끄고 싶을 때가 있다. + public bool LineFilterEnabled { get; set; } = true; + + private Regex[]? _compiled; + private string? _compiledFrom; + + /// 어떤 규칙도 켜져 있지 않으면 비교용 변환을 건너뛸 수 있다. + public bool IsPlain => + Whitespace == WhitespaceMode.Exact && !IgnoreCase; + + /// + /// 정렬에서 "같은 줄인가"를 따질 때 쓰는 문자열. + /// 원문은 그대로 두고 이 값만 비교하므로 화면 표시는 달라지지 않는다. + /// + 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; + } + + /// 줄 필터에 걸리는 줄인지. 걸리면 차이로 세지 않는다. + 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; + } + + /// 상태바에 한 줄로 요약한다. 켜진 게 없으면 null. + public string? Describe() + { + var parts = new List(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); + } + + /// + /// 정규식은 쓸 때마다 다시 만들면 느리므로 캐시한다. + /// 사용자가 식을 고치면 목록 문자열이 달라지므로 그때만 다시 만든다. + /// + private Regex[] Compiled() + { + if (!LineFilterEnabled) return Array.Empty(); + + var signature = string.Join("", LinePatterns); + if (_compiled is not null && _compiledFrom == signature) return _compiled; + + var list = new List(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; + } + + /// 연속된 공백을 하나로 줄이고 앞뒤를 다듬는다. + 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(); + } + } +} diff --git a/src/MergeHub/Services/EditorLayout.cs b/src/MergeHub/Services/EditorLayout.cs new file mode 100644 index 0000000..7ed514b --- /dev/null +++ b/src/MergeHub/Services/EditorLayout.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; + +namespace MergeHub.Services +{ + /// 편집기에 그려 넣을 줄들과, 그중 어느 줄이 필러인지. + public sealed class EditorSnapshot + { + public EditorSnapshot(string[] lines, bool[] filler) + { + Lines = lines; + Filler = filler; + } + + /// 편집기 본문. 필러 자리는 빈 문자열이다. + public string[] Lines { get; } + + /// 같은 인덱스의 줄이 필러(= LOCAL 에 없는 줄)인지. + public bool[] Filler { get; } + } + + /// + /// LOCAL 패널을 "항상 고칠 수 있는 편집기"로 쓰기 위한 줄 계산. + /// + /// 화면에서 BASE 와 LOCAL 은 같은 내용이 같은 눈높이에 와야 한다. 그래서 BASE 에만 있는 + /// 줄(= LOCAL 에서 지워진 줄) 자리에는 LOCAL 쪽에 빈 줄(필러)을 끼워 넣는다. + /// 필러는 화면을 맞추려고 넣은 것일 뿐 파일 내용이 아니므로, 저장하거나 다시 정렬하기 전에 + /// 걷어내야 한다. + /// + /// 문제는 사용자가 편집기에서 줄을 넣고 지운 뒤라 필러 위치가 밀려 있다는 점이다. + /// 그래서 "고치기 전 편집기 내용"과 "지금 편집기 내용"을 정렬해 보고, + /// 예전 필러와 그대로 대응되면서 여전히 비어 있는 줄만 필러로 판정해 걷어낸다. + /// 필러 자리에 사용자가 뭔가 써 넣었다면 그건 새로 추가한 줄이므로 그대로 살린다. + /// + public static class EditorLayout + { + /// 정렬 결과를 편집기에 그릴 줄들로 바꾼다. + 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); + } + + /// + /// 사용자가 고친 편집기 내용에서 필러를 걷어내 실제 LOCAL 줄만 남긴다. + /// + /// 고치기 전 편집기 줄(필러 포함) + /// 그때 어느 줄이 필러였는지 + /// 지금 편집기 줄 + public static List StripFillers(IReadOnlyList previous, + IReadOnlyList wasFiller, + IReadOnlyList current) + { + var kept = new List(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; + } + + /// 두 줄 목록이 같은지. 편집기 글을 다시 써야 하는지 판단할 때 쓴다. + public static bool SameLines(IReadOnlyList a, IReadOnlyList 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; + } + } +} diff --git a/src/MergeHub/Services/FolderComparer.cs b/src/MergeHub/Services/FolderComparer.cs new file mode 100644 index 0000000..35ee0a4 --- /dev/null +++ b/src/MergeHub/Services/FolderComparer.cs @@ -0,0 +1,361 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; + +namespace MergeHub.Services +{ + /// 폴더 비교에서 한 항목이 어떤 상태인지. + public enum FolderEntryStatus + { + /// 양쪽이 같다. + Same, + /// 양쪽에 있고 내용이 다르다. + Different, + /// 왼쪽에만 있다. + LeftOnly, + /// 오른쪽에만 있다. + RightOnly, + /// 내용이 다르지만 이진 파일이라 줄 단위로 볼 수 없다. + Binary + } + + /// 폴더 비교 규칙. + public sealed class FolderCompareOptions + { + /// + /// 빠른 비교. 내용을 읽지 않고 크기와 수정 시각만 본다. + /// 네트워크 드라이브나 수만 개짜리 폴더에서 몇 분이 몇 초로 줄어든다. + /// + public bool QuickCompare { get; set; } + + /// + /// 이 크기를 넘는 파일은 빠른 비교로 돌린다. + /// 전체 비교를 골랐더라도 수백 MB 짜리를 바이트로 훑는 것은 득보다 실이 크다. + /// + public long FullCompareLimitBytes { get; set; } = 8L * 1024 * 1024; + + /// 포함할 파일 정규식. 비우면 전부 포함. + public string IncludePattern { get; set; } = string.Empty; + + /// 제외할 파일·폴더 정규식. 비우면 제외 없음. 포함 규칙보다 우선한다. + public string ExcludePattern { get; set; } = string.Empty; + + /// 하위 폴더까지 훑을지. + public bool IncludeSubfolders { get; set; } = true; + + /// + /// 수정 시각을 같다고 볼 오차. 파일 시스템마다 정밀도가 달라(FAT 는 2초) 정확히 같기를 + /// 기대하면 안 된다. + /// + public TimeSpan TimeTolerance { get; set; } = TimeSpan.FromSeconds(2); + } + + /// 비교 결과 한 항목. 폴더면 Children 이 채워진다. + public sealed class FolderCompareItem + { + public FolderCompareItem(string name, string relativePath, bool isDirectory) + { + Name = name; + RelativePath = relativePath; + IsDirectory = isDirectory; + } + + public string Name { get; } + + /// 두 뿌리 폴더 기준의 상대 경로. 양쪽 실제 경로를 만들 때 쓴다. + 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 Children { get; } = new List(); + + public bool IsDifferent => Status != FolderEntryStatus.Same; + } + + public sealed class FolderCompareResult + { + public List Items { get; } = new List(); + + 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; } + + /// 항목 상한에 걸려 도중에 멈췄는지. + public bool Truncated { get; set; } + + public string Summary => + $"전체 {TotalFiles}개 · 다름 {DifferentCount} · 왼쪽만 {LeftOnlyCount} · 오른쪽만 {RightOnlyCount} · 같음 {SameCount}" + + (Truncated ? " (너무 많아 일부만 봄)" : string.Empty); + } + + /// + /// 폴더 두 개를 재귀로 견준다. + /// + /// 파일 하나하나를 여는 것이 비용의 전부라서, 열기 전에 걸러낼 수 있는 것은 모두 먼저 거른다 - + /// 크기가 다르면 내용을 볼 필요가 없고, 빠른 비교면 아예 열지 않는다. + /// + public static class FolderComparer + { + /// 이 개수를 넘으면 멈춘다. 실수로 C:\ 를 고른 경우에 화면이 굳는 것을 막는다. + 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 into, ref int count) + { + if (count >= MaxEntries) return; + + var leftDir = Path.Combine(leftRoot, relative); + var rightDir = Path.Combine(rightRoot, relative); + + // ---- 파일 ---- + var names = new SortedSet(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(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++; + } + } + + /// 폴더 상태는 아래에 담긴 것들로 정한다. + 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); + } + + /// 다른 파일이 이진이면 따로 표시한다 - 줄 단위 비교 화면을 열어도 볼 것이 없다. + private static FolderEntryStatus DifferentKind(string leftPath, string rightPath) => + TextFile.IsBinary(leftPath) || TextFile.IsBinary(rightPath) + ? FolderEntryStatus.Binary + : FolderEntryStatus.Different; + + /// 내용을 앞에서부터 덩어리째 견준다. 다른 곳을 만나면 바로 멈춘다. + 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; + } + + /// 규칙이 깨져 있으면 규칙이 없는 것으로 본다. 화면이 예외로 죽는 것보다 낫다. + 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 SafeFiles(string dir) + { + if (!Directory.Exists(dir)) return Array.Empty(); + try { return Directory.GetFiles(dir); } + catch (UnauthorizedAccessException) { return Array.Empty(); } + catch (IOException) { return Array.Empty(); } + } + + private static IEnumerable SafeDirs(string dir) + { + if (!Directory.Exists(dir)) return Array.Empty(); + try { return Directory.GetDirectories(dir); } + catch (UnauthorizedAccessException) { return Array.Empty(); } + catch (IOException) { return Array.Empty(); } + } + } +} diff --git a/src/MergeHub/Services/GitService.cs b/src/MergeHub/Services/GitService.cs new file mode 100644 index 0000000..4965375 --- /dev/null +++ b/src/MergeHub/Services/GitService.cs @@ -0,0 +1,1665 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using LibGit2Sharp; +using LibGit2Sharp.Handlers; +using MergeHub.Models; + +namespace MergeHub.Services +{ + /// + /// LibGit2Sharp 기반 Git 조작 구현. + /// libgit2 네이티브 라이브러리가 .git 의 objects/refs/packfile 을 직접 읽으므로 + /// git.exe 를 매번 새 프로세스로 띄우는 방식보다 이력 조회가 빠르다. (기획안 9p) + /// + public sealed class GitService : IGitService + { + /// UTF-8 로 디코딩되지 않는 파일에 적용할 시스템 기본 코드페이지(한국어 Windows = CP949). + private static readonly Encoding AnsiEncoding; + + private static readonly Encoding Utf8Strict = new UTF8Encoding(false, throwOnInvalidBytes: true); + + static GitService() + { + // .NET Core 부터는 CP949 같은 레거시 코드페이지가 기본 제공되지 않아 등록이 필요하다. + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + try + { + AnsiEncoding = Encoding.GetEncoding(System.Globalization.CultureInfo.CurrentCulture.TextInfo.ANSICodePage); + } + catch (Exception) + { + AnsiEncoding = Encoding.UTF8; + } + } + + private Repository? _repo; + + public bool IsOpen => _repo is not null; + + public GitCredentials? Credentials { get; set; } + + public bool IgnoreWhitespace { get; set; } + + public bool WordLevelDiff { get; set; } + + public event EventHandler? Progress; + + private Repository Repo => + _repo ?? throw new InvalidOperationException("저장소가 열려 있지 않습니다. Open() 또는 Clone() 을 먼저 호출하세요."); + + private void Report(string message) => Progress?.Invoke(this, message); + + // ================= 저장소 열기 ================= + + /// 지정 경로에서 위로 올라가며 .git 을 찾는다. 없으면 null. + public static string? Discover(string startPath) + { + if (string.IsNullOrWhiteSpace(startPath)) return null; + var gitDir = Repository.Discover(startPath); + return string.IsNullOrEmpty(gitDir) ? null : gitDir; + } + + public RepositoryInfo Open(string path) + { + Close(); + + var gitDir = Repository.Discover(path); + if (string.IsNullOrEmpty(gitDir)) + throw new RepositoryNotFoundException($"'{path}' 및 상위 경로에서 Git 저장소를 찾지 못했습니다."); + + _repo = new Repository(gitDir); + Report($"저장소 열기: {_repo.Info.WorkingDirectory ?? _repo.Info.Path}"); + return GetRepositoryInfo(); + } + + public RepositoryInfo Clone(string remoteUrl, string localPath, string? branch = null) + { + Close(); + + var options = new CloneOptions(BuildFetchOptions()) + { + Checkout = true + }; + if (!string.IsNullOrWhiteSpace(branch)) + options.BranchName = branch; + + Report($"클론 시작: {remoteUrl} -> {localPath}"); + var repoPath = Repository.Clone(remoteUrl, localPath, options); + _repo = new Repository(repoPath); + Report("클론 완료"); + return GetRepositoryInfo(); + } + + public void Close() + { + _repo?.Dispose(); + _repo = null; + } + + public RepositoryInfo GetRepositoryInfo() + { + var head = Repo.Head; + return new RepositoryInfo( + WorkingDirectory: Repo.Info.WorkingDirectory ?? string.Empty, + GitDirectory: Repo.Info.Path, + CurrentBranchName: head?.FriendlyName, + HeadSha: head?.Tip?.Sha, + IsDetachedHead: Repo.Info.IsHeadDetached, + PendingOperation: Repo.Info.CurrentOperation.ToString(), + RemoteNames: Repo.Network.Remotes.Select(r => r.Name).ToList()); + } + + // ================= 브랜치 ================= + + public IReadOnlyList GetBranches(bool includeRemote = true) + { + var list = new List(); + foreach (var b in Repo.Branches) + { + if (b.IsRemote && !includeRemote) continue; + + list.Add(new BranchInfo( + FriendlyName: b.FriendlyName, + CanonicalName: b.CanonicalName, + IsRemote: b.IsRemote, + IsCurrentHead: b.IsCurrentRepositoryHead, + TrackedBranch: b.TrackedBranch?.FriendlyName, + AheadBy: b.TrackingDetails?.AheadBy, + BehindBy: b.TrackingDetails?.BehindBy, + TipSha: b.Tip?.Sha ?? string.Empty, + TipMessageShort: b.Tip?.MessageShort ?? string.Empty)); + } + + return list + .OrderByDescending(b => b.IsCurrentHead) + .ThenBy(b => b.IsRemote) + .ThenBy(b => b.FriendlyName, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public void Checkout(string branchName, bool force = false) + { + var branch = Repo.Branches[branchName] + ?? throw new ArgumentException($"브랜치를 찾을 수 없습니다: {branchName}"); + + if (branch.IsRemote) + { + CheckoutRemoteBranch(branchName); + return; + } + + var options = new CheckoutOptions + { + CheckoutModifiers = force ? CheckoutModifiers.Force : CheckoutModifiers.None + }; + Commands.Checkout(Repo, branch, options); + Report($"체크아웃: {branch.FriendlyName}"); + } + + public BranchInfo CreateBranch(string name, string? startPointCommittish = null, bool checkout = true) + { + var startCommit = string.IsNullOrWhiteSpace(startPointCommittish) + ? Repo.Head.Tip + : ResolveCommit(startPointCommittish!); + + if (startCommit is null) + throw new ArgumentException($"기준 커밋을 찾을 수 없습니다: {startPointCommittish}"); + + var branch = Repo.CreateBranch(name, startCommit); + if (checkout) Commands.Checkout(Repo, branch); + Report($"브랜치 생성: {name} @ {startCommit.Sha[..7]}"); + + return GetBranches(includeRemote: false).First(b => b.FriendlyName == branch.FriendlyName); + } + + public void DeleteBranch(string name) + { + var branch = Repo.Branches[name] + ?? throw new ArgumentException($"브랜치를 찾을 수 없습니다: {name}"); + Repo.Branches.Remove(branch); + Report($"브랜치 삭제: {name}"); + } + + public BranchInfo CheckoutRemoteBranch(string remoteBranchName) + { + var remote = Repo.Branches[remoteBranchName]; + if (remote is null || !remote.IsRemote) + throw new ArgumentException($"원격 브랜치를 찾을 수 없습니다: {remoteBranchName}"); + + // origin/feature/x -> feature/x + var localName = remoteBranchName; + var slash = localName.IndexOf('/'); + if (slash > 0) localName = localName[(slash + 1)..]; + + var local = Repo.Branches[localName] ?? Repo.CreateBranch(localName, remote.Tip); + local = Repo.Branches.Update(local, b => b.TrackedBranch = remote.CanonicalName); + + Commands.Checkout(Repo, local); + Report($"원격 브랜치 체크아웃: {remoteBranchName} -> {localName}"); + + return GetBranches(includeRemote: false).First(b => b.FriendlyName == local.FriendlyName); + } + + // ================= 원격 ================= + + public IReadOnlyList GetRemotes() => + Repo.Network.Remotes.Select(r => new RemoteInfo(r.Name, r.Url, r.PushUrl ?? r.Url)).ToList(); + + public RemoteInfo AddRemote(string name, string url) + { + Remote remote; + if (Repo.Network.Remotes[name] is null) + { + remote = Repo.Network.Remotes.Add(name, url); + } + else + { + Repo.Network.Remotes.Update(name, r => r.Url = url); + remote = Repo.Network.Remotes[name]; + } + + Report($"원격 등록: {name} -> {url}"); + return new RemoteInfo(remote.Name, remote.Url, remote.PushUrl ?? remote.Url); + } + + public void RemoveRemote(string name) + { + Repo.Network.Remotes.Remove(name); + Report($"원격 삭제: {name}"); + } + + public void Fetch(string remoteName = "origin", bool prune = true) + { + var remote = Repo.Network.Remotes[remoteName] + ?? throw new ArgumentException($"원격을 찾을 수 없습니다: {remoteName}"); + + var refSpecs = remote.FetchRefSpecs.Select(x => x.Specification).ToList(); + var options = BuildFetchOptions(); + options.Prune = prune; + + Report($"fetch {remoteName} ..."); + Commands.Fetch(Repo, remote.Name, refSpecs, options, $"fetch from {remote.Name}"); + Report("fetch 완료"); + } + + public MergeResultInfo Pull(string remoteName = "origin") + { + Fetch(remoteName); + + var tracked = Repo.Head.TrackedBranch; + if (tracked is null) + return new MergeResultInfo(MergeOutcome.Failed, null, Array.Empty(), + $"현재 브랜치 '{Repo.Head.FriendlyName}' 에 추적 중인 원격 브랜치가 없습니다."); + + return Merge(tracked.FriendlyName, commitOnSuccess: false); + } + + public void Push(string? branchName = null, string remoteName = "origin") + { + var branch = string.IsNullOrWhiteSpace(branchName) ? Repo.Head : Repo.Branches[branchName]; + if (branch is null) + throw new ArgumentException($"브랜치를 찾을 수 없습니다: {branchName}"); + + var remote = Repo.Network.Remotes[remoteName] + ?? throw new ArgumentException($"원격을 찾을 수 없습니다: {remoteName}"); + + var options = new PushOptions { CredentialsProvider = BuildCredentialsProvider() }; + var refSpec = $"{branch.CanonicalName}:{branch.CanonicalName}"; + + Report($"push {remoteName} {branch.FriendlyName} ..."); + Repo.Network.Push(remote, refSpec, options); + + if (branch.TrackedBranch is null) + { + Repo.Branches.Update(branch, + b => b.Remote = remote.Name, + b => b.UpstreamBranch = branch.CanonicalName); + } + Report("push 완료"); + } + + // ================= 히스토리 ================= + + public IReadOnlyList GetCommits(string? committish = null, int maxCount = 200) + { + var refsByTarget = new Dictionary>(StringComparer.Ordinal); + foreach (var branch in Repo.Branches) + { + var sha = branch.Tip?.Sha; + if (sha is null) continue; + if (!refsByTarget.TryGetValue(sha, out var names)) + refsByTarget[sha] = names = new List(); + names.Add(branch.FriendlyName); + } + foreach (var tag in Repo.Tags) + { + var sha = tag.Target?.Sha; + if (sha is null) continue; + if (!refsByTarget.TryGetValue(sha, out var names)) + refsByTarget[sha] = names = new List(); + names.Add($"tag: {tag.FriendlyName}"); + } + + var filter = new CommitFilter + { + SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time + }; + + if (!string.IsNullOrWhiteSpace(committish)) + { + var start = ResolveCommit(committish!) + ?? throw new ArgumentException($"커밋/브랜치를 찾을 수 없습니다: {committish}"); + filter.IncludeReachableFrom = start; + } + + var result = new List(Math.Min(maxCount, 256)); + foreach (var c in Repo.Commits.QueryBy(filter)) + { + result.Add(ToCommitInfo(c, refsByTarget)); + if (result.Count >= maxCount) break; + } + return result; + } + + public CommitInfo? GetCommit(string committish) + { + var c = ResolveCommit(committish); + return c is null ? null : ToCommitInfo(c, null); + } + + public string? GetMergeBaseSha(string committishA, string committishB) + { + var a = ResolveCommit(committishA); + var b = ResolveCommit(committishB); + if (a is null || b is null) return null; + return Repo.ObjectDatabase.FindMergeBase(a, b)?.Sha; + } + + public string GetFileTextAtCommit(string committish, string path) + { + var commit = ResolveCommit(committish) + ?? throw new ArgumentException($"커밋을 찾을 수 없습니다: {committish}"); + + var entry = commit[NormalizePath(path)]; + if (entry is null) return string.Empty; + return entry.Target is Blob blob ? ReadBlobText(blob, NormalizePath(path)) : string.Empty; + } + + public IReadOnlyList GetFilesAtCommit(string committish) + { + var commit = ResolveCommit(committish) + ?? throw new ArgumentException($"커밋을 찾을 수 없습니다: {committish}"); + + var files = new List(); + CollectTree(commit.Tree, files); + files.Sort(StringComparer.OrdinalIgnoreCase); + return files; + } + + private static void CollectTree(Tree tree, List into) + { + foreach (var entry in tree) + { + if (entry.TargetType == TreeEntryTargetType.Tree) + CollectTree((Tree)entry.Target, into); + else if (entry.TargetType == TreeEntryTargetType.Blob) + into.Add(entry.Path.Replace('\\', '/')); + } + } + + // ================= 상태 / 스테이징 ================= + + public RepositoryStatusInfo GetStatus(bool includeUntracked = true) + { + var options = new StatusOptions + { + IncludeUntracked = includeUntracked, + RecurseUntrackedDirs = includeUntracked, + DetectRenamesInIndex = true, + DetectRenamesInWorkDir = true + }; + + var files = new List(); + foreach (var entry in Repo.RetrieveStatus(options)) + { + if (entry.State == FileStatus.Ignored) continue; + + var conflicted = entry.State.HasFlag(FileStatus.Conflicted); + files.Add(new FileStatusInfo( + Path: entry.FilePath.Replace('\\', '/'), + StagedState: MapStaged(entry.State), + WorkingDirState: MapWorkingDir(entry.State), + IsConflicted: conflicted)); + } + + files.Sort((a, b) => string.Compare(a.Path, b.Path, StringComparison.OrdinalIgnoreCase)); + + return new RepositoryStatusInfo( + IsClean: files.Count == 0, + StagedCount: files.Count(f => f.IsStaged && !f.IsConflicted), + ModifiedCount: files.Count(f => f.WorkingDirState is WorkingFileState.Modified + or WorkingFileState.Deleted or WorkingFileState.Renamed or WorkingFileState.TypeChanged), + UntrackedCount: files.Count(f => f.WorkingDirState == WorkingFileState.Untracked), + ConflictedCount: files.Count(f => f.IsConflicted), + Files: files); + } + + private static WorkingFileState MapStaged(FileStatus s) + { + if (s.HasFlag(FileStatus.Conflicted)) return WorkingFileState.Conflicted; + if (s.HasFlag(FileStatus.NewInIndex)) return WorkingFileState.Added; + if (s.HasFlag(FileStatus.ModifiedInIndex)) return WorkingFileState.Modified; + if (s.HasFlag(FileStatus.DeletedFromIndex)) return WorkingFileState.Deleted; + if (s.HasFlag(FileStatus.RenamedInIndex)) return WorkingFileState.Renamed; + if (s.HasFlag(FileStatus.TypeChangeInIndex)) return WorkingFileState.TypeChanged; + return WorkingFileState.Unmodified; + } + + private static WorkingFileState MapWorkingDir(FileStatus s) + { + if (s.HasFlag(FileStatus.Conflicted)) return WorkingFileState.Conflicted; + if (s.HasFlag(FileStatus.NewInWorkdir)) return WorkingFileState.Untracked; + if (s.HasFlag(FileStatus.ModifiedInWorkdir)) return WorkingFileState.Modified; + if (s.HasFlag(FileStatus.DeletedFromWorkdir)) return WorkingFileState.Deleted; + if (s.HasFlag(FileStatus.RenamedInWorkdir)) return WorkingFileState.Renamed; + if (s.HasFlag(FileStatus.TypeChangeInWorkdir)) return WorkingFileState.TypeChanged; + return WorkingFileState.Unmodified; + } + + public void Stage(params string[] paths) + { + if (paths is null || paths.Length == 0) return; + Commands.Stage(Repo, paths.Select(NormalizePath)); + } + + /// + /// git add -A. + /// 와일드카드("*") 를 그대로 넘기면 서브모듈 디렉터리에서 libgit2 가 'invalid path' 로 실패하므로, + /// 변경된 경로를 직접 모아서 스테이징한다. + /// + public void StageAll() + { + var options = new StatusOptions + { + IncludeUntracked = true, + RecurseUntrackedDirs = true, + IncludeIgnored = false + }; + + var paths = new List(); + foreach (var entry in Repo.RetrieveStatus(options)) + { + if (entry.State is FileStatus.Unaltered or FileStatus.Ignored) continue; + paths.Add(entry.FilePath); + } + + if (paths.Count == 0) return; + Commands.Stage(Repo, paths); + } + + public void Unstage(params string[] paths) + { + if (paths is null || paths.Length == 0) return; + Commands.Unstage(Repo, paths.Select(NormalizePath)); + } + + public string Commit(string message, bool amend = false) + { + if (string.IsNullOrWhiteSpace(message)) + throw new ArgumentException("커밋 메시지가 비어 있습니다.", nameof(message)); + + var signature = BuildSignature(); + var commit = Repo.Commit(message, signature, signature, new CommitOptions { AmendPreviousCommit = amend }); + Report($"커밋: {commit.Sha[..7]} {commit.MessageShort}"); + return commit.Sha; + } + + // ================= 병합 ================= + + public bool IsMergeInProgress => + Repo.Info.CurrentOperation == CurrentOperation.Merge || Repo.Index.Conflicts.Any(); + + public MergeResultInfo Merge(string branchName, bool commitOnSuccess = false, bool allowFastForward = true) + { + var branch = Repo.Branches[branchName]; + Commit? target = branch?.Tip ?? ResolveCommit(branchName); + if (target is null) + throw new ArgumentException($"병합 대상을 찾을 수 없습니다: {branchName}"); + + if (GetConflicts().Count > 0) + return new MergeResultInfo(MergeOutcome.Conflicts, null, GetConflicts(), + "이미 해결되지 않은 충돌이 있습니다. 먼저 처리하세요."); + + var options = new MergeOptions + { + CommitOnSuccess = commitOnSuccess, + FastForwardStrategy = allowFastForward ? FastForwardStrategy.Default : FastForwardStrategy.NoFastForward, + // 충돌 시 <<<<<<< ======= >>>>>>> 마커가 들어간 파일을 워킹트리에 남긴다. + MergeFileFavor = MergeFileFavor.Normal, + FileConflictStrategy = CheckoutFileConflictStrategy.Merge, + FindRenames = true + }; + + Report($"병합 시작: {branchName} -> {Repo.Head.FriendlyName}"); + var result = Repo.Merge(target, BuildSignature(), options); + + var outcome = result.Status switch + { + MergeStatus.UpToDate => MergeOutcome.UpToDate, + MergeStatus.FastForward => MergeOutcome.FastForward, + MergeStatus.NonFastForward => MergeOutcome.Merged, + MergeStatus.Conflicts => MergeOutcome.Conflicts, + _ => MergeOutcome.Failed + }; + + var conflicts = outcome == MergeOutcome.Conflicts ? GetConflicts() : Array.Empty(); + + var message = outcome switch + { + MergeOutcome.UpToDate => "이미 최신 상태입니다.", + MergeOutcome.FastForward => $"Fast-forward 완료 ({Shorten(result.Commit?.Sha)})", + MergeOutcome.Merged => commitOnSuccess + ? $"병합 커밋 생성 ({Shorten(result.Commit?.Sha)})" + : "충돌 없이 병합됨 - 확인 후 CommitMerge() 를 호출하세요.", + MergeOutcome.Conflicts => $"충돌 {conflicts.Count}건 - 해결이 필요합니다.", + _ => "병합에 실패했습니다." + }; + + Report(message); + return new MergeResultInfo(outcome, result.Commit?.Sha, conflicts, message); + } + + public void AbortMerge() => AbortOperation(); + + public void AbortOperation() + { + if (Repo.Info.CurrentOperation is CurrentOperation.Rebase or CurrentOperation.RebaseInteractive or CurrentOperation.RebaseMerge) + { + Repo.Rebase.Abort(); + Report("rebase 취소"); + return; + } + + // libgit2 의 reset(HARD) 이 MERGE_HEAD / CHERRY_PICK_HEAD / REVERT_HEAD 까지 정리한다. + Repo.Reset(ResetMode.Hard, Repo.Head.Tip); + Report("작업 취소 (reset --hard HEAD)"); + } + + // ================= 되돌리기 ================= + + public void Reset(ResetKind kind, string committish = "HEAD") + { + var target = ResolveCommit(committish) + ?? throw new ArgumentException($"커밋을 찾을 수 없습니다: {committish}"); + + var mode = kind switch + { + ResetKind.Soft => ResetMode.Soft, + ResetKind.Mixed => ResetMode.Mixed, + _ => ResetMode.Hard + }; + + Repo.Reset(mode, target); + Report($"reset --{kind.ToString().ToLowerInvariant()} {Shorten(target.Sha)}"); + } + + public void RestoreFiles(IEnumerable paths, string committish = "HEAD") + { + var list = paths.Select(NormalizePath).ToArray(); + if (list.Length == 0) return; + + Repo.CheckoutPaths(committish, list, new CheckoutOptions + { + CheckoutModifiers = CheckoutModifiers.Force + }); + Report($"파일 복원({committish}): {string.Join(", ", list)}"); + } + + public PatchApplyResult Revert(string committish, bool commitOnSuccess = false, int mainline = 0) + { + var commit = ResolveCommit(committish) + ?? throw new ArgumentException($"커밋을 찾을 수 없습니다: {committish}"); + + Report($"revert 시작: {Shorten(commit.Sha)} {commit.MessageShort}"); + var result = Repo.Revert(commit, BuildSignature(), new RevertOptions + { + CommitOnSuccess = commitOnSuccess, + Mainline = mainline, + FileConflictStrategy = CheckoutFileConflictStrategy.Merge, + FindRenames = true + }); + + var outcome = result.Status switch + { + RevertStatus.Reverted => PatchApplyOutcome.Applied, + RevertStatus.Conflicts => PatchApplyOutcome.Conflicts, + RevertStatus.NothingToRevert => PatchApplyOutcome.NothingToDo, + _ => PatchApplyOutcome.Failed + }; + + return BuildPatchResult(outcome, result.Commit?.Sha, commitOnSuccess, "revert"); + } + + public PatchApplyResult CherryPick(string committish, bool commitOnSuccess = false, int mainline = 0) + { + var commit = ResolveCommit(committish) + ?? throw new ArgumentException($"커밋을 찾을 수 없습니다: {committish}"); + + Report($"cherry-pick 시작: {Shorten(commit.Sha)} {commit.MessageShort}"); + var result = Repo.CherryPick(commit, BuildSignature(), new CherryPickOptions + { + CommitOnSuccess = commitOnSuccess, + Mainline = mainline, + FileConflictStrategy = CheckoutFileConflictStrategy.Merge, + FindRenames = true + }); + + var outcome = result.Status switch + { + CherryPickStatus.CherryPicked => PatchApplyOutcome.Applied, + CherryPickStatus.Conflicts => PatchApplyOutcome.Conflicts, + _ => PatchApplyOutcome.Failed + }; + + return BuildPatchResult(outcome, result.Commit?.Sha, commitOnSuccess, "cherry-pick"); + } + + private PatchApplyResult BuildPatchResult(PatchApplyOutcome outcome, string? sha, bool commitOnSuccess, string operation) + { + var conflicts = outcome == PatchApplyOutcome.Conflicts ? GetConflicts() : Array.Empty(); + + var message = outcome switch + { + PatchApplyOutcome.Applied => commitOnSuccess + ? $"{operation} 완료 ({Shorten(sha)})" + : $"{operation} 적용됨 - 확인 후 Commit() 을 호출하세요.", + PatchApplyOutcome.Conflicts => $"{operation} 충돌 {conflicts.Count}건 - 해결이 필요합니다.", + PatchApplyOutcome.NothingToDo => $"{operation} 할 변경이 없습니다.", + _ => $"{operation} 실패" + }; + + Report(message); + return new PatchApplyResult(outcome, sha, conflicts, message); + } + + // ================= rebase ================= + + public RebaseResultInfo RebaseStart(string upstreamBranch, string? branch = null, string? onto = null) + { + var upstream = Repo.Branches[upstreamBranch] + ?? throw new ArgumentException($"upstream 브랜치를 찾을 수 없습니다: {upstreamBranch}"); + + var target = string.IsNullOrWhiteSpace(branch) ? Repo.Head : Repo.Branches[branch]; + if (target is null) throw new ArgumentException($"브랜치를 찾을 수 없습니다: {branch}"); + + var ontoBranch = string.IsNullOrWhiteSpace(onto) ? upstream : Repo.Branches[onto]; + if (ontoBranch is null) throw new ArgumentException($"onto 브랜치를 찾을 수 없습니다: {onto}"); + + Report($"rebase 시작: {target.FriendlyName} onto {ontoBranch.FriendlyName}"); + var result = Repo.Rebase.Start(target, upstream, ontoBranch, BuildIdentity(), new RebaseOptions + { + FileConflictStrategy = CheckoutFileConflictStrategy.Merge + }); + + return BuildRebaseResult(result); + } + + public RebaseResultInfo RebaseContinue() + { + if (GetConflicts().Count > 0) + throw new InvalidOperationException("해결되지 않은 충돌이 남아 있습니다."); + + var result = Repo.Rebase.Continue(BuildIdentity(), new RebaseOptions + { + FileConflictStrategy = CheckoutFileConflictStrategy.Merge + }); + + return BuildRebaseResult(result); + } + + public void RebaseAbort() + { + Repo.Rebase.Abort(); + Report("rebase 취소"); + } + + public RebaseStatusInfo GetRebaseStatus() + { + var inProgress = Repo.Info.CurrentOperation + is CurrentOperation.Rebase or CurrentOperation.RebaseInteractive or CurrentOperation.RebaseMerge; + + if (!inProgress) return new RebaseStatusInfo(false, 0, 0, null, null); + + var step = Repo.Rebase.GetCurrentStepInfo(); + return new RebaseStatusInfo( + IsInProgress: true, + CurrentStep: (int)(Repo.Rebase.GetCurrentStepIndex() + 1), + TotalSteps: (int)Repo.Rebase.GetTotalStepCount(), + CurrentCommitSha: step?.Commit?.Sha, + CurrentMessage: step?.Commit?.MessageShort); + } + + private RebaseResultInfo BuildRebaseResult(RebaseResult result) + { + var outcome = result.Status switch + { + RebaseStatus.Complete => RebaseOutcome.Completed, + RebaseStatus.Conflicts => RebaseOutcome.Conflicts, + RebaseStatus.Stop => RebaseOutcome.Stopped, + _ => RebaseOutcome.Failed + }; + + if (outcome == RebaseOutcome.Completed && result.TotalStepCount == 0) + outcome = RebaseOutcome.NothingToDo; + + var conflicts = outcome == RebaseOutcome.Conflicts ? GetConflicts() : Array.Empty(); + + var message = outcome switch + { + RebaseOutcome.Completed => $"rebase 완료 ({result.CompletedStepCount}/{result.TotalStepCount} 단계)", + RebaseOutcome.NothingToDo => "재배치할 커밋이 없습니다.", + RebaseOutcome.Conflicts => + $"rebase {result.CompletedStepCount + 1}/{result.TotalStepCount} 단계에서 충돌 {conflicts.Count}건 " + + "- 해결 후 RebaseContinue() 를 호출하세요.", + RebaseOutcome.Stopped => $"rebase 정지 ({result.CompletedStepCount}/{result.TotalStepCount} 단계)", + _ => "rebase 실패" + }; + + Report(message); + return new RebaseResultInfo(outcome, (int)result.CompletedStepCount, (int)result.TotalStepCount, conflicts, message); + } + + // ================= 태그 ================= + + public IReadOnlyList GetTags() + { + var list = new List(); + foreach (var tag in Repo.Tags) + { + list.Add(new TagInfo( + Name: tag.FriendlyName, + TargetSha: tag.Target?.Sha ?? string.Empty, + IsAnnotated: tag.IsAnnotated, + Message: tag.Annotation?.Message?.Trim(), + TaggerName: tag.Annotation?.Tagger?.Name, + When: tag.Annotation?.Tagger?.When)); + } + list.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase)); + return list; + } + + public TagInfo CreateTag(string name, string? committish = null, string? message = null) + { + var target = ResolveCommit(committish ?? "HEAD") + ?? throw new ArgumentException($"커밋을 찾을 수 없습니다: {committish}"); + + var tag = string.IsNullOrWhiteSpace(message) + ? Repo.Tags.Add(name, target) + : Repo.Tags.Add(name, target, BuildSignature(), message); + + Report($"태그 생성: {name} @ {Shorten(target.Sha)}{(tag.IsAnnotated ? " (annotated)" : "")}"); + + return new TagInfo( + Name: tag.FriendlyName, + TargetSha: tag.Target?.Sha ?? target.Sha, + IsAnnotated: tag.IsAnnotated, + Message: tag.Annotation?.Message?.Trim(), + TaggerName: tag.Annotation?.Tagger?.Name, + When: tag.Annotation?.Tagger?.When); + } + + public void DeleteTag(string name) + { + Repo.Tags.Remove(name); + Report($"태그 삭제: {name}"); + } + + // ================= 서브모듈 ================= + + public IReadOnlyList GetSubmodules() + { + var list = new List(); + foreach (var sub in Repo.Submodules) + { + list.Add(new SubmoduleInfo( + Name: sub.Name, + Path: sub.Path.Replace('\\', '/'), + Url: sub.Url ?? string.Empty, + HeadSha: sub.HeadCommitId?.Sha, + IndexSha: sub.IndexCommitId?.Sha, + WorkDirSha: sub.WorkDirCommitId?.Sha, + Status: sub.RetrieveStatus().ToString())); + } + list.Sort((a, b) => string.Compare(a.Path, b.Path, StringComparison.OrdinalIgnoreCase)); + return list; + } + + public void UpdateSubmodule(string name, bool init = true) + { + var options = new SubmoduleUpdateOptions { Init = init }; + options.FetchOptions.CredentialsProvider = BuildCredentialsProvider(); + Repo.Submodules.Update(name, options); + Report($"서브모듈 업데이트: {name}"); + } + + public void UpdateAllSubmodules(bool init = true) + { + foreach (var sub in Repo.Submodules) UpdateSubmodule(sub.Name, init); + } + + private Identity BuildIdentity() + { + var signature = BuildSignature(); + return new Identity(signature.Name, signature.Email); + } + + public IReadOnlyList GetConflicts() + { + var list = new List(); + foreach (var conflict in Repo.Index.Conflicts) + { + var entry = conflict.Ours ?? conflict.Theirs ?? conflict.Ancestor; + if (entry is null) continue; + + var isBinary = + IsBinaryEntry(conflict.Ours) || + IsBinaryEntry(conflict.Theirs) || + IsBinaryEntry(conflict.Ancestor); + + list.Add(new ConflictInfo( + Path: entry.Path.Replace('\\', '/'), + HasBase: conflict.Ancestor is not null, + HasOurs: conflict.Ours is not null, + HasTheirs: conflict.Theirs is not null, + IsBinary: isBinary)); + } + list.Sort((a, b) => string.Compare(a.Path, b.Path, StringComparison.OrdinalIgnoreCase)); + return list; + } + + public ThreeWayContent GetThreeWayContent(string path) + { + var normalized = NormalizePath(path); + var conflict = Repo.Index.Conflicts[normalized] + ?? throw new ArgumentException($"충돌 항목이 아닙니다: {path}"); + + var baseBlob = LookupBlob(conflict.Ancestor); + var ourBlob = LookupBlob(conflict.Ours); + var theirBlob = LookupBlob(conflict.Theirs); + + var isBinary = (baseBlob?.IsBinary ?? false) || (ourBlob?.IsBinary ?? false) || (theirBlob?.IsBinary ?? false); + + return new ThreeWayContent( + Path: normalized, + BaseText: isBinary ? string.Empty : ReadBlobText(baseBlob, normalized), + OursText: isBinary ? string.Empty : ReadBlobText(ourBlob, normalized), + TheirsText: isBinary ? string.Empty : ReadBlobText(theirBlob, normalized), + IsBinary: isBinary); + } + + public void ResolveConflict(string path, ConflictResolution resolution) + { + var normalized = NormalizePath(path); + var conflict = Repo.Index.Conflicts[normalized] + ?? throw new ArgumentException($"충돌 항목이 아닙니다: {path}"); + + var chosen = resolution switch + { + ConflictResolution.Mine => conflict.Ours, + ConflictResolution.Theirs => conflict.Theirs, + ConflictResolution.Base => conflict.Ancestor, + _ => null + }; + + var fullPath = Path.Combine(Repo.Info.WorkingDirectory, normalized.Replace('/', Path.DirectorySeparatorChar)); + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); + + if (resolution == ConflictResolution.Both) + { + // 인코딩 변환 없이 바이트 단위로 이어붙인다 - CP949 등 원본 인코딩이 그대로 보존된다. + var oursBlob = LookupBlob(conflict.Ours); + var theirsBlob = LookupBlob(conflict.Theirs); + var ours = oursBlob is null ? Array.Empty() : ReadBlobBytes(oursBlob, normalized); + var theirs = theirsBlob is null ? Array.Empty() : ReadBlobBytes(theirsBlob, normalized); + + using var output = File.Create(fullPath); + output.Write(ours, 0, ours.Length); + if (ours.Length > 0 && ours[ours.Length - 1] != Lf) + { + var newLine = ContainsCrLf(ours) ? new[] { Cr, Lf } : new[] { Lf }; + output.Write(newLine, 0, newLine.Length); + } + output.Write(theirs, 0, theirs.Length); + } + else if (chosen is null) + { + // 채택한 쪽에 파일이 없음 = 삭제를 채택 + if (File.Exists(fullPath)) File.Delete(fullPath); + Commands.Remove(Repo, normalized, removeFromWorkingDirectory: false); + Report($"충돌 해결({resolution}): {normalized} (삭제 채택)"); + return; + } + else + { + var blob = LookupBlob(chosen)!; + using var source = OpenBlob(blob, normalized); + using var target = File.Create(fullPath); + source.CopyTo(target); + } + + Commands.Stage(Repo, normalized); + Report($"충돌 해결({resolution}): {normalized}"); + } + + public void ResolveConflictWithContent(string path, string content) + { + var normalized = NormalizePath(path); + var fullPath = ToFullPath(normalized); + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); + + // 편집 전 파일의 인코딩을 그대로 유지한다. (CP949 소스를 UTF-8 로 덮어쓰지 않도록) + var encoding = File.Exists(fullPath) + ? DetectEncoding(File.ReadAllBytes(fullPath)) + : new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + + File.WriteAllText(fullPath, content, encoding); + Commands.Stage(Repo, normalized); + Report($"충돌 해결(직접 편집, {encoding.WebName}): {normalized}"); + } + + public string CommitMerge(string? message = null) + { + var remaining = GetConflicts(); + if (remaining.Count > 0) + throw new InvalidOperationException($"아직 해결되지 않은 충돌이 {remaining.Count}건 있습니다."); + + if (string.IsNullOrWhiteSpace(message)) + message = ReadMergeMessage() ?? $"Merge into {Repo.Head.FriendlyName}"; + + var signature = BuildSignature(); + var commit = Repo.Commit(message, signature, signature); + Report($"병합 커밋: {Shorten(commit.Sha)} {commit.MessageShort}"); + return commit.Sha; + } + + private string? ReadMergeMessage() + { + var path = Path.Combine(Repo.Info.Path, "MERGE_MSG"); + return File.Exists(path) ? File.ReadAllText(path).Trim() : null; + } + + // ================= Diff ================= + + public IReadOnlyList DiffCommits(string oldCommittish, string newCommittish, params string[] paths) + { + var oldCommit = ResolveCommit(oldCommittish); + var newCommit = ResolveCommit(newCommittish); + if (newCommit is null) throw new ArgumentException($"커밋을 찾을 수 없습니다: {newCommittish}"); + + using var patch = Repo.Diff.Compare( + oldCommit?.Tree, + newCommit.Tree, + NormalizePaths(paths), + null, + BuildCompareOptions()); + + return BuildFileDiffs(patch); + } + + public IReadOnlyList DiffCommitWithParent(string committish, params string[] paths) + { + var commit = ResolveCommit(committish) + ?? throw new ArgumentException($"커밋을 찾을 수 없습니다: {committish}"); + + var parent = commit.Parents.FirstOrDefault(); + using var patch = Repo.Diff.Compare( + parent?.Tree, + commit.Tree, + NormalizePaths(paths), + null, + BuildCompareOptions()); + + return BuildFileDiffs(patch); + } + + public IReadOnlyList DiffWorkingDirectory(bool stagedOnly = false, params string[] paths) + { + var targets = stagedOnly ? DiffTargets.Index : DiffTargets.Index | DiffTargets.WorkingDirectory; + using var patch = Repo.Diff.Compare( + Repo.Head.Tip?.Tree, + targets, + NormalizePaths(paths), + null, + BuildCompareOptions()); + + return BuildFileDiffs(patch); + } + + private static CompareOptions BuildCompareOptions() => new() + { + ContextLines = 3, + InterhunkLines = 0, + Similarity = SimilarityOptions.Renames + }; + + private List BuildFileDiffs(Patch patch) + { + var list = new List(); + foreach (var change in patch) + { + var lines = change.IsBinaryComparison + ? (IReadOnlyList)Array.Empty() + : ParsePatch(change.Patch, IgnoreWhitespace, WordLevelDiff); + + var added = lines.Count(l => l.Kind == DiffLineKind.Added); + var deleted = lines.Count(l => l.Kind == DiffLineKind.Deleted); + + // 공백 변경 무시 옵션에서 실제 변경이 사라진 파일은 목록에서 제외 + if (IgnoreWhitespace && !change.IsBinaryComparison && added == 0 && deleted == 0) + continue; + + list.Add(new FileDiff( + Path: change.Path.Replace('\\', '/'), + OldPath: change.OldPath?.Replace('\\', '/'), + ChangeKind: change.Status.ToString(), + IsBinary: change.IsBinaryComparison, + LinesAdded: change.IsBinaryComparison ? change.LinesAdded : added, + LinesDeleted: change.IsBinaryComparison ? change.LinesDeleted : deleted, + Patch: change.Patch, + Lines: lines)); + } + return list; + } + + /// unified diff 텍스트를 줄 단위 구조로 변환한다. + internal static IReadOnlyList ParsePatch(string patchText, bool ignoreWhitespace, bool wordLevel = false) + { + var result = new List(); + if (string.IsNullOrEmpty(patchText)) return result; + + int oldLine = 0, newLine = 0; + + foreach (var raw in patchText.Split('\n')) + { + var line = raw.Length > 0 && raw[raw.Length - 1] == '\r' + ? raw.Substring(0, raw.Length - 1) + : raw; + if (line.Length == 0) continue; + + if (line.StartsWith("@@", StringComparison.Ordinal)) + { + (oldLine, newLine) = ParseHunkHeader(line); + result.Add(new DiffLine(DiffLineKind.HunkHeader, null, null, line)); + continue; + } + + if (IsPatchHeaderLine(line)) continue; + + var marker = line[0]; + var text = line[1..]; + + switch (marker) + { + case '+': + result.Add(new DiffLine(DiffLineKind.Added, null, newLine++, text)); + break; + case '-': + result.Add(new DiffLine(DiffLineKind.Deleted, oldLine++, null, text)); + break; + default: + result.Add(new DiffLine(DiffLineKind.Context, oldLine++, newLine++, text)); + break; + } + } + + var lines = ignoreWhitespace ? CollapseWhitespaceOnlyChanges(result) : result; + if (!wordLevel) return lines; + + var withWords = lines as List ?? lines.ToList(); + ApplyWordLevelDiff(withWords); + return withWords; + } + + private static bool IsPatchHeaderLine(string line) => + line.StartsWith("diff --git", StringComparison.Ordinal) || + line.StartsWith("index ", StringComparison.Ordinal) || + line.StartsWith("--- ", StringComparison.Ordinal) || + line.StartsWith("+++ ", StringComparison.Ordinal) || + line.StartsWith("new file mode", StringComparison.Ordinal) || + line.StartsWith("deleted file mode", StringComparison.Ordinal) || + line.StartsWith("old mode", StringComparison.Ordinal) || + line.StartsWith("new mode", StringComparison.Ordinal) || + line.StartsWith("similarity index", StringComparison.Ordinal) || + line.StartsWith("rename from", StringComparison.Ordinal) || + line.StartsWith("rename to", StringComparison.Ordinal) || + line.StartsWith("Binary files", StringComparison.Ordinal) || + line.StartsWith("\\ No newline", StringComparison.Ordinal); + + private static (int oldStart, int newStart) ParseHunkHeader(string header) + { + // @@ -12,7 +12,9 @@ optional section heading + int oldStart = 0, newStart = 0; + var minus = header.IndexOf('-'); + var plus = header.IndexOf('+'); + + if (minus >= 0) oldStart = ReadNumber(header, minus + 1); + if (plus >= 0) newStart = ReadNumber(header, plus + 1); + return (oldStart, newStart); + + static int ReadNumber(string s, int i) + { + var value = 0; + var any = false; + while (i < s.Length && char.IsDigit(s[i])) + { + value = value * 10 + (s[i] - '0'); + i++; + any = true; + } + return any ? value : 0; + } + } + + /// + /// 삭제/추가가 짝을 이루고 공백만 다른 경우 두 줄을 컨텍스트로 접는다. + /// (기획안 4p "whitespace-only diff ignore") + /// + private static IReadOnlyList CollapseWhitespaceOnlyChanges(IReadOnlyList lines) + { + var result = new List(lines.Count); + var i = 0; + + while (i < lines.Count) + { + if (lines[i].Kind != DiffLineKind.Deleted) + { + result.Add(lines[i++]); + continue; + } + + var delStart = i; + while (i < lines.Count && lines[i].Kind == DiffLineKind.Deleted) i++; + var addStart = i; + while (i < lines.Count && lines[i].Kind == DiffLineKind.Added) i++; + + var delCount = addStart - delStart; + var addCount = i - addStart; + + if (delCount == addCount) + { + var allWhitespaceOnly = true; + for (var k = 0; k < delCount; k++) + { + if (!IsWhitespaceOnlyDifference(lines[delStart + k].Text, lines[addStart + k].Text)) + { + allWhitespaceOnly = false; + break; + } + } + + if (allWhitespaceOnly) + { + for (var k = 0; k < delCount; k++) + { + result.Add(new DiffLine( + DiffLineKind.Context, + lines[delStart + k].OldLineNumber, + lines[addStart + k].NewLineNumber, + lines[addStart + k].Text)); + } + continue; + } + } + + for (var k = delStart; k < i; k++) result.Add(lines[k]); + } + + return result; + } + + // ================= 단어 단위 diff ================= + + /// 토큰 수가 이보다 많은 줄은 단어 비교를 건너뛴다(O(n*m) 방어). + private const int MaxWordDiffTokens = 400; + + public (IReadOnlyList Old, IReadOnlyList New) DiffWords(string oldLine, string newLine) + { + var result = ComputeWordDiff(oldLine, newLine); + return result ?? ( + new[] { new WordDiffSegment(WordDiffKind.Deleted, oldLine) }, + new[] { new WordDiffSegment(WordDiffKind.Added, newLine) }); + } + + /// + /// 두 줄을 토큰(단어/공백/기호)으로 쪼개 LCS 를 구하고, 공통 구간과 변경 구간을 나눈다. + /// 줄이 너무 길면 null 을 돌려주고 호출측이 줄 전체를 변경으로 처리한다. + /// + private static (IReadOnlyList Old, IReadOnlyList New)? ComputeWordDiff( + string oldLine, string newLine) + { + var a = Tokenize(oldLine); + var b = Tokenize(newLine); + if (a.Count > MaxWordDiffTokens || b.Count > MaxWordDiffTokens) return null; + + // LCS 길이 표 + var table = new int[a.Count + 1, b.Count + 1]; + for (var i = a.Count - 1; i >= 0; i--) + { + for (var j = b.Count - 1; j >= 0; j--) + { + table[i, j] = a[i] == b[j] + ? table[i + 1, j + 1] + 1 + : Math.Max(table[i + 1, j], table[i, j + 1]); + } + } + + var oldSegments = new List(); + var newSegments = new List(); + + int x = 0, y = 0; + while (x < a.Count && y < b.Count) + { + if (a[x] == b[y]) + { + Append(oldSegments, WordDiffKind.Unchanged, a[x]); + Append(newSegments, WordDiffKind.Unchanged, b[y]); + x++; y++; + } + else if (table[x + 1, y] >= table[x, y + 1]) + { + Append(oldSegments, WordDiffKind.Deleted, a[x++]); + } + else + { + Append(newSegments, WordDiffKind.Added, b[y++]); + } + } + while (x < a.Count) Append(oldSegments, WordDiffKind.Deleted, a[x++]); + while (y < b.Count) Append(newSegments, WordDiffKind.Added, b[y++]); + + return (oldSegments, newSegments); + + // 같은 종류가 이어지면 하나로 합쳐 세그먼트 수를 줄인다. + static void Append(List target, WordDiffKind kind, string text) + { + if (target.Count > 0 && target[^1].Kind == kind) + target[^1] = target[^1] with { Text = target[^1].Text + text }; + else + target.Add(new WordDiffSegment(kind, text)); + } + } + + /// 식별자 / 숫자 / 공백 덩어리 / 단일 기호 단위로 자른다. + private static List Tokenize(string line) + { + var tokens = new List(); + var i = 0; + + while (i < line.Length) + { + var start = i; + var c = line[i]; + + if (char.IsWhiteSpace(c)) + { + while (i < line.Length && char.IsWhiteSpace(line[i])) i++; + } + else if (char.IsLetterOrDigit(c) || c == '_') + { + while (i < line.Length && (char.IsLetterOrDigit(line[i]) || line[i] == '_')) i++; + } + else + { + i++; + } + + tokens.Add(line[start..i]); + } + + return tokens; + } + + /// 짝을 이루는 삭제/추가 줄에 단어 단위 변경 구간을 채운다. + private static void ApplyWordLevelDiff(List lines) + { + var i = 0; + while (i < lines.Count) + { + if (lines[i].Kind != DiffLineKind.Deleted) { i++; continue; } + + var delStart = i; + while (i < lines.Count && lines[i].Kind == DiffLineKind.Deleted) i++; + var addStart = i; + while (i < lines.Count && lines[i].Kind == DiffLineKind.Added) i++; + + var pairs = Math.Min(addStart - delStart, i - addStart); + for (var k = 0; k < pairs; k++) + { + var oldLine = lines[delStart + k]; + var newLine = lines[addStart + k]; + + var diff = ComputeWordDiff(oldLine.Text, newLine.Text); + if (diff is null) continue; + + lines[delStart + k] = oldLine with { Words = diff.Value.Old }; + lines[addStart + k] = newLine with { Words = diff.Value.New }; + } + } + } + + private static bool IsWhitespaceOnlyDifference(string a, string b) + { + int i = 0, j = 0; + while (true) + { + while (i < a.Length && char.IsWhiteSpace(a[i])) i++; + while (j < b.Length && char.IsWhiteSpace(b[j])) j++; + + if (i >= a.Length || j >= b.Length) return i >= a.Length && j >= b.Length; + if (a[i] != b[j]) return false; + i++; j++; + } + } + + // ================= 확장 ================= + + public IReadOnlyList Blame(string path, string? committish = null) + { + var normalized = NormalizePath(path); + + var options = new BlameOptions(); + if (!string.IsNullOrWhiteSpace(committish)) + { + var start = ResolveCommit(committish!); + if (start is not null) options.StartingAt = start; + } + + var hunks = Repo.Blame(normalized, options); + + string[] fileLines; + if (string.IsNullOrWhiteSpace(committish)) + { + var full = Path.Combine(Repo.Info.WorkingDirectory, normalized.Replace('/', Path.DirectorySeparatorChar)); + fileLines = File.Exists(full) + ? File.ReadAllLines(full) + : SplitLines(GetFileTextAtCommit("HEAD", normalized)); + } + else + { + fileLines = SplitLines(GetFileTextAtCommit(committish!, normalized)); + } + + var result = new List(fileLines.Length); + foreach (var hunk in hunks) + { + var commit = hunk.FinalCommit; + for (var k = 0; k < hunk.LineCount; k++) + { + var lineIndex = hunk.FinalStartLineNumber + k; + if (lineIndex < 0 || lineIndex >= fileLines.Length) continue; + + result.Add(new BlameLineInfo( + LineNumber: lineIndex + 1, + Text: fileLines[lineIndex], + CommitSha: commit.Sha, + ShortSha: Shorten(commit.Sha), + AuthorName: commit.Author.Name, + When: commit.Author.When, + MessageShort: commit.MessageShort)); + } + } + + result.Sort((a, b) => a.LineNumber.CompareTo(b.LineNumber)); + return result; + } + + public IReadOnlyList GetStashes() + { + var list = new List(); + var index = 0; + foreach (var stash in Repo.Stashes) + { + list.Add(new StashInfo(index++, stash.Message, stash.WorkTree?.Sha ?? string.Empty)); + } + return list; + } + + public void StashSave(string message) + { + Repo.Stashes.Add(BuildSignature(), message); + Report($"stash 저장: {message}"); + } + + public void StashPop(int index = 0) + { + var status = Repo.Stashes.Pop(index); + Report($"stash 적용: {status}"); + } + + public void StashDrop(int index) + { + Repo.Stashes.Remove(index); + Report($"stash 삭제: index {index}"); + } + + public void RegisterAsMergeTool(string toolName, string executablePath, bool global = true) + { + var level = global ? ConfigurationLevel.Global : ConfigurationLevel.Local; + var exe = executablePath.Replace('\\', '/'); + + // $BASE/$LOCAL/$REMOTE/$MERGED 는 git 이 넘겨주는 임시 파일 경로 + Repo.Config.Set("merge.tool", toolName, level); + Repo.Config.Set($"mergetool.{toolName}.cmd", + $"\"{exe}\" --merge --base \"$BASE\" --local \"$LOCAL\" --remote \"$REMOTE\" --merged \"$MERGED\"", level); + Repo.Config.Set($"mergetool.{toolName}.trustExitCode", "true", level); + Repo.Config.Set("mergetool.keepBackup", "false", level); + + Repo.Config.Set("diff.tool", toolName, level); + Repo.Config.Set($"difftool.{toolName}.cmd", + $"\"{exe}\" --diff --local \"$LOCAL\" --remote \"$REMOTE\"", level); + + Report($"mergetool 등록 완료: merge.tool={toolName} ({(global ? "global" : "local")})"); + } + + public string? GetConfig(string key) => Repo.Config.Get(key)?.Value; + + public void SetConfig(string key, string value, bool global = false) => + Repo.Config.Set(key, value, global ? ConfigurationLevel.Global : ConfigurationLevel.Local); + + // ================= 내부 헬퍼 ================= + + private Commit? ResolveCommit(string committish) + { + if (string.IsNullOrWhiteSpace(committish)) return null; + + if (string.Equals(committish, "HEAD", StringComparison.OrdinalIgnoreCase)) + return Repo.Head.Tip; + + var branch = Repo.Branches[committish]; + if (branch?.Tip is not null) return branch.Tip; + + var obj = Repo.Lookup(committish); + return obj switch + { + Commit c => c, + TagAnnotation t => t.Target as Commit, + _ => null + }; + } + + private static CommitInfo ToCommitInfo(Commit c, Dictionary>? refsByTarget) + { + List? refs = null; + refsByTarget?.TryGetValue(c.Sha, out refs); + + return new CommitInfo( + Sha: c.Sha, + ShortSha: Shorten(c.Sha), + MessageShort: c.MessageShort, + Message: c.Message, + AuthorName: c.Author.Name, + AuthorEmail: c.Author.Email, + When: c.Author.When, + ParentShas: c.Parents.Select(p => p.Sha).ToList(), + Refs: refs ?? new List()); + } + + private Blob? LookupBlob(IndexEntry? entry) => + entry is null ? null : Repo.Lookup(entry.Id); + + private bool IsBinaryEntry(IndexEntry? entry) => LookupBlob(entry)?.IsBinary ?? false; + + /// + /// blob 본문을 읽는다. hintPath 를 주면 git 의 checkout 필터(core.autocrlf, .gitattributes 의 + /// text/eol 속성)를 적용해 워킹트리에 기록될 형태 그대로 얻는다. + /// 이걸 빼면 CRLF 저장소의 파일이 LF 로 바뀌어 전체 줄이 변경으로 잡힌다. + /// + private static string ReadBlobText(Blob? blob, string? hintPath = null) => + blob is null ? string.Empty : DecodeText(ReadBlobBytes(blob, hintPath)); + + private static byte[] ReadBlobBytes(Blob blob, string? hintPath) + { + using var stream = OpenBlob(blob, hintPath); + using var buffer = new MemoryStream(); + stream.CopyTo(buffer); + return buffer.ToArray(); + } + + private static Stream OpenBlob(Blob blob, string? hintPath) => + hintPath is null ? blob.GetContentStream() : blob.GetContentStream(new FilteringOptions(hintPath)); + + /// + /// BOM -> UTF-8 유효성 -> 시스템 코드페이지 순으로 인코딩을 판별한다. + /// 사내 C/C++ 소스가 CP949 로 저장된 경우 UTF-8 로 강제 디코딩하면 한글이 깨진다. + /// + private static Encoding DetectEncoding(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 AnsiEncoding; + } + } + + private static string DecodeText(byte[] bytes) + { + if (bytes.Length == 0) return string.Empty; + + var encoding = DetectEncoding(bytes); + var preamble = encoding.GetPreamble(); + var offset = bytes.Length >= preamble.Length && preamble.Length > 0 && bytes.Take(preamble.Length).SequenceEqual(preamble) + ? preamble.Length + : 0; + + return encoding.GetString(bytes, offset, bytes.Length - offset); + } + + /// 워킹트리 파일을 인코딩 자동 판별로 읽는다. (충돌 마커가 들어간 상태 그대로) + public string ReadWorkingFileText(string path) + { + var full = ToFullPath(path); + return File.Exists(full) ? DecodeText(File.ReadAllBytes(full)) : string.Empty; + } + + /// + /// 워킹트리 파일의 인코딩과 줄바꿈 방식을 사람이 읽을 형태로 알려준다. + /// 편집 화면 상태바에 띄워, 저장할 때 무엇이 유지되는지 사용자가 알 수 있게 한다. + /// + public TextFileInfo DescribeWorkingFile(string path) + { + var full = ToFullPath(path); + if (!File.Exists(full)) return new TextFileInfo("-", "-"); + + var bytes = File.ReadAllBytes(full); + return new TextFileInfo(DescribeEncoding(bytes), DescribeNewLine(DecodeText(bytes))); + } + + private static string DescribeEncoding(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) + { + // 시스템 코드페이지. 국내 장비 소스는 대개 CP949 다. + return AnsiEncoding.WebName.ToUpperInvariant(); + } + } + + /// 줄바꿈이 섞여 있으면 그 사실 자체가 정보다 - 감추지 않고 "혼합"으로 알린다. + private static string DescribeNewLine(string text) + { + if (text.Length == 0) return "-"; + + var crlf = 0; var lf = 0; var cr = 0; + + for (var i = 0; i < text.Length; i++) + { + if (text[i] == 0x0D) + { + if (i + 1 < text.Length && text[i + 1] == 0x0A) { crlf++; i++; } + else cr++; + } + else if (text[i] == 0x0A) 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"; + } + + private string ToFullPath(string path) => + Path.Combine(Repo.Info.WorkingDirectory, NormalizePath(path).Replace('/', Path.DirectorySeparatorChar)); + + private static string[] SplitLines(string text) => + text.Length == 0 ? Array.Empty() : text.Replace("\r\n", "\n").Split('\n'); + + private const byte Cr = 0x0D; + private const byte Lf = 0x0A; + + private static bool ContainsCrLf(byte[] bytes) + { + for (var i = 1; i < bytes.Length; i++) + if (bytes[i] == Lf && bytes[i - 1] == Cr) return true; + return false; + } + + private static string NormalizePath(string path) => path.Replace('\\', '/').TrimStart('/'); + + private static string[]? NormalizePaths(string[]? paths) => + paths is null || paths.Length == 0 ? null : paths.Select(NormalizePath).ToArray(); + + private static string Shorten(string? sha) => + string.IsNullOrEmpty(sha) ? string.Empty : (sha!.Length <= 7 ? sha : sha[..7]); + + private Signature BuildSignature() + { + var name = Repo.Config.Get("user.name")?.Value; + var email = Repo.Config.Get("user.email")?.Value; + + if (string.IsNullOrWhiteSpace(name)) name = Environment.UserName; + if (string.IsNullOrWhiteSpace(email)) email = $"{Environment.UserName}@localhost"; + + return new Signature(name, email, DateTimeOffset.Now); + } + + private CredentialsHandler? BuildCredentialsProvider() + { + var credentials = Credentials; + if (credentials is null) return null; + + return (_, _, _) => new UsernamePasswordCredentials + { + Username = credentials.Username, + Password = credentials.Password + }; + } + + private FetchOptions BuildFetchOptions() + { + var lastPercent = -1; + + return new FetchOptions + { + CredentialsProvider = BuildCredentialsProvider(), + OnTransferProgress = progress => + { + if (progress.TotalObjects <= 0) return true; + var percent = progress.ReceivedObjects * 100 / progress.TotalObjects; + if (percent != lastPercent && percent % 10 == 0) + { + lastPercent = percent; + Report($"수신 {percent}% ({progress.ReceivedObjects}/{progress.TotalObjects})"); + } + return true; + } + }; + } + + public void Dispose() => Close(); + } +} diff --git a/src/MergeHub/Services/GitWorkspace.cs b/src/MergeHub/Services/GitWorkspace.cs new file mode 100644 index 0000000..482fd83 --- /dev/null +++ b/src/MergeHub/Services/GitWorkspace.cs @@ -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 +{ + /// + /// 홈(머지툴) 탭과 Git 탭이 함께 쓰는 저장소 상태. + /// IGitService 는 스레드 안전하지 않으므로 모든 호출을 여기서 한 줄로 직렬화한다. + /// DI 싱글턴으로 등록되어 두 화면이 같은 인스턴스를 본다. + /// + 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"); + + /// 플랫폼에 등록된 사용자. 머지 주석에 남길 이름을 여기서 가져온다. + 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; } + + /// 저장소 상태가 다시 읽힐 때마다 발생. 화면들이 자기 패널을 갱신한다. + public event Action? Refreshed; + + /// + /// 작업이 끝나 커맨드의 실행 가능 여부가 달라졌을 수 있음을 알린다. + /// CommunityToolkit 의 RelayCommand 는 CommandManager 를 구독하지 않아 + /// 각 ViewModel 이 직접 NotifyCanExecuteChanged 를 불러야 한다. + /// + public event Action? StateChanged; + + // ================= 공유 목록 ================= + + public ObservableCollection Branches { get; } = new ObservableCollection(); + public ObservableCollection Commits { get; } = new ObservableCollection(); + + /// 커밋 + 브랜치 그래프 레인. 홈 화면의 커밋 트리가 이걸 바인딩한다. + public ObservableCollection CommitNodes { get; } = new ObservableCollection(); + public ObservableCollection StatusFiles { get; } = new ObservableCollection(); + public ObservableCollection Conflicts { get; } = new ObservableCollection(); + public ObservableCollection Tags { get; } = new ObservableCollection(); + public ObservableCollection Submodules { get; } = new ObservableCollection(); + + // ================= 공유 상태 ================= + + [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; + + /// 원격 등록 여부만 나타낸다 - 서버 접속 확인은 Fetch 를 눌러야 이뤄진다. + [ObservableProperty] private string _remoteStatusText = "원격 없음"; + + /// 현재 원격의 주소(clone/push 대상). + [ObservableProperty] private string _remoteUrlText = "-"; + + /// 추적 브랜치와 비교한 앞섬/뒤처짐. 마지막 fetch 시점 기준이다. + [ObservableProperty] private string _syncStatusText = "-"; + + /// 추적 브랜치와 완전히 같은 상태인지. + [ObservableProperty] private bool _isUpToDate; + + [ObservableProperty] private string _lastFetchText = "fetch 이력 없음"; + + /// 머지 주석에 남길 작성자. 설정값 > git config user.name > 윈도우 계정 순. + [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; + + // ================= 실행 ================= + + /// + /// Git 호출 하나를 백그라운드에서 직렬 실행한다. + /// 실패하면 로그 + Error 토스트를 띄우고 false 를 돌려준다. + /// + public async Task 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()); + } + } + + /// 작업 하나를 실행하고 성공하면 저장소 상태를 다시 읽는다. + public async Task RunAndRefreshAsync(string operation, Action action) + { + var ok = await RunAsync(operation, action); + if (ok) await RefreshAsync(); + return ok; + } + + /// + /// 기본 경로가 Git 저장소로 보이는지. 워크트리는 .git 이 파일이라 둘 다 본다. + /// + /// 저장소가 아닌 폴더에 Open 을 걸면 실패 알림이 뜰 뿐 아니라, 그동안 작업 게이트를 + /// 쥐고 있어서 뒤이어 시작하는 "파일 두 개 비교"까지 통째로 취소된다. + /// + public bool LooksLikeRepository => + !string.IsNullOrWhiteSpace(RepositoryPath) && + (Directory.Exists(Path.Combine(RepositoryPath, ".git")) || + File.Exists(Path.Combine(RepositoryPath, ".git"))); + + /// 설정에 기본 저장소 경로가 있으면 시작하자마자 연다. + public Task TryAutoOpenAsync() + { + if (IsRepositoryOpen || string.IsNullOrWhiteSpace(RepositoryPath) || !Directory.Exists(RepositoryPath)) + return Task.CompletedTask; + + if (!LooksLikeRepository) return Task.CompletedTask; + + return RunAndRefreshAsync("저장소 자동 열기", () => Git.Open(RepositoryPath)); + } + + /// + /// 상태를 다시 읽고, **게이트를 놓은 뒤에** Refreshed 를 발생시킨다. + /// 게이트를 쥔 채로 알리면 구독자가 거는 Git 호출이 전부 버려진다. + /// + 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]; + }); + } + + /// + /// 추적 브랜치와 몇 개 차이 나는지 문장으로 만든다. + /// 이 수치는 **마지막 fetch 시점** 기준이라, fetch 전에는 서버가 앞서 있어도 "최신"으로 보인다. + /// + 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); + } + + /// + /// MergeHub 설정 > 플랫폼에 등록된 사용자 > git config user.name > 윈도우 계정 순으로 고른다. + /// + /// 플랫폼 사용자를 Git 설정보다 앞에 두는 이유 - 처음 켤 때 사람이 직접 적어 넣은 이름이고, + /// 장비를 여럿이 쓰는 현장에서는 그 이름이 실제로 작업한 사람이다. + /// MergeHub 설정을 더 앞에 두는 것은 "이 도구에서만 다르게 남기고 싶다"는 뜻이라 존중한다. + /// + 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; + } + + /// fetch 가 실제로 성공한 시각을 기록한다. 동기화 표시가 언제 기준인지 알리기 위함. + 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}"; + + /// + /// "이 두 파일을 줄 단위로 비교해 달라" - 폴더 비교 탭이 보내고 파일 비교 탭이 받는다. + /// + /// 두 화면이 서로를 직접 알지 않도록 이미 둘 다 들고 있는 이 싱글턴을 통로로 쓴다. + /// + public event Action? PlainCompareRequested; + + /// 아직 아무도 받지 않은 요청. 아래 설명 참고. + private (string Left, string Right)? _pendingPlainCompare; + + public bool HasPendingPlainCompare => _pendingPlainCompare is not null; + + /// + /// 요청을 남겨 두고 받을 사람이 있으면 바로 넘긴다. + /// + /// 남겨 두는 것이 핵심이다. 탭 내용은 그 탭을 처음 열 때 만들어지므로, + /// 폴더 비교에서 파일을 처음 더블클릭하는 순간에는 파일 비교 화면이 아직 없어 + /// 이벤트를 받을 사람이 없다. 그냥 발생시키면 요청이 허공에 사라지고, + /// 새로 만들어진 화면은 기본 파일을 연다 - "더블클릭이 안 먹는다"로 보이는 이유다. + /// + public void RequestPlainCompare(string leftFile, string rightFile) + { + _pendingPlainCompare = (leftFile, rightFile); + OnUi(DeliverPendingPlainCompare); + } + + /// 남아 있는 요청을 넘긴다. 화면이 새로 만들어지면 스스로 한 번 불러 준다. + public void DeliverPendingPlainCompare() + { + if (_pendingPlainCompare is not { } pending) return; + if (PlainCompareRequested is null) return; + + _pendingPlainCompare = null; + PlainCompareRequested.Invoke(pending.Left, pending.Right); + } + + /// ObservableCollection 변경은 UI 스레드에서만 허용된다. + public void OnUi(Action action) + { + if (_uiContext is null || SynchronizationContext.Current == _uiContext) + { + action(); + return; + } + _uiContext.Send(_ => action(), null); + } + + private static void Replace(ObservableCollection target, IEnumerable items) + { + target.Clear(); + foreach (var item in items) target.Add(item); + } + } +} diff --git a/src/MergeHub/Services/IGitService.cs b/src/MergeHub/Services/IGitService.cs new file mode 100644 index 0000000..0af5fa3 --- /dev/null +++ b/src/MergeHub/Services/IGitService.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using MergeHub.Models; + +namespace MergeHub.Services +{ + /// + /// 기획안 9p "Git 연동 방식 - 로컬 Git 조작" 계층. + /// git.exe 프로세스를 띄우지 않고 LibGit2Sharp 로 .git 을 직접 조작한다. + /// 모든 메서드는 동기이며 스레드 안전하지 않다. UI 에서는 Task.Run 으로 감싸 호출할 것. + /// + public interface IGitService : IDisposable + { + bool IsOpen { get; } + + /// 원격 작업(fetch/pull/push/clone)에 사용할 인증 정보. 사내 Gitea 계정 또는 토큰. + GitCredentials? Credentials { get; set; } + + /// diff 계산 시 공백만 다른 줄을 변경으로 보지 않음. (기획안 4p "공백 변경 무시") + bool IgnoreWhitespace { get; set; } + + /// 진행 상황 로그 (clone/fetch 진행률 등). + event EventHandler? Progress; + + // ---- 저장소 열기 -------------------------------------------------- + RepositoryInfo Open(string path); + RepositoryInfo Clone(string remoteUrl, string localPath, string? branch = null); + void Close(); + RepositoryInfo GetRepositoryInfo(); + // ---- 브랜치 ------------------------------------------------------- + IReadOnlyList 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); + + /// 원격 추적 브랜치(origin/xxx)를 로컬로 만들고 체크아웃. + BranchInfo CheckoutRemoteBranch(string remoteBranchName); + + // ---- 원격 --------------------------------------------------------- + IReadOnlyList GetRemotes(); + + /// 다른 저장소의 소스를 끌어와 병합할 때 사용. 이미 있으면 URL 을 갱신한다. + 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 GetCommits(string? committish = null, int maxCount = 200); + CommitInfo? GetCommit(string committish); + string? GetMergeBaseSha(string committishA, string committishB); + + /// 특정 커밋 시점의 파일 본문. (목업 1번 패널: 커밋 클릭 -> 본문 로드) + string GetFileTextAtCommit(string committish, string path); + + IReadOnlyList 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); + + // ---- 병합 --------------------------------------------------------- + /// + /// 브랜치를 현재 HEAD 로 병합. 기획안 6p 비목표("사람 확인 없는 자동 커밋 금지")에 따라 + /// 기본값은 자동 커밋하지 않고 인덱스까지만 병합한다. + /// + MergeResultInfo Merge(string branchName, bool commitOnSuccess = false, bool allowFastForward = true); + + /// 병합 진행 중 취소. reset --hard HEAD 와 동일. + void AbortMerge(); + + bool IsMergeInProgress { get; } + + IReadOnlyList GetConflicts(); + + /// 충돌 파일의 Base/Mine/Theirs 3분할 본문. + ThreeWayContent GetThreeWayContent(string path); + + /// Accept Mine / Theirs / Both 버튼 처리. 파일에 기록하고 인덱스에서 충돌 해제. + void ResolveConflict(string path, ConflictResolution resolution); + + /// 워킹트리 파일을 인코딩 자동 판별로 읽는다. (충돌 마커가 들어간 상태 그대로) + string ReadWorkingFileText(string path); + + /// 워킹트리 파일의 인코딩·줄바꿈 방식(상태바 표시용). + TextFileInfo DescribeWorkingFile(string path); + + /// 편집기에서 직접 수정한 본문으로 해결. + void ResolveConflictWithContent(string path, string content); + + /// 충돌을 모두 해결한 뒤 병합 커밋 생성. 메시지 생략 시 기본 메시지 사용. + string CommitMerge(string? message = null); + + /// 병합/cherry-pick/revert/rebase 중 무엇이든 진행 중인 작업을 취소한다. + void AbortOperation(); + + // ---- 되돌리기 ----------------------------------------------------- + /// git reset --soft/--mixed/--hard. + void Reset(ResetKind kind, string committish = "HEAD"); + + /// git checkout -- <paths>. 워킹트리 수정을 버리고 특정 커밋 상태로 되돌린다. + void RestoreFiles(IEnumerable paths, string committish = "HEAD"); + + /// git revert. 지정 커밋의 변경을 되돌리는 새 커밋을 만든다. + PatchApplyResult Revert(string committish, bool commitOnSuccess = false, int mainline = 0); + + /// git cherry-pick. 다른 브랜치의 커밋 하나만 현재 브랜치로 가져온다. + PatchApplyResult CherryPick(string committish, bool commitOnSuccess = false, int mainline = 0); + + // ---- rebase ------------------------------------------------------- + /// + /// git rebase <upstream>. branch 를 생략하면 현재 HEAD 를 재배치한다. + /// libgit2 는 interactive rebase 를 지원하지 않는다. + /// + RebaseResultInfo RebaseStart(string upstreamBranch, string? branch = null, string? onto = null); + + /// 충돌을 해결하고 스테이징한 뒤 다음 단계로 진행. (git rebase --continue) + RebaseResultInfo RebaseContinue(); + + /// git rebase --abort. + void RebaseAbort(); + + RebaseStatusInfo GetRebaseStatus(); + + // ---- 태그 --------------------------------------------------------- + IReadOnlyList GetTags(); + + /// message 를 주면 annotated tag, 생략하면 lightweight tag. + TagInfo CreateTag(string name, string? committish = null, string? message = null); + + void DeleteTag(string name); + + // ---- 서브모듈 ----------------------------------------------------- + IReadOnlyList GetSubmodules(); + + /// git submodule update --init. 아직 받지 않은 서브모듈을 clone/체크아웃한다. + void UpdateSubmodule(string name, bool init = true); + + void UpdateAllSubmodules(bool init = true); + + // ---- Diff --------------------------------------------------------- + /// diff 결과의 각 줄에 단어 단위 변경 구간(DiffLine.Words)을 채운다. (기획안 4p) + bool WordLevelDiff { get; set; } + + IReadOnlyList DiffCommits(string oldCommittish, string newCommittish, params string[] paths); + IReadOnlyList DiffCommitWithParent(string committish, params string[] paths); + IReadOnlyList DiffWorkingDirectory(bool stagedOnly = false, params string[] paths); + + /// 두 줄을 비교해 바뀐 구간만 뽑는다. (3-way 뷰에서 줄 단위로 호출) + (IReadOnlyList Old, IReadOnlyList New) DiffWords(string oldLine, string newLine); + + // ---- 확장 --------------------------------------------------------- + IReadOnlyList Blame(string path, string? committish = null); + IReadOnlyList GetStashes(); + void StashSave(string message); + void StashPop(int index = 0); + void StashDrop(int index); + + /// 기획안 4p "mergetool 등록" - git config 에 merge.tool / difftool 등록. + void RegisterAsMergeTool(string toolName, string executablePath, bool global = true); + + string? GetConfig(string key); + void SetConfig(string key, string value, bool global = false); + } +} diff --git a/src/MergeHub/Services/LanguageProfile.cs b/src/MergeHub/Services/LanguageProfile.cs new file mode 100644 index 0000000..2580ff9 --- /dev/null +++ b/src/MergeHub/Services/LanguageProfile.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace MergeHub.Services +{ + /// + /// 한 가지 언어(또는 문서 형식)를 어떻게 읽을지 적어 둔 표. + /// + /// 언어마다 토크나이저를 따로 만들지 않는 이유가 있다. 색칠에 필요한 것은 + /// "주석은 어디서 시작하나 · 문자열은 무엇으로 묶나 · 예약어는 무엇인가" 이 셋뿐이고, + /// 나머지(숫자·식별자·연산자)는 어느 언어나 거의 같다. 그래서 + /// 하나가 이 표를 보고 동작한다. + /// + /// C# 만 예외로 전용 토크나이저를 그대로 쓴다 - 축자/보간 문자열(@"" $"")과 + /// 접근제한자 구분처럼 표로 적기 어려운 규칙이 있고, 이미 테스트로 굳어 있다. + /// + public sealed class LanguageProfile + { + public LanguageProfile(string name, + string[] extensions, + string[]? lineComments = null, + string? blockStart = null, + string? blockEnd = null, + char[]? quotes = null, + IEnumerable? keywords = null, + IEnumerable? controlKeywords = null, + IEnumerable? typeKeywords = null, + bool caseSensitive = true, + bool isCode = true, + bool hasBraces = false, + bool useCSharpTokenizer = false) + { + Name = name; + Extensions = extensions; + LineComments = lineComments ?? Array.Empty(); + BlockStart = blockStart; + BlockEnd = blockEnd; + Quotes = quotes ?? Array.Empty(); + CaseSensitive = caseSensitive; + IsCode = isCode; + HasBraces = hasBraces; + UseCSharpTokenizer = useCSharpTokenizer; + + var comparer = caseSensitive ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase; + Keywords = new HashSet(keywords ?? Array.Empty(), comparer); + ControlKeywords = new HashSet(controlKeywords ?? Array.Empty(), comparer); + TypeKeywords = new HashSet(typeKeywords ?? Array.Empty(), 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; } + + /// 문법 색을 입힐 대상인지. 평문·표 형식 문서는 false 다. + public bool IsCode { get; } + + /// 중괄호로 블록을 여닫는지. 코드 검사(짝 맞추기)를 걸 수 있는 조건이다. + public bool HasBraces { get; } + + /// C# 전용 토크나이저를 쓸지. + public bool UseCSharpTokenizer { get; } + + public HashSet Keywords { get; } + public HashSet ControlKeywords { get; } + public HashSet TypeKeywords { get; } + + public bool HasBlockComment => BlockStart is { Length: > 0 } && BlockEnd is { Length: > 0 }; + } + + /// + /// 확장자로 언어를 고른다. 모르는 확장자는 평문으로 본다 - + /// 이 도구는 소스만이 아니라 설정 파일·레시피·문서도 비교해야 한다. + /// + 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: "", + 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); + + /// 화면에 고를 수 있게 늘어놓는 순서. 평문이 맨 앞이다(가장 넓게 쓰인다). + public static readonly IReadOnlyList All = new[] + { + Plain, CSharp, CFamily, Java, JavaScript, Python, Sql, Shell, Markup, Json, Yaml, Ini, Markdown + }; + + /// 경로의 확장자로 언어를 고른다. 모르면 평문. + 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; + } + } +} diff --git a/src/MergeHub/Services/LineAligner.cs b/src/MergeHub/Services/LineAligner.cs new file mode 100644 index 0000000..fa6bf95 --- /dev/null +++ b/src/MergeHub/Services/LineAligner.cs @@ -0,0 +1,310 @@ +using System; +using System.Collections.Generic; + +namespace MergeHub.Services +{ + /// 정렬된 한 행. 한쪽에만 있는 줄은 반대쪽이 null 이다. + 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; } + } + + /// + /// 두 줄 목록을 "내용이 같은 줄끼리" 맞춰 나란히 놓는다. + /// + /// 줄 번호로 짝지으면 한쪽에 줄이 하나만 늘어나도 그 아래가 통째로 밀려 + /// 똑같은 코드가 서로 다른 줄과 비교된다. 그래서 공통 줄을 먼저 고정하고 + /// 그 사이에 낀 것만 추가/삭제/수정으로 본다. + /// + /// 공통 줄을 고르는 방식이 정렬 품질을 좌우한다. 예전에는 최장 공통 부분수열(LCS)을 + /// 썼는데, LCS 는 "가장 많이 겹치는" 답을 고르다 보니 {, }, 빈 줄처럼 + /// 짧고 자주 나오는 줄을 엉뚱한 짝에 붙여 정렬이 지저분해진다. + /// + /// 지금은 히스토그램 방식이다. 원리는 이렇다. + /// 1. BASE 구간에 어떤 줄이 몇 번 나오는지 센다(= 히스토그램). + /// 2. 양쪽에 다 있으면서 드물게 나오는 줄을 앵커로 삼는다. + /// 드문 줄일수록 "그 줄이 맞다"는 확신이 크기 때문이다. + /// 3. 앵커에서 위아래로 같은 줄이 이어지는 만큼 구간을 넓힌다. + /// 4. 가장 긴(같으면 더 드문) 구간을 확정하고, 그 앞뒤를 같은 방법으로 다시 나눈다. + /// + /// git 이 기본으로 쓰는 방식이며, LCS 와 달리 표를 만들지 않아 큰 파일에서도 가볍다. + /// + public static class LineAligner + { + /// + /// 한 줄이 이보다 자주 나오면 앵커로 쓰지 않는다. + /// 여닫는 중괄호나 빈 줄처럼 어디에나 있는 줄은 위치를 특정해 주지 못한다. + /// git 이 쓰는 값과 같다. + /// + private const int MaxOccurrences = 64; + + /// + /// 한 번에 정렬을 시도할 구간의 최대 줄 수. 넘으면 정확한 정렬을 포기하고 + /// 통째로 수정 취급한다 - 화면은 뜨게 하려는 안전선이다. + /// + /// LCS 때는 표 크기(가로×세로)로 막아야 했지만 히스토그램은 표를 만들지 않는다. + /// 중복이 극심한 입력에서 구간 확장 비용이 커지는 것만 막으면 되므로 줄 수로 센다. + /// + private const int MaxRegionLines = 20_000; + + /// + /// 두 줄 목록을 맞춰 놓는다. + /// + /// / 를 주면 "같은 줄인가"를 + /// 그쪽으로 판단한다. 공백·대소문자를 무시할 때 쓴다 - 원문은 그대로 돌려주고 + /// 비교만 다르게 하려는 것이다. 안 주면 원문끼리 비교한다. + /// + public static IReadOnlyList Align(IReadOnlyList baseLines, + IReadOnlyList localLines, + IReadOnlyList? baseKeys = null, + IReadOnlyList? localKeys = null) + { + var a = baseKeys ?? baseLines; + var b = localKeys ?? localLines; + + var rows = new List(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; + } + + /// 정렬할 구간 하나. IsCommon 이면 이미 짝이 확정된 공통 구간이다. + 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; } + } + + /// + /// 앵커를 찾아 구간을 셋(앞 / 앵커 / 뒤)으로 쪼개기를 반복한다. + /// + /// 재귀 대신 스택을 쓴다. 변경 덩어리가 많은 파일은 쪼개는 횟수가 줄 수에 비례해 + /// 재귀로 짜면 깊이가 수천이 되어 스택이 넘칠 수 있다. + /// + private static void AlignRegion(IReadOnlyList baseText, IReadOnlyList localText, + IReadOnlyList a, IReadOnlyList b, + int aStart, int aEnd, int bStart, int bEnd, + List rows) + { + var work = new Stack(); + 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)); + } + } + + /// + /// 구간 안에서 가장 믿을 만한 공통 구간(앵커)을 찾는다. + /// 길수록 좋고, 길이가 같으면 그 안에 흔한 줄이 적은 쪽이 낫다. + /// + private static bool TryFindAnchor(IReadOnlyList a, IReadOnlyList 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; + } + + /// + /// 구간 안 줄별 등장 위치. MaxOccurrences 를 넘으면 더 담지 않는다 - + /// 흔한 줄은 어차피 앵커로 안 쓰므로 위치를 다 기억할 이유가 없다. + /// (담기를 멈춰도 Count 가 상한을 한 칸 넘으므로 "흔하다"는 판정은 그대로 된다.) + /// + private static Dictionary> BuildHistogram(IReadOnlyList a, int start, int end) + { + var index = new Dictionary>(StringComparer.Ordinal); + + for (var i = start; i < end; i++) + { + if (!index.TryGetValue(a[i], out var slots)) + { + slots = new List(1); + index[a[i]] = slots; + } + + if (slots.Count <= MaxOccurrences) slots.Add(i); + } + + return index; + } + + /// 구간 a[from..to] 안에서 가장 흔한 줄의 등장 횟수. 낮을수록 좋은 앵커다. + private static int MaxOccurrenceIn(Dictionary> index, + IReadOnlyList 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; + } + + /// + /// 짝이 안 맞는 삭제·추가 덩어리를 위에서부터 1:1로 붙여 "수정된 줄"로 보여준다. + /// 남는 쪽은 한쪽만 있는 행이 된다. + /// + private static List PairBlocks(IReadOnlyList a, IReadOnlyList 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(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; + } + } +} diff --git a/src/MergeHub/Services/MergeNote.cs b/src/MergeHub/Services/MergeNote.cs new file mode 100644 index 0000000..5a8c8dd --- /dev/null +++ b/src/MergeHub/Services/MergeNote.cs @@ -0,0 +1,90 @@ +using System; +using System.IO; + +namespace MergeHub.Services +{ + /// + /// 머지로 반영한 줄에 남기는 주석을 만든다. + /// + /// 원칙 두 가지: + /// 1. 설명(사용자 입력)은 선택이지만, **누가·언제 반영했는지는 항상 들어간다.** + /// 2. 줄 "위"가 아니라 "끝"에 붙인다 - BASE/LOCAL 을 같은 줄 번호로 나란히 보여주는 구조라 + /// 줄을 새로 끼워 넣으면 두 패널의 정렬이 통째로 어긋난다. + /// + public static class MergeNote + { + /// 코드와 주석 사이 간격. + private const string Gap = " "; + + /// + /// 주석 한 조각을 만든다. 구성은 딱 세 가지 - [사용자] 날짜 설명. + /// 설명이 비어 있어도 [사용자]와 날짜는 항상 남는다. + /// + 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}"; + } + + /// 코드 줄 끝에 주석을 붙인다. 줄 끝 공백은 정리한다. + public static string Append(string line, string note) + { + if (string.IsNullOrEmpty(note)) return line; + return line.TrimEnd() + Gap + note; + } + + /// 확장자에 맞는 주석 기호. 모르는 확장자는 C 계열(//)로 본다. + 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); + } + } + } +} diff --git a/src/MergeHub/Services/MoveDetector.cs b/src/MergeHub/Services/MoveDetector.cs new file mode 100644 index 0000000..c40176c --- /dev/null +++ b/src/MergeHub/Services/MoveDetector.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; + +namespace MergeHub.Services +{ + /// 옮겨진 덩어리 하나. 지워진 자리와 새로 나타난 자리를 짝으로 가리킨다. + public sealed class MovedBlock + { + public MovedBlock(int removedStart, int addedStart, int length) + { + RemovedStart = removedStart; + AddedStart = addedStart; + Length = length; + } + + /// 정렬 결과에서 "지워진 것으로 보이던" 구간의 시작 행. + public int RemovedStart { get; } + + /// 같은 내용이 "새로 추가된 것으로 보이던" 구간의 시작 행. + public int AddedStart { get; } + + public int Length { get; } + } + + /// + /// 정렬 결과에서 자리만 옮긴 덩어리를 찾아낸다. + /// + /// 메서드 순서만 바꿔도 정렬기는 "여기서 12줄 삭제 · 저기서 12줄 추가"로 본다. + /// 사람 눈에는 옮긴 것 하나인데 차이가 둘로 보이니 검토가 번거롭다. + /// 지워진 구간과 추가된 구간의 내용이 똑같으면 이동으로 묶어 표시한다. + /// + /// 한 줄짜리 {, }, 빈 줄까지 이동으로 보면 화면이 온통 이동 표시가 되므로 + /// "알맹이가 있는 줄"이 하나라도 있어야 이동으로 친다. + /// + public static class MoveDetector + { + /// 이 길이 이상 이어져야 이동으로 본다. 한 줄짜리는 우연히 같을 때가 많다. + private const int MinimumLines = 2; + + /// 두 구간을 견줄 때 만드는 표의 최대 칸 수. 넘으면 이동 찾기를 건너뛴다. + private const long MaxCompareCells = 250_000; + + public static IReadOnlyList Find(IReadOnlyList rows) + { + // 한쪽에만 있는 구간을 각각 모은다. + var removed = CollectRuns(rows, deleted: true); + var added = CollectRuns(rows, deleted: false); + + var moves = new List(); + 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; + } + + /// + /// 두 구간에서 줄이 이어지는 채로 똑같은 가장 긴 부분을 찾는다. + /// + /// 구간 전체를 통째로 견주지 않는 이유가 있다. 정렬기는 } 나 빈 줄 같은 공통 줄을 + /// 한쪽에 붙여 놓기 때문에, 같은 메서드를 옮겼어도 구간의 시작과 끝이 한두 줄씩 어긋난다. + /// 그래서 겹치는 알맹이만 골라낸다. + /// + private static (int TakeAt, int GiveAt, int Length) LongestCommonRun( + IReadOnlyList 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; } + } + + /// 한쪽에만 있는 줄이 이어지는 구간들. 알맹이가 없는 구간은 걸러낸다. + private static List CollectRuns(IReadOnlyList rows, bool deleted) + { + var runs = new List(); + 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; + + /// + /// 중괄호·빈 줄만 있는 구간은 이동으로 치지 않는다. + /// 어디에나 있는 모양이라 우연히 겹칠 뿐, 옮겼다고 알려 줄 값어치가 없다. + /// + private static bool HasSubstance(IReadOnlyList 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; + } + + } +} diff --git a/src/MergeHub/Services/ShortcutMap.cs b/src/MergeHub/Services/ShortcutMap.cs new file mode 100644 index 0000000..ff74e8a --- /dev/null +++ b/src/MergeHub/Services/ShortcutMap.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows.Input; + +namespace MergeHub.Services +{ + /// 머지툴이 알아듣는 단축키 하나가 시키는 일. + public enum ShortcutAction + { + NextDiff, + PrevDiff, + FirstDiff, + LastDiff, + CopyLeftToRight, + CopyRightToLeft, + Undo, + Redo, + Save, + Recompare, + GoToLine, + NextBookmark, + ToggleBookmark, + OpenFolderCompare, + Find, + FindNext, + FindPrev, + CloseFind, + ShowHelp, + } + + /// 단축키 한 줄. 키 처리와 설명 창이 둘 다 이 목록만 본다. + 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; } + + /// 설명 창에서 묶어 보여 줄 이름. + public string Group { get; } + + /// 이 키가 하는 일. + public string What { get; } + + /// WinMerge 와 다르게 정한 것 등, 덧붙일 말. + public string? Note { get; } + + /// 사람이 읽는 키 표기(예: Alt+↓). + public string Gesture + { + get + { + var parts = new List(); + 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(), + }; + } + + /// + /// 단축키 표. WinMerge 를 기준으로 맞췄다. + /// + /// 목록을 한 곳에 모아 둔 이유 - 키 처리와 설명 창이 따로 놀면 설명이 곧 거짓말이 된다. + /// 키를 더할 때는 여기에만 줄을 넣고, 그 을 처리부에 이어 준다. + /// + public static class ShortcutMap + { + public static IReadOnlyList 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, "도움말", "이 단축키 목록"), + }; + + /// + /// 눌린 키에 걸린 동작을 찾는다. 없으면 null — 편집기가 그대로 받는다. + /// + /// Ctrl+A·Ctrl+Z·Ctrl+C 처럼 편집기 것인 키는 이 표에 없으므로 + /// 저절로 편집기로 간다. 표에 키를 더할 때 편집 기본 동작을 빼앗지 않는지 확인할 것. + /// + /// + 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; + } + + /// 설명 창이 쓰는 묶음별 목록. 표에 적은 순서를 그대로 지킨다. + public static IEnumerable> ByGroup() => All.GroupBy(e => e.Group); + } +} diff --git a/src/MergeHub/Services/TextFile.cs b/src/MergeHub/Services/TextFile.cs new file mode 100644 index 0000000..53f9bd0 --- /dev/null +++ b/src/MergeHub/Services/TextFile.cs @@ -0,0 +1,179 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using MergeHub.Models; + +namespace MergeHub.Services +{ + /// + /// Git 없이 파일을 직접 읽고 쓸 때 쓰는 최소한의 도구. + /// + /// 인코딩·줄바꿈을 함부로 바꾸지 않는 것이 요점이다. 사내 C/C++ 소스는 CP949 로 저장된 + /// 것이 많아 UTF-8 로 강제 해석하면 한글이 깨지고, 저장할 때 줄바꿈을 바꾸면 실제로는 + /// 한 줄도 안 고쳤는데 파일 전체가 변경된 것으로 보인다. + /// + /// 판별 규칙은 GitService 와 같다 - 같은 파일을 어느 경로로 열든 결과가 같아야 한다. + /// + 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); + } + + /// + /// 원래 파일의 인코딩과 줄바꿈을 그대로 유지해 덮어쓴다. + /// 새 파일이면 UTF-8(BOM 없음) · CRLF 로 쓴다. + /// + 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); + } + + /// + /// 글이 아니라 이진 파일인지. 앞부분에 NUL 바이트가 있으면 이진으로 본다 - + /// Git 을 비롯한 대부분의 도구가 쓰는 판별이고, 실제 텍스트에는 NUL 이 들어가지 않는다. + /// + /// 이진 파일을 줄 단위로 비교하면 화면이 깨진 글자로 가득 찰 뿐 아무 정보도 못 준다. + /// + 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; + } + + /// 상태바에 띄울 인코딩·줄바꿈 이름. + 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(); + } + } + + /// 줄바꿈이 섞여 있으면 감추지 않고 "혼합"으로 알린다. + 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"; + } + } +} diff --git a/src/MergeHub/Services/ThreeWayAligner.cs b/src/MergeHub/Services/ThreeWayAligner.cs new file mode 100644 index 0000000..9d7807c --- /dev/null +++ b/src/MergeHub/Services/ThreeWayAligner.cs @@ -0,0 +1,253 @@ +using System.Collections.Generic; + +namespace MergeHub.Services +{ + /// 세 쪽(BASE · LOCAL · REMOTE)을 같은 눈높이로 묶은 한 행. + 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; } + } + + /// BASE·MINE(내 원본)·REMOTE·MERGED(결과)를 같은 눈높이로 묶은 한 행. + 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; } + } + + /// + /// 3-Way 정렬. BASE 를 축으로 LOCAL 과 REMOTE 를 나란히 세운다. + /// + /// 세 글을 한꺼번에 정렬하지 않고 BASE↔LOCAL, BASE↔REMOTE 두 번 정렬한 뒤 + /// BASE 줄 번호로 다시 맞물린다. 이유가 둘이다. + /// - 이미 검증된 2-way 정렬기(histogram)를 그대로 쓴다. 세 갈래 정렬을 새로 만들면 + /// 기존 테스트가 지켜 주던 성질(중복 줄 오짝짓기 방지 등)을 처음부터 다시 증명해야 한다. + /// - 실제 머지의 뜻과 맞는다. "BASE 대비 내가 뭘 고쳤나 / 저쪽이 뭘 고쳤나"가 판단의 근거다. + /// + /// 어느 쪽에도 BASE 줄이 없는 자리(한쪽에서만 새로 넣은 줄)는 같은 자리끼리 한 번 더 + /// 정렬해 짝을 짓는다. 양쪽이 똑같은 줄을 넣었으면 한 행으로 합쳐진다 - 그래야 + /// "둘 다 같은 내용을 추가함"이 충돌로 보이지 않는다. + /// + public static class ThreeWayAligner + { + public static IReadOnlyList Align(IReadOnlyList baseLines, + IReadOnlyList localLines, + IReadOnlyList remoteLines, + IReadOnlyList? baseKeys = null, + IReadOnlyList? localKeys = null, + IReadOnlyList? remoteKeys = null) + { + var left = LineAligner.Align(baseLines, localLines, baseKeys, localKeys); + var right = LineAligner.Align(baseLines, remoteLines, baseKeys, remoteKeys); + + var rows = new List(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(); + while (i < left.Count && left[i].BaseNumber is null) addedLocal.Add(left[i++]); + + var addedRemote = new List(); + 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; + } + + /// + /// 네 쪽 정렬. BASE 를 축으로 MINE(내 원본) · REMOTE · MERGED(결과) 를 나란히 세운다. + /// + /// 3-Way 인데 왜 넷이냐 - 3-Way 에서 고칠 수 있는 칸은 MERGED 하나이고, 위의 + /// MINE 칸은 "파일을 연 시점의 내 줄"을 그대로 붙들어 보여 주는 참고용이다. 둘을 한 열로 + /// 묶어 두면 MERGED 에 글자를 하나만 쳐도 MINE 칸이 그 편집을 따라가 버려, 정작 무엇과 + /// 견주어 판단하는지를 잃는다. + /// + /// 방식은 와 같다 - BASE 와 두 번이 아니라 세 번 2-way 정렬한 뒤 + /// BASE 줄 번호로 맞물린다. BASE 줄이 없는 자리(새로 넣은 줄)는 + /// 가 짝짓는다. + /// + /// MERGED 가 MINE 과 똑같으면(= 아직 아무것도 안 고쳤으면) 결과는 + /// 와 정확히 같다. 파일을 연 직후의 화면이 달라지지 않는다는 뜻이다. + /// + public static IReadOnlyList AlignFour(IReadOnlyList baseLines, + IReadOnlyList mineLines, + IReadOnlyList remoteLines, + IReadOnlyList mergedLines, + IReadOnlyList? baseKeys = null, + IReadOnlyList? mineKeys = null, + IReadOnlyList? remoteKeys = null, + IReadOnlyList? 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(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(); + while (i < mine.Count && mine[i].BaseNumber is null) addedMine.Add(mine[i++]); + + var addedRemote = new List(); + while (j < remote.Count && remote[j].BaseNumber is null) addedRemote.Add(remote[j++]); + + var addedMerged = new List(); + 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; + } + + /// + /// 같은 자리에 들어온 세 쪽의 추가분을 짝짓는다. + /// + /// 두 걸음으로 나눈다. 먼저 MINE ↔ MERGED - 결과는 내 것의 복사본에서 출발하므로 + /// 거의 그대로 겹치고, 여기서 짝이 맞아야 "내가 넣은 줄"이 편집 뒤에도 제자리에 남는다. + /// 그 다음 그 묶음을 REMOTE 와 짝짓는다 - 기준 글자는 내 줄, 내 줄이 없으면 결과 줄이다. + /// + private static void EmitInsertions4(List mine, + List remote, + List merged, + List 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(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 TextsOf(List source) + { + var texts = new List(source.Count); + foreach (var row in source) texts.Add(row.LocalText ?? string.Empty); + return texts; + } + + /// + /// 같은 자리에 들어온 LOCAL 추가분과 REMOTE 추가분을 한 번 더 정렬해 짝짓는다. + /// 2-way 정렬기에 그대로 넘기므로 같은 줄은 같은 행에 모인다. + /// + private static void EmitInsertions(List local, List remote, List rows) + { + if (local.Count == 0 && remote.Count == 0) return; + + var localTexts = new List(local.Count); + foreach (var row in local) localTexts.Add(row.LocalText ?? string.Empty); + + var remoteTexts = new List(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)); + } + } + } +} diff --git a/src/MergeHub/ViewModels/FolderViewModel.cs b/src/MergeHub/ViewModels/FolderViewModel.cs new file mode 100644 index 0000000..4589e34 --- /dev/null +++ b/src/MergeHub/ViewModels/FolderViewModel.cs @@ -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 +{ + /// + /// 화면에 뿌리는 폴더 비교 항목. 비교 결과(FolderCompareItem)를 그대로 감싸고 + /// 트리에 필요한 것(펼침 상태·자식)만 얹는다. + /// + 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 Children { get; } = new ObservableCollection(); + + 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; + + /// 다른 것부터 눈에 띄도록 처음부터 펼쳐 둔다. 같기만 한 폴더는 접어 둔다. + [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); + + /// + /// 줄 단위 비교 화면으로 넘길 수 있는 항목인지. + /// + /// 한쪽에만 있는 파일도 연다 - 오히려 그런 파일이야말로 "뭐가 들었길래 새로 생겼나"를 + /// 보고 싶은 대상이다. 없는 쪽은 빈 내용으로 열려 전부 추가(또는 삭제)로 보인다. + /// 폴더와 이진 파일만 뺀다. + /// + public bool CanOpenInDiff => + !IsDirectory && (Item.LeftExists || Item.RightExists) && Status != FolderEntryStatus.Binary; + + /// 열 수 없을 때 왜 그런지. 조용히 아무 일도 안 일어나는 것이 제일 나쁘다. + 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}"; + } + } + + /// + /// 폴더 비교 탭. + /// + /// 홈(파일 비교)과는 다른 일이라 탭을 따로 뒀다 - 홈은 이미 네 칸을 쓰고 있어서 + /// 트리까지 얹을 자리가 없고, 하는 일도 "두 폴더 중 무엇이 다른가"로 성격이 다르다. + /// 여기서 파일을 고르면 홈 화면으로 넘겨 줄 단위로 본다. + /// + 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; + } + } + + /// 홈 탭에서 이 두 파일을 비교해 달라고 알린다. + public event EventHandler<(string Left, string Right)>? OpenInDiffRequested; + + public ObservableCollection Nodes { get; } = new ObservableCollection(); + + [ObservableProperty] private string _leftFolder = string.Empty; + [ObservableProperty] private string _rightFolder = string.Empty; + + [ObservableProperty] private FolderNode? _selectedNode; + + [ObservableProperty] private string _summary = "폴더 두 개를 고르고 비교를 누르세요."; + [ObservableProperty] private bool _isBusy; + + /// 내용을 다 읽지 않고 크기·수정 시각만 본다. + [ObservableProperty] private bool _quickCompare; + + [ObservableProperty] private bool _includeSubfolders = true; + + /// 같은 파일도 목록에 남길지. 꺼 두면 다른 것만 본다. + [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(); + } + } + + /// "같은 것 숨기기"가 켜져 있을 때 남길 항목인지. + private static bool Keep(FolderNode node) + { + if (node.Status != FolderEntryStatus.Same) return true; + return node.Children.Any(Keep); + } + + /// 같기만 한 가지를 잘라낸다. 트리에서 다른 것만 남기려는 것이다. + 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; + + /// 고른 파일을 "파일 비교" 탭으로 넘기고 그 탭으로 옮긴다. + [RelayCommand(CanExecute = nameof(CanOpenInDiff))] + private void OpenInDiff() => OpenInDiffCore(SelectedNode); + + /// + /// 더블클릭·Enter·버튼이 모두 여기로 들어온다. + /// + /// 열 수 없는 항목이면 왜 못 여는지 알려 준다 - 눌렀는데 아무 일도 안 일어나면 + /// 기능이 없는 줄 알게 된다(폴더나 한쪽에만 있는 파일을 눌렀을 때 실제로 그랬다). + /// + 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); + + /// + /// 한쪽 파일로 다른 쪽을 덮어쓴다. + /// 덮어쓰기는 되돌릴 수 없으므로 무엇을 어디로 쓰는지를 그대로 보여 주고 확인을 받는다. + /// + 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); + + /// 파일 삭제. 폴더는 지우지 않는다 - 한 번에 너무 많은 것이 사라진다. + 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}"); + } + } + } +} diff --git a/src/MergeHub/ViewModels/GitViewModel.cs b/src/MergeHub/ViewModels/GitViewModel.cs new file mode 100644 index 0000000..547b7c3 --- /dev/null +++ b/src/MergeHub/ViewModels/GitViewModel.cs @@ -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 +{ + /// + /// Git 탭 - 저장소 조작 전부. + /// 열기/클론 · 원격 · 브랜치 · 병합 · 이력 조작(revert/cherry-pick/rebase/reset) · + /// 스테이징/커밋 · 태그 · 서브모듈 · mergetool 등록. + /// + 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 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 등록 완료")); + }); + + /// CommunityToolkit 의 커맨드는 CommandManager 를 구독하지 않아 직접 알려야 한다. + 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); + }); + } + + /// 단어 단위 diff 가 켜져 있으면 바뀐 구간을 〔〕로 감싼다. + 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(); + } + } +} diff --git a/src/MergeHub/ViewModels/HomeViewModel.cs b/src/MergeHub/ViewModels/HomeViewModel.cs new file mode 100644 index 0000000..d7e4e31 --- /dev/null +++ b/src/MergeHub/ViewModels/HomeViewModel.cs @@ -0,0 +1,2067 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using MergeHub.Models; +using MergeHub.Services; +using MergeHub.Views.Controls; +using UbisamBase.Core.Messaging; + +namespace MergeHub.ViewModels +{ + /// + /// 홈 = 머지툴 본체. 커밋 트리 / BASE(공통 조상) / LOCAL(내 작업) 3분할 비교와 + /// 줄 단위 부분 수용(Accept·Revert), Undo/Redo, Save & Commit 을 담당한다. + /// 저장소 조작(clone/fetch/branch/merge 등)은 Git 탭으로 분리했다. + /// + public sealed partial class HomeViewModel : ObservableObject + { + private readonly GitWorkspace _workspace; + + private readonly Stack _undo = new Stack(); + private readonly Stack _redo = new Stack(); + + /// 타이핑이 멈춘 뒤에 다시 정렬하려고 두는 짧은 대기. + private readonly DispatcherTimer _reflowTimer = + new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(600) }; + + public HomeViewModel(GitWorkspace workspace) + { + _workspace = workspace; + _workspace.Refreshed += OnWorkspaceRefreshed; + _workspace.StateChanged += RaiseCanExecuteChanged; + _workspace.PlainCompareRequested += OnPlainCompareRequested; + _reflowTimer.Tick += (_, _) => Reflow(); + + LoadRules(); + IsGitMode = _workspace.IsRepositoryOpen; + RefreshRemoteBranches(); + + // 폴더 비교에서 파일을 골라 이 화면이 만들어진 경우라면 그 파일을 먼저 연다. + // 이 화면은 탭을 처음 열 때 만들어지므로, 요청이 우리보다 먼저 도착해 있을 수 있다. + if (_workspace.HasPendingPlainCompare) + { + _workspace.DeliverPendingPlainCompare(); + return; + } + + // 설정에 기본 저장소가 있으면 바로 열어서 첫 화면부터 내용이 보이게 한다. + // 저장소가 아니면 설정에 적어 둔 두 파일로 비교 화면을 만든다 - Git 없이도 쓰는 + // 도구라는 것을 첫 화면에서 바로 보여 준다. 둘을 동시에 걸면 안 된다. + // 작업 게이트가 하나뿐이라 먼저 건 쪽이 끝날 때까지 뒤엣것이 통째로 취소된다. + if (_workspace.LooksLikeRepository) + _ = _workspace.TryAutoOpenAsync(); + else + _ = LoadDefaultPlainFiles(); + } + + /// + /// 폴더 비교 탭에서 고른 두 파일을 여기서 연다. + /// + /// Git 저장소를 열어 둔 상태여도 "파일 두 개 비교"로 전환한다 - 폴더 비교에서 고른 파일은 + /// 저장소 안의 것이 아닐 수도 있고, 사용자가 보고 싶은 것은 그 두 파일이지 커밋 이력이 아니다. + /// + private void OnPlainCompareRequested(string leftFile, string rightFile) + { + // 한쪽만 있어도 연다 - 없는 쪽은 빈 내용이 되어 전부 추가(또는 삭제)로 보인다. + // 폴더 비교에서 "왼쪽만 / 오른쪽만" 인 파일을 열어 보려는 것이 바로 이 경우다. + if (!File.Exists(leftFile) && !File.Exists(rightFile)) return; + + // 3-Way 를 끄되 그 때문에 본문을 다시 읽지는 않게 한다 - 그 재로드가 작업 게이트를 + // 먼저 잡으면 바로 아래 파일 비교가 "다른 작업 진행 중"으로 건너뛰어진다. + _suspendReload = true; + try + { + IsGitMode = false; + RemoteBranch = NoRemoteBranch; + } + finally + { + _suspendReload = false; + } + + LeftFile = leftFile; + RightFile = rightFile; + OnPropertyChanged(nameof(BaseFileLocation)); + OnPropertyChanged(nameof(SelectedFileFullPath)); + + _ = LoadPlainFilesAsync(); + } + + // ================= 데모 전환 (발표·교육용) ================= + + /// 설정에 데모 경로가 둘 다 적혀 있을 때만 전환 버튼을 띄운다. + public bool CanSwitchDemo => + Directory.Exists(_workspace.Settings.DemoGitPath) && + Directory.Exists(_workspace.Settings.DemoPlainPath); + + /// 다음에 어디로 넘어가는지 버튼에 그대로 적는다. + public string DemoSwitchLabel => IsGitMode ? "데모 → .git 없음" : "데모 → .git 있음"; + + /// + /// Git 있는 폴더와 없는 폴더를 오간다. + /// + /// 발표에서 "저장소면 이렇게, 아니면 이렇게"를 한 자리에서 보여 주려고 둔 것이다. + /// 그동안은 설정 파일을 고치고 프로그램을 다시 켜는 것 말고는 방법이 없었다. + /// + [RelayCommand] + private async Task SwitchDemoAsync() + { + if (!CanSwitchDemo) return; + + var toGit = !IsGitMode; + var target = toGit ? _workspace.Settings.DemoGitPath : _workspace.Settings.DemoPlainPath; + + // 3-Way 를 끄되 그 때문에 본문을 다시 읽지는 않게 한다. + // 재로드가 작업 게이트를 먼저 잡아 버리면 바로 뒤의 저장소 닫기·파일 비교가 + // "다른 작업 진행 중"으로 통째로 건너뛰어져 화면이 빈 채로 남는다. + _suspendReload = true; + try + { + RemoteBranch = NoRemoteBranch; + } + finally + { + _suspendReload = false; + } + + _workspace.RepositoryPath = target; + + if (toGit && _workspace.LooksLikeRepository) + { + await _workspace.RunAndRefreshAsync("데모 저장소 열기", () => _workspace.Git.Open(target)); + } + else + { + // 저장소를 닫아야 Git 이력 칸이 접히고 "파일 비교" 모드로 넘어간다. + // IsGitMode 를 먼저 내리는 것이 중요하다 - 아직 돌고 있던 Git 본문 로드가 + // 이 표식을 보고 결과를 버린다. 안 그러면 경로만 새 파일이고 내용은 옛 파일이 된다. + IsGitMode = false; + ClearPanes(); + + await _workspace.RunAsync("데모 저장소 닫기", () => _workspace.Git.Close()); + await LoadDefaultPlainFiles(); + } + + OnPropertyChanged(nameof(DemoSwitchLabel)); + _workspace.AppendLog($"데모 전환: {target}"); + } + + /// 설정의 기본 비교 파일 두 개를 열어 둔다. 없거나 사라졌으면 조용히 넘어간다. + private Task LoadDefaultPlainFiles() + { + var left = _workspace.Settings.DefaultLeftFile; + var right = _workspace.Settings.DefaultRightFile; + + if (!File.Exists(left) || !File.Exists(right)) return Task.CompletedTask; + + LeftFile = left; + RightFile = right; + OnPropertyChanged(nameof(BaseFileLocation)); + OnPropertyChanged(nameof(SelectedFileFullPath)); + + return LoadPlainFilesAsync(); + } + + public GitWorkspace Workspace => _workspace; + + /// 코드 패널에 C# 문법 색을 입힐지. 설정 > MergeHub 에서 끌 수 있다. + public bool SyntaxHighlight => _workspace.Settings.SyntaxHighlight; + + // ================= 3분할 상태 ================= + + /// BASE 와 LOCAL 을 같은 줄 번호로 묶은 행 목록. + public ObservableCollection Lines { get; } = new ObservableCollection(); + + /// 비교 대상 파일 목록(충돌 중이면 충돌 파일, 아니면 선택 커밋의 파일). + public ObservableCollection Files { get; } = new ObservableCollection(); + + [ObservableProperty] private string? _selectedFile; + [ObservableProperty] private MergeLine? _selectedLine; + [ObservableProperty] private string _baseHeader = "BASE"; + [ObservableProperty] private string _localHeader = "LOCAL (Mine)"; + [ObservableProperty] private string _diffSummary = "-"; + [ObservableProperty] private string _editSummary = "-"; + [ObservableProperty] private string _commitMessage = string.Empty; + [ObservableProperty] private bool _hasChanges; + + /// 커밋 트리 ListBox 가 바인딩하는 선택 항목(그래프 포함). + [ObservableProperty] private CommitNode? _selectedNode; + + /// 미니맵은 컬렉션이 그대로여도 줄 내용이 바뀌면 다시 그려야 한다. + [ObservableProperty] private int _diffVersion; + + /// 머지한 줄에 붙일 사용자 설명. 비워도 작성자·날짜 정보는 항상 들어간다. + [ObservableProperty] private string _mergeComment = string.Empty; + + partial void OnSelectedFileChanged(string? value) + { + OnPropertyChanged(nameof(SelectedFilePath)); + OnPropertyChanged(nameof(SelectedFileFullPath)); + OnPropertyChanged(nameof(BaseFileLocation)); + OnPropertyChanged(nameof(MergeNotePreview)); + _ = LoadFileAsync(); + } + + partial void OnMergeCommentChanged(string value) => OnPropertyChanged(nameof(MergeNotePreview)); + + // ---- 파일 경로 ---- + + /// 저장소 기준 상대 경로. + public string SelectedFilePath => string.IsNullOrEmpty(SelectedFile) ? "-" : SelectedFile!; + + /// 디스크상의 전체 경로. 툴팁·복사용. + public string SelectedFileFullPath + { + get + { + // Git 이 아니면 고른 파일 경로를 그대로 보여 준다. + if (IsPlainMode) return string.IsNullOrWhiteSpace(RightFile) ? "-" : RightFile; + + if (string.IsNullOrEmpty(SelectedFile) || string.IsNullOrEmpty(_workspace.RepositoryPath)) + return "-"; + + return Path.Combine(_workspace.RepositoryPath, + SelectedFile!.Replace('/', Path.DirectorySeparatorChar)); + } + } + + /// + /// BASE 쪽 출처. 워킹트리 파일이 아니라 커밋(또는 병합 중이면 공통 조상)에서 읽은 내용이라 + /// 디스크 경로가 아니라 "커밋:경로" 형태로 보여준다. + /// + public string BaseFileLocation + { + get + { + if (IsPlainMode) return string.IsNullOrWhiteSpace(LeftFile) ? "-" : LeftFile; + + if (string.IsNullOrEmpty(SelectedFile)) return "-"; + + var origin = _workspace.IsMergeInProgress + ? "공통 조상" + : SelectedCommit?.ShortSha ?? "HEAD"; + + return $"{origin} : {SelectedFile}"; + } + } + + /// 실제로 줄 끝에 붙을 주석을 미리 보여준다. + public string MergeNotePreview => + _workspace.Settings.InsertMergeComment ? BuildMergeNote() : "(주석 남기기 꺼짐)"; + + partial void OnSelectedLineChanged(MergeLine? value) => RaiseCanExecuteChanged(); + + partial void OnCommitMessageChanged(string value) => RaiseCanExecuteChanged(); + + partial void OnHasChangesChanged(bool value) => RaiseCanExecuteChanged(); + + /// 새로고침으로 목록을 다시 만들 때 선택을 잇는 중이면 재진입을 막는다. + private bool _syncingSelection; + + partial void OnSelectedNodeChanged(CommitNode? value) + { + if (_syncingSelection) return; + SelectedCommit = value?.Commit; + } + + // ---- 선택 커밋 상세 ---- + + public CommitInfo? SelectedCommit + { + get => _workspace.SelectedCommit; + set + { + if (ReferenceEquals(_workspace.SelectedCommit, value)) return; + _workspace.SelectedCommit = value; + OnPropertyChanged(); + RaiseCommitDetail(); + _ = LoadCommitAsync(); + } + } + + public string CommitHash => SelectedCommit?.ShortSha ?? "-"; + public string CommitDate => SelectedCommit is null ? "-" : SelectedCommit.When.ToString("yyyy-MM-dd"); + public string CommitAuthor => SelectedCommit?.AuthorName ?? "-"; + public string CommitBranch => SelectedCommit?.Refs.FirstOrDefault() ?? _workspace.CurrentBranch; + public string CommitSubject => SelectedCommit is null ? "-" : $"\"{SelectedCommit.MessageShort}\""; + + private void RaiseCommitDetail() + { + OnPropertyChanged(nameof(CommitHash)); + OnPropertyChanged(nameof(CommitDate)); + OnPropertyChanged(nameof(CommitAuthor)); + OnPropertyChanged(nameof(CommitBranch)); + OnPropertyChanged(nameof(CommitSubject)); + OnPropertyChanged(nameof(BaseFileLocation)); + } + + // ================= 커맨드 ================= + + private bool CanTakeLine(MergeLine? line) => line?.CanTakeBase == true; + + /// 목업 ② 부분 수용 - LOCAL 줄 번호 옆 [>] 버튼. BASE 줄을 내 작업으로 가져온다. + [RelayCommand(CanExecute = nameof(CanTakeLine))] + private void TakeLine(MergeLine? line) + { + if (line is null || !line.CanTakeBase) return; + Apply(line); + } + + private bool CanAccept() => SelectedLine?.CanTakeBase == true; + + [RelayCommand(CanExecute = nameof(CanAccept))] + private void Accept() => TakeLine(SelectedLine); + + private bool CanRevert() => SelectedLine is not null && HasEditFor(SelectedLine); + + /// 이 줄에 대한 마지막 편집만 되돌린다. + [RelayCommand(CanExecute = nameof(CanRevert))] + private void Revert() + { + var line = SelectedLine; + if (line is null) return; + + var last = _undo.FirstOrDefault(e => ReferenceEquals(e.Line, line)); + if (last is null) return; + + var remaining = _undo.Where(e => !ReferenceEquals(e, last)).Reverse().ToList(); + Restore(last, toBefore: true); + + _undo.Clear(); + foreach (var e in remaining) _undo.Push(e); + _redo.Clear(); + + _workspace.AppendLog($"{line.LocalNumber ?? line.BaseNumber}행 되돌림"); + AfterEdit(); + } + + private bool HasEditFor(MergeLine line) => _undo.Any(e => ReferenceEquals(e.Line, line)); + + private bool CanUndo() => _undo.Count > 0; + + [RelayCommand(CanExecute = nameof(CanUndo))] + private void Undo() + { + if (_undo.Count == 0) return; + + var edit = _undo.Pop(); + Restore(edit, toBefore: true); + _redo.Push(edit); + AfterEdit(); + } + + private bool CanRedo() => _redo.Count > 0; + + [RelayCommand(CanExecute = nameof(CanRedo))] + private void Redo() + { + if (_redo.Count == 0) return; + + var edit = _redo.Pop(); + Restore(edit, toBefore: false); + _undo.Push(edit); + AfterEdit(); + } + + private bool CanAcceptAll() => Lines.Any(l => l.CanTakeBase); + + [RelayCommand(CanExecute = nameof(CanAcceptAll))] + private void AcceptAll() + { + foreach (var line in Lines.Where(l => l.CanTakeBase).ToList()) + Apply(line, requery: false); + + AfterEdit(); + } + + private bool CanRevertAll() => HasChanges; + + /// 파일을 연 시점의 LOCAL 로 통째로 되돌린다. + [RelayCommand(CanExecute = nameof(CanRevertAll))] + private void RevertAll() + { + ReloadLinesFrom(_originalLocalText, keepAsEdited: false); + } + + private bool CanReload() => _workspace.IsRepositoryOpen; + + [RelayCommand(CanExecute = nameof(CanReload))] + private Task ReloadAsync() => _workspace.RefreshAsync(); + + private bool CanSaveAndCommit() => HasChanges && !string.IsNullOrWhiteSpace(CommitMessage); + + /// LOCAL 패널 내용을 워킹트리 파일에 쓰고 스테이징 후 커밋한다. + [RelayCommand(CanExecute = nameof(CanSaveAndCommit))] + private async Task SaveAndCommitAsync() + { + var path = SelectedFile; + if (string.IsNullOrEmpty(path)) return; + + var content = BuildLocalText(); + var message = CommitMessage; + string? sha = null; + + var ok = await _workspace.RunAsync("Save & Commit", () => + { + var isConflict = _workspace.Git.GetConflicts().Any(c => c.Path == path); + if (isConflict) + { + // 충돌 해결 경로 - 인덱스의 충돌까지 함께 해제된다. + _workspace.Git.ResolveConflictWithContent(path!, content); + } + else + { + var full = Path.Combine(_workspace.RepositoryPath, + path!.Replace('/', Path.DirectorySeparatorChar)); + File.WriteAllText(full, content); + _workspace.Git.Stage(path!); + } + + sha = _workspace.Git.IsMergeInProgress + ? _workspace.Git.CommitMerge(message) + : _workspace.Git.Commit(message); + }); + + if (!ok) return; + + _workspace.OnUi(() => + { + _undo.Clear(); + _redo.Clear(); + HasChanges = false; + CommitMessage = string.Empty; + MessageUtil.ShowSuccessToast( + sha is null ? "커밋 완료" : $"커밋 완료 ({sha.Substring(0, 7)})"); + }); + + await _workspace.RefreshAsync(); + } + + /// CommunityToolkit 의 커맨드는 CommandManager 를 구독하지 않아 직접 알려야 한다. + private void RaiseCanExecuteChanged() + { + TakeLineCommand.NotifyCanExecuteChanged(); + AcceptCommand.NotifyCanExecuteChanged(); + RevertCommand.NotifyCanExecuteChanged(); + UndoCommand.NotifyCanExecuteChanged(); + RedoCommand.NotifyCanExecuteChanged(); + AcceptAllCommand.NotifyCanExecuteChanged(); + FirstDiffCommand.NotifyCanExecuteChanged(); + PrevDiffCommand.NotifyCanExecuteChanged(); + NextDiffCommand.NotifyCanExecuteChanged(); + LastDiffCommand.NotifyCanExecuteChanged(); + NextBookmarkCommand.NotifyCanExecuteChanged(); + RevertAllCommand.NotifyCanExecuteChanged(); + ReloadCommand.NotifyCanExecuteChanged(); + SaveAndCommitCommand.NotifyCanExecuteChanged(); + SavePlainCommand.NotifyCanExecuteChanged(); + } + + // ================= 로드 ================= + + private void OnWorkspaceRefreshed() + { + IsGitMode = _workspace.IsRepositoryOpen; + RefreshRemoteBranches(); + + // 새로 만들어진 CommitNode 목록에서 같은 커밋을 다시 찾아 선택 상태를 잇는다. + // 아래에서 LoadCommitAsync 를 부르므로 setter 가 또 부르지 않도록 막는다. + var sha = _workspace.SelectedCommit?.Sha; + var node = _workspace.CommitNodes.FirstOrDefault(n => n.Commit.Sha == sha); + + _syncingSelection = true; + try { SelectedNode = node; } + finally { _syncingSelection = false; } + + OnPropertyChanged(nameof(SelectedCommit)); + RaiseCommitDetail(); + _ = LoadCommitAsync(); + } + + /// 선택 커밋이 담고 있는 파일 목록을 채우고, 가능하면 이전 선택을 유지한다. + private async Task LoadCommitAsync() + { + var commit = SelectedCommit; + if (commit is null || !_workspace.IsRepositoryOpen) return; + + var previous = SelectedFile; + IReadOnlyList files = Array.Empty(); + var changed = new List(); + + await _workspace.RunAsync("파일 목록", () => + { + // 충돌 중이면 충돌 파일만 보여준다 - 지금 해결해야 할 대상이다. + var conflicts = _workspace.Git.GetConflicts().Select(c => c.Path).ToList(); + if (conflicts.Count > 0) + { + files = conflicts; + changed.AddRange(conflicts); + return; + } + + files = _workspace.Git.GetFilesAtCommit(commit.Sha); + + // 워킹트리에서 손댄 파일을 기본 선택으로 삼는다 - 비교할 내용이 있는 파일이다. + changed.AddRange(_workspace.Git.GetStatus().Files + .Where(f => f.WorkingDirState != WorkingFileState.Unmodified) + .Select(f => f.Path)); + }); + + _workspace.OnUi(() => + { + Files.Clear(); + foreach (var f in files) Files.Add(f); + + var next = previous is not null && files.Contains(previous) + ? previous + : changed.FirstOrDefault(files.Contains) ?? files.FirstOrDefault(); + + if (!string.Equals(SelectedFile, next, StringComparison.Ordinal)) + SelectedFile = next; // setter 가 LoadFileAsync 를 부른다 + else + _ = LoadFileAsync(); + }); + } + + /// 선택 파일의 BASE(선택 커밋 시점)와 LOCAL(현재 워킹트리)을 줄 단위로 맞춰 싣는다. + private async Task LoadFileAsync() + { + var commit = SelectedCommit; + var path = SelectedFile; + if (commit is null || string.IsNullOrEmpty(path) || !_workspace.IsRepositoryOpen) + { + _workspace.OnUi(ClearPanes); + return; + } + + string baseText = string.Empty, localText = string.Empty, remoteText = string.Empty; + var isConflict = false; + var threeWay = IsThreeWay; + var branch = RemoteBranch; + var baseOrigin = string.Empty; + + // 다른 작업이 게이트를 쥐고 있으면 RunAsync 는 아무것도 하지 않고 false 를 준다. + // 그대로 진행하면 빈 글로 화면을 갈아엎어 "차이 없음"이 뜬다(파일 비교 쪽에서 실제로 겪었다). + var loaded = await _workspace.RunAsync("본문 로드", () => + { + var conflict = _workspace.Git.GetConflicts().FirstOrDefault(c => c.Path == path); + if (conflict is not null && !conflict.IsBinary) + { + // 병합 중이면 Git 이 이미 세 벌을 들고 있다. 브랜치를 고를 것도 없이 그걸 쓴다. + var three = _workspace.Git.GetThreeWayContent(path!); + baseText = three.BaseText; + localText = three.OursText; + remoteText = three.TheirsText; + isConflict = true; + threeWay = true; + baseOrigin = "merge base"; + } + else if (threeWay) + { + // 사용자가 고른 브랜치와의 진짜 3-Way. + // BASE = 두 갈래의 공통 조상(merge base) + // LOCAL = 지금 내 워킹트리 + // REMOTE = 고른 브랜치의 그 파일 + // BASE 를 HEAD 로 두면 "저쪽이 고친 것"과 "내가 고친 것"을 가를 수 없다. + var mergeBase = _workspace.Git.GetMergeBaseSha("HEAD", branch); + + baseText = string.IsNullOrEmpty(mergeBase) + ? _workspace.Git.GetFileTextAtCommit("HEAD", path!) + : _workspace.Git.GetFileTextAtCommit(mergeBase!, path!); + + baseOrigin = string.IsNullOrEmpty(mergeBase) ? "HEAD" : "merge base"; + + localText = _workspace.Git.ReadWorkingFileText(path!); + if (string.IsNullOrEmpty(localText)) localText = baseText; + + remoteText = _workspace.Git.GetFileTextAtCommit(branch, path!); + } + else + { + baseText = _workspace.Git.GetFileTextAtCommit(commit.Sha, path!); + localText = _workspace.Git.ReadWorkingFileText(path!); + if (string.IsNullOrEmpty(localText)) localText = baseText; + } + }); + + if (!loaded) return; + + // 행을 만들기 전에 언어를 정해야 한다 - 블록 주석 이어붙이기가 이 값을 본다. + RefreshLanguage(); + + var rows = threeWay + ? BuildThreeWayLines(baseText, localText, remoteText) + : BuildLines(baseText, localText); + + _workspace.OnUi(() => + { + // 읽어 오는 사이에 파일 비교 모드로 넘어갔으면(데모 전환·저장소 닫기) 버린다. + // 늦게 끝난 Git 로드가 이미 바뀐 화면을 덮어쓰면, 경로는 새 파일인데 내용은 + // 옛 파일인 상태가 된다 - 데모 전환에서 실제로 겪었다. + if (!IsGitMode) return; + + Lines.Clear(); + foreach (var row in rows) Lines.Add(row); + + BaseHeader = threeWay + ? $"BASE ({baseOrigin}) · {path}" + : $"BASE · {commit.ShortSha} · {path}"; + LocalHeader = $"LOCAL · {_workspace.CurrentBranch}"; + RemoteHeader = isConflict + ? "REMOTE · THEIRS" + : $"REMOTE · {branch}"; + + _baseTextSnapshot = baseText; + _remoteTextSnapshot = threeWay ? remoteText : string.Empty; + _mineTextSnapshot = threeWay ? localText : string.Empty; + _originalLocalText = BuildLocalText(); + SyncEditorFromLines(); + + var info = _workspace.Git.DescribeWorkingFile(path!); + RefreshLanguage(); + FileEncoding = info.Encoding; + FileNewLine = info.NewLine; + CaretRow = 0; + InvalidateDiffBlocks(); + RefreshDiffPosition(); + + _undo.Clear(); + _redo.Clear(); + SelectedLine = Lines.FirstOrDefault(l => l.IsDifferent) ?? Lines.FirstOrDefault(); + + if (isConflict && string.IsNullOrWhiteSpace(CommitMessage)) + CommitMessage = $"Merge: {path} 충돌 해결"; + + UpdateSummaries(); + RaiseCanExecuteChanged(); + }); + } + + private void ClearPanes() + { + Lines.Clear(); + Files.Clear(); + _undo.Clear(); + _redo.Clear(); + _syncingEditor = true; + EditorTextReplaced?.Invoke(this, string.Empty); + _syncingEditor = false; + _editorLines = Array.Empty(); + _editorFiller = Array.Empty(); + + InvalidateDiffBlocks(); + BaseHeader = "BASE"; + LocalHeader = "LOCAL (Mine)"; + DiffSummary = "-"; + EditSummary = "-"; + HasChanges = false; + } + + /// + /// BASE · LOCAL · REMOTE 세 벌을 한 줄씩 맞춰 행을 만든다. + /// + /// 정렬은 가 맡고, 여기서는 그 결과를 화면 모델로 옮기면서 + /// 무시 규칙·단어 단위 diff 를 얹는다. 이동 감지(MarkMoves)는 걸지 않는다 - + /// 세 갈래에서는 "옮겼다"의 기준이 둘로 갈려(내 쪽 기준? 저쪽 기준?) 뜻이 흐려진다. + /// + private List BuildThreeWayLines(string baseText, string mineText, string remoteText, + string? mergedText = null) + { + Rules.LinePatterns = SplitLines(_workspace.Settings.LineFilters ?? string.Empty); + + var baseLines = TrimTrailingBlank(SplitLines(baseText)); + var mineLines = TrimTrailingBlank(SplitLines(mineText)); + var remoteLines = TrimTrailingBlank(SplitLines(remoteText)); + + // 아직 아무것도 안 고쳤으면 결과 = 내 원본이다. 그때는 네 쪽 정렬이 세 쪽과 같은 답을 낸다. + var mergedLines = mergedText is null + ? mineLines + : TrimTrailingBlank(SplitLines(mergedText)); + + var baseKeys = Rules.IsPlain ? null : Array.ConvertAll(baseLines, Rules.KeyOf); + var mineKeys = Rules.IsPlain ? null : Array.ConvertAll(mineLines, Rules.KeyOf); + var remoteKeys = Rules.IsPlain ? null : Array.ConvertAll(remoteLines, Rules.KeyOf); + var mergedKeys = Rules.IsPlain ? null : Array.ConvertAll(mergedLines, Rules.KeyOf); + + var aligned = ThreeWayAligner.AlignFour(baseLines, mineLines, remoteLines, mergedLines, + baseKeys, mineKeys, remoteKeys, mergedKeys); + + var rows = new List(aligned.Count); + + foreach (var row in aligned) + { + var line = new MergeLine(row.BaseText, row.BaseNumber, row.MergedText, row.MergedNumber, + Rules.IsPlain ? null : Rules.KeyOf); + + line.SetThreeWay(row.RemoteText, row.RemoteNumber, row.MineText, row.MineNumber); + line.IsIgnored = IsFilteredFourWayRow(row); + + if (_workspace.Settings.WordLevelDiff) + { + if (line.State == MergeLineState.Changed) + { + var words = _workspace.Git.DiffWords(line.BaseText, line.LocalText); + line.BaseWords = words.Old; + line.LocalWords = words.New; + } + + // REMOTE 는 BASE 와 견준다. LOCAL 과 견주면 "내가 뭘 고쳤나"가 섞여 보인다. + if (line.HasBase && line.HasRemote && + !string.Equals(line.BaseText, line.RemoteText, StringComparison.Ordinal)) + { + line.RemoteWords = _workspace.Git.DiffWords(line.BaseText, line.RemoteText).New; + } + } + + rows.Add(line); + } + + RefreshBlockCommentState(rows); + return rows; + } + + /// 네 쪽 모두(있는 쪽만) 줄 필터에 걸리면 차이로 세지 않는다. + private bool IsFilteredFourWayRow(FourWayRow row) + { + if (!Rules.LineFilterEnabled) return false; + + var any = false; + + foreach (var text in new[] { row.BaseText, row.MineText, row.RemoteText, row.MergedText }) + { + if (text is null) continue; + if (!Rules.IsFiltered(text)) return false; + any = true; + } + + return any; + } + + private List BuildLines(string baseText, string localText) + { + // 줄 필터는 설정 화면에서 언제든 바뀔 수 있다. 비교할 때마다 최신 값을 가져온다. + Rules.LinePatterns = SplitLines(_workspace.Settings.LineFilters ?? string.Empty); + + // 파일 끝의 빈 줄(마지막 엔터)은 내용 차이가 아니다 - 한쪽만 있어도 무시한다. + var baseLines = TrimTrailingBlank(SplitLines(baseText)); + var localLines = TrimTrailingBlank(SplitLines(localText)); + + // 줄 번호가 아니라 "내용이 같은 줄끼리" 맞춘다. + // 한쪽에 줄이 하나만 늘어도 그 아래가 통째로 밀려 엉뚱한 비교가 되던 문제를 없앤다. + // + // 공백·대소문자 무시는 "비교용 키"로 넘긴다 - 화면에 보이는 원문은 건드리지 않는다. + var baseKeys = Rules.IsPlain ? null : Array.ConvertAll(baseLines, Rules.KeyOf); + var localKeys = Rules.IsPlain ? null : Array.ConvertAll(localLines, Rules.KeyOf); + + var aligned = LineAligner.Align(baseLines, localLines, baseKeys, localKeys); + + var rows = new List(aligned.Count); + foreach (var row in aligned) + { + var line = new MergeLine(row.BaseText, row.BaseNumber, row.LocalText, row.LocalNumber, + Rules.IsPlain ? null : Rules.KeyOf); + + // 양쪽 다 필터에 걸리는 줄이면 차이로 세지 않는다. + // 한쪽만 걸리면 진짜 변경일 수 있으므로 그대로 둔다. + line.IsIgnored = IsFilteredRow(row); + + // 한 줄 안에서 바뀐 구간까지 표시 (기획안 4p) + if (_workspace.Settings.WordLevelDiff && line.State == MergeLineState.Changed) + { + var words = _workspace.Git.DiffWords(line.BaseText, line.LocalText); + line.BaseWords = words.Old; + line.LocalWords = words.New; + } + + rows.Add(line); + } + + MarkMoves(aligned, rows); + RefreshBlockCommentState(rows); + return rows; + } + + /// + /// 자리만 옮긴 덩어리를 찾아 "삭제 + 추가" 대신 "이동"으로 표시한다. + /// 메서드 순서만 바꾼 것을 두 개의 차이로 보여 주면 검토가 번거롭기 때문이다. + /// + private static void MarkMoves(IReadOnlyList aligned, List rows) + { + foreach (var move in MoveDetector.Find(aligned)) + { + var fromLine = rows[move.RemovedStart].BaseNumber; + var toLine = rows[move.AddedStart].LocalNumber; + + for (var k = 0; k < move.Length; k++) + { + rows[move.RemovedStart + k].MoveHint = toLine is null + ? "자리를 옮김" + : $"{toLine}행으로 옮김"; + + rows[move.AddedStart + k].MoveHint = fromLine is null + ? "옮겨 온 줄" + : $"{fromLine}행에서 옮겨 옴"; + } + } + } + + /// + /// 이 행을 차이 계산에서 뺄지. 양쪽에 있는 줄은 둘 다, 한쪽만 있는 줄은 그 한쪽이 + /// 필터에 걸려야 뺀다 - 타임스탬프 줄이 통째로 추가/삭제된 것도 무시하려는 것이다. + /// + private bool IsFilteredRow(AlignedRow row) + { + if (row.BaseText is null) return Rules.IsFiltered(row.LocalText); + if (row.LocalText is null) return Rules.IsFiltered(row.BaseText); + + return Rules.IsFiltered(row.BaseText) && Rules.IsFiltered(row.LocalText); + } + + private static string[] SplitLines(string text) => + text.Length == 0 ? Array.Empty() : text.Replace("\r\n", "\n").Split('\n'); + + /// 파일 끝의 빈 줄을 걷어낸다. 마지막 엔터 유무는 코드 차이가 아니다. + private static string[] TrimTrailingBlank(string[] lines) + { + var end = lines.Length; + while (end > 0 && string.IsNullOrWhiteSpace(lines[end - 1])) end--; + + if (end == lines.Length) return lines; + + var trimmed = new string[end]; + Array.Copy(lines, trimmed, end); + return trimmed; + } + + /// + /// 여러 줄에 걸친 /* ... */ 를 이어서 칠하려면 각 줄이 "앞에서 주석이 열린 채 왔는지"를 알아야 한다. + /// 위에서 아래로 한 번 훑으며 BASE/LOCAL 각각의 상태를 기록한다. + /// LOCAL 을 직접 고치면 그 아래 줄들의 상태가 달라지므로 편집 후에도 다시 계산한다. + /// + private void RefreshBlockCommentState(IEnumerable rows) + { + var inBase = false; + var inLocal = false; + + foreach (var row in rows) + { + row.BaseInComment = inBase; + row.LocalInComment = inLocal; + + CodeTokenizer.Tokenize(row.BaseText, Language, inBase, out inBase); + CodeTokenizer.Tokenize(row.LocalText, Language, inLocal, out inLocal); + } + } + + // ================= 줄 단위 편집 ================= + + /// + /// BASE 내용을 LOCAL 로 가져온다. + /// BASE 에 없는 줄(LOCAL 에서 추가된 줄)에 대해서는 "그 줄을 지우는 것"이 BASE 를 따르는 것이다. + /// + private void Apply(MergeLine line, bool requery = true) + { + var before = line.LocalText; + var beforeHasLocal = line.HasLocal; + + string after; + bool afterHasLocal; + string? note = null; + + if (line.HasBase) + { + // 반영한 줄에는 누가·언제 반영했는지 주석을 남긴다. + // 주석은 줄 끝이 아니라 그 줄 "위"에 따로 들어간다 - 아래 InsertNoteAbove 참고. + note = _workspace.Settings.InsertMergeComment ? BuildMergeNote() : null; + after = line.BaseText; + afterHasLocal = true; + } + else + { + after = string.Empty; + afterHasLocal = false; // BASE 에 없던 줄 = 지운다 + } + + if (beforeHasLocal == afterHasLocal && string.Equals(before, after, StringComparison.Ordinal)) + return; + + var description = line.HasBase + ? $"{line.BaseNumber}행 BASE 반영" + : $"{line.LocalNumber}행 삭제(BASE 에 없음)"; + + line.LocalText = after; + line.HasLocal = afterHasLocal; + line.AppliedNote = note; + + _undo.Push(new LineEdit(line, before, beforeHasLocal, after, afterHasLocal, + EditKind.TakeBase, description) + { + InsertedNote = InsertNoteAbove(line, note), + }); + _redo.Clear(); + + _workspace.AppendLog(description); + if (requery) AfterEdit(); + } + + // ================= 3-Way (REMOTE = 사용자가 고른 브랜치) ================= + + /// REMOTE 로 삼을 수 있는 브랜치 목록. 첫 항목은 "쓰지 않음"이다. + public ObservableCollection RemoteBranches { get; } = new ObservableCollection(); + + /// 브랜치를 고르지 않았다는 뜻의 항목. 이 값이면 2-Way 로 돈다. + public const string NoRemoteBranch = "(3-Way 안 씀)"; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsThreeWay))] + private string _remoteBranch = NoRemoteBranch; + + /// 지금 3-Way 로 보고 있는지. Git 모드에서 브랜치를 고른 경우에만 참이다. + public bool IsThreeWay => + IsGitMode && !string.IsNullOrEmpty(RemoteBranch) && RemoteBranch != NoRemoteBranch; + + public bool IsTwoWay => !IsThreeWay; + + /// + /// 읽기 전용 원본 패널(LOCAL 원본 · BASE · REMOTE)의 머리 높이. + /// + /// 3-Way 에서는 위아래로 두 줄을 쓰므로 머리·바닥이 세로를 두 번 먹는다. + /// 위쪽 세 칸은 참고용이라 머리를 줄이고 바닥은 아예 감춰서, 그만큼 코드를 더 보여 준다. + /// (세 칸끼리만 같으면 되고, 아래 MERGED 와는 맞출 필요가 없다.) + /// + public double SourceHeaderHeight => IsThreeWay ? 74 : 124; + + [ObservableProperty] private string _remoteHeader = "REMOTE"; + + /// 충돌 줄 수. 상태바와 요약에 쓴다. + [ObservableProperty] private int _conflictCount; + + partial void OnRemoteBranchChanged(string value) + { + OnPropertyChanged(nameof(IsThreeWay)); + OnPropertyChanged(nameof(IsTwoWay)); + + // 3-Way 로 들어가고 나가면서 화면이 4분할로 바뀐다. 제목과 머리 높이도 함께 바뀐다. + OnPropertyChanged(nameof(BaseTitle)); + OnPropertyChanged(nameof(LocalTitle)); + OnPropertyChanged(nameof(SourceHeaderHeight)); + + if (IsGitMode && !_suspendReload) _ = LoadFileAsync(); + } + + /// 워크스페이스가 읽어 온 브랜치 목록을 그대로 옮긴다. 고른 값은 남아 있으면 지킨다. + private void RefreshRemoteBranches() + { + var keep = RemoteBranch; + + RemoteBranches.Clear(); + RemoteBranches.Add(NoRemoteBranch); + + foreach (var branch in _workspace.Branches) + { + // 지금 체크아웃된 브랜치는 LOCAL 그 자체라 REMOTE 로 고를 이유가 없다. + if (string.Equals(branch.FriendlyName, _workspace.CurrentBranch, StringComparison.Ordinal)) + continue; + + if (!RemoteBranches.Contains(branch.FriendlyName)) RemoteBranches.Add(branch.FriendlyName); + } + + // 고른 브랜치가 사라졌으면 조용히 2-Way 로 돌아간다. + if (!RemoteBranches.Contains(keep)) RemoteBranch = NoRemoteBranch; + } + + private bool CanTakeLocalLine(MergeLine? line) => line?.CanTakeLocal == true; + + /// 내 원본 줄을 결과(MERGED)로 되돌린다. "Accept Mine" 에 해당한다. + [RelayCommand(CanExecute = nameof(CanTakeLocalLine))] + private void TakeLocalLine(MergeLine? line) + { + if (line is null || !line.CanTakeLocal) return; + + var before = line.LocalText; + var beforeHasLocal = line.HasLocal; + + var after = line.OriginalLocalText; + var afterHasLocal = line.HasOriginalLocal; + + if (beforeHasLocal == afterHasLocal && string.Equals(before, after, StringComparison.Ordinal)) + { + // 이미 내 원본과 같다. 충돌 줄이면 "내 것으로 정했다"는 표시만 남긴다. + line.IsApplied = true; + AfterEdit(); + return; + } + + var description = afterHasLocal + ? $"{line.LocalNumber}행 내 원본으로 되돌림" + : $"{line.LocalNumber}행 삭제(내 원본에 없음)"; + + line.LocalText = after; + line.HasLocal = afterHasLocal; + line.AppliedNote = null; + + _undo.Push(new LineEdit(line, before, beforeHasLocal, after, afterHasLocal, + EditKind.TakeBase, description)); + _redo.Clear(); + + _workspace.AppendLog(description); + AfterEdit(); + } + + private bool CanAcceptLocal() => SelectedLine?.CanTakeLocal == true; + + [RelayCommand(CanExecute = nameof(CanAcceptLocal))] + private void AcceptLocal() => TakeLocalLine(SelectedLine); + + private bool CanTakeRemoteLine(MergeLine? line) => line?.CanTakeRemote == true; + + /// REMOTE 줄을 내 작업으로 가져온다. BASE 를 가져오는 것과 같은 방식·같은 되돌리기 스택. + [RelayCommand(CanExecute = nameof(CanTakeRemoteLine))] + private void TakeRemoteLine(MergeLine? line) + { + if (line is null || !line.CanTakeRemote) return; + ApplyRemote(line); + } + + private bool CanAcceptRemote() => SelectedLine?.CanTakeRemote == true; + + [RelayCommand(CanExecute = nameof(CanAcceptRemote))] + private void AcceptRemote() => TakeRemoteLine(SelectedLine); + + /// + /// REMOTE 내용을 LOCAL 로 가져온다. + /// REMOTE 에 없는 줄이면 "그 줄을 지우는 것"이 저쪽을 따르는 것이다 - Apply 와 같은 규칙. + /// + private void ApplyRemote(MergeLine line, bool requery = true) + { + var before = line.LocalText; + var beforeHasLocal = line.HasLocal; + + string after; + bool afterHasLocal; + string? note = null; + + if (line.HasRemote) + { + note = _workspace.Settings.InsertMergeComment ? BuildMergeNote() : null; + after = line.RemoteText; + afterHasLocal = true; + } + else + { + after = string.Empty; + afterHasLocal = false; + } + + if (beforeHasLocal == afterHasLocal && string.Equals(before, after, StringComparison.Ordinal)) + return; + + var description = line.HasRemote + ? $"{line.RemoteNumber}행 {RemoteBranch} 반영" + : $"{line.LocalNumber}행 삭제({RemoteBranch} 에 없음)"; + + line.LocalText = after; + line.HasLocal = afterHasLocal; + line.AppliedNote = note; + + _undo.Push(new LineEdit(line, before, beforeHasLocal, after, afterHasLocal, + EditKind.TakeBase, description) + { + InsertedNote = InsertNoteAbove(line, note), + }); + _redo.Clear(); + + _workspace.AppendLog(description); + if (requery) AfterEdit(); + } + + /// + /// 반영한 줄 바로 위에 주석을 한 줄로 끼워 넣는다. + /// + /// 줄 끝에 붙이지 않는 이유 - 주석이 코드와 같은 줄에 있으면 줄이 길어져 가로로 밀리고, + /// 같은 줄을 두 번 반영하면 앞의 주석을 지우거나 줄이 끝없이 늘어난다. + /// 따로 한 줄로 두면 지우지 않고 쌓인다 - 새 주석이 코드 바로 위에 들어가면서 + /// 앞서 남긴 주석은 자연스럽게 위로 밀린다. + /// + /// 정렬은 저절로 맞는다. 끼워 넣은 줄은 BASE 에 없는 "LOCAL 에만 있는 줄"이라, + /// 이미 추가된 줄을 다루던 방식 그대로 BASE 쪽에 빈 자리(필러)가 생긴다. + /// + private MergeLine? InsertNoteAbove(MergeLine line, string? note) + { + if (string.IsNullOrWhiteSpace(note)) return null; + + var at = Lines.IndexOf(line); + if (at < 0) return null; + + // 코드 줄과 들여쓰기를 맞춘다. 주석만 왼쪽 끝에 붙어 있으면 눈에 거슬린다. + var indent = new string(' ', line.LocalText.Length - line.LocalText.TrimStart().Length); + + var row = new MergeLine(null, null, indent + note, null, + Rules.IsPlain ? null : Rules.KeyOf) + { + IsApplied = true, + IsMergeNote = true, + }; + + if (line.IsThreeWay) row.SetRemote(null, null); + + Lines.Insert(at, row); + return row; + } + + /// 줄 끝에 붙일 주석 한 조각. [사용자] 날짜 설명 - 세 가지로 끝. + private string BuildMergeNote() => MergeNote.Build( + author: _workspace.AuthorName, + when: DateTime.Now, + comment: MergeComment, + path: SelectedFile); + + /// 검사 목록에서 고른 줄로 화면을 옮긴다. + [RelayCommand] + private void GoToProblem(CodeProblemItem? item) + { + if (item is null || item.Row < 0) return; + GoToRow(item.Row); + } + + // ================= LOCAL 편집기 ================= + + /// + /// LOCAL 패널 편집기에 들어 있는 글. + /// + /// BASE 에만 있는 줄(= LOCAL 에서 지워진 줄) 자리에는 빈 줄(필러)이 들어간다. + /// 그래야 BASE 패널과 같은 내용이 같은 눈높이에 온다. 필러는 화면을 맞추려는 것일 뿐 + /// 파일 내용이 아니므로, 저장할 때 쓰는 글은 BuildLocalText() 로 따로 뽑는다. + /// + /// + /// 편집기 글을 읽어 오는 통로. View 가 꽂아 준다. + /// + /// 예전에는 TextBox.Text 를 양방향 바인딩했는데, 글자를 하나 칠 때마다 문서 전체가 + /// 문자열로 복사돼 큰 파일에서 눈에 띄게 느렸다. 지금은 필요할 때만 읽어 간다. + /// + public Func? ReadEditorText { get; set; } + + private string CurrentEditorText => ReadEditorText?.Invoke() ?? string.Empty; + + /// 편집기에 마지막으로 그려 넣은 줄과 그중 필러였던 자리. 사용자가 고친 뒤 되짚는 기준. + private string[] _editorLines = Array.Empty(); + private bool[] _editorFiller = Array.Empty(); + + /// 모델 쪽에서 편집기 글을 갈아 끼우는 중. 되먹임(다시 정렬)을 막는다. + private bool _syncingEditor; + + /// 모드를 통째로 바꾸는 중. 중간 상태 때문에 본문을 다시 읽지 않게 막는다. + private bool _suspendReload; + + /// 편집기 글을 통째로 갈아 끼웠다. View 가 캐럿 자리를 지키는 데 쓴다. + /// 편집기 글을 통째로 갈아 끼운다. 새 글을 함께 넘긴다. + public event EventHandler? EditorTextReplaced; + + /// 파일을 연 직후의 LOCAL 전문. "전체 되돌리기"의 기준점. + private string _originalLocalText = string.Empty; + + /// 정렬 기준이 되는 BASE 전문. 편집 후 다시 정렬할 때 쓴다. + private string _baseTextSnapshot = string.Empty; + + /// + /// 3-Way 의 REMOTE 전문. 편집 후 다시 정렬할 때 이것도 같이 넘겨야 + /// LOCAL(Mine)·REMOTE 칸이 살아 있다 - 없으면 글자 하나만 고쳐도 두 칸이 비어 버린다. + /// + private string _remoteTextSnapshot = string.Empty; + + /// + /// 3-Way 의 MINE(내 원본) 전문. 와 값이 같지만 뜻이 다르다 - + /// 저쪽은 "전체 되돌리기의 기준점"이고 이쪽은 "위 LOCAL 칸에 그려 줄 글"이다. + /// 편집 후 다시 정렬할 때 이 글을 따로 넘겨야 MINE 칸이 편집을 따라가지 않는다. + /// + private string _mineTextSnapshot = string.Empty; + + /// + /// 사용자가 편집기에서 글자를 고쳤다고 View 가 알려 준다. + /// + /// 화면에 보이는 글자는 편집기가 아니라 아래 표시층이 그린다(편집기 글자는 투명이다). + /// 그래서 표시층을 재정렬 타이머에만 맡기면 친 글자가 그 시간만큼 안 보인다 - + /// 사용자가 말한 "입력 딜레이"의 정체가 이것이다. 지금 치고 있는 줄만 즉시 옮겨 주고, + /// 무거운 BASE 재정렬은 타이핑이 멈춘 뒤에 한 번만 한다. + /// + /// 편집기 전문. 문자열 참조만 넘어오므로 복사 비용은 없다. + /// 캐럿 위치. 어느 줄을 쳤는지 여기서 찾는다. + public void NotifyEditorEdited(string text, int caretIndex) + { + if (_syncingEditor) return; + + ShowTypedLineNow(text, caretIndex); + + // 글자마다 다시 정렬하면 화면이 요동친다. 타이핑이 멈춘 뒤에 한 번만 한다. + _reflowTimer.Stop(); + _reflowTimer.Start(); + } + + /// + /// 캐럿이 있는 줄 하나만 표시층에 바로 반영한다. + /// + /// 줄 수가 달라졌으면(엔터·줄 합치기) 아래 줄이 통째로 밀린 것이라 한 줄만 고쳐서는 + /// 맞지 않는다. 그때는 손대지 않고 재정렬에 맡긴다. + /// + private void ShowTypedLineNow(string text, int caretIndex) + { + if (_editorLines.Length == 0 || Lines.Count != _editorLines.Length) return; + + // 한 번만 훑으면서 (캐럿 줄 번호, 그 줄의 범위, 전체 줄 수)를 함께 구한다. + int line = 0, start = 0, end = -1, count = 1; + + for (var i = 0; i < text.Length; i++) + { + if (text[i] != '\n') continue; + + if (i < caretIndex) { line++; start = i + 1; } + else if (end < 0) end = i; + + count++; + } + + if (end < 0) end = text.Length; + if (line >= _editorLines.Length || count != _editorLines.Length) return; + + var value = text.Substring(start, end - start).TrimEnd('\r'); + if (string.Equals(_editorLines[line], value, StringComparison.Ordinal)) return; + + _editorLines[line] = value; + Lines[line].LocalText = value; + DiffVersion++; + } + + /// 타이핑이 멈췄다. 필러를 걷어내고 BASE 와 다시 맞춘다. + private void Reflow() + { + _reflowTimer.Stop(); + + var current = SplitLines(CurrentEditorText); + var real = EditorLayout.StripFillers(_editorLines, _editorFiller, current); + + ReloadLinesFrom(string.Join("\n", real), keepAsEdited: true, moveSelection: false); + } + + /// 주어진 LOCAL 전문으로 비교 화면을 다시 만든다. + private void ReloadLinesFrom(string localText, bool keepAsEdited, bool moveSelection = true) + { + // 3-Way 면 3-Way 로 다시 맞춰야 한다. 2-Way 로 만들면 REMOTE·LOCAL 원본 칸의 + // 내용이 통째로 사라진다(칸은 그대로 있고 글만 빈다). + var rows = IsThreeWay + ? BuildThreeWayLines(_baseTextSnapshot, _mineTextSnapshot, _remoteTextSnapshot, localText) + : BuildLines(_baseTextSnapshot, localText); + + // 어떤 줄을 사용자가 직접 고쳤는지는 "파일을 연 시점의 LOCAL" 과 견줘야 안다. + // BASE 와 다르다는 이유만으로 칠하면 원래부터 달랐던 줄까지 전부 "직접 수정"이 된다. + var touched = keepAsEdited + ? ChangedLocalNumbers(_originalLocalText, localText) + : new HashSet(); + + Lines.Clear(); + foreach (var row in rows) + { + if (row.LocalNumber is int number && touched.Contains(number)) + row.IsManuallyEdited = true; + + Lines.Add(row); + } + + _undo.Clear(); + _redo.Clear(); + + if (moveSelection) + SelectedLine = Lines.FirstOrDefault(l => l.IsDifferent) ?? Lines.FirstOrDefault(); + + HasChanges = !string.Equals(localText, _originalLocalText, StringComparison.Ordinal); + + InvalidateDiffBlocks(); + SyncEditorFromLines(); + UpdateSummaries(); + RefreshDiffPosition(); + RaiseCanExecuteChanged(); + } + + /// + /// 모델(Lines)에서 편집기 글을 다시 만든다. + /// + /// 결과가 지금 편집기 글과 같으면 건드리지 않는다 - 타이핑 도중에 글을 갈아 끼우면 + /// 캐럿이 튀기 때문이다. 필러 자리가 실제로 달라졌을 때만 갈아 끼운다. + /// + private void SyncEditorFromLines() + { + var rows = new List<(string Text, bool HasLocal)>(Lines.Count); + foreach (var line in Lines) rows.Add((line.LocalText, line.HasLocal)); + + var snapshot = EditorLayout.Build(rows); + + _editorLines = snapshot.Lines; + _editorFiller = snapshot.Filler; + + var text = string.Join(Environment.NewLine, snapshot.Lines); + if (string.Equals(text, CurrentEditorText, StringComparison.Ordinal)) return; + + _syncingEditor = true; + EditorTextReplaced?.Invoke(this, text); + _syncingEditor = false; + } + + /// + /// 파일을 연 시점의 LOCAL 과 견줘, 사용자가 손댄 줄 번호를 가려낸다. + /// 새로 넣은 줄과 내용이 달라진 줄만 담긴다 - 그대로인 줄은 BASE 와 다르더라도 담지 않는다. + /// + private static HashSet ChangedLocalNumbers(string originalLocal, string currentLocal) + { + var before = TrimTrailingBlank(SplitLines(originalLocal)); + var after = TrimTrailingBlank(SplitLines(currentLocal)); + + var touched = new HashSet(); + + foreach (var row in LineAligner.Align(before, after)) + { + if (row.LocalNumber is not int number) continue; // 원본에만 있던 줄 = 지워진 줄 + + if (row.BaseText is null || + !string.Equals(row.BaseText, row.LocalText, StringComparison.Ordinal)) + { + touched.Add(number); + } + } + + return touched; + } + + // ================= Git 없이 파일 두 개 비교 ================= + + /// + /// Git 저장소를 열었는지. 아니면 "파일 두 개 직접 고르기" 모드로 돈다. + /// + /// 이 도구의 본체는 머지툴이고 Git 은 그 위에 얹는 기능이다. 저장소가 아니어도 + /// 아무 텍스트 파일 두 개를 비교·편집·저장할 수 있어야 한다. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsPlainMode))] + [NotifyPropertyChangedFor(nameof(ModeLabel))] + [NotifyPropertyChangedFor(nameof(BaseTitle))] + [NotifyPropertyChangedFor(nameof(LocalTitle))] + [NotifyPropertyChangedFor(nameof(DemoSwitchLabel))] + [NotifyPropertyChangedFor(nameof(CanSwitchDemo))] + private bool _isGitMode; + + public bool IsPlainMode => !IsGitMode; + + public string ModeLabel => IsGitMode + ? "Git 저장소" + : "파일 비교 (Git 아님)"; + + // 패널 이름은 머지툴 표준 용어를 그대로 쓴다(BASE · LOCAL/Mine · REMOTE/Theirs · MERGED). + // 한글 풀이를 덧붙이지 않는 이유 - 이 용어는 Git 과 다른 머지툴에서 그대로 쓰이므로 + // 여기서만 다른 말로 부르면 오히려 옮겨 다닐 때 헷갈린다. + + public string BaseTitle => "BASE"; + + /// 고치고 저장하는 칸. 3-Way 에서는 이 칸이 곧 결과(MERGED)다. + public string LocalTitle => IsThreeWay ? "MERGED" : "LOCAL"; + + /// 왼쪽(BASE) 으로 읽을 파일. Git 없이 비교할 때만 쓴다. + [ObservableProperty] private string _leftFile = string.Empty; + + /// 오른쪽(LOCAL) 으로 읽고, 저장할 때 덮어쓸 파일. + [ObservableProperty] private string _rightFile = string.Empty; + + [RelayCommand] + private void PickLeftFile() + { + if (AskForFile("왼쪽(기준) 파일 고르기", LeftFile) is { } picked) + { + LeftFile = picked; + OnPropertyChanged(nameof(BaseFileLocation)); + _ = LoadPlainFilesAsync(); + } + } + + [RelayCommand] + private void PickRightFile() + { + if (AskForFile("오른쪽(내 작업) 파일 고르기", RightFile) is { } picked) + { + RightFile = picked; + OnPropertyChanged(nameof(SelectedFileFullPath)); + _ = LoadPlainFilesAsync(); + } + } + + private static string? AskForFile(string title, string current) + { + var dialog = new Microsoft.Win32.OpenFileDialog + { + Title = title, + CheckFileExists = true, + Filter = "모든 파일 (*.*)|*.*" + }; + + if (!string.IsNullOrWhiteSpace(current)) + { + try { dialog.InitialDirectory = Path.GetDirectoryName(current); } + catch (ArgumentException) { /* 경로가 깨졌으면 그냥 기본 위치에서 연다 */ } + } + + return dialog.ShowDialog() == true ? dialog.FileName : null; + } + + /// 고른 두 파일을 읽어 비교 화면을 만든다. + private async Task LoadPlainFilesAsync() + { + if (string.IsNullOrWhiteSpace(LeftFile) || string.IsNullOrWhiteSpace(RightFile)) return; + + // 이진 파일은 줄 단위로 견줄 것이 없다. 깨진 글자로 화면을 채우느니 그렇다고 알린다. + if (TextFile.IsBinary(LeftFile) || TextFile.IsBinary(RightFile)) + { + _workspace.OnUi(() => + { + ClearPanes(); + IsBinaryFile = true; + RefreshLanguage(); + BaseHeader = $"BASE · {Path.GetFileName(LeftFile)}"; + LocalHeader = $"LOCAL · {Path.GetFileName(RightFile)}"; + DiffSummary = "이진 파일 - 줄 단위로 비교할 수 없습니다"; + }); + + _workspace.AppendLog("이진 파일이라 비교하지 않음"); + return; + } + + IsBinaryFile = false; + + string baseText = string.Empty, localText = string.Empty; + + // 다른 작업이 게이트를 쥐고 있으면 RunAsync 는 아무것도 하지 않고 false 를 준다. + // 그대로 밀고 나가면 빈 글로 화면을 갈아엎어 "차이 없음"이 뜬다. + if (!await _workspace.RunAsync("파일 비교", () => + { + baseText = TextFile.Read(LeftFile); + localText = TextFile.Read(RightFile); + })) + { + return; + } + + _workspace.OnUi(() => + { + // 읽어 오는 사이에 Git 모드로 넘어갔으면 버린다(위 LoadFileAsync 와 같은 이유). + if (IsGitMode) return; + + _baseTextSnapshot = baseText; + _remoteTextSnapshot = string.Empty; // 파일 두 개 비교는 늘 2-Way 다 + _mineTextSnapshot = string.Empty; + + RefreshLanguage(); + var rows = BuildLines(baseText, localText); + Lines.Clear(); + foreach (var row in rows) Lines.Add(row); + + BaseHeader = $"BASE · {Path.GetFileName(LeftFile)}"; + LocalHeader = $"LOCAL · {Path.GetFileName(RightFile)}"; + + _originalLocalText = BuildLocalText(); + SyncEditorFromLines(); + + var info = TextFile.Describe(RightFile); + RefreshLanguage(); + FileEncoding = info.Encoding; + FileNewLine = info.NewLine; + + _undo.Clear(); + _redo.Clear(); + CaretRow = 0; + InvalidateDiffBlocks(); + SelectedLine = Lines.FirstOrDefault(l => l.IsDifferent) ?? Lines.FirstOrDefault(); + + UpdateSummaries(); + RefreshDiffPosition(); + RaiseCanExecuteChanged(); + }); + } + + private bool CanSavePlain() => IsPlainMode && HasChanges && !string.IsNullOrWhiteSpace(RightFile); + + /// Git 없이 쓸 때는 커밋 없이 오른쪽 파일에 그대로 쓴다. + [RelayCommand(CanExecute = nameof(CanSavePlain))] + private async Task SavePlainAsync() + { + var content = BuildLocalText(); + var target = RightFile; + + var ok = await _workspace.RunAsync("파일 저장", () => TextFile.Write(target, content)); + if (!ok) return; + + _workspace.OnUi(() => + { + _originalLocalText = content; + _undo.Clear(); + _redo.Clear(); + HasChanges = false; + MessageUtil.ShowSuccessToast($"저장 완료 · {Path.GetFileName(target)}"); + }); + } + + // ================= 무시 규칙 ================= + + /// + /// "무엇을 차이로 볼 것인가". 설정에서 읽어 두고 툴바에서 바로 바꾼다. + /// 바꾸면 곧바로 다시 정렬해서 화면에 반영한다. + /// + public CompareRules Rules { get; } = new CompareRules(); + + /// 툴바 버튼에 띄우는 지금의 공백 규칙. + public string WhitespaceLabel => Rules.Whitespace switch + { + WhitespaceMode.IgnoreAmount => "공백 개수↓", + WhitespaceMode.IgnoreAll => "공백 무시", + _ => "공백 비교" + }; + + public string IgnoreCaseLabel => Rules.IgnoreCase ? "Aa 무시" : "Aa 구분"; + + public string LineFilterLabel => Rules.LineFilterEnabled ? "필터 켬" : "필터 끔"; + + /// 상태바에 띄우는 요약. 켠 규칙이 없으면 빈 문자열. + [ObservableProperty] private string _rulesSummary = string.Empty; + + /// 설정에 저장된 값을 규칙 객체로 옮긴다. + private void LoadRules() + { + var settings = _workspace.Settings; + + Rules.Whitespace = settings.WhitespaceMode switch + { + 1 => WhitespaceMode.IgnoreAmount, + 2 => WhitespaceMode.IgnoreAll, + _ => settings.IgnoreWhitespace ? WhitespaceMode.IgnoreAmount : WhitespaceMode.Exact + }; + + Rules.IgnoreCase = settings.IgnoreCase; + Rules.LineFilterEnabled = settings.UseLineFilters; + Rules.LinePatterns = SplitLines(settings.LineFilters ?? string.Empty); + + _appliedSettingsSignature = SettingsSignature(); + RefreshRuleLabels(); + } + + /// 마지막으로 비교에 쓴 설정값. 설정 화면에서 바뀌었는지 알아보려고 들고 있는다. + private string _appliedSettingsSignature = string.Empty; + + private string SettingsSignature() + { + var s = _workspace.Settings; + return $"{s.WhitespaceMode}|{s.IgnoreCase}|{s.UseLineFilters}|{s.LineFilters}"; + } + + /// + /// 홈 화면으로 돌아왔을 때 부른다. + /// 설정 화면에서 비교 규칙을 고치고 저장했으면 그때 한 번만 다시 비교한다. + /// (규칙은 BuildLines 가 매번 읽지만, 누군가 다시 비교를 시켜 줘야 화면이 바뀐다) + /// + public void RecompareIfSettingsChanged() + { + var now = SettingsSignature(); + if (now == _appliedSettingsSignature) return; + + _appliedSettingsSignature = now; + LoadRules(); + + if (Lines.Count > 0) ApplyRuleChange(); + } + + /// 공백 규칙을 전부 비교 → 개수만 → 전부 무시 로 돌린다. + [RelayCommand] + private void CycleWhitespace() + { + Rules.Whitespace = Rules.Whitespace switch + { + WhitespaceMode.Exact => WhitespaceMode.IgnoreAmount, + WhitespaceMode.IgnoreAmount => WhitespaceMode.IgnoreAll, + _ => WhitespaceMode.Exact + }; + + _workspace.Settings.WhitespaceMode = (int)Rules.Whitespace; + ApplyRuleChange(); + } + + [RelayCommand] + private void ToggleIgnoreCase() + { + Rules.IgnoreCase = !Rules.IgnoreCase; + _workspace.Settings.IgnoreCase = Rules.IgnoreCase; + ApplyRuleChange(); + } + + [RelayCommand] + private void ToggleLineFilter() + { + Rules.LineFilterEnabled = !Rules.LineFilterEnabled; + _workspace.Settings.UseLineFilters = Rules.LineFilterEnabled; + ApplyRuleChange(); + } + + /// 규칙이 바뀌면 지금 보고 있는 내용을 그대로 다시 정렬한다. + private void ApplyRuleChange() + { + _appliedSettingsSignature = SettingsSignature(); + RefreshRuleLabels(); + + if (Lines.Count == 0) return; + + // 편집 중이던 내용을 잃지 않도록 지금 편집기에 있는 글을 기준으로 다시 만든다. + var current = EditorLayout.StripFillers(_editorLines, _editorFiller, SplitLines(CurrentEditorText)); + ReloadLinesFrom(string.Join("\n", current), keepAsEdited: true, moveSelection: false); + } + + private void RefreshRuleLabels() + { + OnPropertyChanged(nameof(WhitespaceLabel)); + OnPropertyChanged(nameof(IgnoreCaseLabel)); + OnPropertyChanged(nameof(LineFilterLabel)); + RulesSummary = Rules.Describe() ?? string.Empty; + } + + // ================= 차이 탐색 · 이동 · 북마크 ================= + + /// + /// 차이 "덩어리"의 시작 행 번호들. 이어진 차이 줄 여러 개는 한 덩어리로 센다 - + /// 사용자가 보기에 12줄이 통째로 지워진 것은 차이 하나이지 열두 개가 아니기 때문이다. + /// + /// + /// 차이 덩어리 시작 위치. 줄 목록이 바뀔 때만 다시 센다. + /// + /// 예전에는 캐럿이 움직일 때마다 전체 줄을 훑으며 Highlight 를 계산했다. + /// Highlight 는 무시 규칙까지 적용해 두 쪽 글을 정규화하므로, 글자 하나 칠 때마다 + /// 파일 전체를 정규화하는 셈이었다 - 타이핑이 느려지던 진짜 원인이다. + /// + private List? _diffStarts; + + private List DiffBlockStarts() => _diffStarts ??= ComputeDiffBlockStarts(); + + /// 줄 목록이나 줄 상태가 바뀌면 다시 세도록 표시만 해 둔다. + private void InvalidateDiffBlocks() => _diffStarts = null; + + private List ComputeDiffBlockStarts() + { + var starts = new List(); + var inBlock = false; + + for (var i = 0; i < Lines.Count; i++) + { + // 무시 규칙에 걸린 줄은 건너뛴다 - 차이가 아니라고 해 놓고 탐색에 걸리면 안 된다. + var highlight = Lines[i].Highlight; + var isDiff = highlight != LineHighlight.None && highlight != LineHighlight.Ignored; + + if (isDiff && !inBlock) starts.Add(i); + inBlock = isDiff; + } + + return starts; + } + + /// 지금 캐럿이 놓인 행. 편집기 줄 번호와 1:1 이다. + [ObservableProperty] private int _caretRow; + + /// 상태바에 띄우는 "차이 3 / 15". + [ObservableProperty] private string _diffPosition = "차이 -"; + + /// View 에게 이 행으로 스크롤하라고 알린다. + public event EventHandler? ScrollToRowRequested; + + /// 프로그램이 캐럿을 옮기는 중. View 가 되짚어 주는 행 번호로 덮이지 않게 막는다. + private bool _movingCaret; + + private void GoToRow(int row) + { + if (row < 0 || row >= Lines.Count) return; + + _movingCaret = true; + try + { + CaretRow = row; + SelectedLine = Lines[row]; + ScrollToRowRequested?.Invoke(this, row); + } + finally + { + _movingCaret = false; + } + + RefreshDiffPosition(); + } + + /// 캐럿이 몇 번째 차이 덩어리 안에 있는지 다시 센다. + private void RefreshDiffPosition() + { + var starts = DiffBlockStarts(); + if (starts.Count == 0) + { + DiffPosition = "차이 없음"; + return; + } + + // 캐럿 위(또는 같은 자리)에서 가장 가까운 덩어리가 "지금 보고 있는" 차이다. + var index = -1; + for (var i = 0; i < starts.Count; i++) + if (starts[i] <= CaretRow) index = i; + + DiffPosition = index < 0 + ? $"차이 - / {starts.Count}" + : $"차이 {index + 1} / {starts.Count}"; + } + + private bool HasDiffs() => DiffBlockStarts().Count > 0; + + [RelayCommand(CanExecute = nameof(HasDiffs))] + private void FirstDiff() + { + var starts = DiffBlockStarts(); + if (starts.Count > 0) GoToRow(starts[0]); + } + + [RelayCommand(CanExecute = nameof(HasDiffs))] + private void LastDiff() + { + var starts = DiffBlockStarts(); + if (starts.Count > 0) GoToRow(starts[starts.Count - 1]); + } + + [RelayCommand(CanExecute = nameof(HasDiffs))] + private void NextDiff() + { + foreach (var start in DiffBlockStarts()) + if (start > CaretRow) { GoToRow(start); return; } + + FirstDiff(); // 끝까지 갔으면 처음으로 돈다 + } + + [RelayCommand(CanExecute = nameof(HasDiffs))] + private void PrevDiff() + { + var starts = DiffBlockStarts(); + for (var i = starts.Count - 1; i >= 0; i--) + if (starts[i] < CaretRow) { GoToRow(starts[i]); return; } + + LastDiff(); + } + + // ---- 줄 번호로 이동 ---- + + /// 이동 상자에 친 줄 번호. 비워 두면 아무 일도 안 한다. + [ObservableProperty] private string _goToText = string.Empty; + + [RelayCommand] + private void GoTo() + { + if (!int.TryParse(GoToText.Trim(), out var number) || number < 1) return; + + // 사용자가 아는 번호는 LOCAL 파일의 줄 번호다. 화면 행 번호와 다를 수 있다 + // (지워진 줄 자리에 빈 행이 끼어 있기 때문). + for (var i = 0; i < Lines.Count; i++) + if (Lines[i].LocalNumber == number) { GoToRow(i); return; } + + // 그런 LOCAL 줄이 없으면 화면 행 번호로 받아 준다. + GoToRow(Math.Min(number - 1, Lines.Count - 1)); + } + + // ---- 북마크 ---- + + private bool HasBookmark() => Lines.Any(l => l.IsBookmarked); + + /// 다음 북마크로. 끝까지 가면 처음으로 돈다. + [RelayCommand(CanExecute = nameof(HasBookmark))] + private void NextBookmark() + { + for (var i = CaretRow + 1; i < Lines.Count; i++) + if (Lines[i].IsBookmarked) { GoToRow(i); return; } + + for (var i = 0; i <= CaretRow && i < Lines.Count; i++) + if (Lines[i].IsBookmarked) { GoToRow(i); return; } + } + + [RelayCommand] + private void ToggleBookmark(MergeLine? line) + { + if (line is null) return; + + line.IsBookmarked = !line.IsBookmarked; + NextBookmarkCommand.NotifyCanExecuteChanged(); + } + + // ---- 편집 잠금 ---- + + /// 실수로 고치는 것을 막는 잠금. 화면은 그대로 보이고 타이핑만 막는다. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ReadOnlyLabel))] + private bool _isReadOnly; + + public string ReadOnlyLabel => IsReadOnly ? "잠금 해제" : "편집 잠금"; + + /// 긴 줄을 접어서 보여줄지. 켜면 가로 스크롤이 없어진다. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(WrapLabel))] + private bool _wordWrap; + + // 다른 규칙 버튼과 같은 길이로 맞춘다 - "줄바꿈 끄기"는 칸을 넘어 글자가 잘렸다. + public string WrapLabel => WordWrap ? "줄바꿈 켬" : "줄바꿈 끔"; + + // ---- 상태바 ---- + + /// "12행 5열" 처럼 캐럿 자리를 보여 준다. + [ObservableProperty] private string _caretPosition = "-"; + + /// 파일이 어떤 인코딩·줄바꿈으로 저장돼 있는지. + [ObservableProperty] private string _fileEncoding = "-"; + [ObservableProperty] private string _fileNewLine = "-"; + + // ---- 언어 (작업 4: 소스가 아니어도, C# 이 아니어도 비교할 수 있게) ---- + + /// + /// 지금 보고 있는 파일을 어떤 언어로 읽을지. 확장자로 고르고, 모르면 평문이다. + /// 색칠·코드 검사·블록 주석 이어붙이기가 모두 이 하나를 본다. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(LanguageName))] + private LanguageProfile _language = LanguageCatalog.Plain; + + public string LanguageName => Language.Name; + + /// 지금 파일이 글이 아니라 이진 파일인지. 맞으면 비교하지 않고 그렇다고 알린다. + [ObservableProperty] private bool _isBinaryFile; + + /// 비교 대상 파일의 경로. Git 모드면 워킹트리 경로, 아니면 고른 오른쪽 파일. + private string? CurrentFilePath => IsPlainMode + ? (string.IsNullOrWhiteSpace(RightFile) ? null : RightFile) + : SelectedFile; + + /// 파일이 바뀔 때마다 언어를 다시 고른다. + private void RefreshLanguage() => Language = LanguageCatalog.ForPath(CurrentFilePath); + + /// View 가 캐럿을 옮길 때마다 불러 준다. + public void ReportCaret(int row, int column) + { + CaretPosition = $"{row + 1}행 {column + 1}열"; + + // 프로그램이 옮기는 중이면 목표 행이 이미 정해져 있다 - 그 값을 지킨다. + if (_movingCaret) return; + + // 같은 줄 안에서 좌우로만 움직였으면 선택 줄도 차이 위치도 그대로다. + // 여기서 걸러야 글자마다 명령 상태를 다시 계산하는 일이 없어진다. + if (row == CaretRow) return; + + CaretRow = row; + SelectLineAt(row); + RefreshDiffPosition(); + } + + /// 편집기에서 캐럿이 놓인 줄을 선택 줄로 삼는다. Accept/Revert 의 대상이 된다. + public void SelectLineAt(int index) + { + if (index >= 0 && index < Lines.Count) SelectedLine = Lines[index]; + } + + // ================= 코드 이상 검사 ================= + + /// 검사에서 걸린 문제들. 비어 있으면 이상 없음. + public ObservableCollection Problems { get; } = new ObservableCollection(); + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasProblems))] + private string _problemSummary = "이상 없음"; + + public bool HasProblems => Problems.Count > 0; + + /// + /// LOCAL 내용을 훑어 눈에 띄는 실수를 찾는다. + /// 컴파일러가 아니므로 "빌드된다"는 보증은 아니다 - 주석 기호를 빼먹은 한글, + /// 닫히지 않은 따옴표, 괄호 짝만 본다. + /// + private void RefreshProblems() + { + Problems.Clear(); + + foreach (var line in Lines) line.Problem = null; + + // CurrentFilePath 를 쓴다. SelectedFile 은 Git 모드에서만 채워지므로, + // 파일 두 개를 직접 고른 경우에는 늘 "검사 대상 아님"이 됐다. + if (!CodeSanityChecker.AppliesTo(CurrentFilePath)) + { + ProblemSummary = "검사 대상 아님"; + OnPropertyChanged(nameof(HasProblems)); + return; + } + + var localLines = Lines.Where(l => l.HasLocal).ToList(); + var texts = localLines.Select(l => l.LocalText).ToList(); + var found = CodeSanityChecker.Check(texts, Language); + + foreach (var problem in found) + { + if (problem.LineIndex >= 0 && problem.LineIndex < localLines.Count) + { + var line = localLines[problem.LineIndex]; + line.Problem = problem.Message; + Problems.Add(new CodeProblemItem(Lines.IndexOf(line), $"{line.LocalNumber}행", problem.Message)); + } + else + { + Problems.Add(new CodeProblemItem(-1, "파일", problem.Message)); + } + } + + ProblemSummary = Problems.Count == 0 + ? "이상 없음" + : $"확인 필요 {Problems.Count}건"; + + OnPropertyChanged(nameof(HasProblems)); + } + + + private void AfterEdit() + { + HasChanges = _undo.Count > 0; + RefreshAppliedFlags(); + RefreshBlockCommentState(Lines); + InvalidateDiffBlocks(); + SyncEditorFromLines(); + UpdateSummaries(); + RaiseCanExecuteChanged(); + } + + /// 편집 하나를 되돌리거나 다시 적용한다. 줄 존재 여부까지 함께 복원한다. + private void Restore(LineEdit edit, bool toBefore) + { + edit.Line.LocalText = toBefore ? edit.Before : edit.After; + edit.Line.HasLocal = toBefore ? edit.BeforeHasLocal : edit.AfterHasLocal; + + // 이 편집이 끼워 넣은 주석 줄도 함께 넣고 뺀다. + // 본문만 되돌리고 주석을 남기면 파일에 유령 주석이 쌓인다. + if (edit.InsertedNote is not { } note) return; + + if (toBefore) + { + Lines.Remove(note); + return; + } + + if (Lines.Contains(note)) return; + + var at = Lines.IndexOf(edit.Line); + Lines.Insert(at < 0 ? Lines.Count : at, note); + } + + /// + /// "머지 반영함"과 "직접 수정" 표시를 편집 이력에서 다시 계산한다. + /// 둘을 섞으면 안 된다 - 사용자가 직접 지운 줄이 "머지 반영함"으로 보이면 안 되기 때문이다. + /// Undo 로 되돌린 줄은 자동으로 표시가 풀린다. + /// + private void RefreshAppliedFlags() + { + var applied = new HashSet(); + var edited = new HashSet(); + + foreach (var edit in _undo) + { + // 스택은 최신이 먼저다. 같은 줄을 여러 번 고쳤으면 마지막 편집의 종류를 따른다. + if (applied.Contains(edit.Line) || edited.Contains(edit.Line)) continue; + + if (edit.Kind == EditKind.TakeBase) applied.Add(edit.Line); + else edited.Add(edit.Line); + } + + foreach (var line in Lines) + { + line.IsApplied = applied.Contains(line); + line.IsManuallyEdited = edited.Contains(line); + + if (!line.IsApplied) line.AppliedNote = null; + } + } + + private void UpdateSummaries() + { + var total = Lines.Count; + var diff = Lines.Count(l => l.IsDifferent); + + DiffSummary = total == 0 ? "-" : $"총 {total} 라인 · 차이 {diff} 라인"; + DiffVersion++; + EditSummary = $"편집 {_undo.Count}건 · 되돌리기 {_redo.Count}건 · 남은 차이 {diff} 라인"; + + UpdateLegendCounts(); + RefreshProblems(); + } + + // ================= 범례에 실시간으로 붙는 개수 ================= + + [ObservableProperty] private int _changedCount; + [ObservableProperty] private int _appliedCount; + [ObservableProperty] private int _editedCount; + [ObservableProperty] private int _addedCount; + [ObservableProperty] private int _removedCount; + [ObservableProperty] private int _movedCount; + [ObservableProperty] private int _ignoredCount; + [ObservableProperty] private int _sameCount; + + /// 3-Way 에서 저쪽만 고친 줄 수. + [ObservableProperty] private int _remoteChangeCount; + + /// + /// 줄 하나만 반영해도 곧바로 범례 숫자가 바뀌도록, 편집이 끝날 때마다 다시 센다. + /// (UpdateSummaries 가 AfterEdit / 파일 로드 양쪽에서 호출된다) + /// + private void UpdateLegendCounts() + { + int changed = 0, applied = 0, edited = 0, added = 0, removed = 0, moved = 0, ignored = 0, same = 0; + int remote = 0, conflict = 0; + + foreach (var line in Lines) + { + switch (line.Highlight) + { + case LineHighlight.Applied: applied++; break; + case LineHighlight.Edited: edited++; break; + case LineHighlight.Changed: changed++; break; + case LineHighlight.Added: added++; break; + case LineHighlight.Removed: removed++; break; + case LineHighlight.Moved: moved++; break; + case LineHighlight.Ignored: ignored++; break; + case LineHighlight.RemoteChange: remote++; break; + case LineHighlight.Conflict: conflict++; break; + default: same++; break; + } + } + + ChangedCount = changed; + AppliedCount = applied; + EditedCount = edited; + AddedCount = added; + RemovedCount = removed; + MovedCount = moved; + IgnoredCount = ignored; + SameCount = same; + RemoteChangeCount = remote; + ConflictCount = conflict; + } + + private string BuildLocalText() + { + var sb = new StringBuilder(); + foreach (var line in Lines) + { + if (!line.HasLocal) continue; // BASE 에만 있던 줄 - LOCAL 파일에는 없다 + sb.Append(line.LocalText).Append('\n'); + } + return sb.ToString(); + } + } +} diff --git a/src/MergeHub/Views/Controls/AppStyles.xaml b/src/MergeHub/Views/Controls/AppStyles.xaml new file mode 100644 index 0000000..f65fa0b --- /dev/null +++ b/src/MergeHub/Views/Controls/AppStyles.xaml @@ -0,0 +1,50 @@ + + + + + + diff --git a/src/MergeHub/Views/Controls/CSharpTokenizer.cs b/src/MergeHub/Views/Controls/CSharpTokenizer.cs new file mode 100644 index 0000000..0097640 --- /dev/null +++ b/src/MergeHub/Views/Controls/CSharpTokenizer.cs @@ -0,0 +1,315 @@ +using System; +using System.Collections.Generic; + +namespace MergeHub.Views.Controls +{ + /// + /// 코드 한 조각의 종류. 색을 나누는 단위라 "무엇인지"보다 "어떻게 보여야 하는지"에 맞춰 쪼갰다. + /// + public enum CodeTokenKind + { + /// 지역 변수·매개변수·필드 등 그냥 이름. + Default, + /// class, new, null, is 처럼 선언·식에 쓰는 예약어. + Keyword, + /// if, for, return, throw 처럼 흐름을 바꾸는 예약어. + ControlKeyword, + /// int, string, void, var 같은 내장 타입 예약어. + TypeKeyword, + /// public, private, protected, internal. + AccessModifier, + /// static, readonly, async, override 같은 한정자. + Modifier, + /// 대문자로 시작하는 이름 - 타입으로 본다. + Type, + /// 뒤에 여는 괄호가 오는 이름 - 메서드 호출/선언으로 본다. + Method, + /// 문자열·문자 리터럴. + Text, + /// 일반 주석. + Comment, + /// /// XML 문서 주석. + XmlDoc, + /// 숫자 리터럴. + Number, + /// #region, #if 같은 전처리 지시문. + Preprocessor, + /// + - = == && 같은 연산자. + Operator, + /// ( ) { } [ ] ; , . 같은 구두점. + 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; } + } + + /// + /// 화면 표시용 C# 토크나이저 - 한 줄씩 독립적으로 훑는다. + /// 컴파일러가 아니라 "색을 칠하기 위한" 수준이라 다음은 다루지 않는다: + /// - 여러 줄에 걸친 블록 주석은 시작한 줄만 주석으로 칠해진다 + /// - 제네릭 메서드 호출 Foo<int>(...) 의 Foo 는 타입으로 잡힌다 + /// + public static class CSharpTokenizer + { + private static readonly HashSet AccessModifiers = new HashSet(StringComparer.Ordinal) + { + "public", "private", "protected", "internal", "file" + }; + + private static readonly HashSet Modifiers = new HashSet(StringComparer.Ordinal) + { + "abstract", "async", "const", "extern", "override", "partial", "readonly", + "required", "sealed", "static", "unsafe", "virtual", "volatile", "init" + }; + + private static readonly HashSet ControlKeywords = new HashSet(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 TypeKeywords = new HashSet(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 Keywords = new HashSet(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 Tokenize(string line) => Tokenize(line, false, out _); + + /// + /// 앞 줄에서 블록 주석(/* )이 열린 채 넘어왔는지를 받아 처리하고, + /// 이 줄이 끝난 뒤에도 열려 있는지를 돌려준다. + /// 이렇게 해야 여러 줄에 걸친 /* ... */ 가 전부 주석색으로 칠해진다. + /// + public static IReadOnlyList Tokenize(string line, bool inBlockComment, out bool stillInBlockComment) + { + var tokens = new List(); + 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; + } + + /// 예약어 목록을 먼저 보고, 아니면 뒤에 여는 괄호가 오는지로 메서드를 가린다. + 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; + } + + /// @"..." / $"..." / $@"..." / @$"..." 를 통째로 읽는다. + 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; + } + + /// 여는 따옴표부터 닫는 따옴표까지의 길이. 닫히지 않으면 줄 끝까지. + 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; + } + } +} diff --git a/src/MergeHub/Views/Controls/ChangeMiniMap.cs b/src/MergeHub/Views/Controls/ChangeMiniMap.cs new file mode 100644 index 0000000..f417755 --- /dev/null +++ b/src/MergeHub/Views/Controls/ChangeMiniMap.cs @@ -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 +{ + /// + /// 파일 전체의 변경 분포를 세로 막대로 축소해 보여준다(목업 ③ 코드 미니맵). + /// 클릭하면 그 위치의 줄을 요청한다 - 실제 스크롤은 화면 쪽에서 처리한다. + /// + 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)); + + /// + /// 범례·코드 패널과 같은 색을 쓰되, 미니맵은 얇아서 옅은 알파로는 안 보인다. + /// 그래서 같은 색상값을 불투명하게 올려 쓴다. + /// + 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 = "변경 분포 - 클릭하면 해당 줄로 이동"; + } + + /// 클릭한 지점에 해당하는 줄이 선택되었을 때 발생. + public event EventHandler? 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); + } + + /// + /// 줄 내용이 바뀌어도 컬렉션 자체는 그대로라 다시 그릴 계기가 없다. + /// ViewModel 이 편집할 때마다 이 값을 올려서 갱신을 알린다. + /// + 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 Snapshot() + { + if (Source is null) return new List(); + return Source.OfType().ToList(); + } + + private static T Freeze(T freezable) where T : Freezable + { + freezable.Freeze(); + return freezable; + } + } +} diff --git a/src/MergeHub/Views/Controls/CodeTextBlock.cs b/src/MergeHub/Views/Controls/CodeTextBlock.cs new file mode 100644 index 0000000..57ce11d --- /dev/null +++ b/src/MergeHub/Views/Controls/CodeTextBlock.cs @@ -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 +{ + /// + /// 코드 한 줄을 문법 색과 함께 그린다. + /// - 글자색 = C# 토큰 종류 (접근제한자는 따로 구분해 굵게) + /// - 배경색 = 단어 단위 diff 로 잡힌 "이 줄에서 실제로 바뀐 구간" + /// 두 축이 겹치므로 TextBlock 의 Inlines 를 직접 구성한다. + /// + public sealed class CodeTextBlock : TextBlock + { + /// 이보다 긴 줄은 색칠을 포기하고 평문으로 그린다(목록 성능 보호). + 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)); + + /// 표시할 코드 한 줄. + public string Code + { + get => (string)GetValue(CodeProperty); + set => SetValue(CodeProperty, value); + } + + public static readonly DependencyProperty WordsProperty = DependencyProperty.Register( + nameof(Words), typeof(IReadOnlyList), typeof(CodeTextBlock), + new FrameworkPropertyMetadata(null, OnVisualInputChanged)); + + /// 단어 단위 diff 결과. 없으면 배경 강조를 하지 않는다. + public IReadOnlyList? Words + { + get => (IReadOnlyList?)GetValue(WordsProperty); + set => SetValue(WordsProperty, value); + } + + public static readonly DependencyProperty InBlockCommentProperty = DependencyProperty.Register( + nameof(InBlockComment), typeof(bool), typeof(CodeTextBlock), + new FrameworkPropertyMetadata(false, OnVisualInputChanged)); + + /// 앞 줄에서 /* 가 열린 채 넘어왔는지. 여러 줄 블록 주석을 이어서 칠하기 위한 값. + 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)); + + /// + /// 어떤 언어로 읽을지. 비워 두면 평문으로 본다 - 색을 잘못 입히느니 안 입히는 편이 낫다. + /// + 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; + } + + /// + /// 단어 단위 diff 세그먼트를 문자 위치 마스크로 바꾼다. + /// 세그먼트 길이 합이 본문 길이와 다르면(계산이 어긋난 경우) 강조를 포기한다. + /// + private static bool[] BuildChangeMask(int length, IReadOnlyList? 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; + } + + /// 글자색이 밝으면 어두운 배경 위에 있다고 본다. + 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; } + + /// Default 는 null - 상속받은 Foreground 를 그대로 쓴다. + 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; + } + } + } +} diff --git a/src/MergeHub/Views/Controls/CommitGraphCell.cs b/src/MergeHub/Views/Controls/CommitGraphCell.cs new file mode 100644 index 0000000..2ac1d13 --- /dev/null +++ b/src/MergeHub/Views/Controls/CommitGraphCell.cs @@ -0,0 +1,121 @@ +using System; +using System.Windows; +using System.Windows.Media; +using MergeHub.Models; + +namespace MergeHub.Views.Controls +{ + /// + /// 커밋 목록 한 행의 왼쪽에 브랜치 그래프를 직접 그린다. + /// 도형을 XAML 로 조립하면 행마다 수십 개 요소가 생겨 목록이 무거워지므로 OnRender 로 그린다. + /// + public sealed class CommitGraphCell : FrameworkElement + { + private const double LaneWidth = 14; + private const double DotRadius = 4.5; + private const double StrokeThickness = 1.8; + + /// 레인 색. 플랫폼 테마와 무관하게 그래프끼리 구분되는 색을 쓴다. + 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]; + } +} diff --git a/src/MergeHub/Views/Controls/LineHighlightBrushes.cs b/src/MergeHub/Views/Controls/LineHighlightBrushes.cs new file mode 100644 index 0000000..889ed31 --- /dev/null +++ b/src/MergeHub/Views/Controls/LineHighlightBrushes.cs @@ -0,0 +1,124 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; +using MergeHub.Models; + +namespace MergeHub.Views.Controls +{ + /// + /// 줄 배경색의 단일 정의. 코드 패널과 범례(색 구분 안내)가 같은 값을 쓰도록 여기 한 곳에 둔다. + /// 알파를 낮게 잡아 밝은 테마·어두운 테마 어디에 얹어도 글자가 묻히지 않게 했다. + /// + public static class LineHighlightBrushes + { + /// BASE 와 다름 (아직 반영 안 함) - 주황. + public static readonly Brush Changed = Freeze("#33E0A020"); + + /// BASE 를 반영함 - 초록. + public static readonly Brush Applied = Freeze("#332EA043"); + + /// 내가 직접 고침 - 보라. 반영과 확실히 구분되어야 한다. + public static readonly Brush Edited = Freeze("#339B59B6"); + + /// LOCAL 에만 있는 줄(추가) - 파랑. + public static readonly Brush Added = Freeze("#332E86DE"); + + /// BASE 에만 있는 줄(삭제) - 빨강. + public static readonly Brush Removed = Freeze("#33D14B4B"); + + /// 지운 것이 아니라 자리만 옮긴 줄 - 청록. 삭제/추가와 헷갈리면 안 되므로 계열을 달리했다. + public static readonly Brush Moved = Freeze("#3300A0A8"); + + /// 무시 규칙에 걸린 줄 - 아주 옅은 회색. "차이 없음"과 구분은 되되 눈에 띄지 않아야 한다. + public static readonly Brush Ignored = Freeze("#18808080"); + + /// 3-Way: 저쪽만 고친 줄 - 남색. 내가 고친 것(파랑 계열)과 계열은 같되 더 짙다. + public static readonly Brush RemoteChange = Freeze("#333B5BA5"); + + /// 3-Way: 양쪽이 다르게 고친 줄(충돌) - 진한 자홍. 가장 눈에 띄어야 한다. + 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; + } + } + + /// MergeLine.Highlight -> 배경 Brush. + 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(); + } + + /// 범례에서 쓰는 이름 기반 조회. ConverterParameter 로 "Changed" 같은 이름을 넘긴다. + 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(name, out var highlight) + ? LineHighlightBrushes.For(highlight) + : LineHighlightBrushes.None; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => + throw new NotSupportedException(); + } + + /// + /// 폴더 비교 목록의 줄 배경. 코드 패널과 같은 색 언어를 쓴다 - + /// 다름=주황, 왼쪽만=빨강(없어짐), 오른쪽만=파랑(새로 생김), 이진=청록. + /// + 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(); + } + + /// 빈 문자열이면 숨긴다. 상태바에서 켠 규칙이 없을 때 구분선까지 감추려고 쓴다. + 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(); + } +} diff --git a/src/MergeHub/Views/FolderView.xaml b/src/MergeHub/Views/FolderView.xaml new file mode 100644 index 0000000..34dbb78 --- /dev/null +++ b/src/MergeHub/Views/FolderView.xaml @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +