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:
김승준 2026-09-28 13:21:54 +09:00
parent 3c8d33f8be
commit 13988d1f0a
28 changed files with 2760 additions and 106 deletions

View 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.
### 남긴 것
- 왼쪽 칸은 **타이핑으로는 못 고친다**. 편집기는 오른쪽에만 겹쳐 있다 - 왼쪽은 `[<]` 로 반영해서 고친다
(지시서가 정한 것: "칸마다 저장 단추 · 왼쪽 칸에도 반영 단추 [&lt;]").
- 왼쪽으로 반영한 줄에는 "머지 반영함" 색을 칠하지 않는다. 반영하고 나면 양쪽이 같아져 색이 필요 없다.
---
## 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 포함).

View File

@ -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차 뒤 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-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 칸 끝, 차이 이동은 가운데로 | 아래 | | 2026-09-22 | **11차 F-10** — Araxis 식 화면: 칸마다 자기 줄만(빈 줄로 높이를 맞추지 않음), 두 칸(3분할은 위 세 칸) 사이 틈에 바뀐 덩어리마다 사다리꼴 잇는 선 · 칸 안 경계선(한쪽에 없으면 굵은 선 하나) · 옮긴 덩어리는 청록 점선. 스크롤은 가운데 기준으로 따라옴(`PaneMapping` — 함께 있는 줄 가운데를 짝점, 사이는 비율). 속의 정렬 행은 그대로라 반영 · 되돌리기 · 차이 이동이 그대로 동작. 편집기에서 빈 줄을 없애 `StripFillers` 를 지움(편집기 줄 ↔ 행 표). 지운 줄 [>] 는 BASE 칸 끝, 차이 이동은 가운데로 | 아래 |

153
doc/작업지시/12차.md Normal file
View 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)의 동작을 건드리지 않는다.
- 큰 글(수천 줄)에서도 타이핑이 느려지지 않는다.
---
## 이번 지시서에서 하지 않는 것
- 실제 업무 저장소로 시험하지 않는다(시험 재료는 만들어 쓴다).
- 푸시하지 않는다.
- 플랫폼 배포는 사용자 확인 뒤에만.

View File

@ -1,4 +1,4 @@
# MergeHub 진행 상황 # MergeHub 진행 상황
**지금 무엇이 남았는지만** 적는다. 끝난 것의 상세는 [history/](history/) 로 옮겼다. **지금 무엇이 남았는지만** 적는다. 끝난 것의 상세는 [history/](history/) 로 옮겼다.
@ -389,6 +389,48 @@
| 상태 | 항목 | | 상태 | 항목 |
|---|---| |---|---|
| **완료** | G-1 배율이 크게 다른 새 모니터(3840×2160 · 300%)에서 최대화하면 오른쪽 1280px · 아래 170px 이 화면 밖으로 — 셸이 모니터별 V1 이라 최대화 크기를 윈도가 더 크게 잡았다. 최대화된 뒤 그 모니터 크기와 다르면 한 번 다시 놓게 고침(플랫폼 `FitMaximizedToMonitor`). 넘침 0 · 모니터 밖 요소 0개. PlacementTest +6(51건). **플랫폼 배포함**(2026-09-23) | | **완료** | 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주차로 미룬 것 ## 3~4주차로 미룬 것

View File

@ -1,5 +1,4 @@
using System.Windows; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using MergeHub.Services; using MergeHub.Services;
using MergeHub.ViewModels; using MergeHub.ViewModels;
using MergeHub.Views; using MergeHub.Views;
@ -18,12 +17,10 @@ namespace MergeHub
public void RegisterViews(ITabManager manager) public void RegisterViews(ITabManager manager)
{ {
// 머지툴은 3분할 비교라 가로 공간이 곧 성능이다. // 머지툴은 3분할 비교라 가로 공간이 곧 성능이다. 셸 기본 여백을 없애고 창 전체를 쓰며,
// 셸 기본 여백(26,22)을 없애고 창 전체를 쓴다 - 안쪽 여백은 HomeView가 직접 준다. // 안쪽 여백은 각 화면이 직접 준다. 여백을 없애는 길은 화면마다 붙인
// 이 키는 테마 전환에도 유지되므로 한 번만 덮어쓰면 되고, // shell:PlatformChrome.FullBleed="True" 이다(새 플랫폼 · platform-guide 8-4).
// ShellWindow가 창 생성 시점에 읽어가므로 RegisterViews 안에서 정해야 한다. // 예전처럼 Ubisam.Metric.ContentPadding 리소스를 덮어쓰지 않는다 - 그 키는 이제 없다.
if (Application.Current is { } app)
app.Resources["Ubisam.Metric.ContentPadding"] = new Thickness(0);
// 탭 순서는 실제 작업 순서를 따른다. // 탭 순서는 실제 작업 순서를 따른다.
// 폴더 비교로 "어느 파일이 다른가"를 먼저 보고 → 그 파일을 골라 파일 비교로 들어가고 // 폴더 비교로 "어느 파일이 다른가"를 먼저 보고 → 그 파일을 골라 파일 비교로 들어가고

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>Library</OutputType> <OutputType>Library</OutputType>
@ -9,19 +9,37 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<RootNamespace>MergeHub</RootNamespace> <RootNamespace>MergeHub</RootNamespace>
<AssemblyName>MergeHub</AssemblyName> <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) 배포용) --> <!-- 플랫폼 배포 폴더 (platform-guide 10-2 (B) 배포용) -->
<UbisamPlatformDir>D:\UbisamPlatform\bin\</UbisamPlatformDir> <UbisamPlatformDir>D:\UbisamPlatform\bin\</UbisamPlatformDir>
<!-- 이 프로젝트는 dll이라 직접 실행 불가 - F5 시 Shell.exe가 뜨게 한다 --> <!-- 이 프로젝트는 dll이라 직접 실행 불가 - F5 시 Launcher.exe 가 뜨게 한다.
Launcher 는 켤 때마다 D:\UbisamPlatform\bin 을 이 폴더로 복사한 뒤 Shell 을 같은 프로세스에서 띄운다.
그래서 다시 빌드하지 않아도 최신 플랫폼으로 뜨고, 이 프로젝트의 중단점도 그대로 걸린다.
$(TargetDir) 는 이 시점에 아직 비어 있어 쓰면 안 된다 - 절대 경로로 적는다. -->
<StartAction>Program</StartAction> <StartAction>Program</StartAction>
<StartProgram>$(TargetDir)UbisamBase.Shell.exe</StartProgram> <StartProgram>$(MSBuildProjectDirectory)\bin\$(Configuration)\$(TargetFramework)\UbisamBase.Launcher.exe</StartProgram>
<StartWorkingDirectory>$(TargetDir)</StartWorkingDirectory> <StartWorkingDirectory>$(MSBuildProjectDirectory)\bin\$(Configuration)\$(TargetFramework)\</StartWorkingDirectory>
<DisableFastUpToDateCheck>true</DisableFastUpToDateCheck> <DisableFastUpToDateCheck>true</DisableFastUpToDateCheck>
</PropertyGroup> </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> <ItemGroup>
<Reference Include="UbisamBase.Core"> <Reference Include="UbisamBase.Core">
<HintPath>$(UbisamPlatformDir)UbisamBase.Core.dll</HintPath> <HintPath>$(UbisamPlatformDir)UbisamBase.Core.dll</HintPath>

