diff --git a/doc/진행상황.md b/doc/진행상황.md
index 59e92d5..43855b6 100644
--- a/doc/진행상황.md
+++ b/doc/진행상황.md
@@ -128,11 +128,11 @@ CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘
|---|---|
| **완료** | D-1 주석만 다른 파일을 다른 색으로 |
| **완료** | D-2 C++ 문법 강조 (이미 되고 있었음 · 전처리 지시문만 추가) |
-| 대기 | D-3 폴더 안 전체 검색 |
+| **완료** | D-3 폴더 안 전체 검색 |
| ~~취소~~ | ~~D-4 비교 창 여러 개~~ (2026-09-09 사용자 판단으로 뺌) |
| **완료** | D-5 덩어리 단위 반영 (3-Way 에서 BASE 를 가져오던 것을 REMOTE 로) |
| **완료** | D-6 비교 결과 텍스트 내보내기 |
-| 대기 | D-7 드래그 앤 드롭으로 열기 |
+| **완료**(화면 확인 남음) | D-7 드래그 앤 드롭으로 열기 |
### E. 추가 요청 (2026-09-09 오후)
@@ -160,6 +160,63 @@ BASE 머리글에 그렇게 적었다 - `BASE (merge base) · 이 파일 없음
조상에 파일이 있는 경우는 지금까지처럼 파일 이름이 그대로 나온다.
---
+### D-3. 폴더 안 전체 검색 — **완료** (2026-09-09)
+
+**왜 필요했나** - 폴더 비교는 "무엇이 다른가"에 답하지만 "이 함수를 쓰는 곳이 어디인가"에는
+답하지 못한다. 그때마다 탐색기 검색이나 다른 편집기를 켜야 했다.
+
+`Services/FolderSearcher.cs` 신설. 화면 없이 검사할 수 있게 찾는 일만 여기서 한다.
+
+**양쪽을 다 훑되 같은 줄은 한 번만 보여 준다.** 안 그러면 손대지 않은 파일이 전부 두 번씩 나와
+목록의 절반이 쓸모없어진다. 그래서 결과 한 줄에 "왼쪽 / 오른쪽 / 양쪽"이 붙는다 -
+`양쪽` 은 두 폴더에 똑같이 있는 줄, `왼쪽`·`오른쪽` 은 한쪽에만 있는 줄이다.
+이것만 봐도 "내가 고친 줄인지"를 안다.
+
+내용이 똑같은 파일은 오른쪽을 다시 읽지 않는다. 대신 왼쪽에서 찾아 둔 줄을 `양쪽` 으로 바꾼다.
+
+**결과를 누르면 그 줄에 서서 열린다.** 파일만 열리고 맨 위에 서 있으면 찾은 자리를 눈으로
+다시 찾아야 해서 검색을 한 뜻이 없어진다. `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` |
+| 결과를 열었을 때 | 두 파일이 열리고 캐럿이 5번째 줄에 섰다 |
+| 663개 파일을 훑는 중 | 진행 글자가 계속 바뀌고 [검색 취소]가 눌렸다 |
+| 취소한 뒤 | `517줄 · 파일 63개 · 도중에 멈춤` — 찾은 것이 그대로 남았다 |
+
+검사 63건 추가(`SearchTest`). 전체 512건 통과.
+
+---
+
+### D-7. 드래그 앤 드롭으로 열기 — **코드 완료 · 화면 확인 남음** (2026-09-09)
+
+탐색기에서 파일·폴더를 끌어다 놓으면 그 자리에서 비교가 열린다.
+
+`GitWorkspace.RouteDroppedPaths` 가 명령줄과 같은 판단을 쓴다(`StartupArgs.Parse` 를 그대로).
+끌어다 놓는 것과 우클릭·명령줄이 다르게 굴면 그것부터 버그다 - 폴더면 폴더 비교, 파일이면 파일 비교로
+가고 해당 탭이 켜진다. 화면 아무 데나 놓으면 이 판단을 타고, 왼쪽·오른쪽 경로 칸에 바로 놓으면
+그 칸만 채운다.
+
+화면에서 실제로 끌어다 놓는 확인은 아직 못 했다. 작업 중 컴퓨터가 잠겨 탐색기에서
+끄는 동작을 만들 수 없었다. 잠금이 풀리면 확인한다.
+
+---
+
### D-6. 비교 결과 텍스트 내보내기 — **완료** (2026-09-09)
**왜 필요했나** - 결과를 남에게 보내거나 결재에 붙일 때 화면을 캡처하는 것 말고는 방법이 없었다.
diff --git a/src/MergeHub/Services/FolderSearcher.cs b/src/MergeHub/Services/FolderSearcher.cs
new file mode 100644
index 0000000..093feee
--- /dev/null
+++ b/src/MergeHub/Services/FolderSearcher.cs
@@ -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
+{
+ /// 찾은 줄이 어느 쪽에 있었는지.
+ public enum SearchSide
+ {
+ Left,
+ Right,
+
+ /// 양쪽 같은 자리에 똑같이 있다. 아직 손대지 않은 줄이라는 뜻이다.
+ Both
+ }
+
+ /// 폴더 전체 검색 규칙.
+ public sealed class FolderSearchOptions
+ {
+ public string Query { get; set; } = string.Empty;
+
+ public bool MatchCase { get; set; }
+
+ ///
+ /// 확장자 필터. .cs;.h 든 *.cs, *.h 든 cs h 든 받는다. 비우면 전부 본다.
+ /// 코드 폴더에서 찾을 때 이것 하나로 대상이 열 배 줄어든다.
+ ///
+ public string Extensions { get; set; } = string.Empty;
+
+ public bool IncludeSubfolders { get; set; } = true;
+
+ /// 제외할 파일·폴더 정규식. 폴더 비교와 같은 값을 그대로 넘겨 쓴다.
+ public string ExcludePattern { get; set; } = string.Empty;
+
+ /// 이 크기를 넘는 파일은 건너뛴다. 통째로 읽어 줄로 자르는 방식이라 상한이 필요하다.
+ public long MaxFileBytes { get; set; } = 8L * 1024 * 1024;
+
+ /// 찾은 줄이 이만큼 쌓이면 멈춘다. 목록이 수만 줄이면 어차피 눈으로 못 본다.
+ public int MaxHits { get; set; } = 5000;
+
+ /// 오른쪽 폴더도 훑을지. 꺼 두면 왼쪽만 본다.
+ public bool SearchRight { get; set; } = true;
+ }
+
+ /// 찾은 줄 하나.
+ 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;
+ }
+
+ /// 어느 쪽에서 나왔는지. 화면에서 이것만 봐도 "내가 고친 줄인지"를 안다.
+ public SearchSide Side { get; set; }
+
+ public string RelativePath { get; }
+
+ /// 여는 데 쓸 양쪽 실제 경로. 한쪽에만 있으면 없는 쪽은 그대로 두고 빈 내용으로 열린다.
+ public string LeftPath { get; }
+ public string RightPath { get; }
+
+ /// 1부터 센다.
+ public int LineNumber { get; }
+
+ /// 줄 안에서 찾은 자리. 1부터 센다.
+ public int Column { get; }
+
+ public string LineText { get; }
+
+ public string SideText => Side switch
+ {
+ SearchSide.Left => "왼쪽",
+ SearchSide.Right => "오른쪽",
+ _ => "양쪽"
+ };
+
+ public string Where => $"{RelativePath}:{LineNumber}";
+
+ /// 목록에 뿌릴 한 줄. 앞 공백을 떼고 너무 길면 자른다.
+ public string Preview
+ {
+ get
+ {
+ var text = LineText.Trim();
+ return text.Length <= PreviewLimit ? text : text.Substring(0, PreviewLimit) + " …";
+ }
+ }
+
+ private const int PreviewLimit = 200;
+ }
+
+ /// 검색이 어디까지 갔는지. 진행 중에 화면에 뿌린다.
+ 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 Hits { get; } = new List();
+
+ public int FilesScanned { get; set; }
+
+ /// 한 줄이라도 걸린 파일 수.
+ public int FilesMatched { get; set; }
+
+ /// 이진이거나 너무 커서 건너뛴 파일 수.
+ public int FilesSkipped { get; set; }
+
+ /// 상한에 걸려 도중에 멈췄는지.
+ public bool Truncated { get; set; }
+
+ /// 사용자가 [검색 취소]로 멈췄는지. 그래도 여기까지 찾은 것은 그대로 준다.
+ public bool Cancelled { get; set; }
+
+ public string Summary =>
+ $"{Hits.Count}줄 · 파일 {FilesMatched}개 (훑은 파일 {FilesScanned}"
+ + (FilesSkipped > 0 ? $", 건너뜀 {FilesSkipped}" : string.Empty) + ")"
+ + (Cancelled ? " · 도중에 멈춤" : string.Empty)
+ + (Truncated ? " · 너무 많아 일부만 봄" : string.Empty);
+ }
+
+ ///
+ /// 비교 중인 두 폴더 전체에서 글자를 찾는다.
+ ///
+ /// 폴더 비교는 "무엇이 다른가"를 알려 주지만 "이 함수를 쓰는 곳이 어디인가"는 답하지 못한다.
+ /// 그때마다 탐색기 검색이나 다른 편집기를 켜는 대신 여기서 찾고 바로 비교 화면으로 넘어간다.
+ ///
+ /// 양쪽을 다 훑되 같은 파일 같은 줄에 똑같이 있으면 한 줄로 합친다 - 안 그러면
+ /// 손대지 않은 파일이 전부 두 번씩 나와 목록의 절반이 쓸모없어진다.
+ ///
+ public static class FolderSearcher
+ {
+ /// 이 개수를 넘게 훑지 않는다. 실수로 C:\ 를 골랐을 때 끝나지 않는 것을 막는다.
+ private const int MaxFiles = 50_000;
+
+ /// 파일 하나를 볼 때마다 확인하므로 [취소]를 누르면 곧 멈춘다.
+ /// 화면에 "몇 개째 훑는 중"을 뿌릴 때 쓴다. 없어도 된다.
+ public static FolderSearchResult Search(string leftRoot, string rightRoot, FolderSearchOptions options,
+ CancellationToken cancel = default,
+ IProgress? 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(StringComparer.Ordinal);
+ var byFile = new Dictionary>(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;
+ }
+
+ /// 파일 하나에서 찾는다. 화면 없이 규칙만 확인할 때도 이 자리를 쓴다.
+ 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;
+ }
+
+ ///
+ /// 확장자 필터를 읽는다. *.cs, .cs, cs 를 모두 같게 본다 -
+ /// 어느 쪽으로 적든 사용자가 뜻한 바는 하나다.
+ ///
+ public static IReadOnlyList ParseExtensions(string? text)
+ {
+ var list = new List();
+ 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 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 extensions, Regex? exclude,
+ FolderSearchResult result, Dictionary seen,
+ Dictionary> byFile,
+ CancellationToken cancel, IProgress? 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();
+ byFile[relative] = list;
+ }
+
+ list.Add(hit);
+ }
+
+ progress?.Report(new FolderSearchProgress(result.FilesScanned, result.Hits.Count, relative));
+ }
+ }
+
+ /// 뿌리 폴더 아래 파일들의 상대 경로. 한 번에 다 모으지 않고 하나씩 넘긴다.
+ private static IEnumerable 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; }
+ }
+
+ /// 파일 차례로, 그 안에서는 줄 차례로. 목록을 위에서 아래로 읽을 수 있게 한다.
+ 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; }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/MergeHub/Services/GitWorkspace.cs b/src/MergeHub/Services/GitWorkspace.cs
index aafc625..d220445 100644
--- a/src/MergeHub/Services/GitWorkspace.cs
+++ b/src/MergeHub/Services/GitWorkspace.cs
@@ -374,12 +374,37 @@ namespace MergeHub.Services
/// 이벤트를 받을 사람이 없다. 그냥 발생시키면 요청이 허공에 사라지고,
/// 새로 만들어진 화면은 기본 파일을 연다 - "더블클릭이 안 먹는다"로 보이는 이유다.
///
- public void RequestPlainCompare(string leftFile, string rightFile)
+ ///
+ /// 열자마자 짚어 줄 줄 번호. 0 이면 여느 때처럼 첫 차이로 간다.
+ /// 폴더 전체 검색에서 결과를 눌렀을 때, 파일만 열리고 맨 위에 서 있으면
+ /// 찾은 줄을 눈으로 다시 찾아야 한다.
+ ///
+ /// 그 번호가 왼쪽 파일 기준인지. 아니면 오른쪽 기준이다.
+ public void RequestPlainCompare(string leftFile, string rightFile, int line = 0, bool lineIsLeft = true)
{
_pendingPlainCompare = (leftFile, rightFile);
+ RequestedLine = line;
+ RequestedLineIsLeft = lineIsLeft;
OnUi(DeliverPendingPlainCompare);
}
+ /// 넘긴 파일에서 짚어 줄 자리. 없으면 0.
+ public int RequestedLine { get; private set; }
+
+ /// 그 번호가 왼쪽 파일 기준인지.
+ public bool RequestedLineIsLeft { get; private set; }
+
+ ///
+ /// 짚어 줄 자리를 한 번 쓰고 지운다.
+ /// 지우지 않으면 그 다음에 연 파일까지 같은 줄로 끌려간다.
+ ///
+ public (int Line, bool IsLeft) TakeRequestedLine()
+ {
+ var one = (RequestedLine, RequestedLineIsLeft);
+ RequestedLine = 0;
+ return one;
+ }
+
/// 남아 있는 요청을 넘긴다. 화면이 새로 만들어지면 스스로 한 번 불러 준다.
public void DeliverPendingPlainCompare()
{
@@ -441,6 +466,34 @@ namespace MergeHub.Services
: $"파일 비교 요청: {left} ↔ {right}");
}
+ ///
+ /// 탐색기에서 끌어다 놓은 것을 화면으로 보낸다.
+ ///
+ /// 명령줄로 들어오는 길과 같은 판단을 쓴다 - 폴더면 폴더 비교, 파일이면 파일 비교.
+ /// 두 길이 다르게 굴면 "우클릭으로 열 때랑 끌어다 놓을 때가 다르다"가 되어 설명할 수 없다.
+ ///
+ public void RouteDroppedPaths(IReadOnlyList 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)
{
var app = System.Windows.Application.Current;
diff --git a/src/MergeHub/ViewModels/FolderViewModel.cs b/src/MergeHub/ViewModels/FolderViewModel.cs
index 1175ffe..2148f0e 100644
--- a/src/MergeHub/ViewModels/FolderViewModel.cs
+++ b/src/MergeHub/ViewModels/FolderViewModel.cs
@@ -112,6 +112,9 @@ namespace MergeHub.ViewModels
{
private readonly GitWorkspace _workspace;
+ /// 화면 쪽에서 끌어다 놓기를 넘길 때 쓴다.
+ public GitWorkspace Workspace => _workspace;
+
public FolderViewModel(GitWorkspace workspace)
{
_workspace = workspace;
@@ -209,6 +212,9 @@ namespace MergeHub.ViewModels
DeleteLeftCommand.NotifyCanExecuteChanged();
DeleteRightCommand.NotifyCanExecuteChanged();
ExportReportCommand.NotifyCanExecuteChanged();
+ SearchCommand.NotifyCanExecuteChanged();
+ CancelSearchCommand.NotifyCanExecuteChanged();
+ OpenHitCommand.NotifyCanExecuteChanged();
}
[RelayCommand]
@@ -337,6 +343,158 @@ namespace MergeHub.ViewModels
private CancellationTokenSource? _compareCancel;
+ // ================= 폴더 전체 검색 =================
+
+ /// 찾은 줄. 파일 차례로 늘어선다.
+ public ObservableCollection SearchHits { get; } = new ObservableCollection();
+
+ [ObservableProperty] private string _searchQuery = string.Empty;
+
+ [ObservableProperty] private bool _searchMatchCase;
+
+ /// 확장자 필터. *.cs;*.h 처럼 적는다. 비우면 전부 본다.
+ [ObservableProperty] private string _searchExtensions = string.Empty;
+
+ /// 오른쪽 폴더도 훑을지.
+ [ObservableProperty] private bool _searchBothSides = true;
+
+ [ObservableProperty] private string _searchSummary = "찾을 말을 넣고 [찾기]를 누르세요.";
+
+ [ObservableProperty] private bool _isSearching;
+
+ /// 결과 칸을 띄울지. 검색하기 전에는 트리에 자리를 다 내준다.
+ [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);
+
+ ///
+ /// 비교 중인 두 폴더 전체에서 글자를 찾는다.
+ ///
+ /// 훑는 일은 통째로 다른 스레드로 넘긴다 - 파일 수천 개를 읽는 동안 화면이 굳으면
+ /// 취소 단추조차 누를 수 없다. 결과는 다 모은 뒤 한 번에 붙인다.
+ ///
+ [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(
+ 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();
+
+ /// 결과 칸을 접고 트리에 자리를 돌려준다.
+ [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);
+
+ ///
+ /// 찾은 줄을 파일 비교 화면에서 연다.
+ ///
+ /// 그 줄에 서서 열린다 - 파일만 열리고 맨 위에 서 있으면 찾은 자리를
+ /// 눈으로 다시 찾아야 해서 검색을 한 뜻이 없어진다.
+ ///
+ 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;
[RelayCommand(CanExecute = nameof(CanCancelCompare))]
diff --git a/src/MergeHub/ViewModels/HomeViewModel.cs b/src/MergeHub/ViewModels/HomeViewModel.cs
index d4c394b..dec624c 100644
--- a/src/MergeHub/ViewModels/HomeViewModel.cs
+++ b/src/MergeHub/ViewModels/HomeViewModel.cs
@@ -1602,14 +1602,42 @@ namespace MergeHub.ViewModels
_redo.Clear();
CaretRow = 0;
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();
RefreshDiffPosition();
RaiseCanExecuteChanged();
+
+ // 줄을 고르는 것만으로는 화면이 따라오지 않는다. 캐럿까지 옮겨야 굴러간다.
+ if (asked >= 0) GoToRow(asked);
});
}
+ ///
+ /// 폴더 전체 검색이 짚어 준 줄이 몇 번째 행인지. 없거나 못 찾으면 -1.
+ ///
+ /// 왼쪽 기준 번호는 BASE 칸, 오른쪽 기준 번호는 LOCAL 칸에서 찾는다 -
+ /// 한쪽에만 있는 줄이 끼면 두 번호가 어긋나므로 아무 쪽에서나 찾으면 엉뚱한 줄에 선다.
+ ///
+ 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);
/// Git 없이 쓸 때는 커밋 없이 오른쪽 파일에 그대로 쓴다.
diff --git a/src/MergeHub/Views/FolderView.xaml b/src/MergeHub/Views/FolderView.xaml
index b76ea51..6df4382 100644
--- a/src/MergeHub/Views/FolderView.xaml
+++ b/src/MergeHub/Views/FolderView.xaml
@@ -3,7 +3,8 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:MergeHub.ViewModels"
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">
@@ -82,6 +83,7 @@
+
@@ -100,12 +102,14 @@
@@ -160,11 +164,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
@@ -226,7 +279,13 @@
채움 칸(*)이 0 이 되고 줄 배경이 글자 끝에서 끊긴다. 칸 폭이 고정이라
가로로 넘칠 일이 없으므로 꺼 두는 편이 맞다.
-->
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
diff --git a/src/MergeHub/Views/FolderView.xaml.cs b/src/MergeHub/Views/FolderView.xaml.cs
index 97888a6..ff6ee59 100644
--- a/src/MergeHub/Views/FolderView.xaml.cs
+++ b/src/MergeHub/Views/FolderView.xaml.cs
@@ -1,4 +1,4 @@
-using System.Windows;
+using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using MergeHub.ViewModels;
@@ -52,6 +52,49 @@ namespace MergeHub.Views
e.Handled = true;
}
+ /// 찾기 칸에서 Enter 를 누르면 바로 찾는다. 단추까지 가는 걸음을 줄인다.
+ 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;
+ }
+
+ /// 찾은 줄을 더블클릭하면 그 자리에서 파일 비교를 연다.
+ 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;
+ }
+
+ /// Enter 로도 연다. 결과를 키보드로 훑는 쪽이 빠르다.
+ 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;
+ }
+
+ /// 클릭이 떨어진 자리에서 위로 올라가며 그 줄의 결과를 찾는다.
+ 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;
+ }
+
/// 클릭이 떨어진 시각 요소에서 위로 올라가며 그 줄의 항목을 찾는다.
private static FolderNode? NodeUnder(DependencyObject? source)
{
@@ -65,5 +108,72 @@ namespace MergeHub.Views
return null;
}
+ // ================= 끌어다 놓기 =================
+
+ ///
+ /// 탐색기에서 끌어 온 것을 받을 수 있다고 알린다.
+ ///
+ /// 이 표시가 없으면 커서에 금지 표시가 뜨고 놓아도 아무 일이 없다.
+ /// 파일·폴더가 아닌 것(글자·그림)은 받지 않는다.
+ ///
+ 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);
+
+ ///
+ /// 화면 아무 데나 놓았을 때. 두 개를 놓으면 양쪽에 하나씩 들어간다.
+ ///
+ /// 판단은 명령줄로 들어오는 길과 같은 것을 쓴다 - 폴더면 폴더 비교, 파일이면 파일 비교로
+ /// 탭까지 옮긴다. 폴더 화면에 파일을 놓았다고 아무 일도 안 하는 것은 불친절하다.
+ ///
+ 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;
+ }
+
+ /// 왼쪽·오른쪽 칸에 바로 놓으면 그 자리에만 들어간다.
+ 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;
}
-}
+}
\ No newline at end of file
diff --git a/src/MergeHub/Views/HomeView.xaml b/src/MergeHub/Views/HomeView.xaml
index 20df5be..babef09 100644
--- a/src/MergeHub/Views/HomeView.xaml
+++ b/src/MergeHub/Views/HomeView.xaml
@@ -7,7 +7,8 @@
xmlns:ctl="clr-namespace:MergeHub.Views.Controls"
xmlns:system="clr-namespace:System;assembly=mscorlib"
mc:Ignorable="d"
- PreviewKeyDown="View_PreviewKeyDown">
+ PreviewKeyDown="View_PreviewKeyDown"
+ AllowDrop="True" DragOver="View_DragOver" Drop="View_Drop">
@@ -384,13 +385,15 @@
ToolTip="왼쪽(BASE) 에 놓을 파일"/>
+ ToolTip="{Binding LeftFile}"
+ AllowDrop="True" DragOver="View_DragOver" Drop="LeftFile_Drop"/>
+ ToolTip="{Binding RightFile}"
+ AllowDrop="True" DragOver="View_DragOver" Drop="RightFile_Drop"/>