Compare commits

..

No commits in common. "13988d1f0a9f175827f72b8bde2d4c85916160a0" and "77621b1023e8fead454dd43bbf231b6d29897418" have entirely different histories.

43 changed files with 375 additions and 5020 deletions

View File

@ -164,11 +164,6 @@ A-1 에서 값으로 확인한 것 — 지운 파일은 "삭제되어 건너뜀
- F-3: 매뉴얼 "탐색기에서 열기" — 여러 개를 골라 보내면 앞의 둘(3개 이상은 알림), 폴더 둘은 바로 폴더 비교, 260자 넘는 경로는 열지 못함(알림), 배포 묶음을 옮겨도 켜면 보내기 바로가기가 따라옴, 설치 파일 필요 없음. 알아 둘 한계에 "떠 있어도 새 창". 숫자 23벌 1,411건.
- F-4: 매뉴얼 파일 비교 화면 — [ACTIONS 감추기] · F9, 아래 탭 줄 [탭 접기] · 띠 · F11(플랫폼), 단축키 표에 F9 · F11. 숫자 23벌 1,414건 · 플랫폼 31건.
- F-5: 매뉴얼 설정 표 "보기 > 코드 글자 크기", 파일 비교 화면 Ctrl+휠 · Ctrl++/- · Ctrl+0 · 상태 줄 `글자 N`, 단축키 표. 숫자 24벌 1,538건.
- F-6: 줄 간격을 좁혀 1920 × 1080 두 칸 40 → 50줄(매뉴얼의 화면 설명 · 그림이 있으면 새로). 숫자 24벌 1,572건.
- F-8: 매뉴얼 되돌리기 설명 — 저장 · 저장 후 커밋 뒤에도 Undo/Redo(커밋은 그대로, 화면만), 편집기에 친 것 · 규칙 바꾸기도 이어짐, 요약 `· 저장됨`, [전체 되돌리기] 는 파일을 연 시점(저장했으면 화면만). Git 탭 충돌 화면 "이번 병합에서 푼 파일" + [다시 충돌로](git.exe 필요 — 알아 둘 한계에도). 숫자 24벌 1,593건.
- F-9: 매뉴얼 설정 표 "저장 > 저장할 때 백업"(기본 끔) — 백업 자리 `{설정 폴더}\Backup\MergeHub\날짜\드라이브\원래 폴더\이름.날짜-시각.확장자` · 목록.txt, 모든 저장(병합 중 Save · 파일 단위 Accept · 폴더 복사 포함), 못 만들면 저장 안 함, 저절로 지우지 않음, ACTIONS [백업 폴더 열기]. 숫자 24벌 1,618건.
- F-10: 매뉴얼 파일 비교 화면 설명과 그림을 새로 — 칸마다 자기 줄(빈 줄로 맞추지 않음) · 두 칸(3분할은 위 세 칸) 사이 잇는 선과 색 · 옮김 점선 · 가운데 기준 스크롤 · 차이 이동이 가운데로 · 지운 줄 되살리기 [>] 가 BASE 칸 끝. 상태 줄 `N행` 은 파일 줄 번호. 숫자 25벌 1,639건.
- F-11: 매뉴얼 Git 탭 원격 칸 — 원격 URL · 계정은 적으면 설정에 남고, 토큰은 fetch/pull/push 가 되면 윈도 자격 증명 관리자에 저장(설정 파일에는 토큰이 없음), 껐다 켜면 다시 묻지 않음, [토큰 지우기] · "저장됨" 표시, 인증 실패 안내 문장. 이 PC · 이 윈도 계정에만 저장된다는 것도. 숫자 25벌 1,654건.
## B-1. 배포 묶음을 다시 만들고 꼭 필요한 파일을 가림 (2026-09-22, 완료)
@ -734,241 +729,6 @@ libgit2 의 다듬기(`PrettifyMessages`)를 켜면 **손대지 않은 뒤 커
- 편집기에 포커스가 있으면 **Ctrl+=** 13 → 14(행 34px) · **Ctrl+-** → 13 · **Ctrl+0** → 기본(12, 행 30px), 매번 저장.
- **켜자마자는 포커스가 아무 데도 없어**(`FocusedElement` 빈 값) Ctrl+= 뿐 아니라 F9 같은 **다른 단축키도 듣지 않는다** — F-5 가 아니라 원래 있던 동작(코드 칸을 한 번 누르면 된다). 고칠지는 사용자에게 물음.
### 이어서 고친 것 — 켜자마자 단추키가 안 듣던 것 (2026-09-22 사용자 "응 고쳐줘")
- `HomeView.FocusEditorWhenIdle` — 화면이 처음 뜰 때 · 파일 비교 탭으로 돌아올 때 · 창이 활성이 될 때, 키보드 포커스가 **비어 있거나 이 화면 밖**(아래 탭 줄 등)이면 편집기로 준다. 이 화면 안(찾기 칸 · 가는 줄 칸 등)이나 **다른 창**(대화상자)에 있는 포커스는 건드리지 않는다. 켤 때는 사용자 이름 창이 먼저 떠 있으므로 논리 포커스만 정해 두고, 본 창이 활성이 되면 WPF 가 거기로 돌려준다.
- 확인(`f5-focus.ps1`): 켠 직후 아무 데도 안 누르고 포커스 `LocalEditor` · F9 들음, Git 탭(포커스는 탭 줄 항목)에 갔다 돌아오면 `LocalEditor` · F9 들음, Ctrl+F 뒤 `FindBox` 가 2초 뒤에도 그대로. 빌드 경고 0 · 오류 0.
- 회귀: 11차 E-1 병합 길(마우스 확인 B-1 · 스크린숏만 끔) — 1) 24 → 9 · 누른 Take 18 = 로그 18 · 병합 커밋 본문 26개 · 줄끝 · BOM 바뀐 파일 0 · 충돌 표식 0 · class 검사 0.
- 첫 회차는 "다음 충돌 안 보임" 으로 멈췄다 — 사용자가 그 사이 직접 MergeHub 를 켜 글자 9 · ACTIONS 감춤으로 바꿔 두었고, 시험 도구는 설정을 그대로 썼다. 시험 도구(`d7lib.ps1` `WithSettings`)가 **시험하는 동안만** ACTIONS 보임 · 글자 12 로 두고 끝나면 바이트 그대로 되돌리게 고쳤다(제품 아님).
- 같은 때 시험 스크립트가 사용자가 켜 둔 MergeHub 를 강제로 껐다(16:55). 앞으로 시험 전에 떠 있는 창을 보고, 있으면 먼저 묻는다.
## F-6. 글자를 되도록 많이 보이게 — 줄 간격 좁힘 (2026-09-22, 완료)
지시서는 "한 단추로 넓게 보기" 를 방향으로 적었지만, 사용자는 **넓게 보기라는 기능이 아니라 넓게 보기 위한 방향을 물은 것**이었다. 그래서 방법마다 1920 × 1080 에서 몇 줄이 느는지 재서 드렸고, 사용자가 "**줄 간격 줄여주고 선택 사항은 만들지 마**" 로 정했다.
### 잰 것 (`f6-measure.ps1` — 두 번째 모니터 1920 × 1080(WPF 단위, 물리 2880 × 1620 · 150%)에 최대화, 글자 12)
| 방법 | 두 칸 | 네 칸(위 세 칸 · 아래 MERGED) | 상태 |
|---|---|---|---|
| 고치기 전 | **40줄** | 17 · 18 | — |
| 아래 탭 줄 접기(F11) | 42 | 18 · 19 | 이미 있음(F-4) |
| 글자 12 → 10(Ctrl+-) | 47 | 20 · 22 | 이미 있음(F-5) |
| 줄 간격 좁힘(줄 높이 20 → 16) | 약 50 | 22 · 23 | 새로 |
| 칸 제목 두 줄 → 한 줄 | 약 +0.7 | 약 +1.4 | 새로(안 함) |
| 색 범례 줄 감추기 | 약 +1 | 약 +1 | 새로(안 함) |
| ACTIONS 감추기(F9) | 0(칸 폭 +163px 씩) | 0 | 이미 있음(F-4) |
### 고친 것
- 줄 높이를 글자의 **1.67배 → 1.33배**로(`CodeFontMetrics`, 12 → 16 · 8 → 11 · 24 → 32). 켜고 끄는 단추 · 설정은 만들지 않았다(사용자 결정). 여백 · 번호 칸 · 단추 비율은 그대로.
- `MergeLine.MinimumRowHeight` · `CodeLineHeight` 기본값 · 화면의 줄 높이 대체값을 16 으로.
- 북마크 표식(위아래 여백 3 + 높이 14 = 20)이 16 줄을 넘으므로 표식 높이도 줄에 맞춤(`BookmarkHeight` = 줄 높이 − 6, 12 에서 10).
### 확인한 것
- 하네스 FontTest 144건(+34 — 8~24 모든 크기에서 줄 높이 1.3~1.4배, 북마크가 줄 안, 기본 12 의 값). 24벌 **1,572건** · 빌드 경고 0 · 오류 0.
- 화면(1920 × 1080, 글자 12): **두 칸 40 → 50줄**(탭 줄까지 접으면 53), **네 칸 17 · 18 → 22 · 23줄**(탭 줄 접으면 23 · 24). 행 높이 24px(16 DIP × 1.5).
- 8 · 12 · 24 (`f5-verify.ps1`, 한글 주석 든 코드): 두 칸 행 높이 서로 같음(8 은 16 · 17 번갈아, 12 는 24, 24 는 48), 여백 단추 행 안, 그림에서 한글이 잘리거나 위아래 줄이 겹치지 않고 줄 번호 · 편집기 커서가 행에 맞음.
- 회귀: 11차 E-1 병합 길 — 24 → 9 · 누른 Take 18 = 로그 18 · 병합 커밋 본문 26개 · 줄끝 · BOM 바뀐 파일 0 · 충돌 표식 0 · class 검사 0.
## F-7. 대형 모니터에서 오른쪽(종료 · ACTIONS)이 잘림 (2026-09-22, 완료 — 플랫폼 배포함)
사용자 증상: "대형 모니터에서 화면이 짤리는 현상, 오른쪽이 — 프로그램 종료 및 액션 패널이 안 보였다". 사용자 바람: "모든 해상도의 배율에서 화면이 정상적으로 출력되길".
### 지금 화면이 무엇에 맞춰져 있나 (사용자 물음에 답한 것)
특정 해상도에 맞춘 것이 아니다 — 칸 폭은 비율로 늘고 준다. 플랫폼 창 최소 크기 1280 × 720(WPF 단위 — 100% 1280×720 · 150% 1920×1080 · 200% 2560×1440 px), 화면이 그보다 작으면 최소 크기를 작업 영역에 맞춰 줄인다. 모니터마다 배율이 달라도(PerMonitorV2) 그 모니터 배율로 다시 그리고 최대화 크기도 그 모니터로 잡는다.
### 가려낸 것
- 종료(플랫폼 탭 줄 오른쪽 끝)와 ACTIONS 가 **함께** 안 보였다 → 안쪽 화면이 넓은 것이 아니라 **창이 모니터 밖으로 나간 것**.
- 켤 때 기억한 창 모드 자리를 되살리는 `PreparePlacement` 가 모니터에 200×50px 만 걸쳐도 "보인다" 로 보고 그대로 놓았다. 모니터 안으로 밀어 넣는 `KeepInside` 는 모니터를 옮길 때만 썼다.
### 재현 (`f7-repro.ps1`, 창 자리 파일에 창 모드 자리를 넣고 켬)
| 경우 | 고치기 전 | 고친 뒤 |
|---|---|---|
| 창 모드, 주 모니터(2560) 오른쪽을 넘는 자리 1200~3400 | **창 1200~3400 · 840px 넘침**, 종료 3400 · Accept 3366 · 시계 · 단축키 등 75개 요소가 모니터 밖 | 창 360~2560(크기 그대로 밀림), 종료 2560 · Accept 2526, 넘친 것 없음 |
| 같은 자리를 최대화로 | 모니터에 맞음 | 같음 |
| 가장 좁은 창(1280 DIP) × 화면 배율 80 · 100 · 120 · 133%, 파일 비교 · Git · 폴더 | 넘친 것 없음 | 같음 |
- 안쪽 화면 배치(칸 최소 폭 · 도구 줄)는 가장 좁은 창과 가장 큰 화면 배율에서도 넘치지 않았다 — 원인이 아니다.
### 고친 것 (플랫폼)
- `WindowPlacementMath.FitToMonitors` — 가장 많이 겹치는 모니터의 작업 영역 안으로 밀고 크면 줄인다. `ShellWindow.PreparePlacement` 가 기억한 자리를 이것으로 고쳐 놓는다.
- PlacementTest +8(45건) · 플랫폼 Debug 빌드 경고 0 · 오류 0. MergeHub 쪽 코드 변경 없음.
### 남은 것
- ~~플랫폼 배포~~ — 사용자 "배포해줘" 뒤 `publish-platform.ps1`(경고 0 · 오류 0, Core · Shell · README 셋 바뀜). MergeHub Debug 다시 빌드 → 두 파일 해시가 배포본과 같음, 배포본으로 같은 재현 → 창 360~2560 · 종료 2560 · Accept 2526 · 넘침 0.
- 배율이 **서로 다른** 모니터 사이를 옮길 때는 이 PC(두 모니터 모두 150%)에서 재현할 수 없다. 대형 모니터에서 같은 증상이 또 나면 그 모니터의 해상도 · 배율 · 창 모드였는지를 알려 받는다(창 자리 파일 `ShellWindowPlacement.json` 한 벌이면 그대로 재현할 수 있다).
## F-8. 저장한 뒤에도 되돌리기 · 푼 파일을 다시 충돌로 (2026-09-22, 완료)
사용자 결정: "되돌리면 다시 충돌로", "커밋은 두고 화면만 되돌림", [전체 되돌리기] 는 "파일을 연 시점", 병합 중 [Save] 는 지금 흐름 그대로 두고 **Git 탭 충돌 목록에 [다시 충돌로]**("1번으로 — UNDO, REDO 기능이 중요해"). [다시 충돌로] 는 LibGit2Sharp 에 길이 없어 **이 단추만 git.exe 를 부름**(사용자 결정).
### 고치기 전 (재현 `f8-probe.ps1` · `f8-git.ps1`)
| 경우 | 고치기 전 |
|---|---|
| 두 파일 비교: 반영 → [저장] | Undo · Redo **꺼짐** — 저장하는 네 길(저장 · 저장 후 커밋 · 해결로 저장 · 파일 바꿀 때 "저장하고 계속")이 모두 목록을 비웠다 |
| Git: 반영 → [Save & Commit] | Undo 꺼짐 — 저장 때 비우고, 이어지는 새로고침이 **새 HEAD 기준으로 줄을 다시 세우며** 또 비움(9차 A-2 로 BASE 가 새 HEAD 를 따라감) |
| 편집기에 글자를 치거나 비교 규칙(Aa · 공백 · 필터)을 바꿈 | 재정렬이 목록을 통째로 비워 그 전 Accept 들을 Undo 할 수 없었다 |
| "저장할 것 있음" | 되돌리기 목록이 비었는지로 판단 — 저장 뒤 목록을 두면 저장 단추가 켜진 채로 남으므로 비울 수밖에 없었다 |
### 고친 것
- `SaveBaseline`(새) — 편집 칸의 두 기준 글: **파일을 연 시점**(전체 되돌리기가 가는 곳 · 직접 고친 줄을 가리는 기준)과 **마지막 저장**. "저장할 것 있음" = 지금 글 ≠ 마지막 저장(또는 스타일 맞춤 대기). 저장 뒤 Undo 하면 "있음", Redo 로 저장 지점에 오면 "없음".
- 저장 · 저장 후 커밋 · "저장하고 계속" 은 목록을 **두고** 저장 지점만 옮긴다(`MarkSaved`). 병합 중 [Save](해결로 저장)는 다음 충돌로 넘어가므로 예전처럼 비움.
- **글 단위 이력** — 줄 단위 목록은 줄 객체를 가리켜 줄을 다시 세우면 못 쓴다. 다시 세우기 **전에** 각 지점의 전문을 적어 옮긴다(`FlattenHistory` — 한 묶음이 한 걸음, 덩어리 · 메서드 반영은 11차 F-2 대로 한 번). 쓰는 곳: 커밋 뒤 같은 파일 · 같은 글을 다시 읽을 때, 비교 규칙을 바꿀 때, 편집기에서 친 뒤 재정렬할 때. 글 단위 걸음은 줄을 다시 세워 되돌린다. 걸음마다 파일 전문 한 벌이라 한쪽에 300걸음까지 둔다.
- **편집기에서 친 것도 한 걸음** — 치는 동안 화면을 바로 보이려고 줄 객체를 먼저 고치는데(되돌리기 목록 밖) 그 줄의 원래 글을 적어 두었다가 재정렬 때 한 걸음으로 올린다.
- 커밋 뒤 새로고침은 한 번에 **두 번 읽고 그 사이 화면을 잠깐 비운다**(고른 커밋 바뀜 · 목록 다시 세우기). 한 번 쓰고 버리는 표시로 했더니 두 번째가 비웠다 → 조건만 본다(같은 파일 · 두 칸 · 충돌 아님 · 읽은 글이 화면 글과 같음 — 파일의 CRLF · 끝 줄바꿈은 같은 글로 봄). 화면을 비울 때는 이력 · 두 기준을 맡겨 두고, 같은 파일 · 같은 글을 다시 실으면 되찾는다.
- [전체 되돌리기] — 저장한 뒤에도 켜짐(연 시점과 다르면), 파일을 연 시점으로. 이미 저장했으면 확인 창이 "저장한 파일 · 커밋은 그대로, 화면만 돌아감 — 넣으려면 다시 저장, 되돌리기 목록은 비워짐" 이라고 알린다.
- 요약 줄 — 저장한 지점이면 `· 저장됨` 을 붙인다(`남은 차이 1줄 · 편집 1건 · 되돌리기 0건 · 저장됨`).
- **Git 탭 충돌 목록 아래 "이번 병합에서 푼 파일 N개" + [다시 충돌로]** — 병합 중이고 푼 파일이 있을 때만 보인다. 목록 = 처음 충돌 목록(`.git/MERGEHUB_CONFLICTS`) ∩ git 의 해결 기록(REUC) − 지금 충돌(`MergeConflictRecord.Resolved` — REUC 만 보면 저절로 병합된 파일도 들어온다, 6차 C-1). 누르면 먼저 묻고(저장해 둔 해결 내용이 충돌 표시 글로 바뀜), `git checkout -m -- 파일` 로 세 벌 · 충돌 표시를 되살린 뒤 저장소를 다시 열고(열어 둔 객체는 인덱스를 다시 안 읽어 충돌이 0 으로 보였다 — `UnresolveProbe` 로 확인), 해결 기록(8차 F-1)에서 그 파일을 뺀다(`MergeResolutionLog.Forget`). git.exe 가 없으면 "Git 이 설치되어 PATH 에 있어야" 로 알린다.
### 확인
| 경우 | 결과 |
|---|---|
| 두 파일: 반영 → 저장 → Undo → Redo (`f8-probe.ps1`) | 저장 뒤 Undo 켜짐 · 저장 꺼짐 · `저장됨` → Undo: 저장 켜짐 · 파일은 그대로 → Redo: 저장 꺼짐 · `저장됨`. 전체 되돌리기 켜짐 |
| Git: 반영 → Save & Commit → Undo → Redo (`f8-git.ps1`) | 커밋됨(워킹 변경 0) · 요약 `차이 없음 · 편집 1건 · 되돌리기 0건 · 저장됨` · Undo 켜짐 → Undo: 새 HEAD 대비 `남은 차이 1줄` · 커밋 · 파일 그대로 → Redo: `차이 없음 · 저장됨` |
| 반영 → 편집기에 침 → 규칙(Aa) 바꿈 → Undo ×3 → Redo ×3 (`f8-typing.ps1`) | 친 것이 한 걸음(편집 2건), 규칙을 바꿔도 이력 그대로, Undo 가 친 것 → Accept 차례로, Redo 가 거꾸로 |
| 병합: Accept Theirs 로 푼 파일 · 3분할 [Save] 로 푼 파일을 [다시 충돌로] (`f8-reopen.ps1`, C1Repo) | 푼 파일 목록에 나옴 → 확인 창 → git `UU` · 충돌 표시 줄 되살아남 · 충돌 23 → 24 · 충돌 목록에 다시 · 푼 파일 목록에서 빠짐 · 해결 기록에서 빠짐. 병합 직후 머지 주석만 달라 저절로 푼 2개도 목록에 나옴(MergeHub 가 푼 것이라 맞음) |
| 병합 한 길 회귀(`e11-e1-merge.ps1`) | 충돌 24 · 풀기 26 · 누른 Take 18 = 로그 18 · 병합 커밋 부모 둘 · 줄끝/BOM/인코딩 다른 파일 0 · 충돌 표식 0 · 305초 |
- 하네스 MergeSaveTest **+21건(296 → 317)** — 두 기준 글(연 직후 · 고침 · 저장 · 저장 뒤 Undo/Redo · 전체 되돌리기 대상 · 스타일 대기 · 다시 열기 · null), 줄바꿈만 다른 글, 맡겨 둔 기준 되찾기, 푼 파일 목록(지금 충돌 · 저절로 병합 · 해결 기록 없는 파일 · 겹친 이름), 해결 기록에서 한 파일 빼기. 먼저 넣고 시작. Debug 빌드 경고 0 · 오류 0.
- 시험 도구: `d7lib` 의 머지 주석 줄 패턴이 F-1 뒤 주석 모양(날짜 뒤 출처)을 못 알아봐 "기준 대조 → 다름" 이 F-2 부터 나고 있었다 — 패턴을 고침(앱 문제 아님).
### 남은 것
- 세 칸(3-Way) 화면에서 규칙을 바꾸거나 치면 이력은 지키지만, 커밋 뒤 다시 읽기처럼 **파일에서 새로 읽는** 경우는 두 칸일 때만 지킨다(충돌 · 3-Way 는 저장이 곧 다음 충돌로 넘어감).
- [다시 충돌로] 는 MergeHub 로 시작한 병합만(처음 충돌 목록이 있어야). rebase · cherry-pick · revert 중에는 보이지 않는다.
## F-9. 저장할 때 백업 — 원본을 남기기 (2026-09-22, 완료)
사용자 결정: 방법은 **설정 "저장할 때 백업"**(기본 끔, [다른 이름으로 저장] 은 두지 않음), 자리는 **한곳에 모아 날짜 이름**, **저절로 지우지 않음**, 범위는 **모든 저장**(병합 중 Save 포함).
### 고치기 전
- 저장은 늘 원본을 덮어썼다. `.bak` 도 다른 이름으로 저장도 없었다. 덮어쓰는 자리 여섯: 두 파일 비교 [저장](`WritePlainFile`) · mergetool 저장(같은 함수) · Git [Save & Commit] · 병합 중 [Save] · Git 탭 파일 단위 Accept Mine/Theirs/Both · 폴더 비교 복사(파일 · 폴더).
### 고친 것
- 설정 `저장 > 저장할 때 백업` (`MergeHubSettings.BackupBeforeSave`, 기본 끔).
- `FileBackup`(새) — 쓰기 직전 지금 파일을 **바이트 그대로** 복사한다. 자리 `{설정 폴더}\Backup\MergeHub\2026-09-22\D\원래\폴더\A.20260922-210501.cs` — 날짜 폴더 아래 드라이브와 원래 폴더 모양 그대로, 이름에 날짜-시각(확장자는 끝이라 두 번 누르면 원래 프로그램으로 열림). 같은 초에 또 저장하면 `-2`. 네트워크 경로는 `UNC\서버\공유`. 240자를 넘으면 `_긴경로\폴더해시8_A.날짜-시각.cs`. 날짜 폴더마다 `목록.txt`(시각 · 원본 · 백업). 새로 만드는 저장(파일이 없음)은 백업할 것이 없다.
- 백업을 **못 만들면 저장하지 않는다** — "백업을 만들지 못해 저장하지 않았습니다 - 까닭 (설정을 끄면 백업 없이 저장)". 백업을 약속한 설정이라 원본을 그냥 덮지 않는다.
- 여섯 자리에 모두 붙임. Git 이 쓰는 길(병합 중 Save · 파일 단위 Accept · 머지 주석만 다른 충돌 자동 풀기)은 `IGitService.BeforeOverwrite` 로, 폴더 복사는 `FileOps.CopyDirectory` 의 덮어쓰기 직전 알림으로.
- 로그에 `백업: 원본 → 백업` 한 줄. 파일 비교 ACTIONS 저장 단추 밑 **[백업 폴더 열기]**(켰을 때만).
- 원본 폴더 · Git 저장소 밖에 모이므로 폴더 비교 · 변경분 추출 · git status 에 섞이지 않는다(따로 빼는 규칙이 필요 없음).
### 확인 (`f9-ui.ps1`, 설정을 켜고)
| 경우 | 결과 |
|---|---|
| 두 파일 비교 반영 → [저장] | 백업 1개 = 저장 전 파일(SHA-256 같음), 원본은 바뀜, 자리 `…\2026-09-22\C\Users\…\mergehub-f9\right.20260922-210729.cs`, 원본 폴더에는 `left.cs · right.cs` 뿐 |
| 저장 → Undo(F-8) → 다시 [저장] | 백업 2개, 두 번째 = 첫 저장 뒤 파일. `목록.txt` 두 줄 |
| 병합 직후 | 머지 주석만 다른 충돌 자동 풀기 2개도 백업 |
| Git 탭 Accept Theirs | 백업 = 충돌 표시 든 파일(해시 같음, `<<<<<<<` 줄 있음) |
| 3분할 [Save] | 백업 = 저장 전 파일 |
| 저장소 안 새 파일 | untracked 0 |
| 로그 | `백업: …\Motion\Unit0.cs → {백업}\2026-09-22\…\Unit0.20260922-211310.cs` |
| 끈 채(기본) | F-1~F-8 시험 내내 저장을 많이 했지만 백업 폴더가 생기지 않았다 · 하네스로도 확인 |
- 하네스 MergeSaveTest **+18건(317 → 335)** — 이름 규칙(날짜-시각 · 같은 초 `-2` · 점 파일 · 확장자 없음), 자리(드라이브 · UNC · 긴 경로), 끄면 아무것도 안 함, 켜면 바이트 그대로 · 원본 그대로, 없는 파일 · 빈 경로, 목록.txt, 알림, 원본 폴더에 안 생김, 못 만들면 던짐. FolderTest **+7건(81 → 88)** — 폴더 복사가 덮기 직전에만 알림 · 새 파일은 안 알림 · 알림이 던지면 안 덮음. Debug 빌드 경고 0 · 오류 0.
- 시험이 만든 백업 폴더(`D:\UbisamConfig\Backup`, 시험 전에 없었음)는 시험 끝에 치웠다.
### 남은 것
- 백업은 저절로 지워지지 않는다(사용자 결정) — 오래 쓰면 쌓인다. [백업 폴더 열기] 에서 날짜 폴더째 지우면 된다(매뉴얼에 적는다).
- 폴더 비교의 [삭제] 는 저장이 아니라 백업하지 않는다(지금처럼 먼저 묻는다).
## F-10. 칸마다 자기 줄 · 바뀐 덩어리를 잇는 선 (Araxis 식) (2026-09-22, 완료)
사용자 요청: 지시서의 "옮긴 덩어리를 선으로" 에서 넓혀 "Araxis 머지툴처럼 라인 바뀐 위치 UI 표시를 중점으로"(Araxis 화면 두 장). 물어 정한 것: **지금 보기를 Araxis 식으로 바꿈**(두 보기를 두지 않음), **3분할에서도 잇는 선**, 3분할은 **위 세 칸(LOCAL(Mine) | BASE | REMOTE) 사이만 잇고 MERGED 는 따라 스크롤**.
### 고치기 전
- 모든 칸이 **정렬 행 하나**(`Lines`)를 같은 번호로 보이고, 한쪽에 없는 줄 자리에는 빈 줄(필러)을 채워 같은 줄을 같은 눈높이에 두었다. 편집기에도 빈 줄이 들어가 저장 · 재정렬 전에 걷어냈다(`StripFillers`).
- 자리 옮김은 줄 색과 "N행으로 옮김" 글로만 알렸다 - 어디로 갔는지는 두 칸을 눈으로 따라가야 했다.
### 고친 것
- **속은 그대로, 보이는 것만** — 정렬 행 `Lines` 는 그대로 두고(차이 · 반영 · 되돌리기 · 차이 이동 · 머지 주석 · 미니맵이 그대로 쓴다) 칸마다 **그 칸에 있는 줄만** 걸러 보인다(`ListCollectionView` 필터, LOCAL 은 줄이 생기고 사라지면 바로 따라감). 칸마다 자기 행 높이(워드랩에서 서로 맞추지 않음).
- **편집기에서 빈 줄을 없앰** — 편집기 글이 곧 파일 글. 편집기 줄 ↔ 정렬 행은 `EditorSnapshot.Rows` 표로 옮긴다(캐럿 · 친 줄 바로 보이기 · 차이 이동). `StripFillers` 는 쓸 곳이 없어져 지웠다(매 재정렬마다 돌던 줄 정렬 계산도 없어짐). 상태 줄 `N행` 이 이제 파일 줄 번호 그대로다.
- **스크롤 맞추기** — 한 칸을 굴리면 나머지가 **가운데 기준**으로 따라온다(`PaneMapping`). 두 칸에 함께 있는 줄의 가운데를 짝점으로 삼아 그 사이는 비율로 잇는다 - 같은 줄이 이어지는 곳은 1:1, 한쪽에만 있는 덩어리를 지날 때는 한쪽이 경계에 멈춰 있고 다른 쪽만 지나간다(한 번에 튀지 않음).
- **잇는 선** — 칸 위에 겹친 투명한 그림판(`PaneLinkOverlay`, 마우스 통과)에 바뀐 덩어리마다 두 칸 안의 위 · 아래 경계선과 틈의 사다리꼴을 그린다. 한쪽에 없는 덩어리는 그 칸에 굵은 선 하나(들어갈/빠진 자리). 색: 추가 파랑 · 삭제 빨강 · 수정 주황 · 충돌 분홍 · 상대만 바꿈(REMOTE) · 직접 수정/반영. 자리 옮김은 떠난 자리(BASE)에서 옮겨 간 자리(LOCAL)까지 청록 점선 곡선(두 칸 비교). 칸 사이 틈 4 → 48px(끌면 구분선처럼 폭이 바뀜).
- 칸 좌표는 **그려진 줄이면 그 자리**, 화면 밖이면 보이는 첫 줄에서 모델 높이로 어림(`PaneLinks.Tops`).
- **지운 줄 되살리기 [>]** — 예전에는 LOCAL 의 빈 줄에 달려 있었다. 이제 BASE 칸 그 줄 끝에 단다(Araxis 처럼).
- **차이 이동은 그 자리를 가운데로** — 예전엔 캐럿 줄을 위쪽에 두었는데, LOCAL 에서 지운 덩어리로 가면 지운 줄이 화면 위로 벗어났다. 이제 그 줄 윗변(= 지운 자리)을 가운데로 → 지운 줄들이 BASE 칸 가운데에 온다.
- 3분할: 위 세 칸 사이 두 틈에 같은 선. 충돌 줄은 칸마다 줄 번호가 달라도(Mine 36 · BASE 31 · REMOTE 31) 같은 높이로 이어진다.
- 계산은 화면 없이 검사: `PaneLinks`(윗변 · 덩어리) · `PaneMapping`(가운데 짝점 · 비율) · `EditorLayout.LineOfRow/RowOfLine`.
### 확인 (화면)
| 경우 | 결과 |
|---|---|
| 메서드 옮김 · 두 줄 추가 · 한 줄 수정 · 두 줄 삭제 (`f10-shot.ps1`) | 칸마다 자기 줄(BASE 49 · LOCAL 49), 추가 BASE 18 경계 → LOCAL 18~19, 삭제 BASE 25~32 → LOCAL 26 경계, 수정 37 ↔ 31, 옮긴 Gamma 청록 점선 |
| 400줄 · 60줄 추가 · 40줄 삭제 스크롤 (`f10-sync.ps1`) | 가운데 줄: 0% 25/25 · 20% 99/99 · 추가 지날 때 BASE 101 멈춤 · LOCAL 새 줄 36 · 55% 169/169 · 65% 206/206. BASE 를 굴려 삭제를 지날 때 LOCAL 250 멈춤 |
| 다음 차이 → 삭제 덩어리 | BASE 251~290 가 칸 가운데, LOCAL 310/311 사이 빨간 선 |
| BASE 칸 [>] (지운 줄 되살리기) · Undo | 편집기 420 → 422줄(줄 + 머지 주석) → Undo 420 |
| 3분할 병합 충돌(C1Repo · 새 시험 저장소) | 세 칸 사이 선, 내가 넣은 5줄 → BASE 경계로 비스듬히(파랑), 상대가 지운 5줄 BASE 40~44 → REMOTE 경계(빨강), MERGED 따라 스크롤 |
| 워드랩 켬 | 세 줄로 접힌 LOCAL 31 ↔ BASE 37 사다리꼴이 덩어리 위아래에 붙음 |
| 회귀 | 병합 한 길(e11-e1-merge) 충돌 24 → 26 풀기 · Take 18 = 로그 18 · 기준 대조 같음 · 303초. F-8 저장 · 입력/규칙 · Save & Commit 되돌리기 모두 전과 같음 |
- 하네스 **PaneLinkTest 새로 17건**(윗변 · 덩어리 · 같은 칸 1:1 · 추가/삭제 사이 비율 · 단조 · 워드랩 · 공통 줄 없음 · 빈 칸), **EditorTest 14 → 18**(빈 줄 없이 LOCAL 줄만 · 행 ↔ 줄 표 — 옛 "빈 줄 넣고 걷어내기" 검사는 그 동작이 없어져 새 규칙으로 바꿈). MergeSaveTest · AlignTest · RulesTest · MemberTest · MoveTest · NoteTest 그대로 통과. Debug 빌드 경고 0 · 오류 0.
- 처음 그렸을 때 선이 안 보였다(그림판이 칸의 조상이 아니라 좌표 변환이 예외 → `TransformToVisual`). 스크롤 직후 LOCAL 표시층이 늦게 구르는 사이 옛 자리로 선이 한 번 그려졌다 → 어느 칸이 굴러도 다시 그림. 3분할 LOCAL(Mine)|BASE 에서 "내가 넣은 줄" 이 삭제 색으로 → BASE 가 오른쪽인 짝의 방향을 따름.
### 남은 것
- 옮김 점선은 두 칸 비교만(3분할은 옮김을 찾지 않는다).
- 워드랩을 켜면 화면 밖 줄의 높이는 어림이라, 긴 파일을 멀리 건너뛴 직후에는 가운데 맞춤이 몇 줄 어긋날 수 있다(그려지면 바로잡힘 — 예전 방식에도 있던 한계).
- 3분할 MERGED 와 위 칸 사이 선은 두지 않았다(사용자 결정).
## F-11. Git 계정 · URL · 토큰이 한 번 쓰면 저장되게 (2026-09-23, 완료)
사용자 결정: 토큰은 **윈도 자격 증명 관리자에 저장**(평문 금지 — 지시서 조건), Git 탭에 적은 **원격 URL · 계정은 적으면 설정에 바로 남김**.
### 고치기 전 (화면으로 확인, `f11-check.ps1` · 가짜 서버 `f11-server.ps1`)
| 확인 | 결과 |
|---|---|
| 계정 · 토큰을 넣고 Fetch | 서버가 `tester:토큰` 을 받았다 — 보내는 것은 정상 |
| 설정 파일 | Git 탭에 적은 URL · 계정 · 토큰이 **아무것도 저장되지 않음**(설정 탭 "사내 Gitea" 값만 켤 때 읽음) |
| 껐다 켬 | 원격 URL · 계정 · 토큰 칸이 **모두 빔** |
| 그 상태로 Fetch | 인증 없이 보내고 실패. 로그는 영어로 `remote authentication required but no callback set` 뿐 |
| 설정 폴더 전체에서 토큰 글자 | 0곳(저장하지 않으니 당연) |
### 고친 것
- `GitCredentialStore`(새) — 윈도 자격 증명 관리자(advapi32 `CredWrite`/`CredRead`/`CredDelete`, 사용자 계정 키로 암호화). 이름은 **서버마다 하나** `MergeHub:git:스킴://호스트[:포트]`(`GitCredentialKey.For`) — 같은 서버의 다른 저장소는 한 번만 넣으면 된다. SSH(`git@…`) · 로컬 경로 원격은 토큰을 쓰지 않아 저장하지 않는다. 못 읽거나 못 쓰면 조용히 넘어간다(토큰은 덤이다).
- **저장은 된 것만** — fetch · pull · push · 클론이 **성공했을 때** 그때의 계정 · 토큰을 맡긴다. 켤 때와 원격 주소를 바꿀 때 그 서버의 토큰을 가져와 토큰 칸에 채운다(PasswordBox 는 바인딩이 안 돼 화면 코드가 넣는다).
- 다른 서버로 주소를 바꾸면 **앞 서버에서 가져온 토큰은 치운다** — 남의 서버로 보내지 않는다. 사람이 직접 적은 토큰은 건드리지 않는다.
- **[토큰 지우기]** 단추와 "저장됨" 표시(맡겨 둔 것이 있을 때만). PC 를 넘기거나 토큰을 새로 받았을 때 쓴다.
- Git 탭 원격 칸의 **URL · 계정을 설정에 남긴다**(글자마다 쓰지 않게 0.8초 모았다가 한 번).
- 실패한 영어 말을 사람 말로(`GitErrors.Friendly`) — "원격이 계정 · 토큰을 요구합니다. Git 탭 원격 칸에 계정과 토큰을 넣고 다시 해 보세요." · "계정 또는 토큰이 맞지 않습니다…". 원래 영어 말은 괄호로 함께 남긴다.
### 확인 (`f11-verify.ps1`, 127.0.0.1 가짜 사내 서버 — 인증이 없거나 토큰이 틀리면 401, 맞으면 `git http-backend` 로 진짜 fetch)
| 경우 | 결과 |
|---|---|
| 계정 · 토큰 넣고 Fetch | `fetch 완료` · 서버가 받은 인증 `tester:f11-secret-…` · 자격 증명 관리자에 저장됨 · 화면에 "저장됨" |
| 설정 파일 | URL 있음 · 계정 있음 · **토큰 글자 없음** |
| 껐다 켬 | URL · 계정 칸이 채워져 있고 "저장됨" 표시 |
| 아무것도 안 넣고 Fetch | `fetch 완료` · 서버가 같은 토큰을 받음 — **다시 묻지 않는다** |
| [토큰 지우기] | 자격 증명 관리자에서 사라짐 · "저장됨" 꺼짐 |
| 그 뒤 Fetch | `[fetch] 실패: 계정 또는 토큰이 맞지 않습니다. Git 탭 원격 칸에서 다시 넣어 주세요. (too many redirects or authentication replays)` |
| 시험 뒷정리 | 시험이 만든 자격 증명 삭제 확인 |
- 하네스 MergeSaveTest **+15건(335 → 350)** — 이름 규칙(서버마다 하나 · 저장소가 달라도 같음 · 대소문자 · 포트 · SSH · 로컬 경로 · 빈 주소 · 말이 안 되는 주소)과 실패한 말 옮기기(토큰 없음 · 틀림 · 401 · 인증과 무관 · 빈 말). Debug 빌드 경고 0 · 오류 0.
- 회귀: 저장 후 커밋 되돌리기(f8-git) · 병합 [다시 충돌로](f8-reopen) 그대로.
### 남은 것
- 사내 Gitea 가 실제로 서면 그 서버로 한 번 확인한다(지금은 127.0.0.1 가짜 서버).
- 자격 증명은 **이 PC · 이 윈도 계정**에만 있다(DPAPI). 다른 PC 에서는 한 번 더 넣어야 한다.
## F-12. C# 코드 스타일 맞춤의 기본값을 AURORA 로 (2026-09-18, 완료)
사용자: "기본값은 원본형태를 따르고 C#인 경우에 AURORA 스타일로 하자" → 물어서 정한 것은 **제품 기본값 AURORA · C# 파일만 · 저장 동작은 지금 그대로**(켜져 있으면 저장도 그 스타일로 쓴다).
@ -1001,54 +761,3 @@ libgit2 의 다듬기(`PrettifyMessages`)를 켜면 **손대지 않은 뒤 커
- 하네스 22벌 **1,159건** 통과(1,147 → +12) · 빌드 경고 0 · 오류 0.
- 이미 설정 파일이 있는 PC 는 그 값을 그대로 쓴다. 이번 변경은 **설정이 없는 첫 실행**에만 닿는다.
## G. 11차 뒤 추가 (2026-09-23 사용자 증상)
### G-1. 배율이 크게 다른 새 모니터에서 최대화하면 오른쪽이 넘침 (2026-09-23, 완료 — 플랫폼 배포함)
사용자 증상: "지금 모니터 새로운거 열었는데 최대화하면 화면이 짤려 오른쪽으로 넘쳐".
F-7 은 **창 모드로 기억한 자리**를 고친 것이라 최대화는 손대지 않았다. 이번은 최대화 자체다.
### 잰 것 (쓰던 창은 건드리지 않고 읽기만 → 시험용 창으로 되풀이)
| 무엇 | 값 |
|---|---|
| 주 모니터 | 2560×1600 · 150% (작업 영역 2560×1528) |
| 새 모니터 | 3840×2160 · **300%** (작업 영역 3840×2016) |
| 새 모니터에서 최대화한 창 | **5120×2186** — 오른쪽 **1280px** · 아래 170px 모니터 밖 |
| 모니터 밖으로 나간 화면 요소 | 25개(시계 · 모듈 · 최근 칸 등) |
| 주 모니터에서 최대화 | 2560×1600 — 넘침 0(정상) |
- 처음 잰 값이 뒤섞여 보였다(주 150% · 새 300%) — 재는 스크립트를 **배율을 아는 상태(PerMonitorV2)** 로 돌려야 실제 픽셀이 나온다.
### 까닭
- 실행 중인 셸 창의 DPI 인식을 물어보니 **모니터별 V1**(PerMonitorV2 아님). 매니페스트에는 `PerMonitorV2, PerMonitor` 가 적혀 있고 exe 에도 박혀 있지만, .NET Framework WPF 가 V1 로 올린다.
- V1 창에서 `WM_GETMINMAXINFO` 에 모니터 크기를 줘도 윈도가 더 크게 잡는다. 값을 바꿔 가며 재 보니 **작은 값은 그대로 따르고**(1920×1080 · 2560×1440), 어느 한도를 넘으면 가로가 1280px 더 붙고 세로는 2186 에 고정됐다(3000×2000 → 4280×2186, 3840×2160 → 5120×2186).
- 윈도 기본값에 맡겨도(우리가 손대지 않아도) 3874×2186 으로 테두리만큼 넘쳤다.
### 고친 것 (플랫폼)
- `ShellWindow.FitMaximizedToMonitor` — 최대화된 창이 그 모니터와 다르면 **한 번 다시 놓는다**. 상태 바뀜 · 배율 바뀜 · 켤 때부터 최대화인 세 자리에 건다.
- 판단은 화면 없이 검사하는 `WindowPlacementMath.NeedsMaximizeFix`(자리 · 크기가 모니터와 같으면 하지 않는다 — 되풀이 방지).
- 최대화가 작업 표시줄까지 덮는 정책(7차 E-4 사용자 결정)은 그대로다.
### 확인
| 경우 | 고치기 전 | 고친 뒤 |
|---|---|---|
| 새 모니터(300%) 최대화 | 5120×2186 · 오른쪽 1280px 넘침 | **3840×2160 · 넘침 0** |
| 되풀이 최대화 · 복원 | 같은 값으로 계속 넘침 | 3840×2160 그대로 |
| 주 모니터(150%) 최대화 | 2560×1600 | 2560×1600 (달라지지 않음) |
| 두 모니터 왕복 | — | 각 모니터에 맞음 |
| 최대화 때 모니터 밖 화면 요소 | 25개 | **0개** |
- PlacementTest **+6(45 → 51건)**. 플랫폼 Debug 빌드 경고 0 · 오류 0.
- 배포: 사용자 "배포해 줘" 뒤 `publish-platform.ps1`(2026-09-23, 경고 0 · 오류 0 — 42개 가운데 `UbisamBase.Core.dll` · `UbisamBase.Shell.exe` · `README.txt` 셋). MergeHub Debug 를 다시 빌드해 `Core.dll` 해시가 배포본과 같음을 확인하고, 배포본으로 같은 시험을 다시 돌려 넘침 0.
### 남은 것
- 근본은 **셸이 모니터별 V1** 이라는 점이다. V2 로 올리려면 WPF 보다 먼저 프로세스 DPI 인식을 정해야 하는데(.NET Framework WPF 는 V1 전제), 모든 소비 프로그램의 창틀 · 그리기에 영향이 커서 이번에는 하지 않았다. 다른 배율 관련 증상이 또 나오면 그때 함께 본다.
- 300% 모니터에서는 **창 모드 최소 크기가 곧 모니터 폭**이다(최소 1280 DIP = 3840px). 그 화면의 논리 폭이 1280 DIP 뿐이라 그런 것으로, 창을 그보다 좁게 만들 수는 없다.

