폴더 비교: 폴더째 복사·삭제 · 보기 필터 · 비교 취소

비교만 되고 정리는 안 되던 화면이었다. 셋을 채워 "보고 → 고르고 → 맞춘다" 까지 닫는다.

- 폴더째 복사·삭제: 파일 하나씩만 되던 것을 폴더에도 열었다. 확인 창에 안에 든
  파일 수를 적는다 - "폴더 하나"라고만 물으면 몇 개가 덮이는지 모르는 채 확인을
  누르게 된다. 복사는 겹치는 파일만 덮고 대상에만 있던 파일은 남긴다(동기화가 아니다).
- 보기 필터: 다른 것만 / 전체 / 왼쪽에만 / 오른쪽에만 / 같은 것만.
  다시 비교하지 않고 마지막 결과로 목록만 다시 그린다 - 큰 폴더를 다시 훑는 것은
  필터를 만지는 값으로 너무 비싸다. 기존 "같은 파일도 보기" 체크는 이 필터가 대신한다.
- 비교 취소: 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>
This commit is contained in:
김승준 2026-09-08 11:20:55 +09:00
parent 85c5e9e085
commit 524a6a715d
6 changed files with 273 additions and 74 deletions

View File

@ -43,13 +43,17 @@
CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘만 갱신 목록에서 빠져 있었다. CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘만 갱신 목록에서 빠져 있었다.
키보드(`Alt+←/→`)로는 되고 버튼만 안 되던 이유가 이것이다. 키보드(`Alt+←/→`)로는 되고 버튼만 안 되던 이유가 이것이다.
### ① 폴더 비교 ### ① 폴더 비교 — **완료** (2026-09-08)
| 상태 | 항목 | 내용 | | 상태 | 항목 | 내용 | 확인 |
|---|---|---| |---|---|---|---|
| 대기 | 폴더째 복사·삭제 | 지금은 파일 하나씩만 된다(`CanCopyToRight` 가 파일만 허용) | | **완료** | 폴더째 복사·삭제 | 파일 하나씩만 되던 것을 폴더에도 열었다. 확인 창에 <b>안에 든 파일 수</b>를 적는다 - "폴더 하나"라고만 물으면 몇 개가 덮이는지 모르는 채 확인을 누르게 된다 | `sub`(파일 2개) 폴더째 복사 → `bsubdeep wo.txt` 까지 따라옴 |
| 대기 | 상태별 보기 필터 | 왼쪽만 / 오른쪽만 / 다른 것만 | | **완료** | 상태별 보기 필터 | 다른 것만 / 전체 / 왼쪽에만 / 오른쪽에만 / 같은 것만. <b>다시 비교하지 않고</b> 목록만 다시 그린다 | 15개 → "왼쪽에만" 5개, 요약 줄은 그대로 |
| 대기 | 비교 취소 | 큰 폴더를 잘못 고르면 기다리는 수밖에 없다 | | **완료** | 비교 취소 | `FolderComparer.Compare` 에 `CancellationToken`. 항목 하나를 볼 때마다 확인하므로 누르는 즉시 멈춘다. 앞서 본 결과는 그대로 남는다 | 테스트 3건 |
**설계 하나**: 파일을 실제로 옮기고 지우는 부분을 `Services/FileOps.cs` 로 뺐다.
되돌릴 수 없는 일이라 <b>화면 없이 검사할 수 있어야</b> 하기 때문이다 - 겹치는 파일은 덮고,
대상에만 있던 파일은 남기고, 두 번 복사해도 같은지를 테스트로 못 박았다.
### ② 파일 비교 ### ② 파일 비교
@ -81,4 +85,5 @@ CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘
|---|---|---| |---|---|---|
| 2026-09-08 | 저장소 개설 (MergeHub · UbisamPlatform 각각) | `7719094` / `36ee902` | | 2026-09-08 | 저장소 개설 (MergeHub · UbisamPlatform 각각) | `7719094` / `36ee902` |
| 2026-09-08 | **④ Git 조작 5가지** — amend · stash 목록 · 커밋 간 비교 · blame · 변경분 추출 | `9956567` | | 2026-09-08 | **④ Git 조작 5가지** — amend · stash 목록 · 커밋 간 비교 · blame · 변경분 추출 | `9956567` |
| 2026-09-08 | **③ 3-Way** — 자동 병합 · 충돌만 이동 · 병합 완료 판정 | 아래 | | 2026-09-08 | **③ 3-Way** — 자동 병합 · 충돌만 이동 · 병합 완료 판정 | `85c5e9e` |
| 2026-09-08 | **① 폴더 비교** — 폴더째 복사·삭제 · 보기 필터 · 비교 취소 | 아래 |

