밖에서 부르기: 명령줄 인자 수신 · 탐색기 보내기 등록

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

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

- Services/ShellIntegration: 탐색기 "보내기" 메뉴에 바로가기를 둔다.
  처음에는 우클릭 메뉴에 바로 넣으려고 레지스트리에 정적 verb 를 등록했다
  (HKCU\Software\Classes\AllFilesystemObjects\shell\...). 키는 제대로 들어갔고
  HKEY_CLASSES_ROOT 에도 병합돼 보였는데 탐색기 메뉴에는 뜨지 않았다.
  확인해 보니 이 PC 에서 우클릭 메뉴에 나오는 다른 프로그램(Notepad++ · Git Bash ·
  WinMerge)은 전부 HKLM 에 등록돼 있었고, HKCU 에 넣은 것은 - 우리 것이든 확인용으로
  넣어 본 메모장 항목이든 - 하나도 그려지지 않았다. HKLM 은 관리자 권한이 필요해 쓸 수 없다.

  그래서 보내기(SendTo)로 갔다. %APPDATA%\Microsoft\Windows\SendTo 에 바로가기 파일
  하나를 두는 것이 전부라 레지스트리도 권한도 필요 없고 윈도우 판을 타지 않는다.
  한 단계 더 들어가는 것이 유일한 단점인데, 대신 여러 개를 골라 한 번에 보낼 수 있어
  미뤄 두었던 "파일 두 개 골라 비교" 가 덤으로 따라왔다.
  등록/해제할 때 예전 판이 레지스트리에 남긴 흔적도 같이 지운다.

  셸 확장 DLL 은 쓰지 않았다 - DLL 은 탐색기 프로세스 안에서 돌아 잘못되면 탐색기가 죽는다.

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

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

ArgsTest 26건 신설. 전체 356건 통과.
실행 확인: 폴더 경로로 켜면 폴더 비교 탭에 그 폴더가 왼쪽에, 파일 두 개로 켜면 파일 비교
탭에 나란히. 등록 버튼을 누르면 보내기 메뉴에 항목이 생기고 버튼 이름이 "탐색기 보내기
해제" 로 바뀐다(실제 탐색기 메뉴에서 확인).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
김승준 2026-09-08 17:25:42 +09:00
parent 49a05a0628
commit 4029336b88
4 changed files with 93 additions and 63 deletions

View File