View File

@ -1,415 +0,0 @@
# 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,23 +8,6 @@
| 날짜 | 항목 | 커밋 |
|---|---|---|
| 2026-09-28 | **12차 E-2** — 파일 비교가 짝 여러 개를 동시에 든다(문서 탭). 지금까지는 한 짝만 들어서 짝을 바꾸면 "저장하지 않은 편집이 있습니다" 를 묻고 버리면 고친 글이 사라졌다(재현: 16줄 → 15줄). 이제 떠 있던 탭이 그 글을 맡아 두므로 **묻지 않는다**. 탭마다 `HomeViewModel` 을 따로 만들지 않고(GitWorkspace 의 한 자리짜리 약속 때문) 화면은 하나로 두고 탭이 **파일에 없는 것만** 맡는다 — 고친 글 · 왼쪽 글(B-1) · 되돌리기 이력 · 두 기준점 · 캐럿 · 스크롤 · 저장 형식. 탭 이름은 파일 이름(다르면 `왼 ↔ 오`, 겹치면 위 폴더를 갈릴 때까지 덧붙임), 저장 안 한 탭은 `•`. 탭 줄은 둘 이상일 때만 보인다. `◀ 이전 · 다음 ▶` 은 지금 탭의 짝만 바꾸고(탭이 늘지 않음), 폴더 비교에서 두 번 누르면 새 탭(사용자 결정). Ctrl+Tab · Ctrl+Shift+Tab · Ctrl+W · 가운데 단추 닫기. 탭이 사라져도 고친 글은 **보관함**(짝 50개)에 남아 다시 열면 되살아난다 — E-1 은 이 보관함을 파일로 옮겨 적으면 된다. 원본이 그 사이 바뀌면 되살리고 알린다("연 시점"은 새로 읽은 글로 둔다). 탭 줄은 ItemsControl 이 아니라 ListBox 다(ItemsControl 은 UIA 에 컨트롤로 안 보여 화면 시험이 못 읽는다). 켤 때 저절로 열리는 짝만 탭이 없어 편집이 사라지던 것을 화면에서 찾아 `EnsureTabForCurrentPair` 로 고쳤다. 켜 놓고 눈으로 보다 두 가지를 더 고쳤다(사용자가 고름) — 중괄호 맞춤으로만 바뀐 것에는 점을 붙이지 않고(묻는 자리와 같은 기준), 탭 이름은 양쪽이 같은 부분을 한 번만 적는다(`WeldController_기준.cs ↔ WeldController_내작...` → `WeldController_기준 ↔ 내작업.cs`, 421 → 288px). 뒤쪽은 확장자일 때만 접는다 — 글자 단위로 세면 `a-lef ↔ right.cs` 처럼 낱말 가운데서 잘린다(하네스에서 걸렸다). 하네스 `DocTabTest` 106건, 화면 34건 · 회귀 19건 통과, 전체 27벌 1,787건 · 실패 0. **커밋 안 함**(사용자 지시) | 아래 |
| 2026-09-28 | **시험 도구를 런처에 맞춤** — 새 플랫폼은 `UbisamBase.Launcher.exe` 가 셸을 같은 프로세스에 올려 창 주인 프로세스 이름이 바뀌었고, 예전 이름만 찾던 화면 시험 도구가 전부 "창이 안 뜬다" 로 멈춰 있었다. `a2lib.ps1` 에 `Shell-Procs` · `Shell-Proc` · `Stop-App` 을 두고 `Start-App` · `Get-Win` 이 그것을 쓰게 했다(런처가 제목 없는 진행 창을 먼저 띄우는 경우를 대비해 제목 있는 창을 먼저 고름). 스크립트 76개 · 156곳을 함께 고침. 문법 187개 통과, 끄기 → 켜기 → 창 찾기 → 네 탭 → 단추 → 끄기까지 실제로 돌았고 설정 · 창 자리 네 파일 해시 그대로 되돌림. **제품 코드 변경 없음** | 없음 |
| 2026-09-28 | **12차 C-2** — 플랫폼 하단 탭 줄을 한 단계 작게(항목 74 → 61 DIP: 여백 13/14→9/10 · 아이콘 22→20 · 아이콘 아래 틈 7→5 · 글자 13.5→12.5). 실측 133 → 109px(−18%)이고 그만큼 콘텐츠가 늘어 파일 비교 코드 칸이 47 → 48줄. 라벨 잘림 · 선택 밑줄 · 탭 접기 띠 · 오른쪽 동작 항목 모두 그대로. 플랫폼 쪽이라 모든 사내 프로그램에 함께 간다. 처음에는 배포본을 백업해 잠깐 바꿔치기해 시험하고 되돌렸으나, 그 뒤 **새 플랫폼을 빌드하면서 함께 배포됐다** — 2026-09-28 배포본 `UbisamBase.Core.dll` 해시 일치 · 화면 실측 111px(항목 109px)로 확인. **커밋 안 함**(사용자 지시) | 없음 |
| 2026-09-27 | **12차 B-3** — 칸 아래에 그 칸의 요약(`6줄 · 추가 1 · 삭제 1 · 수정 1`)을, 칸 사이 틈 아래에 진행(`3 / 7` · `남은 2`)을 적는다. 예전에는 창 맨 아래 한 곳에 파일 전체 요약만 있었다. 칸을 기준으로 읽는 수라 두 칸의 추가 · 삭제가 서로 거울이 되고, 무시 규칙 · 머지 주석 줄은 차이로 세지 않되 줄 수에는 넣는다. 3분할은 네 칸 모두 BASE(공통 조상)와 견주고 BASE 칸은 `기준` 이라고만 적는다. 세는 규칙은 `PaneSummary` 한 곳(5만 줄 × 두 칸 × 네 번 22ms). 반영 · 되돌리기 · 저장 · 규칙 바꾸기마다 따라감을 화면에서 확인. SummaryTest 25건 새로. **커밋 안 함**(사용자 지시) | 없음 |
| 2026-09-27 | **12차 B-2** — 프로그램 정보 창([단축키] 옆 [정보]). 이름 · 버전 · 빌드 날짜 · 만든 사람 · 사내 문의처와 [정보 복사](결함 알릴 때 그대로 붙여 넣는 용). 관리 목적이라 **실제 실행 파일 값**을 읽는다 - 버전은 csproj `<Version>`(0.12.0), 빌드 날짜는 빌드할 때 어셈블리에 박은 값(요즘 SDK 는 결정적 빌드라 PE 머리의 시각 자리가 내용 해시다 - 못 찾으면 PE 시각 → 파일 수정 시각 순). 만든 사람 · 문의처는 설정 `프로그램 정보` 에서 적고 비우면 회사 이름 · 안내 글. 창을 닫아도 화면 그대로 확인. AboutTest 28건 새로. **커밋 안 함**(사용자 지시) | 없음 |
| 2026-09-27 | **12차 B-1** — 두 파일 비교에서 **양쪽으로** 반영 · 저장(사용자 결정: 두 파일 비교에서만. Git 모드 왼쪽은 지난 커밋이라 제외). BASE 칸 여백에 `[<]`, 왼쪽에 없는 줄은 LOCAL 여백의 `[<]` 로 왼쪽에 넣는다. ACTIONS 는 `◀ 왼쪽 저장` · `오른쪽 저장 ▶` 로 나뉘고 고친 것이 없는 쪽은 꺼진다. 두 방향의 반영 규칙을 `PaneText` 한 곳에 모으고(반대쪽에 있으면 가져오고 없으면 지운다), 되돌리기는 `LineEdit.Side` 로 어느 칸인지 보고 같은 칸으로 되돌린다. 저장 기준점 · 저장할 때 백업은 양쪽 따로. `MergeLine.HasBase` · `BaseNumber` 를 바뀔 수 있는 값으로 바꾸고 BASE 칸 목록이 그 변화를 따라가게 함(화면 시험에서 걸린 것). 네 칸 · Git 모드에서는 새 단추가 아예 안 보임. TwoWayTest 29건 새로. **커밋 안 함**(사용자 지시) | 없음 |
| 2026-09-27 | **12차 A-2** — 폴더 비교 한 줄을 왼쪽 목록 · 가운데 상태 · 오른쪽 목록 세 칸으로 나눴다(11차 F-10 파일 비교와 같은 모양). 칸마다 자기 쪽 이름 · 크기를 적고, 그쪽에 없으면 칸을 비운다(없는 쪽 `-` 표기도 뺌). TreeView 가 깊이만큼 줄 전체를 밀어 칸이 줄마다 어긋나던 것은 `TreeDepthMarginConverter` 로 민 만큼 되돌리고 이름만 다시 들여써 맞춤 — 칸 경계 · 가운데 구분선이 모든 줄에서 한 자리. 접기 · 펼치기 · 선택 · 복사 · 삭제 · 걸러 보기 · 더블클릭은 트리를 그대로 써서 손대지 않음(화면에서 여섯 가지 확인). FolderTest +7(118건). **커밋 안 함**(사용자 지시) | 없음 |
| 2026-09-27 | **12차 A-1** — 폴더 줄 크기가 늘 `0 B` 였다(크기를 파일 줄에만 채웠다). 폴더 줄에 안에 든 파일 크기 합계를 적는다(하위 폴더까지 · 양쪽 따로 · `FolderSizeText`), 크기 글자도 `490.2KB` · `2.4MB` 처럼 사람이 읽는 단위로. 갈래 판단은 파일 크기를 보고 하므로 합계는 `ClassifyAll` 다음에 채운다. 걸러 낸 파일은 합계에서도 빠지고 한쪽에만 있는 폴더는 그쪽만. 탐색기 속성과 같은 값 7곳 · 4,608 줄 묶음에서 채우기 0ms. FolderTest +23(111건). **커밋 안 함**(사용자 지시) | 없음 |
| 2026-09-23 | **11차 뒤 G-2** — 새 플랫폼(`D:\Sorce\ubsrepo`) 위로 옮김. 플랫폼 쪽: 세 갈래로 묻는 창(`ShowChoice`) · 덮는 창 가운데 맞춤 · D 드라이브 없는 PC(`ConfigLocation`) · 켤 때 터지면 안내창 · 셸 창 자리 기억(`ShellWindowPlacement.json`) · 켤 때 모니터 안으로 · 최대화 크기 맞춤 · 탭 줄 접기(F11) · **트레이(최소화하면 알림 영역으로, 왼쪽 단추로 되살리기 · 오른쪽 단추 [열기]/[종료])** · 같은 설정 키 중복 등록 방지. MergeHub 쪽: 플랫폼에서 없어진 `CurrentUserService` 를 떼고 작성자 이름을 설정(머지 주석 > 작성자) → git config → 윈도 계정 순으로, 셸 여백은 리소스 덮어쓰기 대신 `PlatformChrome.FullBleed`, F5 실행을 `UbisamBase.Launcher.exe` 로. 하네스 34건(ShowChoice · 가운데 맞춤 10 · 창 자리 · 설정 폴더 23 · 실패 0), 화면 확인 여섯 가지. **양쪽 저장소 모두 커밋하지 않음**(사용자 지시) | 없음 |
| 2026-09-23 | **11차 뒤 G-1** — "새 모니터 열었는데 최대화하면 오른쪽으로 넘쳐": 주 150% 옆 **300%** 모니터에서 최대화하면 창이 5120×2186 이 되어 오른쪽 1280px · 아래 170px 이 화면 밖으로 나갔다(요소 25개). 셸이 .NET Framework WPF 라 DPI 인식이 모니터별 V1 에 머물러 윈도가 최대화 크기를 더 크게 잡는 것. 최대화된 뒤 그 모니터 크기와 다르면 한 번 다시 놓게 고침(플랫폼 changelog, 배포함). 넘침 0 · 모니터 밖 요소 0 · 주 모니터 동작 그대로 | 아래 |
| 2026-09-23 | **11차 F-11** — Git 원격 인증: 껐다 켜면 URL · 계정 · 토큰이 모두 비어 매번 다시 넣어야 했다. URL · 계정은 Git 탭에 적으면 설정에 남기고(0.8초 모아 한 번), 토큰은 fetch · pull · push · 클론이 **성공했을 때만** 윈도 자격 증명 관리자에 맡긴다(`GitCredentialStore` — 서버마다 한 이름, 설정 파일에는 토큰 글자 없음, SSH · 로컬 경로는 저장 안 함). 켤 때 · 주소를 바꿀 때 그 서버 토큰만 가져오고, 다른 서버로 바꾸면 앞 서버 토큰은 치운다. [토큰 지우기] 단추 · "저장됨" 표시. 인증 실패는 영어 대신 무엇을 하라는 한글 문장으로 | 아래 |
| 2026-09-22 | **11차 F-10** — Araxis 식 화면: 칸마다 자기 줄만(빈 줄로 높이를 맞추지 않음), 두 칸(3분할은 위 세 칸) 사이 틈에 바뀐 덩어리마다 사다리꼴 잇는 선 · 칸 안 경계선(한쪽에 없으면 굵은 선 하나) · 옮긴 덩어리는 청록 점선. 스크롤은 가운데 기준으로 따라옴(`PaneMapping` — 함께 있는 줄 가운데를 짝점, 사이는 비율). 속의 정렬 행은 그대로라 반영 · 되돌리기 · 차이 이동이 그대로 동작. 편집기에서 빈 줄을 없애 `StripFillers` 를 지움(편집기 줄 ↔ 행 표). 지운 줄 [>] 는 BASE 칸 끝, 차이 이동은 가운데로 | 아래 |
| 2026-09-22 | **11차 F-9** — 저장할 때 백업: 설정 "저장할 때 백업"(기본 끔)을 켜면 모든 저장(두 파일 · mergetool · Save & Commit · 병합 중 Save · 파일 단위 Accept · 폴더 비교 복사) 직전에 지금 파일을 바이트 그대로 `{설정 폴더}\Backup\MergeHub\날짜\드라이브\원래 폴더\이름.날짜-시각.확장자` 로 복사(`FileBackup`, 날짜 폴더마다 목록.txt, 저절로 안 지움 — 사용자 결정). 원본 폴더 · 저장소 밖이라 폴더 비교 · 변경분 · git status 에 안 섞임. 못 만들면 저장하지 않음 | 아래 |
| 2026-09-22 | **11차 F-8** — 저장한 뒤에도 Undo/Redo: 저장하는 길마다 되돌리기 목록을 비우던 것을 두고, "저장할 것 있음" 을 목록이 아니라 "지금 글이 마지막 저장과 다른가" 로(`SaveBaseline`). 커밋 뒤 새 HEAD 로 다시 읽기 · 비교 규칙 바꾸기 · 편집기에 친 뒤 재정렬처럼 줄을 다시 세울 때 이력을 글 단위로 옮겨 지킴(친 것도 한 걸음, 300걸음까지). 전체 되돌리기는 파일을 연 시점. 병합 중 푼 파일은 Git 탭 [다시 충돌로] 로 되돌림 — LibGit2Sharp 에 길이 없어 이 단추만 `git checkout -m`(사용자 결정), 해결 기록에서도 뺌 | 아래 |
| 2026-09-22 | **11차 F-7 (추가 요청)** — "대형 모니터에서 오른쪽이 잘려 종료 · ACTIONS 가 안 보임": 창이 모니터 밖으로 나간 것. 켤 때 창 모드로 기억한 자리를 모니터에 조금만 걸쳐도 그대로 놓았다 — 해상도 · 모니터 배치가 바뀐 뒤 오른쪽으로 넘침(재현 840px). 플랫폼이 가장 많이 겹치는 모니터 안으로 밀게 고침(플랫폼 changelog, 사용자 확인 뒤 배포 09-22). 가장 좁은 창 × 화면 배율 80~133% 에서 안쪽 화면은 원래 넘치지 않음 | 아래 |
| 2026-09-22 | **11차 F-6 (추가 요청)** — 코드 칸 줄 간격을 좁힘: 줄 높이 글자의 1.67배 → 1.33배(12 → 16, 사용자 결정 "선택 사항은 만들지 마" — 켜고 끄는 단추 · 설정 없음). 1920 × 1080 두 칸 40 → 50줄, 네 칸 위 17 → 22 · 아래 18 → 23줄. 북마크 표식 높이도 줄에 맞춤. 방법별 줄 수(탭 줄 접기 +2 · 글자 10 +7 · 칸 제목 한 줄 · 범례 감추기 +1 안팎)는 기록에 | 아래 |
| 2026-09-22 | **11차 F-5 뒤 (사용자 "응 고쳐줘")** — 켜자마자(또는 탭을 눌러 돌아오면) 키보드 포커스가 없거나 탭 줄에 있어, 코드 칸을 한 번 누르기 전까지 **모든 단축키**(Ctrl+S · F9 · Ctrl++ …)가 듣지 않았다. 파일 비교 화면이 뜰 때 · 돌아올 때 · 창이 활성이 될 때 포커스가 비었거나 화면 밖이면 편집기로 준다(화면 안 · 다른 창의 포커스는 그대로). E-1 병합 길 회귀 없음 | 아래 |
| 2026-09-22 | **11차 F-5 (추가 요청)** — 코드 칸 글자 크기 8~24(기본 12). 한 값을 모든 모니터에(사용자 결정 — 모니터마다 다른 Windows 배율은 플랫폼 PerMonitorV2 가 맞춤), 코드 칸만. `CodeFontMetrics` 가 줄 높이 · 번호 칸 · 여백 단추를 같은 비율로 내고(12 에서 지금 값 그대로) 화면이 DynamicResource 로 한 벌 갈아 끼움, `MergeLine.MinimumRowHeight` 도 따라감. Ctrl+휠 · Ctrl++/- · Ctrl+0 · 설정 "보기 > 코드 글자 크기", 상태 줄 `글자 N`. 줄 번호가 코드보다 몇 px 위에 뜨던 것(24 에서 커짐)도 같은 줄 높이 규칙으로 맞춤. 8 · 12 · 24 에서 네 칸 행 높이 같음. FontTest 110 · ShortcutTest +14(1,538건) | 아래 |
| 2026-09-22 | **11차 F-4 (추가 요청)** — 파일 비교 오른쪽 ACTIONS 칸을 감추는 단추 · F9(사용자 결정). 감추면 칸과 구분선 폭을 0 으로 해 코드 칸이 넓어지고, 펴면 끌어 둔 폭 그대로. 설정 `ActionsHidden` 에 기억. 감춘 채 Ctrl+S · Alt+←/→ 그대로. **덧(사용자 요청)**: 아래 탭 줄 접기 — 플랫폼에 [탭 접기] · 띠 · F11 을 넣음(플랫폼 changelog 참고, 사용자 확인 뒤 배포 09-22 16:22). 1440p 150% 에서 코드 칸 폭 +163px · 높이 +71px. ShortcutTest +3 · 플랫폼 PlatformCfgTest +7 | 아래 |
| 2026-09-22 | **11차 F-3 뒤 (사용자 발견)** — 탐색기에서 폴더 둘을 보내면 왼쪽 칸이 설정의 기본 저장소(`plain-compare`)로 덮여 비교가 "왼쪽만" 투성이였다. `FolderViewModel` 이 넘어온 요청을 받은 뒤 "열어 둔 저장소를 왼쪽 기본값으로" 가 덮어쓴 것(비교는 비동기라 덮인 값을 읽음) — 기본값을 먼저, 요청을 나중에. 켤 때 받은 인자를 파일 로그에 남김. 앞선 시험은 로그만 보고 칸 값을 제대로 읽지 않아 지나쳤다 | c35280a |