View File

@ -9,6 +9,16 @@ namespace MergeHub
/// </summary> /// </summary>
public class MergeHubSettings 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("저장소")] [Category("저장소")]
[DisplayName("기본 저장소 경로")] [DisplayName("기본 저장소 경로")]
public string DefaultRepositoryPath { get; set; } = string.Empty; public string DefaultRepositoryPath { get; set; } = string.Empty;

View File

@ -92,10 +92,10 @@ namespace MergeHub.Models
_baseText = baseText ?? string.Empty; _baseText = baseText ?? string.Empty;
_localText = localText ?? string.Empty; _localText = localText ?? string.Empty;
BaseNumber = baseNumber; _baseNumber = baseNumber;
_localNumber = localNumber; _localNumber = localNumber;
HasBase = baseText is not null; _hasBase = baseText is not null;
_hasLocal = localText is not null; _hasLocal = localText is not null;
} }
@ -109,8 +109,15 @@ namespace MergeHub.Models
? string.Equals(a, b, StringComparison.Ordinal) ? string.Equals(a, b, StringComparison.Ordinal)
: string.Equals(_normalize(a), _normalize(b), StringComparison.Ordinal); : string.Equals(_normalize(a), _normalize(b), StringComparison.Ordinal);
/// <summary>BASE 쪽 줄 번호. LOCAL 에만 있는 줄이면 없다.</summary> /// <summary>
public int? BaseNumber { get; } /// BASE 쪽 줄 번호. LOCAL 에만 있는 줄이면 없다.
///
/// 12차 B-1 전에는 정렬 결과라 바뀌지 않았다. 이제 두 파일 비교에서는 왼쪽으로도 반영하므로
/// 왼쪽에 줄이 생기거나 사라진다 - 번호도 따라 바뀐다.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(BaseNumberText))]
private int? _baseNumber;
public string BaseNumberText => BaseNumber?.ToString("00") ?? string.Empty; public string BaseNumberText => BaseNumber?.ToString("00") ?? string.Empty;
@ -337,8 +344,18 @@ namespace MergeHub.Models
[ObservableProperty] private Services.CarryOver _baseInComment; [ObservableProperty] private Services.CarryOver _baseInComment;
[ObservableProperty] private Services.CarryOver _localInComment; [ObservableProperty] private Services.CarryOver _localInComment;
/// <summary>BASE 쪽에 이 줄이 있는지. 정렬 결과라 바뀌지 않는다.</summary> /// <summary>
public bool HasBase { get; } /// BASE 쪽에 이 줄이 있는지.
///
/// 12차 B-1 전에는 정렬 결과라 바뀌지 않았다. 두 파일 비교에서 [&lt;] 로 왼쪽에 반영하면
/// 왼쪽에 없던 줄이 생기고, 왼쪽에만 있던 줄을 반영하면 사라진다.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(State))]
[NotifyPropertyChangedFor(nameof(IsDifferent))]
[NotifyPropertyChangedFor(nameof(CanTakeBase))]
[NotifyPropertyChangedFor(nameof(Highlight))]
private bool _hasBase;
/// <summary>LOCAL 쪽에 줄이 있는지. 줄을 지우거나 되살리면 바뀐다.</summary> /// <summary>LOCAL 쪽에 줄이 있는지. 줄을 지우거나 되살리면 바뀐다.</summary>
[ObservableProperty] [ObservableProperty]
@ -529,5 +546,12 @@ namespace MergeHub.Models
/// 되돌리기 한 번에 통째로 되돌린다(11차 F-2). /// 되돌리기 한 번에 통째로 되돌린다(11차 F-2).
/// </summary> /// </summary>
public int Group { get; init; } public int Group { get; init; }
/// <summary>
/// 이 편집이 <b>어느 칸</b>을 고쳤는지(12차 B-1). 기본은 오른쪽(LOCAL) 이고,
/// 두 파일 비교에서 [&lt;] 로 왼쪽에 반영한 편집만 <see cref="TextPane.Base"/> 다.
/// 되돌리기가 이 값을 보고 같은 칸으로 되돌린다.
/// </summary>
public MergeLine.TextPane Side { get; init; } = MergeLine.TextPane.Local;
} }
} }

View 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;
}
}

View File

@ -223,6 +223,8 @@ namespace MergeHub.Services
Walk(leftRoot, rightRoot, string.Empty, options, include, exclude, result.Items, pending, ref count, cancel); Walk(leftRoot, rightRoot, string.Empty, options, include, exclude, result.Items, pending, ref count, cancel);
ClassifyAll(pending, options, cancel); ClassifyAll(pending, options, cancel);
// 폴더 줄의 크기는 안에 든 것들의 합(12차 A-1). 걸러 낸 것은 목록에 없으니 합계에도 없다.
FolderSizeText.FillFolderSizes(result.Items);
Tally(result.Items, result); Tally(result.Items, result);
result.Truncated = count >= MaxEntries; result.Truncated = count >= MaxEntries;

View 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;
}
}
}
}

View File

