ksj09406 13988d1f0a 12차 - 폴더 비교 읽기 쉽게 · 양방향 머지 · 칸 요약 · 정보 창 · 문서 탭
새 플랫폼(UbisamBase.Launcher) 위로 옮기면서 12차 A~E 를 한 번에 담는다.
항목별 값과 판정은 doc/history/2026-09-23-12차.md, 요약은 doc/history/changelog.md.

A. 폴더 비교를 읽기 쉽게
- A-1 폴더 줄의 크기가 늘 0B 이던 것 - 안에 든 파일 크기 합계를 아래에서 위로 더해 적는다
  (걸러 낸 파일은 합계에서도 빠진다)
- A-2 좌우로 나눠 보이기 - 왼쪽 · 오른쪽 목록을 나란히 두고 같은 줄에 맞춘다.
  한쪽에만 있는 항목은 반대편을 비운다

B. 파일 비교 화면
- B-1 2-Way 에서 양쪽으로 머지 - 칸마다 반영 · 저장. 왼쪽 기준점을 따로 세고,
  되돌리기 · 저장 백업도 양쪽 모두
- B-2 프로그램 정보 창 - 버전 · 빌드 날짜 · 만든 사람 · 사내 문의처 · 플랫폼 버전 · 로그 폴더
- B-3 칸 아래에 무엇이 달라졌는지 - 칸마다 줄 수 · 추가 · 삭제 · 수정, 가운데에 진행

C. 플랫폼 (플랫폼 저장소에서 함께 나감)
- C-2 하단 탭 줄을 한 단계 작게 - 74 -> 61 DIP. 실측 133 -> 109px 이고
  그만큼 코드 칸이 늘어 파일 비교가 47 -> 48줄

E. 저장하지 않아도 남는 편집
- E-2 여러 짝을 동시에(문서 탭). 지금까지는 한 짝만 들어서 짝을 바꾸면
  저장하지 않은 편집을 묻고 버리면 사라졌다(재현: 16줄 -> 15줄).
  이제 떠 있던 탭이 그 글을 맡아 두므로 묻지 않는다.
  - 탭마다 HomeViewModel 을 따로 만들지 않았다 - 이 화면은 GitWorkspace 의 여러 사건과
    한 자리짜리 약속(ConfirmDiscardEdits · ConfirmLeaveCommit)에 물려 있다.
    화면은 하나로 두고 탭이 파일에 없는 것만 맡는다(고친 글 · 왼쪽 글 · 되돌리기 이력 ·
    두 기준점 · 캐럿 · 스크롤 · 저장 형식)
  - 탭 이름은 파일 이름, 다르면 왼 <-> 오. 양쪽이 같은 부분은 한 번만 적는다
    (WeldController_기준 <-> 내작업.cs, 421 -> 288px). 같은 이름이 겹치면
    갈릴 때까지 위 폴더를 한 겹씩 덧붙인다
  - 저장하지 않은 탭에 가운뎃점. 사람이 손댄 것만 센다 - 중괄호 맞춤으로만 바뀐 것은
    묻는 자리와 같은 기준으로 뺀다(안 그러면 .cs 를 여는 것만으로 늘 점이 붙는다)
  - 탭이 사라져도 고친 글은 보관함에 남아 다시 열면 되살아난다(짝 50개).
    E-1(껐다 켜도 남는 편집)은 이 보관함을 파일로 옮겨 적으면 된다
  - 이전 · 다음 파일은 지금 탭의 짝만 바꾸고, 폴더 비교에서 두 번 누르면 새 탭(사용자 결정)
  - Ctrl+Tab · Ctrl+Shift+Tab · Ctrl+W · 가운데 단추 닫기. 닫을 때도 묻지 않는다
  - Git 탭 · 병합 중 네 칸은 탭이 맡지 않는다 - 그쪽은 예전처럼 묻는다

하네스 27벌 1,787건 통과 · 실패 0(DocTabTest 106건 새로 만듦).
빌드 경고 0 · 오류 0. 화면으로 E-2 34건 · 회귀 19건 확인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 13:21:54 +09:00

