- 바꾸기: 찾기 칸 아래에 바꿀 말 줄이 붙는다. Ctrl+F 로 열면 안 뜬다 - 찾기만 할 때 자리를 뺏지 않기 위해서다. "바꾸기" 는 고른 자리가 찾는 말일 때만 바꾸고 다음으로 넘어간다 - 엉뚱한 자리를 덮어쓰는 것보다 한 번 더 누르게 하는 편이 낫다. "모두" 는 한 번에 바꾸고 몇 개인지 알려 준다(되돌리기는 편집기의 Ctrl+Z 가 맡는다). - 덩어리 단위 반영: 줄 하나씩만 되면 연속 다섯 줄을 다섯 번 눌러야 한다. WinMerge·Beyond Compare 가 덩어리를 기본 단위로 쓰는 이유다. 덩어리는 "차이로 잡힌 줄이 이어지는 구간"이고, 캐럿이 그 안에 있을 때만 켜진다. Alt+Shift+→. - 저장 형식: 사내 소스에 CP949 로 저장된 파일이 섞여 있어 새로 만드는 파일과 형식이 갈린다. 읽어서 보여 주기만 하면 그 갈림을 못 고치므로 저장할 때 고를 수 있게 했다. 기본값 "원본 유지" 라 지금까지와 동작이 같다. Save & Commit 경로도 같은 값을 쓴다 (전에는 File.WriteAllText 로 UTF-8 고정이었다). 실행 확인: PulseUs → PulseMicros 2개 바꿈 · 마지막 차이에서 2줄 한 번에 반영. 단축키 61 → 63건, 전체 305건 통과. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2235 lines
95 KiB
C#
2235 lines
95 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 & 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 { Interval = TimeSpan.FromMilliseconds(600) };
|
|
|
|
public HomeViewModel(GitWorkspace workspace)
|
|
{
|
|
_workspace = workspace;
|
|
_workspace.Refreshed += OnWorkspaceRefreshed;
|
|
_workspace.StateChanged += RaiseCanExecuteChanged;
|
|
_workspace.PlainCompareRequested += OnPlainCompareRequested;
|
|
_reflowTimer.Tick += (_, _) => Reflow();
|
|
|
|
LoadRules();
|
|
IsGitMode = _workspace.IsRepositoryOpen;
|
|
RefreshRemoteBranches();
|
|
|
|
// 폴더 비교에서 파일을 골라 이 화면이 만들어진 경우라면 그 파일을 먼저 연다.
|
|
// 이 화면은 탭을 처음 열 때 만들어지므로, 요청이 우리보다 먼저 도착해 있을 수 있다.
|
|
if (_workspace.HasPendingPlainCompare)
|
|
{
|
|
_workspace.DeliverPendingPlainCompare();
|
|
return;
|
|
}
|
|
|
|
// 설정에 기본 저장소가 있으면 바로 열어서 첫 화면부터 내용이 보이게 한다.
|
|
// 저장소가 아니면 설정에 적어 둔 두 파일로 비교 화면을 만든다 - Git 없이도 쓰는
|
|
// 도구라는 것을 첫 화면에서 바로 보여 준다. 둘을 동시에 걸면 안 된다.
|
|
// 작업 게이트가 하나뿐이라 먼저 건 쪽이 끝날 때까지 뒤엣것이 통째로 취소된다.
|
|
if (_workspace.LooksLikeRepository)
|
|
_ = _workspace.TryAutoOpenAsync();
|
|
else
|
|
_ = LoadDefaultPlainFiles();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 폴더 비교 탭에서 고른 두 파일을 여기서 연다.
|
|
///
|
|
/// Git 저장소를 열어 둔 상태여도 "파일 두 개 비교"로 전환한다 - 폴더 비교에서 고른 파일은
|
|
/// 저장소 안의 것이 아닐 수도 있고, 사용자가 보고 싶은 것은 그 두 파일이지 커밋 이력이 아니다.
|
|
/// </summary>
|
|
private void OnPlainCompareRequested(string leftFile, string rightFile)
|
|
{
|
|
// 한쪽만 있어도 연다 - 없는 쪽은 빈 내용이 되어 전부 추가(또는 삭제)로 보인다.
|
|
// 폴더 비교에서 "왼쪽만 / 오른쪽만" 인 파일을 열어 보려는 것이 바로 이 경우다.
|
|
if (!File.Exists(leftFile) && !File.Exists(rightFile)) return;
|
|
|
|
// 3-Way 를 끄되 그 때문에 본문을 다시 읽지는 않게 한다 - 그 재로드가 작업 게이트를
|
|
// 먼저 잡으면 바로 아래 파일 비교가 "다른 작업 진행 중"으로 건너뛰어진다.
|
|
_suspendReload = true;
|
|
try
|
|
{
|
|
IsGitMode = false;
|
|
RemoteBranch = NoRemoteBranch;
|
|
}
|
|
finally
|
|
{
|
|
_suspendReload = false;
|
|
}
|
|
|
|
LeftFile = leftFile;
|
|
RightFile = rightFile;
|
|
OnPropertyChanged(nameof(BaseFileLocation));
|
|
OnPropertyChanged(nameof(SelectedFileFullPath));
|
|
|
|
_ = LoadPlainFilesAsync();
|
|
}
|
|
|
|
// ================= 데모 전환 (발표·교육용) =================
|
|
|
|
/// <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;
|
|
|
|
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# 문법 색을 입힐지. 설정 > 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>();
|
|
|
|
[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;
|
|
|
|
partial void OnSelectedFileChanged(string? value)
|
|
{
|
|
OnPropertyChanged(nameof(SelectedFilePath));
|
|
OnPropertyChanged(nameof(SelectedFileFullPath));
|
|
OnPropertyChanged(nameof(BaseFileLocation));
|
|
OnPropertyChanged(nameof(MergeNotePreview));
|
|
_ = LoadFileAsync();
|
|
}
|
|
|
|
partial void OnMergeCommentChanged(string value) => OnPropertyChanged(nameof(MergeNotePreview));
|
|
|
|
// ---- 파일 경로 ----
|
|
|
|
/// <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;
|
|
|
|
partial void OnSelectedNodeChanged(CommitNode? value)
|
|
{
|
|
if (_syncingSelection) return;
|
|
SelectedCommit = value?.Commit;
|
|
}
|
|
|
|
// ---- 선택 커밋 상세 ----
|
|
|
|
public CommitInfo? SelectedCommit
|
|
{
|
|
get => _workspace.SelectedCommit;
|
|
set
|
|
{
|
|
if (ReferenceEquals(_workspace.SelectedCommit, value)) return;
|
|
_workspace.SelectedCommit = value;
|
|
OnPropertyChanged();
|
|
RaiseCommitDetail();
|
|
_ = LoadCommitAsync();
|
|
}
|
|
}
|
|
|
|
public string CommitHash => SelectedCommit?.ShortSha ?? "-";
|
|
public string CommitDate => SelectedCommit is null ? "-" : SelectedCommit.When.ToString("yyyy-MM-dd");
|
|
public string CommitAuthor => SelectedCommit?.AuthorName ?? "-";
|
|
public string CommitBranch => SelectedCommit?.Refs.FirstOrDefault() ?? _workspace.CurrentBranch;
|
|
public string CommitSubject => SelectedCommit is null ? "-" : $"\"{SelectedCommit.MessageShort}\"";
|
|
|
|
private void RaiseCommitDetail()
|
|
{
|
|
OnPropertyChanged(nameof(CommitHash));
|
|
OnPropertyChanged(nameof(CommitDate));
|
|
OnPropertyChanged(nameof(CommitAuthor));
|
|
OnPropertyChanged(nameof(CommitBranch));
|
|
OnPropertyChanged(nameof(CommitSubject));
|
|
OnPropertyChanged(nameof(BaseFileLocation));
|
|
}
|
|
|
|
// ================= 커맨드 =================
|
|
|
|
private bool CanTakeLine(MergeLine? line) => line?.CanTakeBase == true;
|
|
|
|
/// <summary>목업 ② 부분 수용 - LOCAL 줄 번호 옆 [>] 버튼. 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 로 통째로 되돌린다.</summary>
|
|
[RelayCommand(CanExecute = nameof(CanRevertAll))]
|
|
private void RevertAll()
|
|
{
|
|
ReloadLinesFrom(_originalLocalText, keepAsEdited: false);
|
|
}
|
|
|
|
private bool CanReload() => _workspace.IsRepositoryOpen;
|
|
|
|
[RelayCommand(CanExecute = nameof(CanReload))]
|
|
private Task ReloadAsync() => _workspace.RefreshAsync();
|
|
|
|
private bool CanSaveAndCommit() => HasChanges && !string.IsNullOrWhiteSpace(CommitMessage);
|
|
|
|
/// <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();
|
|
|
|
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();
|
|
}
|
|
|
|
/// <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(() =>
|
|
{
|
|
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();
|
|
});
|
|
}
|
|
|
|
/// <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);
|
|
|
|
BaseHeader = threeWay
|
|
? $"BASE ({baseOrigin}) · {path}"
|
|
: $"BASE · {commit.ShortSha} · {path}";
|
|
LocalHeader = $"LOCAL · {_workspace.CurrentBranch}";
|
|
RemoteHeader = isConflict
|
|
? "REMOTE · THEIRS"
|
|
: $"REMOTE · {branch}";
|
|
|
|
_baseTextSnapshot = baseText;
|
|
_remoteTextSnapshot = threeWay ? remoteText : string.Empty;
|
|
_mineTextSnapshot = threeWay ? localText : string.Empty;
|
|
_resolvedBaseNumbers.Clear(); // 파일이 바뀌면 앞 파일의 결정은 뜻이 없다
|
|
_resolvedMineNumbers.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>
|
|
/// 여러 줄에 걸친 /* ... */ 를 이어서 칠하려면 각 줄이 "앞에서 주석이 열린 채 왔는지"를 알아야 한다.
|
|
/// 위에서 아래로 한 번 훑으며 BASE/LOCAL 각각의 상태를 기록한다.
|
|
/// LOCAL 을 직접 고치면 그 아래 줄들의 상태가 달라지므로 편집 후에도 다시 계산한다.
|
|
/// </summary>
|
|
private void RefreshBlockCommentState(IEnumerable<MergeLine> rows)
|
|
{
|
|
var inBase = false;
|
|
var inLocal = false;
|
|
|
|
foreach (var row in rows)
|
|
{
|
|
row.BaseInComment = inBase;
|
|
row.LocalInComment = inLocal;
|
|
|
|
CodeTokenizer.Tokenize(row.BaseText, Language, inBase, out inBase);
|
|
CodeTokenizer.Tokenize(row.LocalText, Language, inLocal, out inLocal);
|
|
}
|
|
}
|
|
|
|
// ================= 줄 단위 편집 =================
|
|
|
|
/// <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),
|
|
});
|
|
_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;
|
|
|
|
partial void OnRemoteBranchChanged(string value)
|
|
{
|
|
OnPropertyChanged(nameof(IsThreeWay));
|
|
OnPropertyChanged(nameof(IsTwoWay));
|
|
|
|
// 3-Way 로 들어가고 나가면서 화면이 4분할로 바뀐다. 제목과 머리 높이도 함께 바뀐다.
|
|
OnPropertyChanged(nameof(BaseTitle));
|
|
OnPropertyChanged(nameof(LocalTitle));
|
|
OnPropertyChanged(nameof(SourceHeaderHeight));
|
|
|
|
if (IsGitMode && !_suspendReload) _ = LoadFileAsync();
|
|
}
|
|
|
|
/// <summary>워크스페이스가 읽어 온 브랜치 목록을 그대로 옮긴다. 고른 값은 남아 있으면 지킨다.</summary>
|
|
private void RefreshRemoteBranches()
|
|
{
|
|
var keep = RemoteBranch;
|
|
|
|
RemoteBranches.Clear();
|
|
RemoteBranches.Add(NoRemoteBranch);
|
|
|
|
foreach (var branch in _workspace.Branches)
|
|
{
|
|
// 지금 체크아웃된 브랜치는 LOCAL 그 자체라 REMOTE 로 고를 이유가 없다.
|
|
if (string.Equals(branch.FriendlyName, _workspace.CurrentBranch, StringComparison.Ordinal))
|
|
continue;
|
|
|
|
if (!RemoteBranches.Contains(branch.FriendlyName)) RemoteBranches.Add(branch.FriendlyName);
|
|
}
|
|
|
|
// 고른 브랜치가 사라졌으면 조용히 2-Way 로 돌아간다.
|
|
if (!RemoteBranches.Contains(keep)) RemoteBranch = NoRemoteBranch;
|
|
}
|
|
|
|
private bool CanTakeLocalLine(MergeLine? line) => line?.CanTakeLocal == true;
|
|
|
|
/// <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;
|
|
if (line.BaseNumber is int resolvedBase) _resolvedBaseNumbers.Add(resolvedBase);
|
|
else if (line.OriginalLocalNumber is int resolvedMine) _resolvedMineNumbers.Add(resolvedMine);
|
|
AfterEdit();
|
|
return;
|
|
}
|
|
|
|
var description = afterHasLocal
|
|
? $"{line.LocalNumber}행 내 원본으로 되돌림"
|
|
: $"{line.LocalNumber}행 삭제(내 원본에 없음)";
|
|
|
|
line.LocalText = after;
|
|
line.HasLocal = afterHasLocal;
|
|
line.AppliedNote = null;
|
|
|
|
_undo.Push(new LineEdit(line, before, beforeHasLocal, after, afterHasLocal,
|
|
EditKind.TakeBase, description));
|
|
_redo.Clear();
|
|
|
|
_workspace.AppendLog(description);
|
|
AfterEdit();
|
|
}
|
|
|
|
private bool CanAcceptLocal() => SelectedLine?.CanTakeLocal == true;
|
|
|
|
[RelayCommand(CanExecute = nameof(CanAcceptLocal))]
|
|
private void AcceptLocal() => TakeLocalLine(SelectedLine);
|
|
|
|
private bool CanTakeRemoteLine(MergeLine? line) => line?.CanTakeRemote == true;
|
|
|
|
/// <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),
|
|
});
|
|
_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;
|
|
|
|
ShowTypedLineNow(text, caretIndex);
|
|
|
|
// 글자마다 다시 정렬하면 화면이 요동친다. 타이핑이 멈춘 뒤에 한 번만 한다.
|
|
_reflowTimer.Stop();
|
|
_reflowTimer.Start();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 캐럿이 있는 줄 하나만 표시층에 바로 반영한다.
|
|
///
|
|
/// 줄 수가 달라졌으면(엔터·줄 합치기) 아래 줄이 통째로 밀린 것이라 한 줄만 고쳐서는
|
|
/// 맞지 않는다. 그때는 손대지 않고 재정렬에 맡긴다.
|
|
/// </summary>
|
|
private void ShowTypedLineNow(string text, int caretIndex)
|
|
{
|
|
if (_editorLines.Length == 0 || Lines.Count != _editorLines.Length) return;
|
|
|
|
// 한 번만 훑으면서 (캐럿 줄 번호, 그 줄의 범위, 전체 줄 수)를 함께 구한다.
|
|
int line = 0, start = 0, end = -1, count = 1;
|
|
|
|
for (var i = 0; i < text.Length; i++)
|
|
{
|
|
if (text[i] != '\n') continue;
|
|
|
|
if (i < caretIndex) { line++; start = i + 1; }
|
|
else if (end < 0) end = i;
|
|
|
|
count++;
|
|
}
|
|
|
|
if (end < 0) end = text.Length;
|
|
if (line >= _editorLines.Length || count != _editorLines.Length) return;
|
|
|
|
var value = text.Substring(start, end - start).TrimEnd('\r');
|
|
if (string.Equals(_editorLines[line], value, StringComparison.Ordinal)) return;
|
|
|
|
_editorLines[line] = value;
|
|
Lines[line].LocalText = value;
|
|
DiffVersion++;
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
if (row.LocalNumber is int number && touched.Contains(number))
|
|
row.IsManuallyEdited = true;
|
|
|
|
// "내 것으로 간다"고 정했던 충돌은 내용이 그대로라 글만 봐서는 알 수 없다.
|
|
if (row.BaseNumber is int baseNumber
|
|
? _resolvedBaseNumbers.Contains(baseNumber)
|
|
: row.OriginalLocalNumber is int mineNumber && _resolvedMineNumbers.Contains(mineNumber))
|
|
row.IsApplied = 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)
|
|
{
|
|
LeftFile = picked;
|
|
OnPropertyChanged(nameof(BaseFileLocation));
|
|
_ = LoadPlainFilesAsync();
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void PickRightFile()
|
|
{
|
|
if (AskForFile("오른쪽(내 작업) 파일 고르기", RightFile) is { } picked)
|
|
{
|
|
RightFile = picked;
|
|
OnPropertyChanged(nameof(SelectedFileFullPath));
|
|
_ = LoadPlainFilesAsync();
|
|
}
|
|
}
|
|
|
|
private static string? AskForFile(string title, string current)
|
|
{
|
|
var dialog = new Microsoft.Win32.OpenFileDialog
|
|
{
|
|
Title = title,
|
|
CheckFileExists = true,
|
|
Filter = "모든 파일 (*.*)|*.*"
|
|
};
|
|
|
|
if (!string.IsNullOrWhiteSpace(current))
|
|
{
|
|
try { dialog.InitialDirectory = Path.GetDirectoryName(current); }
|
|
catch (ArgumentException) { /* 경로가 깨졌으면 그냥 기본 위치에서 연다 */ }
|
|
}
|
|
|
|
return dialog.ShowDialog() == true ? dialog.FileName : null;
|
|
}
|
|
|
|
/// <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();
|
|
BaseHeader = $"BASE · {Path.GetFileName(LeftFile)}";
|
|
LocalHeader = $"LOCAL · {Path.GetFileName(RightFile)}";
|
|
DiffSummary = "이진 파일 - 줄 단위로 비교할 수 없습니다";
|
|
});
|
|
|
|
_workspace.AppendLog("이진 파일이라 비교하지 않음");
|
|
return;
|
|
}
|
|
|
|
IsBinaryFile = false;
|
|
|
|
string baseText = string.Empty, localText = string.Empty;
|
|
|
|
// 다른 작업이 게이트를 쥐고 있으면 RunAsync 는 아무것도 하지 않고 false 를 준다.
|
|
// 그대로 밀고 나가면 빈 글로 화면을 갈아엎어 "차이 없음"이 뜬다.
|
|
if (!await _workspace.RunAsync("파일 비교", () =>
|
|
{
|
|
baseText = TextFile.Read(LeftFile);
|
|
localText = TextFile.Read(RightFile);
|
|
}))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_workspace.OnUi(() =>
|
|
{
|
|
// 읽어 오는 사이에 Git 모드로 넘어갔으면 버린다(위 LoadFileAsync 와 같은 이유).
|
|
if (IsGitMode) return;
|
|
|
|
_baseTextSnapshot = baseText;
|
|
_remoteTextSnapshot = string.Empty; // 파일 두 개 비교는 늘 2-Way 다
|
|
_mineTextSnapshot = string.Empty;
|
|
_resolvedBaseNumbers.Clear();
|
|
_resolvedMineNumbers.Clear();
|
|
|
|
RefreshLanguage();
|
|
var rows = BuildLines(baseText, localText);
|
|
Lines.Clear();
|
|
foreach (var row in rows) Lines.Add(row);
|
|
|
|
BaseHeader = $"BASE · {Path.GetFileName(LeftFile)}";
|
|
LocalHeader = $"LOCAL · {Path.GetFileName(RightFile)}";
|
|
|
|
_originalLocalText = BuildLocalText();
|
|
SyncEditorFromLines();
|
|
|
|
var info = TextFile.Describe(RightFile);
|
|
RefreshLanguage();
|
|
FileEncoding = info.Encoding;
|
|
FileNewLine = info.NewLine;
|
|
|
|
_undo.Clear();
|
|
_redo.Clear();
|
|
CaretRow = 0;
|
|
InvalidateDiffBlocks();
|
|
SelectedLine = Lines.FirstOrDefault(l => l.IsDifferent) ?? Lines.FirstOrDefault();
|
|
|
|
UpdateSummaries();
|
|
RefreshDiffPosition();
|
|
RaiseCanExecuteChanged();
|
|
});
|
|
}
|
|
|
|
private bool CanSavePlain() => IsPlainMode && HasChanges && !string.IsNullOrWhiteSpace(RightFile);
|
|
|
|
/// <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>
|
|
/// "무엇을 차이로 볼 것인가". 설정에서 읽어 두고 툴바에서 바로 바꾼다.
|
|
/// 바꾸면 곧바로 다시 정렬해서 화면에 반영한다.
|
|
/// </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.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>
|
|
/// "머지 반영함"과 "직접 수정" 표시를 편집 이력에서 다시 계산한다.
|
|
/// 둘을 섞으면 안 된다 - 사용자가 직접 지운 줄이 "머지 반영함"으로 보이면 안 되기 때문이다.
|
|
/// 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)
|
|
{
|
|
line.IsApplied = applied.Contains(line);
|
|
line.IsManuallyEdited = edited.Contains(line);
|
|
|
|
if (!line.IsApplied) line.AppliedNote = null;
|
|
}
|
|
}
|
|
|
|
private void UpdateSummaries()
|
|
{
|
|
var total = Lines.Count;
|
|
var diff = Lines.Count(l => l.IsDifferent);
|
|
|
|
DiffSummary = total == 0 ? "-" : $"총 {total} 라인 · 차이 {diff} 라인";
|
|
DiffVersion++;
|
|
EditSummary = $"편집 {_undo.Count}건 · 되돌리기 {_redo.Count}건 · 남은 차이 {diff} 라인";
|
|
|
|
UpdateLegendCounts();
|
|
RefreshProblems();
|
|
}
|
|
|
|
// ================= 저장 형식 · 덩어리 반영 =================
|
|
|
|
/// <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;
|
|
|
|
foreach (var line in block) Apply(line, requery: false);
|
|
|
|
AfterEdit();
|
|
MessageUtil.ShowSuccessToast($"{block.Count}줄 반영");
|
|
}
|
|
|
|
/// <summary>그 줄이 속한 차이 덩어리. 차이가 아니면 빈 목록이다.</summary>
|
|
private List<MergeLine> BlockAround(int row)
|
|
{
|
|
var found = new List<MergeLine>();
|
|
if (row < 0 || row >= Lines.Count) return found;
|
|
if (!Lines[row].CanTakeBase) return found;
|
|
|
|
var first = row;
|
|
while (first > 0 && Lines[first - 1].CanTakeBase) first--;
|
|
|
|
var last = row;
|
|
while (last + 1 < Lines.Count && Lines[last + 1].CanTakeBase) last++;
|
|
|
|
for (var i = first; i <= last; i++) found.Add(Lines[i]);
|
|
return found;
|
|
}
|
|
|
|
// ================= 자동 병합 · 충돌만 보기 =================
|
|
|
|
/// <summary>
|
|
/// "내 것으로 간다"고 정한 충돌 줄의 BASE 줄 번호.
|
|
///
|
|
/// 그 결정은 <b>내용을 바꾸지 않으므로</b> 글만 봐서는 되살릴 수 없다. 편집 뒤 다시 정렬하면
|
|
/// 행이 새로 만들어지는데, 이걸 들고 있지 않으면 이미 정한 충돌이 되살아나
|
|
/// "남은 충돌"이 줄지 않는다. BASE 는 얼어 있으므로 그 줄 번호가 안전한 열쇠다.
|
|
/// </summary>
|
|
private readonly HashSet<int> _resolvedBaseNumbers = new HashSet<int>();
|
|
|
|
/// <summary>
|
|
/// BASE 에 없는 충돌(양쪽이 각자 새 줄을 넣은 자리)용. 이런 줄은 BASE 줄 번호가 없으므로
|
|
/// 역시 얼어 있는 MINE 줄 번호로 붙든다.
|
|
/// </summary>
|
|
private readonly HashSet<int> _resolvedMineNumbers = 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>
|
|
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();
|
|
}
|
|
}
|
|
}
|