View File

@ -1,153 +0,0 @@
# 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/) 로 옮겼다.
@ -374,64 +374,16 @@
| **완료** | F-2 메서드 단위로 골라 반영 — [이 메서드 반영](C# + C/C++, 클래스 멤버 전부 · 사용자 결정), 캐럿이 놓인 멤버를 반영해 올 쪽에서 중괄호 짝으로 잡음(`MemberRanges` — 문자열 · 주석 · raw · `#if` 갈래에 안 속음, 못 잡으면 단추 끄고 까닭 한 줄). 덩어리 · 메서드 반영은 주석 한 줄(메서드 이름 위) · 로그 한 줄 · Undo 한 번 — 덩어리가 줄마다 주석을 달던 것도 고침. 메서드 반영 = 같은 줄 줄 단위 반영 바이트 같음. MemberTest 83건(C# 29 · C++ 13 멤버) · 1,395건 |
| **완료** | F-3 탐색기 [보내기] 로 폴더 두 개 바로 비교 — 탐색기는 여러 개를 프로세스 하나에 인자로 모두 넘김(일곱 경우 값으로 잼), 폴더 둘은 창 하나에서 폴더 비교까지. 사용자 결정대로 3개 이상은 앞의 둘 + 알림, 260자 넘는 경로(탐색기가 8.3 이름으로 넘김 · net48 이 못 찾음)는 열지 않고 알림, 옮긴 배포 묶음은 켤 때 보내기 바로가기를 저절로 고침(바로가기 파일만 · 레지스트리 안 씀), 떠 있을 때는 새 창 그대로. 설치 파일 필요 없음. ArgsTest +16 · 1,411건. **사용자가 실제 탐색기에서 찾은 것**: 보낸 왼쪽 폴더를 기본 저장소가 덮어써 "왼쪽만" 투성이 → 기본값을 먼저 두고 요청을 나중에 받게 고침, 켤 때 받은 인자를 파일 로그에 남김 |
| **완료** | F-4 ACTIONS 칸 감추기(단추 · F9, 끌어 둔 폭 기억, 감춰도 Ctrl+S · Alt+←/→ 그대로) + **덧: 아래 탭 줄 접기**(사용자 요청 — 플랫폼 [탭 접기] · 16 DIP 띠 · F11, 그 항목만 기억, 다른 소비 프로그램도 쓰되 기본 펼침). 1440p 150% 에서 코드 칸 폭 +163px · 높이 +71px. 플랫폼 배포함(09-22 16:22) |
| **완료** | F-5 코드 글자 크기(8~24, 기본 12) — 한 값을 모든 모니터에(모니터 배율은 플랫폼), 코드 칸만, Ctrl+휠 · Ctrl++/- · Ctrl+0 + 설정 "보기 > 코드 글자 크기"(사용자 결정). 줄 높이 · 번호 칸 · 여백 단추가 같은 비율로(`CodeFontMetrics`, 12 에서 지금 값 그대로), 줄 번호 기준선을 코드와 맞춤. 8 · 12 · 24 에서 네 칸 행 높이 같음 · 단추 행 안. FontTest 110 · 1,538건. Ctrl+휠 · 키 · 껐다 켜도 남음 확인. 켜자마자는 포커스가 없어 단축키가 모두 안 듣던 것(원래 있던 것)도 고침 — 포커스가 비었거나 화면 밖이면 편집기로 |
| **완료** | F-6 글자를 되도록 많이 보이게 — 방법마다 1920×1080 줄 수를 재 드린 뒤 사용자 결정 "줄 간격 줄여주고 선택 사항은 만들지 마": 줄 높이 글자의 1.67배 → 1.33배(12 → 16). 두 칸 40 → 50줄(탭 줄 접으면 53), 네 칸 17 · 18 → 22 · 23줄. 한글 잘림 · 겹침 없음, E-1 회귀 없음. FontTest 144 · 1,572건 |
| **완료** | F-7 대형 모니터에서 오른쪽(종료 · ACTIONS)이 잘림 — 창이 모니터 밖으로 나간 것. 켤 때 창 모드로 기억한 자리를 모니터에 조금만 걸쳐도 그대로 놓던 것을 가장 많이 겹치는 모니터 안으로 밀게(플랫폼 `FitToMonitors`). 재현 840px 넘침 → 0. 좁은 창 × 화면 배율 80~133% 는 원래 넘친 것 없음. PlacementTest +8. 플랫폼 배포함(09-22) |
| **완료** | F-8 저장한 뒤에도 되돌리기 — 저장 · 저장 후 커밋 뒤 Undo/Redo 유지(저장할 것 있음은 "마지막 저장과 다른가" 로), 줄을 다시 세울 때(커밋 뒤 새 HEAD · 규칙 바꾸기 · 친 뒤 재정렬) 이력을 글 단위로 옮겨 지킴, 친 것도 한 걸음. 전체 되돌리기는 파일을 연 시점. Git 탭 "푼 파일" + [다시 충돌로](이 단추만 git.exe, 사용자 결정). MergeSaveTest +21 |
| **완료** | F-9 저장할 때 백업 — 설정(기본 끔)을 켜면 모든 저장(두 파일 · mergetool · Save & Commit · 병합 중 Save · 파일 단위 Accept · 폴더 복사) 직전에 지금 파일을 바이트 그대로 `{설정 폴더}\Backup\MergeHub\날짜\…` 에 모음(목록.txt, 저절로 안 지움). 못 만들면 저장 안 함. ACTIONS [백업 폴더 열기]. 하네스 +25 |
| **완료** | F-10 칸마다 자기 줄 · 바뀐 덩어리를 잇는 선(Araxis 식, 사용자 요청으로 넓힘) — 빈 줄로 맞추지 않고 칸마다 자기 줄만, 틈에 사다리꼴 선 · 옮김 점선, 가운데 기준 스크롤, 차이 이동은 가운데로, 지운 줄 [>] 는 BASE 칸 끝. 3분할은 위 세 칸 사이. 편집기 빈 줄(StripFillers) 없앰. PaneLinkTest 17 새로 · EditorTest 14 → 18 |
| **완료** | F-11 Git 계정 · URL · 토큰 저장 — 확인해 보니 껐다 켜면 셋 다 비었다. URL · 계정은 적으면 설정에 남기고, 토큰은 fetch/pull/push/클론이 성공하면 윈도 자격 증명 관리자에 저장(평문 아님 · 설정 파일에 글자 없음), 켤 때 그 서버 것만 가져옴. [토큰 지우기] · "저장됨" 표시 · 인증 실패 한글 안내. MergeSaveTest +15 |
| **완료** | F-5 코드 글자 크기(8~24, 기본 12) — 한 값을 모든 모니터에(모니터 배율은 플랫폼), 코드 칸만, Ctrl+휠 · Ctrl++/- · Ctrl+0 + 설정 "보기 > 코드 글자 크기"(사용자 결정). 줄 높이 · 번호 칸 · 여백 단추가 같은 비율로(`CodeFontMetrics`, 12 에서 지금 값 그대로), 줄 번호 기준선을 코드와 맞춤. 8 · 12 · 24 에서 네 칸 행 높이 같음 · 단추 행 안. FontTest 110 · 1,538건. Ctrl+휠 · 키 · 껐다 켜도 남음 확인. 켜자마자는 포커스가 없어 단축키가 모두 안 듣는 것(원래 있던 것)을 찾음 |
| 대기 | F-6 글자를 되도록 많이 보이게 — 넓게 보기 한 단추, 늘어난 줄 수로 잼 |
| 대기 | F-7 어느 모니터에서든 제대로 뜨게 — 증상부터 적고 플랫폼 영향 확인 |
| 대기 | F-8 저장한 뒤에도 되돌리기 (지금은 저장 네 길이 스택을 비움) |
| 대기 | F-9 백업본으로 저장 · 원본을 건드릴지 고르게 |
| 대기 | F-10 덩어리가 어디로 갔는지 UI 로 잇기 (지금은 "자리 옮김" 색만) |
| 대기 | F-11 Git 계정 · URL · 암호 저장 확인 (지금은 주소 · 계정만 저장, 토큰은 메모리) |
| **완료** | F-12 C# 코드 스타일 맞춤 기본값을 AURORA 로 (2026-09-18 사용자 결정) — 설정 없는 첫 실행에서 AURORA, 중괄호만 다른 C# 짝 "차이 없음" · 같은 모양의 C++ 짝은 "남은 차이 4줄"(원본 그대로). 저장 동작은 그대로. 하네스 StyleTest +12(90건) |
---
### G. 11차 뒤 추가 (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주차로 미룬 것
디테일이라 지금 붙잡을 일이 아니다.

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using System.Windows;
using Microsoft.Extensions.DependencyInjection;
using MergeHub.Services;
using MergeHub.ViewModels;
using MergeHub.Views;
@ -17,10 +18,12 @@ namespace MergeHub
public void RegisterViews(ITabManager manager)
{
// 머지툴은 3분할 비교라 가로 공간이 곧 성능이다. 셸 기본 여백을 없애고 창 전체를 쓰며,
// 안쪽 여백은 각 화면이 직접 준다. 여백을 없애는 길은 화면마다 붙인
// shell:PlatformChrome.FullBleed="True" 이다(새 플랫폼 · platform-guide 8-4).
// 예전처럼 Ubisam.Metric.ContentPadding 리소스를 덮어쓰지 않는다 - 그 키는 이제 없다.
// 머지툴은 3분할 비교라 가로 공간이 곧 성능이다.
// 셸 기본 여백(26,22)을 없애고 창 전체를 쓴다 - 안쪽 여백은 HomeView가 직접 준다.
// 이 키는 테마 전환에도 유지되므로 한 번만 덮어쓰면 되고,
// ShellWindow가 창 생성 시점에 읽어가므로 RegisterViews 안에서 정해야 한다.
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>
<OutputType>Library</OutputType>
@ -9,37 +9,19 @@
<Nullable>enable</Nullable>
<RootNamespace>MergeHub</RootNamespace>
<AssemblyName>MergeHub</AssemblyName>
<!-- 프로그램 정보 창(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>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<!-- 플랫폼 배포 폴더 (platform-guide 10-2 (B) 배포용) -->
<UbisamPlatformDir>D:\UbisamPlatform\bin\</UbisamPlatformDir>
<!-- 이 프로젝트는 dll이라 직접 실행 불가 - F5 시 Launcher.exe 가 뜨게 한다.
Launcher 는 켤 때마다 D:\UbisamPlatform\bin 을 이 폴더로 복사한 뒤 Shell 을 같은 프로세스에서 띄운다.
그래서 다시 빌드하지 않아도 최신 플랫폼으로 뜨고, 이 프로젝트의 중단점도 그대로 걸린다.
$(TargetDir) 는 이 시점에 아직 비어 있어 쓰면 안 된다 - 절대 경로로 적는다. -->
<!-- 이 프로젝트는 dll이라 직접 실행 불가 - F5 시 Shell.exe가 뜨게 한다 -->
<StartAction>Program</StartAction>
<StartProgram>$(MSBuildProjectDirectory)\bin\$(Configuration)\$(TargetFramework)\UbisamBase.Launcher.exe</StartProgram>
<StartWorkingDirectory>$(MSBuildProjectDirectory)\bin\$(Configuration)\$(TargetFramework)\</StartWorkingDirectory>
<StartProgram>$(TargetDir)UbisamBase.Shell.exe</StartProgram>
<StartWorkingDirectory>$(TargetDir)</StartWorkingDirectory>
<DisableFastUpToDateCheck>true</DisableFastUpToDateCheck>
</PropertyGroup>
<!-- 빌드한 날짜를 실행 파일에 박는다(12차 B-2). 요즘 SDK 는 결정적 빌드라 PE 머리의 시각 자리가
날짜가 아니라 내용 해시다 - 정보 창이 "알 수 없음" 이 되지 않게 여기서 박아 둔다. -->
<ItemGroup>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>BuildDate</_Parameter1>
<_Parameter2>$([System.DateTime]::Now.ToString("yyyy-MM-dd HH:mm"))</_Parameter2>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<Reference Include="UbisamBase.Core">
<HintPath>$(UbisamPlatformDir)UbisamBase.Core.dll</HintPath>

View File

@ -1,4 +1,4 @@
using System.ComponentModel;
using System.ComponentModel;
namespace MergeHub
{
@ -9,16 +9,6 @@ namespace MergeHub
/// </summary>
public class MergeHubSettings
{
// 프로그램 정보 창(12차 B-2)에 적을 것. 버전 · 빌드 날짜는 실행 파일에서 읽으므로 여기 없다 -
// 사람이 적은 값과 실제가 어긋나면 그 창은 없느니만 못하다. 사람이 바뀌는 것만 여기서 적는다.
[Category("프로그램 정보")]
[DisplayName("만든 사람 (비우면 실행 파일의 회사 이름)")]
public string ProgramAuthor { get; set; } = string.Empty;
[Category("프로그램 정보")]
[DisplayName("사내 문의처 (담당자 · 연락처 한 줄)")]
public string SupportContact { get; set; } = string.Empty;
[Category("저장소")]
[DisplayName("기본 저장소 경로")]
public string DefaultRepositoryPath { get; set; } = string.Empty;
@ -113,15 +103,6 @@ namespace MergeHub
[DisplayName("코드 글자 크기 (8~24, 기본 12)")]
public int CodeFontSize { get; set; } = 12;
/// <summary>
/// 저장하기 전 파일을 백업해 둔다(11차 F-9). 모든 저장 - 두 파일 비교 · mergetool · Save &amp; Commit · 병합 중 Save ·
/// 파일 단위 Accept · 폴더 비교 복사. 자리는 {설정 폴더}\Backup\MergeHub\날짜 아래 원래 폴더 모양 그대로(<see cref="Services.FileBackup"/>).
/// 저절로 지우지 않는다(사용자 결정). 기본은 끔.
/// </summary>
[Category("저장")]
[DisplayName("저장할 때 백업 (저장 전 파일을 설정 폴더\\Backup\\MergeHub 에 날짜별로 모음 · 저절로 지우지 않음)")]
public bool BackupBeforeSave { get; set; }
[Category("머지 주석")]
[DisplayName("반영한 줄에 주석 남기기")]
public bool InsertMergeComment { get; set; } = true;

View File

@ -92,10 +92,10 @@ namespace MergeHub.Models
_baseText = baseText ?? string.Empty;
_localText = localText ?? string.Empty;
_baseNumber = baseNumber;
BaseNumber = baseNumber;
_localNumber = localNumber;
_hasBase = baseText is not null;
HasBase = baseText is not null;
_hasLocal = localText is not null;
}
@ -109,15 +109,8 @@ namespace MergeHub.Models
? string.Equals(a, b, StringComparison.Ordinal)
: string.Equals(_normalize(a), _normalize(b), StringComparison.Ordinal);
/// <summary>
/// BASE 쪽 줄 번호. LOCAL 에만 있는 줄이면 없다.
///
/// 12차 B-1 전에는 정렬 결과라 바뀌지 않았다. 이제 두 파일 비교에서는 왼쪽으로도 반영하므로
/// 왼쪽에 줄이 생기거나 사라진다 - 번호도 따라 바뀐다.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(BaseNumberText))]
private int? _baseNumber;
/// <summary>BASE 쪽 줄 번호. LOCAL 에만 있는 줄이면 없다.</summary>
public int? BaseNumber { get; }
public string BaseNumberText => BaseNumber?.ToString("00") ?? string.Empty;
@ -173,12 +166,19 @@ namespace MergeHub.Models
public bool HasProblem => !string.IsNullOrEmpty(Problem);
// ---- 칸마다 자기 행 높이 (11차 F-10) ----
// ---- 워드랩에서 패널들의 행 높이 맞추기 ----
//
// 예전에는 모든 칸이 같은 행을 같은 눈높이에 놓았다(한쪽에 없는 줄은 빈 줄로 채움). 그래서 워드랩으로
// 한쪽만 접힌 행은 큰 쪽 높이를 함께 썼다. 이제는 칸마다 자기 줄만 보이고(Araxis 식) 두 칸은 잇는 선으로
// 짝을 보이므로 <b>높이를 맞출 까닭이 없다</b> - 칸마다 자기 글 높이를 쓴다. 이 높이는 스크롤 맞추기 ·
// 잇는 선의 자리 계산(<see cref="PaneHeight"/>)에도 쓰인다.
// 워드랩을 켜면 줄마다 접히는 횟수가 달라 높이가 제각각이 된다. 패널이 각자 자기 글
// 높이만 쓰면, 한쪽만 접힌 행에서 그 아래가 통째로 밀린다. 그래서 "같은 눈높이에
// 있어야 하는 패널끼리" 높이 중 큰 값을 함께 쓴다.
//
// 여기서 중요한 것은 <b>같이 맞춰야 하는 묶음이 하나가 아니라는 점</b>이다.
// 2-Way : BASE ↔ LOCAL(편집기) 이 나란히 있으므로 넷이 한 묶음이다.
// 3-Way : 위의 원본 세 칸은 폭이 같아 서로 맞춰야 하지만,
// 아래 MERGED 는 폭이 전혀 달라(가로 전체) 접히는 자리가 애초에 다르다.
// 같은 높이를 강요하면 MERGED 는 안 접힌 줄에 빈칸이 생기고,
// 그 위에 겹친 편집기와도 어긋난다.
// 그래서 묶음을 둘로 나눈다 - RowHeight(MERGED·편집기) 와 SourceRowHeight(원본 칸들).
private double _baseTextHeight;
private double _localTextHeight;
@ -198,78 +198,15 @@ namespace MergeHub.Models
/// <summary>편집기가 겹친 칸(2-Way LOCAL · 3-Way MERGED)의 행 높이.</summary>
[ObservableProperty] private double _rowHeight = MinimumRowHeight;
/// <summary>BASE 칸의 행 높이.</summary>
[ObservableProperty] private double _baseRowHeight = MinimumRowHeight;
/// <summary>REMOTE 칸의 행 높이.</summary>
[ObservableProperty] private double _remoteRowHeight = MinimumRowHeight;
/// <summary>3-Way LOCAL(Mine) 원본 칸의 행 높이.</summary>
[ObservableProperty] private double _originalLocalRowHeight = MinimumRowHeight;
/// <summary>
/// 그 칸에서 이 행이 차지하는 높이(11차 F-10). 그 칸에 없는 줄이면 0.
/// <paramref name="measured"/> 가 false 면(워드랩 끔) 모든 줄이 한 줄 높이라 잰 값을 보지 않는다 -
/// 화면에 안 그려진 행은 잰 값이 없어 어림이 되므로, 한 줄 높이가 정확할 때는 그것을 쓴다.
/// </summary>
public double PaneHeight(TextPane pane, bool measured)
{
var present = pane switch
{
TextPane.Base => HasBase,
TextPane.Local => HasLocal,
TextPane.Remote => HasRemote,
_ => HasOriginalLocal,
};
if (!present) return 0;
if (!measured) return MinimumRowHeight;
return pane switch
{
TextPane.Base => BaseRowHeight,
TextPane.Local => RowHeight,
TextPane.Remote => RemoteRowHeight,
_ => OriginalLocalRowHeight,
};
}
/// <summary>그 칸에 이 행의 줄이 있는지(11차 F-10 - 칸마다 자기 줄만 보인다).</summary>
public bool IsIn(TextPane pane) => pane switch
{
TextPane.Base => HasBase,
TextPane.Local => HasLocal,
TextPane.Remote => HasRemote,
_ => HasOriginalLocal,
};
/// <summary>
/// 두 칸 사이에서 이 행이 다른지 - 잇는 선을 그을 덩어리를 가른다(11차 F-10). 무시 규칙에 걸린 줄은 같은 것으로 본다.
/// 머지 주석 줄도 한쪽에만 있는 줄이라 다른 것으로 친다(어디에 끼워 넣었는지 보여야 한다).
/// </summary>
public bool DiffersBetween(TextPane left, TextPane right)
{
if (IsIgnored) return false;
var inLeft = IsIn(left);
var inRight = IsIn(right);
if (inLeft != inRight) return true;
if (!inLeft) return false;
return !SameText(TextOf(left), TextOf(right));
}
private string TextOf(TextPane pane) => pane switch
{
TextPane.Base => BaseText,
TextPane.Local => LocalText,
TextPane.Remote => RemoteText,
_ => OriginalLocalText,
};
/// <summary>읽기 전용 원본 칸들의 행 높이. 2-Way 에서는 RowHeight 와 같다.</summary>
[ObservableProperty] private double _sourceRowHeight = MinimumRowHeight;
/// <summary>
/// 접히지 않은 보통 줄의 높이. HomeView 의 CodeLineHeight 와 같은 값이어야 한다.
/// 코드 글자 크기를 바꾸면 화면이 이 값을 함께 바꾸고 <see cref="RefreshRowHeight"/> 를 부른다(11차 F-5) -
/// 예전에는 20 으로 박혀 있어 글자를 줄여도 행이 20 아래로 줄지 않았다.
/// </summary>
public static double MinimumRowHeight { get; set; } = 16;
public static double MinimumRowHeight { get; set; } = 20;
/// <summary>줄 높이 기준이 바뀌었을 때 알던 글 높이로 다시 잰다.</summary>
public void RefreshRowHeight() => ApplyRowHeights();
@ -292,10 +229,17 @@ namespace MergeHub.Models
private void ApplyRowHeights()
{
RowHeight = Math.Max(MinimumRowHeight, _localTextHeight);
BaseRowHeight = Math.Max(MinimumRowHeight, _baseTextHeight);
RemoteRowHeight = Math.Max(MinimumRowHeight, _remoteTextHeight);
OriginalLocalRowHeight = Math.Max(MinimumRowHeight, _originalLocalTextHeight);
if (IsThreeWay)
{
RowHeight = Math.Max(MinimumRowHeight, _localTextHeight);
SourceRowHeight = Math.Max(MinimumRowHeight,
Math.Max(_originalLocalTextHeight, Math.Max(_baseTextHeight, _remoteTextHeight)));
return;
}
var shared = Math.Max(MinimumRowHeight, Math.Max(_baseTextHeight, _localTextHeight));
RowHeight = shared;
SourceRowHeight = shared;
}
/// <summary>무시 규칙(줄 필터)에 걸린 줄. 차이로 세지 않고 옅게만 표시한다.</summary>
@ -313,15 +257,6 @@ namespace MergeHub.Models
public bool IsMoved => !string.IsNullOrEmpty(MoveHint);
/// <summary>
/// 자리 옮김 짝 번호(11차 F-10). 같은 번호의 BASE 쪽 줄(<see cref="MoveFromBase"/>)과 LOCAL 쪽 줄이 한 짝이다 - 두 칸 사이에
/// 옮긴 곳까지 잇는 선을 그린다. 행 번호가 아니라 줄에 붙여 두어 머지 주석이 끼어들어 번호가 밀려도 짝이 유지된다. 0 이면 옮김 아님.
/// </summary>
public int MoveGroup { get; set; }
/// <summary>옮김 짝에서 떠난 쪽(BASE 에 있던 자리)인지. false 면 옮겨 온 쪽(LOCAL).</summary>
public bool MoveFromBase { get; set; }
/// <summary>사용자가 여백을 눌러 표시해 둔 줄. F2 로 옮겨 다닌다.</summary>
[ObservableProperty] private bool _isBookmarked;
@ -344,18 +279,8 @@ namespace MergeHub.Models
[ObservableProperty] private Services.CarryOver _baseInComment;
[ObservableProperty] private Services.CarryOver _localInComment;
/// <summary>
/// BASE 쪽에 이 줄이 있는지.
///
/// 12차 B-1 전에는 정렬 결과라 바뀌지 않았다. 두 파일 비교에서 [&lt;] 로 왼쪽에 반영하면
/// 왼쪽에 없던 줄이 생기고, 왼쪽에만 있던 줄을 반영하면 사라진다.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(State))]
[NotifyPropertyChangedFor(nameof(IsDifferent))]
[NotifyPropertyChangedFor(nameof(CanTakeBase))]
[NotifyPropertyChangedFor(nameof(Highlight))]
private bool _hasBase;
/// <summary>BASE 쪽에 이 줄이 있는지. 정렬 결과라 바뀌지 않는다.</summary>
public bool HasBase { get; }
/// <summary>LOCAL 쪽에 줄이 있는지. 줄을 지우거나 되살리면 바뀐다.</summary>
[ObservableProperty]
@ -546,12 +471,5 @@ namespace MergeHub.Models
/// 되돌리기 한 번에 통째로 되돌린다(11차 F-2).
/// </summary>
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

@ -5,11 +5,8 @@ namespace MergeHub.Services
/// <summary>
/// 코드 칸 글자 크기 하나에서 나오는 크기들(11차 F-5). 줄 높이 · 줄 번호 칸 · 여백 단추가 같은 비율로 따라간다.
///
/// 기본 12 에서 여백 66 = 번호 46 + 단추 20 · LOCAL 은 표식 6 + 번호 40 + 단추 20 · 단추 14 · 단추 글자 9 는 예전 값 그대로다.
/// 네 칸이 모두 이 값을 쓰므로 어느 크기에서나 행이 같은 눈높이에 온다.
///
/// 줄 높이는 글자의 1.33배(12 → 16)다(11차 F-6, 사용자 "줄 간격 줄여주고 선택 사항은 만들지 마").
/// 예전 1.67배(12 → 20)는 1920×1080 두 칸에 40줄, 지금은 50줄이 든다. Consolas 글자 상자(약 1.17배)보다는 넉넉해 위아래 줄이 닿지 않는다.
/// 기본 12 에서 지금까지의 값(줄 20 · 여백 66 = 번호 46 + 단추 20 · LOCAL 은 표식 6 + 번호 40 + 단추 20 · 단추 14 · 단추 글자 9)과
/// 정확히 같다. 네 칸이 모두 이 값을 쓰므로 어느 크기에서나 행이 같은 눈높이에 온다.
///
/// 크기는 WPF 단위(1/96 인치)다. 모니터마다 Windows 배율은 플랫폼(PerMonitorV2)이 맞추므로
/// 한 값이 어느 모니터에서나 같은 눈 크기로 보인다(사용자 결정 "한 값을 모든 모니터에").
@ -23,8 +20,7 @@ namespace MergeHub.Services
double MarkWidth,
double ButtonWidth,
double ButtonHeight,
double ButtonFontSize,
double BookmarkHeight)
double ButtonFontSize)
{
public const double Default = 12;
public const double Min = 8;
@ -48,15 +44,14 @@ namespace MergeHub.Services
return new CodeFontMetrics(
FontSize: f,
LineHeight: Math.Round(16 * k),
LineHeight: Math.Round(20 * k),
GutterWidth: number + button,
NumberWidth: number,
NarrowNumberWidth: narrow,
MarkWidth: mark,
ButtonWidth: button,
ButtonHeight: Math.Round(14 * k),
ButtonFontSize: Math.Round(9 * k, 1),
BookmarkHeight: Math.Max(4, Math.Round(16 * k) - 6));
ButtonFontSize: Math.Round(9 * k, 1));
}
}
}

