A-1: 폴더·파일 고르기에서 프로그램이 죽던 문제

원인은 경로에 섞인 슬래시였다. 폴더 칸에 C:/Users/... 처럼 슬래시가 섞인
값이 들어 있으면 Directory.Exists 는 통과시키지만, 대화상자의
InitialDirectory 는 윈도우 셸(COM)이 그대로 해석하다 ArgumentException 을
던진다. 그 예외는 대입할 때가 아니라 ShowDialog() 안쪽에서 올라오기 때문에
대입 줄만 try 로 감싸 둔 파일 비교 쪽도 똑같이 죽게 돼 있었다.

- Services/PathUtil.cs 신설. 셸에 넘길 경로는 전부 여기를 거친다.
- 폴더·파일 고르기 둘 다 ShowDialog() 를 감쌌다. 끊긴 네트워크 드라이브처럼
  정규화로 걸러지지 않는 경우에는 시작 폴더 없이 한 번 더 연다.
- 3차 작업 지시서와 진행 상황 문서 신설·갱신.

검사: PathTest 31건 신설(356 -> 387건). 실제 실행으로 왼쪽·오른쪽 폴더 10회
연속, 파일 비교의 기준·내 파일 각 1회 확인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
김승준 2026-09-09 09:28:13 +09:00
parent 4029336b88
commit 5e667af35a
5 changed files with 354 additions and 11 deletions

157
doc/작업지시-3차.md Normal file
View File

