ksj09406 26446d5e6b 5차 B-1: XAML 태그·속성·값을 갈라 칠한다
실제 소스에 XAML 이 400개 42,477줄인데 여태 주석과 따옴표만 알아봐서
사실상 평문이었다. 마크업 전용 토크나이저를 따로 뒀다.

기존 토크나이저에 못 얹은 까닭은 규칙이 다르기 때문이다. 그쪽은 낱말을
읽어 예약어 표와 맞춰 보는데, 마크업은 같은 Content 가 꺾쇠 안에서는
속성 이름이고 밖에서는 그냥 글자다. 낱말이 무엇이냐가 아니라 어디에
있느냐로 갈린다.

줄 넘김 상태를 bool 하나에서 깃발 묶음(CarryOver)으로 넓혔다. XAML 은
여는 태그가 여러 줄에 걸치는 것이 보통이라 - 시험에 쓴 실제 파일은 첫
태그 하나가 14줄이다 - "지금 꺾쇠 안이다"를 안 넘기면 이어지는 줄의
속성이 그냥 글자로 보인다. 부르는 자리 다섯을 모두 바꿨다.

색은 새로 만들지 않고 이미 있는 갈래에 얹었다. 태그는 예약어 색,
속성 이름은 메서드 색, 값은 문자열 색, 앞가지는 타입 색이다. 태그
밖의 본문은 일부러 안 칠한다.

붙이는 중에 파일 비교 화면이 통째로 비어서 뜨는 일이 있었다. 의존
속성 타입을 bool 에서 CarryOver 로 바꾸면서 기본값 false 를 같이 안
고쳤다. 빌드도 통과하고 로그에도 안 남는다. 같은 주의를 코드 옆에
적어 뒀다.

실제 저장소 XAML 사본(638줄)으로 화면 확인. C# 색칠은 그대로.
MarkupTest 65건. 전체 688건 통과(하네스 20벌). 경고 0 · 오류 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:01:58 +09:00