View File

@ -1,461 +0,0 @@
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

@ -1,77 +1,94 @@
using System;
using System;
using System.Collections.Generic;
namespace MergeHub.Services
{
/// <summary>편집기에 그려 넣을 줄들과, 편집기 줄마다 정렬 행 번호.</summary>
/// <summary>편집기에 그려 넣을 줄들과, 그중 어느 줄이 필러인지.</summary>
public sealed class EditorSnapshot
{
public EditorSnapshot(string[] lines, int[] rows)
public EditorSnapshot(string[] lines, bool[] filler)
{
Lines = lines;
Rows = rows;
Filler = filler;
}
/// <summary>편집기 줄마다 정렬 행 번호(11차 F-10). 편집기에는 LOCAL 에 있는 줄만 들어가므로 줄 번호와 행 번호가 다르다.</summary>
public int[] Rows { get; }
/// <summary>편집기 본문 - LOCAL 에 있는 줄만.</summary>
/// <summary>편집기 본문. 필러 자리는 빈 문자열이다.</summary>
public string[] Lines { get; }
/// <summary>같은 인덱스의 줄이 필러(= LOCAL 에 없는 줄)인지.</summary>
public bool[] Filler { get; }
}
/// <summary>
/// LOCAL 패널을 "항상 고칠 수 있는 편집기"로 쓰기 위한 줄 계산.
///
/// 10차까지는 BASE 에만 있는 줄 자리에 LOCAL 쪽 <b>빈 줄(필러)</b>을 끼워 두 칸의 같은 줄을 같은 눈높이에 두고,
/// 저장 · 재정렬 전에 그 빈 줄을 걷어냈다(StripFillers). 11차 F-10 부터 칸마다 자기 줄만 보이고(Araxis 식)
/// 두 칸은 잇는 선으로 짝을 보이므로 <b>빈 줄을 넣지 않는다</b> - 편집기 글이 곧 파일 글이고, 걷어낼 것도 없다.
/// 편집기 줄 ↔ 정렬 행은 <see cref="EditorSnapshot.Rows"/> 로 옮긴다.
/// 화면에서 BASE 와 LOCAL 은 같은 내용이 같은 눈높이에 와야 한다. 그래서 BASE 에만 있는
/// 줄(= LOCAL 에서 지워진 줄) 자리에는 LOCAL 쪽에 <b>빈 줄(필러)</b>을 끼워 넣는다.
/// 필러는 화면을 맞추려고 넣은 것일 뿐 파일 내용이 아니므로, 저장하거나 다시 정렬하기 전에
/// 걷어내야 한다.
///
/// 문제는 사용자가 편집기에서 줄을 넣고 지운 뒤라 필러 위치가 밀려 있다는 점이다.
/// 그래서 "고치기 전 편집기 내용"과 "지금 편집기 내용"을 정렬해 보고,
/// <b>예전 필러와 그대로 대응되면서 여전히 비어 있는 줄</b>만 필러로 판정해 걷어낸다.
/// 필러 자리에 사용자가 뭔가 써 넣었다면 그건 새로 추가한 줄이므로 그대로 살린다.
/// </summary>
public static class EditorLayout
{
/// <summary>
/// 정렬 결과를 편집기에 그릴 줄들로 바꾼다.
///
/// 11차 F-10 부터 <b>빈 줄(필러)을 넣지 않는다</b> - 칸마다 자기 줄만 보이고(Araxis 식) 두 칸은 잇는 선으로 짝을 보인다.
/// 그래서 편집기 글이 곧 파일 글이고, 어느 편집기 줄이 몇 번째 정렬 행인지는 <see cref="EditorSnapshot.Rows"/> 가 알려 준다.
/// </summary>
/// <summary>정렬 결과를 편집기에 그릴 줄들로 바꾼다.</summary>
public static EditorSnapshot Build(IReadOnlyList<(string Text, bool HasLocal)> rows)
{
var lines = new List<string>(rows.Count);
var map = new List<int>(rows.Count);
var lines = new string[rows.Count];
var filler = new bool[rows.Count];
for (var i = 0; i < rows.Count; i++)
{
if (!rows[i].HasLocal) continue;
lines.Add(rows[i].Text);
map.Add(i);
filler[i] = !rows[i].HasLocal;
lines[i] = rows[i].HasLocal ? rows[i].Text : string.Empty;
}
return new EditorSnapshot(lines.ToArray(), map.ToArray());
return new EditorSnapshot(lines, filler);
}
/// <summary>
/// 정렬 행 → 편집기 줄(11차 F-10). 그 행이 LOCAL 에 없으면(지운 줄) 바로 다음에 있는 줄, 뒤에 없으면 마지막 줄.
/// 편집기가 비었으면 0.
/// 사용자가 고친 편집기 내용에서 필러를 걷어내 실제 LOCAL 줄만 남긴다.
/// </summary>
public static int LineOfRow(IReadOnlyList<int> rows, int row)
/// <param name="previous">고치기 전 편집기 줄(필러 포함)</param>
/// <param name="wasFiller">그때 어느 줄이 필러였는지</param>
/// <param name="current">지금 편집기 줄</param>
public static List<string> StripFillers(IReadOnlyList<string> previous,
IReadOnlyList<bool> wasFiller,
IReadOnlyList<string> current)
{
if (rows.Count == 0) return 0;
int lo = 0, hi = rows.Count; // rows 는 오름차순 - row 이상인 첫 자리
while (lo < hi)
{
var mid = (lo + hi) / 2;
if (rows[mid] < row) lo = mid + 1; else hi = mid;
}
return lo < rows.Count ? lo : rows.Count - 1;
}
var kept = new List<string>(current.Count);
/// <summary>편집기 줄 → 정렬 행(11차 F-10). 범위 밖이면 가까운 끝. 편집기가 비었으면 0.</summary>
public static int RowOfLine(IReadOnlyList<int> rows, int line)
{
if (rows.Count == 0) return 0;
if (line < 0) return rows[0];
return line < rows.Count ? rows[line] : rows[rows.Count - 1];
// 고치기 전 내용이 없으면 걷어낼 필러도 없다.
if (previous.Count == 0)
{
foreach (var line in current) kept.Add(line);
return kept;
}
// 어느 줄이 그대로 남았는지 짚어 준다. 빈 줄은 흔해서 앵커가 되지 못하므로
// 주변의 진짜 코드 줄에 딸려 자리를 잡는다 - 그래서 정렬기를 쓰는 것이다.
var map = LineAligner.Align(previous, current);
foreach (var row in map)
{
if (row.LocalNumber is null) continue; // 사용자가 지운 줄
var text = row.LocalText ?? string.Empty;
var cameFromFiller = row.BaseNumber is int oldNumber
&& oldNumber >= 1 && oldNumber <= wasFiller.Count
&& wasFiller[oldNumber - 1];
// 필러였고 여전히 비어 있으면 화면용 빈 줄일 뿐이다.
if (cameFromFiller && text.Length == 0) continue;
kept.Add(text);
}
return kept;
}
/// <summary>두 줄 목록이 같은지. 편집기 글을 다시 써야 하는지 판단할 때 쓴다.</summary>

View File

@ -1,144 +0,0 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace MergeHub.Services
{
/// <summary>
/// 저장하기 전 파일을 백업해 둔다(11차 F-9, 설정 "저장할 때 백업" 을 켰을 때만).
///
/// 사용자 결정: 원본 옆이 아니라 <b>한곳에 모은다</b> — <c>{설정 폴더}\Backup\MergeHub\날짜\드라이브\원래 폴더\이름.날짜-시각.확장자</c>.
/// 원본 폴더 · Git 저장소에 파일이 생기지 않으니 폴더 비교 · 변경분 추출 · git status 에 섞이지 않는다.
/// <b>지우지 않는다</b>(사용자 결정) — 날짜 폴더마다 <c>목록.txt</c> 에 원본과 백업을 한 줄씩 적어 찾기 쉽게 한다.
/// 저장하는 모든 길(두 파일 비교 · mergetool · Save &amp; Commit · 병합 중 Save · 파일 단위 Accept · 폴더 비교 복사)이 쓰기 직전에 부른다.
/// 백업을 못 만들면 던진다 - 부르는 쪽은 저장하지 않는다(백업을 약속한 설정이라 원본을 그냥 덮지 않는다).
/// </summary>
public sealed class FileBackup
{
private readonly Func<bool> _enabled;
private readonly Func<DateTime> _now;
private readonly object _gate = new object();
/// <summary>백업을 모으는 폴더.</summary>
public string Root { get; }
/// <summary>백업을 만들면 원본 · 백업 경로로 알린다(로그).</summary>
public event Action<string, string>? Kept;
public FileBackup(string root, Func<bool> enabled, Func<DateTime>? now = null)
{
Root = root;
_enabled = enabled;
_now = now ?? (() => DateTime.Now);
}
public bool Enabled => _enabled();
/// <summary>설정 폴더 아래 백업 자리.</summary>
public static string DefaultRoot(string configRoot) => Path.Combine(configRoot, "Backup", "MergeHub");
/// <summary>날짜 폴더마다 남기는 목록 파일 이름.</summary>
public const string ListFileName = "목록.txt";
/// <summary>경로가 이보다 길면 원래 폴더 모양을 버리고 짧은 자리로(net48 은 260자를 넘으면 못 쓴다).</summary>
private const int MaxPath = 240;
/// <summary>
/// 켜져 있고 파일이 있으면 지금 바이트 그대로 백업하고 그 경로를 준다. 꺼져 있거나 파일이 없으면(새로 만드는 저장) null.
/// 못 만들면 <see cref="InvalidOperationException"/> - 까닭을 담는다.
/// </summary>
public string? Keep(string? path)
{
if (!_enabled() || string.IsNullOrWhiteSpace(path)) return null;
string full;
try { full = Path.GetFullPath(path); }
catch (Exception ex) when (ex is ArgumentException || ex is NotSupportedException || ex is PathTooLongException)
{
throw new InvalidOperationException($"백업을 만들지 못해 저장하지 않았습니다 - 경로를 읽을 수 없습니다: {path}", ex);
}
if (!File.Exists(full)) return null;
lock (_gate)
{
try
{
var when = _now();
var target = PathFor(Root, full, when);
for (var n = 2; File.Exists(target); n++) target = PathFor(Root, full, when, n); // 같은 초에 또 저장
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
File.Copy(full, target, overwrite: false);
AppendList(Path.Combine(Root, when.ToString("yyyy-MM-dd")), when, full, target);
Kept?.Invoke(full, target);
return target;
}
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is NotSupportedException)
{
throw new InvalidOperationException($"백업을 만들지 못해 저장하지 않았습니다 - {ex.Message} (설정 \"저장할 때 백업\" 을 끄면 백업 없이 저장합니다)", ex);
}
}
}
/// <summary>
/// 백업 경로(저장소 없이 검사). <c>D:\Src\A.cs</c> → <c>{root}\2026-09-22\D\Src\A.20260922-210501.cs</c>,
/// 네트워크 경로 <c>\\서버\공유\A.cs</c> → <c>{root}\날짜\UNC\서버\공유\A.날짜-시각.cs</c>.
/// 너무 길면 <c>{root}\날짜\_긴경로\폴더해시8_A.날짜-시각.cs</c>(원래 자리는 목록.txt 에 남는다).
/// </summary>
public static string PathFor(string root, string fullPath, DateTime when, int number = 1)
{
var day = when.ToString("yyyy-MM-dd");
var name = StampedName(Path.GetFileName(fullPath), when, number);
var folder = Path.GetDirectoryName(fullPath) ?? string.Empty;
var candidate = Path.Combine(root, day, Mirror(folder), name);
if (candidate.Length <= MaxPath) return candidate;
return Path.Combine(root, day, "_긴경로", Hash8(folder) + "_" + name);
}
/// <summary>
/// <c>A.cs</c> → <c>A.20260922-210501.cs</c>. 확장자를 끝에 두어 두 번 누르면 원래 프로그램으로 열린다.
/// 같은 초에 두 번째면 <c>A.20260922-210501-2.cs</c>.
/// </summary>
public static string StampedName(string fileName, DateTime when, int number = 1)
{
var stamp = when.ToString("yyyyMMdd-HHmmss") + (number > 1 ? "-" + number : string.Empty);
var stem = Path.GetFileNameWithoutExtension(fileName);
var ext = Path.GetExtension(fileName);
// ".gitignore" · 확장자 없는 "Makefile" 은 이름 뒤에 붙인다
return string.IsNullOrEmpty(stem) || string.IsNullOrEmpty(ext)
? fileName + "." + stamp
: stem + "." + stamp + ext;
}
/// <summary>원래 폴더를 백업 폴더 안의 상대 경로로. <c>D:\Src</c> → <c>D\Src</c>, <c>\\서버\공유\x</c> → <c>UNC\서버\공유\x</c>.</summary>
private static string Mirror(string folder)
{
if (folder.StartsWith(@"\\", StringComparison.Ordinal))
return Path.Combine("UNC", folder.TrimStart('\\'));
if (folder.Length >= 2 && folder[1] == ':')
return folder.Substring(0, 1).ToUpperInvariant() + folder.Substring(2); // "D" + "\Src"
return folder.TrimStart('\\');
}
private static void AppendList(string dayFolder, DateTime when, string original, string backup)
{
Directory.CreateDirectory(dayFolder);
File.AppendAllText(Path.Combine(dayFolder, ListFileName),
$"{when:HH:mm:ss}\t{original}\t{backup}\r\n", new UTF8Encoding(true));
}
private static string Hash8(string text)
{
using var sha = SHA1.Create();
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(text.ToUpperInvariant()));
var sb = new StringBuilder();
for (var i = 0; i < 4; i++) sb.Append(bytes[i].ToString("x2"));
return sb.ToString();
}
}
}

View File

@ -1,4 +1,3 @@
using System;
using System.IO;
namespace MergeHub.Services
@ -36,20 +35,15 @@ namespace MergeHub.Services
/// 폴더를 통째로 복사한다. 대상에 이미 있는 파일은 덮어쓰고, 대상에만 있는 파일은 그대로 둔다.
/// (지우면서 맞추는 "동기화"가 아니다 - 지우는 것은 삭제 버튼이 따로 맡는다)
/// </summary>
/// <param name="beforeOverwrite">있는 파일을 덮어쓰기 직전에 부른다(11차 F-9 백업). 던지면 그 파일부터 멈춘다.</param>
public static void CopyDirectory(string from, string to, Action<string>? beforeOverwrite = null)
public static void CopyDirectory(string from, string to)
{
Directory.CreateDirectory(to);
foreach (var file in Directory.GetFiles(from))
{
var target = Path.Combine(to, Path.GetFileName(file));
if (beforeOverwrite is not null && File.Exists(target)) beforeOverwrite(target);
File.Copy(file, target, overwrite: true);
}
File.Copy(file, Path.Combine(to, Path.GetFileName(file)), overwrite: true);
foreach (var child in Directory.GetDirectories(from))
CopyDirectory(child, Path.Combine(to, Path.GetFileName(child)), beforeOverwrite);
CopyDirectory(child, Path.Combine(to, Path.GetFileName(child)));
}
}
}

View File

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

View File

@ -1,54 +0,0 @@
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

@ -1,188 +0,0 @@
using System;
using System.Runtime.InteropServices;
using System.Text;
namespace MergeHub.Services
{
/// <summary>
/// 원격 토큰을 어디에 어떤 이름으로 둘지, 실패한 말을 사람 말로 어떻게 옮길지(11차 F-11). 윈도 없이 검사한다.
/// </summary>
public static class GitCredentialKey
{
/// <summary>자격 증명 이름 앞머리. 다른 프로그램 것과 섞이지 않게 둔다.</summary>
public const string Prefix = "MergeHub:git:";
/// <summary>
/// 이 원격 주소의 토큰을 둘 이름. 서버(호스트 · 포트)마다 따로 둔다 - 저장소가 달라도 같은 서버면 한 번만 넣으면 된다.
/// 주소를 읽을 수 없으면 null(저장하지 않는다 - 로컬 경로 원격에는 토큰이 필요 없다).
/// </summary>
public static string? For(string? remoteUrl)
{
if (string.IsNullOrWhiteSpace(remoteUrl)) return null;
var text = remoteUrl!.Trim();
// git@서버:팀/저장소.git (SSH) 는 토큰을 쓰지 않는다
if (text.StartsWith("git@", StringComparison.OrdinalIgnoreCase)) return null;
if (!Uri.TryCreate(text, UriKind.Absolute, out var uri)) return null;
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) return null;
if (string.IsNullOrEmpty(uri.Host)) return null;
var port = uri.IsDefaultPort ? string.Empty : ":" + uri.Port;
return Prefix + uri.Scheme.ToLowerInvariant() + "://" + uri.Host.ToLowerInvariant() + port;
}
}
/// <summary>Git 이 영어로 돌려주는 실패를 사람 말로(11차 F-11).</summary>
public static class GitErrors
{
/// <summary>
/// 인증이 필요하다는 말이면 무엇을 하라는 말로 바꾼다. 아니면 null - 부르는 쪽이 원래 말을 쓴다.
/// </summary>
public static string? Friendly(string? message)
{
if (string.IsNullOrWhiteSpace(message)) return null;
var text = message!.ToLowerInvariant();
var needsLogin = text.Contains("authentication required") ||
text.Contains("no callback set") ||
text.Contains("401") ||
text.Contains("too many redirects or authentication replays") ||
text.Contains("authentication failed") ||
text.Contains("unauthorized");
if (!needsLogin) return null;
var wrong = text.Contains("failed") || text.Contains("401") || text.Contains("unauthorized") ||
text.Contains("replays");
return wrong
? "계정 또는 토큰이 맞지 않습니다. Git 탭 원격 칸에서 다시 넣어 주세요."
: "원격이 계정 · 토큰을 요구합니다. Git 탭 원격 칸에 계정과 토큰을 넣고 다시 해 보세요.";
}
}
/// <summary>
/// 원격 토큰을 <b>윈도 자격 증명 관리자</b>에 맡긴다(11차 F-11, 사용자 결정 - 평문으로 두지 않는다).
///
/// 윈도가 사용자 계정 키로 암호화해 두므로(DPAPI) 다른 계정 · 다른 PC 에서는 읽히지 않고, 설정 파일에는 토큰 글자가 남지 않는다.
/// 이름은 서버마다 하나(<see cref="GitCredentialKey.For"/>). 못 읽거나 못 쓰면 <b>조용히 넘어간다</b> - 토큰은 덤이고,
/// 없으면 사용자가 그때 넣으면 된다.
/// </summary>
public sealed class GitCredentialStore
{
private const int CRED_TYPE_GENERIC = 1;
private const int CRED_PERSIST_LOCAL_MACHINE = 2;
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct CREDENTIAL
{
public int Flags;
public int Type;
public IntPtr TargetName;
public IntPtr Comment;
public long LastWritten;
public int CredentialBlobSize;
public IntPtr CredentialBlob;
public int Persist;
public int AttributeCount;
public IntPtr Attributes;
public IntPtr TargetAlias;
public IntPtr UserName;
}
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "CredWriteW")]
private static extern bool CredWrite(ref CREDENTIAL credential, int flags);
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "CredReadW")]
private static extern bool CredRead(string target, int type, int reservedFlag, out IntPtr credentialPtr);
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "CredDeleteW")]
private static extern bool CredDelete(string target, int type, int flags);
[DllImport("advapi32.dll")]
private static extern void CredFree(IntPtr buffer);
/// <summary>이 서버의 계정 · 토큰을 맡긴다. 맡겼으면 true.</summary>
public bool Save(string? remoteUrl, string? userName, string? token)
{
var target = GitCredentialKey.For(remoteUrl);
if (target is null || string.IsNullOrEmpty(token)) return false;
var blob = Encoding.Unicode.GetBytes(token);
var targetPtr = Marshal.StringToCoTaskMemUni(target);
var userPtr = Marshal.StringToCoTaskMemUni(userName ?? string.Empty);
var blobPtr = Marshal.AllocCoTaskMem(blob.Length);
try
{
Marshal.Copy(blob, 0, blobPtr, blob.Length);
var credential = new CREDENTIAL
{
Type = CRED_TYPE_GENERIC,
TargetName = targetPtr,
CredentialBlobSize = blob.Length,
CredentialBlob = blobPtr,
Persist = CRED_PERSIST_LOCAL_MACHINE,
UserName = userPtr,
};
return CredWrite(ref credential, 0);
}
catch (Exception ex) when (ex is DllNotFoundException || ex is EntryPointNotFoundException)
{
return false;
}
finally
{
Marshal.FreeCoTaskMem(targetPtr);
Marshal.FreeCoTaskMem(userPtr);
Marshal.FreeCoTaskMem(blobPtr);
}
}
/// <summary>맡겨 둔 계정 · 토큰. 없으면 (null, null).</summary>
public (string? UserName, string? Token) Load(string? remoteUrl)
{
var target = GitCredentialKey.For(remoteUrl);
if (target is null) return (null, null);
var ptr = IntPtr.Zero;
try
{
if (!CredRead(target, CRED_TYPE_GENERIC, 0, out ptr)) return (null, null);
var credential = Marshal.PtrToStructure<CREDENTIAL>(ptr);
var user = credential.UserName == IntPtr.Zero ? null : Marshal.PtrToStringUni(credential.UserName);
var token = credential.CredentialBlob == IntPtr.Zero || credential.CredentialBlobSize <= 0
? null
: Marshal.PtrToStringUni(credential.CredentialBlob, credential.CredentialBlobSize / 2);
return (user, token);
}
catch (Exception ex) when (ex is DllNotFoundException || ex is EntryPointNotFoundException)
{
return (null, null);
}
finally
{
if (ptr != IntPtr.Zero) CredFree(ptr);
}
}
/// <summary>맡겨 둔 것을 지운다. 지웠으면 true(없었으면 false).</summary>
public bool Forget(string? remoteUrl)
{
var target = GitCredentialKey.For(remoteUrl);
if (target is null) return false;
try
{
return CredDelete(target, CRED_TYPE_GENERIC, 0);
}
catch (Exception ex) when (ex is DllNotFoundException || ex is EntryPointNotFoundException)
{
return false;
}
}
}
}