944 lines
46 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using MergeHub.Models;
using UbisamBase.Core.Logging;
using UbisamBase.Core.Messaging;
using UbisamBase.Core.SettingItems;
namespace MergeHub.Services
{
/// <summary>
/// 줄 단위로 견줄 파일 한 짝. 폴더 비교가 파일 비교로 넘기는 목록의 단위다.
/// </summary>
public sealed class ComparePair
{
public ComparePair(string left, string right, string name)
{
Left = left ?? string.Empty;
Right = right ?? string.Empty;
Name = name ?? string.Empty;
}
public string Left { get; }
public string Right { get; }
/// <summary>화면에 적을 이름. 대개 상대 경로다.</summary>
public string Name { get; }
}
/// <summary>
/// 홈(머지툴) 탭과 Git 탭이 함께 쓰는 저장소 상태.
/// IGitService 는 스레드 안전하지 않으므로 모든 호출을 여기서 한 줄로 직렬화한다.
/// DI 싱글턴으로 등록되어 두 화면이 같은 인스턴스를 본다.
/// </summary>
public sealed partial class GitWorkspace : ObservableObject
{
private readonly SemaphoreSlim _gate = new SemaphoreSlim(1, 1);
private readonly SynchronizationContext? _uiContext;
private readonly Logger _logger = new Logger("MergeHub");
public GitWorkspace(IGitService git, ISettingsService settingsService)
{
Git = git;
_uiContext = SynchronizationContext.Current;
_settingsService = settingsService;
Settings = settingsService.RegisterSettings(nameof(MergeHubSettings), new MergeHubSettings());
RepositoryPath = Settings.DefaultRepositoryPath;
RemoteName = string.IsNullOrWhiteSpace(Settings.DefaultRemoteName) ? "origin" : Settings.DefaultRemoteName;
RemoteUrl = Settings.GiteaUrl;
UserName = Settings.GiteaUserName;
LoadToken(); // 이 서버에 맡겨 둔 토큰이 있으면 가져온다(11차 F-11)
Git.IgnoreWhitespace = Settings.IgnoreWhitespace;
Git.WordLevelDiff = Settings.WordLevelDiff;
// 저장할 때 백업(11차 F-9). Git 이 워킹트리 파일을 쓰는 길(병합 중 Save · 파일 단위 Accept)도 같은 것을 부른다.
Backup = new FileBackup(FileBackup.DefaultRoot(UbisamBase.Core.Bootstrap.UbisamAppBuilder.ConfigRoot),
() => Settings.BackupBeforeSave);
Backup.Kept += (original, backup) => AppendLog($"백업: {original} → {backup}");
Git.BeforeOverwrite = path => Backup.Keep(path);
// 작성자 이름은 여기서 한 번 정해 둔다.
// 저장소를 읽을 때(ReadState)만 정하면 Git 없이 파일 두 개를 비교할 때 이름이 비고,
// 그러면 주석에 등록한 이름 대신 윈도우 계정이 찍힌다.
AuthorName = ResolveAuthorName();
// 최근 목록은 켤 때 한 번 읽고, 바뀔 때마다 바로 쓴다.
// 프로그램이 갑자기 꺼져도 목록이 남게 하려는 것이다 - 몇 줄짜리 글이라 쓰는 값이 싸다.
Recent.Load(RecentPath);
Recent.Changed += (_, _) => Recent.Save(RecentPath);
// `git mergetool` · 탐색기 우클릭이 넘긴 것을 화면으로 보낸다.
// 여기서 하는 이유 - 이 객체는 첫 화면이 만들어질 때 함께 만들어지므로
// 요청을 남겨 둘 자리로 가장 이르다.
RouteStartupArgs();
// 보내기 바로가기가 옛 자리를 가리키면 지금 실행 파일로 고친다(11차 F-3). 등록한 사람만, 레지스트리는 안 건드린다.
try
{
if (ShellIntegration.RepairIfMoved(ShellIntegration.CurrentExecutable()) is { } old)
AppendLog($"보내기 바로가기가 옛 자리({old})를 가리켜 지금 실행 파일로 고쳤습니다");
}
catch (Exception ex)
{
_logger.W($"보내기 바로가기 확인 실패: {ex.Message}");
}
Git.Progress += (_, message) =>
{
_logger.I(message);
OnUi(() => AppendLog(message));
};
}
public IGitService Git { get; }
/// <summary>
/// 최근에 견준 짝. 파일 비교와 폴더 비교가 함께 본다.
///
/// 여기에 두는 까닭 - 두 화면이 서로를 모르는 채로 같은 목록을 봐야 하고,
/// 이 객체가 이미 그 통로 노릇을 하고 있다.
/// </summary>
public RecentPairs Recent { get; } = new RecentPairs();
/// <summary>
/// 최근 목록을 두는 자리. 플랫폼 설정 폴더 아래에 둔다 - 장비마다 한 자리에 모인다.
/// <b>설정 화면에는 내놓지 않는다.</b> 사람이 손으로 고칠 값이 아니고, 설정 목록만 어지럽힌다.
/// </summary>
private static string RecentPath => Path.Combine(
UbisamBase.Core.Bootstrap.UbisamAppBuilder.ConfigRoot, "Settings", "MergeHub.Recent.txt");
public MergeHubSettings Settings { get; }
/// <summary>저장할 때 백업(11차 F-9). 설정이 꺼져 있으면 아무것도 하지 않는다.</summary>
public FileBackup Backup { get; }
private readonly ISettingsService _settingsService;
/// <summary>
/// 지금 설정을 파일에 쓴다. 화면에서 켜고 끈 것이 다음에 켤 때도 남게 하려는 것이다.
/// 설정 화면에서 고치는 값은 그쪽이 알아서 쓰므로 여기를 부를 일이 없다.
/// </summary>
public void SaveSettings() => _settingsService.Save(nameof(MergeHubSettings));
// ================= 원격 주소 · 계정 · 토큰 (11차 F-11) =================
/// <summary>토큰을 맡겨 두는 곳 - 윈도 자격 증명 관리자(사용자 결정). 설정 파일에는 토큰 글자가 남지 않는다.</summary>
private readonly GitCredentialStore _tokens = new GitCredentialStore();
/// <summary>주소 · 계정을 설정에 적는 것을 잠깐 모아 둔다 - 글자 하나마다 파일을 쓰지 않으려고.</summary>
private System.Threading.Timer? _remoteSaveTimer;
partial void OnRemoteUrlChanged(string value)
{
QueueRemoteSave();
LoadToken(); // 서버가 바뀌면 그 서버에 맡겨 둔 토큰으로
}
partial void OnUserNameChanged(string value) => QueueRemoteSave();
private void QueueRemoteSave()
{
_remoteSaveTimer?.Dispose();
_remoteSaveTimer = new System.Threading.Timer(_ => OnUi(SaveRemoteSettings), null, 800, System.Threading.Timeout.Infinite);
}
/// <summary>Git 탭 원격 칸에 적은 주소 · 계정을 설정에 남긴다(11차 F-11 - 껐다 켜도 남는다).</summary>
private void SaveRemoteSettings()
{
var url = RemoteUrl ?? string.Empty;
var user = UserName ?? string.Empty;
if (Settings.GiteaUrl == url && Settings.GiteaUserName == user) return;
Settings.GiteaUrl = url;
Settings.GiteaUserName = user;
SaveSettings();
}
/// <summary>지금 토큰 칸에 든 것이 어느 서버에서 가져온 것인지. 주소를 바꾸면 남의 서버로 보내지 않게 치운다.</summary>
private string? _tokenFrom;
/// <summary>
/// 이 서버에 맡겨 둔 계정 · 토큰을 가져온다. 사람이 직접 적어 둔 토큰은 건드리지 않는다 -
/// 다른 서버에서 가져왔던 토큰만 치운다(다른 서버로 보내면 안 된다).
/// </summary>
public void LoadToken()
{
var key = GitCredentialKey.For(RemoteUrl);
if (_tokenFrom is not null && _tokenFrom != key)
{
Password = string.Empty; // 앞 서버에서 가져온 토큰이다
_tokenFrom = null;
}
HasSavedToken = false;
var (user, token) = _tokens.Load(RemoteUrl);
if (string.IsNullOrEmpty(token)) return;
Password = token!;
_tokenFrom = key;
if (string.IsNullOrWhiteSpace(UserName) && !string.IsNullOrWhiteSpace(user)) UserName = user!;
HasSavedToken = true;
}
/// <summary>원격 일(fetch · pull · push · 클론)이 잘 끝났다 - 그때의 계정 · 토큰을 맡겨 둔다.</summary>
public void RememberToken()
{
if (string.IsNullOrEmpty(Password)) return;
if (!_tokens.Save(RemoteUrl, UserName, Password)) return;
HasSavedToken = true;
_tokenFrom = GitCredentialKey.For(RemoteUrl);
AppendLog($"토큰을 윈도 자격 증명 관리자에 저장했습니다({GitCredentialKey.For(RemoteUrl)}). 다음에 켤 때 다시 넣지 않아도 됩니다.");
}
/// <summary>맡겨 둔 토큰을 지운다. 화면의 토큰 칸도 비운다.</summary>
public bool ForgetToken()
{
var had = _tokens.Forget(RemoteUrl);
Password = string.Empty;
HasSavedToken = false;
_tokenFrom = null;
if (had) AppendLog($"저장한 토큰을 지웠습니다({GitCredentialKey.For(RemoteUrl)}).");
return had;
}
/// <summary>이 서버의 토큰을 맡겨 두었는지. 화면의 안내 · [저장한 토큰 지우기] 단추가 본다.</summary>
[ObservableProperty] private bool _hasSavedToken;
/// <summary>저장소 상태가 다시 읽힐 때마다 발생. 화면들이 자기 패널을 갱신한다.</summary>
public event Action? Refreshed;
/// <summary>
/// 작업이 끝나 커맨드의 실행 가능 여부가 달라졌을 수 있음을 알린다.
/// CommunityToolkit 의 RelayCommand 는 CommandManager 를 구독하지 않아
/// 각 ViewModel 이 직접 NotifyCanExecuteChanged 를 불러야 한다.
/// </summary>
public event Action? StateChanged;
// ================= 공유 목록 =================
public ObservableCollection<BranchInfo> Branches { get; } = new ObservableCollection<BranchInfo>();
public ObservableCollection<CommitInfo> Commits { get; } = new ObservableCollection<CommitInfo>();
/// <summary>커밋 + 브랜치 그래프 레인. 홈 화면의 커밋 트리가 이걸 바인딩한다.</summary>
public ObservableCollection<CommitNode> CommitNodes { get; } = new ObservableCollection<CommitNode>();
public ObservableCollection<FileStatusInfo> StatusFiles { get; } = new ObservableCollection<FileStatusInfo>();
public ObservableCollection<ConflictInfo> Conflicts { get; } = new ObservableCollection<ConflictInfo>();
/// <summary>이번 병합에서 푼 파일 - [다시 충돌로] 로 되돌릴 수 있는 것(11차 F-8). 병합 중이 아니면 빈다.</summary>
public ObservableCollection<string> ResolvedConflicts { get; } = new ObservableCollection<string>();
public ObservableCollection<TagInfo> Tags { get; } = new ObservableCollection<TagInfo>();
public ObservableCollection<SubmoduleInfo> Submodules { get; } = new ObservableCollection<SubmoduleInfo>();
/// <summary>치워 둔 작업(stash) 목록. 새 것이 0번이다.</summary>
public ObservableCollection<StashInfo> Stashes { get; } = new ObservableCollection<StashInfo>();
// ================= 공유 상태 =================
[ObservableProperty] private string _repositoryPath = string.Empty;
[ObservableProperty] private string _remoteUrl = string.Empty;
[ObservableProperty] private string _remoteName = "origin";
[ObservableProperty] private string _userName = string.Empty;
[ObservableProperty] private string _password = string.Empty;
[ObservableProperty] private string _repositoryName = "-";
[ObservableProperty] private string _currentBranch = "-";
[ObservableProperty] private string _headSha = "-";
/// <summary>HEAD 의 전체 해시. 이력 목록(<see cref="Commits"/>)의 Sha 와 맞춰 볼 때 쓴다(11차 C-1).</summary>
public string? HeadFullSha { get; private set; }
[ObservableProperty] private string _workingTreeSummary = "저장소 없음";
[ObservableProperty] private bool _isWorkingTreeClean = true;
[ObservableProperty] private bool _isRepositoryOpen;
[ObservableProperty] private bool _isRemoteConnected;
/// <summary>원격 등록 여부만 나타낸다 - 서버 접속 확인은 Fetch 를 눌러야 이뤄진다.</summary>
[ObservableProperty] private string _remoteStatusText = "원격 없음";
/// <summary>현재 원격의 주소(clone/push 대상).</summary>
[ObservableProperty] private string _remoteUrlText = "-";
/// <summary>추적 브랜치와 비교한 앞섬/뒤처짐. 마지막 fetch 시점 기준이다.</summary>
[ObservableProperty] private string _syncStatusText = "-";
/// <summary>추적 브랜치와 완전히 같은 상태인지.</summary>
[ObservableProperty] private bool _isUpToDate;
[ObservableProperty] private string _lastFetchText = "fetch 이력 없음";
/// <summary>머지 주석에 남길 작성자. 설정값 > git config user.name > 윈도우 계정 순.</summary>
[ObservableProperty] private string _authorName = Environment.UserName;
[ObservableProperty] private bool _isMergeInProgress;
/// <summary>이번 병합에서 처음 난 충돌 파일 수(6차 C-1). 병합 중이 아니거나 모르면 null.</summary>
[ObservableProperty] private int? _mergeConflictTotal;
/// <summary>지금 병합에서 충돌을 어떻게 풀었는지 - 병합 커밋 본문에 붙는 글(8차 F-1). 병합 중이 아니거나 적힌 것이 없으면 null.</summary>
[ObservableProperty] private string? _mergeResolutionBody;
/// <summary>
/// 충돌 탭 목록 위의 진행 한 줄(6차 C-1). 예전에는 몇 개를 풀었는지 알 수 없었다.
/// 처음 충돌 수는 병합할 때 .git 에 적어 두므로 앱을 다시 켜도 이어진다.
/// MergeHub 밖에서 시작한 병합이라 처음 수를 모르면 남은 수만 적는다.
/// </summary>
public string ConflictProgressText =>
// 작업마다 다 푼 뒤 할 일이 다르다 - 병합 커밋 · [계속] · [커밋](7차 C-2). 병합의 글은 예전과 같다.
OperationLabels.ProgressText(OperationState.Kind, Conflicts.Count, MergeConflictTotal, PendingHasChanges);
/// <summary>진행 중인 작업이 커밋할 것을 들고 있는가(8차 B-2). 없으면 끝낼 수 없고 [작업 취소] 로 나간다.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ConflictProgressText))]
private bool _pendingHasChanges = true;
/// <summary>진행 중인 git 작업과 두 쪽 이름(7차 C-2). 상태를 다시 읽을 때마다 갱신한다.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsOperationInProgress))]
[NotifyPropertyChangedFor(nameof(ConflictProgressText))]
private GitOperationState _operationState = GitOperationState.None;
/// <summary>
/// 병합 · rebase · cherry-pick · revert 중 무엇이든 진행 중인지. 충돌 수와 따로 본다 -
/// 예전에는 충돌이 0 이 되면 cherry-pick 이 진행 중이라는 표시도 [작업 취소] 도 사라졌다(7차 C-2).
/// </summary>
public bool IsOperationInProgress => OperationState.IsInProgress;
/// <summary>이력에 모든 브랜치의 커밋을 보일지(7차 C-2). 다른 브랜치 커밋을 골라 cherry-pick 할 때 켠다.</summary>
[ObservableProperty] private bool _showAllBranchesInHistory;
partial void OnShowAllBranchesInHistoryChanged(bool value)
{
if (IsRepositoryOpen) _ = RefreshAsync();
}
[ObservableProperty] private bool _isRebaseInProgress;
[ObservableProperty] private string _pendingOperationText = string.Empty;
[ObservableProperty] private CommitInfo? _selectedCommit;
/// <summary>지난 새로고침 때 "작업 중 · 충돌 0" 이었는지. 그 상태가 막 된 순간에만 선택을 HEAD 로 옮긴다(9차 A-1).</summary>
private bool _wasResolvedOperation;
/// <summary>지난 새로고침 때의 HEAD(전체 해시). HEAD 를 보고 있다가 HEAD 가 움직이면 새 HEAD 를 따라가려고 둔다(9차 A-2).</summary>
private string? _lastHeadSha;
[ObservableProperty] private BranchInfo? _selectedBranch;
[ObservableProperty] private string _log = string.Empty;
// ================= 실행 =================
/// <summary>
/// 지금 도는 Git 작업이 끝날 때까지 기다린다. 아무 일도 하지 않는다.
///
/// <see cref="RunAsync"/> 는 게이트가 막혀 있으면 기다리지 않고 건너뛴다(겹쳐 돌면 안 되니까).
/// 그런데 새로고침 뒤처럼 "이건 꼭 해야 하는" 일이 그 순간 걸리면 조용히 빠진다 -
/// 병합 중 [해결로 저장] 뒤 파일 목록이 안 바뀐 것이 그랬다(6차 A-2). 그럴 때 이걸로 기다렸다 다시 한다.
/// </summary>
public async Task WhenIdleAsync()
{
await _gate.WaitAsync();
_gate.Release();
}
/// <summary>
/// Git 호출 하나를 백그라운드에서 직렬 실행한다.
/// 실패하면 로그 + Error 토스트를 띄우고 false 를 돌려준다.
/// </summary>
public async Task<bool> RunAsync(string operation, Action action)
{
// 이미 다른 Git 작업이 도는 중이면 겹쳐 실행하지 않는다.
// 조용히 사라지면 원인을 못 찾으니 반드시 로그를 남긴다.
if (!await _gate.WaitAsync(0))
{
_logger.W($"[{operation}] 다른 작업 진행 중이라 건너뜀");
return false;
}
try
{
await Task.Run(action);
return true;
}
catch (Exception ex)
{
_logger.E($"[{operation}] 실패", ex);
OnUi(() =>
{
// 인증이 필요하다는 영어 말은 무엇을 하라는 말로 바꿔 준다(11차 F-11).
var said = GitErrors.Friendly(ex.Message) is { } friendly ? $"{friendly} ({ex.Message})" : ex.Message;
AppendLog($"[{operation}] 실패: {said}");
MessageUtil.ShowErrorToast($"{operation} 실패: {said}");
});
return false;
}
finally
{
_gate.Release();
OnUi(() => StateChanged?.Invoke());
}
}
/// <summary>
/// 저장하지 않은 편집이 있으면 물어봐 달라고 파일 비교 화면이 앉혀 두는 자리.
///
/// 두 화면이 서로를 모르는 채로 이 판단을 나눠야 한다 - Git 탭은 "무슨 작업을 하려는지" 를 알고,
/// 파일 비교 탭은 "고치던 것이 있는지" 를 안다. 이 객체가 그 사이 통로다.
/// 앉은 사람이 없으면(파일 비교 탭을 아직 안 열었으면) 늘 계속한다.
/// </summary>
public Func<string, bool>? ConfirmDiscardEdits { get; set; }
/// <summary>
/// 사람이 한 탭의 이력에서 다른 커밋을 골랐다는 알림. 인자는 고른 쪽(뷰모델)이다(10차 B-1).
///
/// 고른 커밋(<see cref="SelectedCommit"/>)은 Git 탭과 파일 비교 탭이 함께 쓰는데, 예전에는 고른 쪽만 화면을 바꾸고
/// 다른 쪽은 새로고침 알림으로만 다시 읽었다. 파일 비교 탭에서 '바탕' 을 고르면 Git 탭은 '내 쪽 작업' 에 선택 표시가 남은 채
/// [Revert] 가 '바탕' 을 되돌렸다. 새로고침이 목록을 갈아 끼우며 고르는 것은 이 알림을 내지 않는다(<see cref="Refreshed"/> 가 맡는다).
/// </summary>
public event Action<object>? CommitChosen;
public void NotifyCommitChosen(object chooser) => CommitChosen?.Invoke(chooser);
/// <summary>
/// Git 탭에서 다른 커밋을 고르기 전에, 파일 비교 탭에 저장 안 한 편집이 있으면 물어봐 달라고 파일 비교 화면이 앉혀 두는 자리(10차 B-1).
/// 파일 비교 탭 자기 이력에서 고를 때와 같은 확인 창이다. 앉은 사람이 없으면 늘 계속한다.
/// 답: null = 물을 편집이 없어 창을 띄우지 않음 · true = 물었고 계속 · false = 물었고 [취소].
/// 물었는지를 따로 돌려주는 까닭 - 누름 도중에 창이 뜨면 목록이 그 누름을 끝까지 처리하지 않아, 화면이 직접 골라 줘야 한다.
/// </summary>
public Func<bool?>? ConfirmLeaveCommit { get; set; }
/// <summary>
/// 작업 하나를 실행하고 성공하면 저장소 상태를 다시 읽는다.
///
/// <b>다시 읽으면 파일 비교 화면의 본문도 새로 읽힌다.</b> 고치던 것이 있으면 그때 사라진다.
/// 그래서 <b>작업을 시작하기 전에</b> 묻는다 - 이 자리에서는 아직 아무것도 바뀌지 않았으므로
/// 취소가 뜻이 있다. 다 끝난 뒤 목록을 다시 세우는 자리에서 물으면 되돌릴 데가 없다
/// (4차 A-1 에서 그 자리를 일부러 비워 뒀던 까닭이고, 5차 A-2 가 그 구멍을 메운다).
/// </summary>
public async Task<bool> RunAndRefreshAsync(string operation, Action action)
{
if (GitOperationKinds.TouchesFiles(operation) && ConfirmDiscardEdits is { } ask && !ask(operation)) return false;
var ok = await RunAsync(operation, action);
if (ok) await RefreshAsync();
return ok;
}
/// <summary>
/// 기본 경로가 Git 저장소로 보이는지. 워크트리는 .git 이 파일이라 둘 다 본다.
///
/// 저장소가 아닌 폴더에 Open 을 걸면 실패 알림이 뜰 뿐 아니라, 그동안 작업 게이트를
/// 쥐고 있어서 뒤이어 시작하는 "파일 두 개 비교"까지 통째로 취소된다.
/// </summary>
/// <remarks>
/// <b>고른 자리에서 위로 거슬러 올라가 찾는다.</b> <c>.git</c> 은 최상위에만 있는데
/// 사람은 지금 보고 있던 깊은 폴더를 고르기 마련이라, 그 자리만 보면
/// 멀쩡한 저장소를 두고 "저장소가 아니다"라고 하게 된다.
/// </remarks>
public bool LooksLikeRepository => PathUtil.FindRepositoryRoot(RepositoryPath).Length > 0;
/// <summary>설정에 기본 저장소 경로가 있으면 시작하자마자 연다.</summary>
public Task TryAutoOpenAsync()
{
if (IsRepositoryOpen || string.IsNullOrWhiteSpace(RepositoryPath))
return Task.CompletedTask;
var root = PathUtil.FindRepositoryRoot(RepositoryPath);
if (root.Length == 0) return Task.CompletedTask;
// 하위 폴더가 저장돼 있었으면 최상위로 바로잡아 둔다 - 화면의 경로 칸도 함께 맞는다.
RepositoryPath = root;
return RunAndRefreshAsync("저장소 자동 열기", () => Git.Open(root));
}
/// <summary>
/// 상태를 다시 읽고, **게이트를 놓은 뒤에** Refreshed 를 발생시킨다.
/// 게이트를 쥔 채로 알리면 구독자가 거는 Git 호출이 전부 버려진다.
/// </summary>
public async Task RefreshAsync()
{
if (!await RunAsync("새로고침", ReadState)) return;
OnUi(() =>
{
Refreshed?.Invoke();
StateChanged?.Invoke();
});
}
private void ReadState()
{
if (!Git.IsOpen) return;
// 병합이 끝났는데 남은 처음 충돌 목록을 치운다 - MergeHub 밖(터미널)에서 끝낸 병합도 여기서 걸린다(7차 B-1).
Git.ForgetStaleMergeConflicts();
var info = Git.GetRepositoryInfo();
var branches = Git.GetBranches();
var commits = Git.GetCommits(maxCount: Settings.CommitPageSize <= 0 ? 200 : Settings.CommitPageSize,
allBranches: ShowAllBranchesInHistory);
var operation = Git.GetOperationState();
var nodes = CommitGraphBuilder.Build(commits);
var status = Git.GetStatus();
var conflicts = Git.GetConflicts();
var tags = Git.GetTags();
var submodules = Git.GetSubmodules();
var stashes = Git.GetStashes();
var rebase = Git.GetRebaseStatus();
var remotes = Git.GetRemotes();
var mergeInProgress = Git.IsMergeInProgress;
var pendingHasChanges = !operation.IsInProgress || Git.HasStagedChanges();
var conflictTotal = mergeInProgress ? Git.GetMergeConflictTotal() : null;
var resolvedConflicts = mergeInProgress ? Git.GetResolvedMergeConflicts() : Array.Empty<string>();
var resolvedOperation = CommitSelection.IsResolvedOperation(operation.IsInProgress, conflicts.Count);
var remote = remotes.FirstOrDefault(r => r.Name == RemoteName) ?? remotes.FirstOrDefault();
var head = branches.FirstOrDefault(b => b.IsCurrentHead);
var author = ResolveAuthorName();
// 병합 커밋 본문의 "누가" 와 메시지 칸에 미리 보일 해결 내역(8차 F-1).
Git.ResolverName = author;
var resolutionBody = mergeInProgress ? Git.GetResolutionBody() : null;
OnUi(() =>
{
RepositoryPath = info.WorkingDirectory;
RepositoryName = GetRepositoryName(info.WorkingDirectory);
CurrentBranch = info.CurrentBranchName ?? "(detached)";
HeadSha = string.IsNullOrEmpty(info.HeadSha) ? "-" : info.HeadSha!.Substring(0, 7);
HeadFullSha = info.HeadSha;
IsRepositoryOpen = true;
IsRemoteConnected = remotes.Count > 0;
RemoteStatusText = remotes.Count > 0 ? $"원격 {remotes.Count}개" : "원격 없음";
RemoteUrlText = remote?.Url ?? "-";
AuthorName = author;
(SyncStatusText, IsUpToDate) = DescribeSync(head);
IsMergeInProgress = mergeInProgress;
IsRebaseInProgress = rebase.IsInProgress;
OperationState = operation;
PendingHasChanges = pendingHasChanges;
// cherry-pick · revert 도 "병합 진행 중" 이라고 했다. 작업 이름으로 적는다(7차 C-2).
PendingOperationText = OperationLabels.PendingText(operation);
IsWorkingTreeClean = status.IsClean;
WorkingTreeSummary = status.IsClean ? "Clean" : $"{status.Files.Count} files";
// 목록을 갈아 끼우기 <b>전에</b> 고른 커밋을 적어 둔다. Git 탭 이력 목록이 비워지는 순간 선택을 놓치면
// 바인딩이 null 을 밀어 넣는다 - 그 뒤에 보면 고른 커밋을 이미 잃었다(9차 A-2).
var previousSha = SelectedCommit?.Sha;
Replace(Branches, branches);
Replace(Commits, commits);
Replace(CommitNodes, nodes);
Replace(StatusFiles, status.Files);
Replace(Conflicts, conflicts);
Replace(ResolvedConflicts, resolvedConflicts);
MergeConflictTotal = conflictTotal;
MergeResolutionBody = resolutionBody;
OnPropertyChanged(nameof(ConflictProgressText));
Replace(Tags, tags);
Replace(Submodules, submodules);
Replace(Stashes, stashes);
// 예전에는 "비었으면 맨 위" 였다. [모든 브랜치] 에서 맨 위는 HEAD 가 아니어서, cherry-pick 마지막 [Save] 뒤
// 두 칸의 기준이 다른 브랜치 커밋이 되기도 했다. 해시로 되찾고, 없으면 HEAD. 작업 중 충돌이 막 0 이 되면 HEAD 로(9차 A-1).
var entered = CommitSelection.EntersResolvedOperation(_wasResolvedOperation, operation.IsInProgress, conflicts.Count);
_wasResolvedOperation = resolvedOperation;
// HEAD 를 보고 있었으면 커밋 · 체크아웃 뒤 새 HEAD 를 따라간다 - 옛 HEAD 에 남으면 방금 커밋한 줄이 "남은 차이" 로 보였다(9차 A-2).
var pick = CommitSelection.Choose(commits.Select(c => c.Sha).ToList(), previousSha, info.HeadSha, entered, _lastHeadSha);
_lastHeadSha = info.HeadSha;
SelectedCommit = pick < 0 ? null : Commits[pick];
});
}
/// <summary>
/// 추적 브랜치와 몇 개 차이 나는지 문장으로 만든다.
/// 이 수치는 **마지막 fetch 시점** 기준이라, fetch 전에는 서버가 앞서 있어도 "최신"으로 보인다.
/// </summary>
private static (string Text, bool UpToDate) DescribeSync(BranchInfo? head)
{
if (head is null) return ("브랜치 없음", false);
if (head.TrackedBranch is null) return ("추적 브랜치 없음", false);
var ahead = head.AheadBy ?? 0;
var behind = head.BehindBy ?? 0;
if (ahead == 0 && behind == 0) return ($"최신 ({head.TrackedBranch})", true);
if (ahead > 0 && behind > 0) return ($"갈라짐 ↑{ahead} ↓{behind}", false);
if (behind > 0) return ($"뒤처짐 ↓{behind} - pull 필요", false);
return ($"앞섬 ↑{ahead} - push 필요", false);
}
/// <summary>
/// MergeHub 설정 &gt; git config user.name &gt; 윈도우 계정 순으로 고른다.
///
/// 예전에는 플랫폼에 등록된 사용자를 가운데에 두었는데, 새 플랫폼에는 그 자리가 없어졌다.
/// 이름은 이제 이 프로그램 설정(머지 주석 &gt; 작성자)에서 받는다 - 장비를 여럿이 쓰는 현장에서는
/// 여기에 적어 두면 누가 만졌는지 주석에 남는다.
/// </summary>
private string ResolveAuthorName()
{
if (!string.IsNullOrWhiteSpace(Settings.MergeCommentAuthor))
return Settings.MergeCommentAuthor.Trim();
try
{
var configured = Git.GetConfig("user.name");
if (!string.IsNullOrWhiteSpace(configured)) return configured!.Trim();
}
catch (Exception)
{
// 설정을 못 읽는 건 치명적이지 않다 - 윈도우 계정으로 넘어간다.
}
return Environment.UserName;
}
/// <summary>fetch 가 실제로 성공한 시각을 기록한다. 동기화 표시가 언제 기준인지 알리기 위함.</summary>
public void MarkFetched()
{
var now = DateTime.Now;
OnUi(() => LastFetchText = $"마지막 fetch {now:HH:mm:ss}");
}
private static string GetRepositoryName(string workingDirectory)
{
if (string.IsNullOrWhiteSpace(workingDirectory)) return "-";
var trimmed = workingDirectory.TrimEnd('\\', '/');
var slash = trimmed.LastIndexOfAny(new[] { '\\', '/' });
return slash >= 0 ? trimmed.Substring(slash + 1) : trimmed;
}
public void ApplyCredentials()
{
Git.Credentials = string.IsNullOrWhiteSpace(UserName)
? null
: new GitCredentials(UserName, Password);
}
public void AppendLog(string message) =>
Log += $"[{DateTime.Now:HH:mm:ss}] {message}{Environment.NewLine}";
/// <summary>
/// "이 두 파일을 줄 단위로 비교해 달라" - 폴더 비교 탭이 보내고 파일 비교 탭이 받는다.
///
/// 두 화면이 서로를 직접 알지 않도록 이미 둘 다 들고 있는 이 싱글턴을 통로로 쓴다.
/// </summary>
public event Action<string, string>? PlainCompareRequested;
/// <summary>아직 아무도 받지 않은 요청. 아래 설명 참고.</summary>
private (string Left, string Right)? _pendingPlainCompare;
public bool HasPendingPlainCompare => _pendingPlainCompare is not null;
/// <summary>
/// 요청을 남겨 두고 받을 사람이 있으면 바로 넘긴다.
///
/// 남겨 두는 것이 핵심이다. 탭 내용은 <b>그 탭을 처음 열 때</b> 만들어지므로,
/// 폴더 비교에서 파일을 처음 더블클릭하는 순간에는 파일 비교 화면이 아직 없어
/// 이벤트를 받을 사람이 없다. 그냥 발생시키면 요청이 허공에 사라지고,
/// 새로 만들어진 화면은 기본 파일을 연다 - "더블클릭이 안 먹는다"로 보이는 이유다.
/// </summary>
/// <param name="line">
/// 열자마자 짚어 줄 줄 번호. 0 이면 여느 때처럼 첫 차이로 간다.
/// 폴더 전체 검색에서 결과를 눌렀을 때, 파일만 열리고 맨 위에 서 있으면
/// 찾은 줄을 눈으로 다시 찾아야 한다.
/// </param>
/// <param name="lineIsLeft">그 번호가 왼쪽 파일 기준인지. 아니면 오른쪽 기준이다.</param>
public void RequestPlainCompare(string leftFile, string rightFile, int line = 0, bool lineIsLeft = true)
{
_pendingPlainCompare = (leftFile, rightFile);
RequestedLine = line;
RequestedLineIsLeft = lineIsLeft;
OnUi(DeliverPendingPlainCompare);
}
// ================= 폴더 비교에서 훑는 중인 파일 목록 =================
/// <summary>
/// 폴더 비교가 넘긴 "지금 훑는 중인 파일들". 파일 비교 화면의 이전/다음 파일이 이걸 딛는다.
///
/// <b>화면에 보이는 순서 그대로</b> 담는다 - 필터로 "코드가 다른 것만" 을 걸어 뒀으면
/// 그 안에서만 움직여야 한다. 목록에 없는 파일로 넘어가면 왜 거기로 갔는지 알 수 없다.
/// </summary>
public IReadOnlyList<ComparePair> Queue { get; private set; } = Array.Empty<ComparePair>();
/// <summary>지금 몇 번째를 보고 있는지. 목록이 없으면 -1.</summary>
public int QueueIndex { get; set; } = -1;
/// <summary>목록이나 자리가 바뀌었다.</summary>
public event EventHandler? QueueChanged;
public void SetQueue(IReadOnlyList<ComparePair> queue, int index)
{
Queue = queue ?? Array.Empty<ComparePair>();
QueueIndex = index;
OnUi(() => QueueChanged?.Invoke(this, EventArgs.Empty));
}
/// <summary>목록을 비운다. 폴더를 거치지 않고 연 파일은 딛고 갈 자리가 없다.</summary>
public void ClearQueue() => SetQueue(Array.Empty<ComparePair>(), -1);
public void RaiseQueueChanged() => OnUi(() => QueueChanged?.Invoke(this, EventArgs.Empty));
/// <summary>넘긴 파일에서 짚어 줄 자리. 없으면 0.</summary>
public int RequestedLine { get; private set; }
/// <summary>그 번호가 왼쪽 파일 기준인지.</summary>
public bool RequestedLineIsLeft { get; private set; }
/// <summary>
/// 짚어 줄 자리를 한 번 쓰고 지운다.
/// 지우지 않으면 그 다음에 연 파일까지 같은 줄로 끌려간다.
/// </summary>
public (int Line, bool IsLeft) TakeRequestedLine()
{
var one = (RequestedLine, RequestedLineIsLeft);
RequestedLine = 0;
return one;
}
/// <summary>남아 있는 요청을 넘긴다. 화면이 새로 만들어지면 스스로 한 번 불러 준다.</summary>
public void DeliverPendingPlainCompare()
{
if (_pendingPlainCompare is not { } pending) return;
if (PlainCompareRequested is null) return;
_pendingPlainCompare = null;
PlainCompareRequested.Invoke(pending.Left, pending.Right);
}
// ================= 충돌 파일을 파일 비교 탭에서 열기 (6차 B-1) =================
/// <summary>
/// "이 충돌 파일을 3분할로 열어 달라" - Git 탭의 충돌 목록이 보내고 파일 비교 탭이 받는다.
///
/// 예전에는 충돌 목록을 두 번 눌러도 아무 일이 없었다. 줄마다 고르려면 파일 비교 탭으로 가서
/// [새로고침] 을 누르고 콤보에서 같은 파일을 다시 찾아야 했다.
/// </summary>
public event Action<string>? OpenConflictRequested;
/// <summary>아직 아무도 받지 않은 요청. 파일 비교 화면은 탭을 처음 열 때 만들어지므로 남겨 둔다.</summary>
private string? _pendingOpenConflict;
public bool HasPendingOpenConflict => _pendingOpenConflict is not null;
public void RequestOpenConflict(string path)
{
if (string.IsNullOrWhiteSpace(path)) return;
_pendingOpenConflict = path;
OnUi(DeliverPendingOpenConflict);
ActivateWhenReady(MergeHub.ViewIds.Home);
}
/// <summary>남아 있는 요청을 넘긴다. 파일 비교 화면이 새로 만들어지면 스스로 한 번 불러 준다.</summary>
public void DeliverPendingOpenConflict()
{
if (_pendingOpenConflict is not { } path) return;
if (OpenConflictRequested is null) return;
_pendingOpenConflict = null;
OpenConflictRequested.Invoke(path);
}
// ================= mergetool 세 벌을 네 칸으로 열기 =================
/// <summary>
/// "`git mergetool` 이 넘긴 세 벌을 네 칸으로 열어 달라". 켤 때 넘어온 인자가 보내고 파일 비교 탭이 받는다.
/// 파일 비교 화면은 탭을 처음 열 때 만들어지므로 다른 요청들처럼 남겨 둔다.
/// </summary>
public event Action<MergeToolRequest>? MergeToolRequested;
private MergeToolRequest? _pendingMergeTool;
public bool HasPendingMergeTool => _pendingMergeTool is not null;
public void RequestMergeTool(MergeToolRequest request)
{
_pendingMergeTool = request;
OnUi(DeliverPendingMergeTool);
}
/// <summary>남아 있는 요청을 넘긴다. 파일 비교 화면이 새로 만들어지면 스스로 한 번 불러 준다.</summary>
public void DeliverPendingMergeTool()
{
if (_pendingMergeTool is not { } request) return;
if (MergeToolRequested is null) return;
_pendingMergeTool = null;
MergeToolRequested.Invoke(request);
}
// ================= 켤 때 넘어온 인자 =================
/// <summary>폴더 비교 화면이 아직 없을 때 남겨 두는 요청. 파일 비교 쪽과 같은 방식이다.</summary>
private (string Left, string Right)? _pendingFolderCompare;
public void RequestFolderCompare(string left, string right)
{
_pendingFolderCompare = (left, right);
OnUi(DeliverPendingFolderCompare);
}
public void DeliverPendingFolderCompare()
{
if (_pendingFolderCompare is not { } pending) return;
if (FolderCompareRequested is null) return;
_pendingFolderCompare = null;
FolderCompareRequested.Invoke(pending.Left, pending.Right);
}
public event Action<string, string>? FolderCompareRequested;
/// <summary>
/// `git mergetool` · `git difftool` · 탐색기 우클릭이 넘긴 것을 화면으로 보낸다.
///
/// 창이 다 만들어진 뒤에 탭을 옮겨야 한다 - 그 전에는 옮길 곳이 아직 없어 요청이 사라진다.
/// 요청 자체는 남겨 두므로(pending) 늦게 만들어진 화면도 스스로 집어 간다.
/// </summary>
private void RouteStartupArgs()
{
var args = StartupArgs.Current;
// 받은 인자를 파일 로그에 그대로 남긴다(11차 F-3). 화면 로그는 끄면 사라져, 실제 탐색기에서
// "폴더 하나만 읽혔다" 는 일이 났을 때 무엇이 넘어왔는지 알 길이 없었다.
try
{
var raw = Environment.GetCommandLineArgs();
if (raw.Length > 1)
_logger.I($"켤 때 받은 인자 {raw.Length - 1}개: " + string.Join(" | ", raw.Skip(1)) +
$" → {args.Mode} · 왼쪽 '{args.LeftFolder}{args.LooseLeft}' · 오른쪽 '{args.RightFolder}{args.LooseRight}'");
}
catch (NotSupportedException)
{
}
if (!ReportArgs(args, "탐색기에서")) return;
if (args.WantsFolder)
{
RequestFolderCompare(args.LeftFolder, args.RightFolder);
ActivateWhenReady(MergeHub.ViewIds.Folder);
AppendLog($"탐색기에서 폴더 비교 요청: {args.LeftFolder}");
return;
}
// mergetool 이 세 벌을 넘겼으면 네 칸으로 연다(6차 B-4 확인 중 발견 - 예전에는 BASE · MERGED 두 칸이었다).
if (args.ThreeWayMerge is { } merge)
{
RequestMergeTool(merge);
ActivateWhenReady(MergeHub.ViewIds.Home);
AppendLog($"mergetool 로 호출됨(네 칸): {merge.Merged}");
return;
}
if (!args.WantsFiles) return;
var (left, right) = args.DiffPair;
RequestPlainCompare(left, right);
ActivateWhenReady(MergeHub.ViewIds.Home);
AppendLog(args.Mode == StartupMode.Merge
? $"mergetool 로 호출됨: {args.Merged}"
: $"파일 비교 요청: {left} ↔ {right}");
}
/// <summary>
/// 탐색기에서 끌어다 놓은 것을 화면으로 보낸다.
///
/// 명령줄로 들어오는 길과 <b>같은 판단</b>을 쓴다 - 폴더면 폴더 비교, 파일이면 파일 비교.
/// 두 길이 다르게 굴면 "우클릭으로 열 때랑 끌어다 놓을 때가 다르다"가 되어 설명할 수 없다.
/// </summary>
public void RouteDroppedPaths(IReadOnlyList<string> paths)
{
if (paths is null || paths.Count == 0) return;
var args = StartupArgs.Parse(paths);
if (!ReportArgs(args, "끌어다 놓기")) return;
if (args.WantsFolder)
{
RequestFolderCompare(args.LeftFolder, args.RightFolder);
ActivateWhenReady(MergeHub.ViewIds.Folder);
AppendLog($"끌어다 놓기 - 폴더 비교: {args.LeftFolder}");
return;
}
if (!args.WantsFiles) return;
var (left, right) = args.DiffPair;
RequestPlainCompare(left, right);
ActivateWhenReady(MergeHub.ViewIds.Home);
AppendLog($"끌어다 놓기 - 파일 비교: {left} ↔ {right}");
}
/// <summary>
/// 넘어온 경로에 알릴 것(열 수 없는 경로 · 셋째부터)이 있으면 로그와 알림에 같은 말로 남긴다(11차 F-3).
/// 열 수 없는 경로가 있으면 false - 아무것도 열지 않는다.
/// </summary>
private bool ReportArgs(StartupArgs args, string from)
{
if (args.Notice is not { } notice) return true;
AppendLog($"{from}: {notice}");
// 켤 때는 사용자 이름 창(모달)이 먼저 뜬다. 그 뒤에서 알림이 지나가 버리지 않게, 본 창이 활성이 된 뒤에 띄운다.
var app = System.Windows.Application.Current;
app?.Dispatcher.BeginInvoke(new Action(() =>
{
var window = app.MainWindow;
if (window is null || window.IsActive)
{
MessageUtil.ShowWarningToast(notice);
return;
}
EventHandler? once = null;
once = (_, _) =>
{
window.Activated -= once;
MessageUtil.ShowWarningToast(notice);
};
window.Activated += once;
}), System.Windows.Threading.DispatcherPriority.ApplicationIdle);
return args.Missing.Count == 0;
}
private static void ActivateWhenReady(string viewId)
{
var app = System.Windows.Application.Current;
if (app is null) return;
app.Dispatcher.BeginInvoke(
new Action(() => UbisamBase.Core.Modules.TabManager.Activate(viewId)),
System.Windows.Threading.DispatcherPriority.ApplicationIdle);
}
/// <summary>ObservableCollection 변경은 UI 스레드에서만 허용된다.</summary>
public void OnUi(Action action)
{
if (_uiContext is null || SynchronizationContext.Current == _uiContext)
{
action();
return;
}
_uiContext.Send(_ => action(), null);
}
private static void Replace<T>(ObservableCollection<T> target, IEnumerable<T> items)
{
target.Clear();
foreach (var item in items) target.Add(item);
}
}
}