using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MergeHub.Models;
using MergeHub.Services;
using UbisamBase.Core.Messaging;
namespace MergeHub.ViewModels
{
///
/// Git 탭 - 저장소 조작 전부.
/// 열기/클론 · 원격 · 브랜치 · 병합 · 이력 조작(revert/cherry-pick/rebase/reset) ·
/// 스테이징/커밋 · 태그 · 서브모듈 · mergetool 등록.
///
public sealed partial class GitViewModel : ObservableObject
{
private readonly GitWorkspace _workspace;
public GitViewModel(GitWorkspace workspace)
{
_workspace = workspace;
_workspace.Refreshed += () => OnPropertyChanged(nameof(SelectedCommit));
_workspace.Refreshed += SyncStashSelection;
_workspace.StateChanged += RaiseCanExecuteChanged;
// 원격을 이미 쓰고 있으면 펼쳐 둔다 - 접어 두면 쓰던 사람이 사라진 줄 안다.
_isRemoteOpen = _workspace.Settings.GitRemoteExpanded ||
!string.IsNullOrWhiteSpace(_workspace.RemoteUrl);
}
// ================= 원격 칸 접고 펴기 =================
///
/// 원격 칸(원격 URL · Gitea 계정 · Fetch/Pull/Push)을 펼쳐 두었는지.
///
/// 왜 접나 - 이 도구는 로컬에서 쓰는 머지툴이 우선이고 아직 원격 저장소가 없다.
/// 그런데 화면에서는 원격이 위쪽 4분의 1을 차지한 채 전부 비활성이었다.
/// 지우지는 않는다 - 사내 Gitea 가 생기면 다시 필요하다.
///
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(RemoteToggleLabel))]
private bool _isRemoteOpen;
///
/// 단추 글자. 무엇이 숨어 있고 누르면 무슨 일이 나는지를 함께 적는다.
///
/// 처음에는 `▸ 원격 · 계정` 이라고만 했는데 사용자가 못 찾았다 -
/// 화살표 하나로는 "여기 접힌 것이 있다" 가 전해지지 않는다. 동사를 붙였다.
///
public string RemoteToggleLabel =>
IsRemoteOpen ? "▾ 원격 저장소 · 계정 접기" : "▸ 원격 저장소 · 계정 펴기";
[RelayCommand]
private void ToggleRemote()
{
IsRemoteOpen = !IsRemoteOpen;
_workspace.Settings.GitRemoteExpanded = IsRemoteOpen;
_workspace.SaveSettings();
}
public GitWorkspace Workspace => _workspace;
private bool IsOpen() => _workspace.IsRepositoryOpen;
// ================= 입력 =================
public IReadOnlyList ResetKinds { get; } =
new[] { ResetKind.Soft, ResetKind.Mixed, ResetKind.Hard };
[ObservableProperty] private ResetKind _selectedResetKind = ResetKind.Mixed;
[ObservableProperty] private string _newBranchName = string.Empty;
[ObservableProperty] private string _tagName = string.Empty;
[ObservableProperty] private string _tagMessage = string.Empty;
[ObservableProperty] private string _commitMessage = string.Empty;
[ObservableProperty] private FileStatusInfo? _selectedStatusFile;
[ObservableProperty] private ConflictInfo? _selectedConflict;
[ObservableProperty] private TagInfo? _selectedTag;
[ObservableProperty] private string _diffText = string.Empty;
[ObservableProperty] private StashInfo? _selectedStash;
[ObservableProperty] private string _blameText = string.Empty;
[ObservableProperty] private string _blamePath = string.Empty;
///
/// 커밋 비교의 시작점. 정해 두면 이력에서 고르는 커밋이 "끝점"이 되어
/// 두 커밋 사이를 보여 준다. 비워 두면 지금까지처럼 "부모와 비교"다.
///
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(DiffRangeText))]
private CommitInfo? _diffFromCommit;
public string DiffRangeText => DiffFromCommit is { } from
? $"{from.ShortSha} → 고른 커밋"
: "부모 → 고른 커밋";
partial void OnNewBranchNameChanged(string value) => RaiseCanExecuteChanged();
partial void OnTagNameChanged(string value) => RaiseCanExecuteChanged();
partial void OnCommitMessageChanged(string value) => RaiseCanExecuteChanged();
partial void OnBlamePathChanged(string value) => RaiseCanExecuteChanged();
partial void OnSelectedStatusFileChanged(FileStatusInfo? value) => RaiseCanExecuteChanged();
partial void OnSelectedConflictChanged(ConflictInfo? value) => RaiseCanExecuteChanged();
partial void OnSelectedTagChanged(TagInfo? value) => RaiseCanExecuteChanged();
partial void OnSelectedStashChanged(StashInfo? value) => RaiseCanExecuteChanged();
partial void OnDiffFromCommitChanged(CommitInfo? value)
{
RaiseCanExecuteChanged();
_ = ShowDiffAsync(SelectedCommit); // 기준이 바뀌었으니 지금 보고 있는 것도 다시 그린다
}
public BranchInfo? SelectedBranch
{
get => _workspace.SelectedBranch;
set
{
if (ReferenceEquals(_workspace.SelectedBranch, value)) return;
_workspace.SelectedBranch = value;
OnPropertyChanged();
RaiseCanExecuteChanged();
}
}
public CommitInfo? SelectedCommit
{
get => _workspace.SelectedCommit;
set
{
if (ReferenceEquals(_workspace.SelectedCommit, value)) return;
_workspace.SelectedCommit = value;
OnPropertyChanged();
_ = ShowDiffAsync(value);
RaiseCanExecuteChanged();
}
}
// ================= 저장소 =================
[RelayCommand]
private Task OpenAsync() => _workspace.RunAndRefreshAsync("저장소 열기",
() => _workspace.Git.Open(_workspace.RepositoryPath));
private bool CanClone() => !string.IsNullOrWhiteSpace(_workspace.RemoteUrl);
[RelayCommand(CanExecute = nameof(CanClone))]
private Task CloneAsync() => _workspace.RunAndRefreshAsync("클론", () =>
{
_workspace.ApplyCredentials();
_workspace.Git.Clone(_workspace.RemoteUrl, _workspace.RepositoryPath);
});
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task AddRemoteAsync() => _workspace.RunAndRefreshAsync("원격 추가", () =>
{
if (string.IsNullOrWhiteSpace(_workspace.RemoteUrl))
throw new InvalidOperationException("원격 URL 을 입력하세요.");
_workspace.ApplyCredentials();
_workspace.Git.AddRemote(_workspace.RemoteName, _workspace.RemoteUrl);
_workspace.Git.Fetch(_workspace.RemoteName);
_workspace.MarkFetched();
});
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task RemoveRemoteAsync() => _workspace.RunAndRefreshAsync("원격 삭제",
() => _workspace.Git.RemoveRemote(_workspace.RemoteName));
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task FetchAsync() => _workspace.RunAndRefreshAsync("fetch", () =>
{
_workspace.ApplyCredentials();
_workspace.Git.Fetch(_workspace.RemoteName);
_workspace.MarkFetched();
});
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task PullAsync() => _workspace.RunAndRefreshAsync("pull", () =>
{
_workspace.ApplyCredentials();
var result = _workspace.Git.Pull(_workspace.RemoteName);
_workspace.MarkFetched();
// 결과 한 줄은 GitService 가 로그에 적는다(Pull 은 Merge 를 거친다). 여기서 또 적으면 두 번 찍힌다(6차 C-1).
});
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task PushAsync() => _workspace.RunAndRefreshAsync("push", () =>
{
_workspace.ApplyCredentials();
_workspace.Git.Push(remoteName: _workspace.RemoteName);
});
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task RefreshAsync() => _workspace.RefreshAsync();
// ================= 브랜치 =================
private bool HasBranch() => SelectedBranch is not null;
[RelayCommand(CanExecute = nameof(HasBranch))]
private Task CheckoutAsync() => _workspace.RunAndRefreshAsync("체크아웃",
() => _workspace.Git.Checkout(SelectedBranch!.FriendlyName));
private bool CanCreateBranch() => IsOpen() && !string.IsNullOrWhiteSpace(NewBranchName);
[RelayCommand(CanExecute = nameof(CanCreateBranch))]
private Task CreateBranchAsync() => _workspace.RunAndRefreshAsync("브랜치 생성", () =>
{
_workspace.Git.CreateBranch(NewBranchName, SelectedCommit?.Sha, checkout: true);
_workspace.OnUi(() => NewBranchName = string.Empty);
});
private bool CanDeleteBranch() => SelectedBranch is { IsRemote: false, IsCurrentHead: false };
[RelayCommand(CanExecute = nameof(CanDeleteBranch))]
private Task DeleteBranchAsync() => _workspace.RunAndRefreshAsync("브랜치 삭제",
() => _workspace.Git.DeleteBranch(SelectedBranch!.FriendlyName));
// ================= 병합 =================
private bool CanMerge() => SelectedBranch is not null && !SelectedBranch.IsCurrentHead;
[RelayCommand(CanExecute = nameof(CanMerge))]
private Task MergeAsync() => _workspace.RunAndRefreshAsync("병합", () =>
{
var branch = SelectedBranch!.FriendlyName;
var result = _workspace.Git.Merge(branch, commitOnSuccess: false);
// 머지 주석만 다른 충돌은 사람에게 묻지 않고 푼다. 양쪽 주석을 다 남긴다(6차 B-5).
IReadOnlyList noteOnly = result.HasConflicts && _workspace.Settings.IgnoreMergeNotes
? _workspace.Git.ResolveNoteOnlyConflicts()
: Array.Empty();
var remaining = noteOnly.Count > 0 ? _workspace.Git.GetConflicts().Count : result.Conflicts.Count;
_workspace.OnUi(() =>
{
// result.Message("충돌 N건 - 해결이 필요합니다.")는 GitService 가 이미 로그에 적었다(Progress).
// 여기서 또 적으면 두 번 찍히고, 자동 해결 줄 뒤에 다시 나와 순서도 헷갈렸다(6차 C-1).
if (noteOnly.Count > 0)
_workspace.AppendLog($"머지 주석만 다른 충돌 {noteOnly.Count}개 파일은 자동으로 풀었습니다. 남은 충돌 {remaining}건.");
if (string.IsNullOrWhiteSpace(CommitMessage))
CommitMessage = $"Merge branch '{branch}' into {_workspace.CurrentBranch}";
var autoNote = noteOnly.Count > 0 ? $" (머지 주석만 다른 {noteOnly.Count}건은 자동으로 풀림)" : string.Empty;
if (result.HasConflicts && remaining > 0)
MessageUtil.ShowWarningToast($"충돌 {remaining}건{autoNote} - 충돌 탭에서 파일을 두 번 누르면 파일 비교 탭에서 세 칸으로 풉니다.");
else if (result.HasConflicts)
MessageUtil.ShowSuccessToast($"머지 주석만 다른 충돌 {noteOnly.Count}건을 자동으로 풀었습니다 - 병합 커밋을 할 수 있습니다.");
else
MessageUtil.ShowToast(result.Message);
});
});
private bool CanAbort() => _workspace.IsMergeInProgress || _workspace.IsRebaseInProgress;
[RelayCommand(CanExecute = nameof(CanAbort))]
private Task AbortAsync() => _workspace.RunAndRefreshAsync("작업 취소",
() => _workspace.Git.AbortOperation());
private bool CanCommitMerge() => _workspace.IsMergeInProgress && _workspace.Conflicts.Count == 0;
[RelayCommand(CanExecute = nameof(CanCommitMerge))]
private Task CommitMergeAsync() => _workspace.RunAndRefreshAsync("병합 커밋", () =>
{
var message = string.IsNullOrWhiteSpace(CommitMessage) ? null : CommitMessage;
var sha = _workspace.Git.CommitMerge(message);
_workspace.OnUi(() =>
{
CommitMessage = string.Empty;
MessageUtil.ShowSuccessToast($"병합 커밋 완료 ({sha.Substring(0, 7)})");
});
});
///
/// 충돌 목록에서 고른 파일들(6차 B-6). Ctrl · Shift 로 여러 개를 고를 수 있다.
/// ListBox.SelectedItems 는 바인딩이 안 돼 화면이 고를 때마다 넘겨준다.
///
private IReadOnlyList _selectedConflicts = Array.Empty();
public void SetSelectedConflicts(IReadOnlyList conflicts)
{
_selectedConflicts = conflicts ?? Array.Empty();
OnPropertyChanged(nameof(ConflictSelectionText));
RaiseCanExecuteChanged();
}
/// Accept 가 적용될 파일들. 여러 개를 골랐으면 그것들, 아니면 고른 하나.
private IReadOnlyList ConflictTargets =>
_selectedConflicts.Count > 0 ? _selectedConflicts
: SelectedConflict is { } one ? new[] { one } : Array.Empty();
/// 여러 개를 골랐을 때 단추 옆에 띄우는 한 줄.
public string ConflictSelectionText =>
ConflictTargets.Count > 1 ? $"{ConflictTargets.Count}개 고름 - Accept 는 고른 파일 모두에 한 번에 적용됩니다" : string.Empty;
private bool HasConflict() => ConflictTargets.Count > 0;
[RelayCommand(CanExecute = nameof(HasConflict))]
private Task AcceptMineAsync() => ResolveAsync(ConflictResolution.Mine);
[RelayCommand(CanExecute = nameof(HasConflict))]
private Task AcceptTheirsAsync() => ResolveAsync(ConflictResolution.Theirs);
[RelayCommand(CanExecute = nameof(HasConflict))]
private Task AcceptBothAsync()
{
// 파일 단위 "둘 다" 는 파일 두 벌을 통째로 이어 붙인다. 양쪽이 같은 이름으로 새로 만든 파일이면
// 같은 형식이 두 번 나와 빌드가 안 된다(5차 C-1 에서 그랬다). 누르기 전에 무엇이 일어나는지 알린다(6차 A-5).
var targets = ConflictTargets;
var path = targets.Count <= 1
? targets.FirstOrDefault()?.Path ?? string.Empty
: $"{targets.Count}개 파일 ({targets[0].Path} 외 {targets.Count - 1}개)";
var go = MessageUtil.ShowYesNo(
"파일 두 벌을 이어 붙입니다",
$"{path}\n\n내 것 다음에 상대 것을 한 파일로 이어 붙입니다. 내 파일의 인코딩과 줄끝으로 씁니다.\n\n" +
"양쪽이 같은 이름으로 새로 만든 파일이면 같은 형식이 두 번 나와 빌드가 안 될 수 있습니다.\n" +
"줄마다 골라 둘 다 넣으려면 파일 비교 탭의 3분할에서 하세요.\n\n이어 붙일까요?");
return go ? ResolveAsync(ConflictResolution.Both) : Task.CompletedTask;
}
///
/// 고른 충돌 파일을 파일 비교 탭의 3분할로 연다(6차 B-1). 목록을 두 번 누르거나 Enter 로도 온다.
/// 켜짐/꺼짐을 따로 두지 않는다 - 고른 것이 없으면 아무 일도 안 한다. 이진 파일은 줄 단위로 볼 수 없어 알린다.
///
[RelayCommand]
private void OpenConflictInCompare()
{
var conflict = SelectedConflict;
if (conflict is null) return;
if (conflict.IsBinary)
{
MessageUtil.ShowWarningToast($"이진 파일이라 줄 단위로 볼 수 없습니다. 아래 Accept 단추로 한쪽을 고르세요: {conflict.Path}");
return;
}
_workspace.RequestOpenConflict(conflict.Path);
}
///
/// 고른 충돌 파일들을 한 작업으로 푼다(6차 B-6).
///
/// 예전에는 파일 하나마다 작업 하나였고, 작업이 끝날 때마다 저장소 전체를 다시 읽었다(한 개 약 1.5초).
/// 이제는 고른 파일을 모두 풀고 다시 읽기는 끝에 한 번이다.
/// 한 파일이 실패해도 나머지는 계속 풀고, 실패한 것은 로그와 알림으로 알린다.
///
private Task ResolveAsync(ConflictResolution resolution)
{
var targets = ConflictTargets.ToList();
if (targets.Count == 0) return Task.CompletedTask;
var label = targets.Count == 1 ? $"충돌 해결({resolution})" : $"충돌 해결({resolution}) {targets.Count}개";
return _workspace.RunAndRefreshAsync(label, () =>
{
var failed = new List();
var watch = Stopwatch.StartNew();
foreach (var conflict in targets)
{
try
{
_workspace.Git.ResolveConflict(conflict.Path, resolution);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
failed.Add($"{conflict.Path}: {ex.Message}");
}
}
watch.Stop();
_workspace.OnUi(() =>
{
if (targets.Count > 1)
_workspace.AppendLog($"충돌 {targets.Count - failed.Count}개를 {resolution} 로 한 번에 풀었습니다 " +
$"({watch.ElapsedMilliseconds}ms · 저장소 다시 읽기는 끝에 한 번)");
foreach (var line in failed) _workspace.AppendLog("충돌 해결 실패: " + line);
if (failed.Count > 0) MessageUtil.ShowWarningToast($"{failed.Count}개는 풀지 못했습니다 - 로그를 보세요");
});
});
}
// ================= 이력 조작 =================
private bool HasCommit() => SelectedCommit is not null;
[RelayCommand(CanExecute = nameof(HasCommit))]
private Task RevertAsync() => _workspace.RunAndRefreshAsync("revert", () =>
{
// 결과 한 줄은 GitService 가 로그에 적는다. 여기서 또 적으면 두 번 찍힌다(6차 C-1).
_workspace.Git.Revert(SelectedCommit!.Sha);
});
[RelayCommand(CanExecute = nameof(HasCommit))]
private Task CherryPickAsync() => _workspace.RunAndRefreshAsync("cherry-pick", () =>
{
// 결과 한 줄은 GitService 가 로그에 적는다(6차 C-1).
_workspace.Git.CherryPick(SelectedCommit!.Sha);
});
[RelayCommand(CanExecute = nameof(HasCommit))]
private Task ResetAsync() => _workspace.RunAndRefreshAsync($"reset --{SelectedResetKind}",
() => _workspace.Git.Reset(SelectedResetKind, SelectedCommit!.Sha));
private bool CanRebase() =>
SelectedBranch is not null && !SelectedBranch.IsCurrentHead && !_workspace.IsRebaseInProgress;
[RelayCommand(CanExecute = nameof(CanRebase))]
private Task RebaseAsync() => _workspace.RunAndRefreshAsync("rebase", () =>
{
// 결과 한 줄은 GitService 가 로그에 적는다(6차 C-1).
_workspace.Git.RebaseStart(SelectedBranch!.FriendlyName);
});
private bool IsRebasing() => _workspace.IsRebaseInProgress;
[RelayCommand(CanExecute = nameof(IsRebasing))]
private Task RebaseContinueAsync() => _workspace.RunAndRefreshAsync("rebase continue", () =>
{
// 결과 한 줄은 GitService 가 로그에 적는다(6차 C-1).
_workspace.Git.RebaseContinue();
});
[RelayCommand(CanExecute = nameof(IsRebasing))]
private Task RebaseAbortAsync() => _workspace.RunAndRefreshAsync("rebase abort",
() => _workspace.Git.RebaseAbort());
// ================= 스테이징 / 커밋 =================
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task StageAllAsync() => _workspace.RunAndRefreshAsync("전체 스테이징",
() => _workspace.Git.StageAll());
private bool HasStatusFile() => SelectedStatusFile is not null;
[RelayCommand(CanExecute = nameof(HasStatusFile))]
private Task StageSelectedAsync() => _workspace.RunAndRefreshAsync("스테이징",
() => _workspace.Git.Stage(SelectedStatusFile!.Path));
[RelayCommand(CanExecute = nameof(HasStatusFile))]
private Task UnstageSelectedAsync() => _workspace.RunAndRefreshAsync("언스테이징",
() => _workspace.Git.Unstage(SelectedStatusFile!.Path));
[RelayCommand(CanExecute = nameof(HasStatusFile))]
private Task RestoreSelectedAsync() => _workspace.RunAndRefreshAsync("변경 취소",
() => _workspace.Git.RestoreFiles(new[] { SelectedStatusFile!.Path }));
private bool CanCommit() => IsOpen() && !string.IsNullOrWhiteSpace(CommitMessage);
[RelayCommand(CanExecute = nameof(CanCommit))]
private Task CommitAsync() => _workspace.RunAndRefreshAsync("커밋", () =>
{
var sha = _workspace.Git.Commit(CommitMessage);
_workspace.OnUi(() =>
{
CommitMessage = string.Empty;
MessageUtil.ShowSuccessToast($"커밋 완료 ({sha.Substring(0, 7)})");
});
});
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task StashSaveAsync() => _workspace.RunAndRefreshAsync("stash 저장",
() => _workspace.Git.StashSave($"MergeHub {DateTime.Now:yyyy-MM-dd HH:mm}"));
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task StashPopAsync() => _workspace.RunAndRefreshAsync("stash 적용",
() => _workspace.Git.StashPop());
// ================= 커밋 수정 (amend) =================
private bool CanAmend() => IsOpen() && !string.IsNullOrWhiteSpace(CommitMessage);
///
/// 마지막 커밋을 고쳐 쓴다(`git commit --amend`).
///
/// 오타 난 메시지나 빠뜨린 파일을 새 커밋을 쌓지 않고 바로잡을 때 쓴다.
/// 이미 올린 커밋을 고치면 남의 이력과 갈라지므로, 푸시 전에만 쓰는 것이 원칙이다.
///
[RelayCommand(CanExecute = nameof(CanAmend))]
private Task AmendAsync() => _workspace.RunAndRefreshAsync("커밋 수정", () =>
{
var sha = _workspace.Git.Commit(CommitMessage, amend: true);
_workspace.OnUi(() =>
{
CommitMessage = string.Empty;
MessageUtil.ShowSuccessToast($"커밋 수정 완료 ({sha.Substring(0, 7)})");
});
});
private bool CanLoadLastMessage() => IsOpen() && _workspace.Commits.Count > 0;
/// 고칠 메시지를 처음부터 다시 치지 않도록 마지막 커밋의 메시지를 가져온다.
[RelayCommand(CanExecute = nameof(CanLoadLastMessage))]
private void LoadLastMessage() => CommitMessage = _workspace.Commits[0].Message.TrimEnd();
// ================= stash 목록 =================
///
/// 목록이 바뀌면 고른 stash 를 다시 잡는다.
///
/// 그냥 두면 두 가지가 어긋난다 - ① 아무것도 안 고른 상태라 닫힌 콤보가 비어 보이고,
/// ② 하나 꺼내 쓴 뒤 번호가 한 칸씩 당겨지는데 옛 항목을 붙들고 있으면
/// 다음 "삭제"가 엉뚱한 것을 지운다.
///
private void SyncStashSelection()
{
if (SelectedStash is { } picked &&
_workspace.Stashes.Any(s => s.Index == picked.Index && s.Sha == picked.Sha)) return;
SelectedStash = _workspace.Stashes.FirstOrDefault();
}
private bool HasStash() => SelectedStash is not null;
/// 고른 stash 를 꺼내 적용한다. 목록에서 골라야 하므로 "맨 위"에 갇히지 않는다.
[RelayCommand(CanExecute = nameof(HasStash))]
private Task StashApplySelectedAsync() => _workspace.RunAndRefreshAsync("stash 적용",
() => _workspace.Git.StashPop(SelectedStash!.Index));
[RelayCommand(CanExecute = nameof(HasStash))]
private Task StashDropSelectedAsync() => _workspace.RunAndRefreshAsync("stash 삭제",
() => _workspace.Git.StashDrop(SelectedStash!.Index));
// ================= 두 커밋 사이 비교 =================
/// 지금 고른 커밋을 비교의 시작점으로 잡는다.
[RelayCommand(CanExecute = nameof(HasCommit))]
private void SetDiffFrom() => DiffFromCommit = SelectedCommit;
private bool HasDiffFrom() => DiffFromCommit is not null;
[RelayCommand(CanExecute = nameof(HasDiffFrom))]
private void ClearDiffFrom() => DiffFromCommit = null;
// ================= 변경분 파일 추출 =================
private bool CanExportChanges() => HasCommit();
///
/// 두 커밋 사이에 바뀐 파일만 골라 폴더로 뽑는다.
///
/// 장비에 소스를 통째로 넣지 못하는 현장에서 바뀐 것만 들고 갈 때 쓴다.
/// 비교 시작점을 잡아 두었으면 그 사이를, 아니면 고른 커밋의 부모부터 잰다.
///
[RelayCommand(CanExecute = nameof(CanExportChanges))]
private Task ExportChangesAsync()
{
// 저장소가 있는 자리에서 시작한다 - 뽑아 놓을 자리는 보통 그 근처다.
var target = FolderPicker.Pick("변경분 파일을 뽑아 놓을 폴더", ParentOf(_workspace.RepositoryPath));
if (target is null) return Task.CompletedTask;
var to = SelectedCommit!;
var from = DiffFromCommit?.Sha ?? to.ParentShas.FirstOrDefault() ?? to.Sha;
return _workspace.RunAsync("변경분 추출", () =>
{
var (written, skipped) = _workspace.Git.ExportChangedFiles(from, to.Sha, target);
_workspace.OnUi(() => MessageUtil.ShowSuccessToast(
skipped > 0
? $"{written}개 파일 추출 (삭제되어 건너뜀 {skipped}개)"
: $"{written}개 파일 추출"));
});
}
/// 저장소가 놓인 폴더. 대화상자를 그 자리에서 열어 주려고 쓴다.
private static string ParentOf(string path)
{
try
{
var trimmed = path?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (string.IsNullOrEmpty(trimmed)) return string.Empty;
var parent = Path.GetDirectoryName(trimmed);
return Directory.Exists(parent) ? parent! : string.Empty;
}
catch (ArgumentException)
{
return string.Empty; // 경로가 이상하면 대화상자 기본 자리에서 연다
}
}
// ================= blame =================
private bool CanBlame() => IsOpen() && !string.IsNullOrWhiteSpace(BlamePath);
///
/// 줄마다 "누가 언제 이 줄을 만들었나"를 붙여 보여 준다.
///
/// 머지 중에 "이 줄 왜 이렇게 됐지"를 묻게 되는데, 그때 커밋 이력을 뒤지는 대신
/// 줄 옆에서 바로 답이 나오는 편이 빠르다.
///
[RelayCommand(CanExecute = nameof(CanBlame))]
private Task BlameAsync()
{
var path = BlamePath.Trim().Replace(Path.DirectorySeparatorChar, '/');
var at = SelectedCommit?.Sha;
return _workspace.RunAsync("blame", () =>
{
var lines = _workspace.Git.Blame(path, at);
var sb = new StringBuilder();
foreach (var line in lines)
{
sb.Append(line.ShortSha).Append(" ")
.Append(line.When.ToString("yyyy-MM-dd")).Append(" ")
.Append(line.AuthorName.PadRight(10)).Append(" | ")
.Append(line.LineNumber.ToString("0000")).Append(" ")
.AppendLine(line.Text);
}
var text = sb.Length == 0 ? "(내용 없음)" : sb.ToString();
_workspace.OnUi(() => BlameText = text);
});
}
/// 변경 목록에서 고른 파일을 blame 대상으로 옮겨 준다 - 경로를 다시 치지 않게.
[RelayCommand(CanExecute = nameof(HasStatusFile))]
private void BlameSelectedFile() => BlamePath = SelectedStatusFile!.Path;
// ================= 태그 / 서브모듈 / 설정 =================
private bool CanCreateTag() => IsOpen() && !string.IsNullOrWhiteSpace(TagName);
[RelayCommand(CanExecute = nameof(CanCreateTag))]
private Task CreateTagAsync() => _workspace.RunAndRefreshAsync("태그 생성", () =>
{
var message = string.IsNullOrWhiteSpace(TagMessage) ? null : TagMessage;
_workspace.Git.CreateTag(TagName, SelectedCommit?.Sha ?? "HEAD", message);
_workspace.OnUi(() => { TagName = string.Empty; TagMessage = string.Empty; });
});
private bool HasTag() => SelectedTag is not null;
[RelayCommand(CanExecute = nameof(HasTag))]
private Task DeleteTagAsync() => _workspace.RunAndRefreshAsync("태그 삭제",
() => _workspace.Git.DeleteTag(SelectedTag!.Name));
private bool CanUpdateSubmodules() => IsOpen() && _workspace.Submodules.Count > 0;
[RelayCommand(CanExecute = nameof(CanUpdateSubmodules))]
private Task UpdateSubmodulesAsync() => _workspace.RunAndRefreshAsync("서브모듈 업데이트", () =>
{
_workspace.ApplyCredentials();
_workspace.Git.UpdateAllSubmodules();
});
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task RegisterMergeToolAsync() => _workspace.RunAsync("mergetool 등록", () =>
{
// net48 에는 Environment.ProcessPath 가 없다. 호스트는 UbisamBase.Shell.exe.
var exe = Process.GetCurrentProcess().MainModule?.FileName
?? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "UbisamBase.Shell.exe");
_workspace.Git.RegisterAsMergeTool("mergehub", exe, global: true);
_workspace.OnUi(() => MessageUtil.ShowSuccessToast("git config 에 mergehub 등록 완료"));
});
// ================= 탐색기 우클릭 =================
/// 지금 우클릭 메뉴에 걸려 있는지. 버튼 이름을 이 값으로 바꾼다.
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShellIntegrationLabel))]
private bool _isShellRegistered = ShellIntegration.IsRegistered;
public string ShellIntegrationLabel =>
IsShellRegistered ? "탐색기 보내기 해제" : "탐색기 보내기 등록";
///
/// 탐색기 보내기 메뉴에 이 프로그램을 넣고 뺀다.
///
/// 우클릭 메뉴에 바로 넣는 레지스트리 방식은 이 PC 에서 뜨지 않았다
/// (자세한 이유는 ShellIntegration 설명 참고). 보내기는 바로가기 파일 하나라
/// 권한도 필요 없고, 여러 개를 골라 한 번에 보낼 수 있다.
///
[RelayCommand]
private void ToggleShellIntegration()
{
try
{
if (IsShellRegistered)
{
ShellIntegration.Unregister();
MessageUtil.ShowToast("탐색기 보내기 메뉴에서 뺐습니다");
}
else
{
ShellIntegration.Register(ShellIntegration.CurrentExecutable());
MessageUtil.ShowSuccessToast("우클릭 → 보내기 → " + ShellIntegration.MenuLabel + " 로 엽니다");
}
IsShellRegistered = ShellIntegration.IsRegistered;
}
catch (Exception ex)
{
MessageUtil.ShowErrorToast("보내기 등록 실패: " + ex.Message);
}
}
/// CommunityToolkit 의 커맨드는 CommandManager 를 구독하지 않아 직접 알려야 한다.
private void RaiseCanExecuteChanged()
{
OpenCommand.NotifyCanExecuteChanged();
CloneCommand.NotifyCanExecuteChanged();
AddRemoteCommand.NotifyCanExecuteChanged();
RemoveRemoteCommand.NotifyCanExecuteChanged();
FetchCommand.NotifyCanExecuteChanged();
PullCommand.NotifyCanExecuteChanged();
PushCommand.NotifyCanExecuteChanged();
RefreshCommand.NotifyCanExecuteChanged();
CheckoutCommand.NotifyCanExecuteChanged();
CreateBranchCommand.NotifyCanExecuteChanged();
DeleteBranchCommand.NotifyCanExecuteChanged();
MergeCommand.NotifyCanExecuteChanged();
AbortCommand.NotifyCanExecuteChanged();
CommitMergeCommand.NotifyCanExecuteChanged();
AcceptMineCommand.NotifyCanExecuteChanged();
AcceptTheirsCommand.NotifyCanExecuteChanged();
AcceptBothCommand.NotifyCanExecuteChanged();
RevertCommand.NotifyCanExecuteChanged();
CherryPickCommand.NotifyCanExecuteChanged();
ResetCommand.NotifyCanExecuteChanged();
RebaseCommand.NotifyCanExecuteChanged();
RebaseContinueCommand.NotifyCanExecuteChanged();
RebaseAbortCommand.NotifyCanExecuteChanged();
StageAllCommand.NotifyCanExecuteChanged();
StageSelectedCommand.NotifyCanExecuteChanged();
UnstageSelectedCommand.NotifyCanExecuteChanged();
RestoreSelectedCommand.NotifyCanExecuteChanged();
CommitCommand.NotifyCanExecuteChanged();
StashSaveCommand.NotifyCanExecuteChanged();
StashPopCommand.NotifyCanExecuteChanged();
CreateTagCommand.NotifyCanExecuteChanged();
DeleteTagCommand.NotifyCanExecuteChanged();
UpdateSubmodulesCommand.NotifyCanExecuteChanged();
RegisterMergeToolCommand.NotifyCanExecuteChanged();
AmendCommand.NotifyCanExecuteChanged();
LoadLastMessageCommand.NotifyCanExecuteChanged();
StashApplySelectedCommand.NotifyCanExecuteChanged();
StashDropSelectedCommand.NotifyCanExecuteChanged();
SetDiffFromCommand.NotifyCanExecuteChanged();
ClearDiffFromCommand.NotifyCanExecuteChanged();
ExportChangesCommand.NotifyCanExecuteChanged();
BlameCommand.NotifyCanExecuteChanged();
BlameSelectedFileCommand.NotifyCanExecuteChanged();
}
// ================= diff =================
private Task ShowDiffAsync(CommitInfo? commit)
{
if (commit is null) return Task.CompletedTask;
return _workspace.RunAsync("diff", () =>
{
// 비교 시작점을 잡아 두었으면 두 커밋 "사이", 아니면 지금까지처럼 부모와 견준다.
var from = DiffFromCommit;
var diffs = from is null
? _workspace.Git.DiffCommitWithParent(commit.Sha)
: _workspace.Git.DiffCommits(from.Sha, commit.Sha);
var sb = new StringBuilder();
sb.AppendLine(from is null
? $"{commit.ShortSha} {commit.MessageShort}"
: $"{from.ShortSha} → {commit.ShortSha} ({diffs.Count}개 파일)");
sb.AppendLine($"{commit.AuthorName} <{commit.AuthorEmail}> {commit.When:yyyy-MM-dd HH:mm}");
sb.AppendLine(new string('-', 60));
foreach (var d in diffs)
{
sb.AppendLine($"[{d.ChangeKind}] {d.Path} (+{d.LinesAdded} / -{d.LinesDeleted})");
if (d.IsBinary) { sb.AppendLine(" (binary)"); continue; }
foreach (var line in d.Lines)
{
var marker = line.Kind switch
{
DiffLineKind.Added => '+',
DiffLineKind.Deleted => '-',
DiffLineKind.HunkHeader => '@',
_ => ' '
};
sb.Append(marker).Append(' ').AppendLine(RenderLine(line));
}
sb.AppendLine();
}
var text = sb.ToString();
_workspace.OnUi(() => DiffText = text);
});
}
/// 단어 단위 diff 가 켜져 있으면 바뀐 구간을 〔〕로 감싼다.
private static string RenderLine(DiffLine line)
{
if (line.Words is null || line.Words.Count == 0) return line.Text;
var sb = new StringBuilder();
foreach (var word in line.Words)
{
if (word.Kind == WordDiffKind.Unchanged) sb.Append(word.Text);
else sb.Append('〔').Append(word.Text).Append('〕');
}
return sb.ToString();
}
}
}