@ -1,4 +1,4 @@
# MergeHub 진행 상황 # MergeHub 진행 상황
작업 하나가 끝날 때마다 이 표를 고치고 **로컬 커밋**한다. 작업 하나가 끝날 때마다 이 표를 고치고 **로컬 커밋**한다.
푸시는 하지 않는다 — 사내 Gitea 저장소가 만들어지면 그때 한 번에 올린다. 푸시는 하지 않는다 — 사내 Gitea 저장소가 만들어지면 그때 한 번에 올린다.
@ -71,14 +71,24 @@ CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘
| 상태 | 항목 | 내용 | 확인 | | 상태 | 항목 | 내용 | 확인 |
|---|---|---|---| |---|---|---|---|
| **완료** | 명령줄 인자 수신 | `--merge/--diff --base/--local/--remote/--merged` 와 그냥 넘어온 경로. <b>이것이 다리의 나머지 반쪽</b>이다 - `git config` 에 등록하는 쪽은 전부터 있었는데 받는 쪽이 없었다 | `ArgsTest` 26건 · 실제 실행 2건 | | **완료** | 명령줄 인자 수신 | `--merge/--diff --base/--local/--remote/--merged` 와 그냥 넘어온 경로. <b>이것이 다리의 나머지 반쪽</b>이다 - `git config` 에 등록하는 쪽은 전부터 있었는데 받는 쪽이 없었다 | `ArgsTest` 26건 · 실제 실행 2건 |
| **완료** | 탐색기 우클릭 | 파일·폴더·폴더 배경 세 자리. Git 탭 버튼 하나로 등록/해제하고 버튼 이름이 지금 상태를 말한다 | 레지스트리 3개 키 확인 | | **완료** | 탐색기에서 부르기 | 우클릭 → <b>보내기</b> → `MergeHub 로 비교`. Git 탭 버튼 하나로 등록/해제하고 버튼 이름이 지금 상태를 말한다 | 실제 메뉴에서 확인 |
**HKEY_CURRENT_USER 에만 쓴다** - 관리자 권한이 필요 없고 지울 때도 이 사람 것만 지운다. **우클릭 메뉴에 바로 넣으려다 보내기로 돌아섰다.** 처음에는 레지스트리에 정적 verb 를 넣었다
사내 PC 에 권한 없이 깔리는 것이 전제라 이 선택이 중요하다. 셸 확장 DLL 을 만들지 않은 것도 (`HKCU\Software\Classes\AllFilesystemObjects\shell\...`). 키는 제대로 들어갔고
같은 이유다 - DLL 은 탐색기 프로세스 안에서 돌아 잘못되면 탐색기가 통째로 죽는다. `HKEY_CLASSES_ROOT` 에도 병합돼 보였는데 <b>탐색기 메뉴에는 뜨지 않았다.</b>
확인해 보니 이 PC 에서 우클릭 메뉴에 나오는 다른 프로그램(Notepad++ · Git Bash · WinMerge)은
전부 HKLM(전체 사용자)에 등록돼 있었고, HKCU 에 넣은 것은 - 우리 것이든 실험용으로 넣은
메모장 항목이든 - <b>하나도 그려지지 않았다.</b> HKLM 은 관리자 권한이 필요해 쓸 수 없다.
탐색기는 고른 것 <b>하나만</b> 준다. 그때는 왼쪽만 채우고 오른쪽은 비워 둔 채 화면을 연다 - 그래서 <b>보내기(SendTo)</b> 로 갔다. `%APPDATA%\Microsoft\Windows\SendTo` 에 바로가기
사용자가 그 자리에서 상대를 고르면 되고, 아무것도 안 열리는 것보다 낫다. 파일 하나를 두는 것이 전부라 레지스트리도 권한도 필요 없고 윈도우 판을 타지 않는다.
한 단계 더 들어가는 것이 유일한 단점인데, 대신 <b>여러 개를 골라 한 번에 보낼 수 있다</b> -
미뤄 두었던 "파일 두 개 골라 비교"가 덤으로 따라왔다.
셸 확장 DLL 은 쓰지 않았다 - DLL 은 탐색기 프로세스 안에서 돌아 잘못되면 탐색기가 통째로 죽는다.
고른 것이 하나뿐이면 왼쪽만 채우고 오른쪽은 비워 둔 채 화면을 연다 - 사용자가 그 자리에서
상대를 고르면 되고, 아무것도 안 열리는 것보다 낫다.
--- ---
@ -88,8 +98,6 @@ CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘
- 파일·폴더 드래그&드롭, 최근 파일 목록 - 파일·폴더 드래그&드롭, 최근 파일 목록
- 로그인·권한 - 로그인·권한
- 탐색기에서 <b>두 개를 한 번에</b> 골라 비교(지금은 하나씩. 여러 개를 한 번에 받으려면
레지스트리 MultiSelectModel 이나 셸 확장이 필요하다)
- 비교 결과 보고서 내보내기 - 비교 결과 보고서 내보내기
- 대용량 파일 성능 (수만 줄) — 실제 소스 크기를 확인한 뒤 판단 - 대용량 파일 성능 (수만 줄) — 실제 소스 크기를 확인한 뒤 판단

View File