View File

@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
@ -46,9 +45,6 @@ namespace MergeHub.Services
public bool WordLevelDiff { get; set; }
/// <summary>워킹트리 파일을 덮어쓰거나 지우기 직전(11차 F-9 백업). 던지면 쓰지 않는다.</summary>
public Action<string>? BeforeOverwrite { get; set; }
public event EventHandler<string>? Progress;
private Repository Repo =>
@ -1006,106 +1002,6 @@ namespace MergeHub.Services
return lines.Skip(1).Count(l => l.Trim().Length > 0);
}
/// <summary>
/// 이번 병합에서 처음 충돌 났다가 지금은 푼 파일(11차 F-8). [다시 충돌로] 로 되돌릴 수 있는 것만 준다 -
/// git 이 해결 전 세 벌(REUC)을 기억하는 파일. 병합 중이 아니거나 MergeHub 밖에서 시작한 병합이면 빈 목록.
/// </summary>
public IReadOnlyList<string> GetResolvedMergeConflicts()
{
var path = Path.Combine(Repo.Info.Path, MergeConflictListFile);
var head = ReadMergeHeadSha();
if (string.IsNullOrEmpty(head) || !File.Exists(path)) return Array.Empty<string>();
var lines = File.ReadAllLines(path);
if (lines.Length == 0 || !string.Equals(lines[0].Trim(), head, StringComparison.OrdinalIgnoreCase)) return Array.Empty<string>();
return MergeConflictRecord.Resolved(
lines.Skip(1),
GetConflicts().Select(c => c.Path),
Repo.Index.Conflicts.ResolvedConflicts.Select(r => r.Path));
}
/// <summary>
/// 푼 파일을 다시 충돌로 되돌린다(11차 F-8, 사용자 결정 "되돌리면 다시 충돌로").
///
/// LibGit2Sharp 에는 인덱스에 충돌 세 벌을 다시 넣는 길이 없다. 이 한 곳만 git.exe 를 부른다(사용자 결정) -
/// <c>git checkout -m -- 파일</c> 은 해결 기록(REUC)으로 세 벌을 되살리고 워킹트리에 충돌 표시를 다시 쓴다.
/// 워킹트리에 저장해 둔 해결 내용은 충돌 표시 글로 바뀐다(부르는 쪽이 먼저 묻는다).
/// 끝나면 저장소를 다시 연다 - 열어 둔 객체는 인덱스를 다시 읽지 않아 충돌 목록이 비어 보였다(시험으로 확인).
/// </summary>
public void ReopenConflict(string path)
{
var normalized = NormalizePath(path);
var head = ReadMergeHeadSha();
if (string.IsNullOrEmpty(head)) throw new InvalidOperationException("병합 중이 아니라 다시 충돌로 되돌릴 수 없습니다.");
var workdir = Repo.Info.WorkingDirectory ?? throw new InvalidOperationException("워킹트리가 없는 저장소입니다.");
var gitDir = Repo.Info.Path;
RunGitExe(workdir, "-c", "core.quotepath=false", "checkout", "-m", "--", normalized);
_repo?.Dispose();
_repo = new Repository(gitDir);
// 해결 기록(8차 F-1)에서 뺀다 - 다시 풀면 그때 적는다.
try
{
var file = Path.Combine(gitDir, MergeResolutionLog.FileName);
if (File.Exists(file))
File.WriteAllText(file, MergeResolutionLog.Forget(File.ReadAllText(file), head!, normalized), new UTF8Encoding(false));
}
catch (IOException) { }
catch (UnauthorizedAccessException) { }
Report($"다시 충돌로: {normalized}");
}
/// <summary>git.exe 를 한 번 부른다(11차 F-8 [다시 충돌로] 만 쓴다). 실패하면 git 이 한 말을 담아 던진다.</summary>
private static void RunGitExe(string workdir, params string[] args)
{
var info = new ProcessStartInfo("git", string.Join(" ", args.Select(QuoteArg)))
{
WorkingDirectory = workdir,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
Process process;
try
{
process = Process.Start(info) ?? throw new InvalidOperationException("git.exe 를 실행하지 못했습니다.");
}
catch (System.ComponentModel.Win32Exception)
{
throw new InvalidOperationException("Git(git.exe)을 찾지 못했습니다. [다시 충돌로] 는 Git 이 설치되어 PATH 에 있어야 합니다.");
}
using (process)
{
var error = process.StandardError.ReadToEndAsync();
process.StandardOutput.ReadToEnd();
if (!process.WaitForExit(30000))
{
try { process.Kill(); } catch (InvalidOperationException) { }
throw new InvalidOperationException("git.exe 가 30초 안에 끝나지 않았습니다.");
}
if (process.ExitCode != 0)
throw new InvalidOperationException($"git 이 실패했습니다({process.ExitCode}): {error.Result.Trim()}");
}
}
/// <summary>Windows 명령줄 규칙으로 인자 하나를 감싼다(빈칸 · 따옴표가 든 경로).</summary>
private static string QuoteArg(string arg)
{
if (arg.Length > 0 && arg.IndexOfAny(new[] { ' ', '\t', '"' }) < 0) return arg;
return "\"" + arg.Replace("\"", "\\\"") + "\"";
}
/// <summary>
/// 쓸모없어진 충돌 목록 파일을 지운다(7차 B-1). 지웠으면 true.
/// 병합 커밋 · 작업 취소 직후와 상태를 다시 읽을 때마다 부른다 - 터미널에서 병합을 끝낸 경우도 다음 새로고침에 치운다.
@ -1221,8 +1117,6 @@ namespace MergeHub.Services
var directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
BeforeOverwrite?.Invoke(fullPath); // 충돌 표시가 든 지금 파일(11차 F-9)
if (resolution == ConflictResolution.Both)
{
// 양쪽 파일을 읽는다. 한쪽이 없으면 빈 것으로 본다.
@ -1276,7 +1170,6 @@ namespace MergeHub.Services
? ReadBlobBytes(referenceBlob, normalized)
: File.Exists(fullPath) ? File.ReadAllBytes(fullPath) : null;
BeforeOverwrite?.Invoke(fullPath); // 11차 F-9
File.WriteAllBytes(fullPath, TextFile.Encode(content, encodingName, newLineName, reference));
Commands.Stage(Repo, normalized);

View File

@ -42,9 +42,14 @@ namespace MergeHub.Services
private readonly SynchronizationContext? _uiContext;
private readonly Logger _logger = new Logger("MergeHub");
public GitWorkspace(IGitService git, ISettingsService settingsService)
/// <summary>플랫폼에 등록된 사용자. 머지 주석에 남길 이름을 여기서 가져온다.</summary>
private readonly UbisamBase.Core.Settings.CurrentUserService? _currentUser;
public GitWorkspace(IGitService git, ISettingsService settingsService,
UbisamBase.Core.Settings.CurrentUserService currentUser)
{
Git = git;
_currentUser = currentUser;
_uiContext = SynchronizationContext.Current;
_settingsService = settingsService;
Settings = settingsService.RegisterSettings(nameof(MergeHubSettings), new MergeHubSettings());
@ -53,17 +58,10 @@ namespace MergeHub.Services
RemoteName = string.IsNullOrWhiteSpace(Settings.DefaultRemoteName) ? "origin" : Settings.DefaultRemoteName;
RemoteUrl = Settings.GiteaUrl;
UserName = Settings.GiteaUserName;
LoadToken(); // 이 서버에 맡겨 둔 토큰이 있으면 가져온다(11차 F-11)
Git.IgnoreWhitespace = Settings.IgnoreWhitespace;
Git.WordLevelDiff = Settings.WordLevelDiff;
// 저장할 때 백업(11차 F-9). Git 이 워킹트리 파일을 쓰는 길(병합 중 Save · 파일 단위 Accept)도 같은 것을 부른다.
Backup = new FileBackup(FileBackup.DefaultRoot(UbisamBase.Core.Bootstrap.UbisamAppBuilder.ConfigRoot),
() => Settings.BackupBeforeSave);
Backup.Kept += (original, backup) => AppendLog($"백업: {original} → {backup}");
Git.BeforeOverwrite = path => Backup.Keep(path);
// 작성자 이름은 여기서 한 번 정해 둔다.
// 저장소를 읽을 때(ReadState)만 정하면 Git 없이 파일 두 개를 비교할 때 이름이 비고,
// 그러면 주석에 등록한 이름 대신 윈도우 계정이 찍힌다.
@ -90,6 +88,15 @@ namespace MergeHub.Services
_logger.W($"보내기 바로가기 확인 실패: {ex.Message}");
}
// 설정 화면에서 이름을 바꾸면 바로 따라간다.
if (_currentUser is not null)
{
_currentUser.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(_currentUser.UserName)) AuthorName = ResolveAuthorName();
};
}
Git.Progress += (_, message) =>
{
_logger.I(message);
@ -116,9 +123,6 @@ namespace MergeHub.Services
public MergeHubSettings Settings { get; }
/// <summary>저장할 때 백업(11차 F-9). 설정이 꺼져 있으면 아무것도 하지 않는다.</summary>
public FileBackup Backup { get; }
private readonly ISettingsService _settingsService;
/// <summary>
@ -127,91 +131,6 @@ namespace MergeHub.Services
/// </summary>
public void SaveSettings() => _settingsService.Save(nameof(MergeHubSettings));
// ================= 원격 주소 · 계정 · 토큰 (11차 F-11) =================
/// <summary>토큰을 맡겨 두는 곳 - 윈도 자격 증명 관리자(사용자 결정). 설정 파일에는 토큰 글자가 남지 않는다.</summary>
private readonly GitCredentialStore _tokens = new GitCredentialStore();
/// <summary>주소 · 계정을 설정에 적는 것을 잠깐 모아 둔다 - 글자 하나마다 파일을 쓰지 않으려고.</summary>
private System.Threading.Timer? _remoteSaveTimer;
partial void OnRemoteUrlChanged(string value)
{
QueueRemoteSave();
LoadToken(); // 서버가 바뀌면 그 서버에 맡겨 둔 토큰으로
}
partial void OnUserNameChanged(string value) => QueueRemoteSave();
private void QueueRemoteSave()
{
_remoteSaveTimer?.Dispose();
_remoteSaveTimer = new System.Threading.Timer(_ => OnUi(SaveRemoteSettings), null, 800, System.Threading.Timeout.Infinite);
}
/// <summary>Git 탭 원격 칸에 적은 주소 · 계정을 설정에 남긴다(11차 F-11 - 껐다 켜도 남는다).</summary>
private void SaveRemoteSettings()
{
var url = RemoteUrl ?? string.Empty;
var user = UserName ?? string.Empty;
if (Settings.GiteaUrl == url && Settings.GiteaUserName == user) return;
Settings.GiteaUrl = url;
Settings.GiteaUserName = user;
SaveSettings();
}
/// <summary>지금 토큰 칸에 든 것이 어느 서버에서 가져온 것인지. 주소를 바꾸면 남의 서버로 보내지 않게 치운다.</summary>
private string? _tokenFrom;
/// <summary>
/// 이 서버에 맡겨 둔 계정 · 토큰을 가져온다. 사람이 직접 적어 둔 토큰은 건드리지 않는다 -
/// 다른 서버에서 가져왔던 토큰만 치운다(다른 서버로 보내면 안 된다).
/// </summary>
public void LoadToken()
{
var key = GitCredentialKey.For(RemoteUrl);
if (_tokenFrom is not null && _tokenFrom != key)
{
Password = string.Empty; // 앞 서버에서 가져온 토큰이다
_tokenFrom = null;
}
HasSavedToken = false;
var (user, token) = _tokens.Load(RemoteUrl);
if (string.IsNullOrEmpty(token)) return;
Password = token!;
_tokenFrom = key;
if (string.IsNullOrWhiteSpace(UserName) && !string.IsNullOrWhiteSpace(user)) UserName = user!;
HasSavedToken = true;
}
/// <summary>원격 일(fetch · pull · push · 클론)이 잘 끝났다 - 그때의 계정 · 토큰을 맡겨 둔다.</summary>
public void RememberToken()
{
if (string.IsNullOrEmpty(Password)) return;
if (!_tokens.Save(RemoteUrl, UserName, Password)) return;
HasSavedToken = true;
_tokenFrom = GitCredentialKey.For(RemoteUrl);
AppendLog($"토큰을 윈도 자격 증명 관리자에 저장했습니다({GitCredentialKey.For(RemoteUrl)}). 다음에 켤 때 다시 넣지 않아도 됩니다.");
}
/// <summary>맡겨 둔 토큰을 지운다. 화면의 토큰 칸도 비운다.</summary>
public bool ForgetToken()
{
var had = _tokens.Forget(RemoteUrl);
Password = string.Empty;
HasSavedToken = false;
_tokenFrom = null;
if (had) AppendLog($"저장한 토큰을 지웠습니다({GitCredentialKey.For(RemoteUrl)}).");
return had;
}
/// <summary>이 서버의 토큰을 맡겨 두었는지. 화면의 안내 · [저장한 토큰 지우기] 단추가 본다.</summary>
[ObservableProperty] private bool _hasSavedToken;
/// <summary>저장소 상태가 다시 읽힐 때마다 발생. 화면들이 자기 패널을 갱신한다.</summary>
public event Action? Refreshed;
@ -231,9 +150,6 @@ namespace MergeHub.Services
public ObservableCollection<CommitNode> CommitNodes { get; } = new ObservableCollection<CommitNode>();
public ObservableCollection<FileStatusInfo> StatusFiles { get; } = new ObservableCollection<FileStatusInfo>();
public ObservableCollection<ConflictInfo> Conflicts { get; } = new ObservableCollection<ConflictInfo>();
/// <summary>이번 병합에서 푼 파일 - [다시 충돌로] 로 되돌릴 수 있는 것(11차 F-8). 병합 중이 아니면 빈다.</summary>
public ObservableCollection<string> ResolvedConflicts { get; } = new ObservableCollection<string>();
public ObservableCollection<TagInfo> Tags { get; } = new ObservableCollection<TagInfo>();
public ObservableCollection<SubmoduleInfo> Submodules { get; } = new ObservableCollection<SubmoduleInfo>();
@ -367,10 +283,8 @@ namespace MergeHub.Services
_logger.E($"[{operation}] 실패", ex);
OnUi(() =>
{
// 인증이 필요하다는 영어 말은 무엇을 하라는 말로 바꿔 준다(11차 F-11).
var said = GitErrors.Friendly(ex.Message) is { } friendly ? $"{friendly} ({ex.Message})" : ex.Message;
AppendLog($"[{operation}] 실패: {said}");
MessageUtil.ShowErrorToast($"{operation} 실패: {said}");
AppendLog($"[{operation}] 실패: {ex.Message}");
MessageUtil.ShowErrorToast($"{operation} 실패: {ex.Message}");
});
return false;
}
@ -494,7 +408,6 @@ namespace MergeHub.Services
var mergeInProgress = Git.IsMergeInProgress;
var pendingHasChanges = !operation.IsInProgress || Git.HasStagedChanges();
var conflictTotal = mergeInProgress ? Git.GetMergeConflictTotal() : null;
var resolvedConflicts = mergeInProgress ? Git.GetResolvedMergeConflicts() : Array.Empty<string>();
var resolvedOperation = CommitSelection.IsResolvedOperation(operation.IsInProgress, conflicts.Count);
var remote = remotes.FirstOrDefault(r => r.Name == RemoteName) ?? remotes.FirstOrDefault();
@ -540,7 +453,6 @@ namespace MergeHub.Services
Replace(CommitNodes, nodes);
Replace(StatusFiles, status.Files);
Replace(Conflicts, conflicts);
Replace(ResolvedConflicts, resolvedConflicts);
MergeConflictTotal = conflictTotal;
MergeResolutionBody = resolutionBody;
OnPropertyChanged(nameof(ConflictProgressText));
@ -578,17 +490,19 @@ namespace MergeHub.Services
}
/// <summary>
/// MergeHub 설정 &gt; git config user.name &gt; 윈도우 계정 순으로 고른다.
/// MergeHub 설정 &gt; 플랫폼에 등록된 사용자 &gt; git config user.name &gt; 윈도우 계정 순으로 고른다.
///
/// 예전에는 플랫폼에 등록된 사용자를 가운데에 두었는데, 새 플랫폼에는 그 자리가 없어졌다.
/// 이름은 이제 이 프로그램 설정(머지 주석 &gt; 작성자)에서 받는다 - 장비를 여럿이 쓰는 현장에서는
/// 여기에 적어 두면 누가 만졌는지 주석에 남는다.
/// 플랫폼 사용자를 Git 설정보다 앞에 두는 이유 - 처음 켤 때 사람이 직접 적어 넣은 이름이고,
/// 장비를 여럿이 쓰는 현장에서는 그 이름이 실제로 작업한 사람이다.
/// MergeHub 설정을 더 앞에 두는 것은 "이 도구에서만 다르게 남기고 싶다"는 뜻이라 존중한다.
/// </summary>
private string ResolveAuthorName()
{
if (!string.IsNullOrWhiteSpace(Settings.MergeCommentAuthor))
return Settings.MergeCommentAuthor.Trim();
if (_currentUser?.HasName == true) return _currentUser.UserName.Trim();
try
{
var configured = Git.GetConfig("user.name");

View File

@ -87,12 +87,6 @@ namespace MergeHub.Services
/// <summary>이번 병합에서 처음 난 충돌 파일 수. MergeHub 가 병합할 때 적어 둔 것, 모르면 null(6차 C-1).</summary>
int? GetMergeConflictTotal();
/// <summary>이번 병합에서 처음 충돌 났다가 지금은 푼 파일 - [다시 충돌로] 로 되돌릴 수 있는 것(11차 F-8).</summary>
IReadOnlyList<string> GetResolvedMergeConflicts();
/// <summary>푼 파일을 다시 충돌로 되돌린다(11차 F-8). 워킹트리의 해결 내용은 충돌 표시 글로 바뀐다.</summary>
void ReopenConflict(string path);
/// <summary>쓸모없어진 처음 충돌 목록(.git/MERGEHUB_CONFLICTS)을 지운다. 지웠으면 true(7차 B-1).</summary>
bool ForgetStaleMergeConflicts();
@ -203,9 +197,6 @@ namespace MergeHub.Services
/// <summary>diff 결과의 각 줄에 단어 단위 변경 구간(DiffLine.Words)을 채운다. (기획안 4p)</summary>
bool WordLevelDiff { get; set; }
/// <summary>워킹트리 파일을 덮어쓰거나 지우기 직전에 부른다(11차 F-9 백업). 던지면 쓰지 않는다.</summary>
Action<string>? BeforeOverwrite { get; set; }
IReadOnlyList<FileDiff> DiffCommits(string oldCommittish, string newCommittish, params string[] paths);
/// <summary>

View File

@ -1,6 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System;
namespace MergeHub.Services
{
@ -25,21 +23,5 @@ namespace MergeHub.Services
if (string.IsNullOrWhiteSpace(recordFirstLine)) return true;
return !string.Equals(recordFirstLine!.Trim(), mergeHeadSha!.Trim(), StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// [다시 충돌로] 로 되돌릴 수 있는 파일(11차 F-8) - 이번 병합에서 처음 충돌 났고(<paramref name="initial"/>),
/// 지금은 충돌이 아니며(<paramref name="current"/> 에 없음), git 이 해결 전 세 벌을 기억하는(<paramref name="resolveUndo"/>, REUC) 파일.
/// REUC 만 보면 안 된다 - libgit2 는 저절로 병합된 파일도 거기 남긴다(6차 C-1). 처음 목록 차례를 지킨다.
/// </summary>
public static IReadOnlyList<string> Resolved(IEnumerable<string> initial, IEnumerable<string> current, IEnumerable<string> resolveUndo)
{
static string N(string p) => (p ?? string.Empty).Trim().Replace('\\', '/');
var now = new HashSet<string>(current.Select(N), StringComparer.Ordinal);
var undo = new HashSet<string>(resolveUndo.Select(N), StringComparer.Ordinal);
var seen = new HashSet<string>(StringComparer.Ordinal);
return initial.Select(N)
.Where(p => p.Length > 0 && !now.Contains(p) && undo.Contains(p) && seen.Add(p))
.ToList();
}
}
}

View File

@ -126,16 +126,6 @@ namespace MergeHub.Services
/// <summary>두 칸에서 BASE 쪽을 가져온 줄의 출처.</summary>
public static string BaseSource(string? name) => Named("BASE", name);
/// <summary>왼쪽 파일로 반영한 줄의 출처(12차 B-1). 두 파일 비교에서만 나온다.</summary>
public static string LocalSource(string? name) => Named("LOCAL", name);
/// <summary>왼쪽 파일로 반영한 로그(12차 B-1). 오른쪽으로 반영한 <see cref="TakeBaseLog"/> 의 거울이다.</summary>
public static string PushToBaseLog(string? number, string? localName, bool hasLine)
{
var label = LocalSource(localName);
return hasLine ? $"{number}행 {label} 을 왼쪽에 반영" : $"{number}행 왼쪽에서 삭제({label} 에 없음)";
}
private static string Named(string side, string? name) =>
string.IsNullOrWhiteSpace(name) ? side : $"{side}({name!.Trim()})";

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
@ -104,21 +104,6 @@ namespace MergeHub.Services
return sb.ToString();
}
/// <summary>
/// 한 파일의 기록을 뺀 글(11차 F-8 - [다시 충돌로] 한 파일은 아직 푼 것이 아니다).
/// 지난 병합의 기록이었으면 머리 줄만 남긴 새 글. 뺄 것이 없으면 같은 내용.
/// </summary>
public static string Forget(string? existing, string mergeHead, string path)
{
var normalized = Normalize(path);
var sb = new StringBuilder();
sb.Append(mergeHead.Trim()).Append('\n');
foreach (var entry in Parse(existing, mergeHead))
if (!string.Equals(entry.Path, normalized, StringComparison.Ordinal))
sb.Append(entry.Kind).Append('\t').Append(entry.Path).Append('\n');
return sb.ToString();
}
/// <summary>
/// 병합 커밋 본문 블록. 기록이 없으면 null.
/// <code>

View File

@ -1,120 +0,0 @@
using System;
using System.Collections.Generic;
namespace MergeHub.Services
{
/// <summary>정렬 행 [Start, End) 한 덩어리 - 두 칸 사이에 잇는 선 하나.</summary>
public readonly struct LinkBlock
{
public LinkBlock(int start, int end)
{
Start = start;
End = end;
}
public int Start { get; }
public int End { get; }
public int Length => End - Start;
public override string ToString() => $"[{Start},{End})";
}
/// <summary>
/// 칸마다 자기 줄만 보이는 화면(11차 F-10, Araxis 식)에서 두 칸을 잇는 계산. 화면 없이 검사한다.
///
/// 속은 지금처럼 <b>정렬 행</b>(한쪽에 없는 줄은 그 칸에서 높이 0) 하나다. 칸 k 의 높이 목록 = 정렬 행마다 그 칸에서의 높이
/// (그 칸에 없는 줄이면 0). 이것으로 ① 한 칸의 자리를 다른 칸의 자리로 옮기고(스크롤 맞추기) ② 바뀐 덩어리를 찾는다(잇는 선).
/// </summary>
public static class PaneLinks
{
/// <summary>정렬 행 i 의 윗변 자리. 길이 n+1 - 마지막은 칸 전체 높이.</summary>
public static double[] Tops(IReadOnlyList<double> heights)
{
var tops = new double[heights.Count + 1];
for (var i = 0; i < heights.Count; i++) tops[i + 1] = tops[i] + Math.Max(0, heights[i]);
return tops;
}
/// <summary>
/// 바뀐 행이 이어지는 덩어리들. <paramref name="changed"/>[i] = 정렬 행 i 가 두 칸 사이에서 다른가.
/// </summary>
public static List<LinkBlock> Blocks(IReadOnlyList<bool> changed)
{
var blocks = new List<LinkBlock>();
var i = 0;
while (i < changed.Count)
{
if (!changed[i]) { i++; continue; }
var start = i;
while (i < changed.Count && changed[i]) i++;
blocks.Add(new LinkBlock(start, i));
}
return blocks;
}
}
/// <summary>
/// 한 칸(A)의 세로 자리를 다른 칸(B)의 자리로 옮긴다 - 스크롤을 맞출 때 쓴다(11차 F-10).
///
/// 두 칸에 <b>함께 있는 줄</b>의 가운데를 짝점으로 삼고 그 사이는 비율로 잇는다. 같은 줄이 이어지는 곳은 1:1,
/// 한쪽에만 있는 덩어리를 지날 때는 짝점 사이가 벌어진 만큼 천천히/빨리 따라간다 - 덩어리 경계에서 한 번에 튀지 않는다
/// (Araxis 가 이렇게 따라간다). 함께 있는 줄이 없으면 두 칸 전체 높이의 비율로.
/// </summary>
public sealed class PaneMapping
{
private readonly double[] _a;
private readonly double[] _b;
public PaneMapping(IReadOnlyList<double> heightsA, IReadOnlyList<double> heightsB)
{
if (heightsA.Count != heightsB.Count) throw new ArgumentException("두 칸의 정렬 행 수가 다릅니다.");
var topA = PaneLinks.Tops(heightsA);
var topB = PaneLinks.Tops(heightsB);
TotalA = topA[topA.Length - 1];
TotalB = topB[topB.Length - 1];
var xs = new List<double> { 0 };
var ys = new List<double> { 0 };
for (var i = 0; i < heightsA.Count; i++)
{
if (heightsA[i] <= 0 || heightsB[i] <= 0) continue;
xs.Add(topA[i] + heightsA[i] / 2);
ys.Add(topB[i] + heightsB[i] / 2);
}
xs.Add(TotalA);
ys.Add(TotalB);
_a = xs.ToArray();
_b = ys.ToArray();
}
public double TotalA { get; }
public double TotalB { get; }
/// <summary>A 의 자리 → B 의 자리. 끝 밖은 기울기 1 로 늘인다.</summary>
public double Map(double yA) => Interpolate(_a, _b, yA);
/// <summary>B 의 자리 → A 의 자리.</summary>
public double MapBack(double yB) => Interpolate(_b, _a, yB);
private static double Interpolate(double[] from, double[] to, double y)
{
var last = from.Length - 1;
if (y <= from[0]) return to[0] + (y - from[0]);
if (y >= from[last]) return to[last] + (y - from[last]);
// from 은 줄어들지 않는다 - y 가 들어가는 마지막 구간
int lo = 0, hi = last;
while (hi - lo > 1)
{
var mid = (lo + hi) / 2;
if (from[mid] <= y) lo = mid; else hi = mid;
}
var span = from[hi] - from[lo];
if (span <= 0) return to[hi];
return to[lo] + (to[hi] - to[lo]) * (y - from[lo]) / span;
}
}
}

View File

@ -1,83 +0,0 @@
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

@ -1,56 +0,0 @@
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

@ -1,187 +0,0 @@
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

@ -1,50 +0,0 @@
using System;
namespace MergeHub.Services
{
/// <summary>
/// 편집 칸의 두 기준 글(11차 F-8) — <b>파일을 연 시점</b>과 <b>마지막으로 저장한 것</b>.
///
/// 예전에는 저장하는 길마다 되돌리기 목록을 비우고, "저장할 것 있음" 을 "되돌리기 목록이 비었는가" 로 봤다.
/// 그래서 저장한 뒤에는 Undo 가 꺼졌다. 이제 목록은 두고, 저장할 것이 있는지는 <b>지금 글이 마지막 저장과 다른가</b>로 본다 —
/// 저장 뒤 Undo 하면 달라져 "있음", Redo 로 저장 지점에 오면 같아져 "없음". 저장 표시를 따로 들고 다닐 필요가 없다.
/// [전체 되돌리기] 는 파일을 연 시점으로 간다(사용자 결정).
/// </summary>
public sealed class SaveBaseline
{
/// <summary>파일을 연 시점의 글. [전체 되돌리기] 가 가는 곳.</summary>
public string Opened { get; private set; } = string.Empty;
/// <summary>마지막으로 저장한 글(연 뒤 저장하지 않았으면 연 시점과 같다).</summary>
public string Saved { get; private set; } = string.Empty;
public string RevertTarget => Opened;
/// <summary>파일을 새로 열었다.</summary>
public void Open(string text)
{
Opened = text ?? string.Empty;
Saved = Opened;
}
/// <summary>화면을 잠깐 비웠다가 같은 파일 · 같은 글을 다시 실었다 - 맡겨 둔 두 기준을 되찾는다.</summary>
public void Resume(string opened, string saved)
{
Opened = opened ?? string.Empty;
Saved = saved ?? string.Empty;
}
/// <summary>줄바꿈(CRLF · LF)과 끝 줄바꿈만 다르면 같은 글. 파일에서 읽은 글과 화면 글을 견줄 때 쓴다.</summary>
public static bool SameText(string? a, string? b) =>
string.Equals(Norm(a), Norm(b), StringComparison.Ordinal);
private static string Norm(string? s) => (s ?? string.Empty).Replace("\r\n", "\n").TrimEnd('\n');
/// <summary>파일에 썼다.</summary>
public void MarkSaved(string text) => Saved = text ?? string.Empty;
/// <param name="stylePending">C# 스타일 맞춤으로 연 글이 파일과 다르다(저장하면 반영된다).</param>
public bool IsDirty(string current, bool stylePending) =>
stylePending || !string.Equals(current ?? string.Empty, Saved, StringComparison.Ordinal);
}
}

View File

@ -38,11 +38,6 @@ namespace MergeHub.Services
FontBigger,
FontSmaller,
FontReset,
// 12차 E-2 문서 탭
NextTab,
PrevTab,
CloseTab,
}
/// <summary>단축키 한 줄. 키 처리와 설명 창이 <b>둘 다 이 목록만</b> 본다.</summary>
@ -148,17 +143,7 @@ namespace MergeHub.Services
"폴더 비교 목록에서 앞 파일로"),
new ShortcutEntry(ShortcutAction.NextFile, Key.PageDown, ModifierKeys.Control, "이동·북마크",
"폴더 비교 목록에서 다음 파일로",
"폴더 비교에서 파일을 열었을 때만 동작한다. 파일 하나를 보고 폴더 탭으로 돌아갔다 다시 여는 왕복을 없애려는 것이다. 12차 E-2 부터는 지금 탭의 짝만 바뀌어 탭이 늘지 않는다."),
// 12차 E-2 문서 탭. Ctrl+Tab · Ctrl+W 는 notepad++ · 브라우저와 같은 손버릇이다.
new ShortcutEntry(ShortcutAction.NextTab, Key.Tab, ModifierKeys.Control, "문서 탭",
"다음 문서 탭으로",
"짝 여러 개를 동시에 열어 두고 오갈 수 있다. 저장하지 않은 편집은 탭이 맡아 두므로 옮길 때 묻지 않는다."),
new ShortcutEntry(ShortcutAction.PrevTab, Key.Tab, ModifierKeys.Control | ModifierKeys.Shift, "문서 탭",
"앞 문서 탭으로"),
new ShortcutEntry(ShortcutAction.CloseTab, Key.W, ModifierKeys.Control, "문서 탭",
"이 문서 탭 닫기",
"닫아도 저장하지 않은 편집은 그대로 보관된다 - 묻지 않는다."),
"폴더 비교에서 파일을 열었을 때만 동작한다. 파일 하나를 보고 폴더 탭으로 돌아갔다 다시 여는 왕복을 없애려는 것이다."),
new ShortcutEntry(ShortcutAction.OpenFolderCompare, Key.D, ModifierKeys.Control, "파일·보기", "폴더 비교 열기",
"WinMerge 는 새 비교 창을 띄우지만 여기서는 탭 하나로 돌아간다 - 창을 여러 개 띄우는 도구가 아니다."),

