- Shift+휠을 가로 스크롤로(파일 비교 · 메모). 파일 비교는 가로 휠(틸트)과 같은 길로 보내 마우스가 놓인 칸이 움직이고 나머지가 따라온다. 휠 아래 = 오른쪽. - Ctrl+↓ / Ctrl+↑ 를 다음 · 이전 차이의 대체 키로. 칸과 상태 줄 사이에 캐럿 행의 BASE(변경 전) · LOCAL(변경 후)을 위아래로 보이는 칸 - 칸과 같은 CodeTextBlock (문법 색 · 바뀐 글자 칠하기 · 줄 색), 긴 줄은 접는다. HomeViewModel.CaretLine. - 폴더 비교에서 연 문서 탭을 닫으면(Ctrl+W) 폴더 비교 탭으로 간다. RequestPlainCompare(fromFolder:) 로 표시를 보내 DocTab.FromFolder 에 붙이고, ◀ 이전 파일 · 다음 파일 ▶ 로 짝을 바꿔도 이어 받는다. 다른 길로 연 탭은 그대로. - 어두운 테마에서 폴더 비교 목록만 흰 바탕 · 검은 글자였다 - 플랫폼 테마가 TreeView 는 칠하지 않는다. 바탕 · 글자색(항목 포함)을 테마에서 받는다. 색 구분의 0 항목 흐리기 0.35 -> 0.55(어두운 바탕에서 묻혔다). 시험: 빌드 경고 0 · 오류 0. 화면 g27-five(새, 어두운 테마) - Shift+휠 편집기 가로 0 -> 20% · 세로 그대로 · BASE 칸 위에서도 · 메모, Ctrl+↓ 로 차이 이동 · 아래 칸 표시, 폴더 비교에서 연 탭만 Ctrl+W 뒤 폴더 비교로, 사진으로 폴더 목록 · 아래 칸 확인. 실패 0. 기존 회귀는 돌리지 않았다. Agent: main (claude-opus-5-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4875 lines
237 KiB
C#
4875 lines
237 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();
|
|
|
|
public HomeViewModel(GitWorkspace workspace)
|
|
{
|
|
_workspace = workspace;
|
|
_workspace.Refreshed += OnWorkspaceRefreshed;
|
|
_workspace.StateChanged += RaiseCanExecuteChanged;
|
|
_workspace.PlainCompareRequested += OnPlainCompareRequested;
|
|
_workspace.OpenConflictRequested += OnOpenConflictRequested;
|
|
_workspace.MergeToolRequested += OnMergeToolRequested;
|
|
_workspace.Recent.Changed += (_, _) => _workspace.OnUi(RefreshRecent);
|
|
_workspace.QueueChanged += (_, _) => RefreshQueue();
|
|
|
|
// Git 작업이 시작되기 전에 우리에게 물어보게 한다(5차 A-2).
|
|
_workspace.ConfirmDiscardEdits = op => ConfirmDiscard($"Git 작업을 합니다: {op}");
|
|
|
|
// Git 탭에서 고른 커밋을 따라간다. 고르기 전에 저장 안 한 편집을 묻는 것도 이 화면이 맡는다(10차 B-1).
|
|
_workspace.ConfirmLeaveCommit = () => WillAskBeforeDiscard ? ConfirmDiscard("다른 커밋을 봅니다.") : null;
|
|
_workspace.CommitChosen += OnCommitChosenElsewhere;
|
|
_isRulesOpen = _workspace.Settings.ActionsRulesExpanded;
|
|
_isActionsHidden = _workspace.Settings.ActionsHidden;
|
|
_codeFontSize = CodeFontMetrics.Clamp(_workspace.Settings.CodeFontSize);
|
|
_reflowTimer.Tick += (_, _) => Reflow();
|
|
|
|
RefreshRecent();
|
|
|
|
LoadRules();
|
|
IsGitMode = _workspace.IsRepositoryOpen;
|
|
RefreshRemoteBranches();
|
|
|
|
// Git 탭의 충돌 목록에서 파일을 두 번 눌러 이 화면이 처음 만들어진 경우(6차 B-1).
|
|
// 저장소는 Git 탭이 이미 열었다. 아래의 기본 저장소 열기로 가면 다른 저장소를 열 수 있다 -
|
|
// 지금 저장소로 목록만 세우고 그 파일을 연다.
|
|
if (_workspace.HasPendingOpenConflict)
|
|
{
|
|
OnWorkspaceRefreshed();
|
|
_workspace.DeliverPendingOpenConflict();
|
|
return;
|
|
}
|
|
|
|
// git mergetool 이 세 벌을 넘겨 켠 경우. 두 칸 비교 요청보다 먼저 본다.
|
|
if (_workspace.HasPendingMergeTool)
|
|
{
|
|
_workspace.DeliverPendingMergeTool();
|
|
return;
|
|
}
|
|
|
|
// 폴더 비교에서 파일을 골라 이 화면이 만들어진 경우라면 그 파일을 먼저 연다.
|
|
// 이 화면은 탭을 처음 열 때 만들어지므로, 요청이 우리보다 먼저 도착해 있을 수 있다.
|
|
if (_workspace.HasPendingPlainCompare)
|
|
{
|
|
_workspace.DeliverPendingPlainCompare();
|
|
return;
|
|
}
|
|
|
|
// Git 탭에서 저장소를 이미 연 뒤에 이 화면이 처음 만들어진 경우(6차 B-2 를 확인하다 찾은 B-1 의 빈틈).
|
|
// 아래 자동 열기는 이미 열린 저장소면 아무것도 안 하고, 새로고침 신호는 이 화면이 생기기 전에 지나갔다.
|
|
// 그래서 파일 목록이 끝내 0개로 남았다. 지금 저장소로 목록을 세운다.
|
|
if (_workspace.IsRepositoryOpen)
|
|
{
|
|
OnWorkspaceRefreshed();
|
|
return;
|
|
}
|
|
|
|
// 설정에 기본 저장소가 있으면 바로 열어서 첫 화면부터 내용이 보이게 한다.
|
|
// 저장소가 아니면 설정에 적어 둔 두 파일로 비교 화면을 만든다 - Git 없이도 쓰는
|
|
// 도구라는 것을 첫 화면에서 바로 보여 준다. 둘을 동시에 걸면 안 된다.
|
|
// 작업 게이트가 하나뿐이라 먼저 건 쪽이 끝날 때까지 뒤엣것이 통째로 취소된다.
|
|
// 12차 E-1: 지난번에 저장하지 않고 끈 편집이 있으면 그것부터 되살린다(묻지 않는다).
|
|
// 위의 "시키는 일"(충돌 열기 · mergetool · 폴더 비교에서 고른 파일)이 있을 때는 여기까지 오지 않는다 -
|
|
// 사용자가 지금 열라고 한 것이 먼저다. 기본 저장소 · 기본 짝보다는 먼저다.
|
|
if (!_workspace.LooksLikeRepository && RestoreSession())
|
|
{
|
|
_ = LoadRestoredAsync();
|
|
return;
|
|
}
|
|
|
|
if (_workspace.LooksLikeRepository)
|
|
_ = _workspace.TryAutoOpenAsync();
|
|
else
|
|
_ = LoadDefaultPlainFiles();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 폴더 비교 탭에서 고른 두 파일을 여기서 연다.
|
|
///
|
|
/// Git 저장소를 열어 둔 상태여도 "파일 두 개 비교"로 전환한다 - 폴더 비교에서 고른 파일은
|
|
/// 저장소 안의 것이 아닐 수도 있고, 사용자가 보고 싶은 것은 그 두 파일이지 커밋 이력이 아니다.
|
|
/// </summary>
|
|
private void OnPlainCompareRequested(string leftFile, string rightFile)
|
|
{
|
|
// 한쪽만 있어도 연다 - 없는 쪽은 빈 내용이 되어 전부 추가(또는 삭제)로 보인다.
|
|
// 폴더 비교에서 "왼쪽만 / 오른쪽만" 인 파일을 열어 보려는 것이 바로 이 경우다.
|
|
if (!File.Exists(leftFile) && !File.Exists(rightFile)) return;
|
|
|
|
// 폴더 비교 더블클릭 · 폴더 검색 결과 · 끌어다 놓기 · 탐색기 우클릭 · mergetool 이 모두 여기로 온다.
|
|
var opening = Path.GetFileName(File.Exists(rightFile) ? rightFile : leftFile);
|
|
|
|
// 12차 E-2: 두 파일 비교끼리 짝을 바꿀 때는 <b>묻지 않는다</b> - 떠 있던 탭이 편집을 맡아 둔다.
|
|
// Git 모드 · 병합 중 네 칸에서 넘어올 때는 맡을 탭이 없으니 예전처럼 묻는다.
|
|
if (!TabsCanHoldEdits && !ConfirmDiscard($"다른 파일을 엽니다: {opening}")) return;
|
|
|
|
// 탭을 먼저 잡아 둔다. 아래 LoadPlainFilesAsync 가 다 읽은 뒤 맡겨 둔 편집을 되살린다.
|
|
var replaceTab = _replaceTab;
|
|
_replaceTab = false;
|
|
|
|
// 폴더 비교에서 연 짝이면 탭에 적어 둔다 - 닫으면 폴더 비교로 돌아간다. ◀ 이전 파일 · 다음 파일 ▶ 은 폴더 목록을
|
|
// 훑는 것이라 탭을 새로 만들어도(Replace) 그 표시를 이어 받는다.
|
|
var fromFolder = _workspace.TakeRequestedFromFolder() || (replaceTab && Tabs.Current?.FromFolder == true);
|
|
OpenPairInTab(leftFile, rightFile, replaceTab);
|
|
if (fromFolder && Tabs.Current is { } openedTab) openedTab.FromFolder = true;
|
|
|
|
// 3-Way 를 끄되 그 때문에 본문을 다시 읽지는 않게 한다 - 그 재로드가 작업 게이트를
|
|
// 먼저 잡으면 바로 아래 파일 비교가 "다른 작업 진행 중"으로 건너뛰어진다.
|
|
_suspendReload = true;
|
|
try
|
|
{
|
|
IsGitMode = false;
|
|
RemoteBranch = NoRemoteBranch;
|
|
}
|
|
finally
|
|
{
|
|
_suspendReload = false;
|
|
}
|
|
|
|
LeftFile = leftFile;
|
|
RightFile = rightFile;
|
|
OnPropertyChanged(nameof(BaseFileLocation));
|
|
OnPropertyChanged(nameof(SelectedFileFullPath));
|
|
|
|
_ = LoadPlainFilesAsync();
|
|
}
|
|
|
|
// ================= git mergetool 세 벌 =================
|
|
|
|
/// <summary>git mergetool 이 넘긴 세 벌. 네 칸으로 열려 있는 동안만 있다.</summary>
|
|
private MergeToolRequest? _mergeTool;
|
|
|
|
/// <summary>
|
|
/// `git mergetool` 이 넘긴 BASE · LOCAL · REMOTE 를 네 칸으로 연다(6차 B-4 확인 중 발견).
|
|
///
|
|
/// 예전에는 BASE 와 MERGED 두 칸으로만 열었다. MERGED 는 git 이 충돌 표시를 넣어 둔 파일이라
|
|
/// 상대(REMOTE)가 무엇을 바꿨는지 볼 수 없고, 충돌 표시를 손으로 지워 가며 풀어야 했다.
|
|
/// 이제는 저장소 안 충돌 파일과 같은 네 칸으로 열고, 결과(MERGED 칸)는 [저장] 으로 MERGED 파일에 쓴다.
|
|
/// MERGED 칸의 처음 내용은 git 이 넣은 충돌 표시가 아니라 <b>LOCAL(내 쪽)</b>이다 - 저장소 충돌 파일과 같다.
|
|
/// </summary>
|
|
private void OnMergeToolRequested(MergeToolRequest request)
|
|
{
|
|
if (!File.Exists(request.Local) && !File.Exists(request.Remote)) return;
|
|
if (!ConfirmDiscard($"병합할 파일을 엽니다: {Path.GetFileName(request.Merged)}")) return;
|
|
|
|
_suspendReload = true;
|
|
try
|
|
{
|
|
IsGitMode = false;
|
|
RemoteBranch = NoRemoteBranch;
|
|
}
|
|
finally
|
|
{
|
|
_suspendReload = false;
|
|
}
|
|
|
|
_mergeTool = request;
|
|
LeftFile = request.Base;
|
|
RightFile = request.Merged;
|
|
OnPropertyChanged(nameof(BaseFileLocation));
|
|
OnPropertyChanged(nameof(SelectedFileFullPath));
|
|
|
|
_ = LoadMergeToolAsync(request);
|
|
}
|
|
|
|
private async Task LoadMergeToolAsync(MergeToolRequest request)
|
|
{
|
|
string baseText = string.Empty, localText = string.Empty, remoteText = string.Empty;
|
|
|
|
if (!await _workspace.RunAsync("mergetool 파일 읽기", () =>
|
|
{
|
|
baseText = ReadIfExists(request.Base);
|
|
localText = ReadIfExists(request.Local);
|
|
remoteText = ReadIfExists(request.Remote);
|
|
}))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_workspace.OnUi(() =>
|
|
{
|
|
// 읽는 사이에 다른 파일이나 저장소로 넘어갔으면 버린다.
|
|
if (IsGitMode || !ReferenceEquals(_mergeTool, request)) return;
|
|
|
|
IsBinaryFile = false;
|
|
IsImageFile = false;
|
|
HexRows.Clear();
|
|
BinarySummary = string.Empty;
|
|
|
|
// 행을 싣기 전에 틀부터 네 칸으로 바꾼다(LoadFileAsync 와 같은 까닭).
|
|
SetToolThreeWay(true);
|
|
|
|
// C# 스타일 맞춤(6차 추가).
|
|
var rawLocalText = localText;
|
|
baseText = Styled(baseText);
|
|
localText = Styled(localText);
|
|
remoteText = Styled(remoteText);
|
|
|
|
_baseTextSnapshot = baseText;
|
|
_mineTextSnapshot = localText;
|
|
_remoteTextSnapshot = remoteText;
|
|
_appliedBaseNumbers.Clear();
|
|
_appliedMineNumbers.Clear();
|
|
|
|
RefreshLanguage();
|
|
var rows = BuildThreeWayLines(baseText, localText, remoteText);
|
|
Lines.ReplaceAll(rows); // 알림 한 번(MergeLineCollection 참고)
|
|
|
|
BaseHeaderLead = string.Empty;
|
|
LocalHeaderLead = string.Empty;
|
|
BaseHeader = string.IsNullOrEmpty(baseText)
|
|
? "(BASE 없음) 양쪽에서 새로 만든 파일입니다"
|
|
: Path.GetFileName(request.Base);
|
|
// 네 칸이 모두 다른 파일이다(--base · --local · --remote · --merged). 칸마다 제 파일 이름(11차 F-1).
|
|
// 예전에는 Mine 칸도 LocalHeader(저장할 파일 이름)를 같이 써서 실제로 읽은 --local 과 달랐다.
|
|
MineHeader = Path.GetFileName(request.Local);
|
|
LocalHeader = PaneHeaders.Saved(null, Path.GetFileName(request.Merged));
|
|
RemoteHeader = Path.GetFileName(request.Remote);
|
|
_baseSourceName = Path.GetFileName(request.Base);
|
|
_theirsSideName = Path.GetFileName(request.Remote);
|
|
_mineSideName = Path.GetFileName(request.Local);
|
|
|
|
_baseline.Open(BuildLocalText());
|
|
SyncEditorFromLines();
|
|
|
|
var info = TextFile.Describe(File.Exists(request.Local) ? request.Local : request.Remote);
|
|
FileEncoding = info.Encoding;
|
|
FileNewLine = info.NewLine;
|
|
|
|
ClearHistory();
|
|
MarkStyleChange(rawLocalText, localText);
|
|
CaretRow = 0;
|
|
InvalidateDiffBlocks();
|
|
|
|
SelectedLine = Lines.FirstOrDefault(l => l.NeedsDecision)
|
|
?? Lines.FirstOrDefault(l => l.IsDifferent)
|
|
?? Lines.FirstOrDefault();
|
|
|
|
UpdateSummaries();
|
|
RefreshDiffPosition();
|
|
RaiseCanExecuteChanged();
|
|
|
|
_workspace.AppendLog($"mergetool 네 칸: 남은 충돌 {UnresolvedConflictCount}곳 · 결과는 {request.Merged}");
|
|
});
|
|
}
|
|
|
|
/// <summary>없는 파일은 빈 글로 본다. git mergetool 은 조상이 없으면 BASE 에 빈 파일이나 없는 경로를 준다.</summary>
|
|
private static string ReadIfExists(string path) =>
|
|
!string.IsNullOrWhiteSpace(path) && File.Exists(path) ? TextFile.Read(path) : string.Empty;
|
|
|
|
/// <summary>
|
|
/// 파일 비교에서 쓰는 저장. mergetool 의 MERGED 파일이면 <b>형식(인코딩 · 줄끝)을 LOCAL 파일에서</b> 읽는다.
|
|
/// MERGED 는 git 이 충돌 표시를 넣어 둔 파일이라 거기서 읽으면 줄끝이 섞여 보인다(6차 A-1 과 같은 까닭).
|
|
/// </summary>
|
|
private void WritePlainFile(string target, string content, string? encoding, string? newLine)
|
|
{
|
|
_workspace.Backup.Keep(target); // 11차 F-9 - 못 만들면 던져 쓰지 않는다
|
|
if (_mergeTool is { } tool && File.Exists(tool.Local) &&
|
|
string.Equals(Path.GetFullPath(target), Path.GetFullPath(tool.Merged), StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
File.WriteAllBytes(target, TextFile.Encode(content, encoding, newLine, File.ReadAllBytes(tool.Local)));
|
|
return;
|
|
}
|
|
|
|
TextFile.Write(target, content, encoding, newLine);
|
|
}
|
|
|
|
/// <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 MergeLineCollection Lines { get; } = new MergeLineCollection();
|
|
|
|
/// <summary>비교 대상 파일 목록(충돌 중이면 충돌 파일, 아니면 선택 커밋의 파일).</summary>
|
|
public ObservableCollection<string> Files { get; } = new ObservableCollection<string>();
|
|
|
|
// ================= 오른쪽 칸 접고 펴기 =================
|
|
|
|
/// <summary>
|
|
/// 저장 형식 · 비교 규칙 · 편집 잠금을 펼쳐 두었는지.
|
|
///
|
|
/// <b>왜 접나</b> - 오른쪽 칸에는 <b>매번 쓰는 것</b>(반영 · 되돌리기 · 차이 이동 · 저장)과
|
|
/// <b>한 번 정하면 그대로인 것</b>(저장 형식 · 비교 규칙 · 편집 잠금)이 섞여 있었다.
|
|
/// 뒤엣것을 접으면 그만큼 코드 폭이 넓어진다. 코드를 나란히 읽는 도구에서 가로 한 칸은
|
|
/// 그대로 읽을 수 있는 글자 수다.
|
|
/// </summary>
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(RulesToggleLabel))]
|
|
private bool _isRulesOpen;
|
|
|
|
/// <summary>
|
|
/// 단추 글자. 무엇이 숨어 있고 누르면 무슨 일이 나는지 함께 적는다 -
|
|
/// 화살표만으로는 "여기 접힌 것이 있다"가 전해지지 않는다(C-3 · C-4 에서 두 번 겪었다).
|
|
/// </summary>
|
|
public string RulesToggleLabel =>
|
|
IsRulesOpen ? "▾ 저장 형식 · 비교 규칙 접기" : "▸ 저장 형식 · 비교 규칙 펴기";
|
|
|
|
[RelayCommand]
|
|
private void ToggleRules()
|
|
{
|
|
IsRulesOpen = !IsRulesOpen;
|
|
|
|
_workspace.Settings.ActionsRulesExpanded = IsRulesOpen;
|
|
_workspace.SaveSettings();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 오른쪽 ACTIONS 칸을 감췄는지(11차 F-4). 감추면 코드 칸이 그 폭만큼 넓어진다 - 폭은 화면이 다룬다
|
|
/// (<c>HomeView.ApplyActionsHidden</c>). 감춰도 단축키(저장 · 반영 · 차이 이동)는 그대로다.
|
|
/// </summary>
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(ActionsToggleLabel))]
|
|
private bool _isActionsHidden;
|
|
|
|
/// <summary>단추 글자 - 누르면 무엇이 되는지와 키를 함께(RulesToggleLabel 과 같은 까닭).</summary>
|
|
public string ActionsToggleLabel => IsActionsHidden ? "◀ ACTIONS 보이기 (F9)" : "ACTIONS 감추기 ▶ (F9)";
|
|
|
|
/// <summary>
|
|
/// 코드 칸 글자 크기(11차 F-5, 8~24). 화면이 줄 높이 · 여백을 함께 맞춘다(<see cref="CodeFontMetrics"/>).
|
|
/// 바꿀 때마다 설정에 남긴다 - 한 값이 모든 모니터에 쓰인다.
|
|
/// </summary>
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(CodeFontLabel))]
|
|
private double _codeFontSize = CodeFontMetrics.Default;
|
|
|
|
/// <summary>상태 줄에 보일 글. 기본이면 비운다 - 늘 보이면 자리만 먹는다.</summary>
|
|
public string CodeFontLabel => CodeFontSize == CodeFontMetrics.Default ? string.Empty : $"글자 {CodeFontSize:0}";
|
|
|
|
partial void OnCodeFontSizeChanged(double value)
|
|
{
|
|
var size = (int)CodeFontMetrics.Clamp(value);
|
|
if (_workspace.Settings.CodeFontSize == size) return;
|
|
_workspace.Settings.CodeFontSize = size;
|
|
_workspace.SaveSettings();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void FontBigger() => CodeFontSize = CodeFontMetrics.Step(CodeFontSize, +1);
|
|
|
|
[RelayCommand]
|
|
private void FontSmaller() => CodeFontSize = CodeFontMetrics.Step(CodeFontSize, -1);
|
|
|
|
[RelayCommand]
|
|
private void FontReset() => CodeFontSize = CodeFontMetrics.Default;
|
|
|
|
/// <summary>설정 "저장할 때 백업" 이 켜져 있는지(11차 F-9). 켜져 있으면 저장 단추 아래 [백업 폴더] 가 보인다.</summary>
|
|
public bool IsBackupOn => _workspace.Settings.BackupBeforeSave;
|
|
|
|
/// <summary>백업을 모으는 폴더를 탐색기로 연다(11차 F-9). 아직 없으면 만든다.</summary>
|
|
[RelayCommand]
|
|
private void OpenBackupFolder()
|
|
{
|
|
try
|
|
{
|
|
Directory.CreateDirectory(_workspace.Backup.Root);
|
|
System.Diagnostics.Process.Start("explorer.exe", "\"" + _workspace.Backup.Root + "\"");
|
|
}
|
|
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is System.ComponentModel.Win32Exception)
|
|
{
|
|
MessageUtil.ShowErrorToast($"백업 폴더를 열지 못했습니다: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ToggleActions()
|
|
{
|
|
IsActionsHidden = !IsActionsHidden;
|
|
|
|
_workspace.Settings.ActionsHidden = IsActionsHidden;
|
|
_workspace.SaveSettings();
|
|
}
|
|
|
|
// ================= 폴더에서 훑는 중인 파일 이동 =================
|
|
|
|
/// <summary>훑을 목록이 있는지. 폴더를 거치지 않고 연 파일에는 없다.</summary>
|
|
public bool HasFileQueue => _workspace.Queue.Count > 0 && _workspace.QueueIndex >= 0;
|
|
|
|
/// <summary>지금 몇 번째인지. 예: <c>3 / 27</c>.</summary>
|
|
public string FileQueuePosition =>
|
|
HasFileQueue ? $"{_workspace.QueueIndex + 1} / {_workspace.Queue.Count}" : string.Empty;
|
|
|
|
private bool CanPrevFile() => HasFileQueue && _workspace.QueueIndex > 0;
|
|
private bool CanNextFile() => HasFileQueue && _workspace.QueueIndex < _workspace.Queue.Count - 1;
|
|
|
|
[RelayCommand(CanExecute = nameof(CanPrevFile))]
|
|
private void PrevFile() => MoveFile(-1);
|
|
|
|
[RelayCommand(CanExecute = nameof(CanNextFile))]
|
|
private void NextFile() => MoveFile(1);
|
|
|
|
/// <summary>
|
|
/// 폴더 비교 목록에서 앞뒤 파일로 옮긴다.
|
|
///
|
|
/// 여기서 <b>먼저 묻고</b> 그 다음에 자리를 옮긴다. 확인 창에서 취소하면 자리도 그대로여야 하는데,
|
|
/// 자리를 먼저 옮기면 취소했을 때 목록 위치만 앞서 나간다.
|
|
/// 물은 뒤에는 <c>_suspendGuard</c> 로 막는다 - 여는 길(<c>RequestPlainCompare</c>)이
|
|
/// 같은 것을 한 번 더 묻기 때문이다.
|
|
/// </summary>
|
|
private void MoveFile(int delta)
|
|
{
|
|
var next = _workspace.QueueIndex + delta;
|
|
if (next < 0 || next >= _workspace.Queue.Count) return;
|
|
|
|
var pair = _workspace.Queue[next];
|
|
|
|
// 12차 E-2: 목록을 훑는 것은 <b>지금 탭의 짝을 바꾸는 것</b>이다(탭이 늘지 않는다, 사용자 결정).
|
|
// 탭이 맡아 주므로 여기서도 묻지 않는다 - 맡을 수 없는 자리(Git · 네 칸)에서만 묻는다.
|
|
if (!TabsCanHoldEdits && !ConfirmDiscard($"다른 파일을 엽니다: {pair.Name}")) return;
|
|
|
|
_workspace.QueueIndex = next;
|
|
|
|
_suspendGuard = true;
|
|
_replaceTab = true;
|
|
try { _workspace.RequestPlainCompare(pair.Left, pair.Right); }
|
|
finally { _suspendGuard = false; _replaceTab = false; }
|
|
|
|
RefreshQueue();
|
|
}
|
|
|
|
private void RefreshQueue()
|
|
{
|
|
OnPropertyChanged(nameof(HasFileQueue));
|
|
OnPropertyChanged(nameof(FileQueuePosition));
|
|
PrevFileCommand.NotifyCanExecuteChanged();
|
|
NextFileCommand.NotifyCanExecuteChanged();
|
|
}
|
|
|
|
// ================= 최근 비교 목록 =================
|
|
|
|
/// <summary>최근에 견준 파일 짝. 위 줄의 목록에 뿌린다.</summary>
|
|
public ObservableCollection<RecentPair> RecentFiles { get; } = new ObservableCollection<RecentPair>();
|
|
|
|
private void RefreshRecent()
|
|
{
|
|
RecentFiles.Clear();
|
|
foreach (var pair in _workspace.Recent.Files) RecentFiles.Add(pair);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 최근 목록에서 고른 짝을 연다.
|
|
///
|
|
/// 자리를 옮겼거나 지워진 짝이라도 <b>목록에서 지우지 않는다</b> - 네트워크 드라이브나 USB 는
|
|
/// 잠깐 안 보일 뿐이다. 대신 왜 못 여는지 알려 준다.
|
|
/// 여는 길은 다른 데와 같다(<c>RequestPlainCompare</c>) - 저장하지 않은 편집도 그 길에서 함께 걸린다.
|
|
/// </summary>
|
|
public void OpenRecent(RecentPair? pair)
|
|
{
|
|
if (pair is null) return;
|
|
|
|
if (!File.Exists(pair.Left) && !File.Exists(pair.Right))
|
|
{
|
|
MessageUtil.ShowWarningToast("두 파일 모두 찾을 수 없습니다. 자리를 옮겼거나 지워진 것 같습니다.");
|
|
return;
|
|
}
|
|
|
|
// 최근 목록으로 연 파일은 딛고 갈 목록이 없다. 남아 있던 목록을 그대로 두면
|
|
// 다음 파일이 엉뚱한 데로 간다.
|
|
_workspace.ClearQueue();
|
|
_workspace.RequestPlainCompare(pair.Left, pair.Right);
|
|
}
|
|
|
|
[ObservableProperty] private string? _selectedFile;
|
|
[ObservableProperty] private MergeLine? _selectedLine;
|
|
[ObservableProperty] private string _baseHeader = "BASE";
|
|
[ObservableProperty] private string _localHeader = "LOCAL";
|
|
[ObservableProperty] private string _diffSummary = "-";
|
|
[ObservableProperty] private string _editSummary = "-";
|
|
[ObservableProperty] private string _commitMessage = string.Empty;
|
|
|
|
/// <summary>
|
|
/// 프로그램이 채워 넣은 커밋 메시지(6차 B-3). 지금 칸의 글이 이것과 같으면 사람이 손대지 않은 것이라
|
|
/// 바꾸거나 비워도 된다. 다르면 사람이 적은 글이므로 건드리지 않는다.
|
|
/// </summary>
|
|
private string _autoCommitMessage = string.Empty;
|
|
[ObservableProperty] private bool _hasChanges;
|
|
|
|
/// <summary>커밋 트리 ListBox 가 바인딩하는 선택 항목(그래프 포함).</summary>
|
|
[ObservableProperty] private CommitNode? _selectedNode;
|
|
|
|
/// <summary>미니맵은 컬렉션이 그대로여도 줄 내용이 바뀌면 다시 그려야 한다.</summary>
|
|
[ObservableProperty] private int _diffVersion;
|
|
|
|
/// <summary>머지한 줄에 붙일 사용자 설명. 비워도 작성자·날짜 정보는 항상 들어간다.</summary>
|
|
[ObservableProperty] private string _mergeComment = string.Empty;
|
|
|
|
/// <summary>확인 창에서 [취소] 를 눌렀을 때 돌아갈 자리.</summary>
|
|
private string? _lastLoadedFile;
|
|
|
|
/// <summary>
|
|
/// 지금 화면에 <b>실제로 실려 있는</b> 파일. 고른 파일(<see cref="SelectedFile"/>)과 다를 수 있다 -
|
|
/// 읽기가 늦게 끝나거나 여는 도중 선택이 바뀌면 그렇다. 저장은 이 이름과 맞을 때만 한다(8차 A-1).
|
|
/// </summary>
|
|
private string? _shownFile;
|
|
|
|
/// <summary>버린 읽기를 다시 하는 중. 되풀이를 막는다.</summary>
|
|
private bool _reloadingStale;
|
|
|
|
/// <summary>
|
|
/// 늦게 끝난 읽기를 버렸을 때, 화면에 실린 파일이 <b>지금 고른 파일과 다르면</b> 한 번 더 읽는다(8차 A-1).
|
|
///
|
|
/// 버리기만 하면 화면이 빈 채로 남는다 - 충돌 목록에서 [3분할로 열기] 로 파일 비교 탭을 처음 열면
|
|
/// 목록 세우기와 겹쳐 읽기가 버려지고, 아무것도 안 보였다.
|
|
/// </summary>
|
|
private void ReloadWhenShownIsStale()
|
|
{
|
|
if (_reloadingStale) return;
|
|
if (string.Equals(_shownFile, SelectedFile, StringComparison.Ordinal)) return;
|
|
|
|
_reloadingStale = true;
|
|
_ = ReloadShownAsync();
|
|
}
|
|
|
|
private async Task ReloadShownAsync()
|
|
{
|
|
try { await LoadFileAsync(); }
|
|
finally { _reloadingStale = false; }
|
|
}
|
|
|
|
/// <summary>되돌리는 중. 그 사이에는 다시 묻지도, 다시 읽지도 않는다.</summary>
|
|
private bool _revertingFile;
|
|
|
|
partial void OnSelectedFileChanged(string? value)
|
|
{
|
|
if (_revertingFile)
|
|
{
|
|
// 목록 선택만 제자리로 돌린 것이다. 화면 내용은 그대로 두면 된다.
|
|
RaiseSelectedFileNotices();
|
|
return;
|
|
}
|
|
|
|
if (!ConfirmDiscard($"다른 파일을 엽니다: {value}"))
|
|
{
|
|
// 되돌리기는 <b>콤보의 선택 처리가 끝난 다음 차례</b>에 한다(6차 B-2).
|
|
// 이 자리는 콤보가 고르는 도중이라, 여기서 바로 값을 되돌리면 콤보가 그 알림을 흘려 버린다.
|
|
// [취소] 를 눌렀는데 콤보는 새 파일(Io/Unit20.cs)을, 화면은 고치던 파일(Io/Unit12.cs)을 보이며 서로 달랐다.
|
|
var back = _lastLoadedFile;
|
|
Dispatcher.CurrentDispatcher.BeginInvoke(new Action(() =>
|
|
{
|
|
_revertingFile = true;
|
|
try { SelectedFile = back; }
|
|
finally { _revertingFile = false; }
|
|
}), DispatcherPriority.Background);
|
|
return;
|
|
}
|
|
|
|
_lastLoadedFile = value;
|
|
RaiseSelectedFileNotices();
|
|
_ = LoadFileAsync();
|
|
}
|
|
|
|
private void RaiseSelectedFileNotices()
|
|
{
|
|
OnPropertyChanged(nameof(SelectedFilePath));
|
|
OnPropertyChanged(nameof(SelectedFileFullPath));
|
|
OnPropertyChanged(nameof(BaseFileLocation));
|
|
OnPropertyChanged(nameof(MergeNotePreview));
|
|
}
|
|
|
|
partial void OnMergeCommentChanged(string value) => OnPropertyChanged(nameof(MergeNotePreview));
|
|
|
|
// ---- 파일 경로 ----
|
|
|
|
/// <summary>저장소 기준 상대 경로.</summary>
|
|
public string SelectedFilePath => string.IsNullOrEmpty(SelectedFile) ? "-" : SelectedFile!;
|
|
|
|
/// <summary>디스크상의 전체 경로. 툴팁·복사용.</summary>
|
|
public string SelectedFileFullPath
|
|
{
|
|
get
|
|
{
|
|
// Git 이 아니면 고른 파일 경로를 그대로 보여 준다.
|
|
if (IsPlainMode) return string.IsNullOrWhiteSpace(RightFile) ? "-" : RightFile;
|
|
|
|
if (string.IsNullOrEmpty(SelectedFile) || string.IsNullOrEmpty(_workspace.RepositoryPath))
|
|
return "-";
|
|
|
|
return Path.Combine(_workspace.RepositoryPath,
|
|
SelectedFile!.Replace('/', Path.DirectorySeparatorChar));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// BASE 쪽 출처. 워킹트리 파일이 아니라 커밋(또는 병합 중이면 공통 조상)에서 읽은 내용이라
|
|
/// 디스크 경로가 아니라 "커밋:경로" 형태로 보여준다.
|
|
/// </summary>
|
|
public string BaseFileLocation
|
|
{
|
|
get
|
|
{
|
|
if (IsPlainMode) return string.IsNullOrWhiteSpace(LeftFile) ? "-" : LeftFile;
|
|
|
|
if (string.IsNullOrEmpty(SelectedFile)) return "-";
|
|
|
|
var origin = _workspace.IsMergeInProgress
|
|
? "공통 조상"
|
|
: SelectedCommit?.ShortSha ?? "HEAD";
|
|
|
|
return $"{origin} : {SelectedFile}";
|
|
}
|
|
}
|
|
|
|
/// <summary>실제로 줄 끝에 붙을 주석을 미리 보여준다. 작성자가 비면 반영이 막히므로(<see cref="AuthorReady"/>)
|
|
/// 대신 채워질 이름(윈도우 계정)을 보이지 않는다 - 그 이름으로는 반영되지 않는다.</summary>
|
|
public string MergeNotePreview =>
|
|
!_workspace.Settings.InsertMergeComment ? "(주석 남기기 꺼짐)"
|
|
: MergeNote.AuthorMissing(true, _workspace.Settings.MergeCommentAuthor) ? "(작성자가 비어 있어 반영할 수 없습니다 - 설정 > 머지 주석 > 작성자)"
|
|
: BuildMergeNote(PreviewSource);
|
|
|
|
partial void OnSelectedLineChanged(MergeLine? value) => RaiseCanExecuteChanged();
|
|
|
|
partial void OnCommitMessageChanged(string value) => RaiseCanExecuteChanged();
|
|
|
|
partial void OnHasChangesChanged(bool value)
|
|
{
|
|
// 저장했거나 버렸으면 "스타일 맞춤으로 바뀐 글" 도 끝난 것이다.
|
|
if (!value) _styleRawLocalText = null;
|
|
RefreshTabDirty(); // 12차 E-2 - 탭 이름 앞 가운뎃점이 따라간다
|
|
RaiseCanExecuteChanged();
|
|
}
|
|
|
|
/// <summary>새로고침으로 목록을 다시 만들 때 선택을 잇는 중이면 재진입을 막는다.</summary>
|
|
private bool _syncingSelection;
|
|
|
|
/// <summary>확인 창에서 [취소] 를 눌렀을 때 돌아갈 커밋.</summary>
|
|
private CommitNode? _lastLoadedNode;
|
|
private bool _revertingNode;
|
|
|
|
partial void OnSelectedNodeChanged(CommitNode? value)
|
|
{
|
|
if (_syncingSelection || _revertingNode) { _lastLoadedNode = value; return; }
|
|
|
|
// 목록을 다시 세우면 ListBox 가 선택을 <b>잠깐 놓친다</b> - 항목 객체가 통째로 새로 만들어지므로
|
|
// null 이 한 번 들어온다. 이건 사람이 고른 것이 아니다.
|
|
// 그런데도 물으면 <b>스테이징만 해도 "다른 커밋을 봅니다" 창이 뜬다</b>(5차 A-2 에서 실제로 그랬다).
|
|
//
|
|
// 같은 커밋으로 다시 들어오는 경우도 마찬가지다 - 새로고침이 제자리를 찾아 준 것이지
|
|
// 사람이 옮긴 것이 아니다.
|
|
var sameCommit = value?.Commit?.Sha is string sha && sha == SelectedCommit?.Sha;
|
|
|
|
if (value is not null && !sameCommit && !ConfirmDiscard("다른 커밋을 봅니다."))
|
|
{
|
|
var back = _lastLoadedNode;
|
|
_revertingNode = true;
|
|
try { SelectedNode = back; }
|
|
finally { _revertingNode = false; }
|
|
return;
|
|
}
|
|
|
|
_lastLoadedNode = value;
|
|
SelectedCommit = value?.Commit;
|
|
}
|
|
|
|
// ---- 선택 커밋 상세 ----
|
|
|
|
public CommitInfo? SelectedCommit
|
|
{
|
|
get => _workspace.SelectedCommit;
|
|
set
|
|
{
|
|
if (ReferenceEquals(_workspace.SelectedCommit, value)) return;
|
|
var chosen = CommitSelection.IsOtherCommit(_workspace.SelectedCommit?.Sha, value?.Sha);
|
|
_workspace.SelectedCommit = value;
|
|
OnPropertyChanged();
|
|
RaiseCommitDetail();
|
|
_ = LoadCommitAsync();
|
|
|
|
// Git 탭의 이력 선택 표시와 단추가 따라오게 알린다(10차 B-1).
|
|
if (chosen) _workspace.NotifyCommitChosen(this);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Git 탭에서 다른 커밋을 골랐다. 위 줄 Selected Commit · 왼쪽 이력 선택 · 두 칸 기준이 따라간다(10차 B-1).
|
|
/// 저장 안 한 편집은 Git 탭이 고르기 전에 <see cref="GitWorkspace.ConfirmLeaveCommit"/> 로 이미 물었다 - 여기서 또 묻지 않는다.
|
|
/// </summary>
|
|
private void OnCommitChosenElsewhere(object chooser)
|
|
{
|
|
if (ReferenceEquals(chooser, this)) return;
|
|
|
|
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();
|
|
|
|
// Git 탭이 방금 그 커밋의 내용(diff)을 그리느라 작업 게이트를 쥐고 있다. 바로 읽으면 건너뛰어져
|
|
// Selected Commit 만 바뀌고 BASE 칸은 옛 커밋으로 남았다 - 게이트가 빈 뒤에 세운다(새로고침 뒤와 같은 길).
|
|
_ = RebuildListAfterRefreshAsync();
|
|
}
|
|
|
|
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;
|
|
if (!AuthorReady()) return;
|
|
Apply(line);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 반영 명령 <b>맨 앞</b>에서 부른다 - 주석을 남기게 해 두었는데 작성자가 비었으면 알리고 멈춘다(사용자 결정 2026-09-30).
|
|
/// 줄마다(<see cref="Apply"/> 안)가 아니라 명령마다 거는 까닭: [모두 반영] 이 줄 수만큼 알리거나,
|
|
/// 구간 반영이 반쯤 된 채 멈추는 일이 없게.
|
|
/// </summary>
|
|
private bool AuthorReady()
|
|
{
|
|
if (!MergeNote.AuthorMissing(_workspace.Settings.InsertMergeComment, _workspace.Settings.MergeCommentAuthor)) return true;
|
|
MessageUtil.ShowInfo(MergeNote.AuthorMissingTitle, MergeNote.AuthorMissingMessage);
|
|
_workspace.AppendLog("반영하지 않음 - 작성자가 비어 있다(설정 > 머지 주석 > 작성자)");
|
|
return false;
|
|
}
|
|
|
|
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);
|
|
ClearRedo();
|
|
|
|
_workspace.AppendLog($"{line.LocalNumber ?? line.BaseNumber}행 되돌림");
|
|
AfterEdit();
|
|
}
|
|
|
|
private bool HasEditFor(MergeLine line) => _undo.Any(e => ReferenceEquals(e.Line, line));
|
|
|
|
/// <summary>왼쪽으로 반영할 때 주석 · 로그에 적을 이름. 두 파일 비교라 오른쪽 파일 이름이다.</summary>
|
|
private string? LocalSourceName =>
|
|
string.IsNullOrWhiteSpace(RightFile) ? null : Path.GetFileName(RightFile);
|
|
|
|
/// <summary>
|
|
/// 이 줄을 <b>왼쪽(BASE) 파일로</b> 반영한다(12차 B-1). 오른쪽으로 반영하는 <see cref="Apply"/> 의 거울이다.
|
|
///
|
|
/// 오른쪽에 있는 줄이면 그 글을 왼쪽에 쓰고(왼쪽에 없던 줄이면 새로 생긴다),
|
|
/// 오른쪽에 없는 줄이면 왼쪽에서도 지운다. 되돌리기는 오른쪽 반영과 한 스택에 쌓여 누른 차례대로 풀린다.
|
|
/// </summary>
|
|
/// <returns>글이 바뀌었는지.</returns>
|
|
private bool ApplyToBase(MergeLine line, bool requery = true, int group = 0)
|
|
{
|
|
var before = line.BaseText;
|
|
var beforeHasBase = line.HasBase;
|
|
|
|
string after;
|
|
bool afterHasBase;
|
|
string? note = null;
|
|
|
|
// LOCAL 에 없던 줄(BASE 에서 지우는 반영)에도 주석을 남긴다 - 오른쪽 반영(Apply)과 같은 규칙(사용자 2026-09-30).
|
|
(after, afterHasBase) = PaneText.Plan(line, MergeLine.TextPane.Base);
|
|
note = _workspace.Settings.InsertMergeComment && group == 0
|
|
? BuildMergeNote(MergeNote.WithDeletion(MergeNames.LocalSource(LocalSourceName), beforeHasBase && !afterHasBase ? 1 : 0))
|
|
: null;
|
|
|
|
if (beforeHasBase == afterHasBase && string.Equals(before, after, StringComparison.Ordinal))
|
|
return false;
|
|
|
|
var description = MergeNames.PushToBaseLog(
|
|
(line.HasLocal ? line.LocalNumber : line.BaseNumber)?.ToString(), LocalSourceName, line.HasLocal);
|
|
|
|
line.BaseText = after;
|
|
line.HasBase = afterHasBase;
|
|
|
|
_undo.Push(new LineEdit(line, before, beforeHasBase, after, afterHasBase,
|
|
EditKind.TakeBase, description)
|
|
{
|
|
Side = MergeLine.TextPane.Base,
|
|
InsertedNote = InsertBaseNoteAbove(line, note, indentFrom: before),
|
|
Group = group,
|
|
});
|
|
|
|
ClearRedo();
|
|
_workspace.AppendLog(description);
|
|
if (requery) AfterEdit();
|
|
return true;
|
|
}
|
|
|
|
private bool CanTakeLineToBase(MergeLine? line) => BothWays && line is { CanTakeBase: true };
|
|
|
|
/// <summary>
|
|
/// 캐럿이 있는 자리에서 <b>왼쪽으로 보낼 수 있는</b> 덩어리를 잡을 수 있는가(12차 G-2).
|
|
/// 2-Way 에서 <see cref="BlockAround"/> 는 <c>CanTakeBase</c> 로 덩어리를 잡으므로 그대로 쓴다 -
|
|
/// "가져올 수 있는 줄" 과 "보낼 수 있는 줄" 이 같은 줄이다. 방향만 반대다.
|
|
/// </summary>
|
|
private bool CanAcceptBlockToBase() => BothWays && BlockAround(CaretRow).Count > 0;
|
|
|
|
/// <summary>이 덩어리를 통째로 왼쪽 파일로 반영한다(12차 G-2). 한 줄짜리 <see cref="TakeLineToBase"/> 의 덩어리판.</summary>
|
|
[RelayCommand(CanExecute = nameof(CanAcceptBlockToBase))]
|
|
private void AcceptBlockToBase()
|
|
{
|
|
var block = BlockAround(CaretRow);
|
|
if (block.Count == 0) return;
|
|
if (!AuthorReady()) return;
|
|
|
|
var count = ApplyGroupToBase(block, "구간");
|
|
if (count > 0) MessageUtil.ShowSuccessToast($"{count}줄 왼쪽에 반영");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 여러 줄을 <b>한 묶음으로</b> 왼쪽 파일에 반영한다(12차 G-2).
|
|
/// 오른쪽으로 보내는 <see cref="ApplyGroup"/> 의 거울 - 주석은 덩어리 첫 줄 위 한 줄,
|
|
/// 되돌리기는 한 번에(11차 F-2 와 같은 규칙).
|
|
/// </summary>
|
|
private int ApplyGroupToBase(IReadOnlyList<MergeLine> rows, string what)
|
|
{
|
|
var group = ++_lastGroup;
|
|
var changed = 0;
|
|
var deleted = 0; // 주석에 "삭제 N줄" 로 한 번만 적는다(사용자 2026-09-30)
|
|
int? first = null, last = null;
|
|
|
|
foreach (var line in rows.ToList())
|
|
{
|
|
if (line.IsMergeNote) continue;
|
|
var number = line.HasLocal ? line.LocalNumber : line.BaseNumber;
|
|
var hadBase = line.HasBase;
|
|
// group 이 0 이 아니므로 ApplyToBase 는 줄마다 주석을 달지 않는다 - 아래에서 한 줄만 단다.
|
|
if (!line.CanTakeBase || !ApplyToBase(line, requery: false, group: group)) continue;
|
|
|
|
if (hadBase && !line.HasBase) deleted++;
|
|
changed++;
|
|
if (number is int n) { first ??= n; last = n; }
|
|
}
|
|
|
|
if (changed == 0)
|
|
{
|
|
AfterEdit();
|
|
return 0;
|
|
}
|
|
|
|
var where = first is null ? string.Empty : first == last ? $"{first}행 " : $"{first}~{last}행 ";
|
|
var description = $"{where}{what} 왼쪽에 반영 ({changed}줄)";
|
|
|
|
// 묶음이 모두 지우는 반영이면 글이 남은 줄이 없다 - 그래도 지운 자리(첫 줄)에 주석을 남긴다(사용자 2026-09-30).
|
|
var at = rows.FirstOrDefault(r => r.HasLocal && !r.IsMergeNote && Lines.Contains(r))
|
|
?? rows.FirstOrDefault(r => !r.IsMergeNote && Lines.Contains(r));
|
|
if (at is not null && _workspace.Settings.InsertMergeComment)
|
|
{
|
|
var note = BuildMergeNote(MergeNote.WithDeletion(MergeNames.LocalSource(LocalSourceName), deleted));
|
|
_undo.Push(new LineEdit(at, at.BaseText, at.HasBase, at.BaseText, at.HasBase,
|
|
EditKind.NoteOnly, description)
|
|
{
|
|
Side = MergeLine.TextPane.Base,
|
|
InsertedNote = InsertBaseNoteAbove(at, note),
|
|
Group = group,
|
|
});
|
|
}
|
|
|
|
ClearRedo();
|
|
_workspace.AppendLog(description);
|
|
AfterEdit();
|
|
return changed;
|
|
}
|
|
|
|
/// <summary>[<] - 이 줄을 왼쪽 파일로 반영한다(12차 B-1).</summary>
|
|
[RelayCommand(CanExecute = nameof(CanTakeLineToBase))]
|
|
private void TakeLineToBase(MergeLine? line)
|
|
{
|
|
if (line is null) return;
|
|
if (!AuthorReady()) return;
|
|
ApplyToBase(line);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 왼쪽으로 반영한 줄 <b>위에</b> 넣을 머지 주석 한 줄. 오른쪽 것(<see cref="InsertNoteAbove"/>)의 거울이라
|
|
/// <b>왼쪽에만 있는 줄</b>로 넣는다 - 오른쪽 파일에는 들어가지 않는다.
|
|
/// </summary>
|
|
private MergeLine? InsertBaseNoteAbove(MergeLine line, string? note, string? indentFrom = null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(note)) return null;
|
|
|
|
var at = Lines.IndexOf(line);
|
|
if (at < 0) return null;
|
|
|
|
// 지우는 반영이면 BASE 글이 이미 비었다 - 지우기 전 글이나 LOCAL 쪽 글에서 잰다.
|
|
var indent = IndentOf(line.BaseText, indentFrom, line.LocalText);
|
|
|
|
var row = new MergeLine(indent + note, null, null, null, Rules.IsPlain ? null : Rules.KeyOf)
|
|
{
|
|
IsMergeNote = true,
|
|
IsIgnored = Rules.IsFiltered(indent + note),
|
|
};
|
|
|
|
Lines.Insert(at, row);
|
|
NoteRowMoved(at, +1);
|
|
return row;
|
|
}
|
|
|
|
private bool CanUndo() => _undo.Count > 0 || _textUndo.Count > 0;
|
|
|
|
[RelayCommand(CanExecute = nameof(CanUndo))]
|
|
private void Undo()
|
|
{
|
|
if (_undo.Count == 0)
|
|
{
|
|
if (_textUndo.Count > 0) StepText(_textUndo, _textRedo);
|
|
return;
|
|
}
|
|
|
|
// 덩어리 · 메서드 반영은 여러 줄이 한 묶음이다 - 한 번에 되돌린다(11차 F-2).
|
|
var edit = _undo.Pop();
|
|
Restore(edit, toBefore: true);
|
|
_redo.Push(edit);
|
|
while (edit.Group != 0 && _undo.Count > 0 && _undo.Peek().Group == edit.Group)
|
|
{
|
|
var more = _undo.Pop();
|
|
Restore(more, toBefore: true);
|
|
_redo.Push(more);
|
|
}
|
|
|
|
AfterEdit();
|
|
}
|
|
|
|
private bool CanRedo() => _redo.Count > 0 || _textRedo.Count > 0;
|
|
|
|
[RelayCommand(CanExecute = nameof(CanRedo))]
|
|
private void Redo()
|
|
{
|
|
if (_redo.Count == 0)
|
|
{
|
|
if (_textRedo.Count > 0) StepText(_textRedo, _textUndo);
|
|
return;
|
|
}
|
|
|
|
var edit = _redo.Pop();
|
|
Restore(edit, toBefore: false);
|
|
_undo.Push(edit);
|
|
while (edit.Group != 0 && _redo.Count > 0 && _redo.Peek().Group == edit.Group)
|
|
{
|
|
var more = _redo.Pop();
|
|
Restore(more, toBefore: false);
|
|
_undo.Push(more);
|
|
}
|
|
|
|
AfterEdit();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 두 파일 비교에서만 켠다(2026-10-01 사용자 결정). 3-Way 에서 이 단추는 차이 나는 모든 줄을 <b>공통 조상(BASE)</b> 으로
|
|
/// 바꿨다 - 나와 저쪽의 변경을 함께 지우는 일이라 병합에서 쓸 데가 없고, 잘못 누르면 양쪽 작업이 다 사라진다.
|
|
/// "저쪽 전부" 로 바꾸면 충돌 줄까지 말없이 덮는다. 3-Way 는 [자동 병합](저쪽만 고친 줄)과 충돌별 반영을 쓴다.
|
|
/// </summary>
|
|
private bool CanAcceptAll() => !IsThreeWay && Lines.Any(l => l.CanTakeBase);
|
|
|
|
/// <summary>
|
|
/// 반영할 수 있는 줄을 모두 반영한다.
|
|
///
|
|
/// 두 파일 비교에서는 <b>구간마다 [구간 반영] 과 똑같이</b> 한다(2026-10-01 사용자 요청 "주석이 너무 많이 남는다").
|
|
/// 예전에는 줄마다 반영해 바뀐 줄마다 머지 주석이 한 줄씩 붙었다. 이제 이어진 차이 구간마다 주석 한 줄
|
|
/// (N~M행 구간 … 반영 (k줄))만 남는다. 한 번 누른 것이라 되돌리기도 전체가 한 번에 돌아간다.
|
|
/// 3-Way 에서는 끈다(<see cref="CanAcceptAll"/>).
|
|
/// </summary>
|
|
[RelayCommand(CanExecute = nameof(CanAcceptAll))]
|
|
private void AcceptAll()
|
|
{
|
|
if (IsThreeWay || !AuthorReady()) return;
|
|
|
|
// 구간을 먼저 다 나눠 둔다 - 반영하며 주석 줄이 끼면 행 번호가 밀리므로 줄 객체로 들고 간다.
|
|
var blocks = new List<List<MergeLine>>();
|
|
List<MergeLine>? current = null;
|
|
foreach (var line in Lines)
|
|
{
|
|
if (!line.CanTakeBase) { current = null; continue; }
|
|
if (current is null) blocks.Add(current = new List<MergeLine>());
|
|
current.Add(line);
|
|
}
|
|
if (blocks.Count == 0) return;
|
|
|
|
var group = ++_lastGroup;
|
|
var changed = 0;
|
|
var applied = 0;
|
|
foreach (var block in blocks)
|
|
{
|
|
var count = ApplyGroup(block, anchor: null, what: "구간", group: group, finish: false);
|
|
changed += count;
|
|
if (count > 0) applied++;
|
|
}
|
|
|
|
AfterEdit();
|
|
if (changed > 0) MessageUtil.ShowSuccessToast($"{applied}구간 {changed}줄 반영");
|
|
}
|
|
|
|
/// <summary>저장할 것이 있거나, 저장은 했지만 연 시점과 달라졌다(11차 F-8 - 저장해도 연 시점으로 돌아갈 수 있다).</summary>
|
|
private bool CanRevertAll() =>
|
|
HasChanges || !string.Equals(_baseline.Saved, _baseline.Opened, StringComparison.Ordinal);
|
|
|
|
/// <summary>
|
|
/// 파일을 연 시점의 LOCAL 로 통째로 되돌린다.
|
|
///
|
|
/// 되돌리기(Undo) 목록까지 비우므로 <b>되살릴 방법이 없다.</b> 그래서 한 번 묻는다 -
|
|
/// 이름이 분명한 단추라 해도 잘못 누르면 그때까지 고친 것이 통째로 사라진다.
|
|
/// </summary>
|
|
[RelayCommand(CanExecute = nameof(CanRevertAll))]
|
|
private void RevertAll()
|
|
{
|
|
// 연 뒤 저장했으면 파일은 이미 바뀌어 있다 - 화면만 연 시점으로 가고, 파일에는 다시 저장해야 들어간다(11차 F-8).
|
|
var saved = !string.Equals(_baseline.Saved, _baseline.Opened, StringComparison.Ordinal);
|
|
if (!MessageUtil.ShowYesNo("전체 되돌리기",
|
|
saved
|
|
? "고친 내용을 모두 버리고 파일을 연 시점으로 돌아갑니다.\n\n" +
|
|
"이미 저장한 파일 · 커밋은 그대로입니다 - 화면만 돌아가고, 파일에 넣으려면 다시 저장하세요.\n" +
|
|
"되돌리기 목록은 비워집니다. 계속할까요?"
|
|
: "고친 내용을 모두 버리고 파일을 연 시점으로 돌아갑니다.\n\n되살릴 수 없습니다. 계속할까요?"))
|
|
return;
|
|
|
|
ReloadLinesFrom(_originalLocalText, keepAsEdited: false);
|
|
}
|
|
|
|
private bool CanReload() => _workspace.IsRepositoryOpen;
|
|
|
|
[RelayCommand(CanExecute = nameof(CanReload))]
|
|
private Task ReloadAsync() => _workspace.RefreshAsync();
|
|
|
|
private bool CanSaveAndCommit() => HasChanges && !string.IsNullOrWhiteSpace(CommitMessage);
|
|
|
|
/// <summary>LOCAL 패널 내용을 워킹트리 파일에 쓰고 스테이징 후 커밋한다.</summary>
|
|
[RelayCommand(CanExecute = nameof(CanSaveAndCommit))]
|
|
private async Task SaveAndCommitAsync()
|
|
{
|
|
var path = SelectedFile;
|
|
if (string.IsNullOrEmpty(path)) return;
|
|
|
|
// 화면에 실린 파일과 저장할 파일이 다르면 <b>아무것도 쓰지 않는다</b>(8차 A-1).
|
|
var mismatch = MergeSavePlan.ShownFileMismatch(_shownFile, path);
|
|
if (mismatch is not null)
|
|
{
|
|
MessageUtil.ShowWarningToast(mismatch);
|
|
return;
|
|
}
|
|
|
|
var content = BuildLocalText();
|
|
var message = CommitMessage;
|
|
var encoding = SaveEncoding;
|
|
var newLine = SaveNewLine;
|
|
string? sha = null;
|
|
string? blocked = null;
|
|
|
|
var ok = await _workspace.RunAsync("Save & Commit", () =>
|
|
{
|
|
// 병합 중에 다른 충돌이 남아 있으면 <b>아무것도 쓰기 전에</b> 멈춘다(6차 A-2).
|
|
// 예전에는 파일을 쓰고 스테이징한 뒤에 커밋에서 막혔다. 화면은 "실패" 라는데
|
|
// 충돌은 이미 하나 줄어 있었고, 편집기는 여전히 저장 안 한 편집으로 알았다.
|
|
if (_workspace.Git.IsMergeInProgress)
|
|
{
|
|
blocked = MergeSavePlan.CommitBlocker(_workspace.Git.GetConflicts().Select(c => c.Path), path);
|
|
if (blocked is not null) return;
|
|
}
|
|
|
|
var isConflict = _workspace.Git.GetConflicts().Any(c => c.Path == path);
|
|
if (isConflict)
|
|
{
|
|
// 충돌 해결 경로 - 인덱스의 충돌까지 함께 해제된다.
|
|
_workspace.Git.ResolveConflictWithContent(path!, content, encoding, newLine);
|
|
_workspace.Git.RecordResolution(path!, MergeResolutionKind.Lines); // 병합 커밋 본문에 남긴다(8차 F-1)
|
|
}
|
|
else
|
|
{
|
|
var full = Path.Combine(_workspace.RepositoryPath,
|
|
path!.Replace('/', Path.DirectorySeparatorChar));
|
|
|
|
// 저장 형식을 고른 값으로 쓴다("원본 유지"면 지금까지와 같다).
|
|
_workspace.Backup.Keep(full); // 11차 F-9
|
|
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;
|
|
|
|
if (blocked is not null)
|
|
{
|
|
_workspace.OnUi(() => MessageUtil.ShowWarningToast(blocked));
|
|
return;
|
|
}
|
|
|
|
_workspace.OnUi(() =>
|
|
{
|
|
MarkSaved(content);
|
|
CommitMessage = string.Empty;
|
|
_autoCommitMessage = string.Empty;
|
|
MessageUtil.ShowSuccessToast(
|
|
sha is null ? "커밋 완료" : $"커밋 완료 ({sha.Substring(0, 7)})");
|
|
});
|
|
|
|
await _workspace.RefreshAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 지금 열린 파일이 <b>git 충돌로</b> 열렸는지. 해결로 저장을 켤지 가를 때 쓴다.
|
|
/// </summary>
|
|
private bool _loadedAsConflict;
|
|
|
|
/// <summary>
|
|
/// 고친 글이 있으면 저장할 수 있다. 고친 글이 없어도 <b>충돌로 열린 파일의 충돌을 다 정했으면</b> 저장할 수 있다.
|
|
///
|
|
/// 뒤쪽이 필요한 까닭 - Take Mine 은 글을 바꾸지 않는다(이미 내 것이다). 그래서 충돌을 전부 내 것으로
|
|
/// 정하면 "고친 것 없음" 이 되어 저장 단추가 꺼지고, git 은 계속 충돌로 본다. 풀 길이 없었다(6차 A-2).
|
|
/// 3분할이 아닐 때는 충돌 수를 셀 수 없으므로 켜지 않는다 - 정하지도 않은 파일을 내 것으로 덮으면 안 된다.
|
|
/// </summary>
|
|
private bool CanSaveResolved() =>
|
|
IsGitMode && _workspace.IsMergeInProgress && !string.IsNullOrEmpty(SelectedFile) &&
|
|
(HasChanges || (_loadedAsConflict && IsThreeWay && UnresolvedConflictCount == 0));
|
|
|
|
/// <summary>
|
|
/// 병합 중에 <b>이 파일만</b> 풀어서 저장한다. 쓰고 스테이징까지 하고 커밋은 하지 않는다(6차 A-2).
|
|
///
|
|
/// 예전에는 Git 모드의 저장이 Save & Commit 하나뿐이었다. 충돌이 스물두 개면 스물두 개를
|
|
/// 한 번에 다 풀어야만 저장할 수 있었고, 도중에 누르면 반쯤 해 놓고 "실패" 라고 했다.
|
|
/// 커밋은 충돌을 다 푼 뒤에 Git 탭의 [병합 커밋] 이나 Save & Commit 으로 따로 한다.
|
|
/// </summary>
|
|
[RelayCommand(CanExecute = nameof(CanSaveResolved))]
|
|
private async Task SaveResolvedAsync()
|
|
{
|
|
var path = SelectedFile;
|
|
if (string.IsNullOrEmpty(path)) return;
|
|
|
|
// 화면에 실린 파일과 저장할 파일이 다르면 <b>아무것도 쓰지 않는다</b>(8차 A-1).
|
|
var mismatch = MergeSavePlan.ShownFileMismatch(_shownFile, path);
|
|
if (mismatch is not null)
|
|
{
|
|
MessageUtil.ShowWarningToast(mismatch);
|
|
return;
|
|
}
|
|
|
|
var content = BuildLocalText();
|
|
var encoding = SaveEncoding;
|
|
var newLine = SaveNewLine;
|
|
var remaining = 0;
|
|
|
|
var ok = await _workspace.RunAsync("Save", () =>
|
|
{
|
|
var isConflict = _workspace.Git.GetConflicts().Any(c => c.Path == path);
|
|
if (isConflict)
|
|
{
|
|
_workspace.Git.ResolveConflictWithContent(path!, content, encoding, newLine);
|
|
_workspace.Git.RecordResolution(path!, MergeResolutionKind.Lines); // 병합 커밋 본문에 남긴다(8차 F-1)
|
|
}
|
|
else
|
|
{
|
|
// 충돌은 아니지만 병합 중에 손본 파일 - 저장 형식을 따라 쓰고 스테이징한다.
|
|
var full = Path.Combine(_workspace.RepositoryPath,
|
|
path!.Replace('/', Path.DirectorySeparatorChar));
|
|
_workspace.Backup.Keep(full); // 11차 F-9
|
|
TextFile.Write(full, content, encoding, newLine);
|
|
_workspace.Git.Stage(path!);
|
|
}
|
|
|
|
remaining = MergeSavePlan.RemainingAfterSave(_workspace.Git.GetConflicts().Select(c => c.Path), path);
|
|
});
|
|
|
|
if (!ok) return;
|
|
|
|
_workspace.OnUi(() =>
|
|
{
|
|
// 저장한 내용이 이제 "원본" 이다. 이걸 안 하면 저장한 뒤에도 편집이 남은 것으로 보여
|
|
// 다른 파일을 고를 때마다 확인 창이 다시 뜬다(5차 C-1 에서 겪었다).
|
|
_baseline.Open(content);
|
|
_styleRawLocalText = null;
|
|
ClearHistory();
|
|
HasChanges = false;
|
|
RaiseCanExecuteChanged();
|
|
|
|
MessageUtil.ShowSuccessToast(remaining == 0
|
|
? $"Save · {Path.GetFileName(path)} · 남은 충돌 없음 - 이제 커밋할 수 있습니다"
|
|
: $"Save · {Path.GetFileName(path)} · 남은 충돌 {remaining}건");
|
|
});
|
|
|
|
// 새로고침 직후엔 게이트가 잠깐 막혀 목록 세우기가 건너뛰어진다. 기다렸다 다시 세운다.
|
|
await ReloadAfterResolveAsync();
|
|
}
|
|
|
|
// ================= 한쪽이 지운 충돌 (6차 A-4) =================
|
|
|
|
private DeletionConflict _deletion;
|
|
|
|
/// <summary>지금 열린 파일이 한쪽이 지운 충돌인지. 화면 위에 "남길까 지울까" 띠를 띄운다.</summary>
|
|
[ObservableProperty] private bool _isDeletionConflict;
|
|
|
|
[ObservableProperty] private string _deletionConflictText = string.Empty;
|
|
|
|
private void SetDeletion(DeletionConflict deletion, string? theirsName)
|
|
{
|
|
_deletion = deletion;
|
|
IsDeletionConflict = deletion.IsDeletion;
|
|
DeletionConflictText = deletion.IsDeletion
|
|
? deletion.Message(_workspace.CurrentBranch, theirsName ?? "상대")
|
|
: string.Empty;
|
|
KeepFileCommand.NotifyCanExecuteChanged();
|
|
DeleteFileCommand.NotifyCanExecuteChanged();
|
|
}
|
|
|
|
private bool CanDecideDeletion() =>
|
|
IsGitMode && IsDeletionConflict && !string.IsNullOrEmpty(SelectedFile);
|
|
|
|
[RelayCommand(CanExecute = nameof(CanDecideDeletion))]
|
|
private Task KeepFileAsync() => DecideDeletionAsync(keep: true);
|
|
|
|
[RelayCommand(CanExecute = nameof(CanDecideDeletion))]
|
|
private Task DeleteFileAsync() => DecideDeletionAsync(keep: false);
|
|
|
|
/// <summary>
|
|
/// 한쪽이 지운 충돌을 파일 단위로 푼다. 남기기는 파일이 있는 쪽, 지우기는 없는 쪽을 고르는 것이다
|
|
/// (<see cref="DeletionConflict"/>). 푼 뒤에는 해결로 저장과 같이 남은 충돌의 첫 파일로 간다.
|
|
/// </summary>
|
|
private async Task DecideDeletionAsync(bool keep)
|
|
{
|
|
var path = SelectedFile;
|
|
if (string.IsNullOrEmpty(path)) return;
|
|
|
|
// 화면에 실린 파일과 저장할 파일이 다르면 <b>아무것도 쓰지 않는다</b>(8차 A-1).
|
|
var mismatch = MergeSavePlan.ShownFileMismatch(_shownFile, path);
|
|
if (mismatch is not null)
|
|
{
|
|
MessageUtil.ShowWarningToast(mismatch);
|
|
return;
|
|
}
|
|
|
|
// 남기되 고친 것이 있으면 고친 대로 남긴다 - 해결로 저장과 같은 일이다.
|
|
if (keep && HasChanges)
|
|
{
|
|
await SaveResolvedAsync();
|
|
return;
|
|
}
|
|
|
|
// 고친 것이 있는데 지우면 고친 것도 사라진다. 한 번 묻는다.
|
|
if (!keep && HasChanges && !ConfirmDiscard($"이 파일을 지웁니다: {path}")) return;
|
|
|
|
var resolution = keep ? _deletion.KeepWith : _deletion.DeleteWith;
|
|
var remaining = 0;
|
|
|
|
var ok = await _workspace.RunAsync(keep ? "파일 남기기" : "파일 지우기", () =>
|
|
{
|
|
_workspace.Git.ResolveConflict(path!, resolution);
|
|
_workspace.Git.RecordResolution(path!, keep ? MergeResolutionKind.Keep : MergeResolutionKind.Delete); // 8차 F-1
|
|
remaining = MergeSavePlan.RemainingAfterSave(_workspace.Git.GetConflicts().Select(c => c.Path), path);
|
|
});
|
|
|
|
if (!ok) return;
|
|
|
|
_workspace.OnUi(() =>
|
|
{
|
|
ClearHistory();
|
|
HasChanges = false;
|
|
RaiseCanExecuteChanged();
|
|
|
|
var what = keep ? "남김" : "지움";
|
|
MessageUtil.ShowSuccessToast(remaining == 0
|
|
? $"{what} · {Path.GetFileName(path)} · 남은 충돌 없음 - 이제 커밋할 수 있습니다"
|
|
: $"{what} · {Path.GetFileName(path)} · 남은 충돌 {remaining}건");
|
|
});
|
|
|
|
await ReloadAfterResolveAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 충돌 하나를 푼 뒤 저장소를 다시 읽고 파일 목록을 다시 세운다. 방금 푼 파일이 빠지므로 남은 충돌 첫 파일이 열린다.
|
|
/// 새로고침 직후엔 게이트가 잠깐 막혀 목록 세우기가 건너뛰어지므로 풀릴 때까지 기다렸다 한다(6차 A-2).
|
|
/// </summary>
|
|
private async Task ReloadAfterResolveAsync()
|
|
{
|
|
await _workspace.RefreshAsync();
|
|
|
|
for (var attempt = 0; attempt < 5; attempt++)
|
|
{
|
|
await _workspace.WhenIdleAsync();
|
|
if (await LoadCommitAsync()) break;
|
|
}
|
|
}
|
|
|
|
/// <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();
|
|
SaveResolvedCommand.NotifyCanExecuteChanged();
|
|
KeepFileCommand.NotifyCanExecuteChanged();
|
|
DeleteFileCommand.NotifyCanExecuteChanged();
|
|
SavePlainCommand.NotifyCanExecuteChanged();
|
|
SaveLeftPlainCommand.NotifyCanExecuteChanged(); // 12차 B-1
|
|
TakeLineToBaseCommand.NotifyCanExecuteChanged(); // 12차 B-1
|
|
AcceptBlockToBaseCommand.NotifyCanExecuteChanged(); // 12차 G-2
|
|
ExportReportCommand.NotifyCanExecuteChanged();
|
|
|
|
AutoMergeCommand.NotifyCanExecuteChanged();
|
|
AcceptBlockCommand.NotifyCanExecuteChanged();
|
|
AcceptMemberCommand.NotifyCanExecuteChanged();
|
|
OnPropertyChanged(nameof(MemberHint));
|
|
NextConflictCommand.NotifyCanExecuteChanged();
|
|
PrevConflictCommand.NotifyCanExecuteChanged();
|
|
}
|
|
|
|
// ================= 로드 =================
|
|
|
|
/// <summary>병합이 끝났으면 프로그램이 채운 병합 메시지를 비운다. 사람이 적은 글이면 두고 간다.</summary>
|
|
private void ClearAutoCommitMessage()
|
|
{
|
|
if (_autoCommitMessage.Length == 0) return;
|
|
if (CommitMessage == _autoCommitMessage) CommitMessage = string.Empty;
|
|
_autoCommitMessage = string.Empty;
|
|
}
|
|
|
|
private void OnWorkspaceRefreshed()
|
|
{
|
|
// 병합 커밋이나 병합 취소로 병합이 끝났으면 병합 메시지를 남겨 두지 않는다(6차 B-3).
|
|
// 예전에는 병합 커밋 뒤에도 "Merge: Common/Util/Unit15.cs 충돌 해결" 이 칸에 남았다.
|
|
if (!_workspace.IsMergeInProgress) ClearAutoCommitMessage();
|
|
|
|
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();
|
|
_ = RebuildListAfterRefreshAsync();
|
|
// 여기서 다시 세우는 것은 사용자가 고른 것이 아니다. LoadCommitAsync 안에서 묻지 않게 한다.
|
|
}
|
|
|
|
private void OnOpenConflictRequested(string path) => _ = OpenConflictAsync(path);
|
|
|
|
/// <summary>
|
|
/// Git 탭 충돌 목록에서 고른 파일을 연다(6차 B-1). 충돌 파일이므로 A-3 대로 세 칸으로 열린다.
|
|
///
|
|
/// 목록에 그 파일이 아직 없을 수 있다 - 병합 직후라 새로고침 뒤 목록 세우기가 아직 안 끝났거나,
|
|
/// 이 화면이 방금 만들어졌다. 게이트가 풀릴 때까지 기다렸다 목록을 세우고 나서 고른다.
|
|
/// 고르는 일은 <see cref="SelectedFile"/> 에 맡긴다 - 저장 안 한 편집이 있으면 거기서 묻는다.
|
|
/// </summary>
|
|
private async Task OpenConflictAsync(string path)
|
|
{
|
|
if (!_workspace.IsRepositoryOpen) return;
|
|
IsGitMode = true;
|
|
|
|
for (var attempt = 0; attempt < 5 && !Files.Contains(path); attempt++)
|
|
{
|
|
await _workspace.WhenIdleAsync();
|
|
await LoadCommitAsync();
|
|
}
|
|
|
|
if (!Files.Contains(path))
|
|
{
|
|
MessageUtil.ShowWarningToast($"파일 목록에서 찾지 못했습니다: {path}");
|
|
return;
|
|
}
|
|
|
|
if (!string.Equals(SelectedFile, path, StringComparison.Ordinal))
|
|
SelectedFile = path;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 새로고침 뒤 파일 목록을 <b>반드시</b> 다시 세운다(6차 B-1).
|
|
///
|
|
/// 새로고침은 저장소 상태를 읽는 동안(게이트를 쥔 채) 화면 목록을 갈아 끼우고, 끝나자마자 Git 탭이 제 일로
|
|
/// 게이트를 다시 잡는다. 그 틈에 목록 세우기를 부르면 "다른 작업 진행 중" 으로 건너뛰었다.
|
|
/// 병합하고 파일 비교 탭으로 오면 목록이 병합 전 커밋 파일(304개) 그대로였고, [새로고침] 을 한 번 더 눌러야 충돌 23개가 나왔다.
|
|
/// 게이트가 풀릴 때까지 기다렸다가 세우고, 또 막히면 몇 번 더 해 본다.
|
|
/// </summary>
|
|
private async Task RebuildListAfterRefreshAsync()
|
|
{
|
|
for (var attempt = 0; attempt < 5; attempt++)
|
|
{
|
|
await _workspace.WhenIdleAsync();
|
|
if (await LoadCommitAsync()) return;
|
|
}
|
|
}
|
|
|
|
/// <summary>선택 커밋이 담고 있는 파일 목록을 채우고, 가능하면 이전 선택을 유지한다.</summary>
|
|
/// <returns>
|
|
/// 목록을 다시 세웠거나 세울 것이 없으면 true. <b>게이트에 막혀 건너뛰었으면 false</b> -
|
|
/// 꼭 다시 세워야 하는 쪽(해결로 저장)이 기다렸다가 한 번 더 부를 수 있게 한다.
|
|
/// </returns>
|
|
private async Task<bool> LoadCommitAsync()
|
|
{
|
|
var commit = SelectedCommit;
|
|
if (commit is null || !_workspace.IsRepositoryOpen) return true;
|
|
|
|
var previous = SelectedFile;
|
|
IReadOnlyList<string> files = Array.Empty<string>();
|
|
var changed = new List<string>();
|
|
|
|
// 다른 작업이 게이트를 쥐고 있으면 RunAsync 는 아무것도 안 하고 false 를 준다.
|
|
// 그대로 내려가면 <b>빈 목록으로 파일 목록을 갈아엎는다</b> - 병합 중에 [해결로 저장] 을 누르자
|
|
// 파일 콤보가 0개가 되고 화면이 반쯤 비었다(6차 A-2, 로그 "[파일 목록] 건너뜀").
|
|
// 본문 로드는 이미 이렇게 막고 있었는데 목록만 빠져 있었다. 건너뛰면 지금 목록을 그대로 둔다.
|
|
var listed = 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));
|
|
});
|
|
|
|
if (!listed) return false;
|
|
|
|
_workspace.OnUi(() =>
|
|
{
|
|
// 목록을 프로그램이 다시 세우는 중이다. 사용자가 고른 것이 아니므로 묻지 않는다 -
|
|
// 무엇에 답하는 창인지 알 수 없고, 취소해도 목록은 이미 새로 세워진 뒤다.
|
|
//
|
|
// <b>Clear() 까지 감싸야 한다.</b> 목록을 비우면 거기 묶인 ListBox 가 선택을 놓치면서
|
|
// SelectedFile 에 null 을 밀어 넣는다. 그것도 "사용자가 파일을 바꿨다" 로 읽혀
|
|
// 스테이징만 해도 "다른 파일을 엽니다" 창이 떴다(5차 A-2).
|
|
_suspendGuard = true;
|
|
try
|
|
{
|
|
Files.Clear();
|
|
foreach (var f in files) Files.Add(f);
|
|
|
|
var next = previous is not null && files.Contains(previous)
|
|
? previous
|
|
: changed.FirstOrDefault(files.Contains) ?? files.FirstOrDefault();
|
|
|
|
if (!string.Equals(SelectedFile, next, StringComparison.Ordinal))
|
|
SelectedFile = next; // setter 가 LoadFileAsync 를 부른다
|
|
else
|
|
_ = LoadFileAsync();
|
|
}
|
|
finally
|
|
{
|
|
_suspendGuard = false;
|
|
}
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <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;
|
|
// 콤보로 고른 3-Way 인지만 본다. 앞 파일이 충돌이라 켜 둔 3분할을 그대로 들고 오면
|
|
// 충돌이 아닌 다음 파일을 "(3-Way 안 씀)" 이라는 이름의 브랜치와 견주려다 깨진다.
|
|
var threeWay = IsBranchThreeWay;
|
|
var branch = RemoteBranch;
|
|
var baseOrigin = string.Empty;
|
|
string? theirsName = null;
|
|
var operation = GitOperationState.None;
|
|
var deletion = default(DeletionConflict);
|
|
|
|
// 다른 작업이 게이트를 쥐고 있으면 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;
|
|
baseOrigin = "merge base";
|
|
// 병합 · rebase · cherry-pick · revert 마다 두 쪽이 다르다. rebase 는 LOCAL 이 올릴 자리, REMOTE 가 내 커밋(7차 C-2).
|
|
operation = _workspace.Git.GetOperationState();
|
|
theirsName = OperationLabels.TheirsLogName(operation);
|
|
|
|
// 한쪽이 파일을 지운 충돌(6차 A-4). 정할 것은 "남길까 지울까" 하나라 줄 단위 3분할로 끌고 가지 않는다.
|
|
// 끌고 가면 지운 쪽 칸이 통째로 비고, 범례는 남은 쪽에만 있는 줄을 거꾸로 센다("Theirs only 89").
|
|
// 대신 <b>남아 있는 파일을 BASE 와 두 칸으로</b> 보여 준다 - 남은 쪽이 무엇을 고쳤는지는 보인다.
|
|
deletion = DeletionConflict.From(conflict.HasOurs, conflict.HasTheirs);
|
|
if (deletion.IsDeletion)
|
|
{
|
|
threeWay = false;
|
|
localText = deletion.Deleted == DeletedSide.Ours ? three.TheirsText : three.OursText;
|
|
remoteText = string.Empty;
|
|
}
|
|
else
|
|
{
|
|
threeWay = true;
|
|
}
|
|
}
|
|
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;
|
|
|
|
// 행을 만들기 전에 한 번 본다 - 읽는 동안 선택이 바뀌었으면 여기서 그만둔다(8차 A-1).
|
|
if (MergeSavePlan.IsStaleLoad(path, commit.Sha, SelectedFile, SelectedCommit?.Sha))
|
|
{
|
|
ReloadWhenShownIsStale();
|
|
return;
|
|
}
|
|
|
|
// 행을 만들기 전에 언어를 정해야 한다 - 블록 주석 이어붙이기가 이 값을 본다.
|
|
RefreshLanguage();
|
|
|
|
// C# 스타일 맞춤(6차 추가). 세 벌 모두 같은 스타일로 맞춘 뒤 견준다.
|
|
var rawLocalText = localText;
|
|
baseText = Styled(baseText);
|
|
localText = Styled(localText);
|
|
remoteText = Styled(remoteText);
|
|
|
|
var rows = threeWay
|
|
? BuildThreeWayLines(baseText, localText, remoteText)
|
|
: BuildLines(baseText, localText);
|
|
|
|
_workspace.OnUi(() =>
|
|
{
|
|
// 읽어 오는 사이에 파일 비교 모드로 넘어갔으면(저장소 닫기) 버린다.
|
|
// 늦게 끝난 Git 로드가 이미 바뀐 화면을 덮어쓰면, 경로는 새 파일인데 내용은
|
|
// 옛 파일인 상태가 된다 - 저장소를 닫는 길에서 실제로 겪었다.
|
|
if (!IsGitMode) return;
|
|
|
|
// 읽기 시작한 뒤 <b>다른 파일 · 다른 커밋을 골랐으면 버린다</b>(8차 A-1).
|
|
// 그대로 덮으면 고른 파일은 새 파일인데 화면 내용은 옛 파일이 되고, 그 화면을 저장하면 남의 내용이 써진다.
|
|
if (MergeSavePlan.IsStaleLoad(path, commit.Sha, SelectedFile, SelectedCommit?.Sha))
|
|
{
|
|
ReloadWhenShownIsStale();
|
|
return;
|
|
}
|
|
|
|
// 새로고침이 <b>화면에 있는 파일을 화면 그대로의 글로</b> 다시 읽었다(11차 F-8) - [저장 후 커밋] 뒤가 그렇다.
|
|
// 커밋하면 BASE 가 새 HEAD 로 옮겨 가(9차 A-2) 줄을 다시 세울 수밖에 없는데, 그러면 되돌리기 목록이 비어
|
|
// 커밋한 뒤에는 Undo 가 꺼졌다. 글이 같으니 이력은 그대로 뜻이 있다 - 글 단위로 옮겨 지킨다(커밋은 두고 화면만 되돌림, 사용자 결정).
|
|
// 새로고침은 한 번에 두 번 읽기도 한다(고른 커밋이 바뀜 · 목록 다시 세우기). 표시를 한 번 쓰고 버리면 두 번째가 비웠다 - 조건만 본다.
|
|
// 파일에서 읽은 글은 CRLF · 끝 줄바꿈이 붙어 있다 - 줄바꿈 차이는 같은 글로 본다.
|
|
// 새로고침 사이에 화면이 잠깐 비워지기도 한다(목록을 다시 세우는 동안 고른 파일 · 커밋이 잠깐 빔) - 그때 맡겨 둔 이력을 되찾는다.
|
|
var parked = _parkedHistory;
|
|
_parkedHistory = null;
|
|
var keepHistory = false;
|
|
if (!threeWay && !isConflict && !IsThreeWay)
|
|
{
|
|
if (string.Equals(_shownFile, path, StringComparison.Ordinal) && SaveBaseline.SameText(localText, BuildLocalText()))
|
|
{
|
|
FlattenHistory();
|
|
keepHistory = true;
|
|
}
|
|
else if (_shownFile is null && parked is not null && string.Equals(parked.File, path, StringComparison.Ordinal) &&
|
|
SaveBaseline.SameText(localText, parked.Text))
|
|
{
|
|
ClearHistory();
|
|
for (var i = parked.Undo.Length - 1; i >= 0; i--) _textUndo.Push(parked.Undo[i]);
|
|
for (var i = parked.Redo.Length - 1; i >= 0; i--) _textRedo.Push(parked.Redo[i]);
|
|
_baseline.Resume(parked.Opened, parked.Saved);
|
|
keepHistory = true;
|
|
}
|
|
}
|
|
|
|
// 여기서부터 화면에 실리는 것은 이 파일의 내용이다. 저장은 이 이름으로만 쓴다.
|
|
_shownFile = path;
|
|
|
|
// 행을 싣기 전에 틀부터 바꾼다. 세 칸 행을 두 칸 틀에 실으면 REMOTE 칸이 안 보인다.
|
|
_theirsSideName = isConflict ? theirsName ?? "THEIRS" : null;
|
|
_mineSideName = isConflict ? OperationLabels.MineLogName(operation, _workspace.CurrentBranch) : null;
|
|
_conflictHeaderText = OperationLabels.HeaderText(operation);
|
|
SetConflictThreeWay(isConflict && !deletion.IsDeletion);
|
|
SetDeletion(deletion, theirsName);
|
|
OnPropertyChanged(nameof(ConflictSideText));
|
|
|
|
Lines.ReplaceAll(rows); // 알림 한 번(MergeLineCollection 참고)
|
|
|
|
// BASE 가 비어 있으면 <b>그 파일이 공통 조상에 없었다</b>는 뜻이다 - 양쪽 브랜치가
|
|
// 각자 새로 만든 파일이라 견줄 조상이 없다. 그냥 두면 빈 칸이 고장으로 보인다.
|
|
// 큰 제목이 이미 BASE/LOCAL 이라고 말하므로 여기서는 되풀이하지 않는다(4차 C-1).
|
|
BaseHeaderLead = string.Empty;
|
|
LocalHeaderLead = string.Empty;
|
|
|
|
// 충돌 파일이면 두 칸으로 보여도(한쪽이 지운 충돌, 6차 A-4) BASE 는 선택 커밋이 아니라 공통 조상이다.
|
|
// threeWay 만 보면 "a751a6b · 경로" 로 적혀 지금 커밋과 견주는 것처럼 읽혔다.
|
|
BaseHeader = threeWay || isConflict
|
|
? string.IsNullOrEmpty(baseText)
|
|
? $"({baseOrigin}) 이 파일 없음 - 양쪽에서 새로 만든 파일입니다"
|
|
: $"({baseOrigin}) {path}"
|
|
: _workspace.IsOperationInProgress && commit.ShortSha == _workspace.HeadSha
|
|
? OperationLabels.BaseHeader(_workspace.OperationState.Kind, commit.ShortSha, path!)
|
|
: $"{commit.ShortSha} · {path}";
|
|
// 칸마다 "어느 쪽 · 어느 파일"(11차 F-1). 칸 이름(LOCAL(Mine) · REMOTE(Theirs) · MERGED)은 큰 제목이 말한다.
|
|
// 예전에는 Mine · MERGED 칸이 브랜치 이름뿐이고 REMOTE 칸은 "REMOTE · " 를 한 번 더 적었다.
|
|
MineHeader = PaneHeaders.Of(isConflict
|
|
? OperationLabels.OursTitle(operation, _workspace.CurrentBranch)
|
|
: _workspace.CurrentBranch, path);
|
|
LocalHeader = threeWay
|
|
? PaneHeaders.Saved(null, path)
|
|
: PaneHeaders.Saved(_workspace.CurrentBranch, path);
|
|
RemoteHeader = PaneHeaders.Of(isConflict ? OperationLabels.TheirsTitle(operation) : branch, path);
|
|
|
|
if (deletion.IsDeletion)
|
|
LocalHeader = PaneHeaders.Of(deletion.SurvivingLabel(_workspace.CurrentBranch, theirsName ?? "상대"), path);
|
|
|
|
// 두 칸에서 BASE 줄을 가져오면 주석 · 로그에 적을 이름 - 머리에 보인 그 커밋(11차 F-1).
|
|
_baseSourceName = threeWay || isConflict ? baseOrigin : commit.ShortSha;
|
|
OnPropertyChanged(nameof(MergeNotePreview));
|
|
|
|
_baseTextSnapshot = baseText;
|
|
_remoteTextSnapshot = threeWay ? remoteText : string.Empty;
|
|
_mineTextSnapshot = threeWay ? localText : string.Empty;
|
|
_loadedAsConflict = isConflict;
|
|
_appliedBaseNumbers.Clear(); // 파일이 바뀌면 앞 파일의 결정은 뜻이 없다
|
|
_appliedMineNumbers.Clear();
|
|
if (!keepHistory) _baseline.Open(BuildLocalText()); // 이어서 읽은 것이면 연 시점 · 저장 지점을 그대로 둔다
|
|
SyncEditorFromLines();
|
|
|
|
// 충돌 파일은 충돌 표시가 든 워킹트리가 아니라 내 쪽 파일로 본다 - 저장도 그 형식을 따른다(6차 B-7).
|
|
var info = isConflict
|
|
? _workspace.Git.DescribeConflictFile(path!)
|
|
: _workspace.Git.DescribeWorkingFile(path!);
|
|
RefreshLanguage();
|
|
FileEncoding = info.Encoding;
|
|
FileNewLine = info.NewLine;
|
|
CaretRow = 0;
|
|
InvalidateDiffBlocks();
|
|
RefreshDiffPosition();
|
|
|
|
if (!keepHistory) ClearHistory();
|
|
SelectedLine = Lines.FirstOrDefault(l => l.IsDifferent) ?? Lines.FirstOrDefault();
|
|
|
|
// 병합 커밋 메시지 기본값은 <b>병합 전체의 이름</b>이다(6차 B-3). 예전에는 "Merge: 첫 충돌 파일 충돌 해결" 을
|
|
// 채우고, 다른 파일로 옮겨도 그 이름이 굳어 남았다. 사람이 고쳐 적은 글은 건드리지 않는다.
|
|
if (isConflict && operation.Kind == GitOperationKind.Merge &&
|
|
(string.IsNullOrWhiteSpace(CommitMessage) || CommitMessage == _autoCommitMessage))
|
|
{
|
|
_autoCommitMessage = MergeNames.BranchMergeMessage(theirsName, _workspace.CurrentBranch);
|
|
CommitMessage = _autoCommitMessage;
|
|
}
|
|
else if (!isConflict && !_workspace.IsMergeInProgress)
|
|
{
|
|
ClearAutoCommitMessage();
|
|
}
|
|
|
|
MarkStyleChange(rawLocalText, localText);
|
|
UpdateSummaries();
|
|
RaiseCanExecuteChanged();
|
|
});
|
|
}
|
|
|
|
private void ClearPanes()
|
|
{
|
|
// 비우기 전에 이력을 맡겨 둔다(11차 F-8). 새로고침 중에 잠깐 비웠다가 같은 파일 · 같은 글을 다시 싣는 일이 있다 -
|
|
// [저장 후 커밋] 뒤가 그랬다. 다른 파일이거나 글이 다르면 다음 불러오기가 버린다.
|
|
_parkedHistory = null;
|
|
if (_shownFile is not null && Lines.Count > 0)
|
|
{
|
|
FlattenHistory();
|
|
if (_textUndo.Count > 0 || _textRedo.Count > 0)
|
|
_parkedHistory = new ParkedHistory(_shownFile, BuildLocalText(), _textUndo.ToArray(), _textRedo.ToArray(),
|
|
_baseline.Opened, _baseline.Saved);
|
|
}
|
|
|
|
Lines.Clear();
|
|
Files.Clear();
|
|
ClearHistory();
|
|
_syncingEditor = true;
|
|
EditorTextReplaced?.Invoke(this, string.Empty);
|
|
_syncingEditor = false;
|
|
_editorLines = Array.Empty<string>();
|
|
_typedOriginals.Clear(); // 편집기를 줄에서 다시 채웠다 - 친 것은 줄에 이미 들어갔다
|
|
_editorRows = Array.Empty<int>();
|
|
|
|
InvalidateDiffBlocks();
|
|
BaseHeader = "BASE";
|
|
LocalHeader = "LOCAL";
|
|
MineHeader = "LOCAL(Mine)";
|
|
RemoteHeader = "REMOTE";
|
|
_baseSourceName = null;
|
|
DiffSummary = "-";
|
|
EditSummary = "-";
|
|
_baseline.Open(string.Empty);
|
|
HasChanges = false;
|
|
_loadedAsConflict = false;
|
|
_shownFile = null;
|
|
SetConflictThreeWay(false);
|
|
SetDeletion(default, null);
|
|
}
|
|
|
|
/// <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);
|
|
Rules.NotePath = CurrentFilePath; // 머지 주석 기호는 확장자로 고른다(6차 B-5)
|
|
|
|
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);
|
|
|
|
// 머지 주석 줄과 코드 줄이 한 행에 짝지어졌으면 떼어 놓는다(6차 B-5, NoteRows 설명 참고).
|
|
// 규칙 "머지 주석 무시" 를 꺼도 뗀다(10차 C-1) - 끄면 주석 줄이 BASE 의 코드 줄과 한 행이 되어, BASE 줄 번호로 붙든
|
|
// 반영 표시가 주석 줄에 붙고 반영한 코드 줄은 BASE 없는 "직접 수정" 으로 보였다. 무시할지(색 · 차이 수)는 규칙이 따로 정한다.
|
|
aligned = NoteRows.SplitFour(aligned, IsNoteLine);
|
|
|
|
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);
|
|
RenumberMerged(rows); // MERGED 칸은 결과 파일 번호로(8차 B-3)
|
|
return rows;
|
|
}
|
|
|
|
/// <summary>네 쪽 모두(있는 쪽만) 줄 필터에 걸리면 차이로 세지 않는다.</summary>
|
|
private bool IsFilteredFourWayRow(FourWayRow row)
|
|
{
|
|
// 머지 주석 무시는 줄 필터와 따로 켠다(6차 B-5). 둘 다 꺼져 있을 때만 바로 돌아간다.
|
|
if (!Rules.LineFilterEnabled && !Rules.IgnoreMergeNotes) 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);
|
|
Rules.NotePath = CurrentFilePath; // 머지 주석 기호는 확장자로 고른다(6차 B-5)
|
|
|
|
// 파일 끝의 빈 줄(마지막 엔터)은 내용 차이가 아니다 - 한쪽만 있어도 무시한다.
|
|
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);
|
|
|
|
// 머지 주석 줄과 코드 줄이 한 행에 짝지어졌으면 떼어 놓는다(6차 B-5).
|
|
// 네 칸과 달리 규칙을 끄면 떼지 않는다(10차 C-1 에서 잼) - 끄면 주석 줄에 비교 키가 없어 정렬기가 이미 한 줄씩 밀려 짝짓고,
|
|
// 떼어 놓아도 밀린 짝이 풀리지 않아 "남은 차이" 가 4 → 5줄로 늘었다. 두 칸에는 반영 표시를 BASE 줄 번호로 붙드는 일도 없다.
|
|
if (Rules.IgnoreMergeNotes) aligned = NoteRows.SplitTwo(aligned, IsNoteLine);
|
|
|
|
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);
|
|
RenumberMerged(rows); // MERGED 칸은 결과 파일 번호로(8차 B-3)
|
|
return rows;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 자리만 옮긴 덩어리를 찾아 "삭제 + 추가" 대신 "이동"으로 표시한다.
|
|
/// 메서드 순서만 바꾼 것을 두 개의 차이로 보여 주면 검토가 번거롭기 때문이다.
|
|
/// </summary>
|
|
private static void MarkMoves(IReadOnlyList<AlignedRow> aligned, List<MergeLine> rows)
|
|
{
|
|
var group = 0;
|
|
foreach (var move in MoveDetector.Find(aligned))
|
|
{
|
|
var fromLine = rows[move.RemovedStart].BaseNumber;
|
|
var toLine = rows[move.AddedStart].LocalNumber;
|
|
group++;
|
|
|
|
for (var k = 0; k < move.Length; k++)
|
|
{
|
|
// 두 칸 사이에 옮긴 곳까지 잇는 선의 짝(11차 F-10)
|
|
rows[move.RemovedStart + k].MoveGroup = group;
|
|
rows[move.RemovedStart + k].MoveFromBase = true;
|
|
rows[move.AddedStart + k].MoveGroup = group;
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>지금 파일의 주석 기호로 본 머지 주석 줄인지.</summary>
|
|
private bool IsNoteLine(string line) => MergeNote.LooksLikeNote(line, Rules.NotePath);
|
|
|
|
private static string[] SplitLines(string text) =>
|
|
text.Length == 0 ? Array.Empty<string>() : text.Replace("\r\n", "\n").Split('\n');
|
|
|
|
/// <summary>파일 끝의 빈 줄을 걷어낸다. 마지막 엔터 유무는 코드 차이가 아니다.</summary>
|
|
private static string[] TrimTrailingBlank(string[] lines)
|
|
{
|
|
var end = lines.Length;
|
|
while (end > 0 && string.IsNullOrWhiteSpace(lines[end - 1])) end--;
|
|
|
|
if (end == lines.Length) return lines;
|
|
|
|
var trimmed = new string[end];
|
|
Array.Copy(lines, trimmed, end);
|
|
return trimmed;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 줄을 넘어가는 것들(<c>/* ... */</c> · XAML 의 여러 줄짜리 여는 태그)을 이어서 칠하려면
|
|
/// 각 줄이 "앞에서 무엇이 열린 채 왔는지"를 알아야 한다.
|
|
/// 위에서 아래로 한 번 훑으며 BASE/LOCAL 각각의 상태를 기록한다.
|
|
/// LOCAL 을 직접 고치면 그 아래 줄들의 상태가 달라지므로 편집 후에도 다시 계산한다.
|
|
/// </summary>
|
|
private void RefreshBlockCommentState(IEnumerable<MergeLine> rows)
|
|
{
|
|
var inBase = CarryOver.None;
|
|
var inLocal = CarryOver.None;
|
|
|
|
foreach (var row in rows)
|
|
{
|
|
row.BaseInComment = inBase;
|
|
row.LocalInComment = inLocal;
|
|
|
|
CodeTokenizer.Tokenize(row.BaseText, Language, inBase, out inBase);
|
|
CodeTokenizer.Tokenize(row.LocalText, Language, inLocal, out inLocal);
|
|
}
|
|
}
|
|
|
|
// ================= 줄 단위 편집 =================
|
|
|
|
/// <summary>
|
|
/// BASE 내용을 LOCAL 로 가져온다.
|
|
/// BASE 에 없는 줄(LOCAL 에서 추가된 줄)에 대해서는 "그 줄을 지우는 것"이 BASE 를 따르는 것이다.
|
|
/// </summary>
|
|
/// <param name="group">덩어리 · 메서드로 함께 반영하는 묶음 번호. 0 이 아니면 줄마다 주석 · 로그를 남기지 않는다 -
|
|
/// 부르는 쪽이 주석 한 줄 · 로그 한 줄로 남긴다(11차 F-2).</param>
|
|
/// <returns>글이 바뀌었는지.</returns>
|
|
private bool Apply(MergeLine line, bool requery = true, int group = 0)
|
|
{
|
|
var before = line.LocalText;
|
|
var beforeHasLocal = line.HasLocal;
|
|
|
|
string after;
|
|
bool afterHasLocal;
|
|
string? note = null;
|
|
|
|
// 반영한 줄에는 누가·언제 반영했는지 주석을 남긴다.
|
|
// 주석은 줄 끝이 아니라 그 줄 "위"에 따로 들어간다 - 아래 InsertNoteAbove 참고.
|
|
// BASE 에 없던 줄(LOCAL 에서 지우는 반영)에도 남긴다 - 지운 자리에 누가 지웠는지가 남아야 한다(사용자 2026-09-30).
|
|
// 예전에는 BASE 에 줄이 있을 때만 남겨, 지운 반영은 흔적이 없었다. 지운 반영이면 출처 뒤에 "삭제".
|
|
|
|
// 반영하면 그 칸이 어떻게 되는지는 양쪽이 같은 규칙을 쓴다(12차 B-1, PaneText).
|
|
// BASE 에 없던 줄이면 LOCAL 에서도 지운다.
|
|
(after, afterHasLocal) = PaneText.Plan(line, MergeLine.TextPane.Local);
|
|
note = _workspace.Settings.InsertMergeComment && group == 0
|
|
? BuildMergeNote(MergeNote.WithDeletion(MergeNames.BaseSource(_baseSourceName), beforeHasLocal && !afterHasLocal ? 1 : 0))
|
|
: null;
|
|
|
|
if (beforeHasLocal == afterHasLocal && string.Equals(before, after, StringComparison.Ordinal))
|
|
return false;
|
|
|
|
var description = MergeNames.TakeBaseLog(
|
|
(line.HasBase ? line.BaseNumber : line.LocalNumber)?.ToString(), _baseSourceName, line.HasBase);
|
|
|
|
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, indentFrom: before),
|
|
Group = group,
|
|
});
|
|
|
|
// 반영 표시는 되돌리기 스택 밖에도 남긴다 - 규칙을 바꾸면 스택이 비워지기 때문이다.
|
|
MarkApplied(line);
|
|
ClearRedo();
|
|
|
|
if (group == 0) _workspace.AppendLog(description);
|
|
if (requery) AfterEdit();
|
|
return true;
|
|
}
|
|
|
|
// ================= 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))]
|
|
[NotifyPropertyChangedFor(nameof(BothWays))]
|
|
private string _remoteBranch = NoRemoteBranch;
|
|
|
|
/// <summary>
|
|
/// 지금 3-Way 로 보고 있는지. 두 가지 경우에 참이다.
|
|
/// - 콤보에서 브랜치를 골랐다(<see cref="IsBranchThreeWay"/>).
|
|
/// - <b>병합 중인 충돌 파일을 열었다</b>(6차 A-3). 콤보를 안 건드려도 세 칸이 뜬다.
|
|
/// 예전에는 충돌 파일을 열어도 BASE 와 내 것 두 칸뿐이라, 상대가 무엇을 바꿨는지 모른 채 풀게 됐다.
|
|
/// </summary>
|
|
/// Git 저장소가 아니면 <b>git mergetool 이 세 벌을 넘겨 연 경우</b>에만 참이다(<see cref="_toolThreeWay"/>).
|
|
public bool IsThreeWay => IsGitMode ? _conflictThreeWay || IsBranchThreeWay : _toolThreeWay;
|
|
|
|
/// <summary>git mergetool 세 벌이라 켠 네 칸. 저장소 밖 파일이라 Git 쪽 두 까닭과 따로 둔다.</summary>
|
|
private bool _toolThreeWay;
|
|
|
|
private void SetToolThreeWay(bool on)
|
|
{
|
|
if (_toolThreeWay == on) return;
|
|
_toolThreeWay = on;
|
|
RaiseThreeWayShapeChanged();
|
|
}
|
|
|
|
/// <summary>콤보에서 브랜치를 골라 켠 3-Way 인지. 충돌 때문에 켠 것은 뺀다.</summary>
|
|
private bool IsBranchThreeWay =>
|
|
IsGitMode && !string.IsNullOrEmpty(RemoteBranch) && RemoteBranch != NoRemoteBranch;
|
|
|
|
/// <summary>
|
|
/// 병합 충돌 파일이라 켠 3분할. 콤보 값과 따로 둔다 - 콤보를 상대 브랜치로 바꿔 두면
|
|
/// 병합이 끝난 뒤에도 보통 파일이 그 브랜치와 3-Way 로 열려, 사람이 고르지 않은 비교가 남는다.
|
|
/// </summary>
|
|
private bool _conflictThreeWay;
|
|
|
|
/// <summary>
|
|
/// 병합 상대 · 내 쪽 이름. 충돌 파일(상대 브랜치)이나 git mergetool 세 벌(파일 이름)을 열 때 정한다(7차 A-2).
|
|
/// 콤보 값(<see cref="RemoteBranch"/>)은 이때 <c>(3-Way 안 씀)</c> 이라 로그와 머리에 쓰면 틀린다.
|
|
/// </summary>
|
|
private string? _theirsSideName;
|
|
private string? _mineSideName;
|
|
|
|
/// <summary>줄을 고를 때 병합의 두 쪽(Theirs · Mine)으로 말하는지. 콤보로 고른 브랜치 3-Way 는 아니다.</summary>
|
|
private bool TakesMergeSides => _conflictThreeWay || _toolThreeWay;
|
|
|
|
private string? TheirsSideName => TakesMergeSides ? _theirsSideName : RemoteBranch;
|
|
|
|
private string? MineSideName => _mineSideName ?? _workspace.CurrentBranch;
|
|
|
|
/// <summary>충돌 네 칸의 머리 글. 작업(병합 · rebase · cherry-pick · revert)마다 다르다(7차 C-2).</summary>
|
|
private string _conflictHeaderText = string.Empty;
|
|
|
|
/// <summary>병합 충돌 파일을 네 칸으로 보는 중. 머리에서 3-Way 콤보 대신 상대 이름을 보인다.</summary>
|
|
public bool IsConflictThreeWay => IsGitMode && _conflictThreeWay;
|
|
|
|
/// <summary>3-Way 콤보를 보일지. 충돌 네 칸일 때는 콤보가 가리키는 것이 없어 감춘다.</summary>
|
|
public bool ShowBranchCombo => !IsConflictThreeWay;
|
|
|
|
/// <summary>충돌 네 칸일 때 콤보 자리에 보이는 글.</summary>
|
|
public string ConflictSideText => _conflictHeaderText.Length > 0
|
|
? _conflictHeaderText
|
|
: $"병합 충돌 · REMOTE = {_theirsSideName ?? "THEIRS"}";
|
|
|
|
private void SetConflictThreeWay(bool on)
|
|
{
|
|
if (_conflictThreeWay == on) return;
|
|
_conflictThreeWay = on;
|
|
RaiseThreeWayShapeChanged();
|
|
}
|
|
|
|
private void RaiseThreeWayShapeChanged()
|
|
{
|
|
// 콤보로 바꿀 때(OnRemoteBranchChanged)와 같은 것들을 알린다 - 화면이 4분할로 바뀌고 제목·머리 높이도 바뀐다.
|
|
OnPropertyChanged(nameof(IsThreeWay));
|
|
OnPropertyChanged(nameof(BothWays));
|
|
OnPropertyChanged(nameof(IsTwoWay));
|
|
OnPropertyChanged(nameof(BaseTitle));
|
|
OnPropertyChanged(nameof(LocalTitle));
|
|
OnPropertyChanged(nameof(SourceHeaderHeight));
|
|
OnPropertyChanged(nameof(IsMergeComplete));
|
|
OnPropertyChanged(nameof(IsConflictThreeWay));
|
|
OnPropertyChanged(nameof(ShowBranchCombo));
|
|
}
|
|
|
|
public bool IsTwoWay => !IsThreeWay;
|
|
|
|
/// <summary>
|
|
/// 읽기 전용 원본 패널(LOCAL 원본 · BASE · REMOTE)의 머리 높이.
|
|
///
|
|
/// 3-Way 에서는 위아래로 두 줄을 쓰므로 머리·바닥이 세로를 두 번 먹는다.
|
|
/// 위쪽 세 칸은 참고용이라 머리를 줄이고 바닥은 아예 감춰서, 그만큼 코드를 더 보여 준다.
|
|
/// (세 칸끼리만 같으면 되고, 아래 MERGED 와는 맞출 필요가 없다.)
|
|
/// </summary>
|
|
public double SourceHeaderHeight => IsThreeWay ? 74 : 124;
|
|
|
|
[ObservableProperty] private string _remoteHeader = "REMOTE";
|
|
|
|
/// <summary>네 칸의 LOCAL(Mine) 칸 머리 - 내 쪽 · 파일(11차 F-1). 예전에는 MERGED 칸과 같은 값을 썼다.</summary>
|
|
[ObservableProperty] private string _mineHeader = "LOCAL(Mine)";
|
|
|
|
/// <summary>두 칸에서 BASE 를 가져올 때 주석 · 로그에 적는 이름(커밋 해시 · merge base · 왼쪽 파일 이름, 11차 F-1).</summary>
|
|
private string? _baseSourceName;
|
|
|
|
/// <summary>충돌 줄 수. 상태바와 요약에 쓴다.</summary>
|
|
[ObservableProperty] private int _conflictCount;
|
|
|
|
/// <summary>확인 창에서 [취소] 를 눌렀을 때 돌아갈 브랜치.</summary>
|
|
private string _lastLoadedRemoteBranch = NoRemoteBranch;
|
|
private bool _revertingRemoteBranch;
|
|
|
|
partial void OnRemoteBranchChanged(string value)
|
|
{
|
|
// 3-Way 로 들고 나면 본문을 다시 읽는다. 고치던 것이 있으면 사라진다.
|
|
// _suspendReload 가 걸린 길(파일 비교 전환)은 다시 읽지 않으므로 묻지 않는다.
|
|
if (!_revertingRemoteBranch && !_syncingBranches && IsGitMode && !_suspendReload &&
|
|
!ConfirmDiscard("비교 방식을 바꿉니다."))
|
|
{
|
|
var back = _lastLoadedRemoteBranch;
|
|
_revertingRemoteBranch = true;
|
|
try { RemoteBranch = back; }
|
|
finally { _revertingRemoteBranch = false; }
|
|
return;
|
|
}
|
|
|
|
_lastLoadedRemoteBranch = value;
|
|
OnPropertyChanged(nameof(IsThreeWay));
|
|
OnPropertyChanged(nameof(BothWays));
|
|
OnPropertyChanged(nameof(IsTwoWay));
|
|
|
|
// 3-Way 로 들어가고 나가면서 화면이 4분할로 바뀐다. 제목과 머리 높이도 함께 바뀐다.
|
|
OnPropertyChanged(nameof(BaseTitle));
|
|
OnPropertyChanged(nameof(LocalTitle));
|
|
OnPropertyChanged(nameof(SourceHeaderHeight));
|
|
|
|
if (IsGitMode && !_suspendReload) _ = LoadFileAsync();
|
|
}
|
|
|
|
/// <summary>워크스페이스가 읽어 온 브랜치 목록을 그대로 옮긴다. 고른 값은 남아 있으면 지킨다.</summary>
|
|
/// <summary>
|
|
/// 브랜치 목록을 다시 세우는 중. 그 사이 콤보가 값을 흘리는 것은 사람이 고른 것이 아니다.
|
|
/// 묻지 않게 막는다 - 안 막으면 <b>스테이징만 해도 "비교 방식을 바꿉니다" 창이 뜬다</b>
|
|
/// (5차 A-2 에서 실제로 그랬다).
|
|
/// </summary>
|
|
private bool _syncingBranches;
|
|
|
|
private void RefreshRemoteBranches()
|
|
{
|
|
var keep = RemoteBranch;
|
|
|
|
_syncingBranches = true;
|
|
try
|
|
{
|
|
RemoteBranches.Clear();
|
|
RemoteBranches.Add(NoRemoteBranch);
|
|
|
|
foreach (var branch in _workspace.Branches)
|
|
{
|
|
// 지금 체크아웃된 브랜치는 LOCAL 그 자체라 REMOTE 로 고를 이유가 없다.
|
|
if (string.Equals(branch.FriendlyName, _workspace.CurrentBranch, StringComparison.Ordinal))
|
|
continue;
|
|
|
|
if (!RemoteBranches.Contains(branch.FriendlyName)) RemoteBranches.Add(branch.FriendlyName);
|
|
}
|
|
|
|
// 고른 브랜치가 사라졌으면 조용히 2-Way 로 돌아간다.
|
|
if (!RemoteBranches.Contains(keep)) RemoteBranch = NoRemoteBranch;
|
|
else RemoteBranch = keep; // 목록을 다시 세우며 흘린 값을 제자리로
|
|
}
|
|
finally
|
|
{
|
|
_syncingBranches = false;
|
|
}
|
|
}
|
|
|
|
private bool CanTakeLocalLine(MergeLine? line) => line?.CanTakeLocal == true;
|
|
|
|
/// <summary>내 원본 줄을 결과(MERGED)로 되돌린다. "Accept Mine" 에 해당한다.</summary>
|
|
[RelayCommand(CanExecute = nameof(CanTakeLocalLine))]
|
|
private void TakeLocalLine(MergeLine? line)
|
|
{
|
|
if (line is null || !line.CanTakeLocal) return;
|
|
if (!AuthorReady()) 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 줄 번호로 따로 붙들어 둔다.
|
|
var first = !line.IsApplied;
|
|
line.IsApplied = true;
|
|
MarkApplied(line);
|
|
|
|
// 병합의 두 쪽 중 내 것을 고른 것이다 - 내용은 안 바뀌어도 무엇을 골랐는지 로그에 남긴다(7차 A-2).
|
|
if (TakesMergeSides)
|
|
{
|
|
var chosen = MergeNames.TakeMineLog((line.LocalNumber ?? line.BaseNumber)?.ToString(),
|
|
MineSideName, afterHasLocal, alreadySame: true, mergeSides: true);
|
|
|
|
// 소스에도 남긴다(11차 F-1) - 로그는 앱을 끄면 사라진다. 같은 줄을 또 눌러 주석이 쌓이지 않게 처음 한 번만.
|
|
var chosenNote = first && afterHasLocal ? MineNote() : null;
|
|
if (chosenNote is not null)
|
|
{
|
|
line.AppliedNote = chosenNote;
|
|
_undo.Push(new LineEdit(line, before, beforeHasLocal, after, afterHasLocal,
|
|
EditKind.TakeBase, chosen)
|
|
{
|
|
InsertedNote = InsertNoteAbove(line, chosenNote),
|
|
});
|
|
ClearRedo();
|
|
}
|
|
|
|
_workspace.AppendLog(chosen);
|
|
}
|
|
|
|
AfterEdit();
|
|
return;
|
|
}
|
|
|
|
var description = MergeNames.TakeMineLog(line.LocalNumber?.ToString(), MineSideName,
|
|
afterHasLocal, alreadySame: false, TakesMergeSides);
|
|
|
|
// 병합의 두 쪽에서 내 것으로 되돌리면 그 출처도 적는다(11차 F-1). 콤보로 고른 브랜치 3-Way 의
|
|
// "내 원본으로 되돌림" 은 되돌리기일 뿐이라 주석을 넣지 않는다(지금처럼).
|
|
var mineNote = TakesMergeSides && afterHasLocal ? MineNote() : null;
|
|
|
|
line.LocalText = after;
|
|
line.HasLocal = afterHasLocal;
|
|
line.AppliedNote = mineNote;
|
|
|
|
_undo.Push(new LineEdit(line, before, beforeHasLocal, after, afterHasLocal,
|
|
EditKind.TakeBase, description)
|
|
{
|
|
InsertedNote = InsertNoteAbove(line, mineNote),
|
|
});
|
|
|
|
// 반영 표시는 되돌리기 스택 밖에도 남긴다 - 규칙을 바꾸면 스택이 비워지기 때문이다.
|
|
MarkApplied(line);
|
|
ClearRedo();
|
|
|
|
_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;
|
|
if (!AuthorReady()) return;
|
|
ApplyRemote(line);
|
|
}
|
|
|
|
private bool CanAcceptRemote() => SelectedLine?.CanTakeRemote == true;
|
|
|
|
[RelayCommand(CanExecute = nameof(CanAcceptRemote))]
|
|
private void AcceptRemote() => TakeRemoteLine(SelectedLine);
|
|
|
|
/// <summary>
|
|
/// REMOTE 내용을 LOCAL 로 가져온다.
|
|
/// REMOTE 에 없는 줄이면 "그 줄을 지우는 것"이 저쪽을 따르는 것이다 - Apply 와 같은 규칙.
|
|
/// </summary>
|
|
/// <param name="group"><see cref="Apply"/> 와 같다.</param>
|
|
private bool ApplyRemote(MergeLine line, bool requery = true, int group = 0)
|
|
{
|
|
var before = line.LocalText;
|
|
var beforeHasLocal = line.HasLocal;
|
|
|
|
string after;
|
|
bool afterHasLocal;
|
|
string? note = null;
|
|
|
|
// 저쪽에 없던 줄(내 쪽에서 지우는 반영)에도 주석을 남긴다 - Apply 와 같은 규칙(사용자 2026-09-30).
|
|
note = _workspace.Settings.InsertMergeComment && group == 0
|
|
? BuildMergeNote(MergeNote.WithDeletion(MergeNames.TheirsSource(TheirsSideName, TakesMergeSides),
|
|
beforeHasLocal && !line.HasRemote ? 1 : 0))
|
|
: null;
|
|
if (line.HasRemote)
|
|
{
|
|
after = line.RemoteText;
|
|
afterHasLocal = true;
|
|
}
|
|
else
|
|
{
|
|
after = string.Empty;
|
|
afterHasLocal = false;
|
|
}
|
|
|
|
if (beforeHasLocal == afterHasLocal && string.Equals(before, after, StringComparison.Ordinal))
|
|
return false;
|
|
|
|
var description = MergeNames.TakeTheirsLog(
|
|
(line.HasRemote ? line.RemoteNumber : line.LocalNumber)?.ToString(),
|
|
TheirsSideName, line.HasRemote, TakesMergeSides);
|
|
|
|
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, indentFrom: before),
|
|
Group = group,
|
|
});
|
|
|
|
// 반영 표시는 되돌리기 스택 밖에도 남긴다 - 규칙을 바꾸면 스택이 비워지기 때문이다.
|
|
MarkApplied(line);
|
|
ClearRedo();
|
|
|
|
if (group == 0) _workspace.AppendLog(description);
|
|
if (requery) AfterEdit();
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 반영한 줄 <b>바로 위</b>에 주석을 한 줄로 끼워 넣는다.
|
|
///
|
|
/// 줄 끝에 붙이지 않는 이유 - 주석이 코드와 같은 줄에 있으면 줄이 길어져 가로로 밀리고,
|
|
/// 같은 줄을 두 번 반영하면 앞의 주석을 지우거나 줄이 끝없이 늘어난다.
|
|
/// 따로 한 줄로 두면 <b>지우지 않고 쌓인다</b> - 새 주석이 코드 바로 위에 들어가면서
|
|
/// 앞서 남긴 주석은 자연스럽게 위로 밀린다.
|
|
///
|
|
/// 정렬은 저절로 맞는다. 끼워 넣은 줄은 BASE 에 없는 "LOCAL 에만 있는 줄"이라,
|
|
/// 이미 추가된 줄을 다루던 방식 그대로 BASE 쪽에 빈 자리(필러)가 생긴다.
|
|
/// </summary>
|
|
private MergeLine? InsertNoteAbove(MergeLine line, string? note, string? indentFrom = null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(note)) return null;
|
|
|
|
var at = Lines.IndexOf(line);
|
|
if (at < 0) return null;
|
|
|
|
// 코드 줄과 들여쓰기를 맞춘다. 주석만 왼쪽 끝에 붙어 있으면 눈에 거슬린다.
|
|
// 지우는 반영이면 그 줄 글이 이미 비었으므로 지우기 전 글(indentFrom)이나 BASE 쪽 글에서 잰다.
|
|
var indent = IndentOf(line.LocalText, indentFrom, line.BaseText);
|
|
|
|
var row = new MergeLine(null, null, indent + note, null,
|
|
Rules.IsPlain ? null : Rules.KeyOf)
|
|
{
|
|
IsApplied = true,
|
|
IsMergeNote = true,
|
|
// 다시 정렬했을 때와 같은 모양으로 넣는다(9차 B-1, 사용자 결정 "처음부터 무시 색").
|
|
// 예전에는 넣는 순간 "추가"(파랑)였다가 타이핑이 멈추거나 규칙을 바꿔 다시 정렬하면 "무시"(회색)가 됐다.
|
|
// 파일에 원래 있던 주석 줄은 처음부터 무시다. 규칙 "머지 주석 무시" 를 끄면 지금처럼 추가로 남는다.
|
|
IsIgnored = Rules.IsFiltered(indent + note),
|
|
};
|
|
|
|
// 네 칸에서는 MINE · REMOTE 칸을 빈 자리로 둔다. SetRemote 는 MINE 칸에 LocalText(주석)를 넣어,
|
|
// 내 파일에 없는 주석이 LOCAL(Mine) 칸에 번호 없이 보였다 - 다시 정렬하면 빈 자리가 됐다.
|
|
if (line.IsThreeWay) row.SetThreeWay(null, null, null, null);
|
|
|
|
Lines.Insert(at, row);
|
|
NoteRowMoved(at, +1);
|
|
return row;
|
|
}
|
|
|
|
/// <summary>처음으로 글이 있는 쪽의 앞 공백. 모두 비었으면 들여쓰지 않는다.</summary>
|
|
private static string IndentOf(params string?[] candidates)
|
|
{
|
|
foreach (var text in candidates)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(text)) continue;
|
|
return new string(' ', text!.Length - text.TrimStart().Length);
|
|
}
|
|
return string.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 주석 줄을 끼우거나 빼기 <b>직전</b> 캐럿이 있던 행(2026-09-30 사용자 보고 "Accept 뒤 Alt+↓ 를 두 번 눌러야 한다").
|
|
///
|
|
/// 편집기는 글을 갈아 끼울 때 캐럿을 <b>글자 수 자리</b>로 지킨다. 반영하면서 캐럿 위에 주석 한 줄이 끼면
|
|
/// 같은 글자 수 자리는 그 주석 줄이 되고, 그러면 "다음 차이" 가 방금 반영한 줄을 다음 차이로 잡았다.
|
|
/// 끼운 줄 수만큼 옮기는 셈은 지운 줄(편집기에 줄이 없는 행)에서 어긋나서, <b>행 자체</b>를 기억했다가
|
|
/// 글을 갈아 끼운 뒤 그 행이 몇 번째 편집기 줄이 됐는지(<see cref="EditorLayout.LineOfRow"/>)로 캐럿을 둔다.
|
|
/// </summary>
|
|
private MergeLine? _caretAnchor;
|
|
|
|
private void NoteRowMoved(int at, int delta)
|
|
{
|
|
// 처음 끼우거나 뺄 때만 잡는다 - 한 번 누른 반영이 주석을 여럿 넣어도 기준은 누르기 전 캐럿 행이다.
|
|
// 이미 바뀐 뒤라 캐럿 행 번호를 그만큼 되돌려 누르기 전 행을 찾는다.
|
|
if (_caretAnchor is not null) return;
|
|
var before = at <= CaretRow ? CaretRow + delta : CaretRow;
|
|
if (before >= 0 && before < Lines.Count) _caretAnchor = Lines[before];
|
|
}
|
|
|
|
/// <summary>
|
|
/// 편집기가 글을 갈아 끼운 뒤 캐럿을 둘 편집기 줄. 기억한 행이 없으면 -1(글자 수 자리를 그대로 지킨다).
|
|
/// 그 행이 LOCAL 에 없으면(지운 줄) 바로 다음 줄이다. 한 번 넘기면 비운다.
|
|
/// </summary>
|
|
public int TakeCaretLine()
|
|
{
|
|
var anchor = _caretAnchor;
|
|
_caretAnchor = null;
|
|
if (anchor is null) return -1;
|
|
var row = Lines.IndexOf(anchor);
|
|
return row < 0 ? -1 : EditorLayout.LineOfRow(_editorRows, row);
|
|
}
|
|
|
|
/// <summary>반영한 줄 위에 넣을 주석 한 줄. [사용자] 날짜 출처 설명(출처는 11차 F-1).</summary>
|
|
private string BuildMergeNote(string? source) => MergeNote.Build(
|
|
author: _workspace.AuthorName,
|
|
when: DateTime.Now,
|
|
source: source,
|
|
comment: MergeComment,
|
|
path: SelectedFile);
|
|
|
|
/// <summary>내 쪽으로 정한 줄의 주석. 설정 "머지 주석 남기기" 가 꺼져 있으면 null.</summary>
|
|
private string? MineNote() => _workspace.Settings.InsertMergeComment
|
|
? BuildMergeNote(MergeNames.MineSource(MineSideName))
|
|
: null;
|
|
|
|
/// <summary>미리 보기에 쓸 출처. 네 칸 · 브랜치 3-Way 는 상대 쪽, 두 칸은 BASE - 가장 흔히 누르는 쪽이다.</summary>
|
|
private string PreviewSource => IsThreeWay
|
|
? MergeNames.TheirsSource(TheirsSideName, TakesMergeSides)
|
|
: MergeNames.BaseSource(_baseSourceName);
|
|
|
|
/// <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>();
|
|
|
|
/// <summary>
|
|
/// 편집기 줄마다 정렬 행 번호(11차 F-10). 편집기에는 LOCAL 에 있는 줄만 들어간다 - 예전처럼 빈 줄(필러)로
|
|
/// 행 번호를 맞추지 않으므로 캐럿 줄 ↔ 행, 행 ↔ 편집기 줄은 이 표로 옮긴다.
|
|
/// </summary>
|
|
private int[] _editorRows = Array.Empty<int>();
|
|
|
|
/// <summary>정렬 행 → 편집기 줄. 그 행이 LOCAL 에 없으면(지운 줄) 바로 다음 줄. View 가 캐럿을 옮길 때 쓴다.</summary>
|
|
public int EditorLineOfRow(int row) => EditorLayout.LineOfRow(_editorRows, row);
|
|
|
|
/// <summary>편집기 줄 → 정렬 행.</summary>
|
|
public int RowOfEditorLine(int line) => EditorLayout.RowOfLine(_editorRows, line);
|
|
|
|
/// <summary>모델 쪽에서 편집기 글을 갈아 끼우는 중. 되먹임(다시 정렬)을 막는다.</summary>
|
|
private bool _syncingEditor;
|
|
|
|
/// <summary>모드를 통째로 바꾸는 중. 중간 상태 때문에 본문을 다시 읽지 않게 막는다.</summary>
|
|
private bool _suspendReload;
|
|
|
|
/// <summary>편집기 글을 통째로 갈아 끼웠다. View 가 캐럿 자리를 지키는 데 쓴다.</summary>
|
|
/// <summary>편집기 글을 통째로 갈아 끼운다. 새 글을 함께 넘긴다.</summary>
|
|
public event EventHandler<string>? EditorTextReplaced;
|
|
|
|
/// <summary>
|
|
/// 파일을 연 시점 · 마지막으로 저장한 글(11차 F-8). 저장할 것이 있는지는 되돌리기 목록이 아니라
|
|
/// "지금 글이 마지막 저장과 다른가" 로 본다 - 그래서 저장해도 되돌리기 목록을 비우지 않는다.
|
|
/// </summary>
|
|
private readonly SaveBaseline _baseline = new();
|
|
|
|
/// <summary>
|
|
/// 글 단위 되돌리기 · 다시하기(11차 F-8). 줄 단위 목록(<see cref="_undo"/>)은 줄 객체를 가리키므로
|
|
/// 줄을 다시 세우면(커밋 뒤 새 HEAD 로 다시 읽기 · 비교 규칙 바꾸기) 쓸 수 없다. 그 전에 각 지점의 <b>전문</b>을 여기로 옮긴다.
|
|
/// 여기 든 것은 줄 단위 목록보다 늘 먼 쪽이다(되돌리기는 더 옛날, 다시하기는 더 나중).
|
|
/// </summary>
|
|
private readonly Stack<string> _textUndo = new();
|
|
private readonly Stack<string> _textRedo = new();
|
|
|
|
/// <summary>화면을 비울 때 맡겨 둔 이력(11차 F-8). 맨 위가 첫 칸(가장 가까운 것) - <see cref="Stack{T}.ToArray"/> 차례.</summary>
|
|
private sealed record ParkedHistory(string File, string Text, string[] Undo, string[] Redo, string Opened, string Saved);
|
|
|
|
private ParkedHistory? _parkedHistory;
|
|
|
|
|
|
/// <summary>파일을 연 직후의 LOCAL 전문. "전체 되돌리기"의 기준점 · 직접 고친 줄을 가리는 기준.</summary>
|
|
private string _originalLocalText => _baseline.Opened;
|
|
|
|
/// <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;
|
|
|
|
TouchSession(); // 12차 E-1 - 손이 멈추면 그때 한 번 보관한다
|
|
_reflowTimer.Stop();
|
|
|
|
if (ShowTypedLineNow(text, caretIndex))
|
|
{
|
|
// 지금 치는 줄은 이미 화면에 나왔다. 무거운 BASE 재정렬만 손이 멈춘 뒤로 미룬다.
|
|
_reflowTimer.Interval = ReflowAfterTyping;
|
|
_reflowTimer.Start();
|
|
return;
|
|
}
|
|
|
|
// 줄 수가 달라졌다 - 엔터 · 줄 합치기 · 여러 줄 붙여넣기.
|
|
//
|
|
// 한 줄만 고쳐서는 맞출 수 없는 경우인데, 예전에는 여기서도 0.6초 타이머에 맡겼다.
|
|
// 편집기 글자는 투명이라 <b>그 동안 화면에 아무것도 나오지 않았다</b> -
|
|
// "엔터를 누르면 한참 뒤에 반응한다"는 것의 정체가 이것이다.
|
|
//
|
|
// 그렇다고 <b>이 자리에서 바로 부르면 안 된다.</b> 재정렬은 편집기 글을 통째로 갈아 끼우는데,
|
|
// 지금은 편집기의 TextChanged 안이다. 자기 이벤트 안에서 자기 글을 바꾸면 캐럿이 엉키고
|
|
// 뒤따라오던 글자가 엉뚱한 자리에 찍힌다(실제로 그렇게 됐다).
|
|
// 타이머를 짧게 놓아 <b>이 입력이 끝난 바로 다음 차례</b>에 돌게 한다.
|
|
_reflowTimer.Interval = ReflowNow;
|
|
_reflowTimer.Start();
|
|
}
|
|
|
|
/// <summary>타이핑이 멈추기를 기다리는 시간. 글자마다 다시 정렬하면 화면이 요동친다.</summary>
|
|
private static readonly TimeSpan ReflowAfterTyping = TimeSpan.FromMilliseconds(600);
|
|
|
|
/// <summary>
|
|
/// 기다리지 않고 바로. 다만 <b>0 이 아니라 1ms</b> 다 - 지금 이벤트가 끝난 다음 차례에
|
|
/// 돌게 하려는 것이지, 늦추려는 것이 아니다.
|
|
/// </summary>
|
|
private static readonly TimeSpan ReflowNow = TimeSpan.FromMilliseconds(1);
|
|
|
|
/// <summary>
|
|
/// 캐럿이 있는 줄 하나만 표시층에 바로 반영한다.
|
|
///
|
|
/// 줄 수가 달라졌으면(엔터·줄 합치기) 아래 줄이 통째로 밀린 것이라 한 줄만 고쳐서는
|
|
/// 맞지 않는다. 그때는 <b>false 를 주고</b> 부르는 쪽이 곧바로 재정렬하게 한다.
|
|
/// </summary>
|
|
/// <returns>이 자리에서 화면을 맞췄으면 true.</returns>
|
|
private bool ShowTypedLineNow(string text, int caretIndex)
|
|
{
|
|
if (_editorLines.Length == 0 || _editorRows.Length != _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;
|
|
|
|
// 편집기 줄 → 정렬 행(11차 F-10 - 편집기에는 빈 줄이 없다).
|
|
var row = _editorRows[line];
|
|
if (row < 0 || row >= Lines.Count) return false;
|
|
|
|
// 친 줄의 원래 글을 한 번만 적어 둔다 - 재정렬 때 되돌리기 한 걸음으로 삼는다(11차 F-8).
|
|
if (!_typedOriginals.ContainsKey(row)) _typedOriginals[row] = Lines[row].LocalText;
|
|
_editorLines[line] = value;
|
|
Lines[row].LocalText = value;
|
|
DiffVersion++;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 편집기에서 친 줄의 원래 글(줄 번호 → 글, 11차 F-8). 치는 동안 화면을 바로 보이려고 줄 객체를 먼저 고치는데
|
|
/// 되돌리기 목록에는 안 들어간다. 재정렬할 때 이것으로 친 것 전의 글을 되살려 한 걸음으로 올린다.
|
|
/// </summary>
|
|
private readonly Dictionary<int, string> _typedOriginals = new();
|
|
|
|
/// <summary>타이핑이 멈췄다. 필러를 걷어내고 BASE 와 다시 맞춘다.</summary>
|
|
private void Reflow()
|
|
{
|
|
_reflowTimer.Stop();
|
|
|
|
var current = SplitLines(CurrentEditorText);
|
|
// 편집기에는 빈 줄(필러)이 없다(11차 F-10) - 편집기 글이 곧 LOCAL 글이다.
|
|
var real = string.Join("\n", current);
|
|
|
|
// 편집기에서 친 것도 되돌리기 한 걸음이다(11차 F-8). 예전에는 재정렬이 목록을 통째로 비워,
|
|
// 글자 하나만 쳐도 그 전의 Accept 들을 Undo 할 수 없었다. 친 줄을 원래 글로 돌려 친 것 전의 글을 얻고,
|
|
// 그 상태에서 목록을 글 단위로 옮긴 뒤 친 것 전의 글을 한 걸음으로 올린다.
|
|
foreach (var typed in _typedOriginals)
|
|
if (typed.Key < Lines.Count) Lines[typed.Key].LocalText = typed.Value;
|
|
_typedOriginals.Clear();
|
|
|
|
var before = BuildLocalText();
|
|
FlattenHistory();
|
|
if (!string.Equals(before, real, StringComparison.Ordinal))
|
|
{
|
|
_textUndo.Push(before);
|
|
KeepNewest(_textUndo);
|
|
ClearRedo();
|
|
}
|
|
|
|
ReloadLinesFrom(real, keepAsEdited: true, moveSelection: false);
|
|
}
|
|
|
|
/// <summary>주어진 LOCAL 전문으로 비교 화면을 다시 만든다.</summary>
|
|
private void ReloadLinesFrom(string localText, bool keepAsEdited, bool moveSelection = true)
|
|
{
|
|
// 줄을 다시 세우면 되돌리기 목록이 가리키던 줄이 사라진다 - 그 전에 목록을 글 단위로 옮겨 둔다(11차 F-8).
|
|
// 예전에는 비교 규칙 하나만 바꿔도 목록이 비었다. [전체 되돌리기](keepAsEdited 아님)만 목록을 비운다.
|
|
if (keepAsEdited) FlattenHistory();
|
|
|
|
// 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>();
|
|
|
|
foreach (var row in rows)
|
|
{
|
|
// 반영한 줄인지를 <b>먼저</b> 본다.
|
|
//
|
|
// 반영한 줄은 글이 원본과 달라지므로 아래 "직접 수정" 조건에도 걸린다.
|
|
// 그대로 두면 규칙을 하나 건드리는 순간 반영한 줄이 전부 보라색(직접 수정)이 된다.
|
|
// 사람이 직접 친 것과 버튼으로 가져온 것은 뜻이 다르니 섞이면 안 된다.
|
|
row.IsApplied = IsMarkedApplied(row);
|
|
|
|
// 우리가 남긴 머지 주석 줄은 "사용자가 직접 넣은 줄"이 아니다.
|
|
// 표시가 줄 객체에만 있어서 다시 만들면 사라진다 - 글을 보고 되찾는다.
|
|
// "반영함" 으로도 칠하지 않는다(10차 C-1) - 넣는 순간은 반영 표시가 없는데(규칙 끔이면 "추가") 다시 정렬하면
|
|
// "머지 반영함" 이 되어 반영 수가 코드 줄 · 주석 줄 두 번 셌다. 넣는 순간과 같은 모양으로 둔다(9차 B-1 과 같은 원칙).
|
|
if (MergeNote.LooksLikeNote(row.LocalText, SelectedFile))
|
|
row.IsMergeNote = true;
|
|
|
|
if (!row.IsApplied && !row.IsMergeNote && row.LocalNumber is int number && touched.Contains(number))
|
|
row.IsManuallyEdited = true;
|
|
}
|
|
|
|
// 줄마다 넣으면 알림이 줄 수만큼 간다 - 표시를 다 매긴 뒤 한 번에 넣는다. 위 · 아래로 그대로인 줄은 기존 객체를 두고
|
|
// 번호만 고치고, 바뀐 가운데만 빼고 넣는다 - 칸 목록이 보이는 줄 요소를 전부 다시 만들지 않게(MergeLineCollection.Patch).
|
|
Lines.Patch(rows);
|
|
|
|
if (keepAsEdited) { _undo.Clear(); _redo.Clear(); } // 위에서 글로 옮겨 이미 비었다
|
|
else ClearHistory();
|
|
|
|
if (moveSelection)
|
|
SelectedLine = Lines.FirstOrDefault(l => l.IsDifferent) ?? Lines.FirstOrDefault();
|
|
|
|
HasChanges = _baseline.IsDirty(localText, _styleRawLocalText is not null);
|
|
|
|
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;
|
|
_typedOriginals.Clear(); // 편집기를 줄에서 다시 채웠다 - 친 것은 줄에 이미 들어갔다
|
|
_editorRows = snapshot.Rows;
|
|
|
|
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(BothWays))]
|
|
[NotifyPropertyChangedFor(nameof(ModeLabel))]
|
|
[NotifyPropertyChangedFor(nameof(BaseTitle))]
|
|
[NotifyPropertyChangedFor(nameof(LocalTitle))]
|
|
private bool _isGitMode;
|
|
|
|
public bool IsPlainMode => !IsGitMode;
|
|
|
|
public string ModeLabel => IsGitMode
|
|
? "Git 저장소"
|
|
: "파일 비교 (Git 아님)";
|
|
|
|
// 패널 이름은 머지툴 표준 용어를 그대로 쓴다(BASE · LOCAL/Mine · REMOTE/Theirs · MERGED).
|
|
// 한글 풀이를 덧붙이지 않는 이유 - 이 용어는 Git 과 다른 머지툴에서 그대로 쓰이므로
|
|
// 여기서만 다른 말로 부르면 오히려 옮겨 다닐 때 헷갈린다.
|
|
|
|
public string BaseTitle => "BASE";
|
|
|
|
/// <summary>고치고 저장하는 칸. 3-Way 에서는 이 칸이 곧 결과(MERGED)다.</summary>
|
|
public string LocalTitle => IsThreeWay ? "MERGED" : "LOCAL";
|
|
|
|
/// <summary>왼쪽(BASE) 으로 읽을 파일. Git 없이 비교할 때만 쓴다.</summary>
|
|
[ObservableProperty] private string _leftFile = string.Empty;
|
|
|
|
/// <summary>오른쪽(LOCAL) 으로 읽고, 저장할 때 덮어쓸 파일.</summary>
|
|
[ObservableProperty] private string _rightFile = string.Empty;
|
|
|
|
[RelayCommand]
|
|
private void PickLeftFile()
|
|
{
|
|
if (AskForFile("왼쪽(기준) 파일 고르기", LeftFile) is { } picked)
|
|
{
|
|
if (!ConfirmDiscard($"왼쪽 파일을 바꿉니다: {Path.GetFileName(picked)}")) return;
|
|
|
|
LeftFile = picked;
|
|
OnPropertyChanged(nameof(BaseFileLocation));
|
|
_ = LoadPlainFilesAsync();
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void PickRightFile()
|
|
{
|
|
if (AskForFile("오른쪽(내 작업) 파일 고르기", RightFile) is { } picked)
|
|
{
|
|
if (!ConfirmDiscard($"오른쪽 파일을 바꿉니다: {Path.GetFileName(picked)}")) return;
|
|
|
|
RightFile = picked;
|
|
OnPropertyChanged(nameof(SelectedFileFullPath));
|
|
_ = LoadPlainFilesAsync();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 끌어다 놓은 파일을 한쪽 칸에 넣고 <b>바로 다시 견준다.</b>
|
|
///
|
|
/// 칸 값만 바꾸고 두면 화면은 옛 파일을 그대로 보여 준다 - "놓았는데 아무 일도 없다"로 읽힌다.
|
|
/// 파일 고르기 단추와 같은 길(<c>RequestPlainCompare</c>)로 보내는 까닭은 그 길이
|
|
/// Git 모드를 끄는 것까지 함께 하기 때문이다. 저장소를 열어 둔 채로 파일을 끌어다 놓으면
|
|
/// 사용자가 보고 싶은 것은 그 두 파일이지 커밋 이력이 아니다.
|
|
/// </summary>
|
|
public void DropFile(string path, bool left)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path)) return;
|
|
if (!ConfirmDiscard($"놓은 파일을 엽니다: {Path.GetFileName(path)}")) return;
|
|
|
|
var newLeft = left ? path : LeftFile;
|
|
var newRight = left ? RightFile : path;
|
|
|
|
_workspace.RequestPlainCompare(newLeft, newRight);
|
|
}
|
|
|
|
// ================= 결과 내보내기 =================
|
|
|
|
private bool CanExportReport() => Lines.Count > 0;
|
|
|
|
/// <summary>
|
|
/// 비교 결과를 글로 뽑아 파일에 쓴다.
|
|
///
|
|
/// 화면을 캡처하는 것 말고는 결과를 남에게 보낼 방법이 없었다. 캡처는 검색도 안 되고
|
|
/// 줄 번호를 짚어 말하기도 어렵다.
|
|
/// </summary>
|
|
[RelayCommand(CanExecute = nameof(CanExportReport))]
|
|
private void ExportReport()
|
|
{
|
|
var dialog = new Microsoft.Win32.SaveFileDialog
|
|
{
|
|
Title = "파일 비교 결과 저장",
|
|
Filter = "텍스트 파일 (*.txt)|*.txt|모든 파일 (*.*)|*.*",
|
|
DefaultExt = ".txt",
|
|
FileName = $"파일비교_{DateTime.Now:yyyyMMdd_HHmm}.txt"
|
|
};
|
|
|
|
var start = PathUtil.FolderOfFile(RightFile.Length > 0 ? RightFile : LeftFile);
|
|
if (start.Length > 0) dialog.InitialDirectory = start;
|
|
|
|
bool ok;
|
|
try
|
|
{
|
|
ok = dialog.ShowDialog() == true;
|
|
}
|
|
catch (Exception)
|
|
{
|
|
dialog.InitialDirectory = string.Empty;
|
|
ok = dialog.ShowDialog() == true;
|
|
}
|
|
|
|
if (!ok) return;
|
|
|
|
try
|
|
{
|
|
// 머지 주석과 같은 자리에서 이름을 가져온다. 두 곳에 다른 이름이 찍히면 그 자체가 버그다.
|
|
var report = CompareReport.ForFile(ReportLabel(BaseHeader, LeftFile),
|
|
ReportLabel(LocalHeader, RightFile),
|
|
Lines, DateTime.Now,
|
|
MergeNote.AuthorOf(_workspace.AuthorName));
|
|
TextFile.Write(dialog.FileName, report);
|
|
MessageUtil.ShowSuccessToast($"저장했습니다 · {Path.GetFileName(dialog.FileName)}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
MessageUtil.ShowErrorToast($"저장하지 못했습니다: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 보고서에 적을 쪽 이름. Git 모드면 머리글(브랜치·커밋)이 더 쓸모 있고,
|
|
/// 파일 비교 모드면 경로가 있어야 어느 파일인지 알 수 있다.
|
|
/// </summary>
|
|
private string ReportLabel(string header, string path) =>
|
|
IsGitMode || path.Length == 0 ? header : path;
|
|
|
|
private static string? AskForFile(string title, string current)
|
|
{
|
|
var dialog = new Microsoft.Win32.OpenFileDialog
|
|
{
|
|
Title = title,
|
|
CheckFileExists = true,
|
|
Filter = "모든 파일 (*.*)|*.*"
|
|
};
|
|
|
|
// 시작 폴더는 PathUtil 을 거친다 - 슬래시가 섞인 경로를 그대로 넘기면
|
|
// 대입할 때가 아니라 ShowDialog() 안쪽에서 예외가 나 프로그램이 죽는다. PathUtil.cs 참고.
|
|
var start = PathUtil.FolderOfFile(current);
|
|
if (start.Length > 0) dialog.InitialDirectory = start;
|
|
|
|
try
|
|
{
|
|
return dialog.ShowDialog() == true ? dialog.FileName : null;
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// 셸이 시작 폴더를 거부하면 기본 위치에서 다시 연다.
|
|
dialog.InitialDirectory = string.Empty;
|
|
return dialog.ShowDialog() == true ? dialog.FileName : null;
|
|
}
|
|
|
|
}
|
|
|
|
/// <summary>고른 두 파일을 읽어 비교 화면을 만든다.</summary>
|
|
private async Task LoadPlainFilesAsync()
|
|
{
|
|
// 한쪽만 있어도 연다 - 탐색기 우클릭은 고른 파일 하나만 준다.
|
|
// 없는 쪽은 빈 내용이 되어 전부 추가(또는 삭제)로 보인다.
|
|
if (string.IsNullOrWhiteSpace(LeftFile) && string.IsNullOrWhiteSpace(RightFile)) return;
|
|
|
|
// 12차 E-2: 여는 길을 거치지 않고 바로 불린 경우에도 탭이 하나는 있게 한다
|
|
// (켤 때 기본 짝 · [기준 파일...] 로 한쪽만 바꾸기).
|
|
EnsureTabForCurrentPair();
|
|
|
|
// 파일 두 개 비교는 늘 두 칸이다. mergetool 네 칸에서 넘어왔으면 접는다.
|
|
_mergeTool = null;
|
|
SetToolThreeWay(false);
|
|
|
|
// 이진 파일은 줄 단위로 견줄 것이 없다. 깨진 글자로 화면을 채우느니 그렇다고 알린다.
|
|
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;
|
|
|
|
// C# 스타일 맞춤(6차 추가).
|
|
var rawLocalText = localText;
|
|
baseText = Styled(baseText);
|
|
localText = Styled(localText);
|
|
|
|
_baseTextSnapshot = baseText;
|
|
_remoteTextSnapshot = string.Empty; // 파일 두 개 비교는 늘 2-Way 다
|
|
_mineTextSnapshot = string.Empty;
|
|
_appliedBaseNumbers.Clear();
|
|
_appliedMineNumbers.Clear();
|
|
|
|
RefreshLanguage();
|
|
var rows = BuildLines(baseText, localText);
|
|
Lines.ReplaceAll(rows); // 알림 한 번(MergeLineCollection 참고)
|
|
|
|
SetPlainHeaders();
|
|
|
|
_baseline.Open(BuildLocalText());
|
|
_leftBaseline.Open(BuildBaseText()); // 12차 B-1 - 왼쪽도 연 시점을 따로 기억한다
|
|
HasLeftChanges = false;
|
|
SyncEditorFromLines();
|
|
|
|
var info = TextFile.Describe(RightFile);
|
|
RefreshLanguage();
|
|
FileEncoding = info.Encoding;
|
|
FileNewLine = info.NewLine;
|
|
|
|
ClearHistory();
|
|
|
|
// 새로 읽은 파일에는 고친 것이 없다. 이걸 안 끄면 앞 파일의 상태가 그대로 끌려와
|
|
// 저장 단추가 켜진 채로 남고, 다음에 파일을 열 때마다 "저장하지 않은 편집" 을 묻는다.
|
|
// 스타일 맞춤으로 글이 바뀌었을 때만 저장할 것이 있다.
|
|
MarkStyleChange(rawLocalText, localText);
|
|
|
|
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);
|
|
|
|
// 12차 E-2: 이 짝의 탭에 맡겨 둔 편집이 있으면 여기서 되살린다.
|
|
// 파일을 다 읽은 <b>뒤에</b> 해야 한다 - 먼저 하면 읽은 글이 되살린 글을 덮어쓴다.
|
|
ResumePendingTab();
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// 폴더 전체 검색이 짚어 준 줄이 몇 번째 행인지. 없거나 못 찾으면 -1.
|
|
///
|
|
/// 왼쪽 기준 번호는 BASE 칸, 오른쪽 기준 번호는 LOCAL 칸에서 찾는다 -
|
|
/// 한쪽에만 있는 줄이 끼면 두 번호가 어긋나므로 아무 쪽에서나 찾으면 엉뚱한 줄에 선다.
|
|
/// </summary>
|
|
private int FindRequestedRow()
|
|
{
|
|
var (line, isLeft) = _workspace.TakeRequestedLine();
|
|
if (line <= 0) return -1;
|
|
|
|
for (var i = 0; i < Lines.Count; i++)
|
|
{
|
|
var number = isLeft ? Lines[i].BaseNumber : Lines[i].LocalNumber;
|
|
if (number == line) return i;
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 고친 것이 있으면 저장할 수 있다. mergetool 네 칸은 <b>안 고쳤어도 충돌이 다 풀렸으면</b> 저장할 수 있다 -
|
|
/// 상대 쪽이 고친 줄이 저절로 들어가 LOCAL 과 같은 글이 되는 경우가 있고, 그래도 MERGED 파일에는 써야 한다.
|
|
/// </summary>
|
|
private bool CanSavePlain() => IsPlainMode && !string.IsNullOrWhiteSpace(RightFile) &&
|
|
(HasChanges || (_toolThreeWay && UnresolvedConflictCount == 0));
|
|
|
|
/// <summary>
|
|
/// 왼쪽(BASE) 파일에 저장할 것이 있는지(12차 B-1).
|
|
///
|
|
/// 두 파일 비교에서만 왼쪽으로 반영할 수 있으므로 Git 모드 · 네 칸에서는 늘 거짓이다 -
|
|
/// 거기서 왼쪽은 지난 커밋이라 고쳐 쓸 파일이 아니다(사용자 결정 2026-09-27).
|
|
/// </summary>
|
|
private bool CanSaveLeftPlain() => BothWays && !string.IsNullOrWhiteSpace(LeftFile) && HasLeftChanges;
|
|
|
|
/// <summary>왼쪽 파일에 그대로 쓴다. 오른쪽은 건드리지 않는다(12차 B-1).</summary>
|
|
[RelayCommand(CanExecute = nameof(CanSaveLeftPlain))]
|
|
private async Task SaveLeftPlainAsync()
|
|
{
|
|
var content = BuildBaseText();
|
|
var target = LeftFile;
|
|
|
|
var encoding = SaveEncoding;
|
|
var newLine = SaveNewLine;
|
|
|
|
var ok = await _workspace.RunAsync("왼쪽 파일 저장",
|
|
() => WritePlainFile(target, content, encoding, newLine));
|
|
if (!ok) return;
|
|
|
|
_workspace.OnUi(() =>
|
|
{
|
|
MarkLeftSaved(content);
|
|
MessageUtil.ShowSuccessToast($"왼쪽 저장 완료 · {Path.GetFileName(target)}");
|
|
});
|
|
}
|
|
|
|
/// <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("파일 저장",
|
|
() => WritePlainFile(target, content, encoding, newLine));
|
|
if (!ok) return;
|
|
|
|
_workspace.OnUi(() =>
|
|
{
|
|
MarkSaved(content);
|
|
MessageUtil.ShowSuccessToast($"저장 완료 · {Path.GetFileName(target)}");
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// 파일 두 개를 견줄 때의 패널 머리글.
|
|
///
|
|
/// <b>파일 이름만 적지 않는다.</b> 파일 비교는 다른 자리에 있는 파일을 견주는 일이라
|
|
/// 이름이 같은 경우가 흔하고(같은 파일의 두 판), 그러면 양쪽 머리글이 똑같아져
|
|
/// 어느 쪽이 어느 쪽인지 알 수 없다. 그렇다고 전체 경로를 다 적으면 길어서 읽히지 않는다.
|
|
/// 그래서 <see cref="PathUtil.DistinctTail"/> 로 <b>같은 앞부분만 떼고</b> 나머지를 적는다.
|
|
/// 전체 경로는 툴팁과 우클릭 복사에 그대로 있다.
|
|
/// </summary>
|
|
private void SetPlainHeaders()
|
|
{
|
|
var (left, right) = PathUtil.DistinctTail(LeftFile, RightFile);
|
|
|
|
// 큰 제목이 이미 BASE/LOCAL 이라고 말한다. 여기에 또 적으면 자리만 먹는다.
|
|
var l = SplitLead(left);
|
|
BaseHeaderLead = l.Lead;
|
|
BaseHeader = l.Tail;
|
|
|
|
var r = SplitLead(right);
|
|
LocalHeaderLead = r.Lead;
|
|
LocalHeader = r.Tail;
|
|
|
|
_baseSourceName = string.IsNullOrEmpty(LeftFile) ? null : Path.GetFileName(LeftFile);
|
|
OnPropertyChanged(nameof(MergeNotePreview));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 경로에서 <b>실제로 갈리는 첫 칸</b>만 떼어 낸다.
|
|
///
|
|
/// <see cref="PathUtil.DistinctTail"/> 이 같은 앞부분을 이미 뗐으므로, 남은 것의 첫 칸이
|
|
/// 두 쪽을 가르는 자리다(<c>left\</c> · <c>branch\</c>). 그 칸만 굵게 칠하면
|
|
/// <b>어디가 다른지 눈이 바로 간다</b> - 나머지는 양쪽이 같은 글자라 읽을 값이 없다.
|
|
/// 칸이 하나뿐이면(파일 이름만 남은 경우) 뗄 것이 없다.
|
|
/// </summary>
|
|
// 'Rest' 는 ValueTuple 이 제 필드 이름으로 쓰는 예약어라 튜플 요소 이름으로 못 쓴다.
|
|
private static (string Lead, string Tail) SplitLead(string path)
|
|
{
|
|
if (string.IsNullOrEmpty(path)) return (string.Empty, string.Empty);
|
|
|
|
var at = path.IndexOfAny(new[] { '\\', '/' });
|
|
return at < 0 ? (string.Empty, path) : (path.Substring(0, at + 1), path.Substring(at + 1));
|
|
}
|
|
|
|
/// <summary>머리글에서 굵게 칠할 앞부분. 없으면 빈 글자.</summary>
|
|
[ObservableProperty] private string _baseHeaderLead = string.Empty;
|
|
|
|
[ObservableProperty] private string _localHeaderLead = string.Empty;
|
|
|
|
// ================= 저장 안 한 편집 지키기 =================
|
|
|
|
/// <summary>
|
|
/// 프로그램이 스스로 화면을 다시 그리는 중. 그때는 묻지 않는다.
|
|
/// 사용자가 고른 것이 아닌데 창이 뜨면 무엇에 답하는지 알 수 없다.
|
|
/// </summary>
|
|
private bool _suspendGuard;
|
|
|
|
/// <summary>
|
|
/// 지금 <see cref="ConfirmDiscard"/> 를 부르면 창이 뜨는지. 저장 안 한 편집이 있을 때만 묻는다.
|
|
/// 스타일 맞춤으로만 바뀐 글은 다시 열면 똑같이 만들어진다 - 묻지 않는다.
|
|
/// 묻게 두면 스타일이 다른 파일을 훑을 때마다 창이 떠서 쓸 수가 없다.
|
|
/// </summary>
|
|
private bool WillAskBeforeDiscard =>
|
|
!_suspendGuard && ((HasChanges && !OnlyStyleChanged) || HasLeftChanges); // 왼쪽도 본다(12차 B-1)
|
|
|
|
/// <summary>
|
|
/// 고치던 것이 있으면 물어보고, 계속해도 되면 true 를 준다.
|
|
///
|
|
/// <b>왜 세 갈래인가</b> - 예/아니오로는 "버리고 가기"와 "그만두기"가 구분되지 않는다.
|
|
/// 아니오를 눌렀을 때 그대로 열려 버리면 그것이 곧 잃는 것이다.
|
|
/// 그래서 플랫폼에 <c>ShowChoice</c> 를 새로 넣었다.
|
|
///
|
|
/// 고친 것이 없으면 묻지 않는다. 넘어가도 되는 경우까지 묻는 것이 더 나쁘다 -
|
|
/// 매번 뜨는 창은 읽지 않고 누르게 되고, 정작 위험할 때도 그냥 눌러 버린다.
|
|
/// </summary>
|
|
/// <param name="what">이제 무엇을 하려는지. 창에 그대로 적힌다.</param>
|
|
private bool ConfirmDiscard(string what)
|
|
{
|
|
if (!WillAskBeforeDiscard) return true;
|
|
|
|
// <b>고친 파일</b>의 이름을 댄다(6차 B-2). 파일을 바꿀 때는 이 창이 뜨는 순간 SelectedFile 이 이미 새 파일이라,
|
|
// 예전에는 그 새 파일 이름을 댔다 - Motion/Unit0.cs 를 고쳤는데 "ToBeDeleted.cs 에 저장하지 않은 편집" 이라고 했다.
|
|
// Git 모드는 저장소 안 경로로 적는다. 다른 폴더에 같은 이름 파일이 흔하다.
|
|
var editing = IsPlainMode ? RightFile : (_lastLoadedFile ?? SelectedFile);
|
|
var name = string.IsNullOrWhiteSpace(editing)
|
|
? "지금 파일"
|
|
: IsPlainMode ? Path.GetFileName(editing) : editing;
|
|
|
|
var choice = MessageUtil.ShowChoice(
|
|
"저장하지 않은 편집",
|
|
$"{name} 에 저장하지 않은 편집이 있습니다.\n\n{what}\n\n그대로 두면 고친 내용이 사라집니다.",
|
|
"저장하고 계속", "저장하지 않고 계속");
|
|
|
|
return choice switch
|
|
{
|
|
// 저장이 실패하면 넘어가지 않는다. 여기서 넘어가면 저장한 줄 알고 잃는다.
|
|
DialogChoice.Primary => SaveWorkingCopy(),
|
|
DialogChoice.Secondary => true,
|
|
_ => false
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 고친 내용을 지금 파일에 쓴다. <b>커밋은 하지 않는다.</b>
|
|
///
|
|
/// Git 모드의 "저장 후 커밋"은 커밋 메시지가 있어야 한다. 확인 창에서 메시지를 받을 수는 없으므로
|
|
/// 여기서는 워킹트리 파일에 쓰는 데까지만 한다 - 커밋은 사용자가 따로 할 일이다.
|
|
/// 작업 게이트를 타지 않는다. 파일 하나를 쓰는 일이라 Git 작업과 줄 세울 까닭이 없고,
|
|
/// 확인 창은 UI 스레드에서 답을 기다리는 중이라 게이트를 기다리면 서로 막힌다.
|
|
/// </summary>
|
|
private bool SaveWorkingCopy()
|
|
{
|
|
// <b>지금 화면에 실려 있는 파일</b>에 쓴다(6차 B-2). 파일을 바꾸다 이 확인 창이 떴을 때는 SelectedFile 이
|
|
// 이미 새 파일이다. 예전에는 그 새 파일에 썼다 - Io/Unit20.cs 를 고치고 Io/Unit12.cs 로 옮기며
|
|
// "저장하고 계속" 을 누르자 <b>Unit20 의 글이 Unit12 에 써지고 Unit12 가 해결된 것으로 스테이징됐다.</b>
|
|
var loaded = IsPlainMode ? null : (_lastLoadedFile ?? SelectedFile);
|
|
|
|
var target = IsPlainMode
|
|
? RightFile
|
|
: string.IsNullOrEmpty(loaded)
|
|
? string.Empty
|
|
: Path.Combine(_workspace.RepositoryPath,
|
|
loaded!.Replace('/', Path.DirectorySeparatorChar));
|
|
|
|
if (string.IsNullOrWhiteSpace(target))
|
|
{
|
|
MessageUtil.ShowErrorToast("저장할 파일을 알 수 없습니다.");
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
var content = BuildLocalText();
|
|
|
|
// 병합 중 충돌 파일을 워킹트리에만 쓰면 git 은 여전히 충돌로 본다. 충돌 표시는 사라졌는데
|
|
// 인덱스는 풀리지 않은 어정쩡한 상태가 된다. [해결로 저장] 과 같은 길로 보낸다(6차 A-2).
|
|
var conflictPath = loaded;
|
|
if (conflictPath is not null && _workspace.IsMergeInProgress &&
|
|
_workspace.Git.GetConflicts().Any(c => c.Path == conflictPath))
|
|
{
|
|
_workspace.Git.ResolveConflictWithContent(conflictPath, content, SaveEncoding, SaveNewLine);
|
|
}
|
|
else
|
|
{
|
|
WritePlainFile(target, content, SaveEncoding, SaveNewLine);
|
|
}
|
|
|
|
MarkSaved(content);
|
|
|
|
MessageUtil.ShowSuccessToast($"저장 완료 · {Path.GetFileName(target)}");
|
|
|
|
// 왼쪽에도 고친 것이 있으면 함께 쓴다(12차 B-1). 한쪽만 쓰면 "저장하고 계속" 을 눌러도 나머지가 사라진다.
|
|
return !HasLeftChanges || SaveLeftWorkingCopy();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
MessageUtil.ShowErrorToast($"저장하지 못했습니다: {ex.Message}");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>왼쪽 파일에 지금 글을 쓴다(12차 B-1). 확인 창의 [저장하고 계속] 도 이 길로 온다.</summary>
|
|
private bool SaveLeftWorkingCopy()
|
|
{
|
|
if (!BothWays || string.IsNullOrWhiteSpace(LeftFile)) return true;
|
|
|
|
try
|
|
{
|
|
var content = BuildBaseText();
|
|
WritePlainFile(LeftFile, content, SaveEncoding, SaveNewLine);
|
|
MarkLeftSaved(content);
|
|
MessageUtil.ShowSuccessToast($"왼쪽 저장 완료 · {Path.GetFileName(LeftFile)}");
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
MessageUtil.ShowErrorToast($"왼쪽 파일을 저장하지 못했습니다: {ex.Message}");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ================= 무시 규칙 =================
|
|
|
|
/// <summary>
|
|
/// "무엇을 차이로 볼 것인가". 설정에서 읽어 두고 툴바에서 바로 바꾼다.
|
|
/// 바꾸면 곧바로 다시 정렬해서 화면에 반영한다.
|
|
/// </summary>
|
|
public CompareRules Rules { get; } = new CompareRules();
|
|
|
|
/// <summary>툴바 버튼에 띄우는 지금의 공백 규칙.</summary>
|
|
public string WhitespaceLabel => Rules.Whitespace switch
|
|
{
|
|
WhitespaceMode.IgnoreAmount => "공백 개수↓",
|
|
WhitespaceMode.IgnoreAll => "공백 무시",
|
|
_ => "공백 비교"
|
|
};
|
|
|
|
public string IgnoreCaseLabel => Rules.IgnoreCase ? "Aa 무시" : "Aa 구분";
|
|
|
|
public string LineFilterLabel => Rules.LineFilterEnabled ? "필터 켬" : "필터 끔";
|
|
|
|
/// <summary>상태바에 띄우는 요약. 켠 규칙이 없으면 빈 문자열.</summary>
|
|
[ObservableProperty] private string _rulesSummary = string.Empty;
|
|
|
|
/// <summary>설정에 저장된 값을 규칙 객체로 옮긴다.</summary>
|
|
private void LoadRules()
|
|
{
|
|
var settings = _workspace.Settings;
|
|
|
|
Rules.Whitespace = settings.WhitespaceMode switch
|
|
{
|
|
1 => WhitespaceMode.IgnoreAmount,
|
|
2 => WhitespaceMode.IgnoreAll,
|
|
_ => settings.IgnoreWhitespace ? WhitespaceMode.IgnoreAmount : WhitespaceMode.Exact
|
|
};
|
|
|
|
Rules.IgnoreCase = settings.IgnoreCase;
|
|
Rules.LineFilterEnabled = settings.UseLineFilters;
|
|
Rules.IgnoreMergeNotes = settings.IgnoreMergeNotes;
|
|
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}|{s.IgnoreMergeNotes}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// 홈 화면으로 돌아왔을 때 부른다.
|
|
/// 설정 화면에서 비교 규칙을 고치고 저장했으면 그때 한 번만 다시 비교한다.
|
|
/// (규칙은 BuildLines 가 매번 읽지만, 누군가 다시 비교를 시켜 줘야 화면이 바뀐다)
|
|
/// </summary>
|
|
public void RecompareIfSettingsChanged()
|
|
{
|
|
var font = CodeFontMetrics.Clamp(_workspace.Settings.CodeFontSize);
|
|
if (font != CodeFontSize) CodeFontSize = font;
|
|
OnPropertyChanged(nameof(IsBackupOn)); // 11차 F-9 - 설정에서 켜고 끄면 [백업 폴더] 단추가 따라간다
|
|
|
|
// C# 스타일은 원본부터 다시 읽어야 맞춰진다(편집 칸만 다시 정렬하면 BASE 가 옛 스타일로 남는다).
|
|
if (_workspace.Settings.CSharpBraceStyle != _appliedStyle && Lines.Count > 0)
|
|
{
|
|
OnPropertyChanged(nameof(CSharpStyleIndex));
|
|
if (ConfirmDiscard("설정에서 C# 스타일이 바뀌어 다시 엽니다.")) ReloadCurrentSource();
|
|
return;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
// ================= C# 코드 스타일 맞춤 (6차 추가) =================
|
|
|
|
/// <summary>콤보에 보이는 이름. 순서가 <see cref="CSharpBraceStyle"/> 값과 같다.</summary>
|
|
public IReadOnlyList<string> CSharpStyleNames { get; } = new[] { "C# 스타일 끔", "C# AURORA", "C# Allman" };
|
|
|
|
/// <summary>
|
|
/// 고른 스타일. 바꾸면 지금 파일을 <b>원본부터 다시 읽어</b> 그 스타일로 맞춘다.
|
|
/// 공백 · 대소문자 규칙과 달리 편집 칸 글만 다시 정렬해서는 안 된다 - BASE · REMOTE 도 다시 맞춰야 한다.
|
|
/// </summary>
|
|
public int CSharpStyleIndex
|
|
{
|
|
get => (int)_workspace.Settings.CSharpBraceStyle;
|
|
set
|
|
{
|
|
var style = (CSharpBraceStyle)Math.Max(0, Math.Min(2, value));
|
|
if (style == _workspace.Settings.CSharpBraceStyle) return;
|
|
|
|
if (!ConfirmDiscard($"C# 스타일을 {CSharpStyle.Label(style)} 로 바꿔 다시 엽니다."))
|
|
{
|
|
// 콤보는 제 선택 처리 중에 되돌린 값을 무시한다 - 다음 차례에 알린다(6차 B-2 와 같다).
|
|
Dispatcher.CurrentDispatcher.BeginInvoke(
|
|
new Action(() => OnPropertyChanged(nameof(CSharpStyleIndex))), DispatcherPriority.Background);
|
|
return;
|
|
}
|
|
|
|
_workspace.Settings.CSharpBraceStyle = style;
|
|
_workspace.SaveSettings();
|
|
OnPropertyChanged();
|
|
ReloadCurrentSource();
|
|
}
|
|
}
|
|
|
|
/// <summary>스타일 맞춤을 걸기 전의 편집 칸 글. 맞춤으로 글이 바뀌었을 때만 있다.</summary>
|
|
private string? _styleRawLocalText;
|
|
|
|
/// <summary>지금 불러온 글에 적용한 스타일. 설정 화면에서 바꾸고 돌아왔는지 알아보려고 둔다.</summary>
|
|
private CSharpBraceStyle _appliedStyle;
|
|
|
|
/// <summary>고친 것이 스타일 맞춤뿐인지(사람이 손대지 않았다).</summary>
|
|
private bool OnlyStyleChanged =>
|
|
_styleRawLocalText is not null && string.Equals(BuildLocalText(), _originalLocalText, StringComparison.Ordinal);
|
|
|
|
/// <summary>C# 파일이고 스타일을 골랐으면 그 스타일로 맞춘 글. 아니면 그대로.</summary>
|
|
private string Styled(string text) =>
|
|
CSharpStyle.Applies(CurrentFilePath, _workspace.Settings.CSharpBraceStyle)
|
|
? CSharpStyle.Format(text, _workspace.Settings.CSharpBraceStyle)
|
|
: text;
|
|
|
|
/// <summary>
|
|
/// 불러오기를 마칠 때 부른다. 스타일 맞춤으로 편집 칸 글이 바뀌었으면 <b>저장할 것이 있는</b> 상태로 둔다 -
|
|
/// 저장하면 그 스타일이 파일에 반영된다. 안 바뀌었으면 고친 것 없음.
|
|
/// </summary>
|
|
private void MarkStyleChange(string raw, string styled)
|
|
{
|
|
_appliedStyle = _workspace.Settings.CSharpBraceStyle;
|
|
_styleRawLocalText = null;
|
|
HasChanges = false;
|
|
RefreshRuleLabels(); // 상태바 "무시: …" 요약에 지금 스타일을 적는다
|
|
|
|
if (string.Equals(raw, styled, StringComparison.Ordinal)) return;
|
|
|
|
_styleRawLocalText = raw;
|
|
HasChanges = true;
|
|
|
|
var before = raw.Split('\n').Length;
|
|
var after = styled.Split('\n').Length;
|
|
_workspace.AppendLog($"C# 스타일({CSharpStyle.Label(_appliedStyle)})로 맞춤: 편집 칸 {before}줄 → {after}줄. 저장하면 파일에 반영됩니다.");
|
|
}
|
|
|
|
/// <summary>지금 보고 있는 파일을 원본부터 다시 읽는다. Git · 파일 두 개 · mergetool 중 지금 길로.</summary>
|
|
private void ReloadCurrentSource()
|
|
{
|
|
if (IsGitMode) _ = LoadFileAsync();
|
|
else if (_mergeTool is { } tool) _ = LoadMergeToolAsync(tool);
|
|
else if (!string.IsNullOrWhiteSpace(LeftFile) || !string.IsNullOrWhiteSpace(RightFile)) _ = LoadPlainFilesAsync();
|
|
}
|
|
|
|
/// <summary>규칙이 바뀌면 지금 보고 있는 내용을 그대로 다시 정렬한다.</summary>
|
|
private void ApplyRuleChange()
|
|
{
|
|
_appliedSettingsSignature = SettingsSignature();
|
|
RefreshRuleLabels();
|
|
|
|
if (Lines.Count == 0) return;
|
|
|
|
// 편집 중이던 내용을 잃지 않도록 지금 편집기에 있는 글을 기준으로 다시 만든다.
|
|
var current = SplitLines(CurrentEditorText); // 편집기 글 = LOCAL 글(11차 F-10 - 빈 줄 없음)
|
|
ReloadLinesFrom(string.Join("\n", current), keepAsEdited: true, moveSelection: false);
|
|
}
|
|
|
|
private void RefreshRuleLabels()
|
|
{
|
|
OnPropertyChanged(nameof(WhitespaceLabel));
|
|
OnPropertyChanged(nameof(IgnoreCaseLabel));
|
|
OnPropertyChanged(nameof(LineFilterLabel));
|
|
var style = _workspace.Settings.CSharpBraceStyle;
|
|
var summary = Rules.Describe() ?? string.Empty;
|
|
RulesSummary = style == CSharpBraceStyle.None
|
|
? summary
|
|
: string.IsNullOrEmpty(summary) ? $"C# {CSharpStyle.Label(style)}" : $"{summary} · C# {CSharpStyle.Label(style)}";
|
|
}
|
|
|
|
// ================= 차이 탐색 · 이동 · 북마크 =================
|
|
|
|
/// <summary>
|
|
/// 차이 "덩어리"의 시작 행 번호들. 이어진 차이 줄 여러 개는 한 덩어리로 센다 -
|
|
/// 사용자가 보기에 12줄이 통째로 지워진 것은 차이 하나이지 열두 개가 아니기 때문이다.
|
|
/// </summary>
|
|
/// <summary>
|
|
/// 차이 덩어리 시작 위치. 줄 목록이 바뀔 때만 다시 센다.
|
|
///
|
|
/// 예전에는 캐럿이 움직일 때마다 전체 줄을 훑으며 Highlight 를 계산했다.
|
|
/// Highlight 는 무시 규칙까지 적용해 두 쪽 글을 정규화하므로, 글자 하나 칠 때마다
|
|
/// 파일 전체를 정규화하는 셈이었다 - 타이핑이 느려지던 진짜 원인이다.
|
|
/// </summary>
|
|
private List<int>? _diffStarts;
|
|
|
|
private List<int> DiffBlockStarts() => _diffStarts ??= ComputeDiffBlockStarts();
|
|
|
|
/// <summary>줄 목록이나 줄 상태가 바뀌면 다시 세도록 표시만 해 둔다.</summary>
|
|
private void InvalidateDiffBlocks()
|
|
{
|
|
_diffStarts = null;
|
|
_sourceMembers = 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();
|
|
AcceptBlockToBaseCommand.NotifyCanExecuteChanged(); // 12차 G-2 - 왼쪽으로 보내는 짝
|
|
AcceptMemberCommand.NotifyCanExecuteChanged();
|
|
OnPropertyChanged(nameof(MemberHint));
|
|
OnPropertyChanged(nameof(CaretLine));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 캐럿이 있는 행. 화면 아래 "지금 줄" 칸이 이 행의 BASE · LOCAL 을 위아래로 보여 준다(2026-10-01 사용자 요청 -
|
|
/// 다음 차이로 옮겨 가면 무엇이 바뀌었는지 아래에서 한 줄씩 견줘 보고 싶다). 행이 다시 만들어지면 <see cref="UpdateSummaries"/> 가 다시 알린다.
|
|
/// </summary>
|
|
public MergeLine? CaretLine => CaretRow >= 0 && CaretRow < Lines.Count ? Lines[CaretRow] : null;
|
|
|
|
/// <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 = "차이 없음";
|
|
DiffProgress = PaneSummaries.ProgressText(0, 0);
|
|
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}";
|
|
|
|
// 가운데에 적는 진행도 같은 값에서 나온다(12차 B-3).
|
|
DiffProgress = PaneSummaries.ProgressText(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)
|
|
{
|
|
// 편집기 줄 번호 = 파일 줄 번호(11차 F-10 - 편집기에 빈 줄이 없다). 선택 줄 · 차이 위치는 정렬 행으로 센다.
|
|
CaretPosition = $"{row + 1}행 {column + 1}열";
|
|
row = RowOfEditorLine(row);
|
|
|
|
// 프로그램이 옮기는 중이면 목표 행이 이미 정해져 있다 - 그 값을 지킨다.
|
|
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));
|
|
}
|
|
|
|
|
|
/// <summary>새 편집을 했다 - 다시하기는 줄 단위 · 글 단위 모두 뜻을 잃는다.</summary>
|
|
private void ClearRedo()
|
|
{
|
|
_redo.Clear();
|
|
_textRedo.Clear();
|
|
}
|
|
|
|
/// <summary>다른 파일을 열었거나 화면을 비웠다 - 이력을 모두 버린다.</summary>
|
|
private void ClearHistory()
|
|
{
|
|
_undo.Clear();
|
|
_redo.Clear();
|
|
_textUndo.Clear();
|
|
_textRedo.Clear();
|
|
}
|
|
|
|
/// <summary>한 묶음(덩어리 · 메서드 반영은 여러 줄이 한 묶음, 11차 F-2)을 꺼낸다. 꺼낸 순서대로.</summary>
|
|
private static List<LineEdit> PopGroup(Stack<LineEdit> from)
|
|
{
|
|
var edit = from.Pop();
|
|
var group = new List<LineEdit> { edit };
|
|
while (edit.Group != 0 && from.Count > 0 && from.Peek().Group == edit.Group) group.Add(from.Pop());
|
|
return group;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 줄 단위 이력을 글 단위로 옮긴다(11차 F-8). 줄을 다시 세우기 <b>전에</b> 부른다.
|
|
/// 되돌리기 쪽은 한 묶음씩 거꾸로 밟아 가며 그 지점의 전문을 적고, 다시하기 쪽은 앞으로 밟아 적은 뒤
|
|
/// 화면을 지금 상태로 돌려 놓는다. 한 묶음이 한 걸음이다 - Undo 한 번에 돌아가던 만큼 그대로 돌아간다.
|
|
/// </summary>
|
|
private void FlattenHistory()
|
|
{
|
|
if (_undo.Count == 0 && _redo.Count == 0) return;
|
|
|
|
var undoTexts = new List<string>(); // 가까운 것부터
|
|
var undone = new List<LineEdit>();
|
|
while (_undo.Count > 0)
|
|
{
|
|
foreach (var e in PopGroup(_undo)) { Restore(e, toBefore: true); undone.Add(e); }
|
|
undoTexts.Add(BuildLocalText());
|
|
}
|
|
for (var i = undone.Count - 1; i >= 0; i--) Restore(undone[i], toBefore: false);
|
|
|
|
var redoTexts = new List<string>(); // 가까운 것부터
|
|
var redone = new List<LineEdit>();
|
|
while (_redo.Count > 0)
|
|
{
|
|
foreach (var e in PopGroup(_redo)) { Restore(e, toBefore: false); redone.Add(e); }
|
|
redoTexts.Add(BuildLocalText());
|
|
}
|
|
for (var i = redone.Count - 1; i >= 0; i--) Restore(redone[i], toBefore: true);
|
|
|
|
// 이미 든 글 단위 이력은 더 먼 쪽이다 - 그 위에 먼 것부터 쌓아 가장 가까운 것이 맨 위.
|
|
for (var i = undoTexts.Count - 1; i >= 0; i--) _textUndo.Push(undoTexts[i]);
|
|
for (var i = redoTexts.Count - 1; i >= 0; i--) _textRedo.Push(redoTexts[i]);
|
|
KeepNewest(_textUndo);
|
|
KeepNewest(_textRedo);
|
|
}
|
|
|
|
/// <summary>글 단위 이력은 한 걸음이 파일 전문 한 벌이다 - 가장 먼 것부터 버려 이만큼만 둔다.</summary>
|
|
private const int TextHistoryLimit = 300;
|
|
|
|
private static void KeepNewest(Stack<string> stack)
|
|
{
|
|
if (stack.Count <= TextHistoryLimit) return;
|
|
var keep = stack.Take(TextHistoryLimit).Reverse().ToList(); // 맨 위(가까운 것)부터 300개
|
|
stack.Clear();
|
|
foreach (var text in keep) stack.Push(text);
|
|
}
|
|
|
|
/// <summary>글 단위로 한 걸음(되돌리기 또는 다시하기). 지금 글을 반대쪽에 올리고 꺼낸 글로 줄을 다시 세운다.</summary>
|
|
private void StepText(Stack<string> from, Stack<string> to)
|
|
{
|
|
// 줄 단위 다시하기가 남아 있으면(줄 편집을 되돌리다 글 단위로 넘어옴) 그것도 먼저 글로 옮긴다 - 안 그러면 다시 세우며 사라진다.
|
|
FlattenHistory();
|
|
var target = from.Pop();
|
|
to.Push(BuildLocalText());
|
|
ReloadLinesFrom(target, keepAsEdited: true, moveSelection: false);
|
|
RaiseCanExecuteChanged();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 파일에 썼다(11차 F-8). 되돌리기 · 다시하기 목록은 <b>두고</b> 저장 지점만 옮긴다 -
|
|
/// 저장한 뒤에도 Undo 로 저장 전 글로 갈 수 있다. 스타일 맞춤은 파일에 들어갔으니 끝났다.
|
|
/// </summary>
|
|
/// <summary>
|
|
/// 두 파일 비교에서 <b>양쪽으로</b> 반영 · 저장할 수 있는지(12차 B-1).
|
|
///
|
|
/// Git 모드에서는 왼쪽이 지난 커밋이라 고칠 파일이 없고, 네 칸(3-Way)의 BASE 는 공통 조상이라
|
|
/// 거기에 쓰는 것이 뜻이 없다. 그래서 파일 두 개를 견줄 때만 켠다(사용자 결정 2026-09-27).
|
|
/// </summary>
|
|
public bool BothWays => IsPlainMode && !IsThreeWay;
|
|
|
|
/// <summary>왼쪽 파일에 저장하지 않은 것이 있는지(12차 B-1).</summary>
|
|
[ObservableProperty] private bool _hasLeftChanges;
|
|
|
|
/// <summary>왼쪽 파일의 저장 기준점. 오른쪽 것(<see cref="_baseline"/>)과 따로 센다.</summary>
|
|
private readonly SaveBaseline _leftBaseline = new SaveBaseline();
|
|
|
|
/// <summary>왼쪽(BASE) 칸의 지금 글. 왼쪽에 없는 줄은 빠진다.</summary>
|
|
private string BuildBaseText() => PaneText.Build(Lines, MergeLine.TextPane.Base);
|
|
|
|
private void MarkLeftSaved(string content)
|
|
{
|
|
_leftBaseline.MarkSaved(content);
|
|
HasLeftChanges = _leftBaseline.IsDirty(BuildBaseText(), stylePending: false);
|
|
UpdateSummaries();
|
|
RaiseCanExecuteChanged();
|
|
}
|
|
|
|
private void MarkSaved(string content)
|
|
{
|
|
_baseline.MarkSaved(content);
|
|
_styleRawLocalText = null;
|
|
HasChanges = _baseline.IsDirty(BuildLocalText(), stylePending: false);
|
|
UpdateSummaries();
|
|
RaiseCanExecuteChanged();
|
|
}
|
|
|
|
private void AfterEdit()
|
|
{
|
|
// 저장한 뒤 Undo 하면 저장한 글과 달라져 "있음", Redo 로 저장 지점에 오면 "없음"(11차 F-8).
|
|
HasChanges = _baseline.IsDirty(BuildLocalText(), _styleRawLocalText is not null);
|
|
HasLeftChanges = _leftBaseline.IsDirty(BuildBaseText(), stylePending: false); // 12차 B-1
|
|
RefreshAppliedFlags();
|
|
RefreshBlockCommentState(Lines);
|
|
InvalidateDiffBlocks();
|
|
SyncEditorFromLines();
|
|
UpdateSummaries();
|
|
RaiseCanExecuteChanged();
|
|
}
|
|
|
|
/// <summary>편집 하나를 되돌리거나 다시 적용한다. 줄 존재 여부까지 함께 복원한다.</summary>
|
|
private void Restore(LineEdit edit, bool toBefore)
|
|
{
|
|
// 왼쪽으로 반영한 편집은 왼쪽으로 되돌린다(12차 B-1). 주석 줄을 넣고 빼는 것은 두 칸이 같다.
|
|
if (edit.Side == MergeLine.TextPane.Base)
|
|
{
|
|
edit.Line.BaseText = toBefore ? edit.Before : edit.After;
|
|
edit.Line.HasBase = toBefore ? edit.BeforeHasLocal : edit.AfterHasLocal;
|
|
RestoreNote(edit, toBefore);
|
|
return;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
RestoreNote(edit, toBefore);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 이 편집이 끼워 넣은 주석 줄도 함께 넣고 뺀다.
|
|
/// 본문만 되돌리고 주석을 남기면 파일에 유령 주석이 쌓인다.
|
|
/// </summary>
|
|
private void RestoreNote(LineEdit edit, bool toBefore)
|
|
{
|
|
if (edit.InsertedNote is not { } note) return;
|
|
|
|
if (toBefore)
|
|
{
|
|
var was = Lines.IndexOf(note);
|
|
if (was >= 0)
|
|
{
|
|
Lines.RemoveAt(was);
|
|
NoteRowMoved(was, -1); // 되돌리기로 주석이 빠지면 캐럿도 한 줄 올라와야 같은 줄에 남는다
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (Lines.Contains(note)) return;
|
|
|
|
var at = Lines.IndexOf(edit.Line);
|
|
if (at < 0) at = Lines.Count;
|
|
Lines.Insert(at, note);
|
|
NoteRowMoved(at, +1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// "이 줄은 반영했다"를 <b>줄 번호로</b> 기억해 둔다.
|
|
///
|
|
/// 반영 표시는 지금까지 되돌리기 스택에만 있었다. 그런데 비교 규칙을 바꾸면 화면을 통째로
|
|
/// 다시 만들면서 스택을 비우므로, 그때 표시가 사라지고 <b>글이 원본과 다르다는 이유만으로
|
|
/// "직접 수정"으로 칠해졌다.</b> 실제로 Accept 한 줄이 규칙 하나 건드리자 보라색이 됐다.
|
|
///
|
|
/// 열쇠는 얼어 있는 번호여야 한다 - BASE 줄 번호, 없으면(양쪽이 각자 새 줄을 넣은 자리)
|
|
/// MINE 줄 번호.
|
|
/// </summary>
|
|
private void MarkApplied(MergeLine line)
|
|
{
|
|
if (line.BaseNumber is int baseNumber) _appliedBaseNumbers.Add(baseNumber);
|
|
else if (line.OriginalLocalNumber is int mineNumber) _appliedMineNumbers.Add(mineNumber);
|
|
}
|
|
|
|
/// <summary>되돌리면 그 표시도 함께 지운다. 안 그러면 되돌린 줄이 계속 "반영함"으로 남는다.</summary>
|
|
private void UnmarkApplied(MergeLine line)
|
|
{
|
|
if (line.BaseNumber is int baseNumber) _appliedBaseNumbers.Remove(baseNumber);
|
|
else if (line.OriginalLocalNumber is int mineNumber) _appliedMineNumbers.Remove(mineNumber);
|
|
}
|
|
|
|
/// <summary>
|
|
/// "반영했다"고 표시해 둔 줄인지.
|
|
///
|
|
/// 그 결정은 <b>글을 바꾸지 않는다</b>(이미 내 원본과 같은 줄이다). 그래서 되돌리기
|
|
/// 스택에도 남지 않고, 다시 정렬하면 줄 객체 자체가 새로 만들어진다.
|
|
/// 붙들어 둘 것이 줄 번호밖에 없어 여기에 따로 모아 둔다.
|
|
/// </summary>
|
|
private bool IsMarkedApplied(MergeLine line) =>
|
|
line.BaseNumber is int baseNumber
|
|
? _appliedBaseNumbers.Contains(baseNumber)
|
|
: line.OriginalLocalNumber is int mineNumber && _appliedMineNumbers.Contains(mineNumber);
|
|
|
|
/// <summary>
|
|
/// "머지 반영함"과 "직접 수정" 표시를 편집 이력에서 다시 계산한다.
|
|
/// 둘을 섞으면 안 된다 - 사용자가 직접 지운 줄이 "머지 반영함"으로 보이면 안 되기 때문이다.
|
|
/// Undo 로 되돌린 줄은 자동으로 표시가 풀린다.
|
|
/// </summary>
|
|
private void RefreshAppliedFlags()
|
|
{
|
|
var applied = new HashSet<MergeLine>();
|
|
var edited = new HashSet<MergeLine>();
|
|
|
|
foreach (var edit in _undo)
|
|
{
|
|
// 스택은 최신이 먼저다. 같은 줄을 여러 번 고쳤으면 마지막 편집의 종류를 따른다.
|
|
if (applied.Contains(edit.Line) || edited.Contains(edit.Line)) continue;
|
|
|
|
// 주석만 든 편집은 그 줄을 바꾸지 않았다 - 반영도 직접 수정도 아니다(11차 F-2).
|
|
if (edit.Kind == EditKind.NoteOnly) 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;
|
|
}
|
|
}
|
|
|
|
// ---- 칸마다 요약 · 가운데 진행 (12차 B-3) ----
|
|
|
|
/// <summary>왼쪽(BASE) 칸 아래 한 줄. 3-Way 에서는 기준이라 견줄 것이 없다.</summary>
|
|
[ObservableProperty] private string _baseSummary = string.Empty;
|
|
|
|
/// <summary>오른쪽(2-Way LOCAL · 3-Way MERGED) 칸 아래 한 줄.</summary>
|
|
[ObservableProperty] private string _localSummary = string.Empty;
|
|
|
|
/// <summary>3-Way 의 LOCAL(Mine) 칸 아래 한 줄.</summary>
|
|
[ObservableProperty] private string _mineSummary = string.Empty;
|
|
|
|
/// <summary>3-Way 의 REMOTE(Theirs) 칸 아래 한 줄.</summary>
|
|
[ObservableProperty] private string _remoteSummary = string.Empty;
|
|
|
|
/// <summary>칸 사이 틈 아래에 적는 진행 - 몇 번째 차이를 보고 있는지(<c>3 / 7</c>).</summary>
|
|
[ObservableProperty] private string _diffProgress = string.Empty;
|
|
|
|
/// <summary>그 아래 줄 - 아직 남은 차이 줄 수. 없으면 빈 글.</summary>
|
|
[ObservableProperty] private string _diffRemaining = string.Empty;
|
|
|
|
/// <summary>
|
|
/// 칸마다의 요약을 다시 센다.
|
|
///
|
|
/// 2-Way 는 서로 견주고(왼쪽 ↔ 오른쪽), 3-Way 는 <b>모두 BASE 와</b> 견준다 -
|
|
/// 공통 조상을 기준으로 누가 무엇을 고쳤는지가 그 화면에서 읽어야 할 것이기 때문이다.
|
|
/// 세는 일은 목록을 한 번 훑는 것이라(칸 수만큼) 긴 파일에서도 값이 싸다.
|
|
/// </summary>
|
|
private void UpdatePaneSummaries()
|
|
{
|
|
if (IsThreeWay)
|
|
{
|
|
BaseSummary = $"{Lines.Count(l => l.IsIn(MergeLine.TextPane.Base))}줄 · 기준";
|
|
MineSummary = PaneSummaries.For(Lines, MergeLine.TextPane.OriginalLocal, MergeLine.TextPane.Base).Text();
|
|
RemoteSummary = PaneSummaries.For(Lines, MergeLine.TextPane.Remote, MergeLine.TextPane.Base).Text();
|
|
LocalSummary = PaneSummaries.For(Lines, MergeLine.TextPane.Local, MergeLine.TextPane.Base).Text();
|
|
}
|
|
else
|
|
{
|
|
BaseSummary = PaneSummaries.For(Lines, MergeLine.TextPane.Base, MergeLine.TextPane.Local).Text();
|
|
LocalSummary = PaneSummaries.For(Lines, MergeLine.TextPane.Local, MergeLine.TextPane.Base).Text();
|
|
MineSummary = string.Empty;
|
|
RemoteSummary = string.Empty;
|
|
}
|
|
|
|
DiffRemaining = PaneSummaries.RemainingText(Lines.Count(l => l.IsDifferent));
|
|
}
|
|
|
|
private void UpdateSummaries()
|
|
{
|
|
OnPropertyChanged(nameof(CaretLine)); // 행이 다시 만들어지면 아래 "지금 줄" 칸이 옛 행을 붙들지 않게
|
|
|
|
// 줄을 고르거나 머지 주석이 끼어들면 결과 줄 수가 바뀐다. 그릴 때마다 번호를 다시 매긴다(8차 B-3).
|
|
RenumberMerged(Lines);
|
|
RenumberBase(Lines); // 왼쪽도 마찬가지다(12차 B-1 로 왼쪽이 바뀔 수 있게 됐다)
|
|
|
|
var total = Lines.Count;
|
|
var diff = Lines.Count(l => l.IsDifferent);
|
|
|
|
// 같은 숫자를 두 번 적지 않는다(4차 C-2). 예전에는 여기서 "차이 {diff} 라인" 을 적고
|
|
// 바로 아래 줄에서 "남은 차이 {diff} 라인" 을 또 적었다 - 어느 쪽을 봐야 하는지 헷갈렸다.
|
|
// 파일 크기(총 N줄)와 남은 일(차이 N줄)은 다른 것이므로 하나씩만 남긴다.
|
|
DiffSummary = total == 0 ? "-" : $"총 {total}줄";
|
|
DiffVersion++;
|
|
|
|
// 범례 수(남은 충돌 수 포함)를 먼저 센다. 아래 요약이 그 수를 쓴다 - 뒤에서 세면
|
|
// 상태바가 한 박자 늦어, 충돌을 다 정해도 "남은 충돌 1개" 로 남았다(6차 B-7 확인 중).
|
|
UpdateLegendCounts();
|
|
|
|
// 네 칸에서는 "남은 일" 이 차이 줄 수가 아니다(6차 B-7). 정한 충돌도 BASE 와는 여전히 달라
|
|
// "남은 차이 1줄" 이 끝까지 남았고, 남은 충돌은 오른쪽 칸에만 있었다.
|
|
// 사람이 할 일 두 가지만 센다 - 아직 안 정한 충돌, 아직 안 받은 상대 쪽 변경.
|
|
var work = diff == 0 ? "차이 없음" : $"남은 차이 {diff}줄";
|
|
if (IsThreeWay)
|
|
{
|
|
var conflicts = UnresolvedConflictCount;
|
|
var incoming = Lines.Count(l => l.ThreeWay == ThreeWayState.RemoteChanged && l.CanTakeRemote &&
|
|
!l.IsApplied && !l.IsManuallyEdited && !l.IsIgnored);
|
|
work = conflicts == 0 && incoming == 0
|
|
? "충돌 정리 끝"
|
|
: incoming == 0 ? $"남은 충돌 {conflicts}개" : $"남은 충돌 {conflicts}개 · 받을 변경 {incoming}줄";
|
|
}
|
|
else if (IsGitMode &&
|
|
OperationLabels.ResolvedSummary(_workspace.OperationState.Kind, _workspace.Conflicts.Count, diff,
|
|
baseIsHead: SelectedCommit?.ShortSha == _workspace.HeadSha) is { } resolved)
|
|
{
|
|
// 작업(병합 · rebase · cherry-pick · revert) 중 충돌을 다 푼 뒤의 두 칸(HEAD ↔ 워킹트리).
|
|
// 차이는 할 일이 아니라 방금 받아 놓은 변경이다(7차 A-1 · 8차 B-1).
|
|
// 기준이 HEAD 일 때만 그렇다 - BASE 제목이 "가져오기 전 내 쪽" 을 붙이는 조건과 같다(9차 A-1).
|
|
work = resolved;
|
|
}
|
|
|
|
// 주석만 든 편집(덩어리 · 메서드 반영의 주석 한 줄, 11차 F-2)은 세지 않는다 - 편집 수는 바뀐 줄 수다.
|
|
var edits = _undo.Count(e => e.Kind != EditKind.NoteOnly) + _textUndo.Count; // 글 단위는 한 걸음이 한 건(11차 F-8)
|
|
var redos = _redo.Count(e => e.Kind != EditKind.NoteOnly) + _textRedo.Count;
|
|
// 저장한 뒤에도 목록이 남으므로(11차 F-8) 저장한 지점인지를 같이 적는다.
|
|
EditSummary = edits == 0 && redos == 0
|
|
? work
|
|
: $"{work} · 편집 {edits}건 · 되돌리기 {redos}건{(HasChanges || edits == 0 ? string.Empty : " · 저장됨")}";
|
|
|
|
UpdatePaneSummaries(); // 12차 B-3 - 반영 · 되돌리기 · 저장 · 규칙 바꾸기가 모두 이 길을 지난다
|
|
RefreshProblems();
|
|
}
|
|
|
|
// ================= 이진 파일 비교 =================
|
|
|
|
/// <summary>이진 파일 비교 결과 한 줄 요약. "그래서 같다는 거야 다르다는 거야"에 답한다.</summary>
|
|
[ObservableProperty] private string _binarySummary = string.Empty;
|
|
|
|
/// <summary>16진 나란히 보기. 앞부분만 담는다.</summary>
|
|
public ObservableCollection<HexRow> HexRows { get; } = new ObservableCollection<HexRow>();
|
|
|
|
[ObservableProperty] private bool _isImageFile;
|
|
[ObservableProperty] private string _leftImagePath = string.Empty;
|
|
[ObservableProperty] private string _rightImagePath = string.Empty;
|
|
|
|
/// <summary>이진 파일인데 그림이 아니라 16진으로 봐야 하는 경우.</summary>
|
|
public bool ShowHexView => IsBinaryFile && !IsImageFile;
|
|
|
|
partial void OnIsBinaryFileChanged(bool value) => OnPropertyChanged(nameof(ShowHexView));
|
|
partial void OnIsImageFileChanged(bool value) => OnPropertyChanged(nameof(ShowHexView));
|
|
|
|
/// <summary>
|
|
/// 이진 파일 두 개를 견준다.
|
|
///
|
|
/// 지금까지는 "줄 단위로 비교할 수 없습니다" 한 줄로 끝났다. 그러면 정작 알고 싶은
|
|
/// <b>같은지 다른지</b>를 모른 채 화면을 닫게 된다. 크기·해시·처음 갈리는 자리를 알려 주고,
|
|
/// 그림이면 나란히 띄우고 아니면 앞부분을 16진으로 늘어놓는다.
|
|
/// </summary>
|
|
private void CompareBinary(string left, string right)
|
|
{
|
|
HexRows.Clear();
|
|
IsImageFile = BinaryCompare.IsImage(left) || BinaryCompare.IsImage(right);
|
|
LeftImagePath = File.Exists(left) ? left : string.Empty;
|
|
RightImagePath = File.Exists(right) ? right : string.Empty;
|
|
|
|
try
|
|
{
|
|
var result = BinaryCompare.Compare(left, right);
|
|
|
|
BinarySummary = result.Truncated
|
|
? $"{result.Summary} · 앞 {result.Rows.Count * BinaryCompare.BytesPerRow}바이트만 표시"
|
|
: result.Summary;
|
|
|
|
if (!IsImageFile)
|
|
{
|
|
foreach (var row in result.Rows) HexRows.Add(row);
|
|
}
|
|
|
|
DiffSummary = result.Same ? "이진 파일 · 내용이 같습니다" : "이진 파일 · 내용이 다릅니다";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
BinarySummary = $"이진 파일을 읽지 못했습니다: {ex.Message}";
|
|
DiffSummary = "이진 파일";
|
|
}
|
|
}
|
|
|
|
// ================= 저장 형식 · 덩어리 반영 =================
|
|
|
|
/// <summary>
|
|
/// 저장할 때 쓸 인코딩·줄바꿈. 기본은 "원본 유지"라 지금까지와 똑같이 동작한다.
|
|
///
|
|
/// 사내 소스에는 CP949 로 저장된 파일이 섞여 있어 새로 만드는 파일과 형식이 갈린다.
|
|
/// 읽어서 보여 주기만 하면 그 갈림을 못 고치므로 저장할 때 고를 수 있게 한다.
|
|
/// </summary>
|
|
public IReadOnlyList<SaveChoice> SaveEncodings => TextFile.SaveEncodingChoices;
|
|
public IReadOnlyList<SaveChoice> SaveNewLines => TextFile.SaveNewLineChoices;
|
|
|
|
[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;
|
|
if (!AuthorReady()) return;
|
|
|
|
// 2-Way 는 BASE 를, 3-Way 는 저쪽(REMOTE)을 가져온다. BlockAround 주석 참고.
|
|
// 주석은 덩어리 첫 줄 위 한 줄, 되돌리기는 한 번에(11차 F-2 - 예전에는 줄마다 주석이 붙었다).
|
|
var count = ApplyGroup(block, anchor: null, what: "구간");
|
|
if (count > 0) MessageUtil.ShowSuccessToast($"{count}줄 반영");
|
|
}
|
|
|
|
private int _lastGroup;
|
|
|
|
/// <summary>
|
|
/// 여러 줄을 한 번 누른 것으로 반영한다(덩어리 · 메서드, 11차 F-2).
|
|
/// 머지 주석은 <paramref name="anchor"/>(없으면 반영 뒤 글이 남은 첫 줄) 위에 <b>한 줄만</b>, 로그도 한 줄,
|
|
/// 되돌리기는 묶음째. 머지 주석 줄은 건드리지 않는다 - 이력이다.
|
|
/// </summary>
|
|
/// <returns>글이 바뀐 줄 수.</returns>
|
|
/// <param name="group">함께 되돌릴 묶음 번호. 0 이면 새로 하나 뗀다 - [전체 반영] 은 여러 구간을 한 묶음으로 넘긴다.</param>
|
|
/// <param name="finish">끝에 화면을 다시 맞출지. 여러 구간을 이어서 반영할 때는 부르는 쪽이 마지막에 한 번 한다.</param>
|
|
private int ApplyGroup(IReadOnlyList<MergeLine> rows, MergeLine? anchor, string what, int group = 0, bool finish = true)
|
|
{
|
|
if (group == 0) group = ++_lastGroup;
|
|
var changed = 0;
|
|
var deleted = 0; // 주석에 "삭제 N줄" 로 한 번만 적는다(사용자 2026-09-30)
|
|
int? first = null, last = null;
|
|
|
|
foreach (var line in rows.ToList())
|
|
{
|
|
if (line.IsMergeNote) continue;
|
|
var number = IsThreeWay ? line.RemoteNumber : line.BaseNumber;
|
|
var hadLocal = line.HasLocal;
|
|
var done = IsThreeWay
|
|
? line.CanTakeRemote && ApplyRemote(line, requery: false, group: group)
|
|
: line.CanTakeBase && Apply(line, requery: false, group: group);
|
|
if (!done) continue;
|
|
|
|
if (hadLocal && !line.HasLocal) deleted++;
|
|
changed++;
|
|
if (number is int n) { first ??= n; last = n; }
|
|
}
|
|
|
|
if (changed == 0)
|
|
{
|
|
if (finish) AfterEdit();
|
|
return 0;
|
|
}
|
|
|
|
var source = IsThreeWay
|
|
? MergeNames.TheirsSource(TheirsSideName, TakesMergeSides)
|
|
: MergeNames.BaseSource(_baseSourceName);
|
|
var where = first is null ? string.Empty : first == last ? $"{first}행 " : $"{first}~{last}행 ";
|
|
var description = $"{where}{what} {source} 반영 ({changed}줄)";
|
|
|
|
// 묶음이 모두 지우는 반영이면 글이 남은 줄이 없다 - 그래도 지운 자리(첫 줄)에 주석을 남긴다(사용자 2026-09-30).
|
|
var at = anchor is { HasLocal: true } && Lines.Contains(anchor)
|
|
? anchor
|
|
: rows.FirstOrDefault(r => r.HasLocal && !r.IsMergeNote && Lines.Contains(r))
|
|
?? rows.FirstOrDefault(r => !r.IsMergeNote && Lines.Contains(r));
|
|
if (at is not null && _workspace.Settings.InsertMergeComment)
|
|
{
|
|
var note = BuildMergeNote(MergeNote.WithDeletion(source, deleted));
|
|
_undo.Push(new LineEdit(at, at.LocalText, at.HasLocal, at.LocalText, at.HasLocal,
|
|
EditKind.NoteOnly, description)
|
|
{
|
|
InsertedNote = InsertNoteAbove(at, note),
|
|
Group = group,
|
|
});
|
|
}
|
|
|
|
ClearRedo();
|
|
_workspace.AppendLog(description);
|
|
if (finish) AfterEdit();
|
|
return changed;
|
|
}
|
|
|
|
// ---- 메서드 단위 반영(11차 F-2) ----
|
|
|
|
private IReadOnlyList<MemberRange>? _sourceMembers;
|
|
|
|
/// <summary>
|
|
/// 반영해 올 쪽(2-Way 는 BASE, 3-Way 는 REMOTE)의 멤버 범위. 그쪽 글은 파일을 연 뒤 바뀌지 않지만
|
|
/// 행이 다시 만들어지면 지운다(<see cref="InvalidateDiffBlocks"/>).
|
|
/// </summary>
|
|
private IReadOnlyList<MemberRange> SourceMembers(MemberLanguage language)
|
|
{
|
|
if (_sourceMembers is not null) return _sourceMembers;
|
|
|
|
var source = Lines
|
|
.Select(l => IsThreeWay ? (Number: l.RemoteNumber, Text: l.RemoteText) : (Number: l.BaseNumber, Text: l.BaseText))
|
|
.Where(x => x.Number is not null)
|
|
.OrderBy(x => x.Number)
|
|
.Select(x => x.Text ?? string.Empty)
|
|
.ToList();
|
|
return _sourceMembers = MemberRanges.Find(source, language);
|
|
}
|
|
|
|
private List<int?> SourceNumbers() =>
|
|
Lines.Select(l => IsThreeWay ? l.RemoteNumber : l.BaseNumber).ToList();
|
|
|
|
/// <summary>캐럿이 놓인 멤버와 그 화면 행들. 못 잡으면 null 과 까닭.</summary>
|
|
private (MemberRange Member, List<MergeLine> Rows, int Changes)? MemberAround(int row, out string why)
|
|
{
|
|
var side = IsThreeWay ? "REMOTE" : "BASE";
|
|
var language = MemberRanges.LanguageOf(CurrentFilePath);
|
|
if (language == MemberLanguage.None)
|
|
{
|
|
why = "메서드 반영은 C# · C/C++ 파일에서만 씁니다";
|
|
return null;
|
|
}
|
|
|
|
if (row < 0 || row >= Lines.Count)
|
|
{
|
|
why = "캐럿을 메서드 안에 두면 켜집니다";
|
|
return null;
|
|
}
|
|
|
|
var numbers = SourceNumbers();
|
|
var member = MemberRanges.AtRow(numbers, row, SourceMembers(language));
|
|
if (member is null)
|
|
{
|
|
why = $"캐럿이 {side} 쪽 메서드 안에 있지 않습니다(클래스 선언 · 빈 줄 · 범위를 못 잡은 곳)";
|
|
return null;
|
|
}
|
|
|
|
var span = MemberRanges.RowsOf(numbers, member);
|
|
if (span is null)
|
|
{
|
|
why = $"{member.Name} 의 범위를 화면에서 찾지 못했습니다";
|
|
return null;
|
|
}
|
|
|
|
var rows = new List<MergeLine>();
|
|
for (var r = span.Value.First; r <= span.Value.Last; r++) rows.Add(Lines[r]);
|
|
var changes = rows.Count(l => !l.IsMergeNote && (IsThreeWay ? l.CanTakeRemote : l.CanTakeBase));
|
|
if (changes == 0)
|
|
{
|
|
why = $"{member.Name} 안에 가져올 차이가 없습니다";
|
|
return null;
|
|
}
|
|
|
|
why = string.Empty;
|
|
return (member, rows, changes);
|
|
}
|
|
|
|
/// <summary>메서드 반영 단추 옆 한 줄 - 무엇을 잡았는지, 꺼졌으면 왜 꺼졌는지(지시서: 까닭을 알린다).</summary>
|
|
public string MemberHint
|
|
{
|
|
get
|
|
{
|
|
var found = MemberAround(CaretRow, out var why);
|
|
if (found is null) return why;
|
|
var m = found.Value.Member;
|
|
return $"{m.Name} · {(IsThreeWay ? "REMOTE" : "BASE")} {m.Start + 1}~{m.End + 1}행 · 바뀐 줄 {found.Value.Changes}";
|
|
}
|
|
}
|
|
|
|
private bool CanAcceptMember() => MemberAround(CaretRow, out _) is not null;
|
|
|
|
/// <summary>
|
|
/// 캐럿이 놓인 <b>클래스 멤버</b>(메서드 · 생성자 · 속성 · 이벤트 · 연산자 · 필드)를 통째로 반영한다(11차 F-2).
|
|
/// 2-Way 는 BASE 를, 3-Way 는 REMOTE 를 가져온다 - 덩어리 반영과 같은 규칙으로 줄마다 반영하므로
|
|
/// 결과는 같은 행들을 줄 단위로 반영한 것과 같다. 머지 주석은 메서드 이름 줄 위에 한 줄.
|
|
/// </summary>
|
|
[RelayCommand(CanExecute = nameof(CanAcceptMember))]
|
|
private void AcceptMember()
|
|
{
|
|
var found = MemberAround(CaretRow, out var why);
|
|
if (found is null)
|
|
{
|
|
MessageUtil.ShowErrorToast(why);
|
|
return;
|
|
}
|
|
|
|
if (!AuthorReady()) return;
|
|
var (member, rows, _) = found.Value;
|
|
var nameNumber = member.NameLine + 1;
|
|
var anchor = rows.FirstOrDefault(r => (IsThreeWay ? r.RemoteNumber : r.BaseNumber) == nameNumber);
|
|
|
|
var count = ApplyGroup(rows, anchor, $"메서드 {member.Name}");
|
|
if (count > 0) MessageUtil.ShowSuccessToast($"메서드 {member.Name} 반영 · {count}줄");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 그 줄이 속한 차이 덩어리. 차이가 아니면 빈 목록이다.
|
|
///
|
|
/// <b>무엇을 가져오는지가 모드마다 다르다.</b>
|
|
/// 2-Way : BASE 를 결과로 가져온다. 덩어리는 "BASE 를 가져올 수 있는 줄"이 이어진 구간.
|
|
/// 3-Way : BASE 는 공통 조상이라 가져올 것이 아니다. 저쪽(REMOTE)을 가져오는 것이
|
|
/// <c>Take Theirs</c> 이고, 덩어리 반영은 그것을 줄 묶음으로 하는 것이다.
|
|
/// 여기서 BASE 를 가져오면 <b>조상으로 되돌리는 셈</b>이라 뜻이 전혀 달라진다.
|
|
/// </summary>
|
|
private List<MergeLine> BlockAround(int row)
|
|
{
|
|
var found = new List<MergeLine>();
|
|
if (row < 0 || row >= Lines.Count) return found;
|
|
|
|
bool Takeable(MergeLine line) => IsThreeWay ? line.CanTakeRemote : line.CanTakeBase;
|
|
|
|
if (!Takeable(Lines[row])) return found;
|
|
|
|
var first = row;
|
|
while (first > 0 && Takeable(Lines[first - 1])) first--;
|
|
|
|
var last = row;
|
|
while (last + 1 < Lines.Count && Takeable(Lines[last + 1])) last++;
|
|
|
|
for (var i = first; i <= last; i++) found.Add(Lines[i]);
|
|
return found;
|
|
}
|
|
|
|
// ================= 자동 병합 · 충돌만 보기 =================
|
|
|
|
/// <summary>
|
|
/// "내 것으로 간다"고 정한 충돌 줄의 BASE 줄 번호.
|
|
///
|
|
/// 그 결정은 <b>내용을 바꾸지 않으므로</b> 글만 봐서는 되살릴 수 없다. 편집 뒤 다시 정렬하면
|
|
/// 행이 새로 만들어지는데, 이걸 들고 있지 않으면 이미 정한 충돌이 되살아나
|
|
/// "남은 충돌"이 줄지 않는다. BASE 는 얼어 있으므로 그 줄 번호가 안전한 열쇠다.
|
|
/// </summary>
|
|
private readonly HashSet<int> _appliedBaseNumbers = new HashSet<int>();
|
|
|
|
/// <summary>
|
|
/// BASE 에 없는 충돌(양쪽이 각자 새 줄을 넣은 자리)용. 이런 줄은 BASE 줄 번호가 없으므로
|
|
/// 역시 얼어 있는 MINE 줄 번호로 붙든다.
|
|
/// </summary>
|
|
private readonly HashSet<int> _appliedMineNumbers = new HashSet<int>();
|
|
|
|
private bool CanAutoMerge() => IsThreeWay && Lines.Any(l => l.ThreeWay == ThreeWayState.RemoteChanged);
|
|
|
|
/// <summary>
|
|
/// 저쪽만 고친 줄을 한 번에 결과로 가져온다. 사람은 <b>충돌만</b> 판단하면 된다.
|
|
///
|
|
/// 안 겹치는 변경까지 하나씩 누르게 하면 100줄짜리 충돌 파일에서 정작 판단이 필요한
|
|
/// 서너 줄이 묻힌다 - `git merge` 가 자동으로 해 주는 일을 여기서도 해 준다.
|
|
/// 내가 고친 줄과 충돌 줄은 건드리지 않는다.
|
|
/// </summary>
|
|
[RelayCommand(CanExecute = nameof(CanAutoMerge))]
|
|
private void AutoMerge()
|
|
{
|
|
// 반영하면서 주석 줄이 끼어들어 Lines 가 바뀐다. 먼저 목록을 떠 놓고 돈다.
|
|
// 이어진 줄끼리 묶어 <b>구간마다 주석 한 줄</b>만 남긴다(2026-10-01 사용자 결정 - 예전에는 줄마다 붙어 주석이 너무 많았다).
|
|
// 묶음에는 저쪽만 고친 줄만 넣는다 - 사이에 낀 충돌 · 내가 고친 줄은 구간을 끊고 그대로 둔다.
|
|
var blocks = new List<List<MergeLine>>();
|
|
List<MergeLine>? current = null;
|
|
foreach (var line in Lines)
|
|
{
|
|
if (line.ThreeWay != ThreeWayState.RemoteChanged || !line.CanTakeRemote) { current = null; continue; }
|
|
if (current is null) blocks.Add(current = new List<MergeLine>());
|
|
current.Add(line);
|
|
}
|
|
if (blocks.Count == 0) return;
|
|
|
|
// 한 번 누른 것이라 되돌리기도 한 번에 - 모든 구간을 한 묶음 번호로.
|
|
var group = ++_lastGroup;
|
|
var changed = 0;
|
|
foreach (var block in blocks)
|
|
changed += ApplyGroup(block, anchor: null, what: "자동 병합", group: group, finish: false);
|
|
|
|
AfterEdit();
|
|
MessageUtil.ShowSuccessToast(UnresolvedConflictCount == 0
|
|
? $"{changed}줄 자동 병합 · 남은 충돌 없음"
|
|
: $"{changed}줄 자동 병합 · 남은 충돌 {UnresolvedConflictCount}개");
|
|
}
|
|
|
|
private bool CanGoConflict() => IsThreeWay && Lines.Any(l => l.NeedsDecision);
|
|
|
|
/// <summary>다음 충돌로. `다음 차이` 와 달리 <b>아직 안 정한 충돌만</b> 딛는다.</summary>
|
|
[RelayCommand(CanExecute = nameof(CanGoConflict))]
|
|
private void NextConflict()
|
|
{
|
|
for (var row = CaretRow + 1; row < Lines.Count; row++)
|
|
if (Lines[row].NeedsDecision) { GoToRow(row); return; }
|
|
|
|
// 끝까지 갔으면 처음으로 돈다 - 차이 이동과 같은 감각이다.
|
|
for (var row = 0; row < Lines.Count; row++)
|
|
if (Lines[row].NeedsDecision) { GoToRow(row); return; }
|
|
}
|
|
|
|
[RelayCommand(CanExecute = nameof(CanGoConflict))]
|
|
private void PrevConflict()
|
|
{
|
|
for (var row = CaretRow - 1; row >= 0; row--)
|
|
if (Lines[row].NeedsDecision) { GoToRow(row); return; }
|
|
|
|
for (var row = Lines.Count - 1; row >= 0; row--)
|
|
if (Lines[row].NeedsDecision) { GoToRow(row); return; }
|
|
}
|
|
|
|
/// <summary>아직 사람이 골라야 하는 충돌 줄 수. 0 이면 이 파일의 병합은 끝난 것이다.</summary>
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(MergeStatusText))]
|
|
[NotifyPropertyChangedFor(nameof(IsMergeComplete))]
|
|
private int _unresolvedConflictCount;
|
|
|
|
/// <summary>3-Way 에서 지금 어디까지 왔는지 한 줄로.</summary>
|
|
public string MergeStatusText =>
|
|
!IsThreeWay ? string.Empty :
|
|
UnresolvedConflictCount == 0 ? "충돌 정리 끝" :
|
|
$"남은 충돌 {UnresolvedConflictCount}개";
|
|
|
|
public bool IsMergeComplete => IsThreeWay && UnresolvedConflictCount == 0;
|
|
|
|
// ================= 범례에 실시간으로 붙는 개수 =================
|
|
|
|
[ObservableProperty] private int _changedCount;
|
|
[ObservableProperty] private int _appliedCount;
|
|
[ObservableProperty] private int _editedCount;
|
|
[ObservableProperty] private int _addedCount;
|
|
[ObservableProperty] private int _removedCount;
|
|
[ObservableProperty] private int _movedCount;
|
|
[ObservableProperty] private int _ignoredCount;
|
|
[ObservableProperty] private int _sameCount;
|
|
|
|
/// <summary>3-Way 에서 저쪽만 고친 줄 수.</summary>
|
|
[ObservableProperty] private int _remoteChangeCount;
|
|
|
|
/// <summary>
|
|
/// 줄 하나만 반영해도 곧바로 범례 숫자가 바뀌도록, 편집이 끝날 때마다 다시 센다.
|
|
/// (UpdateSummaries 가 AfterEdit / 파일 로드 양쪽에서 호출된다)
|
|
/// </summary>
|
|
/// <summary>
|
|
/// 범례에서 고른 갈래의 <b>다음 줄</b>로 옮긴다.
|
|
///
|
|
/// 범례는 지금까지 읽기만 하는 설명이었다. 그런데 "추가된 줄만 훑고 싶다" 는 일이 잦은데
|
|
/// 차이 이동(Alt+↑↓)은 갈래를 가리지 않는다. 개수를 누르면 그 갈래만 딛게 해서
|
|
/// <b>범례를 도구로</b> 바꾼다. 색 이름을 외울 필요도 없어진다.
|
|
///
|
|
/// 지금 자리 다음부터 찾고 끝에 닿으면 처음으로 돌아온다 - 차이 이동과 같은 방식이라
|
|
/// 새 규칙을 외울 것이 없다.
|
|
/// </summary>
|
|
[RelayCommand]
|
|
private void GoToKind(LineHighlight kind)
|
|
{
|
|
if (Lines.Count == 0) return;
|
|
|
|
for (var step = 1; step <= Lines.Count; step++)
|
|
{
|
|
var at = (CaretRow + step) % Lines.Count;
|
|
if (Lines[at].Highlight != kind) continue;
|
|
|
|
GoToRow(at);
|
|
return;
|
|
}
|
|
|
|
// 한 줄도 없으면 조용히 넘어가는 대신 왜 아무 일도 없는지 알려 준다.
|
|
MessageUtil.ShowToast($"{KindName(kind)} 줄이 없습니다.");
|
|
}
|
|
|
|
private static string KindName(LineHighlight kind) => kind switch
|
|
{
|
|
LineHighlight.Changed => "BASE와 다름",
|
|
LineHighlight.Applied => "머지 반영함",
|
|
LineHighlight.Edited => "직접 수정",
|
|
LineHighlight.Added => "추가",
|
|
LineHighlight.Removed => "삭제",
|
|
LineHighlight.Ignored => "무시",
|
|
LineHighlight.Moved => "자리 옮김",
|
|
LineHighlight.RemoteChange => "Theirs only",
|
|
LineHighlight.Conflict => "Conflict",
|
|
_ => "그"
|
|
};
|
|
|
|
private void UpdateLegendCounts()
|
|
{
|
|
int changed = 0, applied = 0, edited = 0, added = 0, removed = 0, moved = 0, ignored = 0, same = 0;
|
|
int remote = 0, conflict = 0, needsDecision = 0, inline = 0;
|
|
|
|
foreach (var line in Lines)
|
|
{
|
|
if (line.NeedsDecision) needsDecision++;
|
|
if (HasInlineChange(line)) inline++;
|
|
|
|
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;
|
|
InlineChangeCount = inline;
|
|
SaveResolvedCommand.NotifyCanExecuteChanged();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 색 구분의 "줄 안에서 바뀐 부분" 줄 수(사용자 요청 2026-09-30 - 이 칸만 개수가 없었다).
|
|
/// 지금도 BASE 와 다른(<see cref="LineHighlight.Changed"/>) 줄 가운데 단어 단위로 달라진 구간이 있는 줄 - 화면에서 그 색이 칠해지는 줄이다.
|
|
/// 반영 · 무시로 색이 바뀐 줄은 세지 않는다.
|
|
/// </summary>
|
|
[ObservableProperty] private int _inlineChangeCount;
|
|
|
|
private static bool HasInlineChange(MergeLine line) =>
|
|
line.Highlight == LineHighlight.Changed &&
|
|
(HasChangedWord(line.BaseWords) || HasChangedWord(line.LocalWords));
|
|
|
|
private static bool HasChangedWord(IReadOnlyList<WordDiffSegment>? words) =>
|
|
words is not null && words.Any(w => w.Kind != WordDiffKind.Unchanged);
|
|
|
|
/// <summary>
|
|
/// MERGED 칸 번호를 <b>결과 파일 번호</b>로 다시 매긴다(8차 B-3).
|
|
///
|
|
/// 줄을 고르거나 머지 주석이 끼어들면 결과 줄 수가 바뀐다. 정렬기가 준 번호를 그대로 두면
|
|
/// 끝 번호가 상태바의 "총 N줄" 과 어긋났다. 결과에 들어가는 줄(<see cref="MergeLine.HasLocal"/>)만 1부터 센다.
|
|
/// </summary>
|
|
private static void RenumberMerged(IList<MergeLine> rows)
|
|
{
|
|
if (rows is null || rows.Count == 0) return;
|
|
|
|
var inResult = new bool[rows.Count];
|
|
for (var i = 0; i < rows.Count; i++) inResult[i] = rows[i].HasLocal;
|
|
|
|
var numbers = MergedNumbering.Assign(inResult);
|
|
for (var i = 0; i < rows.Count; i++) rows[i].LocalNumber = numbers[i];
|
|
}
|
|
|
|
/// <summary>
|
|
/// 왼쪽(BASE) 칸 번호를 <b>왼쪽 파일 번호</b>로 다시 매긴다(12차 B-1 을 쓰다 나온 것, 2026-09-28).
|
|
///
|
|
/// <b>무엇이 잘못됐었나</b> - 12차 B-1 로 왼쪽에도 반영할 수 있게 되면서 왼쪽 줄이 늘고 줄게 됐는데,
|
|
/// 번호는 정렬기가 처음 준 것 그대로였다. 오른쪽에만 있던 줄을 왼쪽에 넣으면 그 줄은 <b>번호가 비고</b>
|
|
/// 뒤 줄은 옛 번호를 그대로 달고 있어, 왼쪽 파일이 8줄인데 칸에는 <c>01 02 (빈) 03 04 05 (빈) 06</c> 으로 보였다.
|
|
/// 저장하면 8줄이 되는데 번호는 6까지만 있으니 어느 줄을 고쳤는지 짚을 수가 없다.
|
|
///
|
|
/// <para>
|
|
/// Git 모드 · 네 칸(3-Way)의 BASE 는 지난 커밋 · 공통 조상이라 고칠 수 없다 - 그때 번호는 그 파일의 번호이므로
|
|
/// 건드리지 않는다. 두 파일 비교(<see cref="BothWays"/>)에서만 다시 맨다.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// "머지 반영함" 표시는 <b>BASE 줄 번호를 열쇠로</b> 붙들고 있다(<see cref="_appliedBaseNumbers"/>) -
|
|
/// 번호를 바꾸면 그 열쇠도 같은 줄을 가리키도록 함께 옮긴다. 안 그러면 규칙을 한 번 건드리는 순간
|
|
/// 엉뚱한 줄이 "반영함"으로 칠해진다.
|
|
/// </para>
|
|
/// </summary>
|
|
private void RenumberBase(IList<MergeLine> rows)
|
|
{
|
|
if (!BothWays || rows is null || rows.Count == 0) return;
|
|
|
|
var inLeft = new bool[rows.Count];
|
|
for (var i = 0; i < rows.Count; i++) inLeft[i] = rows[i].HasBase;
|
|
|
|
var numbers = MergedNumbering.Assign(inLeft);
|
|
|
|
// 바뀐 것이 없으면(연 직후가 그렇다) 표시도 건드리지 않는다.
|
|
var changed = false;
|
|
for (var i = 0; i < rows.Count; i++)
|
|
{
|
|
if (rows[i].BaseNumber == numbers[i]) continue;
|
|
changed = true;
|
|
break;
|
|
}
|
|
if (!changed) return;
|
|
|
|
var moved = _appliedBaseNumbers.Count > 0 ? new HashSet<int>() : null;
|
|
|
|
for (var i = 0; i < rows.Count; i++)
|
|
{
|
|
if (moved is not null && rows[i].BaseNumber is int was && numbers[i] is int now &&
|
|
_appliedBaseNumbers.Contains(was))
|
|
{
|
|
moved.Add(now);
|
|
}
|
|
|
|
rows[i].BaseNumber = numbers[i];
|
|
}
|
|
|
|
if (moved is null) return;
|
|
|
|
_appliedBaseNumbers.Clear();
|
|
foreach (var number in moved) _appliedBaseNumbers.Add(number);
|
|
}
|
|
|
|
private string BuildLocalText() => PaneText.Build(Lines, MergeLine.TextPane.Local);
|
|
}
|
|
}
|