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-1 주석만 다른 파일을 다른 색으로 |
|
||||||
| **완료** | D-2 C++ 문법 강조 (이미 되고 있었음 · 전처리 지시문만 추가) |
|
| **완료** | D-2 C++ 문법 강조 (이미 되고 있었음 · 전처리 지시문만 추가) |
|
||||||
| 대기 | D-3 폴더 안 전체 검색 |
|
| **완료** | D-3 폴더 안 전체 검색 |
|
||||||
| ~~취소~~ | ~~D-4 비교 창 여러 개~~ (2026-09-09 사용자 판단으로 뺌) |
|
| ~~취소~~ | ~~D-4 비교 창 여러 개~~ (2026-09-09 사용자 판단으로 뺌) |
|
||||||
| **완료** | D-5 덩어리 단위 반영 (3-Way 에서 BASE 를 가져오던 것을 REMOTE 로) |
|
| **완료** | D-5 덩어리 단위 반영 (3-Way 에서 BASE 를 가져오던 것을 REMOTE 로) |
|
||||||
| **완료** | D-6 비교 결과 텍스트 내보내기 |
|
| **완료** | D-6 비교 결과 텍스트 내보내기 |
|
||||||
| 대기 | D-7 드래그 앤 드롭으로 열기 |
|
| **완료**(화면 확인 남음) | D-7 드래그 앤 드롭으로 열기 |
|
||||||
|
|
||||||
### E. 추가 요청 (2026-09-09 오후)
|
### E. 추가 요청 (2026-09-09 오후)
|
||||||
|
|
||||||
@ -160,6 +160,63 @@ BASE 머리글에 그렇게 적었다 - `BASE (merge base) · 이 파일 없음
|
|||||||
조상에 파일이 있는 경우는 지금까지처럼 파일 이름이 그대로 나온다.
|
조상에 파일이 있는 경우는 지금까지처럼 파일 이름이 그대로 나온다.
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### D-3. 폴더 안 전체 검색 — **완료** (2026-09-09)
|
||||||
|
|
||||||
|
**왜 필요했나** - 폴더 비교는 "무엇이 다른가"에 답하지만 <b>"이 함수를 쓰는 곳이 어디인가"</b>에는
|
||||||
|
답하지 못한다. 그때마다 탐색기 검색이나 다른 편집기를 켜야 했다.
|
||||||
|
|
||||||
|
`Services/FolderSearcher.cs` 신설. 화면 없이 검사할 수 있게 <b>찾는 일만</b> 여기서 한다.
|
||||||
|
|
||||||
|
**양쪽을 다 훑되 같은 줄은 한 번만 보여 준다.** 안 그러면 손대지 않은 파일이 전부 두 번씩 나와
|
||||||
|
목록의 절반이 쓸모없어진다. 그래서 결과 한 줄에 "왼쪽 / 오른쪽 / 양쪽"이 붙는다 -
|
||||||
|
`양쪽` 은 두 폴더에 똑같이 있는 줄, `왼쪽`·`오른쪽` 은 한쪽에만 있는 줄이다.
|
||||||
|
이것만 봐도 "내가 고친 줄인지"를 안다.
|
||||||
|
|
||||||
|
내용이 똑같은 파일은 오른쪽을 <b>다시 읽지 않는다</b>. 대신 왼쪽에서 찾아 둔 줄을 `양쪽` 으로 바꾼다.
|
||||||
|
|
||||||
|
**결과를 누르면 그 줄에 서서 열린다.** 파일만 열리고 맨 위에 서 있으면 찾은 자리를 눈으로
|
||||||
|
다시 찾아야 해서 검색을 한 뜻이 없어진다. `GitWorkspace.RequestPlainCompare` 에 줄 번호를
|
||||||
|
얹고(`RequestedLine`), 파일 비교 화면이 파일을 다 읽은 뒤 그 줄로 옮긴다.
|
||||||
|
오른쪽에서만 나온 줄은 오른쪽 파일 기준 번호로 찾는다 - 한쪽에만 있는 줄이 끼면
|
||||||
|
두 번호가 어긋나 엉뚱한 줄에 선다.
|
||||||
|
|
||||||
|
**취소하면 여기까지 찾은 것을 그대로 남긴다.** 처음에는 예외를 던져 목록이 비었는데,
|
||||||
|
큰 폴더에서 원하는 줄이 이미 나왔는데 멈췄다고 다 지우면 처음부터 다시 해야 한다.
|
||||||
|
|
||||||
|
- 대소문자 구분 · 확장자 필터(`*.cs;*.h` · `.cs` · `cs` 를 모두 같게 본다) · 하위 폴더 · 제외 규칙
|
||||||
|
- 이진 파일과 8MB 넘는 파일은 건너뛴다. 훑은 파일 수를 계속 알려 준다
|
||||||
|
- 한 줄에 여러 번 나와도 한 줄로 센다
|
||||||
|
|
||||||
|
**실제로 확인한 것** (화면이 잠겨 있어 UI 자동화로 조작하고 값을 읽었다)
|
||||||
|
|
||||||
|
| 확인 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 양쪽 같은 파일 | `양쪽 · Common.cpp:2` 한 줄만 |
|
||||||
|
| 한 파일 안에서 다른 줄 | `왼쪽 · :5 int Speed = 100;` / `오른쪽 · :5 int Speed = 250;` |
|
||||||
|
| 같은 파일 안 안 바뀐 줄 | `양쪽 · :6 Move(Speed);` |
|
||||||
|
| 한쪽에만 있는 파일 | `왼쪽 · sub\Only.cpp` / `오른쪽 · notes.txt` |
|
||||||
|
| 결과를 열었을 때 | 두 파일이 열리고 <b>캐럿이 5번째 줄</b>에 섰다 |
|
||||||
|
| 663개 파일을 훑는 중 | 진행 글자가 계속 바뀌고 [검색 취소]가 눌렸다 |
|
||||||
|
| 취소한 뒤 | `517줄 · 파일 63개 · 도중에 멈춤` — 찾은 것이 그대로 남았다 |
|
||||||
|
|
||||||
|
검사 63건 추가(`SearchTest`). 전체 512건 통과.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### D-7. 드래그 앤 드롭으로 열기 — **코드 완료 · 화면 확인 남음** (2026-09-09)
|
||||||
|
|
||||||
|
탐색기에서 파일·폴더를 끌어다 놓으면 그 자리에서 비교가 열린다.
|
||||||
|
|
||||||
|
`GitWorkspace.RouteDroppedPaths` 가 <b>명령줄과 같은 판단</b>을 쓴다(`StartupArgs.Parse` 를 그대로).
|
||||||
|
끌어다 놓는 것과 우클릭·명령줄이 다르게 굴면 그것부터 버그다 - 폴더면 폴더 비교, 파일이면 파일 비교로
|
||||||
|
가고 해당 탭이 켜진다. 화면 아무 데나 놓으면 이 판단을 타고, 왼쪽·오른쪽 경로 칸에 바로 놓으면
|
||||||
|
그 칸만 채운다.
|
||||||
|
|
||||||
|
<b>화면에서 실제로 끌어다 놓는 확인은 아직 못 했다.</b> 작업 중 컴퓨터가 잠겨 탐색기에서
|
||||||
|
끄는 동작을 만들 수 없었다. 잠금이 풀리면 확인한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### D-6. 비교 결과 텍스트 내보내기 — **완료** (2026-09-09)
|
### D-6. 비교 결과 텍스트 내보내기 — **완료** (2026-09-09)
|
||||||
|
|
||||||
**왜 필요했나** - 결과를 남에게 보내거나 결재에 붙일 때 <b>화면을 캡처하는 것 말고는 방법이 없었다.</b>
|
**왜 필요했나** - 결과를 남에게 보내거나 결재에 붙일 때 <b>화면을 캡처하는 것 말고는 방법이 없었다.</b>
|
||||||
|
|||||||
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>
|
/// </summary>
|
||||||
public void RequestPlainCompare(string leftFile, string rightFile)
|
/// <param name="line">
|
||||||
|
/// 열자마자 짚어 줄 줄 번호. 0 이면 여느 때처럼 첫 차이로 간다.
|
||||||
|
/// 폴더 전체 검색에서 결과를 눌렀을 때, 파일만 열리고 맨 위에 서 있으면
|
||||||
|
/// 찾은 줄을 눈으로 다시 찾아야 한다.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="lineIsLeft">그 번호가 왼쪽 파일 기준인지. 아니면 오른쪽 기준이다.</param>
|
||||||
|
public void RequestPlainCompare(string leftFile, string rightFile, int line = 0, bool lineIsLeft = true)
|
||||||
{
|
{
|
||||||
_pendingPlainCompare = (leftFile, rightFile);
|
_pendingPlainCompare = (leftFile, rightFile);
|
||||||
|
RequestedLine = line;
|
||||||
|
RequestedLineIsLeft = lineIsLeft;
|
||||||
OnUi(DeliverPendingPlainCompare);
|
OnUi(DeliverPendingPlainCompare);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>넘긴 파일에서 짚어 줄 자리. 없으면 0.</summary>
|
||||||
|
public int RequestedLine { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>그 번호가 왼쪽 파일 기준인지.</summary>
|
||||||
|
public bool RequestedLineIsLeft { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 짚어 줄 자리를 한 번 쓰고 지운다.
|
||||||
|
/// 지우지 않으면 그 다음에 연 파일까지 같은 줄로 끌려간다.
|
||||||
|
/// </summary>
|
||||||
|
public (int Line, bool IsLeft) TakeRequestedLine()
|
||||||
|
{
|
||||||
|
var one = (RequestedLine, RequestedLineIsLeft);
|
||||||
|
RequestedLine = 0;
|
||||||
|
return one;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>남아 있는 요청을 넘긴다. 화면이 새로 만들어지면 스스로 한 번 불러 준다.</summary>
|
/// <summary>남아 있는 요청을 넘긴다. 화면이 새로 만들어지면 스스로 한 번 불러 준다.</summary>
|
||||||
public void DeliverPendingPlainCompare()
|
public void DeliverPendingPlainCompare()
|
||||||
{
|
{
|
||||||
@ -441,6 +466,34 @@ namespace MergeHub.Services
|
|||||||
: $"파일 비교 요청: {left} ↔ {right}");
|
: $"파일 비교 요청: {left} ↔ {right}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 탐색기에서 끌어다 놓은 것을 화면으로 보낸다.
|
||||||
|
///
|
||||||
|
/// 명령줄로 들어오는 길과 <b>같은 판단</b>을 쓴다 - 폴더면 폴더 비교, 파일이면 파일 비교.
|
||||||
|
/// 두 길이 다르게 굴면 "우클릭으로 열 때랑 끌어다 놓을 때가 다르다"가 되어 설명할 수 없다.
|
||||||
|
/// </summary>
|
||||||
|
public void RouteDroppedPaths(IReadOnlyList<string> paths)
|
||||||
|
{
|
||||||
|
if (paths is null || paths.Count == 0) return;
|
||||||
|
|
||||||
|
var args = StartupArgs.Parse(paths);
|
||||||
|
|
||||||
|
if (args.WantsFolder)
|
||||||
|
{
|
||||||
|
RequestFolderCompare(args.LeftFolder, args.RightFolder);
|
||||||
|
ActivateWhenReady(MergeHub.ViewIds.Folder);
|
||||||
|
AppendLog($"끌어다 놓기 - 폴더 비교: {args.LeftFolder}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.WantsFiles) return;
|
||||||
|
|
||||||
|
var (left, right) = args.DiffPair;
|
||||||
|
RequestPlainCompare(left, right);
|
||||||
|
ActivateWhenReady(MergeHub.ViewIds.Home);
|
||||||
|
AppendLog($"끌어다 놓기 - 파일 비교: {left} ↔ {right}");
|
||||||
|
}
|
||||||
|
|
||||||
private static void ActivateWhenReady(string viewId)
|
private static void ActivateWhenReady(string viewId)
|
||||||
{
|
{
|
||||||
var app = System.Windows.Application.Current;
|
var app = System.Windows.Application.Current;
|
||||||
|
|||||||
@ -112,6 +112,9 @@ namespace MergeHub.ViewModels
|
|||||||
{
|
{
|
||||||
private readonly GitWorkspace _workspace;
|
private readonly GitWorkspace _workspace;
|
||||||
|
|
||||||
|
/// <summary>화면 쪽에서 끌어다 놓기를 넘길 때 쓴다.</summary>
|
||||||
|
public GitWorkspace Workspace => _workspace;
|
||||||
|
|
||||||
public FolderViewModel(GitWorkspace workspace)
|
public FolderViewModel(GitWorkspace workspace)
|
||||||
{
|
{
|
||||||
_workspace = workspace;
|
_workspace = workspace;
|
||||||
@ -209,6 +212,9 @@ namespace MergeHub.ViewModels
|
|||||||
DeleteLeftCommand.NotifyCanExecuteChanged();
|
DeleteLeftCommand.NotifyCanExecuteChanged();
|
||||||
DeleteRightCommand.NotifyCanExecuteChanged();
|
DeleteRightCommand.NotifyCanExecuteChanged();
|
||||||
ExportReportCommand.NotifyCanExecuteChanged();
|
ExportReportCommand.NotifyCanExecuteChanged();
|
||||||
|
SearchCommand.NotifyCanExecuteChanged();
|
||||||
|
CancelSearchCommand.NotifyCanExecuteChanged();
|
||||||
|
OpenHitCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@ -337,6 +343,158 @@ namespace MergeHub.ViewModels
|
|||||||
|
|
||||||
private CancellationTokenSource? _compareCancel;
|
private CancellationTokenSource? _compareCancel;
|
||||||
|
|
||||||
|
// ================= 폴더 전체 검색 =================
|
||||||
|
|
||||||
|
/// <summary>찾은 줄. 파일 차례로 늘어선다.</summary>
|
||||||
|
public ObservableCollection<FolderSearchHit> SearchHits { get; } = new ObservableCollection<FolderSearchHit>();
|
||||||
|
|
||||||
|
[ObservableProperty] private string _searchQuery = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _searchMatchCase;
|
||||||
|
|
||||||
|
/// <summary>확장자 필터. <c>*.cs;*.h</c> 처럼 적는다. 비우면 전부 본다.</summary>
|
||||||
|
[ObservableProperty] private string _searchExtensions = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>오른쪽 폴더도 훑을지.</summary>
|
||||||
|
[ObservableProperty] private bool _searchBothSides = true;
|
||||||
|
|
||||||
|
[ObservableProperty] private string _searchSummary = "찾을 말을 넣고 [찾기]를 누르세요.";
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _isSearching;
|
||||||
|
|
||||||
|
/// <summary>결과 칸을 띄울지. 검색하기 전에는 트리에 자리를 다 내준다.</summary>
|
||||||
|
[ObservableProperty] private bool _hasSearched;
|
||||||
|
|
||||||
|
[ObservableProperty] private FolderSearchHit? _selectedHit;
|
||||||
|
|
||||||
|
partial void OnSearchQueryChanged(string value) => SearchCommand.NotifyCanExecuteChanged();
|
||||||
|
|
||||||
|
partial void OnSelectedHitChanged(FolderSearchHit? value) => OpenHitCommand.NotifyCanExecuteChanged();
|
||||||
|
|
||||||
|
private CancellationTokenSource? _searchCancel;
|
||||||
|
|
||||||
|
private bool CanSearch() => !IsSearching && !string.IsNullOrEmpty(SearchQuery);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 비교 중인 두 폴더 전체에서 글자를 찾는다.
|
||||||
|
///
|
||||||
|
/// 훑는 일은 통째로 다른 스레드로 넘긴다 - 파일 수천 개를 읽는 동안 화면이 굳으면
|
||||||
|
/// 취소 단추조차 누를 수 없다. 결과는 다 모은 뒤 한 번에 붙인다.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand(CanExecute = nameof(CanSearch))]
|
||||||
|
private async Task SearchAsync()
|
||||||
|
{
|
||||||
|
var left = PathUtil.FullFolderPath(LeftFolder);
|
||||||
|
var right = PathUtil.FullFolderPath(RightFolder);
|
||||||
|
|
||||||
|
if (left.Length == 0 && right.Length == 0)
|
||||||
|
{
|
||||||
|
SearchSummary = "먼저 폴더를 고르세요.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = new FolderSearchOptions
|
||||||
|
{
|
||||||
|
Query = SearchQuery,
|
||||||
|
MatchCase = SearchMatchCase,
|
||||||
|
Extensions = SearchExtensions,
|
||||||
|
IncludeSubfolders = IncludeSubfolders,
|
||||||
|
ExcludePattern = ExcludePattern,
|
||||||
|
SearchRight = SearchBothSides,
|
||||||
|
};
|
||||||
|
|
||||||
|
_searchCancel?.Dispose();
|
||||||
|
_searchCancel = new CancellationTokenSource();
|
||||||
|
var token = _searchCancel.Token;
|
||||||
|
|
||||||
|
SearchHits.Clear();
|
||||||
|
SelectedHit = null;
|
||||||
|
|
||||||
|
IsSearching = true;
|
||||||
|
HasSearched = true;
|
||||||
|
RaiseCanExecuteChanged();
|
||||||
|
SearchSummary = "찾는 중... (멈추려면 [검색 취소])";
|
||||||
|
|
||||||
|
// 몇 개째 훑는 중인지 알려 준다. 아무 글자도 안 바뀌면 멈춘 것처럼 보인다.
|
||||||
|
var progress = new Progress<FolderSearchProgress>(
|
||||||
|
p => SearchSummary = $"찾는 중... 파일 {p.FilesScanned}개 · {p.Hits}줄 · {p.CurrentFile}");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await Task.Run(
|
||||||
|
() => FolderSearcher.Search(left, right, options, token, progress), token);
|
||||||
|
|
||||||
|
foreach (var hit in result.Hits) SearchHits.Add(hit);
|
||||||
|
|
||||||
|
SearchSummary = result.Hits.Count == 0
|
||||||
|
? (result.Cancelled
|
||||||
|
? $"검색을 멈췄습니다. (훑은 파일 {result.FilesScanned}개, 찾은 줄 없음)"
|
||||||
|
: $"\"{SearchQuery}\" 를 찾지 못했습니다. (훑은 파일 {result.FilesScanned}개)")
|
||||||
|
: result.Summary;
|
||||||
|
|
||||||
|
_workspace.AppendLog($"폴더 검색: \"{SearchQuery}\" · {result.Summary}");
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
SearchSummary = $"검색을 멈췄습니다. (여기까지 {SearchHits.Count}줄)";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
SearchSummary = $"검색 실패: {ex.Message}";
|
||||||
|
MessageUtil.ShowErrorToast($"폴더 검색 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsSearching = false;
|
||||||
|
RaiseCanExecuteChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanCancelSearch() => IsSearching;
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanCancelSearch))]
|
||||||
|
private void CancelSearch() => _searchCancel?.Cancel();
|
||||||
|
|
||||||
|
/// <summary>결과 칸을 접고 트리에 자리를 돌려준다.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void ClearSearch()
|
||||||
|
{
|
||||||
|
SearchHits.Clear();
|
||||||
|
SelectedHit = null;
|
||||||
|
HasSearched = false;
|
||||||
|
SearchSummary = "찾을 말을 넣고 [찾기]를 누르세요.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanOpenHit() => SelectedHit is not null;
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanOpenHit))]
|
||||||
|
private void OpenHit() => OpenHitCore(SelectedHit);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 찾은 줄을 파일 비교 화면에서 연다.
|
||||||
|
///
|
||||||
|
/// <b>그 줄에 서서</b> 열린다 - 파일만 열리고 맨 위에 서 있으면 찾은 자리를
|
||||||
|
/// 눈으로 다시 찾아야 해서 검색을 한 뜻이 없어진다.
|
||||||
|
/// </summary>
|
||||||
|
public void OpenHitCore(FolderSearchHit? hit)
|
||||||
|
{
|
||||||
|
if (hit is null) return;
|
||||||
|
|
||||||
|
if (!File.Exists(hit.LeftPath) && !File.Exists(hit.RightPath))
|
||||||
|
{
|
||||||
|
MessageUtil.ShowWarningToast("파일을 찾을 수 없습니다. 다시 검색해 보세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
OpenInDiffRequested?.Invoke(this, (hit.LeftPath, hit.RightPath));
|
||||||
|
|
||||||
|
// 오른쪽에서만 나온 줄이면 오른쪽 파일 기준 번호다.
|
||||||
|
_workspace.RequestPlainCompare(hit.LeftPath, hit.RightPath, hit.LineNumber,
|
||||||
|
lineIsLeft: hit.Side != SearchSide.Right);
|
||||||
|
UbisamBase.Core.Modules.TabManager.Activate(ViewIds.Home);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private bool CanCancelCompare() => IsBusy;
|
private bool CanCancelCompare() => IsBusy;
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanCancelCompare))]
|
[RelayCommand(CanExecute = nameof(CanCancelCompare))]
|
||||||
|
|||||||
@ -1602,14 +1602,42 @@ namespace MergeHub.ViewModels
|
|||||||
_redo.Clear();
|
_redo.Clear();
|
||||||
CaretRow = 0;
|
CaretRow = 0;
|
||||||
InvalidateDiffBlocks();
|
InvalidateDiffBlocks();
|
||||||
SelectedLine = Lines.FirstOrDefault(l => l.IsDifferent) ?? Lines.FirstOrDefault();
|
|
||||||
|
// 폴더 검색에서 넘어왔으면 그 줄로, 아니면 여느 때처럼 첫 차이로 간다.
|
||||||
|
var asked = FindRequestedRow();
|
||||||
|
SelectedLine = asked >= 0
|
||||||
|
? Lines[asked]
|
||||||
|
: Lines.FirstOrDefault(l => l.IsDifferent) ?? Lines.FirstOrDefault();
|
||||||
|
|
||||||
UpdateSummaries();
|
UpdateSummaries();
|
||||||
RefreshDiffPosition();
|
RefreshDiffPosition();
|
||||||
RaiseCanExecuteChanged();
|
RaiseCanExecuteChanged();
|
||||||
|
|
||||||
|
// 줄을 고르는 것만으로는 화면이 따라오지 않는다. 캐럿까지 옮겨야 굴러간다.
|
||||||
|
if (asked >= 0) GoToRow(asked);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 폴더 전체 검색이 짚어 준 줄이 몇 번째 행인지. 없거나 못 찾으면 -1.
|
||||||
|
///
|
||||||
|
/// 왼쪽 기준 번호는 BASE 칸, 오른쪽 기준 번호는 LOCAL 칸에서 찾는다 -
|
||||||
|
/// 한쪽에만 있는 줄이 끼면 두 번호가 어긋나므로 아무 쪽에서나 찾으면 엉뚱한 줄에 선다.
|
||||||
|
/// </summary>
|
||||||
|
private int FindRequestedRow()
|
||||||
|
{
|
||||||
|
var (line, isLeft) = _workspace.TakeRequestedLine();
|
||||||
|
if (line <= 0) return -1;
|
||||||
|
|
||||||
|
for (var i = 0; i < Lines.Count; i++)
|
||||||
|
{
|
||||||
|
var number = isLeft ? Lines[i].BaseNumber : Lines[i].LocalNumber;
|
||||||
|
if (number == line) return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
private bool CanSavePlain() => IsPlainMode && HasChanges && !string.IsNullOrWhiteSpace(RightFile);
|
private bool CanSavePlain() => IsPlainMode && HasChanges && !string.IsNullOrWhiteSpace(RightFile);
|
||||||
|
|
||||||
/// <summary>Git 없이 쓸 때는 커밋 없이 오른쪽 파일에 그대로 쓴다.</summary>
|
/// <summary>Git 없이 쓸 때는 커밋 없이 오른쪽 파일에 그대로 쓴다.</summary>
|
||||||
|
|||||||
@ -3,7 +3,8 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="clr-namespace:MergeHub.ViewModels"
|
xmlns:vm="clr-namespace:MergeHub.ViewModels"
|
||||||
xmlns:svc="clr-namespace:MergeHub.Services"
|
xmlns:svc="clr-namespace:MergeHub.Services"
|
||||||
xmlns:ctl="clr-namespace:MergeHub.Views.Controls">
|
xmlns:ctl="clr-namespace:MergeHub.Views.Controls"
|
||||||
|
AllowDrop="True" DragOver="View_DragOver" Drop="View_Drop">
|
||||||
|
|
||||||
<UserControl.Resources>
|
<UserControl.Resources>
|
||||||
<ResourceDictionary>
|
<ResourceDictionary>
|
||||||
@ -82,6 +83,7 @@
|
|||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="*"/>
|
<RowDefinition Height="*"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
@ -100,12 +102,14 @@
|
|||||||
<Button Grid.Column="0" Content="왼쪽 폴더..." Command="{Binding PickLeftFolderCommand}"
|
<Button Grid.Column="0" Content="왼쪽 폴더..." Command="{Binding PickLeftFolderCommand}"
|
||||||
Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0"/>
|
Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0"/>
|
||||||
<TextBox Grid.Column="1" Text="{Binding LeftFolder, UpdateSourceTrigger=PropertyChanged}"
|
<TextBox Grid.Column="1" Text="{Binding LeftFolder, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
AllowDrop="True" DragOver="View_DragOver" Drop="LeftFolder_Drop"
|
||||||
FontFamily="Consolas" FontSize="11" Height="28" VerticalContentAlignment="Center"
|
FontFamily="Consolas" FontSize="11" Height="28" VerticalContentAlignment="Center"
|
||||||
Margin="0,0,14,0" ToolTip="비교 기준이 되는 폴더"/>
|
Margin="0,0,14,0" ToolTip="비교 기준이 되는 폴더"/>
|
||||||
|
|
||||||
<Button Grid.Column="2" Content="오른쪽 폴더..." Command="{Binding PickRightFolderCommand}"
|
<Button Grid.Column="2" Content="오른쪽 폴더..." Command="{Binding PickRightFolderCommand}"
|
||||||
Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0"/>
|
Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0"/>
|
||||||
<TextBox Grid.Column="3" Text="{Binding RightFolder, UpdateSourceTrigger=PropertyChanged}"
|
<TextBox Grid.Column="3" Text="{Binding RightFolder, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
AllowDrop="True" DragOver="View_DragOver" Drop="RightFolder_Drop"
|
||||||
FontFamily="Consolas" FontSize="11" Height="28" VerticalContentAlignment="Center"
|
FontFamily="Consolas" FontSize="11" Height="28" VerticalContentAlignment="Center"
|
||||||
ToolTip="견줄 폴더"/>
|
ToolTip="견줄 폴더"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
@ -160,11 +164,60 @@
|
|||||||
</WrapPanel>
|
</WrapPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
③ 폴더 전체 검색.
|
||||||
|
|
||||||
|
폴더 비교는 "무엇이 다른가"에 답하지만 "이 함수를 쓰는 곳이 어디인가"에는 답하지 못한다.
|
||||||
|
그때마다 다른 도구를 켜는 대신 여기서 찾고 결과를 눌러 바로 그 줄로 넘어간다.
|
||||||
|
-->
|
||||||
|
<Border Grid.Row="2" Padding="8" BorderThickness="1" Margin="0,0,0,6"
|
||||||
|
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
|
||||||
|
<WrapPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Text="폴더 안 찾기" VerticalAlignment="Center" FontSize="11" FontWeight="Bold"
|
||||||
|
Margin="0,0,8,0"/>
|
||||||
|
<TextBox x:Name="SearchBox"
|
||||||
|
Text="{Binding SearchQuery, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
KeyDown="SearchBox_KeyDown"
|
||||||
|
Width="220" Height="26" FontFamily="Consolas" FontSize="11"
|
||||||
|
VerticalContentAlignment="Center" Margin="0,0,12,0"
|
||||||
|
ToolTip="찾을 글자. Enter 를 눌러도 찾습니다"/>
|
||||||
|
|
||||||
|
<CheckBox Content="대소문자 구분" IsChecked="{Binding SearchMatchCase}"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,12,0"/>
|
||||||
|
|
||||||
|
<TextBlock Text="확장자" VerticalAlignment="Center" Style="{StaticResource Muted}"
|
||||||
|
FontSize="11" Margin="0,0,4,0"/>
|
||||||
|
<TextBox Text="{Binding SearchExtensions, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Width="140" Height="26" FontFamily="Consolas" FontSize="11"
|
||||||
|
VerticalContentAlignment="Center" Margin="0,0,12,0"
|
||||||
|
ToolTip="*.cs;*.h 처럼 적습니다. 비우면 전부 봅니다"/>
|
||||||
|
|
||||||
|
<CheckBox Content="오른쪽 폴더도" IsChecked="{Binding SearchBothSides}"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,12,0"
|
||||||
|
ToolTip="양쪽에 똑같이 있는 줄은 한 번만 나오고 '양쪽'으로 적힙니다"/>
|
||||||
|
|
||||||
|
<Button Content="찾기" Command="{Binding SearchCommand}" Height="28" MinWidth="70"
|
||||||
|
Margin="0,0,6,0" FontWeight="Bold"/>
|
||||||
|
<Button Content="검색 취소" Command="{Binding CancelSearchCommand}" Height="28" MinWidth="70"
|
||||||
|
Margin="0,0,6,0"
|
||||||
|
ToolTip="검색을 멈춥니다. 여기까지 찾은 것은 그대로 남습니다"/>
|
||||||
|
<Button Content="찾은 줄 열기" Command="{Binding OpenHitCommand}" Height="28" MinWidth="90"
|
||||||
|
Margin="0,0,6,0"
|
||||||
|
ToolTip="고른 줄을 '파일 비교' 탭에서 그 자리에 서서 엽니다. 더블클릭·Enter 도 같습니다"/>
|
||||||
|
<Button Content="결과 닫기" Command="{Binding ClearSearchCommand}" Height="28" MinWidth="70"
|
||||||
|
Margin="0,0,12,0"
|
||||||
|
ToolTip="결과 칸을 접고 목록에 자리를 돌려줍니다"/>
|
||||||
|
|
||||||
|
<TextBlock Text="{Binding SearchSummary}" VerticalAlignment="Center" FontSize="11"
|
||||||
|
Style="{StaticResource Muted}" TextTrimming="CharacterEllipsis" MaxWidth="520"/>
|
||||||
|
</WrapPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
④ 색 뜻풀이. 색만 칠해 두면 무슨 뜻인지 아무도 모른다 -
|
④ 색 뜻풀이. 색만 칠해 두면 무슨 뜻인지 아무도 모른다 -
|
||||||
파일 비교 화면의 "색 구분" 줄과 같은 성격이다.
|
파일 비교 화면의 "색 구분" 줄과 같은 성격이다.
|
||||||
-->
|
-->
|
||||||
<Border Grid.Row="2" Padding="8,4" BorderThickness="1,1,1,0" Margin="0,0,0,0"
|
<Border Grid.Row="3" Padding="8,4" BorderThickness="1,1,1,0" Margin="0,0,0,0"
|
||||||
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
|
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
|
||||||
<WrapPanel Orientation="Horizontal">
|
<WrapPanel Orientation="Horizontal">
|
||||||
<TextBlock Text="색 구분" Style="{StaticResource Muted}" FontSize="11" Margin="0,0,10,0"
|
<TextBlock Text="색 구분" Style="{StaticResource Muted}" FontSize="11" Margin="0,0,10,0"
|
||||||
@ -203,7 +256,7 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- ③ 목록 머리글. 트리 항목의 칸 폭과 같은 값을 쓴다. -->
|
<!-- ③ 목록 머리글. 트리 항목의 칸 폭과 같은 값을 쓴다. -->
|
||||||
<Border Grid.Row="3" Padding="8,4" BorderThickness="1,1,1,0"
|
<Border Grid.Row="4" Padding="8,4" BorderThickness="1,1,1,0"
|
||||||
Background="{DynamicResource Ubisam.Brush.Sidebar.Background}"
|
Background="{DynamicResource Ubisam.Brush.Sidebar.Background}"
|
||||||
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
|
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
|
||||||
<Grid Margin="26,0,0,0">
|
<Grid Margin="26,0,0,0">
|
||||||
@ -226,7 +279,13 @@
|
|||||||
채움 칸(*)이 0 이 되고 줄 배경이 글자 끝에서 끊긴다. 칸 폭이 고정이라
|
채움 칸(*)이 0 이 되고 줄 배경이 글자 끝에서 끊긴다. 칸 폭이 고정이라
|
||||||
가로로 넘칠 일이 없으므로 꺼 두는 편이 맞다.
|
가로로 넘칠 일이 없으므로 꺼 두는 편이 맞다.
|
||||||
-->
|
-->
|
||||||
<TreeView Grid.Row="4" x:Name="Tree" BorderThickness="1"
|
<Grid Grid.Row="5">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<TreeView Grid.Row="0" x:Name="Tree" BorderThickness="1"
|
||||||
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
|
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
|
||||||
ItemsSource="{Binding Nodes}"
|
ItemsSource="{Binding Nodes}"
|
||||||
ItemTemplate="{StaticResource NodeTemplate}"
|
ItemTemplate="{StaticResource NodeTemplate}"
|
||||||
@ -239,8 +298,48 @@
|
|||||||
VirtualizingPanel.IsVirtualizing="True"
|
VirtualizingPanel.IsVirtualizing="True"
|
||||||
VirtualizingPanel.VirtualizationMode="Recycling"/>
|
VirtualizingPanel.VirtualizationMode="Recycling"/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
찾은 줄. 검색하기 전에는 접혀 있어 목록이 자리를 다 쓴다.
|
||||||
|
높이를 묶어 두는 까닭 - 결과가 수천 줄이어도 목록을 화면 밖으로 밀면 안 된다.
|
||||||
|
-->
|
||||||
|
<Border Grid.Row="1" BorderThickness="1,0,1,1" Margin="0,0,0,0"
|
||||||
|
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
|
||||||
|
Visibility="{Binding HasSearched, Converter={StaticResource BoolToVis}}">
|
||||||
|
<ListBox x:Name="HitList" ItemsSource="{Binding SearchHits}"
|
||||||
|
SelectedItem="{Binding SelectedHit}"
|
||||||
|
MinHeight="90" MaxHeight="260" BorderThickness="0"
|
||||||
|
MouseDoubleClick="HitList_MouseDoubleClick"
|
||||||
|
KeyDown="HitList_KeyDown"
|
||||||
|
HorizontalContentAlignment="Stretch"
|
||||||
|
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||||
|
VirtualizingPanel.IsVirtualizing="True"
|
||||||
|
VirtualizingPanel.VirtualizationMode="Recycling">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="52"/>
|
||||||
|
<ColumnDefinition Width="320"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding SideText}" FontSize="11"
|
||||||
|
FontWeight="Bold"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding Where}" FontFamily="Consolas"
|
||||||
|
FontSize="11" TextTrimming="CharacterEllipsis"
|
||||||
|
ToolTip="{Binding RelativePath}"/>
|
||||||
|
<TextBlock Grid.Column="2" Text="{Binding Preview}" FontFamily="Consolas"
|
||||||
|
FontSize="11" TextTrimming="CharacterEllipsis"
|
||||||
|
Style="{StaticResource Muted}"/>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<!-- ⑤ 요약 · 동작 -->
|
<!-- ⑤ 요약 · 동작 -->
|
||||||
<Border Grid.Row="5" Padding="8" BorderThickness="1,0,1,1" Margin="0,0,0,0"
|
<Border Grid.Row="6" Padding="8" BorderThickness="1,0,1,1" Margin="0,0,0,0"
|
||||||
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
|
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
using MergeHub.ViewModels;
|
using MergeHub.ViewModels;
|
||||||
@ -52,6 +52,49 @@ namespace MergeHub.Views
|
|||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>찾기 칸에서 Enter 를 누르면 바로 찾는다. 단추까지 가는 걸음을 줄인다.</summary>
|
||||||
|
private void SearchBox_KeyDown(object sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key != Key.Enter) return;
|
||||||
|
if (DataContext is not FolderViewModel vm) return;
|
||||||
|
|
||||||
|
if (vm.SearchCommand.CanExecute(null)) vm.SearchCommand.Execute(null);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>찾은 줄을 더블클릭하면 그 자리에서 파일 비교를 연다.</summary>
|
||||||
|
private void HitList_MouseDoubleClick(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not FolderViewModel vm) return;
|
||||||
|
if (HitUnder(e.OriginalSource as DependencyObject) is not { } hit) return;
|
||||||
|
|
||||||
|
vm.SelectedHit = hit;
|
||||||
|
vm.OpenHitCore(hit);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Enter 로도 연다. 결과를 키보드로 훑는 쪽이 빠르다.</summary>
|
||||||
|
private void HitList_KeyDown(object sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key != Key.Enter) return;
|
||||||
|
if (DataContext is not FolderViewModel vm || vm.SelectedHit is not { } hit) return;
|
||||||
|
|
||||||
|
vm.OpenHitCore(hit);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>클릭이 떨어진 자리에서 위로 올라가며 그 줄의 결과를 찾는다.</summary>
|
||||||
|
private static Services.FolderSearchHit? HitUnder(DependencyObject? source)
|
||||||
|
{
|
||||||
|
while (source is not null)
|
||||||
|
{
|
||||||
|
if (source is ListBoxItem item) return item.DataContext as Services.FolderSearchHit;
|
||||||
|
source = System.Windows.Media.VisualTreeHelper.GetParent(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>클릭이 떨어진 시각 요소에서 위로 올라가며 그 줄의 항목을 찾는다.</summary>
|
/// <summary>클릭이 떨어진 시각 요소에서 위로 올라가며 그 줄의 항목을 찾는다.</summary>
|
||||||
private static FolderNode? NodeUnder(DependencyObject? source)
|
private static FolderNode? NodeUnder(DependencyObject? source)
|
||||||
{
|
{
|
||||||
@ -65,5 +108,72 @@ namespace MergeHub.Views
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// ================= 끌어다 놓기 =================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 탐색기에서 끌어 온 것을 받을 수 있다고 알린다.
|
||||||
|
///
|
||||||
|
/// 이 표시가 없으면 커서에 <b>금지 표시</b>가 뜨고 놓아도 아무 일이 없다.
|
||||||
|
/// 파일·폴더가 아닌 것(글자·그림)은 받지 않는다.
|
||||||
|
/// </summary>
|
||||||
|
private static void AllowFileDrop(DragEventArgs e)
|
||||||
|
{
|
||||||
|
e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None;
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void View_DragOver(object sender, DragEventArgs e) => AllowFileDrop(e);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 화면 아무 데나 놓았을 때. 두 개를 놓으면 양쪽에 하나씩 들어간다.
|
||||||
|
///
|
||||||
|
/// 판단은 명령줄로 들어오는 길과 <b>같은 것</b>을 쓴다 - 폴더면 폴더 비교, 파일이면 파일 비교로
|
||||||
|
/// 탭까지 옮긴다. 폴더 화면에 파일을 놓았다고 아무 일도 안 하는 것은 불친절하다.
|
||||||
|
/// </summary>
|
||||||
|
private void View_Drop(object sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not FolderViewModel vm) return;
|
||||||
|
if (DroppedPaths(e) is not { Length: > 0 } paths) return;
|
||||||
|
|
||||||
|
vm.Workspace.RouteDroppedPaths(paths);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>왼쪽·오른쪽 칸에 바로 놓으면 <b>그 자리에만</b> 들어간다.</summary>
|
||||||
|
private void LeftFolder_Drop(object sender, DragEventArgs e) => DropIntoSlot(e, left: true);
|
||||||
|
|
||||||
|
private void RightFolder_Drop(object sender, DragEventArgs e) => DropIntoSlot(e, left: false);
|
||||||
|
|
||||||
|
private void DropIntoSlot(DragEventArgs e, bool left)
|
||||||
|
{
|
||||||
|
if (DataContext is not FolderViewModel vm) return;
|
||||||
|
if (DroppedPaths(e) is not { Length: > 0 } paths) return;
|
||||||
|
|
||||||
|
// 파일을 놓았으면 그 파일이 든 폴더로 받는다 - 폴더 칸이니 그렇게 읽는 편이 자연스럽다.
|
||||||
|
var folder = Services.PathUtil.FullFolderPath(paths[0]);
|
||||||
|
if (folder.Length == 0) folder = Services.PathUtil.FolderOfFile(paths[0]);
|
||||||
|
if (folder.Length == 0) return;
|
||||||
|
|
||||||
|
if (left) vm.LeftFolder = folder;
|
||||||
|
else vm.RightFolder = folder;
|
||||||
|
|
||||||
|
// 두 개를 한꺼번에 놓았으면 나머지 하나는 반대쪽에 넣는다.
|
||||||
|
if (paths.Length > 1)
|
||||||
|
{
|
||||||
|
var other = Services.PathUtil.FullFolderPath(paths[1]);
|
||||||
|
if (other.Length == 0) other = Services.PathUtil.FolderOfFile(paths[1]);
|
||||||
|
|
||||||
|
if (other.Length > 0)
|
||||||
|
{
|
||||||
|
if (left) vm.RightFolder = other;
|
||||||
|
else vm.LeftFolder = other;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[]? DroppedPaths(DragEventArgs e) =>
|
||||||
|
e.Data.GetDataPresent(DataFormats.FileDrop) ? e.Data.GetData(DataFormats.FileDrop) as string[] : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -7,7 +7,8 @@
|
|||||||
xmlns:ctl="clr-namespace:MergeHub.Views.Controls"
|
xmlns:ctl="clr-namespace:MergeHub.Views.Controls"
|
||||||
xmlns:system="clr-namespace:System;assembly=mscorlib"
|
xmlns:system="clr-namespace:System;assembly=mscorlib"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
PreviewKeyDown="View_PreviewKeyDown">
|
PreviewKeyDown="View_PreviewKeyDown"
|
||||||
|
AllowDrop="True" DragOver="View_DragOver" Drop="View_Drop">
|
||||||
<UserControl.Resources>
|
<UserControl.Resources>
|
||||||
<ResourceDictionary>
|
<ResourceDictionary>
|
||||||
<ResourceDictionary.MergedDictionaries>
|
<ResourceDictionary.MergedDictionaries>
|
||||||
@ -384,13 +385,15 @@
|
|||||||
ToolTip="왼쪽(BASE) 에 놓을 파일"/>
|
ToolTip="왼쪽(BASE) 에 놓을 파일"/>
|
||||||
<TextBlock Text="{Binding LeftFile}" Style="{StaticResource Muted}" FontSize="11"
|
<TextBlock Text="{Binding LeftFile}" Style="{StaticResource Muted}" FontSize="11"
|
||||||
MaxWidth="260" TextTrimming="CharacterEllipsis" Margin="4,0,10,0"
|
MaxWidth="260" TextTrimming="CharacterEllipsis" Margin="4,0,10,0"
|
||||||
ToolTip="{Binding LeftFile}"/>
|
ToolTip="{Binding LeftFile}"
|
||||||
|
AllowDrop="True" DragOver="View_DragOver" Drop="LeftFile_Drop"/>
|
||||||
<Button Content="내 파일..." Command="{Binding PickRightFileCommand}"
|
<Button Content="내 파일..." Command="{Binding PickRightFileCommand}"
|
||||||
Height="26" MinHeight="26" Padding="8,2" FontSize="11"
|
Height="26" MinHeight="26" Padding="8,2" FontSize="11"
|
||||||
ToolTip="오른쪽(LOCAL) 에 놓을 파일. 저장하면 이 파일에 씁니다"/>
|
ToolTip="오른쪽(LOCAL) 에 놓을 파일. 저장하면 이 파일에 씁니다"/>
|
||||||
<TextBlock Text="{Binding RightFile}" Style="{StaticResource Muted}" FontSize="11"
|
<TextBlock Text="{Binding RightFile}" Style="{StaticResource Muted}" FontSize="11"
|
||||||
MaxWidth="260" TextTrimming="CharacterEllipsis" Margin="4,0,0,0"
|
MaxWidth="260" TextTrimming="CharacterEllipsis" Margin="4,0,0,0"
|
||||||
ToolTip="{Binding RightFile}"/>
|
ToolTip="{Binding RightFile}"
|
||||||
|
AllowDrop="True" DragOver="View_DragOver" Drop="RightFile_Drop"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Git 일 때: 비교할 파일과 3-Way 상대 브랜치를 여기서 고른다.
|
<!-- Git 일 때: 비교할 파일과 3-Way 상대 브랜치를 여기서 고른다.
|
||||||
|
|||||||
@ -915,6 +915,65 @@ namespace MergeHub.Views
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================= 끌어다 놓기 =================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 탐색기에서 끌어 온 것을 받을 수 있다고 알린다. 이 표시가 없으면 커서에
|
||||||
|
/// <b>금지 표시</b>가 뜨고 놓아도 아무 일이 없다.
|
||||||
|
/// </summary>
|
||||||
|
private void View_DragOver(object sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None;
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 화면 아무 데나 놓았을 때. 판단은 명령줄로 들어오는 길과 <b>같은 것</b>을 쓴다 -
|
||||||
|
/// 폴더를 놓으면 폴더 비교 탭으로 넘어간다. 파일 화면이라고 폴더를 버리면 불친절하다.
|
||||||
|
/// </summary>
|
||||||
|
private void View_Drop(object sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not HomeViewModel vm) return;
|
||||||
|
if (DroppedPaths(e) is not { Length: > 0 } paths) return;
|
||||||
|
|
||||||
|
vm.Workspace.RouteDroppedPaths(paths);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>왼쪽·오른쪽 칸에 바로 놓으면 <b>그 자리에만</b> 들어간다.</summary>
|
||||||
|
private void LeftFile_Drop(object sender, DragEventArgs e) => DropIntoSlot(e, left: true);
|
||||||
|
|
||||||
|
private void RightFile_Drop(object sender, DragEventArgs e) => DropIntoSlot(e, left: false);
|
||||||
|
|
||||||
|
private void DropIntoSlot(DragEventArgs e, bool left)
|
||||||
|
{
|
||||||
|
if (DataContext is not HomeViewModel vm) return;
|
||||||
|
if (DroppedPaths(e) is not { Length: > 0 } paths) return;
|
||||||
|
|
||||||
|
// 폴더를 파일 칸에 놓은 것은 뜻이 통하지 않는다. 그때는 화면 전체 규칙으로 넘긴다.
|
||||||
|
if (System.IO.Directory.Exists(paths[0]))
|
||||||
|
{
|
||||||
|
vm.Workspace.RouteDroppedPaths(paths);
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left) vm.LeftFile = paths[0];
|
||||||
|
else vm.RightFile = paths[0];
|
||||||
|
|
||||||
|
// 두 개를 한꺼번에 놓았으면 나머지 하나는 반대쪽에 넣는다.
|
||||||
|
if (paths.Length > 1 && !System.IO.Directory.Exists(paths[1]))
|
||||||
|
{
|
||||||
|
if (left) vm.RightFile = paths[1];
|
||||||
|
else vm.LeftFile = paths[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[]? DroppedPaths(DragEventArgs e) =>
|
||||||
|
e.Data.GetDataPresent(DataFormats.FileDrop) ? e.Data.GetData(DataFormats.FileDrop) as string[] : null;
|
||||||
|
|
||||||
private void ShortcutHelp_Click(object sender, RoutedEventArgs e) =>
|
private void ShortcutHelp_Click(object sender, RoutedEventArgs e) =>
|
||||||
ShortcutHelpWindow.ShowOrActivate(Window.GetWindow(this));
|
ShortcutHelpWindow.ShowOrActivate(Window.GetWindow(this));
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user