View File

@ -33,28 +33,18 @@ namespace MergeHub.ViewModels
/// </summary>
public sealed partial class FolderNode : ObservableObject
{
public FolderNode(FolderCompareItem item, string leftRoot, string rightRoot, int depth = 0)
public FolderNode(FolderCompareItem item, string leftRoot, string rightRoot)
{
Item = item;
Depth = depth;
LeftPath = Path.Combine(leftRoot, item.RelativePath);
RightPath = Path.Combine(rightRoot, item.RelativePath);
foreach (var child in item.Children)
{
Children.Add(new FolderNode(child, leftRoot, rightRoot, depth + 1));
Children.Add(new FolderNode(child, leftRoot, rightRoot));
}
}
/// <summary>
/// 트리에서 몇 겹 안인지(뿌리 0).
///
/// 좌 · 우 두 목록을 나란히 두려면(12차 A-2) 가운데 칸과 오른쪽 칸이 <b>줄마다 같은 자리</b>에 와야 하는데,
/// TreeView 는 깊이만큼 줄 전체를 오른쪽으로 민다. 화면 쪽에서 이 값으로 민 만큼 되돌리고,
/// 이름에만 다시 그만큼 들여쓴다.
/// </summary>
public int Depth { get; }
public FolderCompareItem Item { get; }
public ObservableCollection<FolderNode> Children { get; } = new ObservableCollection<FolderNode>();
@ -98,11 +88,6 @@ namespace MergeHub.ViewModels
public string LeftInfo => Describe(Item.LeftExists, Item.LeftSize, Item.LeftModified);
public string RightInfo => Describe(Item.RightExists, Item.RightSize, Item.RightModified);
/// <summary>왼쪽에 있는 것인지. 없으면 왼쪽 목록의 그 줄은 <b>비워 둔다</b>(12차 A-2).</summary>
public bool HasLeft => Item.LeftExists;
public bool HasRight => Item.RightExists;
/// <summary>
/// 줄 단위 비교 화면으로 넘길 수 있는 항목인지.
///
@ -122,14 +107,10 @@ namespace MergeHub.ViewModels
private static string Describe(bool exists, long size, DateTime? modified)
{
// 없는 쪽은 비워 둔다(12차 A-2). 예전에는 "-" 를 적었는데, 두 목록을 나란히 놓고 보면
// 빈칸이 "여기엔 없다"를 더 빨리 말해 준다 - 어느 쪽에만 있는지는 가운데 상태 칸이 적는다.
if (!exists) return string.Empty;
if (!exists) return "-";
// 크기는 사람이 읽는 단위로(12차 A-1). 폴더 줄은 안에 든 파일 크기의 합이다 - 예전에는 폴더가 늘 0 B 였다.
var when = modified?.ToString("yyyy-MM-dd HH:mm") ?? string.Empty;
var text = Services.FolderSizeText.Of(size);
return string.IsNullOrEmpty(when) ? text : $"{text} · {when}";
return $"{size:N0} B · {when}";
}
}
@ -613,7 +594,7 @@ namespace MergeHub.ViewModels
foreach (var item in _lastResult.Items)
{
var node = new FolderNode(item, _lastLeft, _lastRight, 0);
var node = new FolderNode(item, _lastLeft, _lastRight);
if (!Keep(node)) continue;
Prune(node);
@ -772,7 +753,7 @@ namespace MergeHub.ViewModels
if (!Confirm(question, "폴더 복사")) return;
Run(() => FileOps.CopyDirectory(from, to, path => _workspace.Backup.Keep(path)), $"복사: {from} → {to} (파일 {files}개)");
Run(() => FileOps.CopyDirectory(from, to), $"복사: {from} → {to} (파일 {files}개)");
return;
}
@ -790,7 +771,6 @@ namespace MergeHub.ViewModels
{
var folder = Path.GetDirectoryName(to);
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
_workspace.Backup.Keep(to); // 덮어쓰기 전(11차 F-9) - 못 만들면 던져 복사하지 않는다
File.Copy(from, to, overwrite: true);
}, $"복사: {from} → {to}");
}

View File

@ -97,7 +97,6 @@ namespace MergeHub.ViewModels
[ObservableProperty] private string _commitMessage = string.Empty;
[ObservableProperty] private FileStatusInfo? _selectedStatusFile;
[ObservableProperty] private ConflictInfo? _selectedConflict;
[ObservableProperty] private string? _selectedResolvedConflict;
[ObservableProperty] private TagInfo? _selectedTag;
[ObservableProperty] private string _diffText = string.Empty;
[ObservableProperty] private StashInfo? _selectedStash;
@ -122,7 +121,6 @@ namespace MergeHub.ViewModels
partial void OnBlamePathChanged(string value) => RaiseCanExecuteChanged();
partial void OnSelectedStatusFileChanged(FileStatusInfo? value) => RaiseCanExecuteChanged();
partial void OnSelectedConflictChanged(ConflictInfo? value) => RaiseCanExecuteChanged();
partial void OnSelectedResolvedConflictChanged(string? value) => ReopenConflictCommand.NotifyCanExecuteChanged();
partial void OnSelectedTagChanged(TagInfo? value) => RaiseCanExecuteChanged();
partial void OnSelectedStashChanged(StashInfo? value) => RaiseCanExecuteChanged();
@ -201,15 +199,11 @@ namespace MergeHub.ViewModels
private bool CanClone() => !string.IsNullOrWhiteSpace(_workspace.RemoteUrl);
[RelayCommand(CanExecute = nameof(CanClone))]
private async Task CloneAsync()
private Task CloneAsync() => _workspace.RunAndRefreshAsync("클론", () =>
{
var ok = await _workspace.RunAndRefreshAsync("클론", () =>
{
_workspace.ApplyCredentials();
_workspace.Git.Clone(_workspace.RemoteUrl, _workspace.RepositoryPath);
});
if (ok) _workspace.RememberToken(); // 된 토큰만 맡긴다(11차 F-11)
}
_workspace.ApplyCredentials();
_workspace.Git.Clone(_workspace.RemoteUrl, _workspace.RepositoryPath);
});
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task AddRemoteAsync() => _workspace.RunAndRefreshAsync("원격 추가", () =>
@ -228,27 +222,12 @@ namespace MergeHub.ViewModels
() => _workspace.Git.RemoveRemote(_workspace.RemoteName));
[RelayCommand(CanExecute = nameof(IsOpen))]
private async Task FetchAsync()
private Task FetchAsync() => _workspace.RunAndRefreshAsync("fetch", () =>
{
var ok = await _workspace.RunAndRefreshAsync("fetch", () =>
{
_workspace.ApplyCredentials();
_workspace.Git.Fetch(_workspace.RemoteName);
_workspace.MarkFetched();
});
if (ok) _workspace.RememberToken();
}
private bool CanForgetToken() => _workspace.HasSavedToken || !string.IsNullOrEmpty(_workspace.Password);
/// <summary>맡겨 둔 토큰을 지운다(11차 F-11). 다른 사람에게 PC 를 넘길 때 · 토큰을 새로 받았을 때.</summary>
[RelayCommand]
private void ForgetToken()
{
var had = _workspace.ForgetToken();
MessageUtil.ShowSuccessToast(had ? "저장한 토큰을 지웠습니다." : "저장해 둔 토큰이 없습니다. 토큰 칸만 비웠습니다.");
RaiseCanExecuteChanged();
}
_workspace.ApplyCredentials();
_workspace.Git.Fetch(_workspace.RemoteName);
_workspace.MarkFetched();
});
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task PullAsync()
@ -256,12 +235,7 @@ namespace MergeHub.ViewModels
// 목록은 화면 스레드의 것이라 작업에 들어가기 전에 읽어 둔다.
var tracked = _workspace.Branches.FirstOrDefault(b => b.IsCurrentHead)?.TrackedBranch;
return PullCoreAsync(tracked);
}
private async Task PullCoreAsync(string? tracked)
{
var ok = await _workspace.RunAndRefreshAsync("pull", () =>
return _workspace.RunAndRefreshAsync("pull", () =>
{
_workspace.ApplyCredentials();
var result = _workspace.Git.Pull(_workspace.RemoteName);
@ -271,19 +245,14 @@ namespace MergeHub.ViewModels
// 예전에는 여기서 아무것도 안 해 주석만 다른 충돌까지 사람에게 물었고, 알림도 커밋 메시지도 없었다.
AfterMerge(result, MergeNames.PullMergeMessage(tracked, _workspace.CurrentBranch));
});
if (ok) _workspace.RememberToken();
}
[RelayCommand(CanExecute = nameof(IsOpen))]
private async Task PushAsync()
private Task PushAsync() => _workspace.RunAndRefreshAsync("push", () =>
{
var ok = await _workspace.RunAndRefreshAsync("push", () =>
{
_workspace.ApplyCredentials();
_workspace.Git.Push(remoteName: _workspace.RemoteName);
});
if (ok) _workspace.RememberToken();
}
_workspace.ApplyCredentials();
_workspace.Git.Push(remoteName: _workspace.RemoteName);
});
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task RefreshAsync() => _workspace.RefreshAsync();
@ -517,28 +486,6 @@ namespace MergeHub.ViewModels
return go ? ResolveAsync(ConflictResolution.Both) : Task.CompletedTask;
}
private bool CanReopenConflict() => _workspace.IsMergeInProgress && !string.IsNullOrEmpty(SelectedResolvedConflict);
/// <summary>
/// 푼 파일을 다시 충돌로 되돌린다(11차 F-8, 사용자 결정 "되돌리면 다시 충돌로").
/// 병합 중 [Save](해결로 저장)는 파일을 풀고 다음 충돌로 넘어가 편집기의 Undo 로는 돌아올 수 없다 - 여기서 되돌린다.
/// 워킹트리의 해결 내용이 충돌 표시 글로 바뀌므로 먼저 묻는다.
/// </summary>
[RelayCommand(CanExecute = nameof(CanReopenConflict))]
private async Task ReopenConflictAsync()
{
var path = SelectedResolvedConflict;
if (string.IsNullOrEmpty(path)) return;
if (!MessageUtil.ShowYesNo("다시 충돌로",
$"{path}\n\n이 파일을 푼 것을 되돌려 다시 충돌로 만듭니다.\n" +
"파일에 저장해 둔 해결 내용은 충돌 표시(<<<<<<< ======= >>>>>>>)가 든 글로 바뀌고, 다시 풀어야 병합 커밋을 할 수 있습니다.\n\n계속할까요?"))
return;
var ok = await _workspace.RunAndRefreshAsync("다시 충돌로", () => _workspace.Git.ReopenConflict(path!));
if (ok) MessageUtil.ShowSuccessToast($"다시 충돌로 · {path} - 충돌 목록에서 다시 풀 수 있습니다");
}
/// <summary>
/// 고른 충돌 파일을 파일 비교 탭의 3분할로 연다(6차 B-1). 목록을 두 번 누르거나 Enter 로도 온다.
/// 켜짐/꺼짐을 따로 두지 않는다 - 고른 것이 없으면 아무 일도 안 한다. 이진 파일은 줄 단위로 볼 수 없어 알린다.
@ -1121,8 +1068,6 @@ namespace MergeHub.ViewModels
/// <summary>CommunityToolkit 의 커맨드는 CommandManager 를 구독하지 않아 직접 알려야 한다.</summary>
private void RaiseCanExecuteChanged()
{
ReopenConflictCommand.NotifyCanExecuteChanged();
ForgetTokenCommand.NotifyCanExecuteChanged();
OpenCommand.NotifyCanExecuteChanged();
CloneCommand.NotifyCanExecuteChanged();
AddRemoteCommand.NotifyCanExecuteChanged();

View File

@ -1,336 +0,0 @@
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]);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,95 +0,0 @@
<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

@ -1,107 +0,0 @@
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

@ -1,31 +0,0 @@
using System;
using System.Windows;
using System.Windows.Media;
namespace MergeHub.Views.Controls
{
/// <summary>
/// 칸들 위에 겹쳐 두는 투명한 그림판(11차 F-10). 두 칸 사이 틈에 바뀐 덩어리를 잇는 선 · 칸 안의 덩어리 경계선을 그린다.
/// 마우스는 통과시킨다(IsHitTestVisible = false) - 아래 칸 · 구분선이 그대로 받는다.
/// 무엇을 그릴지는 화면(HomeView)이 안다 - 행 · 칸 좌표를 쥔 쪽이 <see cref="Draw"/> 로 그린다.
/// </summary>
public sealed class PaneLinkOverlay : FrameworkElement
{
public PaneLinkOverlay()
{
IsHitTestVisible = false;
SnapsToDevicePixels = true;
}
/// <summary>그릴 때 부른다.</summary>
public Action<DrawingContext>? Draw { get; set; }
protected override void OnRender(DrawingContext dc)
{
if (ActualWidth <= 0 || ActualHeight <= 0) return;
dc.PushClip(new RectangleGeometry(new Rect(0, 0, ActualWidth, ActualHeight)));
Draw?.Invoke(dc);
dc.Pop();
}
}
}

View File

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

View File

@ -4,8 +4,6 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:MergeHub.Views"
xmlns:shell="clr-namespace:UbisamBase.Core.Shell;assembly=UbisamBase.Core"
shell:PlatformChrome.FullBleed="True"
mc:Ignorable="d">
<UserControl.Resources>
<ResourceDictionary>
@ -124,17 +122,11 @@
<TextBlock Grid.Column="0" Text="Gitea 계정" Style="{StaticResource Muted}" Margin="0,0,6,0"/>
<TextBox Grid.Column="1" Text="{Binding Workspace.UserName, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Grid.Column="2" Text="토큰/암호" Style="{StaticResource Muted}" Margin="8,0,6,0"/>
<PasswordBox x:Name="TokenBox" Grid.Column="3" PasswordChanged="Password_Changed"
<PasswordBox Grid.Column="3" PasswordChanged="Password_Changed"
Background="{DynamicResource Ubisam.Brush.Input.Background}"
Foreground="{DynamicResource Ubisam.Brush.ContentForeground}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}" Padding="4,3"/>
<StackPanel Grid.Column="4" Orientation="Horizontal" HorizontalAlignment="Right">
<!-- 11차 F-11: 토큰은 윈도 자격 증명 관리자에 맡긴다. 맡겨 둔 것이 있을 때만 보인다. -->
<TextBlock Text="저장됨" Style="{StaticResource Muted}" FontSize="11" VerticalAlignment="Center" Margin="0,0,6,0"
ToolTip="이 서버의 토큰을 윈도 자격 증명 관리자에 맡겨 두었습니다. 설정 파일에는 토큰이 남지 않습니다"
Visibility="{Binding Workspace.HasSavedToken, Converter={StaticResource BoolToVis}}"/>
<Button Content="토큰 지우기" Command="{Binding ForgetTokenCommand}" Margin="0,0,8,0"
ToolTip="맡겨 둔 토큰을 지우고 토큰 칸을 비웁니다"/>
<Button Content="Fetch" Command="{Binding FetchCommand}"/>
<Button Content="Pull" Command="{Binding PullCommand}"/>
<Button Content="Push" Command="{Binding PushCommand}"/>
@ -245,7 +237,6 @@
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 진행 한 줄(6차 C-1): "남은 충돌 N개 · 푼 것 M개 (M/전체)". 병합이 아니면 비어 접힌다. -->
@ -313,37 +304,6 @@
<TextBlock Text="{Binding ConflictSelectionText}" Style="{StaticResource Muted}"
VerticalAlignment="Center" Margin="8,0,0,0"/>
</WrapPanel>
<!-- 이번 병합에서 푼 파일(11차 F-8). [Save](해결로 저장)는 다음 충돌로 넘어가 편집기 Undo 로 돌아올 수 없다 - 여기서 다시 충돌로 되돌린다.
병합 중이고 푼 파일이 있을 때만 보인다. -->
<Grid Grid.Row="3" Margin="0,10,0,0">
<Grid.Style>
<Style TargetType="Grid">
<Style.Triggers>
<DataTrigger Binding="{Binding Workspace.ResolvedConflicts.Count}" Value="0">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Grid.Style>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.ColumnSpan="2" Style="{StaticResource Muted}" Margin="0,0,0,4"
Text="{Binding Workspace.ResolvedConflicts.Count, StringFormat=이번 병합에서 푼 파일 {0}개 - 잘못 풀었으면 골라 [다시 충돌로]}"/>
<ListBox Grid.Row="1" ItemsSource="{Binding Workspace.ResolvedConflicts}" MaxHeight="120"
SelectedItem="{Binding SelectedResolvedConflict}"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
AutomationProperties.Name="푼 파일"/>
<Button Grid.Row="1" Grid.Column="1" Content="다시 충돌로" Command="{Binding ReopenConflictCommand}"
MinWidth="100" Margin="6,0,0,0" VerticalAlignment="Top"
ToolTip="고른 파일을 푼 것을 되돌려 다시 충돌로 만듭니다. 저장해 둔 해결 내용은 충돌 표시 글로 바뀝니다"/>
</Grid>
</Grid>
</TabItem>

View File

