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(); 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(); } /// /// 폴더 비교 탭에서 고른 두 파일을 여기서 연다. /// /// Git 저장소를 열어 둔 상태여도 "파일 두 개 비교"로 전환한다 - 폴더 비교에서 고른 파일은 /// 저장소 안의 것이 아닐 수도 있고, 사용자가 보고 싶은 것은 그 두 파일이지 커밋 이력이 아니다. /// 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(); } // ================= 데모 전환 (발표·교육용) ================= /// 설정에 데모 경로가 둘 다 적혀 있을 때만 전환 버튼을 띄운다. 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; 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}"); } /// 설정의 기본 비교 파일 두 개를 열어 둔다. 없거나 사라졌으면 조용히 넘어간다. 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] [NotifyPropertyChangedFor(nameof(RulesToggleLabel))] private bool _isRulesOpen; /// /// 단추 글자. 무엇이 숨어 있고 누르면 무슨 일이 나는지 함께 적는다 - /// 화살표만으로는 "여기 접힌 것이 있다"가 전해지지 않는다(C-3 · C-4 에서 두 번 겪었다). /// public string RulesToggleLabel => IsRulesOpen ? "▾ 저장 형식 · 비교 규칙 접기" : "▸ 저장 형식 · 비교 규칙 펴기"; [RelayCommand] private void ToggleRules() { IsRulesOpen = !IsRulesOpen; _workspace.Settings.ActionsRulesExpanded = IsRulesOpen; _workspace.SaveSettings(); } // ================= 폴더에서 훑는 중인 파일 이동 ================= /// 훑을 목록이 있는지. 폴더를 거치지 않고 연 파일에는 없다. public bool HasFileQueue => _workspace.Queue.Count > 0 && _workspace.QueueIndex >= 0; /// 지금 몇 번째인지. 예: 3 / 27. 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); /// /// 폴더 비교 목록에서 앞뒤 파일로 옮긴다. /// /// 여기서 먼저 묻고 그 다음에 자리를 옮긴다. 확인 창에서 취소하면 자리도 그대로여야 하는데, /// 자리를 먼저 옮기면 취소했을 때 목록 위치만 앞서 나간다. /// 물은 뒤에는 _suspendGuard 로 막는다 - 여는 길(RequestPlainCompare)이 /// 같은 것을 한 번 더 묻기 때문이다. /// 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(); } // ================= 최근 비교 목록 ================= /// 최근에 견준 파일 짝. 위 줄의 목록에 뿌린다. public ObservableCollection RecentFiles { get; } = new ObservableCollection(); private void RefreshRecent() { RecentFiles.Clear(); foreach (var pair in _workspace.Recent.Files) RecentFiles.Add(pair); } /// /// 최근 목록에서 고른 짝을 연다. /// /// 자리를 옮겼거나 지워진 짝이라도 목록에서 지우지 않는다 - 네트워크 드라이브나 USB 는 /// 잠깐 안 보일 뿐이다. 대신 왜 못 여는지 알려 준다. /// 여는 길은 다른 데와 같다(RequestPlainCompare) - 저장하지 않은 편집도 그 길에서 함께 걸린다. /// 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; /// 커밋 트리 ListBox 가 바인딩하는 선택 항목(그래프 포함). [ObservableProperty] private CommitNode? _selectedNode; /// 미니맵은 컬렉션이 그대로여도 줄 내용이 바뀌면 다시 그려야 한다. [ObservableProperty] private int _diffVersion; /// 머지한 줄에 붙일 사용자 설명. 비워도 작성자·날짜 정보는 항상 들어간다. [ObservableProperty] private string _mergeComment = string.Empty; /// 확인 창에서 [취소] 를 눌렀을 때 돌아갈 자리. private string? _lastLoadedFile; /// 되돌리는 중. 그 사이에는 다시 묻지도, 다시 읽지도 않는다. 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)); // ---- 파일 경로 ---- /// 저장소 기준 상대 경로. 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; /// 확인 창에서 [취소] 를 눌렀을 때 돌아갈 커밋. private CommitNode? _lastLoadedNode; private bool _revertingNode; partial void OnSelectedNodeChanged(CommitNode? value) { if (_syncingSelection || _revertingNode) { _lastLoadedNode = value; return; } // 목록을 다시 세우면 ListBox 가 선택을 잠깐 놓친다 - 항목 객체가 통째로 새로 만들어지므로 // null 이 한 번 들어온다. 이건 사람이 고른 것이 아니다. // 그런데도 물으면 스테이징만 해도 "다른 커밋을 봅니다" 창이 뜬다(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; /// 목업 ② 부분 수용 - 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 로 통째로 되돌린다. /// /// 되돌리기(Undo) 목록까지 비우므로 되살릴 방법이 없다. 그래서 한 번 묻는다 - /// 이름이 분명한 단추라 해도 잘못 누르면 그때까지 고친 것이 통째로 사라진다. /// [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); /// LOCAL 패널 내용을 워킹트리 파일에 쓰고 스테이징 후 커밋한다. [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(); } /// CommunityToolkit 의 커맨드는 CommandManager 를 구독하지 않아 직접 알려야 한다. 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 안에서 묻지 않게 한다. } /// 선택 커밋이 담고 있는 파일 목록을 채우고, 가능하면 이전 선택을 유지한다. 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(() => { // 목록을 프로그램이 다시 세우는 중이다. 사용자가 고른 것이 아니므로 묻지 않는다 - // 무엇에 답하는 창인지 알 수 없고, 취소해도 목록은 이미 새로 세워진 뒤다. // // Clear() 까지 감싸야 한다. 목록을 비우면 거기 묶인 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; } }); } /// 선택 파일의 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); // BASE 가 비어 있으면 그 파일이 공통 조상에 없었다는 뜻이다 - 양쪽 브랜치가 // 각자 새로 만든 파일이라 견줄 조상이 없다. 그냥 두면 빈 칸이 고장으로 보인다. // 큰 제목이 이미 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(); _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; } /// /// 줄을 넘어가는 것들(/* ... */ · XAML 의 여러 줄짜리 여는 태그)을 이어서 칠하려면 /// 각 줄이 "앞에서 무엇이 열린 채 왔는지"를 알아야 한다. /// 위에서 아래로 한 번 훑으며 BASE/LOCAL 각각의 상태를 기록한다. /// LOCAL 을 직접 고치면 그 아래 줄들의 상태가 달라지므로 편집 후에도 다시 계산한다. /// private void RefreshBlockCommentState(IEnumerable 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); } } // ================= 줄 단위 편집 ================= /// /// 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), }); // 반영 표시는 되돌리기 스택 밖에도 남긴다 - 규칙을 바꾸면 스택이 비워지기 때문이다. MarkApplied(line); _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; /// 확인 창에서 [취소] 를 눌렀을 때 돌아갈 브랜치. 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(); } /// 워크스페이스가 읽어 온 브랜치 목록을 그대로 옮긴다. 고른 값은 남아 있으면 지킨다. /// /// 브랜치 목록을 다시 세우는 중. 그 사이 콤보가 값을 흘리는 것은 사람이 고른 것이 아니다. /// 묻지 않게 막는다 - 안 막으면 스테이징만 해도 "비교 방식을 바꿉니다" 창이 뜬다 /// (5차 A-2 에서 실제로 그랬다). /// 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; /// 내 원본 줄을 결과(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)) { // 이미 내 원본과 같다. 충돌 줄이면 "내 것으로 정했다"는 표시만 남긴다. // 내용이 안 바뀌므로 다시 정렬하면 이 표시가 사라진다 - 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; /// 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), }); // 반영 표시는 되돌리기 스택 밖에도 남긴다 - 규칙을 바꾸면 스택이 비워지기 때문이다. MarkApplied(line); _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; _reflowTimer.Stop(); if (ShowTypedLineNow(text, caretIndex)) { // 지금 치는 줄은 이미 화면에 나왔다. 무거운 BASE 재정렬만 손이 멈춘 뒤로 미룬다. _reflowTimer.Interval = ReflowAfterTyping; _reflowTimer.Start(); return; } // 줄 수가 달라졌다 - 엔터 · 줄 합치기 · 여러 줄 붙여넣기. // // 한 줄만 고쳐서는 맞출 수 없는 경우인데, 예전에는 여기서도 0.6초 타이머에 맡겼다. // 편집기 글자는 투명이라 그 동안 화면에 아무것도 나오지 않았다 - // "엔터를 누르면 한참 뒤에 반응한다"는 것의 정체가 이것이다. // // 그렇다고 이 자리에서 바로 부르면 안 된다. 재정렬은 편집기 글을 통째로 갈아 끼우는데, // 지금은 편집기의 TextChanged 안이다. 자기 이벤트 안에서 자기 글을 바꾸면 캐럿이 엉키고 // 뒤따라오던 글자가 엉뚱한 자리에 찍힌다(실제로 그렇게 됐다). // 타이머를 짧게 놓아 이 입력이 끝난 바로 다음 차례에 돌게 한다. _reflowTimer.Interval = ReflowNow; _reflowTimer.Start(); } /// 타이핑이 멈추기를 기다리는 시간. 글자마다 다시 정렬하면 화면이 요동친다. private static readonly TimeSpan ReflowAfterTyping = TimeSpan.FromMilliseconds(600); /// /// 기다리지 않고 바로. 다만 0 이 아니라 1ms 다 - 지금 이벤트가 끝난 다음 차례에 /// 돌게 하려는 것이지, 늦추려는 것이 아니다. /// private static readonly TimeSpan ReflowNow = TimeSpan.FromMilliseconds(1); /// /// 캐럿이 있는 줄 하나만 표시층에 바로 반영한다. /// /// 줄 수가 달라졌으면(엔터·줄 합치기) 아래 줄이 통째로 밀린 것이라 한 줄만 고쳐서는 /// 맞지 않는다. 그때는 false 를 주고 부르는 쪽이 곧바로 재정렬하게 한다. /// /// 이 자리에서 화면을 맞췄으면 true. 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; } /// 타이핑이 멈췄다. 필러를 걷어내고 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) { // 반영한 줄인지를 먼저 본다. // // 반영한 줄은 글이 원본과 달라지므로 아래 "직접 수정" 조건에도 걸린다. // 그대로 두면 규칙을 하나 건드리는 순간 반영한 줄이 전부 보라색(직접 수정)이 된다. // 사람이 직접 친 것과 버튼으로 가져온 것은 뜻이 다르니 섞이면 안 된다. 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(); } /// /// 모델(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) { 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(); } } /// /// 끌어다 놓은 파일을 한쪽 칸에 넣고 바로 다시 견준다. /// /// 칸 값만 바꾸고 두면 화면은 옛 파일을 그대로 보여 준다 - "놓았는데 아무 일도 없다"로 읽힌다. /// 파일 고르기 단추와 같은 길(RequestPlainCompare)로 보내는 까닭은 그 길이 /// Git 모드를 끄는 것까지 함께 하기 때문이다. 저장소를 열어 둔 채로 파일을 끌어다 놓으면 /// 사용자가 보고 싶은 것은 그 두 파일이지 커밋 이력이 아니다. /// 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; /// /// 비교 결과를 글로 뽑아 파일에 쓴다. /// /// 화면을 캡처하는 것 말고는 결과를 남에게 보낼 방법이 없었다. 캡처는 검색도 안 되고 /// 줄 번호를 짚어 말하기도 어렵다. /// [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}"); } } /// /// 보고서에 적을 쪽 이름. Git 모드면 머리글(브랜치·커밋)이 더 쓸모 있고, /// 파일 비교 모드면 경로가 있어야 어느 파일인지 알 수 있다. /// 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; } } /// 고른 두 파일을 읽어 비교 화면을 만든다. 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); }); } /// /// 폴더 전체 검색이 짚어 준 줄이 몇 번째 행인지. 없거나 못 찾으면 -1. /// /// 왼쪽 기준 번호는 BASE 칸, 오른쪽 기준 번호는 LOCAL 칸에서 찾는다 - /// 한쪽에만 있는 줄이 끼면 두 번호가 어긋나므로 아무 쪽에서나 찾으면 엉뚱한 줄에 선다. /// 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); /// Git 없이 쓸 때는 커밋 없이 오른쪽 파일에 그대로 쓴다. [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)}"); }); } /// /// 파일 두 개를 견줄 때의 패널 머리글. /// /// 파일 이름만 적지 않는다. 파일 비교는 다른 자리에 있는 파일을 견주는 일이라 /// 이름이 같은 경우가 흔하고(같은 파일의 두 판), 그러면 양쪽 머리글이 똑같아져 /// 어느 쪽이 어느 쪽인지 알 수 없다. 그렇다고 전체 경로를 다 적으면 길어서 읽히지 않는다. /// 그래서 로 같은 앞부분만 떼고 나머지를 적는다. /// 전체 경로는 툴팁과 우클릭 복사에 그대로 있다. /// 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; } /// /// 경로에서 실제로 갈리는 첫 칸만 떼어 낸다. /// /// 이 같은 앞부분을 이미 뗐으므로, 남은 것의 첫 칸이 /// 두 쪽을 가르는 자리다(left\ · branch\). 그 칸만 굵게 칠하면 /// 어디가 다른지 눈이 바로 간다 - 나머지는 양쪽이 같은 글자라 읽을 값이 없다. /// 칸이 하나뿐이면(파일 이름만 남은 경우) 뗄 것이 없다. /// // '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)); } /// 머리글에서 굵게 칠할 앞부분. 없으면 빈 글자. [ObservableProperty] private string _baseHeaderLead = string.Empty; [ObservableProperty] private string _localHeaderLead = string.Empty; // ================= 저장 안 한 편집 지키기 ================= /// /// 프로그램이 스스로 화면을 다시 그리는 중. 그때는 묻지 않는다. /// 사용자가 고른 것이 아닌데 창이 뜨면 무엇에 답하는지 알 수 없다. /// private bool _suspendGuard; /// /// 고치던 것이 있으면 물어보고, 계속해도 되면 true 를 준다. /// /// 왜 세 갈래인가 - 예/아니오로는 "버리고 가기"와 "그만두기"가 구분되지 않는다. /// 아니오를 눌렀을 때 그대로 열려 버리면 그것이 곧 잃는 것이다. /// 그래서 플랫폼에 ShowChoice 를 새로 넣었다. /// /// 고친 것이 없으면 묻지 않는다. 넘어가도 되는 경우까지 묻는 것이 더 나쁘다 - /// 매번 뜨는 창은 읽지 않고 누르게 되고, 정작 위험할 때도 그냥 눌러 버린다. /// /// 이제 무엇을 하려는지. 창에 그대로 적힌다. 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 }; } /// /// 고친 내용을 지금 파일에 쓴다. 커밋은 하지 않는다. /// /// Git 모드의 "저장 후 커밋"은 커밋 메시지가 있어야 한다. 확인 창에서 메시지를 받을 수는 없으므로 /// 여기서는 워킹트리 파일에 쓰는 데까지만 한다 - 커밋은 사용자가 따로 할 일이다. /// 작업 게이트를 타지 않는다. 파일 하나를 쓰는 일이라 Git 작업과 줄 세울 까닭이 없고, /// 확인 창은 UI 스레드에서 답을 기다리는 중이라 게이트를 기다리면 서로 막힌다. /// 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; } } // ================= 무시 규칙 ================= /// /// "무엇을 차이로 볼 것인가". 설정에서 읽어 두고 툴바에서 바로 바꾼다. /// 바꾸면 곧바로 다시 정렬해서 화면에 반영한다. /// 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; /// 줄을 옮기면 "이 덩어리 반영"이 켜지고 꺼진다. partial void OnCaretRowChanged(int value) => AcceptBlockCommand.NotifyCanExecuteChanged(); /// 상태바에 띄우는 "차이 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.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); } /// /// "이 줄은 반영했다"를 줄 번호로 기억해 둔다. /// /// 반영 표시는 지금까지 되돌리기 스택에만 있었다. 그런데 비교 규칙을 바꾸면 화면을 통째로 /// 다시 만들면서 스택을 비우므로, 그때 표시가 사라지고 글이 원본과 다르다는 이유만으로 /// "직접 수정"으로 칠해졌다. 실제로 Accept 한 줄이 규칙 하나 건드리자 보라색이 됐다. /// /// 열쇠는 얼어 있는 번호여야 한다 - BASE 줄 번호, 없으면(양쪽이 각자 새 줄을 넣은 자리) /// MINE 줄 번호. /// private void MarkApplied(MergeLine line) { if (line.BaseNumber is int baseNumber) _appliedBaseNumbers.Add(baseNumber); else if (line.OriginalLocalNumber is int mineNumber) _appliedMineNumbers.Add(mineNumber); } /// 되돌리면 그 표시도 함께 지운다. 안 그러면 되돌린 줄이 계속 "반영함"으로 남는다. private void UnmarkApplied(MergeLine line) { if (line.BaseNumber is int baseNumber) _appliedBaseNumbers.Remove(baseNumber); else if (line.OriginalLocalNumber is int mineNumber) _appliedMineNumbers.Remove(mineNumber); } /// /// "반영했다"고 표시해 둔 줄인지. /// /// 그 결정은 글을 바꾸지 않는다(이미 내 원본과 같은 줄이다). 그래서 되돌리기 /// 스택에도 남지 않고, 다시 정렬하면 줄 객체 자체가 새로 만들어진다. /// 붙들어 둘 것이 줄 번호밖에 없어 여기에 따로 모아 둔다. /// private bool IsMarkedApplied(MergeLine line) => line.BaseNumber is int baseNumber ? _appliedBaseNumbers.Contains(baseNumber) : line.OriginalLocalNumber is int mineNumber && _appliedMineNumbers.Contains(mineNumber); /// /// "머지 반영함"과 "직접 수정" 표시를 편집 이력에서 다시 계산한다. /// 둘을 섞으면 안 된다 - 사용자가 직접 지운 줄이 "머지 반영함"으로 보이면 안 되기 때문이다. /// 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) { // "내 것으로 간다"고 정한 충돌은 글을 바꾸지 않아 되돌리기 스택에 남지 않는다. // 그것까지 여기서 지워 버리면 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(); } // ================= 이진 파일 비교 ================= /// 이진 파일 비교 결과 한 줄 요약. "그래서 같다는 거야 다르다는 거야"에 답한다. [ObservableProperty] private string _binarySummary = string.Empty; /// 16진 나란히 보기. 앞부분만 담는다. public ObservableCollection HexRows { get; } = new ObservableCollection(); [ObservableProperty] private bool _isImageFile; [ObservableProperty] private string _leftImagePath = string.Empty; [ObservableProperty] private string _rightImagePath = string.Empty; /// 이진 파일인데 그림이 아니라 16진으로 봐야 하는 경우. public bool ShowHexView => IsBinaryFile && !IsImageFile; partial void OnIsBinaryFileChanged(bool value) => OnPropertyChanged(nameof(ShowHexView)); partial void OnIsImageFileChanged(bool value) => OnPropertyChanged(nameof(ShowHexView)); /// /// 이진 파일 두 개를 견준다. /// /// 지금까지는 "줄 단위로 비교할 수 없습니다" 한 줄로 끝났다. 그러면 정작 알고 싶은 /// 같은지 다른지를 모른 채 화면을 닫게 된다. 크기·해시·처음 갈리는 자리를 알려 주고, /// 그림이면 나란히 띄우고 아니면 앞부분을 16진으로 늘어놓는다. /// 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 = "이진 파일"; } } // ================= 저장 형식 · 덩어리 반영 ================= /// /// 저장할 때 쓸 인코딩·줄바꿈. 기본은 "원본 유지"라 지금까지와 똑같이 동작한다. /// /// 사내 소스에는 CP949 로 저장된 파일이 섞여 있어 새로 만드는 파일과 형식이 갈린다. /// 읽어서 보여 주기만 하면 그 갈림을 못 고치므로 저장할 때 고를 수 있게 한다. /// public IReadOnlyList SaveEncodings => TextFile.SaveEncodings; public IReadOnlyList SaveNewLines => TextFile.SaveNewLines; [ObservableProperty] private string _saveEncoding = TextFile.KeepOriginal; [ObservableProperty] private string _saveNewLine = TextFile.KeepOriginal; /// /// 지금 캐럿이 있는 차이 덩어리 전체를 BASE 내용으로 바꾼다. /// /// 줄 하나씩만 되면 연속 다섯 줄을 다섯 번 눌러야 한다 - WinMerge·Beyond Compare 가 /// 덩어리를 기본 단위로 쓰는 이유다. 덩어리는 "차이로 잡힌 줄이 이어지는 구간"이다. /// 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}줄 반영"); } /// /// 그 줄이 속한 차이 덩어리. 차이가 아니면 빈 목록이다. /// /// 무엇을 가져오는지가 모드마다 다르다. /// 2-Way : BASE 를 결과로 가져온다. 덩어리는 "BASE 를 가져올 수 있는 줄"이 이어진 구간. /// 3-Way : BASE 는 공통 조상이라 가져올 것이 아니다. 저쪽(REMOTE)을 가져오는 것이 /// Take Theirs 이고, 덩어리 반영은 그것을 줄 묶음으로 하는 것이다. /// 여기서 BASE 를 가져오면 조상으로 되돌리는 셈이라 뜻이 전혀 달라진다. /// private List BlockAround(int row) { var found = new List(); 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; } // ================= 자동 병합 · 충돌만 보기 ================= /// /// "내 것으로 간다"고 정한 충돌 줄의 BASE 줄 번호. /// /// 그 결정은 내용을 바꾸지 않으므로 글만 봐서는 되살릴 수 없다. 편집 뒤 다시 정렬하면 /// 행이 새로 만들어지는데, 이걸 들고 있지 않으면 이미 정한 충돌이 되살아나 /// "남은 충돌"이 줄지 않는다. BASE 는 얼어 있으므로 그 줄 번호가 안전한 열쇠다. /// private readonly HashSet _appliedBaseNumbers = new HashSet(); /// /// BASE 에 없는 충돌(양쪽이 각자 새 줄을 넣은 자리)용. 이런 줄은 BASE 줄 번호가 없으므로 /// 역시 얼어 있는 MINE 줄 번호로 붙든다. /// private readonly HashSet _appliedMineNumbers = new HashSet(); private bool CanAutoMerge() => IsThreeWay && Lines.Any(l => l.ThreeWay == ThreeWayState.RemoteChanged); /// /// 저쪽만 고친 줄을 한 번에 결과로 가져온다. 사람은 충돌만 판단하면 된다. /// /// 안 겹치는 변경까지 하나씩 누르게 하면 100줄짜리 충돌 파일에서 정작 판단이 필요한 /// 서너 줄이 묻힌다 - `git merge` 가 자동으로 해 주는 일을 여기서도 해 준다. /// 내가 고친 줄과 충돌 줄은 건드리지 않는다. /// [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); /// 다음 충돌로. `다음 차이` 와 달리 아직 안 정한 충돌만 딛는다. [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; } } /// 아직 사람이 골라야 하는 충돌 줄 수. 0 이면 이 파일의 병합은 끝난 것이다. [ObservableProperty] [NotifyPropertyChangedFor(nameof(MergeStatusText))] [NotifyPropertyChangedFor(nameof(IsMergeComplete))] private int _unresolvedConflictCount; /// 3-Way 에서 지금 어디까지 왔는지 한 줄로. 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; /// 3-Way 에서 저쪽만 고친 줄 수. [ObservableProperty] private int _remoteChangeCount; /// /// 줄 하나만 반영해도 곧바로 범례 숫자가 바뀌도록, 편집이 끝날 때마다 다시 센다. /// (UpdateSummaries 가 AfterEdit / 파일 로드 양쪽에서 호출된다) /// /// /// 범례에서 고른 갈래의 다음 줄로 옮긴다. /// /// 범례는 지금까지 읽기만 하는 설명이었다. 그런데 "추가된 줄만 훑고 싶다" 는 일이 잦은데 /// 차이 이동(Alt+↑↓)은 갈래를 가리지 않는다. 개수를 누르면 그 갈래만 딛게 해서 /// 범례를 도구로 바꾼다. 색 이름을 외울 필요도 없어진다. /// /// 지금 자리 다음부터 찾고 끝에 닿으면 처음으로 돌아온다 - 차이 이동과 같은 방식이라 /// 새 규칙을 외울 것이 없다. /// [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(); } } }