밖에서 부르기: 명령줄 인자 수신 · 탐색기 우클릭 등록

git config 에 mergetool/difftool 로 등록하는 쪽은 전부터 있었는데 정작 그 인자를
받는 쪽이 없어 다리가 반만 놓여 있었다. 나머지 반쪽을 놓는다.

- Services/StartupArgs: 켤 때 넘어온 인자를 읽는다.
  이름표 붙은 것(--merge --base B --local L --remote R --merged M)과 그냥 넘어온 경로
  둘 다 받는다. 탐색기는 "%1" 하나만 주므로 후자가 그 자리다. 경로가 폴더면 폴더 비교로,
  파일이면 파일 비교로 보낸다.
  difftool 의 LOCAL/REMOTE 와 탐색기의 "먼저 고른 것/나중 것" 은 뜻이 달라 따로 담는다 -
  이 도구의 왼쪽은 늘 "기준"이라 difftool 에서는 REMOTE 가 왼쪽이 된다.

- Services/ShellIntegration: 탐색기 우클릭 메뉴를 파일·폴더·폴더 배경 세 자리에 넣는다.
  HKEY_CURRENT_USER 에만 쓰므로 관리자 권한이 필요 없고, 지울 때도 이 사람 것만 지운다 -
  사내 PC 에 권한 없이 깔리는 것이 전제다. 셸 확장 DLL 을 만들지 않은 것도 같은 이유로,
  DLL 은 탐색기 프로세스 안에서 돌아 잘못되면 탐색기가 통째로 죽는다.
  폴더 배경에서는 %1 이 아니라 %V 가 그 폴더다.

- GitWorkspace.RouteStartupArgs 가 요청을 화면으로 보낸다. 창이 다 만들어진 뒤에 탭을
  옮겨야 해서(그 전에는 옮길 곳이 없다) ApplicationIdle 로 미루고, 요청 자체는 남겨 두어
  늦게 만들어진 화면이 스스로 집어 가게 했다(폴더 비교 쪽도 파일 비교와 같은 방식으로 추가).

- 곁들여: 파일 비교가 "양쪽 경로가 모두 있을 때만" 열리고 있었다. 탐색기는 하나만 주므로
  한쪽만 있어도 열도록 고쳤다 - 없는 쪽은 빈 내용이 되어 전부 추가로 보인다.

ArgsTest 26건 신설. 전체 356건 통과.
실행 확인: 폴더 경로로 켜면 폴더 비교 탭에 그 폴더가 왼쪽에, 파일 두 개로 켜면
파일 비교 탭에 나란히. 등록 버튼을 누르면 레지스트리 3개 키가 생기고 버튼 이름이
"탐색기 우클릭 해제" 로 바뀐다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
김승준 2026-09-08 16:56:56 +09:00
parent 55d9deb418
commit 49a05a0628
8 changed files with 426 additions and 4 deletions

View File