View File

@ -0,0 +1,49 @@
using System.IO;
namespace MergeHub.Services
{
/// <summary>
/// 폴더 비교에서 실제로 파일을 옮기고 지우는 부분.
///
/// ViewModel 이 아니라 여기 두는 이유 - 화면 없이 검사할 수 있어야 한다.
/// 되돌릴 수 없는 일이라 "정말 그렇게 도는지"를 눈이 아니라 테스트로 확인해야 한다.
/// </summary>
public static class FileOps
{
/// <summary>
/// 폴더 안에 든 파일 수(하위 폴더 포함).
/// 확인 창에 "몇 개가 덮이는지"를 적어 주려고 쓴다 - "폴더 하나"라고만 물으면
/// 몇 개가 사라지는지 모르는 채 확인을 누르게 된다.
/// </summary>
public static int CountFiles(string folder)
{
try
{
return Directory.GetFiles(folder, "*", SearchOption.AllDirectories).Length;
}
catch (IOException)
{
return 0; // 셀 수 없으면 0 으로 두고 진행 여부는 사람이 판단한다
}
catch (System.UnauthorizedAccessException)
{
return 0;
}
}
/// <summary>
/// 폴더를 통째로 복사한다. 대상에 이미 있는 파일은 덮어쓰고, 대상에만 있는 파일은 그대로 둔다.
/// (지우면서 맞추는 "동기화"가 아니다 - 지우는 것은 삭제 버튼이 따로 맡는다)
/// </summary>
public static void CopyDirectory(string from, string to)
{
Directory.CreateDirectory(to);
foreach (var file in Directory.GetFiles(from))
File.Copy(file, Path.Combine(to, Path.GetFileName(file)), overwrite: true);
foreach (var child in Directory.GetDirectories(from))
CopyDirectory(child, Path.Combine(to, Path.GetFileName(child)));
}
}
}