@ -42,14 +42,9 @@ namespace MergeHub.Services
private readonly SynchronizationContext? _uiContext; private readonly SynchronizationContext? _uiContext;
private readonly Logger _logger = new Logger("MergeHub"); private readonly Logger _logger = new Logger("MergeHub");
/// <summary>플랫폼에 등록된 사용자. 머지 주석에 남길 이름을 여기서 가져온다.</summary> public GitWorkspace(IGitService git, ISettingsService settingsService)
private readonly UbisamBase.Core.Settings.CurrentUserService? _currentUser;
public GitWorkspace(IGitService git, ISettingsService settingsService,
UbisamBase.Core.Settings.CurrentUserService currentUser)
{ {
Git = git; Git = git;
_currentUser = currentUser;
_uiContext = SynchronizationContext.Current; _uiContext = SynchronizationContext.Current;
_settingsService = settingsService; _settingsService = settingsService;
Settings = settingsService.RegisterSettings(nameof(MergeHubSettings), new MergeHubSettings()); Settings = settingsService.RegisterSettings(nameof(MergeHubSettings), new MergeHubSettings());
@ -95,15 +90,6 @@ namespace MergeHub.Services
_logger.W($"보내기 바로가기 확인 실패: {ex.Message}"); _logger.W($"보내기 바로가기 확인 실패: {ex.Message}");
} }
// 설정 화면에서 이름을 바꾸면 바로 따라간다.
if (_currentUser is not null)
{
_currentUser.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(_currentUser.UserName)) AuthorName = ResolveAuthorName();
};
}
Git.Progress += (_, message) => Git.Progress += (_, message) =>
{ {
_logger.I(message); _logger.I(message);
@ -592,19 +578,17 @@ namespace MergeHub.Services
} }
/// <summary> /// <summary>
/// MergeHub 설정 &gt; 플랫폼에 등록된 사용자 &gt; git config user.name &gt; 윈도우 계정 순으로 고른다. /// MergeHub 설정 &gt; git config user.name &gt; 윈도우 계정 순으로 고른다.
/// ///
/// 플랫폼 사용자를 Git 설정보다 앞에 두는 이유 - 처음 켤 때 사람이 직접 적어 넣은 이름이고, /// 예전에는 플랫폼에 등록된 사용자를 가운데에 두었는데, 새 플랫폼에는 그 자리가 없어졌다.
/// 장비를 여럿이 쓰는 현장에서는 그 이름이 실제로 작업한 사람이다. /// 이름은 이제 이 프로그램 설정(머지 주석 &gt; 작성자)에서 받는다 - 장비를 여럿이 쓰는 현장에서는
/// MergeHub 설정을 더 앞에 두는 것은 "이 도구에서만 다르게 남기고 싶다"는 뜻이라 존중한다. /// 여기에 적어 두면 누가 만졌는지 주석에 남는다.
/// </summary> /// </summary>
private string ResolveAuthorName() private string ResolveAuthorName()
{ {
if (!string.IsNullOrWhiteSpace(Settings.MergeCommentAuthor)) if (!string.IsNullOrWhiteSpace(Settings.MergeCommentAuthor))
return Settings.MergeCommentAuthor.Trim(); return Settings.MergeCommentAuthor.Trim();
if (_currentUser?.HasName == true) return _currentUser.UserName.Trim();
try try
{ {
var configured = Git.GetConfig("user.name"); var configured = Git.GetConfig("user.name");

View File

@ -126,6 +126,16 @@ namespace MergeHub.Services
/// <summary>두 칸에서 BASE 쪽을 가져온 줄의 출처.</summary> /// <summary>두 칸에서 BASE 쪽을 가져온 줄의 출처.</summary>
public static string BaseSource(string? name) => Named("BASE", name); 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) => private static string Named(string side, string? name) =>
string.IsNullOrWhiteSpace(name) ? side : $"{side}({name!.Trim()})"; string.IsNullOrWhiteSpace(name) ? side : $"{side}({name!.Trim()})";

View 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}";
}
}

View 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;
}
}

View 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>&lt;Version&gt;</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;
}
}
}
}

View File

@ -38,6 +38,11 @@ namespace MergeHub.Services
FontBigger, FontBigger,
FontSmaller, FontSmaller,
FontReset, FontReset,
// 12차 E-2 문서 탭
NextTab,
PrevTab,
CloseTab,
} }
/// <summary>단축키 한 줄. 키 처리와 설명 창이 <b>둘 다 이 목록만</b> 본다.</summary> /// <summary>단축키 한 줄. 키 처리와 설명 창이 <b>둘 다 이 목록만</b> 본다.</summary>
@ -143,7 +148,17 @@ namespace MergeHub.Services
"폴더 비교 목록에서 앞 파일로"), "폴더 비교 목록에서 앞 파일로"),
new ShortcutEntry(ShortcutAction.NextFile, Key.PageDown, ModifierKeys.Control, "이동·북마크", 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, "파일·보기", "폴더 비교 열기", new ShortcutEntry(ShortcutAction.OpenFolderCompare, Key.D, ModifierKeys.Control, "파일·보기", "폴더 비교 열기",
"WinMerge 는 새 비교 창을 띄우지만 여기서는 탭 하나로 돌아간다 - 창을 여러 개 띄우는 도구가 아니다."), "WinMerge 는 새 비교 창을 띄우지만 여기서는 탭 하나로 돌아간다 - 창을 여러 개 띄우는 도구가 아니다."),

View File

@ -33,18 +33,28 @@ namespace MergeHub.ViewModels
/// </summary> /// </summary>
public sealed partial class FolderNode : ObservableObject 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; Item = item;
Depth = depth;
LeftPath = Path.Combine(leftRoot, item.RelativePath); LeftPath = Path.Combine(leftRoot, item.RelativePath);
RightPath = Path.Combine(rightRoot, item.RelativePath); RightPath = Path.Combine(rightRoot, item.RelativePath);
foreach (var child in item.Children) 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 FolderCompareItem Item { get; }
public ObservableCollection<FolderNode> Children { get; } = new ObservableCollection<FolderNode>(); 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 LeftInfo => Describe(Item.LeftExists, Item.LeftSize, Item.LeftModified);
public string RightInfo => Describe(Item.RightExists, Item.RightSize, Item.RightModified); 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> /// <summary>
/// 줄 단위 비교 화면으로 넘길 수 있는 항목인지. /// 줄 단위 비교 화면으로 넘길 수 있는 항목인지.
/// ///
@ -107,10 +122,14 @@ namespace MergeHub.ViewModels
private static string Describe(bool exists, long size, DateTime? modified) 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; 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) 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; if (!Keep(node)) continue;
Prune(node); Prune(node);

View 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&lt;T&gt; 의 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]);
}
}
}

View File

