폴더 비교: 폴더째 복사·삭제 · 보기 필터 · 비교 취소
비교만 되고 정리는 안 되던 화면이었다. 셋을 채워 "보고 → 고르고 → 맞춘다" 까지 닫는다. - 폴더째 복사·삭제: 파일 하나씩만 되던 것을 폴더에도 열었다. 확인 창에 안에 든 파일 수를 적는다 - "폴더 하나"라고만 물으면 몇 개가 덮이는지 모르는 채 확인을 누르게 된다. 복사는 겹치는 파일만 덮고 대상에만 있던 파일은 남긴다(동기화가 아니다). - 보기 필터: 다른 것만 / 전체 / 왼쪽에만 / 오른쪽에만 / 같은 것만. 다시 비교하지 않고 마지막 결과로 목록만 다시 그린다 - 큰 폴더를 다시 훑는 것은 필터를 만지는 값으로 너무 비싸다. 기존 "같은 파일도 보기" 체크는 이 필터가 대신한다. - 비교 취소: 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:
parent
85c5e9e085
commit
524a6a715d
19
doc/진행상황.md
19
doc/진행상황.md
@ -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 | **① 폴더 비교** — 폴더째 복사·삭제 · 보기 필터 · 비교 취소 | 아래 |
|
||||||
|
|||||||
49
src/MergeHub/Services/FileOps.cs
Normal file
49
src/MergeHub/Services/FileOps.cs
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user