12차 G-3 ~ G-8 · 알파/베타 전 정리 (사용자 지시로 한 번에 커밋)

G-3 "덩어리" 를 "구간" 으로 - 단추 [구간 반영] · [구간 왼쪽으로], 단축키 설명, 로그.
G-4 저장 형식 콤보 둘을 구별 - 원본 인코딩 유지 · 원본 줄바꿈 유지. 저장값은
    "원본 유지" 그대로(세션 보관본 7·8칸) - SaveChoice(Value, Label) 로 보이는 글만 가른다.
G-5 사내 문의처 받침값 - csproj <SupportContact> 를 실행 파일에 박는다(문구는 아직 비움).
G-6 설정 > 개발자 정보. 담당 카드는 Platform 최민영 대리 · MergeHub 김승준 사원,
    소개 문구는 "사내 Merge/Diff + Git 도구"(Merge 먼저 - 창 제목 · csproj · 정보 창).
G-7 Alt+Down 화면 잘림 - 가운데 맞추기의 두 번째 스크롤이 메아리 막기에 걸려 버려졌다.
    CenterCharInView 가 다른 칸을 직접 맞춘다(g7-verify 고치기 전 10건 실패 -> 0).
G-8 메모 문법 강조 · 같은 글 칠하기 · 색 마킹(Ctrl+1~5) · 위치 막대. 투명 TextBox +
    아래층(사용자 결정). 화면에서 찾은 셋: 플랫폼 TextBox 스타일이 아래층을 덮음
    (Style={x:Null}), 콤보에 형 이름(LanguageProfile.ToString), 어두운 테마에서 메모 탭 ·
    문서 탭 제목이 검정(ItemContainerStyle 에 Foreground).

마무리(사용자 지시)
- 설정의 "프로그램 정보" 칸(만든 사람 · 사내 문의처)을 뺐다. [정보] 창은 실행 파일 값만.
- 주석 남기기가 켜져 있는데 작성자가 비면 반영 명령 맨 앞에서 알리고 반영하지 않는다
  (예전에는 git 이름 · 윈도우 계정으로 조용히 채웠다). 주석을 끄면 묻지 않는다.
- 데모(설정 두 칸 · [데모 ->] 단추 · 전환 코드)를 걷어냈다.

시험: 빌드 경고 0 · 오류 0. 하네스 MemoHlTest 45 · MemoTest 69 · OverviewTest 15 ·
AboutTest 30 · NoteTest 99. 화면 최종 회귀 22벌 423건(실패 4건은 대본이 툴팁을
읽은 것 - 고쳐 통과), 마무리 뒤 g5 · g6 · g9-author 실패 0. 한 번 나오고 재현 안 된
g8 가로 스크롤 항목은 12차 기록에 "값으로 확정 못 함" 으로 남겼다.