@ -66,14 +66,30 @@ CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘
---
### ⑤ 밖에서 부르기 — **완료** (2026-09-08)
| 상태 | 항목 | 내용 | 확인 |
|---|---|---|---|
| **완료** | 명령줄 인자 수신 | `--merge/--diff --base/--local/--remote/--merged` 와 그냥 넘어온 경로. <b>이것이 다리의 나머지 반쪽</b>이다 - `git config` 에 등록하는 쪽은 전부터 있었는데 받는 쪽이 없었다 | `ArgsTest` 26건 · 실제 실행 2건 |
| **완료** | 탐색기 우클릭 | 파일·폴더·폴더 배경 세 자리. Git 탭 버튼 하나로 등록/해제하고 버튼 이름이 지금 상태를 말한다 | 레지스트리 3개 키 확인 |
**HKEY_CURRENT_USER 에만 쓴다** - 관리자 권한이 필요 없고 지울 때도 이 사람 것만 지운다.
사내 PC 에 권한 없이 깔리는 것이 전제라 이 선택이 중요하다. 셸 확장 DLL 을 만들지 않은 것도
같은 이유다 - DLL 은 탐색기 프로세스 안에서 돌아 잘못되면 탐색기가 통째로 죽는다.
탐색기는 고른 것 <b>하나만</b> 준다. 그때는 왼쪽만 채우고 오른쪽은 비워 둔 채 화면을 연다 -
사용자가 그 자리에서 상대를 고르면 되고, 아무것도 안 열리는 것보다 낫다.
---
## 3~4주차로 미룬 것
디테일이라 지금 붙잡을 일이 아니다.
- 파일·폴더 드래그&드롭, 최근 파일 목록
- 로그인·권한
- 탐색기 우클릭 등록, `git mergetool` 인자 수신
(※ `git config` 에 등록하는 쪽은 이미 있는데 **받는 쪽이 없다** — 다리를 반만 놓은 상태다)
- 탐색기에서 <b>두 개를 한 번에</b> 골라 비교(지금은 하나씩. 여러 개를 한 번에 받으려면
레지스트리 MultiSelectModel 이나 셸 확장이 필요하다)
- 비교 결과 보고서 내보내기
- 대용량 파일 성능 (수만 줄) — 실제 소스 크기를 확인한 뒤 판단
@ -87,4 +103,5 @@ CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘
| 2026-09-08 | **④ Git 조작 5가지** — amend · stash 목록 · 커밋 간 비교 · blame · 변경분 추출 | `9956567` |
| 2026-09-08 | **③ 3-Way** — 자동 병합 · 충돌만 이동 · 병합 완료 판정 | `85c5e9e` |
| 2026-09-08 | **① 폴더 비교** — 폴더째 복사·삭제 · 보기 필터 · 비교 취소 | `524a6a7` |
| 2026-09-08 | **② 파일 비교** — 바꾸기 · 덩어리 반영 · 저장 형식 · 이진/그림 비교 | `de1a43c` + 아래 |
| 2026-09-08 | **② 파일 비교** — 바꾸기 · 덩어리 반영 · 저장 형식 · 이진/그림 비교 | `de1a43c` `55d9deb` |
| 2026-09-08 | **⑤ 밖에서 부르기** — 명령줄 인자 · 탐색기 우클릭 | 아래 |

View File