@ -116,7 +116,15 @@ namespace MergeHub.ViewModels
// 폴더 비교 더블클릭 · 폴더 검색 결과 · 끌어다 놓기 · 탐색기 우클릭 · mergetool 이 모두 여기로 온다. // 폴더 비교 더블클릭 · 폴더 검색 결과 · 끌어다 놓기 · 탐색기 우클릭 · mergetool 이 모두 여기로 온다.
var opening = Path.GetFileName(File.Exists(rightFile) ? rightFile : leftFile); 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 를 끄되 그 때문에 본문을 다시 읽지는 않게 한다 - 그 재로드가 작업 게이트를 // 3-Way 를 끄되 그 때문에 본문을 다시 읽지는 않게 한다 - 그 재로드가 작업 게이트를
// 먼저 잡으면 바로 아래 파일 비교가 "다른 작업 진행 중"으로 건너뛰어진다. // 먼저 잡으면 바로 아래 파일 비교가 "다른 작업 진행 중"으로 건너뛰어진다.
@ -497,13 +505,17 @@ namespace MergeHub.ViewModels
if (next < 0 || next >= _workspace.Queue.Count) return; if (next < 0 || next >= _workspace.Queue.Count) return;
var pair = _workspace.Queue[next]; 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; _workspace.QueueIndex = next;
_suspendGuard = true; _suspendGuard = true;
_replaceTab = true;
try { _workspace.RequestPlainCompare(pair.Left, pair.Right); } try { _workspace.RequestPlainCompare(pair.Left, pair.Right); }
finally { _suspendGuard = false; } finally { _suspendGuard = false; _replaceTab = false; }
RefreshQueue(); RefreshQueue();
} }
@ -702,6 +714,7 @@ namespace MergeHub.ViewModels
{ {
// 저장했거나 버렸으면 "스타일 맞춤으로 바뀐 글" 도 끝난 것이다. // 저장했거나 버렸으면 "스타일 맞춤으로 바뀐 글" 도 끝난 것이다.
if (!value) _styleRawLocalText = null; if (!value) _styleRawLocalText = null;
RefreshTabDirty(); // 12차 E-2 - 탭 이름 앞 가운뎃점이 따라간다
RaiseCanExecuteChanged(); RaiseCanExecuteChanged();
} }
@ -837,6 +850,90 @@ namespace MergeHub.ViewModels
private bool HasEditFor(MergeLine line) => _undo.Any(e => ReferenceEquals(e.Line, line)); 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>[&lt;] - 이 줄을 왼쪽 파일로 반영한다(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; private bool CanUndo() => _undo.Count > 0 || _textUndo.Count > 0;
[RelayCommand(CanExecute = nameof(CanUndo))] [RelayCommand(CanExecute = nameof(CanUndo))]
@ -1214,6 +1311,8 @@ namespace MergeHub.ViewModels
KeepFileCommand.NotifyCanExecuteChanged(); KeepFileCommand.NotifyCanExecuteChanged();
DeleteFileCommand.NotifyCanExecuteChanged(); DeleteFileCommand.NotifyCanExecuteChanged();
SavePlainCommand.NotifyCanExecuteChanged(); SavePlainCommand.NotifyCanExecuteChanged();
SaveLeftPlainCommand.NotifyCanExecuteChanged(); // 12차 B-1
TakeLineToBaseCommand.NotifyCanExecuteChanged(); // 12차 B-1
ExportReportCommand.NotifyCanExecuteChanged(); ExportReportCommand.NotifyCanExecuteChanged();
AutoMergeCommand.NotifyCanExecuteChanged(); AutoMergeCommand.NotifyCanExecuteChanged();
@ -1895,15 +1994,12 @@ namespace MergeHub.ViewModels
// 반영한 줄에는 누가·언제 반영했는지 주석을 남긴다. // 반영한 줄에는 누가·언제 반영했는지 주석을 남긴다.
// 주석은 줄 끝이 아니라 그 줄 "위"에 따로 들어간다 - 아래 InsertNoteAbove 참고. // 주석은 줄 끝이 아니라 그 줄 "위"에 따로 들어간다 - 아래 InsertNoteAbove 참고.
note = _workspace.Settings.InsertMergeComment && group == 0 ? BuildMergeNote(MergeNames.BaseSource(_baseSourceName)) : null; 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)) if (beforeHasLocal == afterHasLocal && string.Equals(before, after, StringComparison.Ordinal))
return false; return false;
@ -1940,6 +2036,7 @@ namespace MergeHub.ViewModels
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsThreeWay))] [NotifyPropertyChangedFor(nameof(IsThreeWay))]
[NotifyPropertyChangedFor(nameof(BothWays))]
private string _remoteBranch = NoRemoteBranch; private string _remoteBranch = NoRemoteBranch;
/// <summary> /// <summary>
@ -2010,6 +2107,7 @@ namespace MergeHub.ViewModels
{ {
// 콤보로 바꿀 때(OnRemoteBranchChanged)와 같은 것들을 알린다 - 화면이 4분할로 바뀌고 제목·머리 높이도 바뀐다. // 콤보로 바꿀 때(OnRemoteBranchChanged)와 같은 것들을 알린다 - 화면이 4분할로 바뀌고 제목·머리 높이도 바뀐다.
OnPropertyChanged(nameof(IsThreeWay)); OnPropertyChanged(nameof(IsThreeWay));
OnPropertyChanged(nameof(BothWays));
OnPropertyChanged(nameof(IsTwoWay)); OnPropertyChanged(nameof(IsTwoWay));
OnPropertyChanged(nameof(BaseTitle)); OnPropertyChanged(nameof(BaseTitle));
OnPropertyChanged(nameof(LocalTitle)); OnPropertyChanged(nameof(LocalTitle));
@ -2061,6 +2159,7 @@ namespace MergeHub.ViewModels
_lastLoadedRemoteBranch = value; _lastLoadedRemoteBranch = value;
OnPropertyChanged(nameof(IsThreeWay)); OnPropertyChanged(nameof(IsThreeWay));
OnPropertyChanged(nameof(BothWays));
OnPropertyChanged(nameof(IsTwoWay)); OnPropertyChanged(nameof(IsTwoWay));
// 3-Way 로 들어가고 나가면서 화면이 4분할로 바뀐다. 제목과 머리 높이도 함께 바뀐다. // 3-Way 로 들어가고 나가면서 화면이 4분할로 바뀐다. 제목과 머리 높이도 함께 바뀐다.
@ -2647,6 +2746,7 @@ namespace MergeHub.ViewModels
/// </summary> /// </summary>
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsPlainMode))] [NotifyPropertyChangedFor(nameof(IsPlainMode))]
[NotifyPropertyChangedFor(nameof(BothWays))]
[NotifyPropertyChangedFor(nameof(ModeLabel))] [NotifyPropertyChangedFor(nameof(ModeLabel))]
[NotifyPropertyChangedFor(nameof(BaseTitle))] [NotifyPropertyChangedFor(nameof(BaseTitle))]
[NotifyPropertyChangedFor(nameof(LocalTitle))] [NotifyPropertyChangedFor(nameof(LocalTitle))]
@ -2814,6 +2914,10 @@ namespace MergeHub.ViewModels
// 없는 쪽은 빈 내용이 되어 전부 추가(또는 삭제)로 보인다. // 없는 쪽은 빈 내용이 되어 전부 추가(또는 삭제)로 보인다.
if (string.IsNullOrWhiteSpace(LeftFile) && string.IsNullOrWhiteSpace(RightFile)) return; if (string.IsNullOrWhiteSpace(LeftFile) && string.IsNullOrWhiteSpace(RightFile)) return;
// 12차 E-2: 여는 길을 거치지 않고 바로 불린 경우에도 탭이 하나는 있게 한다
// (켤 때 기본 짝 · [기준 파일...] 로 한쪽만 바꾸기 · 데모 전환 뒤 돌아오기).
EnsureTabForCurrentPair();
// 파일 두 개 비교는 늘 두 칸이다. mergetool 네 칸에서 넘어왔으면 접는다. // 파일 두 개 비교는 늘 두 칸이다. mergetool 네 칸에서 넘어왔으면 접는다.
_mergeTool = null; _mergeTool = null;
SetToolThreeWay(false); SetToolThreeWay(false);
@ -2876,6 +2980,8 @@ namespace MergeHub.ViewModels
SetPlainHeaders(); SetPlainHeaders();
_baseline.Open(BuildLocalText()); _baseline.Open(BuildLocalText());
_leftBaseline.Open(BuildBaseText()); // 12차 B-1 - 왼쪽도 연 시점을 따로 기억한다
HasLeftChanges = false;
SyncEditorFromLines(); SyncEditorFromLines();
var info = TextFile.Describe(RightFile); var info = TextFile.Describe(RightFile);
@ -2909,6 +3015,10 @@ namespace MergeHub.ViewModels
// 줄을 고르는 것만으로는 화면이 따라오지 않는다. 캐럿까지 옮겨야 굴러간다. // 줄을 고르는 것만으로는 화면이 따라오지 않는다. 캐럿까지 옮겨야 굴러간다.
if (asked >= 0) GoToRow(asked); 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) && private bool CanSavePlain() => IsPlainMode && !string.IsNullOrWhiteSpace(RightFile) &&
(HasChanges || (_toolThreeWay && UnresolvedConflictCount == 0)); (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> /// <summary>Git 없이 쓸 때는 커밋 없이 오른쪽 파일에 그대로 쓴다.</summary>
[RelayCommand(CanExecute = nameof(CanSavePlain))] [RelayCommand(CanExecute = nameof(CanSavePlain))]
private async Task SavePlainAsync() private async Task SavePlainAsync()
@ -3021,7 +3160,8 @@ namespace MergeHub.ViewModels
/// 스타일 맞춤으로만 바뀐 글은 다시 열면 똑같이 만들어진다 - 묻지 않는다. /// 스타일 맞춤으로만 바뀐 글은 다시 열면 똑같이 만들어진다 - 묻지 않는다.
/// 묻게 두면 스타일이 다른 파일을 훑을 때마다 창이 떠서 쓸 수가 없다. /// 묻게 두면 스타일이 다른 파일을 훑을 때마다 창이 떠서 쓸 수가 없다.
/// </summary> /// </summary>
private bool WillAskBeforeDiscard => !_suspendGuard && HasChanges && !OnlyStyleChanged; private bool WillAskBeforeDiscard =>
!_suspendGuard && ((HasChanges && !OnlyStyleChanged) || HasLeftChanges); // 왼쪽도 본다(12차 B-1)
/// <summary> /// <summary>
/// 고치던 것이 있으면 물어보고, 계속해도 되면 true 를 준다. /// 고치던 것이 있으면 물어보고, 계속해도 되면 true 를 준다.
@ -3108,7 +3248,9 @@ namespace MergeHub.ViewModels
MarkSaved(content); MarkSaved(content);
MessageUtil.ShowSuccessToast($"저장 완료 · {Path.GetFileName(target)}"); MessageUtil.ShowSuccessToast($"저장 완료 · {Path.GetFileName(target)}");
return true;
// 왼쪽에도 고친 것이 있으면 함께 쓴다(12차 B-1). 한쪽만 쓰면 "저장하고 계속" 을 눌러도 나머지가 사라진다.
return !HasLeftChanges || SaveLeftWorkingCopy();
} }
catch (Exception ex) 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> /// <summary>
@ -3419,6 +3581,7 @@ namespace MergeHub.ViewModels
if (starts.Count == 0) if (starts.Count == 0)
{ {
DiffPosition = "차이 없음"; DiffPosition = "차이 없음";
DiffProgress = PaneSummaries.ProgressText(0, 0);
return; return;
} }
@ -3430,6 +3593,9 @@ namespace MergeHub.ViewModels
DiffPosition = index < 0 DiffPosition = index < 0
? $"차이 - / {starts.Count}" ? $"차이 - / {starts.Count}"
: $"차이 {index + 1} / {starts.Count}"; : $"차이 {index + 1} / {starts.Count}";
// 가운데에 적는 진행도 같은 값에서 나온다(12차 B-3).
DiffProgress = PaneSummaries.ProgressText(index + 1, starts.Count);
} }
private bool HasDiffs() => DiffBlockStarts().Count > 0; private bool HasDiffs() => DiffBlockStarts().Count > 0;
@ -3726,6 +3892,31 @@ namespace MergeHub.ViewModels
/// 파일에 썼다(11차 F-8). 되돌리기 · 다시하기 목록은 <b>두고</b> 저장 지점만 옮긴다 - /// 파일에 썼다(11차 F-8). 되돌리기 · 다시하기 목록은 <b>두고</b> 저장 지점만 옮긴다 -
/// 저장한 뒤에도 Undo 로 저장 전 글로 갈 수 있다. 스타일 맞춤은 파일에 들어갔으니 끝났다. /// 저장한 뒤에도 Undo 로 저장 전 글로 갈 수 있다. 스타일 맞춤은 파일에 들어갔으니 끝났다.
/// </summary> /// </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) private void MarkSaved(string content)
{ {
_baseline.MarkSaved(content); _baseline.MarkSaved(content);
@ -3739,6 +3930,7 @@ namespace MergeHub.ViewModels
{ {
// 저장한 뒤 Undo 하면 저장한 글과 달라져 "있음", Redo 로 저장 지점에 오면 "없음"(11차 F-8). // 저장한 뒤 Undo 하면 저장한 글과 달라져 "있음", Redo 로 저장 지점에 오면 "없음"(11차 F-8).
HasChanges = _baseline.IsDirty(BuildLocalText(), _styleRawLocalText is not null); HasChanges = _baseline.IsDirty(BuildLocalText(), _styleRawLocalText is not null);
HasLeftChanges = _leftBaseline.IsDirty(BuildBaseText(), stylePending: false); // 12차 B-1
RefreshAppliedFlags(); RefreshAppliedFlags();
RefreshBlockCommentState(Lines); RefreshBlockCommentState(Lines);
InvalidateDiffBlocks(); InvalidateDiffBlocks();
@ -3750,6 +3942,15 @@ namespace MergeHub.ViewModels
/// <summary>편집 하나를 되돌리거나 다시 적용한다. 줄 존재 여부까지 함께 복원한다.</summary> /// <summary>편집 하나를 되돌리거나 다시 적용한다. 줄 존재 여부까지 함께 복원한다.</summary>
private void Restore(LineEdit edit, bool toBefore) 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.LocalText = toBefore ? edit.Before : edit.After;
edit.Line.HasLocal = toBefore ? edit.BeforeHasLocal : edit.AfterHasLocal; edit.Line.HasLocal = toBefore ? edit.BeforeHasLocal : edit.AfterHasLocal;
@ -3761,8 +3962,15 @@ namespace MergeHub.ViewModels
else MarkApplied(edit.Line); else MarkApplied(edit.Line);
} }
// 이 편집이 끼워 넣은 주석 줄도 함께 넣고 뺀다. RestoreNote(edit, toBefore);
// 본문만 되돌리고 주석을 남기면 파일에 유령 주석이 쌓인다. }
/// <summary>
/// 이 편집이 끼워 넣은 주석 줄도 함께 넣고 뺀다.
/// 본문만 되돌리고 주석을 남기면 파일에 유령 주석이 쌓인다.
/// </summary>
private void RestoreNote(LineEdit edit, bool toBefore)
{
if (edit.InsertedNote is not { } note) return; if (edit.InsertedNote is not { } note) return;
if (toBefore) 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() private void UpdateSummaries()
{ {
// 줄을 고르거나 머지 주석이 끼어들면 결과 줄 수가 바뀐다. 그릴 때마다 번호를 다시 매긴다(8차 B-3). // 줄을 고르거나 머지 주석이 끼어들면 결과 줄 수가 바뀐다. 그릴 때마다 번호를 다시 매긴다(8차 B-3).
@ -3894,6 +4149,7 @@ namespace MergeHub.ViewModels
? work ? work
: $"{work} · 편집 {edits}건 · 되돌리기 {redos}건{(HasChanges || edits == 0 ? string.Empty : " · 저장됨")}"; : $"{work} · 편집 {edits}건 · 되돌리기 {redos}건{(HasChanges || edits == 0 ? string.Empty : " · 저장됨")}";
UpdatePaneSummaries(); // 12차 B-3 - 반영 · 되돌리기 · 저장 · 규칙 바꾸기가 모두 이 길을 지난다
RefreshProblems(); RefreshProblems();
} }
@ -4372,15 +4628,6 @@ namespace MergeHub.ViewModels
for (var i = 0; i < rows.Count; i++) rows[i].LocalNumber = numbers[i]; for (var i = 0; i < rows.Count; i++) rows[i].LocalNumber = numbers[i];
} }
private string BuildLocalText() private string BuildLocalText() => PaneText.Build(Lines, MergeLine.TextPane.Local);
{
var sb = new StringBuilder();
foreach (var line in Lines)
{
if (!line.HasLocal) continue; // BASE 에만 있던 줄 - LOCAL 파일에는 없다
sb.Append(line.LocalText).Append('\n');
}
return sb.ToString();
}
} }
} }

