비교만 되고 정리는 안 되던 화면이었다. 셋을 채워 "보고 → 고르고 → 맞춘다" 까지 닫는다. - 폴더째 복사·삭제: 파일 하나씩만 되던 것을 폴더에도 열었다. 확인 창에 안에 든 파일 수를 적는다 - "폴더 하나"라고만 물으면 몇 개가 덮이는지 모르는 채 확인을 누르게 된다. 복사는 겹치는 파일만 덮고 대상에만 있던 파일은 남긴다(동기화가 아니다). - 보기 필터: 다른 것만 / 전체 / 왼쪽에만 / 오른쪽에만 / 같은 것만. 다시 비교하지 않고 마지막 결과로 목록만 다시 그린다 - 큰 폴더를 다시 훑는 것은 필터를 만지는 값으로 너무 비싸다. 기존 "같은 파일도 보기" 체크는 이 필터가 대신한다. - 비교 취소: FolderComparer.Compare 에 CancellationToken 을 넣고 항목마다 확인한다. 누르는 즉시 멈추고, 앞서 본 결과는 그대로 둔다(실패가 아니라 사용자의 결정이다). 설계 하나 - 파일을 실제로 옮기고 지우는 부분을 Services/FileOps.cs 로 뺐다. 되돌릴 수 없는 일이라 화면 없이 검사할 수 있어야 한다. 실행 확인: sub(파일 2개) 폴더째 복사 → b\sub\deep\two.txt 까지 따라옴. 목록 15개 → "왼쪽에만" 5개(요약 줄은 그대로 = 다시 비교하지 않음). FolderTest 27 → 37건(취소 3 · 파일 세기 2 · 폴더 복사 5). 전체 300건 통과. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
485 lines
19 KiB
C#
485 lines
19 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 System.Windows;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using MergeHub.Services;
|
|
using UbisamBase.Core.Messaging;
|
|
|
|
namespace MergeHub.ViewModels
|
|
{
|
|
/// <summary>목록에 무엇을 남길지. 파일이 수백 개면 눈으로 고르는 것이 제일 느리다.</summary>
|
|
public enum FolderFilter
|
|
{
|
|
Different,
|
|
All,
|
|
LeftOnly,
|
|
RightOnly,
|
|
Same
|
|
}
|
|
|
|
/// <summary>
|
|
/// 화면에 뿌리는 폴더 비교 항목. 비교 결과(FolderCompareItem)를 그대로 감싸고
|
|
/// 트리에 필요한 것(펼침 상태·자식)만 얹는다.
|
|
/// </summary>
|
|
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<FolderNode> Children { get; } = new ObservableCollection<FolderNode>();
|
|
|
|
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;
|
|
|
|
/// <summary>다른 것부터 눈에 띄도록 처음부터 펼쳐 둔다. 같기만 한 폴더는 접어 둔다.</summary>
|
|
[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);
|
|
|
|
/// <summary>
|
|
/// 줄 단위 비교 화면으로 넘길 수 있는 항목인지.
|
|
///
|
|
/// 한쪽에만 있는 파일도 연다 - 오히려 그런 파일이야말로 "뭐가 들었길래 새로 생겼나"를
|
|
/// 보고 싶은 대상이다. 없는 쪽은 빈 내용으로 열려 전부 추가(또는 삭제)로 보인다.
|
|
/// 폴더와 이진 파일만 뺀다.
|
|
/// </summary>
|
|
public bool CanOpenInDiff =>
|
|
!IsDirectory && (Item.LeftExists || Item.RightExists) && Status != FolderEntryStatus.Binary;
|
|
|
|
/// <summary>열 수 없을 때 왜 그런지. 조용히 아무 일도 안 일어나는 것이 제일 나쁘다.</summary>
|
|
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}";
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 폴더 비교 탭.
|
|
///
|
|
/// 홈(파일 비교)과는 다른 일이라 탭을 따로 뒀다 - 홈은 이미 네 칸을 쓰고 있어서
|
|
/// 트리까지 얹을 자리가 없고, 하는 일도 "두 폴더 중 무엇이 다른가"로 성격이 다르다.
|
|
/// 여기서 파일을 고르면 홈 화면으로 넘겨 줄 단위로 본다.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>홈 탭에서 이 두 파일을 비교해 달라고 알린다.</summary>
|
|
public event EventHandler<(string Left, string Right)>? OpenInDiffRequested;
|
|
|
|
public ObservableCollection<FolderNode> Nodes { get; } = new ObservableCollection<FolderNode>();
|
|
|
|
[ObservableProperty] private string _leftFolder = string.Empty;
|
|
[ObservableProperty] private string _rightFolder = string.Empty;
|
|
|
|
[ObservableProperty] private FolderNode? _selectedNode;
|
|
|
|
[ObservableProperty] private string _summary = "폴더 두 개를 고르고 비교를 누르세요.";
|
|
[ObservableProperty] private bool _isBusy;
|
|
|
|
/// <summary>내용을 다 읽지 않고 크기·수정 시각만 본다.</summary>
|
|
[ObservableProperty] private bool _quickCompare;
|
|
|
|
[ObservableProperty] private bool _includeSubfolders = true;
|
|
|
|
/// <summary>같은 파일도 목록에 남길지. 꺼 두면 다른 것만 본다.</summary>
|
|
[ObservableProperty] private bool _showSame;
|
|
|
|
/// <summary>
|
|
/// 무엇을 보여 줄지. 고치면 <b>다시 비교하지 않고</b> 목록만 다시 그린다 -
|
|
/// 큰 폴더를 다시 훑는 것은 필터를 만지는 값으로 너무 비싸다.
|
|
/// </summary>
|
|
[ObservableProperty] private FolderFilter _filter = FolderFilter.Different;
|
|
|
|
public IReadOnlyList<FolderFilter> 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();
|
|
|
|
/// <summary>마지막 비교 결과. 필터를 바꿀 때 다시 훑지 않으려고 들고 있는다.</summary>
|
|
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();
|
|
|
|
/// <summary>마지막 비교 결과를 지금 필터로 다시 그린다.</summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>지금 필터에 걸리는 항목인지. 폴더는 <b>안에 걸리는 것이 하나라도 있으면</b> 남긴다.</summary>
|
|
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,
|
|
};
|
|
|
|
/// <summary>필터에 안 걸리는 가지를 잘라낸다.</summary>
|
|
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;
|
|
|
|
/// <summary>고른 파일을 "파일 비교" 탭으로 넘기고 그 탭으로 옮긴다.</summary>
|
|
[RelayCommand(CanExecute = nameof(CanOpenInDiff))]
|
|
private void OpenInDiff() => OpenInDiffCore(SelectedNode);
|
|
|
|
/// <summary>
|
|
/// 더블클릭·Enter·버튼이 모두 여기로 들어온다.
|
|
///
|
|
/// 열 수 없는 항목이면 <b>왜 못 여는지 알려 준다</b> - 눌렀는데 아무 일도 안 일어나면
|
|
/// 기능이 없는 줄 알게 된다(폴더나 한쪽에만 있는 파일을 눌렀을 때 실제로 그랬다).
|
|
/// </summary>
|
|
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);
|
|
|
|
/// <summary>
|
|
/// 한쪽을 다른 쪽으로 덮어쓴다. 파일 하나든 폴더째든 같은 자리에서 한다.
|
|
///
|
|
/// 폴더는 <b>안에 든 파일 수를 세어 보여 준다</b> - "폴더 하나"라고만 물으면
|
|
/// 몇 개가 덮이는지 모르는 채 확인을 누르게 된다.
|
|
/// 덮어쓰기는 되돌릴 수 없으므로 무엇을 어디로 쓰는지 그대로 적는다.
|
|
/// </summary>
|
|
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);
|
|
|
|
/// <summary>파일 또는 폴더 삭제. 폴더는 몇 개가 사라지는지 세어 보여 준다.</summary>
|
|
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;
|
|
|
|
/// <summary>파일을 건드리는 일은 모두 여기를 지난다 - 로그와 실패 알림을 한 곳에서 한다.</summary>
|
|
private void Run(Action work, string log)
|
|
{
|
|
try
|
|
{
|
|
work();
|
|
_workspace.AppendLog(log);
|
|
MessageUtil.ShowSuccessToast("했습니다. 다시 비교하면 결과가 갱신됩니다.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
MessageUtil.ShowErrorToast($"실패: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|