using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using System.Windows; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using MergeHub.Services; using UbisamBase.Core.Messaging; namespace MergeHub.ViewModels { /// 목록에 무엇을 남길지. 파일이 수백 개면 눈으로 고르는 것이 제일 느리다. public enum FolderFilter { Different, All, LeftOnly, RightOnly, Same } /// /// 화면에 뿌리는 폴더 비교 항목. 비교 결과(FolderCompareItem)를 그대로 감싸고 /// 트리에 필요한 것(펼침 상태·자식)만 얹는다. /// public sealed partial class FolderNode : ObservableObject { public FolderNode(FolderCompareItem item, string leftRoot, string rightRoot) { Item = item; LeftPath = Path.Combine(leftRoot, item.RelativePath); RightPath = Path.Combine(rightRoot, item.RelativePath); foreach (var child in item.Children) { Children.Add(new FolderNode(child, leftRoot, rightRoot)); } } public FolderCompareItem Item { get; } public ObservableCollection Children { get; } = new ObservableCollection(); public string LeftPath { get; } public string RightPath { get; } public string Name => Item.Name; public string RelativePath => Item.RelativePath; public bool IsDirectory => Item.IsDirectory; public FolderEntryStatus Status => Item.Status; /// 다른 것부터 눈에 띄도록 처음부터 펼쳐 둔다. 같기만 한 폴더는 접어 둔다. [ObservableProperty] private bool _isExpanded; [ObservableProperty] private bool _isSelected; public string StatusText => Status switch { FolderEntryStatus.Same => "같음", FolderEntryStatus.Different => "다름", FolderEntryStatus.LeftOnly => "왼쪽만", FolderEntryStatus.RightOnly => "오른쪽만", FolderEntryStatus.Binary => "다름(이진)", _ => string.Empty }; public string LeftInfo => Describe(Item.LeftExists, Item.LeftSize, Item.LeftModified); public string RightInfo => Describe(Item.RightExists, Item.RightSize, Item.RightModified); /// /// 줄 단위 비교 화면으로 넘길 수 있는 항목인지. /// /// 한쪽에만 있는 파일도 연다 - 오히려 그런 파일이야말로 "뭐가 들었길래 새로 생겼나"를 /// 보고 싶은 대상이다. 없는 쪽은 빈 내용으로 열려 전부 추가(또는 삭제)로 보인다. /// 폴더와 이진 파일만 뺀다. /// public bool CanOpenInDiff => !IsDirectory && (Item.LeftExists || Item.RightExists) && Status != FolderEntryStatus.Binary; /// 열 수 없을 때 왜 그런지. 조용히 아무 일도 안 일어나는 것이 제일 나쁘다. public string? OpenBlockedReason => IsDirectory ? "폴더입니다. 더블클릭하면 펼치고 접습니다." : Status == FolderEntryStatus.Binary ? "이진 파일이라 줄 단위로 비교할 수 없습니다." : !Item.LeftExists && !Item.RightExists ? "양쪽 모두 파일이 없습니다." : null; private static string Describe(bool exists, long size, DateTime? modified) { if (!exists) return "-"; var when = modified?.ToString("yyyy-MM-dd HH:mm") ?? string.Empty; return $"{size:N0} B · {when}"; } } /// /// 폴더 비교 탭. /// /// 홈(파일 비교)과는 다른 일이라 탭을 따로 뒀다 - 홈은 이미 네 칸을 쓰고 있어서 /// 트리까지 얹을 자리가 없고, 하는 일도 "두 폴더 중 무엇이 다른가"로 성격이 다르다. /// 여기서 파일을 고르면 홈 화면으로 넘겨 줄 단위로 본다. /// public sealed partial class FolderViewModel : ObservableObject { private readonly GitWorkspace _workspace; public FolderViewModel(GitWorkspace workspace) { _workspace = workspace; // 저장소를 열어 뒀으면 왼쪽 기본값으로 삼는다 - 대개 그 폴더를 견주게 된다. if (!string.IsNullOrWhiteSpace(_workspace.RepositoryPath) && Directory.Exists(_workspace.RepositoryPath)) { LeftFolder = _workspace.RepositoryPath; } } /// 홈 탭에서 이 두 파일을 비교해 달라고 알린다. public event EventHandler<(string Left, string Right)>? OpenInDiffRequested; public ObservableCollection Nodes { get; } = new ObservableCollection(); [ObservableProperty] private string _leftFolder = string.Empty; [ObservableProperty] private string _rightFolder = string.Empty; [ObservableProperty] private FolderNode? _selectedNode; [ObservableProperty] private string _summary = "폴더 두 개를 고르고 비교를 누르세요."; [ObservableProperty] private bool _isBusy; /// 내용을 다 읽지 않고 크기·수정 시각만 본다. [ObservableProperty] private bool _quickCompare; [ObservableProperty] private bool _includeSubfolders = true; /// 같은 파일도 목록에 남길지. 꺼 두면 다른 것만 본다. [ObservableProperty] private bool _showSame; /// /// 무엇을 보여 줄지. 고치면 다시 비교하지 않고 목록만 다시 그린다 - /// 큰 폴더를 다시 훑는 것은 필터를 만지는 값으로 너무 비싸다. /// [ObservableProperty] private FolderFilter _filter = FolderFilter.Different; public IReadOnlyList Filters { get; } = new[] { FolderFilter.Different, FolderFilter.All, FolderFilter.LeftOnly, FolderFilter.RightOnly, FolderFilter.Same }; public static string FilterName(FolderFilter filter) => filter switch { FolderFilter.All => "전체", FolderFilter.LeftOnly => "왼쪽에만", FolderFilter.RightOnly => "오른쪽에만", FolderFilter.Same => "같은 것만", _ => "다른 것만" }; partial void OnFilterChanged(FolderFilter value) => Rebuild(); /// 마지막 비교 결과. 필터를 바꿀 때 다시 훑지 않으려고 들고 있는다. private FolderCompareResult? _lastResult; private string _lastLeft = string.Empty; private string _lastRight = string.Empty; [ObservableProperty] private string _includePattern = string.Empty; [ObservableProperty] private string _excludePattern = @"\\\.git\\|^\.git$|\\bin\\|\\obj\\|\.dll$|\.pdb$"; partial void OnSelectedNodeChanged(FolderNode? value) => RaiseCanExecuteChanged(); private void RaiseCanExecuteChanged() { CompareCommand.NotifyCanExecuteChanged(); CancelCompareCommand.NotifyCanExecuteChanged(); OpenInDiffCommand.NotifyCanExecuteChanged(); CopyToRightCommand.NotifyCanExecuteChanged(); CopyToLeftCommand.NotifyCanExecuteChanged(); DeleteLeftCommand.NotifyCanExecuteChanged(); DeleteRightCommand.NotifyCanExecuteChanged(); } [RelayCommand] private void PickLeftFolder() { if (AskForFolder("왼쪽(기준) 폴더 고르기", LeftFolder) is { } picked) LeftFolder = picked; } [RelayCommand] private void PickRightFolder() { if (AskForFolder("오른쪽(비교 대상) 폴더 고르기", RightFolder) is { } picked) RightFolder = picked; } private static string? AskForFolder(string title, string current) { // 파일 열기 대화상자를 폴더 고르기로 쓴다. net48 WPF 에는 폴더 전용 대화상자가 없고, // 셸 COM 을 직접 부르는 것보다 이 편이 유지보수가 쉽다. var dialog = new Microsoft.Win32.OpenFileDialog { Title = title + " (폴더 안에서 아무 파일이나 고르거나, 파일 이름 칸에 폴더명만 두고 열기)", CheckFileExists = false, CheckPathExists = true, FileName = "이 폴더 선택", Filter = "폴더 (*.*)|*.*" }; if (!string.IsNullOrWhiteSpace(current) && Directory.Exists(current)) { dialog.InitialDirectory = current; } if (dialog.ShowDialog() != true) return null; var path = Path.GetDirectoryName(dialog.FileName); return string.IsNullOrEmpty(path) ? null : path; } private bool CanCompare() => !IsBusy; [RelayCommand(CanExecute = nameof(CanCompare))] private async Task CompareAsync() { if (!Directory.Exists(LeftFolder) || !Directory.Exists(RightFolder)) { Summary = "양쪽 폴더가 모두 있어야 합니다."; return; } var options = new FolderCompareOptions { QuickCompare = QuickCompare, IncludeSubfolders = IncludeSubfolders, IncludePattern = IncludePattern, ExcludePattern = ExcludePattern, }; var left = LeftFolder; var right = RightFolder; _compareCancel?.Dispose(); _compareCancel = new CancellationTokenSource(); var token = _compareCancel.Token; IsBusy = true; RaiseCanExecuteChanged(); Summary = "비교하는 중... (멈추려면 [취소])"; try { var result = await Task.Run(() => FolderComparer.Compare(left, right, options, token), token); _lastResult = result; _lastLeft = left; _lastRight = right; Rebuild(); Summary = result.Summary; _workspace.AppendLog($"폴더 비교: {left} ↔ {right} · {result.Summary}"); } catch (OperationCanceledException) { // 사용자가 스스로 멈춘 것이라 실패가 아니다. 앞 결과는 그대로 둔다. Summary = "비교를 멈췄습니다."; } catch (Exception ex) { Summary = $"비교 실패: {ex.Message}"; MessageUtil.ShowErrorToast($"폴더 비교 실패: {ex.Message}"); } finally { IsBusy = false; RaiseCanExecuteChanged(); } } private CancellationTokenSource? _compareCancel; private bool CanCancelCompare() => IsBusy; [RelayCommand(CanExecute = nameof(CanCancelCompare))] private void CancelCompare() => _compareCancel?.Cancel(); /// 마지막 비교 결과를 지금 필터로 다시 그린다. private void Rebuild() { Nodes.Clear(); SelectedNode = null; if (_lastResult is null) return; foreach (var item in _lastResult.Items) { var node = new FolderNode(item, _lastLeft, _lastRight); if (!Keep(node)) continue; Prune(node); // 다른 것부터 눈에 띄도록 펼쳐 둔다. 같기만 한 가지는 접어 둔다. node.IsExpanded = Filter != FolderFilter.Same && node.Status != FolderEntryStatus.Same; Nodes.Add(node); } } /// 지금 필터에 걸리는 항목인지. 폴더는 안에 걸리는 것이 하나라도 있으면 남긴다. private bool Keep(FolderNode node) { if (Matches(node.Status)) return true; return node.Children.Any(Keep); } private bool Matches(FolderEntryStatus status) => Filter switch { FolderFilter.All => true, FolderFilter.LeftOnly => status == FolderEntryStatus.LeftOnly, FolderFilter.RightOnly => status == FolderEntryStatus.RightOnly, FolderFilter.Same => status == FolderEntryStatus.Same, _ => status != FolderEntryStatus.Same, }; /// 필터에 안 걸리는 가지를 잘라낸다. private void Prune(FolderNode node) { for (var i = node.Children.Count - 1; i >= 0; i--) { var child = node.Children[i]; if (!Keep(child)) node.Children.RemoveAt(i); else Prune(child); } } private bool CanOpenInDiff() => SelectedNode?.CanOpenInDiff == true; /// 고른 파일을 "파일 비교" 탭으로 넘기고 그 탭으로 옮긴다. [RelayCommand(CanExecute = nameof(CanOpenInDiff))] private void OpenInDiff() => OpenInDiffCore(SelectedNode); /// /// 더블클릭·Enter·버튼이 모두 여기로 들어온다. /// /// 열 수 없는 항목이면 왜 못 여는지 알려 준다 - 눌렀는데 아무 일도 안 일어나면 /// 기능이 없는 줄 알게 된다(폴더나 한쪽에만 있는 파일을 눌렀을 때 실제로 그랬다). /// public void OpenInDiffCore(FolderNode? node) { if (node is null) return; if (!node.CanOpenInDiff) { if (node.OpenBlockedReason is { } reason) MessageUtil.ShowWarningToast(reason); return; } OpenInDiffRequested?.Invoke(this, (node.LeftPath, node.RightPath)); // 요청을 먼저 걸고 탭을 연다. 파일 비교 화면이 아직 없으면 그 화면이 만들어지면서 // 남아 있는 요청을 직접 집어 간다(GitWorkspace.RequestPlainCompare 설명 참고). _workspace.RequestPlainCompare(node.LeftPath, node.RightPath); UbisamBase.Core.Modules.TabManager.Activate(ViewIds.Home); } private bool CanCopyToRight() => SelectedNode is { } node && node.Item.LeftExists; [RelayCommand(CanExecute = nameof(CanCopyToRight))] private void CopyToRight() => Copy(SelectedNode, toRight: true); private bool CanCopyToLeft() => SelectedNode is { } node && node.Item.RightExists; [RelayCommand(CanExecute = nameof(CanCopyToLeft))] private void CopyToLeft() => Copy(SelectedNode, toRight: false); /// /// 한쪽을 다른 쪽으로 덮어쓴다. 파일 하나든 폴더째든 같은 자리에서 한다. /// /// 폴더는 안에 든 파일 수를 세어 보여 준다 - "폴더 하나"라고만 물으면 /// 몇 개가 덮이는지 모르는 채 확인을 누르게 된다. /// 덮어쓰기는 되돌릴 수 없으므로 무엇을 어디로 쓰는지 그대로 적는다. /// private void Copy(FolderNode? node, bool toRight) { if (node is null) return; var from = toRight ? node.LeftPath : node.RightPath; var to = toRight ? node.RightPath : node.LeftPath; var direction = toRight ? "왼쪽 → 오른쪽" : "오른쪽 → 왼쪽"; if (node.IsDirectory) { if (!Directory.Exists(from)) return; var files = FileOps.CountFiles(from); var question = $"{direction} 폴더째 복사합니다.\n\n" + $"원본: {from}\n대상: {to}\n\n" + $"파일 {files}개를 덮어씁니다. 되돌릴 수 없습니다."; if (!Confirm(question, "폴더 복사")) return; Run(() => FileOps.CopyDirectory(from, to), $"복사: {from} → {to} (파일 {files}개)"); return; } if (!File.Exists(from)) return; var exists = File.Exists(to); var fileQuestion = $"{direction} 복사합니다.\n\n" + $"원본: {from}\n대상: {to}\n\n" + (exists ? "대상 파일을 덮어씁니다. 되돌릴 수 없습니다." : "대상에 새로 만듭니다."); if (!Confirm(fileQuestion, "파일 복사")) return; Run(() => { var folder = Path.GetDirectoryName(to); if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder); File.Copy(from, to, overwrite: true); }, $"복사: {from} → {to}"); } private bool CanDeleteLeft() => SelectedNode is { } node && node.Item.LeftExists; [RelayCommand(CanExecute = nameof(CanDeleteLeft))] private void DeleteLeft() => Delete(SelectedNode, left: true); private bool CanDeleteRight() => SelectedNode is { } node && node.Item.RightExists; [RelayCommand(CanExecute = nameof(CanDeleteRight))] private void DeleteRight() => Delete(SelectedNode, left: false); /// 파일 또는 폴더 삭제. 폴더는 몇 개가 사라지는지 세어 보여 준다. private void Delete(FolderNode? node, bool left) { if (node is null) return; var path = left ? node.LeftPath : node.RightPath; if (node.IsDirectory) { if (!Directory.Exists(path)) return; var files = FileOps.CountFiles(path); if (!Confirm($"폴더를 통째로 지웁니다.\n\n{path}\n\n" + $"안에 든 파일 {files}개가 함께 사라집니다. 되돌릴 수 없습니다.", "폴더 삭제")) return; Run(() => Directory.Delete(path, recursive: true), $"삭제: {path} (파일 {files}개)"); return; } if (!File.Exists(path)) return; if (!Confirm($"파일을 지웁니다.\n\n{path}\n\n되돌릴 수 없습니다. 지울까요?", "파일 삭제")) return; Run(() => File.Delete(path), $"삭제: {path}"); } private static bool Confirm(string question, string title) => MessageBox.Show(question, title, MessageBoxButton.OKCancel, MessageBoxImage.Warning, MessageBoxResult.Cancel) == MessageBoxResult.OK; /// 파일을 건드리는 일은 모두 여기를 지난다 - 로그와 실패 알림을 한 곳에서 한다. private void Run(Action work, string log) { try { work(); _workspace.AppendLog(log); MessageUtil.ShowSuccessToast("했습니다. 다시 비교하면 결과가 갱신됩니다."); } catch (Exception ex) { MessageUtil.ShowErrorToast($"실패: {ex.Message}"); } } } }