@ -48,6 +48,11 @@ namespace MergeHub.Services
// 그러면 주석에 등록한 이름 대신 윈도우 계정이 찍힌다.
AuthorName = ResolveAuthorName();
// `git mergetool` · 탐색기 우클릭이 넘긴 것을 화면으로 보낸다.
// 여기서 하는 이유 - 이 객체는 첫 화면이 만들어질 때 함께 만들어지므로
// 요청을 남겨 둘 자리로 가장 이르다.
RouteStartupArgs();
// 설정 화면에서 이름을 바꾸면 바로 따라간다.
if (_currentUser is not null)
{
@ -379,6 +384,67 @@ namespace MergeHub.Services
PlainCompareRequested.Invoke(pending.Left, pending.Right);
}
// ================= 켤 때 넘어온 인자 =================
/// <summary>폴더 비교 화면이 아직 없을 때 남겨 두는 요청. 파일 비교 쪽과 같은 방식이다.</summary>
private (string Left, string Right)? _pendingFolderCompare;
public void RequestFolderCompare(string left, string right)
{
_pendingFolderCompare = (left, right);
OnUi(DeliverPendingFolderCompare);
}
public void DeliverPendingFolderCompare()
{
if (_pendingFolderCompare is not { } pending) return;
if (FolderCompareRequested is null) return;
_pendingFolderCompare = null;
FolderCompareRequested.Invoke(pending.Left, pending.Right);
}
public event Action<string, string>? FolderCompareRequested;
/// <summary>
/// `git mergetool` · `git difftool` · 탐색기 우클릭이 넘긴 것을 화면으로 보낸다.
///
/// 창이 다 만들어진 뒤에 탭을 옮겨야 한다 - 그 전에는 옮길 곳이 아직 없어 요청이 사라진다.
/// 요청 자체는 남겨 두므로(pending) 늦게 만들어진 화면도 스스로 집어 간다.
/// </summary>
private void RouteStartupArgs()
{
var args = StartupArgs.Current;
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(args.Mode == StartupMode.Merge
? $"mergetool 로 호출됨: {args.Merged}"
: $"파일 비교 요청: {left} ↔ {right}");
}
private static void ActivateWhenReady(string viewId)
{
var app = System.Windows.Application.Current;
if (app is null) return;
app.Dispatcher.BeginInvoke(
new Action(() => UbisamBase.Core.Modules.TabManager.Activate(viewId)),
System.Windows.Threading.DispatcherPriority.ApplicationIdle);
}
/// <summary>ObservableCollection 변경은 UI 스레드에서만 허용된다.</summary>
public void OnUi(Action action)
{

View File

@ -0,0 +1,105 @@
using System;
using System.IO;
using Microsoft.Win32;
namespace MergeHub.Services
{
/// <summary>
/// 탐색기 우클릭 메뉴 등록.
///
/// <b>HKEY_CURRENT_USER 에만 쓴다</b> - 관리자 권한이 필요 없고, 지울 때도 이 사람 것만 지운다.
/// 사내 PC 에 관리자 권한 없이 깔리는 것이 전제라 이 선택이 중요하다.
///
/// 셸 확장 DLL 을 만들지 않은 이유 - DLL 을 등록하면 탐색기 프로세스 안으로 들어가
/// 잘못되면 탐색기가 통째로 죽는다. 레지스트리 항목만 쓰는 이 방식은 최악의 경우에도
/// "메뉴를 눌러도 프로그램이 안 뜬다"에서 끝난다.
/// </summary>
public static class ShellIntegration
{
/// <summary>메뉴에 보일 이름.</summary>
public const string MenuLabel = "MergeHub 로 비교";
private const string KeyName = "MergeHub.Compare";
/// <summary>파일 우클릭 · 폴더 우클릭 · 폴더 안 빈 곳 우클릭, 세 자리에 넣는다.</summary>
private static readonly string[] Targets =
{
@"Software\Classes\*\shell\" + KeyName,
@"Software\Classes\Directory\shell\" + KeyName,
@"Software\Classes\Directory\Background\shell\" + KeyName,
};
public static bool IsRegistered
{
get
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(Targets[0]);
return key is not null;
}
catch (System.Security.SecurityException)
{
return false; // 정책으로 막힌 PC 면 "안 걸려 있다"로 본다
}
catch (UnauthorizedAccessException)
{
return false;
}
}
}
/// <summary>
/// 우클릭 메뉴를 넣는다. 이미 있으면 경로만 새로 쓴다(프로그램을 옮겼을 때를 위해).
/// </summary>
public static void Register(string executablePath)
{
if (string.IsNullOrWhiteSpace(executablePath)) throw new ArgumentException("실행 파일 경로가 비었습니다.");
var exe = executablePath;
var icon = exe + ",0";
foreach (var path in Targets)
{
var background = path.Contains("Background");
using var key = Registry.CurrentUser.CreateSubKey(path)
?? throw new InvalidOperationException($"레지스트리 키를 만들지 못했습니다: {path}");
key.SetValue(null, MenuLabel);
key.SetValue("Icon", icon);
using var command = key.CreateSubKey("command")
?? throw new InvalidOperationException("command 키를 만들지 못했습니다.");
// 폴더 안 빈 곳에서 부르면 %1 이 아니라 %V 가 그 폴더다.
var target = background ? "%V" : "%1";
command.SetValue(null, $"\"{exe}\" \"{target}\"");
}
}
public static void Unregister()
{
foreach (var path in Targets)
{
try
{
Registry.CurrentUser.DeleteSubKeyTree(path, throwOnMissingSubKey: false);
}
catch (ArgumentException)
{
// 이미 없으면 그만이다.
}
}
}
/// <summary>지금 돌고 있는 실행 파일. net48 에는 Environment.ProcessPath 가 없다.</summary>
public static string CurrentExecutable()
{
var path = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName;
return string.IsNullOrEmpty(path)
? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "UbisamBase.Shell.exe")
: path!;
}
}
}