View 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>

View 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();
}
}
}

View 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();
}
}

View File

@ -4,6 +4,8 @@
xmlns:vm="clr-namespace:MergeHub.ViewModels" xmlns:vm="clr-namespace:MergeHub.ViewModels"
xmlns:svc="clr-namespace:MergeHub.Services" xmlns:svc="clr-namespace:MergeHub.Services"
xmlns:ctl="clr-namespace:MergeHub.Views.Controls" 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"> AllowDrop="True" DragOver="View_DragOver" Drop="View_Drop">
<UserControl.Resources> <UserControl.Resources>
@ -14,6 +16,9 @@
<BooleanToVisibilityConverter x:Key="BoolToVis"/> <BooleanToVisibilityConverter x:Key="BoolToVis"/>
<ctl:FolderStatusBrushConverter x:Key="StatusBrush"/> <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"/> <ctl:FolderFilterNameConverter x:Key="FolderFilterName"/>
<!-- 파일 비교 화면과 같은 글자 스타일. 두 화면이 따로 노는 것처럼 보이지 않게 값을 맞춰 둔다. --> <!-- 파일 비교 화면과 같은 글자 스타일. 두 화면이 따로 노는 것처럼 보이지 않게 값을 맞춰 둔다. -->
@ -37,34 +42,63 @@
<Border Padding="2,1" Background="{Binding DisplayStatus, Converter={StaticResource StatusBrush}}" <Border Padding="2,1" Background="{Binding DisplayStatus, Converter={StaticResource StatusBrush}}"
MinWidth="{Binding ActualWidth, RelativeSource={RelativeSource AncestorType=TreeView}}"> MinWidth="{Binding ActualWidth, RelativeSource={RelativeSource AncestorType=TreeView}}">
<!-- <!--
칸 폭을 고정한다. TreeView 는 깊이마다 왼쪽으로 들여쓰기 때문에 star 폭을 쓰면 12차 A-2 - 왼쪽 목록 · 가운데 상태 · 오른쪽 목록 세 칸.
줄마다 상태 칸이 다른 자리에 오고 머리글과도 어긋난다. 고정 폭이면
들여쓰기만큼(한 단계 약 19px)만 밀려서 눈으로 읽을 수 있다. TreeView 는 깊이마다 줄 전체를 오른쪽으로 민다. 그대로 두면 가운데 · 오른쪽 칸이
줄마다 다른 자리에 와서 두 목록으로 읽히지 않는다 - 그래서 민 만큼 되돌리고(DepthPull)
이름에만 다시 들여쓴다(DepthIndent). 칸 경계와 가운데 구분선은 줄마다 같은 자리에 온다.
--> -->
<Grid> <Grid Margin="{Binding Depth, Converter={StaticResource DepthPull}}">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="420"/> <ColumnDefinition Width="420"/>
<ColumnDefinition Width="200"/> <ColumnDefinition Width="170"/>
<ColumnDefinition Width="190"/> <ColumnDefinition Width="420"/>
<ColumnDefinition Width="190"/>
<!-- 남는 폭을 먹는 빈 칸. 이게 없으면 줄 배경이 글자 끝에서 잘려 <!-- 남는 폭을 먹는 빈 칸. 이게 없으면 줄 배경이 글자 끝에서 잘려
넓은 화면에서 색 띠가 중간에 끊겨 보인다. --> 넓은 화면에서 색 띠가 중간에 끊겨 보인다. -->
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- 왼쪽 목록. 왼쪽에 없는 것이면 통째로 비운다(반대편만 채워져 어느 쪽 것인지 바로 읽힌다). -->
<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"> <StackPanel Grid.Column="0" Orientation="Horizontal">
<TextBlock Text="📁" FontSize="11" Margin="0,0,4,0" <TextBlock Text="📁" FontSize="11" Margin="0,0,4,0"
Visibility="{Binding IsDirectory, Converter={StaticResource BoolToVis}}"/> Visibility="{Binding IsDirectory, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="{Binding Name}" FontFamily="Consolas" FontSize="12" <TextBlock Text="{Binding Name}" FontFamily="Consolas" FontSize="12"
ToolTip="{Binding RelativePath}"/> TextTrimming="CharacterEllipsis" ToolTip="{Binding RelativePath}"/>
</StackPanel> </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" <Border Grid.Column="1" BorderThickness="1,0,1,0"
Style="{StaticResource Muted}"/> BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<TextBlock Grid.Column="3" Text="{Binding RightInfo}" FontSize="11" <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}"/> Style="{StaticResource Muted}"/>
</Grid> </Grid>
</Grid>
</Border> </Border>
</HierarchicalDataTemplate> </HierarchicalDataTemplate>
@ -286,17 +320,24 @@
<Border Grid.Row="4" Padding="8,4" BorderThickness="1,1,1,0" <Border Grid.Row="4" Padding="8,4" BorderThickness="1,1,1,0"
Background="{DynamicResource Ubisam.Brush.Sidebar.Background}" Background="{DynamicResource Ubisam.Brush.Sidebar.Background}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"> BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<Grid Margin="26,0,0,0"> <Grid Margin="12,0,0,0">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="420"/> <ColumnDefinition Width="420"/>
<ColumnDefinition Width="200"/> <ColumnDefinition Width="170"/>
<ColumnDefinition Width="190"/> <ColumnDefinition Width="420"/>
<ColumnDefinition Width="190"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="이름" FontSize="11" FontWeight="Bold"/> <Grid Grid.Column="0">
<TextBlock Grid.Column="1" Text="상태" FontSize="11" FontWeight="Bold"/> <TextBlock Text="왼쪽" FontSize="11" FontWeight="Bold"/>
<TextBlock Grid.Column="2" Text="왼쪽 (크기 · 수정일)" FontSize="11" FontWeight="Bold"/> <TextBlock Text="크기 · 수정일" FontSize="11" FontWeight="Bold" Margin="0,0,10,0"
<TextBlock Grid.Column="3" Text="오른쪽 (크기 · 수정일)" FontSize="11" FontWeight="Bold"/> 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> </Grid>
</Border> </Border>

