D-7, D-3: 끌어다 놓기 · 폴더 안 전체 검색
D-7 은 코드만 끝났다. 화면이 잠겨 탐색기에서 끄는 확인을 못 했다. D-3 은 UI 자동화로 검색·결과 열기·취소까지 확인했다.
This commit is contained in:
parent
ee5d34c2a6
commit
373990b3b6
61
doc/진행상황.md
61
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)
|
||||
|
||||
**왜 필요했나** - 폴더 비교는 "무엇이 다른가"에 답하지만 <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)
|
||||
|
||||
**왜 필요했나** - 결과를 남에게 보내거나 결재에 붙일 때 <b>화면을 캡처하는 것 말고는 방법이 없었다.</b>
|
||||
|
||||
451
src/MergeHub/Services/FolderSearcher.cs
Normal file
451
src/MergeHub/Services/FolderSearcher.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -374,12 +374,37 @@ namespace MergeHub.Services
|
||||
/// 이벤트를 받을 사람이 없다. 그냥 발생시키면 요청이 허공에 사라지고,
|
||||
/// 새로 만들어진 화면은 기본 파일을 연다 - "더블클릭이 안 먹는다"로 보이는 이유다.
|
||||
/// </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);
|
||||
RequestedLine = line;
|
||||
RequestedLineIsLeft = lineIsLeft;
|
||||
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>
|
||||
public void DeliverPendingPlainCompare()
|
||||
{
|
||||
@ -441,6 +466,34 @@ namespace MergeHub.Services
|
||||
: $"파일 비교 요청: {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)
|
||||
{
|
||||
var app = System.Windows.Application.Current;
|
||||
|
||||
@ -112,6 +112,9 @@ namespace MergeHub.ViewModels
|
||||
{
|
||||
private readonly GitWorkspace _workspace;
|
||||
|
||||
/// <summary>화면 쪽에서 끌어다 놓기를 넘길 때 쓴다.</summary>
|
||||
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;
|
||||
|
||||
// ================= 폴더 전체 검색 =================
|
||||
|
||||
/// <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;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanCancelCompare))]
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <summary>Git 없이 쓸 때는 커밋 없이 오른쪽 파일에 그대로 쓴다.</summary>
|
||||
|
||||
@ -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">
|
||||
|
||||
<UserControl.Resources>
|
||||
<ResourceDictionary>
|
||||
@ -82,6 +83,7 @@
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
@ -100,12 +102,14 @@
|
||||
<Button Grid.Column="0" Content="왼쪽 폴더..." Command="{Binding PickLeftFolderCommand}"
|
||||
Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0"/>
|
||||
<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"
|
||||
Margin="0,0,14,0" ToolTip="비교 기준이 되는 폴더"/>
|
||||
|
||||
<Button Grid.Column="2" Content="오른쪽 폴더..." Command="{Binding PickRightFolderCommand}"
|
||||
Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0"/>
|
||||
<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"
|
||||
ToolTip="견줄 폴더"/>
|
||||
</Grid>
|
||||
@ -160,11 +164,60 @@
|
||||
</WrapPanel>
|
||||
</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}">
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<TextBlock Text="색 구분" Style="{StaticResource Muted}" FontSize="11" Margin="0,0,10,0"
|
||||
@ -203,7 +256,7 @@
|
||||
</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}"
|
||||
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
|
||||
<Grid Margin="26,0,0,0">
|
||||
@ -226,7 +279,13 @@
|
||||
채움 칸(*)이 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}"
|
||||
ItemsSource="{Binding Nodes}"
|
||||
ItemTemplate="{StaticResource NodeTemplate}"
|
||||
@ -239,8 +298,48 @@
|
||||
VirtualizingPanel.IsVirtualizing="True"
|
||||
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}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
private static FolderNode? NodeUnder(DependencyObject? source)
|
||||
{
|
||||
@ -65,5 +108,72 @@ namespace MergeHub.Views
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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">
|
||||
<UserControl.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
@ -384,13 +385,15 @@
|
||||
ToolTip="왼쪽(BASE) 에 놓을 파일"/>
|
||||
<TextBlock Text="{Binding LeftFile}" Style="{StaticResource Muted}" FontSize="11"
|
||||
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}"
|
||||
Height="26" MinHeight="26" Padding="8,2" FontSize="11"
|
||||
ToolTip="오른쪽(LOCAL) 에 놓을 파일. 저장하면 이 파일에 씁니다"/>
|
||||
<TextBlock Text="{Binding RightFile}" Style="{StaticResource Muted}" FontSize="11"
|
||||
MaxWidth="260" TextTrimming="CharacterEllipsis" Margin="4,0,0,0"
|
||||
ToolTip="{Binding RightFile}"/>
|
||||
ToolTip="{Binding RightFile}"
|
||||
AllowDrop="True" DragOver="View_DragOver" Drop="RightFile_Drop"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Git 일 때: 비교할 파일과 3-Way 상대 브랜치를 여기서 고른다.
|
||||
|
||||
@ -915,6 +915,65 @@ namespace MergeHub.Views
|
||||
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) =>
|
||||
ShortcutHelpWindow.ShowOrActivate(Window.GetWindow(this));
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user