View File

@ -0,0 +1,167 @@
using System;
using System.Collections.Generic;
using System.IO;
namespace MergeHub.Services
{
public enum StartupMode
{
/// <summary>그냥 켠 것. 지금까지처럼 설정의 기본 파일을 연다.</summary>
None,
/// <summary>파일 두 개를 나란히(`git difftool`, 탐색기 우클릭).</summary>
Diff,
/// <summary>충돌 해결(`git mergetool`). BASE·LOCAL·REMOTE 를 받고 MERGED 에 쓴다.</summary>
Merge,
/// <summary>폴더 비교.</summary>
Folder,
}
/// <summary>
/// 프로그램을 켤 때 넘어온 인자.
///
/// 이것이 있어야 <b>`git mergetool` 과 탐색기 우클릭이 이 프로그램을 부를 수 있다.</b>
/// (git config 에 등록하는 쪽은 전부터 있었는데 받는 쪽이 없어 다리가 반만 놓여 있었다.)
///
/// 받는 모양은 두 가지다.
/// - 이름표 붙은 것: <c>--merge --base B --local L --remote R --merged M</c>
/// - 그냥 경로: 탐색기가 <c>"%1"</c> 하나만 주는 경우. 파일이면 비교, 폴더면 폴더 비교로 본다.
/// </summary>
public sealed class StartupArgs
{
public StartupMode Mode { get; private set; } = StartupMode.None;
public string Base { get; private set; } = string.Empty;
public string Local { get; private set; } = string.Empty;
public string Remote { get; private set; } = string.Empty;
/// <summary>결과를 써 넣을 파일. `git mergetool` 이 이 파일을 다시 읽어 간다.</summary>
public string Merged { get; private set; } = string.Empty;
public string LeftFolder { get; private set; } = string.Empty;
public string RightFolder { get; private set; } = string.Empty;
/// <summary>
/// 이름표 없이 그냥 넘어온 파일 경로. 탐색기가 <c>"%1"</c> 만 줄 때가 이 자리다.
/// 이름표가 붙은 <see cref="Local"/>·<see cref="Remote"/> 와 뜻이 달라 따로 둔다 -
/// difftool 은 "내 것/상대 것"이지만 탐색기는 그냥 "먼저 고른 것/나중 것"이다.
/// </summary>
public string LooseLeft { get; private set; } = string.Empty;
public string LooseRight { get; private set; } = string.Empty;
/// <summary>이번 실행에 넘어온 인자. 한 번만 읽는다.</summary>
public static StartupArgs Current { get; } = Parse(SafeArgs());
private static IReadOnlyList<string> SafeArgs()
{
try
{
var all = Environment.GetCommandLineArgs();
return all.Length > 1 ? new List<string>(all).GetRange(1, all.Length - 1) : Array.Empty<string>();
}
catch (NotSupportedException)
{
return Array.Empty<string>();
}
}
public static StartupArgs Parse(IReadOnlyList<string> args)
{
var result = new StartupArgs();
if (args is null || args.Count == 0) return result;
var loose = new List<string>();
for (var i = 0; i < args.Count; i++)
{
var arg = args[i] ?? string.Empty;
switch (arg.ToLowerInvariant())
{
case "--merge": result.Mode = StartupMode.Merge; continue;
case "--diff": result.Mode = StartupMode.Diff; continue;
case "--folder": result.Mode = StartupMode.Folder; continue;
case "--base": result.Base = Next(args, ref i); continue;
case "--local": result.Local = Next(args, ref i); continue;
case "--remote": result.Remote = Next(args, ref i); continue;
case "--merged": result.Merged = Next(args, ref i); continue;
}
// 이름표가 없는 것은 경로로 본다. 셸에서 넘어오는 "%1" 이 이 자리다.
if (!arg.StartsWith("-", StringComparison.Ordinal) && arg.Length > 0) loose.Add(arg);
}
Fill(result, loose);
return result;
}
private static string Next(IReadOnlyList<string> args, ref int i) =>
i + 1 < args.Count ? args[++i] : string.Empty;
/// <summary>
/// 이름표 없이 넘어온 경로를 자리에 맞춘다.
///
/// 탐색기 우클릭은 고른 것 하나만 준다. 그때는 <b>왼쪽만 채우고 오른쪽은 비워 둔다</b> -
/// 사용자가 그 자리에서 상대를 고르면 되고, 아무것도 안 열리는 것보다 훨씬 낫다.
/// </summary>
private static void Fill(StartupArgs result, List<string> loose)
{
if (loose.Count == 0) return;
var first = loose[0];
var second = loose.Count > 1 ? loose[1] : string.Empty;
// 이름표로 이미 모드를 정했으면 남은 경로만 채운다.
if (result.Mode == StartupMode.Folder || (result.Mode == StartupMode.None && IsFolder(first)))
{
result.Mode = StartupMode.Folder;
if (result.LeftFolder.Length == 0) result.LeftFolder = first;
if (result.RightFolder.Length == 0) result.RightFolder = second;
return;
}
if (result.Mode == StartupMode.None) result.Mode = StartupMode.Diff;
result.LooseLeft = first;
result.LooseRight = second;
}
private static bool IsFolder(string path)
{
try
{
return Directory.Exists(path);
}
catch (ArgumentException)
{
return false;
}
}
/// <summary>
/// 파일 비교 화면에 넣을 두 파일.
/// `git difftool` 은 LOCAL 이 내 것, REMOTE 가 상대 것이라 <b>왼쪽이 REMOTE</b> 가 된다 -
/// 이 도구의 왼쪽은 늘 "기준"이기 때문이다.
/// </summary>
public (string Left, string Right) DiffPair
{
get
{
// 그냥 넘어온 경로가 있으면 그 순서가 곧 왼쪽·오른쪽이다.
if (LooseLeft.Length > 0 || LooseRight.Length > 0) return (LooseLeft, LooseRight);
return Mode == StartupMode.Merge
? (Base, Merged.Length > 0 ? Merged : Local)
: (Remote.Length > 0 ? Remote : Base, Local);
}
}
public bool WantsFolder => Mode == StartupMode.Folder && LeftFolder.Length > 0;
public bool WantsFiles =>
Mode is StartupMode.Diff or StartupMode.Merge && (DiffPair.Left.Length > 0 || DiffPair.Right.Length > 0);
}
}

