D-7, D-3: 끌어다 놓기 · 폴더 안 전체 검색

D-7 은 코드만 끝났다. 화면이 잠겨 탐색기에서 끄는 확인을 못 했다.
D-3 은 UI 자동화로 검색·결과 열기·취소까지 확인했다.
This commit is contained in:
김승준 2026-09-09 15:16:19 +09:00
parent ee5d34c2a6
commit 373990b3b6
9 changed files with 1032 additions and 14 deletions

View File

@ -128,11 +128,11 @@ CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘
|---|---| |---|---|
| **완료** | D-1 주석만 다른 파일을 다른 색으로 | | **완료** | D-1 주석만 다른 파일을 다른 색으로 |
| **완료** | D-2 C++ 문법 강조 (이미 되고 있었음 · 전처리 지시문만 추가) | | **완료** | D-2 C++ 문법 강조 (이미 되고 있었음 · 전처리 지시문만 추가) |
| 대기 | D-3 폴더 안 전체 검색 | | **완료** | D-3 폴더 안 전체 검색 |
| ~~취소~~ | ~~D-4 비교 창 여러 개~~ (2026-09-09 사용자 판단으로 뺌) | | ~~취소~~ | ~~D-4 비교 창 여러 개~~ (2026-09-09 사용자 판단으로 뺌) |
| **완료** | D-5 덩어리 단위 반영 (3-Way 에서 BASE 를 가져오던 것을 REMOTE 로) | | **완료** | D-5 덩어리 단위 반영 (3-Way 에서 BASE 를 가져오던 것을 REMOTE 로) |
| **완료** | D-6 비교 결과 텍스트 내보내기 | | **완료** | D-6 비교 결과 텍스트 내보내기 |
| 대기 | D-7 드래그 앤 드롭으로 열기 | | **완료**(화면 확인 남음) | D-7 드래그 앤 드롭으로 열기 |
### E. 추가 요청 (2026-09-09 오후) ### E. 추가 요청 (2026-09-09 오후)
@ -160,6 +160,63 @@ BASE 머리글에 그렇게 적었다 - `BASE (merge base) · 이 파일 없음
조상에 파일이 있는 경우는 지금까지처럼 파일 이름이 그대로 나온다. 조상에 파일이 있는 경우는 지금까지처럼 파일 이름이 그대로 나온다.
--- ---
### D-3. 폴더 안 전체 검색 — **완료** (2026-09-09)
**왜 필요했나** - 폴더 비교는 "무엇이 다른가"에 답하지만 <b>"이 함수를 쓰는 곳이 어디인가"</b>에는
답하지 못한다. 그때마다 탐색기 검색이나 다른 편집기를 켜야 했다.
`Services/FolderSearcher.cs` 신설. 화면 없이 검사할 수 있게 <b>찾는 일만</b> 여기서 한다.
**양쪽을 다 훑되 같은 줄은 한 번만 보여 준다.** 안 그러면 손대지 않은 파일이 전부 두 번씩 나와
목록의 절반이 쓸모없어진다. 그래서 결과 한 줄에 "왼쪽 / 오른쪽 / 양쪽"이 붙는다 -
`양쪽` 은 두 폴더에 똑같이 있는 줄, `왼쪽`·`오른쪽` 은 한쪽에만 있는 줄이다.
이것만 봐도 "내가 고친 줄인지"를 안다.
내용이 똑같은 파일은 오른쪽을 <b>다시 읽지 않는다</b>. 대신 왼쪽에서 찾아 둔 줄을 `양쪽` 으로 바꾼다.
**결과를 누르면 그 줄에 서서 열린다.** 파일만 열리고 맨 위에 서 있으면 찾은 자리를 눈으로
다시 찾아야 해서 검색을 한 뜻이 없어진다. `GitWorkspace.RequestPlainCompare` 에 줄 번호를
얹고(`RequestedLine`), 파일 비교 화면이 파일을 다 읽은 뒤 그 줄로 옮긴다.
오른쪽에서만 나온 줄은 오른쪽 파일 기준 번호로 찾는다 - 한쪽에만 있는 줄이 끼면
두 번호가 어긋나 엉뚱한 줄에 선다.
**취소하면 여기까지 찾은 것을 그대로 남긴다.** 처음에는 예외를 던져 목록이 비었는데,
큰 폴더에서 원하는 줄이 이미 나왔는데 멈췄다고 다 지우면 처음부터 다시 해야 한다.
- 대소문자 구분 · 확장자 필터(`*.cs;*.h` · `.cs` · `cs` 를 모두 같게 본다) · 하위 폴더 · 제외 규칙
- 이진 파일과 8MB 넘는 파일은 건너뛴다. 훑은 파일 수를 계속 알려 준다
- 한 줄에 여러 번 나와도 한 줄로 센다
**실제로 확인한 것** (화면이 잠겨 있어 UI 자동화로 조작하고 값을 읽었다)
| 확인 | 결과 |
|---|---|
| 양쪽 같은 파일 | `양쪽 · Common.cpp:2` 한 줄만 |
| 한 파일 안에서 다른 줄 | `왼쪽 · :5 int Speed = 100;` / `오른쪽 · :5 int Speed = 250;` |
| 같은 파일 안 안 바뀐 줄 | `양쪽 · :6 Move(Speed);` |
| 한쪽에만 있는 파일 | `왼쪽 · sub\Only.cpp` / `오른쪽 · notes.txt` |
| 결과를 열었을 때 | 두 파일이 열리고 <b>캐럿이 5번째 줄</b>에 섰다 |
| 663개 파일을 훑는 중 | 진행 글자가 계속 바뀌고 [검색 취소]가 눌렸다 |
| 취소한 뒤 | `517줄 · 파일 63개 · 도중에 멈춤` — 찾은 것이 그대로 남았다 |
검사 63건 추가(`SearchTest`). 전체 512건 통과.
---
### D-7. 드래그 앤 드롭으로 열기 — **코드 완료 · 화면 확인 남음** (2026-09-09)
탐색기에서 파일·폴더를 끌어다 놓으면 그 자리에서 비교가 열린다.
`GitWorkspace.RouteDroppedPaths` 가 <b>명령줄과 같은 판단</b>을 쓴다(`StartupArgs.Parse` 를 그대로).
끌어다 놓는 것과 우클릭·명령줄이 다르게 굴면 그것부터 버그다 - 폴더면 폴더 비교, 파일이면 파일 비교로
가고 해당 탭이 켜진다. 화면 아무 데나 놓으면 이 판단을 타고, 왼쪽·오른쪽 경로 칸에 바로 놓으면
그 칸만 채운다.
<b>화면에서 실제로 끌어다 놓는 확인은 아직 못 했다.</b> 작업 중 컴퓨터가 잠겨 탐색기에서
끄는 동작을 만들 수 없었다. 잠금이 풀리면 확인한다.
---
### D-6. 비교 결과 텍스트 내보내기 — **완료** (2026-09-09) ### D-6. 비교 결과 텍스트 내보내기 — **완료** (2026-09-09)
**왜 필요했나** - 결과를 남에게 보내거나 결재에 붙일 때 <b>화면을 캡처하는 것 말고는 방법이 없었다.</b> **왜 필요했나** - 결과를 남에게 보내거나 결재에 붙일 때 <b>화면을 캡처하는 것 말고는 방법이 없었다.</b>

View File

@ -0,0 +1,451 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
namespace MergeHub.Services
{
/// <summary>찾은 줄이 어느 쪽에 있었는지.</summary>
public enum SearchSide
{
Left,
Right,
/// <summary>양쪽 같은 자리에 똑같이 있다. 아직 손대지 않은 줄이라는 뜻이다.</summary>
Both
}
/// <summary>폴더 전체 검색 규칙.</summary>
public sealed class FolderSearchOptions
{
public string Query { get; set; } = string.Empty;
public bool MatchCase { get; set; }
/// <summary>
/// 확장자 필터. <c>.cs;.h</c> 든 <c>*.cs, *.h</c> 든 <c>cs h</c> 든 받는다. 비우면 전부 본다.
/// 코드 폴더에서 찾을 때 이것 하나로 대상이 열 배 줄어든다.
/// </summary>
public string Extensions { get; set; } = string.Empty;
public bool IncludeSubfolders { get; set; } = true;
/// <summary>제외할 파일·폴더 정규식. 폴더 비교와 같은 값을 그대로 넘겨 쓴다.</summary>
public string ExcludePattern { get; set; } = string.Empty;
/// <summary>이 크기를 넘는 파일은 건너뛴다. 통째로 읽어 줄로 자르는 방식이라 상한이 필요하다.</summary>
public long MaxFileBytes { get; set; } = 8L * 1024 * 1024;
/// <summary>찾은 줄이 이만큼 쌓이면 멈춘다. 목록이 수만 줄이면 어차피 눈으로 못 본다.</summary>
public int MaxHits { get; set; } = 5000;
/// <summary>오른쪽 폴더도 훑을지. 꺼 두면 왼쪽만 본다.</summary>
public bool SearchRight { get; set; } = true;
}
/// <summary>찾은 줄 하나.</summary>
public sealed class FolderSearchHit
{
public FolderSearchHit(SearchSide side, string relativePath, string leftPath, string rightPath,
int lineNumber, int column, string lineText)
{
Side = side;
RelativePath = relativePath;
LeftPath = leftPath;
RightPath = rightPath;
LineNumber = lineNumber;
Column = column;
LineText = lineText;
}
/// <summary>어느 쪽에서 나왔는지. 화면에서 이것만 봐도 "내가 고친 줄인지"를 안다.</summary>
public SearchSide Side { get; set; }
public string RelativePath { get; }
/// <summary>여는 데 쓸 양쪽 실제 경로. 한쪽에만 있으면 없는 쪽은 그대로 두고 빈 내용으로 열린다.</summary>
public string LeftPath { get; }
public string RightPath { get; }
/// <summary>1부터 센다.</summary>
public int LineNumber { get; }
/// <summary>줄 안에서 찾은 자리. 1부터 센다.</summary>
public int Column { get; }
public string LineText { get; }
public string SideText => Side switch
{
SearchSide.Left => "왼쪽",
SearchSide.Right => "오른쪽",
_ => "양쪽"
};
public string Where => $"{RelativePath}:{LineNumber}";
/// <summary>목록에 뿌릴 한 줄. 앞 공백을 떼고 너무 길면 자른다.</summary>
public string Preview
{
get
{
var text = LineText.Trim();
return text.Length <= PreviewLimit ? text : text.Substring(0, PreviewLimit) + " …";
}
}
private const int PreviewLimit = 200;
}
/// <summary>검색이 어디까지 갔는지. 진행 중에 화면에 뿌린다.</summary>
public sealed class FolderSearchProgress
{
public FolderSearchProgress(int filesScanned, int hits, string currentFile)
{
FilesScanned = filesScanned;
Hits = hits;
CurrentFile = currentFile;
}
public int FilesScanned { get; }
public int Hits { get; }
public string CurrentFile { get; }
}
public sealed class FolderSearchResult
{
public List<FolderSearchHit> Hits { get; } = new List<FolderSearchHit>();
public int FilesScanned { get; set; }
/// <summary>한 줄이라도 걸린 파일 수.</summary>
public int FilesMatched { get; set; }
/// <summary>이진이거나 너무 커서 건너뛴 파일 수.</summary>
public int FilesSkipped { get; set; }
/// <summary>상한에 걸려 도중에 멈췄는지.</summary>
public bool Truncated { get; set; }
/// <summary>사용자가 [검색 취소]로 멈췄는지. 그래도 여기까지 찾은 것은 그대로 준다.</summary>
public bool Cancelled { get; set; }
public string Summary =>
$"{Hits.Count}줄 · 파일 {FilesMatched}개 (훑은 파일 {FilesScanned}"
+ (FilesSkipped > 0 ? $", 건너뜀 {FilesSkipped}" : string.Empty) + ")"
+ (Cancelled ? " · 도중에 멈춤" : string.Empty)
+ (Truncated ? " · 너무 많아 일부만 봄" : string.Empty);
}
/// <summary>
/// 비교 중인 두 폴더 전체에서 글자를 찾는다.
///
/// 폴더 비교는 "무엇이 다른가"를 알려 주지만 "이 함수를 쓰는 곳이 어디인가"는 답하지 못한다.
/// 그때마다 탐색기 검색이나 다른 편집기를 켜는 대신 여기서 찾고 바로 비교 화면으로 넘어간다.
///
/// 양쪽을 다 훑되 <b>같은 파일 같은 줄에 똑같이 있으면 한 줄로 합친다</b> - 안 그러면
/// 손대지 않은 파일이 전부 두 번씩 나와 목록의 절반이 쓸모없어진다.
/// </summary>
public static class FolderSearcher
{
/// <summary>이 개수를 넘게 훑지 않는다. 실수로 C:\ 를 골랐을 때 끝나지 않는 것을 막는다.</summary>
private const int MaxFiles = 50_000;
/// <param name="cancel">파일 하나를 볼 때마다 확인하므로 [취소]를 누르면 곧 멈춘다.</param>
/// <param name="progress">화면에 "몇 개째 훑는 중"을 뿌릴 때 쓴다. 없어도 된다.</param>
public static FolderSearchResult Search(string leftRoot, string rightRoot, FolderSearchOptions options,
CancellationToken cancel = default,
IProgress<FolderSearchProgress>? progress = null)
{
var result = new FolderSearchResult();
if (options is null || string.IsNullOrEmpty(options.Query)) return result;
var left = PathUtil.FullFolderPath(leftRoot);
var right = options.SearchRight ? PathUtil.FullFolderPath(rightRoot) : string.Empty;
if (left.Length == 0 && right.Length == 0) return result;
var extensions = ParseExtensions(options.Extensions);
var exclude = Compile(options.ExcludePattern);
// 왼쪽을 먼저 다 훑고 오른쪽을 훑는다. 오른쪽에서 나온 줄이 왼쪽에도 똑같이 있으면
// 새 줄을 만들지 않고 왼쪽 줄을 "양쪽" 으로 바꾼다.
var seen = new Dictionary<string, FolderSearchHit>(StringComparer.Ordinal);
var byFile = new Dictionary<string, List<FolderSearchHit>>(StringComparer.OrdinalIgnoreCase);
// 취소는 실패가 아니다. 여기까지 찾은 것을 그대로 돌려준다 -
// 큰 폴더에서 원하는 줄이 이미 나왔는데 멈췄다고 목록이 비면 처음부터 다시 해야 한다.
try
{
Scan(left, SearchSide.Left, left, right, options, extensions, exclude, result, seen, byFile, cancel, progress);
if (!result.Truncated && right.Length > 0)
{
Scan(right, SearchSide.Right, left, right, options, extensions, exclude, result, seen, byFile, cancel, progress);
}
}
catch (OperationCanceledException)
{
result.Cancelled = true;
}
result.Hits.Sort(Order);
return result;
}
/// <summary>파일 하나에서 찾는다. 화면 없이 규칙만 확인할 때도 이 자리를 쓴다.</summary>
public static IReadOnlyList<(int Line, int Column, string Text)> FindInText(string content, string query, bool matchCase)
{
var found = new List<(int, int, string)>();
if (string.IsNullOrEmpty(content) || string.IsNullOrEmpty(query)) return found;
var comparison = matchCase ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
var lines = content.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
for (var i = 0; i < lines.Length; i++)
{
// 한 줄에 여러 번 나와도 한 줄로 센다. 같은 줄을 몇 번씩 보여 줄 까닭이 없다.
var at = lines[i].IndexOf(query, comparison);
if (at >= 0) found.Add((i + 1, at + 1, lines[i]));
}
return found;
}
/// <summary>
/// 확장자 필터를 읽는다. <c>*.cs</c>, <c>.cs</c>, <c>cs</c> 를 모두 같게 본다 -
/// 어느 쪽으로 적든 사용자가 뜻한 바는 하나다.
/// </summary>
public static IReadOnlyList<string> ParseExtensions(string? text)
{
var list = new List<string>();
if (string.IsNullOrWhiteSpace(text)) return list;
foreach (var raw in text!.Split(new[] { ';', ',', ' ', '\t', '|' }, StringSplitOptions.RemoveEmptyEntries))
{
var one = raw.Trim().TrimStart('*');
if (one.Length == 0) continue;
if (one[0] != '.') one = "." + one;
if (one.Length == 1) continue;
if (!list.Contains(one, StringComparer.OrdinalIgnoreCase)) list.Add(one);
}
return list;
}
public static bool ExtensionAllowed(string path, IReadOnlyList<string> extensions)
{
if (extensions is null || extensions.Count == 0) return true;
var ext = Path.GetExtension(path);
if (string.IsNullOrEmpty(ext)) return false;
foreach (var one in extensions)
{
if (string.Equals(ext, one, StringComparison.OrdinalIgnoreCase)) return true;
}
return false;
}
// ================= 안쪽 =================
private static void Scan(string root, SearchSide side, string leftRoot, string rightRoot,
FolderSearchOptions options, IReadOnlyList<string> extensions, Regex? exclude,
FolderSearchResult result, Dictionary<string, FolderSearchHit> seen,
Dictionary<string, List<FolderSearchHit>> byFile,
CancellationToken cancel, IProgress<FolderSearchProgress>? progress)
{
if (root.Length == 0 || !Directory.Exists(root)) return;
foreach (var relative in Files(root, string.Empty, options.IncludeSubfolders, exclude, cancel))
{
cancel.ThrowIfCancellationRequested();
if (result.FilesScanned >= MaxFiles || result.Hits.Count >= options.MaxHits)
{
result.Truncated = true;
return;
}
if (!ExtensionAllowed(relative, extensions)) continue;
var path = Path.Combine(root, relative);
// 오른쪽에서 왼쪽에도 있는 파일을 다시 훑을 때, 내용이 같으면 다시 읽지 않는다.
// 대신 왼쪽에서 찾아 둔 줄을 "양쪽" 으로 바꾼다 - 손대지 않은 파일이라는 뜻이고,
// 그냥 건너뛰면 양쪽에 다 있는 줄이 "왼쪽" 으로만 남아 사실과 어긋난다.
if (side == SearchSide.Right && leftRoot.Length > 0)
{
var twin = Path.Combine(leftRoot, relative);
if (File.Exists(twin) && SameFile(twin, path))
{
if (byFile.TryGetValue(relative, out var mine))
{
foreach (var one in mine) one.Side = SearchSide.Both;
}
continue;
}
}
long size;
try
{
size = new FileInfo(path).Length;
}
catch (IOException) { continue; }
catch (UnauthorizedAccessException) { continue; }
if (size > options.MaxFileBytes) { result.FilesSkipped++; continue; }
if (TextFile.IsBinary(path)) { result.FilesSkipped++; continue; }
string content;
try
{
content = TextFile.Read(path);
}
catch (IOException) { result.FilesSkipped++; continue; }
catch (UnauthorizedAccessException) { result.FilesSkipped++; continue; }
result.FilesScanned++;
var found = FindInText(content, options.Query, options.MatchCase);
if (found.Count > 0) result.FilesMatched++;
var leftPath = leftRoot.Length > 0 ? Path.Combine(leftRoot, relative) : path;
var rightPath = rightRoot.Length > 0 ? Path.Combine(rightRoot, relative) : path;
foreach (var one in found)
{
if (result.Hits.Count >= options.MaxHits)
{
result.Truncated = true;
break;
}
var key = relative + "\u0000" + one.Line + "\u0000" + one.Text;
if (side == SearchSide.Right && seen.TryGetValue(key, out var already))
{
already.Side = SearchSide.Both;
continue;
}
var hit = new FolderSearchHit(side, relative, leftPath, rightPath, one.Line, one.Column, one.Text);
result.Hits.Add(hit);
seen[key] = hit;
if (!byFile.TryGetValue(relative, out var list))
{
list = new List<FolderSearchHit>();
byFile[relative] = list;
}
list.Add(hit);
}
progress?.Report(new FolderSearchProgress(result.FilesScanned, result.Hits.Count, relative));
}
}
/// <summary>뿌리 폴더 아래 파일들의 상대 경로. 한 번에 다 모으지 않고 하나씩 넘긴다.</summary>
private static IEnumerable<string> Files(string root, string relative, bool subfolders, Regex? exclude,
CancellationToken cancel)
{
var dir = relative.Length == 0 ? root : Path.Combine(root, relative);
string[] files;
try { files = Directory.GetFiles(dir); }
catch (UnauthorizedAccessException) { yield break; }
catch (IOException) { yield break; }
Array.Sort(files, StringComparer.OrdinalIgnoreCase);
foreach (var path in files)
{
cancel.ThrowIfCancellationRequested();
var name = Path.GetFileName(path);
var childRelative = relative.Length == 0 ? name : Path.Combine(relative, name);
if (exclude is not null && (exclude.IsMatch(name) || exclude.IsMatch(childRelative))) continue;
yield return childRelative;
}
if (!subfolders) yield break;
string[] dirs;
try { dirs = Directory.GetDirectories(dir); }
catch (UnauthorizedAccessException) { yield break; }
catch (IOException) { yield break; }
Array.Sort(dirs, StringComparer.OrdinalIgnoreCase);
foreach (var path in dirs)
{
var name = Path.GetFileName(path);
var childRelative = relative.Length == 0 ? name : Path.Combine(relative, name);
if (exclude is not null && (exclude.IsMatch(name) || exclude.IsMatch(childRelative))) continue;
foreach (var found in Files(root, childRelative, subfolders: true, exclude, cancel))
{
yield return found;
}
}
}
private static bool SameFile(string a, string b)
{
try
{
var left = new FileInfo(a);
var right = new FileInfo(b);
if (left.Length != right.Length) return false;
using var one = File.OpenRead(a);
using var two = File.OpenRead(b);
var bufferOne = new byte[64 * 1024];
var bufferTwo = new byte[64 * 1024];
while (true)
{
var readOne = one.Read(bufferOne, 0, bufferOne.Length);
var readTwo = two.Read(bufferTwo, 0, bufferTwo.Length);
if (readOne != readTwo) return false;
if (readOne == 0) return true;
for (var i = 0; i < readOne; i++)
{
if (bufferOne[i] != bufferTwo[i]) return false;
}
}
}
catch (IOException) { return false; }
catch (UnauthorizedAccessException) { return false; }
}
/// <summary>파일 차례로, 그 안에서는 줄 차례로. 목록을 위에서 아래로 읽을 수 있게 한다.</summary>
private static int Order(FolderSearchHit a, FolderSearchHit b)
{
var byPath = string.Compare(a.RelativePath, b.RelativePath, StringComparison.OrdinalIgnoreCase);
if (byPath != 0) return byPath;
if (a.LineNumber != b.LineNumber) return a.LineNumber.CompareTo(b.LineNumber);
return a.Side.CompareTo(b.Side);
}
private static Regex? Compile(string pattern)
{
if (string.IsNullOrWhiteSpace(pattern)) return null;
try { return new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.Compiled); }
catch (ArgumentException) { return null; }
}
}
}

View File

@ -374,12 +374,37 @@ namespace MergeHub.Services
/// 이벤트를 받을 사람이 없다. 그냥 발생시키면 요청이 허공에 사라지고, /// 이벤트를 받을 사람이 없다. 그냥 발생시키면 요청이 허공에 사라지고,
/// 새로 만들어진 화면은 기본 파일을 연다 - "더블클릭이 안 먹는다"로 보이는 이유다. /// 새로 만들어진 화면은 기본 파일을 연다 - "더블클릭이 안 먹는다"로 보이는 이유다.
/// </summary> /// </summary>
public void RequestPlainCompare(string leftFile, string rightFile) /// <param name="line">
/// 열자마자 짚어 줄 줄 번호. 0 이면 여느 때처럼 첫 차이로 간다.
/// 폴더 전체 검색에서 결과를 눌렀을 때, 파일만 열리고 맨 위에 서 있으면
/// 찾은 줄을 눈으로 다시 찾아야 한다.
/// </param>
/// <param name="lineIsLeft">그 번호가 왼쪽 파일 기준인지. 아니면 오른쪽 기준이다.</param>
public void RequestPlainCompare(string leftFile, string rightFile, int line = 0, bool lineIsLeft = true)
{ {
_pendingPlainCompare = (leftFile, rightFile); _pendingPlainCompare = (leftFile, rightFile);
RequestedLine = line;
RequestedLineIsLeft = lineIsLeft;
OnUi(DeliverPendingPlainCompare); OnUi(DeliverPendingPlainCompare);
} }
/// <summary>넘긴 파일에서 짚어 줄 자리. 없으면 0.</summary>
public int RequestedLine { get; private set; }
/// <summary>그 번호가 왼쪽 파일 기준인지.</summary>
public bool RequestedLineIsLeft { get; private set; }
/// <summary>
/// 짚어 줄 자리를 한 번 쓰고 지운다.
/// 지우지 않으면 그 다음에 연 파일까지 같은 줄로 끌려간다.
/// </summary>
public (int Line, bool IsLeft) TakeRequestedLine()
{
var one = (RequestedLine, RequestedLineIsLeft);
RequestedLine = 0;
return one;
}
/// <summary>남아 있는 요청을 넘긴다. 화면이 새로 만들어지면 스스로 한 번 불러 준다.</summary> /// <summary>남아 있는 요청을 넘긴다. 화면이 새로 만들어지면 스스로 한 번 불러 준다.</summary>
public void DeliverPendingPlainCompare() public void DeliverPendingPlainCompare()
{ {
@ -441,6 +466,34 @@ namespace MergeHub.Services
: $"파일 비교 요청: {left} ↔ {right}"); : $"파일 비교 요청: {left} ↔ {right}");
} }
/// <summary>
/// 탐색기에서 끌어다 놓은 것을 화면으로 보낸다.
///
/// 명령줄로 들어오는 길과 <b>같은 판단</b>을 쓴다 - 폴더면 폴더 비교, 파일이면 파일 비교.
/// 두 길이 다르게 굴면 "우클릭으로 열 때랑 끌어다 놓을 때가 다르다"가 되어 설명할 수 없다.
/// </summary>
public void RouteDroppedPaths(IReadOnlyList<string> paths)
{
if (paths is null || paths.Count == 0) return;
var args = StartupArgs.Parse(paths);
if (args.WantsFolder)
{
RequestFolderCompare(args.LeftFolder, args.RightFolder);
ActivateWhenReady(MergeHub.ViewIds.Folder);
AppendLog($"끌어다 놓기 - 폴더 비교: {args.LeftFolder}");
return;
}
if (!args.WantsFiles) return;
var (left, right) = args.DiffPair;
RequestPlainCompare(left, right);
ActivateWhenReady(MergeHub.ViewIds.Home);
AppendLog($"끌어다 놓기 - 파일 비교: {left} ↔ {right}");
}
private static void ActivateWhenReady(string viewId) private static void ActivateWhenReady(string viewId)
{ {
var app = System.Windows.Application.Current; var app = System.Windows.Application.Current;

View File

@ -112,6 +112,9 @@ namespace MergeHub.ViewModels
{ {
private readonly GitWorkspace _workspace; private readonly GitWorkspace _workspace;
/// <summary>화면 쪽에서 끌어다 놓기를 넘길 때 쓴다.</summary>
public GitWorkspace Workspace => _workspace;
public FolderViewModel(GitWorkspace workspace) public FolderViewModel(GitWorkspace workspace)
{ {
_workspace = workspace; _workspace = workspace;
@ -209,6 +212,9 @@ namespace MergeHub.ViewModels
DeleteLeftCommand.NotifyCanExecuteChanged(); DeleteLeftCommand.NotifyCanExecuteChanged();
DeleteRightCommand.NotifyCanExecuteChanged(); DeleteRightCommand.NotifyCanExecuteChanged();
ExportReportCommand.NotifyCanExecuteChanged(); ExportReportCommand.NotifyCanExecuteChanged();
SearchCommand.NotifyCanExecuteChanged();
CancelSearchCommand.NotifyCanExecuteChanged();
OpenHitCommand.NotifyCanExecuteChanged();
} }
[RelayCommand] [RelayCommand]
@ -337,6 +343,158 @@ namespace MergeHub.ViewModels
private CancellationTokenSource? _compareCancel; private CancellationTokenSource? _compareCancel;
// ================= 폴더 전체 검색 =================
/// <summary>찾은 줄. 파일 차례로 늘어선다.</summary>
public ObservableCollection<FolderSearchHit> SearchHits { get; } = new ObservableCollection<FolderSearchHit>();
[ObservableProperty] private string _searchQuery = string.Empty;
[ObservableProperty] private bool _searchMatchCase;
/// <summary>확장자 필터. <c>*.cs;*.h</c> 처럼 적는다. 비우면 전부 본다.</summary>
[ObservableProperty] private string _searchExtensions = string.Empty;
/// <summary>오른쪽 폴더도 훑을지.</summary>
[ObservableProperty] private bool _searchBothSides = true;
[ObservableProperty] private string _searchSummary = "찾을 말을 넣고 [찾기]를 누르세요.";
[ObservableProperty] private bool _isSearching;
/// <summary>결과 칸을 띄울지. 검색하기 전에는 트리에 자리를 다 내준다.</summary>
[ObservableProperty] private bool _hasSearched;
[ObservableProperty] private FolderSearchHit? _selectedHit;
partial void OnSearchQueryChanged(string value) => SearchCommand.NotifyCanExecuteChanged();
partial void OnSelectedHitChanged(FolderSearchHit? value) => OpenHitCommand.NotifyCanExecuteChanged();
private CancellationTokenSource? _searchCancel;
private bool CanSearch() => !IsSearching && !string.IsNullOrEmpty(SearchQuery);
/// <summary>
/// 비교 중인 두 폴더 전체에서 글자를 찾는다.
///
/// 훑는 일은 통째로 다른 스레드로 넘긴다 - 파일 수천 개를 읽는 동안 화면이 굳으면
/// 취소 단추조차 누를 수 없다. 결과는 다 모은 뒤 한 번에 붙인다.
/// </summary>
[RelayCommand(CanExecute = nameof(CanSearch))]
private async Task SearchAsync()
{
var left = PathUtil.FullFolderPath(LeftFolder);
var right = PathUtil.FullFolderPath(RightFolder);
if (left.Length == 0 && right.Length == 0)
{
SearchSummary = "먼저 폴더를 고르세요.";
return;
}
var options = new FolderSearchOptions
{
Query = SearchQuery,
MatchCase = SearchMatchCase,
Extensions = SearchExtensions,
IncludeSubfolders = IncludeSubfolders,
ExcludePattern = ExcludePattern,
SearchRight = SearchBothSides,
};
_searchCancel?.Dispose();
_searchCancel = new CancellationTokenSource();
var token = _searchCancel.Token;
SearchHits.Clear();
SelectedHit = null;
IsSearching = true;
HasSearched = true;
RaiseCanExecuteChanged();
SearchSummary = "찾는 중... (멈추려면 [검색 취소])";
// 몇 개째 훑는 중인지 알려 준다. 아무 글자도 안 바뀌면 멈춘 것처럼 보인다.
var progress = new Progress<FolderSearchProgress>(
p => SearchSummary = $"찾는 중... 파일 {p.FilesScanned}개 · {p.Hits}줄 · {p.CurrentFile}");
try
{
var result = await Task.Run(
() => FolderSearcher.Search(left, right, options, token, progress), token);
foreach (var hit in result.Hits) SearchHits.Add(hit);
SearchSummary = result.Hits.Count == 0
? (result.Cancelled
? $"검색을 멈췄습니다. (훑은 파일 {result.FilesScanned}개, 찾은 줄 없음)"
: $"\"{SearchQuery}\" 를 찾지 못했습니다. (훑은 파일 {result.FilesScanned}개)")
: result.Summary;
_workspace.AppendLog($"폴더 검색: \"{SearchQuery}\" · {result.Summary}");
}
catch (OperationCanceledException)
{
SearchSummary = $"검색을 멈췄습니다. (여기까지 {SearchHits.Count}줄)";
}
catch (Exception ex)
{
SearchSummary = $"검색 실패: {ex.Message}";
MessageUtil.ShowErrorToast($"폴더 검색 실패: {ex.Message}");
}
finally
{
IsSearching = false;
RaiseCanExecuteChanged();
}
}
private bool CanCancelSearch() => IsSearching;
[RelayCommand(CanExecute = nameof(CanCancelSearch))]
private void CancelSearch() => _searchCancel?.Cancel();
/// <summary>결과 칸을 접고 트리에 자리를 돌려준다.</summary>
[RelayCommand]
private void ClearSearch()
{
SearchHits.Clear();
SelectedHit = null;
HasSearched = false;
SearchSummary = "찾을 말을 넣고 [찾기]를 누르세요.";
}
private bool CanOpenHit() => SelectedHit is not null;
[RelayCommand(CanExecute = nameof(CanOpenHit))]
private void OpenHit() => OpenHitCore(SelectedHit);
/// <summary>
/// 찾은 줄을 파일 비교 화면에서 연다.
///
/// <b>그 줄에 서서</b> 열린다 - 파일만 열리고 맨 위에 서 있으면 찾은 자리를
/// 눈으로 다시 찾아야 해서 검색을 한 뜻이 없어진다.
/// </summary>
public void OpenHitCore(FolderSearchHit? hit)
{
if (hit is null) return;
if (!File.Exists(hit.LeftPath) && !File.Exists(hit.RightPath))
{
MessageUtil.ShowWarningToast("파일을 찾을 수 없습니다. 다시 검색해 보세요.");
return;
}
OpenInDiffRequested?.Invoke(this, (hit.LeftPath, hit.RightPath));
// 오른쪽에서만 나온 줄이면 오른쪽 파일 기준 번호다.
_workspace.RequestPlainCompare(hit.LeftPath, hit.RightPath, hit.LineNumber,
lineIsLeft: hit.Side != SearchSide.Right);
UbisamBase.Core.Modules.TabManager.Activate(ViewIds.Home);
}
private bool CanCancelCompare() => IsBusy; private bool CanCancelCompare() => IsBusy;
[RelayCommand(CanExecute = nameof(CanCancelCompare))] [RelayCommand(CanExecute = nameof(CanCancelCompare))]

View File

@ -1602,14 +1602,42 @@ namespace MergeHub.ViewModels
_redo.Clear(); _redo.Clear();
CaretRow = 0; CaretRow = 0;
InvalidateDiffBlocks(); InvalidateDiffBlocks();
SelectedLine = Lines.FirstOrDefault(l => l.IsDifferent) ?? Lines.FirstOrDefault();
// 폴더 검색에서 넘어왔으면 그 줄로, 아니면 여느 때처럼 첫 차이로 간다.
var asked = FindRequestedRow();
SelectedLine = asked >= 0
? Lines[asked]
: Lines.FirstOrDefault(l => l.IsDifferent) ?? Lines.FirstOrDefault();
UpdateSummaries(); UpdateSummaries();
RefreshDiffPosition(); RefreshDiffPosition();
RaiseCanExecuteChanged(); RaiseCanExecuteChanged();
// 줄을 고르는 것만으로는 화면이 따라오지 않는다. 캐럿까지 옮겨야 굴러간다.
if (asked >= 0) GoToRow(asked);
}); });
} }
/// <summary>
/// 폴더 전체 검색이 짚어 준 줄이 몇 번째 행인지. 없거나 못 찾으면 -1.
///
/// 왼쪽 기준 번호는 BASE 칸, 오른쪽 기준 번호는 LOCAL 칸에서 찾는다 -
/// 한쪽에만 있는 줄이 끼면 두 번호가 어긋나므로 아무 쪽에서나 찾으면 엉뚱한 줄에 선다.
/// </summary>
private int FindRequestedRow()
{
var (line, isLeft) = _workspace.TakeRequestedLine();
if (line <= 0) return -1;
for (var i = 0; i < Lines.Count; i++)
{
var number = isLeft ? Lines[i].BaseNumber : Lines[i].LocalNumber;
if (number == line) return i;
}
return -1;
}
private bool CanSavePlain() => IsPlainMode && HasChanges && !string.IsNullOrWhiteSpace(RightFile); private bool CanSavePlain() => IsPlainMode && HasChanges && !string.IsNullOrWhiteSpace(RightFile);
/// <summary>Git 없이 쓸 때는 커밋 없이 오른쪽 파일에 그대로 쓴다.</summary> /// <summary>Git 없이 쓸 때는 커밋 없이 오른쪽 파일에 그대로 쓴다.</summary>

View File

@ -3,7 +3,8 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:MergeHub.ViewModels" xmlns:vm="clr-namespace:MergeHub.ViewModels"
xmlns:svc="clr-namespace:MergeHub.Services" xmlns:svc="clr-namespace:MergeHub.Services"
xmlns:ctl="clr-namespace:MergeHub.Views.Controls"> xmlns:ctl="clr-namespace:MergeHub.Views.Controls"
AllowDrop="True" DragOver="View_DragOver" Drop="View_Drop">
<UserControl.Resources> <UserControl.Resources>
<ResourceDictionary> <ResourceDictionary>
@ -82,6 +83,7 @@
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/> <RowDefinition Height="*"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
@ -100,12 +102,14 @@
<Button Grid.Column="0" Content="왼쪽 폴더..." Command="{Binding PickLeftFolderCommand}" <Button Grid.Column="0" Content="왼쪽 폴더..." Command="{Binding PickLeftFolderCommand}"
Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0"/> Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0"/>
<TextBox Grid.Column="1" Text="{Binding LeftFolder, UpdateSourceTrigger=PropertyChanged}" <TextBox Grid.Column="1" Text="{Binding LeftFolder, UpdateSourceTrigger=PropertyChanged}"
AllowDrop="True" DragOver="View_DragOver" Drop="LeftFolder_Drop"
FontFamily="Consolas" FontSize="11" Height="28" VerticalContentAlignment="Center" FontFamily="Consolas" FontSize="11" Height="28" VerticalContentAlignment="Center"
Margin="0,0,14,0" ToolTip="비교 기준이 되는 폴더"/> Margin="0,0,14,0" ToolTip="비교 기준이 되는 폴더"/>
<Button Grid.Column="2" Content="오른쪽 폴더..." Command="{Binding PickRightFolderCommand}" <Button Grid.Column="2" Content="오른쪽 폴더..." Command="{Binding PickRightFolderCommand}"
Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0"/> Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0"/>
<TextBox Grid.Column="3" Text="{Binding RightFolder, UpdateSourceTrigger=PropertyChanged}" <TextBox Grid.Column="3" Text="{Binding RightFolder, UpdateSourceTrigger=PropertyChanged}"
AllowDrop="True" DragOver="View_DragOver" Drop="RightFolder_Drop"
FontFamily="Consolas" FontSize="11" Height="28" VerticalContentAlignment="Center" FontFamily="Consolas" FontSize="11" Height="28" VerticalContentAlignment="Center"
ToolTip="견줄 폴더"/> ToolTip="견줄 폴더"/>
</Grid> </Grid>
@ -160,11 +164,60 @@
</WrapPanel> </WrapPanel>
</Border> </Border>
<!--
③ 폴더 전체 검색.
폴더 비교는 "무엇이 다른가"에 답하지만 "이 함수를 쓰는 곳이 어디인가"에는 답하지 못한다.
그때마다 다른 도구를 켜는 대신 여기서 찾고 결과를 눌러 바로 그 줄로 넘어간다.
-->
<Border Grid.Row="2" Padding="8" BorderThickness="1" Margin="0,0,0,6"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<WrapPanel Orientation="Horizontal">
<TextBlock Text="폴더 안 찾기" VerticalAlignment="Center" FontSize="11" FontWeight="Bold"
Margin="0,0,8,0"/>
<TextBox x:Name="SearchBox"
Text="{Binding SearchQuery, UpdateSourceTrigger=PropertyChanged}"
KeyDown="SearchBox_KeyDown"
Width="220" Height="26" FontFamily="Consolas" FontSize="11"
VerticalContentAlignment="Center" Margin="0,0,12,0"
ToolTip="찾을 글자. Enter 를 눌러도 찾습니다"/>
<CheckBox Content="대소문자 구분" IsChecked="{Binding SearchMatchCase}"
VerticalAlignment="Center" Margin="0,0,12,0"/>
<TextBlock Text="확장자" VerticalAlignment="Center" Style="{StaticResource Muted}"
FontSize="11" Margin="0,0,4,0"/>
<TextBox Text="{Binding SearchExtensions, UpdateSourceTrigger=PropertyChanged}"
Width="140" Height="26" FontFamily="Consolas" FontSize="11"
VerticalContentAlignment="Center" Margin="0,0,12,0"
ToolTip="*.cs;*.h 처럼 적습니다. 비우면 전부 봅니다"/>
<CheckBox Content="오른쪽 폴더도" IsChecked="{Binding SearchBothSides}"
VerticalAlignment="Center" Margin="0,0,12,0"
ToolTip="양쪽에 똑같이 있는 줄은 한 번만 나오고 '양쪽'으로 적힙니다"/>
<Button Content="찾기" Command="{Binding SearchCommand}" Height="28" MinWidth="70"
Margin="0,0,6,0" FontWeight="Bold"/>
<Button Content="검색 취소" Command="{Binding CancelSearchCommand}" Height="28" MinWidth="70"
Margin="0,0,6,0"
ToolTip="검색을 멈춥니다. 여기까지 찾은 것은 그대로 남습니다"/>
<Button Content="찾은 줄 열기" Command="{Binding OpenHitCommand}" Height="28" MinWidth="90"
Margin="0,0,6,0"
ToolTip="고른 줄을 '파일 비교' 탭에서 그 자리에 서서 엽니다. 더블클릭·Enter 도 같습니다"/>
<Button Content="결과 닫기" Command="{Binding ClearSearchCommand}" Height="28" MinWidth="70"
Margin="0,0,12,0"
ToolTip="결과 칸을 접고 목록에 자리를 돌려줍니다"/>
<TextBlock Text="{Binding SearchSummary}" VerticalAlignment="Center" FontSize="11"
Style="{StaticResource Muted}" TextTrimming="CharacterEllipsis" MaxWidth="520"/>
</WrapPanel>
</Border>
<!-- <!--
④ 색 뜻풀이. 색만 칠해 두면 무슨 뜻인지 아무도 모른다 - ④ 색 뜻풀이. 색만 칠해 두면 무슨 뜻인지 아무도 모른다 -
파일 비교 화면의 "색 구분" 줄과 같은 성격이다. 파일 비교 화면의 "색 구분" 줄과 같은 성격이다.
--> -->
<Border Grid.Row="2" Padding="8,4" BorderThickness="1,1,1,0" Margin="0,0,0,0" <Border Grid.Row="3" Padding="8,4" BorderThickness="1,1,1,0" Margin="0,0,0,0"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"> BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<WrapPanel Orientation="Horizontal"> <WrapPanel Orientation="Horizontal">
<TextBlock Text="색 구분" Style="{StaticResource Muted}" FontSize="11" Margin="0,0,10,0" <TextBlock Text="색 구분" Style="{StaticResource Muted}" FontSize="11" Margin="0,0,10,0"
@ -203,7 +256,7 @@
</Border> </Border>
<!-- ③ 목록 머리글. 트리 항목의 칸 폭과 같은 값을 쓴다. --> <!-- ③ 목록 머리글. 트리 항목의 칸 폭과 같은 값을 쓴다. -->
<Border Grid.Row="3" Padding="8,4" BorderThickness="1,1,1,0" <Border Grid.Row="4" Padding="8,4" BorderThickness="1,1,1,0"
Background="{DynamicResource Ubisam.Brush.Sidebar.Background}" Background="{DynamicResource Ubisam.Brush.Sidebar.Background}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"> BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<Grid Margin="26,0,0,0"> <Grid Margin="26,0,0,0">
@ -226,7 +279,13 @@
채움 칸(*)이 0 이 되고 줄 배경이 글자 끝에서 끊긴다. 칸 폭이 고정이라 채움 칸(*)이 0 이 되고 줄 배경이 글자 끝에서 끊긴다. 칸 폭이 고정이라
가로로 넘칠 일이 없으므로 꺼 두는 편이 맞다. 가로로 넘칠 일이 없으므로 꺼 두는 편이 맞다.
--> -->
<TreeView Grid.Row="4" x:Name="Tree" BorderThickness="1" <Grid Grid.Row="5">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TreeView Grid.Row="0" x:Name="Tree" BorderThickness="1"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}" BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
ItemsSource="{Binding Nodes}" ItemsSource="{Binding Nodes}"
ItemTemplate="{StaticResource NodeTemplate}" ItemTemplate="{StaticResource NodeTemplate}"
@ -239,8 +298,48 @@
VirtualizingPanel.IsVirtualizing="True" VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"/> VirtualizingPanel.VirtualizationMode="Recycling"/>
<!--
찾은 줄. 검색하기 전에는 접혀 있어 목록이 자리를 다 쓴다.
높이를 묶어 두는 까닭 - 결과가 수천 줄이어도 목록을 화면 밖으로 밀면 안 된다.
-->
<Border Grid.Row="1" BorderThickness="1,0,1,1" Margin="0,0,0,0"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
Visibility="{Binding HasSearched, Converter={StaticResource BoolToVis}}">
<ListBox x:Name="HitList" ItemsSource="{Binding SearchHits}"
SelectedItem="{Binding SelectedHit}"
MinHeight="90" MaxHeight="260" BorderThickness="0"
MouseDoubleClick="HitList_MouseDoubleClick"
KeyDown="HitList_KeyDown"
HorizontalContentAlignment="Stretch"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="52"/>
<ColumnDefinition Width="320"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="{Binding SideText}" FontSize="11"
FontWeight="Bold"/>
<TextBlock Grid.Column="1" Text="{Binding Where}" FontFamily="Consolas"
FontSize="11" TextTrimming="CharacterEllipsis"
ToolTip="{Binding RelativePath}"/>
<TextBlock Grid.Column="2" Text="{Binding Preview}" FontFamily="Consolas"
FontSize="11" TextTrimming="CharacterEllipsis"
Style="{StaticResource Muted}"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
</Grid>
<!-- ⑤ 요약 · 동작 --> <!-- ⑤ 요약 · 동작 -->
<Border Grid.Row="5" Padding="8" BorderThickness="1,0,1,1" Margin="0,0,0,0" <Border Grid.Row="6" Padding="8" BorderThickness="1,0,1,1" Margin="0,0,0,0"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"> BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>

View File

@ -1,4 +1,4 @@
using System.Windows; using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Input; using System.Windows.Input;
using MergeHub.ViewModels; using MergeHub.ViewModels;
@ -52,6 +52,49 @@ namespace MergeHub.Views
e.Handled = true; e.Handled = true;
} }
/// <summary>찾기 칸에서 Enter 를 누르면 바로 찾는다. 단추까지 가는 걸음을 줄인다.</summary>
private void SearchBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key != Key.Enter) return;
if (DataContext is not FolderViewModel vm) return;
if (vm.SearchCommand.CanExecute(null)) vm.SearchCommand.Execute(null);
e.Handled = true;
}
/// <summary>찾은 줄을 더블클릭하면 그 자리에서 파일 비교를 연다.</summary>
private void HitList_MouseDoubleClick(object sender, MouseButtonEventArgs e)
{
if (DataContext is not FolderViewModel vm) return;
if (HitUnder(e.OriginalSource as DependencyObject) is not { } hit) return;
vm.SelectedHit = hit;
vm.OpenHitCore(hit);
e.Handled = true;
}
/// <summary>Enter 로도 연다. 결과를 키보드로 훑는 쪽이 빠르다.</summary>
private void HitList_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key != Key.Enter) return;
if (DataContext is not FolderViewModel vm || vm.SelectedHit is not { } hit) return;
vm.OpenHitCore(hit);
e.Handled = true;
}
/// <summary>클릭이 떨어진 자리에서 위로 올라가며 그 줄의 결과를 찾는다.</summary>
private static Services.FolderSearchHit? HitUnder(DependencyObject? source)
{
while (source is not null)
{
if (source is ListBoxItem item) return item.DataContext as Services.FolderSearchHit;
source = System.Windows.Media.VisualTreeHelper.GetParent(source);
}
return null;
}
/// <summary>클릭이 떨어진 시각 요소에서 위로 올라가며 그 줄의 항목을 찾는다.</summary> /// <summary>클릭이 떨어진 시각 요소에서 위로 올라가며 그 줄의 항목을 찾는다.</summary>
private static FolderNode? NodeUnder(DependencyObject? source) private static FolderNode? NodeUnder(DependencyObject? source)
{ {
@ -65,5 +108,72 @@ namespace MergeHub.Views
return null; return null;
} }
// ================= 끌어다 놓기 =================
/// <summary>
/// 탐색기에서 끌어 온 것을 받을 수 있다고 알린다.
///
/// 이 표시가 없으면 커서에 <b>금지 표시</b>가 뜨고 놓아도 아무 일이 없다.
/// 파일·폴더가 아닌 것(글자·그림)은 받지 않는다.
/// </summary>
private static void AllowFileDrop(DragEventArgs e)
{
e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None;
e.Handled = true;
}
private void View_DragOver(object sender, DragEventArgs e) => AllowFileDrop(e);
/// <summary>
/// 화면 아무 데나 놓았을 때. 두 개를 놓으면 양쪽에 하나씩 들어간다.
///
/// 판단은 명령줄로 들어오는 길과 <b>같은 것</b>을 쓴다 - 폴더면 폴더 비교, 파일이면 파일 비교로
/// 탭까지 옮긴다. 폴더 화면에 파일을 놓았다고 아무 일도 안 하는 것은 불친절하다.
/// </summary>
private void View_Drop(object sender, DragEventArgs e)
{
if (DataContext is not FolderViewModel vm) return;
if (DroppedPaths(e) is not { Length: > 0 } paths) return;
vm.Workspace.RouteDroppedPaths(paths);
e.Handled = true;
}
/// <summary>왼쪽·오른쪽 칸에 바로 놓으면 <b>그 자리에만</b> 들어간다.</summary>
private void LeftFolder_Drop(object sender, DragEventArgs e) => DropIntoSlot(e, left: true);
private void RightFolder_Drop(object sender, DragEventArgs e) => DropIntoSlot(e, left: false);
private void DropIntoSlot(DragEventArgs e, bool left)
{
if (DataContext is not FolderViewModel vm) return;
if (DroppedPaths(e) is not { Length: > 0 } paths) return;
// 파일을 놓았으면 그 파일이 든 폴더로 받는다 - 폴더 칸이니 그렇게 읽는 편이 자연스럽다.
var folder = Services.PathUtil.FullFolderPath(paths[0]);
if (folder.Length == 0) folder = Services.PathUtil.FolderOfFile(paths[0]);
if (folder.Length == 0) return;
if (left) vm.LeftFolder = folder;
else vm.RightFolder = folder;
// 두 개를 한꺼번에 놓았으면 나머지 하나는 반대쪽에 넣는다.
if (paths.Length > 1)
{
var other = Services.PathUtil.FullFolderPath(paths[1]);
if (other.Length == 0) other = Services.PathUtil.FolderOfFile(paths[1]);
if (other.Length > 0)
{
if (left) vm.RightFolder = other;
else vm.LeftFolder = other;
}
}
e.Handled = true;
}
private static string[]? DroppedPaths(DragEventArgs e) =>
e.Data.GetDataPresent(DataFormats.FileDrop) ? e.Data.GetData(DataFormats.FileDrop) as string[] : null;
} }
} }

View File

@ -7,7 +7,8 @@
xmlns:ctl="clr-namespace:MergeHub.Views.Controls" xmlns:ctl="clr-namespace:MergeHub.Views.Controls"
xmlns:system="clr-namespace:System;assembly=mscorlib" xmlns:system="clr-namespace:System;assembly=mscorlib"
mc:Ignorable="d" mc:Ignorable="d"
PreviewKeyDown="View_PreviewKeyDown"> PreviewKeyDown="View_PreviewKeyDown"
AllowDrop="True" DragOver="View_DragOver" Drop="View_Drop">
<UserControl.Resources> <UserControl.Resources>
<ResourceDictionary> <ResourceDictionary>
<ResourceDictionary.MergedDictionaries> <ResourceDictionary.MergedDictionaries>
@ -384,13 +385,15 @@
ToolTip="왼쪽(BASE) 에 놓을 파일"/> ToolTip="왼쪽(BASE) 에 놓을 파일"/>
<TextBlock Text="{Binding LeftFile}" Style="{StaticResource Muted}" FontSize="11" <TextBlock Text="{Binding LeftFile}" Style="{StaticResource Muted}" FontSize="11"
MaxWidth="260" TextTrimming="CharacterEllipsis" Margin="4,0,10,0" MaxWidth="260" TextTrimming="CharacterEllipsis" Margin="4,0,10,0"
ToolTip="{Binding LeftFile}"/> ToolTip="{Binding LeftFile}"
AllowDrop="True" DragOver="View_DragOver" Drop="LeftFile_Drop"/>
<Button Content="내 파일..." Command="{Binding PickRightFileCommand}" <Button Content="내 파일..." Command="{Binding PickRightFileCommand}"
Height="26" MinHeight="26" Padding="8,2" FontSize="11" Height="26" MinHeight="26" Padding="8,2" FontSize="11"
ToolTip="오른쪽(LOCAL) 에 놓을 파일. 저장하면 이 파일에 씁니다"/> ToolTip="오른쪽(LOCAL) 에 놓을 파일. 저장하면 이 파일에 씁니다"/>
<TextBlock Text="{Binding RightFile}" Style="{StaticResource Muted}" FontSize="11" <TextBlock Text="{Binding RightFile}" Style="{StaticResource Muted}" FontSize="11"
MaxWidth="260" TextTrimming="CharacterEllipsis" Margin="4,0,0,0" MaxWidth="260" TextTrimming="CharacterEllipsis" Margin="4,0,0,0"
ToolTip="{Binding RightFile}"/> ToolTip="{Binding RightFile}"
AllowDrop="True" DragOver="View_DragOver" Drop="RightFile_Drop"/>
</StackPanel> </StackPanel>
<!-- Git 일 때: 비교할 파일과 3-Way 상대 브랜치를 여기서 고른다. <!-- Git 일 때: 비교할 파일과 3-Way 상대 브랜치를 여기서 고른다.

View File

@ -915,6 +915,65 @@ namespace MergeHub.Views
return true; return true;
} }
// ================= 끌어다 놓기 =================
/// <summary>
/// 탐색기에서 끌어 온 것을 받을 수 있다고 알린다. 이 표시가 없으면 커서에
/// <b>금지 표시</b>가 뜨고 놓아도 아무 일이 없다.
/// </summary>
private void View_DragOver(object sender, DragEventArgs e)
{
e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None;
e.Handled = true;
}
/// <summary>
/// 화면 아무 데나 놓았을 때. 판단은 명령줄로 들어오는 길과 <b>같은 것</b>을 쓴다 -
/// 폴더를 놓으면 폴더 비교 탭으로 넘어간다. 파일 화면이라고 폴더를 버리면 불친절하다.
/// </summary>
private void View_Drop(object sender, DragEventArgs e)
{
if (DataContext is not HomeViewModel vm) return;
if (DroppedPaths(e) is not { Length: > 0 } paths) return;
vm.Workspace.RouteDroppedPaths(paths);
e.Handled = true;
}
/// <summary>왼쪽·오른쪽 칸에 바로 놓으면 <b>그 자리에만</b> 들어간다.</summary>
private void LeftFile_Drop(object sender, DragEventArgs e) => DropIntoSlot(e, left: true);
private void RightFile_Drop(object sender, DragEventArgs e) => DropIntoSlot(e, left: false);
private void DropIntoSlot(DragEventArgs e, bool left)
{
if (DataContext is not HomeViewModel vm) return;
if (DroppedPaths(e) is not { Length: > 0 } paths) return;
// 폴더를 파일 칸에 놓은 것은 뜻이 통하지 않는다. 그때는 화면 전체 규칙으로 넘긴다.
if (System.IO.Directory.Exists(paths[0]))
{
vm.Workspace.RouteDroppedPaths(paths);
e.Handled = true;
return;
}
if (left) vm.LeftFile = paths[0];
else vm.RightFile = paths[0];
// 두 개를 한꺼번에 놓았으면 나머지 하나는 반대쪽에 넣는다.
if (paths.Length > 1 && !System.IO.Directory.Exists(paths[1]))
{
if (left) vm.RightFile = paths[1];
else vm.LeftFile = paths[1];
}
e.Handled = true;
}
private static string[]? DroppedPaths(DragEventArgs e) =>
e.Data.GetDataPresent(DataFormats.FileDrop) ? e.Data.GetData(DataFormats.FileDrop) as string[] : null;
private void ShortcutHelp_Click(object sender, RoutedEventArgs e) => private void ShortcutHelp_Click(object sender, RoutedEventArgs e) =>
ShortcutHelpWindow.ShowOrActivate(Window.GetWindow(this)); ShortcutHelpWindow.ShowOrActivate(Window.GetWindow(this));