diff --git a/doc/작업지시-3차.md b/doc/작업지시-3차.md new file mode 100644 index 0000000..fc8e88f --- /dev/null +++ b/doc/작업지시-3차.md @@ -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. 파일·폴더 드래그 앤 드롭으로 열기 + +- 파일 두 개를 끌어다 놓으면 파일 비교, 폴더 두 개면 폴더 비교로 연다. +- 하나만 놓으면 그 자리(왼쪽/오른쪽)에만 넣는다. +- **판정 기준**: 탐색기에서 끌어다 놓는 것만으로 비교가 시작된다. + +--- + +## 보고 형식 + +항목마다 이렇게 적는다. + +- 무엇이 원인이었는지 한두 줄 +- 어떤 파일을 어떻게 고쳤는지 +- 실제로 실행해 확인한 결과 +- 커밋 해시 diff --git a/doc/진행상황.md b/doc/진행상황.md index e85e5b7..2b94723 100644 --- a/doc/진행상황.md +++ b/doc/진행상황.md @@ -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)이 그대로 +해석하고, 슬래시가 섞이면 거부하며 예외를 던진다. 그 예외가 대입할 때가 아니라 +`ShowDialog()` 안쪽에서 올라오기 때문에 대입 줄만 `try` 로 감싸 둔 곳(파일 비교 쪽)도 +똑같이 죽게 돼 있었다. 오른쪽 폴더 칸은 비어 있어서 멀쩡했던 것이고, 사실상 +"경로를 한 번이라도 채워 둔 채 다시 고르려 하면 죽는" 상태였다. + +**고친 것** + +- `Services/PathUtil.cs` 신설. 셸에 넘길 경로는 전부 여기를 거친다. 구분자를 역슬래시로 + 맞추고, 상대 경로를 펴고, 확인할 수 없는 경로는 빈 문자열로 돌려준다. 빈 문자열이면 + 부르는 쪽이 `InitialDirectory` 를 아예 지정하지 않는다. +- 폴더 고르기·파일 고르기 둘 다 `ShowDialog()` 를 감쌌다. 끊긴 네트워크 드라이브처럼 + 정규화로 걸러지지 않는 경우가 남기 때문에, 그럴 때는 시작 폴더 없이 한 번 더 연다. + 폴더 고르기 자체를 못 쓰게 되는 것보다 낫다. +- 플랫폼(`UbisamBase.Shell/App.xaml.cs`)에 전역 예외 그물을 놓았다. 화면 스레드에서 + 예외가 새어 나오면 WPF 는 프로그램을 그대로 끝내 버린다. 현장 장비에서 버튼 하나 잘못 + 눌렀다고 프로그램이 사라지면 무슨 일이 있었는지도 남지 않는다. 이제 로그에 남기고 + 알림창을 띄운 뒤 계속 돈다. 조용히 삼키지는 않는다 - 알리지 않으면 "눌렀는데 + 아무 일도 안 일어난다"가 되어 더 나쁘다. + 이 저장소만이 아니라 플랫폼을 쓰는 모든 프로그램에 적용된다. + +**검사**: `PathTest` 31건 신설(슬래시 섞인 경로 · 따옴표 · 못 쓰는 글자 · 없는 드라이브 · +아주 긴 경로 · 드라이브 뿌리). 전체 356 → **387건**. + +--- ## 3~4주차로 미룬 것 디테일이라 지금 붙잡을 일이 아니다. diff --git a/src/MergeHub/Services/PathUtil.cs b/src/MergeHub/Services/PathUtil.cs new file mode 100644 index 0000000..016e15a --- /dev/null +++ b/src/MergeHub/Services/PathUtil.cs @@ -0,0 +1,85 @@ +using System; +using System.IO; + +namespace MergeHub.Services +{ + /// + /// 경로를 "윈도우 셸이 받아들이는 모양"으로 맞춰 주는 곳. + /// + /// 왜 필요한가 - .NET 의 는 C:/a/b 처럼 슬래시를 섞어 써도 + /// 통과시킨다. 그런데 파일 대화상자의 InitialDirectory 는 셸(COM)이 그대로 해석하고, + /// 슬래시가 섞인 경로를 받으면 을 던진다. + /// 그 예외는 ShowDialog() 안쪽에서 올라오기 때문에 대입할 때 감싸 봐야 소용이 없고, + /// 잡지 않으면 프로그램이 그대로 죽는다(실제로 "왼쪽 폴더..." 를 누르면 죽던 원인이 이것이다). + /// + /// 그래서 셸에 넘기는 경로는 반드시 여기를 거친다. 확인할 수 없는 경로는 빈 문자열이 되고, + /// 부르는 쪽은 그때 InitialDirectory 를 아예 지정하지 않으면 된다. + /// + public static class PathUtil + { + /// 폴더 경로를 정규화한다. 실제로 있는 폴더가 아니면 빈 문자열. + 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; + } + } + + /// 파일 경로가 든 폴더를 정규화해서 돌려준다. 확인할 수 없으면 빈 문자열. + 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; + } + } + + /// + /// 구분자를 역슬래시로 맞추고 상대 경로를 절대 경로로 편다. + /// 경로로 쓸 수 없는 글자가 들어 있으면 빈 문자열. + /// + 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; + } + } + } +} diff --git a/src/MergeHub/ViewModels/FolderViewModel.cs b/src/MergeHub/ViewModels/FolderViewModel.cs index e00776c..37d7907 100644 --- a/src/MergeHub/ViewModels/FolderViewModel.cs +++ b/src/MergeHub/ViewModels/FolderViewModel.cs @@ -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))] diff --git a/src/MergeHub/ViewModels/HomeViewModel.cs b/src/MergeHub/ViewModels/HomeViewModel.cs index 785452f..32aba08 100644 --- a/src/MergeHub/ViewModels/HomeViewModel.cs +++ b/src/MergeHub/ViewModels/HomeViewModel.cs @@ -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; } /// 고른 두 파일을 읽어 비교 화면을 만든다.