View File

@ -111,6 +111,10 @@ namespace MergeHub.ViewModels
{
_workspace = workspace;
// 탐색기 우클릭으로 켰으면 그 폴더가 왼쪽에 들어온다.
_workspace.FolderCompareRequested += OnFolderCompareRequested;
_workspace.DeliverPendingFolderCompare();
// 저장소를 열어 뒀으면 왼쪽 기본값으로 삼는다 - 대개 그 폴더를 견주게 된다.
if (!string.IsNullOrWhiteSpace(_workspace.RepositoryPath) &&
Directory.Exists(_workspace.RepositoryPath))
@ -119,6 +123,21 @@ namespace MergeHub.ViewModels
}
}
/// <summary>
/// 탐색기 우클릭·명령줄이 넘긴 폴더를 받는다.
///
/// 오른쪽이 비어 있으면 <b>왼쪽만 채우고 기다린다</b> - 탐색기는 고른 것 하나만 주므로
/// 대개 이쪽이다. 아무 일도 안 일어나는 것보다 반쯤 채워 놓는 편이 낫다.
/// </summary>
private void OnFolderCompareRequested(string left, string right)
{
if (!string.IsNullOrWhiteSpace(left)) LeftFolder = left;
if (!string.IsNullOrWhiteSpace(right)) RightFolder = right;
if (Directory.Exists(LeftFolder) && Directory.Exists(RightFolder)) _ = CompareAsync();
else Summary = "오른쪽 폴더를 고르고 비교를 누르세요.";
}
/// <summary>홈 탭에서 이 두 파일을 비교해 달라고 알린다.</summary>
public event EventHandler<(string Left, string Right)>? OpenInDiffRequested;

