ksj09406 5210ead661 11차 F-3: 탐색기 보내기로 폴더 두 개 바로 비교 · 알림 · 바로가기 저절로 고침
- 잰 것: 탐색기 [보내기] 는 여러 개를 프로세스 하나에 인자로 모두 넘긴다(일곱 경우).
  폴더 둘은 창 하나에서 폴더 비교까지 된다. 설치 파일 필요 없음
- 3개 이상: 앞의 둘 + 알림(StartupArgs.Extra · Notice) - 예전엔 말없이 버림
- 260자 넘는 경로(8.3 이름으로 넘어와 net48 이 못 찾음) 등 없는 경로: 아무것도
  열지 않고 까닭 알림(Missing) - 예전엔 폴더인데 파일 비교로 가서 빈 화면
- 알림은 사용자 이름 창 뒤로 지나가지 않게 본 창이 활성이 된 뒤에
- 켤 때 보내기 바로가기가 옛 자리를 가리키면 지금 실행 파일로(RepairIfMoved,
  등록한 사람만 · 바로가기 파일만 · 레지스트리 안 씀)
- 사용자 결정: 떠 있을 때는 새 창 그대로. ArgsTest +16(1,411건)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:57:58 +09:00

259 lines
11 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
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>
/// 셋째부터 넘어온 경로(11차 F-3). 탐색기 [보내기] 는 고른 것을 모두 한 번에 넘기는데 비교는 둘이라,
/// 앞의 둘만 열고 나머지는 알린다(사용자 결정). 예전에는 말없이 버렸다.
/// </summary>
public IReadOnlyList<string> Extra { get; private set; } = Array.Empty<string>();
/// <summary>
/// 앞의 둘 가운데 파일도 폴더도 아닌 경로(11차 F-3). 있으면 아무것도 열지 않고 알린다.
/// 260자를 넘는 경로는 탐색기가 8.3 짧은 이름으로 넘기는데, .NET Framework 가 긴 이름으로 펼친 뒤
/// 찾지 못한다 - 예전에는 폴더인데도 "파일 비교 요청" 으로 적히고 아무것도 안 열렸다.
/// 이름표 붙은 경로(<c>git difftool</c> 의 지워진 쪽 등)는 따지지 않는다.
/// </summary>
public IReadOnlyList<string> Missing { get; private set; } = Array.Empty<string>();
/// <summary>사용자에게 알릴 한 줄(없으면 null). 로그와 알림에 같은 말을 쓴다.</summary>
public string? Notice
{
get
{
if (Missing.Count > 0)
{
var first = Missing[0];
var why = FullLength(first) >= 260
? $"경로가 {FullLength(first)}자라 열 수 없습니다(260자가 넘는 경로는 아직 열지 못합니다)"
: "찾을 수 없습니다";
var more = Missing.Count > 1 ? $" 외 {Missing.Count - 1}개" : string.Empty;
return $"{Path.GetFileName(first.TrimEnd('\\', '/'))}{more} - {why}";
}
if (Extra.Count > 0)
{
var names = string.Join(", ", Extra.Select(p => Path.GetFileName(p.TrimEnd('\\', '/'))));
return $"{Extra.Count + 2}개를 골랐습니다 - 앞의 둘만 비교하고 {Extra.Count}개({names})는 두었습니다";
}
return null;
}
}
private static int FullLength(string path)
{
try
{
return Path.GetFullPath(path).Length;
}
catch (Exception e) when (e is ArgumentException || e is NotSupportedException || e is PathTooLongException)
{
return path.Length;
}
}
/// <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;
if (loose.Count > 2) result.Extra = loose.GetRange(2, loose.Count - 2);
var missing = loose.Take(2).Where(p => !Exists(p)).ToList();
if (missing.Count > 0)
{
// 없는 것을 열려 하면 엉뚱한 갈래(폴더인데 파일 비교)로 가서 빈 화면이 된다. 아무것도 열지 않는다.
result.Missing = missing;
result.Mode = StartupMode.None;
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 Exists(string path)
{
try
{
return File.Exists(path) || Directory.Exists(path);
}
catch (ArgumentException)
{
return false;
}
}
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);
}
}
/// <summary>
/// `git mergetool` 이 세 벌(BASE · LOCAL · REMOTE)을 넘겼으면 네 칸으로 열 요청. 아니면 null.
///
/// 예전에는 <see cref="DiffPair"/> 로 BASE 와 MERGED 두 칸만 열었다(6차 B-4 확인 중 발견).
/// MERGED 는 git 이 충돌 표시를 넣어 둔 파일이라 두 칸으로는 상대가 무엇을 바꿨는지 볼 수 없었다.
/// REMOTE 가 없으면 풀 것이 없으므로 세 벌로 보지 않는다. BASE 는 없어도 된다(양쪽이 새로 만든 파일).
/// MERGED 가 없으면 LOCAL 에 쓴다.
/// </summary>
public MergeToolRequest? ThreeWayMerge =>
Mode == StartupMode.Merge && LooseLeft.Length == 0 && Local.Length > 0 && Remote.Length > 0
? new MergeToolRequest(Base, Local, Remote, Merged.Length > 0 ? Merged : Local)
: null;
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);
}
/// <summary>`git mergetool` 이 넘긴 세 벌과 결과를 쓸 파일.</summary>
public sealed record MergeToolRequest(string Base, string Local, string Remote, string Merged);
}