diff --git a/doc/진행상황.md b/doc/진행상황.md
index 8a5ae36..9e76cb0 100644
--- a/doc/진행상황.md
+++ b/doc/진행상황.md
@@ -66,14 +66,30 @@ CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘
---
+### ⑤ 밖에서 부르기 — **완료** (2026-09-08)
+
+| 상태 | 항목 | 내용 | 확인 |
+|---|---|---|---|
+| **완료** | 명령줄 인자 수신 | `--merge/--diff --base/--local/--remote/--merged` 와 그냥 넘어온 경로. 이것이 다리의 나머지 반쪽이다 - `git config` 에 등록하는 쪽은 전부터 있었는데 받는 쪽이 없었다 | `ArgsTest` 26건 · 실제 실행 2건 |
+| **완료** | 탐색기 우클릭 | 파일·폴더·폴더 배경 세 자리. Git 탭 버튼 하나로 등록/해제하고 버튼 이름이 지금 상태를 말한다 | 레지스트리 3개 키 확인 |
+
+**HKEY_CURRENT_USER 에만 쓴다** - 관리자 권한이 필요 없고 지울 때도 이 사람 것만 지운다.
+사내 PC 에 권한 없이 깔리는 것이 전제라 이 선택이 중요하다. 셸 확장 DLL 을 만들지 않은 것도
+같은 이유다 - DLL 은 탐색기 프로세스 안에서 돌아 잘못되면 탐색기가 통째로 죽는다.
+
+탐색기는 고른 것 하나만 준다. 그때는 왼쪽만 채우고 오른쪽은 비워 둔 채 화면을 연다 -
+사용자가 그 자리에서 상대를 고르면 되고, 아무것도 안 열리는 것보다 낫다.
+
+---
+
## 3~4주차로 미룬 것
디테일이라 지금 붙잡을 일이 아니다.
- 파일·폴더 드래그&드롭, 최근 파일 목록
- 로그인·권한
-- 탐색기 우클릭 등록, `git mergetool` 인자 수신
- (※ `git config` 에 등록하는 쪽은 이미 있는데 **받는 쪽이 없다** — 다리를 반만 놓은 상태다)
+- 탐색기에서 두 개를 한 번에 골라 비교(지금은 하나씩. 여러 개를 한 번에 받으려면
+ 레지스트리 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 | **⑤ 밖에서 부르기** — 명령줄 인자 · 탐색기 우클릭 | 아래 |
diff --git a/src/MergeHub/Services/GitWorkspace.cs b/src/MergeHub/Services/GitWorkspace.cs
index 3f5fe7c..41e2599 100644
--- a/src/MergeHub/Services/GitWorkspace.cs
+++ b/src/MergeHub/Services/GitWorkspace.cs
@@ -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);
}
+ // ================= 켤 때 넘어온 인자 =================
+
+ /// 폴더 비교 화면이 아직 없을 때 남겨 두는 요청. 파일 비교 쪽과 같은 방식이다.
+ 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? FolderCompareRequested;
+
+ ///
+ /// `git mergetool` · `git difftool` · 탐색기 우클릭이 넘긴 것을 화면으로 보낸다.
+ ///
+ /// 창이 다 만들어진 뒤에 탭을 옮겨야 한다 - 그 전에는 옮길 곳이 아직 없어 요청이 사라진다.
+ /// 요청 자체는 남겨 두므로(pending) 늦게 만들어진 화면도 스스로 집어 간다.
+ ///
+ 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);
+ }
+
/// ObservableCollection 변경은 UI 스레드에서만 허용된다.
public void OnUi(Action action)
{
diff --git a/src/MergeHub/Services/ShellIntegration.cs b/src/MergeHub/Services/ShellIntegration.cs
new file mode 100644
index 0000000..da23202
--- /dev/null
+++ b/src/MergeHub/Services/ShellIntegration.cs
@@ -0,0 +1,105 @@
+using System;
+using System.IO;
+using Microsoft.Win32;
+
+namespace MergeHub.Services
+{
+ ///
+ /// 탐색기 우클릭 메뉴 등록.
+ ///
+ /// HKEY_CURRENT_USER 에만 쓴다 - 관리자 권한이 필요 없고, 지울 때도 이 사람 것만 지운다.
+ /// 사내 PC 에 관리자 권한 없이 깔리는 것이 전제라 이 선택이 중요하다.
+ ///
+ /// 셸 확장 DLL 을 만들지 않은 이유 - DLL 을 등록하면 탐색기 프로세스 안으로 들어가
+ /// 잘못되면 탐색기가 통째로 죽는다. 레지스트리 항목만 쓰는 이 방식은 최악의 경우에도
+ /// "메뉴를 눌러도 프로그램이 안 뜬다"에서 끝난다.
+ ///
+ public static class ShellIntegration
+ {
+ /// 메뉴에 보일 이름.
+ public const string MenuLabel = "MergeHub 로 비교";
+
+ private const string KeyName = "MergeHub.Compare";
+
+ /// 파일 우클릭 · 폴더 우클릭 · 폴더 안 빈 곳 우클릭, 세 자리에 넣는다.
+ 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;
+ }
+ }
+ }
+
+ ///
+ /// 우클릭 메뉴를 넣는다. 이미 있으면 경로만 새로 쓴다(프로그램을 옮겼을 때를 위해).
+ ///
+ 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)
+ {
+ // 이미 없으면 그만이다.
+ }
+ }
+ }
+
+ /// 지금 돌고 있는 실행 파일. net48 에는 Environment.ProcessPath 가 없다.
+ 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!;
+ }
+ }
+}
diff --git a/src/MergeHub/Services/StartupArgs.cs b/src/MergeHub/Services/StartupArgs.cs
new file mode 100644
index 0000000..e8d6360
--- /dev/null
+++ b/src/MergeHub/Services/StartupArgs.cs
@@ -0,0 +1,167 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+
+namespace MergeHub.Services
+{
+ public enum StartupMode
+ {
+ /// 그냥 켠 것. 지금까지처럼 설정의 기본 파일을 연다.
+ None,
+
+ /// 파일 두 개를 나란히(`git difftool`, 탐색기 우클릭).
+ Diff,
+
+ /// 충돌 해결(`git mergetool`). BASE·LOCAL·REMOTE 를 받고 MERGED 에 쓴다.
+ Merge,
+
+ /// 폴더 비교.
+ Folder,
+ }
+
+ ///
+ /// 프로그램을 켤 때 넘어온 인자.
+ ///
+ /// 이것이 있어야 `git mergetool` 과 탐색기 우클릭이 이 프로그램을 부를 수 있다.
+ /// (git config 에 등록하는 쪽은 전부터 있었는데 받는 쪽이 없어 다리가 반만 놓여 있었다.)
+ ///
+ /// 받는 모양은 두 가지다.
+ /// - 이름표 붙은 것: --merge --base B --local L --remote R --merged M
+ /// - 그냥 경로: 탐색기가 "%1" 하나만 주는 경우. 파일이면 비교, 폴더면 폴더 비교로 본다.
+ ///
+ 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;
+
+ /// 결과를 써 넣을 파일. `git mergetool` 이 이 파일을 다시 읽어 간다.
+ public string Merged { get; private set; } = string.Empty;
+
+ public string LeftFolder { get; private set; } = string.Empty;
+ public string RightFolder { get; private set; } = string.Empty;
+
+ ///
+ /// 이름표 없이 그냥 넘어온 파일 경로. 탐색기가 "%1" 만 줄 때가 이 자리다.
+ /// 이름표가 붙은 · 와 뜻이 달라 따로 둔다 -
+ /// difftool 은 "내 것/상대 것"이지만 탐색기는 그냥 "먼저 고른 것/나중 것"이다.
+ ///
+ public string LooseLeft { get; private set; } = string.Empty;
+ public string LooseRight { get; private set; } = string.Empty;
+
+ /// 이번 실행에 넘어온 인자. 한 번만 읽는다.
+ public static StartupArgs Current { get; } = Parse(SafeArgs());
+
+ private static IReadOnlyList SafeArgs()
+ {
+ try
+ {
+ var all = Environment.GetCommandLineArgs();
+ return all.Length > 1 ? new List(all).GetRange(1, all.Length - 1) : Array.Empty();
+ }
+ catch (NotSupportedException)
+ {
+ return Array.Empty();
+ }
+ }
+
+ public static StartupArgs Parse(IReadOnlyList args)
+ {
+ var result = new StartupArgs();
+ if (args is null || args.Count == 0) return result;
+
+ var loose = new List();
+
+ 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 args, ref int i) =>
+ i + 1 < args.Count ? args[++i] : string.Empty;
+
+ ///
+ /// 이름표 없이 넘어온 경로를 자리에 맞춘다.
+ ///
+ /// 탐색기 우클릭은 고른 것 하나만 준다. 그때는 왼쪽만 채우고 오른쪽은 비워 둔다 -
+ /// 사용자가 그 자리에서 상대를 고르면 되고, 아무것도 안 열리는 것보다 훨씬 낫다.
+ ///
+ private static void Fill(StartupArgs result, List 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;
+ }
+ }
+
+ ///
+ /// 파일 비교 화면에 넣을 두 파일.
+ /// `git difftool` 은 LOCAL 이 내 것, REMOTE 가 상대 것이라 왼쪽이 REMOTE 가 된다 -
+ /// 이 도구의 왼쪽은 늘 "기준"이기 때문이다.
+ ///
+ 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);
+ }
+}
diff --git a/src/MergeHub/ViewModels/FolderViewModel.cs b/src/MergeHub/ViewModels/FolderViewModel.cs
index 09ef638..e00776c 100644
--- a/src/MergeHub/ViewModels/FolderViewModel.cs
+++ b/src/MergeHub/ViewModels/FolderViewModel.cs
@@ -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
}
}
+ ///
+ /// 탐색기 우클릭·명령줄이 넘긴 폴더를 받는다.
+ ///
+ /// 오른쪽이 비어 있으면 왼쪽만 채우고 기다린다 - 탐색기는 고른 것 하나만 주므로
+ /// 대개 이쪽이다. 아무 일도 안 일어나는 것보다 반쯤 채워 놓는 편이 낫다.
+ ///
+ 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 = "오른쪽 폴더를 고르고 비교를 누르세요.";
+ }
+
/// 홈 탭에서 이 두 파일을 비교해 달라고 알린다.
public event EventHandler<(string Left, string Right)>? OpenInDiffRequested;
diff --git a/src/MergeHub/ViewModels/GitViewModel.cs b/src/MergeHub/ViewModels/GitViewModel.cs
index cbcf78a..3ace239 100644
--- a/src/MergeHub/ViewModels/GitViewModel.cs
+++ b/src/MergeHub/ViewModels/GitViewModel.cs
@@ -528,6 +528,47 @@ namespace MergeHub.ViewModels
_workspace.OnUi(() => MessageUtil.ShowSuccessToast("git config 에 mergehub 등록 완료"));
});
+ // ================= 탐색기 우클릭 =================
+
+ /// 지금 우클릭 메뉴에 걸려 있는지. 버튼 이름을 이 값으로 바꾼다.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ShellIntegrationLabel))]
+ private bool _isShellRegistered = ShellIntegration.IsRegistered;
+
+ public string ShellIntegrationLabel =>
+ IsShellRegistered ? "탐색기 우클릭 해제" : "탐색기 우클릭 등록";
+
+ ///
+ /// 탐색기 우클릭 메뉴를 넣고 뺀다.
+ ///
+ /// HKEY_CURRENT_USER 에만 쓰므로 관리자 권한이 필요 없다 - 사내 PC 에 권한 없이
+ /// 깔리는 것이 전제다. 셸 확장 DLL 을 만들지 않은 것도 같은 이유로,
+ /// DLL 은 탐색기 프로세스 안에서 돌아 잘못되면 탐색기가 통째로 죽는다.
+ ///
+ [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}");
+ }
+ }
+
/// CommunityToolkit 의 커맨드는 CommandManager 를 구독하지 않아 직접 알려야 한다.
private void RaiseCanExecuteChanged()
{
diff --git a/src/MergeHub/ViewModels/HomeViewModel.cs b/src/MergeHub/ViewModels/HomeViewModel.cs
index 153f1e6..785452f 100644
--- a/src/MergeHub/ViewModels/HomeViewModel.cs
+++ b/src/MergeHub/ViewModels/HomeViewModel.cs
@@ -1443,7 +1443,9 @@ namespace MergeHub.ViewModels
/// 고른 두 파일을 읽어 비교 화면을 만든다.
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))
diff --git a/src/MergeHub/Views/GitView.xaml b/src/MergeHub/Views/GitView.xaml
index a3c7a2f..3279c74 100644
--- a/src/MergeHub/Views/GitView.xaml
+++ b/src/MergeHub/Views/GitView.xaml
@@ -130,6 +130,11 @@
ToolTip="진행 중인 merge / cherry-pick / revert / rebase 를 취소"/>
+
+
+