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}");
}
}
}
}