View File

@ -4,6 +4,8 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:MergeHub.Views" xmlns:local="clr-namespace:MergeHub.Views"
xmlns:shell="clr-namespace:UbisamBase.Core.Shell;assembly=UbisamBase.Core"
shell:PlatformChrome.FullBleed="True"
mc:Ignorable="d"> mc:Ignorable="d">
<UserControl.Resources> <UserControl.Resources>
<ResourceDictionary> <ResourceDictionary>

View File

@ -30,6 +30,13 @@ namespace MergeHub.Views
view.IsLiveFiltering = true; view.IsLiveFiltering = true;
view.LiveFilteringProperties.Add(nameof(MergeLine.HasLocal)); 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; return view;
} }

View File

@ -7,6 +7,8 @@
xmlns:ctl="clr-namespace:MergeHub.Views.Controls" xmlns:ctl="clr-namespace:MergeHub.Views.Controls"
xmlns:system="clr-namespace:System;assembly=mscorlib" xmlns:system="clr-namespace:System;assembly=mscorlib"
xmlns:models="clr-namespace:MergeHub.Models" xmlns:models="clr-namespace:MergeHub.Models"
xmlns:shell="clr-namespace:UbisamBase.Core.Shell;assembly=UbisamBase.Core"
shell:PlatformChrome.FullBleed="True"
mc:Ignorable="d" mc:Ignorable="d"
PreviewKeyDown="View_PreviewKeyDown" PreviewKeyDown="View_PreviewKeyDown"
AllowDrop="True" DragOver="View_DragOver" Drop="View_Drop"> AllowDrop="True" DragOver="View_DragOver" Drop="View_Drop">
@ -158,13 +160,39 @@
66 인 까닭은 LocalLineTemplate 에 적었다(6차 B-4). --> 66 인 까닭은 LocalLineTemplate 에 적었다(6차 B-4). -->
<Grid MinHeight="{Binding BaseRowHeight}"> <Grid MinHeight="{Binding BaseRowHeight}">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="{DynamicResource GutterWidth}"/> <ColumnDefinition Width="{DynamicResource GutterWidth}"/>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- [<] 이 줄을 왼쪽 파일로 반영(12차 B-1). 두 파일 비교에서만 보인다 -
Git 모드의 왼쪽은 지난 커밋이라 쓸 파일이 없다. -->
<Button Grid.Column="0" Content="&lt;" 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 에서 지운 줄을 되살리는 [>] (11차 F-10). 예전에는 LOCAL 의 빈 줄(필러)에 달려 있었는데
이제 LOCAL 칸에는 그 줄이 없으므로 BASE 쪽 줄 끝에 단다(Araxis 처럼). LOCAL 에 있는 줄은 LOCAL 여백의 [>] 그대로. --> 이제 LOCAL 칸에는 그 줄이 없으므로 BASE 쪽 줄 끝에 단다(Araxis 처럼). LOCAL 에 있는 줄은 LOCAL 여백의 [>] 그대로. -->
<Button Grid.Column="2" Content="&gt;" Padding="0" Margin="2,0,2,0" MinWidth="16" <Button Grid.Column="3" Content="&gt;" Padding="0" Margin="2,0,2,0" MinWidth="16"
Height="{DynamicResource GutterButtonHeight}" MinHeight="{DynamicResource GutterButtonHeight}" Height="{DynamicResource GutterButtonHeight}" MinHeight="{DynamicResource GutterButtonHeight}"
FontSize="{DynamicResource GutterButtonFontSize}" VerticalAlignment="Top" FontSize="{DynamicResource GutterButtonFontSize}" VerticalAlignment="Top"
Command="{Binding DataContext.TakeLineCommand, RelativeSource={RelativeSource AncestorType=UserControl}}" Command="{Binding DataContext.TakeLineCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
@ -185,10 +213,10 @@
</Style> </Style>
</Button.Style> </Button.Style>
</Button> </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" Foreground="{DynamicResource Ubisam.Brush.Muted}" TextAlignment="Right"
VerticalAlignment="Top" Margin="0,0,8,0"/> 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}}"> Background="{Binding Highlight, Converter={StaticResource HighlightBrush}}">
<Grid ClipToBounds="True"> <Grid ClipToBounds="True">
<ctl:CodeTextBlock Code="{Binding BaseText}" Words="{Binding BaseWords}" <ctl:CodeTextBlock Code="{Binding BaseText}" Words="{Binding BaseWords}"
@ -337,6 +365,7 @@
<ColumnDefinition Width="{DynamicResource GutterMarkWidth}"/> <ColumnDefinition Width="{DynamicResource GutterMarkWidth}"/>
<ColumnDefinition Width="{DynamicResource GutterNarrowNumberWidth}"/> <ColumnDefinition Width="{DynamicResource GutterNarrowNumberWidth}"/>
<ColumnDefinition Width="{DynamicResource GutterButtonWidth}"/> <ColumnDefinition Width="{DynamicResource GutterButtonWidth}"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
@ -359,6 +388,33 @@
CommandParameter="{Binding}" CommandParameter="{Binding}"
ToolTip="이 줄만 BASE 내용으로 가져오기"/> ToolTip="이 줄만 BASE 내용으로 가져오기"/>
<!-- [<] 왼쪽에 없는 줄(오른쪽에서 새로 생긴 줄)을 왼쪽 파일에 넣는다(12차 B-1).
왼쪽에 있는 줄은 BASE 칸의 [<] 를 쓴다 - 거기 줄이 있으니 그 자리에 다는 것이 맞다. -->
<Button Grid.Column="3" Content="&lt;" 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 더 밀려 있으면 그 위에 겹친 편집기의 캐럿이 같은 x 에서 시작한다. 여기가 2 더 밀려 있으면 그 위에 겹친 편집기의 캐럿이
글자 가운데에 박힌 것처럼 보인다. 글자 가운데에 박힌 것처럼 보인다.
--> -->
<Border Grid.Column="3" Padding="0" <Border Grid.Column="4" Padding="0"
Background="{Binding Highlight, Converter={StaticResource HighlightBrush}}" Background="{Binding Highlight, Converter={StaticResource HighlightBrush}}"
BorderThickness="2,0,0,0"> BorderThickness="2,0,0,0">
<Border.ToolTip> <Border.ToolTip>
@ -413,7 +469,77 @@
</ResourceDictionary> </ResourceDictionary>
</UserControl.Resources> </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> <Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
@ -432,6 +558,11 @@
줄었다 하는 WrapPanel 이라 그 안에 넣으면 자리가 떠다닌다 - DockPanel 로 줄었다 하는 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" <Button DockPanel.Dock="Right" Content="단축키" Click="ShortcutHelp_Click"
Height="26" MinHeight="26" Padding="10,2" FontSize="11" Margin="10,0,0,0" Height="26" MinHeight="26" Padding="10,2" FontSize="11" Margin="10,0,0,0"
VerticalAlignment="Top" VerticalAlignment="Top"
@ -1057,6 +1188,8 @@
<RowDefinition Height="Auto" SharedSizeGroup="PaneHeader"/> <RowDefinition Height="Auto" SharedSizeGroup="PaneHeader"/>
<RowDefinition Height="*"/> <RowDefinition Height="*"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<!-- 12차 B-3: 칸 아래 한 줄 요약 -->
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<StackPanel Grid.Row="0" Margin="8,6"> <StackPanel Grid.Row="0" Margin="8,6">
@ -1111,6 +1244,15 @@
Style="{StaticResource Muted}"/> Style="{StaticResource Muted}"/>
</StackPanel> </StackPanel>
</Border> </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> </Grid>
</Border> </Border>
@ -1136,6 +1278,8 @@
<RowDefinition Height="Auto" SharedSizeGroup="PaneHeader"/> <RowDefinition Height="Auto" SharedSizeGroup="PaneHeader"/>
<RowDefinition Height="*"/> <RowDefinition Height="*"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<!-- 12차 B-3: 칸 아래 한 줄 요약 -->
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<!-- BASE / LOCAL 의 머리 영역 높이를 같게 고정해야 두 패널의 01행이 같은 눈높이에 온다 --> <!-- BASE / LOCAL 의 머리 영역 높이를 같게 고정해야 두 패널의 01행이 같은 눈높이에 온다 -->
@ -1196,7 +1340,17 @@
Value="{Binding SourceScrollValue, Mode=TwoWay, RelativeSource={RelativeSource AncestorType=UserControl}}" Value="{Binding SourceScrollValue, Mode=TwoWay, RelativeSource={RelativeSource AncestorType=UserControl}}"
Visibility="{Binding SourceBarVisibility, 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> </Grid>
</Border> </Border>
@ -1373,7 +1527,17 @@
LineRequested="MiniMap_LineRequested"/> LineRequested="MiniMap_LineRequested"/>
</Grid> </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> </Grid>
</Border> </Border>
@ -1396,6 +1560,8 @@
<RowDefinition Height="Auto" SharedSizeGroup="PaneHeader"/> <RowDefinition Height="Auto" SharedSizeGroup="PaneHeader"/>
<RowDefinition Height="*"/> <RowDefinition Height="*"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<!-- 12차 B-3: 칸 아래 한 줄 요약 -->
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<!-- BASE/LOCAL 과 같은 머리 높이. 세 패널의 01행이 같은 눈높이에 와야 한다. <!-- BASE/LOCAL 과 같은 머리 높이. 세 패널의 01행이 같은 눈높이에 와야 한다.
@ -1462,6 +1628,15 @@
</TextBlock> </TextBlock>
</StackPanel> </StackPanel>
</Border> </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> </Grid>
</Border> </Border>
@ -1471,6 +1646,21 @@
Visibility="{Binding IsThreeWay, Converter={StaticResource BoolToVis}}" Visibility="{Binding IsThreeWay, Converter={StaticResource BoolToVis}}"
Background="{DynamicResource Ubisam.Brush.Table.GridLine}"/> 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). 위 칸들 위에 겹친 투명한 그림판 - 마우스는 통과한다. --> <!-- 잇는 선(11차 F-10). 위 칸들 위에 겹친 투명한 그림판 - 마우스는 통과한다. -->
<ctl:PaneLinkOverlay x:Name="LinkOverlay" Grid.Row="0" Grid.Column="2" Grid.ColumnSpan="5" Panel.ZIndex="5"/> <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}}" Visibility="{Binding IsBackupOn, Converter={StaticResource BoolToVis}}"
ToolTip="저장할 때 남긴 백업을 모아 둔 폴더를 엽니다. 날짜 폴더마다 목록.txt 에 원본과 백업이 적혀 있습니다"/> 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 이 아니면 커밋할 곳이 없다. 파일에 쓰기만 한다. --> <!-- 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}" Command="{Binding SavePlainCommand}"
Visibility="{Binding IsPlainMode, Converter={StaticResource BoolToVis}}" Visibility="{Binding IsPlainMode, Converter={StaticResource BoolToVis}}"
Style="{StaticResource FilledButton}" Style="{StaticResource FilledButton}"
@ -1885,4 +2081,5 @@
</Grid> </Grid>
</Border> </Border>
</Grid> </Grid>
</DockPanel>
</UserControl> </UserControl>