@ -17,15 +17,6 @@ namespace MergeHub.Views
// 다른 탭에 있는 동안 파일 비교 탭에서 고른 커밋의 내용을, 이 탭이 다시 보일 때 그린다(10차 B-1).
IsVisibleChanged += (_, e) => { if (e.NewValue is true) ViewModel?.ShowPendingDiff(); };
// 맡겨 둔 토큰을 토큰 칸에 보인다(11차 F-11).
DataContextChanged += (_, e) =>
{
if (e.OldValue is GitViewModel old) old.Workspace.PropertyChanged -= OnWorkspaceChanged;
if (e.NewValue is GitViewModel now) now.Workspace.PropertyChanged += OnWorkspaceChanged;
ShowToken();
};
Loaded += (_, _) => ShowToken();
// 이력에서 다른 커밋을 누르면 파일 비교 탭도 따라간다. 거기 저장 안 한 편집이 있으면 선택이 바뀌기 전에 묻고,
// [취소] 면 누름을 막아 선택이 그대로다(10차 B-1).
HistoryList.PreviewMouseLeftButtonDown += (_, e) =>
@ -86,11 +77,6 @@ namespace MergeHub.Views
private GitViewModel? ViewModel => DataContext as GitViewModel;
private void OnWorkspaceChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(MergeHub.Services.GitWorkspace.Password)) ShowToken();
}
private void BrowseFolder_Click(object sender, RoutedEventArgs e)
{
var vm = ViewModel;
@ -128,28 +114,9 @@ namespace MergeHub.Views
private void Password_Changed(object sender, RoutedEventArgs e)
{
if (_fillingToken) return;
if (sender is PasswordBox box && ViewModel is { } vm) vm.Workspace.Password = box.Password;
}
/// <summary>토큰 칸을 프로그램이 채우는 중. 사람이 친 것으로 되돌아오지 않게 막는다.</summary>
private bool _fillingToken;
/// <summary>
/// 맡겨 둔 토큰(11차 F-11)을 토큰 칸에 보인다. PasswordBox 는 바인딩이 안 돼 코드로 넣는다 -
/// 켤 때 한 번, 그리고 원격 주소가 바뀌어 다른 서버의 토큰을 가져왔을 때.
/// </summary>
private void ShowToken()
{
if (ViewModel is not { } vm) return;
var token = vm.Workspace.Password ?? string.Empty;
if (TokenBox.Password == token) return;
_fillingToken = true;
try { TokenBox.Password = token; }
finally { _fillingToken = false; }
}
private void Log_TextChanged(object sender, TextChangedEventArgs e)
{
if (sender is TextBox box) box.ScrollToEnd();

View File

@ -1,430 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Media;
using System.Windows.Threading;
using MergeHub.Models;
using MergeHub.Services;
using MergeHub.ViewModels;
namespace MergeHub.Views
{
/// <summary>
/// 칸마다 자기 줄만 보이는 화면(11차 F-10, Araxis 식).
///
/// 속은 예전처럼 <b>정렬 행</b> 하나(<see cref="HomeViewModel.Lines"/>)다 - 차이 · 반영 · 되돌리기 · 차이 이동이 그대로 쓴다.
/// 칸마다 그 칸에 있는 줄만 걸러 보이고(빈 줄로 높이를 맞추지 않는다), 두 칸의 짝은 틈에 그리는 잇는 선으로 보인다.
/// 스크롤은 한 칸을 굴리면 나머지가 <b>가운데 기준</b>으로 따라온다(<see cref="PaneMapping"/>).
/// </summary>
public partial class HomeView
{
/// <summary>그 칸에 있는 줄만 보이는 목록. 반영 · 되살리기로 LOCAL 줄이 생기고 사라지면 바로 따라간다.</summary>
private static ListCollectionView PaneView(HomeViewModel vm, MergeLine.TextPane pane)
{
var view = new ListCollectionView(vm.Lines) { Filter = o => o is MergeLine line && line.IsIn(pane) };
if (pane == MergeLine.TextPane.Local)
{
view.IsLiveFiltering = true;
view.LiveFilteringProperties.Add(nameof(MergeLine.HasLocal));
}
else if (pane == MergeLine.TextPane.Base)
{
// 두 파일 비교는 왼쪽으로도 반영한다(12차 B-1) - 왼쪽에 줄이 생기고 사라지므로 BASE 칸도 따라가야 한다.
// 이게 없으면 왼쪽에 넣은 줄이 안 보이고, 왼쪽에서 지운 줄이 빈 칸으로 남는다.
view.IsLiveFiltering = true;
view.LiveFilteringProperties.Add(nameof(MergeLine.HasBase));
}
return view;
}
private HomeViewModel? _linkedVm;
/// <summary>네 칸에 자기 줄 목록을 붙인다. 새 ViewModel 이 올 때 한 번.</summary>
private void BindPaneViews(HomeViewModel? vm)
{
if (_linkedVm is not null) _linkedVm.Lines.CollectionChanged -= OnLinesChanged;
_linkedVm = vm;
if (vm is null)
{
foreach (var list in new[] { LocalLines, BaseLines, RemoteLines, OriginalLocalLines }) list.ItemsSource = null;
return;
}
LocalLines.ItemsSource = PaneView(vm, MergeLine.TextPane.Local);
BaseLines.ItemsSource = PaneView(vm, MergeLine.TextPane.Base);
RemoteLines.ItemsSource = PaneView(vm, MergeLine.TextPane.Remote);
OriginalLocalLines.ItemsSource = PaneView(vm, MergeLine.TextPane.OriginalLocal);
vm.Lines.CollectionChanged += OnLinesChanged;
LinkOverlay.Draw = DrawLinks;
}
private void OnLinesChanged(object? sender, NotifyCollectionChangedEventArgs e) => ScheduleResync();
private bool _resyncQueued;
/// <summary>글 · 줄이 바뀌면 칸마다 높이가 달라진다 - 배치가 끝난 뒤 편집기 기준으로 한 번 다시 맞추고 선을 다시 그린다.</summary>
private void ScheduleResync()
{
if (_resyncQueued) return;
_resyncQueued = true;
Dispatcher.BeginInvoke(new Action(() =>
{
_resyncQueued = false;
if (FindScrollViewer(LocalEditor) is { } viewer)
{
HoldSync();
SyncPanesFrom(MergeLine.TextPane.Local, viewer.VerticalOffset, viewer.ViewportHeight);
}
LinkOverlay.InvalidateVisual();
}), DispatcherPriority.Loaded);
}
/// <summary>
/// 칸 목록에서 줄을 눌렀다. 선택 줄은 ViewModel 이 쥔다 - 목록은 한 방향으로만 받는다(OneWay).
/// 고른 줄이 그 칸에 없으면(다른 칸에만 있는 줄) 목록 선택은 비지만, 그것을 ViewModel 로 되돌려 보내지 않는다.
/// </summary>
private void PaneLines_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (DataContext is not HomeViewModel vm) return;
if (e.AddedItems.Count == 0 || e.AddedItems[0] is not MergeLine line) return;
if (!ReferenceEquals(vm.SelectedLine, line)) vm.SelectedLine = line;
}
/// <summary>지금 보이는 칸들(목록 · 칸 종류). LOCAL 은 편집기가 주인이라 목록과 함께 따로 다룬다.</summary>
private (ListBox List, MergeLine.TextPane Kind)[] VisiblePanes(HomeViewModel vm) => vm.IsThreeWay
? new[]
{
(OriginalLocalLines, MergeLine.TextPane.OriginalLocal),
(BaseLines, MergeLine.TextPane.Base),
(RemoteLines, MergeLine.TextPane.Remote),
(LocalLines, MergeLine.TextPane.Local),
}
: new[]
{
(BaseLines, MergeLine.TextPane.Base),
(LocalLines, MergeLine.TextPane.Local),
};
/// <summary>잇는 선을 그릴 칸 짝(왼쪽 · 오른쪽). 3-Way 는 위 세 칸 사이 두 틈(사용자 결정 - MERGED 는 따라 스크롤만).</summary>
private (ListBox Left, MergeLine.TextPane LeftKind, ListBox Right, MergeLine.TextPane RightKind)[] LinkPairs(HomeViewModel vm) => vm.IsThreeWay
? new[]
{
(OriginalLocalLines, MergeLine.TextPane.OriginalLocal, BaseLines, MergeLine.TextPane.Base),
(BaseLines, MergeLine.TextPane.Base, RemoteLines, MergeLine.TextPane.Remote),
}
: new[]
{
(BaseLines, MergeLine.TextPane.Base, LocalLines, MergeLine.TextPane.Local),
};
/// <summary>워드랩을 켰을 때만 칸마다 잰 줄 높이를 쓴다. 끄면 모든 줄이 한 줄 높이라 그것이 정확하다.</summary>
private bool MeasuredRows => WrapMode == TextWrapping.Wrap;
/// <summary>정렬 행마다 그 칸에서의 높이(없는 줄은 0).</summary>
private double[] PaneHeights(HomeViewModel vm, MergeLine.TextPane pane)
{
var lines = vm.Lines;
var heights = new double[lines.Count];
var measured = MeasuredRows;
for (var i = 0; i < heights.Length; i++) heights[i] = lines[i].PaneHeight(pane, measured);
return heights;
}
/// <summary>
/// 한 칸(<paramref name="master"/>)이 굴렀다 - 나머지 칸을 가운데 기준으로 따라오게 한다.
/// 같은 줄이 이어지는 곳은 1:1, 한쪽에만 있는 덩어리를 지날 때는 비율로 따라가 한 번에 튀지 않는다.
/// </summary>
private void SyncPanesFrom(MergeLine.TextPane master, double offset, double viewport)
{
if (DataContext is not HomeViewModel vm || vm.Lines.Count == 0) return;
var fromHeights = PaneHeights(vm, master);
var center = offset + viewport / 2;
foreach (var (list, kind) in VisiblePanes(vm))
{
if (kind == master) continue;
var map = new PaneMapping(fromHeights, PaneHeights(vm, kind));
var target = map.Map(center);
if (kind == MergeLine.TextPane.Local)
{
// LOCAL 칸의 주인은 편집기다. 아래 표시층은 편집기와 같은 자리로.
if (FindScrollViewer(LocalEditor) is not { } editor) continue;
var y = Clamp(target - editor.ViewportHeight / 2, editor.ScrollableHeight);
editor.ScrollToVerticalOffset(y);
Scroll(LocalLines, y, null);
continue;
}
if (FindScrollViewer(list) is not { } viewer) continue;
viewer.ScrollToVerticalOffset(Clamp(target - viewer.ViewportHeight / 2, viewer.ScrollableHeight));
}
LinkOverlay.InvalidateVisual();
}
private static double Clamp(double value, double max) => Math.Max(0, Math.Min(value, Math.Max(0, max)));
// ================= 잇는 선 그리기 =================
/// <summary>한 칸의 세로 좌표를 그림판 기준으로 재는 도구. 그릴 때마다 새로 만든다.</summary>
private sealed class PaneGeometry
{
private readonly ListBox _list;
private readonly Visual _to;
private readonly int[] _index; // 정렬 행 i 앞에 이 칸의 줄이 몇 개 있는지 (길이 n+1)
private readonly double[] _tops; // 이 칸 k 번째 줄의 윗변(모델 높이 기준, 길이 count+1)
private readonly int _anchorIndex = -1;
private readonly double _anchorTop;
public PaneGeometry(ListBox list, Visual to, IList<MergeLine> lines, MergeLine.TextPane pane, bool measured)
{
_list = list;
_to = to;
var n = lines.Count;
_index = new int[n + 1];
var heights = new List<double>(n);
for (var i = 0; i < n; i++)
{
var h = lines[i].PaneHeight(pane, measured);
_index[i + 1] = _index[i] + (h > 0 ? 1 : 0);
if (h > 0) heights.Add(h);
}
_tops = PaneLinks.Tops(heights);
Count = heights.Count;
var origin = list.TransformToVisual(to).Transform(new Point(0, 0));
Left = origin.X;
Top = origin.Y;
Right = origin.X + list.ActualWidth;
Bottom = origin.Y + list.ActualHeight;
// 어림의 기준점: 칸 안에 보이는 첫 줄
if (FirstVisibleContainer(list) is { } first)
{
_anchorIndex = list.ItemContainerGenerator.IndexFromContainer(first);
if (_anchorIndex >= 0) _anchorTop = first.TransformToVisual(to).Transform(new Point(0, 0)).Y;
}
else if (FindScrollViewer(list) is { } viewer)
{
_anchorIndex = 0;
_anchorTop = Top - viewer.VerticalOffset;
}
}
public int Count { get; }
public double Left { get; }
public double Right { get; }
public double Top { get; }
public double Bottom { get; }
public bool Ok => _anchorIndex >= 0 && Right > Left;
/// <summary>정렬 행 경계 i(행 i 의 윗변)의 세로 자리. 그려진 줄이면 그 자리, 아니면 보이는 첫 줄에서 모델 높이로 어림.</summary>
public double Y(int i)
{
var k = _index[Math.Max(0, Math.Min(i, _index.Length - 1))];
if (k < Count && _list.ItemContainerGenerator.ContainerFromIndex(k) is FrameworkElement item && item.IsVisible)
return item.TransformToVisual(_to).Transform(new Point(0, 0)).Y;
if (k == Count && k > 0 && _list.ItemContainerGenerator.ContainerFromIndex(k - 1) is FrameworkElement last && last.IsVisible)
return last.TransformToVisual(_to).Transform(new Point(0, last.ActualHeight)).Y;
return _anchorTop + (_tops[k] - _tops[Math.Min(_anchorIndex, Count)]);
}
}
private static readonly Dictionary<Color, (Brush Fill, Pen Edge, Pen Strong)> LinkPens = new Dictionary<Color, (Brush, Pen, Pen)>();
private static (Brush Fill, Pen Edge, Pen Strong) PensFor(Brush highlight)
{
var c = highlight is SolidColorBrush solid ? solid.Color : Colors.Gray;
if (LinkPens.TryGetValue(c, out var found)) return found;
var fill = new SolidColorBrush(Color.FromArgb(0x38, c.R, c.G, c.B));
var line = new SolidColorBrush(Color.FromArgb(0xD0, c.R, c.G, c.B));
fill.Freeze();
line.Freeze();
var edge = new Pen(line, 1);
var strong = new Pen(line, 2);
edge.Freeze();
strong.Freeze();
var pens = (fill, edge, strong);
LinkPens[c] = pens;
return pens;
}
private static readonly Pen MovePen = CreateMovePen();
private static Pen CreateMovePen()
{
var c = ((SolidColorBrush)Controls.LineHighlightBrushes.Moved).Color;
var pen = new Pen(new SolidColorBrush(Color.FromArgb(0xF0, c.R, c.G, c.B)), 2.5) { DashStyle = DashStyles.Dash };
pen.Freeze();
return pen;
}
/// <summary>덩어리 색. 두 칸 가운데 어느 쪽에만 있는지 · 충돌인지로 가른다.</summary>
private static Brush BlockBrush(IList<MergeLine> lines, LinkBlock block, MergeLine.TextPane left, MergeLine.TextPane right)
{
bool anyLeft = false, anyRight = false, conflict = false;
LineHighlight first = LineHighlight.None;
for (var i = block.Start; i < block.End; i++)
{
var line = lines[i];
anyLeft |= line.IsIn(left);
anyRight |= line.IsIn(right);
conflict |= line.IsThreeWay && line.IsConflict;
if (first == LineHighlight.None) first = line.Highlight;
}
if (conflict) return Controls.LineHighlightBrushes.Conflict;
// BASE 쪽에만 있으면 지운 것, BASE 가 아닌 쪽에만 있으면 넣은 것. BASE 가 왼쪽(2-Way · BASE|REMOTE)일 수도
// 오른쪽(LOCAL(Mine)|BASE)일 수도 있다 - 방향을 따라 정한다.
var baseIsLeft = left == MergeLine.TextPane.Base;
var onlyBase = baseIsLeft ? !anyRight : !anyLeft;
var onlyOther = baseIsLeft ? !anyLeft : !anyRight;
if (onlyOther) return right == MergeLine.TextPane.Remote ? Controls.LineHighlightBrushes.RemoteChange : Controls.LineHighlightBrushes.Added;
if (onlyBase) return Controls.LineHighlightBrushes.Removed;
if (right == MergeLine.TextPane.Remote) return Controls.LineHighlightBrushes.RemoteChange;
return first is LineHighlight.Edited or LineHighlight.Applied
? Controls.LineHighlightBrushes.For(first)
: Controls.LineHighlightBrushes.Changed;
}
/// <summary>그림판에 잇는 선을 그린다. 칸이 구를 때 · 글이 바뀔 때마다 다시 그린다.</summary>
private void DrawLinks(DrawingContext dc)
{
if (DataContext is not HomeViewModel vm) return;
var lines = vm.Lines;
if (lines.Count == 0) return;
var measured = MeasuredRows;
foreach (var (leftList, leftKind, rightList, rightKind) in LinkPairs(vm))
{
if (!leftList.IsVisible || !rightList.IsVisible || leftList.ActualHeight <= 0 || rightList.ActualHeight <= 0) continue;
PaneGeometry left, right;
try
{
left = new PaneGeometry(leftList, LinkOverlay, lines, leftKind, measured);
right = new PaneGeometry(rightList, LinkOverlay, lines, rightKind, measured);
}
catch (InvalidOperationException)
{
continue; // 아직 배치 전 - 다음 그리기에서
}
if (!left.Ok || !right.Ok) continue;
var top = Math.Max(left.Top, right.Top);
var bottom = Math.Min(left.Bottom, right.Bottom);
if (bottom <= top) continue;
dc.PushClip(new RectangleGeometry(new Rect(left.Left, top, Math.Max(0, right.Right - left.Left), bottom - top)));
var changed = new bool[lines.Count];
for (var i = 0; i < changed.Length; i++) changed[i] = lines[i].DiffersBetween(leftKind, rightKind);
foreach (var block in PaneLinks.Blocks(changed))
{
var l1 = left.Y(block.Start);
var l2 = left.Y(block.End);
var r1 = right.Y(block.Start);
var r2 = right.Y(block.End);
if (Math.Max(l2, r2) < top - 2 || Math.Min(l1, r1) > bottom + 2) continue;
var (fill, edge, strong) = PensFor(BlockBrush(lines, block, leftKind, rightKind));
DrawBlock(dc, fill, edge, strong, left, right, l1, l2, r1, r2);
}
// 자리만 옮긴 덩어리 - 떠난 자리(BASE)에서 옮겨 간 자리(LOCAL)까지 점선으로(두 칸 비교만)
if (!vm.IsThreeWay) DrawMoves(dc, lines, left, right, top, bottom);
dc.Pop();
}
}
/// <summary>
/// 덩어리 하나: 두 칸 안의 위 · 아래 경계선과 틈의 사다리꼴. 한쪽에 없는 덩어리는 그 칸에 굵은 선 하나(어디에 들어갈 자리인지).
/// </summary>
private static void DrawBlock(DrawingContext dc, Brush fill, Pen edge, Pen strong,
PaneGeometry left, PaneGeometry right, double l1, double l2, double r1, double r2)
{
DrawEdges(dc, edge, strong, left.Left, left.Right, l1, l2);
DrawEdges(dc, edge, strong, right.Left, right.Right, r1, r2);
var gap = new StreamGeometry();
using (var g = gap.Open())
{
g.BeginFigure(new Point(left.Right, l1), isFilled: true, isClosed: true);
g.LineTo(new Point(right.Left, r1), isStroked: true, isSmoothJoin: false);
g.LineTo(new Point(right.Left, r2), isStroked: false, isSmoothJoin: false);
g.LineTo(new Point(left.Right, l2), isStroked: true, isSmoothJoin: false);
}
gap.Freeze();
dc.DrawGeometry(fill, edge, gap);
}
private static void DrawEdges(DrawingContext dc, Pen edge, Pen strong, double x1, double x2, double y1, double y2)
{
if (y2 - y1 < 0.5)
{
dc.DrawLine(strong, new Point(x1, y1), new Point(x2, y1));
return;
}
dc.DrawLine(edge, new Point(x1, y1), new Point(x2, y1));
dc.DrawLine(edge, new Point(x1, y2), new Point(x2, y2));
}
private static void DrawMoves(DrawingContext dc, IList<MergeLine> lines, PaneGeometry left, PaneGeometry right, double top, double bottom)
{
// 짝 번호 → [떠난 자리 시작, 끝, 옮겨 간 자리 시작, 끝] (정렬 행)
var groups = new Dictionary<int, int[]>();
for (var i = 0; i < lines.Count; i++)
{
var line = lines[i];
if (line.MoveGroup == 0 || !line.IsDifferent) continue;
var fromSide = line.MoveFromBase && line.HasBase;
var toSide = !line.MoveFromBase && line.HasLocal;
if (!fromSide && !toSide) continue;
if (!groups.TryGetValue(line.MoveGroup, out var g)) groups[line.MoveGroup] = g = new[] { -1, -1, -1, -1 };
var at = fromSide ? 0 : 2;
if (g[at] < 0) g[at] = i;
g[at + 1] = i + 1;
}
foreach (var g in groups.Values)
{
if (g[0] < 0 || g[2] < 0) continue;
var l1 = left.Y(g[0]);
var l2 = left.Y(g[1]);
var r1 = right.Y(g[2]);
var r2 = right.Y(g[3]);
if (Math.Max(l2, r2) < top || Math.Min(l1, r1) > bottom) continue;
// 틈을 가로지르는 두 곡선 - 위끼리 · 아래끼리
var mid = (left.Right + right.Left) / 2;
var curve = new StreamGeometry();
using (var c = curve.Open())
{
c.BeginFigure(new Point(left.Right, (l1 + l2) / 2), isFilled: false, isClosed: false);
c.BezierTo(new Point(mid, (l1 + l2) / 2), new Point(mid, (r1 + r2) / 2), new Point(right.Left, (r1 + r2) / 2), isStroked: true, isSmoothJoin: true);
}
curve.Freeze();
dc.DrawGeometry(null, MovePen, curve);
}
}
}
}

View File

@ -7,8 +7,6 @@
xmlns:ctl="clr-namespace:MergeHub.Views.Controls"
xmlns:system="clr-namespace:System;assembly=mscorlib"
xmlns:models="clr-namespace:MergeHub.Models"
xmlns:shell="clr-namespace:UbisamBase.Core.Shell;assembly=UbisamBase.Core"
shell:PlatformChrome.FullBleed="True"
mc:Ignorable="d"
PreviewKeyDown="View_PreviewKeyDown"
AllowDrop="True" DragOver="View_DragOver" Drop="View_Drop">
@ -22,13 +20,11 @@
<system:Double x:Key="PaneHeaderHeight">124</system:Double>
<!-- 코드 한 줄의 높이. BASE/LOCAL 이 이 값을 똑같이 써야 두 패널의 같은 행이 같은 눈높이에 온다. -->
<system:Double x:Key="CodeLineHeight">16</system:Double>
<system:Double x:Key="CodeLineHeight">20</system:Double>
<!-- 코드 글자 크기와 그에 따르는 여백(11차 F-5). 기본 12 의 값이다 - 화면(ApplyCodeFont)이
CodeFontMetrics 로 함께 바꾼다. DynamicResource 로 읽어야 바뀐 값이 바로 그려진다. -->
<system:Double x:Key="CodeFontSize">12</system:Double>
<!-- 칸 사이 잇는 선 자리 폭(11차 F-10) -->
<GridLength x:Key="LinkGapWidth">48</GridLength>
<GridLength x:Key="GutterWidth">66</GridLength>
<GridLength x:Key="GutterNumberWidth">46</GridLength>
<GridLength x:Key="GutterNarrowNumberWidth">40</GridLength>
@ -36,7 +32,6 @@
<GridLength x:Key="GutterButtonWidth">20</GridLength>
<system:Double x:Key="GutterButtonHeight">14</system:Double>
<system:Double x:Key="GutterButtonFontSize">9</system:Double>
<system:Double x:Key="BookmarkHeight">10</system:Double>
<Thickness x:Key="EditorGutterMargin">66,0,0,0</Thickness>
<!-- BASE / LOCAL 하단 요약 칸 공통 높이. 두 패널의 코드 영역이 같은 크기여야 나란히 읽힌다. -->
@ -158,65 +153,15 @@
<DataTemplate x:Key="BaseLineTemplate">
<!-- 여백 폭(66)을 LOCAL 과 맞춘다. 접히는 자리가 같아야 워드랩에서도 행이 어긋나지 않는다.
66 인 까닭은 LocalLineTemplate 에 적었다(6차 B-4). -->
<Grid MinHeight="{Binding BaseRowHeight}">
<Grid MinHeight="{Binding SourceRowHeight}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="{DynamicResource GutterWidth}"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- [<] 이 줄을 왼쪽 파일로 반영(12차 B-1). 두 파일 비교에서만 보인다 -
Git 모드의 왼쪽은 지난 커밋이라 쓸 파일이 없다. -->
<Button Grid.Column="0" Content="&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 칸에는 그 줄이 없으므로 BASE 쪽 줄 끝에 단다(Araxis 처럼). LOCAL 에 있는 줄은 LOCAL 여백의 [>] 그대로. -->
<Button Grid.Column="3" Content="&gt;" Padding="0" Margin="2,0,2,0" MinWidth="16"
Height="{DynamicResource GutterButtonHeight}" MinHeight="{DynamicResource GutterButtonHeight}"
FontSize="{DynamicResource GutterButtonFontSize}" VerticalAlignment="Top"
Command="{Binding DataContext.TakeLineCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}"
ToolTip="LOCAL 에서 지운 이 줄을 되살리기 (BASE 내용으로 가져오기)">
<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 HasLocal}" Value="False"/>
</MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible"/>
</MultiDataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<TextBlock Grid.Column="1" Text="{Binding BaseNumberText}" Style="{StaticResource Mono}" LineHeight="{DynamicResource CodeLineHeight}" LineStackingStrategy="BlockLineHeight"
<TextBlock Grid.Column="0" Text="{Binding BaseNumberText}" Style="{StaticResource Mono}" LineHeight="{DynamicResource CodeLineHeight}" LineStackingStrategy="BlockLineHeight"
Foreground="{DynamicResource Ubisam.Brush.Muted}" TextAlignment="Right"
VerticalAlignment="Top" Margin="0,0,8,0"/>
<Border Grid.Column="2" Padding="2,0"
<Border Grid.Column="1" Padding="2,0"
Background="{Binding Highlight, Converter={StaticResource HighlightBrush}}">
<Grid ClipToBounds="True">
<ctl:CodeTextBlock Code="{Binding BaseText}" Words="{Binding BaseWords}"
@ -247,7 +192,7 @@
여백의 [&lt;] 를 누르면 그 줄을 결과(MERGED)로 되돌린다 - "Accept Mine".
-->
<DataTemplate x:Key="OriginalLocalLineTemplate">
<Grid MinHeight="{Binding OriginalLocalRowHeight}">
<Grid MinHeight="{Binding SourceRowHeight}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="{DynamicResource GutterNumberWidth}"/>
<ColumnDefinition Width="{DynamicResource GutterButtonWidth}"/>
@ -297,7 +242,7 @@
누르면 그 줄을 LOCAL 로 가져온다. 방향을 헷갈리지 않게 LOCAL 쪽 [&gt;] 와 화살표를 반대로 했다.
-->
<DataTemplate x:Key="RemoteLineTemplate">
<Grid MinHeight="{Binding RemoteRowHeight}">
<Grid MinHeight="{Binding SourceRowHeight}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="{DynamicResource GutterNumberWidth}"/>
<ColumnDefinition Width="{DynamicResource GutterButtonWidth}"/>
@ -365,13 +310,12 @@
<ColumnDefinition Width="{DynamicResource GutterMarkWidth}"/>
<ColumnDefinition Width="{DynamicResource GutterNarrowNumberWidth}"/>
<ColumnDefinition Width="{DynamicResource GutterButtonWidth}"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 북마크 표식. 켜진 줄만 색 막대가 보인다. -->
<Border Grid.Column="0" Width="4" HorizontalAlignment="Left" CornerRadius="1"
Margin="1,3" VerticalAlignment="Top" Height="{DynamicResource BookmarkHeight}"
Margin="1,3" VerticalAlignment="Top" Height="{DynamicResource GutterButtonHeight}"
Background="{DynamicResource Ubisam.Brush.Accent}"
Visibility="{Binding IsBookmarked, Converter={StaticResource BoolToVis}}"/>
@ -388,33 +332,6 @@
CommandParameter="{Binding}"
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>
<!--
색 배경은 칸 전체를 채우고 제자리에 있는다. 미는 것은 글자뿐이다.
@ -423,7 +340,7 @@
같은 x 에서 시작한다. 여기가 2 더 밀려 있으면 그 위에 겹친 편집기의 캐럿이
글자 가운데에 박힌 것처럼 보인다.
-->
<Border Grid.Column="4" Padding="0"
<Border Grid.Column="3" Padding="0"
Background="{Binding Highlight, Converter={StaticResource HighlightBrush}}"
BorderThickness="2,0,0,0">
<Border.ToolTip>
@ -469,77 +386,7 @@
</ResourceDictionary>
</UserControl.Resources>
<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 Margin="2" TextElement.Foreground="{DynamicResource Ubisam.Brush.ContentForeground}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
@ -558,11 +405,6 @@
줄었다 하는 WrapPanel 이라 그 안에 넣으면 자리가 떠다닌다 - DockPanel 로
오른쪽에 붙박아 어느 모드에서든 같은 자리에 있게 했다.
-->
<!-- 12차 B-2: 프로그램 정보(관리 목적). 단축키 바로 옆 - 둘 다 "이 프로그램에 대한 안내" 다. -->
<Button DockPanel.Dock="Right" Content="정보" Click="About_Click"
Height="26" MinHeight="26" Padding="10,2" FontSize="11" Margin="10,0,0,0"
VerticalAlignment="Top"
ToolTip="이 프로그램의 버전 · 빌드 날짜 · 만든 사람 · 사내 문의처를 봅니다"/>
<Button DockPanel.Dock="Right" Content="단축키" Click="ShortcutHelp_Click"
Height="26" MinHeight="26" Padding="10,2" FontSize="11" Margin="10,0,0,0"
VerticalAlignment="Top"
@ -1003,8 +845,7 @@
</ColumnDefinition>
<ColumnDefinition Width="4"/>
<ColumnDefinition x:Name="LocalColumn" Width="*" MinWidth="200"/>
<!-- 칸 사이 틈(11차 F-10). 바뀐 덩어리를 잇는 선을 그리는 자리라 구분선보다 넓다. 끌면 구분선처럼 폭이 바뀐다. -->
<ColumnDefinition Width="{StaticResource LinkGapWidth}"/>
<ColumnDefinition Width="4"/>
<ColumnDefinition x:Name="BaseColumn" Width="*" MinWidth="200"/>
<!-- 3-Way 를 안 쓰면 REMOTE 칸과 그 앞 구분선을 폭 0 으로 접는다.
@ -1015,7 +856,7 @@
<Setter Property="Width" Value="0"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsThreeWay}" Value="True">
<Setter Property="Width" Value="{StaticResource LinkGapWidth}"/>
<Setter Property="Width" Value="4"/>
</DataTrigger>
</Style.Triggers>
</Style>
@ -1188,8 +1029,6 @@
<RowDefinition Height="Auto" SharedSizeGroup="PaneHeader"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<!-- 12차 B-3: 칸 아래 한 줄 요약 -->
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Margin="8,6">
@ -1206,7 +1045,7 @@
그리기라 자르지 않으면 아래 바닥 글 위에 코드가 겹쳐 그려진다. -->
<Grid Grid.Row="1" ClipToBounds="True">
<ListBox x:Name="OriginalLocalLines" BorderThickness="0" Background="Transparent"
SelectedItem="{Binding SelectedLine, Mode=OneWay}" SelectionChanged="PaneLines_SelectionChanged"
ItemsSource="{Binding Lines}" SelectedItem="{Binding SelectedLine}"
ItemTemplate="{StaticResource OriginalLocalLineTemplate}"
ItemContainerStyle="{StaticResource CodeLineItem}"
VirtualizingPanel.ScrollUnit="Pixel"
@ -1244,15 +1083,6 @@
Style="{StaticResource Muted}"/>
</StackPanel>
</Border>
<!-- 12차 B-3: 이 칸이 저쪽과 무엇이 다른가. 맨 아래 상태 줄의 "파일 전체" 와 다른 값이다. -->
<Border Grid.Row="3" Padding="8,3" BorderThickness="0,1,0,0"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
Background="{DynamicResource Ubisam.Brush.Sidebar.Background}">
<TextBlock x:Name="MineSummaryText" Text="{Binding MineSummary}" FontSize="11"
TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
</Border>
</Grid>
</Border>
@ -1278,8 +1108,6 @@
<RowDefinition Height="Auto" SharedSizeGroup="PaneHeader"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<!-- 12차 B-3: 칸 아래 한 줄 요약 -->
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- BASE / LOCAL 의 머리 영역 높이를 같게 고정해야 두 패널의 01행이 같은 눈높이에 온다 -->
@ -1313,7 +1141,7 @@
그리기라 자르지 않으면 아래 바닥 글 위에 코드가 겹쳐 그려진다. -->
<Grid Grid.Row="1" ClipToBounds="True">
<ListBox x:Name="BaseLines" BorderThickness="0" Background="Transparent"
SelectedItem="{Binding SelectedLine, Mode=OneWay}" SelectionChanged="PaneLines_SelectionChanged"
ItemsSource="{Binding Lines}" SelectedItem="{Binding SelectedLine}"
ItemTemplate="{StaticResource BaseLineTemplate}"
ItemContainerStyle="{StaticResource CodeLineItem}"
VirtualizingPanel.ScrollUnit="Pixel"
@ -1340,23 +1168,12 @@
Value="{Binding SourceScrollValue, Mode=TwoWay, RelativeSource={RelativeSource AncestorType=UserControl}}"
Visibility="{Binding SourceBarVisibility, RelativeSource={RelativeSource AncestorType=UserControl}}"/>
<!-- 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>
<!-- Diff 요약도 아래 한 줄로 갔다(4차 C-2). 같은 숫자가 세 군데 있었다. -->
</Grid>
</Border>
<!-- 칸 사이 틈 전체가 구분선이다(11차 F-10). 바탕은 비워 두고 잇는 선은 LinkOverlay 가 그 위에 그린다. -->
<GridSplitter Grid.Column="3" Grid.Row="0" HorizontalAlignment="Stretch" DragCompleted="PaneSplitter_DragCompleted"
Background="Transparent"/>
<GridSplitter Grid.Column="3" Grid.Row="0" Width="4" HorizontalAlignment="Stretch" DragCompleted="PaneSplitter_DragCompleted"
Background="{DynamicResource Ubisam.Brush.Table.GridLine}"/>
<!--
③ LOCAL(2-Way) = MERGED(3-Way).
@ -1430,7 +1247,7 @@
-->
<Grid Grid.Column="0">
<ListBox x:Name="LocalLines" BorderThickness="0" Background="Transparent"
SelectedItem="{Binding SelectedLine, Mode=OneWay}" SelectionChanged="PaneLines_SelectionChanged"
ItemsSource="{Binding Lines}" SelectedItem="{Binding SelectedLine}"
ItemTemplate="{StaticResource LocalLineTemplate}"
ItemContainerStyle="{StaticResource CodeLineItem}"
VirtualizingPanel.ScrollUnit="Pixel"
@ -1527,23 +1344,13 @@
LineRequested="MiniMap_LineRequested"/>
</Grid>
<!-- 상태바는 창 아래 한 줄로 옮겼다(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>
<!-- 상태바는 창 아래 한 줄로 옮겼다(4차 C-2). 두 패널이 같은 높이를 갖게 된다. -->
</Grid>
</Border>
<GridSplitter Grid.Column="5" Grid.Row="0" HorizontalAlignment="Stretch" DragCompleted="PaneSplitter_DragCompleted"
<GridSplitter Grid.Column="5" Grid.Row="0" Width="4" HorizontalAlignment="Stretch" DragCompleted="PaneSplitter_DragCompleted"
Visibility="{Binding IsThreeWay, Converter={StaticResource BoolToVis}}"
Background="Transparent"/>
Background="{DynamicResource Ubisam.Brush.Table.GridLine}"/>
<!--
③-2 REMOTE (3-Way). 머리·바닥 높이를 BASE/LOCAL 과 똑같이 맞춰야
@ -1560,8 +1367,6 @@
<RowDefinition Height="Auto" SharedSizeGroup="PaneHeader"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<!-- 12차 B-3: 칸 아래 한 줄 요약 -->
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- BASE/LOCAL 과 같은 머리 높이. 세 패널의 01행이 같은 눈높이에 와야 한다.
@ -1589,7 +1394,7 @@
그리기라 자르지 않으면 아래 바닥 글 위에 코드가 겹쳐 그려진다. -->
<Grid Grid.Row="1" ClipToBounds="True">
<ListBox x:Name="RemoteLines" BorderThickness="0" Background="Transparent"
SelectedItem="{Binding SelectedLine, Mode=OneWay}" SelectionChanged="PaneLines_SelectionChanged"
ItemsSource="{Binding Lines}" SelectedItem="{Binding SelectedLine}"
ItemTemplate="{StaticResource RemoteLineTemplate}"
ItemContainerStyle="{StaticResource CodeLineItem}"
VirtualizingPanel.ScrollUnit="Pixel"
@ -1628,15 +1433,6 @@
</TextBlock>
</StackPanel>
</Border>
<!-- 12차 B-3: 이 칸이 저쪽과 무엇이 다른가. 맨 아래 상태 줄의 "파일 전체" 와 다른 값이다. -->
<Border Grid.Row="3" Padding="8,3" BorderThickness="0,1,0,0"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
Background="{DynamicResource Ubisam.Brush.Sidebar.Background}">
<TextBlock x:Name="RemoteSummaryText" Text="{Binding RemoteSummary}" FontSize="11"
TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
</Border>
</Grid>
</Border>
@ -1646,24 +1442,6 @@
Visibility="{Binding IsThreeWay, Converter={StaticResource BoolToVis}}"
Background="{DynamicResource Ubisam.Brush.Table.GridLine}"/>
<!--
12차 B-3: 가운데(칸 사이 틈) 아래에 진행을 적는다.
틈이 48 DIP 뿐이라 두 줄로 짧게 - "3 / 7"(몇 번째 차이) 과 "남은 2"(아직 다른 줄 수).
잇는 선 그림판보다 앞에 두어 선에 가리지 않게 한다.
-->
<StackPanel Grid.Row="0" Grid.Column="3" VerticalAlignment="Bottom" HorizontalAlignment="Center"
Margin="0,0,0,6" Panel.ZIndex="6" IsHitTestVisible="False">
<TextBlock x:Name="DiffProgressText" Text="{Binding DiffProgress}" FontSize="11" FontWeight="Bold"
HorizontalAlignment="Center"
Foreground="{DynamicResource Ubisam.Brush.ContentForeground}"/>
<TextBlock x:Name="DiffRemainingText" Text="{Binding DiffRemaining}" FontSize="10"
HorizontalAlignment="Center"
Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
</StackPanel>
<!-- 잇는 선(11차 F-10). 위 칸들 위에 겹친 투명한 그림판 - 마우스는 통과한다. -->
<ctl:PaneLinkOverlay x:Name="LinkOverlay" Grid.Row="0" Grid.Column="2" Grid.ColumnSpan="5" Panel.ZIndex="5"/>
<GridSplitter x:Name="ActionsSplitter" Grid.Column="7" Grid.RowSpan="3" Width="4" HorizontalAlignment="Stretch" DragCompleted="PaneSplitter_DragCompleted"
Background="{DynamicResource Ubisam.Brush.Table.GridLine}"/>
@ -1671,20 +1449,8 @@
<Border x:Name="ActionsPanel" Grid.Column="8" Grid.RowSpan="3" BorderThickness="1" Padding="8"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<DockPanel>
<!-- 설정 "저장할 때 백업" 을 켰을 때만(11차 F-9). 맨 아래 - 저장 단추들 밑. -->
<Button DockPanel.Dock="Bottom" Content="백업 폴더 열기" Margin="0,6,0,0" FontSize="11"
Command="{Binding OpenBackupFolderCommand}"
Visibility="{Binding IsBackupOn, Converter={StaticResource BoolToVis}}"
ToolTip="저장할 때 남긴 백업을 모아 둔 폴더를 엽니다. 날짜 폴더마다 목록.txt 에 원본과 백업이 적혀 있습니다"/>
<!-- 왼쪽 파일에 쓰는 단추(12차 B-1). 두 파일 비교에서만 보이고, 왼쪽에 고친 것이 없으면 꺼진다. -->
<Button DockPanel.Dock="Bottom" Content="◀ 왼쪽 저장" MinHeight="32" Margin="0,8,0,0" FontSize="11"
Command="{Binding SaveLeftPlainCommand}"
Visibility="{Binding BothWays, Converter={StaticResource BoolToVis}}"
ToolTip="왼쪽으로 반영한 내용을 왼쪽 파일에 씁니다"/>
<!-- Git 이 아니면 커밋할 곳이 없다. 파일에 쓰기만 한다. -->
<Button DockPanel.Dock="Bottom" Content="오른쪽 저장 ▶" MinHeight="38" Margin="0,8,0,0"
<Button DockPanel.Dock="Bottom" Content="저장" MinHeight="38" Margin="0,8,0,0"
Command="{Binding SavePlainCommand}"
Visibility="{Binding IsPlainMode, Converter={StaticResource BoolToVis}}"
Style="{StaticResource FilledButton}"
@ -2081,5 +1847,4 @@
</Grid>
</Border>
</Grid>
</DockPanel>
</UserControl>

View File

@ -251,7 +251,6 @@ namespace MergeHub.Views
IsVisibleChanged += (_, e) =>
{
if (e.NewValue is true && DataContext is HomeViewModel vm) vm.RecompareIfSettingsChanged();
if (e.NewValue is true) FocusEditorWhenIdle();
};
// 두 패널은 같은 이동값을 쓴다. LOCAL 만 밀면 BASE 와 줄이 어긋나 보인다 -
// 나란히 놓고 보는 도구에서 이건 곧바로 눈에 띈다.
@ -272,18 +271,7 @@ namespace MergeHub.Views
// 글꼴·배율·창 크기가 바뀌면 다시 재서 맞춘다.
LayoutUpdated += (_, _) => AlignDecorationToEditor();
Loaded += (_, _) =>
{
HookHorizontalWheel();
FocusEditorWhenIdle();
// 켤 때는 사용자 이름 창이 먼저 떠 본 창이 늦게 활성이 된다. 그때도 한 번 더 본다.
if (Window.GetWindow(this) is { } window && !_watchingActivation)
{
_watchingActivation = true;
window.Activated += (_, _) => { if (IsVisible) FocusEditorWhenIdle(); };
}
};
Loaded += (_, _) => HookHorizontalWheel();
Unloaded += (_, _) => UnhookHorizontalWheel();
}
@ -389,18 +377,14 @@ namespace MergeHub.Views
{
old.EditorTextReplaced -= OnEditorTextReplaced;
old.ScrollToRowRequested -= OnScrollToRowRequested;
old.CaretIndexRequested -= OnCaretIndexRequested;
old.PropertyChanged -= OnViewModelPropertyChanged;
old.ReadEditorText = null;
old.ReadCaretIndex = null;
BindPaneViews(null);
}
if (e.NewValue is HomeViewModel now)
{
now.EditorTextReplaced += OnEditorTextReplaced;
now.ScrollToRowRequested += OnScrollToRowRequested;
now.CaretIndexRequested += OnCaretIndexRequested;
now.PropertyChanged += OnViewModelPropertyChanged;
ApplyActionsHidden(now.IsActionsHidden);
ApplyCodeFont(now.CodeFontSize);
@ -408,47 +392,9 @@ namespace MergeHub.Views
// 편집기 글은 이 컨트롤이 들고 있고, ViewModel 은 필요할 때만 읽어 간다.
// 바인딩으로 매 글자마다 문서 전체를 넘기면 큰 파일에서 눈에 띄게 느리다.
now.ReadEditorText = () => LocalEditor.Text;
// 탭을 옮길 때 캐럿 자리를 맡겼다 되살리는 데 쓴다(12차 E-2).
now.ReadCaretIndex = () => LocalEditor.CaretIndex;
// 칸마다 자기 줄만(11차 F-10)
BindPaneViews(now);
}
}
/// <summary>
/// 문서 탭을 <b>가운데 단추로</b> 누르면 닫는다(12차 E-2) - 브라우저 · notepad++ 와 같은 손버릇이다.
///
/// 왼쪽 단추는 손대지 않는다. 탭 줄이 ListBox 라 고르기가 곧 탭 옮기기이고,
/// 여기서 <c>Handled</c> 로 먹어 버리면 그 고르기가 일어나지 않는다.
/// </summary>
private void DocTab_MouseDown(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton != MouseButton.Middle) return;
if (DataContext is not HomeViewModel vm) return;
if (sender is not FrameworkElement element || element.DataContext is not DocTab tab) return;
if (vm.CloseTabCommand.CanExecute(tab)) vm.CloseTabCommand.Execute(tab);
e.Handled = true;
}
/// <summary>
/// 탭에 맡겨 둔 캐럿 자리로 돌아간다(12차 E-2).
///
/// 글을 다 실은 <b>뒤</b>에 옮겨야 한다 - 배치가 끝나기 전에 옮기면 화면이 따라오지 않아
/// 캐럿만 저 아래에 있고 보이는 곳은 맨 위가 된다. 그래서 다음 차례로 미룬다.
/// </summary>
private void OnCaretIndexRequested(object? sender, int caretIndex)
{
Dispatcher.BeginInvoke(new Action(() =>
{
var at = Math.Max(0, Math.Min(caretIndex, LocalEditor.Text.Length));
LocalEditor.CaretIndex = at;
LocalEditor.ScrollToLine(LocalEditor.GetLineIndexFromCharacterIndex(at));
}), DispatcherPriority.Loaded);
}
/// <summary>
/// 2-Way ↔ 3-Way 로 바뀌면 패널 배치가 통째로 달라진다.
/// 워드랩의 접히는 자리를 정하는 글자 폭 보정도 그때 다시 잡아야 한다.
@ -467,14 +413,7 @@ namespace MergeHub.Views
return;
}
if (e.PropertyName == nameof(HomeViewModel.DiffVersion))
{
ScheduleResync(); // 칸마다 높이가 달라졌다 - 다시 맞추고 선을 다시 그린다(11차 F-10)
return;
}
if (e.PropertyName != nameof(HomeViewModel.IsThreeWay)) return;
ScheduleResync();
// 배치가 통째로 달라지므로 원본 칸의 가로 위치는 처음으로 되돌린다.
SourceShiftX = 0;
@ -506,7 +445,6 @@ namespace MergeHub.Views
viewer.ScrollChanged += (_, e) =>
{
if (e.VerticalChange != 0 || e.ViewportHeightChange != 0) LinkOverlay.InvalidateVisual();
if (Syncing) return;
if (e.VerticalChange == 0 && e.HorizontalChange == 0) return;
@ -517,7 +455,7 @@ namespace MergeHub.Views
// 표시층 ListBox 를 가로로 굴리는 방법은 못 쓴다 - 행이 칸 너비에 맞춰 늘어나
// 굴릴 여지(ScrollableWidth)가 0 이라 아무 일도 일어나지 않는다.
Scroll(LocalLines, e.VerticalOffset, null);
if (e.VerticalChange != 0) SyncPanesFrom(MergeLine.TextPane.Local, e.VerticalOffset, e.ViewportHeight);
ScrollSourcePanesTo(e.VerticalOffset);
TextShiftX = -e.HorizontalOffset;
// 2-Way 는 BASE 와 LOCAL 의 폭이 같으니 한 덩어리로 움직인다.
@ -552,7 +490,6 @@ namespace MergeHub.Views
Resources["GutterButtonWidth"] = new GridLength(m.ButtonWidth);
Resources["GutterButtonHeight"] = m.ButtonHeight;
Resources["GutterButtonFontSize"] = m.ButtonFontSize;
Resources["BookmarkHeight"] = m.BookmarkHeight;
Resources["EditorGutterMargin"] = new Thickness(m.GutterWidth, 0, 0, 0);
if (MergeLine.MinimumRowHeight != m.LineHeight)
@ -565,38 +502,8 @@ namespace MergeHub.Views
}
Dispatcher.BeginInvoke(new Action(MatchTextWidths), System.Windows.Threading.DispatcherPriority.Loaded);
ScheduleResync();
}
private bool _watchingActivation;
/// <summary>
/// 키보드 포커스가 비어 있거나 이 화면 밖(아래 탭 줄 등)에 있으면 편집기로 준다(11차 F-5 에서 찾음).
///
/// 켜자마자는 포커스가 아무 데도 없어 단축키(Ctrl+S · F9 · Ctrl++ …)가 코드 칸을 한 번 누르기 전까지
/// 하나도 듣지 않았다 - 단축키는 이 화면의 PreviewKeyDown 이 받기 때문이다. 탭을 눌러 돌아와도
/// 포커스가 탭 줄에 남아 같았다.
/// 이 화면 안(찾기 칸 · 커밋 메시지 칸 등)이나 다른 창(대화상자)에 있는 포커스는 건드리지 않는다.
/// </summary>
private void FocusEditorWhenIdle() =>
Dispatcher.BeginInvoke(new Action(() =>
{
if (!IsVisible || !LocalEditor.IsVisible) return;
var window = Window.GetWindow(this);
if (window is null) return;
if (Keyboard.FocusedElement is DependencyObject focused)
{
if (IsAncestorOf(focused)) return;
if (!ReferenceEquals(Window.GetWindow(focused), window)) return;
}
// 창이 아직 활성이 아니면(이름 창이 떠 있을 때) 논리 포커스만 정해 둔다 - 활성이 되면 WPF 가 거기로 돌려준다.
FocusManager.SetFocusedElement(window, LocalEditor);
if (window.IsActive) LocalEditor.Focus();
}), DispatcherPriority.Input);
/// <summary>감추기 전 ACTIONS 칸 폭. 구분선을 끌어 바꿔 뒀으면 그 폭으로 돌아온다.</summary>
private GridLength _actionsWidth = new GridLength(214);
@ -752,7 +659,6 @@ namespace MergeHub.Views
// 가로는 건드리지 않는다. 여백(줄번호·[>])까지 밀려나기 때문이다 -
// 가로 스크롤은 글자에만 거는 TextShiftX 가 따로 맡는다.
_decorationShift.Y = wanted;
LinkOverlay.InvalidateVisual(); // 표시층이 움직였다 - 잇는 선도 따라간다
}
catch (InvalidOperationException)
{
@ -801,9 +707,19 @@ namespace MergeHub.Views
// ================= 세로 스크롤 맞추기 =================
/// <summary>
/// 위쪽 원본 칸과 아래 MERGED 의 <b>행 높이가 다른</b> 상태.
///
/// 3-Way 에서 두 무리는 칸 너비가 다르므로, 워드랩을 켜면 접히는 자리가 달라
/// 같은 행이라도 높이가 달라진다. 이때는 픽셀을 그대로 넘겨서는 맞출 수 없다 -
/// 접힌 줄을 지날 때마다 조금씩 벌어져 결국 다른 줄을 보게 된다.
/// </summary>
private bool SourceRowsDifferFromEditor =>
WrapMode == TextWrapping.Wrap && DataContext is HomeViewModel { IsThreeWay: true };
/// <summary>코드 한 줄의 높이. XAML 의 CodeLineHeight 와 같은 값이어야 한다.</summary>
private double LineHeight =>
TryFindResource("CodeLineHeight") is double h && h > 0 ? h : 16;
TryFindResource("CodeLineHeight") is double h && h > 0 ? h : 20;
/// <summary>원본 세 칸. 늘 함께 움직인다.</summary>
private ListBox[] SourcePanes => new[] { BaseLines, RemoteLines, OriginalLocalLines };
@ -822,8 +738,7 @@ namespace MergeHub.Views
/// </summary>
private void ApplySourceScrollUnit()
{
// 11차 F-10: 칸마다 자기 줄 높이로 가운데를 맞춘다(PaneMapping) - 행 단위로 굴릴 까닭이 없다.
const ScrollUnit unit = ScrollUnit.Pixel;
var unit = SourceRowsDifferFromEditor ? ScrollUnit.Item : ScrollUnit.Pixel;
foreach (var pane in SourcePanes)
{
@ -831,6 +746,55 @@ namespace MergeHub.Views
}
}
/// <summary>
/// 편집기 화면 <b>맨 위에 걸린 논리 행</b>. 좌표로 직접 물어본다.
///
/// 스크롤 값을 줄 높이로 나눠 세는 방법은 못 쓴다 - 실제로 재 보면 스크롤 160 일 때
/// 편집기가 말하는 첫 줄은 11 인데 화면 맨 위에 있는 것은 9행이었다. TextBox 의
/// 줄 번호 계산은 화면에 그려진 것과 딱 맞아떨어지지 않는다.
/// 그래서 "왼쪽 위 점에 있는 글자가 몇 행인가"를 물어본다 - 이건 그려진 대로 답한다.
/// </summary>
private int EditorTopRow()
{
var index = LocalEditor.GetCharacterIndexFromPoint(new Point(2, 2), snapToText: true);
if (index < 0) return 0;
var row = RowColumnAt(LocalEditor.Text, index).Row;
return row < 0 ? 0 : row;
}
/// <summary>그 논리 행이 편집기에서 시작하는 세로 위치(위의 역). 역시 좌표로 잰다.</summary>
private double EditorOffsetOfRow(int row)
{
if (FindScrollViewer(LocalEditor) is not { } viewer) return 0;
var start = CharacterIndexOfLine(LocalEditor.Text, row);
if (start < 0) return viewer.VerticalOffset;
try
{
// GetRectFromCharacterIndex 는 <b>보이는 영역 기준</b> 좌표를 준다.
// 지금 굴러가 있는 만큼을 더해야 문서 전체 기준이 된다.
var top = LocalEditor.GetRectFromCharacterIndex(start).Top;
if (double.IsNaN(top) || double.IsInfinity(top)) return viewer.VerticalOffset;
return Math.Max(0, viewer.VerticalOffset + top);
}
catch (ArgumentOutOfRangeException)
{
return viewer.VerticalOffset;
}
}
/// <summary>편집기가 굴러간 만큼 위쪽 원본 세 칸을 따라오게 한다.</summary>
private void ScrollSourcePanesTo(double editorOffset)
{
// 행 단위로 굴리는 중이면 오프셋의 뜻이 "픽셀"이 아니라 "몇 번째 행"이다.
var offset = SourceRowsDifferFromEditor ? EditorTopRow() : editorOffset;
foreach (var pane in SourcePanes) Scroll(pane, offset, null);
}
/// <summary>
/// 표시층 ListBox 가 스스로 굴러가지 못하게 막는다.
///
@ -854,10 +818,6 @@ namespace MergeHub.Views
private void Lines_ScrollChanged(object sender, ScrollChangedEventArgs e)
{
if (sender is not ListBox source) return;
// 어느 칸이든 구르면 잇는 선을 다시 그린다(11차 F-10) - LOCAL 표시층은 편집기보다 한 박자 늦게 굴러 그 사이 옛 자리로 그려졌다.
if (e.VerticalChange != 0 || e.ViewportHeightChange != 0 || e.ExtentHeightChange != 0) LinkOverlay.InvalidateVisual();
if (!ReferenceEquals(source, BaseLines) &&
!ReferenceEquals(source, RemoteLines) &&
!ReferenceEquals(source, OriginalLocalLines)) return;
@ -878,11 +838,17 @@ namespace MergeHub.Views
HoldSync();
// 이 칸을 기준으로 나머지(편집기 · 다른 원본 칸)가 가운데를 맞춰 따라온다(11차 F-10).
var kind = ReferenceEquals(source, BaseLines) ? MergeLine.TextPane.Base
: ReferenceEquals(source, RemoteLines) ? MergeLine.TextPane.Remote
: MergeLine.TextPane.OriginalLocal;
SyncPanesFrom(kind, e.VerticalOffset, e.ViewportHeight);
var editorOffset = SourceRowsDifferFromEditor
? EditorOffsetOfRow((int)Math.Round(e.VerticalOffset))
: e.VerticalOffset;
Scroll(LocalEditor, editorOffset, null);
Scroll(LocalLines, editorOffset, null);
foreach (var other in new[] { BaseLines, RemoteLines, OriginalLocalLines })
{
if (!ReferenceEquals(other, source)) Scroll(other, e.VerticalOffset, null);
}
}
private static void Scroll(DependencyObject target, double? vertical, double? horizontal)
@ -1046,11 +1012,6 @@ namespace MergeHub.Views
case ShortcutAction.PrevFile: return Run(vm.PrevFileCommand);
case ShortcutAction.NextFile: return Run(vm.NextFileCommand);
// 12차 E-2 문서 탭
case ShortcutAction.NextTab: return Run(vm.NextTabCommand);
case ShortcutAction.PrevTab: return Run(vm.PreviousTabCommand);
case ShortcutAction.CloseTab: return Run(vm.CloseTabCommand);
case ShortcutAction.ToggleBookmark:
if (vm.SelectedLine is null) return false;
vm.ToggleBookmarkCommand.Execute(vm.SelectedLine);
@ -1199,13 +1160,6 @@ namespace MergeHub.Views
private void ShortcutHelp_Click(object sender, RoutedEventArgs e) =>
ShortcutHelpWindow.ShowOrActivate(Window.GetWindow(this));
/// <summary>프로그램 정보 창(12차 B-2). 만든 사람 · 문의처는 설정에서 읽어 넘긴다.</summary>
private void About_Click(object sender, RoutedEventArgs e)
{
var settings = (DataContext as ViewModels.HomeViewModel)?.Workspace.Settings;
AboutWindow.ShowOrActivate(Window.GetWindow(this), settings?.ProgramAuthor, settings?.SupportContact);
}
// ================= 찾기 =================
/// <summary>
@ -1430,7 +1384,6 @@ namespace MergeHub.Views
// 워드랩을 켜면 밀 곳이 없어지므로 막대도 사라져야 한다.
ApplySourceScrollUnit();
UpdateSourceScrollRange();
ScheduleResync(); // 줄 높이가 바뀌었다 - 칸 맞추기 · 잇는 선 다시(11차 F-10)
}
/// <summary>ViewModel 이 "이 행으로 가라"고 하면 캐럿과 스크롤을 함께 옮긴다.</summary>
@ -1445,18 +1398,15 @@ namespace MergeHub.Views
/// </summary>
private void OnScrollToRowRequested(object? sender, int row)
{
// 정렬 행 → 편집기 줄(11차 F-10 - 편집기에는 LOCAL 에 있는 줄만 있다). LOCAL 에 없는 행이면 바로 다음 줄.
var line = DataContext is HomeViewModel vm ? vm.EditorLineOfRow(row) : row;
var start = CharacterIndexOfLine(LocalEditor.Text, line);
var start = CharacterIndexOfLine(LocalEditor.Text, row);
if (start < 0) return;
LocalEditor.CaretIndex = start;
LocalEditor.Focus();
// 배치가 끝난 뒤에 굴린다. 글을 갈아 끼운 직후에는 글자 좌표를 물어봐야
// 옛 정보가 나온다. 그 줄의 윗변을 칸 <b>가운데</b>에 둔다(11차 F-10, Araxis 처럼) - LOCAL 에서 지운 덩어리로 가면
// 윗변이 곧 지운 자리라, 다른 칸이 가운데 기준으로 따라오며 지운 줄들이 BASE 칸 가운데에 온다.
Dispatcher.BeginInvoke(new Action(() => CenterCharInView(start)), DispatcherPriority.Loaded);
// 옛 정보가 나온다.
Dispatcher.BeginInvoke(new Action(BringCaretIntoView), DispatcherPriority.Loaded);
}
/// <summary>
@ -1469,25 +1419,6 @@ namespace MergeHub.Views
/// </summary>
private void BringCaretIntoView() => BringCharIntoView(LocalEditor.CaretIndex);
/// <summary>그 글자가 있는 줄의 윗변을 편집기 칸 가운데로 굴린다(차이 이동 · 11차 F-10).</summary>
private void CenterCharInView(int charIndex)
{
if (FindScrollViewer(LocalEditor) is not { } viewer) return;
Rect rect;
try
{
rect = LocalEditor.GetRectFromCharacterIndex(Math.Min(charIndex, LocalEditor.Text.Length));
}
catch (InvalidOperationException)
{
return;
}
if (double.IsInfinity(rect.Top) || double.IsNaN(rect.Top)) return;
viewer.ScrollToVerticalOffset(Math.Max(0, viewer.VerticalOffset + rect.Top - viewer.ViewportHeight / 2));
}
/// <summary>글 안의 어느 자리든 화면에 들여 놓는다(캐럿·찾은 자리 공통).</summary>
private void BringCharIntoView(int charIndex)
{