View File

@ -528,6 +528,47 @@ namespace MergeHub.ViewModels
_workspace.OnUi(() => MessageUtil.ShowSuccessToast("git config 에 mergehub 등록 완료"));
});
// ================= 탐색기 우클릭 =================
/// <summary>지금 우클릭 메뉴에 걸려 있는지. 버튼 이름을 이 값으로 바꾼다.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShellIntegrationLabel))]
private bool _isShellRegistered = ShellIntegration.IsRegistered;
public string ShellIntegrationLabel =>
IsShellRegistered ? "탐색기 우클릭 해제" : "탐색기 우클릭 등록";
/// <summary>
/// 탐색기 우클릭 메뉴를 넣고 뺀다.
///
/// HKEY_CURRENT_USER 에만 쓰므로 <b>관리자 권한이 필요 없다</b> - 사내 PC 에 권한 없이
/// 깔리는 것이 전제다. 셸 확장 DLL 을 만들지 않은 것도 같은 이유로,
/// DLL 은 탐색기 프로세스 안에서 돌아 잘못되면 탐색기가 통째로 죽는다.
/// </summary>
[RelayCommand]
private void ToggleShellIntegration()
{
try
{
if (IsShellRegistered)
{
ShellIntegration.Unregister();
MessageUtil.ShowToast("탐색기 우클릭 메뉴를 뺐습니다");
}
else
{
ShellIntegration.Register(ShellIntegration.CurrentExecutable());
MessageUtil.ShowSuccessToast($"파일·폴더를 우클릭하면 \"{ShellIntegration.MenuLabel}\" 가 보입니다");
}
IsShellRegistered = ShellIntegration.IsRegistered;
}
catch (Exception ex)
{
MessageUtil.ShowErrorToast($"탐색기 등록 실패: {ex.Message}");
}
}
/// <summary>CommunityToolkit 의 커맨드는 CommandManager 를 구독하지 않아 직접 알려야 한다.</summary>
private void RaiseCanExecuteChanged()
{

View File

@ -1443,7 +1443,9 @@ namespace MergeHub.ViewModels
/// <summary>고른 두 파일을 읽어 비교 화면을 만든다.</summary>
private async Task LoadPlainFilesAsync()
{
if (string.IsNullOrWhiteSpace(LeftFile) || string.IsNullOrWhiteSpace(RightFile)) return;
// 한쪽만 있어도 연다 - 탐색기 우클릭은 고른 파일 하나만 준다.
// 없는 쪽은 빈 내용이 되어 전부 추가(또는 삭제)로 보인다.
if (string.IsNullOrWhiteSpace(LeftFile) && string.IsNullOrWhiteSpace(RightFile)) return;
// 이진 파일은 줄 단위로 견줄 것이 없다. 깨진 글자로 화면을 채우느니 그렇다고 알린다.
if (TextFile.IsBinary(LeftFile) || TextFile.IsBinary(RightFile))

View File

@ -130,6 +130,11 @@
ToolTip="진행 중인 merge / cherry-pick / revert / rebase 를 취소"/>
<Button Content="mergetool 등록" Command="{Binding RegisterMergeToolCommand}" MinWidth="110"
ToolTip="git config 의 merge.tool 로 이 프로그램을 등록 (전역 .gitconfig 수정)"/>
<!-- HKCU 에만 쓰므로 관리자 권한이 필요 없다. 버튼 이름이 지금 상태를 그대로 말한다. -->
<Button Content="{Binding ShellIntegrationLabel}" Command="{Binding ToggleShellIntegrationCommand}"
MinWidth="140"
ToolTip="탐색기에서 파일·폴더를 우클릭하면 이 프로그램으로 열 수 있게 합니다 (관리자 권한 필요 없음)"/>
</WrapPanel>
<!-- ===== 작업 영역 ===== -->