From 5210ead6618190238f040a12875de809d298aa92 Mon Sep 17 00:00:00 2001 From: ksj09406 Date: Tue, 22 Sep 2026 15:57:58 +0900 Subject: [PATCH] =?UTF-8?q?11=EC=B0=A8=20F-3:=20=ED=83=90=EC=83=89?= =?UTF-8?q?=EA=B8=B0=20=EB=B3=B4=EB=82=B4=EA=B8=B0=EB=A1=9C=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EB=91=90=20=EA=B0=9C=20=EB=B0=94=EB=A1=9C=20?= =?UTF-8?q?=EB=B9=84=EA=B5=90=20=C2=B7=20=EC=95=8C=EB=A6=BC=20=C2=B7=20?= =?UTF-8?q?=EB=B0=94=EB=A1=9C=EA=B0=80=EA=B8=B0=20=EC=A0=80=EC=A0=88?= =?UTF-8?q?=EB=A1=9C=20=EA=B3=A0=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 잰 것: 탐색기 [보내기] 는 여러 개를 프로세스 하나에 인자로 모두 넘긴다(일곱 경우). 폴더 둘은 창 하나에서 폴더 비교까지 된다. 설치 파일 필요 없음 - 3개 이상: 앞의 둘 + 알림(StartupArgs.Extra · Notice) - 예전엔 말없이 버림 - 260자 넘는 경로(8.3 이름으로 넘어와 net48 이 못 찾음) 등 없는 경로: 아무것도 열지 않고 까닭 알림(Missing) - 예전엔 폴더인데 파일 비교로 가서 빈 화면 - 알림은 사용자 이름 창 뒤로 지나가지 않게 본 창이 활성이 된 뒤에 - 켤 때 보내기 바로가기가 옛 자리를 가리키면 지금 실행 파일로(RepairIfMoved, 등록한 사람만 · 바로가기 파일만 · 레지스트리 안 씀) - 사용자 결정: 떠 있을 때는 새 창 그대로. ArgsTest +16(1,411건) Co-Authored-By: Claude Opus 5 --- doc/history/2026-09-18-11차.md | 54 ++++++++++++++++ doc/history/changelog.md | 1 + doc/진행상황.md | 2 +- src/MergeHub/Services/GitWorkspace.cs | 44 +++++++++++++ src/MergeHub/Services/ShellIntegration.cs | 48 +++++++++++++++ src/MergeHub/Services/StartupArgs.cs | 75 +++++++++++++++++++++++ 6 files changed, 223 insertions(+), 1 deletion(-) diff --git a/doc/history/2026-09-18-11차.md b/doc/history/2026-09-18-11차.md index b0704ed..8e0f136 100644 --- a/doc/history/2026-09-18-11차.md +++ b/doc/history/2026-09-18-11차.md @@ -161,6 +161,7 @@ A-1 에서 값으로 확인한 것 — 지운 파일은 "삭제되어 건너뜀 - 이 뒤 11차 항목(F 등)에서 바뀌는 화면 · 설정 · 숫자. - F-1: 칸 이름 `LOCAL(Mine)` · `REMOTE(Theirs)`, 칸 머리 "어느 쪽 · 어느 파일"(MERGED · 두 칸 LOCAL 은 "(저장할 파일)", mergetool 은 임시 파일 이름), 긴 이름은 가운데 줄임 · 툴팁. 머지 주석 모양 `[사람] 날짜 출처 설명`(출처 = 로그의 말 — Theirs(…) · Mine(…) · REMOTE(…) · BASE(…)), **Take Mine 도 주석**, 두 칸 로그 `BASE(해시) 반영` · 브랜치 3-Way 로그 `REMOTE(브랜치) 반영`. 매뉴얼의 주석 예시 · 칸 설명 · 해결 기록이 남는 곳 표. 숫자 1,312건. - F-2: [이 메서드 반영](C# · C/C++, 클래스 멤버 통째, 단추 아래 한 줄에 잡은 범위 · 못 잡은 까닭), 덩어리 · 메서드 반영은 주석 한 줄 · 로그 한 줄 · Undo 한 번. 매뉴얼의 반영 단추 설명에. 숫자 23벌 1,395건. +- F-3: 매뉴얼 "탐색기에서 열기" — 여러 개를 골라 보내면 앞의 둘(3개 이상은 알림), 폴더 둘은 바로 폴더 비교, 260자 넘는 경로는 열지 못함(알림), 배포 묶음을 옮겨도 켜면 보내기 바로가기가 따라옴, 설치 파일 필요 없음. 알아 둘 한계에 "떠 있어도 새 창". 숫자 23벌 1,411건. ## B-1. 배포 묶음을 다시 만들고 꼭 필요한 파일을 가림 (2026-09-22, 완료) @@ -587,6 +588,59 @@ libgit2 의 다듬기(`PrettifyMessages`)를 켜면 **손대지 않은 뒤 커 - 범위는 반영해 올 쪽(BASE · REMOTE) 글로 잡는다. 그쪽에 없는 메서드(내 쪽에서만 새로 만든 메서드)는 잡히지 않아 단추가 꺼진다. - XAML · 그 밖의 언어는 단추가 꺼지고 까닭을 말한다(사용자 결정: C# + C/C++). +## F-3. 탐색기에서 폴더 두 개를 [보내기] 로 바로 비교 · 설치 없이 되는지 (2026-09-22, 완료) + +### 어떻게 쟀나 — 사용자의 보내기 메뉴는 건드리지 않고 + +탐색기 [보내기] 는 SendTo 폴더의 바로가기에 고른 항목들을 **떨어뜨리는 것**(바로가기의 IDropTarget::Drop)이다. 같은 셸 길을 부르는 도구(`SendToSim` — 고른 항목의 데이터 개체를 임시 폴더의 바로가기에 Drop)와 받은 명령줄을 적기만 하는 `ArgsEcho` 로 쟀다. 바로가기는 [탐색기 보내기 등록] 과 같은 모양(대상 · 작업 폴더). + +| 경우 | 탐색기가 넘긴 것 | 고치기 전 MergeHub | +|---|---|---| +| 파일 2개 | **프로세스 1개에 인자 2개** | 파일 비교로 열림 | +| 폴더 2개 | 프로세스 1개에 인자 2개 | **창 하나에 왼쪽 · 오른쪽 폴더, 폴더 비교까지**(`전체 1개 · 같음 1`) | +| 폴더 1개 | 인자 1개 | 폴더 비교, 왼쪽만 채움(오른쪽은 사용자가 고름) | +| 3개 이상 | 프로세스 1개에 인자 3개 | 앞의 둘만 비교, 셋째는 **말없이 버림** | +| 경로에 공백 | 그대로(`left side`) | 폴더 비교까지 | +| 한글 | 그대로(`왼쪽 폴더`) | 폴더 비교까지 | +| 260자 넘는 경로 | **8.3 짧은 이름**(123자, `ME0359~1\긴폴더~1\…\RIGHTL~1`) | **아무것도 안 열림** — 로그엔 폴더인데 "파일 비교 요청" | + +- 탐색기는 여러 개를 **한 번, 프로세스 하나**로 넘긴다 — "두 번 뜬 프로세스를 하나로 모으는 길" 은 필요 없다. +- 긴 경로: net48 에서 짧은 이름을 `Path.GetFullPath` 가 긴 이름(277자)으로 펼친 뒤 못 찾는다(`Directory.Exists` 거짓, `\\?\` 를 붙이면 "잘못된 문자" — `LongPathProbe`). 열려면 플랫폼 실행 파일 설정(긴 경로 스위치)을 바꿔야 한다. +- MergeHub 가 **이미 떠 있을 때** 보내면 창이 하나 더 뜬다(프로세스 2개). + +### 정한 것 (2026-09-22 사용자) + +- 긴 경로: **열 수 없다고 알린다**(지원은 플랫폼 설정을 바꿔야 해서 하지 않음). +- 3개 이상: **앞의 둘 + 알림**. +- 배포 묶음을 옮겨 바로가기가 옛 자리를 가리키면: **켜질 때 저절로 고친다**. +- 이미 떠 있을 때: **지금처럼 새 창**(하나로 모으려면 플랫폼에 한 번만 실행 · 창 사이 전달이 필요 — 남은 것). + +### 고친 것 + +- `StartupArgs` — 셋째부터는 `Extra`, 앞의 둘 가운데 파일도 폴더도 아닌 것은 `Missing`(있으면 **아무것도 열지 않는다**). 알림 한 줄 `Notice`: `3개를 골랐습니다 - 앞의 둘만 비교하고 1개(Third)는 두었습니다` · `LeftLong 외 1개 - 경로가 277자라 열 수 없습니다(260자가 넘는 경로는 아직 열지 못합니다)` · 그 밖은 `… - 찾을 수 없습니다`. 이름표 붙은 경로(`git difftool` 의 지워진 쪽)는 따지지 않는다. +- `GitWorkspace` — 켤 때 넘어온 것 · 끌어다 놓은 것 모두 같은 말로 로그 한 줄 + 경고 알림. 켤 때는 사용자 이름 창(모달)이 먼저 떠서 그 뒤로 알림이 지나가 버렸다 — **본 창이 활성이 된 뒤에** 띄운다(이름 창을 닫고 0.4초 뒤에 보임). +- `ShellIntegration.RepairIfMoved` — 등록해 둔 사람만, 바로가기 대상이 지금 실행 파일과 다르면 **바로가기 파일만** 다시 쓰고 로그 한 줄(`보내기 바로가기가 옛 자리(…)를 가리켜 지금 실행 파일로 고쳤습니다`). 레지스트리는 손대지 않는다. 이미 맞으면 파일을 건드리지 않는다. + +### 설치 파일 없이 되는가 + +**된다.** 보내기는 `%APPDATA%\Microsoft\Windows\SendTo` 에 바로가기 하나를 두는 것이 전부다(관리자 권한 · HKLM · 설치 관리자 없음). 배포 묶음을 옮기면 켤 때 바로가기가 따라온다. 레지스트리에 닿는 것은 [탐색기 보내기 등록] 을 누를 때 **예전 판이 HKCU 에 남긴 항목이 있으면 지우는 것** 하나뿐이다(새로 쓰지 않음). + +### 확인한 것 + +- 하네스 ArgsTest +16(52건): 3개 → 앞의 둘 · 남은 것 · 알림 글, 2개 · 1개 → 알림 없음, 없는 경로 → 안 열고 그 경로 · "찾을 수 없습니다", 260자 넘는 경로 → 안 열고 "260자", 이름표 붙은 difftool 경로는 안 따짐, 빈 인자, 바로가기 대상 판정(같은 곳 · 대소문자 · 빈 대상 · 지금 실행 파일 모름). 23벌 **1,411건** · 빌드 경고 0 · 오류 0. +- 화면(`f3-sendto.ps1` · `f3-e2e.ps1` · `f3-e2e2.ps1` · `f3-toast.ps1` · `f3-second.ps1`, 바로가기 대상을 실제 MergeHub Debug 로): + - 폴더 2개 · 공백 · 한글 → 창 하나에서 폴더 비교까지(로그 `폴더 비교: 왼쪽 ↔ 오른쪽 · 전체 1개 · 같음 1`). 파일 2개 → 파일 비교. + - 3개 → 폴더 비교(앞의 둘) + 로그 · 알림 `3개를 골랐습니다 - …1개(Third)는 두었습니다`, 알림은 이름 창을 닫은 직후. + - 긴 경로 → 아무것도 열지 않고 로그 · 알림 `LeftLong 외 1개 - 경로가 277자라 열 수 없습니다…`. + - 바로가기 저절로 고침: 사용자의 실제 바로가기를 바이트 그대로 떠 두고 대상을 `D:\옛자리\bin\…` 로 바꿈 → 켜자 지금 실행 파일 · 작업 폴더로 고쳐지고 로그 한 줄, 한 번 더 켜도 파일 그대로 → 원래 파일 **바이트 그대로 되돌림**(원래 대상이 이미 Debug 실행 파일이라 평소 시험으로는 바뀌지 않는다). +- 시험 재료(`%TEMP%\mergehub-f3`, 긴 경로 포함)는 지웠다. + +### 남은 것 + +- 이미 떠 있는 창으로 보내기(한 번만 실행 · 창 사이 전달) — 플랫폼 일이라 이번 범위 밖. +- 260자 넘는 경로를 여는 것 — 플랫폼 실행 파일의 긴 경로 설정과 전체 시험이 필요. +- 여러 개를 골랐을 때 **어느 것이 왼쪽인지**는 탐색기가 넘기는 순서를 따른다(도구는 고른 순서대로 넘겼다). 실제 탐색기 창에서 누른 순서와의 관계는 재지 않았다. + ## F-12. C# 코드 스타일 맞춤의 기본값을 AURORA 로 (2026-09-18, 완료) 사용자: "기본값은 원본형태를 따르고 C#인 경우에 AURORA 스타일로 하자" → 물어서 정한 것은 **제품 기본값 AURORA · C# 파일만 · 저장 동작은 지금 그대로**(켜져 있으면 저장도 그 스타일로 쓴다). diff --git a/doc/history/changelog.md b/doc/history/changelog.md index 8f0af71..934bc67 100644 --- a/doc/history/changelog.md +++ b/doc/history/changelog.md @@ -8,6 +8,7 @@ | 날짜 | 항목 | 커밋 | |---|---|---| +| 2026-09-22 | **11차 F-3 (추가 요청)** — 탐색기 [보내기] 가 여러 개를 어떻게 넘기는지 셸의 같은 길(바로가기에 Drop)로 일곱 경우를 잼: **프로세스 하나에 인자로 모두**, 폴더 둘은 창 하나에서 폴더 비교까지 됨. 고친 것(사용자 결정): 3개 이상은 앞의 둘 + 알림(예전엔 말없이 버림), 260자 넘는 경로(탐색기가 8.3 이름으로 넘기고 net48 이 긴 이름으로 펼쳐 못 찾음 — 예전엔 아무것도 안 열림)는 열지 않고 까닭 알림, 알림은 이름 창 뒤로 지나가지 않게 본 창이 활성이 된 뒤에. 배포 묶음을 옮기면 켤 때 보내기 바로가기를 지금 실행 파일로 고침(등록한 사람만 · 바로가기 파일만 · 레지스트리 안 씀). 설치 파일 필요 없음 확인. 떠 있을 때 새 창은 그대로(플랫폼 일). ArgsTest +16(1,411건) | 아래 | | 2026-09-22 | **11차 F-2 (추가 요청)** — **[이 메서드 반영]**: 캐럿이 놓인 클래스 멤버(메서드 · 생성자 · 속성 · 이벤트 · 연산자 · 필드 · enum, 로컬 함수 안이면 감싸는 멤버)를 통째로 반영(사용자 결정: C# + C/C++, 멤버 전부). Roslyn 없이 `MemberRanges` 가 주석 · 문자열(축자 · 보간 · raw · C++ raw · 숫자 구분자)을 가리고 `#if` 갈래마다 다시 읽어 중괄호 짝으로 범위를 잡는다 — 못 잡으면 단추를 끄고 단추 아래 한 줄에 까닭. 덩어리 · 메서드 반영은 머지 주석 한 줄(메서드 이름 위 · 덩어리 첫 줄 위) · 로그 한 줄 · Undo 한 번(묶음 번호) — [이 덩어리 반영] 이 줄마다 주석을 달던 것도 고침(사용자 결정). 메서드 반영 저장본 = 같은 줄 줄 단위 반영 저장본(바이트). 하네스 MemberTest 83건(23벌 1,395건) | 아래 | | 2026-09-22 | **11차 F-1 (추가 요청)** — 칸 이름을 `LOCAL(Mine)` · `REMOTE(Theirs)` 로(사용자 결정: 표준 용어 + 영문 쪽 이름), 칸 머리마다 한 줄 "어느 쪽 · 어느 파일" — 파일 이름이 BASE 칸에만 있었고, mergetool 에서 Mine 칸이 MERGED 이름을 보였다. 좁으면 가운데를 줄이고 툴팁에 전체(`MiddleTrim`). 머지 주석에 출처(사용자 결정: 쪽 이름 + 브랜치/해시, 출처 먼저 설명 뒤) `[사람] 날짜 Theirs(feature/theirs)` — 로그와 같은 말이 되게 두 칸 로그 `BASE(해시) 반영` · 브랜치 3-Way 로그 `REMOTE(브랜치) 반영` 으로. Take Mine 도 주석을 남긴다(처음 한 번, 되돌리면 함께 빠짐). 무시 조건은 `[사람] 날짜` 뒤를 따지지 않아 코드 그대로 — 옛 · 새 · 출처만 다른 주석을 하네스로 굳힘. 네 길(병합 네 칸 · 두 칸 · `--merge` · git mergetool) 화면 확인, E-1 병합 길 회귀 없음. 하네스 +52(1,312건) | 아래 | | 2026-09-22 | **11차 A-4** — 변경분을 폴더에 더해 **ZIP 으로도** 뽑는다(사용자 결정). 뽑은 폴더 옆에 같은 이름 `.zip`, 안에 폴더와 안내 파일을 나란히(풀면 폴더로 뽑은 것과 같은 모양). 바이트 · 경로 그대로, 한글 이름은 UTF-8 표시, 반쯤 된 ZIP 은 남기지 않음, 못 만들어도 추출은 성공. 같은 이름 ZIP 이 남아 있으면 새 이름을 고름. 아홉 경우를 다시 뽑아 ZIP 8/8 을 Windows tar 로 풀어 폴더와 경로 · 해시가 같음. 하네스 ReportTest +13(70건). .NET Framework 기본 기능이라 배포 묶음 파일은 그대로 | 아래 | diff --git a/doc/진행상황.md b/doc/진행상황.md index 1da8d90..aa8cb91 100644 --- a/doc/진행상황.md +++ b/doc/진행상황.md @@ -372,7 +372,7 @@ |---|---| | **완료** | F-1 칸 이름 `LOCAL(Mine)` · `REMOTE(Theirs)` 로 맞추고 칸마다 "어느 쪽 · 어느 파일"(MERGED · 두 칸 LOCAL 은 "저장할 파일", mergetool 은 임시 파일 이름 — Mine 칸이 MERGED 이름을 보이던 것 고침), 한 줄 · 가운데 줄이기 · 툴팁 전체 · **덧**: 머지 주석 `[사람] 날짜 출처 설명`, 출처는 로그와 같은 말(Theirs(…) · Mine(…) · REMOTE(…) · BASE(해시)), Take Mine 도 주석. 무시 조건은 원래 출처를 받는 모양이라 코드 그대로, 옛 · 새 · 출처만 다른 주석을 하네스로 굳힘. 네 길 화면 확인 · E-1 병합 길 회귀 없음 · 1,312건 | | **완료** | F-2 메서드 단위로 골라 반영 — [이 메서드 반영](C# + C/C++, 클래스 멤버 전부 · 사용자 결정), 캐럿이 놓인 멤버를 반영해 올 쪽에서 중괄호 짝으로 잡음(`MemberRanges` — 문자열 · 주석 · raw · `#if` 갈래에 안 속음, 못 잡으면 단추 끄고 까닭 한 줄). 덩어리 · 메서드 반영은 주석 한 줄(메서드 이름 위) · 로그 한 줄 · Undo 한 번 — 덩어리가 줄마다 주석을 달던 것도 고침. 메서드 반영 = 같은 줄 줄 단위 반영 바이트 같음. MemberTest 83건(C# 29 · C++ 13 멤버) · 1,395건 | -| 대기 | F-3 탐색기에서 폴더 두 개를 [보내기] 로 바로 비교 + 설치 파일 없이 되는지 확인 | +| **완료** | F-3 탐색기 [보내기] 로 폴더 두 개 바로 비교 — 탐색기는 여러 개를 프로세스 하나에 인자로 모두 넘김(일곱 경우 값으로 잼), 폴더 둘은 창 하나에서 폴더 비교까지. 사용자 결정대로 3개 이상은 앞의 둘 + 알림, 260자 넘는 경로(탐색기가 8.3 이름으로 넘김 · net48 이 못 찾음)는 열지 않고 알림, 옮긴 배포 묶음은 켤 때 보내기 바로가기를 저절로 고침(바로가기 파일만 · 레지스트리 안 씀), 떠 있을 때는 새 창 그대로. 설치 파일 필요 없음. ArgsTest +16 · 1,411건 | | 대기 | F-4 파일 비교 탭 툴박스(ACTIONS) 감추는 단추 (지금은 접어도 폭 그대로) | | 대기 | F-5 글자 크기 조절 (지금은 Consolas 12 고정) | | 대기 | F-6 글자를 되도록 많이 보이게 — 넓게 보기 한 단추, 늘어난 줄 수로 잼 | diff --git a/src/MergeHub/Services/GitWorkspace.cs b/src/MergeHub/Services/GitWorkspace.cs index 7fa0b59..f1410ca 100644 --- a/src/MergeHub/Services/GitWorkspace.cs +++ b/src/MergeHub/Services/GitWorkspace.cs @@ -77,6 +77,17 @@ namespace MergeHub.Services // 요청을 남겨 둘 자리로 가장 이르다. RouteStartupArgs(); + // 보내기 바로가기가 옛 자리를 가리키면 지금 실행 파일로 고친다(11차 F-3). 등록한 사람만, 레지스트리는 안 건드린다. + try + { + if (ShellIntegration.RepairIfMoved(ShellIntegration.CurrentExecutable()) is { } old) + AppendLog($"보내기 바로가기가 옛 자리({old})를 가리켜 지금 실행 파일로 고쳤습니다"); + } + catch (Exception ex) + { + _logger.W($"보내기 바로가기 확인 실패: {ex.Message}"); + } + // 설정 화면에서 이름을 바꾸면 바로 따라간다. if (_currentUser is not null) { @@ -712,6 +723,7 @@ namespace MergeHub.Services private void RouteStartupArgs() { var args = StartupArgs.Current; + if (!ReportArgs(args, "탐색기에서")) return; if (args.WantsFolder) { @@ -752,6 +764,7 @@ namespace MergeHub.Services if (paths is null || paths.Count == 0) return; var args = StartupArgs.Parse(paths); + if (!ReportArgs(args, "끌어다 놓기")) return; if (args.WantsFolder) { @@ -769,6 +782,37 @@ namespace MergeHub.Services AppendLog($"끌어다 놓기 - 파일 비교: {left} ↔ {right}"); } + /// + /// 넘어온 경로에 알릴 것(열 수 없는 경로 · 셋째부터)이 있으면 로그와 알림에 같은 말로 남긴다(11차 F-3). + /// 열 수 없는 경로가 있으면 false - 아무것도 열지 않는다. + /// + private bool ReportArgs(StartupArgs args, string from) + { + if (args.Notice is not { } notice) return true; + + AppendLog($"{from}: {notice}"); + // 켤 때는 사용자 이름 창(모달)이 먼저 뜬다. 그 뒤에서 알림이 지나가 버리지 않게, 본 창이 활성이 된 뒤에 띄운다. + var app = System.Windows.Application.Current; + app?.Dispatcher.BeginInvoke(new Action(() => + { + var window = app.MainWindow; + if (window is null || window.IsActive) + { + MessageUtil.ShowWarningToast(notice); + return; + } + + EventHandler? once = null; + once = (_, _) => + { + window.Activated -= once; + MessageUtil.ShowWarningToast(notice); + }; + window.Activated += once; + }), System.Windows.Threading.DispatcherPriority.ApplicationIdle); + return args.Missing.Count == 0; + } + private static void ActivateWhenReady(string viewId) { var app = System.Windows.Application.Current; diff --git a/src/MergeHub/Services/ShellIntegration.cs b/src/MergeHub/Services/ShellIntegration.cs index 9beb116..a2df5e3 100644 --- a/src/MergeHub/Services/ShellIntegration.cs +++ b/src/MergeHub/Services/ShellIntegration.cs @@ -72,6 +72,54 @@ namespace MergeHub.Services RemoveLegacyRegistryEntries(); } + /// + /// 등록해 둔 바로가기가 옛 자리를 가리키면 지금 실행 파일로 고친다(11차 F-3, 사용자 결정 "켜질 때 저절로"). + /// 배포 묶음을 다른 폴더로 옮기면 바로가기가 없는 파일을 가리켜 보내기가 동작하지 않았다. + /// + /// 등록하지 않은 사람은 건드리지 않는다. 바로가기 파일만 다시 쓰고 레지스트리는 손대지 않는다 + /// ( 의 옛 흔적 치우기도 부르지 않는다). + /// + /// 고쳤으면 옛 대상, 아니면 null. + public static string? RepairIfMoved(string executablePath) + { + if (!IsRegistered || string.IsNullOrWhiteSpace(executablePath)) return null; + + var shell = Type.GetTypeFromProgID("WScript.Shell"); + if (shell is null) return null; + var host = Activator.CreateInstance(shell); + if (host is null) return null; + + var link = shell.InvokeMember("CreateShortcut", System.Reflection.BindingFlags.InvokeMethod, + null, host, new object[] { ShortcutPath }); + if (link is null) return null; + + var type = link.GetType(); + var old = type.InvokeMember("TargetPath", System.Reflection.BindingFlags.GetProperty, null, link, null) as string ?? string.Empty; + if (!PointsElsewhere(old, executablePath)) return null; + + Set(type, link, "TargetPath", executablePath); + Set(type, link, "WorkingDirectory", Path.GetDirectoryName(executablePath) ?? string.Empty); + type.InvokeMember("Save", System.Reflection.BindingFlags.InvokeMethod, null, link, null); + return old; + } + + /// 바로가기 대상이 지금 실행 파일과 다른 곳인지(대소문자 · 경로 모양은 가리지 않음). + public static bool PointsElsewhere(string? linkTarget, string? currentExecutable) + { + if (string.IsNullOrWhiteSpace(currentExecutable)) return false; + if (string.IsNullOrWhiteSpace(linkTarget)) return true; + + try + { + return !string.Equals(Path.GetFullPath(linkTarget!.Trim()), Path.GetFullPath(currentExecutable!.Trim()), + StringComparison.OrdinalIgnoreCase); + } + catch (Exception e) when (e is ArgumentException || e is NotSupportedException || e is PathTooLongException) + { + return true; + } + } + private static void Set(Type type, object link, string name, string value) => type.InvokeMember(name, System.Reflection.BindingFlags.SetProperty, null, link, new object[] { value }); diff --git a/src/MergeHub/Services/StartupArgs.cs b/src/MergeHub/Services/StartupArgs.cs index cf42535..45df91f 100644 --- a/src/MergeHub/Services/StartupArgs.cs +++ b/src/MergeHub/Services/StartupArgs.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; namespace MergeHub.Services { @@ -51,6 +52,57 @@ namespace MergeHub.Services public string LooseLeft { get; private set; } = string.Empty; public string LooseRight { get; private set; } = string.Empty; + /// + /// 셋째부터 넘어온 경로(11차 F-3). 탐색기 [보내기] 는 고른 것을 모두 한 번에 넘기는데 비교는 둘이라, + /// 앞의 둘만 열고 나머지는 알린다(사용자 결정). 예전에는 말없이 버렸다. + /// + public IReadOnlyList Extra { get; private set; } = Array.Empty(); + + /// + /// 앞의 둘 가운데 파일도 폴더도 아닌 경로(11차 F-3). 있으면 아무것도 열지 않고 알린다. + /// 260자를 넘는 경로는 탐색기가 8.3 짧은 이름으로 넘기는데, .NET Framework 가 긴 이름으로 펼친 뒤 + /// 찾지 못한다 - 예전에는 폴더인데도 "파일 비교 요청" 으로 적히고 아무것도 안 열렸다. + /// 이름표 붙은 경로(git difftool 의 지워진 쪽 등)는 따지지 않는다. + /// + public IReadOnlyList Missing { get; private set; } = Array.Empty(); + + /// 사용자에게 알릴 한 줄(없으면 null). 로그와 알림에 같은 말을 쓴다. + 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; + } + } + /// 이번 실행에 넘어온 인자. 한 번만 읽는다. public static StartupArgs Current { get; } = Parse(SafeArgs()); @@ -111,6 +163,17 @@ namespace MergeHub.Services { 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; @@ -129,6 +192,18 @@ namespace MergeHub.Services 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