View File

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading;
namespace MergeHub.Services namespace MergeHub.Services
{ {
@ -110,7 +111,12 @@ namespace MergeHub.Services
/// <summary>이 개수를 넘으면 멈춘다. 실수로 C:\ 를 고른 경우에 화면이 굳는 것을 막는다.</summary> /// <summary>이 개수를 넘으면 멈춘다. 실수로 C:\ 를 고른 경우에 화면이 굳는 것을 막는다.</summary>
private const int MaxEntries = 50_000; private const int MaxEntries = 50_000;
public static FolderCompareResult Compare(string leftRoot, string rightRoot, FolderCompareOptions options) /// <param name="cancel">
/// 큰 폴더를 잘못 골랐을 때 기다리는 수밖에 없으면 안 된다.
/// 항목 하나를 볼 때마다 확인하므로 누르는 즉시 멈춘다.
/// </param>
public static FolderCompareResult Compare(string leftRoot, string rightRoot, FolderCompareOptions options,
CancellationToken cancel = default)
{ {
var result = new FolderCompareResult(); var result = new FolderCompareResult();
@ -121,7 +127,7 @@ namespace MergeHub.Services
var exclude = Compile(options.ExcludePattern); var exclude = Compile(options.ExcludePattern);
var count = 0; var count = 0;
Walk(leftRoot, rightRoot, string.Empty, options, include, exclude, result, result.Items, ref count); Walk(leftRoot, rightRoot, string.Empty, options, include, exclude, result, result.Items, ref count, cancel);
result.Truncated = count >= MaxEntries; result.Truncated = count >= MaxEntries;
return result; return result;
@ -129,8 +135,10 @@ namespace MergeHub.Services
private static void Walk(string leftRoot, string rightRoot, string relative, private static void Walk(string leftRoot, string rightRoot, string relative,
FolderCompareOptions options, Regex? include, Regex? exclude, FolderCompareOptions options, Regex? include, Regex? exclude,
FolderCompareResult result, List<FolderCompareItem> into, ref int count) FolderCompareResult result, List<FolderCompareItem> into, ref int count,
CancellationToken cancel)
{ {
cancel.ThrowIfCancellationRequested();
if (count >= MaxEntries) return; if (count >= MaxEntries) return;
var leftDir = Path.Combine(leftRoot, relative); var leftDir = Path.Combine(leftRoot, relative);
@ -143,6 +151,7 @@ namespace MergeHub.Services
foreach (var name in names) foreach (var name in names)
{ {
cancel.ThrowIfCancellationRequested();
if (count >= MaxEntries) return; if (count >= MaxEntries) return;
var childRelative = string.IsNullOrEmpty(relative) ? name : Path.Combine(relative, name); var childRelative = string.IsNullOrEmpty(relative) ? name : Path.Combine(relative, name);
@ -208,7 +217,7 @@ namespace MergeHub.Services
RightExists = Directory.Exists(Path.Combine(rightDir, name)), RightExists = Directory.Exists(Path.Combine(rightDir, name)),
}; };
Walk(leftRoot, rightRoot, childRelative, options, include, exclude, result, item.Children, ref count); Walk(leftRoot, rightRoot, childRelative, options, include, exclude, result, item.Children, ref count, cancel);
item.Status = FolderStatus(item); item.Status = FolderStatus(item);

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Windows; using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
@ -12,6 +13,16 @@ using UbisamBase.Core.Messaging;
namespace MergeHub.ViewModels namespace MergeHub.ViewModels
{ {
/// <summary>목록에 무엇을 남길지. 파일이 수백 개면 눈으로 고르는 것이 제일 느리다.</summary>
public enum FolderFilter
{
Different,
All,
LeftOnly,
RightOnly,
Same
}
/// <summary> /// <summary>
/// 화면에 뿌리는 폴더 비교 항목. 비교 결과(FolderCompareItem)를 그대로 감싸고 /// 화면에 뿌리는 폴더 비교 항목. 비교 결과(FolderCompareItem)를 그대로 감싸고
/// 트리에 필요한 것(펼침 상태·자식)만 얹는다. /// 트리에 필요한 것(펼침 상태·자식)만 얹는다.
@ -129,6 +140,35 @@ namespace MergeHub.ViewModels
/// <summary>같은 파일도 목록에 남길지. 꺼 두면 다른 것만 본다.</summary> /// <summary>같은 파일도 목록에 남길지. 꺼 두면 다른 것만 본다.</summary>
[ObservableProperty] private bool _showSame; [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 _includePattern = string.Empty;
[ObservableProperty] private string _excludePattern = @"\\\.git\\|^\.git$|\\bin\\|\\obj\\|\.dll$|\.pdb$"; [ObservableProperty] private string _excludePattern = @"\\\.git\\|^\.git$|\\bin\\|\\obj\\|\.dll$|\.pdb$";
@ -136,6 +176,8 @@ namespace MergeHub.ViewModels
private void RaiseCanExecuteChanged() private void RaiseCanExecuteChanged()
{ {
CompareCommand.NotifyCanExecuteChanged();
CancelCompareCommand.NotifyCanExecuteChanged();
OpenInDiffCommand.NotifyCanExecuteChanged(); OpenInDiffCommand.NotifyCanExecuteChanged();
CopyToRightCommand.NotifyCanExecuteChanged(); CopyToRightCommand.NotifyCanExecuteChanged();
CopyToLeftCommand.NotifyCanExecuteChanged(); CopyToLeftCommand.NotifyCanExecuteChanged();
@ -201,28 +243,31 @@ namespace MergeHub.ViewModels
var left = LeftFolder; var left = LeftFolder;
var right = RightFolder; var right = RightFolder;
_compareCancel?.Dispose();
_compareCancel = new CancellationTokenSource();
var token = _compareCancel.Token;
IsBusy = true; IsBusy = true;
CompareCommand.NotifyCanExecuteChanged(); RaiseCanExecuteChanged();
Summary = "비교하는 중..."; Summary = "비교하는 중... (멈추려면 [취소])";
try try
{ {
var result = await Task.Run(() => FolderComparer.Compare(left, right, options)); var result = await Task.Run(() => FolderComparer.Compare(left, right, options, token), token);
Nodes.Clear(); _lastResult = result;
foreach (var item in result.Items) _lastLeft = left;
{ _lastRight = right;
var node = new FolderNode(item, left, right);
if (!ShowSame && !Keep(node)) continue;
Prune(node);
node.IsExpanded = node.Status != FolderEntryStatus.Same;
Nodes.Add(node);
}
Rebuild();
Summary = result.Summary; Summary = result.Summary;
_workspace.AppendLog($"폴더 비교: {left} ↔ {right} · {result.Summary}"); _workspace.AppendLog($"폴더 비교: {left} ↔ {right} · {result.Summary}");
} }
catch (OperationCanceledException)
{
// 사용자가 스스로 멈춘 것이라 실패가 아니다. 앞 결과는 그대로 둔다.
Summary = "비교를 멈췄습니다.";
}
catch (Exception ex) catch (Exception ex)
{ {
Summary = $"비교 실패: {ex.Message}"; Summary = $"비교 실패: {ex.Message}";
@ -231,22 +276,57 @@ namespace MergeHub.ViewModels
finally finally
{ {
IsBusy = false; IsBusy = false;
CompareCommand.NotifyCanExecuteChanged(); RaiseCanExecuteChanged();
} }
} }
/// <summary>"같은 것 숨기기"가 켜져 있을 때 남길 항목인지.</summary> private CancellationTokenSource? _compareCancel;
private static bool Keep(FolderNode node)
private bool CanCancelCompare() => IsBusy;
[RelayCommand(CanExecute = nameof(CanCancelCompare))]
private void CancelCompare() => _compareCancel?.Cancel();
/// <summary>마지막 비교 결과를 지금 필터로 다시 그린다.</summary>
private void Rebuild()
{ {
if (node.Status != FolderEntryStatus.Same) return true; 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); return node.Children.Any(Keep);
} }
/// <summary>같기만 한 가지를 잘라낸다. 트리에서 다른 것만 남기려는 것이다.</summary> 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) private void Prune(FolderNode node)
{ {
if (ShowSame) return;
for (var i = node.Children.Count - 1; i >= 0; i--) for (var i = node.Children.Count - 1; i >= 0; i--)
{ {
var child = node.Children[i]; var child = node.Children[i];
@ -255,6 +335,7 @@ namespace MergeHub.ViewModels
} }
} }
private bool CanOpenInDiff() => SelectedNode?.CanOpenInDiff == true; private bool CanOpenInDiff() => SelectedNode?.CanOpenInDiff == true;
/// <summary>고른 파일을 "파일 비교" 탭으로 넘기고 그 탭으로 옮긴다.</summary> /// <summary>고른 파일을 "파일 비교" 탭으로 넘기고 그 탭으로 옮긴다.</summary>
@ -285,92 +366,119 @@ namespace MergeHub.ViewModels
UbisamBase.Core.Modules.TabManager.Activate(ViewIds.Home); UbisamBase.Core.Modules.TabManager.Activate(ViewIds.Home);
} }
private bool CanCopyToRight() => SelectedNode is { IsDirectory: false } node && node.Item.LeftExists; private bool CanCopyToRight() => SelectedNode is { } node && node.Item.LeftExists;
[RelayCommand(CanExecute = nameof(CanCopyToRight))] [RelayCommand(CanExecute = nameof(CanCopyToRight))]
private void CopyToRight() => Copy(SelectedNode, toRight: true); private void CopyToRight() => Copy(SelectedNode, toRight: true);
private bool CanCopyToLeft() => SelectedNode is { IsDirectory: false } node && node.Item.RightExists; private bool CanCopyToLeft() => SelectedNode is { } node && node.Item.RightExists;
[RelayCommand(CanExecute = nameof(CanCopyToLeft))] [RelayCommand(CanExecute = nameof(CanCopyToLeft))]
private void CopyToLeft() => Copy(SelectedNode, toRight: false); private void CopyToLeft() => Copy(SelectedNode, toRight: false);
/// <summary> /// <summary>
/// 한쪽 파일로 다른 쪽을 덮어쓴다. /// 한쪽을 다른 쪽으로 덮어쓴다. 파일 하나든 폴더째든 같은 자리에서 한다.
/// 덮어쓰기는 되돌릴 수 없으므로 <b>무엇을 어디로 쓰는지</b>를 그대로 보여 주고 확인을 받는다. ///
/// 폴더는 <b>안에 든 파일 수를 세어 보여 준다</b> - "폴더 하나"라고만 물으면
/// 몇 개가 덮이는지 모르는 채 확인을 누르게 된다.
/// 덮어쓰기는 되돌릴 수 없으므로 무엇을 어디로 쓰는지 그대로 적는다.
/// </summary> /// </summary>
private void Copy(FolderNode? node, bool toRight) private void Copy(FolderNode? node, bool toRight)
{ {
if (node is null || node.IsDirectory) return; if (node is null) return;
var from = toRight ? node.LeftPath : node.RightPath; var from = toRight ? node.LeftPath : node.RightPath;
var to = toRight ? node.RightPath : node.LeftPath; var to = toRight ? node.RightPath : node.LeftPath;
if (!File.Exists(from)) return; var direction = toRight ? "왼쪽 → 오른쪽" : "오른쪽 → 왼쪽";
var exists = File.Exists(to); if (node.IsDirectory)
var question =
$"{(toRight ? "왼쪽 → 오른쪽" : "오른쪽 → 왼쪽")} 복사합니다.\n\n" +
$"원본: {from}\n대상: {to}\n\n" +
(exists ? "대상 파일을 덮어씁니다. 되돌릴 수 없습니다." : "대상에 새로 만듭니다.");
if (MessageBox.Show(question, "파일 복사", MessageBoxButton.OKCancel,
MessageBoxImage.Warning, MessageBoxResult.Cancel) != MessageBoxResult.OK)
{ {
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; return;
} }
try 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); var folder = Path.GetDirectoryName(to);
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder); if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
File.Copy(from, to, overwrite: true); File.Copy(from, to, overwrite: true);
_workspace.AppendLog($"복사: {from} → {to}"); }, $"복사: {from} → {to}");
MessageUtil.ShowSuccessToast("복사했습니다. 다시 비교하면 결과가 갱신됩니다.");
}
catch (Exception ex)
{
MessageUtil.ShowErrorToast($"복사 실패: {ex.Message}");
}
} }
private bool CanDeleteLeft() => SelectedNode is { IsDirectory: false } node && node.Item.LeftExists; private bool CanDeleteLeft() => SelectedNode is { } node && node.Item.LeftExists;
[RelayCommand(CanExecute = nameof(CanDeleteLeft))] [RelayCommand(CanExecute = nameof(CanDeleteLeft))]
private void DeleteLeft() => Delete(SelectedNode, left: true); private void DeleteLeft() => Delete(SelectedNode, left: true);
private bool CanDeleteRight() => SelectedNode is { IsDirectory: false } node && node.Item.RightExists; private bool CanDeleteRight() => SelectedNode is { } node && node.Item.RightExists;
[RelayCommand(CanExecute = nameof(CanDeleteRight))] [RelayCommand(CanExecute = nameof(CanDeleteRight))]
private void DeleteRight() => Delete(SelectedNode, left: false); private void DeleteRight() => Delete(SelectedNode, left: false);
/// <summary>파일 삭제. 폴더는 지우지 않는다 - 한 번에 너무 많은 것이 사라진다.</summary> /// <summary>파일 또는 폴더 삭제. 폴더는 몇 개가 사라지는지 세어 보여 준다.</summary>
private void Delete(FolderNode? node, bool left) private void Delete(FolderNode? node, bool left)
{ {
if (node is null || node.IsDirectory) return; if (node is null) return;
var path = left ? node.LeftPath : node.RightPath; var path = left ? node.LeftPath : node.RightPath;
if (!File.Exists(path)) return;
var question = $"파일을 지웁니다.\n\n{path}\n\n되돌릴 수 없습니다. 지울까요?"; if (node.IsDirectory)
if (MessageBox.Show(question, "파일 삭제", MessageBoxButton.OKCancel,
MessageBoxImage.Warning, MessageBoxResult.Cancel) != MessageBoxResult.OK)
{ {
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; 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 try
{ {
File.Delete(path); work();
_workspace.AppendLog($"삭제: {path}"); _workspace.AppendLog(log);
MessageUtil.ShowSuccessToast("지웠습니다. 다시 비교하면 결과가 갱신됩니다."); MessageUtil.ShowSuccessToast("했습니다. 다시 비교하면 결과가 갱신됩니다.");
} }
catch (Exception ex) catch (Exception ex)
{ {
MessageUtil.ShowErrorToast($"삭제 실패: {ex.Message}"); MessageUtil.ShowErrorToast($"실패: {ex.Message}");
} }
} }
} }
} }

View File

@ -121,4 +121,16 @@ namespace MergeHub.Views.Controls
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException(); throw new NotSupportedException();
} }
/// <summary>보기 필터를 사람이 읽는 말로. 목록에 enum 이름이 그대로 나오면 읽히지 않는다.</summary>
public sealed class FolderFilterNameConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
value is ViewModels.FolderFilter filter
? ViewModels.FolderViewModel.FilterName(filter)
: string.Empty;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
} }

