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 @@