Agent: main (claude-opus-5-5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
김승준 2026-09-30 16:32:42 +09:00
parent 8a3ddf338a
commit b7ade6a800
25 changed files with 1540 additions and 140 deletions

View File

@ -2,6 +2,353 @@
지시서: [../작업지시/12차.md](../작업지시/12차.md)
## G 묶음 마무리 — 알파 · 베타 전 정리 (2026-09-30, 사용자 지시 · 완료)
### 설정의 "프로그램 정보" 칸을 뺐다
`만든 사람` · `사내 문의처` 두 칸 — 설정 → 개발자 정보가 담당을 보이므로 필요 없다(사용자). [정보] 창은 이제 **실행 파일에 박힌 값만**
읽는다(만든 사람 = 회사 이름, 문의처 = csproj `<SupportContact>`). 그래서 `ProgramInfoReader.For` 의 "설정값 먼저" 인수를 걷고,
빈 칸 안내 글을 `적혀 있지 않습니다 (설정 > MergeHub 에서 적습니다)` → **`적혀 있지 않습니다`** 로 줄였다(없는 칸을 가리키게 되므로).
옛 설정 파일에 남은 두 키는 읽을 때 버려진다 — `g5-verify` 2단계가 옛 키에 값을 적어 두고 켜서 안 쓰이는 것을 확인.
`OverviewViewModel` 은 설정을 읽을 일이 없어져 인수 없는 생성자로(플랫폼 `AddSub` 가 DI 로 만든다).
하네스 `AboutTest` 를 새 모양으로 먼저 고쳐 **컴파일 실패** 확인 → 30건 통과. 화면 `g5-verify` 3건 · `g6-verify` 2단계
(설정 → MergeHub 에 `프로그램 정보` 묶음 · 두 칸 없음, 다른 칸 그대로) 실패 0.
### 작성자가 비면 반영하지 않고 알린다
사용자 요청 "반영한 줄에 주석 남기기에서 작성자를 입력하지 않으면 알람". 방식은 물어 정했다 — **반영할 때 알림 + 반영 멈춤**.
예전에는 비어 있으면 git `user.name` → 윈도우 계정으로 조용히 채웠다(장비를 여럿이 쓰면 주석 이름이 틀리게 남는다).
- 판단은 `MergeNote.AuthorMissing(주석 켬, 작성자)` — 주석을 끈 사람에게는 묻지 않는다. 알림 글도 `MergeNote` 에(설정 자리를 댄다).
- 거는 자리: 반영 **명령 맨 앞** 여덟 곳(한 줄 · 모두 · 구간 · 메서드 · 왼쪽으로 한 줄 · 왼쪽으로 구간 · 3-Way 내 것 · 저쪽).
줄마다(`Apply` 안)가 아니라 명령마다 — [모두 반영] 이 줄 수만큼 알리거나 구간이 반쯤 반영된 채 멈추지 않게.
보고서 내보내기 · 변경분 추출의 이름은 반영이 아니므로 그대로(옛 물러서기).
- 화면에서 하나 더: 작성자가 비었는데 "머지 설명" 미리보기가 `// [Ubisam] …`(윈도우 계정)을 보였다 — 그 이름으로는 반영되지 않으므로
`(작성자가 비어 있어 반영할 수 없습니다 - 설정 > 머지 주석 > 작성자)` 로.
- 설정 칸 이름 `작성자 (비우면 git config user.name)` → `작성자 (주석을 남기면 꼭 적습니다 - 비어 있으면 반영하지 않고 알립니다)`.
- 한때 사용자 지시로 "주석 남기기를 늘 켬"(설정 칸 감춤)으로 바꿨다가 **곧 "고를 수 있게 원복"** 지시 — 되돌렸다. 늘 켬 판은 커밋되지 않았다.
- 하네스 `NoteTest` 에 6건을 먼저 넣어 **컴파일 실패** 확인 → 93 → 99건 통과. 화면 `g9-author.ps1`(새): ① 주석 켬 · 작성자 빔 →
알림 창("작성자를 적어 주세요", 설정 자리를 댐) · 편집기 그대로 · 미리보기 안내 ② 주석 끔 → 묻지 않고 반영 ③ 작성자 적음 →
`[G9시험자]` 주석과 함께 반영. 실패 0. (처음 판 3단계 실패 2건은 2단계 편집이 저장 안 한 편집 보관함으로 되살아난 것 — 단계 사이에 보관함을 비움.)
### 데모 걷어냄 · 이 PC 의 시험 흔적
- 코드: 설정 `데모` 묶음(`DemoGitPath` · `DemoPlainPath`), 파일 비교 위 [데모 → .git 있음/없음] 단추와 `SwitchDemoAsync`. 켤 때 쓰는
`LoadDefaultPlainFiles` 는 남김. 데모를 예로 든 주석 네 곳도 고침. `g6-verify` 가 설정 화면에 `데모` 가 없는 것을 본다.
- 이 PC(사용자 선택 "시험 흔적까지"): 시험 대본이 만드는 `%TEMP%` 재료 13개(e2-fold · g2 · mergehub-demo · plain-compare 등, 약 3MB)를
**휴지통으로**(영구 삭제 아님). 설정의 기본 저장소 · 기본 왼쪽/오른쪽 파일(시험 경로)을 비우고 데모 키 둘을 뺐다.
최근 목록에서 시험 경로 6줄만 뺐다(사용자가 실제로 비교한 18줄은 그대로). 이 상태를 새 기준본으로(`base-0930`, 옛 설정은 옆에 따로).
## G-6 고침. 담당 카드 · 프로그램 소개 문구 (2026-09-30, 사용자 지시 · 완료)
- 담당 카드를 **갈래 둘**로: `Platform` 최민영 대리 · `MergeHub` 김승준 사원. 이름표는 영문(사용자 선택). 사람은
`ProgramOverview.PlatformOwner` · `MergeHubOwner` 두 상수 — 설정의 "만든 사람" 은 정보 창의 회사 이름 받침이라 따로 둔다.
- **사내 문의처는 카드에서만 뺐다**(사용자 선택). [정보] 창의 문의처 줄과 G-5 구조(csproj `<SupportContact>` · 설정)는 그대로 — `g5-verify` 3건 통과.
- 소개 문구 `사내 Git Merge/Diff 도구` → **`사내 Merge/Diff + Git 도구`**(Merge 가 먼저 — 사용자 지시). 네 곳을 같은 말로:
csproj `<Description>` · `ProgramOverview.DefaultSubtitle` · 정보 창 받침 글 · 창 제목 `GetAppName()`(`MergeHub - 사내 Merge/Diff + Git`).
창 제목은 플랫폼이 왼쪽 위 글로만 쓰는 것을 읽어 확인했다(설정 폴더 등 열쇠로 안 쓴다).
- 시험: 하네스 `OverviewTest` 새 기대값으로 먼저 고쳐 **고치기 전 4건 실패** → 15건 통과, `AboutTest` 30건. 화면 `g6-verify` 실패 0
(두 담당자 위아래 · 같은 가운데, 설정에 만든 사람 · 문의처를 적어도 카드에 안 나옴, 창 제목) · 사진 확인. 끝에 기준본으로 되돌림.
## G-8. 메모 문법 강조 · 같은 글 칠하기 · 색 마킹 (2026-09-30, 사용자 요청 · 완료)
### 정한 것 (사용자 결정)
- 방식: 파일 비교처럼 **투명한 TextBox + 아래층**. 처음 지시의 `RichTextBox` 는 버렸다 — 칠하는 일이 되돌리기 기록에
섞이고, 줄 번호 · 캐럿 · 찾기 · 보관을 다시 짜야 해서.
- 강조 언어: 메모마다 콤보로 고른다. 기본은 파일이면 확장자, 이름 없는 메모는 C#. **보관하지 않는다.**
- 같은 글 칠하기: 한 줄 · 공백 아님 · 200자 이하 선택만. 낱말을 통째로 고르면 낱말 단위, 아니면 글자 단위.
대소문자를 가린다. 상한 5,000곳. 한 곳뿐이면(고른 그 자리) 칠하지 않는다.
- 마킹: 색 다섯, **Ctrl+1~5**(숫자판 포함) · 단추. 같은 글에 같은 색이면 풀고 다른 색이면 바꾼다. 고른 것이 없으면
캐럿 아래 낱말. 메모마다 따로, **보관하지 않는다.** 선택을 풀어도 남는다(글을 기억해 두었다가 그릴 때마다 찾아 칠한다).
- 오른쪽 위치 막대 12px: 같은 글은 왼쪽 반, 마킹은 오른쪽 반, 캐럿 줄은 가는 선. 누르면 그 줄로.
### 만든 것
- `Services/MemoHighlight.cs` — 셈하는 부분(`SmartTarget` · `Occurrences` · `LineStarts` · `LineOf` · `BarTicks` ·
`Tokenize`(여러 줄 주석 이어받기) · `SplitLines`), 줄 토큰 캐시 `MemoSyntaxCache`, 마킹 목록 `MemoMarkBook`.
로직을 먼저 쓰고 하네스를 붙였다 — **"고치기 전 실패" 단계는 없었다.**
- `Views/Controls/MemoPaintLayer.cs` — 아래층(보이는 줄만 · 배경 → 글자). 글자 자리는 스스로 셈하지 않고 편집기
`GetRectFromCharacterIndex` 에 묻고, 탭이 든 줄은 탭마다 끊어 다시 묻는다. `MemoMarkBar` — 위치 막대.
- `Memos.cs` — `Memo.Language`(고르지 않으면 기본값을 셈) · `Memo.Marks`.
- `MemoView.xaml(.cs)` — 콤보 · 마킹 단추 다섯 · [마킹 지우기] · 세 열 편집기. 다시 칠하기는 `Background` 우선순위로
한 번에 모은다(타이핑 한 번에 TextChanged · SelectionChanged 가 함께 온다). 글 · 언어가 그대로면 줄 토큰을 다시 셈하지 않는다.
세로 **또는 가로** 스크롤 · 크기 바뀜 · Ctrl+휠 뒤에 다시 그린다. 상태 줄 캐럿 글 끝에 `· 같은 글 N곳`.
### 화면에서 나온 것 (셋, 모두 값 · 사진으로 확인)
1. **아래층이 통째로 안 보였다.** 첫 `g8-verify` 에서 칠한 글자 잉크가 **0px**, 사진에는 편집기 선택 색만 있었다.
`같은 글 5곳` 은 나왔으니 칠할 값까지는 셈했다 — 파일 비교 `LocalEditor` 주석에 적힌 것과 같은 까닭:
플랫폼 암시적 TextBox 스타일이 템플릿 안에서 배경을 직접 칠해 `Background="Transparent"` 가 무시된다.
편집기에 `Style="{x:Null}"`. 줄 번호 칸은 "편집기와 같은 템플릿이라 같은 자로 잰다" 가 전제라 **함께** 뺐다
(줄 번호 7 과 7번째 줄 윗변 403px = 403px 로 확인). 선택 색은 LocalEditor 와 같은 값(Accent · 0.35)을 주었다 —
**선택 색은 사진으로 확인 못 함**(시험이 편집기에 포커스를 주지 않아 WPF 가 선택을 안 그렸다).
2. **강조 콤보에 `MergeHub.Services.LanguageProfile` 이 보였다**(사진). 플랫폼 콤보 템플릿은 선택 칸에 `DisplayMemberPath` 를
안 쓴다 — G-4 `SaveChoice` 와 같은 길로 `LanguageProfile.ToString() => Name`. 부르는 곳이 없는 것을 확인하고 넣었다.
3. `MemoMarkBar` 는 `FrameworkElement` 라 `Foreground` 가 없어 빌드 오류 — `TextElement.Foreground` 로.
### 어두운 테마에서 메모 탭 제목이 안 보인다 (사용자 보고, 같은 날)
`g8-tabs.ps1`(키 입력 없이 UIA + 사진)로 재현: 제목 `메모 1` 글 칸에서 바탕(#23262B)과 가장 다른 점의 차이 **116**(검정 글씨).
탭 줄의 `ItemContainerStyle` 이 플랫폼 `ListBoxItem` 스타일을 대신해서 글자색이 WPF 기본(검정)으로 떨어졌다.
`Foreground = Ubisam.Brush.ContentForeground` 를 그 스타일에 넣음 → 어두운 587 · 밝은 635.
파일 비교 문서 탭(`DocTabStrip`)도 같은 구조라 사용자 지시로 이어서 확인했다. `g8-doctabs.ps1`(폴더 비교에서 alpha · bravo 를
열어 탭 둘)로 재현 — 어두운 테마에서 `alpha.cs` 157 · `bravo.cs` 116. 같은 한 줄을 넣어 → 어두운 546 · 587, 밝은 673 · 635.
### 시험
- 빌드 경고 0 · 오류 0. 하네스 `MemoHlTest` 45건, `MemoTest` 69건(그대로 — `.csproj` 에 새로 필요한 소스 여섯 개를 넣음).
- 화면 `g8-verify.ps1 [-Dark]` — 편집기가 UIA 로 알려 주는 글자 칸(= 캐럿이 서는 자리)과 그 칸 안에 실제로 찍힌 잉크의
왼끝 · 오른끝을 px 로 비교한다(반 글자만 밀려도 칸 밖에 잉크가 걸린다).
① 겹침: `speed` 칸 95~148 · 잉크 96~146(왼 +1 · 오른 +2), 탭 든 줄 `speedLimit` · 뒤쪽 `speed` 도 +1~2 · 세로 칸 안.
② 색: 키워드 · 형 키워드 · 보통 이름 · 주석이 갈리고 여러 줄 주석 둘째 줄이 주석 색을 이어받는다. 콤보로 `평문` 을 고르면
`public` 이 보통 글 색, C# 으로 돌리면 돌아온다. ③ 낱말 통째 `같은 글 5곳`(speedLimit 안은 빼고) · 일부 `6곳` ·
`Speed` 는 표시 없음, 다른 자리 배경 칸 안 바탕 0%. ④ Ctrl+1 · 다시 누르면 풀림 · [마킹 2] · Ctrl+숫자판3 으로 색 바꿈 ·
[마킹 지우기] 1개 · 낱말 없는 자리는 안내만. ⑤ 노랑 눈금 y 81 · 95 · 111 · 1122 · 1138(기대 80 · 96 · 112 · 1123 · 1139),
막대를 누르면 71행. ⑥ **'7' 을 치고 마킹 · 선택을 바꾼 뒤 Ctrl+Z 한 번에 타이핑 전 글자 수로**(G-1 회귀 없음).
⑦ 가로로 굴린 뒤 71줄 끝 `speed` 도 +1~2. ⑧ 어두운 테마 같은 항목 모두.
**밝은 · 어두운 모두 실패 0**(사용자가 손을 뗀 동안 마지막으로 돌린 판).
- 흔들림: 사용자가 메시지를 보내던 동안 돌린 세 판은 판마다 다른 자리가 실패했다('7' 이 안 들어감 → Ctrl+Z 가 재료까지
되돌려 0자, 안내 글 항목 등). 흉내 낸 입력에 사용자 입력이 섞인 것으로 보이나 **값으로 확정하지 못했다.**
- 끝날 때마다 설정 네 파일 · 메모를 `base-0930` 과 해시가 같게 되돌림.
### 최종 회귀 (G 묶음 커밋 전, 사용자가 손을 뗀 동안)
`run-final.ps1` — 아홉 벌 + g1~g8(g8 셋은 밝은 · 어두운 두 번) 22벌: **통과 423 · 실패 4**, 끝에 설정 · 메모 해시 기준본과 같음 · 세션 보관함 0개.
실패 4건은 모두 `g8-verify -Dark` ④ 상태 글 — 읽힌 글이 `같은 글 · 마킹이 있는 자리. 누르면 그 줄로 갑니다`(위치 막대 **툴팁**)였다.
앞 벌(밝은)이 ⑤에서 막대를 누른 자리에 마우스가 남아 툴팁이 떴다. 같은 판의 배경 값(걸면 0% · 풀면 61% · 지우기 "1개")은 맞았다 →
**대본 잘못.** 상태 글에서 툴팁을 빼고 시작할 때 마우스를 치우게 고쳐 다시 돌림: 마킹 4건 통과.
그 판에서 ⑦ 마지막 항목("위로 돌아온 탭 줄") 한 번이 실패했다 — `speedLimit` 칸 130~237 안 잉크가 127~154 로 일부만.
실패할 때 사진을 남기게 하고 **다섯 번 더 돌렸으나 모두 실패 0** 이라 재현하지 못했다. 가로 스크롤을 되돌린 직후의 그림을 뜬 것으로
보이나 **값으로 확정하지 못했다**(사진이 없다). 다시 나오면 `g8-dark-7b.png` · `7c.png`(2초 뒤 다시 잰 것)가 남는다.
## G-7. `Alt+↓` 로 이동하다 보면 화면이 잘린다 (2026-09-30, 사용자 보고 · 완료)
### 먼저 재현 (값)
600줄 재료에 모양이 다른 차이 여덟 개(수정 한 줄 · 추가 5줄 · 삭제 10줄 · 400자 긴 줄 · 수정 · 50줄 큰 추가 ·
수정 · 마지막 줄)를 만들고 `Alt+↓` 를 차례로 누르며 칸마다 위치를 쟀다(`g7-probe.ps1`, 사진 `g7-00`~`g7-10`).
증상이 **둘** 나왔다.
| 언제 | 값 | 화면 |
|---|---|---|
| 앞으로 갈 때(2~7번째) | 차이 줄 위 **1,361px** · 칸 아래 1,394px · 줄 높이 약 35px | 가운데(842px)에 와야 할 차이 줄이 **칸 맨 아래에 걸려 잘림** |
| 마지막 → 첫 차이로 돌 때(9번째) | 두 칸의 위가 289 → **832px**(+543) | 두 칸이 통째로 반 칸 밀려 **위는 비고 아래는 잘림** |
### 원인 (기록으로 확인)
`MERGEHUB_G7_TRACE` 로 켜는 임시 기록을 `OnScrollToRowRequested` · `AlignDecorationToEditor` 에 심었다(끝나고 뺐다).
```
GOTO row=29 edOff=8950.5 listOff=8948.5 shift=-6.5
ALIGN row=29 ... wanted=-5.0 edOff=436.7 listOff=436.7 ← 편집기가 캐럿을 들이려고 먼저 구름, 목록도 따라옴
ALIGN row=29 ... wanted=357.2 edOff=74.4 listOff=436.7 ← 9ms 뒤 가운데 맞추기로 편집기만 74.4, 목록은 그대로
```
1. 차이로 가면 캐럿을 옮기는데, 그 순간 편집기가 **"캐럿을 화면에 들이려고" 스스로 한 번 구른다**(WPF 기본).
이 스크롤은 다른 칸을 따라오게 하면서 메아리 막기(`HoldSync`)를 건다 — 이 막기는 `Input` 우선순위에서 풀린다.
2. 곧이어 `CenterCharInView` 가 그 줄을 가운데로 올리려고 편집기를 **한 번 더** 굴린다. 이것은 `Loaded` 우선순위라
`Input` 보다 **먼저** 돈다 → 두 번째 스크롤은 메아리로 여겨져 **버려진다.**
3. 그래서 목록 칸들은 첫 스크롤 자리(= 차이 줄이 칸 맨 아래)에 남았다. 뒤로 크게 돌 때는 편집기와 목록이 반 칸
어긋난 채로 표시층 맞추기(`AlignDecorationToEditor`)가 그 차이를 **이동값 357**(화면 배율 1.5 → 약 543px)로
메워, 네 칸에 함께 걸린 이동값 때문에 두 칸이 통째로 밀렸다.
### 고침
`CenterCharInView` 가 편집기를 굴린 뒤 **다른 칸을 직접 맞춘다** — `HoldSync()` · `Scroll(LocalLines, y)` ·
`SyncPanesFrom(Local, y, 뷰포트)`. 편집기 ScrollChanged 가 하던 일과 같은 호출이라 동작이 갈리지 않고,
메아리 막기에 걸릴 일이 없다. 굴릴 값도 `Math.Max(0, …)` 에서 `Clamp(…, ScrollableHeight)` 로 위아래를 함께 막았다.
### 시험
- 화면 `g7-verify.ps1` — `Alt+↓` 9번(한 바퀴) · `Alt+↑` 3번(뒤로 돌기) · [처음] · [마지막] · [처음] 마다
① 두 칸이 제자리(위 ±12px) ② 차이 줄이 칸 안에 보임 ③ 파일 끝이 아니면 **칸 가운데 근처(칸 높이 ±25%)**.
**고치기 전 10건 실패**(맨 아래 걸림 8 · 칸 밀림 2) → **고친 뒤 실패 0**: 차이 줄 위 837px · 칸 가운데 842px,
칸 위 289px 그대로, 표시층 이동값 −5~−7 (예전 357 없음). 파일 끝 차이(8번)에서 칸이 2~3px 움직이는 것은
원래 있던 미세 맞춤(이동값 −5 → −6.5)이라 이번 증상과 별개다.
- 빌드 경고 0 · 오류 0. 회귀는 아래 G-6 과 함께 적는다.
## G-6. 개발자 정보 (설정 안 메뉴) (2026-09-30, 사용자 요청 · 완료)
### 자리가 바뀌었다 (사용자 지시)
처음에는 하단 탭 맨 끝의 `개요` 로 만들었는데, 사용자가 **"탭으로 만들지 말고 설정에 메뉴 하나 만들어서 개발자
정보라는 이름으로"** 옮겼다. 플랫폼은 건드리지 않고(사용자: "이제 웬만하면 플랫폼은 건들지 않을 거야") 플랫폼이
이미 주는 `AddSub<OverviewView, OverviewViewModel>(TabManager.SettingsTabKey, "개발자 정보")` 로 붙였다 — 플랫폼의
UI Setting · 백업 · 로그와 같은 길이다. 설정 하위 메뉴 차례는 `MergeHub · 개발자 정보 · UI Setting · 설정값 · 백업 ·
클리너 · 로그`, 하단 탭은 예전 그대로(`폴더 비교 · 파일 비교 · Git · 메모 · 설정`).
### 무엇을 만들었나
읽기 전용 안내 화면. 사용자가 준 참고 화면(가운데 제목 + 버전 배지 ·
소개 한 줄 · 목적 문장 · 카드)의 모양을 따랐다. 참고 화면의 이름들은 다른 서비스의 예시라 가져오지 않았다.
| 자리 | 값 | 어디서 |
|---|---|---|
| 제목 · 배지 | `MergeHub` · `v0.12` (배지에 올리면 빌드 날짜) | 실행 파일 (`ProgramInfo`) |
| 소개 한 줄 | `사내 Git Merge/Diff 도구` | csproj `<Description>` |
| 목적 문장 | `MergeHub 는 폴더 비교 · 파일 비교(2-Way · 3-Way 머지) · Git 커밋을 한 곳에서 하는 UbiSam 사내 머지 도구입니다.` | 코드(`ProgramOverview.PurposeText`) |
| 카드 `담당` | 만든 사람 · 사내 문의처 | 설정 → 실행 파일 받침값(G-5) — **정보 창과 같은 길** |
| 아래 | `빌드 2026-09-30 14:14` | 실행 파일 |
사용자 결정(추천안 그대로): ① (처음) 맨 뒤 탭 → 뒤에 설정 메뉴로 옮김 ② 카드 한 장(`담당`) — 이 프로그램에는 Backend/Frontend 같은 갈래가 없다
③ 목적 문장은 코드에, 사람 이름은 기존 설정 · csproj 받침값을 다시 씀 ④ [정보] 창은 그대로 둔다.
### 짠 방식
- `Services/ProgramOverview.cs` — 화면에 올릴 값을 셈하는 순수 모델(`From(ProgramInfo, description)`).
버전을 못 읽으면 `v알 수 없음` 이 되지 않게 `v` 를 붙이지 않는다.
- `OverviewViewModel` — 정보 창과 같은 `ProgramInfoReader.For(어셈블리, 설정 만든 사람, 설정 문의처)`.
설정에서 이름을 고칠 수 있으므로 **화면이 보일 때마다 다시 읽는다**(`IsVisibleChanged`).
- `OverviewView.xaml` — 가운데 정렬 · 카드는 `WrapPanel`(늘어나면 가로로, 좁으면 아래로). 색은 기존 토큰만 썼다
(`ContentBackground` · 카드 `Input.Background`/`Input.Border` · 배지 `Tag.Accent*`).
- `AppSetup.RegisterSettings` — `AddSub<OverviewView, OverviewViewModel>(TabManager.SettingsTabKey, "개발자 정보")`(처음의 `AddMain` · `ViewIds.Overview` 는 뺐다).
### 화면을 보고 고친 것
처음에는 카드 한 줄에 `이름표 값` 을 나란히 두었는데, 사진으로 보니 문의처 안내 글(`적혀 있지 않습니다 (설정 >
MergeHub 에서 적습니다)`)이 카드 폭에서 **`에 / 서` 로 낱말 가운데가 꺾였다.** 또 `Run` 사이 줄바꿈이 공백으로
들어가 이름표와 값 사이가 4칸이 됐다. → **이름표를 위에 작게, 값을 아래에** 두었다. 안내 글 높이 26px = 짧은 값
`UbiSam` 26px(한 줄), 폭 483px.
### 시험
- 하네스 `OverviewTest` **14건 새로** — 고치기 전에는 모델 파일이 없어 컴파일 실패. 제목 · 배지(`v0.12`, 못 읽으면
`v` 없음) · 소개(없으면 기본) · 목적 · 빌드 줄 · 카드 한 장 · 두 줄 · **문의처가 비면 정보 창과 같은 안내 글**.
- 빌드 경고 0 · 오류 0.
- 화면 `g6-verify.ps1` **22건 · 실패 0**(설정 메뉴로 옮긴 뒤):
하단 탭에 개요가 없음(`폴더 비교,파일 비교,Git,메모,설정,…`) · 설정 → 개발자 정보가 열림 · 글 값 전부 · 머리 가운데 1,271 / 목적 1,280 /
카드 1,281 (px, ±20 안) · 설정에 적은 만든 사람 · 문의처가 카드에 나옴 · 파일 비교 · 메모 탭 회귀 · 오류 글 없음.
시험 대본의 헛짚음 둘: 하단 탭 이름은 항목 Name 이 아니라 템플릿 안 TextBlock 이라 **Raw 보기**로 읽어야 했다.
### 값으로 확정하지 못한 것
- **설정 화면에서 이름을 고치고 돌아왔을 때 바로 바뀌는가**(`IsVisibleChanged` 로 다시 읽기) — 시험은 설정 파일을
고치고 **다시 켜서** 확인했다. 켜 둔 채 설정 화면을 흉내 내 고치는 길은 만들지 않았다.
- 시험 뒤 **메모 한 장에 `TL` 두 글자가 들어가 있었다.** 대본은 키를 보내지 않는다. 같은 탭 이동(개요 → 파일 비교 →
메모)만 따로 돌려 보니 메모는 0바이트 그대로라 재현되지 않았다 — 앱에 초점이 간 사이 밖에서 들어온 키 입력으로
보이나 확정하지 못했다. 메모 보관함은 기준본으로 되돌렸다.
## G-5. 사내 문의처가 배포본에 따라가지 않는다 (2026-09-30, 사용자 요청 · 구조 완료 · 문구 대기)
### 무엇이 문제였나
정보 창(12차 B-2)의 만든 사람은 설정이 비면 `csproj` 의 `<Company>` 로 받쳐지지만, 문의처는
`Prefer(contact, null)` — **받칠 것이 없었다.** 설정(`D:\UbisamConfig\Settings\MergeHubSettings.json`)은
PC 마다 따로라, 배포본을 새로 깐 PC 에서는 늘 `적혀 있지 않습니다` 가 보였다.
### 고친 방식 (사용자 결정: csproj 에 받침값)
- `csproj` 에 `<SupportContact></SupportContact>` 를 두고, 빌드할 때 `AssemblyMetadata("SupportContact", …)` 로
실행 파일에 박는다 — 이미 있는 `BuildDate` 와 같은 길이다. **비어 있어도 키는 박는다**(읽는 쪽이 "자리는 있다" 를 확인).
- `ProgramInfoReader.StampedContact` 가 그것을 읽고, `Contact = Prefer(설정, 박힌 값)`. 순서: 설정 → 박힌 값 → 안내 글.
`StampedBuildDate` 와 같은 읽기를 `Stamped(assembly, key)` 하나로 모았다.
- 설정 화면 글: `사내 문의처 (담당자 · 연락처 한 줄, 비우면 실행 파일에 박힌 값)`.
- **문구는 아직 받지 않았다**(사용자 결정: 구조만 먼저). 지금은 비어 있어 화면은 예전과 같다.
나중에 `csproj` 의 `<SupportContact>` 한 줄만 채우면 된다.
### 시험
- 하네스 `AboutTest`(옛 세션 scratchpad 에서 이번 도구 자리로 옮김) 28 → **30건**(+3, 기존 "안내 글" 1건을 바꿈).
고치기 전에는 `StampedContact` 가 없어 **컴파일부터 실패**.
`ABOUT_DLL` · `ABOUT_CONTACT` 로 dll 을 갈아 끼울 수 있게 해 **두 빌드를 모두 읽었다** —
원래 빌드(비어 있음) 30건 · 문구를 채운 시험 빌드(`-p:SupportContact=G5 시험 문의처 (내선 0000)`, scratchpad 로 출력) 30건 실패 0.
- 빌드 경고 0 · 오류 0.
- 화면 `g5-verify.ps1` — 두 빌드에서 각각 3건 · 실패 0:
| 빌드 | 설정 비움 | 설정에 `G5 설정 문의처` |
|---|---|---|
| 원래(비어 있음) | `적혀 있지 않습니다 (설정 > MergeHub 에서 적습니다)` | `G5 설정 문의처` |
| 시험(문구 채움) | **`G5 시험 문의처 (내선 0000)`** | `G5 설정 문의처` (설정이 앞선다) |
만든 사람은 두 경우 모두 `UbiSam` 그대로. 시험 빌드는 같은 자리(`bin\Debug`)에 빌드해 띄운 뒤 **원래대로 다시 빌드**했고,
되돌린 dll 이 안내 글을 내는 것을 하네스로 확인했다.
## G-4. 저장 형식 칸의 `원본 유지` 콤보 둘이 구별되지 않는다 (2026-09-30, 사용자 요청 · 완료)
### 무엇이 문제였나 (값)
저장 형식 칸에 콤보 둘이 나란히 붙어 있고, 기본값이 **둘 다 `원본 유지`** 였다 —
`TextFile.SaveEncodings[0]` 과 `TextFile.SaveNewLines[0]` 이 같은 상수 `KeepOriginal` 이다.
툴팁을 올려 봐야 어느 쪽이 인코딩인지 알 수 있었다.
### 그냥 문자열을 바꾸면 안 되는 까닭
`KeepOriginal` 은 **보이는 글이면서 저장되는 값**이다. `HomeViewModel.SaveEncoding`·`SaveNewLine` 의 값이
그대로 문서 탭 → 세션 보관본(`목록.txt` 의 7·8번째 칸)에 적힌다. 문자열을 바꾸면 이미 보관된 세션의 값
`원본 유지` 가 목록에 없는 값이 되어 되살릴 때 콤보가 빈 채로 뜬다. 또 `TextFile.Encode` 는 모르는 값을
원본 유지로 보므로 동작은 같겠지만, 화면과 값이 어긋난다.
### 고친 방식 — 보이는 글과 값을 가른다 (인수인계의 1안)
- `TextFile.SaveChoice(Value, Label)` 를 새로 두고, 목록을 `SaveEncodingChoices` · `SaveNewLineChoices` 로 바꿨다.
첫 칸만 글이 다르다 — `(원본 유지, "원본 인코딩 유지")` · `(원본 유지, "원본 줄바꿈 유지")`. 나머지는 글 = 값.
- 콤보는 `DisplayMemberPath="Label"` · `SelectedValuePath="Value"` · `SelectedValue="{Binding SaveEncoding}"`.
**ViewModel · 저장 · 세션 보관은 한 줄도 안 바뀌었다** — 여전히 문자열 값을 주고받는다.
- `SaveChoice.ToString()` 이 `Label` 을 돌려준다 — UIA 가 콤보 칸의 이름으로 이것을 읽는다.
- 화면 시험이 집을 수 있게 콤보에 `SaveEncodingBox` · `SaveNewLineBox` 이름을 달았다.
### 시험
- 하네스를 먼저 넣었다. `EncTest` 에 G-4 8건 — 고치기 전에는 새 목록이 없어 **컴파일부터 실패**, 고친 뒤
**56건 · 실패 0**(두 콤보 글이 겹치지 않음 · 저장값은 그대로 `원본 유지` · 원본 유지로 쓰면 원본 줄끝을 따름).
`SessionTest` 에 3건 — 보관본에 `원본 유지` 가 값으로 적히고 그대로 되읽힌다. 고치기 전에도 통과했다
(보관은 값을 글자 그대로 옮길 뿐이라는 뜻 = **값만 안 바꾸면 옛 보관본이 산다**), 고친 뒤 **68건 · 실패 0**.
- 빌드 경고 0 · 오류 0.
- 화면 `g4-verify.ps1` **25건 · 실패 0**:
| 확인 | 값 |
|---|---|
| 연 직후 콤보 글 | `원본 인코딩 유지` · `원본 줄바꿈 유지` |
| 콤보 칸들 | `원본 인코딩 유지,UTF-8,UTF-8 (BOM),CP949` · `원본 줄바꿈 유지,CRLF,LF` |
| 콤보 폭 | 330px (글이 잘리지 않음) |
| LF · UTF-8 (BOM) 골라 오른쪽 저장 | CRLF 로 만든 파일이 **CR 0개** · BOM 붙음 — 고른 값이 저장까지 간다 |
| 원본 유지로 돌리고 강제로 끔 | 보관본 7·8칸 = `원본 유지 \| 원본 유지` (옛 값 그대로) |
| 다시 켬 | 고친 글이 되살아나고 콤보 = `원본 인코딩 유지` · `원본 줄바꿈 유지` |
| CP949 · CRLF 로 같은 시험 | 보관본 `CP949 \| CRLF` → 되살린 콤보 `CP949` · `CRLF` |
- 회귀 `e1-verify`(세션 되살리기) — **첫 번은 12건 실패, 같은 빌드로 다시 돌리니 29건 · 실패 0.**
첫 번은 1단계에서 폴더 비교 줄을 **진짜 마우스로 더블클릭**해 둘째 짝을 여는 것이 안 먹어 탭이 하나뿐이었고,
뒤 단계가 줄줄이 따라 실패했다. 코드가 같은데 결과가 갈렸으므로 G-4 와는 무관하다. 다만 **더블클릭이 왜
빗나갔는지는 값으로 확정하지 못했다**(창이 가려졌거나 앞에 오지 않았던 것으로 보이나 찍어 두지 않았다).
## G-3. "덩어리" 라는 말이 어색하다 (2026-09-30, 사용자 요청 · 완료)
### 무엇을 바꿨나
사용자 결정대로 화면에 보이는 "덩어리" 를 **"구간"** 으로 바꿨다. 툴팁은 원래 "차이가 이어지는 구간" 이라
적고 있었다 — 단추 글만 다른 말을 쓰고 있던 셈이다.
| 자리 | 전 | 후 |
|---|---|---|
| 단추 (`AcceptBlockButton`) | `이 덩어리 반영` | **`구간 반영`** |
| 단추 (`AcceptBlockToBaseButton`) | `이 덩어리 왼쪽으로` | **`구간 왼쪽으로`** |
| 단축키 설명 `Alt+Shift+→` | `이 덩어리 반영 - 차이가 이어지는 구간을 한 번에` | `구간 반영 - 차이가 이어지는 구간을 한 번에` |
| 단축키 설명 `Alt+Shift+←` | `이 덩어리를 왼쪽 파일로 - 이어지는 구간을 한 번에 (2-Way)` | `구간 왼쪽으로 - 차이가 이어지는 구간을 한 번에 왼쪽 파일로 (2-Way)` |
| 로그 (Git 탭 아래) | `5~7행 덩어리 BASE(…) 반영 (3줄)` | `5~7행 구간 BASE(…) 반영 (3줄)` |
| 로그 (왼쪽으로) | `5~7행 덩어리 왼쪽에 반영 (3줄)` | `5~7행 구간 왼쪽에 반영 (3줄)` |
토스트(`3줄 반영` · `3줄 왼쪽에 반영`)는 원래 "덩어리" 가 없어 그대로다. 옆의 `이 메서드 반영` 도 그대로다.
**바꾸지 않은 것**: 코드 안 이름(`AcceptBlock` · `BlockAround` 등)과 주석의 "덩어리"(개념을 가리키는 말이라
화면에 안 나온다). 단추 이름을 그대로 인용하던 주석 두 곳만 맞췄다. **웹 문서(`doc/web/index.html`)에 옛 문구가
네 곳 남아 있다** — 웹 문서는 차수 끝에 한 번에 고친다.
### 시험
- 하네스 `ShortcutTest` 101 → **104건**(+3): 두 설명의 첫머리가 `구간 반영` · `구간 왼쪽으로` 인가,
단축키 표 **전체**에 "덩어리" 가 남지 않았는가. **고치기 전에 돌려 새 3건이 실패하는 것을 먼저 확인했다**
(`통과 101 · 실패 3`) → 고친 뒤 `통과 104 · 실패 0`.
- 빌드 경고 0 · 오류 0.
- 화면 `g3-verify.ps1` **18건 · 실패 0** — 단추 글(UIA Name)이 `구간 반영` · `구간 왼쪽으로`, 두 단추 폭 270px
(잘리지 않음), 누르기 전후 화면 어디에도 "덩어리" 없음, 두 방향 모두 세 줄이 한 번에 가고 한 번에 돌아옴,
**로그 칸(Git 탭, ValuePattern)** 에 `5~7행 구간 왼쪽에 반영 (3줄)` · `5~7행 구간 BASE(block.cs) 반영 (3줄)`.
- 회귀 `g2-verify` 23건 실패 0(단추는 AutomationId 로 찾아 문구와 무관).
- `f2-verify`·`f2-probe` 는 단추를 **글로** 찾아 새 문구로 맞췄다. 다만 `f2-verify` 는 이번 변경보다 앞 단계에서
`'저장' 안 보임` 으로 멈춘다 — `저장` 단추는 12차 B-1 에서 `◀ 왼쪽 저장` · `오른쪽 저장 ▶` 로 나뉘어 이미 없고,
이 대본은 회귀 몰이(`run-all-merge`)에도 없다. **원래 낡은 대본이라 G-3 과 무관**하다. 멈춘 채 뒤처리를 못 해
설정 두 파일 · 세션 보관함이 흐트러졌고, 이번 세션 시작 때 떠 둔 기준본으로 바이트까지 되돌렸다.
## G-2. base 칸으로 머지하는 단축키 (2026-09-30, 사용자 요청 · 완료)
### 무엇이 없었나

View File

@ -8,6 +8,14 @@
| 날짜 | 항목 | 커밋 |
|---|---|---|
| 2026-09-30 | **12차 G 묶음 마무리(알파 · 베타 전)** — ① 설정의 `프로그램 정보`(만든 사람 · 사내 문의처) 칸을 뺐다 — 개발자 정보가 담당을 보인다(사용자). [정보] 창은 실행 파일 값만 읽고(`ProgramInfoReader.For` 의 설정 인수를 걷음), 빈 칸 안내 글에서 없어진 설정 자리를 뺐다. ② 사용자 요청 "작성자를 입력하지 않으면 알람" — 주석 남기기가 켜져 있는데 작성자가 비면 **반영 명령 맨 앞**(여덟 곳)에서 알림 창을 띄우고 반영하지 않는다(사용자 선택, 줄마다가 아니라 명령마다 — [모두 반영] 이 줄 수만큼 알리지 않게). 예전에는 git 이름 · 윈도우 계정으로 조용히 채웠다. 미리보기도 그 이름을 안 보인다. 한때 "주석 늘 켬" 으로 바꿨다가 사용자 지시로 원복. ③ 데모(설정 두 칸 · [데모 →] 단추 · 전환 코드)를 걷어냄. 이 PC 의 시험 재료는 휴지통으로, 설정 · 최근 목록의 시험 경로를 비움. 하네스 `AboutTest` 30 · `NoteTest` 99 · `OverviewTest` 15건(둘은 먼저 고쳐 컴파일 실패 확인), 화면 `g5` · `g6` · `g9-author`(새) 실패 0. 빌드 경고 0 · 오류 0 | 아래 |
| 2026-09-30 | **12차 G-6 고침** — 사용자 지시로 개발자 정보 담당 카드를 갈래 둘(`Platform` 최민영 대리 · `MergeHub` 김승준 사원, 이름표 영문)로 바꾸고 사내 문의처는 **카드에서만** 뺐다([정보] 창과 G-5 구조는 그대로 — 사용자 선택). 사람은 `ProgramOverview` 상수 둘 — 설정의 "만든 사람" 은 정보 창의 회사 이름 받침이라 섞지 않는다. 소개 문구를 `사내 Git Merge/Diff 도구` → **`사내 Merge/Diff + Git 도구`**(Merge 가 먼저)로 csproj `<Description>` · 개발자 정보 기본값 · 정보 창 받침 · 창 제목(`MergeHub - 사내 Merge/Diff + Git`) 네 곳을 맞춤 — 창 제목은 플랫폼이 표시에만 쓰는 것을 읽어 확인. 하네스 `OverviewTest` 기대값을 먼저 바꿔 4건 실패 → 15건 통과, `AboutTest` 30건, 화면 `g6-verify` · `g5-verify` 실패 0. 빌드 경고 0 · 오류 0 | 아래 |
| 2026-09-30 | **12차 G-8** — 메모 편집기에 문법 강조 · 같은 글 칠하기 · 색 마킹 다섯(Ctrl+1~5) · 오른쪽 위치 막대. 방식은 파일 비교와 같은 **투명한 TextBox + 아래층**(사용자 결정 — RichTextBox 는 칠하기가 되돌리기에 섞여 버림). 셈은 `MemoHighlight.cs`(하네스 `MemoHlTest` 45건), 그리기는 `MemoPaintLayer`(글자 자리를 편집기 `GetRectFromCharacterIndex` 에 묻고 탭마다 끊어 다시 묻는다). 언어 · 마킹은 메모마다 따로, 보관하지 않는다. 화면에서 셋을 고쳤다: ① 첫 시험에서 칠한 잉크가 0px — 플랫폼 암시적 TextBox 스타일이 템플릿 안에서 배경을 직접 칠해 아래층을 덮었다(LocalEditor 와 같은 까닭) → 편집기 · 줄 번호 칸 `Style="{x:Null}"`, 선택 색은 LocalEditor 와 같은 값 ② 강조 콤보에 형 이름이 보임 → `LanguageProfile.ToString() => Name`(G-4 `SaveChoice` 와 같은 길) ③ 사용자 보고 "어두운 테마에서 메모 제목이 안 보인다" — 탭 줄 `ItemContainerStyle` 이 플랫폼 스타일을 대신해 글자색이 검정으로 떨어짐(잉크 차이 116) → 그 스타일에 `ContentForeground`(587). 같은 구조인 파일 비교 문서 탭도 재현(157 · 116)해 같은 한 줄로 고침(546 · 587). 화면 `g8-verify` — 편집기 글자 칸과 칠한 잉크를 px 로 비교: +1~2px(탭 든 줄 · 가로 스크롤 포함) · 문법 색 · 같은 글 5곳/6곳 · 마킹 걸기/풀기/바꾸기/지우기 · 막대 눈금과 누르기 · **Ctrl+Z 가 칠하기로 한 걸음도 안 늘어남** — 밝은 · 어두운 **실패 0**. 사용자가 PC 를 쓰던 동안의 판은 흔들렸다(입력이 섞인 것으로 보이나 확정 못 함). 빌드 경고 0 · 오류 0 | 아래 |
| 2026-09-30 | **12차 G-7** — 사용자 보고 "Alt+↓ 로 이동하다 보면 화면이 잘린다". 600줄에 모양이 다른 차이 여덟 개를 만들어 값으로 재현했다: 앞으로 갈 때 차이 줄이 칸 가운데(842px)가 아니라 **맨 아래(1,361px, 칸 아래 1,394px)에 걸려 잘리고**, 마지막 → 첫 차이로 돌면 **두 칸이 통째로 543px 밀려** 위는 비고 아래는 잘렸다. 코드를 읽어 추리하지 않고 임시 기록(`MERGEHUB_G7_TRACE`, 끝나고 뺌)으로 고리를 떴다 — 캐럿을 옮기는 순간 편집기가 "캐럿을 화면에 들이려고" 먼저 한 번 구르며 다른 칸을 따라오게 하고 메아리 막기(`HoldSync`, `Input` 우선순위에서 풀림)를 건다. 곧이어 `CenterCharInView`(`Loaded` 우선순위 — `Input` 보다 먼저 돈다)가 가운데로 올리는 두 번째 스크롤은 **메아리로 여겨져 버려졌다.** 그래서 목록 칸은 첫 자리(맨 아래)에 남았고, 크게 돌 때는 편집기 74.4 · 목록 436.7 로 어긋난 것을 표시층 맞추기가 이동값 357(배율 1.5 → 543px)로 메워 네 칸이 함께 밀렸다. 고침: `CenterCharInView` 가 편집기를 굴린 뒤 다른 칸을 **직접** 맞춘다(`HoldSync` · `Scroll(LocalLines)` · `SyncPanesFrom` — 편집기 ScrollChanged 가 하던 것과 같은 호출). 화면 `g7-verify` — `Alt+↓` 한 바퀴 · `Alt+↑` 뒤로 돌기 · [처음]/[마지막] 마다 칸 제자리 · 차이 줄이 칸 안 · 가운데 근처: **고치기 전 10건 실패 → 고친 뒤 0**(차이 줄 837px · 칸 가운데 842px · 칸 위 289px 그대로 · 이동값 −5~−7). 빌드 경고 0 · 오류 0 | 아래 |
| 2026-09-30 | **12차 G-6** — 사용자 요청 "프로그램 개요". **설정 안 메뉴 `개발자 정보`** — 처음에 하단 탭 맨 끝 `개요` 로 만들었다가 사용자가 "탭으로 만들지 말고 설정에 메뉴로" 옮겼다. 플랫폼은 건드리지 않고(사용자: 웬만하면 플랫폼은 안 건드림) 기존 `AddSub(TabManager.SettingsTabKey, "개발자 정보")` 로 붙였다 — 하단 탭은 예전 그대로. 읽기 전용 안내 화면이고 모양은 사용자가 준 참고 화면을 따랐다: 가운데 제목 `MergeHub` + 버전 배지 `v0.12`(올리면 빌드 날짜), 소개 한 줄(csproj Description), 목적 문장, 카드 한 장 `담당`(만든 사람 · 사내 문의처), 아래 빌드 날짜. 사용자 결정(추천안): 카드 한 장 — 갈래가 없는 프로그램이다, 목적 문장만 코드에, 사람 이름은 **정보 창과 같은 길**(`ProgramInfoReader.For` — 설정 → 실행 파일 받침값)로 읽어 두 화면이 다른 값을 가리키지 않게, [정보] 창은 그대로. 값 셈은 순수 모델 `ProgramOverview` 로 떼었고 화면이 보일 때마다 다시 읽는다. 화면 사진을 보고 한 가지 고침 — 카드 한 줄에 `이름표 값` 을 나란히 두니 문의처 안내 글이 낱말 가운데서 꺾여(`에 / 서`) 이름표를 위에 작게 · 값을 아래에 두었다(안내 글 26px = 한 줄). 하네스 `OverviewTest` 14건 새로(고치기 전 컴파일 실패), 빌드 경고 0 · 오류 0, 화면 `g6-verify` 22건 실패 0 — 하단 탭에 개요 없음 · 설정 → 개발자 정보가 열림, 글 값, 가운데 정렬(머리 1,271 · 목적 1,280 · 카드 1,281px), 설정에 적은 값이 카드에, 앞 탭 회귀. **켜 둔 채 설정을 고치면 바로 바뀌는지는 값으로 확정 못 함**(다시 켜서 확인). 시험 뒤 메모 한 장에 `TL` 두 글자가 들어가 있었는데 같은 동작만 따로 돌리면 재현되지 않았다 — 밖에서 들어온 키 입력으로 보이나 확정 못 함, 메모는 기준본으로 되돌림 | 아래 |
| 2026-09-30 | **12차 G-5** — 사용자 요청 "사내 문의처가 배포본에 안 따라간다". 정보 창(B-2)의 만든 사람은 설정이 비면 `csproj` `<Company>` 로 받쳐지지만 문의처는 `Prefer(contact, null)` 로 받칠 것이 없었다 — 설정은 PC 마다 따로라 배포본을 새로 깐 PC 에서는 늘 안내 글이었다. `csproj` 에 `<SupportContact>` 를 두고 `BuildDate` 와 같은 길(`AssemblyMetadata`)로 실행 파일에 박는다(비어 있어도 키는 박음). `ProgramInfoReader.StampedContact` 가 읽고 `Contact = Prefer(설정, 박힌 값)` — 설정이 앞선다. 두 메타데이터 읽기를 `Stamped(assembly, key)` 하나로 모음. 설정 화면 글에 "비우면 실행 파일에 박힌 값" 을 더함. **문구는 아직 받지 않았다(사용자 결정: 구조만 먼저)** — 지금은 비어 있어 화면이 예전과 같고, 나중에 `csproj` 한 줄만 채우면 된다. 하네스 `AboutTest` 28 → 30건 — 고치기 전 컴파일 실패, 원래 빌드 · 문구를 채운 시험 빌드(`-p:SupportContact=…`) 둘 다 실패 0. 화면 `g5-verify` 두 빌드 × 3건 실패 0: 설정이 비면 박힌 값(`G5 시험 문의처 (내선 0000)`, 비었으면 안내 글), 설정에 적으면 그것. 시험 빌드 뒤 원래대로 다시 빌드해 되돌린 dll 을 하네스로 확인. 빌드 경고 0 · 오류 0 | 아래 |
| 2026-09-30 | **12차 G-4** — 사용자 요청 "저장 형식 칸에 `원본 유지` 콤보가 둘이라 구별이 안 된다". 인코딩 · 줄바꿈 두 콤보의 기본 칸이 같은 상수 `TextFile.KeepOriginal`(`원본 유지`)이었다. 글을 **`원본 인코딩 유지` · `원본 줄바꿈 유지`** 로 갈랐다(사용자 결정). 그런데 그 문자열은 **보이는 글이면서 저장되는 값**이라 — `SaveEncoding`·`SaveNewLine` 이 문서 탭을 거쳐 세션 보관본 `목록.txt` 7·8번째 칸에 그대로 적힌다 — 문자열만 바꾸면 옛 보관본의 값이 목록에 없는 값이 된다. 그래서 `SaveChoice(Value, Label)` 를 두고 콤보가 `DisplayMemberPath="Label"` · `SelectedValuePath="Value"` 로 **보이는 글만** 바꾸게 했다. ViewModel · 저장 · 세션 보관 코드는 그대로다. `ToString()` 은 보이는 글(UIA 가 읽는다). 하네스 `EncTest` 56건(+8, 고치기 전 컴파일 실패) · `SessionTest` 68건(+3 — 고치기 전에도 통과, 보관은 값을 글자 그대로 옮길 뿐임을 확인), 빌드 경고 0 · 오류 0. 화면 `g4-verify` 25건 — 콤보 글 · 칸 목록 · 폭 330px, LF · UTF-8 (BOM) 을 골라 저장하면 CRLF 파일이 CR 0개 · BOM 붙음, 원본 유지로 두고 강제 종료하면 보관본에 `원본 유지` 가 그대로 적히고 다시 켜면 콤보가 `원본 인코딩 유지` · `원본 줄바꿈 유지` 로 되살아남, CP949 · CRLF 도 되살아남. 회귀 `e1-verify` 29건 실패 0 — 첫 번은 폴더 비교 줄 마우스 더블클릭이 먹지 않아 12건 실패했고 같은 빌드로 다시 돌려 통과(더블클릭이 빗나간 원인은 값으로 확정하지 못했다) | 아래 |
| 2026-09-30 | **12차 G-3** — 사용자 요청 "덩어리라는 말이 어색하다". 화면에 보이는 "덩어리" 를 **"구간"** 으로 바꿨다(사용자 결정) — 단추 `[이 덩어리 반영]` → **`[구간 반영]`**, `[이 덩어리 왼쪽으로]` → **`[구간 왼쪽으로]`**, 단축키 설명 두 줄(`Alt+Shift+→` · `Alt+Shift+←`), 로그 `5~7행 덩어리 … 반영` → `5~7행 구간 … 반영`. 툴팁은 원래 "차이가 이어지는 구간" 이라 적고 있어 단추 글만 다른 말을 쓰던 셈이다. 토스트(`3줄 반영`)는 원래 그 말이 없어 그대로. 코드 안 이름(`AcceptBlock` · `BlockAround`)과 개념을 가리키는 주석은 화면에 안 나오므로 그대로 두고, 단추 이름을 인용하던 주석 두 곳만 맞췄다. **웹 문서의 옛 문구 네 곳은 차수 끝에 한 번에.** 하네스 `ShortcutTest` 101 → 104건 — 고치기 전에 새 3건이 실패하는 것을 먼저 확인했다. 화면 `g3-verify` 18건(단추 글 · 폭 270px · 화면 전체에 "덩어리" 없음 · Git 탭 로그 칸 값) · 회귀 `g2-verify` 23건 실패 0, 빌드 경고 0 · 오류 0. 단추를 글로 찾던 `f2-verify` 는 새 문구로 맞췄지만 원래 낡아서(`저장` 단추가 B-1 에서 둘로 나뉨) 앞 단계에서 멈춘다 — G-3 과 무관 | 아래 |
| 2026-09-30 | **12차 G-2** — 사용자 요청 "base 영역에 머지할 수 있는 단축키". 12차 B-1 로 **왼쪽 파일로 반영**하는 길(`[<]` · `TakeLineToBase`)이 생겼는데 단축키가 없었고, 덩어리를 왼쪽으로 보내는 것은 명령 자체가 없었다(`AcceptBlock` 은 오른쪽으로만 간다). `Alt+←` 는 비어 있지 않았는데 — `CopyRightToLeft` 가 쓰고 있었고 2-Way 에서 하는 일이 이름·화살표와 달리 **"이 줄 되돌리기"** 였다. 그 이름이 가리키는 일이 B-1 로 실제로 생겼으므로 그쪽으로 넘겼다(사용자 결정). **`Alt+←` = 한 줄 왼쪽으로 · `Alt+Shift+←` = 덩어리 왼쪽으로**(새로 만듦) · 되돌리기는 `Ctrl+Z` 만. 3-Way(Take Theirs)는 안 건드렸다. 찾을 수 있게 **[이 덩어리 왼쪽으로] 단추**도 달았다 (두 파일 비교에서만 뜬다). `ApplyGroupToBase` 는 `ApplyGroup` 의 거울 — 주석은 덩어리 첫 줄 위 한 줄, 되돌리기는 한 번에. 덩어리를 잡는 `BlockAround` 는 2-Way 에서 이미 `CanTakeBase` 로 잡으므로 그대로 썼다. 겪은 것: `RaiseCanExecuteChanged`·`OnCaretRowChanged` 에 새 명령을 안 넣어 단추가 꺼진 채였고, 덩어리 명령은 고른 줄이 아니라 `CaretRow`(편집기 캐럿)를 본다. 화면 `g2-verify` 21건 실패 0 · 하네스 `ShortcutTest` 88 → 101건 · 회귀 `g1`·`e2`·`e3`·`e4` 실패 0. **`Alt+Shift` 조합은 윈도우가 입력 언어 전환으로 먼저 채가 흉내 낸 키로 못 눌렀다** — 기존 `Alt+Shift+→` 로 견줘 봐도 같아 시험 도구 한계이고(C-3 의 DragMove 와 같은 부류), 덩어리는 같은 명령을 부르는 단추로 확인했다. 사람 손으로 한 번 눌러 보는 것이 남았다 | 아래 |
| 2026-09-30 | **12차 G-1** — 사용자 보고 "undo 가 계속 이어진다". 짝을 바꾼 뒤 `Ctrl+Z` 를 누르면 **앞 파일 글(234자) ↔ 지금 파일 글(228자)을 끝없이 오갔다** — 40번을 눌러도 안 끝났고, 글자를 하나도 안 쳐도 났다. `Ctrl+Z` 는 우리 목록(`_undo`·`_textUndo`)이 비면 일부러 키를 먹지 않는데(그래야 글자 되돌리기가 산다), 그러면 키가 `TextBox` 자체 되돌리기로 넘어간다. WPF 의 그것은 한도가 없고 **프로그램이 코드로 넣은 글까지 기록**해서, 짝을 바꾸면 앞 파일 글을 편집기에 되살렸다. 되살아난 글을 `Reflow` 가 "사용자가 친 것" 으로 보고 모델을 그쪽으로 되돌리며 되돌리기에 다시 쌓고 다시하기를 지워, 두 상태에 갇혔다(기록을 심어 고리를 떴다 — `221 = 234 − 13` 으로 편집기가 쥔 것이 앞 파일임을 값으로 확인). **고침은 한 줄** — `LocalEditor` 에 `IsUndoEnabled="False"`. 되돌리기는 우리 목록만 쓴다. 화면 `g1-verify` 14건 실패 0(30번 눌러도 길이 자취가 `228` 하나), 편집 관련 다섯 벌(`e1`·`e2`·`e2-reg`·`e3`·`e4`) 회귀 실패 0. **헛짚은 것 둘도 남긴다** — "문서가 바뀔 때 TextBox 기록을 비우면 된다"(`EditorUndoGate` 를 만들어 하네스 16건까지 붙였다)와 "재정렬 타이머가 뒤늦게 터진다" 는 둘 다 증상이 안 없어져 **되돌렸다**. 검증 안 된 동작 변경은 남기지 않는다. 대신 글자 **한 자 단위** 되돌리기는 없어지고 "타이핑이 멈춘 자리" 가 한 걸음이 됐다 | 아래 |
| 2026-09-30 | **플랫폼을 공용 저장소로 합침** — 우리가 따로 쓰던 플랫폼 소스(`D:\Sorce\ubsrepo`, 원격이 `Ubisam-MergeTool` 이었다)에 얹어 둔 두 커밋을 **진짜 플랫폼 저장소** `D:\Sorce\UbisamCommonPlatform`(사내 Gitea) 로 옮겼다. `6991dfd` 이후 양쪽이 갈라져 있었고 — 우리 2커밋(트레이 · 창 자리 기억 · 세 갈래 묻는 창 · 탭 줄 크기 · 헤더 접기) vs 공용 4커밋(우클릭 메뉴 테마 · 앱 아이콘 · 업데이트 기본값 · HANDOFF) — **소스 파일이 하나도 겹치지 않아** 부딪힌 것은 `changelog.md` 한 군데뿐이었다(양쪽 줄 다 살려 날짜순으로 합침). 공용 쪽에 빠져 있던 문서도 채웠다: 가이드 8-5 에 헤더 접기, `HANDOFF.md` 5·6절에 우리 기능 — **C-3 은 플랫폼 changelog 에도 줄이 없었다.** 빌드해 이 PC 의 `D:\UbisamPlatform\bin` 까지 반영(오류 0 · 경고 4, 배포본 해시로 확인). `dist\` 는 손대지 않았다 — 사내 다른 PC 는 그대로다. 화면 회귀 아홉 벌 **208건 실패 0 · 예외 0**, MergeHub 재빌드 경고 0 · 오류 0. **앞으로 플랫폼은 공용 저장소에서만 고친다** | 아래 |

View File

@ -446,6 +446,15 @@
| 상태 | 항목 |
|---|---|
| **완료** | **작성자가 비면 반영하지 않고 알린다**(사용자 요청) — '반영한 줄에 주석 남기기' 가 켜져 있는데 작성자가 비면 반영 명령 여덟 곳 맨 앞에서 알림 창("작성자를 적어 주세요", 설정 자리를 댐)을 띄우고 멈춘다(사용자 선택). 주석을 끄면 묻지 않는다(늘 켬으로 바꿨다가 사용자 지시로 원복). 미리보기도 대신 채울 이름을 안 보인다. 하네스 `NoteTest` 93 → 99건, 화면 `g9-author` 실패 0 |
| **완료** | **설정의 프로그램 정보 칸 · 데모 걷어냄**(사용자 지시) — 만든 사람 · 사내 문의처 칸을 뺐다(개발자 정보가 대신). [정보] 창은 실행 파일 값만, 빈 칸 안내는 `적혀 있지 않습니다`. 데모 설정 두 칸 · [데모 →] 단추 · 전환 코드를 지움. 이 PC 의 시험 재료 13개는 휴지통으로, 설정 · 최근 목록의 시험 경로를 비움. 하네스 `AboutTest` 30건, 화면 `g5` · `g6` 실패 0 |
| **완료** | **담당 카드 · 소개 문구 고침**(사용자 지시, 12차 G-6 고침) — 개발자 정보 담당 카드를 `Platform` 최민영 대리 · `MergeHub` 김승준 사원 두 줄로, 사내 문의처는 카드에서만 뺌([정보] 창 · G-5 구조는 그대로). 소개 문구를 **`사내 Merge/Diff + Git 도구`**(Merge 먼저)로 — csproj 설명 · 개발자 정보 · 정보 창 받침 · 창 제목 네 곳. 하네스 `OverviewTest` 고치기 전 4건 실패 → 15건 통과, `AboutTest` 30건, 화면 `g6-verify` · `g5-verify` 실패 0 |
| **완료** | **메모 문법 강조 · 같은 글 칠하기 · 색 마킹**(사용자 요청, 12차 G-8) — 파일 비교처럼 투명한 편집기 + 아래층(사용자 결정). 강조 언어는 메모마다 콤보(파일은 확장자, 이름 없는 메모는 C#). 고른 글과 같은 글 모두 배경(낱말 통째면 낱말 단위 · 대소문자 구분 · 상태 줄 `같은 글 N곳`). 마킹은 색 다섯 · Ctrl+1~5 · 다시 누르면 풀림 · [마킹 지우기], 선택을 풀어도 남고 보관하지는 않는다. 오른쪽 위치 막대(누르면 그 줄로). 화면에서 나온 것: 플랫폼 TextBox 스타일이 아래층을 통째로 덮음(`Style="{x:Null}"`, 줄 번호 칸도 함께) · 콤보에 형 이름이 보임(`ToString`) · **어두운 테마에서 메모 탭 · 파일 비교 문서 탭 제목이 검정**(사용자 보고, 잉크 차이 116 → 587 · 문서 탭 157/116 → 546/587). 하네스 `MemoHlTest` 45건 · `MemoTest` 69건, 화면 `g8-verify` 밝은 · 어두운 실패 0(칠한 글자가 편집기 글자 칸 안 +1~2px — 탭 든 줄 · 가로 스크롤 포함, Ctrl+Z 가 칠하기로 늘지 않음). |
| **완료** | **`Alt+↓` 로 이동하다 보면 화면이 잘린다**(사용자 보고, 12차 G-7) — 재현: 차이 줄이 가운데가 아니라 **칸 맨 아래에 걸려 잘리고**(줄 위 1,361px · 칸 아래 1,394px), 마지막 → 첫 차이로 돌면 **두 칸이 통째로 543px 밀려** 아래가 잘렸다. 기록으로 원인을 떴다 — 캐럿을 옮길 때 편집기가 먼저 한 번 구르며 건 메아리 막기(`Input` 에서 풀림)에, 곧이은 가운데 맞추기(`Loaded`)의 두 번째 스크롤이 걸려 **버려졌다**. 목록 칸은 첫 자리에 남고, 크게 돌면 표시층 맞추기가 그 차이를 이동값 357 로 메워 칸이 밀렸다. `CenterCharInView` 가 다른 칸을 **직접** 맞추게 고침. 화면 `g7-verify` 고치기 전 10건 실패 → 0(차이 줄 837px · 칸 가운데 842px, 칸 위 289px 그대로) |
| **완료** | **개발자 정보**(사용자 요청, 12차 G-6) — **설정 안 메뉴 `개발자 정보`**(처음에 하단 탭 `개요` 로 만들었다가 사용자 지시로 옮김, 플랫폼은 안 건드리고 기존 `AddSub(설정 탭 키)` 로). 읽기 전용 안내 화면으로 사용자가 준 참고 화면 모양(가운데 제목 + 버전 배지 `v0.12` · 소개 한 줄 · 목적 문장 · 카드). 카드는 한 장 `담당`(만든 사람 · 사내 문의처) — 정보 창과 같은 길(설정 → 실행 파일 받침값)로 읽고, 보일 때마다 다시 읽는다. 목적 문장만 코드에. [정보] 창은 그대로. 화면 사진을 보고 긴 문의처 글이 낱말 가운데서 꺾이던 것을 이름표 위 · 값 아래로 고침. 하네스 `OverviewTest` 14건 새로, 화면 `g6-verify` 22건 실패 0(하단 탭에 개요 없음 · 설정 → 개발자 정보 · 글 값 · 가운데 ±20px · 설정값 반영 · 회귀). **켜 둔 채 설정을 고쳤을 때 바로 바뀌는지는 값으로 확정 못 함**(다시 켜서 확인) |
| **구조 완료 · 문구 대기** | **사내 문의처가 배포본에 따라가지 않는다**(사용자 요청, 12차 G-5) — 만든 사람은 `<Company>` 로 받쳐지는데 문의처는 받칠 것이 없어 새로 깐 PC 에서는 늘 `적혀 있지 않습니다` 였다. `csproj` 의 `<SupportContact>` 를 `AssemblyMetadata` 로 실행 파일에 박고, 정보 창은 설정 → 박힌 값 → 안내 글 순으로 보인다(사용자 결정). **문구는 아직 받지 않아 비워 두었다** — 나중에 `csproj` 한 줄만 채우면 된다. 하네스 `AboutTest` 28 → 30건(두 빌드 모두), 화면 `g5-verify` 두 빌드 × 3건 실패 0 (문구를 채운 시험 빌드에서 `G5 시험 문의처 (내선 0000)` 이 보이고, 설정에 적으면 그것이 앞섬) |
| **완료** | **저장 형식 콤보 둘이 구별되지 않는다**(사용자 요청, 12차 G-4) — 둘 다 `원본 유지` 라 어느 쪽이 인코딩인지 몰랐다. 글을 **`원본 인코딩 유지` · `원본 줄바꿈 유지`** 로 갈랐다(사용자 결정). 그 글이 곧 세션 보관본에 적히는 값이라, 값은 `원본 유지` 그대로 두고 **보이는 글만** `SaveChoice(Value, Label)` 로 갈랐다(콤보 `DisplayMemberPath` · `SelectedValuePath`) — 옛 보관본이 그대로 되살아난다. 하네스 `EncTest` +8(56건) · `SessionTest` +3(68건), 화면 `g4-verify` 25건(고른 값이 저장 바이트까지 · 강제 종료 뒤 되살리기) · 회귀 `e1-verify` 29건 실패 0(첫 번은 마우스 더블클릭이 빗나가 12건 실패 — 다시 돌려 통과, 원인은 값으로 확정 못 함) |
| **완료** | **"덩어리" 라는 말이 어색하다**(사용자 요청, 12차 G-3) — 화면의 "덩어리" 를 **"구간"** 으로(사용자 결정): 단추 `[구간 반영]` · `[구간 왼쪽으로]`, 단축키 설명 두 줄, 로그 `5~7행 구간 … 반영 (3줄)`. 툴팁은 원래 "구간" 이라 적고 있었다. 코드 안 이름 · 개념 주석은 그대로, **웹 문서의 옛 문구 네 곳은 차수 끝에**. 하네스 `ShortcutTest` 101 → 104건(고치기 전 새 3건 실패 확인), 화면 `g3-verify` 18건 · 회귀 `g2-verify` 23건 실패 0 |
| **완료** | **base 칸으로 머지하는 단축키**(사용자 요청, 12차 G-2) — `Alt+←` 를 **한 줄 왼쪽 파일로**(`[<]` 와 같은 일), `Alt+Shift+←` 를 **덩어리 왼쪽으로**(명령을 새로 만듦). `Alt+←` 는 원래 2-Way 에서 "이 줄 되돌리기" 였는데 이름·화살표와 하는 일이 달랐고, 그 뜻대로 하는 일이 B-1 로 생겨 넘겼다(되돌리기는 `Ctrl+Z` 만, 사용자 결정). 3-Way 는 안 건드림. 찾을 수 있게 **[이 덩어리 왼쪽으로] 단추**도 달았다. 화면 `g2-verify` 21건 · 하네스 `ShortcutTest` 88 → 101건. **`Alt+Shift` 조합은 윈도우가 입력 언어 전환으로 채가 흉내 낸 키로 못 눌렀다** — 기존 `Alt+Shift+→` 도 같아 시험 도구 한계이고, 덩어리는 단추로 확인했다 |
| **완료** | **undo 가 계속 이어진다**(사용자 보고, 12차 G-1) — 짝을 바꾼 뒤 `Ctrl+Z` 를 누르면 앞 파일 글 ↔ 지금 파일 글을 **끝없이 오갔다**(40번을 눌러도 안 끝남, 글자를 안 쳐도 남). 우리 목록이 비면 `Ctrl+Z` 가 키를 안 먹어 `TextBox` 자체 되돌리기로 넘어가고, 그것이 **앞 파일 글을 되살리면** `Reflow` 가 그걸 "친 것" 으로 보고 모델을 되돌리며 다시 쌓았다. 고침은 한 줄 — `LocalEditor` 에 `IsUndoEnabled="False"`. 되돌리기는 우리 목록만 쓴다. 화면 `g1-verify` 14건 · 편집 관련 다섯 벌 회귀 실패 0. 글자 **한 자 단위** 되돌리기는 없어지고 "타이핑이 멈춘 자리" 가 한 걸음이 됐다 |
| **완료** | `[<]` 단추가 있는 줄만 글이 밀린다(사용자 보고, 그림으로 짚어 줌) — 단추 칸 폭이 `Auto` 라 단추가 보이는 줄만 뒤 칸이 밀렸다(BASE 는 번호까지, LOCAL 은 코드가). `GutterBothWaysWidth` 로 두 칸이 같은 자리를 늘 잡아 두고, 모드에 따라 0 ↔ 단추 폭으로 갈아 끼운다. 편집기 여백도 같이 민다. 값으로 확인: 한 칸의 모든 줄이 같은 x(BASE 137 · LOCAL 1283), 글자 크기 · Git 모드도 그대로 |

View File

@ -13,7 +13,7 @@ namespace MergeHub
/// </summary>
public class AppSetup : IAppSetup
{
public string GetAppName() => "MergeHub - 사내 Git Merge/Diff";
public string GetAppName() => "MergeHub - 사내 Merge/Diff + Git";
public void RegisterViews(ITabManager manager)
{
@ -38,6 +38,11 @@ namespace MergeHub
public void RegisterSettings(ITabManager manager)
{
manager.AddSettingsSub<MergeHubSettings>(TabManager.SettingsTabKey, "MergeHub");
// 개발자 정보(12차 G-6). 처음에는 하단 탭(개요)으로 만들었으나 사용자가 설정 안 메뉴로 옮겼다 -
// 작업 흐름 밖의 안내라 매일 누르는 하단 탭 줄에 자리를 차지할 까닭이 없다. 플랫폼은 건드리지 않고
// 플랫폼이 이미 주는 AddSub(설정 탭 키)로 붙인다(플랫폼의 UI Setting · 백업 · 로그와 같은 길).
manager.AddSub<OverviewView, OverviewViewModel>(TabManager.SettingsTabKey, "개발자 정보");
}
public void RegisterServices(IServiceCollection services)

View File

@ -15,7 +15,11 @@
<Version>0.12.0</Version>
<Product>MergeHub</Product>
<Company>UbiSam</Company>
<Description>사내 Git Merge/Diff 도구</Description>
<!-- 사내 문의처 받침값(12차 G-5). 설정의 "사내 문의처" 가 비면 정보 창이 이것을 보인다 -
설정은 PC 마다 따로라 배포본을 새로 깐 PC 에서는 늘 비어 있다. 담당자 · 연락처 한 줄.
비워 두면 지금처럼 "설정 안 됨". 문구는 아직 받지 않았다. -->
<SupportContact></SupportContact>
<Description>사내 Merge/Diff + Git 도구</Description>
<!-- 플랫폼 배포 폴더 (platform-guide 10-2 (B) 배포용) -->
<UbisamPlatformDir>D:\UbisamPlatform\bin\</UbisamPlatformDir>
@ -38,6 +42,11 @@
<_Parameter1>BuildDate</_Parameter1>
<_Parameter2>$([System.DateTime]::Now.ToString("yyyy-MM-dd HH:mm"))</_Parameter2>
</AssemblyAttribute>
<!-- 문의처 받침값(12차 G-5). 비어 있어도 키는 박는다 - 읽는 쪽이 "자리는 있다" 를 확인할 수 있게. -->
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>SupportContact</_Parameter1>
<_Parameter2>$(SupportContact)</_Parameter2>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>

View File

@ -9,15 +9,8 @@ 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;
// "프로그램 정보" 칸(만든 사람 · 사내 문의처)은 2026-09-30 사용자 지시로 뺐다 - 설정 → 개발자 정보가 담당을 보인다.
// [정보] 창은 실행 파일에 박힌 값(회사 이름 · csproj <SupportContact>)만 읽는다. 옛 설정 파일에 남은 두 키는 읽을 때 무시된다.
[Category("저장소")]
[DisplayName("기본 저장소 경로")]
@ -36,16 +29,6 @@ namespace MergeHub
[DisplayName("기본 오른쪽 파일")]
public string DefaultRightFile { get; set; } = string.Empty;
// 발표·교육용. 상단 "데모" 버튼이 이 두 경로를 오가며 Git 있는 화면과 없는 화면을 바로 보여 준다.
// 비워 두면 버튼이 나오지 않는다.
[Category("데모")]
[DisplayName("데모: .git 있는 폴더")]
public string DemoGitPath { get; set; } = string.Empty;
[Category("데모")]
[DisplayName("데모: .git 없는 폴더")]
public string DemoPlainPath { get; set; } = string.Empty;
[Category("사내 Gitea")]
[DisplayName("서버 주소")]
public string GiteaUrl { get; set; } = string.Empty;
@ -135,7 +118,7 @@ namespace MergeHub
public bool IgnoreMergeNotes { get; set; } = true;
[Category("머지 주석")]
[DisplayName("작성자 (비우면 git config user.name)")]
[DisplayName("작성자 (주석을 남기면 꼭 적습니다 - 비어 있으면 반영하지 않고 알립니다)")]
public string MergeCommentAuthor { get; set; } = string.Empty;
/// <summary>

View File

@ -86,6 +86,12 @@ namespace MergeHub.Services
public HashSet<string> TypeKeywords { get; }
public bool HasBlockComment => BlockStart is { Length: > 0 } && BlockEnd is { Length: > 0 };
/// <summary>
/// 메모 강조 콤보(12차 G-8)의 선택 칸이 이 글을 그린다 - 플랫폼 콤보 템플릿은 선택 칸에 DisplayMemberPath 를
/// 안 써서, 없으면 형 이름(MergeHub.Services.LanguageProfile)이 보였다(사진으로 확인). UIA 이름도 이것이다.
/// </summary>
public override string ToString() => Name;
}
/// <summary>

View File

@ -0,0 +1,290 @@
using System;
using System.Collections.Generic;
using MergeHub.Views.Controls;
namespace MergeHub.Services
{
/// <summary>글 안의 한 구간(시작 · 길이).</summary>
public readonly struct TextSpan
{
public TextSpan(int start, int length)
{
Start = start;
Length = length;
}
public int Start { get; }
public int Length { get; }
public int End => Start + Length;
}
/// <summary>색 마킹 한 구간. <see cref="Color"/> 는 0 부터 <see cref="MemoMarkBook.ColorCount"/> - 1.</summary>
public readonly struct MarkSpan
{
public MarkSpan(int start, int length, int color)
{
Start = start;
Length = length;
Color = color;
}
public int Start { get; }
public int Length { get; }
public int Color { get; }
}
/// <summary>
/// 오른쪽 위치 막대의 눈금 하나. <see cref="Kind"/> 가 -1 이면 "고른 글과 같은 글", 0 이상이면 그 색 마킹.
/// </summary>
public readonly struct BarTick
{
public BarTick(int line, int kind)
{
Line = line;
Kind = kind;
}
public int Line { get; }
public int Kind { get; }
}
/// <summary>
/// 메모 편집기의 문법 강조 · 마킹(12차 G-8)에서 <b>셈하는 부분</b>. 화면 없이 검사할 수 있게 떼어 두었다.
///
/// <para>
/// 편집기는 파일 비교처럼 <b>투명한 TextBox + 아래층</b>이다(사용자 결정). 글자는 TextBox 가 받고, 색 · 마킹은
/// 아래층이 그린다 - 칠하는 일이 편집 기록(되돌리기)에 섞이지 않고, 줄 번호 · 찾기 · 보관이 TextBox 그대로 산다.
/// </para>
/// </summary>
public static class MemoHighlight
{
/// <summary>고른 글이 이보다 길면 같은 글 칠하기를 하지 않는다 - 긴 선택은 대개 "이 구간을 옮기겠다" 는 뜻이다.</summary>
public const int MaxTargetLength = 200;
/// <summary>한 번에 칠할 같은 글의 상한. 한 글자를 골랐을 때 수만 곳을 칠하느라 멈추지 않게.</summary>
public const int MaxOccurrences = 5000;
public static bool IsWordChar(char c) => char.IsLetterOrDigit(c) || c == '_';
/// <summary>
/// 지금 고른 것을 "같은 글 모두 칠하기" 의 대상으로 삼을지. 삼으면 찾을 글, 아니면 null.
///
/// 대상이 아닌 것: 빈 선택 · 공백뿐 · 줄을 넘는 선택 · 너무 긴 선택.
/// <paramref name="wholeWord"/> - 고른 것이 <b>낱말 하나를 통째로</b> 고른 것이면 참(notepad++ 식):
/// <c>speed</c> 를 골랐을 때 <c>speedLimit</c> 안의 speed 까지 칠하면 눈이 어지럽다.
/// </summary>
public static string? SmartTarget(string text, int start, int length, out bool wholeWord)
{
wholeWord = false;
if (text is null || length <= 0 || start < 0 || start + length > text.Length) return null;
if (length > MaxTargetLength) return null;
var target = text.Substring(start, length);
if (target.IndexOf('\n') >= 0 || target.IndexOf('\r') >= 0) return null;
if (target.Trim().Length == 0) return null;
var allWord = true;
foreach (var c in target)
{
if (!IsWordChar(c)) { allWord = false; break; }
}
var leftOk = start == 0 || !IsWordChar(text[start - 1]);
var end = start + length;
var rightOk = end >= text.Length || !IsWordChar(text[end]);
wholeWord = allWord && leftOk && rightOk;
return target;
}
/// <summary>
/// <paramref name="needle"/> 이 나오는 자리를 모두(대소문자 구분). <paramref name="wholeWord"/> 면 앞뒤가
/// 낱말 글자가 아닌 곳만. 겹치는 자리는 세지 않는다(<c>aaa</c> 에서 <c>aa</c> 는 한 번).
/// </summary>
public static List<TextSpan> Occurrences(string text, string needle, bool wholeWord, int limit = MaxOccurrences)
{
var found = new List<TextSpan>();
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(needle)) return found;
var from = 0;
while (from <= text.Length - needle.Length && found.Count < limit)
{
var at = text.IndexOf(needle, from, StringComparison.Ordinal);
if (at < 0) break;
var end = at + needle.Length;
if (!wholeWord ||
((at == 0 || !IsWordChar(text[at - 1])) && (end >= text.Length || !IsWordChar(text[end]))))
{
found.Add(new TextSpan(at, needle.Length));
from = end;
}
else
{
from = at + 1;
}
}
return found;
}
/// <summary>줄마다 첫 글자 위치. TextBox 처럼 <c>\n</c> 을 줄 끝으로 본다(<c>\r\n</c> 의 <c>\r</c> 는 앞 줄에 붙는다).</summary>
public static int[] LineStarts(string text)
{
var starts = new List<int> { 0 };
if (!string.IsNullOrEmpty(text))
{
for (var i = 0; i < text.Length; i++)
{
if (text[i] == '\n') starts.Add(i + 1);
}
}
return starts.ToArray();
}
/// <summary>글자 위치가 몇 번째 줄(0부터)인지. <see cref="LineStarts"/> 결과에서 이분 탐색.</summary>
public static int LineOf(int[] lineStarts, int index)
{
var lo = 0;
var hi = lineStarts.Length - 1;
while (lo < hi)
{
var mid = (lo + hi + 1) / 2;
if (lineStarts[mid] <= index) lo = mid;
else hi = mid - 1;
}
return lo;
}
/// <summary>
/// 오른쪽 위치 막대의 눈금. 같은 줄 · 같은 종류는 하나로 줄인다 - 한 줄에 열 번 나와도 눈금은 하나면 된다.
/// 마킹이 같은 글 칠하기보다 뒤에 온다(위에 그려진다).
/// </summary>
public static List<BarTick> BarTicks(int[] lineStarts, IEnumerable<TextSpan> occurrences, IEnumerable<MarkSpan> marks)
{
var seen = new HashSet<long>();
var ticks = new List<BarTick>();
void Add(int start, int kind)
{
var line = LineOf(lineStarts, start);
if (seen.Add(((long)line << 8) | (uint)(kind + 1))) ticks.Add(new BarTick(line, kind));
}
foreach (var span in occurrences) Add(span.Start, -1);
foreach (var mark in marks) Add(mark.Start, mark.Color);
return ticks;
}
/// <summary>
/// 줄마다 문법 토큰. 여러 줄 주석(<c>/* */</c>)이 다음 줄로 이어지므로 <b>위에서부터</b> 넘긴다.
/// <paramref name="lines"/> 는 줄 끝 <c>\r</c> 을 뗀 글이어야 한다.
/// </summary>
public static IReadOnlyList<CodeToken>[] Tokenize(IReadOnlyList<string> lines, LanguageProfile profile, MemoSyntaxCache? cache = null)
{
var result = new IReadOnlyList<CodeToken>[lines.Count];
var carry = CarryOver.None;
for (var i = 0; i < lines.Count; i++)
{
result[i] = cache is null
? CodeTokenizer.Tokenize(lines[i], profile, carry, out carry)
: cache.Tokenize(lines[i], profile, carry, out carry);
}
return result;
}
/// <summary>글을 줄로 자른다 - 줄 끝 <c>\r</c> 은 뗀다. 줄 수는 <see cref="LineStarts"/> 와 같다.</summary>
public static string[] SplitLines(string text)
{
var parts = (text ?? string.Empty).Split('\n');
for (var i = 0; i < parts.Length; i++)
{
if (parts[i].Length > 0 && parts[i][parts[i].Length - 1] == '\r') parts[i] = parts[i].Substring(0, parts[i].Length - 1);
}
return parts;
}
}
/// <summary>
/// 줄 단위 토큰 캐시. 한 글자 칠 때마다 글 전체를 다시 쪼개지 않도록, 바뀌지 않은 줄(같은 글 · 같은 이어받기)은 그대로 쓴다.
/// 언어를 바꾸면 통째로 비운다.
/// </summary>
public sealed class MemoSyntaxCache
{
private const int MaxEntries = 20000;
private readonly Dictionary<(string, CarryOver), (IReadOnlyList<CodeToken> Tokens, CarryOver Next)> _items =
new Dictionary<(string, CarryOver), (IReadOnlyList<CodeToken>, CarryOver)>();
private LanguageProfile? _profile;
public int Count => _items.Count;
public IReadOnlyList<CodeToken> Tokenize(string line, LanguageProfile profile, CarryOver carry, out CarryOver next)
{
if (!ReferenceEquals(profile, _profile))
{
_items.Clear();
_profile = profile;
}
if (_items.TryGetValue((line, carry), out var hit))
{
next = hit.Next;
return hit.Tokens;
}
var tokens = CodeTokenizer.Tokenize(line, profile, carry, out next);
if (_items.Count >= MaxEntries) _items.Clear();
_items[(line, carry)] = (tokens, next);
return tokens;
}
}
/// <summary>
/// 색 마킹 목록(12차 G-8). 고른 글을 색 하나로 <b>모든 자리</b>에 칠해 둔다 - 여러 색을 함께 둘 수 있다.
/// 같은 글에 같은 색을 다시 걸면 풀고, 다른 색이면 그 색으로 바꾼다.
/// 메모마다 따로 들고, 보관하지는 않는다(읽는 동안 쓰는 표시다).
/// </summary>
public sealed class MemoMarkBook
{
public const int ColorCount = 5;
private readonly List<(string Text, int Color)> _items = new List<(string, int)>();
public int Count => _items.Count;
public IReadOnlyList<(string Text, int Color)> Items => _items;
/// <summary>걸었으면 true, 풀었으면 false.</summary>
public bool Toggle(string text, int color)
{
if (string.IsNullOrEmpty(text)) return false;
if (color < 0 || color >= ColorCount) throw new ArgumentOutOfRangeException(nameof(color));
var at = _items.FindIndex(i => i.Text == text);
if (at >= 0)
{
var same = _items[at].Color == color;
_items.RemoveAt(at);
if (same) return false;
}
_items.Add((text, color));
return true;
}
public void Clear() => _items.Clear();
/// <summary>글 안에서 마킹이 걸린 자리들. 나중에 건 것이 뒤에 온다(위에 그려진다).</summary>
public List<MarkSpan> Spans(string text)
{
var spans = new List<MarkSpan>();
foreach (var (mark, color) in _items)
{
foreach (var at in MemoHighlight.Occurrences(text, mark, wholeWord: false))
spans.Add(new MarkSpan(at.Start, at.Length, color));
}
return spans;
}
}
}

View File

@ -26,7 +26,12 @@ namespace MergeHub.Services
public string? Path
{
get => _path;
internal set { if (Set(ref _path, value)) Raise(nameof(Title)); }
internal set
{
if (!Set(ref _path, value)) return;
Raise(nameof(Title));
Raise(nameof(Language)); // 다른 이름으로 저장하면 확장자가 바뀐다 - 고르지 않았으면 따라간다(G-8)
}
}
/// <summary>파일에 매인 메모인가.</summary>
@ -69,6 +74,21 @@ namespace MergeHub.Services
/// <summary>캐럿 자리. 메모를 오갈 때 그 자리로 돌아가려고 든다.</summary>
public int CaretIndex { get; set; }
private LanguageProfile? _language;
/// <summary>
/// 문법 강조 언어(12차 G-8). 고르지 않았으면 파일은 확장자로, 이름 없는 메모는 C#(메모에 붙여 넣는 것은
/// 대개 사내 C# 코드다). 보관하지 않는다 - 다시 켜면 이 기본값으로 돌아온다.
/// </summary>
public LanguageProfile Language
{
get => _language ?? (HasFile ? LanguageCatalog.ForPath(Path) : LanguageCatalog.CSharp);
set => Set(ref _language, value);
}
/// <summary>색 마킹(12차 G-8). 메모마다 따로 들고, 보관하지 않는다 - 읽는 동안 쓰는 표시다.</summary>
public MemoMarkBook Marks { get; } = new MemoMarkBook();
public string Tip => HasFile ? Path! : "이름 없는 메모입니다. 저장하지 않아도 남습니다";
/// <summary>마지막으로 떠 있던 순서.</summary>

View File

@ -31,6 +31,21 @@ namespace MergeHub.Services
public static string AuthorOf(string? author) =>
string.IsNullOrWhiteSpace(author) ? Environment.UserName : author!.Trim();
/// <summary>
/// 반영하기 전에 막아야 하는가 - 주석을 남기게 해 두었는데 설정의 작성자가 비었다(사용자 결정 2026-09-30).
/// 비었을 때 git 이름 · 윈도우 계정으로 조용히 채우면, 장비를 여럿이 쓰는 현장에서 누가 반영했는지 주석이 틀리게 남는다.
/// 주석을 끈 사람에게는 묻지 않는다 - 이름이 들어갈 곳이 없다.
/// </summary>
public static bool AuthorMissing(bool insertComment, string? author) =>
insertComment && string.IsNullOrWhiteSpace(author);
public const string AuthorMissingTitle = "작성자를 적어 주세요";
public const string AuthorMissingMessage =
"'반영한 줄에 주석 남기기' 가 켜져 있는데 작성자가 비어 있어 반영하지 않았습니다.\n\n" +
"설정 > MergeHub > 머지 주석 > 작성자 에 이름을 적은 뒤 다시 반영하세요.\n" +
"주석을 남기지 않으려면 같은 곳에서 '반영한 줄에 주석 남기기' 를 끕니다.";
public static string Build(string? author, DateTime when, string? comment, string? path) =>
Build(author, when, source: null, comment, path);

View File

@ -18,14 +18,15 @@ namespace MergeHub.Services
/// <summary>실행 파일을 만든 시각. 못 읽으면 빈 글.</summary>
public string BuildDate { get; set; } = string.Empty;
/// <summary>만든 사람. 설정에 적어 두면 그것, 없으면 어셈블리의 회사 이름.</summary>
/// <summary>만든 사람 - 어셈블리의 회사 이름(csproj <c>&lt;Company&gt;</c>).</summary>
public string Author { get; set; } = string.Empty;
/// <summary>사내 문의처. 설정에서 적는다 - 사람이 바뀌면 소스를 고칠 일이 아니다.</summary>
/// <summary>사내 문의처 - csproj <c>&lt;SupportContact&gt;</c> 를 박아 둔 값(12차 G-5). 설정 칸은 2026-09-30 에 뺐다.</summary>
public string Contact { get; set; } = string.Empty;
/// <summary>적혀 있지 않은 칸에 대신 적을 글. 빈칸으로 두면 "왜 비었나" 를 묻게 된다.</summary>
public const string NotSet = "적혀 있지 않습니다 (설정 > MergeHub 에서 적습니다)";
/// <summary>적혀 있지 않은 칸에 대신 적을 글. 빈칸으로 두면 "왜 비었나" 를 묻게 된다.
/// 설정 칸이 없어졌으므로 "설정에서 적습니다" 를 붙이지 않는다 - 없는 곳을 가리키게 된다.</summary>
public const string NotSet = "적혀 있지 않습니다";
public const string Unknown = "알 수 없음";
}
@ -106,8 +107,8 @@ namespace MergeHub.Services
: !string.IsNullOrWhiteSpace(second) ? second!.Trim()
: ProgramInfo.NotSet;
/// <summary>실행 파일에서 읽어 채운다. 설정에 적어 둔 만든 사람 · 문의처가 있으면 그것을 앞에 둔다.</summary>
public static ProgramInfo For(Assembly assembly, string? author, string? contact, Func<string, byte[]?>? readBytes = null)
/// <summary>실행 파일에서 읽어 채운다.</summary>
public static ProgramInfo For(Assembly assembly, Func<string, byte[]?>? readBytes = null)
{
var name = assembly.GetCustomAttribute<AssemblyProductAttribute>()?.Product;
var company = assembly.GetCustomAttribute<AssemblyCompanyAttribute>()?.Company;
@ -129,17 +130,23 @@ namespace MergeHub.Services
Name = Prefer(name, assembly.GetName().Name),
Version = VersionText(assembly.GetName().Version),
BuildDate = ChooseBuildDate(stamped, pe, fileTime),
Author = Prefer(author, company),
Contact = Prefer(contact, null),
Author = Prefer(company, null),
// 12차 G-5: 배포본을 새로 깐 PC 에도 따라가게 실행 파일에 박는다 - csproj 의 <SupportContact>.
Contact = Prefer(StampedContact(assembly), null),
};
}
/// <summary>csproj 가 빌드할 때 박아 둔 날짜(AssemblyMetadata "BuildDate"). 없으면 null.</summary>
public static string? StampedBuildDate(Assembly assembly)
public static string? StampedBuildDate(Assembly assembly) => Stamped(assembly, "BuildDate");
/// <summary>csproj 의 <c>&lt;SupportContact&gt;</c> 를 박아 둔 값(12차 G-5). 비워 두었으면 빈 글, 키가 없으면 null.</summary>
public static string? StampedContact(Assembly assembly) => Stamped(assembly, "SupportContact");
private static string? Stamped(Assembly assembly, string key)
{
foreach (var meta in assembly.GetCustomAttributes<AssemblyMetadataAttribute>())
{
if (string.Equals(meta.Key, "BuildDate", StringComparison.Ordinal)) return meta.Value;
if (string.Equals(meta.Key, key, StringComparison.Ordinal)) return meta.Value;
}
return null;
}

View File

@ -0,0 +1,82 @@
using System.Collections.Generic;
namespace MergeHub.Services
{
/// <summary>
/// 개발자 정보 화면(12차 G-6, 설정 안 메뉴)에 올릴 값. 이 프로그램이 무엇이고 누가 맡는지를 한 화면에 적는다.
///
/// <para>
/// 값은 <see cref="ProgramInfo"/> 에서 온다 - 정보 창(12차 B-2)과 <b>같은 곳</b>에서 읽어야 두 화면이
/// 다른 사람 · 다른 버전을 가리키는 일이 없다. 여기서 새로 적는 것은 목적 문장 하나뿐이다
/// (사람이 바뀌어도 목적은 안 바뀌므로 코드에 둔다 - 사용자 결정).
/// </para>
/// </summary>
public sealed class ProgramOverview
{
/// <summary>이 프로그램이 왜 있는가. 탭 이름 순서(폴더 → 파일 → Git)대로 적는다.</summary>
public const string PurposeText =
"MergeHub 는 폴더 비교 · 파일 비교(2-Way · 3-Way 머지) · Git 커밋을 한 곳에서 하는 UbiSam 사내 머지 도구입니다.";
/// <summary>
/// 담당자(사용자 지정 2026-09-30). 설정의 "만든 사람" 과 따로 둔다 - 그것은 정보 창의 회사 이름 받침이고,
/// 여기는 갈래마다 맡은 사람이다. 사람이 바뀌면 이 두 줄을 고친다.
/// </summary>
public const string PlatformOwner = "최민영 대리";
public const string MergeHubOwner = "김승준 사원";
/// <summary>csproj 에 Description 이 없을 때의 소개 한 줄.</summary>
public const string DefaultSubtitle = "사내 Merge/Diff + Git 도구";
public string Title { get; private set; } = string.Empty;
public string VersionBadge { get; private set; } = string.Empty;
public string Subtitle { get; private set; } = string.Empty;
public string Purpose { get; private set; } = PurposeText;
public string BuildLine { get; private set; } = string.Empty;
public IReadOnlyList<OverviewCard> Cards { get; private set; } = new OverviewCard[0];
public static ProgramOverview From(ProgramInfo info, string? description) => new ProgramOverview
{
Title = info.Name,
// 못 읽었으면 "v알 수 없음" 이 되지 않게 v 를 붙이지 않는다.
VersionBadge = info.Version == ProgramInfo.Unknown ? info.Version : "v" + info.Version,
Subtitle = string.IsNullOrWhiteSpace(description) ? DefaultSubtitle : description!.Trim(),
BuildLine = "빌드 " + (string.IsNullOrWhiteSpace(info.BuildDate) ? ProgramInfo.Unknown : info.BuildDate),
// 카드는 한 장(사용자 결정). 갈래는 이 프로그램이 올라선 공용 플랫폼과 MergeHub 자체 둘이다(2026-09-30).
// 문의처는 이 카드에서 뺐다 - [정보] 창에는 그대로 남는다(사용자 결정).
Cards = new[]
{
new OverviewCard("담당", new[]
{
new OverviewRow("Platform", PlatformOwner),
new OverviewRow("MergeHub", MergeHubOwner),
}),
},
};
}
/// <summary>개발자 정보 화면의 카드 한 장.</summary>
public sealed class OverviewCard
{
public OverviewCard(string title, IReadOnlyList<OverviewRow> rows)
{
Title = title;
Rows = rows;
}
public string Title { get; }
public IReadOnlyList<OverviewRow> Rows { get; }
}
/// <summary>카드 안의 한 줄 - 이름표와 값.</summary>
public sealed class OverviewRow
{
public OverviewRow(string label, string value)
{
Label = label;
Value = value;
}
public string Label { get; }
public string Value { get; }
}
}

View File

@ -24,7 +24,7 @@ namespace MergeHub.Services
NextFile,
ToggleBookmark,
AcceptBlock,
AcceptBlockToBase, // 12차 G-2 - 덩어리를 왼쪽 파일로
AcceptBlockToBase, // 12차 G-2 - 구간을 왼쪽 파일로 (G-3 에서 "덩어리" → "구간")
NextConflict,
PrevConflict,
AutoMerge,
@ -129,7 +129,7 @@ namespace MergeHub.Services
"가리키는 '왼쪽으로 반영'이 12차 B-1 로 실제로 생겼다([<] 단추). 화살표는 늘 어느 쪽으로 " +
"가는가를 가리켜야 하므로 그쪽으로 넘겼다. 되돌리기는 Ctrl+Z 가 한다."),
new ShortcutEntry(ShortcutAction.AcceptBlockToBase, Key.Left, ModifierKeys.Alt | ModifierKeys.Shift, "병합·편집",
"이 덩어리를 왼쪽 파일로 - 이어지는 구간을 한 번에 (2-Way)",
"구간 왼쪽으로 - 차이가 이어지는 구간을 한 번에 왼쪽 파일로 (2-Way)",
"오른쪽으로 보내는 Alt+Shift+→ 의 거울이다."),
new ShortcutEntry(ShortcutAction.NextConflict, Key.Down, ModifierKeys.Alt | ModifierKeys.Shift, "병합·편집",
@ -139,7 +139,7 @@ namespace MergeHub.Services
new ShortcutEntry(ShortcutAction.AutoMerge, Key.M, ModifierKeys.Alt, "병합·편집",
"자동 병합 - 저쪽만 고친 줄을 한 번에 가져온다 (3-Way)"),
new ShortcutEntry(ShortcutAction.AcceptBlock, Key.Right, ModifierKeys.Alt | ModifierKeys.Shift, "병합·편집",
"이 덩어리 반영 - 차이가 이어지는 구간을 한 번에",
"구간 반영 - 차이가 이어지는 구간을 한 번에",
"줄 하나씩만 되면 연속 다섯 줄을 다섯 번 눌러야 한다."),
new ShortcutEntry(ShortcutAction.Undo, Key.Z, ModifierKeys.Control, "병합·편집", "실행 취소",

View File

@ -56,11 +56,23 @@ namespace MergeHub.Services
/// <summary>저장할 때 고를 수 있는 인코딩. 빈 값(원본 유지)이 기본이다.</summary>
public const string KeepOriginal = "원본 유지";
public static IReadOnlyList<string> SaveEncodings { get; } =
new[] { KeepOriginal, "UTF-8", "UTF-8 (BOM)", "CP949" };
/// <summary>
/// 저장 형식 콤보의 칸들(12차 G-4). 두 콤보가 나란히 붙어 둘 다 "원본 유지" 라고만 적혀 있어
/// 어느 쪽이 인코딩인지 구별이 안 됐다. 그래서 <b>보이는 글만</b> 가른다 -
/// <see cref="KeepOriginal"/> 값은 세션 보관본(목록.txt 7·8번째 칸)에 그대로 적혀 있으므로
/// 값까지 바꾸면 옛 보관본을 되살릴 때 목록에 없는 값이 된다.
/// </summary>
public static IReadOnlyList<SaveChoice> SaveEncodingChoices { get; } = new[]
{
new SaveChoice(KeepOriginal, "원본 인코딩 유지"),
new SaveChoice("UTF-8"), new SaveChoice("UTF-8 (BOM)"), new SaveChoice("CP949"),
};
public static IReadOnlyList<string> SaveNewLines { get; } =
new[] { KeepOriginal, "CRLF", "LF" };
public static IReadOnlyList<SaveChoice> SaveNewLineChoices { get; } = new[]
{
new SaveChoice(KeepOriginal, "원본 줄바꿈 유지"),
new SaveChoice("CRLF"), new SaveChoice("LF"),
};
/// <summary>
/// 원래 파일의 인코딩과 줄바꿈을 그대로 유지해 덮어쓴다.
@ -304,4 +316,23 @@ namespace MergeHub.Services
return crlf > 0 ? "CRLF" : lf > 0 ? "LF" : "CR";
}
}
/// <summary>
/// 저장 형식 콤보의 한 칸(12차 G-4). <see cref="Value"/> 는 저장 · 세션 보관본에 쓰이고
/// <see cref="Label"/> 은 화면에만 보인다. 콤보는 <c>SelectedValuePath="Value"</c> 로 값에 묶는다.
/// </summary>
public sealed class SaveChoice
{
public SaveChoice(string value, string? label = null)
{
Value = value;
Label = label ?? value;
}
public string Value { get; }
public string Label { get; }
/// <summary>UIA 가 콤보 칸의 이름으로 읽는 글이기도 하다 - 화면에 보이는 글과 맞춘다.</summary>
public override string ToString() => Label;
}
}

View File

@ -221,7 +221,7 @@ namespace MergeHub.ViewModels
/// 지금 짝에 탭이 하나는 있게 한다. <see cref="LoadPlainFilesAsync"/> 가 맨 앞에서 부른다.
///
/// 여는 길(<c>OnPlainCompareRequested</c>)을 거치지 않고 바로 짝이 바뀌는 자리가 있다 -
/// 켤 때 설정의 기본 짝, [기준 파일...] · [내 파일...] 로 한쪽만 바꾸기, 데모 전환 뒤 돌아오기.
/// 켤 때 설정의 기본 짝, [기준 파일...] · [내 파일...] 로 한쪽만 바꾸기.
/// 그 자리에 탭이 없으면 거기서 고친 것을 맡을 데가 없어 <b>다음에 짝을 바꿀 때 사라진다</b> -
/// E-2 가 없애려던 바로 그 일이다.
///

View File

@ -297,66 +297,6 @@ namespace MergeHub.ViewModels
TextFile.Write(target, content, encoding, newLine);
}
// ================= 데모 전환 (발표·교육용) =================
/// <summary>설정에 데모 경로가 둘 다 적혀 있을 때만 전환 버튼을 띄운다.</summary>
public bool CanSwitchDemo =>
Directory.Exists(_workspace.Settings.DemoGitPath) &&
Directory.Exists(_workspace.Settings.DemoPlainPath);
/// <summary>다음에 어디로 넘어가는지 버튼에 그대로 적는다.</summary>
public string DemoSwitchLabel => IsGitMode ? "데모 → .git 없음" : "데모 → .git 있음";
/// <summary>
/// Git 있는 폴더와 없는 폴더를 오간다.
///
/// 발표에서 "저장소면 이렇게, 아니면 이렇게"를 한 자리에서 보여 주려고 둔 것이다.
/// 그동안은 설정 파일을 고치고 프로그램을 다시 켜는 것 말고는 방법이 없었다.
/// </summary>
[RelayCommand]
private async Task SwitchDemoAsync()
{
if (!CanSwitchDemo) return;
if (!ConfirmDiscard("데모 폴더를 바꿉니다.")) return;
var toGit = !IsGitMode;
var target = toGit ? _workspace.Settings.DemoGitPath : _workspace.Settings.DemoPlainPath;
// 3-Way 를 끄되 그 때문에 본문을 다시 읽지는 않게 한다.
// 재로드가 작업 게이트를 먼저 잡아 버리면 바로 뒤의 저장소 닫기·파일 비교가
// "다른 작업 진행 중"으로 통째로 건너뛰어져 화면이 빈 채로 남는다.
_suspendReload = true;
try
{
RemoteBranch = NoRemoteBranch;
}
finally
{
_suspendReload = false;
}
_workspace.RepositoryPath = target;
if (toGit && _workspace.LooksLikeRepository)
{
await _workspace.RunAndRefreshAsync("데모 저장소 열기", () => _workspace.Git.Open(target));
}
else
{
// 저장소를 닫아야 Git 이력 칸이 접히고 "파일 비교" 모드로 넘어간다.
// IsGitMode 를 먼저 내리는 것이 중요하다 - 아직 돌고 있던 Git 본문 로드가
// 이 표식을 보고 결과를 버린다. 안 그러면 경로만 새 파일이고 내용은 옛 파일이 된다.
IsGitMode = false;
ClearPanes();
await _workspace.RunAsync("데모 저장소 닫기", () => _workspace.Git.Close());
await LoadDefaultPlainFiles();
}
OnPropertyChanged(nameof(DemoSwitchLabel));
_workspace.AppendLog($"데모 전환: {target}");
}
/// <summary>설정의 기본 비교 파일 두 개를 열어 둔다. 없거나 사라졌으면 조용히 넘어간다.</summary>
private Task LoadDefaultPlainFiles()
{
@ -711,9 +651,12 @@ namespace MergeHub.ViewModels
}
}
/// <summary>실제로 줄 끝에 붙을 주석을 미리 보여준다.</summary>
/// <summary>실제로 줄 끝에 붙을 주석을 미리 보여준다. 작성자가 비면 반영이 막히므로(<see cref="AuthorReady"/>)
/// 대신 채워질 이름(윈도우 계정)을 보이지 않는다 - 그 이름으로는 반영되지 않는다.</summary>
public string MergeNotePreview =>
_workspace.Settings.InsertMergeComment ? BuildMergeNote(PreviewSource) : "(주석 남기기 꺼짐)";
!_workspace.Settings.InsertMergeComment ? "(주석 남기기 꺼짐)"
: MergeNote.AuthorMissing(true, _workspace.Settings.MergeCommentAuthor) ? "(작성자가 비어 있어 반영할 수 없습니다 - 설정 > 머지 주석 > 작성자)"
: BuildMergeNote(PreviewSource);
partial void OnSelectedLineChanged(MergeLine? value) => RaiseCanExecuteChanged();
@ -826,9 +769,23 @@ namespace MergeHub.ViewModels
private void TakeLine(MergeLine? line)
{
if (line is null || !line.CanTakeBase) return;
if (!AuthorReady()) return;
Apply(line);
}
/// <summary>
/// 반영 명령 <b>맨 앞</b>에서 부른다 - 주석을 남기게 해 두었는데 작성자가 비었으면 알리고 멈춘다(사용자 결정 2026-09-30).
/// 줄마다(<see cref="Apply"/> 안)가 아니라 명령마다 거는 까닭: [모두 반영] 이 줄 수만큼 알리거나,
/// 구간 반영이 반쯤 된 채 멈추는 일이 없게.
/// </summary>
private bool AuthorReady()
{
if (!MergeNote.AuthorMissing(_workspace.Settings.InsertMergeComment, _workspace.Settings.MergeCommentAuthor)) return true;
MessageUtil.ShowInfo(MergeNote.AuthorMissingTitle, MergeNote.AuthorMissingMessage);
_workspace.AppendLog("반영하지 않음 - 작성자가 비어 있다(설정 > 머지 주석 > 작성자)");
return false;
}
private bool CanAccept() => SelectedLine?.CanTakeBase == true;
[RelayCommand(CanExecute = nameof(CanAccept))]
@ -925,8 +882,9 @@ namespace MergeHub.ViewModels
{
var block = BlockAround(CaretRow);
if (block.Count == 0) return;
if (!AuthorReady()) return;
var count = ApplyGroupToBase(block, "덩어리");
var count = ApplyGroupToBase(block, "구간");
if (count > 0) MessageUtil.ShowSuccessToast($"{count}줄 왼쪽에 반영");
}
@ -985,6 +943,7 @@ namespace MergeHub.ViewModels
private void TakeLineToBase(MergeLine? line)
{
if (line is null) return;
if (!AuthorReady()) return;
ApplyToBase(line);
}
@ -1065,6 +1024,7 @@ namespace MergeHub.ViewModels
[RelayCommand(CanExecute = nameof(CanAcceptAll))]
private void AcceptAll()
{
if (!AuthorReady()) return;
foreach (var line in Lines.Where(l => l.CanTakeBase).ToList())
Apply(line, requery: false);
@ -1660,9 +1620,9 @@ namespace MergeHub.ViewModels
_workspace.OnUi(() =>
{
// 읽어 오는 사이에 파일 비교 모드로 넘어갔으면(데모 전환·저장소 닫기) 버린다.
// 읽어 오는 사이에 파일 비교 모드로 넘어갔으면(저장소 닫기) 버린다.
// 늦게 끝난 Git 로드가 이미 바뀐 화면을 덮어쓰면, 경로는 새 파일인데 내용은
// 옛 파일인 상태가 된다 - 데모 전환에서 실제로 겪었다.
// 옛 파일인 상태가 된다 - 저장소를 닫는 길에서 실제로 겪었다.
if (!IsGitMode) return;
// 읽기 시작한 뒤 <b>다른 파일 · 다른 커밋을 골랐으면 버린다</b>(8차 A-1).
@ -2224,7 +2184,7 @@ namespace MergeHub.ViewModels
partial void OnRemoteBranchChanged(string value)
{
// 3-Way 로 들고 나면 본문을 다시 읽는다. 고치던 것이 있으면 사라진다.
// _suspendReload 가 걸린 길(데모 전환·파일 비교 전환)은 다시 읽지 않으므로 묻지 않는다.
// _suspendReload 가 걸린 길(파일 비교 전환)은 다시 읽지 않으므로 묻지 않는다.
if (!_revertingRemoteBranch && !_syncingBranches && IsGitMode && !_suspendReload &&
!ConfirmDiscard("비교 방식을 바꿉니다."))
{
@ -2292,6 +2252,7 @@ namespace MergeHub.ViewModels
private void TakeLocalLine(MergeLine? line)
{
if (line is null || !line.CanTakeLocal) return;
if (!AuthorReady()) return;
var before = line.LocalText;
var beforeHasLocal = line.HasLocal;
@ -2370,6 +2331,7 @@ namespace MergeHub.ViewModels
private void TakeRemoteLine(MergeLine? line)
{
if (line is null || !line.CanTakeRemote) return;
if (!AuthorReady()) return;
ApplyRemote(line);
}
@ -2829,8 +2791,6 @@ namespace MergeHub.ViewModels
[NotifyPropertyChangedFor(nameof(ModeLabel))]
[NotifyPropertyChangedFor(nameof(BaseTitle))]
[NotifyPropertyChangedFor(nameof(LocalTitle))]
[NotifyPropertyChangedFor(nameof(DemoSwitchLabel))]
[NotifyPropertyChangedFor(nameof(CanSwitchDemo))]
private bool _isGitMode;
public bool IsPlainMode => !IsGitMode;
@ -2994,7 +2954,7 @@ namespace MergeHub.ViewModels
if (string.IsNullOrWhiteSpace(LeftFile) && string.IsNullOrWhiteSpace(RightFile)) return;
// 12차 E-2: 여는 길을 거치지 않고 바로 불린 경우에도 탭이 하나는 있게 한다
// (켤 때 기본 짝 · [기준 파일...] 로 한쪽만 바꾸기 · 데모 전환 뒤 돌아오기).
// (켤 때 기본 짝 · [기준 파일...] 로 한쪽만 바꾸기).
EnsureTabForCurrentPair();
// 파일 두 개 비교는 늘 두 칸이다. mergetool 네 칸에서 넘어왔으면 접는다.
@ -3617,7 +3577,7 @@ namespace MergeHub.ViewModels
/// <summary>지금 캐럿이 놓인 행. 편집기 줄 번호와 1:1 이다.</summary>
[ObservableProperty] private int _caretRow;
/// <summary>줄을 옮기면 "이 덩어리 반영"이 켜지고 꺼진다.</summary>
/// <summary>줄을 옮기면 [구간 반영] 이 켜지고 꺼진다.</summary>
partial void OnCaretRowChanged(int value)
{
AcceptBlockCommand.NotifyCanExecuteChanged();
@ -4296,8 +4256,8 @@ namespace MergeHub.ViewModels
/// 사내 소스에는 CP949 로 저장된 파일이 섞여 있어 새로 만드는 파일과 형식이 갈린다.
/// 읽어서 보여 주기만 하면 그 갈림을 못 고치므로 저장할 때 고를 수 있게 한다.
/// </summary>
public IReadOnlyList<string> SaveEncodings => TextFile.SaveEncodings;
public IReadOnlyList<string> SaveNewLines => TextFile.SaveNewLines;
public IReadOnlyList<SaveChoice> SaveEncodings => TextFile.SaveEncodingChoices;
public IReadOnlyList<SaveChoice> SaveNewLines => TextFile.SaveNewLineChoices;
[ObservableProperty] private string _saveEncoding = TextFile.KeepOriginal;
[ObservableProperty] private string _saveNewLine = TextFile.KeepOriginal;
@ -4315,10 +4275,11 @@ namespace MergeHub.ViewModels
{
var block = BlockAround(CaretRow);
if (block.Count == 0) return;
if (!AuthorReady()) return;
// 2-Way 는 BASE 를, 3-Way 는 저쪽(REMOTE)을 가져온다. BlockAround 주석 참고.
// 주석은 덩어리 첫 줄 위 한 줄, 되돌리기는 한 번에(11차 F-2 - 예전에는 줄마다 주석이 붙었다).
var count = ApplyGroup(block, anchor: null, what: "덩어리");
var count = ApplyGroup(block, anchor: null, what: "구간");
if (count > 0) MessageUtil.ShowSuccessToast($"{count}줄 반영");
}
@ -4479,6 +4440,7 @@ namespace MergeHub.ViewModels
return;
}
if (!AuthorReady()) return;
var (member, rows, _) = found.Value;
var nameNumber = member.NameLine + 1;
var anchor = rows.FirstOrDefault(r => (IsThreeWay ? r.RemoteNumber : r.BaseNumber) == nameNumber);

View File

@ -0,0 +1,33 @@
using System.Reflection;
using CommunityToolkit.Mvvm.ComponentModel;
using MergeHub.Services;
namespace MergeHub.ViewModels
{
/// <summary>
/// 개발자 정보(12차 G-6) - 설정 안 메뉴. 읽기 전용 안내 화면 - 개요 · 버전 · 담당(Platform · MergeHub).
///
/// <para>
/// 값은 정보 창과 같은 길로 읽고 <b>화면이 보일 때마다</b> 다시 읽는다(<see cref="Refresh"/>).
/// 담당 카드는 2026-09-30 부터 코드에 적은 사람을 보이므로 설정의 만든 사람 · 문의처는 카드에 안 나온다.
/// </para>
/// </summary>
public sealed partial class OverviewViewModel : ObservableObject
{
[ObservableProperty] private ProgramOverview _overview = ProgramOverview.From(new ProgramInfo(), null);
public OverviewViewModel()
{
Refresh();
}
/// <summary>실행 파일에서 다시 읽는다. 정보 창과 같은 길(<see cref="ProgramInfoReader.For"/>)이다.</summary>
public void Refresh()
{
var assembly = typeof(OverviewViewModel).Assembly;
var info = ProgramInfoReader.For(assembly);
var description = assembly.GetCustomAttribute<AssemblyDescriptionAttribute>()?.Description;
Overview = ProgramOverview.From(info, description);
}
}
}

View File

@ -85,7 +85,7 @@ namespace MergeHub.Views
/// <summary>이미 떠 있으면 앞으로 가져온다 - 단축키 창과 같은 규칙.</summary>
private static AboutWindow? open;
public static void ShowOrActivate(Window? owner, string? author, string? contact)
public static void ShowOrActivate(Window? owner)
{
if (open is { IsLoaded: true })
{
@ -94,9 +94,9 @@ namespace MergeHub.Views
}
var assembly = Assembly.GetExecutingAssembly();
var info = ProgramInfoReader.For(assembly, author, contact);
var info = ProgramInfoReader.For(assembly);
var description = assembly.GetCustomAttribute<AssemblyDescriptionAttribute>()?.Description
?? "사내 Git Merge/Diff 도구";
?? "사내 Merge/Diff + Git 도구";
open = new AboutWindow(info, description);
if (owner is not null) open.Owner = owner;

View File

@ -0,0 +1,263 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Media;
using MergeHub.Services;
namespace MergeHub.Views.Controls
{
/// <summary>
/// 메모 편집기 아래층(12차 G-8). 투명한 TextBox 밑에서 <b>보이는 줄만</b> 칠한다 -
/// 같은 글 칠하기 · 색 마킹(배경)과 문법 색(글자).
///
/// <para>
/// 글자 자리는 스스로 셈하지 않고 <b>편집기에게 묻는다</b>(<see cref="TextBox.GetRectFromCharacterIndex(int)"/>).
/// 같은 글꼴이라도 탭 · 가로 스크롤 · 여백을 따로 셈하면 조금씩 어긋나고, 투명한 글자와 칠한 글자가 어긋나면
/// 캐럿이 글자 사이가 아닌 곳에 선다. 탭이 든 줄은 탭마다 끊어 그 자리를 다시 묻는다.
/// </para>
/// </summary>
public sealed class MemoPaintLayer : FrameworkElement
{
// 같은 글 칠하기 · 색 마킹의 배경. 밝은 바탕 / 어두운 바탕 따로(글자 색과 겹쳐도 읽히게).
private static readonly Brush[] MarkLight = Freeze("#B0FFF176", "#B0A5D6A7", "#B090CAF9", "#B0F8BBD0", "#B0FFCC80");
private static readonly Brush[] MarkDark = Freeze("#807A6A00", "#80306B34", "#80235A8C", "#80803050", "#80804A10");
private static readonly Brush OccurrenceLight = Freeze("#502F80ED")[0];
private static readonly Brush OccurrenceDark = Freeze("#604A90E0")[0];
/// <summary>오른쪽 위치 막대가 같은 색을 쓰도록 내어 준다(불투명한 쪽).</summary>
public static readonly Brush[] MarkSolid = Freeze("#F2C200", "#43A047", "#1E88E5", "#E91E63", "#FB8C00");
public static readonly Brush OccurrenceSolid = Freeze("#2F80ED")[0];
private TextBox? _editor;
private string _text = string.Empty;
private string[] _lines = { string.Empty };
private int[] _lineStarts = { 0 };
private IReadOnlyList<CodeToken>[] _tokens = Array.Empty<IReadOnlyList<CodeToken>>();
private List<TextSpan> _occurrences = new List<TextSpan>();
private List<MarkSpan> _marks = new List<MarkSpan>();
public MemoPaintLayer()
{
IsHitTestVisible = false;
ClipToBounds = true;
}
public TextBox? Editor
{
get => _editor;
set { _editor = value; InvalidateVisual(); }
}
/// <summary>한 번에 갈아 끼운다 - 글 · 줄 · 토큰 · 칠할 자리가 서로 다른 때의 것이면 어긋나 그려진다.</summary>
public void Update(string text, string[] lines, int[] lineStarts, IReadOnlyList<CodeToken>[] tokens,
List<TextSpan> occurrences, List<MarkSpan> marks)
{
_text = text ?? string.Empty;
_lines = lines;
_lineStarts = lineStarts;
_tokens = tokens;
_occurrences = occurrences;
_marks = marks;
InvalidateVisual();
}
protected override void OnRender(DrawingContext dc)
{
var editor = _editor;
if (editor is null || !editor.IsLoaded || _lines.Length == 0) return;
if (editor.Text.Length != _text.Length) return; // 한 박자 늦은 갱신 - 다음 Update 를 기다린다
var first = editor.GetFirstVisibleLineIndex();
var last = editor.GetLastVisibleLineIndex();
if (first < 0 || last < first) return;
last = Math.Min(last, _lines.Length - 1);
var dark = IsOnDarkBackground();
var marks = dark ? MarkDark : MarkLight;
var occurrenceBrush = dark ? OccurrenceDark : OccurrenceLight;
var palette = dark ? CodeTextBlock.Dark : CodeTextBlock.Light;
var textBrush = TextElement.GetForeground(this) ?? Brushes.Black;
var typeface = new Typeface(editor.FontFamily, editor.FontStyle, editor.FontWeight, editor.FontStretch);
var dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip;
var lineHeight = editor.FontFamily.LineSpacing * editor.FontSize;
// 편집기 글 칸(스크롤 안쪽) 밖으로는 그리지 않는다 - 스크롤 막대 · 여백 위로 번지지 않게.
if (ContentBounds(editor) is { } bounds) dc.PushClip(new RectangleGeometry(bounds));
for (var line = first; line <= last; line++)
{
var start = _lineStarts[line];
var content = _lines[line];
var origin = CharRect(editor, start);
if (origin is null) continue;
var top = origin.Value.Top;
// ① 배경: 같은 글 칠하기 먼저, 마킹이 그 위에.
foreach (var span in _occurrences)
Fill(dc, editor, span.Start, span.Length, start, content.Length, top, lineHeight, occurrenceBrush);
foreach (var mark in _marks)
Fill(dc, editor, mark.Start, mark.Length, start, content.Length, top, lineHeight, marks[mark.Color]);
// ② 글자. 탭이 없으면 한 번에, 있으면 탭마다 끊어 그 자리를 다시 묻는다.
if (content.Length == 0) continue;
var tokens = line < _tokens.Length ? _tokens[line] : Array.Empty<CodeToken>();
var from = 0;
while (from < content.Length)
{
var tab = content.IndexOf('\t', from);
var to = tab < 0 ? content.Length : tab;
if (to > from)
{
var at = from == 0 ? origin : CharRect(editor, start + from);
if (at is { } p)
{
var formatted = new FormattedText(content.Substring(from, to - from), CultureInfo.CurrentUICulture,
FlowDirection.LeftToRight, typeface, editor.FontSize, textBrush, dpi);
foreach (var token in tokens)
{
var s = Math.Max(token.Start, from);
var e = Math.Min(token.Start + token.Length, to);
if (e <= s || palette.For(token.Kind) is not { } brush) continue;
formatted.SetForegroundBrush(brush, s - from, e - s);
}
dc.DrawText(formatted, new Point(p.Left, p.Top));
}
}
from = to + 1;
}
}
if (ContentBounds(editor) is not null) dc.Pop();
}
/// <summary>구간이 이 줄에 걸친 만큼 배경을 칠한다.</summary>
private void Fill(DrawingContext dc, TextBox editor, int spanStart, int spanLength, int lineStart, int lineLength,
double top, double height, Brush brush)
{
var s = Math.Max(spanStart, lineStart);
var e = Math.Min(spanStart + spanLength, lineStart + lineLength);
if (e <= s) return;
if (CharRect(editor, s) is not { } a || CharRect(editor, e) is not { } b) return;
var width = b.Left - a.Left;
if (width <= 0) return;
dc.DrawRectangle(brush, null, new Rect(a.Left, top, width, height));
}
/// <summary>편집기가 알려 주는 글자 자리를 이 층 좌표로. 배치 전이면 null.</summary>
private Rect? CharRect(TextBox editor, int index)
{
try
{
var r = editor.GetRectFromCharacterIndex(Math.Min(index, editor.Text.Length));
if (r.IsEmpty || double.IsNaN(r.Top) || double.IsInfinity(r.Top)) return null;
var p = editor.TranslatePoint(r.TopLeft, this);
return new Rect(p, r.Size);
}
catch (InvalidOperationException)
{
return null;
}
}
/// <summary>편집기의 글 칸(ScrollContentPresenter) 자리를 이 층 좌표로.</summary>
private Rect? ContentBounds(TextBox editor)
{
if (Find<ScrollContentPresenter>(editor) is not { } presenter) return null;
var p = presenter.TranslatePoint(new Point(0, 0), this);
return new Rect(p, new Size(presenter.ActualWidth, presenter.ActualHeight));
}
private bool IsOnDarkBackground() =>
TextElement.GetForeground(this) is SolidColorBrush b &&
(0.299 * b.Color.R + 0.587 * b.Color.G + 0.114 * b.Color.B) / 255.0 > 0.5;
private static T? Find<T>(DependencyObject root) where T : DependencyObject
{
if (root is T hit) return hit;
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++)
{
if (Find<T>(VisualTreeHelper.GetChild(root, i)) is { } found) return found;
}
return null;
}
private static Brush[] Freeze(params string[] colors)
{
var brushes = new Brush[colors.Length];
for (var i = 0; i < colors.Length; i++)
{
var b = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors[i]));
b.Freeze();
brushes[i] = b;
}
return brushes;
}
}
/// <summary>
/// 편집기 오른쪽 끝의 위치 막대(12차 G-8). 같은 글 · 마킹이 글 전체의 어디에 있는지 눈금으로 보이고,
/// 누르면 그 자리로 간다. 파일 비교의 미니맵과 같은 손버릇이다.
/// </summary>
public sealed class MemoMarkBar : FrameworkElement
{
private List<BarTick> _ticks = new List<BarTick>();
private int _lineCount = 1;
private int _caretLine = -1;
/// <summary>눌린 자리의 줄(0부터).</summary>
public event EventHandler<int>? LineRequested;
public MemoMarkBar()
{
Width = 12;
Cursor = System.Windows.Input.Cursors.Hand;
ToolTip = "같은 글 · 마킹이 있는 자리. 누르면 그 줄로 갑니다";
}
public int TickCount => _ticks.Count;
public void Update(List<BarTick> ticks, int lineCount, int caretLine)
{
_ticks = ticks;
_lineCount = Math.Max(1, lineCount);
_caretLine = caretLine;
InvalidateVisual();
}
/// <summary>줄 → 막대 안의 y. 막대 전체가 글 전체다.</summary>
public double YOf(int line) => ActualHeight * line / _lineCount;
protected override void OnRender(DrawingContext dc)
{
// 눌러야 하므로 빈 곳도 칠해 둔다(투명이면 누름을 못 받는다).
dc.DrawRectangle(Brushes.Transparent, null, new Rect(0, 0, ActualWidth, ActualHeight));
foreach (var tick in _ticks)
{
var brush = tick.Kind < 0 ? MemoPaintLayer.OccurrenceSolid : MemoPaintLayer.MarkSolid[tick.Kind];
var x = tick.Kind < 0 ? 1 : 5; // 같은 글은 왼쪽 반, 마킹은 오른쪽 반 - 한 줄에 둘 다 있어도 보이게
dc.DrawRectangle(brush, null, new Rect(x, Math.Max(0, YOf(tick.Line) - 1), 6, 3));
}
if (_caretLine >= 0)
{
var pen = new Pen(TextElement.GetForeground(this) ?? Brushes.Gray, 1);
var y = Math.Round(YOf(_caretLine)) + 0.5;
dc.DrawLine(pen, new Point(0, y), new Point(ActualWidth, y));
}
}
protected override void OnMouseLeftButtonDown(System.Windows.Input.MouseButtonEventArgs e)
{
base.OnMouseLeftButtonDown(e);
if (ActualHeight <= 0) return;
var line = (int)(e.GetPosition(this).Y / ActualHeight * _lineCount);
LineRequested?.Invoke(this, Math.Max(0, Math.Min(_lineCount - 1, line)));
e.Handled = true;
}
}
}

View File

@ -496,10 +496,13 @@
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemContainerStyle>
<!-- 고른 항목의 파란 바탕을 없앤다 - 탭 모양은 아래 DataTemplate 이 그린다. -->
<!-- 고른 항목의 파란 바탕을 없앤다 - 탭 모양은 아래 DataTemplate 이 그린다.
이 스타일이 플랫폼 ListBoxItem 스타일을 대신하므로 글자색도 여기서 준다 - 없으면 WPF 기본(검정)이라
어두운 테마에서 탭 이름이 바탕에 묻혔다(12차 G-8 곁, 잉크 차이 157 · 116 으로 확인). -->
<Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="0"/>
<Setter Property="Margin" Value="0"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.ContentForeground}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
@ -593,13 +596,6 @@
Foreground="{DynamicResource Ubisam.Brush.Accent}"/>
</Border>
<!-- 발표용. .git 있는 폴더 ↔ 없는 폴더를 한 번에 오간다. 설정에 두 경로가
모두 적혀 있을 때만 보인다. -->
<Button Content="{Binding DemoSwitchLabel}" Command="{Binding SwitchDemoCommand}"
Height="26" MinHeight="26" Padding="8,2" FontSize="11" Margin="0,0,10,0"
Visibility="{Binding CanSwitchDemo, Converter={StaticResource BoolToVis}}"
ToolTip="Git 저장소 화면과 파일 비교 화면을 오갑니다"/>
<!-- Git 이 아닐 때: 비교할 파일 두 개를 직접 고른다 -->
<StackPanel Orientation="Horizontal" VerticalAlignment="Center"
Visibility="{Binding IsPlainMode, Converter={StaticResource BoolToVis}}">
@ -1780,13 +1776,13 @@
Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}"
ToolTip="선택한 줄을 REMOTE(Theirs) 내용으로"/>
<Button Content="이 덩어리 반영" x:Name="AcceptBlockButton" Command="{Binding AcceptBlockCommand}"
<Button Content="구간 반영" x:Name="AcceptBlockButton" Command="{Binding AcceptBlockCommand}"
MinHeight="30" Margin="2,4,2,0" FontSize="11"
ToolTip="차이가 이어지는 구간을 한 번에 반영합니다 (Alt+Shift+Right)"/>
<!-- 12차 G-2: 위 단추의 거울. 두 파일 비교(BothWays)에서만 뜬다 -
Git 모드의 왼쪽은 지난 커밋이라 쓸 파일이 없다. -->
<Button Content="이 덩어리 왼쪽으로" x:Name="AcceptBlockToBaseButton"
<Button Content="구간 왼쪽으로" x:Name="AcceptBlockToBaseButton"
Command="{Binding AcceptBlockToBaseCommand}"
MinHeight="30" Margin="2,4,2,0" FontSize="11"
Visibility="{Binding BothWays, Converter={StaticResource BoolToVis}}"
@ -1944,10 +1940,13 @@
<TextBlock Text="저장 형식" Style="{StaticResource Muted}" FontSize="11" Margin="0,10,0,4"
Visibility="{Binding IsRulesOpen, Converter={StaticResource BoolToVis}}"/>
<UniformGrid Columns="2" Visibility="{Binding IsRulesOpen, Converter={StaticResource BoolToVis}}">
<ComboBox ItemsSource="{Binding SaveEncodings}" SelectedItem="{Binding SaveEncoding}"
<!-- 12차 G-4: 보이는 글(Label)과 저장되는 값(Value)을 가른다. 값은 세션 보관본에 그대로 적힌다. -->
<ComboBox ItemsSource="{Binding SaveEncodings}" x:Name="SaveEncodingBox"
DisplayMemberPath="Label" SelectedValuePath="Value" SelectedValue="{Binding SaveEncoding}"
MinHeight="26" FontSize="11" Margin="2,0"
ToolTip="저장할 때 쓸 인코딩"/>
<ComboBox ItemsSource="{Binding SaveNewLines}" SelectedItem="{Binding SaveNewLine}"
<ComboBox ItemsSource="{Binding SaveNewLines}" x:Name="SaveNewLineBox"
DisplayMemberPath="Label" SelectedValuePath="Value" SelectedValue="{Binding SaveNewLine}"
MinHeight="26" FontSize="11" Margin="2,0"
ToolTip="저장할 때 쓸 줄바꿈"/>
</UniformGrid>

View File

@ -1240,12 +1240,9 @@ 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>프로그램 정보 창(12차 B-2). 값은 모두 실행 파일에서 읽는다(설정 칸은 2026-09-30 에 뺐다).</summary>
private void About_Click(object sender, RoutedEventArgs e) =>
AboutWindow.ShowOrActivate(Window.GetWindow(this));
// ================= 찾기 =================
@ -1526,7 +1523,18 @@ namespace MergeHub.Views
}
if (double.IsInfinity(rect.Top) || double.IsNaN(rect.Top)) return;
viewer.ScrollToVerticalOffset(Math.Max(0, viewer.VerticalOffset + rect.Top - viewer.ViewportHeight / 2));
var y = Clamp(viewer.VerticalOffset + rect.Top - viewer.ViewportHeight / 2, viewer.ScrollableHeight);
viewer.ScrollToVerticalOffset(y);
// 다른 칸은 <b>여기서 직접</b> 맞춘다(12차 G-7). 편집기의 ScrollChanged 에 맡기면 안 된다 -
// 캐럿을 옮기는 순간 편집기가 "캐럿을 화면에 들이려고" 먼저 한 번 구르고, 그 스크롤이 건 메아리 막기
// (HoldSync)는 Input 우선순위에서 풀리는데 이 가운데 맞추기는 그보다 앞선 Loaded 에서 돈다.
// 그래서 이 두 번째 스크롤은 메아리로 여겨져 버려졌고, 목록 칸은 첫 스크롤 자리(차이 줄이 칸 맨 아래)에
// 남았다. 뒤로 크게 돌면 표시층 맞추기가 그 차이를 이동값으로 메워 두 칸이 통째로 반 칸 밀려 아래가 잘렸다
// (기록: 편집기 74.4 · 목록 436.7 · 이동값 357).
HoldSync();
Scroll(LocalLines, y, null);
SyncPanesFrom(MergeLine.TextPane.Local, y, viewer.ViewportHeight);
}
/// <summary>글 안의 어느 자리든 화면에 들여 놓는다(캐럿·찾은 자리 공통).</summary>

View File

@ -2,6 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:UbisamBase.Core.Shell;assembly=UbisamBase.Core"
xmlns:controls="clr-namespace:MergeHub.Views.Controls"
shell:PlatformChrome.FullBleed="True"
PreviewKeyDown="View_PreviewKeyDown">
@ -34,7 +35,40 @@
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<DockPanel>
<TextBlock DockPanel.Dock="Right" Style="{StaticResource Muted}" FontSize="11"
Text="저장하지 않아도 남습니다 · Ctrl+Tab 넘기기 · Ctrl+F 찾기"/>
Text="저장하지 않아도 남습니다 · Ctrl+Tab 넘기기 · Ctrl+F 찾기 · Ctrl+1~5 마킹"/>
<!-- 12차 G-8: 문법 강조 언어 · 색 마킹. 오른쪽 안내 글 바로 앞에 둔다(왼쪽은 파일 동작). -->
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Margin="0,0,12,0">
<TextBlock Text="강조" Style="{StaticResource Muted}" FontSize="11" Margin="0,0,4,0"/>
<ComboBox x:Name="LanguageBox" Width="96" Height="26" FontSize="11" Margin="0,0,12,0"
DisplayMemberPath="Name"
SelectedItem="{Binding CurrentMemo.Language, Mode=TwoWay}"
SelectionChanged="LanguageBox_SelectionChanged"
ToolTip="문법 강조 언어. 파일이면 확장자, 이름 없는 메모는 C# 이 기본입니다"/>
<TextBlock Text="마킹" Style="{StaticResource Muted}" FontSize="11" Margin="0,0,4,0"/>
<Button Tag="0" Click="Mark_Click" Width="22" Height="22" Padding="0" Margin="0,0,2,0"
AutomationProperties.Name="마킹 1" ToolTip="고른 글(또는 캐럿의 낱말)을 노랑으로 모두 칠합니다. 다시 누르면 풉니다 (Ctrl+1)">
<Border Width="14" Height="14" CornerRadius="2" Background="#F2C200"/>
</Button>
<Button Tag="1" Click="Mark_Click" Width="22" Height="22" Padding="0" Margin="0,0,2,0"
AutomationProperties.Name="마킹 2" ToolTip="초록으로 모두 칠합니다 (Ctrl+2)">
<Border Width="14" Height="14" CornerRadius="2" Background="#43A047"/>
</Button>
<Button Tag="2" Click="Mark_Click" Width="22" Height="22" Padding="0" Margin="0,0,2,0"
AutomationProperties.Name="마킹 3" ToolTip="파랑으로 모두 칠합니다 (Ctrl+3)">
<Border Width="14" Height="14" CornerRadius="2" Background="#1E88E5"/>
</Button>
<Button Tag="3" Click="Mark_Click" Width="22" Height="22" Padding="0" Margin="0,0,2,0"
AutomationProperties.Name="마킹 4" ToolTip="분홍으로 모두 칠합니다 (Ctrl+4)">
<Border Width="14" Height="14" CornerRadius="2" Background="#E91E63"/>
</Button>
<Button Tag="4" Click="Mark_Click" Width="22" Height="22" Padding="0" Margin="0,0,4,0"
AutomationProperties.Name="마킹 5" ToolTip="주황으로 모두 칠합니다 (Ctrl+5)">
<Border Width="14" Height="14" CornerRadius="2" Background="#FB8C00"/>
</Button>
<Button Content="마킹 지우기" Click="ClearMarks_Click" Height="26" Padding="8,0" FontSize="11"
ToolTip="이 메모의 마킹을 모두 풉니다"/>
</StackPanel>
<Button Content="새 메모" Command="{Binding NewMemoCommand}"
Height="26" MinWidth="70" Padding="10,0" FontSize="11" Margin="0,0,4,0"
Style="{StaticResource FilledButton}"
@ -69,9 +103,12 @@
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemContainerStyle>
<!-- 이 스타일이 플랫폼 ListBoxItem 스타일을 대신하므로 글자색도 여기서 준다 - 없으면 WPF 기본(검정)이라
어두운 테마에서 제목이 바탕에 묻혔다(사용자 보고 2026-09-30, 잉크 차이 116 으로 확인). -->
<Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="0"/>
<Setter Property="Margin" Value="0"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.ContentForeground}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
@ -150,6 +187,7 @@
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/> <!-- 12차 G-8: 위치 막대 -->
</Grid.ColumnDefinitions>
<!--
@ -161,7 +199,7 @@
<Border Grid.Column="0" Background="{DynamicResource Ubisam.Brush.Sidebar.Background}"
BorderThickness="0,0,1,0"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<TextBox x:Name="LineNumbers"
<TextBox x:Name="LineNumbers" Style="{x:Null}"
IsReadOnly="True" IsTabStop="False" Focusable="False"
IsHitTestVisible="False"
BorderThickness="0" Background="Transparent"
@ -178,8 +216,18 @@
<!-- VerticalContentAlignment 을 Top 으로 <b>못 박는다</b>. 플랫폼 입력 칸 스타일은
한 줄짜리 칸을 겨냥해 가운데로 두는데, 여러 줄 편집기에서는 글이
칸 한가운데에서 시작해 위아래로 자란다(사용자 보고 2026-09-28). -->
<TextBox Grid.Column="1" x:Name="Editor"
<!-- 12차 G-8: 파일 비교처럼 <b>투명한 글자 + 아래층</b>(사용자 결정). 글자는 TextBox 가 받고
색 · 마킹은 아래층이 그린다 - 칠하는 일이 되돌리기 기록에 섞이지 않고, 줄 번호 · 찾기가 그대로 산다.
캐럿 색은 글자와 따로 준다(글자가 투명이라 캐럿까지 사라지면 안 된다). -->
<!-- Style="{x:Null}" 이 중요하다(파일 비교 LocalEditor 와 같은 까닭). 플랫폼의 암시적 TextBox 스타일은
템플릿 안에서 배경을 직접 칠해 Background="Transparent" 가 무시되고, 아래층이 통째로 덮여
안 보였다(g8-verify 첫 판 - 잉크 0 px). 줄 번호 칸도 같은 템플릿이어야 줄이 맞으므로 함께 뺀다. -->
<controls:MemoPaintLayer Grid.Column="1" x:Name="Paint"/>
<TextBox Grid.Column="1" x:Name="Editor" Style="{x:Null}"
AcceptsReturn="True" AcceptsTab="True"
Foreground="Transparent"
CaretBrush="{DynamicResource Ubisam.Brush.ContentForeground}"
SelectionBrush="{DynamicResource Ubisam.Brush.Accent}" SelectionOpacity="0.35"
BorderThickness="0" Background="Transparent"
FontFamily="Consolas" FontSize="13"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"
@ -187,6 +235,9 @@
TextWrapping="NoWrap" Padding="4,2"
TextChanged="Editor_TextChanged"
SelectionChanged="Editor_SelectionChanged"/>
<controls:MemoMarkBar Grid.Column="2" x:Name="MarkBar"
TextElement.Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
</Grid>
</Border>

View File

@ -1,9 +1,12 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Threading;
using MergeHub.Services;
using MergeHub.Views.Controls;
using MergeHub.ViewModels;
namespace MergeHub.Views
@ -22,10 +25,22 @@ namespace MergeHub.Views
private ScrollViewer? _editorScroll;
// 12차 G-8: 문법 강조 · 마킹. 줄 토큰은 캐시에 두고, 글이 그대로면(선택만 바뀌면) 다시 쪼개지 않는다.
private readonly MemoSyntaxCache _syntax = new MemoSyntaxCache();
private bool _refreshQueued;
private string? _paintedText;
private LanguageProfile? _paintedLanguage;
private string[] _lines = { string.Empty };
private int[] _lineStarts = { 0 };
private IReadOnlyList<CodeToken>[] _tokens = Array.Empty<IReadOnlyList<CodeToken>>();
public MemoView()
{
InitializeComponent();
// DataContext 보다 먼저 채운다 - 빈 목록일 때 SelectedItem 바인딩이 걸리면 null 을 되써 버린다.
LanguageBox.ItemsSource = LanguageCatalog.All;
DataContextChanged += OnDataContextChanged;
Editor.Loaded += (_, _) =>
@ -34,6 +49,20 @@ namespace MergeHub.Views
StretchContent(LineNumbers);
HookScroll();
DrawNumbers();
Paint.Editor = Editor;
QueueRefresh();
};
// 칸 너비가 바뀌면 보이는 줄 · 글 칸 자리가 달라진다.
Editor.SizeChanged += (_, _) => Paint.InvalidateVisual();
MarkBar.LineRequested += (_, line) =>
{
var at = Editor.GetCharacterIndexFromLineIndex(Math.Min(line, Math.Max(0, Editor.LineCount - 1)));
if (at < 0) return;
Editor.CaretIndex = at;
Editor.ScrollToLine(line);
Editor.Focus();
};
// Ctrl+휠로 글자 크기(파일 비교 F-5 와 같은 손버릇).
@ -43,6 +72,7 @@ namespace MergeHub.Views
var size = Editor.FontSize + (e.Delta > 0 ? 1 : -1);
Editor.FontSize = Math.Max(8, Math.Min(28, size));
DrawNumbers();
Paint.InvalidateVisual();
e.Handled = true;
};
@ -92,6 +122,7 @@ namespace MergeHub.Views
_replacing = false;
}
DrawNumbers();
QueueRefresh();
}
private void OnCaretIndexRequested(object? sender, int caretIndex)
@ -107,15 +138,129 @@ namespace MergeHub.Views
private void Editor_TextChanged(object sender, TextChangedEventArgs e)
{
DrawNumbers();
QueueRefresh();
if (_replacing) return;
if (DataContext is MemoViewModel vm) vm.NotifyEdited(Editor.Text, Editor.CaretIndex);
}
private void Editor_SelectionChanged(object sender, RoutedEventArgs e)
{
CaretText.Text = CaretPosition();
QueueRefresh();
}
private string CaretPosition()
{
var line = Editor.GetLineIndexFromCharacterIndex(Editor.CaretIndex);
var column = Editor.CaretIndex - Editor.GetCharacterIndexFromLineIndex(line);
CaretText.Text = $"{line + 1}행 {column + 1}열 · 총 {Editor.LineCount}줄";
return $"{line + 1}행 {column + 1}열 · 총 {Editor.LineCount}줄";
}
// ================= 문법 강조 · 마킹 (12차 G-8) =================
/// <summary>
/// 다시 칠하기를 한 번만 걸어 둔다. 타이핑 · 선택 한 번에 TextChanged 와 SelectionChanged 가 함께 오므로
/// 바로 칠하면 같은 일을 두 번 한다 - Background 로 미뤄 한 번에 모은다.
/// </summary>
private void QueueRefresh()
{
if (_refreshQueued) return;
_refreshQueued = true;
Dispatcher.BeginInvoke(new Action(Refresh), DispatcherPriority.Background);
}
private void Refresh()
{
_refreshQueued = false;
if (!Editor.IsLoaded || (DataContext as MemoViewModel)?.CurrentMemo is not { } memo) return;
var text = Editor.Text;
// 글 · 언어가 그대로면(캐럿 · 선택만 움직였으면) 줄 토큰을 다시 셈하지 않는다.
if (!ReferenceEquals(text, _paintedText) || !ReferenceEquals(memo.Language, _paintedLanguage))
{
_lines = MemoHighlight.SplitLines(text);
_lineStarts = MemoHighlight.LineStarts(text);
_tokens = MemoHighlight.Tokenize(_lines, memo.Language, _syntax);
_paintedText = text;
_paintedLanguage = memo.Language;
}
var target = MemoHighlight.SmartTarget(text, Editor.SelectionStart, Editor.SelectionLength, out var wholeWord);
var occurrences = target is null
? new List<TextSpan>()
: MemoHighlight.Occurrences(text, target, wholeWord);
var marks = memo.Marks.Spans(text);
// 한 곳뿐이면 고른 그 자리다 - 칠해 봐야 선택 색 밑에 가려 뜻이 없다.
if (occurrences.Count < 2) occurrences.Clear();
Paint.Update(text, _lines, _lineStarts, _tokens, occurrences, marks);
var caretLine = MemoHighlight.LineOf(_lineStarts, Math.Min(Editor.CaretIndex, text.Length));
MarkBar.Update(MemoHighlight.BarTicks(_lineStarts, occurrences, marks), _lineStarts.Length, caretLine);
// 화면 시험(g8-verify)이 이 글로 같은 글 수를 읽는다.
CaretText.Text = CaretPosition() + (occurrences.Count >= 2 ? $" · 같은 글 {occurrences.Count}곳" : string.Empty);
}
private void LanguageBox_SelectionChanged(object sender, SelectionChangedEventArgs e) => QueueRefresh();
private void Mark_Click(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement { Tag: string tag } && int.TryParse(tag, out var color)) MarkSelection(color);
Editor.Focus();
}
private void ClearMarks_Click(object sender, RoutedEventArgs e)
{
if (DataContext is not MemoViewModel vm || vm.CurrentMemo is not { } memo) return;
var had = memo.Marks.Count;
memo.Marks.Clear();
vm.Status = had == 0 ? "지울 마킹이 없습니다." : $"마킹 {had}개를 지웠습니다.";
QueueRefresh();
Editor.Focus();
}
/// <summary>
/// 고른 글(없으면 캐럿 아래 낱말)을 그 색으로 모두 칠한다. 같은 글에 같은 색이면 푼다.
/// 고른 것이 같은 글 칠하기의 대상이 못 되면(여러 줄 · 너무 긺 · 공백뿐) 마킹하지 않는다 - 규칙을 하나로 둔다.
/// </summary>
private void MarkSelection(int color)
{
if (DataContext is not MemoViewModel vm || vm.CurrentMemo is not { } memo) return;
var text = Editor.Text;
var target = MemoHighlight.SmartTarget(text, Editor.SelectionStart, Editor.SelectionLength, out _);
if (target is null && Editor.SelectionLength == 0) target = WordAt(text, Editor.CaretIndex);
if (target is null)
{
vm.Status = "마킹할 글을 고르세요";
return;
}
if (memo.Marks.Toggle(target, color))
{
var count = MemoHighlight.Occurrences(text, target, wholeWord: false).Count;
vm.Status = $"'{target}' 을(를) 색 {color + 1} 으로 마킹했습니다 ({count}곳)";
}
else
{
vm.Status = $"'{target}' 마킹을 풀었습니다";
}
QueueRefresh();
}
/// <summary>캐럿 양옆으로 낱말 글자를 넓혀 잡는다. 캐럿이 낱말에 닿아 있지 않으면 null.</summary>
private static string? WordAt(string text, int caret)
{
caret = Math.Max(0, Math.Min(caret, text.Length));
var start = caret;
var end = caret;
while (start > 0 && MemoHighlight.IsWordChar(text[start - 1])) start--;
while (end < text.Length && MemoHighlight.IsWordChar(text[end])) end++;
return end > start ? text.Substring(start, end - start) : null;
}
/// <summary>줄 번호를 다시 그린다. 줄 수가 그대로면 손대지 않는다.</summary>
@ -153,6 +298,8 @@ namespace MergeHub.Views
_editorScroll.ScrollChanged += (_, e) =>
{
// 아래층은 보이는 줄만 그리므로 어느 쪽으로 굴러도 다시 그린다(G-8).
if (Math.Abs(e.VerticalChange) >= 0.01 || Math.Abs(e.HorizontalChange) >= 0.01) Paint.InvalidateVisual();
if (Math.Abs(e.VerticalChange) < 0.01) return;
LineNumbers.ScrollToVerticalOffset(_editorScroll.VerticalOffset);
};
@ -221,6 +368,14 @@ namespace MergeHub.Views
return;
}
// Ctrl+1~5 색 마킹(G-8). 숫자 줄 · 숫자판 둘 다.
if (ctrl && MarkColorOf(e.Key) is { } color)
{
MarkSelection(color);
e.Handled = true;
return;
}
if (e.Key == Key.Escape && vm.IsFindOpen)
{
vm.CloseFindCommand.Execute(null);
@ -229,6 +384,13 @@ namespace MergeHub.Views
}
}
private static int? MarkColorOf(Key key) => key switch
{
>= Key.D1 and <= Key.D5 => key - Key.D1,
>= Key.NumPad1 and <= Key.NumPad5 => key - Key.NumPad1,
_ => null,
};
// ================= 찾기 · 바꾸기 =================
private StringComparison Comparison =>

View File

@ -0,0 +1,89 @@
<UserControl x:Class="MergeHub.Views.OverviewView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:UbisamBase.Core.Shell;assembly=UbisamBase.Core"
shell:PlatformChrome.FullBleed="True"
Background="{DynamicResource Ubisam.Brush.ContentBackground}">
<!-- 12차 G-6: 개발자 정보(설정 안 메뉴) - 읽기 전용 안내 화면. 사용자가 준 참고 화면(가운데 제목 + 버전 배지 ·
소개 한 줄 · 목적 문장 · 카드)의 모양을 따른다. 카드는 한 장(담당)이다 - 이 프로그램에는 갈래가 없다. -->
<UserControl.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="/MergeHub;component/Views/Controls/AppStyles.xaml"/>
</ResourceDictionary.MergedDictionaries>
<Style x:Key="Muted" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Muted}"/>
</Style>
</ResourceDictionary>
</UserControl.Resources>
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Margin="24,40" MaxWidth="820">
<!-- 제목 + 버전 배지. 배지에 올리면 빌드 날짜가 보인다 - 결함을 알릴 때 둘을 함께 묻게 된다. -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<TextBlock x:Name="TitleText" Text="{Binding Overview.Title}" FontSize="24" FontWeight="Bold"
Foreground="{DynamicResource Ubisam.Brush.ContentForeground}" VerticalAlignment="Center"/>
<Border Margin="12,0,0,0" Padding="12,4" CornerRadius="14" VerticalAlignment="Center"
Background="{DynamicResource Ubisam.Brush.Tag.AccentBackground}"
ToolTip="{Binding Overview.BuildLine}">
<TextBlock x:Name="VersionBadgeText" Text="{Binding Overview.VersionBadge}" FontSize="13" FontWeight="Bold"
Foreground="{DynamicResource Ubisam.Brush.Tag.AccentForeground}"/>
</Border>
</StackPanel>
<TextBlock x:Name="SubtitleText" Text="{Binding Overview.Subtitle}" Style="{StaticResource Muted}"
FontSize="14" Margin="0,12,0,0" HorizontalAlignment="Center"/>
<TextBlock x:Name="PurposeText" Text="{Binding Overview.Purpose}" Style="{StaticResource Muted}"
FontSize="14" Margin="0,14,0,0" TextWrapping="Wrap" TextAlignment="Center"/>
<!-- 카드들. 늘어나면 가로로 늘어서고, 좁으면 아래로 내려간다. -->
<ItemsControl ItemsSource="{Binding Overview.Cards}" Margin="0,28,0,0" HorizontalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel HorizontalAlignment="Center"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Width="380" Margin="10" Padding="28,32" CornerRadius="12" BorderThickness="1"
Background="{DynamicResource Ubisam.Brush.Input.Background}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<StackPanel>
<!-- 사람 모양 - 참고 화면처럼 카드 머리에 작은 그림 하나 -->
<Path HorizontalAlignment="Center" Width="26" Height="26" Stretch="Uniform"
Stroke="{DynamicResource Ubisam.Brush.Accent}" StrokeThickness="2"
StrokeStartLineCap="Round" StrokeEndLineCap="Round"
Data="M12,3 A4,4 0 1 1 11.99,3 Z M4,21 C4,16.5 7.6,14 12,14 C16.4,14 20,16.5 20,21"/>
<TextBlock Text="{Binding Title}" FontSize="16" FontWeight="Bold" Margin="0,16,0,0"
Foreground="{DynamicResource Ubisam.Brush.ContentForeground}"
HorizontalAlignment="Center"/>
<ItemsControl ItemsSource="{Binding Rows}" Margin="0,12,0,0">
<ItemsControl.ItemTemplate>
<!-- 이름표는 위에 작게, 값은 아래에. 한 줄에 나란히 두면 긴 값(문의처 안내 글)이
카드 폭에서 낱말 가운데로 꺾였다(화면 사진으로 봄). -->
<DataTemplate>
<StackPanel Margin="0,6">
<TextBlock Text="{Binding Label}" FontSize="11" HorizontalAlignment="Center"
Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
<TextBlock Text="{Binding Value}" FontSize="13" Margin="0,2,0,0"
TextWrapping="Wrap" TextAlignment="Center"
Foreground="{DynamicResource Ubisam.Brush.ContentForeground}"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock x:Name="BuildLineText" Text="{Binding Overview.BuildLine}" Style="{StaticResource Muted}"
FontSize="11" Margin="0,20,0,0" HorizontalAlignment="Center"/>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@ -0,0 +1,21 @@
using System.Windows;
using System.Windows.Controls;
using MergeHub.ViewModels;
namespace MergeHub.Views
{
/// <summary>개발자 정보(12차 G-6, 설정 안 메뉴). 읽기 전용이라 하는 일은 "보일 때 다시 읽기" 하나다.</summary>
public partial class OverviewView : UserControl
{
public OverviewView()
{
InitializeComponent();
// 설정에서 만든 사람 · 문의처를 고치고 돌아오면 바로 보여야 한다.
IsVisibleChanged += (_, e) =>
{
if (e.NewValue is true) (DataContext as OverviewViewModel)?.Refresh();
};
}
}
}