2952 lines
130 KiB
C#

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
{
/// <summary>
/// 홈 = 머지툴 본체. 커밋 트리 / BASE(공통 조상) / LOCAL(내 작업) 3분할 비교와
/// 줄 단위 부분 수용(Accept·Revert), Undo/Redo, Save &amp; Commit 을 담당한다.
/// 저장소 조작(clone/fetch/branch/merge 등)은 Git 탭으로 분리했다.
/// </summary>
public sealed partial class HomeViewModel : ObservableObject
{
private readonly GitWorkspace _workspace;
private readonly Stack<LineEdit> _undo = new Stack<LineEdit>();
private readonly Stack<LineEdit> _redo = new Stack<LineEdit>();
/// <summary>타이핑이 멈춘 뒤에 다시 정렬하려고 두는 짧은 대기.</summary>
private readonly DispatcherTimer _reflowTimer = new DispatcherTimer();
public HomeViewModel(GitWorkspace workspace)
{
_workspace = workspace;
_workspace.Refreshed += OnWorkspaceRefreshed;
_workspace.StateChanged += RaiseCanExecuteChanged;
_workspace.PlainCompareRequested += OnPlainCompareRequested;
_workspace.Recent.Changed += (_, _) => _workspace.OnUi(RefreshRecent);
_workspace.QueueChanged += (_, _) => RefreshQueue();
// Git 작업이 시작되기 전에 우리에게 물어보게 한다(5차 A-2).
_workspace.ConfirmDiscardEdits = op => ConfirmDiscard($"Git 작업을 합니다: {op}");
_isRulesOpen = _workspace.Settings.ActionsRulesExpanded;
_reflowTimer.Tick += (_, _) => Reflow();
RefreshRecent();
LoadRules();
IsGitMode = _workspace.IsRepositoryOpen;
RefreshRemoteBranches();
// 폴더 비교에서 파일을 골라 이 화면이 만들어진 경우라면 그 파일을 먼저 연다.
// 이 화면은 탭을 처음 열 때 만들어지므로, 요청이 우리보다 먼저 도착해 있을 수 있다.
if (_workspace.HasPendingPlainCompare)
{
_workspace.DeliverPendingPlainCompare();
return;
}
// 설정에 기본 저장소가 있으면 바로 열어서 첫 화면부터 내용이 보이게 한다.
// 저장소가 아니면 설정에 적어 둔 두 파일로 비교 화면을 만든다 - Git 없이도 쓰는
// 도구라는 것을 첫 화면에서 바로 보여 준다. 둘을 동시에 걸면 안 된다.
// 작업 게이트가 하나뿐이라 먼저 건 쪽이 끝날 때까지 뒤엣것이 통째로 취소된다.
if (_workspace.LooksLikeRepository)
_ = _workspace.TryAutoOpenAsync();
else
_ = LoadDefaultPlainFiles();
}
/// <summary>
/// 폴더 비교 탭에서 고른 두 파일을 여기서 연다.
///
/// Git 저장소를 열어 둔 상태여도 "파일 두 개 비교"로 전환한다 - 폴더 비교에서 고른 파일은
/// 저장소 안의 것이 아닐 수도 있고, 사용자가 보고 싶은 것은 그 두 파일이지 커밋 이력이 아니다.
/// </summary>
private void OnPlainCompareRequested(string leftFile, string rightFile)
{
// 한쪽만 있어도 연다 - 없는 쪽은 빈 내용이 되어 전부 추가(또는 삭제)로 보인다.
// 폴더 비교에서 "왼쪽만 / 오른쪽만" 인 파일을 열어 보려는 것이 바로 이 경우다.
if (!File.Exists(leftFile) && !File.Exists(rightFile)) return;
// 폴더 비교 더블클릭 · 폴더 검색 결과 · 끌어다 놓기 · 탐색기 우클릭 · mergetool 이 모두 여기로 온다.
var opening = Path.GetFileName(File.Exists(rightFile) ? rightFile : leftFile);
if (!ConfirmDiscard($"다른 파일을 엽니다: {opening}")) return;
// 3-Way 를 끄되 그 때문에 본문을 다시 읽지는 않게 한다 - 그 재로드가 작업 게이트를
// 먼저 잡으면 바로 아래 파일 비교가 "다른 작업 진행 중"으로 건너뛰어진다.
_suspendReload = true;
try
{
IsGitMode = false;
RemoteBranch = NoRemoteBranch;
}
finally
{
_suspendReload = false;
}
LeftFile = leftFile;
RightFile = rightFile;
OnPropertyChanged(nameof(BaseFileLocation));
OnPropertyChanged(nameof(SelectedFileFullPath));
_ = LoadPlainFilesAsync();
}
// ================= 데모 전환 (발표·교육용) =================
/// <summary>설정에 데모 경로가 둘 다 적혀 있을 때만 전환 버튼을 띄운다.</summary>
public bool CanSwitchDemo =>
Directory.Exists(_workspace.Settings.DemoGitPath) &&
Directory.Exists(_workspace.Settings.DemoPlainPath);
/// <summary>다음에 어디로 넘어가는지 버튼에 그대로 적는다.</summary>
public string DemoSwitchLabel => IsGitMode ? "데모 → .git 없음" : "데모 → .git 있음";
/// <summary>
/// Git 있는 폴더와 없는 폴더를 오간다.
///
/// 발표에서 "저장소면 이렇게, 아니면 이렇게"를 한 자리에서 보여 주려고 둔 것이다.
/// 그동안은 설정 파일을 고치고 프로그램을 다시 켜는 것 말고는 방법이 없었다.
/// </summary>
[RelayCommand]
private async Task SwitchDemoAsync()
{
if (!CanSwitchDemo) return;
if (!ConfirmDiscard("데모 폴더를 바꿉니다.")) 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}");
}
/// <summary>설정의 기본 비교 파일 두 개를 열어 둔다. 없거나 사라졌으면 조용히 넘어간다.</summary>
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;
/// <summary>코드 패널에 C# 문법 색을 입힐지. 설정 &gt; MergeHub 에서 끌 수 있다.</summary>
public bool SyntaxHighlight => _workspace.Settings.SyntaxHighlight;
// ================= 3분할 상태 =================
/// <summary>BASE 와 LOCAL 을 같은 줄 번호로 묶은 행 목록.</summary>
public ObservableCollection<MergeLine> Lines { get; } = new ObservableCollection<MergeLine>();
/// <summary>비교 대상 파일 목록(충돌 중이면 충돌 파일, 아니면 선택 커밋의 파일).</summary>
public ObservableCollection<string> Files { get; } = new ObservableCollection<string>();
// ================= 오른쪽 칸 접고 펴기 =================
/// <summary>
/// 저장 형식 · 비교 규칙 · 편집 잠금을 펼쳐 두었는지.
///
/// <b>왜 접나</b> - 오른쪽 칸에는 <b>매번 쓰는 것</b>(반영 · 되돌리기 · 차이 이동 · 저장)과
/// <b>한 번 정하면 그대로인 것</b>(저장 형식 · 비교 규칙 · 편집 잠금)이 섞여 있었다.
/// 뒤엣것을 접으면 그만큼 코드 폭이 넓어진다. 코드를 나란히 읽는 도구에서 가로 한 칸은
/// 그대로 읽을 수 있는 글자 수다.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(RulesToggleLabel))]
private bool _isRulesOpen;
/// <summary>
/// 단추 글자. 무엇이 숨어 있고 누르면 무슨 일이 나는지 함께 적는다 -
/// 화살표만으로는 "여기 접힌 것이 있다"가 전해지지 않는다(C-3 · C-4 에서 두 번 겪었다).
/// </summary>
public string RulesToggleLabel =>
IsRulesOpen ? "▾ 저장 형식 · 비교 규칙 접기" : "▸ 저장 형식 · 비교 규칙 펴기";
[RelayCommand]
private void ToggleRules()
{
IsRulesOpen = !IsRulesOpen;
_workspace.Settings.ActionsRulesExpanded = IsRulesOpen;
_workspace.SaveSettings();
}
// ================= 폴더에서 훑는 중인 파일 이동 =================
/// <summary>훑을 목록이 있는지. 폴더를 거치지 않고 연 파일에는 없다.</summary>
public bool HasFileQueue => _workspace.Queue.Count > 0 && _workspace.QueueIndex >= 0;
/// <summary>지금 몇 번째인지. 예: <c>3 / 27</c>.</summary>
public string FileQueuePosition =>
HasFileQueue ? $"{_workspace.QueueIndex + 1} / {_workspace.Queue.Count}" : string.Empty;
private bool CanPrevFile() => HasFileQueue && _workspace.QueueIndex > 0;
private bool CanNextFile() => HasFileQueue && _workspace.QueueIndex < _workspace.Queue.Count - 1;
[RelayCommand(CanExecute = nameof(CanPrevFile))]
private void PrevFile() => MoveFile(-1);
[RelayCommand(CanExecute = nameof(CanNextFile))]
private void NextFile() => MoveFile(1);
/// <summary>
/// 폴더 비교 목록에서 앞뒤 파일로 옮긴다.
///
/// 여기서 <b>먼저 묻고</b> 그 다음에 자리를 옮긴다. 확인 창에서 취소하면 자리도 그대로여야 하는데,
/// 자리를 먼저 옮기면 취소했을 때 목록 위치만 앞서 나간다.
/// 물은 뒤에는 <c>_suspendGuard</c> 로 막는다 - 여는 길(<c>RequestPlainCompare</c>)이
/// 같은 것을 한 번 더 묻기 때문이다.
/// </summary>
private void MoveFile(int delta)
{
var next = _workspace.QueueIndex + delta;
if (next < 0 || next >= _workspace.Queue.Count) return;
var pair = _workspace.Queue[next];
if (!ConfirmDiscard($"다른 파일을 엽니다: {pair.Name}")) return;
_workspace.QueueIndex = next;
_suspendGuard = true;
try { _workspace.RequestPlainCompare(pair.Left, pair.Right); }
finally { _suspendGuard = false; }
RefreshQueue();
}
private void RefreshQueue()
{
OnPropertyChanged(nameof(HasFileQueue));
OnPropertyChanged(nameof(FileQueuePosition));
PrevFileCommand.NotifyCanExecuteChanged();
NextFileCommand.NotifyCanExecuteChanged();
}
// ================= 최근 비교 목록 =================
/// <summary>최근에 견준 파일 짝. 위 줄의 목록에 뿌린다.</summary>
public ObservableCollection<RecentPair> RecentFiles { get; } = new ObservableCollection<RecentPair>();
private void RefreshRecent()
{
RecentFiles.Clear();
foreach (var pair in _workspace.Recent.Files) RecentFiles.Add(pair);
}
/// <summary>
/// 최근 목록에서 고른 짝을 연다.
///
/// 자리를 옮겼거나 지워진 짝이라도 <b>목록에서 지우지 않는다</b> - 네트워크 드라이브나 USB 는
/// 잠깐 안 보일 뿐이다. 대신 왜 못 여는지 알려 준다.
/// 여는 길은 다른 데와 같다(<c>RequestPlainCompare</c>) - 저장하지 않은 편집도 그 길에서 함께 걸린다.
/// </summary>
public void OpenRecent(RecentPair? pair)
{
if (pair is null) return;
if (!File.Exists(pair.Left) && !File.Exists(pair.Right))
{
MessageUtil.ShowWarningToast("두 파일 모두 찾을 수 없습니다. 자리를 옮겼거나 지워진 것 같습니다.");
return;
}
// 최근 목록으로 연 파일은 딛고 갈 목록이 없다. 남아 있던 목록을 그대로 두면
// 다음 파일이 엉뚱한 데로 간다.
_workspace.ClearQueue();
_workspace.RequestPlainCompare(pair.Left, pair.Right);
}
[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;
/// <summary>커밋 트리 ListBox 가 바인딩하는 선택 항목(그래프 포함).</summary>
[ObservableProperty] private CommitNode? _selectedNode;
/// <summary>미니맵은 컬렉션이 그대로여도 줄 내용이 바뀌면 다시 그려야 한다.</summary>
[ObservableProperty] private int _diffVersion;
/// <summary>머지한 줄에 붙일 사용자 설명. 비워도 작성자·날짜 정보는 항상 들어간다.</summary>
[ObservableProperty] private string _mergeComment = string.Empty;
/// <summary>확인 창에서 [취소] 를 눌렀을 때 돌아갈 자리.</summary>
private string? _lastLoadedFile;
/// <summary>되돌리는 중. 그 사이에는 다시 묻지도, 다시 읽지도 않는다.</summary>
private bool _revertingFile;
partial void OnSelectedFileChanged(string? value)
{
if (_revertingFile)
{
// 목록 선택만 제자리로 돌린 것이다. 화면 내용은 그대로 두면 된다.
RaiseSelectedFileNotices();
return;
}
if (!ConfirmDiscard($"다른 파일을 엽니다: {value}"))
{
var back = _lastLoadedFile;
_revertingFile = true;
try { SelectedFile = back; }
finally { _revertingFile = false; }
return;
}
_lastLoadedFile = value;
RaiseSelectedFileNotices();
_ = LoadFileAsync();
}
private void RaiseSelectedFileNotices()
{
OnPropertyChanged(nameof(SelectedFilePath));
OnPropertyChanged(nameof(SelectedFileFullPath));
OnPropertyChanged(nameof(BaseFileLocation));
OnPropertyChanged(nameof(MergeNotePreview));
}
partial void OnMergeCommentChanged(string value) => OnPropertyChanged(nameof(MergeNotePreview));
// ---- 파일 경로 ----
/// <summary>저장소 기준 상대 경로.</summary>
public string SelectedFilePath => string.IsNullOrEmpty(SelectedFile) ? "-" : SelectedFile!;
/// <summary>디스크상의 전체 경로. 툴팁·복사용.</summary>
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));
}
}
/// <summary>
/// BASE 쪽 출처. 워킹트리 파일이 아니라 커밋(또는 병합 중이면 공통 조상)에서 읽은 내용이라
/// 디스크 경로가 아니라 "커밋:경로" 형태로 보여준다.
/// </summary>
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}";
}
}
/// <summary>실제로 줄 끝에 붙을 주석을 미리 보여준다.</summary>
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();
/// <summary>새로고침으로 목록을 다시 만들 때 선택을 잇는 중이면 재진입을 막는다.</summary>
private bool _syncingSelection;
/// <summary>확인 창에서 [취소] 를 눌렀을 때 돌아갈 커밋.</summary>
private CommitNode? _lastLoadedNode;
private bool _revertingNode;
partial void OnSelectedNodeChanged(CommitNode? value)
{
if (_syncingSelection || _revertingNode) { _lastLoadedNode = value; return; }
// 목록을 다시 세우면 ListBox 가 선택을 <b>잠깐 놓친다</b> - 항목 객체가 통째로 새로 만들어지므로
// null 이 한 번 들어온다. 이건 사람이 고른 것이 아니다.
// 그런데도 물으면 <b>스테이징만 해도 "다른 커밋을 봅니다" 창이 뜬다</b>(5차 A-2 에서 실제로 그랬다).
//
// 같은 커밋으로 다시 들어오는 경우도 마찬가지다 - 새로고침이 제자리를 찾아 준 것이지
// 사람이 옮긴 것이 아니다.
var sameCommit = value?.Commit?.Sha is string sha && sha == SelectedCommit?.Sha;
if (value is not null && !sameCommit && !ConfirmDiscard("다른 커밋을 봅니다."))
{
var back = _lastLoadedNode;
_revertingNode = true;
try { SelectedNode = back; }
finally { _revertingNode = false; }
return;
}
_lastLoadedNode = value;
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;
/// <summary>목업 ② 부분 수용 - LOCAL 줄 번호 옆 [&gt;] 버튼. BASE 줄을 내 작업으로 가져온다.</summary>
[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);
/// <summary>이 줄에 대한 마지막 편집만 되돌린다.</summary>
[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;
/// <summary>
/// 파일을 연 시점의 LOCAL 로 통째로 되돌린다.
///
/// 되돌리기(Undo) 목록까지 비우므로 <b>되살릴 방법이 없다.</b> 그래서 한 번 묻는다 -
/// 이름이 분명한 단추라 해도 잘못 누르면 그때까지 고친 것이 통째로 사라진다.
/// </summary>
[RelayCommand(CanExecute = nameof(CanRevertAll))]
private void RevertAll()
{
if (!MessageUtil.ShowYesNo("전체 되돌리기",
"고친 내용을 모두 버리고 파일을 연 시점으로 돌아갑니다.\n\n되살릴 수 없습니다. 계속할까요?"))
return;
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);
/// <summary>LOCAL 패널 내용을 워킹트리 파일에 쓰고 스테이징 후 커밋한다.</summary>
[RelayCommand(CanExecute = nameof(CanSaveAndCommit))]
private async Task SaveAndCommitAsync()
{
var path = SelectedFile;
if (string.IsNullOrEmpty(path)) return;
var content = BuildLocalText();
var message = CommitMessage;
var encoding = SaveEncoding;
var newLine = SaveNewLine;
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));
// 저장 형식을 고른 값으로 쓴다("원본 유지"면 지금까지와 같다).
TextFile.Write(full, content, encoding, newLine);
_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();
}
/// <summary>CommunityToolkit 의 커맨드는 CommandManager 를 구독하지 않아 직접 알려야 한다.</summary>
private void RaiseCanExecuteChanged()
{
TakeLineCommand.NotifyCanExecuteChanged();
AcceptCommand.NotifyCanExecuteChanged();
RevertCommand.NotifyCanExecuteChanged();
UndoCommand.NotifyCanExecuteChanged();
RedoCommand.NotifyCanExecuteChanged();
AcceptAllCommand.NotifyCanExecuteChanged();
// 3-Way 의 두 버튼. 빠져 있어서 줄을 옮겨도 켜짐/꺼짐이 그대로였다 -
// CommunityToolkit 의 커맨드는 CommandManager 를 구독하지 않으므로 직접 알려야 한다.
AcceptLocalCommand.NotifyCanExecuteChanged();
AcceptRemoteCommand.NotifyCanExecuteChanged();
FirstDiffCommand.NotifyCanExecuteChanged();
PrevDiffCommand.NotifyCanExecuteChanged();
NextDiffCommand.NotifyCanExecuteChanged();
LastDiffCommand.NotifyCanExecuteChanged();
NextBookmarkCommand.NotifyCanExecuteChanged();
RevertAllCommand.NotifyCanExecuteChanged();
ReloadCommand.NotifyCanExecuteChanged();
SaveAndCommitCommand.NotifyCanExecuteChanged();
SavePlainCommand.NotifyCanExecuteChanged();
ExportReportCommand.NotifyCanExecuteChanged();
AutoMergeCommand.NotifyCanExecuteChanged();
AcceptBlockCommand.NotifyCanExecuteChanged();
NextConflictCommand.NotifyCanExecuteChanged();
PrevConflictCommand.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();
// 여기서 다시 세우는 것은 사용자가 고른 것이 아니다. LoadCommitAsync 안에서 묻지 않게 한다.
}
/// <summary>선택 커밋이 담고 있는 파일 목록을 채우고, 가능하면 이전 선택을 유지한다.</summary>
private async Task LoadCommitAsync()
{
var commit = SelectedCommit;
if (commit is null || !_workspace.IsRepositoryOpen) return;
var previous = SelectedFile;
IReadOnlyList<string> files = Array.Empty<string>();
var changed = new List<string>();
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(() =>
{
// 목록을 프로그램이 다시 세우는 중이다. 사용자가 고른 것이 아니므로 묻지 않는다 -
// 무엇에 답하는 창인지 알 수 없고, 취소해도 목록은 이미 새로 세워진 뒤다.
//
// <b>Clear() 까지 감싸야 한다.</b> 목록을 비우면 거기 묶인 ListBox 가 선택을 놓치면서
// SelectedFile 에 null 을 밀어 넣는다. 그것도 "사용자가 파일을 바꿨다" 로 읽혀
// 스테이징만 해도 "다른 파일을 엽니다" 창이 떴다(5차 A-2).
_suspendGuard = true;
try
{
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();
}
finally
{
_suspendGuard = false;
}
});
}
/// <summary>선택 파일의 BASE(선택 커밋 시점)와 LOCAL(현재 워킹트리)을 줄 단위로 맞춰 싣는다.</summary>
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);
// BASE 가 비어 있으면 <b>그 파일이 공통 조상에 없었다</b>는 뜻이다 - 양쪽 브랜치가
// 각자 새로 만든 파일이라 견줄 조상이 없다. 그냥 두면 빈 칸이 고장으로 보인다.
// 큰 제목이 이미 BASE/LOCAL 이라고 말하므로 여기서는 되풀이하지 않는다(4차 C-1).
BaseHeaderLead = string.Empty;
LocalHeaderLead = string.Empty;
BaseHeader = threeWay
? string.IsNullOrEmpty(baseText)
? $"({baseOrigin}) 이 파일 없음 - 양쪽에서 새로 만든 파일입니다"
: $"({baseOrigin}) {path}"
: $"{commit.ShortSha} · {path}";
LocalHeader = _workspace.CurrentBranch;
RemoteHeader = isConflict
? "REMOTE · THEIRS"
: $"REMOTE · {branch}";
_baseTextSnapshot = baseText;
_remoteTextSnapshot = threeWay ? remoteText : string.Empty;
_mineTextSnapshot = threeWay ? localText : string.Empty;
_appliedBaseNumbers.Clear(); // 파일이 바뀌면 앞 파일의 결정은 뜻이 없다
_appliedMineNumbers.Clear();
_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<string>();
_editorFiller = Array.Empty<bool>();
InvalidateDiffBlocks();
BaseHeader = "BASE";
LocalHeader = "LOCAL (Mine)";
DiffSummary = "-";
EditSummary = "-";
HasChanges = false;
}
/// <summary>
/// BASE · LOCAL · REMOTE 세 벌을 한 줄씩 맞춰 행을 만든다.
///
/// 정렬은 <see cref="ThreeWayAligner"/> 가 맡고, 여기서는 그 결과를 화면 모델로 옮기면서
/// 무시 규칙·단어 단위 diff 를 얹는다. 이동 감지(MarkMoves)는 걸지 않는다 -
/// 세 갈래에서는 "옮겼다"의 기준이 둘로 갈려(내 쪽 기준? 저쪽 기준?) 뜻이 흐려진다.
/// </summary>
private List<MergeLine> 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<MergeLine>(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;
}
/// <summary>네 쪽 모두(있는 쪽만) 줄 필터에 걸리면 차이로 세지 않는다.</summary>
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<MergeLine> 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<MergeLine>(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;
}
/// <summary>
/// 자리만 옮긴 덩어리를 찾아 "삭제 + 추가" 대신 "이동"으로 표시한다.
/// 메서드 순서만 바꾼 것을 두 개의 차이로 보여 주면 검토가 번거롭기 때문이다.
/// </summary>
private static void MarkMoves(IReadOnlyList<AlignedRow> aligned, List<MergeLine> 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}행에서 옮겨 옴";
}
}
}
/// <summary>
/// 이 행을 차이 계산에서 뺄지. 양쪽에 있는 줄은 둘 다, 한쪽만 있는 줄은 그 한쪽이
/// 필터에 걸려야 뺀다 - 타임스탬프 줄이 통째로 추가/삭제된 것도 무시하려는 것이다.
/// </summary>
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<string>() : text.Replace("\r\n", "\n").Split('\n');
/// <summary>파일 끝의 빈 줄을 걷어낸다. 마지막 엔터 유무는 코드 차이가 아니다.</summary>
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;
}
/// <summary>
/// 줄을 넘어가는 것들(<c>/* ... */</c> · XAML 의 여러 줄짜리 여는 태그)을 이어서 칠하려면
/// 각 줄이 "앞에서 무엇이 열린 채 왔는지"를 알아야 한다.
/// 위에서 아래로 한 번 훑으며 BASE/LOCAL 각각의 상태를 기록한다.
/// LOCAL 을 직접 고치면 그 아래 줄들의 상태가 달라지므로 편집 후에도 다시 계산한다.
/// </summary>
private void RefreshBlockCommentState(IEnumerable<MergeLine> rows)
{
var inBase = CarryOver.None;
var inLocal = CarryOver.None;
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);
}
}
// ================= 줄 단위 편집 =================
/// <summary>
/// BASE 내용을 LOCAL 로 가져온다.
/// BASE 에 없는 줄(LOCAL 에서 추가된 줄)에 대해서는 "그 줄을 지우는 것"이 BASE 를 따르는 것이다.
/// </summary>
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),
});
// 반영 표시는 되돌리기 스택 밖에도 남긴다 - 규칙을 바꾸면 스택이 비워지기 때문이다.
MarkApplied(line);
_redo.Clear();
_workspace.AppendLog(description);
if (requery) AfterEdit();
}
// ================= 3-Way (REMOTE = 사용자가 고른 브랜치) =================
/// <summary>REMOTE 로 삼을 수 있는 브랜치 목록. 첫 항목은 "쓰지 않음"이다.</summary>
public ObservableCollection<string> RemoteBranches { get; } = new ObservableCollection<string>();
/// <summary>브랜치를 고르지 않았다는 뜻의 항목. 이 값이면 2-Way 로 돈다.</summary>
public const string NoRemoteBranch = "(3-Way 안 씀)";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsThreeWay))]
private string _remoteBranch = NoRemoteBranch;
/// <summary>지금 3-Way 로 보고 있는지. Git 모드에서 브랜치를 고른 경우에만 참이다.</summary>
public bool IsThreeWay =>
IsGitMode && !string.IsNullOrEmpty(RemoteBranch) && RemoteBranch != NoRemoteBranch;
public bool IsTwoWay => !IsThreeWay;
/// <summary>
/// 읽기 전용 원본 패널(LOCAL 원본 · BASE · REMOTE)의 머리 높이.
///
/// 3-Way 에서는 위아래로 두 줄을 쓰므로 머리·바닥이 세로를 두 번 먹는다.
/// 위쪽 세 칸은 참고용이라 머리를 줄이고 바닥은 아예 감춰서, 그만큼 코드를 더 보여 준다.
/// (세 칸끼리만 같으면 되고, 아래 MERGED 와는 맞출 필요가 없다.)
/// </summary>
public double SourceHeaderHeight => IsThreeWay ? 74 : 124;
[ObservableProperty] private string _remoteHeader = "REMOTE";
/// <summary>충돌 줄 수. 상태바와 요약에 쓴다.</summary>
[ObservableProperty] private int _conflictCount;
/// <summary>확인 창에서 [취소] 를 눌렀을 때 돌아갈 브랜치.</summary>
private string _lastLoadedRemoteBranch = NoRemoteBranch;
private bool _revertingRemoteBranch;
partial void OnRemoteBranchChanged(string value)
{
// 3-Way 로 들고 나면 본문을 다시 읽는다. 고치던 것이 있으면 사라진다.
// _suspendReload 가 걸린 길(데모 전환·파일 비교 전환)은 다시 읽지 않으므로 묻지 않는다.
if (!_revertingRemoteBranch && !_syncingBranches && IsGitMode && !_suspendReload &&
!ConfirmDiscard("비교 방식을 바꿉니다."))
{
var back = _lastLoadedRemoteBranch;
_revertingRemoteBranch = true;
try { RemoteBranch = back; }
finally { _revertingRemoteBranch = false; }
return;
}
_lastLoadedRemoteBranch = value;
OnPropertyChanged(nameof(IsThreeWay));
OnPropertyChanged(nameof(IsTwoWay));
// 3-Way 로 들어가고 나가면서 화면이 4분할로 바뀐다. 제목과 머리 높이도 함께 바뀐다.
OnPropertyChanged(nameof(BaseTitle));
OnPropertyChanged(nameof(LocalTitle));
OnPropertyChanged(nameof(SourceHeaderHeight));
if (IsGitMode && !_suspendReload) _ = LoadFileAsync();
}
/// <summary>워크스페이스가 읽어 온 브랜치 목록을 그대로 옮긴다. 고른 값은 남아 있으면 지킨다.</summary>
/// <summary>
/// 브랜치 목록을 다시 세우는 중. 그 사이 콤보가 값을 흘리는 것은 사람이 고른 것이 아니다.
/// 묻지 않게 막는다 - 안 막으면 <b>스테이징만 해도 "비교 방식을 바꿉니다" 창이 뜬다</b>
/// (5차 A-2 에서 실제로 그랬다).
/// </summary>
private bool _syncingBranches;
private void RefreshRemoteBranches()
{
var keep = RemoteBranch;
_syncingBranches = true;
try
{
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;
else RemoteBranch = keep; // 목록을 다시 세우며 흘린 값을 제자리로
}
finally
{
_syncingBranches = false;
}
}
private bool CanTakeLocalLine(MergeLine? line) => line?.CanTakeLocal == true;
/// <summary>내 원본 줄을 결과(MERGED)로 되돌린다. "Accept Mine" 에 해당한다.</summary>
[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))
{
// 이미 내 원본과 같다. 충돌 줄이면 "내 것으로 정했다"는 표시만 남긴다.
// 내용이 안 바뀌므로 다시 정렬하면 이 표시가 사라진다 - BASE 줄 번호로 따로 붙들어 둔다.
line.IsApplied = true;
MarkApplied(line);
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));
// 반영 표시는 되돌리기 스택 밖에도 남긴다 - 규칙을 바꾸면 스택이 비워지기 때문이다.
MarkApplied(line);
_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;
/// <summary>REMOTE 줄을 내 작업으로 가져온다. BASE 를 가져오는 것과 같은 방식·같은 되돌리기 스택.</summary>
[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);
/// <summary>
/// REMOTE 내용을 LOCAL 로 가져온다.
/// REMOTE 에 없는 줄이면 "그 줄을 지우는 것"이 저쪽을 따르는 것이다 - Apply 와 같은 규칙.
/// </summary>
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),
});
// 반영 표시는 되돌리기 스택 밖에도 남긴다 - 규칙을 바꾸면 스택이 비워지기 때문이다.
MarkApplied(line);
_redo.Clear();
_workspace.AppendLog(description);
if (requery) AfterEdit();
}
/// <summary>
/// 반영한 줄 <b>바로 위</b>에 주석을 한 줄로 끼워 넣는다.
///
/// 줄 끝에 붙이지 않는 이유 - 주석이 코드와 같은 줄에 있으면 줄이 길어져 가로로 밀리고,
/// 같은 줄을 두 번 반영하면 앞의 주석을 지우거나 줄이 끝없이 늘어난다.
/// 따로 한 줄로 두면 <b>지우지 않고 쌓인다</b> - 새 주석이 코드 바로 위에 들어가면서
/// 앞서 남긴 주석은 자연스럽게 위로 밀린다.
///
/// 정렬은 저절로 맞는다. 끼워 넣은 줄은 BASE 에 없는 "LOCAL 에만 있는 줄"이라,
/// 이미 추가된 줄을 다루던 방식 그대로 BASE 쪽에 빈 자리(필러)가 생긴다.
/// </summary>
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;
}
/// <summary>줄 끝에 붙일 주석 한 조각. [사용자] 날짜 설명 - 세 가지로 끝.</summary>
private string BuildMergeNote() => MergeNote.Build(
author: _workspace.AuthorName,
when: DateTime.Now,
comment: MergeComment,
path: SelectedFile);
/// <summary>검사 목록에서 고른 줄로 화면을 옮긴다.</summary>
[RelayCommand]
private void GoToProblem(CodeProblemItem? item)
{
if (item is null || item.Row < 0) return;
GoToRow(item.Row);
}
// ================= LOCAL 편집기 =================
/// <summary>
/// LOCAL 패널 편집기에 들어 있는 글.
///
/// BASE 에만 있는 줄(= LOCAL 에서 지워진 줄) 자리에는 빈 줄(필러)이 들어간다.
/// 그래야 BASE 패널과 같은 내용이 같은 눈높이에 온다. 필러는 화면을 맞추려는 것일 뿐
/// 파일 내용이 아니므로, 저장할 때 쓰는 글은 BuildLocalText() 로 따로 뽑는다.
/// </summary>
/// <summary>
/// 편집기 글을 읽어 오는 통로. View 가 꽂아 준다.
///
/// 예전에는 TextBox.Text 를 양방향 바인딩했는데, 글자를 하나 칠 때마다 문서 전체가
/// 문자열로 복사돼 큰 파일에서 눈에 띄게 느렸다. 지금은 필요할 때만 읽어 간다.
/// </summary>
public Func<string>? ReadEditorText { get; set; }
private string CurrentEditorText => ReadEditorText?.Invoke() ?? string.Empty;
/// <summary>편집기에 마지막으로 그려 넣은 줄과 그중 필러였던 자리. 사용자가 고친 뒤 되짚는 기준.</summary>
private string[] _editorLines = Array.Empty<string>();
private bool[] _editorFiller = Array.Empty<bool>();
/// <summary>모델 쪽에서 편집기 글을 갈아 끼우는 중. 되먹임(다시 정렬)을 막는다.</summary>
private bool _syncingEditor;
/// <summary>모드를 통째로 바꾸는 중. 중간 상태 때문에 본문을 다시 읽지 않게 막는다.</summary>
private bool _suspendReload;
/// <summary>편집기 글을 통째로 갈아 끼웠다. View 가 캐럿 자리를 지키는 데 쓴다.</summary>
/// <summary>편집기 글을 통째로 갈아 끼운다. 새 글을 함께 넘긴다.</summary>
public event EventHandler<string>? EditorTextReplaced;
/// <summary>파일을 연 직후의 LOCAL 전문. "전체 되돌리기"의 기준점.</summary>
private string _originalLocalText = string.Empty;
/// <summary>정렬 기준이 되는 BASE 전문. 편집 후 다시 정렬할 때 쓴다.</summary>
private string _baseTextSnapshot = string.Empty;
/// <summary>
/// 3-Way 의 REMOTE 전문. 편집 후 다시 정렬할 때 <b>이것도 같이 넘겨야</b>
/// LOCAL(Mine)·REMOTE 칸이 살아 있다 - 없으면 글자 하나만 고쳐도 두 칸이 비어 버린다.
/// </summary>
private string _remoteTextSnapshot = string.Empty;
/// <summary>
/// 3-Way 의 MINE(내 원본) 전문. <see cref="_originalLocalText"/> 와 값이 같지만 뜻이 다르다 -
/// 저쪽은 "전체 되돌리기의 기준점"이고 이쪽은 "위 LOCAL 칸에 그려 줄 글"이다.
/// 편집 후 다시 정렬할 때 이 글을 따로 넘겨야 MINE 칸이 편집을 따라가지 않는다.
/// </summary>
private string _mineTextSnapshot = string.Empty;
/// <summary>
/// 사용자가 편집기에서 글자를 고쳤다고 View 가 알려 준다.
///
/// 화면에 보이는 글자는 편집기가 아니라 아래 표시층이 그린다(편집기 글자는 투명이다).
/// 그래서 표시층을 재정렬 타이머에만 맡기면 <b>친 글자가 그 시간만큼 안 보인다</b> -
/// 사용자가 말한 "입력 딜레이"의 정체가 이것이다. 지금 치고 있는 줄만 즉시 옮겨 주고,
/// 무거운 BASE 재정렬은 타이핑이 멈춘 뒤에 한 번만 한다.
/// </summary>
/// <param name="text">편집기 전문. 문자열 참조만 넘어오므로 복사 비용은 없다.</param>
/// <param name="caretIndex">캐럿 위치. 어느 줄을 쳤는지 여기서 찾는다.</param>
public void NotifyEditorEdited(string text, int caretIndex)
{
if (_syncingEditor) return;
_reflowTimer.Stop();
if (ShowTypedLineNow(text, caretIndex))
{
// 지금 치는 줄은 이미 화면에 나왔다. 무거운 BASE 재정렬만 손이 멈춘 뒤로 미룬다.
_reflowTimer.Interval = ReflowAfterTyping;
_reflowTimer.Start();
return;
}
// 줄 수가 달라졌다 - 엔터 · 줄 합치기 · 여러 줄 붙여넣기.
//
// 한 줄만 고쳐서는 맞출 수 없는 경우인데, 예전에는 여기서도 0.6초 타이머에 맡겼다.
// 편집기 글자는 투명이라 <b>그 동안 화면에 아무것도 나오지 않았다</b> -
// "엔터를 누르면 한참 뒤에 반응한다"는 것의 정체가 이것이다.
//
// 그렇다고 <b>이 자리에서 바로 부르면 안 된다.</b> 재정렬은 편집기 글을 통째로 갈아 끼우는데,
// 지금은 편집기의 TextChanged 안이다. 자기 이벤트 안에서 자기 글을 바꾸면 캐럿이 엉키고
// 뒤따라오던 글자가 엉뚱한 자리에 찍힌다(실제로 그렇게 됐다).
// 타이머를 짧게 놓아 <b>이 입력이 끝난 바로 다음 차례</b>에 돌게 한다.
_reflowTimer.Interval = ReflowNow;
_reflowTimer.Start();
}
/// <summary>타이핑이 멈추기를 기다리는 시간. 글자마다 다시 정렬하면 화면이 요동친다.</summary>
private static readonly TimeSpan ReflowAfterTyping = TimeSpan.FromMilliseconds(600);
/// <summary>
/// 기다리지 않고 바로. 다만 <b>0 이 아니라 1ms</b> 다 - 지금 이벤트가 끝난 다음 차례에
/// 돌게 하려는 것이지, 늦추려는 것이 아니다.
/// </summary>
private static readonly TimeSpan ReflowNow = TimeSpan.FromMilliseconds(1);
/// <summary>
/// 캐럿이 있는 줄 하나만 표시층에 바로 반영한다.
///
/// 줄 수가 달라졌으면(엔터·줄 합치기) 아래 줄이 통째로 밀린 것이라 한 줄만 고쳐서는
/// 맞지 않는다. 그때는 <b>false 를 주고</b> 부르는 쪽이 곧바로 재정렬하게 한다.
/// </summary>
/// <returns>이 자리에서 화면을 맞췄으면 true.</returns>
private bool ShowTypedLineNow(string text, int caretIndex)
{
if (_editorLines.Length == 0 || Lines.Count != _editorLines.Length) return false;
// 한 번만 훑으면서 (캐럿 줄 번호, 그 줄의 범위, 전체 줄 수)를 함께 구한다.
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 false;
var value = text.Substring(start, end - start).TrimEnd('\r');
// 글자가 그대로면 그릴 것이 없다. 이것도 "맞춰 둔 것"이라 true 다.
if (string.Equals(_editorLines[line], value, StringComparison.Ordinal)) return true;
_editorLines[line] = value;
Lines[line].LocalText = value;
DiffVersion++;
return true;
}
/// <summary>타이핑이 멈췄다. 필러를 걷어내고 BASE 와 다시 맞춘다.</summary>
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);
}
/// <summary>주어진 LOCAL 전문으로 비교 화면을 다시 만든다.</summary>
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<int>();
Lines.Clear();
foreach (var row in rows)
{
// 반영한 줄인지를 <b>먼저</b> 본다.
//
// 반영한 줄은 글이 원본과 달라지므로 아래 "직접 수정" 조건에도 걸린다.
// 그대로 두면 규칙을 하나 건드리는 순간 반영한 줄이 전부 보라색(직접 수정)이 된다.
// 사람이 직접 친 것과 버튼으로 가져온 것은 뜻이 다르니 섞이면 안 된다.
row.IsApplied = IsMarkedApplied(row);
// 우리가 남긴 머지 주석 줄은 "사용자가 직접 넣은 줄"이 아니다.
// 표시가 줄 객체에만 있어서 다시 만들면 사라진다 - 글을 보고 되찾는다.
if (MergeNote.LooksLikeNote(row.LocalText, SelectedFile))
{
row.IsMergeNote = true;
row.IsApplied = true;
}
if (!row.IsApplied && 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();
}
/// <summary>
/// 모델(Lines)에서 편집기 글을 다시 만든다.
///
/// 결과가 지금 편집기 글과 같으면 건드리지 않는다 - 타이핑 도중에 글을 갈아 끼우면
/// 캐럿이 튀기 때문이다. 필러 자리가 실제로 달라졌을 때만 갈아 끼운다.
/// </summary>
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;
}
/// <summary>
/// 파일을 연 시점의 LOCAL 과 견줘, 사용자가 손댄 줄 번호를 가려낸다.
/// 새로 넣은 줄과 내용이 달라진 줄만 담긴다 - 그대로인 줄은 BASE 와 다르더라도 담지 않는다.
/// </summary>
private static HashSet<int> ChangedLocalNumbers(string originalLocal, string currentLocal)
{
var before = TrimTrailingBlank(SplitLines(originalLocal));
var after = TrimTrailingBlank(SplitLines(currentLocal));
var touched = new HashSet<int>();
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 없이 파일 두 개 비교 =================
/// <summary>
/// Git 저장소를 열었는지. 아니면 "파일 두 개 직접 고르기" 모드로 돈다.
///
/// 이 도구의 본체는 머지툴이고 Git 은 그 위에 얹는 기능이다. 저장소가 아니어도
/// 아무 텍스트 파일 두 개를 비교·편집·저장할 수 있어야 한다.
/// </summary>
[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";
/// <summary>고치고 저장하는 칸. 3-Way 에서는 이 칸이 곧 결과(MERGED)다.</summary>
public string LocalTitle => IsThreeWay ? "MERGED" : "LOCAL";
/// <summary>왼쪽(BASE) 으로 읽을 파일. Git 없이 비교할 때만 쓴다.</summary>
[ObservableProperty] private string _leftFile = string.Empty;
/// <summary>오른쪽(LOCAL) 으로 읽고, 저장할 때 덮어쓸 파일.</summary>
[ObservableProperty] private string _rightFile = string.Empty;
[RelayCommand]
private void PickLeftFile()
{
if (AskForFile("왼쪽(기준) 파일 고르기", LeftFile) is { } picked)
{
if (!ConfirmDiscard($"왼쪽 파일을 바꿉니다: {Path.GetFileName(picked)}")) return;
LeftFile = picked;
OnPropertyChanged(nameof(BaseFileLocation));
_ = LoadPlainFilesAsync();
}
}
[RelayCommand]
private void PickRightFile()
{
if (AskForFile("오른쪽(내 작업) 파일 고르기", RightFile) is { } picked)
{
if (!ConfirmDiscard($"오른쪽 파일을 바꿉니다: {Path.GetFileName(picked)}")) return;
RightFile = picked;
OnPropertyChanged(nameof(SelectedFileFullPath));
_ = LoadPlainFilesAsync();
}
}
/// <summary>
/// 끌어다 놓은 파일을 한쪽 칸에 넣고 <b>바로 다시 견준다.</b>
///
/// 칸 값만 바꾸고 두면 화면은 옛 파일을 그대로 보여 준다 - "놓았는데 아무 일도 없다"로 읽힌다.
/// 파일 고르기 단추와 같은 길(<c>RequestPlainCompare</c>)로 보내는 까닭은 그 길이
/// Git 모드를 끄는 것까지 함께 하기 때문이다. 저장소를 열어 둔 채로 파일을 끌어다 놓으면
/// 사용자가 보고 싶은 것은 그 두 파일이지 커밋 이력이 아니다.
/// </summary>
public void DropFile(string path, bool left)
{
if (string.IsNullOrWhiteSpace(path)) return;
if (!ConfirmDiscard($"놓은 파일을 엽니다: {Path.GetFileName(path)}")) return;
var newLeft = left ? path : LeftFile;
var newRight = left ? RightFile : path;
_workspace.RequestPlainCompare(newLeft, newRight);
}
// ================= 결과 내보내기 =================
private bool CanExportReport() => Lines.Count > 0;
/// <summary>
/// 비교 결과를 글로 뽑아 파일에 쓴다.
///
/// 화면을 캡처하는 것 말고는 결과를 남에게 보낼 방법이 없었다. 캡처는 검색도 안 되고
/// 줄 번호를 짚어 말하기도 어렵다.
/// </summary>
[RelayCommand(CanExecute = nameof(CanExportReport))]
private void ExportReport()
{
var dialog = new Microsoft.Win32.SaveFileDialog
{
Title = "파일 비교 결과 저장",
Filter = "텍스트 파일 (*.txt)|*.txt|모든 파일 (*.*)|*.*",
DefaultExt = ".txt",
FileName = $"파일비교_{DateTime.Now:yyyyMMdd_HHmm}.txt"
};
var start = PathUtil.FolderOfFile(RightFile.Length > 0 ? RightFile : LeftFile);
if (start.Length > 0) dialog.InitialDirectory = start;
bool ok;
try
{
ok = dialog.ShowDialog() == true;
}
catch (Exception)
{
dialog.InitialDirectory = string.Empty;
ok = dialog.ShowDialog() == true;
}
if (!ok) return;
try
{
// 머지 주석과 같은 자리에서 이름을 가져온다. 두 곳에 다른 이름이 찍히면 그 자체가 버그다.
var report = CompareReport.ForFile(ReportLabel(BaseHeader, LeftFile),
ReportLabel(LocalHeader, RightFile),
Lines, DateTime.Now,
MergeNote.AuthorOf(_workspace.AuthorName));
TextFile.Write(dialog.FileName, report);
MessageUtil.ShowSuccessToast($"저장했습니다 · {Path.GetFileName(dialog.FileName)}");
}
catch (Exception ex)
{
MessageUtil.ShowErrorToast($"저장하지 못했습니다: {ex.Message}");
}
}
/// <summary>
/// 보고서에 적을 쪽 이름. Git 모드면 머리글(브랜치·커밋)이 더 쓸모 있고,
/// 파일 비교 모드면 경로가 있어야 어느 파일인지 알 수 있다.
/// </summary>
private string ReportLabel(string header, string path) =>
IsGitMode || path.Length == 0 ? header : path;
private static string? AskForFile(string title, string current)
{
var dialog = new Microsoft.Win32.OpenFileDialog
{
Title = title,
CheckFileExists = true,
Filter = "모든 파일 (*.*)|*.*"
};
// 시작 폴더는 PathUtil 을 거친다 - 슬래시가 섞인 경로를 그대로 넘기면
// 대입할 때가 아니라 ShowDialog() 안쪽에서 예외가 나 프로그램이 죽는다. PathUtil.cs 참고.
var start = PathUtil.FolderOfFile(current);
if (start.Length > 0) dialog.InitialDirectory = start;
try
{
return dialog.ShowDialog() == true ? dialog.FileName : null;
}
catch (Exception)
{
// 셸이 시작 폴더를 거부하면 기본 위치에서 다시 연다.
dialog.InitialDirectory = string.Empty;
return dialog.ShowDialog() == true ? dialog.FileName : null;
}
}
/// <summary>고른 두 파일을 읽어 비교 화면을 만든다.</summary>
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();
SetPlainHeaders();
CompareBinary(LeftFile, RightFile);
});
_workspace.AppendLog("이진 파일이라 비교하지 않음");
return;
}
IsBinaryFile = false;
IsImageFile = false;
HexRows.Clear();
BinarySummary = string.Empty;
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;
_appliedBaseNumbers.Clear();
_appliedMineNumbers.Clear();
RefreshLanguage();
var rows = BuildLines(baseText, localText);
Lines.Clear();
foreach (var row in rows) Lines.Add(row);
SetPlainHeaders();
_originalLocalText = BuildLocalText();
SyncEditorFromLines();
var info = TextFile.Describe(RightFile);
RefreshLanguage();
FileEncoding = info.Encoding;
FileNewLine = info.NewLine;
_undo.Clear();
_redo.Clear();
// 새로 읽은 파일에는 고친 것이 없다. 이걸 안 끄면 앞 파일의 상태가 그대로 끌려와
// 저장 단추가 켜진 채로 남고, 다음에 파일을 열 때마다 "저장하지 않은 편집" 을 묻는다.
HasChanges = false;
CaretRow = 0;
InvalidateDiffBlocks();
// 폴더 검색에서 넘어왔으면 그 줄로, 아니면 여느 때처럼 첫 차이로 간다.
var asked = FindRequestedRow();
SelectedLine = asked >= 0
? Lines[asked]
: Lines.FirstOrDefault(l => l.IsDifferent) ?? Lines.FirstOrDefault();
UpdateSummaries();
RefreshDiffPosition();
RaiseCanExecuteChanged();
// 다 읽고 나서 최근 목록에 담는다. 읽다 실패한 짝을 목록에 남기면
// 다음에 골랐을 때 또 실패한다.
_workspace.Recent.Add(RecentKind.Files, LeftFile, RightFile);
// 줄을 고르는 것만으로는 화면이 따라오지 않는다. 캐럿까지 옮겨야 굴러간다.
if (asked >= 0) GoToRow(asked);
});
}
/// <summary>
/// 폴더 전체 검색이 짚어 준 줄이 몇 번째 행인지. 없거나 못 찾으면 -1.
///
/// 왼쪽 기준 번호는 BASE 칸, 오른쪽 기준 번호는 LOCAL 칸에서 찾는다 -
/// 한쪽에만 있는 줄이 끼면 두 번호가 어긋나므로 아무 쪽에서나 찾으면 엉뚱한 줄에 선다.
/// </summary>
private int FindRequestedRow()
{
var (line, isLeft) = _workspace.TakeRequestedLine();
if (line <= 0) return -1;
for (var i = 0; i < Lines.Count; i++)
{
var number = isLeft ? Lines[i].BaseNumber : Lines[i].LocalNumber;
if (number == line) return i;
}
return -1;
}
private bool CanSavePlain() => IsPlainMode && HasChanges && !string.IsNullOrWhiteSpace(RightFile);
/// <summary>Git 없이 쓸 때는 커밋 없이 오른쪽 파일에 그대로 쓴다.</summary>
[RelayCommand(CanExecute = nameof(CanSavePlain))]
private async Task SavePlainAsync()
{
var content = BuildLocalText();
var target = RightFile;
var encoding = SaveEncoding;
var newLine = SaveNewLine;
var ok = await _workspace.RunAsync("파일 저장",
() => TextFile.Write(target, content, encoding, newLine));
if (!ok) return;
_workspace.OnUi(() =>
{
_originalLocalText = content;
_undo.Clear();
_redo.Clear();
HasChanges = false;
MessageUtil.ShowSuccessToast($"저장 완료 · {Path.GetFileName(target)}");
});
}
/// <summary>
/// 파일 두 개를 견줄 때의 패널 머리글.
///
/// <b>파일 이름만 적지 않는다.</b> 파일 비교는 다른 자리에 있는 파일을 견주는 일이라
/// 이름이 같은 경우가 흔하고(같은 파일의 두 판), 그러면 양쪽 머리글이 똑같아져
/// 어느 쪽이 어느 쪽인지 알 수 없다. 그렇다고 전체 경로를 다 적으면 길어서 읽히지 않는다.
/// 그래서 <see cref="PathUtil.DistinctTail"/> 로 <b>같은 앞부분만 떼고</b> 나머지를 적는다.
/// 전체 경로는 툴팁과 우클릭 복사에 그대로 있다.
/// </summary>
private void SetPlainHeaders()
{
var (left, right) = PathUtil.DistinctTail(LeftFile, RightFile);
// 큰 제목이 이미 BASE/LOCAL 이라고 말한다. 여기에 또 적으면 자리만 먹는다.
var l = SplitLead(left);
BaseHeaderLead = l.Lead;
BaseHeader = l.Tail;
var r = SplitLead(right);
LocalHeaderLead = r.Lead;
LocalHeader = r.Tail;
}
/// <summary>
/// 경로에서 <b>실제로 갈리는 첫 칸</b>만 떼어 낸다.
///
/// <see cref="PathUtil.DistinctTail"/> 이 같은 앞부분을 이미 뗐으므로, 남은 것의 첫 칸이
/// 두 쪽을 가르는 자리다(<c>left\</c> · <c>branch\</c>). 그 칸만 굵게 칠하면
/// <b>어디가 다른지 눈이 바로 간다</b> - 나머지는 양쪽이 같은 글자라 읽을 값이 없다.
/// 칸이 하나뿐이면(파일 이름만 남은 경우) 뗄 것이 없다.
/// </summary>
// 'Rest' 는 ValueTuple 이 제 필드 이름으로 쓰는 예약어라 튜플 요소 이름으로 못 쓴다.
private static (string Lead, string Tail) SplitLead(string path)
{
if (string.IsNullOrEmpty(path)) return (string.Empty, string.Empty);
var at = path.IndexOfAny(new[] { '\\', '/' });
return at < 0 ? (string.Empty, path) : (path.Substring(0, at + 1), path.Substring(at + 1));
}
/// <summary>머리글에서 굵게 칠할 앞부분. 없으면 빈 글자.</summary>
[ObservableProperty] private string _baseHeaderLead = string.Empty;
[ObservableProperty] private string _localHeaderLead = string.Empty;
// ================= 저장 안 한 편집 지키기 =================
/// <summary>
/// 프로그램이 스스로 화면을 다시 그리는 중. 그때는 묻지 않는다.
/// 사용자가 고른 것이 아닌데 창이 뜨면 무엇에 답하는지 알 수 없다.
/// </summary>
private bool _suspendGuard;
/// <summary>
/// 고치던 것이 있으면 물어보고, 계속해도 되면 true 를 준다.
///
/// <b>왜 세 갈래인가</b> - 예/아니오로는 "버리고 가기"와 "그만두기"가 구분되지 않는다.
/// 아니오를 눌렀을 때 그대로 열려 버리면 그것이 곧 잃는 것이다.
/// 그래서 플랫폼에 <c>ShowChoice</c> 를 새로 넣었다.
///
/// 고친 것이 없으면 묻지 않는다. 넘어가도 되는 경우까지 묻는 것이 더 나쁘다 -
/// 매번 뜨는 창은 읽지 않고 누르게 되고, 정작 위험할 때도 그냥 눌러 버린다.
/// </summary>
/// <param name="what">이제 무엇을 하려는지. 창에 그대로 적힌다.</param>
private bool ConfirmDiscard(string what)
{
if (_suspendGuard) return true;
if (!HasChanges) return true;
var name = IsPlainMode
? (string.IsNullOrWhiteSpace(RightFile) ? "지금 파일" : Path.GetFileName(RightFile))
: (string.IsNullOrWhiteSpace(SelectedFile) ? "지금 파일" : Path.GetFileName(SelectedFile));
var choice = MessageUtil.ShowChoice(
"저장하지 않은 편집",
$"{name} 에 저장하지 않은 편집이 있습니다.\n\n{what}\n\n그대로 두면 고친 내용이 사라집니다.",
"저장하고 계속", "저장하지 않고 계속");
return choice switch
{
// 저장이 실패하면 넘어가지 않는다. 여기서 넘어가면 저장한 줄 알고 잃는다.
DialogChoice.Primary => SaveWorkingCopy(),
DialogChoice.Secondary => true,
_ => false
};
}
/// <summary>
/// 고친 내용을 지금 파일에 쓴다. <b>커밋은 하지 않는다.</b>
///
/// Git 모드의 "저장 후 커밋"은 커밋 메시지가 있어야 한다. 확인 창에서 메시지를 받을 수는 없으므로
/// 여기서는 워킹트리 파일에 쓰는 데까지만 한다 - 커밋은 사용자가 따로 할 일이다.
/// 작업 게이트를 타지 않는다. 파일 하나를 쓰는 일이라 Git 작업과 줄 세울 까닭이 없고,
/// 확인 창은 UI 스레드에서 답을 기다리는 중이라 게이트를 기다리면 서로 막힌다.
/// </summary>
private bool SaveWorkingCopy()
{
var target = IsPlainMode
? RightFile
: string.IsNullOrEmpty(SelectedFile)
? string.Empty
: Path.Combine(_workspace.RepositoryPath,
SelectedFile!.Replace('/', Path.DirectorySeparatorChar));
if (string.IsNullOrWhiteSpace(target))
{
MessageUtil.ShowErrorToast("저장할 파일을 알 수 없습니다.");
return false;
}
try
{
var content = BuildLocalText();
TextFile.Write(target, content, SaveEncoding, SaveNewLine);
_originalLocalText = content;
_undo.Clear();
_redo.Clear();
HasChanges = false;
MessageUtil.ShowSuccessToast($"저장 완료 · {Path.GetFileName(target)}");
return true;
}
catch (Exception ex)
{
MessageUtil.ShowErrorToast($"저장하지 못했습니다: {ex.Message}");
return false;
}
}
// ================= 무시 규칙 =================
/// <summary>
/// "무엇을 차이로 볼 것인가". 설정에서 읽어 두고 툴바에서 바로 바꾼다.
/// 바꾸면 곧바로 다시 정렬해서 화면에 반영한다.
/// </summary>
public CompareRules Rules { get; } = new CompareRules();
/// <summary>툴바 버튼에 띄우는 지금의 공백 규칙.</summary>
public string WhitespaceLabel => Rules.Whitespace switch
{
WhitespaceMode.IgnoreAmount => "공백 개수↓",
WhitespaceMode.IgnoreAll => "공백 무시",
_ => "공백 비교"
};
public string IgnoreCaseLabel => Rules.IgnoreCase ? "Aa 무시" : "Aa 구분";
public string LineFilterLabel => Rules.LineFilterEnabled ? "필터 켬" : "필터 끔";
/// <summary>상태바에 띄우는 요약. 켠 규칙이 없으면 빈 문자열.</summary>
[ObservableProperty] private string _rulesSummary = string.Empty;
/// <summary>설정에 저장된 값을 규칙 객체로 옮긴다.</summary>
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();
}
/// <summary>마지막으로 비교에 쓴 설정값. 설정 화면에서 바뀌었는지 알아보려고 들고 있는다.</summary>
private string _appliedSettingsSignature = string.Empty;
private string SettingsSignature()
{
var s = _workspace.Settings;
return $"{s.WhitespaceMode}|{s.IgnoreCase}|{s.UseLineFilters}|{s.LineFilters}";
}
/// <summary>
/// 홈 화면으로 돌아왔을 때 부른다.
/// 설정 화면에서 비교 규칙을 고치고 저장했으면 그때 한 번만 다시 비교한다.
/// (규칙은 BuildLines 가 매번 읽지만, 누군가 다시 비교를 시켜 줘야 화면이 바뀐다)
/// </summary>
public void RecompareIfSettingsChanged()
{
var now = SettingsSignature();
if (now == _appliedSettingsSignature) return;
_appliedSettingsSignature = now;
LoadRules();
if (Lines.Count > 0) ApplyRuleChange();
}
/// <summary>공백 규칙을 전부 비교 → 개수만 → 전부 무시 로 돌린다.</summary>
[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();
}
/// <summary>규칙이 바뀌면 지금 보고 있는 내용을 그대로 다시 정렬한다.</summary>
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;
}
// ================= 차이 탐색 · 이동 · 북마크 =================
/// <summary>
/// 차이 "덩어리"의 시작 행 번호들. 이어진 차이 줄 여러 개는 한 덩어리로 센다 -
/// 사용자가 보기에 12줄이 통째로 지워진 것은 차이 하나이지 열두 개가 아니기 때문이다.
/// </summary>
/// <summary>
/// 차이 덩어리 시작 위치. 줄 목록이 바뀔 때만 다시 센다.
///
/// 예전에는 캐럿이 움직일 때마다 전체 줄을 훑으며 Highlight 를 계산했다.
/// Highlight 는 무시 규칙까지 적용해 두 쪽 글을 정규화하므로, 글자 하나 칠 때마다
/// 파일 전체를 정규화하는 셈이었다 - 타이핑이 느려지던 진짜 원인이다.
/// </summary>
private List<int>? _diffStarts;
private List<int> DiffBlockStarts() => _diffStarts ??= ComputeDiffBlockStarts();
/// <summary>줄 목록이나 줄 상태가 바뀌면 다시 세도록 표시만 해 둔다.</summary>
private void InvalidateDiffBlocks() => _diffStarts = null;
private List<int> ComputeDiffBlockStarts()
{
var starts = new List<int>();
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;
}
/// <summary>지금 캐럿이 놓인 행. 편집기 줄 번호와 1:1 이다.</summary>
[ObservableProperty] private int _caretRow;
/// <summary>줄을 옮기면 "이 덩어리 반영"이 켜지고 꺼진다.</summary>
partial void OnCaretRowChanged(int value) => AcceptBlockCommand.NotifyCanExecuteChanged();
/// <summary>상태바에 띄우는 "차이 3 / 15".</summary>
[ObservableProperty] private string _diffPosition = "차이 -";
/// <summary>View 에게 이 행으로 스크롤하라고 알린다.</summary>
public event EventHandler<int>? ScrollToRowRequested;
/// <summary>프로그램이 캐럿을 옮기는 중. View 가 되짚어 주는 행 번호로 덮이지 않게 막는다.</summary>
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();
}
/// <summary>캐럿이 몇 번째 차이 덩어리 안에 있는지 다시 센다.</summary>
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();
}
// ---- 줄 번호로 이동 ----
/// <summary>이동 상자에 친 줄 번호. 비워 두면 아무 일도 안 한다.</summary>
[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);
/// <summary>다음 북마크로. 끝까지 가면 처음으로 돈다.</summary>
[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();
}
// ---- 편집 잠금 ----
/// <summary>실수로 고치는 것을 막는 잠금. 화면은 그대로 보이고 타이핑만 막는다.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ReadOnlyLabel))]
private bool _isReadOnly;
public string ReadOnlyLabel => IsReadOnly ? "잠금 해제" : "편집 잠금";
/// <summary>긴 줄을 접어서 보여줄지. 켜면 가로 스크롤이 없어진다.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(WrapLabel))]
private bool _wordWrap;
// 다른 규칙 버튼과 같은 길이로 맞춘다 - "줄바꿈 끄기"는 칸을 넘어 글자가 잘렸다.
public string WrapLabel => WordWrap ? "줄바꿈 켬" : "줄바꿈 끔";
// ---- 상태바 ----
/// <summary>"12행 5열" 처럼 캐럿 자리를 보여 준다.</summary>
[ObservableProperty] private string _caretPosition = "-";
/// <summary>파일이 어떤 인코딩·줄바꿈으로 저장돼 있는지.</summary>
[ObservableProperty] private string _fileEncoding = "-";
[ObservableProperty] private string _fileNewLine = "-";
// ---- 언어 (작업 4: 소스가 아니어도, C# 이 아니어도 비교할 수 있게) ----
/// <summary>
/// 지금 보고 있는 파일을 어떤 언어로 읽을지. 확장자로 고르고, 모르면 평문이다.
/// 색칠·코드 검사·블록 주석 이어붙이기가 모두 이 하나를 본다.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(LanguageName))]
private LanguageProfile _language = LanguageCatalog.Plain;
public string LanguageName => Language.Name;
/// <summary>지금 파일이 글이 아니라 이진 파일인지. 맞으면 비교하지 않고 그렇다고 알린다.</summary>
[ObservableProperty] private bool _isBinaryFile;
/// <summary>비교 대상 파일의 경로. Git 모드면 워킹트리 경로, 아니면 고른 오른쪽 파일.</summary>
private string? CurrentFilePath => IsPlainMode
? (string.IsNullOrWhiteSpace(RightFile) ? null : RightFile)
: SelectedFile;
/// <summary>파일이 바뀔 때마다 언어를 다시 고른다.</summary>
private void RefreshLanguage() => Language = LanguageCatalog.ForPath(CurrentFilePath);
/// <summary>View 가 캐럿을 옮길 때마다 불러 준다.</summary>
public void ReportCaret(int row, int column)
{
CaretPosition = $"{row + 1}행 {column + 1}열";
// 프로그램이 옮기는 중이면 목표 행이 이미 정해져 있다 - 그 값을 지킨다.
if (_movingCaret) return;
// 같은 줄 안에서 좌우로만 움직였으면 선택 줄도 차이 위치도 그대로다.
// 여기서 걸러야 글자마다 명령 상태를 다시 계산하는 일이 없어진다.
if (row == CaretRow) return;
CaretRow = row;
SelectLineAt(row);
RefreshDiffPosition();
}
/// <summary>편집기에서 캐럿이 놓인 줄을 선택 줄로 삼는다. Accept/Revert 의 대상이 된다.</summary>
public void SelectLineAt(int index)
{
if (index >= 0 && index < Lines.Count) SelectedLine = Lines[index];
}
// ================= 코드 이상 검사 =================
/// <summary>검사에서 걸린 문제들. 비어 있으면 이상 없음.</summary>
public ObservableCollection<CodeProblemItem> Problems { get; } = new ObservableCollection<CodeProblemItem>();
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasProblems))]
private string _problemSummary = "이상 없음";
public bool HasProblems => Problems.Count > 0;
/// <summary>
/// LOCAL 내용을 훑어 눈에 띄는 실수를 찾는다.
/// 컴파일러가 아니므로 "빌드된다"는 보증은 아니다 - 주석 기호를 빼먹은 한글,
/// 닫히지 않은 따옴표, 괄호 짝만 본다.
/// </summary>
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();
}
/// <summary>편집 하나를 되돌리거나 다시 적용한다. 줄 존재 여부까지 함께 복원한다.</summary>
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.Kind == EditKind.TakeBase)
{
if (toBefore) UnmarkApplied(edit.Line);
else MarkApplied(edit.Line);
}
// 이 편집이 끼워 넣은 주석 줄도 함께 넣고 뺀다.
// 본문만 되돌리고 주석을 남기면 파일에 유령 주석이 쌓인다.
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);
}
/// <summary>
/// "이 줄은 반영했다"를 <b>줄 번호로</b> 기억해 둔다.
///
/// 반영 표시는 지금까지 되돌리기 스택에만 있었다. 그런데 비교 규칙을 바꾸면 화면을 통째로
/// 다시 만들면서 스택을 비우므로, 그때 표시가 사라지고 <b>글이 원본과 다르다는 이유만으로
/// "직접 수정"으로 칠해졌다.</b> 실제로 Accept 한 줄이 규칙 하나 건드리자 보라색이 됐다.
///
/// 열쇠는 얼어 있는 번호여야 한다 - BASE 줄 번호, 없으면(양쪽이 각자 새 줄을 넣은 자리)
/// MINE 줄 번호.
/// </summary>
private void MarkApplied(MergeLine line)
{
if (line.BaseNumber is int baseNumber) _appliedBaseNumbers.Add(baseNumber);
else if (line.OriginalLocalNumber is int mineNumber) _appliedMineNumbers.Add(mineNumber);
}
/// <summary>되돌리면 그 표시도 함께 지운다. 안 그러면 되돌린 줄이 계속 "반영함"으로 남는다.</summary>
private void UnmarkApplied(MergeLine line)
{
if (line.BaseNumber is int baseNumber) _appliedBaseNumbers.Remove(baseNumber);
else if (line.OriginalLocalNumber is int mineNumber) _appliedMineNumbers.Remove(mineNumber);
}
/// <summary>
/// "반영했다"고 표시해 둔 줄인지.
///
/// 그 결정은 <b>글을 바꾸지 않는다</b>(이미 내 원본과 같은 줄이다). 그래서 되돌리기
/// 스택에도 남지 않고, 다시 정렬하면 줄 객체 자체가 새로 만들어진다.
/// 붙들어 둘 것이 줄 번호밖에 없어 여기에 따로 모아 둔다.
/// </summary>
private bool IsMarkedApplied(MergeLine line) =>
line.BaseNumber is int baseNumber
? _appliedBaseNumbers.Contains(baseNumber)
: line.OriginalLocalNumber is int mineNumber && _appliedMineNumbers.Contains(mineNumber);
/// <summary>
/// "머지 반영함"과 "직접 수정" 표시를 편집 이력에서 다시 계산한다.
/// 둘을 섞으면 안 된다 - 사용자가 직접 지운 줄이 "머지 반영함"으로 보이면 안 되기 때문이다.
/// Undo 로 되돌린 줄은 자동으로 표시가 풀린다.
/// </summary>
private void RefreshAppliedFlags()
{
var applied = new HashSet<MergeLine>();
var edited = new HashSet<MergeLine>();
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)
{
// "내 것으로 간다"고 정한 충돌은 글을 바꾸지 않아 되돌리기 스택에 남지 않는다.
// 그것까지 여기서 지워 버리면 Take Mine 을 눌러도 남은 충돌이 줄지 않는다.
line.IsApplied = applied.Contains(line) || IsMarkedApplied(line);
line.IsManuallyEdited = !line.IsApplied && edited.Contains(line);
if (!line.IsApplied) line.AppliedNote = null;
}
}
private void UpdateSummaries()
{
var total = Lines.Count;
var diff = Lines.Count(l => l.IsDifferent);
// 같은 숫자를 두 번 적지 않는다(4차 C-2). 예전에는 여기서 "차이 {diff} 라인" 을 적고
// 바로 아래 줄에서 "남은 차이 {diff} 라인" 을 또 적었다 - 어느 쪽을 봐야 하는지 헷갈렸다.
// 파일 크기(총 N줄)와 남은 일(차이 N줄)은 다른 것이므로 하나씩만 남긴다.
DiffSummary = total == 0 ? "-" : $"총 {total}줄";
DiffVersion++;
EditSummary = _undo.Count == 0 && _redo.Count == 0
? $"남은 차이 {diff}줄"
: $"남은 차이 {diff}줄 · 편집 {_undo.Count}건 · 되돌리기 {_redo.Count}건";
UpdateLegendCounts();
RefreshProblems();
}
// ================= 이진 파일 비교 =================
/// <summary>이진 파일 비교 결과 한 줄 요약. "그래서 같다는 거야 다르다는 거야"에 답한다.</summary>
[ObservableProperty] private string _binarySummary = string.Empty;
/// <summary>16진 나란히 보기. 앞부분만 담는다.</summary>
public ObservableCollection<HexRow> HexRows { get; } = new ObservableCollection<HexRow>();
[ObservableProperty] private bool _isImageFile;
[ObservableProperty] private string _leftImagePath = string.Empty;
[ObservableProperty] private string _rightImagePath = string.Empty;
/// <summary>이진 파일인데 그림이 아니라 16진으로 봐야 하는 경우.</summary>
public bool ShowHexView => IsBinaryFile && !IsImageFile;
partial void OnIsBinaryFileChanged(bool value) => OnPropertyChanged(nameof(ShowHexView));
partial void OnIsImageFileChanged(bool value) => OnPropertyChanged(nameof(ShowHexView));
/// <summary>
/// 이진 파일 두 개를 견준다.
///
/// 지금까지는 "줄 단위로 비교할 수 없습니다" 한 줄로 끝났다. 그러면 정작 알고 싶은
/// <b>같은지 다른지</b>를 모른 채 화면을 닫게 된다. 크기·해시·처음 갈리는 자리를 알려 주고,
/// 그림이면 나란히 띄우고 아니면 앞부분을 16진으로 늘어놓는다.
/// </summary>
private void CompareBinary(string left, string right)
{
HexRows.Clear();
IsImageFile = BinaryCompare.IsImage(left) || BinaryCompare.IsImage(right);
LeftImagePath = File.Exists(left) ? left : string.Empty;
RightImagePath = File.Exists(right) ? right : string.Empty;
try
{
var result = BinaryCompare.Compare(left, right);
BinarySummary = result.Truncated
? $"{result.Summary} · 앞 {result.Rows.Count * BinaryCompare.BytesPerRow}바이트만 표시"
: result.Summary;
if (!IsImageFile)
{
foreach (var row in result.Rows) HexRows.Add(row);
}
DiffSummary = result.Same ? "이진 파일 · 내용이 같습니다" : "이진 파일 · 내용이 다릅니다";
}
catch (Exception ex)
{
BinarySummary = $"이진 파일을 읽지 못했습니다: {ex.Message}";
DiffSummary = "이진 파일";
}
}
// ================= 저장 형식 · 덩어리 반영 =================
/// <summary>
/// 저장할 때 쓸 인코딩·줄바꿈. 기본은 "원본 유지"라 지금까지와 똑같이 동작한다.
///
/// 사내 소스에는 CP949 로 저장된 파일이 섞여 있어 새로 만드는 파일과 형식이 갈린다.
/// 읽어서 보여 주기만 하면 그 갈림을 못 고치므로 저장할 때 고를 수 있게 한다.
/// </summary>
public IReadOnlyList<string> SaveEncodings => TextFile.SaveEncodings;
public IReadOnlyList<string> SaveNewLines => TextFile.SaveNewLines;
[ObservableProperty] private string _saveEncoding = TextFile.KeepOriginal;
[ObservableProperty] private string _saveNewLine = TextFile.KeepOriginal;
/// <summary>
/// 지금 캐럿이 있는 <b>차이 덩어리</b> 전체를 BASE 내용으로 바꾼다.
///
/// 줄 하나씩만 되면 연속 다섯 줄을 다섯 번 눌러야 한다 - WinMerge·Beyond Compare 가
/// 덩어리를 기본 단위로 쓰는 이유다. 덩어리는 "차이로 잡힌 줄이 이어지는 구간"이다.
/// </summary>
private bool CanAcceptBlock() => BlockAround(CaretRow).Count > 0;
[RelayCommand(CanExecute = nameof(CanAcceptBlock))]
private void AcceptBlock()
{
var block = BlockAround(CaretRow);
if (block.Count == 0) return;
// 2-Way 는 BASE 를, 3-Way 는 저쪽(REMOTE)을 가져온다. BlockAround 주석 참고.
foreach (var line in block)
{
if (IsThreeWay) ApplyRemote(line, requery: false);
else Apply(line, requery: false);
}
AfterEdit();
MessageUtil.ShowSuccessToast($"{block.Count}줄 반영");
}
/// <summary>
/// 그 줄이 속한 차이 덩어리. 차이가 아니면 빈 목록이다.
///
/// <b>무엇을 가져오는지가 모드마다 다르다.</b>
/// 2-Way : BASE 를 결과로 가져온다. 덩어리는 "BASE 를 가져올 수 있는 줄"이 이어진 구간.
/// 3-Way : BASE 는 공통 조상이라 가져올 것이 아니다. 저쪽(REMOTE)을 가져오는 것이
/// <c>Take Theirs</c> 이고, 덩어리 반영은 그것을 줄 묶음으로 하는 것이다.
/// 여기서 BASE 를 가져오면 <b>조상으로 되돌리는 셈</b>이라 뜻이 전혀 달라진다.
/// </summary>
private List<MergeLine> BlockAround(int row)
{
var found = new List<MergeLine>();
if (row < 0 || row >= Lines.Count) return found;
bool Takeable(MergeLine line) => IsThreeWay ? line.CanTakeRemote : line.CanTakeBase;
if (!Takeable(Lines[row])) return found;
var first = row;
while (first > 0 && Takeable(Lines[first - 1])) first--;
var last = row;
while (last + 1 < Lines.Count && Takeable(Lines[last + 1])) last++;
for (var i = first; i <= last; i++) found.Add(Lines[i]);
return found;
}
// ================= 자동 병합 · 충돌만 보기 =================
/// <summary>
/// "내 것으로 간다"고 정한 충돌 줄의 BASE 줄 번호.
///
/// 그 결정은 <b>내용을 바꾸지 않으므로</b> 글만 봐서는 되살릴 수 없다. 편집 뒤 다시 정렬하면
/// 행이 새로 만들어지는데, 이걸 들고 있지 않으면 이미 정한 충돌이 되살아나
/// "남은 충돌"이 줄지 않는다. BASE 는 얼어 있으므로 그 줄 번호가 안전한 열쇠다.
/// </summary>
private readonly HashSet<int> _appliedBaseNumbers = new HashSet<int>();
/// <summary>
/// BASE 에 없는 충돌(양쪽이 각자 새 줄을 넣은 자리)용. 이런 줄은 BASE 줄 번호가 없으므로
/// 역시 얼어 있는 MINE 줄 번호로 붙든다.
/// </summary>
private readonly HashSet<int> _appliedMineNumbers = new HashSet<int>();
private bool CanAutoMerge() => IsThreeWay && Lines.Any(l => l.ThreeWay == ThreeWayState.RemoteChanged);
/// <summary>
/// 저쪽만 고친 줄을 한 번에 결과로 가져온다. 사람은 <b>충돌만</b> 판단하면 된다.
///
/// 안 겹치는 변경까지 하나씩 누르게 하면 100줄짜리 충돌 파일에서 정작 판단이 필요한
/// 서너 줄이 묻힌다 - `git merge` 가 자동으로 해 주는 일을 여기서도 해 준다.
/// 내가 고친 줄과 충돌 줄은 건드리지 않는다.
/// </summary>
[RelayCommand(CanExecute = nameof(CanAutoMerge))]
private void AutoMerge()
{
// 반영하면서 주석 줄이 끼어들어 Lines 가 바뀐다. 먼저 목록을 떠 놓고 돈다.
var targets = Lines.Where(l => l.ThreeWay == ThreeWayState.RemoteChanged && l.CanTakeRemote).ToList();
if (targets.Count == 0) return;
foreach (var line in targets) ApplyRemote(line, requery: false);
AfterEdit();
MessageUtil.ShowSuccessToast(UnresolvedConflictCount == 0
? $"{targets.Count}줄 자동 병합 · 남은 충돌 없음"
: $"{targets.Count}줄 자동 병합 · 남은 충돌 {UnresolvedConflictCount}개");
}
private bool CanGoConflict() => IsThreeWay && Lines.Any(l => l.NeedsDecision);
/// <summary>다음 충돌로. `다음 차이` 와 달리 <b>아직 안 정한 충돌만</b> 딛는다.</summary>
[RelayCommand(CanExecute = nameof(CanGoConflict))]
private void NextConflict()
{
for (var row = CaretRow + 1; row < Lines.Count; row++)
if (Lines[row].NeedsDecision) { GoToRow(row); return; }
// 끝까지 갔으면 처음으로 돈다 - 차이 이동과 같은 감각이다.
for (var row = 0; row < Lines.Count; row++)
if (Lines[row].NeedsDecision) { GoToRow(row); return; }
}
[RelayCommand(CanExecute = nameof(CanGoConflict))]
private void PrevConflict()
{
for (var row = CaretRow - 1; row >= 0; row--)
if (Lines[row].NeedsDecision) { GoToRow(row); return; }
for (var row = Lines.Count - 1; row >= 0; row--)
if (Lines[row].NeedsDecision) { GoToRow(row); return; }
}
/// <summary>아직 사람이 골라야 하는 충돌 줄 수. 0 이면 이 파일의 병합은 끝난 것이다.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(MergeStatusText))]
[NotifyPropertyChangedFor(nameof(IsMergeComplete))]
private int _unresolvedConflictCount;
/// <summary>3-Way 에서 지금 어디까지 왔는지 한 줄로.</summary>
public string MergeStatusText =>
!IsThreeWay ? string.Empty :
UnresolvedConflictCount == 0 ? "충돌 정리 끝" :
$"남은 충돌 {UnresolvedConflictCount}개";
public bool IsMergeComplete => IsThreeWay && UnresolvedConflictCount == 0;
// ================= 범례에 실시간으로 붙는 개수 =================
[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;
/// <summary>3-Way 에서 저쪽만 고친 줄 수.</summary>
[ObservableProperty] private int _remoteChangeCount;
/// <summary>
/// 줄 하나만 반영해도 곧바로 범례 숫자가 바뀌도록, 편집이 끝날 때마다 다시 센다.
/// (UpdateSummaries 가 AfterEdit / 파일 로드 양쪽에서 호출된다)
/// </summary>
/// <summary>
/// 범례에서 고른 갈래의 <b>다음 줄</b>로 옮긴다.
///
/// 범례는 지금까지 읽기만 하는 설명이었다. 그런데 "추가된 줄만 훑고 싶다" 는 일이 잦은데
/// 차이 이동(Alt+↑↓)은 갈래를 가리지 않는다. 개수를 누르면 그 갈래만 딛게 해서
/// <b>범례를 도구로</b> 바꾼다. 색 이름을 외울 필요도 없어진다.
///
/// 지금 자리 다음부터 찾고 끝에 닿으면 처음으로 돌아온다 - 차이 이동과 같은 방식이라
/// 새 규칙을 외울 것이 없다.
/// </summary>
[RelayCommand]
private void GoToKind(LineHighlight kind)
{
if (Lines.Count == 0) return;
for (var step = 1; step <= Lines.Count; step++)
{
var at = (CaretRow + step) % Lines.Count;
if (Lines[at].Highlight != kind) continue;
GoToRow(at);
return;
}
// 한 줄도 없으면 조용히 넘어가는 대신 왜 아무 일도 없는지 알려 준다.
MessageUtil.ShowToast($"{KindName(kind)} 줄이 없습니다.");
}
private static string KindName(LineHighlight kind) => kind switch
{
LineHighlight.Changed => "BASE와 다름",
LineHighlight.Applied => "머지 반영함",
LineHighlight.Edited => "직접 수정",
LineHighlight.Added => "추가",
LineHighlight.Removed => "삭제",
LineHighlight.Ignored => "무시",
LineHighlight.Moved => "자리 옮김",
LineHighlight.RemoteChange => "Theirs only",
LineHighlight.Conflict => "Conflict",
_ => "그"
};
private void UpdateLegendCounts()
{
int changed = 0, applied = 0, edited = 0, added = 0, removed = 0, moved = 0, ignored = 0, same = 0;
int remote = 0, conflict = 0, needsDecision = 0;
foreach (var line in Lines)
{
if (line.NeedsDecision) needsDecision++;
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;
UnresolvedConflictCount = needsDecision;
}
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();
}
}
}