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 를 취소"/>