@ -5,29 +5,29 @@ using Microsoft.Win32;
namespace MergeHub.Services namespace MergeHub.Services
{ {
/// <summary> /// <summary>
/// 탐색기 우클릭 메뉴 등록. /// 탐색기에서 이 프로그램을 부를 수 있게 한다 - <b>보내기(SendTo)</b> 메뉴에 바로가기를 둔다.
/// ///
/// <b>HKEY_CURRENT_USER 에만 쓴다</b> - 관리자 권한이 필요 없고, 지울 때도 이 사람 것만 지운다. /// 처음에는 우클릭 메뉴에 바로 넣으려고 레지스트리에 정적 verb 를 등록했다.
/// 사내 PC 에 관리자 권한 없이 깔리는 것이 전제라 이 선택이 중요하다. /// (<c>HKCU\Software\Classes\AllFilesystemObjects\shell\...</c>)
/// <b>이 PC 에서는 뜨지 않았다.</b> 확인해 보니 우클릭 메뉴에 나오는 다른 프로그램
/// (Notepad++ · Git Bash · WinMerge)은 전부 HKLM(전체 사용자)에 등록돼 있었고,
/// HKCU 에 넣은 항목은 - 우리 것이든 실험용으로 넣은 것이든 - 하나도 그려지지 않았다.
/// HKLM 은 관리자 권한이 필요해 쓸 수 없다.
/// ///
/// 셸 확장 DLL 을 만들지 않은 이유 - DLL 을 등록하면 탐색기 프로세스 안으로 들어가 /// 그래서 보내기로 간다. 이쪽은 레지스트리를 아예 쓰지 않고
/// 잘못되면 탐색기가 통째로 죽는다. 레지스트리 항목만 쓰는 이 방식은 최악의 경우에도 /// <c>%APPDATA%\Microsoft\Windows\SendTo</c> 에 바로가기 파일 하나를 두는 것이 전부라
/// "메뉴를 눌러도 프로그램이 안 뜬다"에서 끝난다. /// 권한도 필요 없고 윈도우 판을 타지 않는다. 덤이 하나 있는데, <b>여러 개를 골라
/// 한 번에 보낼 수 있다</b> - 파일 두 개를 골라 바로 비교하는 것이 그냥 된다.
///
/// 단점은 한 단계 더 들어간다는 것뿐이다(우클릭 → 보내기 → MergeHub 로 비교).
/// </summary> /// </summary>
public static class ShellIntegration public static class ShellIntegration
{ {
/// <summary>메뉴에 보일 이름.</summary> /// <summary>보내기 메뉴에 보일 이름.</summary>
public const string MenuLabel = "MergeHub 로 비교"; public const string MenuLabel = "MergeHub 로 비교";
private const string KeyName = "MergeHub.Compare"; private static string ShortcutPath =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.SendTo), MenuLabel + ".lnk");
/// <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 public static bool IsRegistered
{ {
@ -35,60 +35,82 @@ namespace MergeHub.Services
{ {
try try
{ {
using var key = Registry.CurrentUser.OpenSubKey(Targets[0]); return File.Exists(ShortcutPath);
return key is not null;
} }
catch (System.Security.SecurityException) catch (IOException)
{
return false; // 정책으로 막힌 PC 면 "안 걸려 있다"로 본다
}
catch (UnauthorizedAccessException)
{ {
return false; return false;
} }
} }
} }
/// <summary> /// <summary>보내기 메뉴에 바로가기를 만든다. 이미 있으면 경로만 새로 쓴다(프로그램을 옮겼을 때).</summary>
/// 우클릭 메뉴를 넣는다. 이미 있으면 경로만 새로 쓴다(프로그램을 옮겼을 때를 위해).
/// </summary>
public static void Register(string executablePath) public static void Register(string executablePath)
{ {
if (string.IsNullOrWhiteSpace(executablePath)) throw new ArgumentException("실행 파일 경로가 비었습니다."); if (string.IsNullOrWhiteSpace(executablePath)) throw new ArgumentException("실행 파일 경로가 비었습니다.");
var exe = executablePath; var shell = Type.GetTypeFromProgID("WScript.Shell")
var icon = exe + ",0"; ?? throw new InvalidOperationException("WScript.Shell 을 찾을 수 없습니다.");
foreach (var path in Targets) var host = Activator.CreateInstance(shell)
{ ?? throw new InvalidOperationException("WScript.Shell 을 만들지 못했습니다.");
var background = path.Contains("Background");
using var key = Registry.CurrentUser.CreateSubKey(path) // 바로가기(.lnk)는 COM 으로만 만들 수 있다. 참조를 더하지 않으려고 늦은 바인딩으로 부른다.
?? throw new InvalidOperationException($"레지스트리 키를 만들지 못했습니다: {path}"); var link = shell.InvokeMember("CreateShortcut", System.Reflection.BindingFlags.InvokeMethod,
null, host, new object[] { ShortcutPath })
?? throw new InvalidOperationException("바로가기를 만들지 못했습니다.");
key.SetValue(null, MenuLabel); var type = link.GetType();
key.SetValue("Icon", icon);
using var command = key.CreateSubKey("command") Set(type, link, "TargetPath", executablePath);
?? throw new InvalidOperationException("command 키를 만들지 못했습니다."); Set(type, link, "WorkingDirectory", Path.GetDirectoryName(executablePath) ?? string.Empty);
Set(type, link, "Description", "고른 파일·폴더를 MergeHub 으로 비교합니다");
// 폴더 안 빈 곳에서 부르면 %1 이 아니라 %V 가 그 폴더다. type.InvokeMember("Save", System.Reflection.BindingFlags.InvokeMethod, null, link, null);
var target = background ? "%V" : "%1";
command.SetValue(null, $"\"{exe}\" \"{target}\""); // 예전 판이 레지스트리에 써 둔 것이 남아 있으면 같이 치운다.
} RemoveLegacyRegistryEntries();
} }
private static void Set(Type type, object link, string name, string value) =>
type.InvokeMember(name, System.Reflection.BindingFlags.SetProperty, null, link, new object[] { value });
public static void Unregister() public static void Unregister()
{ {
foreach (var path in Targets) try
{
if (File.Exists(ShortcutPath)) File.Delete(ShortcutPath);
}
catch (IOException)
{
// 지우지 못하면 그대로 둔다. 다시 누르면 된다.
}
RemoveLegacyRegistryEntries();
}
/// <summary>
/// 레지스트리로 등록하던 시절의 흔적. 이 PC 에서는 뜨지도 않던 것이라 남겨 둘 이유가 없다.
/// </summary>
private static void RemoveLegacyRegistryEntries()
{
string[] legacy =
{
@"Software\Classes\AllFilesystemObjects\shell\MergeHub.Compare",
@"Software\Classes\Directory\Background\shell\MergeHub.Compare",
@"Software\Classes\*\shell\MergeHub.Compare",
@"Software\Classes\Directory\shell\MergeHub.Compare",
};
foreach (var path in legacy)
{ {
try try
{ {
Registry.CurrentUser.DeleteSubKeyTree(path, throwOnMissingSubKey: false); Registry.CurrentUser.DeleteSubKeyTree(path, throwOnMissingSubKey: false);
} }
catch (ArgumentException) catch (Exception e) when (e is ArgumentException || e is UnauthorizedAccessException)
{ {
// 이미 없으면 그만이다. // 이미 없거나 손댈 수 없으면 그만이다.
} }
} }
} }

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
@ -536,14 +536,14 @@ namespace MergeHub.ViewModels
private bool _isShellRegistered = ShellIntegration.IsRegistered; private bool _isShellRegistered = ShellIntegration.IsRegistered;
public string ShellIntegrationLabel => public string ShellIntegrationLabel =>
IsShellRegistered ? "탐색기 우클릭 해제" : "탐색기 우클릭 등록"; IsShellRegistered ? "탐색기 보내기 해제" : "탐색기 보내기 등록";
/// <summary> /// <summary>
/// 탐색기 우클릭 메뉴를 넣고 뺀다. /// 탐색기 <b>보내기</b> 메뉴에 이 프로그램을 넣고 뺀다.
/// ///
/// HKEY_CURRENT_USER 에만 쓰므로 <b>관리자 권한이 필요 없다</b> - 사내 PC 에 권한 없이 /// 우클릭 메뉴에 바로 넣는 레지스트리 방식은 이 PC 에서 뜨지 않았다
/// 깔리는 것이 전제다. 셸 확장 DLL 을 만들지 않은 것도 같은 이유로, /// (자세한 이유는 ShellIntegration 설명 참고). 보내기는 바로가기 파일 하나라
/// DLL 은 탐색기 프로세스 안에서 돌아 잘못되면 탐색기가 통째로 죽는다. /// 권한도 필요 없고, 여러 개를 골라 한 번에 보낼 수 있다.
/// </summary> /// </summary>
[RelayCommand] [RelayCommand]
private void ToggleShellIntegration() private void ToggleShellIntegration()
@ -553,19 +553,19 @@ namespace MergeHub.ViewModels
if (IsShellRegistered) if (IsShellRegistered)
{ {
ShellIntegration.Unregister(); ShellIntegration.Unregister();
MessageUtil.ShowToast("탐색기 우클릭 메뉴를 뺐습니다"); MessageUtil.ShowToast("탐색기 보내기 메뉴에서 뺐습니다");
} }
else else
{ {
ShellIntegration.Register(ShellIntegration.CurrentExecutable()); ShellIntegration.Register(ShellIntegration.CurrentExecutable());
MessageUtil.ShowSuccessToast($"파일·폴더를 우클릭하면 \"{ShellIntegration.MenuLabel}\" 가 보입니다"); MessageUtil.ShowSuccessToast("우클릭 → 보내기 → " + ShellIntegration.MenuLabel + " 로 엽니다");
} }
IsShellRegistered = ShellIntegration.IsRegistered; IsShellRegistered = ShellIntegration.IsRegistered;
} }
catch (Exception ex) catch (Exception ex)
{ {
MessageUtil.ShowErrorToast($"탐색기 등록 실패: {ex.Message}"); MessageUtil.ShowErrorToast("보내기 등록 실패: " + ex.Message);
} }
} }

View File

@ -134,7 +134,7 @@
<!-- HKCU 에만 쓰므로 관리자 권한이 필요 없다. 버튼 이름이 지금 상태를 그대로 말한다. --> <!-- HKCU 에만 쓰므로 관리자 권한이 필요 없다. 버튼 이름이 지금 상태를 그대로 말한다. -->
<Button Content="{Binding ShellIntegrationLabel}" Command="{Binding ToggleShellIntegrationCommand}" <Button Content="{Binding ShellIntegrationLabel}" Command="{Binding ToggleShellIntegrationCommand}"
MinWidth="140" MinWidth="140"
ToolTip="탐색기에서 파일·폴더를 우클릭하면 이 프로그램으로 열 수 있게 합니다 (관리자 권한 필요 없음)"/> ToolTip="탐색기에서 파일·폴더를 고르고 [우클릭 → 보내기] 로 이 프로그램에 넘길 수 있게 합니다. 여러 개를 한 번에 골라도 됩니다 (관리자 권한 필요 없음)"/>
</WrapPanel> </WrapPanel>
<!-- ===== 작업 영역 ===== --> <!-- ===== 작업 영역 ===== -->