diff --git a/doc/진행상황.md b/doc/진행상황.md index 7380d1f..82b8965 100644 --- a/doc/진행상황.md +++ b/doc/진행상황.md @@ -43,13 +43,17 @@ CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘만 갱신 목록에서 빠져 있었다. 키보드(`Alt+←/→`)로는 되고 버튼만 안 되던 이유가 이것이다. -### ① 폴더 비교 +### ① 폴더 비교 — **완료** (2026-09-08) -| 상태 | 항목 | 내용 | -|---|---|---| -| 대기 | 폴더째 복사·삭제 | 지금은 파일 하나씩만 된다(`CanCopyToRight` 가 파일만 허용) | -| 대기 | 상태별 보기 필터 | 왼쪽만 / 오른쪽만 / 다른 것만 | -| 대기 | 비교 취소 | 큰 폴더를 잘못 고르면 기다리는 수밖에 없다 | +| 상태 | 항목 | 내용 | 확인 | +|---|---|---|---| +| **완료** | 폴더째 복사·삭제 | 파일 하나씩만 되던 것을 폴더에도 열었다. 확인 창에 안에 든 파일 수를 적는다 - "폴더 하나"라고만 물으면 몇 개가 덮이는지 모르는 채 확인을 누르게 된다 | `sub`(파일 2개) 폴더째 복사 → `bsubdeep wo.txt` 까지 따라옴 | +| **완료** | 상태별 보기 필터 | 다른 것만 / 전체 / 왼쪽에만 / 오른쪽에만 / 같은 것만. 다시 비교하지 않고 목록만 다시 그린다 | 15개 → "왼쪽에만" 5개, 요약 줄은 그대로 | +| **완료** | 비교 취소 | `FolderComparer.Compare` 에 `CancellationToken`. 항목 하나를 볼 때마다 확인하므로 누르는 즉시 멈춘다. 앞서 본 결과는 그대로 남는다 | 테스트 3건 | + +**설계 하나**: 파일을 실제로 옮기고 지우는 부분을 `Services/FileOps.cs` 로 뺐다. +되돌릴 수 없는 일이라 화면 없이 검사할 수 있어야 하기 때문이다 - 겹치는 파일은 덮고, +대상에만 있던 파일은 남기고, 두 번 복사해도 같은지를 테스트로 못 박았다. ### ② 파일 비교 @@ -81,4 +85,5 @@ CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘 |---|---|---| | 2026-09-08 | 저장소 개설 (MergeHub · UbisamPlatform 각각) | `7719094` / `36ee902` | | 2026-09-08 | **④ Git 조작 5가지** — amend · stash 목록 · 커밋 간 비교 · blame · 변경분 추출 | `9956567` | -| 2026-09-08 | **③ 3-Way** — 자동 병합 · 충돌만 이동 · 병합 완료 판정 | 아래 | +| 2026-09-08 | **③ 3-Way** — 자동 병합 · 충돌만 이동 · 병합 완료 판정 | `85c5e9e` | +| 2026-09-08 | **① 폴더 비교** — 폴더째 복사·삭제 · 보기 필터 · 비교 취소 | 아래 | diff --git a/src/MergeHub/Services/FileOps.cs b/src/MergeHub/Services/FileOps.cs new file mode 100644 index 0000000..57934b5 --- /dev/null +++ b/src/MergeHub/Services/FileOps.cs @@ -0,0 +1,49 @@ +using System.IO; + +namespace MergeHub.Services +{ + /// + /// 폴더 비교에서 실제로 파일을 옮기고 지우는 부분. + /// + /// ViewModel 이 아니라 여기 두는 이유 - 화면 없이 검사할 수 있어야 한다. + /// 되돌릴 수 없는 일이라 "정말 그렇게 도는지"를 눈이 아니라 테스트로 확인해야 한다. + /// + public static class FileOps + { + /// + /// 폴더 안에 든 파일 수(하위 폴더 포함). + /// 확인 창에 "몇 개가 덮이는지"를 적어 주려고 쓴다 - "폴더 하나"라고만 물으면 + /// 몇 개가 사라지는지 모르는 채 확인을 누르게 된다. + /// + public static int CountFiles(string folder) + { + try + { + return Directory.GetFiles(folder, "*", SearchOption.AllDirectories).Length; + } + catch (IOException) + { + return 0; // 셀 수 없으면 0 으로 두고 진행 여부는 사람이 판단한다 + } + catch (System.UnauthorizedAccessException) + { + return 0; + } + } + + /// + /// 폴더를 통째로 복사한다. 대상에 이미 있는 파일은 덮어쓰고, 대상에만 있는 파일은 그대로 둔다. + /// (지우면서 맞추는 "동기화"가 아니다 - 지우는 것은 삭제 버튼이 따로 맡는다) + /// + 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))); + } + } +} diff --git a/src/MergeHub/Services/FolderComparer.cs b/src/MergeHub/Services/FolderComparer.cs index 35ee0a4..a6469cc 100644 --- a/src/MergeHub/Services/FolderComparer.cs +++ b/src/MergeHub/Services/FolderComparer.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Text.RegularExpressions; +using System.Threading; namespace MergeHub.Services { @@ -110,7 +111,12 @@ namespace MergeHub.Services /// 이 개수를 넘으면 멈춘다. 실수로 C:\ 를 고른 경우에 화면이 굳는 것을 막는다. private const int MaxEntries = 50_000; - public static FolderCompareResult Compare(string leftRoot, string rightRoot, FolderCompareOptions options) + /// + /// 큰 폴더를 잘못 골랐을 때 기다리는 수밖에 없으면 안 된다. + /// 항목 하나를 볼 때마다 확인하므로 누르는 즉시 멈춘다. + /// + public static FolderCompareResult Compare(string leftRoot, string rightRoot, FolderCompareOptions options, + CancellationToken cancel = default) { var result = new FolderCompareResult(); @@ -121,7 +127,7 @@ namespace MergeHub.Services var exclude = Compile(options.ExcludePattern); 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; return result; @@ -129,8 +135,10 @@ namespace MergeHub.Services private static void Walk(string leftRoot, string rightRoot, string relative, FolderCompareOptions options, Regex? include, Regex? exclude, - FolderCompareResult result, List into, ref int count) + FolderCompareResult result, List into, ref int count, + CancellationToken cancel) { + cancel.ThrowIfCancellationRequested(); if (count >= MaxEntries) return; var leftDir = Path.Combine(leftRoot, relative); @@ -143,6 +151,7 @@ namespace MergeHub.Services foreach (var name in names) { + cancel.ThrowIfCancellationRequested(); if (count >= MaxEntries) return; var childRelative = string.IsNullOrEmpty(relative) ? name : Path.Combine(relative, name); @@ -208,7 +217,7 @@ namespace MergeHub.Services 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); diff --git a/src/MergeHub/ViewModels/FolderViewModel.cs b/src/MergeHub/ViewModels/FolderViewModel.cs index 4589e34..09ef638 100644 --- a/src/MergeHub/ViewModels/FolderViewModel.cs +++ b/src/MergeHub/ViewModels/FolderViewModel.cs @@ -3,6 +3,7 @@ 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; @@ -12,6 +13,16 @@ using UbisamBase.Core.Messaging; namespace MergeHub.ViewModels { + /// 목록에 무엇을 남길지. 파일이 수백 개면 눈으로 고르는 것이 제일 느리다. + public enum FolderFilter + { + Different, + All, + LeftOnly, + RightOnly, + Same + } + /// /// 화면에 뿌리는 폴더 비교 항목. 비교 결과(FolderCompareItem)를 그대로 감싸고 /// 트리에 필요한 것(펼침 상태·자식)만 얹는다. @@ -129,6 +140,35 @@ namespace MergeHub.ViewModels /// 같은 파일도 목록에 남길지. 꺼 두면 다른 것만 본다. [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$"; @@ -136,6 +176,8 @@ namespace MergeHub.ViewModels private void RaiseCanExecuteChanged() { + CompareCommand.NotifyCanExecuteChanged(); + CancelCompareCommand.NotifyCanExecuteChanged(); OpenInDiffCommand.NotifyCanExecuteChanged(); CopyToRightCommand.NotifyCanExecuteChanged(); CopyToLeftCommand.NotifyCanExecuteChanged(); @@ -201,28 +243,31 @@ namespace MergeHub.ViewModels var left = LeftFolder; var right = RightFolder; + _compareCancel?.Dispose(); + _compareCancel = new CancellationTokenSource(); + var token = _compareCancel.Token; + IsBusy = true; - CompareCommand.NotifyCanExecuteChanged(); - Summary = "비교하는 중..."; + RaiseCanExecuteChanged(); + Summary = "비교하는 중... (멈추려면 [취소])"; 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(); - foreach (var item in result.Items) - { - var node = new FolderNode(item, left, right); - if (!ShowSame && !Keep(node)) continue; - - Prune(node); - node.IsExpanded = node.Status != FolderEntryStatus.Same; - Nodes.Add(node); - } + _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}"; @@ -231,22 +276,57 @@ namespace MergeHub.ViewModels finally { IsBusy = false; - CompareCommand.NotifyCanExecuteChanged(); + RaiseCanExecuteChanged(); } } - /// "같은 것 숨기기"가 켜져 있을 때 남길 항목인지. - private static bool Keep(FolderNode node) + private CancellationTokenSource? _compareCancel; + + private bool CanCancelCompare() => IsBusy; + + [RelayCommand(CanExecute = nameof(CanCancelCompare))] + private void CancelCompare() => _compareCancel?.Cancel(); + + /// 마지막 비교 결과를 지금 필터로 다시 그린다. + 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); + } + } + + /// 지금 필터에 걸리는 항목인지. 폴더는 안에 걸리는 것이 하나라도 있으면 남긴다. + 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) { - if (ShowSame) return; - for (var i = node.Children.Count - 1; i >= 0; i--) { var child = node.Children[i]; @@ -255,6 +335,7 @@ namespace MergeHub.ViewModels } } + private bool CanOpenInDiff() => SelectedNode?.CanOpenInDiff == true; /// 고른 파일을 "파일 비교" 탭으로 넘기고 그 탭으로 옮긴다. @@ -285,92 +366,119 @@ namespace MergeHub.ViewModels 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))] 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))] private void CopyToLeft() => Copy(SelectedNode, toRight: false); /// - /// 한쪽 파일로 다른 쪽을 덮어쓴다. - /// 덮어쓰기는 되돌릴 수 없으므로 무엇을 어디로 쓰는지를 그대로 보여 주고 확인을 받는다. + /// 한쪽을 다른 쪽으로 덮어쓴다. 파일 하나든 폴더째든 같은 자리에서 한다. + /// + /// 폴더는 안에 든 파일 수를 세어 보여 준다 - "폴더 하나"라고만 물으면 + /// 몇 개가 덮이는지 모르는 채 확인을 누르게 된다. + /// 덮어쓰기는 되돌릴 수 없으므로 무엇을 어디로 쓰는지 그대로 적는다. /// 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 to = toRight ? node.RightPath : node.LeftPath; - if (!File.Exists(from)) return; + var direction = toRight ? "왼쪽 → 오른쪽" : "오른쪽 → 왼쪽"; - var exists = File.Exists(to); - var question = - $"{(toRight ? "왼쪽 → 오른쪽" : "오른쪽 → 왼쪽")} 복사합니다.\n\n" + - $"원본: {from}\n대상: {to}\n\n" + - (exists ? "대상 파일을 덮어씁니다. 되돌릴 수 없습니다." : "대상에 새로 만듭니다."); - - if (MessageBox.Show(question, "파일 복사", MessageBoxButton.OKCancel, - MessageBoxImage.Warning, MessageBoxResult.Cancel) != MessageBoxResult.OK) + 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; } - 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); if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder); - File.Copy(from, to, overwrite: true); - _workspace.AppendLog($"복사: {from} → {to}"); - MessageUtil.ShowSuccessToast("복사했습니다. 다시 비교하면 결과가 갱신됩니다."); - } - catch (Exception ex) - { - MessageUtil.ShowErrorToast($"복사 실패: {ex.Message}"); - } + }, $"복사: {from} → {to}"); } - private bool CanDeleteLeft() => SelectedNode is { IsDirectory: false } node && node.Item.LeftExists; + 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 { IsDirectory: false } node && node.Item.RightExists; + 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 || node.IsDirectory) return; + if (node is null) return; var path = left ? node.LeftPath : node.RightPath; - if (!File.Exists(path)) return; - var question = $"파일을 지웁니다.\n\n{path}\n\n되돌릴 수 없습니다. 지울까요?"; - - if (MessageBox.Show(question, "파일 삭제", MessageBoxButton.OKCancel, - MessageBoxImage.Warning, MessageBoxResult.Cancel) != MessageBoxResult.OK) + 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 { - File.Delete(path); - _workspace.AppendLog($"삭제: {path}"); - MessageUtil.ShowSuccessToast("지웠습니다. 다시 비교하면 결과가 갱신됩니다."); + work(); + _workspace.AppendLog(log); + MessageUtil.ShowSuccessToast("했습니다. 다시 비교하면 결과가 갱신됩니다."); } catch (Exception ex) { - MessageUtil.ShowErrorToast($"삭제 실패: {ex.Message}"); + MessageUtil.ShowErrorToast($"실패: {ex.Message}"); } } + } } diff --git a/src/MergeHub/Views/Controls/LineHighlightBrushes.cs b/src/MergeHub/Views/Controls/LineHighlightBrushes.cs index 889ed31..f23c841 100644 --- a/src/MergeHub/Views/Controls/LineHighlightBrushes.cs +++ b/src/MergeHub/Views/Controls/LineHighlightBrushes.cs @@ -121,4 +121,16 @@ namespace MergeHub.Views.Controls public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotSupportedException(); } + + /// 보기 필터를 사람이 읽는 말로. 목록에 enum 이름이 그대로 나오면 읽히지 않는다. + 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(); + } } diff --git a/src/MergeHub/Views/FolderView.xaml b/src/MergeHub/Views/FolderView.xaml index 34dbb78..8a32649 100644 --- a/src/MergeHub/Views/FolderView.xaml +++ b/src/MergeHub/Views/FolderView.xaml @@ -12,6 +12,7 @@ +