View File

@ -12,6 +12,7 @@
<BooleanToVisibilityConverter x:Key="BoolToVis"/> <BooleanToVisibilityConverter x:Key="BoolToVis"/>
<ctl:FolderStatusBrushConverter x:Key="StatusBrush"/> <ctl:FolderStatusBrushConverter x:Key="StatusBrush"/>
<ctl:FolderFilterNameConverter x:Key="FolderFilterName"/>
<!-- 파일 비교 화면과 같은 글자 스타일. 두 화면이 따로 노는 것처럼 보이지 않게 값을 맞춰 둔다. --> <!-- 파일 비교 화면과 같은 글자 스타일. 두 화면이 따로 노는 것처럼 보이지 않게 값을 맞춰 둔다. -->
<Style x:Key="Muted" TargetType="TextBlock"> <Style x:Key="Muted" TargetType="TextBlock">
@ -117,9 +118,19 @@
ToolTip="내용을 읽지 않습니다. 파일이 아주 많거나 네트워크 드라이브일 때 씁니다"/> ToolTip="내용을 읽지 않습니다. 파일이 아주 많거나 네트워크 드라이브일 때 씁니다"/>
<CheckBox Content="하위 폴더 포함" IsChecked="{Binding IncludeSubfolders}" <CheckBox Content="하위 폴더 포함" IsChecked="{Binding IncludeSubfolders}"
VerticalAlignment="Center" Margin="0,0,16,0"/> VerticalAlignment="Center" Margin="0,0,16,0"/>
<CheckBox Content="같은 파일도 보기" IsChecked="{Binding ShowSame}" <!-- 보기 필터는 다시 비교하지 않고 목록만 다시 그린다. -->
VerticalAlignment="Center" Margin="0,0,16,0" <TextBlock Text="보기" VerticalAlignment="Center" Style="{StaticResource Muted}"
ToolTip="꺼 두면 다른 것만 남깁니다"/> FontSize="11" Margin="0,0,4,0"/>
<ComboBox ItemsSource="{Binding Filters}" SelectedItem="{Binding Filter}"
Width="110" Height="26" FontSize="11" Margin="0,0,16,0"
VerticalContentAlignment="Center"
ToolTip="파일이 수백 개면 눈으로 고르는 것이 제일 느립니다">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource FolderFilterName}}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="포함" VerticalAlignment="Center" Style="{StaticResource Muted}" <TextBlock Text="포함" VerticalAlignment="Center" Style="{StaticResource Muted}"
FontSize="11" Margin="0,0,4,0"/> FontSize="11" Margin="0,0,4,0"/>
@ -136,9 +147,14 @@
ToolTip="정규식. 포함 규칙보다 우선합니다"/> ToolTip="정규식. 포함 규칙보다 우선합니다"/>
<Button Content="비교" Command="{Binding CompareCommand}" Height="28" MinWidth="80" <Button Content="비교" Command="{Binding CompareCommand}" Height="28" MinWidth="80"
Margin="0,0,6,0"
Style="{StaticResource FilledButton}" Style="{StaticResource FilledButton}"
Background="{DynamicResource Ubisam.Brush.Accent.Fill}" Background="{DynamicResource Ubisam.Brush.Accent.Fill}"
Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}" FontWeight="Bold"/> Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}" FontWeight="Bold"/>
<!-- 큰 폴더를 잘못 고르면 기다리는 수밖에 없었다. 비교하는 동안에만 켜진다. -->
<Button Content="취소" Command="{Binding CancelCompareCommand}" Height="28" MinWidth="60"
ToolTip="비교를 멈춥니다. 앞서 본 결과는 그대로 남습니다"/>
</WrapPanel> </WrapPanel>
</Border> </Border>
@ -199,19 +215,19 @@
Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0"/> Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0"/>
<Button Content="→ 오른쪽으로 복사" Command="{Binding CopyToRightCommand}" <Button Content="→ 오른쪽으로 복사" Command="{Binding CopyToRightCommand}"
Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0" Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0"
ToolTip="왼쪽 파일로 오른쪽을 덮어씁니다 (확인을 묻습니다)"/> ToolTip="왼쪽을 오른쪽으로 덮어씁니다. 폴더를 고르면 폴더째 (확인을 묻습니다)"/>
<Button Content="← 왼쪽으로 복사" Command="{Binding CopyToLeftCommand}" <Button Content="← 왼쪽으로 복사" Command="{Binding CopyToLeftCommand}"
Height="28" Padding="10,2" FontSize="11" Margin="0,0,14,0" Height="28" Padding="10,2" FontSize="11" Margin="0,0,14,0"
ToolTip="오른쪽 파일로 왼쪽을 덮어씁니다 (확인을 묻습니다)"/> ToolTip="오른쪽을 왼쪽으로 덮어씁니다. 폴더를 고르면 폴더째 (확인을 묻습니다)"/>
<Button Content="왼쪽 삭제" Command="{Binding DeleteLeftCommand}" <Button Content="왼쪽 삭제" Command="{Binding DeleteLeftCommand}"
Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0" Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0"
Foreground="{DynamicResource Ubisam.Brush.Danger}" Foreground="{DynamicResource Ubisam.Brush.Danger}"
ToolTip="되돌릴 수 없습니다 (확인을 묻습니다)"/> ToolTip="파일 또는 폴더째. 되돌릴 수 없습니다 (확인을 묻습니다)"/>
<Button Content="오른쪽 삭제" Command="{Binding DeleteRightCommand}" <Button Content="오른쪽 삭제" Command="{Binding DeleteRightCommand}"
Height="28" Padding="10,2" FontSize="11" Height="28" Padding="10,2" FontSize="11"
Foreground="{DynamicResource Ubisam.Brush.Danger}" Foreground="{DynamicResource Ubisam.Brush.Danger}"
ToolTip="되돌릴 수 없습니다 (확인을 묻습니다)"/> ToolTip="파일 또는 폴더째. 되돌릴 수 없습니다 (확인을 묻습니다)"/>
</WrapPanel> </WrapPanel>
</Grid> </Grid>
</Border> </Border>