12차 - 폴더 비교 읽기 쉽게 · 양방향 머지 · 칸 요약 · 정보 창 · 문서 탭
새 플랫폼(UbisamBase.Launcher) 위로 옮기면서 12차 A~E 를 한 번에 담는다.
항목별 값과 판정은 doc/history/2026-09-23-12차.md, 요약은 doc/history/changelog.md.
A. 폴더 비교를 읽기 쉽게
- A-1 폴더 줄의 크기가 늘 0B 이던 것 - 안에 든 파일 크기 합계를 아래에서 위로 더해 적는다
(걸러 낸 파일은 합계에서도 빠진다)
- A-2 좌우로 나눠 보이기 - 왼쪽 · 오른쪽 목록을 나란히 두고 같은 줄에 맞춘다.
한쪽에만 있는 항목은 반대편을 비운다
B. 파일 비교 화면
- B-1 2-Way 에서 양쪽으로 머지 - 칸마다 반영 · 저장. 왼쪽 기준점을 따로 세고,
되돌리기 · 저장 백업도 양쪽 모두
- B-2 프로그램 정보 창 - 버전 · 빌드 날짜 · 만든 사람 · 사내 문의처 · 플랫폼 버전 · 로그 폴더
- B-3 칸 아래에 무엇이 달라졌는지 - 칸마다 줄 수 · 추가 · 삭제 · 수정, 가운데에 진행
C. 플랫폼 (플랫폼 저장소에서 함께 나감)
- C-2 하단 탭 줄을 한 단계 작게 - 74 -> 61 DIP. 실측 133 -> 109px 이고
그만큼 코드 칸이 늘어 파일 비교가 47 -> 48줄
E. 저장하지 않아도 남는 편집
- E-2 여러 짝을 동시에(문서 탭). 지금까지는 한 짝만 들어서 짝을 바꾸면
저장하지 않은 편집을 묻고 버리면 사라졌다(재현: 16줄 -> 15줄).
이제 떠 있던 탭이 그 글을 맡아 두므로 묻지 않는다.
- 탭마다 HomeViewModel 을 따로 만들지 않았다 - 이 화면은 GitWorkspace 의 여러 사건과
한 자리짜리 약속(ConfirmDiscardEdits · ConfirmLeaveCommit)에 물려 있다.
화면은 하나로 두고 탭이 파일에 없는 것만 맡는다(고친 글 · 왼쪽 글 · 되돌리기 이력 ·
두 기준점 · 캐럿 · 스크롤 · 저장 형식)
- 탭 이름은 파일 이름, 다르면 왼 <-> 오. 양쪽이 같은 부분은 한 번만 적는다
(WeldController_기준 <-> 내작업.cs, 421 -> 288px). 같은 이름이 겹치면
갈릴 때까지 위 폴더를 한 겹씩 덧붙인다
- 저장하지 않은 탭에 가운뎃점. 사람이 손댄 것만 센다 - 중괄호 맞춤으로만 바뀐 것은
묻는 자리와 같은 기준으로 뺀다(안 그러면 .cs 를 여는 것만으로 늘 점이 붙는다)
- 탭이 사라져도 고친 글은 보관함에 남아 다시 열면 되살아난다(짝 50개).
E-1(껐다 켜도 남는 편집)은 이 보관함을 파일로 옮겨 적으면 된다
- 이전 · 다음 파일은 지금 탭의 짝만 바꾸고, 폴더 비교에서 두 번 누르면 새 탭(사용자 결정)
- Ctrl+Tab · Ctrl+Shift+Tab · Ctrl+W · 가운데 단추 닫기. 닫을 때도 묻지 않는다
- Git 탭 · 병합 중 네 칸은 탭이 맡지 않는다 - 그쪽은 예전처럼 묻는다
하네스 27벌 1,787건 통과 · 실패 0(DocTabTest 106건 새로 만듦).
빌드 경고 0 · 오류 0. 화면으로 E-2 34건 · 회귀 19건 확인.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
3c8d33f8be
commit
13988d1f0a
415
doc/history/2026-09-23-12차.md
Normal file
415
doc/history/2026-09-23-12차.md
Normal file
@ -0,0 +1,415 @@
|
||||
# 12차 진행 기록 (2026-09-23 ~)
|
||||
|
||||
지시서: [../작업지시/12차.md](../작업지시/12차.md)
|
||||
|
||||
## A-1. 폴더 줄의 크기가 늘 0B (2026-09-27, 완료)
|
||||
|
||||
### 무엇이 문제였나
|
||||
|
||||
크기를 **파일 줄에만** 채웠다(`FolderComparer` 의 파일 루프에서 `FileInfo.Length`). 폴더 줄은 `LeftSize` ·
|
||||
`RightSize` 가 0 인 채로 화면에 내려가 어느 폴더든 `0 B` 로 보였다. 어느 쪽이 더 큰지, 어느 폴더가 무거운지를
|
||||
목록에서 알 수 없었다.
|
||||
|
||||
### 어떻게 고쳤나
|
||||
|
||||
`Services/FolderSizeText.cs` 를 새로 두었다. 화면 없이 검사할 수 있게 계산만 떼어 낸 것이다.
|
||||
|
||||
| 하는 일 | 함수 |
|
||||
|---|---|
|
||||
| 사람이 읽는 크기로 적기 | `Of(long bytes)` — 1KB 미만은 바이트 그대로(`999 B`), 그 위는 소수 한 자리(`1.5KB` · `2.4MB` · `3GB`) |
|
||||
| 폴더 줄에 합계 채우기 | `FillFolderSizes(items)` — 아래에서 위로, 폴더는 자기 아래 것들의 합 |
|
||||
|
||||
- 부르는 자리는 `FolderComparer.Compare` 의 **`ClassifyAll` 다음, `Tally` 앞**이다. 파일 갈래를 정하는 판단은
|
||||
파일 크기를 보고 하므로(크기가 다르면 열지 않는다) 그보다 **먼저 채우면 판단이 달라진다** — 그래서 뒤에 둔다.
|
||||
- 걸러 낸 것은 합계에서도 빠진다. 포함 · 제외 규칙에 걸린 파일은 애초에 목록에 없으므로 따로 뺄 일이 없다.
|
||||
- 한쪽에만 있는 폴더는 그쪽만 합계고 반대쪽은 0 이다(화면에는 `-`).
|
||||
- 화면 쪽은 `FolderViewModel.Describe` 가 `{크기} · {수정일}` 을 만들 때 `FolderSizeText.Of` 를 쓰게 바꿨다.
|
||||
예전에는 `{바이트:N0} B` 라 300,000 B 처럼 읽기 어려웠다.
|
||||
|
||||
### 값으로 확인한 것
|
||||
|
||||
시험 재료는 만들어 썼다(`%TEMP%\mergehub-a1`, 실제 업무 저장소는 읽지 않는다).
|
||||
|
||||
| 폴더 | 화면 | 탐색기 속성(파일 크기 합) |
|
||||
|---|---|---|
|
||||
| `Docs` | 2.4MB · 2.4MB | 2,500,000 · 2,500,000 |
|
||||
| `Legacy`(왼쪽만) | 50KB · `-` | 51,200 · 없음 |
|
||||
| `NewOnly`(오른쪽만) | `-` · 40KB | 없음 · 40,960 |
|
||||
| `Src` | 490.2KB · 491.7KB | 502,000 · 503,500 (기본 제외 규칙에 걸린 `Src\bin` 9MB 는 빠짐) |
|
||||
| `Src\Core` | 472.7KB · 474.1KB | 484,000 · 485,500 |
|
||||
| `Src\Core\Motion` | 179.7KB · 181.2KB | 184,000 · 185,500 |
|
||||
| `Src\Ui` | 17.6KB · 17.6KB | 18,000 · 18,000 |
|
||||
|
||||
- 파일 줄도 함께 읽기 쉬워졌다 — `Unit0.cs 293KB` · `Home.cs 62.5KB / 64KB` · `manual.txt 2.4MB`.
|
||||
- **비교 시간**: 4,608 줄(136MB · 파일 8,922개) 짜리 묶음에서 합계 채우기 **0ms**(빠른 비교 203ms 대비 0.0%).
|
||||
디스크를 다시 읽지 않고 이미 만들어 둔 목록을 한 번 훑을 뿐이라 늘어날 여지가 없다.
|
||||
|
||||
### 하네스
|
||||
|
||||
`FolderTest` **88 → 111건(+23)** · 실패 0.
|
||||
|
||||
- 글자 8건: 1KB 미만 · 1024 · 소수 한 자리 · 딱 떨어질 때 · MB · GB · TB 를 넘을 때 · 음수
|
||||
- 합계 9건: 여러 겹 · 양쪽 따로 · 가운데 폴더 · 맨 안쪽 · 한쪽에만 있는 폴더 · 파일 줄은 그대로 ·
|
||||
탐색기 속성과 같은 값(양쪽)
|
||||
- 걸러 내기 3건: 제외한 폴더 · 제외해도 다른 줄은 그대로 · 포함 규칙에 안 맞는 파일
|
||||
- 판단이 안 바뀌는지 4건: 크기가 다른 이진 파일 · 같은 파일 · 한쪽에만 있는 폴더 · 하위 폴더를 안 볼 때
|
||||
|
||||
### 남긴 것
|
||||
|
||||
- 폴더 줄의 **수정일**은 그대로 비워 둔다. 폴더의 수정 시각은 안의 파일과 무관하게 바뀌어(윈도가 하위 항목을
|
||||
더하거나 지울 때만 갱신) 사람이 기대하는 값과 다르다 — 크기만 적는 편이 오해가 적다.
|
||||
|
||||
## A-2. 좌우로 나눠 보이기 (2026-09-27, 완료)
|
||||
|
||||
### 무엇이 문제였나
|
||||
|
||||
한 줄에 `이름 | 상태 | 왼쪽(크기·수정일) | 오른쪽(크기·수정일)` 이 늘어서 있었다. 이름이 한 번만 나오니
|
||||
"이 줄이 어느 쪽 것인가"를 상태 글자를 읽어야 알 수 있었고, 한쪽에만 있는 항목도 `-` 한 글자로만 표시됐다.
|
||||
|
||||
### 어떻게 고쳤나
|
||||
|
||||
한 줄을 **왼쪽 목록 · 가운데 상태 · 오른쪽 목록** 세 칸으로 나눴다(11차 F-10 파일 비교와 같은 모양).
|
||||
|
||||
- 칸마다 **자기 쪽 이름과 크기**를 적는다. 그쪽에 없으면 그 칸을 통째로 비운다 — 빈칸이 "여기엔 없다"를
|
||||
상태 글자보다 먼저 말해 준다. 없는 쪽 크기를 `-` 로 적던 것도 뺐다.
|
||||
- 가운데 칸이 두 목록을 가르는 선이다(같음 · 다름 · 왼쪽만 · 오른쪽만).
|
||||
|
||||
**줄마다 칸이 어긋나는 문제**가 하나 있었다. TreeView 는 깊이만큼 <b>줄 전체</b>를 오른쪽으로 밀기 때문에,
|
||||
칸 폭을 고정해도 깊은 줄일수록 가운데 · 오른쪽 칸이 오른쪽으로 밀려 두 목록으로 읽히지 않는다.
|
||||
그래서 `TreeDepthMarginConverter`(새로 둠)로
|
||||
|
||||
1. 줄 바깥틀을 민 만큼 **되돌리고**(`Negative=True`, 깊이 × 19 DIP 음수 여백) — 칸 경계와 가운데 구분선이 줄마다 같은 자리에 온다,
|
||||
2. 이름에만 **다시 같은 값을 들여쓴다** — 계층은 그대로 보이고, 왼쪽 · 오른쪽 이름이 같은 높이 · 같은 들여쓰기로 맞는다.
|
||||
|
||||
트리 자체는 그대로 쓴다(목록 둘을 따로 두고 스크롤 · 펼침을 맞추는 길은 택하지 않았다) — 접기 · 펼치기,
|
||||
선택, 복사 · 삭제, 걸러 보기, 더블클릭으로 파일 비교 열기가 손대지 않은 채로 남는다.
|
||||
|
||||
### 화면으로 확인한 것
|
||||
|
||||
| 판정 기준 | 결과 |
|
||||
|---|---|
|
||||
| 같은 이름이 같은 높이에 온다 | `Src > Core > Motion > Axis.cs` 까지 네 겹 모두 양쪽 같은 줄 · 같은 들여쓰기 |
|
||||
| 한쪽만 있는 항목이 어느 쪽인지 한눈에 | `Legacy`(왼쪽만)는 오른쪽 칸이 빈칸, `NewOnly`(오른쪽만)는 왼쪽 칸이 빈칸 |
|
||||
| 접기 · 펼치기 | 모두 펼침 · 모두 접기 모두 그대로. 칸 경계는 깊이가 달라도 한 자리 |
|
||||
| 걸러 보기 | 왼쪽에만 2줄 · 오른쪽에만 2줄 · 같은 것만 2줄 · 다른 것만 6줄 |
|
||||
| 복사 단추 | `Legacy` 고르고 [→ 오른쪽으로 복사] → 확인 창(본 창 가운데) → 복사됨. 다시 비교하니 `왼쪽만 1 · 같음 4` 가 `왼쪽만 0 · 같음 5` 로 |
|
||||
| 좁은 창 | 1750×1100(1166 DIP) 창모드에서도 세 칸(420+170+420 DIP) 이 다 보인다 |
|
||||
|
||||
### 하네스
|
||||
|
||||
`FolderTest` **111 → 118건(+7)** · 실패 0 — 뿌리 여백 0 · 한 겹 19 · 세 겹 57 · 되돌리는 쪽은 음수 ·
|
||||
되돌린 뒤 다시 들여쓰면 제자리 · 위아래 여백은 안 건드림 · 깊이가 아니거나 음수면 0.
|
||||
|
||||
### 남긴 것
|
||||
|
||||
- 칸 폭은 고정(420 · 170 · 420 DIP)이다. 트리가 깊이마다 폭을 다르게 잡아서 비율(`*`) 폭을 쓰면 줄마다 칸이
|
||||
달라진다 — 화면이 아주 좁으면 오른쪽 목록이 잘린다(1166 DIP 까지는 확인했다).
|
||||
|
||||
## B-1. 2-Way 에서 양쪽으로 머지 (2026-09-27, 완료)
|
||||
|
||||
지시서에 남겨 둔 "정할 것"을 물어 정했다 — **두 파일 비교에서만 양방향**(사용자 결정 2026-09-27).
|
||||
Git 모드에서 왼쪽은 지난 커밋이라 고쳐 쓸 파일이 없고, 네 칸(3-Way)의 BASE 는 공통 조상이라 거기에 쓰는 것이 뜻이 없다.
|
||||
|
||||
### 무엇이 문제였나
|
||||
|
||||
반영이 BASE → LOCAL 한 방향뿐이고 저장도 오른쪽 파일에만 됐다. 두 파일을 견주다 **왼쪽이 맞는 경우**
|
||||
(오른쪽에서 잘못 지웠거나, 왼쪽 파일도 같이 맞춰야 하는 경우)에는 도구 밖에서 손으로 고쳐야 했다.
|
||||
|
||||
### 어떻게 고쳤나
|
||||
|
||||
| 무엇 | 어디 |
|
||||
|---|---|
|
||||
| 줄 반영 `[<]` | BASE 칸 여백(왼쪽에 있는 줄) · LOCAL 칸 여백(왼쪽에 **없는** 줄 - BASE 칸에는 그 줄 자체가 없어 달 자리가 없다) |
|
||||
| 칸마다 저장 | ACTIONS 의 `◀ 왼쪽 저장` · `오른쪽 저장 ▶`. 고친 것이 없는 쪽은 꺼진다 |
|
||||
| 반영 규칙 | `Services/PaneText.cs`(새로 둠) — 두 방향이 **같은 규칙**을 쓴다 |
|
||||
| 되돌리기 | 편집 기록(`LineEdit`)에 `Side` 를 더해 어느 칸을 고친 편집인지 들고 다닌다. 한 스택에 쌓여 누른 차례대로 풀린다 |
|
||||
| 저장 기준점 | 왼쪽 · 오른쪽을 따로 센다(`_leftBaseline`) — 한쪽을 저장해도 다른 쪽의 "저장할 것 있음"은 그대로 |
|
||||
|
||||
반영 규칙은 어느 쪽으로든 같다. **반대쪽에 줄이 있으면 그 글을 가져오고(없던 줄이면 생긴다), 반대쪽에 없으면
|
||||
이 칸에서도 지운다.** 머지 주석(설정 "머지 주석 넣기")도 양쪽 모두 남는데, 왼쪽에 넣는 주석은
|
||||
`LOCAL(파일이름)` 을 출처로 적고 **왼쪽에만 있는 줄**로 들어간다 — 오른쪽 파일에는 섞이지 않는다.
|
||||
|
||||
`MergeLine.HasBase` · `BaseNumber` 가 이제 바뀔 수 있는 값이다(예전에는 정렬 결과라 고정이었다).
|
||||
BASE 칸 목록도 그 변화를 따라가게 했다(`PaneView` 의 live filtering) — 이게 없으면 왼쪽에 넣은 줄이 안 보이고
|
||||
왼쪽에서 지운 줄이 빈 칸으로 남는다(화면 시험에서 실제로 걸려 고쳤다).
|
||||
|
||||
### 화면으로 확인한 것
|
||||
|
||||
시험 재료는 만들어 썼다(`%TEMP%\mergehub-b1`, left.cs · right.cs).
|
||||
|
||||
| 판정 기준 | 결과 |
|
||||
|---|---|
|
||||
| 왼쪽 파일에 반영 · 저장된다 | 값이 다른 줄 · 왼쪽에만 있는 줄 · 오른쪽에만 있는 줄 셋을 `[<]` 로 반영 → `◀ 왼쪽 저장` → 왼쪽 파일이 오른쪽 내용 + 머지 주석 2줄 |
|
||||
| 오른쪽은 그대로다 | 오른쪽 파일 해시 `07f16198…` 저장 전후 같음. `오른쪽 저장 ▶` 은 고친 것이 없어 꺼져 있었다 |
|
||||
| 되돌리기(11차 F-8) | Undo 세 번에 BASE 칸이 처음 모습으로(값 100 · 왼쪽에만 있던 줄 · 주석 사라짐), Redo 켜짐. 저장한 뒤라 `◀ 왼쪽 저장` 은 다시 켜진다(저장한 글과 달라졌으므로 - F-8 의 뜻 그대로) |
|
||||
| 저장할 때 백업(11차 F-9) | `{설정}\Backup\MergeHub\2026-09-27\…\left.20260927-191108.cs` |
|
||||
| 두 파일 비교에서만 | git mergetool 네 칸(`--merge --base --local --remote --merged`)에서 `[<]`(새 단추) 0개 · `◀ 왼쪽 저장` 안 보임. 네 칸의 기존 `<`(Take Mine · Take Theirs)는 그대로 |
|
||||
|
||||
### 하네스
|
||||
|
||||
`TwoWayTest` **29건 새로** · 실패 0 — 칸의 글 만들기 3 · 반영 규칙 7(양방향 · 한쪽에만 있는 줄 · 같은 줄 ·
|
||||
멱등) · 왼쪽으로 반영한 뒤 5 · 오른쪽으로 반영한 뒤 3(예전 길 그대로) · 양쪽 저장 기준점 5 · 로그 문구 4 · 기타 2.
|
||||
|
||||
### 남긴 것
|
||||
|
||||
- 왼쪽 칸은 **타이핑으로는 못 고친다**. 편집기는 오른쪽에만 겹쳐 있다 - 왼쪽은 `[<]` 로 반영해서 고친다
|
||||
(지시서가 정한 것: "칸마다 저장 단추 · 왼쪽 칸에도 반영 단추 [<]").
|
||||
- 왼쪽으로 반영한 줄에는 "머지 반영함" 색을 칠하지 않는다. 반영하고 나면 양쪽이 같아져 색이 필요 없다.
|
||||
|
||||
---
|
||||
|
||||
## B-2. 프로그램 정보 창 (2026-09-27, 완료)
|
||||
|
||||
지시서의 "정할 것"을 물어 정했다(사용자 결정 2026-09-27).
|
||||
|
||||
- **넣을 것**: 기본 4가지(프로그램 이름 · 버전 · 빌드 날짜 · 만든 사람) + **사내 문의처**.
|
||||
- **버전 출처**: `MergeHub.csproj` 의 `<Version>` 을 실행 파일에서 읽는다. 12차 = `0.12.0`.
|
||||
|
||||
### 어떻게 만들었나
|
||||
|
||||
| 값 | 어디서 |
|
||||
|---|---|
|
||||
| 이름 · 만든 곳 | 어셈블리의 `Product`(MergeHub) · `Company`(UbiSam) — csproj 에 적는다 |
|
||||
| 버전 | 어셈블리 버전. 뒤가 0 인 자리는 떼고 적는다(`0.12.0.0` → `0.12`) |
|
||||
| 빌드 날짜 | **csproj 가 빌드할 때 박아 둔 값**(`AssemblyMetadata "BuildDate"`) |
|
||||
| 만든 사람 · 문의처 | 설정 `프로그램 정보 > 만든 사람 · 사내 문의처`. 비우면 만든 사람은 회사 이름, 문의처는 "적혀 있지 않습니다" |
|
||||
|
||||
빌드 날짜를 어셈블리에 박는 까닭 — 실행 파일(PE) 머리의 시각 자리를 읽어 보니 **요즘 SDK 는 결정적 빌드라
|
||||
그 자리에 내용 해시**가 들어간다(날짜로 읽으면 2100년 같은 값). 그래서 csproj 에서 빌드 시각을 박고,
|
||||
못 찾을 때만 PE 시각 → 파일 수정 시각 순으로 물러난다(`ProgramInfoReader.ChooseBuildDate`).
|
||||
파일 수정 시각을 첫째로 두지 않는 까닭은 복사 · 압축 풀기로 바뀌기 때문이다.
|
||||
|
||||
창은 [단축키] 옆 **[정보]** 단추로 연다. 단축키 창과 같은 규칙 — 이미 떠 있으면 새로 띄우지 않고 앞으로 가져온다.
|
||||
[정보 복사] 로 네 줄을 그대로 클립보드에 담는다(결함을 알릴 때 첫 질문이 늘 "어느 버전이냐" 라서).
|
||||
|
||||
### 화면으로 확인한 것
|
||||
|
||||
| 판정 기준 | 결과 |
|
||||
|---|---|
|
||||
| 버전이 실제 실행 파일 값과 같다 | 창 `0.12` ↔ `MergeHub.dll` 어셈블리 버전 `0.12.0.0` |
|
||||
| 빌드 날짜가 실제 값과 같다 | 창 `2026-09-27 19:51` ↔ 실행 파일에 박힌 `BuildDate` `2026-09-27 19:51` |
|
||||
| 창을 닫아도 화면 상태가 그대로다 | 닫기 전후 화면 같음(BASE · LOCAL 칸 · 선택 줄 · ACTIONS 상태 그대로) |
|
||||
| 설정에서 받은 값 | 만든 사람 · 문의처를 적으니 그대로 보이고, 비우면 회사 이름 · 안내 글 |
|
||||
|
||||
### 하네스
|
||||
|
||||
`AboutTest` **28건 새로** · 실패 0 — 버전 글 5 · PE 시각 읽기 9 · 빌드 날짜 고르기 4 ·
|
||||
적혀 있지 않은 칸 4 · 실제 실행 파일 대조 5(버전 · 박힌 날짜 · 창에 적을 값 한 벌 · 문의처 빈칸)
|
||||
|
||||
### 남긴 것
|
||||
|
||||
- **만든 사람 · 사내 문의처는 비워 두었다.** 사람 이름 · 연락처는 지어낼 값이 아니다 -
|
||||
설정 > MergeHub > 프로그램 정보 에서 적으면 그때부터 창에 나온다.
|
||||
- 버전은 차수에 맞춰 손으로 올린다(12차 = 0.12.0). 올리는 자리는 csproj 한 곳이다.
|
||||
|
||||
## B-3. 칸 아래에 무엇이 달라졌는지 (2026-09-27, 완료)
|
||||
|
||||
### 무엇이 문제였나
|
||||
|
||||
요약이 창 맨 아래 상태 줄 한 곳에만 있었다("총 7줄 · 남은 차이 3줄"). 칸이 둘 · 넷이 되면서
|
||||
**이 칸에 몇 줄이 있고 저쪽과 무엇이 다른가**는 눈으로 세야 했다.
|
||||
|
||||
### 어떻게 고쳤나
|
||||
|
||||
- **칸마다 한 줄**을 칸 아래에 붙였다 — `6줄 · 추가 1 · 삭제 1 · 수정 1`.
|
||||
다른 것이 없으면 `6줄 · 같음`, 0 인 칸은 아예 적지 않는다(0 을 늘어놓으면 읽을 것만 는다).
|
||||
- **가운데(칸 사이 틈) 아래에 진행** — 위에 `3 / 7`(몇 번째 차이를 보고 있나), 아래에 `남은 2`(아직 다른 줄).
|
||||
틈이 48 DIP 라 두 줄로 짧게 적는다. 남은 것이 없으면 아랫줄은 비운다.
|
||||
- 세는 규칙은 `Services/PaneSummary.cs`(새로 둠)에 모았다.
|
||||
|
||||
**칸을 기준으로 읽는다** — `추가` 는 *이 칸에만* 있는 줄, `삭제` 는 *저쪽에는 있는데 이 칸에 없는* 줄,
|
||||
`수정` 은 양쪽에 있고 글이 다른 줄이다. 그래서 두 칸의 추가 · 삭제는 서로 거울이 된다.
|
||||
무시 규칙에 걸린 줄과 우리가 넣은 머지 주석은 차이로 세지 않는다(화면 색 · 차이 이동과 같은 기준) —
|
||||
다만 **줄 수에는 들어간다**(파일에 있는 줄이므로).
|
||||
|
||||
3분할에서는 네 칸 모두 **BASE(공통 조상)와** 견준다 — "누가 무엇을 고쳤나" 가 그 화면에서 읽어야 할 것이라서다.
|
||||
BASE 칸 자신은 견줄 것이 없어 `4줄 · 기준` 이라고만 적는다.
|
||||
|
||||
### 화면으로 확인한 것
|
||||
|
||||
| 판정 기준 | 결과 |
|
||||
|---|---|
|
||||
| 반영 · 되돌리기 · 저장마다 숫자가 따라간다 | 반영 전 `6줄 · 추가 1 · 삭제 1 · 수정 1`(양쪽) · `남은 3` → `[>]` 하나 → 오른쪽 `8줄 · 추가 1 · 수정 1` · 왼쪽 `6줄 · 삭제 1 · 수정 1` · `남은 2` → Undo → 처음 값 그대로 |
|
||||
| 3분할에서도 칸마다 맞는 숫자 | LOCAL(Mine) `4줄 · 수정 1` · BASE `4줄 · 기준` · REMOTE(Theirs) `4줄 · 수정 1` · MERGED `4줄 · 수정 1` · 가운데 `- / 1` · `남은 1` |
|
||||
| 긴 파일에서 느려지지 않는다 | 5만 줄 × 두 칸 × 네 번 = **22ms**(한 번에 3ms 남짓). 목록을 한 번 훑을 뿐 파일을 다시 읽지 않는다 |
|
||||
|
||||
### 하네스
|
||||
|
||||
`SummaryTest` **25건 새로** · 실패 0 — 두 칸 요약 7 · 다른 것이 없을 때 4 · 세지 않는 줄 3 ·
|
||||
세 칸(BASE 기준) 5 · 가운데 진행 5 · 긴 파일 1.
|
||||
|
||||
### 남긴 것
|
||||
|
||||
- 맨 아래 상태 줄의 "파일 전체" 요약은 그대로 둔다 — 칸 요약과 다른 값이다(전체 줄 수 · 남은 차이 · 편집 건수).
|
||||
- 가운데 진행은 두 줄뿐이다. 틈을 넓히면 더 적을 수 있지만, 틈은 잇는 선(11차 F-10)의 자리라 좁게 두었다.
|
||||
|
||||
## C-2. 플랫폼 탭 줄 크기 줄이기 (2026-09-28, 완료 · 배포됨)
|
||||
|
||||
플랫폼(`D:\Sorce\ubsrepo`) 쪽이라 **모든 사내 프로그램에 함께 간다.** 지시서 원칙대로 코드와 값은 다 만들고
|
||||
**배포는 사용자 확인 뒤**에 한다 — 아래 "배포" 참고.
|
||||
|
||||
### 무엇을 얼마나 줄였나
|
||||
|
||||
`Themes/ShellStyles.xaml` 의 `Ubisam.Style.SidebarItem.Horizontal`(하단 탭 줄 항목) 한 곳이다.
|
||||
|
||||
| 값 | 전 | 후 |
|
||||
|---|---|---|
|
||||
| 위아래 여백 | 13 / 14 | **9 / 10** |
|
||||
| 아이콘 | 22×22 | **20×20** |
|
||||
| 아이콘 아래 틈 | 7 | **5** |
|
||||
| 글자 | 13.5 | **12.5** |
|
||||
| 항목 높이(DIP) | 74 | **61** |
|
||||
|
||||
### 값으로 잰 것
|
||||
|
||||
2560×1600 · 배율 150% · 셸 배율 1.2 에서 실제 화면 픽셀로 쟀다.
|
||||
|
||||
| | 전 | 후 |
|
||||
|---|---|---|
|
||||
| 탭 줄 높이 | **133px** | **109px** (−24px, −18%) |
|
||||
| 파일 비교 코드 칸 높이 | 1,081px | **1,105px** |
|
||||
| 코드 줄 수(줄 높이 23px) | 47줄 | **48줄** (+1) |
|
||||
|
||||
배율 1.2 기준으로는 89 → 73px 이다(사용자가 말한 "95px" 과 같은 자리 - 재는 방법에 따라 테두리 · 밑줄이 붙는다).
|
||||
|
||||
글자 · 아이콘이 작아졌지만 한글 라벨(폴더 비교 · 파일 비교 · 화면 잠금 · 바탕화면 · 종료)은 잘리지 않고,
|
||||
선택 밑줄 · 탭 접기 띠 · 오른쪽 동작 항목도 그대로다(화면 확인).
|
||||
|
||||
### 배포
|
||||
|
||||
이 항목만 빌드하면 `D:\UbisamPlatform\bin` 에 바로 반영된다. 처음에는 배포본을 백업해 두고 잠깐 바꿔치기해서
|
||||
시험하고 되돌렸는데, **그 뒤 새 플랫폼(`D:\Sorce\ubsrepo`)을 빌드하면서 함께 나갔다.**
|
||||
|
||||
- 영향: 이 플랫폼으로 만든 **모든 프로그램의 하단 탭 줄**이 함께 작아진다. 기능 · 배치는 그대로고 높이만 준다.
|
||||
- **배포 확인 (2026-09-28)**: 배포본 `UbisamBase.Core.dll` 이 지금 소스의 빌드 결과와 **해시가 같고**(BA27EE48…, 738,304바이트, 04:09),
|
||||
실제로 켜서 잰 하단 탭 줄이 **111px · 항목 109px**(배율 150% 기준 72.7 DIP)로 "후" 값과 맞았다.
|
||||
앞서 적었던 "지금 배포 폴더에는 예전 것이 그대로" 는 그 뒤 빌드로 낡은 글이 되어 고쳤다.
|
||||
|
||||
## E-2. 여러 짝을 동시에 (문서 탭) (2026-09-28, 완료)
|
||||
|
||||
### 먼저 재현 (화면, 값)
|
||||
|
||||
| 단계 | 값 |
|
||||
|---|---|
|
||||
| 짝 A 열기 | 오른쪽 15줄 · `public int Version = 2;` |
|
||||
| 고치고 저장하지 않음 | 16줄 · 마지막 줄 `// E2-MARK-ALPHA` · `[오른쪽 저장 ▶]` 켜짐 · 아래 줄 "남은 차이 3줄 · **편집 1건**" |
|
||||
| 다른 짝 열기 | 모달 "**a-right.cs 에 저장하지 않은 편집이 있습니다**" · [취소] [저장하지 않고 계속] [저장하고 계속] |
|
||||
| [저장하지 않고 계속] | 화면이 15줄로 돌아감 — **고친 글이 사라졌다**(원본 파일은 안 바뀜) |
|
||||
|
||||
한 번에 한 짝만 들기 때문이다. 짝을 바꾸는 순간 앞 짝의 편집 글이 갈 곳이 없어서 묻고, 버리면 그대로 사라진다.
|
||||
|
||||
### 고친 방식
|
||||
|
||||
**탭마다 `HomeViewModel` 을 따로 만들지 않았다.** 이 화면은 `GitWorkspace` 의 여러 사건과 <b>한 자리짜리 약속</b>
|
||||
(`ConfirmDiscardEdits` · `ConfirmLeaveCommit`)에 물려 있다 — 여럿을 만들면 같은 사건에 여러 화면이 동시에 반응하고
|
||||
약속은 마지막 것만 남는다. 그래서 **화면은 하나로 두고 탭이 편집만 맡는다.**
|
||||
|
||||
- `Services/DocTabs.cs` (새로 만듦) — 계산만 한다(화면 없이 검사할 수 있다).
|
||||
- `DocTab` : 짝 하나 · 이름(`Title`) · 저장 안 한 표시(`IsDirty`) · 떠 있는지(`IsCurrent`) · 맡아 둔 편집(`Parked`)
|
||||
- `DocTabState` : **파일에 없는 것만** 담는다 — 고친 글 · 왼쪽 글(B-1) · 되돌리기/다시하기 이력 · 두 기준점 ·
|
||||
캐럿 자리 · 스크롤 행 · 저장 형식. 파일에서 다시 읽을 수 있는 것(원본 글 · 줄 정렬)은 담지 않는다 —
|
||||
담아 두면 파일이 그 사이 바뀌었을 때 낡은 것을 보여 준다.
|
||||
- **이름 짓기** : 파일 이름, 양쪽이 다르면 `왼 ↔ 오`(최근 목록 `RecentPair.Label` 과 같은 규칙).
|
||||
같은 이름이 겹치면 **갈릴 때까지 위 폴더를 한 겹씩** 덧붙인다(`Unit0.cs` 가 여러 폴더에 있는 사내 소스 때문).
|
||||
- **보관함** : 탭을 닫거나 짝을 바꿔 탭 자체가 사라져도 고친 글은 짝 이름으로 남는다(50개).
|
||||
◀ ▶ 로 훑다가 앞 파일로 돌아왔을 때 고친 것이 사라지면 그것이 곧 E-2 가 없애려던 일이다.
|
||||
**E-1(껐다 켜도 남는 편집)은 이 보관함을 파일로 옮겨 적으면 된다.**
|
||||
- **상한 20** : 넘으면 저장할 것 없는 탭 가운데 가장 오래 안 본 것을 닫는다. **고친 탭은 절대 닫지 않는다**(넘겨서라도 연다).
|
||||
- `ViewModels/HomeViewModel.Tabs.cs` (새로 만듦) — 화면과 이어 주는 자리.
|
||||
`ParkCurrentTab`(맡기기) · `ResumePendingTab`(되살리기) · `OpenPairInTab` · `EnsureTabForCurrentPair` ·
|
||||
`GoToTab` · 명령 네 개(ActivateTab · CloseTab · NextTab · PreviousTab).
|
||||
- `HomeViewModel.cs` 세 군데만 고쳤다.
|
||||
- `OnPlainCompareRequested` : 두 파일 비교끼리 짝을 바꿀 때 **묻지 않는다**. Git · 네 칸에서 넘어올 때는 그대로 묻는다.
|
||||
- `MoveFile`(◀ ▶) : 묻지 않고 **지금 탭의 짝만 바꾼다**(사용자 결정 — 탭이 늘지 않아 목록을 훑기 좋다).
|
||||
- `LoadPlainFilesAsync` : 맨 앞에서 `EnsureTabForCurrentPair`, 맨 끝(파일을 다 읽은 뒤)에서 `ResumePendingTab`.
|
||||
- `HomeView.xaml` : 맨 위에 문서 탭 줄. **ItemsControl 이 아니라 ListBox** 다 — ItemsControl 은 UIA 에 "컨트롤" 로
|
||||
보이지 않아 화면 시험이 탭 줄을 아예 못 읽고, 키로 옮기는 것도 공짜로 얻을 수 없다. 고르기가 곧 탭 옮기기다.
|
||||
탭이 둘 이상일 때만 보인다(한 짝만 볼 때 줄을 낭비하지 않는다).
|
||||
- 단축키 : **Ctrl+Tab · Ctrl+Shift+Tab · Ctrl+W**(notepad++ · 브라우저와 같은 손버릇). 가운데 단추로도 닫힌다.
|
||||
- 닫을 때 · 옮길 때 **묻지 않는다** — 글이 보관함에 남기 때문이다.
|
||||
|
||||
### 원본 파일이 그 사이 바뀌었으면
|
||||
|
||||
**되살리고 알린다.** 사용자가 아까운 것은 자기가 고친 글이다. "파일을 연 시점"은 **새로 읽은 글**로 두어
|
||||
[전체 되돌리기]가 지금 파일로 가고, 고친 글은 편집으로 남는다 — 그래야 어느 쪽도 잃지 않는다.
|
||||
알림으로 "저장하면 바뀐 파일을 덮어씁니다" 를 말해 준다.
|
||||
|
||||
### 화면을 켜 놓고 보다 고친 두 가지 (2026-09-28)
|
||||
|
||||
만들어 놓고 실제 설정으로 켜서 눈으로 보다 찾았다. 둘 다 사용자가 고르게 했다.
|
||||
|
||||
**(가) 켜자마자 가운뎃점이 붙었다.** 설정에 C# 중괄호 맞춤이 켜져 있으면 `.cs` 를 <b>여는 것만으로</b>
|
||||
"저장할 것 있음" 이 되어, 손대지도 않은 탭에 점이 붙었다. 늘 붙어 있는 점은 뜻이 없다.
|
||||
"저장하지 않은 편집이 있습니다" 를 묻는 자리(`WillAskBeforeDiscard`)는 이미 스타일 맞춤만 바뀐 것을 빼고 세고 있었다 —
|
||||
**점도 같은 기준**(`HasTypedEdits`)을 쓰게 했다. 맡길 때도 같다(스타일로만 바뀐 글은 맡기지 않는다 — 다시 열면 똑같이 만들어진다).
|
||||
|
||||
**(나) 긴 짝 이름이 탭 줄을 많이 먹었다.** `WeldController_기준.cs ↔ WeldController_내작업.cs` 하나가 **421px(280 DIP)**,
|
||||
탭 줄의 1/9 를 차지하고 뒤가 잘려 `WeldController_내작...` 으로 보였다. 사내 소스에는 이름이 살짝만 다른 짝이 흔하다.
|
||||
**양쪽이 같은 부분은 한 번만** 적게 했다(사용자 결정).
|
||||
|
||||
| | 전 | 후 |
|
||||
|---|---|---|
|
||||
| 탭 이름 | `WeldController_기준.cs ↔ WeldController_내작...` | `WeldController_기준 ↔ 내작업.cs` |
|
||||
| 탭 폭 | 421px | **288px** (−32%, 잘림 없음) |
|
||||
|
||||
뒤쪽은 **확장자일 때만** 접는다. 글자 단위로 같은 꼬리를 세면 낱말 가운데서 잘린다 —
|
||||
`a-left.cs ↔ a-right.cs` 에서 `t.cs` 가 같다고 `a-lef ↔ right.cs` 가 되어 읽을 수 없었다(하네스에서 걸렸다).
|
||||
앞이 한 글자만 같은 것은 접지 않는다(`abc.cs ↔ axy.cs` 는 그대로).
|
||||
|
||||
### 하네스
|
||||
|
||||
`DocTabTest` **106건** 통과 — 이름 짓기(겹칠 때 폴더 덧붙이기 · 한쪽만 있는 짝) · 저장 표시 · 같은 짝 두 탭 금지 ·
|
||||
Replace · 닫기 자리 이동 · Ctrl+Tab 돌기 · 상한 · 보관함(짝 바꿔도 · 닫아도 · 모두 닫아도 · 치워도 되살아남 · 50개 한도) ·
|
||||
**같은 부분 접기**(사내 소스 짝 · 앞뒤가 다 겹치는 짝 · 한 글자만 같으면 접지 않음 · 폴더까지 겹치는 경우).
|
||||
|
||||
### 화면으로 확인 (값)
|
||||
|
||||
| 확인 | 값 |
|
||||
|---|---|
|
||||
| 폴더 비교에서 두 번 누르기 | alpha.cs → 탭 1개(줄 안 보임) · bravo.cs → **탭 2개** `alpha.cs \| bravo.cs` |
|
||||
| 짝을 바꿀 때 | **묻는 창이 뜨지 않는다** |
|
||||
| 저장 안 한 표시 | alpha 만 고쳤을 때 가운뎃점 **1개** → 둘 다 고치면 **2개** |
|
||||
| 탭을 오간 뒤 | alpha 15줄(`E2-MARK-ALPHA` 그대로) · bravo 15줄(`E2-MARK-BRAVO` 그대로) · **서로 섞이지 않음** |
|
||||
| 되돌리기 | Undo 가 그 탭 것만 되돌리고 Redo 로 돌아옴 · 다른 탭은 그대로 |
|
||||
| Ctrl+Tab | alpha 로 넘어가고 그 탭의 편집도 그대로 |
|
||||
| 저장 | 그 탭의 가운뎃점만 사라짐(2 → 1) · 저장 단추 꺼짐 · 파일에 실제로 써짐 · 다른 파일은 안 바뀜 |
|
||||
| 탭 닫기 | 묻지 않음 · 한 짝만 남자 탭 줄이 사라짐 · 오류 글 0 |
|
||||
|
||||
회귀로 함께 본 것.
|
||||
|
||||
- ◀ ▶ 로 훑기 : `1 / 2` → `2 / 2` → `1 / 2`, **탭이 늘지 않고**(0개 = 줄 안 보임) 돌아오니 고친 글 그대로(15줄).
|
||||
- 켤 때 저절로 열리는 짝(설정의 기본 짝)에서 고친 것도 탭이 맡는다 — `EnsureTabForCurrentPair` 를 넣기 전에는
|
||||
그 짝만 탭이 없어 편집이 사라졌다(화면에서 찾아 고쳤다).
|
||||
- Git 모드로 넘어갈 때는 **여전히 묻는다**(탭이 맡을 수 없는 자리). 데모 전환은 예전부터 **두 번** 묻는다 —
|
||||
화면이 한 번(`데모 폴더를 바꿉니다`), `GitWorkspace` 가 한 번(`Git 작업을 합니다: 데모 저장소 열기`). 이번 변경과 무관하다.
|
||||
- 하네스 전체 **27벌 1,777건 통과 · 실패 0**(빌드 경고 0 · 오류 0).
|
||||
|
||||
## 시험 도구를 런처에 맞추기 (2026-09-28)
|
||||
|
||||
새 플랫폼부터 프로그램을 `UbisamBase.Launcher.exe` 로 켜고, 런처가 셸을 **같은 프로세스 안에** 올린다
|
||||
(별도 프로세스로 띄우면 디버거가 안 붙어서 소비 프로젝트 중단점이 안 걸린다 — 런처 주석). 그래서 창을 든
|
||||
프로세스 이름이 `UbisamBase.Shell` 에서 `UbisamBase.Launcher` 로 바뀌었고, 예전 이름만 찾던 화면 시험
|
||||
도구가 전부 **"창이 안 뜬다"** 로 멈춰 있었다.
|
||||
|
||||
| | 전 | 후 |
|
||||
|---|---|---|
|
||||
| 켜는 파일 | `UbisamBase.Shell.exe` | `UbisamBase.Launcher.exe` (없으면 셸로 되돌아감) |
|
||||
| 창 주인 프로세스 | `UbisamBase.Shell` 만 | `UbisamBase.Launcher` · `UbisamBase.Shell` 둘 다 |
|
||||
|
||||
- `a2lib.ps1` 에 `Shell-Procs` · `Shell-Proc` · `Stop-App` 을 두고 `Start-App` · `Get-Win` 이 그것을 쓰게 했다.
|
||||
런처가 업데이트 진행 창(제목 없는 창)을 먼저 띄우는 경우를 대비해 **제목이 있는 창을 먼저** 고른다.
|
||||
- 스크립트 **76개 · 156곳**을 함께 고쳤다(옛 이름만 찾던 `Get-Process`, 지금 빌드 자리를 가리키던 실행 경로).
|
||||
고치기 전 파일은 스크래치패드 `_prelauncher\` 에 그대로 뒀다.
|
||||
- 확인: 문법 검사 187개 통과, 끄기 → 켜기 → 창 찾기 → 네 탭 넘기기 → 단추 찾기 → 끄기까지 실제로 돌았고,
|
||||
설정 · 창 자리 네 파일은 해시 그대로 되돌렸다.
|
||||
- 시험 도구일 뿐 **제품 코드는 바뀌지 않았다.**
|
||||
|
||||
## 여기까지의 하네스 (2026-09-27)
|
||||
|
||||
A-1 · A-2 · B-1 · B-2 · B-3 을 넣은 뒤 **30벌 1,830건 통과 · 실패 0**(빌드 경고 0 · 오류 0).
|
||||
|
||||
- 새로 만든 것: `TwoWayTest` 29건(B-1) · `AboutTest` 28건(B-2) · `SummaryTest` 25건(B-3)
|
||||
- 늘어난 것: `FolderTest` 88 → 118건(A-1 +23 · A-2 +7)
|
||||
- 나머지 26벌은 그대로 통과 — `MergeLine.HasBase` · `BaseNumber` 를 바뀔 수 있는 값으로 바꾼 것이
|
||||
정렬 · 이동 · 3-Way · 저장 길에 영향을 주지 않았다는 뜻이다(MergeSaveTest 350 · NoteTest 93 · MemberTest 83 포함).
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# 변경 이력
|
||||
# 변경 이력
|
||||
|
||||
무엇을 왜 바꿨는지 한 줄로 남긴다. 자세한 이야기는 날짜별 파일에 있다.
|
||||
|
||||
@ -8,6 +8,15 @@
|
||||
|
||||
| 날짜 | 항목 | 커밋 |
|
||||
|---|---|---|
|
||||
| 2026-09-28 | **12차 E-2** — 파일 비교가 짝 여러 개를 동시에 든다(문서 탭). 지금까지는 한 짝만 들어서 짝을 바꾸면 "저장하지 않은 편집이 있습니다" 를 묻고 버리면 고친 글이 사라졌다(재현: 16줄 → 15줄). 이제 떠 있던 탭이 그 글을 맡아 두므로 **묻지 않는다**. 탭마다 `HomeViewModel` 을 따로 만들지 않고(GitWorkspace 의 한 자리짜리 약속 때문) 화면은 하나로 두고 탭이 **파일에 없는 것만** 맡는다 — 고친 글 · 왼쪽 글(B-1) · 되돌리기 이력 · 두 기준점 · 캐럿 · 스크롤 · 저장 형식. 탭 이름은 파일 이름(다르면 `왼 ↔ 오`, 겹치면 위 폴더를 갈릴 때까지 덧붙임), 저장 안 한 탭은 `•`. 탭 줄은 둘 이상일 때만 보인다. `◀ 이전 · 다음 ▶` 은 지금 탭의 짝만 바꾸고(탭이 늘지 않음), 폴더 비교에서 두 번 누르면 새 탭(사용자 결정). Ctrl+Tab · Ctrl+Shift+Tab · Ctrl+W · 가운데 단추 닫기. 탭이 사라져도 고친 글은 **보관함**(짝 50개)에 남아 다시 열면 되살아난다 — E-1 은 이 보관함을 파일로 옮겨 적으면 된다. 원본이 그 사이 바뀌면 되살리고 알린다("연 시점"은 새로 읽은 글로 둔다). 탭 줄은 ItemsControl 이 아니라 ListBox 다(ItemsControl 은 UIA 에 컨트롤로 안 보여 화면 시험이 못 읽는다). 켤 때 저절로 열리는 짝만 탭이 없어 편집이 사라지던 것을 화면에서 찾아 `EnsureTabForCurrentPair` 로 고쳤다. 켜 놓고 눈으로 보다 두 가지를 더 고쳤다(사용자가 고름) — 중괄호 맞춤으로만 바뀐 것에는 점을 붙이지 않고(묻는 자리와 같은 기준), 탭 이름은 양쪽이 같은 부분을 한 번만 적는다(`WeldController_기준.cs ↔ WeldController_내작...` → `WeldController_기준 ↔ 내작업.cs`, 421 → 288px). 뒤쪽은 확장자일 때만 접는다 — 글자 단위로 세면 `a-lef ↔ right.cs` 처럼 낱말 가운데서 잘린다(하네스에서 걸렸다). 하네스 `DocTabTest` 106건, 화면 34건 · 회귀 19건 통과, 전체 27벌 1,787건 · 실패 0. **커밋 안 함**(사용자 지시) | 아래 |
|
||||
| 2026-09-28 | **시험 도구를 런처에 맞춤** — 새 플랫폼은 `UbisamBase.Launcher.exe` 가 셸을 같은 프로세스에 올려 창 주인 프로세스 이름이 바뀌었고, 예전 이름만 찾던 화면 시험 도구가 전부 "창이 안 뜬다" 로 멈춰 있었다. `a2lib.ps1` 에 `Shell-Procs` · `Shell-Proc` · `Stop-App` 을 두고 `Start-App` · `Get-Win` 이 그것을 쓰게 했다(런처가 제목 없는 진행 창을 먼저 띄우는 경우를 대비해 제목 있는 창을 먼저 고름). 스크립트 76개 · 156곳을 함께 고침. 문법 187개 통과, 끄기 → 켜기 → 창 찾기 → 네 탭 → 단추 → 끄기까지 실제로 돌았고 설정 · 창 자리 네 파일 해시 그대로 되돌림. **제품 코드 변경 없음** | 없음 |
|
||||
| 2026-09-28 | **12차 C-2** — 플랫폼 하단 탭 줄을 한 단계 작게(항목 74 → 61 DIP: 여백 13/14→9/10 · 아이콘 22→20 · 아이콘 아래 틈 7→5 · 글자 13.5→12.5). 실측 133 → 109px(−18%)이고 그만큼 콘텐츠가 늘어 파일 비교 코드 칸이 47 → 48줄. 라벨 잘림 · 선택 밑줄 · 탭 접기 띠 · 오른쪽 동작 항목 모두 그대로. 플랫폼 쪽이라 모든 사내 프로그램에 함께 간다. 처음에는 배포본을 백업해 잠깐 바꿔치기해 시험하고 되돌렸으나, 그 뒤 **새 플랫폼을 빌드하면서 함께 배포됐다** — 2026-09-28 배포본 `UbisamBase.Core.dll` 해시 일치 · 화면 실측 111px(항목 109px)로 확인. **커밋 안 함**(사용자 지시) | 없음 |
|
||||
| 2026-09-27 | **12차 B-3** — 칸 아래에 그 칸의 요약(`6줄 · 추가 1 · 삭제 1 · 수정 1`)을, 칸 사이 틈 아래에 진행(`3 / 7` · `남은 2`)을 적는다. 예전에는 창 맨 아래 한 곳에 파일 전체 요약만 있었다. 칸을 기준으로 읽는 수라 두 칸의 추가 · 삭제가 서로 거울이 되고, 무시 규칙 · 머지 주석 줄은 차이로 세지 않되 줄 수에는 넣는다. 3분할은 네 칸 모두 BASE(공통 조상)와 견주고 BASE 칸은 `기준` 이라고만 적는다. 세는 규칙은 `PaneSummary` 한 곳(5만 줄 × 두 칸 × 네 번 22ms). 반영 · 되돌리기 · 저장 · 규칙 바꾸기마다 따라감을 화면에서 확인. SummaryTest 25건 새로. **커밋 안 함**(사용자 지시) | 없음 |
|
||||
| 2026-09-27 | **12차 B-2** — 프로그램 정보 창([단축키] 옆 [정보]). 이름 · 버전 · 빌드 날짜 · 만든 사람 · 사내 문의처와 [정보 복사](결함 알릴 때 그대로 붙여 넣는 용). 관리 목적이라 **실제 실행 파일 값**을 읽는다 - 버전은 csproj `<Version>`(0.12.0), 빌드 날짜는 빌드할 때 어셈블리에 박은 값(요즘 SDK 는 결정적 빌드라 PE 머리의 시각 자리가 내용 해시다 - 못 찾으면 PE 시각 → 파일 수정 시각 순). 만든 사람 · 문의처는 설정 `프로그램 정보` 에서 적고 비우면 회사 이름 · 안내 글. 창을 닫아도 화면 그대로 확인. AboutTest 28건 새로. **커밋 안 함**(사용자 지시) | 없음 |
|
||||
| 2026-09-27 | **12차 B-1** — 두 파일 비교에서 **양쪽으로** 반영 · 저장(사용자 결정: 두 파일 비교에서만. Git 모드 왼쪽은 지난 커밋이라 제외). BASE 칸 여백에 `[<]`, 왼쪽에 없는 줄은 LOCAL 여백의 `[<]` 로 왼쪽에 넣는다. ACTIONS 는 `◀ 왼쪽 저장` · `오른쪽 저장 ▶` 로 나뉘고 고친 것이 없는 쪽은 꺼진다. 두 방향의 반영 규칙을 `PaneText` 한 곳에 모으고(반대쪽에 있으면 가져오고 없으면 지운다), 되돌리기는 `LineEdit.Side` 로 어느 칸인지 보고 같은 칸으로 되돌린다. 저장 기준점 · 저장할 때 백업은 양쪽 따로. `MergeLine.HasBase` · `BaseNumber` 를 바뀔 수 있는 값으로 바꾸고 BASE 칸 목록이 그 변화를 따라가게 함(화면 시험에서 걸린 것). 네 칸 · Git 모드에서는 새 단추가 아예 안 보임. TwoWayTest 29건 새로. **커밋 안 함**(사용자 지시) | 없음 |
|
||||
| 2026-09-27 | **12차 A-2** — 폴더 비교 한 줄을 왼쪽 목록 · 가운데 상태 · 오른쪽 목록 세 칸으로 나눴다(11차 F-10 파일 비교와 같은 모양). 칸마다 자기 쪽 이름 · 크기를 적고, 그쪽에 없으면 칸을 비운다(없는 쪽 `-` 표기도 뺌). TreeView 가 깊이만큼 줄 전체를 밀어 칸이 줄마다 어긋나던 것은 `TreeDepthMarginConverter` 로 민 만큼 되돌리고 이름만 다시 들여써 맞춤 — 칸 경계 · 가운데 구분선이 모든 줄에서 한 자리. 접기 · 펼치기 · 선택 · 복사 · 삭제 · 걸러 보기 · 더블클릭은 트리를 그대로 써서 손대지 않음(화면에서 여섯 가지 확인). FolderTest +7(118건). **커밋 안 함**(사용자 지시) | 없음 |
|
||||
| 2026-09-27 | **12차 A-1** — 폴더 줄 크기가 늘 `0 B` 였다(크기를 파일 줄에만 채웠다). 폴더 줄에 안에 든 파일 크기 합계를 적는다(하위 폴더까지 · 양쪽 따로 · `FolderSizeText`), 크기 글자도 `490.2KB` · `2.4MB` 처럼 사람이 읽는 단위로. 갈래 판단은 파일 크기를 보고 하므로 합계는 `ClassifyAll` 다음에 채운다. 걸러 낸 파일은 합계에서도 빠지고 한쪽에만 있는 폴더는 그쪽만. 탐색기 속성과 같은 값 7곳 · 4,608 줄 묶음에서 채우기 0ms. FolderTest +23(111건). **커밋 안 함**(사용자 지시) | 없음 |
|
||||
| 2026-09-23 | **11차 뒤 G-2** — 새 플랫폼(`D:\Sorce\ubsrepo`) 위로 옮김. 플랫폼 쪽: 세 갈래로 묻는 창(`ShowChoice`) · 덮는 창 가운데 맞춤 · D 드라이브 없는 PC(`ConfigLocation`) · 켤 때 터지면 안내창 · 셸 창 자리 기억(`ShellWindowPlacement.json`) · 켤 때 모니터 안으로 · 최대화 크기 맞춤 · 탭 줄 접기(F11) · **트레이(최소화하면 알림 영역으로, 왼쪽 단추로 되살리기 · 오른쪽 단추 [열기]/[종료])** · 같은 설정 키 중복 등록 방지. MergeHub 쪽: 플랫폼에서 없어진 `CurrentUserService` 를 떼고 작성자 이름을 설정(머지 주석 > 작성자) → git config → 윈도 계정 순으로, 셸 여백은 리소스 덮어쓰기 대신 `PlatformChrome.FullBleed`, F5 실행을 `UbisamBase.Launcher.exe` 로. 하네스 34건(ShowChoice · 가운데 맞춤 10 · 창 자리 · 설정 폴더 23 · 실패 0), 화면 확인 여섯 가지. **양쪽 저장소 모두 커밋하지 않음**(사용자 지시) | 없음 |
|
||||
| 2026-09-23 | **11차 뒤 G-1** — "새 모니터 열었는데 최대화하면 오른쪽으로 넘쳐": 주 150% 옆 **300%** 모니터에서 최대화하면 창이 5120×2186 이 되어 오른쪽 1280px · 아래 170px 이 화면 밖으로 나갔다(요소 25개). 셸이 .NET Framework WPF 라 DPI 인식이 모니터별 V1 에 머물러 윈도가 최대화 크기를 더 크게 잡는 것. 최대화된 뒤 그 모니터 크기와 다르면 한 번 다시 놓게 고침(플랫폼 changelog, 배포함). 넘침 0 · 모니터 밖 요소 0 · 주 모니터 동작 그대로 | 아래 |
|
||||
| 2026-09-23 | **11차 F-11** — Git 원격 인증: 껐다 켜면 URL · 계정 · 토큰이 모두 비어 매번 다시 넣어야 했다. URL · 계정은 Git 탭에 적으면 설정에 남기고(0.8초 모아 한 번), 토큰은 fetch · pull · push · 클론이 **성공했을 때만** 윈도 자격 증명 관리자에 맡긴다(`GitCredentialStore` — 서버마다 한 이름, 설정 파일에는 토큰 글자 없음, SSH · 로컬 경로는 저장 안 함). 켤 때 · 주소를 바꿀 때 그 서버 토큰만 가져오고, 다른 서버로 바꾸면 앞 서버 토큰은 치운다. [토큰 지우기] 단추 · "저장됨" 표시. 인증 실패는 영어 대신 무엇을 하라는 한글 문장으로 | 아래 |
|
||||
| 2026-09-22 | **11차 F-10** — Araxis 식 화면: 칸마다 자기 줄만(빈 줄로 높이를 맞추지 않음), 두 칸(3분할은 위 세 칸) 사이 틈에 바뀐 덩어리마다 사다리꼴 잇는 선 · 칸 안 경계선(한쪽에 없으면 굵은 선 하나) · 옮긴 덩어리는 청록 점선. 스크롤은 가운데 기준으로 따라옴(`PaneMapping` — 함께 있는 줄 가운데를 짝점, 사이는 비율). 속의 정렬 행은 그대로라 반영 · 되돌리기 · 차이 이동이 그대로 동작. 편집기에서 빈 줄을 없애 `StripFillers` 를 지움(편집기 줄 ↔ 행 표). 지운 줄 [>] 는 BASE 칸 끝, 차이 이동은 가운데로 | 아래 |
|
||||
|
||||
153
doc/작업지시/12차.md
Normal file
153
doc/작업지시/12차.md
Normal file
@ -0,0 +1,153 @@
|
||||
# 12차 작업지시 — 폴더 비교를 읽기 쉽게 · 양방향 머지 · 화면 안내 (2026-09-23 사용자 요청)
|
||||
|
||||
11차가 끝난 뒤 쓰면서 나온 요청을 모았다. 사용자가 한 줄씩 말한 것을 물어 정한 결과가 아래 "정한 것"이다.
|
||||
|
||||
원칙은 11차와 같다.
|
||||
|
||||
- **먼저 재현하고 값으로 적는다.** 고치기 전 화면 · 숫자를 남긴다.
|
||||
- 계산은 화면 없이 검사할 수 있게 떼어 내고 **하네스를 먼저** 쓴다.
|
||||
- 빌드는 경고 0 · 오류 0.
|
||||
- 끝내면 `history/2026-09-23-12차.md` · `진행상황.md` · `history/changelog.md` 세 곳에 남기고 로컬 커밋한다(푸시 없음).
|
||||
- 플랫폼을 고치면 영향과 함께 **사용자 확인 뒤** 배포한다.
|
||||
|
||||
---
|
||||
|
||||
## A. 폴더 비교를 읽기 쉽게
|
||||
|
||||
### A-1. 폴더 줄의 크기가 늘 0B
|
||||
|
||||
- 지금은: 크기를 **파일에만** 채운다(`FolderComparer` 의 파일 루프). 폴더 줄은 `LeftSize` · `RightSize` 가 0 이라 화면에 0B 로 나온다.
|
||||
- **정한 것**: 폴더 줄에는 **안에 든 파일 크기 합계**를 적는다(하위 폴더까지). 양쪽을 따로 더해 어느 쪽이 큰지 보이게.
|
||||
- **판정 기준**: 하위 폴더가 여러 겹인 시험 재료에서 폴더 합계가 탐색기 속성과 같다. 한쪽에만 있는 폴더는 그쪽만 합계. 걸러 낸 파일(포함 · 제외 규칙)은 합계에서도 빠진다. 큰 폴더에서 비교 시간이 눈에 띄게 늘지 않는다.
|
||||
|
||||
### A-2. 좌우로 나눠 보이기
|
||||
|
||||
- 지금은: 트리 하나에 좌우 열이 붙어 있다.
|
||||
- **정한 것**: **왼쪽 목록 · 오른쪽 목록을 나란히** 두고 같은 줄에 맞춘다. 가운데에 상태(같음 · 다름 · 왼쪽만 · 오른쪽만)를 적고, 한쪽에 없는 줄은 반대편을 비워 둔다(11차 F-10 파일 비교와 같은 모양).
|
||||
- **판정 기준**: 같은 이름이 같은 높이에 온다. 한쪽만 있는 항목이 어느 쪽인지 한눈에 보인다. 접기 · 펼치기, 복사 · 삭제 단추, 걸러 보기(같은 것만 · 왼쪽만 · 오른쪽만)가 그대로 동작한다.
|
||||
|
||||
## B. 파일 비교 화면
|
||||
|
||||
### B-1. 2-Way 에서 양쪽으로 머지
|
||||
|
||||
- 지금은: 반영이 BASE → LOCAL 한 방향뿐이고, 저장도 LOCAL(오른쪽) 파일에만 쓴다.
|
||||
- **정한 것**: **칸마다 저장 단추**. 왼쪽(BASE) 칸에도 반영 단추 `[<]` 와 [왼쪽 저장] 을 둔다. 양쪽을 각각 고치고 각각 저장한다. 저장할 것이 없는 쪽 단추는 꺼진다.
|
||||
- **정할 것**: Git 모드에서 왼쪽은 지난 커밋이라 고칠 수 없다 — 두 파일 비교에서만 양방향인지, Git 워킹트리 비교에서도 되는지.
|
||||
- **판정 기준**: 왼쪽 파일에 반영 · 저장이 되고 오른쪽은 그대로다. 되돌리기(11차 F-8)가 양쪽 모두에서 동작한다. 저장할 때 백업(11차 F-9)도 양쪽 모두.
|
||||
|
||||
### B-2. 프로그램 정보 창
|
||||
|
||||
- **정한 것**: [단축키] 옆에 단추를 두고, 누르면 **이 프로그램 설명 · 만든 사람 · 버전**을 보여 주는 창을 연다(관리 목적).
|
||||
- **정할 것**: 창에 넣을 항목(프로그램 이름 · 버전 · 빌드 날짜 · 만든 사람 · 사내 문의처 · 플랫폼 버전 · 저장소 경로 · 로그 폴더 열기 등)과 버전을 어디서 읽을지.
|
||||
- **판정 기준**: 버전 · 빌드 날짜가 실제 실행 파일 값과 같다. 창을 닫아도 화면 상태가 그대로다.
|
||||
|
||||
### B-3. 칸 아래에 무엇이 달라졌는지
|
||||
|
||||
- 지금은: 요약이 창 맨 아래 상태 줄 한 곳에만 있다.
|
||||
- **정한 것**: **칸마다 요약**(그 파일의 줄 수 · 추가 · 삭제 · 수정)을 칸 아래에 적고, **가운데에 진행**(남은 차이 · 지금 몇 번째 차이)을 적는다.
|
||||
- **판정 기준**: 반영 · 되돌리기 · 저장을 할 때마다 숫자가 따라간다. 3분할에서도 칸마다 맞는 숫자가 나온다. 긴 파일에서 느려지지 않는다.
|
||||
|
||||
## C. 플랫폼 (버전 올릴 때 함께 — 지금은 대기)
|
||||
|
||||
### C-1. 트레이 아이콘
|
||||
|
||||
- **정한 것**: **최소화하면 트레이로** 내려가고, 아이콘을 누르면 다시 뜬다. 우클릭 메뉴는 [열기] · [종료]. [X] 는 지금처럼 종료.
|
||||
- 셸(플랫폼) 쪽 기능이라 모든 사내 프로그램에 영향이 간다. 플랫폼 버전을 올릴 때 함께 한다.
|
||||
|
||||
### C-2. 탭 줄 크기 줄이기
|
||||
|
||||
- 지금 탭 줄 높이 95px(배율 1.2). 한 단계 작게.
|
||||
- 역시 플랫폼 쪽이라 버전 올릴 때 함께.
|
||||
|
||||
### C-3. 상단 헤더 줄도 접을 수 있게 (2026-09-28 사용자 요청)
|
||||
|
||||
- 지금은: 맨 위 헤더 줄(UbiSam 로고 · 프로그램 이름 · 가운데 아이콘 · 날짜 · 시계)이 늘 자리를 차지한다. 2560×1600 · 배율 150% 에서 약 68px.
|
||||
- **정한 것**: 아래 탭 줄 접기(11차 F-4)와 **같은 방식**으로 접을 수 있게 — 접으면 얇은 띠만 남고, 띠를 누르거나 단축키로 다시 편다. 접은 상태는 다음에 켤 때도 그대로.
|
||||
- 새 플랫폼(`D:\Sorce\ubsrepo`) 쪽이라 **모든 사내 프로그램에 함께 간다**. 배포는 사용자 확인 뒤.
|
||||
- **정할 것**: 단축키(아래 탭 줄이 F11 이니 F10 또는 Ctrl+F11), 접었을 때 시계 · 프로그램 이름을 띠에 남길지.
|
||||
- **판정 기준**: 접으면 코드 칸이 그만큼 높아진다(값으로 잰다). 다시 켜도 접힌 채다. 창 끌기 · 최대화 · 두 번 누르기가 그대로 동작한다 — 헤더가 창을 끄는 자리라 접은 띠에서도 끌 수 있어야 한다.
|
||||
|
||||
## D. 폴더 비교 화면 다시 만들기 (2026-09-28 사용자 요청)
|
||||
|
||||
### D-1. 위쪽을 한 줄로 · 옵션은 서랍으로
|
||||
|
||||
- 지금은: 경로 줄 · 옵션 줄 · 찾기 줄 · 색 구분 범례까지 **네 줄**이 약 160px 을 차지한다. 비교 전에도 그대로다. 자주 쓰는 [비교] 가 옵션 줄 끝에 묻혀 있다.
|
||||
- **정한 것**(사용자가 그림으로 고름 — "디자인을 깔끔하게 아예"):
|
||||
- **위 한 줄**: `왼쪽 경로 ⇄ 오른쪽 경로 [비교] [옵션 ▾]`. 좌우 바꾸기(⇄)도 여기에 둔다.
|
||||
- **옵션 서랍**: 빠른 비교 · 하위 폴더 포함 · 보기(다른 것만 …) · 포함 · 제외 규칙을 [옵션 ▾] 안으로. 켜 둔 옵션은 한 줄에 작은 표시로 알려 준다.
|
||||
- **찾기**: 늘 보이는 줄을 없애고 **Ctrl+F 로 뜨는 줄**로(파일 비교의 찾기와 같은 방식).
|
||||
- **색 구분 범례**: 위에서 빼고 아래 상태 줄로 옮긴다.
|
||||
- **아래 상태 줄**: `다름 12 · 왼쪽만 3 · 오른쪽만 1 · 같음 240` 처럼 결과 요약.
|
||||
- 목표: 위쪽 160px → **약 48px**.
|
||||
- **판정 기준**: 지금 되는 것이 하나도 없어지지 않는다(폴더 고르기 · 최근 · 빠른 비교 · 하위 폴더 포함 · 보기 거르기 · 포함/제외 규칙 · 폴더 안 찾기 · 결과 내보내기 · 복사/삭제 단추 · 줄 단위 비교). 위쪽 높이를 값으로 재서 줄어든 것을 확인한다. 좁은 창(1280 DIP)에서도 한 줄이 접히거나 잘리지 않는다.
|
||||
|
||||
## E. 저장하지 않아도 남는 편집 (2026-09-28 사용자 요청)
|
||||
|
||||
### E-1. 꺼도 작업 내용이 남아 있게 (notepad++ 식)
|
||||
|
||||
- 지금은: 창을 닫거나 다른 파일을 열 때 "저장하지 않은 편집이 있습니다" 를 묻고, [버리고 계속] 을 고르면 **글이 사라진다**. 다시 켜면 아무것도 없다.
|
||||
- **정한 것**:
|
||||
- **되살릴 범위**: 마지막에 **열려 있던 탭 전부**(E-2) — 탭마다 파일 짝 + 저장하지 않은 편집 글, 캐럿 자리 · 스크롤 위치, 어느 탭을 보고 있었는지. 되돌리기(Undo) 이력은 되살리지 않는다.
|
||||
- **되살리는 순간**: 묻지 않고 그대로 되살린다(notepad++ 처럼). 상태 줄에 "되살림" 을 잠깐 띄운다.
|
||||
- **보관 대상**: **두 파일 비교만**. Git 탭 · 병합 중은 git 이 이미 상태를 들고 있어 건드리지 않는다.
|
||||
- **정할 것**:
|
||||
- 언제 보관할지 — 글자가 멈춘 뒤 몇 초, 창을 닫을 때, 탭을 바꿀 때.
|
||||
- 보관 자리 — `{설정 폴더}\Session\` 아래(11차 F-9 백업과 다른 자리, 백업은 "저장 전 파일", 이것은 "저장 안 한 편집").
|
||||
- 되살릴 때 **원본 파일이 그 사이 바뀌었으면** 어떻게 할지(알리고 되살림 · 알리고 버림 · 나란히 보여 주기).
|
||||
- 오래된 보관본을 언제 치울지.
|
||||
- **판정 기준**:
|
||||
- 짝 두세 개를 탭으로 열어 고치고 **저장하지 않은 채** 창을 닫았다가 다시 켜면, 탭이 그 수·그 순서로 열리고 글이 그대로다(글자 · 줄 수 · 캐럿 자리를 값으로 대조).
|
||||
- 저장한 뒤 닫으면 되살릴 것이 없다(보관본이 치워진다).
|
||||
- 원본 파일이 그 사이 바뀐 경우가 정한 대로 동작한다.
|
||||
- 앱을 강제로 끝내도(작업 관리자) 마지막 보관 시점까지는 남는다.
|
||||
- 보관 때문에 타이핑이 느려지지 않는다(큰 파일에서 값으로 잰다).
|
||||
|
||||
### E-2. 여러 짝을 동시에 (문서 탭) — E-1 의 앞단계 (2026-09-28 사용자 지적)
|
||||
|
||||
- 지금은: 파일 비교 탭이 **한 번에 한 짝**만 든다(`◀ 이전 파일` · `다음 파일 ▶` 로 목록을 걷는 방식). 짝을 바꾸는 순간 앞 짝의 편집 글이 갈 곳이 없어서 "버리고 계속" 을 묻는다. notepad++ 처럼 되려면 **탭이 먼저** 있어야 E-1 이 뜻을 가진다.
|
||||
- **정한 것**:
|
||||
- **자리**: 파일 비교 화면 맨 위에 **문서 탭 줄 한 줄**(약 28px). 플랫폼 서브탭이 아니라 MergeHub 안에 둔다 — 플랫폼 배포와 엮이지 않게.
|
||||
- **탭 이름**: 파일 이름. 양쪽 이름이 다르면 `왼 ↔ 오`. **저장하지 않은 탭은 이름 앞에 `•`**. 같은 이름이 여럿이면 위 폴더 한 겹을 덧붙인다.
|
||||
- **닫기**: 탭마다 [×] · 가운데 단추로도 닫기. 저장 안 한 탭을 닫아도 **묻지 않는다** — 글은 그대로 보관되고 E-1 이 되살린다.
|
||||
- **넘기기**: Ctrl+Tab · Ctrl+Shift+Tab.
|
||||
- **◀ 이전 파일 · 다음 파일 ▶**: 지금처럼 **지금 탭을 다음 짝으로 바꾼다**(탭이 늘지 않아 목록을 훑기 좋다).
|
||||
- **폴더 비교에서 줄을 두 번 누르기**: **새 탭으로 연다**(앞에 보던 것이 사라지지 않는다).
|
||||
- **탭 하나가 드는 것**: 왼쪽 · 오른쪽 파일 경로, 편집 글, 캐럿 자리, 스크롤, 되돌리기 · 다시하기 이력(11차 F-8), 반영 상태, 저장 형식 · 비교 규칙.
|
||||
- **만들면서 정한 것**: 탭 상한 **20**(넘으면 저장할 것 없는 탭 가운데 가장 오래 안 본 것을 닫는다 · 고친 탭은 절대 닫지 않는다) · 한 줄을 넘치면 **가로 스크롤** · Git 탭 충돌 목록은 **탭으로 열지 않는다** · 탭이 사라져도 고친 글은 **보관함**에 남아 다시 열면 되살아난다(짝 50개).
|
||||
- **판정 기준**:
|
||||
- 짝 두 개를 열어 양쪽 다 고쳐도 글이 서로 섞이지 않는다(양쪽을 값으로 대조).
|
||||
- 탭을 옮겨 갔다 돌아오면 캐럿 자리 · 스크롤이 그 자리다.
|
||||
- 되돌리기 · 다시하기가 **탭마다 따로** 동작한다(한 탭에서 Undo 해도 다른 탭은 그대로).
|
||||
- 칸 아래 요약(B-3) · 아래 상태 줄이 **지금 탭 것만** 보여 준다.
|
||||
- 저장하면 그 탭의 `•` 만 사라진다. 저장할 것이 없는 탭의 저장 단추는 꺼진다.
|
||||
- 탭 20개를 열어도 넘기기 · 타이핑이 느려지지 않는다(값으로 잰다).
|
||||
|
||||
## F. 메모장 탭 (2026-09-28 사용자 요청)
|
||||
|
||||
### F-1. 하단 탭에 메모장 자리를 새로 만든다
|
||||
|
||||
- 하고 싶은 것: 하단 탭(폴더 비교 · 파일 비교 · Git) 옆에 **메모장 탭**을 하나 더 두고, 거기서 notepad++ 처럼 쓴다.
|
||||
- **정한 것**:
|
||||
- **성격**: **메모 + 파일 열기 둘 다**. 기본은 이름 없는 메모 여러 장이고, 원하면 파일로 여닫고 저장도 한다.
|
||||
- **보관**: **자동으로 계속** — 쓰는 도중 알아서 보관하고, 껐다 켜도 그대로 있다(E-1 과 같은 생각). 저장 단추를 눌러야 남는 방식이 아니다.
|
||||
- 탭 줄 · 저장 안 한 표시 `•` · Ctrl+Tab 넘기기는 **E-2 문서 탭과 같은 것을 쓴다**(같은 모양이어야 배우는 것이 하나다).
|
||||
- **정할 것**:
|
||||
- 메모 보관 자리(`{설정 폴더}\Memo\` 또는 E-1 의 `Session\` 아래 같이 둘지).
|
||||
- 이름 없는 메모의 탭 이름(`메모 1` · 첫 줄 몇 자 · 사용자가 붙이기).
|
||||
- 파일로 연 메모와 이름 없는 메모를 같은 탭 줄에 섞을지.
|
||||
- 어디까지 파일 비교와 같게 할지 — 줄 번호 · 문법 강조 · 찾기/바꾸기 · 글자 크기(Ctrl+휠) · 줄바꿈.
|
||||
- 메모를 파일 비교로 보내는 길이 필요한지(메모 두 장을 견주기).
|
||||
- **판정 기준**:
|
||||
- 메모를 여러 장 만들어 각각 쓰고 껐다 켜면 장 수 · 순서 · 글이 그대로다(값으로 대조).
|
||||
- 이름 없는 메모도 저장 단추 없이 남는다. 강제로 끝내도 마지막 보관 시점까지 남는다.
|
||||
- 파일로 연 메모는 저장하면 파일이 바뀌고, 저장 전에는 `•` 가 붙는다.
|
||||
- 메모 탭이 다른 탭(폴더 비교 · 파일 비교 · Git)의 동작을 건드리지 않는다.
|
||||
- 큰 글(수천 줄)에서도 타이핑이 느려지지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 이번 지시서에서 하지 않는 것
|
||||
|
||||
- 실제 업무 저장소로 시험하지 않는다(시험 재료는 만들어 쓴다).
|
||||
- 푸시하지 않는다.
|
||||
- 플랫폼 배포는 사용자 확인 뒤에만.
|
||||
44
doc/진행상황.md
44
doc/진행상황.md
@ -1,4 +1,4 @@
|
||||
# MergeHub 진행 상황
|
||||
# MergeHub 진행 상황
|
||||
|
||||
**지금 무엇이 남았는지만** 적는다. 끝난 것의 상세는 [history/](history/) 로 옮겼다.
|
||||
|
||||
@ -389,6 +389,48 @@
|
||||
| 상태 | 항목 |
|
||||
|---|---|
|
||||
| **완료** | G-1 배율이 크게 다른 새 모니터(3840×2160 · 300%)에서 최대화하면 오른쪽 1280px · 아래 170px 이 화면 밖으로 — 셸이 모니터별 V1 이라 최대화 크기를 윈도가 더 크게 잡았다. 최대화된 뒤 그 모니터 크기와 다르면 한 번 다시 놓게 고침(플랫폼 `FitMaximizedToMonitor`). 넘침 0 · 모니터 밖 요소 0개. PlacementTest +6(51건). **플랫폼 배포함**(2026-09-23) |
|
||||
| **완료** | G-2 새 플랫폼(`D:\Sorce\ubsrepo`)으로 옮겨 태움 — 우리가 옛 플랫폼에 넣었던 여덟 가지(세 갈래로 묻는 창 · 덮는 창 가운데 맞춤 · D 드라이브 없는 PC · 켤 때 터지면 안내창 · 셸 창 자리 기억 · 모니터 안으로 · 최대화 크기 맞춤 · 탭 줄 접기 F11)와 **트레이(최소화하면 트레이로)** 를 새 플랫폼에 넣고, MergeHub 를 그 위에서 뜨게 맞춤(플랫폼에서 없어진 사용자 이름 서비스 대신 설정 "머지 주석 > 작성자", 여백은 `PlatformChrome.FullBleed`, 실행은 `UbisamBase.Launcher.exe`). 하네스 34건 · 화면 확인(탭 접기 · F11 · 트레이 왕복 · 창 자리 기억 · 화면 밖 자리 · 두 모니터 최대화). **커밋 안 함**(사용자 지시) |
|
||||
|
||||
---
|
||||
## 12차 — 폴더 비교를 읽기 쉽게 · 양방향 머지 · 화면 안내 (2026-09-23 ~)
|
||||
|
||||
진행 기록: [history/2026-09-23-12차.md](history/2026-09-23-12차.md)
|
||||
지시서: [작업지시/12차.md](작업지시/12차.md)
|
||||
|
||||
### A. 폴더 비교를 읽기 쉽게
|
||||
|
||||
| 상태 | 항목 |
|
||||
|---|---|
|
||||
| **완료** | A-1 폴더 줄의 크기가 늘 0B — 폴더 줄에 **안에 든 파일 크기 합계**를 적는다(하위 폴더까지, 양쪽 따로). 크기 글자도 사람이 읽는 단위로(`490.2KB` · `2.4MB`). 걸러 낸 파일은 합계에서도 빠지고, 한쪽에만 있는 폴더는 그쪽만. 탐색기 속성과 같은 값 7곳 확인. 4,608 줄 묶음에서 합계 채우기 **0ms**. FolderTest +23(111건) |
|
||||
| **완료** | A-2 좌우로 나눠 보이기 — 한 줄을 **왼쪽 목록 · 가운데 상태 · 오른쪽 목록** 세 칸으로. 칸마다 자기 쪽 이름 · 크기를 적고 그쪽에 없으면 칸을 비운다. 트리가 깊이만큼 줄을 미는 것을 되돌리고 이름만 다시 들여써(`TreeDepthMarginConverter`) 칸 경계 · 구분선이 줄마다 같은 자리. 접기 · 복사 · 걸러 보기 그대로. FolderTest +7(118건) |
|
||||
|
||||
### B. 파일 비교 화면
|
||||
|
||||
| 상태 | 항목 |
|
||||
|---|---|
|
||||
| **완료** | B-1 2-Way 에서 양쪽으로 머지 — **두 파일 비교에서만**(사용자 결정 2026-09-27, Git 모드의 왼쪽은 지난 커밋이라 제외). 줄마다 `[<]` 로 왼쪽에 반영(왼쪽에 없는 줄은 LOCAL 여백의 `[<]`), ACTIONS 에 `◀ 왼쪽 저장` · `오른쪽 저장 ▶` - 고친 것이 없는 쪽은 꺼짐. 두 방향이 같은 규칙(`PaneText`), 되돌리기는 편집마다 어느 칸인지 들고 한 스택에서, 저장 기준점 · 백업은 양쪽 따로. TwoWayTest 29건 새로 |
|
||||
| **완료** | B-2 프로그램 정보 창 — [단축키] 옆 **[정보]** 단추. 프로그램 이름 · 버전 · 빌드 날짜 · 만든 사람 · 사내 문의처(사용자 결정) + [정보 복사]. 버전은 csproj `<Version>`(12차 = 0.12.0)을 실행 파일에서 읽고, 빌드 날짜는 빌드할 때 어셈블리에 박는다(결정적 빌드라 PE 시각은 날짜가 아님). 만든 사람 · 문의처는 설정에서 적는다(비워 둠). AboutTest 28건 새로 |
|
||||
| **완료** | B-3 칸 아래에 무엇이 달라졌는지 — 칸마다 `6줄 · 추가 1 · 삭제 1 · 수정 1`(칸을 기준으로 읽는다, 무시 · 머지 주석 줄은 차이로 안 셈), 가운데 틈 아래에 `3 / 7` · `남은 2`. 3분할은 네 칸 모두 BASE 와 견주고 BASE 는 `기준`. 반영 · 되돌리기 · 저장마다 따라감. 5만 줄 × 두 칸 × 네 번 22ms. SummaryTest 25건 새로 |
|
||||
|
||||
### C. 플랫폼
|
||||
|
||||
| 상태 | 항목 |
|
||||
|---|---|
|
||||
| **완료** | C-1 트레이 아이콘 — 최소화하면 알림 영역으로, 왼쪽 단추로 되살리기 · 우클릭 [열기]/[종료]. 새 플랫폼(`D:\Sorce\ubsrepo`) 에 넣음(11차 뒤 G-2 와 같은 작업) |
|
||||
| **완료** | C-2 탭 줄 크기 줄이기 — 하단 탭 줄 항목을 74 → 61 DIP 로(여백 13/14→9/10 · 아이콘 22→20 · 틈 7→5 · 글자 13.5→12.5). 실측 133 → 109px(−18%), 파일 비교 코드 칸 47 → 48줄. **배포됨** — 새 플랫폼을 빌드하면서 함께 나갔고, 2026-09-28 배포본 해시 · 화면 실측(111px · 항목 109px)으로 확인 |
|
||||
|
||||
### E. 저장하지 않아도 남는 편집
|
||||
|
||||
| 상태 | 항목 |
|
||||
|---|---|
|
||||
| **완료** | E-2 여러 짝을 동시에(문서 탭) — 파일 비교 맨 위에 문서 탭 줄(둘 이상일 때만 보임). 탭 이름은 파일 이름 · 다르면 `왼 ↔ 오` · 겹치면 위 폴더를 덧붙임 · 저장 안 한 탭은 `•`. 짝을 바꿀 때 **묻지 않는다**(탭이 편집을 맡는다). 탭이 사라져도 고친 글은 보관함에 남아 다시 열면 되살아남(◀ ▶ 로 훑다 돌아와도). Ctrl+Tab · Ctrl+Shift+Tab · Ctrl+W · 가운데 단추 닫기. 탭마다 되돌리기 · 캐럿 · 스크롤 · 저장 형식. 상한 20(고친 탭은 안 닫음). 탭 이름은 **양쪽이 같은 부분을 한 번만** 적는다(`WeldController_기준 ↔ 내작업.cs` — 421 → 288px). 점은 **사람이 손댄 것만**(스타일 맞춤으로만 바뀐 것은 빼고 센다 — 묻는 자리와 같은 기준). 하네스 `DocTabTest` 106건, 화면 34건 · 회귀 19건 확인 |
|
||||
| 할 일 | E-1 꺼도 남는 편집(notepad++ 식) — E-2 의 보관함을 `{설정 폴더}\Session\` 에 옮겨 적고 켤 때 되살린다 |
|
||||
|
||||
### 시험 도구
|
||||
|
||||
| 상태 | 항목 |
|
||||
|---|---|
|
||||
| **완료** | 화면 시험 도구를 런처에 맞춤 — 새 플랫폼은 `UbisamBase.Launcher.exe` 가 창 주인이라 예전 이름만 찾던 시험이 전부 "창이 안 뜬다" 로 멈춰 있었다. `a2lib.ps1` 에 `Shell-Procs` · `Shell-Proc` · `Stop-App` 을 두고 스크립트 76개 · 156곳을 함께 고침. 문법 187개 통과 · 끄고 켜고 탭 넘기기까지 실제 확인. 제품 코드 변경 없음 |
|
||||
|
||||
## 3~4주차로 미룬 것
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
using System.Windows;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MergeHub.Services;
|
||||
using MergeHub.ViewModels;
|
||||
using MergeHub.Views;
|
||||
@ -18,12 +17,10 @@ namespace MergeHub
|
||||
|
||||
public void RegisterViews(ITabManager manager)
|
||||
{
|
||||
// 머지툴은 3분할 비교라 가로 공간이 곧 성능이다.
|
||||
// 셸 기본 여백(26,22)을 없애고 창 전체를 쓴다 - 안쪽 여백은 HomeView가 직접 준다.
|
||||
// 이 키는 테마 전환에도 유지되므로 한 번만 덮어쓰면 되고,
|
||||
// ShellWindow가 창 생성 시점에 읽어가므로 RegisterViews 안에서 정해야 한다.
|
||||
if (Application.Current is { } app)
|
||||
app.Resources["Ubisam.Metric.ContentPadding"] = new Thickness(0);
|
||||
// 머지툴은 3분할 비교라 가로 공간이 곧 성능이다. 셸 기본 여백을 없애고 창 전체를 쓰며,
|
||||
// 안쪽 여백은 각 화면이 직접 준다. 여백을 없애는 길은 화면마다 붙인
|
||||
// shell:PlatformChrome.FullBleed="True" 이다(새 플랫폼 · platform-guide 8-4).
|
||||
// 예전처럼 Ubisam.Metric.ContentPadding 리소스를 덮어쓰지 않는다 - 그 키는 이제 없다.
|
||||
|
||||
// 탭 순서는 실제 작업 순서를 따른다.
|
||||
// 폴더 비교로 "어느 파일이 다른가"를 먼저 보고 → 그 파일을 골라 파일 비교로 들어가고
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Library</OutputType>
|
||||
@ -9,19 +9,37 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>MergeHub</RootNamespace>
|
||||
<AssemblyName>MergeHub</AssemblyName>
|
||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||
<!-- 프로그램 정보 창(12차 B-2)이 실행 파일에서 읽는 값들. 손으로 적은 글이 아니라 이 값이 보인다.
|
||||
차수에 맞춰 올린다 - 12차 = 0.12.x. 여기만 고치면 창 · 파일 속성이 함께 따라간다. -->
|
||||
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
|
||||
<Version>0.12.0</Version>
|
||||
<Product>MergeHub</Product>
|
||||
<Company>UbiSam</Company>
|
||||
<Description>사내 Git Merge/Diff 도구</Description>
|
||||
|
||||
<!-- 플랫폼 배포 폴더 (platform-guide 10-2 (B) 배포용) -->
|
||||
<UbisamPlatformDir>D:\UbisamPlatform\bin\</UbisamPlatformDir>
|
||||
|
||||
<!-- 이 프로젝트는 dll이라 직접 실행 불가 - F5 시 Shell.exe가 뜨게 한다 -->
|
||||
<!-- 이 프로젝트는 dll이라 직접 실행 불가 - F5 시 Launcher.exe 가 뜨게 한다.
|
||||
Launcher 는 켤 때마다 D:\UbisamPlatform\bin 을 이 폴더로 복사한 뒤 Shell 을 같은 프로세스에서 띄운다.
|
||||
그래서 다시 빌드하지 않아도 최신 플랫폼으로 뜨고, 이 프로젝트의 중단점도 그대로 걸린다.
|
||||
$(TargetDir) 는 이 시점에 아직 비어 있어 쓰면 안 된다 - 절대 경로로 적는다. -->
|
||||
<StartAction>Program</StartAction>
|
||||
<StartProgram>$(TargetDir)UbisamBase.Shell.exe</StartProgram>
|
||||
<StartWorkingDirectory>$(TargetDir)</StartWorkingDirectory>
|
||||
<StartProgram>$(MSBuildProjectDirectory)\bin\$(Configuration)\$(TargetFramework)\UbisamBase.Launcher.exe</StartProgram>
|
||||
<StartWorkingDirectory>$(MSBuildProjectDirectory)\bin\$(Configuration)\$(TargetFramework)\</StartWorkingDirectory>
|
||||
|
||||
<DisableFastUpToDateCheck>true</DisableFastUpToDateCheck>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- 빌드한 날짜를 실행 파일에 박는다(12차 B-2). 요즘 SDK 는 결정적 빌드라 PE 머리의 시각 자리가
|
||||
날짜가 아니라 내용 해시다 - 정보 창이 "알 수 없음" 이 되지 않게 여기서 박아 둔다. -->
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
|
||||
<_Parameter1>BuildDate</_Parameter1>
|
||||
<_Parameter2>$([System.DateTime]::Now.ToString("yyyy-MM-dd HH:mm"))</_Parameter2>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="UbisamBase.Core">
|
||||
<HintPath>$(UbisamPlatformDir)UbisamBase.Core.dll</HintPath>
|
||||
|
||||
@ -9,6 +9,16 @@ namespace MergeHub
|
||||
/// </summary>
|
||||
public class MergeHubSettings
|
||||
{
|
||||
// 프로그램 정보 창(12차 B-2)에 적을 것. 버전 · 빌드 날짜는 실행 파일에서 읽으므로 여기 없다 -
|
||||
// 사람이 적은 값과 실제가 어긋나면 그 창은 없느니만 못하다. 사람이 바뀌는 것만 여기서 적는다.
|
||||
[Category("프로그램 정보")]
|
||||
[DisplayName("만든 사람 (비우면 실행 파일의 회사 이름)")]
|
||||
public string ProgramAuthor { get; set; } = string.Empty;
|
||||
|
||||
[Category("프로그램 정보")]
|
||||
[DisplayName("사내 문의처 (담당자 · 연락처 한 줄)")]
|
||||
public string SupportContact { get; set; } = string.Empty;
|
||||
|
||||
[Category("저장소")]
|
||||
[DisplayName("기본 저장소 경로")]
|
||||
public string DefaultRepositoryPath { get; set; } = string.Empty;
|
||||
|
||||
@ -92,10 +92,10 @@ namespace MergeHub.Models
|
||||
_baseText = baseText ?? string.Empty;
|
||||
_localText = localText ?? string.Empty;
|
||||
|
||||
BaseNumber = baseNumber;
|
||||
_baseNumber = baseNumber;
|
||||
_localNumber = localNumber;
|
||||
|
||||
HasBase = baseText is not null;
|
||||
_hasBase = baseText is not null;
|
||||
_hasLocal = localText is not null;
|
||||
}
|
||||
|
||||
@ -109,8 +109,15 @@ namespace MergeHub.Models
|
||||
? string.Equals(a, b, StringComparison.Ordinal)
|
||||
: string.Equals(_normalize(a), _normalize(b), StringComparison.Ordinal);
|
||||
|
||||
/// <summary>BASE 쪽 줄 번호. LOCAL 에만 있는 줄이면 없다.</summary>
|
||||
public int? BaseNumber { get; }
|
||||
/// <summary>
|
||||
/// BASE 쪽 줄 번호. LOCAL 에만 있는 줄이면 없다.
|
||||
///
|
||||
/// 12차 B-1 전에는 정렬 결과라 바뀌지 않았다. 이제 두 파일 비교에서는 왼쪽으로도 반영하므로
|
||||
/// 왼쪽에 줄이 생기거나 사라진다 - 번호도 따라 바뀐다.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(BaseNumberText))]
|
||||
private int? _baseNumber;
|
||||
|
||||
public string BaseNumberText => BaseNumber?.ToString("00") ?? string.Empty;
|
||||
|
||||
@ -337,8 +344,18 @@ namespace MergeHub.Models
|
||||
[ObservableProperty] private Services.CarryOver _baseInComment;
|
||||
[ObservableProperty] private Services.CarryOver _localInComment;
|
||||
|
||||
/// <summary>BASE 쪽에 이 줄이 있는지. 정렬 결과라 바뀌지 않는다.</summary>
|
||||
public bool HasBase { get; }
|
||||
/// <summary>
|
||||
/// BASE 쪽에 이 줄이 있는지.
|
||||
///
|
||||
/// 12차 B-1 전에는 정렬 결과라 바뀌지 않았다. 두 파일 비교에서 [<] 로 왼쪽에 반영하면
|
||||
/// 왼쪽에 없던 줄이 생기고, 왼쪽에만 있던 줄을 반영하면 사라진다.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(State))]
|
||||
[NotifyPropertyChangedFor(nameof(IsDifferent))]
|
||||
[NotifyPropertyChangedFor(nameof(CanTakeBase))]
|
||||
[NotifyPropertyChangedFor(nameof(Highlight))]
|
||||
private bool _hasBase;
|
||||
|
||||
/// <summary>LOCAL 쪽에 줄이 있는지. 줄을 지우거나 되살리면 바뀐다.</summary>
|
||||
[ObservableProperty]
|
||||
@ -529,5 +546,12 @@ namespace MergeHub.Models
|
||||
/// 되돌리기 한 번에 통째로 되돌린다(11차 F-2).
|
||||
/// </summary>
|
||||
public int Group { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 이 편집이 <b>어느 칸</b>을 고쳤는지(12차 B-1). 기본은 오른쪽(LOCAL) 이고,
|
||||
/// 두 파일 비교에서 [<] 로 왼쪽에 반영한 편집만 <see cref="TextPane.Base"/> 다.
|
||||
/// 되돌리기가 이 값을 보고 같은 칸으로 되돌린다.
|
||||
/// </summary>
|
||||
public MergeLine.TextPane Side { get; init; } = MergeLine.TextPane.Local;
|
||||
}
|
||||
}
|
||||
|
||||
461
src/MergeHub/Services/DocTabs.cs
Normal file
461
src/MergeHub/Services/DocTabs.cs
Normal file
@ -0,0 +1,461 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace MergeHub.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// 문서 탭 하나가 들고 있는 "저장하지 않은 것"(12차 E-2).
|
||||
///
|
||||
/// 탭을 옮기면 지금 화면의 이것을 떠 있던 탭에 맡겨 두고, 가려는 탭에 맡겨 둔 것을 되살린다.
|
||||
/// 파일에서 다시 읽어 오는 것(원본 글 · 줄 정렬)은 여기에 담지 않는다 - 그건 언제든 다시 읽을 수 있고,
|
||||
/// 담아 두면 파일이 그 사이 바뀌었을 때 낡은 것을 보여 주게 된다. 여기에는 <b>파일에 없는 것만</b> 담는다.
|
||||
/// </summary>
|
||||
public sealed class DocTabState
|
||||
{
|
||||
/// <summary>오른쪽(LOCAL) 편집기의 글. null 이면 파일 그대로라 되살릴 것이 없다.</summary>
|
||||
public string? Text { get; set; }
|
||||
|
||||
/// <summary>왼쪽(BASE) 칸의 글(12차 B-1 양방향). null 이면 파일 그대로.</summary>
|
||||
public string? LeftText { get; set; }
|
||||
|
||||
public string[] Undo { get; set; } = Array.Empty<string>();
|
||||
public string[] Redo { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>연 시점 · 저장한 시점의 글(11차 F-8 <see cref="SaveBaseline"/>).</summary>
|
||||
public string Opened { get; set; } = string.Empty;
|
||||
public string Saved { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>왼쪽 칸의 연 시점 · 저장한 시점(12차 B-1 은 왼쪽 기준점을 따로 센다).</summary>
|
||||
public string LeftOpened { get; set; } = string.Empty;
|
||||
public string LeftSaved { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>캐럿이 있던 글자 자리와 맨 위에 보이던 행.</summary>
|
||||
public int CaretIndex { get; set; }
|
||||
public int TopRow { get; set; }
|
||||
|
||||
/// <summary>저장 형식 · 비교 규칙처럼 이 짝에만 걸어 둔 것.</summary>
|
||||
public string? SaveEncoding { get; set; }
|
||||
public string? SaveNewLine { get; set; }
|
||||
|
||||
/// <summary>되살릴 것이 있는가. 없으면 파일만 다시 읽으면 된다.</summary>
|
||||
public bool HasEdits => Text is not null || LeftText is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 파일 비교 화면의 문서 탭 하나(12차 E-2).
|
||||
///
|
||||
/// 탭 줄에 그릴 이름(<see cref="Title"/>)과 저장 안 한 표시(<see cref="IsDirty"/>)를 들고,
|
||||
/// 화면에 없는 동안 편집 글을 <see cref="Parked"/> 에 맡아 둔다.
|
||||
/// </summary>
|
||||
public sealed class DocTab : INotifyPropertyChanged
|
||||
{
|
||||
public DocTab(string left, string right)
|
||||
{
|
||||
Left = left ?? string.Empty;
|
||||
Right = right ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>왼쪽(BASE) 파일 경로.</summary>
|
||||
public string Left { get; }
|
||||
|
||||
/// <summary>오른쪽(LOCAL) 파일 경로. 저장은 이 이름으로 한다.</summary>
|
||||
public string Right { get; }
|
||||
|
||||
/// <summary>화면에 없는 동안 맡아 둔 편집. 떠 있는 탭은 화면이 주인이라 비어 있다.</summary>
|
||||
public DocTabState? Parked { get; set; }
|
||||
|
||||
private bool _isDirty;
|
||||
|
||||
/// <summary>저장하지 않은 편집이 있는가. 탭 이름 앞 · 표시에 쓴다.</summary>
|
||||
public bool IsDirty
|
||||
{
|
||||
get => _isDirty;
|
||||
set => Set(ref _isDirty, value);
|
||||
}
|
||||
|
||||
private bool _isCurrent;
|
||||
|
||||
/// <summary>지금 화면에 떠 있는 탭인가. 탭 줄에서 이 탭을 도드라지게 그리는 데 쓴다.</summary>
|
||||
public bool IsCurrent
|
||||
{
|
||||
get => _isCurrent;
|
||||
internal set => Set(ref _isCurrent, value);
|
||||
}
|
||||
|
||||
private string _title = string.Empty;
|
||||
|
||||
/// <summary>탭 줄에 그릴 이름. 같은 이름이 여럿이면 <see cref="DocTabs"/> 가 위 폴더를 덧붙인다.</summary>
|
||||
public string Title
|
||||
{
|
||||
get => _title;
|
||||
internal set => Set(ref _title, value);
|
||||
}
|
||||
|
||||
/// <summary>마우스를 올렸을 때 보여 줄 전체 경로.</summary>
|
||||
public string Tip => Left + Environment.NewLine + Right;
|
||||
|
||||
/// <summary>같은 짝인가. 대소문자는 가리지 않는다(윈도우 경로).</summary>
|
||||
public bool Same(string left, string right) =>
|
||||
string.Equals(Left, left ?? string.Empty, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(Right, right ?? string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>마지막으로 떠 있던 순서. 자리가 모자랄 때 오래 안 본 탭부터 치우는 데 쓴다.</summary>
|
||||
internal long Touched { get; set; }
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private void Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value)) return;
|
||||
field = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name!));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 문서 탭 목록(12차 E-2). 화면 없이 검사할 수 있도록 <b>계산만</b> 한다 -
|
||||
/// 파일을 읽거나 화면을 고치는 일은 부르는 쪽(HomeViewModel)이 한다.
|
||||
///
|
||||
/// <para>
|
||||
/// 이름 짓는 규칙은 최근 목록(<see cref="RecentPair.Label"/>)과 같다 - 양쪽 이름이 같으면 그 이름 하나,
|
||||
/// 다르면 <c>왼 ↔ 오</c>. 그래도 같은 이름이 여럿이면 갈릴 때까지 위 폴더를 한 겹씩 덧붙인다.
|
||||
/// 폴더가 다른 같은 이름 파일(Unit0.cs 가 여러 폴더에 있는 것)이 사내 소스에 흔하다.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class DocTabs
|
||||
{
|
||||
/// <summary>이보다 많으면 <b>저장할 것이 없는</b> 탭 가운데 가장 오래 안 본 것을 치운다.</summary>
|
||||
public const int Soft = 20;
|
||||
|
||||
private readonly List<DocTab> _tabs = new List<DocTab>();
|
||||
private long _clock;
|
||||
|
||||
public IReadOnlyList<DocTab> Tabs => _tabs;
|
||||
|
||||
public int Count => _tabs.Count;
|
||||
|
||||
/// <summary>지금 떠 있는 탭. 하나도 없으면 null.</summary>
|
||||
public DocTab? Current { get; private set; }
|
||||
|
||||
public int CurrentIndex => Current is null ? -1 : _tabs.IndexOf(Current);
|
||||
|
||||
/// <summary>탭이 여럿일 때만 탭 줄을 보여 준다 - 한 짝만 보는 동안 줄을 낭비하지 않는다.</summary>
|
||||
public bool ShowStrip => _tabs.Count > 1;
|
||||
|
||||
/// <summary>자리가 모자라 조용히 치운 탭. 부르는 쪽이 알림에 쓴다.</summary>
|
||||
public DocTab? LastEvicted { get; private set; }
|
||||
|
||||
/// <summary>같은 짝을 든 탭. 없으면 null.</summary>
|
||||
public DocTab? Find(string left, string right) =>
|
||||
_tabs.FirstOrDefault(t => t.Same(left, right));
|
||||
|
||||
// ================= 보관함 =================
|
||||
|
||||
/// <summary>
|
||||
/// 탭에서 내려온 편집을 <b>짝 이름으로</b> 보관한다.
|
||||
///
|
||||
/// 탭을 닫거나(<see cref="Close"/>) 짝을 바꿔(<see cref="Replace"/>) 탭 자체가 사라져도
|
||||
/// 고친 글은 남아야 한다 - 목록을 ◀ ▶ 로 훑다가 앞 파일로 돌아왔을 때 고친 것이 사라지면
|
||||
/// 그것이 곧 E-2 가 없애려던 일이다. 다시 그 짝을 열면 여기서 되살아난다.
|
||||
/// E-1(껐다 켜도 남는 편집)은 이 보관함을 그대로 파일에 옮겨 적으면 된다.
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, DocTabState> _attic =
|
||||
new Dictionary<string, DocTabState>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>보관 순서(오래된 것이 앞). 넘치면 앞에서 버린다.</summary>
|
||||
private readonly List<string> _atticOrder = new List<string>();
|
||||
|
||||
/// <summary>보관함에 이만큼만 둔다. 짝마다 파일 전문 한 벌이라 끝없이 쌓을 수 없다.</summary>
|
||||
public const int AtticMax = 50;
|
||||
|
||||
public int StashedCount => _attic.Count;
|
||||
|
||||
/// <summary>짝 하나를 가리키는 이름. 윈도우 경로에는 널 문자가 들어갈 수 없어 가름자로 쓴다.</summary>
|
||||
public static string KeyOf(string? left, string? right) =>
|
||||
(left ?? string.Empty) + "\0" + (right ?? string.Empty);
|
||||
|
||||
/// <summary>이 짝으로 보관해 둔 편집. 없으면 null.</summary>
|
||||
public DocTabState? Stashed(string left, string right) =>
|
||||
_attic.TryGetValue(KeyOf(left, right), out var state) ? state : null;
|
||||
|
||||
/// <summary>탭이 사라질 때 그 편집을 보관함으로 옮긴다. 고친 것이 없으면 보관하지 않고 지운다.</summary>
|
||||
private void Stash(DocTab tab)
|
||||
{
|
||||
var key = KeyOf(tab.Left, tab.Right);
|
||||
|
||||
if (tab.Parked is { } state && state.HasEdits)
|
||||
{
|
||||
if (!_attic.ContainsKey(key)) _atticOrder.Add(key);
|
||||
_attic[key] = state;
|
||||
|
||||
while (_atticOrder.Count > AtticMax)
|
||||
{
|
||||
var oldest = _atticOrder[0];
|
||||
_atticOrder.RemoveAt(0);
|
||||
_attic.Remove(oldest);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_attic.Remove(key)) _atticOrder.Remove(key);
|
||||
}
|
||||
|
||||
/// <summary>새 탭을 만들 때 보관해 둔 편집이 있으면 되찾아 얹는다.</summary>
|
||||
private DocTab NewTab(string left, string right)
|
||||
{
|
||||
var tab = new DocTab(left, right);
|
||||
var key = KeyOf(left, right);
|
||||
|
||||
if (_attic.TryGetValue(key, out var kept))
|
||||
{
|
||||
tab.Parked = kept;
|
||||
tab.IsDirty = true;
|
||||
_attic.Remove(key);
|
||||
_atticOrder.Remove(key);
|
||||
}
|
||||
|
||||
return tab;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 짝을 연다. 같은 짝이 이미 있으면 <b>새로 만들지 않고 그 탭을 띄운다</b> -
|
||||
/// 같은 파일이 두 탭에 있으면 어느 쪽 편집이 맞는지 알 수 없다.
|
||||
/// </summary>
|
||||
public DocTab Open(string left, string right)
|
||||
{
|
||||
LastEvicted = null;
|
||||
|
||||
var found = Find(left, right);
|
||||
if (found is not null)
|
||||
{
|
||||
Activate(found);
|
||||
return found;
|
||||
}
|
||||
|
||||
Evict();
|
||||
|
||||
var tab = NewTab(left, right);
|
||||
_tabs.Add(tab);
|
||||
Retitle();
|
||||
Activate(tab);
|
||||
return tab;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 떠 있는 탭의 짝을 다른 짝으로 바꾼다(◀ 이전 파일 · 다음 파일 ▶).
|
||||
/// 탭이 늘지 않아 목록을 훑기 좋다 - 사용자가 정한 것.
|
||||
/// </summary>
|
||||
public DocTab Replace(string left, string right)
|
||||
{
|
||||
if (Current is null) return Open(left, right);
|
||||
|
||||
// 바꾸려는 짝을 다른 탭이 이미 들고 있으면 그 탭으로 간다(같은 짝 두 탭 금지).
|
||||
var found = Find(left, right);
|
||||
if (found is not null && !ReferenceEquals(found, Current))
|
||||
{
|
||||
Activate(found);
|
||||
return found;
|
||||
}
|
||||
if (found is not null) return found;
|
||||
|
||||
var at = _tabs.IndexOf(Current);
|
||||
|
||||
// 내려가는 탭의 편집은 보관함으로 간다 - 훑다가 돌아오면 되살아난다.
|
||||
Stash(Current);
|
||||
Current.IsCurrent = false;
|
||||
|
||||
var tab = NewTab(left, right);
|
||||
_tabs[at] = tab;
|
||||
Retitle();
|
||||
Activate(tab);
|
||||
return tab;
|
||||
}
|
||||
|
||||
public void Activate(DocTab tab)
|
||||
{
|
||||
if (tab is null || !_tabs.Contains(tab)) return;
|
||||
Current = tab;
|
||||
tab.Touched = ++_clock;
|
||||
foreach (var other in _tabs) other.IsCurrent = ReferenceEquals(other, tab);
|
||||
}
|
||||
|
||||
public void Activate(int index)
|
||||
{
|
||||
if (index < 0 || index >= _tabs.Count) return;
|
||||
Activate(_tabs[index]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 탭을 닫는다. 닫은 자리의 <b>오른쪽</b> 탭으로 옮겨 가고, 오른쪽이 없으면 왼쪽으로 간다 -
|
||||
/// 여러 장을 차례로 닫을 때 손이 한 자리에 머문다.
|
||||
/// </summary>
|
||||
public DocTab? Close(DocTab tab)
|
||||
{
|
||||
var at = _tabs.IndexOf(tab);
|
||||
if (at < 0) return Current;
|
||||
|
||||
_tabs.RemoveAt(at);
|
||||
|
||||
// 닫아도 고친 글은 보관함에 남는다 - 그래서 닫을 때 묻지 않는다.
|
||||
Stash(tab);
|
||||
Retitle();
|
||||
|
||||
if (!ReferenceEquals(Current, tab)) return Current;
|
||||
|
||||
tab.IsCurrent = false;
|
||||
if (_tabs.Count == 0) { Current = null; return null; }
|
||||
Activate(_tabs[Math.Min(at, _tabs.Count - 1)]);
|
||||
return Current;
|
||||
}
|
||||
|
||||
/// <summary>다음 탭으로(Ctrl+Tab). 끝에서는 처음으로 돈다.</summary>
|
||||
public DocTab? Next() => Step(1);
|
||||
|
||||
/// <summary>앞 탭으로(Ctrl+Shift+Tab).</summary>
|
||||
public DocTab? Previous() => Step(-1);
|
||||
|
||||
private DocTab? Step(int by)
|
||||
{
|
||||
if (_tabs.Count == 0) return null;
|
||||
var at = CurrentIndex;
|
||||
if (at < 0) { Activate(_tabs[0]); return Current; }
|
||||
|
||||
var next = (at + by) % _tabs.Count;
|
||||
if (next < 0) next += _tabs.Count;
|
||||
Activate(_tabs[next]);
|
||||
return Current;
|
||||
}
|
||||
|
||||
/// <summary>모두 닫는다.</summary>
|
||||
public void Clear()
|
||||
{
|
||||
foreach (var tab in _tabs) { Stash(tab); tab.IsCurrent = false; }
|
||||
_tabs.Clear();
|
||||
Current = null;
|
||||
LastEvicted = null;
|
||||
}
|
||||
|
||||
/// <summary>저장하지 않은 편집이 있는 탭들.</summary>
|
||||
public IReadOnlyList<DocTab> Dirty => _tabs.Where(t => t.IsDirty).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// 자리가 모자라면 <b>저장할 것이 없는</b> 탭 가운데 가장 오래 안 본 것을 치운다.
|
||||
/// 고친 탭은 절대 치우지 않는다 - 그것이 E-1 이 지키려는 바로 그 글이다.
|
||||
/// 모두 고쳐진 상태면 <see cref="Soft"/> 를 넘겨서라도 새 탭을 연다.
|
||||
/// </summary>
|
||||
private void Evict()
|
||||
{
|
||||
if (_tabs.Count < Soft) return;
|
||||
|
||||
var victim = _tabs.Where(t => !t.IsDirty && !ReferenceEquals(t, Current))
|
||||
.OrderBy(t => t.Touched)
|
||||
.FirstOrDefault();
|
||||
if (victim is null) return;
|
||||
|
||||
_tabs.Remove(victim);
|
||||
Stash(victim);
|
||||
victim.IsCurrent = false;
|
||||
LastEvicted = victim;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 탭 이름을 다시 짓는다. 이름이 겹치는 탭끼리만 갈릴 때까지 위 폴더를 한 겹씩 덧붙인다.
|
||||
/// </summary>
|
||||
private void Retitle()
|
||||
{
|
||||
foreach (var tab in _tabs) tab.Title = BaseTitle(tab, 0);
|
||||
|
||||
for (var depth = 1; depth <= 6; depth++)
|
||||
{
|
||||
var clashed = _tabs.GroupBy(t => t.Title, StringComparer.OrdinalIgnoreCase)
|
||||
.Where(g => g.Count() > 1)
|
||||
.SelectMany(g => g)
|
||||
.ToList();
|
||||
if (clashed.Count == 0) return;
|
||||
|
||||
var moved = false;
|
||||
foreach (var tab in clashed)
|
||||
{
|
||||
var wider = BaseTitle(tab, depth);
|
||||
if (string.Equals(wider, tab.Title, StringComparison.Ordinal)) continue;
|
||||
tab.Title = wider;
|
||||
moved = true;
|
||||
}
|
||||
|
||||
// 더 붙일 폴더가 없으면(뿌리까지 갔다) 그대로 둔다 - 끝없이 돌지 않게.
|
||||
if (!moved) return;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 탭 이름. <paramref name="depth"/> 만큼 위 폴더를 앞에 붙인다.
|
||||
/// 양쪽 이름이 같으면 하나만, 다르면 <c>왼 ↔ 오</c> 로 적는다.
|
||||
/// </summary>
|
||||
public static string BaseTitle(DocTab tab, int depth) =>
|
||||
Pair(Tail(tab.Left, depth), Tail(tab.Right, depth));
|
||||
|
||||
/// <summary>
|
||||
/// 두 이름을 한 줄로. 같으면 하나만, 다르면 <c>왼 ↔ 오</c> 인데
|
||||
/// <b>양쪽이 같은 부분은 한 번만 적는다</b>(사용자 결정 2026-09-28).
|
||||
///
|
||||
/// <code>
|
||||
/// WeldController_기준.cs ↔ WeldController_내작업.cs → WeldController_기준 ↔ 내작업.cs
|
||||
/// </code>
|
||||
///
|
||||
/// 사내 소스에는 <c>_기준</c> · <c>_내작업</c> 처럼 <b>이름이 살짝만 다른</b> 짝이 흔해서, 그대로 두면
|
||||
/// 탭 하나가 탭 줄의 1/9(280 DIP)을 먹는다. 겹치는 부분이 짧으면(우연히 첫 글자가 같은 정도)
|
||||
/// 접지 않는다 - 접어서 얻는 것보다 읽기 어려워지는 것이 크다.
|
||||
/// </summary>
|
||||
public static string Pair(string left, string right)
|
||||
{
|
||||
if (string.Equals(left, right, StringComparison.OrdinalIgnoreCase)) return left;
|
||||
|
||||
var head = CommonHead(left, right);
|
||||
|
||||
// 우연히 한 글자 같은 정도로는 접지 않는다 - 접어서 얻는 것보다 읽기 어려워지는 것이 크다.
|
||||
if (head < 2) return left + " ↔ " + right;
|
||||
|
||||
// 뒤쪽은 <b>확장자일 때만</b> 접는다. 글자 단위로 같은 꼬리를 세면 낱말 가운데서 잘린다 -
|
||||
// a-left.cs ↔ a-right.cs 에서 "t.cs" 가 같다고 "a-lef ↔ right.cs" 가 되어 읽을 수 없다(하네스에서 걸렸다).
|
||||
var ext = Path.GetExtension(left);
|
||||
var same = ext.Length > 1 &&
|
||||
string.Equals(ext, Path.GetExtension(right), StringComparison.OrdinalIgnoreCase) &&
|
||||
left.Length - ext.Length > head;
|
||||
|
||||
var shown = same ? left.Substring(0, left.Length - ext.Length) : left;
|
||||
return shown + " ↔ " + right.Substring(head);
|
||||
}
|
||||
|
||||
private static int CommonHead(string a, string b)
|
||||
{
|
||||
var n = Math.Min(a.Length, b.Length);
|
||||
var i = 0;
|
||||
while (i < n && char.ToUpperInvariant(a[i]) == char.ToUpperInvariant(b[i])) i++;
|
||||
return i;
|
||||
}
|
||||
|
||||
/// <summary>경로 끝에서 <paramref name="depth"/>+1 조각. 조각이 모자라면 있는 만큼.</summary>
|
||||
public static string Tail(string path, int depth)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return "-";
|
||||
|
||||
var trimmed = path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var parts = trimmed.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var take = Math.Min(depth + 1, parts.Length);
|
||||
|
||||
var tail = parts.Skip(parts.Length - take).Where(p => p.Length > 0).ToArray();
|
||||
if (tail.Length == 0) return trimmed;
|
||||
|
||||
return string.Join("\\", tail);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 탭 줄에 그릴 글. 저장하지 않은 탭은 앞에 가운뎃점을 붙인다(notepad++ 와 같은 표시).
|
||||
/// </summary>
|
||||
public static string Mark(DocTab tab) => tab.IsDirty ? "• " + tab.Title : tab.Title;
|
||||
}
|
||||
}
|
||||
@ -223,6 +223,8 @@ namespace MergeHub.Services
|
||||
Walk(leftRoot, rightRoot, string.Empty, options, include, exclude, result.Items, pending, ref count, cancel);
|
||||
|
||||
ClassifyAll(pending, options, cancel);
|
||||
// 폴더 줄의 크기는 안에 든 것들의 합(12차 A-1). 걸러 낸 것은 목록에 없으니 합계에도 없다.
|
||||
FolderSizeText.FillFolderSizes(result.Items);
|
||||
Tally(result.Items, result);
|
||||
|
||||
result.Truncated = count >= MaxEntries;
|
||||
|
||||
54
src/MergeHub/Services/FolderSizeText.cs
Normal file
54
src/MergeHub/Services/FolderSizeText.cs
Normal file
@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace MergeHub.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// 폴더 비교 목록에 크기를 적는 법(12차 A-1). 화면 없이 검사한다.
|
||||
///
|
||||
/// 폴더 줄은 예전에 늘 <c>0 B</c> 였다 - 크기를 파일에만 채웠기 때문이다. 이제 <b>안에 든 파일 크기 합계</b>를 적는다
|
||||
/// (사용자 결정). 바이트 수를 그대로 적으면 큰 폴더에서 읽기 어려워 KB · MB · GB 로 줄여 적는다.
|
||||
/// </summary>
|
||||
public static class FolderSizeText
|
||||
{
|
||||
/// <summary>사람이 읽는 크기. 1KB 미만은 바이트 그대로, 그 위는 소수 한 자리.</summary>
|
||||
public static string Of(long bytes)
|
||||
{
|
||||
if (bytes < 0) bytes = 0;
|
||||
if (bytes < 1024) return $"{bytes:N0} B";
|
||||
|
||||
double value = bytes;
|
||||
foreach (var unit in new[] { "KB", "MB", "GB", "TB" })
|
||||
{
|
||||
value /= 1024;
|
||||
if (value < 1024 || unit == "TB") return $"{value:0.#}{unit}";
|
||||
}
|
||||
|
||||
return $"{value:0.#}TB";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 폴더 줄에 적을 합계를 채운다(아래에서 위로). 파일 줄은 그대로 두고, 폴더는 <b>자기 아래 것들의 합</b>이다 -
|
||||
/// 걸러 낸 파일 · 제외한 폴더는 애초에 목록에 없으니 합계에도 들어가지 않는다.
|
||||
/// </summary>
|
||||
public static void FillFolderSizes(IReadOnlyList<FolderCompareItem> items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (!item.IsDirectory) continue;
|
||||
|
||||
FillFolderSizes(item.Children);
|
||||
|
||||
long left = 0, right = 0;
|
||||
foreach (var child in item.Children)
|
||||
{
|
||||
left += child.LeftSize;
|
||||
right += child.RightSize;
|
||||
}
|
||||
|
||||
item.LeftSize = left;
|
||||
item.RightSize = right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -42,14 +42,9 @@ namespace MergeHub.Services
|
||||
private readonly SynchronizationContext? _uiContext;
|
||||
private readonly Logger _logger = new Logger("MergeHub");
|
||||
|
||||
/// <summary>플랫폼에 등록된 사용자. 머지 주석에 남길 이름을 여기서 가져온다.</summary>
|
||||
private readonly UbisamBase.Core.Settings.CurrentUserService? _currentUser;
|
||||
|
||||
public GitWorkspace(IGitService git, ISettingsService settingsService,
|
||||
UbisamBase.Core.Settings.CurrentUserService currentUser)
|
||||
public GitWorkspace(IGitService git, ISettingsService settingsService)
|
||||
{
|
||||
Git = git;
|
||||
_currentUser = currentUser;
|
||||
_uiContext = SynchronizationContext.Current;
|
||||
_settingsService = settingsService;
|
||||
Settings = settingsService.RegisterSettings(nameof(MergeHubSettings), new MergeHubSettings());
|
||||
@ -95,15 +90,6 @@ namespace MergeHub.Services
|
||||
_logger.W($"보내기 바로가기 확인 실패: {ex.Message}");
|
||||
}
|
||||
|
||||
// 설정 화면에서 이름을 바꾸면 바로 따라간다.
|
||||
if (_currentUser is not null)
|
||||
{
|
||||
_currentUser.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(_currentUser.UserName)) AuthorName = ResolveAuthorName();
|
||||
};
|
||||
}
|
||||
|
||||
Git.Progress += (_, message) =>
|
||||
{
|
||||
_logger.I(message);
|
||||
@ -592,19 +578,17 @@ namespace MergeHub.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MergeHub 설정 > 플랫폼에 등록된 사용자 > git config user.name > 윈도우 계정 순으로 고른다.
|
||||
/// MergeHub 설정 > git config user.name > 윈도우 계정 순으로 고른다.
|
||||
///
|
||||
/// 플랫폼 사용자를 Git 설정보다 앞에 두는 이유 - 처음 켤 때 사람이 직접 적어 넣은 이름이고,
|
||||
/// 장비를 여럿이 쓰는 현장에서는 그 이름이 실제로 작업한 사람이다.
|
||||
/// MergeHub 설정을 더 앞에 두는 것은 "이 도구에서만 다르게 남기고 싶다"는 뜻이라 존중한다.
|
||||
/// 예전에는 플랫폼에 등록된 사용자를 가운데에 두었는데, 새 플랫폼에는 그 자리가 없어졌다.
|
||||
/// 이름은 이제 이 프로그램 설정(머지 주석 > 작성자)에서 받는다 - 장비를 여럿이 쓰는 현장에서는
|
||||
/// 여기에 적어 두면 누가 만졌는지 주석에 남는다.
|
||||
/// </summary>
|
||||
private string ResolveAuthorName()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(Settings.MergeCommentAuthor))
|
||||
return Settings.MergeCommentAuthor.Trim();
|
||||
|
||||
if (_currentUser?.HasName == true) return _currentUser.UserName.Trim();
|
||||
|
||||
try
|
||||
{
|
||||
var configured = Git.GetConfig("user.name");
|
||||
|
||||
@ -126,6 +126,16 @@ namespace MergeHub.Services
|
||||
/// <summary>두 칸에서 BASE 쪽을 가져온 줄의 출처.</summary>
|
||||
public static string BaseSource(string? name) => Named("BASE", name);
|
||||
|
||||
/// <summary>왼쪽 파일로 반영한 줄의 출처(12차 B-1). 두 파일 비교에서만 나온다.</summary>
|
||||
public static string LocalSource(string? name) => Named("LOCAL", name);
|
||||
|
||||
/// <summary>왼쪽 파일로 반영한 로그(12차 B-1). 오른쪽으로 반영한 <see cref="TakeBaseLog"/> 의 거울이다.</summary>
|
||||
public static string PushToBaseLog(string? number, string? localName, bool hasLine)
|
||||
{
|
||||
var label = LocalSource(localName);
|
||||
return hasLine ? $"{number}행 {label} 을 왼쪽에 반영" : $"{number}행 왼쪽에서 삭제({label} 에 없음)";
|
||||
}
|
||||
|
||||
private static string Named(string side, string? name) =>
|
||||
string.IsNullOrWhiteSpace(name) ? side : $"{side}({name!.Trim()})";
|
||||
|
||||
|
||||
83
src/MergeHub/Services/PaneSummary.cs
Normal file
83
src/MergeHub/Services/PaneSummary.cs
Normal file
@ -0,0 +1,83 @@
|
||||
using System.Collections.Generic;
|
||||
using MergeHub.Models;
|
||||
|
||||
namespace MergeHub.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// 칸 아래에 적을 한 줄 요약(12차 B-3). "이 칸이 저쪽과 무엇이 다른가"를 그 칸의 눈으로 센다.
|
||||
///
|
||||
/// 화면 맨 아래 상태 줄에는 <b>파일 전체</b> 요약만 있었다. 칸이 둘 · 넷이 되면서
|
||||
/// "이 칸에 몇 줄이 있고 저쪽과 무엇이 다른가"는 눈으로 세야 했다.
|
||||
/// </summary>
|
||||
public sealed class PaneSummary
|
||||
{
|
||||
/// <summary>이 칸에 있는 줄 수(그 파일의 줄 수).</summary>
|
||||
public int Lines { get; set; }
|
||||
|
||||
/// <summary>이 칸에만 있는 줄. 저쪽 눈으로 보면 "지운 줄" 이다.</summary>
|
||||
public int Added { get; set; }
|
||||
|
||||
/// <summary>저쪽에는 있는데 이 칸에 없는 줄.</summary>
|
||||
public int Removed { get; set; }
|
||||
|
||||
/// <summary>양쪽에 다 있는데 글이 다른 줄.</summary>
|
||||
public int Changed { get; set; }
|
||||
|
||||
public bool Same => Added == 0 && Removed == 0 && Changed == 0;
|
||||
|
||||
/// <summary>
|
||||
/// 칸 아래에 적을 글. 다른 것이 없으면 <c>8줄 · 같음</c>, 있으면 있는 것만 적는다
|
||||
/// (<c>8줄 · 추가 2 · 수정 1</c>) - 0 을 늘어놓으면 읽을 것이 늘기만 한다.
|
||||
/// </summary>
|
||||
public string Text()
|
||||
{
|
||||
var text = $"{Lines}줄";
|
||||
if (Same) return text + " · 같음";
|
||||
|
||||
if (Added > 0) text += $" · 추가 {Added}";
|
||||
if (Removed > 0) text += $" · 삭제 {Removed}";
|
||||
if (Changed > 0) text += $" · 수정 {Changed}";
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
public static class PaneSummaries
|
||||
{
|
||||
/// <summary>
|
||||
/// <paramref name="pane"/> 칸을 <paramref name="against"/> 칸과 견준 요약.
|
||||
///
|
||||
/// 3-Way 에서는 늘 BASE(공통 조상)와 견준다 - "누가 무엇을 고쳤나" 가 거기서만 나온다.
|
||||
/// 무시 규칙에 걸린 줄과 우리가 넣은 머지 주석은 차이로 세지 않는다(화면의 색 · 차이 이동과 같은 기준).
|
||||
/// </summary>
|
||||
public static PaneSummary For(IEnumerable<MergeLine> lines, MergeLine.TextPane pane, MergeLine.TextPane against)
|
||||
{
|
||||
var summary = new PaneSummary();
|
||||
if (lines is null) return summary;
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var here = line.IsIn(pane);
|
||||
var there = line.IsIn(against);
|
||||
|
||||
if (here) summary.Lines++;
|
||||
if (line.IsIgnored || line.IsMergeNote || pane == against) continue;
|
||||
|
||||
if (here && !there) summary.Added++;
|
||||
else if (!here && there) summary.Removed++;
|
||||
else if (here && line.DiffersBetween(pane, against)) summary.Changed++;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/// <summary>가운데에 적을 진행. 차이 덩어리 몇 번째인지와 남은 차이 줄 수.</summary>
|
||||
public static string ProgressText(int current, int total)
|
||||
{
|
||||
if (total <= 0) return "차이 없음";
|
||||
return current <= 0 ? $"- / {total}" : $"{current} / {total}";
|
||||
}
|
||||
|
||||
/// <summary>진행 아래 줄. 남은 차이가 없으면 빈 글 - "남은 0" 은 적을 값이 아니다.</summary>
|
||||
public static string RemainingText(int remaining) => remaining <= 0 ? string.Empty : $"남은 {remaining}";
|
||||
}
|
||||
}
|
||||
56
src/MergeHub/Services/PaneText.cs
Normal file
56
src/MergeHub/Services/PaneText.cs
Normal file
@ -0,0 +1,56 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using MergeHub.Models;
|
||||
|
||||
namespace MergeHub.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// 한 칸(BASE · LOCAL)의 글을 만들고, 반영하면 그 칸이 어떻게 되는지 정한다(12차 B-1).
|
||||
///
|
||||
/// 두 파일 비교는 <b>양쪽으로</b> 반영하고 양쪽에 저장한다(사용자 결정 2026-09-27). 두 방향이 같은 규칙을
|
||||
/// 따라야 한쪽만 고쳐지는 일이 없어서, 규칙을 화면 밖 한 곳에 모아 두고 양쪽이 같이 쓴다.
|
||||
/// </summary>
|
||||
public static class PaneText
|
||||
{
|
||||
/// <summary>그 칸의 지금 글. 그 칸에 없는 줄은 빠진다(줄 끝은 늘 <c>\n</c>).</summary>
|
||||
public static string Build(IEnumerable<MergeLine> lines, MergeLine.TextPane pane)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (!Has(line, pane)) continue;
|
||||
sb.Append(Text(line, pane)).Append('\n');
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="target"/> 칸에 반영하면 그 줄이 어떻게 되는지.
|
||||
///
|
||||
/// 반대쪽에 줄이 있으면 그 글을 그대로 가져오고(없던 줄이면 생긴다),
|
||||
/// 반대쪽에 없으면 이 칸에서도 <b>지운다</b> - 두 파일을 같게 만드는 것이 반영이다.
|
||||
/// </summary>
|
||||
public static (string Text, bool Has) Plan(MergeLine line, MergeLine.TextPane target)
|
||||
{
|
||||
var from = Other(target);
|
||||
return Has(line, from) ? (Text(line, from), true) : (string.Empty, false);
|
||||
}
|
||||
|
||||
/// <summary>반영하면 실제로 달라지는지. 같으면 되돌리기에 쌓을 것도 없다.</summary>
|
||||
public static bool Changes(MergeLine line, MergeLine.TextPane target)
|
||||
{
|
||||
var (text, has) = Plan(line, target);
|
||||
return has != Has(line, target) || !string.Equals(text, Text(line, target), System.StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>두 칸짜리 비교에서 반대쪽 칸.</summary>
|
||||
public static MergeLine.TextPane Other(MergeLine.TextPane pane) =>
|
||||
pane == MergeLine.TextPane.Base ? MergeLine.TextPane.Local : MergeLine.TextPane.Base;
|
||||
|
||||
private static bool Has(MergeLine line, MergeLine.TextPane pane) =>
|
||||
pane == MergeLine.TextPane.Base ? line.HasBase : line.HasLocal;
|
||||
|
||||
private static string Text(MergeLine line, MergeLine.TextPane pane) =>
|
||||
pane == MergeLine.TextPane.Base ? line.BaseText : line.LocalText;
|
||||
}
|
||||
}
|
||||
187
src/MergeHub/Services/ProgramInfo.cs
Normal file
187
src/MergeHub/Services/ProgramInfo.cs
Normal file
@ -0,0 +1,187 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
|
||||
namespace MergeHub.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// 프로그램 정보 창에 적을 것들(12차 B-2). 관리 목적이라 <b>실제 실행 파일의 값</b>이어야 한다 -
|
||||
/// 손으로 적은 글을 보여 주면 옛 버전을 쓰면서 새 버전이라고 읽게 된다.
|
||||
/// </summary>
|
||||
public sealed class ProgramInfo
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>어셈블리 버전. csproj 의 <c><Version></c> 이 그대로 온다.</summary>
|
||||
public string Version { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>실행 파일을 만든 시각. 못 읽으면 빈 글.</summary>
|
||||
public string BuildDate { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>만든 사람. 설정에 적어 두면 그것, 없으면 어셈블리의 회사 이름.</summary>
|
||||
public string Author { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>사내 문의처. 설정에서 적는다 - 사람이 바뀌면 소스를 고칠 일이 아니다.</summary>
|
||||
public string Contact { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>적혀 있지 않은 칸에 대신 적을 글. 빈칸으로 두면 "왜 비었나" 를 묻게 된다.</summary>
|
||||
public const string NotSet = "적혀 있지 않습니다 (설정 > MergeHub 에서 적습니다)";
|
||||
|
||||
public const string Unknown = "알 수 없음";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 실행 파일에서 프로그램 정보를 읽는다. 셈하는 부분은 화면 · 파일 없이 검사할 수 있게 떼어 두었다.
|
||||
/// </summary>
|
||||
public static class ProgramInfoReader
|
||||
{
|
||||
/// <summary>
|
||||
/// 버전을 사람이 읽는 글로. <c>0.12.0.0</c> 처럼 뒤가 0 인 자리는 떼고 적어도 두 자리는 남긴다
|
||||
/// (<c>1.0</c>). 네 자리를 그대로 보이면 무엇이 올라간 것인지 읽히지 않는다.
|
||||
/// </summary>
|
||||
public static string VersionText(Version? version)
|
||||
{
|
||||
if (version is null) return ProgramInfo.Unknown;
|
||||
|
||||
var parts = new[] { version.Major, version.Minor, version.Build, version.Revision };
|
||||
var last = 1;
|
||||
for (var i = 3; i >= 2; i--)
|
||||
{
|
||||
if (parts[i] > 0) { last = i; break; }
|
||||
}
|
||||
|
||||
var text = string.Empty;
|
||||
for (var i = 0; i <= last; i++) text += (i == 0 ? string.Empty : ".") + Math.Max(0, parts[i]);
|
||||
return text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 실행 파일(PE)에 박힌 <b>만든 시각</b>. 파일을 복사해도 따라오므로 수정 시각보다 믿을 만하다.
|
||||
///
|
||||
/// PE 머리 구조: <c>0x3C</c> 에 PE 머리 자리가 있고, 그 뒤 <c>PE\0\0</c> 4바이트 다음 COFF 머리의
|
||||
/// 4바이트가 1970-01-01 부터의 초다. .NET Framework 는 결정적 빌드가 아니라 이 값이 실제 빌드 시각이다.
|
||||
/// 모양이 맞지 않으면 null - 부르는 쪽이 파일 수정 시각으로 넘어간다.
|
||||
/// </summary>
|
||||
public static DateTime? BuildTimeFromPe(byte[] bytes)
|
||||
{
|
||||
if (bytes is null || bytes.Length < 0x40) return null;
|
||||
|
||||
var at = BitConverter.ToInt32(bytes, 0x3C);
|
||||
if (at <= 0 || at + 8 > bytes.Length) return null;
|
||||
if (!(bytes[at] == (byte)'P' && bytes[at + 1] == (byte)'E' && bytes[at + 2] == 0 && bytes[at + 3] == 0))
|
||||
return null;
|
||||
|
||||
var seconds = BitConverter.ToUInt32(bytes, at + 8);
|
||||
if (seconds == 0) return null;
|
||||
|
||||
var when = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddSeconds(seconds).ToLocalTime();
|
||||
|
||||
// 결정적 빌드는 이 자리에 해시를 넣는다 - 그러면 2100년 같은 값이 나온다. 그런 값은 버린다.
|
||||
if (when.Year < 2000 || when > DateTime.Now.AddDays(1)) return null;
|
||||
return when;
|
||||
}
|
||||
|
||||
/// <summary>빌드 시각을 글로. 못 읽으면 빈 글이 아니라 "알 수 없음" 이라고 적는다.</summary>
|
||||
public static string BuildDateText(DateTime? when) =>
|
||||
when is null ? ProgramInfo.Unknown : when.Value.ToString("yyyy-MM-dd HH:mm");
|
||||
|
||||
/// <summary>
|
||||
/// 빌드 날짜를 무엇으로 적을지 고른다.
|
||||
///
|
||||
/// 차례는 <b>어셈블리에 박아 둔 값 → PE 시각 → 파일 수정 시각</b> 이다.
|
||||
/// 박아 둔 값이 첫째인 까닭 - 요즘 SDK 는 결정적 빌드라 PE 시각 자리에 내용 해시를 넣어
|
||||
/// 날짜가 아예 아니다(그래서 csproj 에서 빌드할 때 날짜를 박는다).
|
||||
/// 파일 수정 시각은 마지막 수단이다 - 복사 · 압축 풀기로 바뀔 수 있다.
|
||||
/// </summary>
|
||||
public static string ChooseBuildDate(string? stamped, DateTime? pe, DateTime? fileTime)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(stamped)) return stamped!.Trim();
|
||||
if (pe is not null) return BuildDateText(pe);
|
||||
return BuildDateText(fileTime);
|
||||
}
|
||||
|
||||
/// <summary>두 값 중 앞의 것을 쓰되, 비었으면 뒤의 것. 둘 다 비면 <see cref="ProgramInfo.NotSet"/>.</summary>
|
||||
public static string Prefer(string? first, string? second) =>
|
||||
!string.IsNullOrWhiteSpace(first) ? first!.Trim()
|
||||
: !string.IsNullOrWhiteSpace(second) ? second!.Trim()
|
||||
: ProgramInfo.NotSet;
|
||||
|
||||
/// <summary>실행 파일에서 읽어 채운다. 설정에 적어 둔 만든 사람 · 문의처가 있으면 그것을 앞에 둔다.</summary>
|
||||
public static ProgramInfo For(Assembly assembly, string? author, string? contact, Func<string, byte[]?>? readBytes = null)
|
||||
{
|
||||
var name = assembly.GetCustomAttribute<AssemblyProductAttribute>()?.Product;
|
||||
var company = assembly.GetCustomAttribute<AssemblyCompanyAttribute>()?.Company;
|
||||
var path = SafeLocation(assembly);
|
||||
|
||||
DateTime? pe = null;
|
||||
DateTime? fileTime = null;
|
||||
if (path is not null)
|
||||
{
|
||||
var bytes = (readBytes ?? ReadHead)(path);
|
||||
pe = BuildTimeFromPe(bytes ?? Array.Empty<byte>());
|
||||
fileTime = SafeWriteTime(path);
|
||||
}
|
||||
|
||||
var stamped = StampedBuildDate(assembly);
|
||||
|
||||
return new ProgramInfo
|
||||
{
|
||||
Name = Prefer(name, assembly.GetName().Name),
|
||||
Version = VersionText(assembly.GetName().Version),
|
||||
BuildDate = ChooseBuildDate(stamped, pe, fileTime),
|
||||
Author = Prefer(author, company),
|
||||
Contact = Prefer(contact, null),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>csproj 가 빌드할 때 박아 둔 날짜(AssemblyMetadata "BuildDate"). 없으면 null.</summary>
|
||||
public static string? StampedBuildDate(Assembly assembly)
|
||||
{
|
||||
foreach (var meta in assembly.GetCustomAttributes<AssemblyMetadataAttribute>())
|
||||
{
|
||||
if (string.Equals(meta.Key, "BuildDate", StringComparison.Ordinal)) return meta.Value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>PE 머리를 읽을 만큼만. 파일을 통째로 들 까닭이 없다.</summary>
|
||||
private static byte[]? ReadHead(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var file = File.OpenRead(path);
|
||||
var head = new byte[Math.Min(4096, file.Length)];
|
||||
var read = file.Read(head, 0, head.Length);
|
||||
return read == head.Length ? head : null;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? SafeLocation(Assembly assembly)
|
||||
{
|
||||
try
|
||||
{
|
||||
return string.IsNullOrEmpty(assembly.Location) ? null : assembly.Location;
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
return null; // 단일 파일 · 메모리에서 띄운 어셈블리
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTime? SafeWriteTime(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.Exists(path) ? File.GetLastWriteTime(path) : (DateTime?)null;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -38,6 +38,11 @@ namespace MergeHub.Services
|
||||
FontBigger,
|
||||
FontSmaller,
|
||||
FontReset,
|
||||
|
||||
// 12차 E-2 문서 탭
|
||||
NextTab,
|
||||
PrevTab,
|
||||
CloseTab,
|
||||
}
|
||||
|
||||
/// <summary>단축키 한 줄. 키 처리와 설명 창이 <b>둘 다 이 목록만</b> 본다.</summary>
|
||||
@ -143,7 +148,17 @@ namespace MergeHub.Services
|
||||
"폴더 비교 목록에서 앞 파일로"),
|
||||
new ShortcutEntry(ShortcutAction.NextFile, Key.PageDown, ModifierKeys.Control, "이동·북마크",
|
||||
"폴더 비교 목록에서 다음 파일로",
|
||||
"폴더 비교에서 파일을 열었을 때만 동작한다. 파일 하나를 보고 폴더 탭으로 돌아갔다 다시 여는 왕복을 없애려는 것이다."),
|
||||
"폴더 비교에서 파일을 열었을 때만 동작한다. 파일 하나를 보고 폴더 탭으로 돌아갔다 다시 여는 왕복을 없애려는 것이다. 12차 E-2 부터는 지금 탭의 짝만 바뀌어 탭이 늘지 않는다."),
|
||||
|
||||
// 12차 E-2 문서 탭. Ctrl+Tab · Ctrl+W 는 notepad++ · 브라우저와 같은 손버릇이다.
|
||||
new ShortcutEntry(ShortcutAction.NextTab, Key.Tab, ModifierKeys.Control, "문서 탭",
|
||||
"다음 문서 탭으로",
|
||||
"짝 여러 개를 동시에 열어 두고 오갈 수 있다. 저장하지 않은 편집은 탭이 맡아 두므로 옮길 때 묻지 않는다."),
|
||||
new ShortcutEntry(ShortcutAction.PrevTab, Key.Tab, ModifierKeys.Control | ModifierKeys.Shift, "문서 탭",
|
||||
"앞 문서 탭으로"),
|
||||
new ShortcutEntry(ShortcutAction.CloseTab, Key.W, ModifierKeys.Control, "문서 탭",
|
||||
"이 문서 탭 닫기",
|
||||
"닫아도 저장하지 않은 편집은 그대로 보관된다 - 묻지 않는다."),
|
||||
|
||||
new ShortcutEntry(ShortcutAction.OpenFolderCompare, Key.D, ModifierKeys.Control, "파일·보기", "폴더 비교 열기",
|
||||
"WinMerge 는 새 비교 창을 띄우지만 여기서는 탭 하나로 돌아간다 - 창을 여러 개 띄우는 도구가 아니다."),
|
||||
|
||||
@ -33,18 +33,28 @@ namespace MergeHub.ViewModels
|
||||
/// </summary>
|
||||
public sealed partial class FolderNode : ObservableObject
|
||||
{
|
||||
public FolderNode(FolderCompareItem item, string leftRoot, string rightRoot)
|
||||
public FolderNode(FolderCompareItem item, string leftRoot, string rightRoot, int depth = 0)
|
||||
{
|
||||
Item = item;
|
||||
Depth = depth;
|
||||
LeftPath = Path.Combine(leftRoot, item.RelativePath);
|
||||
RightPath = Path.Combine(rightRoot, item.RelativePath);
|
||||
|
||||
foreach (var child in item.Children)
|
||||
{
|
||||
Children.Add(new FolderNode(child, leftRoot, rightRoot));
|
||||
Children.Add(new FolderNode(child, leftRoot, rightRoot, depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 트리에서 몇 겹 안인지(뿌리 0).
|
||||
///
|
||||
/// 좌 · 우 두 목록을 나란히 두려면(12차 A-2) 가운데 칸과 오른쪽 칸이 <b>줄마다 같은 자리</b>에 와야 하는데,
|
||||
/// TreeView 는 깊이만큼 줄 전체를 오른쪽으로 민다. 화면 쪽에서 이 값으로 민 만큼 되돌리고,
|
||||
/// 이름에만 다시 그만큼 들여쓴다.
|
||||
/// </summary>
|
||||
public int Depth { get; }
|
||||
|
||||
public FolderCompareItem Item { get; }
|
||||
public ObservableCollection<FolderNode> Children { get; } = new ObservableCollection<FolderNode>();
|
||||
|
||||
@ -88,6 +98,11 @@ namespace MergeHub.ViewModels
|
||||
public string LeftInfo => Describe(Item.LeftExists, Item.LeftSize, Item.LeftModified);
|
||||
public string RightInfo => Describe(Item.RightExists, Item.RightSize, Item.RightModified);
|
||||
|
||||
/// <summary>왼쪽에 있는 것인지. 없으면 왼쪽 목록의 그 줄은 <b>비워 둔다</b>(12차 A-2).</summary>
|
||||
public bool HasLeft => Item.LeftExists;
|
||||
|
||||
public bool HasRight => Item.RightExists;
|
||||
|
||||
/// <summary>
|
||||
/// 줄 단위 비교 화면으로 넘길 수 있는 항목인지.
|
||||
///
|
||||
@ -107,10 +122,14 @@ namespace MergeHub.ViewModels
|
||||
|
||||
private static string Describe(bool exists, long size, DateTime? modified)
|
||||
{
|
||||
if (!exists) return "-";
|
||||
// 없는 쪽은 비워 둔다(12차 A-2). 예전에는 "-" 를 적었는데, 두 목록을 나란히 놓고 보면
|
||||
// 빈칸이 "여기엔 없다"를 더 빨리 말해 준다 - 어느 쪽에만 있는지는 가운데 상태 칸이 적는다.
|
||||
if (!exists) return string.Empty;
|
||||
|
||||
// 크기는 사람이 읽는 단위로(12차 A-1). 폴더 줄은 안에 든 파일 크기의 합이다 - 예전에는 폴더가 늘 0 B 였다.
|
||||
var when = modified?.ToString("yyyy-MM-dd HH:mm") ?? string.Empty;
|
||||
return $"{size:N0} B · {when}";
|
||||
var text = Services.FolderSizeText.Of(size);
|
||||
return string.IsNullOrEmpty(when) ? text : $"{text} · {when}";
|
||||
}
|
||||
}
|
||||
|
||||
@ -594,7 +613,7 @@ namespace MergeHub.ViewModels
|
||||
|
||||
foreach (var item in _lastResult.Items)
|
||||
{
|
||||
var node = new FolderNode(item, _lastLeft, _lastRight);
|
||||
var node = new FolderNode(item, _lastLeft, _lastRight, 0);
|
||||
if (!Keep(node)) continue;
|
||||
|
||||
Prune(node);
|
||||
|
||||
336
src/MergeHub/ViewModels/HomeViewModel.Tabs.cs
Normal file
336
src/MergeHub/ViewModels/HomeViewModel.Tabs.cs
Normal file
@ -0,0 +1,336 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using MergeHub.Services;
|
||||
using UbisamBase.Core.Messaging;
|
||||
|
||||
namespace MergeHub.ViewModels
|
||||
{
|
||||
/// <summary>
|
||||
/// 파일 비교 화면의 <b>문서 탭</b>(12차 E-2).
|
||||
///
|
||||
/// 지금까지 이 화면은 한 번에 한 짝만 들었다. 짝을 바꾸면 앞 짝의 편집 글이 갈 곳이 없어서
|
||||
/// "저장하지 않은 편집이 있습니다" 를 묻고, [저장하지 않고 계속] 을 고르면 그 글이 사라졌다.
|
||||
/// 이제는 <b>떠 있던 탭이 그 글을 맡아 둔다</b> - 그래서 짝을 바꿀 때 묻지 않는다.
|
||||
///
|
||||
/// <para>
|
||||
/// 탭마다 <see cref="HomeViewModel"/> 을 따로 만들지 않는다 - 이 화면은 GitWorkspace 의 여러 사건과
|
||||
/// 한 자리짜리 약속(<c>ConfirmDiscardEdits</c> · <c>ConfirmLeaveCommit</c>)에 물려 있어서,
|
||||
/// 여럿을 만들면 같은 사건에 여러 화면이 동시에 반응하고 약속은 마지막 것만 남는다.
|
||||
/// 대신 <b>화면은 하나로 두고 탭이 편집만 맡는다</b> - 파일에서 다시 읽을 수 있는 것(원본 글 · 줄 정렬)은
|
||||
/// 맡기지 않고, 파일에 없는 것(고친 글 · 되돌리기 이력 · 캐럿)만 맡긴다.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Git 탭 · 병합 중(네 칸)은 탭이 맡지 않는다 - 그쪽 상태는 git 이 이미 들고 있고,
|
||||
/// 사용자가 정한 범위도 "두 파일 비교만" 이다.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed partial class HomeViewModel
|
||||
{
|
||||
/// <summary>문서 탭 목록. 계산은 <see cref="DocTabs"/> 가 하고 여기서는 화면과 이어 준다.</summary>
|
||||
public DocTabs Tabs { get; } = new DocTabs();
|
||||
|
||||
/// <summary>탭 줄에 뿌릴 목록. <see cref="DocTabs.Tabs"/> 를 그대로 옮겨 담는다.</summary>
|
||||
public ObservableCollection<DocTab> TabItems { get; } = new ObservableCollection<DocTab>();
|
||||
|
||||
/// <summary>탭이 둘 이상이고 두 파일 비교일 때만 탭 줄을 보여 준다.</summary>
|
||||
public bool ShowTabStrip => Tabs.ShowStrip && TabsCanHoldEdits;
|
||||
|
||||
/// <summary>
|
||||
/// 지금 탭이 편집을 맡을 수 있는 자리인가. 두 파일 비교(2-Way)일 때만이다.
|
||||
/// Git 모드 · 병합 중 네 칸에서는 맡을 탭이 없으니 예전처럼 묻고 넘어간다.
|
||||
/// </summary>
|
||||
private bool TabsCanHoldEdits => IsPlainMode && !IsThreeWay && _mergeTool is null;
|
||||
|
||||
/// <summary>탭 줄에서 고른 탭. 고르는 것만으로 그 탭으로 옮겨 간다.</summary>
|
||||
public DocTab? CurrentTab
|
||||
{
|
||||
get => Tabs.Current;
|
||||
set
|
||||
{
|
||||
if (value is null || ReferenceEquals(value, Tabs.Current)) return;
|
||||
GoToTab(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>파일을 다시 읽은 뒤에 되살릴 탭. <see cref="ResumePendingTab"/> 가 쓰고 비운다.</summary>
|
||||
private DocTab? _resumeTab;
|
||||
|
||||
/// <summary>지금 탭을 바꾸는 중(◀ 이전 파일 · 다음 파일 ▶). 새 탭을 만들지 않는다.</summary>
|
||||
private bool _replaceTab;
|
||||
|
||||
// ================= 화면 ↔ 탭 =================
|
||||
|
||||
/// <summary>편집기의 캐럿 자리를 읽어 오는 길. View 가 꽂아 준다(<see cref="ReadEditorText"/> 와 같은 방식).</summary>
|
||||
public Func<int>? ReadCaretIndex { get; set; }
|
||||
|
||||
/// <summary>캐럿을 이 자리로 옮겨 달라고 View 에 부탁한다.</summary>
|
||||
public event EventHandler<int>? CaretIndexRequested;
|
||||
|
||||
private void SyncTabItems()
|
||||
{
|
||||
// 목록이 그대로면 손대지 않는다 - 탭을 옮길 때마다 비우고 다시 담으면
|
||||
// 탭 줄(ListBox)의 고르기가 풀려 깜빡이고, 가로 스크롤도 처음으로 돌아간다.
|
||||
if (!TabItems.SequenceEqual(Tabs.Tabs))
|
||||
{
|
||||
TabItems.Clear();
|
||||
foreach (var tab in Tabs.Tabs) TabItems.Add(tab);
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(ShowTabStrip));
|
||||
OnPropertyChanged(nameof(CurrentTab));
|
||||
CloseTabCommand.NotifyCanExecuteChanged();
|
||||
NextTabCommand.NotifyCanExecuteChanged();
|
||||
PreviousTabCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 지금 화면의 "저장하지 않은 것" 을 떠 있는 탭에 맡긴다.
|
||||
///
|
||||
/// 줄 단위 되돌리기 목록은 줄 객체를 가리켜 다른 짝을 실으면 쓸 수 없다 -
|
||||
/// 맡기기 전에 글 단위로 옮긴다(11차 F-8 <c>FlattenHistory</c> 와 같은 이유).
|
||||
/// </summary>
|
||||
private void ParkCurrentTab()
|
||||
{
|
||||
var tab = Tabs.Current;
|
||||
if (tab is null || !TabsCanHoldEdits) return;
|
||||
|
||||
FlattenHistory();
|
||||
|
||||
var right = BuildLocalText();
|
||||
var left = BuildBaseText();
|
||||
|
||||
// 스타일 맞춤으로만 바뀐 글은 맡기지 않는다 - 다시 열면 똑같이 만들어진다.
|
||||
var typed = HasChanges && !OnlyStyleChanged;
|
||||
tab.IsDirty = HasTypedEdits;
|
||||
tab.Parked = new DocTabState
|
||||
{
|
||||
Text = typed ? right : null,
|
||||
LeftText = HasLeftChanges ? left : null,
|
||||
Undo = _textUndo.ToArray(),
|
||||
Redo = _textRedo.ToArray(),
|
||||
Opened = _baseline.Opened,
|
||||
Saved = _baseline.Saved,
|
||||
LeftOpened = _leftBaseline.Opened,
|
||||
LeftSaved = _leftBaseline.Saved,
|
||||
CaretIndex = ReadCaretIndex?.Invoke() ?? 0,
|
||||
TopRow = CaretRow,
|
||||
SaveEncoding = SaveEncoding,
|
||||
SaveNewLine = SaveNewLine
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 파일을 다시 읽은 직후에 맡겨 둔 편집을 화면에 되살린다.
|
||||
///
|
||||
/// <b>원본 파일이 그 사이 바뀌었으면</b> 어떻게 하는가 - 되살리고 알린다.
|
||||
/// 사용자가 아까운 것은 자기가 고친 글이다. 파일을 연 시점(<c>Opened</c>)은 <b>새로 읽은 글</b>로 두어
|
||||
/// [전체 되돌리기]가 지금 파일로 가고, 고친 글은 편집으로 남는다 - 그래야 어느 쪽도 잃지 않는다.
|
||||
/// </summary>
|
||||
private void ResumePendingTab()
|
||||
{
|
||||
var tab = _resumeTab;
|
||||
_resumeTab = null;
|
||||
if (tab is null) return;
|
||||
|
||||
var parked = tab.Parked;
|
||||
tab.Parked = null;
|
||||
if (parked is null || !parked.HasEdits) { RefreshTabDirty(); return; }
|
||||
|
||||
// 새로 읽은 글. 맡길 때의 "연 시점" 과 견줘 파일이 바뀌었는지 본다.
|
||||
var fresh = BuildLocalText();
|
||||
var fileChanged = !SaveBaseline.SameText(parked.Opened, fresh);
|
||||
|
||||
if (parked.SaveEncoding is not null) SaveEncoding = parked.SaveEncoding;
|
||||
if (parked.SaveNewLine is not null) SaveNewLine = parked.SaveNewLine;
|
||||
|
||||
// 왼쪽(BASE) 칸에 고친 것이 있었으면 그 글로 다시 세운다(12차 B-1).
|
||||
if (parked.LeftText is not null)
|
||||
{
|
||||
_baseTextSnapshot = parked.LeftText;
|
||||
if (!fileChanged) _leftBaseline.Resume(parked.LeftOpened, parked.LeftSaved);
|
||||
}
|
||||
|
||||
if (parked.Text is not null)
|
||||
{
|
||||
ClearHistory();
|
||||
for (var i = parked.Undo.Length - 1; i >= 0; i--) _textUndo.Push(parked.Undo[i]);
|
||||
for (var i = parked.Redo.Length - 1; i >= 0; i--) _textRedo.Push(parked.Redo[i]);
|
||||
|
||||
if (!fileChanged) _baseline.Resume(parked.Opened, parked.Saved);
|
||||
|
||||
ReloadLinesFrom(parked.Text, keepAsEdited: true, moveSelection: false);
|
||||
}
|
||||
else
|
||||
{
|
||||
ReloadLinesFrom(BuildLocalText(), keepAsEdited: true, moveSelection: false);
|
||||
}
|
||||
|
||||
HasLeftChanges = _leftBaseline.IsDirty(BuildBaseText(), stylePending: false);
|
||||
|
||||
// 캐럿과 스크롤을 그 자리로.
|
||||
if (parked.TopRow > 0 && parked.TopRow < Lines.Count) GoToRow(parked.TopRow);
|
||||
if (parked.CaretIndex > 0) CaretIndexRequested?.Invoke(this, parked.CaretIndex);
|
||||
|
||||
RefreshTabDirty();
|
||||
UpdateSummaries();
|
||||
RaiseCanExecuteChanged();
|
||||
|
||||
if (fileChanged)
|
||||
MessageUtil.ShowWarningToast(
|
||||
$"{tab.Title} 은 그 사이 파일이 바뀌었습니다. 저장하지 않은 편집을 그대로 되살렸습니다 - 저장하면 바뀐 파일을 덮어씁니다.");
|
||||
else
|
||||
_workspace.AppendLog($"탭 되살림: {tab.Title}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 짝 하나를 탭으로 연다. 여는 길(<c>OnPlainCompareRequested</c>)이 파일을 읽기 <b>직전</b>에 부른다.
|
||||
/// </summary>
|
||||
/// <param name="replace">지금 탭의 짝만 바꾼다(◀ 이전 파일 · 다음 파일 ▶). 탭이 늘지 않는다.</param>
|
||||
private void OpenPairInTab(string left, string right, bool replace)
|
||||
{
|
||||
if (!TabsCanHoldEdits)
|
||||
{
|
||||
// Git · 네 칸에서 두 파일 비교로 넘어온다. 여기서부터 탭이 맡는다 -
|
||||
// 앞의 것은 이미 물어서 정리했으므로 맡길 것이 없다.
|
||||
Tabs.Clear();
|
||||
var fresh = Tabs.Open(left, right);
|
||||
_resumeTab = fresh;
|
||||
SyncTabItems();
|
||||
return;
|
||||
}
|
||||
|
||||
ParkCurrentTab();
|
||||
|
||||
var tab = replace ? Tabs.Replace(left, right) : Tabs.Open(left, right);
|
||||
_resumeTab = tab;
|
||||
SyncTabItems();
|
||||
|
||||
if (Tabs.LastEvicted is { } gone)
|
||||
_workspace.AppendLog($"탭이 {DocTabs.Soft}개를 넘어 오래 안 본 탭을 닫음: {gone.Title}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 지금 짝에 탭이 하나는 있게 한다. <see cref="LoadPlainFilesAsync"/> 가 맨 앞에서 부른다.
|
||||
///
|
||||
/// 여는 길(<c>OnPlainCompareRequested</c>)을 거치지 않고 바로 짝이 바뀌는 자리가 있다 -
|
||||
/// 켤 때 설정의 기본 짝, [기준 파일...] · [내 파일...] 로 한쪽만 바꾸기, 데모 전환 뒤 돌아오기.
|
||||
/// 그 자리에 탭이 없으면 거기서 고친 것을 맡을 데가 없어 <b>다음에 짝을 바꿀 때 사라진다</b> -
|
||||
/// E-2 가 없애려던 바로 그 일이다.
|
||||
///
|
||||
/// 이미 떠 있는 탭이 있으면 <b>그 탭의 짝을 바꾼다</b>(탭이 늘지 않는다) -
|
||||
/// 한쪽 파일만 바꾸는 것은 새 문서를 여는 것이 아니라 지금 보는 것을 바꾸는 일이다.
|
||||
/// 내려간 짝의 편집은 보관함으로 가므로 되돌아오면 되살아난다.
|
||||
/// </summary>
|
||||
private void EnsureTabForCurrentPair()
|
||||
{
|
||||
if (!TabsCanHoldEdits) return;
|
||||
if (string.IsNullOrWhiteSpace(LeftFile) && string.IsNullOrWhiteSpace(RightFile)) return;
|
||||
|
||||
// 여는 길이 이미 잡아 두었다(그쪽이 _resumeTab 도 정해 놓았다).
|
||||
if (Tabs.Find(LeftFile, RightFile) is not null) return;
|
||||
|
||||
ParkCurrentTab();
|
||||
var tab = Tabs.Current is null ? Tabs.Open(LeftFile, RightFile) : Tabs.Replace(LeftFile, RightFile);
|
||||
_resumeTab ??= tab;
|
||||
SyncTabItems();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 사람이 손댄 것이 있는가. 탭에 가운뎃점을 붙이는 기준이자 맡길 것이 있는지 보는 기준이다.
|
||||
///
|
||||
/// <b>스타일 맞춤(C# 중괄호)으로만 바뀐 글은 세지 않는다</b> - 그것은 파일을 다시 열면 똑같이 만들어지므로
|
||||
/// 잃을 것이 없고, "저장하지 않은 편집이 있습니다" 를 묻는 자리(<c>WillAskBeforeDiscard</c>)도 같은 기준이다.
|
||||
/// 여기서만 세면 중괄호 맞춤을 켜 둔 사람은 .cs 를 여는 것만으로 점이 붙어, 늘 붙어 있는 점이 되어 뜻을 잃는다
|
||||
/// (화면을 보다 찾았다 - 2026-09-28).
|
||||
/// </summary>
|
||||
private bool HasTypedEdits => (HasChanges && !OnlyStyleChanged) || HasLeftChanges;
|
||||
|
||||
/// <summary>떠 있는 탭의 "저장 안 한 표시" 를 지금 상태에 맞춘다.</summary>
|
||||
internal void RefreshTabDirty()
|
||||
{
|
||||
if (Tabs.Current is { } tab && TabsCanHoldEdits)
|
||||
tab.IsDirty = HasTypedEdits;
|
||||
}
|
||||
|
||||
// [ObservableProperty] 가 만들어 주는 갈고리. 고친 것이 생기거나 없어지면 탭 표시가 따라간다.
|
||||
// 오른쪽(HasChanges)은 HomeViewModel.cs 에 이미 구현이 있어 거기서 RefreshTabDirty 를 부른다.
|
||||
partial void OnHasLeftChangesChanged(bool value) => RefreshTabDirty();
|
||||
|
||||
// ================= 명령 =================
|
||||
|
||||
/// <summary>탭으로 옮겨 간다. 떠 있던 것을 맡기고 그 탭의 짝을 다시 읽는다.</summary>
|
||||
private void GoToTab(DocTab tab)
|
||||
{
|
||||
if (tab is null || ReferenceEquals(tab, Tabs.Current)) return;
|
||||
|
||||
ParkCurrentTab();
|
||||
Tabs.Activate(tab);
|
||||
_resumeTab = tab;
|
||||
SyncTabItems();
|
||||
|
||||
LeftFile = tab.Left;
|
||||
RightFile = tab.Right;
|
||||
OnPropertyChanged(nameof(BaseFileLocation));
|
||||
OnPropertyChanged(nameof(SelectedFileFullPath));
|
||||
|
||||
_ = LoadPlainFilesAsync();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ActivateTab(DocTab? tab)
|
||||
{
|
||||
if (tab is not null) GoToTab(tab);
|
||||
}
|
||||
|
||||
// RelayCommand<T> 의 CanExecute 는 같은 매개변수를 받아야 한다. 단축키(Ctrl+W)는 인자 없이 부르므로
|
||||
// null 이 들어오는데, 그때는 "떠 있는 탭" 을 뜻한다.
|
||||
private bool CanCloseTab(DocTab? tab) => (tab ?? Tabs.Current) is not null;
|
||||
|
||||
/// <summary>
|
||||
/// 탭을 닫는다. <b>저장 안 한 편집이 있어도 묻지 않는다</b> -
|
||||
/// 글은 그대로 보관되고 E-1(껐다 켜도 남는 편집)이 되살린다.
|
||||
/// </summary>
|
||||
[RelayCommand(CanExecute = nameof(CanCloseTab))]
|
||||
private void CloseTab(DocTab? tab)
|
||||
{
|
||||
tab ??= Tabs.Current;
|
||||
if (tab is null) return;
|
||||
|
||||
// 닫는 것이 지금 떠 있는 탭이면, 그 편집을 먼저 맡겨 둔다(보관은 계속된다).
|
||||
if (ReferenceEquals(tab, Tabs.Current)) ParkCurrentTab();
|
||||
|
||||
var next = Tabs.Close(tab);
|
||||
SyncTabItems();
|
||||
|
||||
if (next is null || ReferenceEquals(next, tab)) return;
|
||||
|
||||
_resumeTab = next;
|
||||
LeftFile = next.Left;
|
||||
RightFile = next.Right;
|
||||
OnPropertyChanged(nameof(BaseFileLocation));
|
||||
OnPropertyChanged(nameof(SelectedFileFullPath));
|
||||
_ = LoadPlainFilesAsync();
|
||||
}
|
||||
|
||||
private bool CanStepTab() => Tabs.Count > 1;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanStepTab))]
|
||||
private void NextTab()
|
||||
{
|
||||
var at = Tabs.CurrentIndex;
|
||||
if (at < 0 || Tabs.Count < 2) return;
|
||||
GoToTab(Tabs.Tabs[(at + 1) % Tabs.Count]);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanStepTab))]
|
||||
private void PreviousTab()
|
||||
{
|
||||
var at = Tabs.CurrentIndex;
|
||||
if (at < 0 || Tabs.Count < 2) return;
|
||||
GoToTab(Tabs.Tabs[(at - 1 + Tabs.Count) % Tabs.Count]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -116,7 +116,15 @@ namespace MergeHub.ViewModels
|
||||
|
||||
// 폴더 비교 더블클릭 · 폴더 검색 결과 · 끌어다 놓기 · 탐색기 우클릭 · mergetool 이 모두 여기로 온다.
|
||||
var opening = Path.GetFileName(File.Exists(rightFile) ? rightFile : leftFile);
|
||||
if (!ConfirmDiscard($"다른 파일을 엽니다: {opening}")) return;
|
||||
|
||||
// 12차 E-2: 두 파일 비교끼리 짝을 바꿀 때는 <b>묻지 않는다</b> - 떠 있던 탭이 편집을 맡아 둔다.
|
||||
// Git 모드 · 병합 중 네 칸에서 넘어올 때는 맡을 탭이 없으니 예전처럼 묻는다.
|
||||
if (!TabsCanHoldEdits && !ConfirmDiscard($"다른 파일을 엽니다: {opening}")) return;
|
||||
|
||||
// 탭을 먼저 잡아 둔다. 아래 LoadPlainFilesAsync 가 다 읽은 뒤 맡겨 둔 편집을 되살린다.
|
||||
var replaceTab = _replaceTab;
|
||||
_replaceTab = false;
|
||||
OpenPairInTab(leftFile, rightFile, replaceTab);
|
||||
|
||||
// 3-Way 를 끄되 그 때문에 본문을 다시 읽지는 않게 한다 - 그 재로드가 작업 게이트를
|
||||
// 먼저 잡으면 바로 아래 파일 비교가 "다른 작업 진행 중"으로 건너뛰어진다.
|
||||
@ -497,13 +505,17 @@ namespace MergeHub.ViewModels
|
||||
if (next < 0 || next >= _workspace.Queue.Count) return;
|
||||
|
||||
var pair = _workspace.Queue[next];
|
||||
if (!ConfirmDiscard($"다른 파일을 엽니다: {pair.Name}")) return;
|
||||
|
||||
// 12차 E-2: 목록을 훑는 것은 <b>지금 탭의 짝을 바꾸는 것</b>이다(탭이 늘지 않는다, 사용자 결정).
|
||||
// 탭이 맡아 주므로 여기서도 묻지 않는다 - 맡을 수 없는 자리(Git · 네 칸)에서만 묻는다.
|
||||
if (!TabsCanHoldEdits && !ConfirmDiscard($"다른 파일을 엽니다: {pair.Name}")) return;
|
||||
|
||||
_workspace.QueueIndex = next;
|
||||
|
||||
_suspendGuard = true;
|
||||
_replaceTab = true;
|
||||
try { _workspace.RequestPlainCompare(pair.Left, pair.Right); }
|
||||
finally { _suspendGuard = false; }
|
||||
finally { _suspendGuard = false; _replaceTab = false; }
|
||||
|
||||
RefreshQueue();
|
||||
}
|
||||
@ -702,6 +714,7 @@ namespace MergeHub.ViewModels
|
||||
{
|
||||
// 저장했거나 버렸으면 "스타일 맞춤으로 바뀐 글" 도 끝난 것이다.
|
||||
if (!value) _styleRawLocalText = null;
|
||||
RefreshTabDirty(); // 12차 E-2 - 탭 이름 앞 가운뎃점이 따라간다
|
||||
RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
@ -837,6 +850,90 @@ namespace MergeHub.ViewModels
|
||||
|
||||
private bool HasEditFor(MergeLine line) => _undo.Any(e => ReferenceEquals(e.Line, line));
|
||||
|
||||
/// <summary>왼쪽으로 반영할 때 주석 · 로그에 적을 이름. 두 파일 비교라 오른쪽 파일 이름이다.</summary>
|
||||
private string? LocalSourceName =>
|
||||
string.IsNullOrWhiteSpace(RightFile) ? null : Path.GetFileName(RightFile);
|
||||
|
||||
/// <summary>
|
||||
/// 이 줄을 <b>왼쪽(BASE) 파일로</b> 반영한다(12차 B-1). 오른쪽으로 반영하는 <see cref="Apply"/> 의 거울이다.
|
||||
///
|
||||
/// 오른쪽에 있는 줄이면 그 글을 왼쪽에 쓰고(왼쪽에 없던 줄이면 새로 생긴다),
|
||||
/// 오른쪽에 없는 줄이면 왼쪽에서도 지운다. 되돌리기는 오른쪽 반영과 한 스택에 쌓여 누른 차례대로 풀린다.
|
||||
/// </summary>
|
||||
/// <returns>글이 바뀌었는지.</returns>
|
||||
private bool ApplyToBase(MergeLine line, bool requery = true, int group = 0)
|
||||
{
|
||||
var before = line.BaseText;
|
||||
var beforeHasBase = line.HasBase;
|
||||
|
||||
string after;
|
||||
bool afterHasBase;
|
||||
string? note = null;
|
||||
|
||||
if (line.HasLocal)
|
||||
{
|
||||
note = _workspace.Settings.InsertMergeComment && group == 0
|
||||
? BuildMergeNote(MergeNames.LocalSource(LocalSourceName)) : null;
|
||||
}
|
||||
|
||||
(after, afterHasBase) = PaneText.Plan(line, MergeLine.TextPane.Base);
|
||||
|
||||
if (beforeHasBase == afterHasBase && string.Equals(before, after, StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
var description = MergeNames.PushToBaseLog(
|
||||
(line.HasLocal ? line.LocalNumber : line.BaseNumber)?.ToString(), LocalSourceName, line.HasLocal);
|
||||
|
||||
line.BaseText = after;
|
||||
line.HasBase = afterHasBase;
|
||||
|
||||
_undo.Push(new LineEdit(line, before, beforeHasBase, after, afterHasBase,
|
||||
EditKind.TakeBase, description)
|
||||
{
|
||||
Side = MergeLine.TextPane.Base,
|
||||
InsertedNote = InsertBaseNoteAbove(line, note),
|
||||
Group = group,
|
||||
});
|
||||
|
||||
ClearRedo();
|
||||
_workspace.AppendLog(description);
|
||||
if (requery) AfterEdit();
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool CanTakeLineToBase(MergeLine? line) => BothWays && line is { CanTakeBase: true };
|
||||
|
||||
/// <summary>[<] - 이 줄을 왼쪽 파일로 반영한다(12차 B-1).</summary>
|
||||
[RelayCommand(CanExecute = nameof(CanTakeLineToBase))]
|
||||
private void TakeLineToBase(MergeLine? line)
|
||||
{
|
||||
if (line is null) return;
|
||||
ApplyToBase(line);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 왼쪽으로 반영한 줄 <b>위에</b> 넣을 머지 주석 한 줄. 오른쪽 것(<see cref="InsertNoteAbove"/>)의 거울이라
|
||||
/// <b>왼쪽에만 있는 줄</b>로 넣는다 - 오른쪽 파일에는 들어가지 않는다.
|
||||
/// </summary>
|
||||
private MergeLine? InsertBaseNoteAbove(MergeLine line, string? note)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(note)) return null;
|
||||
|
||||
var at = Lines.IndexOf(line);
|
||||
if (at < 0) return null;
|
||||
|
||||
var indent = new string(' ', line.BaseText.Length - line.BaseText.TrimStart().Length);
|
||||
|
||||
var row = new MergeLine(indent + note, null, null, null, Rules.IsPlain ? null : Rules.KeyOf)
|
||||
{
|
||||
IsMergeNote = true,
|
||||
IsIgnored = Rules.IsFiltered(indent + note),
|
||||
};
|
||||
|
||||
Lines.Insert(at, row);
|
||||
return row;
|
||||
}
|
||||
|
||||
private bool CanUndo() => _undo.Count > 0 || _textUndo.Count > 0;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanUndo))]
|
||||
@ -1214,6 +1311,8 @@ namespace MergeHub.ViewModels
|
||||
KeepFileCommand.NotifyCanExecuteChanged();
|
||||
DeleteFileCommand.NotifyCanExecuteChanged();
|
||||
SavePlainCommand.NotifyCanExecuteChanged();
|
||||
SaveLeftPlainCommand.NotifyCanExecuteChanged(); // 12차 B-1
|
||||
TakeLineToBaseCommand.NotifyCanExecuteChanged(); // 12차 B-1
|
||||
ExportReportCommand.NotifyCanExecuteChanged();
|
||||
|
||||
AutoMergeCommand.NotifyCanExecuteChanged();
|
||||
@ -1895,15 +1994,12 @@ namespace MergeHub.ViewModels
|
||||
// 반영한 줄에는 누가·언제 반영했는지 주석을 남긴다.
|
||||
// 주석은 줄 끝이 아니라 그 줄 "위"에 따로 들어간다 - 아래 InsertNoteAbove 참고.
|
||||
note = _workspace.Settings.InsertMergeComment && group == 0 ? BuildMergeNote(MergeNames.BaseSource(_baseSourceName)) : null;
|
||||
after = line.BaseText;
|
||||
afterHasLocal = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
after = string.Empty;
|
||||
afterHasLocal = false; // BASE 에 없던 줄 = 지운다
|
||||
}
|
||||
|
||||
// 반영하면 그 칸이 어떻게 되는지는 양쪽이 같은 규칙을 쓴다(12차 B-1, PaneText).
|
||||
// BASE 에 없던 줄이면 LOCAL 에서도 지운다.
|
||||
(after, afterHasLocal) = PaneText.Plan(line, MergeLine.TextPane.Local);
|
||||
|
||||
if (beforeHasLocal == afterHasLocal && string.Equals(before, after, StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
@ -1940,6 +2036,7 @@ namespace MergeHub.ViewModels
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsThreeWay))]
|
||||
[NotifyPropertyChangedFor(nameof(BothWays))]
|
||||
private string _remoteBranch = NoRemoteBranch;
|
||||
|
||||
/// <summary>
|
||||
@ -2010,6 +2107,7 @@ namespace MergeHub.ViewModels
|
||||
{
|
||||
// 콤보로 바꿀 때(OnRemoteBranchChanged)와 같은 것들을 알린다 - 화면이 4분할로 바뀌고 제목·머리 높이도 바뀐다.
|
||||
OnPropertyChanged(nameof(IsThreeWay));
|
||||
OnPropertyChanged(nameof(BothWays));
|
||||
OnPropertyChanged(nameof(IsTwoWay));
|
||||
OnPropertyChanged(nameof(BaseTitle));
|
||||
OnPropertyChanged(nameof(LocalTitle));
|
||||
@ -2061,6 +2159,7 @@ namespace MergeHub.ViewModels
|
||||
|
||||
_lastLoadedRemoteBranch = value;
|
||||
OnPropertyChanged(nameof(IsThreeWay));
|
||||
OnPropertyChanged(nameof(BothWays));
|
||||
OnPropertyChanged(nameof(IsTwoWay));
|
||||
|
||||
// 3-Way 로 들어가고 나가면서 화면이 4분할로 바뀐다. 제목과 머리 높이도 함께 바뀐다.
|
||||
@ -2647,6 +2746,7 @@ namespace MergeHub.ViewModels
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsPlainMode))]
|
||||
[NotifyPropertyChangedFor(nameof(BothWays))]
|
||||
[NotifyPropertyChangedFor(nameof(ModeLabel))]
|
||||
[NotifyPropertyChangedFor(nameof(BaseTitle))]
|
||||
[NotifyPropertyChangedFor(nameof(LocalTitle))]
|
||||
@ -2814,6 +2914,10 @@ namespace MergeHub.ViewModels
|
||||
// 없는 쪽은 빈 내용이 되어 전부 추가(또는 삭제)로 보인다.
|
||||
if (string.IsNullOrWhiteSpace(LeftFile) && string.IsNullOrWhiteSpace(RightFile)) return;
|
||||
|
||||
// 12차 E-2: 여는 길을 거치지 않고 바로 불린 경우에도 탭이 하나는 있게 한다
|
||||
// (켤 때 기본 짝 · [기준 파일...] 로 한쪽만 바꾸기 · 데모 전환 뒤 돌아오기).
|
||||
EnsureTabForCurrentPair();
|
||||
|
||||
// 파일 두 개 비교는 늘 두 칸이다. mergetool 네 칸에서 넘어왔으면 접는다.
|
||||
_mergeTool = null;
|
||||
SetToolThreeWay(false);
|
||||
@ -2876,6 +2980,8 @@ namespace MergeHub.ViewModels
|
||||
SetPlainHeaders();
|
||||
|
||||
_baseline.Open(BuildLocalText());
|
||||
_leftBaseline.Open(BuildBaseText()); // 12차 B-1 - 왼쪽도 연 시점을 따로 기억한다
|
||||
HasLeftChanges = false;
|
||||
SyncEditorFromLines();
|
||||
|
||||
var info = TextFile.Describe(RightFile);
|
||||
@ -2909,6 +3015,10 @@ namespace MergeHub.ViewModels
|
||||
|
||||
// 줄을 고르는 것만으로는 화면이 따라오지 않는다. 캐럿까지 옮겨야 굴러간다.
|
||||
if (asked >= 0) GoToRow(asked);
|
||||
|
||||
// 12차 E-2: 이 짝의 탭에 맡겨 둔 편집이 있으면 여기서 되살린다.
|
||||
// 파일을 다 읽은 <b>뒤에</b> 해야 한다 - 먼저 하면 읽은 글이 되살린 글을 덮어쓴다.
|
||||
ResumePendingTab();
|
||||
});
|
||||
}
|
||||
|
||||
@ -2939,6 +3049,35 @@ namespace MergeHub.ViewModels
|
||||
private bool CanSavePlain() => IsPlainMode && !string.IsNullOrWhiteSpace(RightFile) &&
|
||||
(HasChanges || (_toolThreeWay && UnresolvedConflictCount == 0));
|
||||
|
||||
/// <summary>
|
||||
/// 왼쪽(BASE) 파일에 저장할 것이 있는지(12차 B-1).
|
||||
///
|
||||
/// 두 파일 비교에서만 왼쪽으로 반영할 수 있으므로 Git 모드 · 네 칸에서는 늘 거짓이다 -
|
||||
/// 거기서 왼쪽은 지난 커밋이라 고쳐 쓸 파일이 아니다(사용자 결정 2026-09-27).
|
||||
/// </summary>
|
||||
private bool CanSaveLeftPlain() => BothWays && !string.IsNullOrWhiteSpace(LeftFile) && HasLeftChanges;
|
||||
|
||||
/// <summary>왼쪽 파일에 그대로 쓴다. 오른쪽은 건드리지 않는다(12차 B-1).</summary>
|
||||
[RelayCommand(CanExecute = nameof(CanSaveLeftPlain))]
|
||||
private async Task SaveLeftPlainAsync()
|
||||
{
|
||||
var content = BuildBaseText();
|
||||
var target = LeftFile;
|
||||
|
||||
var encoding = SaveEncoding;
|
||||
var newLine = SaveNewLine;
|
||||
|
||||
var ok = await _workspace.RunAsync("왼쪽 파일 저장",
|
||||
() => WritePlainFile(target, content, encoding, newLine));
|
||||
if (!ok) return;
|
||||
|
||||
_workspace.OnUi(() =>
|
||||
{
|
||||
MarkLeftSaved(content);
|
||||
MessageUtil.ShowSuccessToast($"왼쪽 저장 완료 · {Path.GetFileName(target)}");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Git 없이 쓸 때는 커밋 없이 오른쪽 파일에 그대로 쓴다.</summary>
|
||||
[RelayCommand(CanExecute = nameof(CanSavePlain))]
|
||||
private async Task SavePlainAsync()
|
||||
@ -3021,7 +3160,8 @@ namespace MergeHub.ViewModels
|
||||
/// 스타일 맞춤으로만 바뀐 글은 다시 열면 똑같이 만들어진다 - 묻지 않는다.
|
||||
/// 묻게 두면 스타일이 다른 파일을 훑을 때마다 창이 떠서 쓸 수가 없다.
|
||||
/// </summary>
|
||||
private bool WillAskBeforeDiscard => !_suspendGuard && HasChanges && !OnlyStyleChanged;
|
||||
private bool WillAskBeforeDiscard =>
|
||||
!_suspendGuard && ((HasChanges && !OnlyStyleChanged) || HasLeftChanges); // 왼쪽도 본다(12차 B-1)
|
||||
|
||||
/// <summary>
|
||||
/// 고치던 것이 있으면 물어보고, 계속해도 되면 true 를 준다.
|
||||
@ -3108,7 +3248,9 @@ namespace MergeHub.ViewModels
|
||||
MarkSaved(content);
|
||||
|
||||
MessageUtil.ShowSuccessToast($"저장 완료 · {Path.GetFileName(target)}");
|
||||
return true;
|
||||
|
||||
// 왼쪽에도 고친 것이 있으면 함께 쓴다(12차 B-1). 한쪽만 쓰면 "저장하고 계속" 을 눌러도 나머지가 사라진다.
|
||||
return !HasLeftChanges || SaveLeftWorkingCopy();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@ -3117,6 +3259,26 @@ namespace MergeHub.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>왼쪽 파일에 지금 글을 쓴다(12차 B-1). 확인 창의 [저장하고 계속] 도 이 길로 온다.</summary>
|
||||
private bool SaveLeftWorkingCopy()
|
||||
{
|
||||
if (!BothWays || string.IsNullOrWhiteSpace(LeftFile)) return true;
|
||||
|
||||
try
|
||||
{
|
||||
var content = BuildBaseText();
|
||||
WritePlainFile(LeftFile, content, SaveEncoding, SaveNewLine);
|
||||
MarkLeftSaved(content);
|
||||
MessageUtil.ShowSuccessToast($"왼쪽 저장 완료 · {Path.GetFileName(LeftFile)}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageUtil.ShowErrorToast($"왼쪽 파일을 저장하지 못했습니다: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ================= 무시 규칙 =================
|
||||
|
||||
/// <summary>
|
||||
@ -3419,6 +3581,7 @@ namespace MergeHub.ViewModels
|
||||
if (starts.Count == 0)
|
||||
{
|
||||
DiffPosition = "차이 없음";
|
||||
DiffProgress = PaneSummaries.ProgressText(0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -3430,6 +3593,9 @@ namespace MergeHub.ViewModels
|
||||
DiffPosition = index < 0
|
||||
? $"차이 - / {starts.Count}"
|
||||
: $"차이 {index + 1} / {starts.Count}";
|
||||
|
||||
// 가운데에 적는 진행도 같은 값에서 나온다(12차 B-3).
|
||||
DiffProgress = PaneSummaries.ProgressText(index + 1, starts.Count);
|
||||
}
|
||||
|
||||
private bool HasDiffs() => DiffBlockStarts().Count > 0;
|
||||
@ -3726,6 +3892,31 @@ namespace MergeHub.ViewModels
|
||||
/// 파일에 썼다(11차 F-8). 되돌리기 · 다시하기 목록은 <b>두고</b> 저장 지점만 옮긴다 -
|
||||
/// 저장한 뒤에도 Undo 로 저장 전 글로 갈 수 있다. 스타일 맞춤은 파일에 들어갔으니 끝났다.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// 두 파일 비교에서 <b>양쪽으로</b> 반영 · 저장할 수 있는지(12차 B-1).
|
||||
///
|
||||
/// Git 모드에서는 왼쪽이 지난 커밋이라 고칠 파일이 없고, 네 칸(3-Way)의 BASE 는 공통 조상이라
|
||||
/// 거기에 쓰는 것이 뜻이 없다. 그래서 파일 두 개를 견줄 때만 켠다(사용자 결정 2026-09-27).
|
||||
/// </summary>
|
||||
public bool BothWays => IsPlainMode && !IsThreeWay;
|
||||
|
||||
/// <summary>왼쪽 파일에 저장하지 않은 것이 있는지(12차 B-1).</summary>
|
||||
[ObservableProperty] private bool _hasLeftChanges;
|
||||
|
||||
/// <summary>왼쪽 파일의 저장 기준점. 오른쪽 것(<see cref="_baseline"/>)과 따로 센다.</summary>
|
||||
private readonly SaveBaseline _leftBaseline = new SaveBaseline();
|
||||
|
||||
/// <summary>왼쪽(BASE) 칸의 지금 글. 왼쪽에 없는 줄은 빠진다.</summary>
|
||||
private string BuildBaseText() => PaneText.Build(Lines, MergeLine.TextPane.Base);
|
||||
|
||||
private void MarkLeftSaved(string content)
|
||||
{
|
||||
_leftBaseline.MarkSaved(content);
|
||||
HasLeftChanges = _leftBaseline.IsDirty(BuildBaseText(), stylePending: false);
|
||||
UpdateSummaries();
|
||||
RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private void MarkSaved(string content)
|
||||
{
|
||||
_baseline.MarkSaved(content);
|
||||
@ -3739,6 +3930,7 @@ namespace MergeHub.ViewModels
|
||||
{
|
||||
// 저장한 뒤 Undo 하면 저장한 글과 달라져 "있음", Redo 로 저장 지점에 오면 "없음"(11차 F-8).
|
||||
HasChanges = _baseline.IsDirty(BuildLocalText(), _styleRawLocalText is not null);
|
||||
HasLeftChanges = _leftBaseline.IsDirty(BuildBaseText(), stylePending: false); // 12차 B-1
|
||||
RefreshAppliedFlags();
|
||||
RefreshBlockCommentState(Lines);
|
||||
InvalidateDiffBlocks();
|
||||
@ -3750,6 +3942,15 @@ namespace MergeHub.ViewModels
|
||||
/// <summary>편집 하나를 되돌리거나 다시 적용한다. 줄 존재 여부까지 함께 복원한다.</summary>
|
||||
private void Restore(LineEdit edit, bool toBefore)
|
||||
{
|
||||
// 왼쪽으로 반영한 편집은 왼쪽으로 되돌린다(12차 B-1). 주석 줄을 넣고 빼는 것은 두 칸이 같다.
|
||||
if (edit.Side == MergeLine.TextPane.Base)
|
||||
{
|
||||
edit.Line.BaseText = toBefore ? edit.Before : edit.After;
|
||||
edit.Line.HasBase = toBefore ? edit.BeforeHasLocal : edit.AfterHasLocal;
|
||||
RestoreNote(edit, toBefore);
|
||||
return;
|
||||
}
|
||||
|
||||
edit.Line.LocalText = toBefore ? edit.Before : edit.After;
|
||||
edit.Line.HasLocal = toBefore ? edit.BeforeHasLocal : edit.AfterHasLocal;
|
||||
|
||||
@ -3761,8 +3962,15 @@ namespace MergeHub.ViewModels
|
||||
else MarkApplied(edit.Line);
|
||||
}
|
||||
|
||||
// 이 편집이 끼워 넣은 주석 줄도 함께 넣고 뺀다.
|
||||
// 본문만 되돌리고 주석을 남기면 파일에 유령 주석이 쌓인다.
|
||||
RestoreNote(edit, toBefore);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이 편집이 끼워 넣은 주석 줄도 함께 넣고 뺀다.
|
||||
/// 본문만 되돌리고 주석을 남기면 파일에 유령 주석이 쌓인다.
|
||||
/// </summary>
|
||||
private void RestoreNote(LineEdit edit, bool toBefore)
|
||||
{
|
||||
if (edit.InsertedNote is not { } note) return;
|
||||
|
||||
if (toBefore)
|
||||
@ -3845,6 +4053,53 @@ namespace MergeHub.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 칸마다 요약 · 가운데 진행 (12차 B-3) ----
|
||||
|
||||
/// <summary>왼쪽(BASE) 칸 아래 한 줄. 3-Way 에서는 기준이라 견줄 것이 없다.</summary>
|
||||
[ObservableProperty] private string _baseSummary = string.Empty;
|
||||
|
||||
/// <summary>오른쪽(2-Way LOCAL · 3-Way MERGED) 칸 아래 한 줄.</summary>
|
||||
[ObservableProperty] private string _localSummary = string.Empty;
|
||||
|
||||
/// <summary>3-Way 의 LOCAL(Mine) 칸 아래 한 줄.</summary>
|
||||
[ObservableProperty] private string _mineSummary = string.Empty;
|
||||
|
||||
/// <summary>3-Way 의 REMOTE(Theirs) 칸 아래 한 줄.</summary>
|
||||
[ObservableProperty] private string _remoteSummary = string.Empty;
|
||||
|
||||
/// <summary>칸 사이 틈 아래에 적는 진행 - 몇 번째 차이를 보고 있는지(<c>3 / 7</c>).</summary>
|
||||
[ObservableProperty] private string _diffProgress = string.Empty;
|
||||
|
||||
/// <summary>그 아래 줄 - 아직 남은 차이 줄 수. 없으면 빈 글.</summary>
|
||||
[ObservableProperty] private string _diffRemaining = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 칸마다의 요약을 다시 센다.
|
||||
///
|
||||
/// 2-Way 는 서로 견주고(왼쪽 ↔ 오른쪽), 3-Way 는 <b>모두 BASE 와</b> 견준다 -
|
||||
/// 공통 조상을 기준으로 누가 무엇을 고쳤는지가 그 화면에서 읽어야 할 것이기 때문이다.
|
||||
/// 세는 일은 목록을 한 번 훑는 것이라(칸 수만큼) 긴 파일에서도 값이 싸다.
|
||||
/// </summary>
|
||||
private void UpdatePaneSummaries()
|
||||
{
|
||||
if (IsThreeWay)
|
||||
{
|
||||
BaseSummary = $"{Lines.Count(l => l.IsIn(MergeLine.TextPane.Base))}줄 · 기준";
|
||||
MineSummary = PaneSummaries.For(Lines, MergeLine.TextPane.OriginalLocal, MergeLine.TextPane.Base).Text();
|
||||
RemoteSummary = PaneSummaries.For(Lines, MergeLine.TextPane.Remote, MergeLine.TextPane.Base).Text();
|
||||
LocalSummary = PaneSummaries.For(Lines, MergeLine.TextPane.Local, MergeLine.TextPane.Base).Text();
|
||||
}
|
||||
else
|
||||
{
|
||||
BaseSummary = PaneSummaries.For(Lines, MergeLine.TextPane.Base, MergeLine.TextPane.Local).Text();
|
||||
LocalSummary = PaneSummaries.For(Lines, MergeLine.TextPane.Local, MergeLine.TextPane.Base).Text();
|
||||
MineSummary = string.Empty;
|
||||
RemoteSummary = string.Empty;
|
||||
}
|
||||
|
||||
DiffRemaining = PaneSummaries.RemainingText(Lines.Count(l => l.IsDifferent));
|
||||
}
|
||||
|
||||
private void UpdateSummaries()
|
||||
{
|
||||
// 줄을 고르거나 머지 주석이 끼어들면 결과 줄 수가 바뀐다. 그릴 때마다 번호를 다시 매긴다(8차 B-3).
|
||||
@ -3894,6 +4149,7 @@ namespace MergeHub.ViewModels
|
||||
? work
|
||||
: $"{work} · 편집 {edits}건 · 되돌리기 {redos}건{(HasChanges || edits == 0 ? string.Empty : " · 저장됨")}";
|
||||
|
||||
UpdatePaneSummaries(); // 12차 B-3 - 반영 · 되돌리기 · 저장 · 규칙 바꾸기가 모두 이 길을 지난다
|
||||
RefreshProblems();
|
||||
}
|
||||
|
||||
@ -4372,15 +4628,6 @@ namespace MergeHub.ViewModels
|
||||
for (var i = 0; i < rows.Count; i++) rows[i].LocalNumber = numbers[i];
|
||||
}
|
||||
|
||||
private string BuildLocalText()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var line in Lines)
|
||||
{
|
||||
if (!line.HasLocal) continue; // BASE 에만 있던 줄 - LOCAL 파일에는 없다
|
||||
sb.Append(line.LocalText).Append('\n');
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
private string BuildLocalText() => PaneText.Build(Lines, MergeLine.TextPane.Local);
|
||||
}
|
||||
}
|
||||
|
||||
95
src/MergeHub/Views/AboutWindow.xaml
Normal file
95
src/MergeHub/Views/AboutWindow.xaml
Normal file
@ -0,0 +1,95 @@
|
||||
<Window x:Class="MergeHub.Views.AboutWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Width="520" SizeToContent="Height"
|
||||
WindowStyle="None"
|
||||
ResizeMode="NoResize"
|
||||
ShowInTaskbar="False"
|
||||
Background="{DynamicResource Ubisam.Brush.ContentBackground}">
|
||||
|
||||
<!-- 별도 창이라 셸의 리소스를 자동 상속받지 못한다 - 단축키 창 · 플랫폼 대화상자들과 같은 패턴. -->
|
||||
<Window.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="pack://application:,,,/UbisamBase.Core;component/Themes/ShellStyles.xaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Window.Resources>
|
||||
|
||||
<Border BorderBrush="{DynamicResource Ubisam.Brush.SubTabBar.Border}" BorderThickness="1">
|
||||
<DockPanel TextElement.Foreground="{DynamicResource Ubisam.Brush.ContentForeground}">
|
||||
|
||||
<Border x:Name="TitleBar" DockPanel.Dock="Top"
|
||||
Background="{DynamicResource Ubisam.Brush.Sidebar.Background}"
|
||||
BorderBrush="{DynamicResource Ubisam.Brush.SubTabBar.Border}"
|
||||
BorderThickness="0,0,0,1" Padding="16,10"
|
||||
MouseLeftButtonDown="TitleBar_MouseLeftButtonDown">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Content="✕" Click="Close_Click"
|
||||
Background="Transparent" BorderThickness="0" Padding="8,0"
|
||||
MinHeight="20" FontSize="13"
|
||||
Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
|
||||
<TextBlock Text="프로그램 정보" FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource Ubisam.Brush.ContentForeground}"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<Border DockPanel.Dock="Bottom" Padding="16,12"
|
||||
BorderBrush="{DynamicResource Ubisam.Brush.SubTabBar.Border}" BorderThickness="0,1,0,0">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Content="닫기" MinWidth="84" Padding="14,7"
|
||||
Click="Close_Click" IsDefault="True" IsCancel="True"/>
|
||||
<!-- 값을 그대로 옮겨 적을 일이 잦다(문의 · 결함 보고). 눈으로 읽어 치는 대신 복사한다. -->
|
||||
<Button DockPanel.Dock="Left" Content="정보 복사" MinWidth="84" Padding="14,7"
|
||||
Click="Copy_Click"
|
||||
ToolTip="창에 보이는 내용을 클립보드로 복사합니다"/>
|
||||
<TextBlock x:Name="CopiedText" Text="복사했습니다" Margin="10,0,0,0"
|
||||
VerticalAlignment="Center" FontSize="11" Visibility="Collapsed"
|
||||
Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Margin="20,18,20,18">
|
||||
<TextBlock x:Name="NameText" FontSize="18" FontWeight="Bold"/>
|
||||
<TextBlock x:Name="DescriptionText" FontSize="12" Margin="0,4,0,0"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
|
||||
|
||||
<Border Height="1" Margin="0,14,0,14"
|
||||
Background="{DynamicResource Ubisam.Brush.Table.GridLine}"/>
|
||||
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="110"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="버전" Margin="0,0,0,8"
|
||||
Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" x:Name="VersionText" Margin="0,0,0,8"
|
||||
FontFamily="Consolas" TextWrapping="Wrap"/>
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="빌드 날짜" Margin="0,0,0,8"
|
||||
Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="1" x:Name="BuildDateText" Margin="0,0,0,8"
|
||||
FontFamily="Consolas" TextWrapping="Wrap"/>
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="만든 사람" Margin="0,0,0,8"
|
||||
Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
|
||||
<TextBlock Grid.Row="2" Grid.Column="1" x:Name="AuthorText" Margin="0,0,0,8"
|
||||
TextWrapping="Wrap"/>
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="사내 문의처"
|
||||
Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
|
||||
<TextBlock Grid.Row="3" Grid.Column="1" x:Name="ContactText" TextWrapping="Wrap"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Window>
|
||||
107
src/MergeHub/Views/AboutWindow.xaml.cs
Normal file
107
src/MergeHub/Views/AboutWindow.xaml.cs
Normal file
@ -0,0 +1,107 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Threading;
|
||||
using MergeHub.Services;
|
||||
|
||||
namespace MergeHub.Views
|
||||
{
|
||||
/// <summary>
|
||||
/// 프로그램 정보 창(12차 B-2). [단축키] 옆 단추로 연다.
|
||||
///
|
||||
/// 관리 목적이다 - "지금 이 PC 에 깔린 것이 무엇인가"를 묻고 답하는 자리.
|
||||
/// 그래서 버전 · 빌드 날짜는 <b>실행 파일에서 읽고</b>(<see cref="ProgramInfoReader"/>),
|
||||
/// 사람이 바뀌는 것(만든 사람 · 문의처)만 설정에서 받는다.
|
||||
/// </summary>
|
||||
public partial class AboutWindow : Window
|
||||
{
|
||||
private readonly ProgramInfo info;
|
||||
|
||||
public AboutWindow(ProgramInfo info, string description)
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
this.info = info;
|
||||
|
||||
NameText.Text = info.Name;
|
||||
DescriptionText.Text = description;
|
||||
VersionText.Text = info.Version;
|
||||
BuildDateText.Text = info.BuildDate;
|
||||
AuthorText.Text = info.Author;
|
||||
ContactText.Text = info.Contact;
|
||||
|
||||
if (Application.Current?.MainWindow is { IsLoaded: true } main && !ReferenceEquals(main, this))
|
||||
{
|
||||
Owner = main;
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
}
|
||||
else
|
||||
{
|
||||
WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||||
}
|
||||
}
|
||||
|
||||
private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) => DragMove();
|
||||
|
||||
private void Close_Click(object sender, RoutedEventArgs e) => Close();
|
||||
|
||||
/// <summary>창에 보이는 것을 그대로 옮긴다. 결함을 알릴 때 이 네 줄이 첫 질문이다.</summary>
|
||||
private void Copy_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var text = new StringBuilder()
|
||||
.AppendLine(info.Name)
|
||||
.AppendLine($"버전: {info.Version}")
|
||||
.AppendLine($"빌드 날짜: {info.BuildDate}")
|
||||
.AppendLine($"만든 사람: {info.Author}")
|
||||
.AppendLine($"사내 문의처: {info.Contact}")
|
||||
.ToString();
|
||||
|
||||
try
|
||||
{
|
||||
Clipboard.SetText(text);
|
||||
ShowCopied();
|
||||
}
|
||||
catch (Exception ex) when (ex is System.Runtime.InteropServices.COMException)
|
||||
{
|
||||
// 다른 프로그램이 클립보드를 잡고 있으면 실패한다 - 창을 닫을 일은 아니다.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>복사했다는 표시를 잠깐 보인다. 토스트는 셸 것이라 이 창 뒤로 가려 안 보인다.</summary>
|
||||
private void ShowCopied()
|
||||
{
|
||||
CopiedText.Visibility = Visibility.Visible;
|
||||
var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
|
||||
timer.Tick += (_, _) =>
|
||||
{
|
||||
timer.Stop();
|
||||
CopiedText.Visibility = Visibility.Collapsed;
|
||||
};
|
||||
timer.Start();
|
||||
}
|
||||
|
||||
/// <summary>이미 떠 있으면 앞으로 가져온다 - 단축키 창과 같은 규칙.</summary>
|
||||
private static AboutWindow? open;
|
||||
|
||||
public static void ShowOrActivate(Window? owner, string? author, string? contact)
|
||||
{
|
||||
if (open is { IsLoaded: true })
|
||||
{
|
||||
open.Activate();
|
||||
return;
|
||||
}
|
||||
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var info = ProgramInfoReader.For(assembly, author, contact);
|
||||
var description = assembly.GetCustomAttribute<AssemblyDescriptionAttribute>()?.Description
|
||||
?? "사내 Git Merge/Diff 도구";
|
||||
|
||||
open = new AboutWindow(info, description);
|
||||
if (owner is not null) open.Owner = owner;
|
||||
open.Closed += (_, _) => open = null;
|
||||
open.Show();
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/MergeHub/Views/Controls/TreeDepthMargin.cs
Normal file
33
src/MergeHub/Views/Controls/TreeDepthMargin.cs
Normal file
@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace MergeHub.Views.Controls
|
||||
{
|
||||
/// <summary>
|
||||
/// 트리 깊이를 왼쪽 여백으로 바꾼다(12차 A-2).
|
||||
///
|
||||
/// 폴더 비교는 <b>왼쪽 목록 · 가운데 상태 · 오른쪽 목록</b> 세 칸이라, 가운데와 오른쪽이 줄마다 같은 자리에
|
||||
/// 와야 두 목록으로 읽힌다. 그런데 TreeView 는 깊이만큼 <b>줄 전체</b>를 오른쪽으로 민다.
|
||||
/// 그래서 줄 바깥틀은 민 만큼 <see cref="Negative"/> 로 되돌리고(칸 경계가 고정된다),
|
||||
/// 이름에만 다시 같은 값을 들여쓴다(계층이 보인다).
|
||||
/// </summary>
|
||||
public sealed class TreeDepthMarginConverter : IValueConverter
|
||||
{
|
||||
/// <summary>WPF 기본 TreeViewItem 한 겹의 들여쓰기(DIP). 템플릿을 갈아끼우지 않았으므로 이 값이다.</summary>
|
||||
public const double Step = 19;
|
||||
|
||||
/// <summary>참이면 민 만큼 되돌리는 음수 여백을 준다.</summary>
|
||||
public bool Negative { get; set; }
|
||||
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
var depth = value is int d && d > 0 ? d : 0;
|
||||
return new Thickness((Negative ? -Step : Step) * depth, 0, 0, 0);
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,8 @@
|
||||
xmlns:vm="clr-namespace:MergeHub.ViewModels"
|
||||
xmlns:svc="clr-namespace:MergeHub.Services"
|
||||
xmlns:ctl="clr-namespace:MergeHub.Views.Controls"
|
||||
xmlns:shell="clr-namespace:UbisamBase.Core.Shell;assembly=UbisamBase.Core"
|
||||
shell:PlatformChrome.FullBleed="True"
|
||||
AllowDrop="True" DragOver="View_DragOver" Drop="View_Drop">
|
||||
|
||||
<UserControl.Resources>
|
||||
@ -14,6 +16,9 @@
|
||||
|
||||
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
||||
<ctl:FolderStatusBrushConverter x:Key="StatusBrush"/>
|
||||
<!-- 12차 A-2: 트리가 민 만큼 되돌리고(DepthPull) 이름만 다시 들여쓴다(DepthIndent). -->
|
||||
<ctl:TreeDepthMarginConverter x:Key="DepthPull" Negative="True"/>
|
||||
<ctl:TreeDepthMarginConverter x:Key="DepthIndent"/>
|
||||
<ctl:FolderFilterNameConverter x:Key="FolderFilterName"/>
|
||||
|
||||
<!-- 파일 비교 화면과 같은 글자 스타일. 두 화면이 따로 노는 것처럼 보이지 않게 값을 맞춰 둔다. -->
|
||||
@ -37,33 +42,62 @@
|
||||
<Border Padding="2,1" Background="{Binding DisplayStatus, Converter={StaticResource StatusBrush}}"
|
||||
MinWidth="{Binding ActualWidth, RelativeSource={RelativeSource AncestorType=TreeView}}">
|
||||
<!--
|
||||
칸 폭을 고정한다. TreeView 는 깊이마다 왼쪽으로 들여쓰기 때문에 star 폭을 쓰면
|
||||
줄마다 상태 칸이 다른 자리에 오고 머리글과도 어긋난다. 고정 폭이면
|
||||
들여쓰기만큼(한 단계 약 19px)만 밀려서 눈으로 읽을 수 있다.
|
||||
12차 A-2 - 왼쪽 목록 · 가운데 상태 · 오른쪽 목록 세 칸.
|
||||
|
||||
TreeView 는 깊이마다 줄 전체를 오른쪽으로 민다. 그대로 두면 가운데 · 오른쪽 칸이
|
||||
줄마다 다른 자리에 와서 두 목록으로 읽히지 않는다 - 그래서 민 만큼 되돌리고(DepthPull)
|
||||
이름에만 다시 들여쓴다(DepthIndent). 칸 경계와 가운데 구분선은 줄마다 같은 자리에 온다.
|
||||
-->
|
||||
<Grid>
|
||||
<Grid Margin="{Binding Depth, Converter={StaticResource DepthPull}}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="420"/>
|
||||
<ColumnDefinition Width="200"/>
|
||||
<ColumnDefinition Width="190"/>
|
||||
<ColumnDefinition Width="190"/>
|
||||
<ColumnDefinition Width="170"/>
|
||||
<ColumnDefinition Width="420"/>
|
||||
<!-- 남는 폭을 먹는 빈 칸. 이게 없으면 줄 배경이 글자 끝에서 잘려
|
||||
넓은 화면에서 색 띠가 중간에 끊겨 보인다. -->
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal">
|
||||
<TextBlock Text="📁" FontSize="11" Margin="0,0,4,0"
|
||||
Visibility="{Binding IsDirectory, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBlock Text="{Binding Name}" FontFamily="Consolas" FontSize="12"
|
||||
ToolTip="{Binding RelativePath}"/>
|
||||
</StackPanel>
|
||||
<!-- 왼쪽 목록. 왼쪽에 없는 것이면 통째로 비운다(반대편만 채워져 어느 쪽 것인지 바로 읽힌다). -->
|
||||
<Grid Grid.Column="0" Margin="{Binding Depth, Converter={StaticResource DepthIndent}}"
|
||||
Visibility="{Binding HasLeft, Converter={StaticResource BoolToVis}}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal">
|
||||
<TextBlock Text="📁" FontSize="11" Margin="0,0,4,0"
|
||||
Visibility="{Binding IsDirectory, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBlock Text="{Binding Name}" FontFamily="Consolas" FontSize="12"
|
||||
TextTrimming="CharacterEllipsis" ToolTip="{Binding RelativePath}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Text="{Binding LeftInfo}" FontSize="11" Margin="8,0,10,0"
|
||||
Style="{StaticResource Muted}"/>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Grid.Column="1" Text="{Binding StatusText}" FontSize="11" FontWeight="Bold"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding LeftInfo}" FontSize="11"
|
||||
Style="{StaticResource Muted}"/>
|
||||
<TextBlock Grid.Column="3" Text="{Binding RightInfo}" FontSize="11"
|
||||
Style="{StaticResource Muted}"/>
|
||||
<!-- 가운데 상태. 양쪽 목록을 가르는 선이기도 하다. -->
|
||||
<Border Grid.Column="1" BorderThickness="1,0,1,0"
|
||||
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
|
||||
<TextBlock Text="{Binding StatusText}" FontSize="11" FontWeight="Bold"
|
||||
HorizontalAlignment="Center" TextTrimming="CharacterEllipsis"/>
|
||||
</Border>
|
||||
|
||||
<!-- 오른쪽 목록. 같은 이름이 같은 높이에 오도록 왼쪽과 같은 만큼 들여쓴다. -->
|
||||
<Grid Grid.Column="2" Margin="{Binding Depth, Converter={StaticResource DepthIndent}}"
|
||||
Visibility="{Binding HasRight, Converter={StaticResource BoolToVis}}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Margin="6,0,0,0">
|
||||
<TextBlock Text="📁" FontSize="11" Margin="0,0,4,0"
|
||||
Visibility="{Binding IsDirectory, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBlock Text="{Binding Name}" FontFamily="Consolas" FontSize="12"
|
||||
TextTrimming="CharacterEllipsis" ToolTip="{Binding RelativePath}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Text="{Binding RightInfo}" FontSize="11" Margin="8,0,10,0"
|
||||
Style="{StaticResource Muted}"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</HierarchicalDataTemplate>
|
||||
@ -286,17 +320,24 @@
|
||||
<Border Grid.Row="4" Padding="8,4" BorderThickness="1,1,1,0"
|
||||
Background="{DynamicResource Ubisam.Brush.Sidebar.Background}"
|
||||
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
|
||||
<Grid Margin="26,0,0,0">
|
||||
<Grid Margin="12,0,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="420"/>
|
||||
<ColumnDefinition Width="200"/>
|
||||
<ColumnDefinition Width="190"/>
|
||||
<ColumnDefinition Width="190"/>
|
||||
<ColumnDefinition Width="170"/>
|
||||
<ColumnDefinition Width="420"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Text="이름" FontSize="11" FontWeight="Bold"/>
|
||||
<TextBlock Grid.Column="1" Text="상태" FontSize="11" FontWeight="Bold"/>
|
||||
<TextBlock Grid.Column="2" Text="왼쪽 (크기 · 수정일)" FontSize="11" FontWeight="Bold"/>
|
||||
<TextBlock Grid.Column="3" Text="오른쪽 (크기 · 수정일)" FontSize="11" FontWeight="Bold"/>
|
||||
<Grid Grid.Column="0">
|
||||
<TextBlock Text="왼쪽" FontSize="11" FontWeight="Bold"/>
|
||||
<TextBlock Text="크기 · 수정일" FontSize="11" FontWeight="Bold" Margin="0,0,10,0"
|
||||
HorizontalAlignment="Right"/>
|
||||
</Grid>
|
||||
<TextBlock Grid.Column="1" Text="상태" FontSize="11" FontWeight="Bold"
|
||||
HorizontalAlignment="Center"/>
|
||||
<Grid Grid.Column="2">
|
||||
<TextBlock Text="오른쪽" FontSize="11" FontWeight="Bold" Margin="6,0,0,0"/>
|
||||
<TextBlock Text="크기 · 수정일" FontSize="11" FontWeight="Bold" Margin="0,0,10,0"
|
||||
HorizontalAlignment="Right"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
|
||||
@ -4,6 +4,8 @@
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:local="clr-namespace:MergeHub.Views"
|
||||
xmlns:shell="clr-namespace:UbisamBase.Core.Shell;assembly=UbisamBase.Core"
|
||||
shell:PlatformChrome.FullBleed="True"
|
||||
mc:Ignorable="d">
|
||||
<UserControl.Resources>
|
||||
<ResourceDictionary>
|
||||
|
||||
@ -30,6 +30,13 @@ namespace MergeHub.Views
|
||||
view.IsLiveFiltering = true;
|
||||
view.LiveFilteringProperties.Add(nameof(MergeLine.HasLocal));
|
||||
}
|
||||
else if (pane == MergeLine.TextPane.Base)
|
||||
{
|
||||
// 두 파일 비교는 왼쪽으로도 반영한다(12차 B-1) - 왼쪽에 줄이 생기고 사라지므로 BASE 칸도 따라가야 한다.
|
||||
// 이게 없으면 왼쪽에 넣은 줄이 안 보이고, 왼쪽에서 지운 줄이 빈 칸으로 남는다.
|
||||
view.IsLiveFiltering = true;
|
||||
view.LiveFilteringProperties.Add(nameof(MergeLine.HasBase));
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,8 @@
|
||||
xmlns:ctl="clr-namespace:MergeHub.Views.Controls"
|
||||
xmlns:system="clr-namespace:System;assembly=mscorlib"
|
||||
xmlns:models="clr-namespace:MergeHub.Models"
|
||||
xmlns:shell="clr-namespace:UbisamBase.Core.Shell;assembly=UbisamBase.Core"
|
||||
shell:PlatformChrome.FullBleed="True"
|
||||
mc:Ignorable="d"
|
||||
PreviewKeyDown="View_PreviewKeyDown"
|
||||
AllowDrop="True" DragOver="View_DragOver" Drop="View_Drop">
|
||||
@ -158,13 +160,39 @@
|
||||
66 인 까닭은 LocalLineTemplate 에 적었다(6차 B-4). -->
|
||||
<Grid MinHeight="{Binding BaseRowHeight}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="{DynamicResource GutterWidth}"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- [<] 이 줄을 왼쪽 파일로 반영(12차 B-1). 두 파일 비교에서만 보인다 -
|
||||
Git 모드의 왼쪽은 지난 커밋이라 쓸 파일이 없다. -->
|
||||
<Button Grid.Column="0" Content="<" Padding="0" Margin="2,0,2,0" MinWidth="16"
|
||||
Height="{DynamicResource GutterButtonHeight}" MinHeight="{DynamicResource GutterButtonHeight}"
|
||||
FontSize="{DynamicResource GutterButtonFontSize}" VerticalAlignment="Top"
|
||||
Command="{Binding DataContext.TakeLineToBaseCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip="이 줄을 오른쪽 내용으로 왼쪽 파일에 반영">
|
||||
<Button.Style>
|
||||
<Style TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding CanTakeBase}" Value="True"/>
|
||||
<Condition Binding="{Binding DataContext.BothWays,
|
||||
RelativeSource={RelativeSource AncestorType=UserControl}}" Value="True"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</MultiDataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Button.Style>
|
||||
</Button>
|
||||
<!-- LOCAL 에서 지운 줄을 되살리는 [>] (11차 F-10). 예전에는 LOCAL 의 빈 줄(필러)에 달려 있었는데
|
||||
이제 LOCAL 칸에는 그 줄이 없으므로 BASE 쪽 줄 끝에 단다(Araxis 처럼). LOCAL 에 있는 줄은 LOCAL 여백의 [>] 그대로. -->
|
||||
<Button Grid.Column="2" Content=">" Padding="0" Margin="2,0,2,0" MinWidth="16"
|
||||
<Button Grid.Column="3" Content=">" Padding="0" Margin="2,0,2,0" MinWidth="16"
|
||||
Height="{DynamicResource GutterButtonHeight}" MinHeight="{DynamicResource GutterButtonHeight}"
|
||||
FontSize="{DynamicResource GutterButtonFontSize}" VerticalAlignment="Top"
|
||||
Command="{Binding DataContext.TakeLineCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
@ -185,10 +213,10 @@
|
||||
</Style>
|
||||
</Button.Style>
|
||||
</Button>
|
||||
<TextBlock Grid.Column="0" Text="{Binding BaseNumberText}" Style="{StaticResource Mono}" LineHeight="{DynamicResource CodeLineHeight}" LineStackingStrategy="BlockLineHeight"
|
||||
<TextBlock Grid.Column="1" Text="{Binding BaseNumberText}" Style="{StaticResource Mono}" LineHeight="{DynamicResource CodeLineHeight}" LineStackingStrategy="BlockLineHeight"
|
||||
Foreground="{DynamicResource Ubisam.Brush.Muted}" TextAlignment="Right"
|
||||
VerticalAlignment="Top" Margin="0,0,8,0"/>
|
||||
<Border Grid.Column="1" Padding="2,0"
|
||||
<Border Grid.Column="2" Padding="2,0"
|
||||
Background="{Binding Highlight, Converter={StaticResource HighlightBrush}}">
|
||||
<Grid ClipToBounds="True">
|
||||
<ctl:CodeTextBlock Code="{Binding BaseText}" Words="{Binding BaseWords}"
|
||||
@ -337,6 +365,7 @@
|
||||
<ColumnDefinition Width="{DynamicResource GutterMarkWidth}"/>
|
||||
<ColumnDefinition Width="{DynamicResource GutterNarrowNumberWidth}"/>
|
||||
<ColumnDefinition Width="{DynamicResource GutterButtonWidth}"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
@ -359,6 +388,33 @@
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip="이 줄만 BASE 내용으로 가져오기"/>
|
||||
|
||||
<!-- [<] 왼쪽에 없는 줄(오른쪽에서 새로 생긴 줄)을 왼쪽 파일에 넣는다(12차 B-1).
|
||||
왼쪽에 있는 줄은 BASE 칸의 [<] 를 쓴다 - 거기 줄이 있으니 그 자리에 다는 것이 맞다. -->
|
||||
<Button Grid.Column="3" Content="<" Padding="0" Margin="0" MinWidth="16"
|
||||
Height="{DynamicResource GutterButtonHeight}" MinHeight="{DynamicResource GutterButtonHeight}"
|
||||
FontSize="{DynamicResource GutterButtonFontSize}" VerticalAlignment="Top"
|
||||
Command="{Binding DataContext.TakeLineToBaseCommand,
|
||||
RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip="이 줄을 왼쪽 파일에 넣기">
|
||||
<Button.Style>
|
||||
<Style TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding CanTakeBase}" Value="True"/>
|
||||
<Condition Binding="{Binding HasBase}" Value="False"/>
|
||||
<Condition Binding="{Binding DataContext.BothWays,
|
||||
RelativeSource={RelativeSource AncestorType=UserControl}}" Value="True"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</MultiDataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Button.Style>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
색 배경은 칸 전체를 채우고 제자리에 있는다. 미는 것은 글자뿐이다.
|
||||
|
||||
@ -367,7 +423,7 @@
|
||||
같은 x 에서 시작한다. 여기가 2 더 밀려 있으면 그 위에 겹친 편집기의 캐럿이
|
||||
글자 가운데에 박힌 것처럼 보인다.
|
||||
-->
|
||||
<Border Grid.Column="3" Padding="0"
|
||||
<Border Grid.Column="4" Padding="0"
|
||||
Background="{Binding Highlight, Converter={StaticResource HighlightBrush}}"
|
||||
BorderThickness="2,0,0,0">
|
||||
<Border.ToolTip>
|
||||
@ -413,7 +469,77 @@
|
||||
</ResourceDictionary>
|
||||
</UserControl.Resources>
|
||||
|
||||
<Grid Margin="2" TextElement.Foreground="{DynamicResource Ubisam.Brush.ContentForeground}">
|
||||
<DockPanel Margin="2" TextElement.Foreground="{DynamicResource Ubisam.Brush.ContentForeground}">
|
||||
|
||||
<!-- ===== 문서 탭 줄 (12차 E-2) =====
|
||||
짝 여러 개를 동시에 든다. 짝이 하나뿐일 때는 줄을 내주지 않는다(ShowTabStrip).
|
||||
탭 이름은 파일 이름이고, 양쪽 이름이 다르면 "왼 ↔ 오". 저장하지 않은 탭에는 앞에 가운뎃점.
|
||||
가운데 단추로도 닫힌다 - 브라우저와 같은 손버릇. -->
|
||||
<!-- ItemsControl 이 아니라 ListBox 다 - 그쪽은 UIA 에 "컨트롤" 로 안 보여서 화면 시험이 탭 줄을
|
||||
아예 못 읽고, 키로 탭을 옮기는 것도 공짜로 얻을 수 없다. 고르기가 곧 탭 옮기기(CurrentTab) 다. -->
|
||||
<ListBox DockPanel.Dock="Top" x:Name="DocTabStrip"
|
||||
ItemsSource="{Binding TabItems}"
|
||||
SelectedItem="{Binding CurrentTab, Mode=TwoWay}"
|
||||
Visibility="{Binding ShowTabStrip, Converter={StaticResource BoolToVis}}"
|
||||
Margin="0,0,0,3" Padding="0" BorderThickness="0" Background="Transparent"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
||||
ScrollViewer.VerticalScrollBarVisibility="Disabled">
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
<ListBox.ItemContainerStyle>
|
||||
<!-- 고른 항목의 파란 바탕을 없앤다 - 탭 모양은 아래 DataTemplate 이 그린다. -->
|
||||
<Style TargetType="ListBoxItem">
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
<Setter Property="Margin" Value="0"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListBoxItem">
|
||||
<ContentPresenter/>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ListBox.ItemContainerStyle>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border x:Name="TabShell" Margin="0,0,3,0" CornerRadius="3,3,0,0" BorderThickness="1,1,1,0"
|
||||
Background="{DynamicResource Ubisam.Brush.Input.Background}"
|
||||
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
|
||||
MouseDown="DocTab_MouseDown"
|
||||
ToolTip="{Binding Tip}">
|
||||
<StackPanel Orientation="Horizontal" Height="24">
|
||||
<!-- 저장하지 않은 표시. 이름이 움직이지 않게 자리를 늘 차지한다. -->
|
||||
<TextBlock Text="•" Width="10" FontSize="13" FontWeight="Bold"
|
||||
VerticalAlignment="Center" TextAlignment="Center" Margin="4,0,0,0"
|
||||
Foreground="{DynamicResource Ubisam.Brush.Accent}"
|
||||
Visibility="{Binding IsDirty, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBlock Text="{Binding Title}" FontSize="11" VerticalAlignment="Center"
|
||||
Margin="2,0,2,0" MaxWidth="240" TextTrimming="CharacterEllipsis"/>
|
||||
<Button Content="×" FontSize="12" Width="18" Height="18" Padding="0" Margin="2,0,3,0"
|
||||
VerticalAlignment="Center" Focusable="False"
|
||||
Command="{Binding DataContext.CloseTabCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip="이 탭을 닫습니다 (저장하지 않은 편집은 그대로 보관됩니다)"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<DataTemplate.Triggers>
|
||||
<!-- 떠 있는 탭은 본문과 같은 바탕 · 진한 테두리로 이어 보이게 한다. -->
|
||||
<DataTrigger Binding="{Binding IsCurrent}" Value="True">
|
||||
<Setter TargetName="TabShell" Property="Background"
|
||||
Value="{DynamicResource Ubisam.Brush.ContentBackground}"/>
|
||||
<Setter TargetName="TabShell" Property="BorderBrush"
|
||||
Value="{DynamicResource Ubisam.Brush.Accent}"/>
|
||||
<Setter TargetName="TabShell" Property="BorderThickness" Value="1,2,1,0"/>
|
||||
</DataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
@ -432,6 +558,11 @@
|
||||
줄었다 하는 WrapPanel 이라 그 안에 넣으면 자리가 떠다닌다 - DockPanel 로
|
||||
오른쪽에 붙박아 어느 모드에서든 같은 자리에 있게 했다.
|
||||
-->
|
||||
<!-- 12차 B-2: 프로그램 정보(관리 목적). 단축키 바로 옆 - 둘 다 "이 프로그램에 대한 안내" 다. -->
|
||||
<Button DockPanel.Dock="Right" Content="정보" Click="About_Click"
|
||||
Height="26" MinHeight="26" Padding="10,2" FontSize="11" Margin="10,0,0,0"
|
||||
VerticalAlignment="Top"
|
||||
ToolTip="이 프로그램의 버전 · 빌드 날짜 · 만든 사람 · 사내 문의처를 봅니다"/>
|
||||
<Button DockPanel.Dock="Right" Content="단축키" Click="ShortcutHelp_Click"
|
||||
Height="26" MinHeight="26" Padding="10,2" FontSize="11" Margin="10,0,0,0"
|
||||
VerticalAlignment="Top"
|
||||
@ -1057,6 +1188,8 @@
|
||||
<RowDefinition Height="Auto" SharedSizeGroup="PaneHeader"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- 12차 B-3: 칸 아래 한 줄 요약 -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<StackPanel Grid.Row="0" Margin="8,6">
|
||||
@ -1111,6 +1244,15 @@
|
||||
Style="{StaticResource Muted}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 12차 B-3: 이 칸이 저쪽과 무엇이 다른가. 맨 아래 상태 줄의 "파일 전체" 와 다른 값이다. -->
|
||||
<Border Grid.Row="3" Padding="8,3" BorderThickness="0,1,0,0"
|
||||
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
|
||||
Background="{DynamicResource Ubisam.Brush.Sidebar.Background}">
|
||||
<TextBlock x:Name="MineSummaryText" Text="{Binding MineSummary}" FontSize="11"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@ -1136,6 +1278,8 @@
|
||||
<RowDefinition Height="Auto" SharedSizeGroup="PaneHeader"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- 12차 B-3: 칸 아래 한 줄 요약 -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- BASE / LOCAL 의 머리 영역 높이를 같게 고정해야 두 패널의 01행이 같은 눈높이에 온다 -->
|
||||
@ -1196,7 +1340,17 @@
|
||||
Value="{Binding SourceScrollValue, Mode=TwoWay, RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
Visibility="{Binding SourceBarVisibility, RelativeSource={RelativeSource AncestorType=UserControl}}"/>
|
||||
|
||||
<!-- Diff 요약도 아래 한 줄로 갔다(4차 C-2). 같은 숫자가 세 군데 있었다. -->
|
||||
<!-- Diff 요약도 아래 한 줄로 갔다(4차 C-2). 같은 숫자가 세 군데 있었다.
|
||||
칸마다의 요약은 아래 한 줄에 따로 적는다(12차 B-3) - 파일 전체 요약과 다른 값이다. -->
|
||||
|
||||
<!-- 12차 B-3: 이 칸이 저쪽과 무엇이 다른가. 맨 아래 상태 줄의 "파일 전체" 와 다른 값이다. -->
|
||||
<Border Grid.Row="3" Padding="8,3" BorderThickness="0,1,0,0"
|
||||
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
|
||||
Background="{DynamicResource Ubisam.Brush.Sidebar.Background}">
|
||||
<TextBlock x:Name="BaseSummaryText" Text="{Binding BaseSummary}" FontSize="11"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@ -1373,7 +1527,17 @@
|
||||
LineRequested="MiniMap_LineRequested"/>
|
||||
</Grid>
|
||||
|
||||
<!-- 상태바는 창 아래 한 줄로 옮겼다(4차 C-2). 두 패널이 같은 높이를 갖게 된다. -->
|
||||
<!-- 상태바는 창 아래 한 줄로 옮겼다(4차 C-2). 두 패널이 같은 높이를 갖게 된다.
|
||||
칸마다의 요약만 여기 한 줄로 적는다(12차 B-3). -->
|
||||
|
||||
<!-- 12차 B-3: 이 칸이 저쪽과 무엇이 다른가. 맨 아래 상태 줄의 "파일 전체" 와 다른 값이다. -->
|
||||
<Border Grid.Row="2" Padding="8,3" BorderThickness="0,1,0,0"
|
||||
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
|
||||
Background="{DynamicResource Ubisam.Brush.Sidebar.Background}">
|
||||
<TextBlock x:Name="LocalSummaryText" Text="{Binding LocalSummary}" FontSize="11"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@ -1396,6 +1560,8 @@
|
||||
<RowDefinition Height="Auto" SharedSizeGroup="PaneHeader"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- 12차 B-3: 칸 아래 한 줄 요약 -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- BASE/LOCAL 과 같은 머리 높이. 세 패널의 01행이 같은 눈높이에 와야 한다.
|
||||
@ -1462,6 +1628,15 @@
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 12차 B-3: 이 칸이 저쪽과 무엇이 다른가. 맨 아래 상태 줄의 "파일 전체" 와 다른 값이다. -->
|
||||
<Border Grid.Row="3" Padding="8,3" BorderThickness="0,1,0,0"
|
||||
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
|
||||
Background="{DynamicResource Ubisam.Brush.Sidebar.Background}">
|
||||
<TextBlock x:Name="RemoteSummaryText" Text="{Binding RemoteSummary}" FontSize="11"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@ -1471,6 +1646,21 @@
|
||||
Visibility="{Binding IsThreeWay, Converter={StaticResource BoolToVis}}"
|
||||
Background="{DynamicResource Ubisam.Brush.Table.GridLine}"/>
|
||||
|
||||
<!--
|
||||
12차 B-3: 가운데(칸 사이 틈) 아래에 진행을 적는다.
|
||||
틈이 48 DIP 뿐이라 두 줄로 짧게 - "3 / 7"(몇 번째 차이) 과 "남은 2"(아직 다른 줄 수).
|
||||
잇는 선 그림판보다 앞에 두어 선에 가리지 않게 한다.
|
||||
-->
|
||||
<StackPanel Grid.Row="0" Grid.Column="3" VerticalAlignment="Bottom" HorizontalAlignment="Center"
|
||||
Margin="0,0,0,6" Panel.ZIndex="6" IsHitTestVisible="False">
|
||||
<TextBlock x:Name="DiffProgressText" Text="{Binding DiffProgress}" FontSize="11" FontWeight="Bold"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource Ubisam.Brush.ContentForeground}"/>
|
||||
<TextBlock x:Name="DiffRemainingText" Text="{Binding DiffRemaining}" FontSize="10"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 잇는 선(11차 F-10). 위 칸들 위에 겹친 투명한 그림판 - 마우스는 통과한다. -->
|
||||
<ctl:PaneLinkOverlay x:Name="LinkOverlay" Grid.Row="0" Grid.Column="2" Grid.ColumnSpan="5" Panel.ZIndex="5"/>
|
||||
|
||||
@ -1487,8 +1677,14 @@
|
||||
Visibility="{Binding IsBackupOn, Converter={StaticResource BoolToVis}}"
|
||||
ToolTip="저장할 때 남긴 백업을 모아 둔 폴더를 엽니다. 날짜 폴더마다 목록.txt 에 원본과 백업이 적혀 있습니다"/>
|
||||
|
||||
<!-- 왼쪽 파일에 쓰는 단추(12차 B-1). 두 파일 비교에서만 보이고, 왼쪽에 고친 것이 없으면 꺼진다. -->
|
||||
<Button DockPanel.Dock="Bottom" Content="◀ 왼쪽 저장" MinHeight="32" Margin="0,8,0,0" FontSize="11"
|
||||
Command="{Binding SaveLeftPlainCommand}"
|
||||
Visibility="{Binding BothWays, Converter={StaticResource BoolToVis}}"
|
||||
ToolTip="왼쪽으로 반영한 내용을 왼쪽 파일에 씁니다"/>
|
||||
|
||||
<!-- Git 이 아니면 커밋할 곳이 없다. 파일에 쓰기만 한다. -->
|
||||
<Button DockPanel.Dock="Bottom" Content="저장" MinHeight="38" Margin="0,8,0,0"
|
||||
<Button DockPanel.Dock="Bottom" Content="오른쪽 저장 ▶" MinHeight="38" Margin="0,8,0,0"
|
||||
Command="{Binding SavePlainCommand}"
|
||||
Visibility="{Binding IsPlainMode, Converter={StaticResource BoolToVis}}"
|
||||
Style="{StaticResource FilledButton}"
|
||||
@ -1885,4 +2081,5 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
|
||||
@ -389,8 +389,10 @@ namespace MergeHub.Views
|
||||
{
|
||||
old.EditorTextReplaced -= OnEditorTextReplaced;
|
||||
old.ScrollToRowRequested -= OnScrollToRowRequested;
|
||||
old.CaretIndexRequested -= OnCaretIndexRequested;
|
||||
old.PropertyChanged -= OnViewModelPropertyChanged;
|
||||
old.ReadEditorText = null;
|
||||
old.ReadCaretIndex = null;
|
||||
BindPaneViews(null);
|
||||
}
|
||||
|
||||
@ -398,6 +400,7 @@ namespace MergeHub.Views
|
||||
{
|
||||
now.EditorTextReplaced += OnEditorTextReplaced;
|
||||
now.ScrollToRowRequested += OnScrollToRowRequested;
|
||||
now.CaretIndexRequested += OnCaretIndexRequested;
|
||||
now.PropertyChanged += OnViewModelPropertyChanged;
|
||||
ApplyActionsHidden(now.IsActionsHidden);
|
||||
ApplyCodeFont(now.CodeFontSize);
|
||||
@ -406,11 +409,46 @@ namespace MergeHub.Views
|
||||
// 바인딩으로 매 글자마다 문서 전체를 넘기면 큰 파일에서 눈에 띄게 느리다.
|
||||
now.ReadEditorText = () => LocalEditor.Text;
|
||||
|
||||
// 탭을 옮길 때 캐럿 자리를 맡겼다 되살리는 데 쓴다(12차 E-2).
|
||||
now.ReadCaretIndex = () => LocalEditor.CaretIndex;
|
||||
|
||||
// 칸마다 자기 줄만(11차 F-10)
|
||||
BindPaneViews(now);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 문서 탭을 <b>가운데 단추로</b> 누르면 닫는다(12차 E-2) - 브라우저 · notepad++ 와 같은 손버릇이다.
|
||||
///
|
||||
/// 왼쪽 단추는 손대지 않는다. 탭 줄이 ListBox 라 고르기가 곧 탭 옮기기이고,
|
||||
/// 여기서 <c>Handled</c> 로 먹어 버리면 그 고르기가 일어나지 않는다.
|
||||
/// </summary>
|
||||
private void DocTab_MouseDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (e.ChangedButton != MouseButton.Middle) return;
|
||||
if (DataContext is not HomeViewModel vm) return;
|
||||
if (sender is not FrameworkElement element || element.DataContext is not DocTab tab) return;
|
||||
|
||||
if (vm.CloseTabCommand.CanExecute(tab)) vm.CloseTabCommand.Execute(tab);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 탭에 맡겨 둔 캐럿 자리로 돌아간다(12차 E-2).
|
||||
///
|
||||
/// 글을 다 실은 <b>뒤</b>에 옮겨야 한다 - 배치가 끝나기 전에 옮기면 화면이 따라오지 않아
|
||||
/// 캐럿만 저 아래에 있고 보이는 곳은 맨 위가 된다. 그래서 다음 차례로 미룬다.
|
||||
/// </summary>
|
||||
private void OnCaretIndexRequested(object? sender, int caretIndex)
|
||||
{
|
||||
Dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
var at = Math.Max(0, Math.Min(caretIndex, LocalEditor.Text.Length));
|
||||
LocalEditor.CaretIndex = at;
|
||||
LocalEditor.ScrollToLine(LocalEditor.GetLineIndexFromCharacterIndex(at));
|
||||
}), DispatcherPriority.Loaded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2-Way ↔ 3-Way 로 바뀌면 패널 배치가 통째로 달라진다.
|
||||
/// 워드랩의 접히는 자리를 정하는 글자 폭 보정도 그때 다시 잡아야 한다.
|
||||
@ -1008,6 +1046,11 @@ namespace MergeHub.Views
|
||||
case ShortcutAction.PrevFile: return Run(vm.PrevFileCommand);
|
||||
case ShortcutAction.NextFile: return Run(vm.NextFileCommand);
|
||||
|
||||
// 12차 E-2 문서 탭
|
||||
case ShortcutAction.NextTab: return Run(vm.NextTabCommand);
|
||||
case ShortcutAction.PrevTab: return Run(vm.PreviousTabCommand);
|
||||
case ShortcutAction.CloseTab: return Run(vm.CloseTabCommand);
|
||||
|
||||
case ShortcutAction.ToggleBookmark:
|
||||
if (vm.SelectedLine is null) return false;
|
||||
vm.ToggleBookmarkCommand.Execute(vm.SelectedLine);
|
||||
@ -1156,6 +1199,13 @@ namespace MergeHub.Views
|
||||
private void ShortcutHelp_Click(object sender, RoutedEventArgs e) =>
|
||||
ShortcutHelpWindow.ShowOrActivate(Window.GetWindow(this));
|
||||
|
||||
/// <summary>프로그램 정보 창(12차 B-2). 만든 사람 · 문의처는 설정에서 읽어 넘긴다.</summary>
|
||||
private void About_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var settings = (DataContext as ViewModels.HomeViewModel)?.Workspace.Settings;
|
||||
AboutWindow.ShowOrActivate(Window.GetWindow(this), settings?.ProgramAuthor, settings?.SupportContact);
|
||||
}
|
||||
|
||||
// ================= 찾기 =================
|
||||
|
||||
/// <summary>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user