@ -0,0 +1,157 @@
# 작업 지시 — MergeHub 3차 (버그 정리 + 기능 확장)
대상: `D:\Sorce\MergeTool\MergeHub` (플러그인) · `D:\Sorce\MergeTool\UbisamPlatform` (셸)
전제: 1차·2차 지시서와 `진행상황.md` 의 ①~⑤ 는 끝난 상태다. 아래는 실제로 써 보고 나온 문제와 다음 요구사항이다.
---
## 공통 규칙
- **A → B → C → D 순서로 진행한다.** A 는 프로그램이 죽거나 화면이 망가지는 문제라 제일 먼저다.
- **항목 하나를 끝낼 때마다** 빌드하고 실제로 실행해 화면을 확인한 뒤 보고한다. 전부 끝내고 한 번에 보고하지 않는다.
- 항목 하나가 끝나면 `doc/진행상황.md` 의 대기/진행/완료를 갱신하고 **로컬 커밋**한다. 커밋 메시지는 한국어 한 줄로 무엇을 고쳤는지 적는다.
- **푸쉬하지 않는다.** 원격 저장소가 생기기 전까지 `.git` 에만 쌓아 둔다.
- 기존 테스트 하네스(총 356건)는 계속 통과해야 한다. 로직을 건드리면 테스트를 먼저 늘린다.
- 빌드 경고 0 · 오류 0 을 유지한다. 앱이 실행 중이면 빌드가 파일 잠금으로 실패하므로 `UbisamBase.Shell` 을 먼저 종료한다.
- 플랫폼을 고치면 **다른 소비 프로그램에 미치는 영향**을 함께 적고 `doc/history/changelog.md` 에 남긴다.
---
## A. 먼저 고칠 것 — 죽거나 화면이 망가지는 문제
### A-1. 폴더 비교 탭에서 "왼쪽 폴더..." 를 누르면 프로그램이 다운된다
- 증상: 폴더 비교 탭의 왼쪽 폴더 선택 버튼(`FolderView.xaml` 의 `PickLeftFolderCommand`)을 누르면 프로그램이 그대로 죽는다.
- 오른쪽 폴더도 같은 증상인지 반드시 함께 확인한다.
- 죽는 지점을 먼저 특정한다(예외 로그 · 대화상자 호출 스레드 · 셸의 잠금/오버레이와의 충돌).
- **판정 기준**: 왼쪽·오른쪽 폴더를 연속으로 열 번 골라도 죽지 않는다.
### A-2. "화면 이동" 을 누르면 프로그램이 종료되거나 최소화된다
- 위치: `UbisamPlatform/src/UbisamBase.Core/Shell/ShellWindow.xaml.cs` 의 `MoveToNextMonitor`.
- 코드 주석에 이미 "새 자리의 다른 버튼에 떨어진다" 는 언급이 있다. 창을 옮긴 뒤 마우스 아래 버튼이 눌리거나 포커스가 엉키는 것으로 보인다.
- **판정 기준**: 모니터 두 대에서 화면 이동을 스무 번 연속으로 눌러도 종료·최소화가 한 번도 없다.
### A-3. 파일 비교 화면에서 "화면 이동" 을 누르면 원래 모니터로 되돌아온다
- 증상: 다른 탭에서는 옮겨지는데 파일 비교 탭에서 누르면 창이 원래 모니터로 복귀한다.
- 파일 비교 화면이 자기 위치를 되돌리는 코드(창 위치 복원 · 레이아웃 재계산 · 포커스 복원)를 찾아 원인을 적고 고친다.
- **판정 기준**: 파일 비교 탭에서도 다른 탭과 똑같이 다음 모니터로 옮겨지고, 그 자리에 남는다.
### A-4. "화면 이동" 을 누르면 잠금 관련 팝업이 뜬다
- 증상: 화면 이동을 눌렀는데 `LockConfirmDialog` 로 보이는 확인 창이 뜬다. 화면 이동과 화면 잠금은 별개 기능이므로 떠서는 안 된다.
- A-2 와 원인이 같을 수 있다(옮긴 뒤 다른 내비 버튼이 눌림). 같은 원인이면 함께 정리하고 그렇게 보고한다.
- **판정 기준**: 화면 이동으로는 어떤 팝업도 뜨지 않는다.
### A-5. Git 저장소를 열 때 최상위 폴더를 고를 수 없다
- 증상: `.git` 을 보려면 저장소 최상위에서 열어야 하는데, 지금은 가장 깊은 폴더만 열리는 식이라 최상위를 고를 수 없다.
- 두 가지를 모두 만족시킨다.
1. 폴더를 고르는 창에서 최상위 폴더 자체를 고를 수 있다.
2. 하위 폴더를 골랐더라도 위로 거슬러 올라가며 `.git` 이 있는 저장소 최상위를 자동으로 찾아 그곳을 연다.
- **판정 기준**: 저장소 안 아무 하위 폴더나 골라도 Git 탭이 그 저장소를 정상으로 연다.
---
## B. 3-Way 화면
### B-1. 3-Way 에서 가로 스크롤 막대가 보이지 않는다
- 지금은 가로 휠로만 움직인다. 막대가 없으면 긴 줄이 있다는 사실 자체를 모른다.
- 워드랩이 켜져 있을 때는 가로 스크롤이 필요 없으므로 막대도 숨긴다. 꺼져 있을 때만 보인다.
- 소스 3칸(LOCAL/BASE/REMOTE)과 MERGED 는 스크롤 그룹이 다르다. 막대도 그 구분을 따라야 한다.
- **판정 기준**: 워드랩을 끄고 긴 줄이 있는 파일을 3-Way 로 열면 막대가 보이고, 막대를 끌면 해당 그룹이 함께 움직인다.
### B-2. 3-Way 에서 아래로 스크롤하면 창마다 보이는 행 수가 달라진다
- 증상: 스크롤을 내릴수록 네 칸의 줄 맞춤이 어긋나 같은 줄이 나란히 보이지 않는다.
- 워드랩이 켜졌을 때와 꺼졌을 때를 모두 확인한다. 접힌 줄이 있으면 시각 줄 수가 칸마다 달라진다는 점이 원인일 가능성이 크다.
- **판정 기준**: 파일 끝까지 내려도 같은 줄 번호가 네 칸에서 같은 높이에 있다.
### B-3. Take Mine / Take Theirs 가 3-Way 에서 실제로 동작하는지 확인
- 2-Way 에서는 동작한다. 3-Way 에서 두 버튼과 단축키가 모두 먹는지, 충돌 줄에서 버튼이 회색으로 죽지 않는지 확인한다.
- 동작하지 않으면 고치고, 동작하면 "확인함" 으로 보고한다.
- **판정 기준**: 3-Way 충돌 줄에서 두 버튼이 눌리고 MERGED 에 반영되며, 해결한 만큼 남은 충돌 수가 줄어든다.
---
## C. 잔손질
### C-1. 소스 창의 코드 위 툴팁을 없앤다
- 증상: 파일 비교 화면에서 코드 위에 마우스를 올리면 그 줄 내용이 툴팁으로 다시 뜬다. 코드가 이미 보이는데 겹쳐 가려 방해만 된다.
- 코드 본문 툴팁만 없앤다. 버튼·상태 아이콘 툴팁은 그대로 둔다.
- **판정 기준**: 코드 위 어디에 올려도 툴팁이 뜨지 않는다.
### C-2. Git 탭에서 버튼과 텍스트 상자가 잘린다
- 증상: 작업 취소 등 버튼 글자와 입력 상자가 잘려 보인다.
- 창을 좁혔을 때·넓혔을 때 모두 확인한다. 고정 폭 대신 내용에 맞게 늘어나도록 정리한다.
- **판정 기준**: 창을 최소 크기까지 줄여도 글자가 잘리지 않는다.
### C-3. 폴더 복사 확인 팝업을 플랫폼 토스트 방식으로 바꾼다
- 지금은 별도 확인 창이 뜬다. 플랫폼이 제공하는 토스트 메시지와 같은 모양으로 맞춘다.
- **되돌릴 수 없는 동작(복사·삭제)의 확인 자체를 없애지는 않는다.** 확인은 남기되 생김새를 토스트와 통일한다. 완료 알림은 토스트로 띄운다.
- **판정 기준**: 폴더 복사·삭제 시 확인과 완료 알림이 프로그램의 다른 알림과 같은 모양이다.
---
## D. 기능 추가
### D-1. 코드는 같고 주석만 다른 파일을 다른 색으로
- 폴더 비교 결과에서 "코드가 다름" 과 "주석만 다름" 을 구분해 다른 색으로 보여 준다.
- 판단은 언어별 주석 기호(`LanguageProfile`)를 써서 주석과 공백을 뺀 뒤 비교한다.
- 어느 색이 무슨 뜻인지 화면 어딘가에 알려 준다.
- **판정 기준**: 주석 한 줄만 다른 파일과 코드가 다른 파일이 서로 다른 색으로 나온다.
### D-2. C++ 문법 강조
- 지금 C# 은 되고 C++ 은 안 된다. `.h .hpp .c .cpp .cc .cxx .inl` 을 포함한다.
- 키워드 · 문자열 · 주석 · 전처리기(`#include` 등)를 구분한다.
- **판정 기준**: C++ 파일을 열면 C# 과 같은 수준으로 색이 입혀진다.
### D-3. 폴더 안 전체 검색
- 폴더 비교 대상 폴더 전체에서 문자열을 찾는다.
- 대소문자 구분 · 확장자 필터 정도는 있어야 한다. 결과를 누르면 그 파일이 비교 화면으로 열린다.
- 큰 폴더에서도 화면이 멈추면 안 된다. 취소할 수 있어야 한다.
- **판정 기준**: 결과 목록에서 항목을 고르면 해당 파일이 열리고, 검색 중에도 창이 반응한다.
### D-4. 비교 창을 여러 개 띄울 수 있게
- 파일 비교를 하나만 열 수 있는 지금 구조를 여러 개로 늘린다. 탭을 늘리는 방식이 플랫폼과 맞는지 먼저 확인하고, 맞지 않으면 별도 창 방식을 검토한 뒤 어느 쪽인지 적고 시작한다.
- 창마다 스크롤 · 편집 · 저장이 서로 섞이지 않아야 한다.
- **판정 기준**: 서로 다른 파일 쌍을 세 개 열고 각각 편집·저장해도 내용이 섞이지 않는다.
### D-5. 덩어리 단위 반영
- 덩어리 단위 반영은 2-Way 에 이미 들어가 있다(`de1a43c`). **먼저 지금 무엇이 되고 무엇이 안 되는지 확인해 보고한 뒤** 빠진 곳을 채운다.
- 3-Way 화면과 단축키에서도 같은 방식으로 동작해야 한다.
- **판정 기준**: 연속된 변경 줄 묶음이 한 번의 조작으로 통째로 반영된다.
### D-6. 비교 결과 텍스트 내보내기
- 비교 결과를 텍스트 파일로 저장한다. 파일 비교와 폴더 비교 양쪽에서 된다.
- 내용에는 양쪽 경로 · 요약(같음/다름/한쪽만) · 다른 줄 목록이 들어간다.
- **판정 기준**: 내보낸 파일을 메모장으로 열어 무엇이 어떻게 다른지 알아볼 수 있다.
### D-7. 파일·폴더 드래그 앤 드롭으로 열기
- 파일 두 개를 끌어다 놓으면 파일 비교, 폴더 두 개면 폴더 비교로 연다.
- 하나만 놓으면 그 자리(왼쪽/오른쪽)에만 넣는다.
- **판정 기준**: 탐색기에서 끌어다 놓는 것만으로 비교가 시작된다.
---
## 보고 형식
항목마다 이렇게 적는다.
- 무엇이 원인이었는지 한두 줄
- 어떤 파일을 어떻게 고쳤는지
- 실제로 실행해 확인한 결과
- 커밋 해시