View File

@ -389,8 +389,10 @@ namespace MergeHub.Views
{ {
old.EditorTextReplaced -= OnEditorTextReplaced; old.EditorTextReplaced -= OnEditorTextReplaced;
old.ScrollToRowRequested -= OnScrollToRowRequested; old.ScrollToRowRequested -= OnScrollToRowRequested;
old.CaretIndexRequested -= OnCaretIndexRequested;
old.PropertyChanged -= OnViewModelPropertyChanged; old.PropertyChanged -= OnViewModelPropertyChanged;
old.ReadEditorText = null; old.ReadEditorText = null;
old.ReadCaretIndex = null;
BindPaneViews(null); BindPaneViews(null);
} }
@ -398,6 +400,7 @@ namespace MergeHub.Views
{ {
now.EditorTextReplaced += OnEditorTextReplaced; now.EditorTextReplaced += OnEditorTextReplaced;
now.ScrollToRowRequested += OnScrollToRowRequested; now.ScrollToRowRequested += OnScrollToRowRequested;
now.CaretIndexRequested += OnCaretIndexRequested;
now.PropertyChanged += OnViewModelPropertyChanged; now.PropertyChanged += OnViewModelPropertyChanged;
ApplyActionsHidden(now.IsActionsHidden); ApplyActionsHidden(now.IsActionsHidden);
ApplyCodeFont(now.CodeFontSize); ApplyCodeFont(now.CodeFontSize);
@ -406,11 +409,46 @@ namespace MergeHub.Views
// 바인딩으로 매 글자마다 문서 전체를 넘기면 큰 파일에서 눈에 띄게 느리다. // 바인딩으로 매 글자마다 문서 전체를 넘기면 큰 파일에서 눈에 띄게 느리다.
now.ReadEditorText = () => LocalEditor.Text; now.ReadEditorText = () => LocalEditor.Text;
// 탭을 옮길 때 캐럿 자리를 맡겼다 되살리는 데 쓴다(12차 E-2).
now.ReadCaretIndex = () => LocalEditor.CaretIndex;
// 칸마다 자기 줄만(11차 F-10) // 칸마다 자기 줄만(11차 F-10)
BindPaneViews(now); 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> /// <summary>
/// 2-Way ↔ 3-Way 로 바뀌면 패널 배치가 통째로 달라진다. /// 2-Way ↔ 3-Way 로 바뀌면 패널 배치가 통째로 달라진다.
/// 워드랩의 접히는 자리를 정하는 글자 폭 보정도 그때 다시 잡아야 한다. /// 워드랩의 접히는 자리를 정하는 글자 폭 보정도 그때 다시 잡아야 한다.
@ -1008,6 +1046,11 @@ namespace MergeHub.Views
case ShortcutAction.PrevFile: return Run(vm.PrevFileCommand); case ShortcutAction.PrevFile: return Run(vm.PrevFileCommand);
case ShortcutAction.NextFile: return Run(vm.NextFileCommand); 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: case ShortcutAction.ToggleBookmark:
if (vm.SelectedLine is null) return false; if (vm.SelectedLine is null) return false;
vm.ToggleBookmarkCommand.Execute(vm.SelectedLine); vm.ToggleBookmarkCommand.Execute(vm.SelectedLine);
@ -1156,6 +1199,13 @@ namespace MergeHub.Views
private void ShortcutHelp_Click(object sender, RoutedEventArgs e) => private void ShortcutHelp_Click(object sender, RoutedEventArgs e) =>
ShortcutHelpWindow.ShowOrActivate(Window.GetWindow(this)); 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> /// <summary>