View File

@ -92,6 +92,89 @@ CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘
---
## 3차 — 버그 정리 + 기능 확장 (2026-09-09 ~)
지시서: `doc/작업지시-3차.md`. 실제로 써 보고 나온 문제부터 순서대로 잡는다.
### A. 죽거나 화면이 망가지는 문제
| 상태 | 항목 | 내용 | 확인 |
|---|---|---|---|
| **완료** | A-1 폴더 고르기에서 프로그램이 죽음 | 아래 따로 적음 | 왼쪽·오른쪽 10회 연속 · 파일 비교 쪽 2회 |
| 대기 | A-2 화면 이동 시 종료·최소화 | | |
| 대기 | A-3 파일 비교에서 화면 이동이 원래 모니터로 복귀 | | |
| 대기 | A-4 화면 이동 시 잠금 팝업 | | |
| 대기 | A-5 Git 저장소 최상위를 고를 수 없음 | | |
### B. 3-Way 화면
| 상태 | 항목 |
|---|---|
| 대기 | B-1 가로 스크롤 막대가 없음 |
| 대기 | B-2 아래로 내리면 칸마다 보이는 행 수가 다름 |
| 대기 | B-3 Take Mine / Take Theirs 3-Way 동작 확인 |
### C. 잔손질
| 상태 | 항목 |
|---|---|
| 대기 | C-1 코드 위 툴팁 제거 |
| 대기 | C-2 Git 탭 버튼·입력칸 잘림 |
| 대기 | C-3 폴더 복사 팝업을 토스트 모양으로 |
### D. 기능 추가
| 상태 | 항목 |
|---|---|
| 대기 | D-1 주석만 다른 파일을 다른 색으로 |
| 대기 | D-2 C++ 문법 강조 |
| 대기 | D-3 폴더 안 전체 검색 |
| 대기 | D-4 비교 창 여러 개 |
| 대기 | D-5 덩어리 단위 반영 (2-Way 는 이미 됨 · 3-Way 확인 필요) |
| 대기 | D-6 비교 결과 텍스트 내보내기 |
| 대기 | D-7 드래그 앤 드롭으로 열기 |
---
### A-1. 폴더 고르기에서 프로그램이 죽던 원인 — **완료** (2026-09-09)
"왼쪽 폴더..." 를 누르면 창이 하얗게 굳었다가 그대로 사라졌다. 윈도우 이벤트 로그에 남은 것:
```
System.ArgumentException
MS.Internal.AppModel.ShellUtil.GetShellItemForPath(System.String)
Microsoft.Win32.FileDialog.PrepareVistaDialog(...)
Microsoft.Win32.CommonDialog.ShowDialog()
MergeHub.ViewModels.FolderViewModel.AskForFolder(...)
```
**원인은 경로에 섞인 슬래시였다.** 왼쪽 폴더 칸에 들어 있던 값이
`C:/Users/Ubisam/AppData/Local/Temp/plain-compare` 였는데, .NET 의 `Directory.Exists` 는
이런 경로도 통과시킨다. 그런데 대화상자의 `InitialDirectory` 는 윈도우 셸(COM)이 그대로
해석하고, 슬래시가 섞이면 거부하며 예외를 던진다. 그 예외가 <b>대입할 때가 아니라
`ShowDialog()` 안쪽에서</b> 올라오기 때문에 대입 줄만 `try` 로 감싸 둔 곳(파일 비교 쪽)도
똑같이 죽게 돼 있었다. 오른쪽 폴더 칸은 비어 있어서 멀쩡했던 것이고, 사실상
"경로를 한 번이라도 채워 둔 채 다시 고르려 하면 죽는" 상태였다.
**고친 것**
- `Services/PathUtil.cs` 신설. 셸에 넘길 경로는 전부 여기를 거친다. 구분자를 역슬래시로
맞추고, 상대 경로를 펴고, 확인할 수 없는 경로는 빈 문자열로 돌려준다. 빈 문자열이면
부르는 쪽이 `InitialDirectory` 를 아예 지정하지 않는다.
- 폴더 고르기·파일 고르기 둘 다 `ShowDialog()` 를 감쌌다. 끊긴 네트워크 드라이브처럼
정규화로 걸러지지 않는 경우가 남기 때문에, 그럴 때는 시작 폴더 없이 한 번 더 연다.
폴더 고르기 자체를 못 쓰게 되는 것보다 낫다.
- 플랫폼(`UbisamBase.Shell/App.xaml.cs`)에 <b>전역 예외 그물</b>을 놓았다. 화면 스레드에서
예외가 새어 나오면 WPF 는 프로그램을 그대로 끝내 버린다. 현장 장비에서 버튼 하나 잘못
눌렀다고 프로그램이 사라지면 무슨 일이 있었는지도 남지 않는다. 이제 로그에 남기고
알림창을 띄운 뒤 계속 돈다. <b>조용히 삼키지는 않는다</b> - 알리지 않으면 "눌렀는데
아무 일도 안 일어난다"가 되어 더 나쁘다.
이 저장소만이 아니라 <b>플랫폼을 쓰는 모든 프로그램에 적용된다.</b>
**검사**: `PathTest` 31건 신설(슬래시 섞인 경로 · 따옴표 · 못 쓰는 글자 · 없는 드라이브 ·
아주 긴 경로 · 드라이브 뿌리). 전체 356 → **387건**.
---
## 3~4주차로 미룬 것
디테일이라 지금 붙잡을 일이 아니다.

View File

@ -0,0 +1,85 @@
using System;
using System.IO;
namespace MergeHub.Services
{
/// <summary>
/// 경로를 "윈도우 셸이 받아들이는 모양"으로 맞춰 주는 곳.
///
/// 왜 필요한가 - .NET 의 <see cref="Directory.Exists"/> 는 <c>C:/a/b</c> 처럼 슬래시를 섞어 써도
/// 통과시킨다. 그런데 파일 대화상자의 <c>InitialDirectory</c> 는 셸(COM)이 그대로 해석하고,
/// 슬래시가 섞인 경로를 받으면 <see cref="ArgumentException"/> 을 던진다.
/// 그 예외는 <c>ShowDialog()</c> 안쪽에서 올라오기 때문에 대입할 때 감싸 봐야 소용이 없고,
/// 잡지 않으면 프로그램이 그대로 죽는다(실제로 "왼쪽 폴더..." 를 누르면 죽던 원인이 이것이다).
///
/// 그래서 셸에 넘기는 경로는 반드시 여기를 거친다. 확인할 수 없는 경로는 빈 문자열이 되고,
/// 부르는 쪽은 그때 <c>InitialDirectory</c> 를 아예 지정하지 않으면 된다.
/// </summary>
public static class PathUtil
{
/// <summary>폴더 경로를 정규화한다. 실제로 있는 폴더가 아니면 빈 문자열.</summary>
public static string FullFolderPath(string? path)
{
var full = Normalize(path);
if (full.Length == 0) return string.Empty;
try
{
return Directory.Exists(full) ? full : string.Empty;
}
catch (IOException)
{
return string.Empty;
}
catch (UnauthorizedAccessException)
{
return string.Empty;
}
}
/// <summary>파일 경로가 든 폴더를 정규화해서 돌려준다. 확인할 수 없으면 빈 문자열.</summary>
public static string FolderOfFile(string? filePath)
{
var full = Normalize(filePath);
if (full.Length == 0) return string.Empty;
try
{
return FullFolderPath(Path.GetDirectoryName(full));
}
catch (ArgumentException)
{
return string.Empty;
}
}
/// <summary>
/// 구분자를 역슬래시로 맞추고 상대 경로를 절대 경로로 편다.
/// 경로로 쓸 수 없는 글자가 들어 있으면 빈 문자열.
/// </summary>
private static string Normalize(string? path)
{
if (string.IsNullOrWhiteSpace(path)) return string.Empty;
try
{
return Path.GetFullPath(path!.Trim());
}
catch (ArgumentException)
{
return string.Empty;
}
catch (NotSupportedException)
{
return string.Empty;
}
catch (PathTooLongException)
{
return string.Empty;
}
catch (IOException)
{
return string.Empty;
}
}
}
}

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
@ -229,17 +229,26 @@ namespace MergeHub.ViewModels
Filter = "폴더 (*.*)|*.*"
};
if (!string.IsNullOrWhiteSpace(current) && Directory.Exists(current))
{
dialog.InitialDirectory = current;
}
// 시작 폴더는 반드시 PathUtil 을 거친다. 슬래시가 섞인 경로("C:/a/b")를 그대로 넘기면
// ShowDialog() 안쪽에서 ArgumentException 이 나고 프로그램이 죽는다. PathUtil.cs 참고.
var start = PathUtil.FullFolderPath(current);
if (start.Length > 0) dialog.InitialDirectory = start;
if (dialog.ShowDialog() != true) return null;
try
{
if (dialog.ShowDialog() != true) return null;
}
catch (Exception)
{
// 그래도 셸이 시작 폴더를 거부하는 경우(끊긴 네트워크 드라이브 등)가 남는다.
// 폴더 고르기를 아예 못 쓰게 되는 것보다 기본 위치에서 다시 여는 편이 낫다.
dialog.InitialDirectory = string.Empty;
if (dialog.ShowDialog() != true) return null;
}
var path = Path.GetDirectoryName(dialog.FileName);
return string.IsNullOrEmpty(path) ? null : path;
}
private bool CanCompare() => !IsBusy;
[RelayCommand(CanExecute = nameof(CanCompare))]

View File

@ -1431,13 +1431,22 @@ namespace MergeHub.ViewModels
Filter = "모든 파일 (*.*)|*.*"
};
if (!string.IsNullOrWhiteSpace(current))
// 시작 폴더는 PathUtil 을 거친다 - 슬래시가 섞인 경로를 그대로 넘기면
// 대입할 때가 아니라 ShowDialog() 안쪽에서 예외가 나 프로그램이 죽는다. PathUtil.cs 참고.
var start = PathUtil.FolderOfFile(current);
if (start.Length > 0) dialog.InitialDirectory = start;
try
{
try { dialog.InitialDirectory = Path.GetDirectoryName(current); }
catch (ArgumentException) { /* 경로가 깨졌으면 그냥 기본 위치에서 연다 */ }
return dialog.ShowDialog() == true ? dialog.FileName : null;
}
catch (Exception)
{
// 셸이 시작 폴더를 거부하면 기본 위치에서 다시 연다.
dialog.InitialDirectory = string.Empty;
return dialog.ShowDialog() == true ? dialog.FileName : null;
}
return dialog.ShowDialog() == true ? dialog.FileName : null;
}
/// <summary>고른 두 파일을 읽어 비교 화면을 만든다.</summary>