Compare commits

...

16 Commits

Author SHA1 Message Date
4d66abb7fa 끌 때 저장하지 않은 편집을 묻는다 (사용자 요청 · 결정)
편집이 있어도 하단 [종료] 를 누르면 묻지 않고 꺼졌다(편집은 보관본이 다음에
되살렸다). 사용자 판단 "잘못 고치고 있었으면 버릴 수 있어야 한다" 에 따라
끌 때 [저장하고 종료] · [저장하지 않고 종료] · [취소] 를 묻는다.

범위(사용자 결정): 파일 비교의 모든 탭 - 지금 화면(두 파일 · Git 모드 · 네 칸),
뒤에 맡겨 둔 탭, 닫았지만 편집이 남은 짝. 메모는 제외. 창에 파일 이름을 댄다.
저장은 기존 길(SaveWorkingCopy · WritePlainFile)로, 하나라도 못 쓰면 끄지
않는다. 버리면 걸려 있던 보관 쓰기를 기다린 뒤 보관본을 비워 다음에 되살아
나지 않게 한다.

플랫폼의 ICloseGuard 를 HomeViewModel 이 구현(플랫폼 수정 없음). 처음엔 곧바로
답해 셸의 Close() 가 Closing 안에서 불려 창이 안 꺼졌다(로그: 창을 닫는 중에는
Close 를 호출할 수 없습니다) - await Task.Yield() 로 비켜선 뒤 묻는다.

시험: 빌드 경고 0 · 오류 0. 화면 g19-exit-confirm 다섯 경우 실패 0 - 고친 것
없으면 묻지 않음, [취소] 면 그대로, [저장하지 않고 종료] 면 파일 그대로 · 보관본
빔 · 다시 켜도 안 살아남, 고친 직후 [저장하고 종료] 면 파일에 씀, 뒤 탭에만 편집이
있어도 묻고 그 파일만 씀. Git 모드는 화면 시험 안 함. 기존 회귀는 사용자
지시로 돌리지 않았다.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:19:05 +09:00
f105bab13c 엔터 꾹 · Ctrl+휠 확대 · 쭉 내리기에서 칸 어긋남 (사용자 보고)
엔터를 꾹 누르면 캐럿 줄과 LOCAL 칸 줄이 벌어졌다 - 캐럿은 글에서 센 줄
자리로 두고, 메아리 막기 중에도 LOCAL 칸은 편집기 스크롤을 따라가게 했다.

Ctrl+휠로 키우면 칸 위가 비고, 내리면 BASE 가 다른 줄을 봤다. 칸 목록은
가상화라 안 그린 줄 높이를 어림으로 세어(1,806줄 847행: 편집기 28,896 ·
목록 27,940) 같은 픽셀이 다른 줄을 가리키는데, 이 차이를 네 칸 공통
이동값으로 메우다 467px 까지 커졌다. 두 줄 넘게 어긋나면 LOCAL 목록을
실제로 굴리고 차이를 칸별 보정(_paneBias)으로 기억한다. BASE · REMOTE 는
LOCAL 가운데 줄과 같은 줄끼리 맞춘다(AlignOtherPanesByRow) - 한쪽에만 있는
줄 묶음 안은 맞추지 않는다(짝을 억지로 불러 초당 600번 되풀이하며 타이핑을
밀어내던 것을 기록으로 잡았다).

맞추기 기준은 편집기에 보이는 첫 줄. 편집기에 묻는 값 둘이 틀려
(GetFirstVisibleLineIndex 886 -> 1033, 글자 크기 바꾼 뒤
GetRectFromCharacterIndex 가 몇 초씩 옛 배치로 1,892px 틀림) 줄 바꿈이
꺼져 있으면 고정 줄 높이로 계산한다. UIA 로 읽은 실제 첫 줄과 대조했다.

시험: 빌드 경고 0 · 오류 0. 화면 g17-hold-enter - 엔터 40번 뒤 LOCAL 칸
번호 = 편집기 줄(936) · 같은 높이, 확대 뒤 · 30칸 내린 뒤 LOCAL = BASE,
칸 위 비지 않음. 꾹 누름 흉내에서 40번 중 30~34번만 들어가는 것은 고치기
전 커밋도 같고 따로 누르면 40번 다 들어간다(Windows 가 반복 키를 합침).
기존 회귀는 사용자 지시(방금 요청한 것만)로 돌리지 않았다.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:32:30 +09:00
0530832e79 머지 주석 · 타이핑 속도 · 탭 알림 (사용자 요청 · 보고)
Accept 뒤 Alt+Down 을 두 번 눌러야 다음 차이로 가던 것 - 반영하며 캐럿 위에
주석 줄이 끼면 글자 수로 지킨 캐럿 자리가 그 주석 줄이 됐다. 주석을 끼우거나
빼기 직전 캐럿 행을 기억했다가, 글을 갈아 끼운 뒤 그 행의 편집기 줄에 캐럿을
둔다. 임시 기록으로 확인한 것: 글을 막 갈아 끼운 TextBox 의 줄 정보는 아직
옛 글 기준이다 - 줄 시작 자리는 새 글에서 직접 센다.

줄을 지우는 반영(BASE 에 없는 줄)에도 주석을 남긴다. 한 줄이면 "삭제",
구간 · 메서드로 여러 줄이면 한 번만 "삭제 N줄"(사용자 선택). 왼쪽으로 반영 ·
3-Way 저쪽 반영에도 같은 규칙.

색 구분 "줄 안에서 바뀐 부분" 에 줄 수를 붙였다(이 칸만 없었다).

타이핑: 1,806줄에서 재 보니 엔터마다 도는 재정렬 0.4초의 대부분이 목록에
1,800줄을 한 줄씩 넣으며 알림이 1,800번 가는 것이었다(파일 비교 자체 0.012초).
MergeLineCollection 으로 한 번에 넣고(ReplaceAll), 재정렬에서는 번호 말고 모든
값이 같은 줄은 기존 객체를 두고 번호만 고쳐 가운데만 빼고 넣는다(Patch,
MergeLine.SameStateExceptNumbers). 엔터 한 번 약 0.55초 -> 약 0.05초.
화면 요소 재활용(Recycling)은 효과가 없어 넣지 않았다.

탭이 20개를 넘어 오래 안 본 탭이 조용히 닫히면 알림을 띄운다.

시험: 빌드 경고 0 · 오류 0. 하네스 NoteTest 106 · PatchTest(새) 16.
화면 g12-note(주석 · 캐럿 · 되돌리기) · g14-inline · g15-typing-tabs(새 줄
제자리 · 번호 896 -> 897 -> 898, 21번째 탭에서 알림 · 20개 유지) 통과.
기존 회귀는 사용자 지시("방금 요청한 것만")로 돌리지 않았다.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:27:47 +09:00
4d0bc5c7a7 Git 탭 끌어다 놓기 · 파일 비교 편집 칸 바탕색 (사용자 요청)
Git 탭에 폴더나 파일을 끌어다 놓으면 그 저장소를 연다. [폴더...] 와 같은 규칙을
PathUtil.RepositoryFolderFor 한 곳에 두었다 - 파일이면 그 폴더, 저장소 안이면
최상위, 저장소가 아니면 그 폴더 그대로(열기가 "저장소가 아니다" 를 알린다).
탭 전체에서 PreviewDrop 으로 받는다. 경로 칸 · 커밋 메시지 칸이 TextBox 라
파일 놓기를 먼저 먹고 버리는 문제를 폴더 비교(7차 E-2)에서 겪었다.
PathTest 6건을 먼저 넣어 실패 확인 -> 68건 통과. 흉내 낸 OLE 끌기는 이미 되는
폴더 비교 칸에도 들어가지 않아 도구 한계로 보고, 사용자가 직접 끌어다 놓아
확인했다(로그에 저장소 열기 두 번, 그 사이 단추 누름 없음).

파일 비교 편집 칸은 바탕이 없어 페이지 바탕(밝음 #F2F2F3 · 어두움 #23262B)이
비쳐 회색이었다. PaneBackgroundConverter 가 물려받은 글자색 밝기로 테마를 가려
밝음 #FAFAFA · 어두움 #252830 을 준다 - 후보를 실제 코드 색으로 그려 보여 주고
사용자가 골랐다. g11-pane 두 테마에서 빈 자리 화소로 확인.

하단 탭이 따라가지 않던 문제(폴더 비교 -> 두 번 눌러 파일 비교)는 공용 플랫폼
셸에서 고쳤다(MergeHub 코드 변경 없음, 기록만).

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:05:07 +09:00
b7ade6a800 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>
2026-09-30 16:32:42 +09:00
8a3ddf338a 인수인계 문서 추가 - 다음 세션이 먼저 읽을 것
세션을 새로 여는 참에, 다음 사람이 맨땅에서 시작하지 않게 정리한다.
"어디에 무엇이 있는지" 보다 "왜 그렇게 되어 있고 무엇을 건드리면 깨지는지" 를
적었다. 진행상황.md 맨 위에서 이 문서를 가리키게 했다.

담은 것
- 지켜야 할 것: 푸시 금지, 설정 파일을 바이트까지 되돌리기, D:\UbisamConfig
  아래는 Remove-Item 이 막히니 IO.File.Delete 를 쓸 것
- 항목 하나를 끝내는 순서(재현 -> 하네스 -> 빌드 0/0 -> 화면 -> 세 문서 -> 커밋)와
  "검증 안 된 동작 변경은 남기지 않는다"
- 시험 도구가 있는 자리. 새 세션은 scratchpad 경로가 달라지므로 지금 경로를
  그대로 적어 뒀다. 함정도 함께 - .ps1 은 BOM 필요, bash 히어독은 역슬래시를
  먹음, 빌드 전에 시험용 앱을 꺼야 함(안 그러면 옛 빌드로 시험하게 된다)
- 흉내 낸 입력으로 못 하는 것: Alt+Shift 조합(윈도우가 입력 언어 전환으로 채감),
  창 끌기(DragMove), 큰 파일 타이핑 지연. 셋 다 사람 손이 필요하다
- 저장소·배포 지금 상태. 공용 플랫폼에 dist 커밋 하나(028a50b)가 안 올라가 있고,
  그것을 올리면 사내 다른 PC 가 업데이트를 묻는다는 것까지
- 12차 G 의 남은 여섯 항목과 사용자가 이미 정해 준 결정
- G-1 에서 두 번 헛짚은 기록과 교훈(추리하지 말고 기록을 찍어라)
- 코드 지형과 자주 걸리는 것들(되돌리기 두 겹, 덩어리 명령은 CaretRow 를 본다,
  CommunityToolkit 커맨드는 RaiseCanExecuteChanged 에 넣어야 단추가 켜진다)

G-4 를 고치기 직전에 찾은 것도 적어 뒀다. TextFile.KeepOriginal 은 화면에
보이는 글이면서 동시에 세션 보관본에 저장되는 값이라(SessionStore 목록.txt
7·8번째 칸), 문자열만 바꾸면 이미 보관된 세션이 어긋난다. 보여 주는 글과
저장하는 값을 나누는 쪽을 권해 뒀다.

Agent: main (claude-opus-5)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 13:21:23 +09:00
a86dc1596f 12차 G-2 - base 칸으로 머지하는 단축키 (사용자 요청)
12차 B-1 로 왼쪽 파일로 반영하는 길([<] · TakeLineToBase)이 생겼는데 단축키가
없었다. 덩어리를 왼쪽으로 보내는 것은 명령 자체가 없었다 - AcceptBlock
(Alt+Shift+Right)은 오른쪽으로만 간다.

키를 어디에 둘지

사용자가 "Alt+Left 도 비어 있지 않냐" 고 물었다. 비어 있지 않았다 -
CopyRightToLeft 가 쓰고 있었는데, 2-Way 에서 그것이 하는 일은 이름·화살표와
달리 "이 줄 되돌리기" 였다. 그런데 이름이 가리키는 "왼쪽으로 반영" 이 B-1 로
실제로 생겼다. 그래서 감각이 맞는 쪽으로 넘겼다(사용자 결정).

  Alt+Right        한 줄 오른쪽으로        그대로
  Alt+Shift+Right  덩어리 오른쪽으로       그대로
  Alt+Left         이 줄 되돌리기      ->  한 줄 왼쪽 파일로
  Alt+Shift+Left   (없음)              ->  덩어리 왼쪽 파일로 (새로 만듦)
  이 줄 되돌리기   Alt+Left            ->  단축키 없음, Ctrl+Z 만

Alt+Right 가 이미 2-Way/3-Way 로 갈라져 있으므로 Alt+Left 도 같은 식으로
갈랐다. 3-Way(Take Theirs)는 안 건드렸다.

만든 것
- AcceptBlockToBase · ApplyGroupToBase - AcceptBlock/ApplyGroup 의 거울.
  주석은 덩어리 첫 줄 위 한 줄, 되돌리기는 한 번에(11차 F-2 와 같은 규칙).
  덩어리를 잡는 BlockAround 는 2-Way 에서 이미 CanTakeBase 로 잡으므로
  그대로 썼다 - 가져올 수 있는 줄과 보낼 수 있는 줄이 같은 줄이고 방향만 반대다.
- [이 덩어리 왼쪽으로] 단추 - [이 덩어리 반영] 옆에. 두 파일 비교에서만 뜬다.
  단축키만 두면 찾을 길이 없다.

겪은 것
- RaiseCanExecuteChanged 와 OnCaretRowChanged 에 새 명령을 안 넣어 단추가
  꺼진 채였다. CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는다.
- 덩어리 명령은 고른 줄이 아니라 CaretRow(편집기 캐럿)를 본다. 시험이 목록을
  고르기만 해서 한 줄만 갔다 - Alt+Down 으로 캐럿을 놓아야 했다.

확인
- 화면 g2-verify 21건 실패 0 (연속 세 줄이 다른 재료를 따로 만들어 씀)
  Alt+Left 는 한 줄만, Ctrl+Z 로 되돌아가고, 덩어리는 셋이 한 번에 가고
  한 번에 돌아온다
- 하네스 ShortcutTest 88 -> 101건 (키 겹침 없음, 화살표 넷의 모양이 맞음,
  충돌 이동을 안 빼앗음, Alt+Left 설명이 "되돌리기" 라고 거짓말하지 않음)
- 회귀 g1 · e2 · e3 · e4 실패 0, 빌드 경고 0 · 오류 0

값으로 확정하지 못한 것: Alt+Shift 조합을 흉내 낸 키로는 못 눌렀다. 윈도우가
입력 언어 전환으로 먼저 채간다 - SendKeys 로는 아예 안 닿고 keybd_event 로는
Shift 가 빠져 Alt+Left 처럼 동작했다. 기존 Alt+Shift+Right 로 견줘 봐도 같아
시험 도구 한계다(C-3 의 DragMove 와 같은 부류). 그래서 덩어리는 같은 명령을
부르는 단추로 확인했다. 사람 손으로 한 번 눌러 보는 것이 남았다.

Agent: main (claude-opus-5)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 13:04:49 +09:00
b95a07d028 12차 G-1 - undo 가 계속 이어지던 것 (사용자 보고)
짝을 바꾼 뒤 Ctrl+Z 를 누르면 앞 파일 글(234자)과 지금 파일 글(228자)을
끝없이 오갔다. 40번을 눌러도 안 끝났고, 글자를 하나도 안 쳐도 났다.

Ctrl+Z 는 우리 목록(_undo · _textUndo)이 비면 일부러 키를 먹지 않는다
(RunShortcut - 그래야 편집기의 글자 되돌리기가 살아 있다는 뜻이었다).
그러면 그 키가 TextBox 자체 되돌리기로 넘어가는데, WPF 의 그것은 한도가
없고 프로그램이 코드로 넣은 글까지 기록한다. 그래서 짝을 바꿔 새 파일을
실으면 그 글도 기록에 쌓이고, Ctrl+Z 가 앞 파일 글을 편집기에 되살렸다.

거기서 끝이 아니었다. 되살아난 앞 파일 글을 Reflow 가 "사용자가 친 것"
으로 보고 모델을 그쪽으로 되돌리며 되돌리기에 다시 쌓고 다시하기를 지운다.
다음 Ctrl+Z 는 우리 목록으로 되돌리고, 그러면 또 TextBox 것이 되살린다.

기록을 심어 고리를 떴다:
  Sync 끝났다   편집기=228
  편집알림     편집기=234  모델=216
  Reflow      모델=216 편집기=221 같나=False
  StepText    목표=216 … 편집기=234
221 = 234 - 13(줄 13개의 CRLF/LF 차이)이라 편집기가 쥔 것이 앞 파일임을
값으로 확인했다.

고침은 한 줄이다 - LocalEditor 에 IsUndoEnabled="False". 되돌리기는 우리
목록만 쓴다(줄 단위 _undo, 글 단위 _textUndo).

확인
- g1-verify 14건 실패 0. 30번을 눌러도 길이 자취가 228 하나뿐
- 앞 파일(234자)은 자취에 한 번도 안 나온다
- 되돌리기 자체는 살아 있다 - 친 글은 Ctrl+Z 로 되돌아간다
- 회귀 e1 · e2 · e2-reg · e3 · e4 전부 실패 0
- 빌드 경고 0 · 오류 0

헛짚은 것 둘도 기록에 남겼다. "문서가 바뀔 때 TextBox 기록을 비우면 된다"
(EditorUndoGate 를 만들어 하네스 16건까지 붙였다)와 "재정렬 타이머가 되돌린
뒤 뒤늦게 터진다" 는 둘 다 증상이 안 없어져 되돌렸다. 검증 안 된 동작
변경은 남기지 않는다.

남은 것: 글자 한 자 단위 되돌리기는 없어졌다. 이제 한 걸음은 "타이핑이
멈춘 자리" 다. 쓰다가 너무 굵으면 _textUndo 를 더 잘게 쌓는 것으로 따로 잡는다.

Agent: main (claude-opus-5)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 12:45:19 +09:00
177c7ae8e7 플랫폼을 공용 저장소로 합친 기록 - 회귀 208건 실패 0
우리가 따로 쓰던 플랫폼 소스(D:\Sorce\ubsrepo, 원격이 Ubisam-MergeTool 이었다)에
얹어 둔 두 커밋을 진짜 플랫폼 저장소 D:\Sorce\UbisamCommonPlatform 으로 옮겼다.
MergeHub 코드는 손대지 않았다 - 문서만 고친다.

6991dfd 이후 양쪽이 갈라져 있었다. 우리 2커밋(트레이 · 창 자리 기억 · 세 갈래
묻는 창 · 탭 줄 크기 · 헤더 접기) vs 공용 4커밋(우클릭 메뉴 테마 · 앱 아이콘 ·
업데이트 기본값 · HANDOFF). 소스 파일이 하나도 겹치지 않아 부딪힌 것은
changelog.md 한 군데뿐이었고, 양쪽 줄을 다 살려 날짜순으로 합쳤다.

공용 쪽에 빠져 있던 문서도 채웠다 - 가이드 8-5 에 헤더 접기, HANDOFF 5·6절에
우리 기능. C-3 은 플랫폼 changelog 에도 줄이 없었다.

빌드해 이 PC 의 D:\UbisamPlatform\bin 까지 반영했다(오류 0 · 경고 4). dist 는
손대지 않았으므로 사내 다른 PC 는 그대로다.

화면 회귀 아홉 벌 208건 실패 0 · 예외 0. MergeHub 재빌드 경고 0 · 오류 0 -
플랫폼 API 는 깨지지 않았다.

지난번에 적은 "204건" 은 c3-verify 를 21건으로 잘못 센 것이었다(실제 25건).
세 문서 모두 208 로 고쳤다.

값 하나는 확정하지 못했다 - 이 회귀에서 C-3 의 모듈 시작 자리가 144px -> 51px
대신 154px -> 61px 로 나왔다. 벌어드린 높이는 93px · 62 DIP 로 똑같고, 따로
돌리니 다시 144 -> 51 이 나왔다. 공용 쪽 네 커밋(암시적 스타일이 ContextMenu ·
MenuItem 뿐)과 뜨는 모드(mode-probe.ps1 로 재 보니 둘 다 144px)는 원인이
아니었다. 무엇이 10px 을 밀었는지는 끝까지 캐지 않았다.

Agent: main (claude-opus-5)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 10:05:23 +09:00
a8843d0477 12차 C-3 배포 뒤 화면 회귀 기록 - 아홉 벌 204건 실패 0
플랫폼을 바꿔 배포했으니 그 위에 얹혀 도는 12차 화면 시험을 전부 다시 돌렸다.
MergeHub 코드는 손대지 않았다 - 문서만 고친다.

f1 38 · d1 39 · e1 24 · e2 34 · e2-reg 18 · e2-start 8 · e3 16 · e4 6 · c3 21
= 204건, 실패 0 · 예외 0.

한 벌에 들어가기 전 앞 벌이 남긴 창과 보관함(D:\UbisamConfig\Memo ·
Session\MergeHub)을 치워 서로 섞이지 않게 했다(run-all-c3.ps1). 끝나고
MergeHubSettings.json · UiSettingsService.json · MergeHub.Recent.txt ·
ShellWindowPlacement.json 을 시작 기준본과 바이트까지 같게 되돌렸다.

여전히 사람 손이 필요한 것: 접은 C-3 띠를 끌면 창이 따라오는가.
흉내 낸 마우스로는 WPF DragMove 가 움직이지 않고, 예전부터 되던 기존 헤더
끌기로 견줘 봐도 같았다(시험 도구 한계).

Agent: main (claude-opus-5)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 09:32:45 +09:00
362eecc992 12차 C-3 기록 - 플랫폼 헤더 줄 접기 (배포함)
플랫폼 쪽 코드는 D:\Sorce\ubsrepo 에 따로 커밋했다. 여기에는 값과 판단을 남긴다.

- 재현: 헤더가 모듈 화면을 144px 아래로 밀고 있었고, 접는 길은 설정 화면 체크박스뿐,
  접으면 되살릴 띠도 단축키도 없었다
- 정한 것(사용자): 단축키 Ctrl+F11, 띠에는 화살표와 안내 글만(시계 · 이름은 빼고)
- 값: 모듈 시작 144px -> 51px (62 DIP 벌었다), 다시 켜도 접힌 채,
  F11 은 그대로 아래 탭 줄만
- 확정 못 한 것: 접은 띠를 끌면 창이 따라오는가 - 흉내 낸 마우스로는 WPF DragMove 가
  움직이지 않고 기존 헤더로 견줘 봐도 같았다(시험 도구 한계)
- 배포: 사용자 확인 뒤 빌드해서 바로 반영. 배포본은 bin-backup-c3 에 백업

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 17:04:05 +09:00
9bd27a1960 12차 F-1 - 하단 탭에 메모장
사용자가 정한 것: 메모 + 파일 열기 둘 다, 저장 단추 없이 계속 남는다,
이름은 "메모 1 · 메모 2" 번호만, 편집기는 줄 번호 · 글자 크기 · 줄바꿈 + 찾기/바꾸기.

무엇을 만들었나
- Services/Memos.cs: Memo(한 장) · MemoBook(번호 · 이름 · 넘기기 · 닫기, 계산만) ·
  MemoStore(보관, {설정 폴더}\Memo\)
- ViewModels/MemoViewModel.cs: 새 메모 · 파일 열기 · 저장 · 다른 이름으로 · 닫기 ·
  자동 보관(2초) · 켤 때 되살리기
- Views/MemoView.xaml(.cs): 동작 한 줄 · 메모 탭 줄 · 찾기/바꾸기 줄 · 줄 번호 편집기 · 상태 줄
- AppSetup: 하단 탭 맨 뒤에 넣었다 - 앞 세 탭의 차례가 바뀌면 손버릇도 바뀐다

정한 것
- 보관 자리는 {설정 폴더}\Memo\ - E-1 의 Session\ 과 따로. 저쪽은 "저장하면 사라지는 것",
  메모는 "지우기 전까지 남는 것"이라 수명이 다르다. 모양(목록 한 장 + 글마다 파일)은 같다
- 이름 없는 메모는 번호만. 첫 줄을 따오면 글을 고칠 때마다 탭 줄이 요동친다.
  번호는 비어 있는 가장 작은 수 - 메모 1 을 닫고 새로 만들면 다시 메모 1
- 파일로 연 메모와 이름 없는 메모를 한 탭 줄에 섞는다(notepad++ 와 같다)
- 탭 줄 · 저장 안 한 가운뎃점 · Ctrl+Tab · 가운데 단추 닫기는 12차 E-2 문서 탭과 같은 모양 -
  두 화면이 서로 다르게 움직이면 배우는 것이 둘이 된다
- 닫을 때 저장하지 않은 것이 있으면 세 갈래로 묻는다(사용자 결정) -
  저장하고 닫기 · 저장하지 않고 닫기 · 취소. 고친 것이 없으면 묻지 않는다.
  저장이 막히면(취소 · 쓰기 실패) 닫지 않는다
- 메모는 문서 탭과 달리 글을 스스로 들고 있다 - 메모는 원래 작은 글이고,
  이름 없는 메모에는 다시 읽어 올 파일 자체가 없다

화면을 보고 고친 세 가지(사용자 보고)
- 다른 탭에 갔다 오면 메모 글이 비어 보였다. 탭을 오가면 화면이 다시 만들어지는데
  그때 편집기가 잠깐 비어 있고, 그 틈에 글을 거두면 빈 글로 메모를 덮어썼다.
  편집기가 지금 어느 장을 싣고 있는지를 두고, 맞지 않으면 거두지 않게 했다
- 줄 번호와 글이 어긋났다. TextBlock 으로 그렸더니 줄 높이가 조금씩 달라 아래로 갈수록 벌어졌다.
  편집기와 같은 종류(TextBox)로 바꾸고 글꼴 · 크기 · 여백을 같은 값으로 두니 같은 자로 줄을 잰다
- 글이 칸 한가운데에서 시작했다. 플랫폼 입력 칸 스타일이 글 칸을 세로 가운데에 붙박아 둔다 -
  한 줄짜리 칸을 겨냥한 값이다. VerticalContentAlignment 로는 안 풀려 그 칸을 직접 늘렸다

확인
- 하네스 MemoTest 69건 새로 만듦. 전체 29벌 1,934건 · 실패 0. 빌드 경고 0 · 오류 0
- 화면 30건: 세 장 만들어 각각 쓰기 · 글 안 섞임 · 강제 종료 뒤 세 장이 차례 · 글 그대로 ·
  Ctrl+F 찾기 · 모두 바꾸기 3곳 · 닫을 때 세 갈래로 묻고 취소하면 그대로 · 빈 메모는 안 물음 ·
  40줄에서 줄 번호 40개가 글과 맞음 · 다른 탭 멀쩡
- 화면 여덟 벌(E-1 · E-2 · D-1 · 줄 번호 · 줄 밀림 · F-1) 모두 통과

시험 도구도 고쳤다: 스크립트가 변수 이름을 $keep 로 써서 라이브러리의 $Keep(되돌릴 설정 목록)을
덮어쓰는 바람에 시험이 끝나도 설정이 안 돌아왔다. 묻는 창을 찾는 도구도 단추 이름을
"저장하지 않고 계속" 로 못 박아 두어 말이 다른 창을 못 알아봤다 - 앞부분만 맞춰 보게 했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 16:30:24 +09:00
b28db92515 12차 D-1 - 폴더 비교 위쪽을 한 줄로 · 옵션은 서랍으로
재현: 경로 · 옵션 · 찾기 · 색 범례가 네 줄로 305px(배율 150% 기준 203 DIP)를 먹었고,
비교하기 전에도 그대로였다. 자주 쓰는 [비교] 는 옵션 줄 끝에 묻혀 있었다.

위 한 줄
  [...] 왼쪽 경로  <->  [...] 오른쪽 경로  [최근 v]  켠 옵션  [비교] [취소] [옵션 v]

- 폴더 고르기는 글자 대신 ... 로. 경로 칸 바로 앞에 두어 어느 쪽 것인지 헷갈리지 않는다
- 좌우 바꾸기를 새로 넣었다(사용자 결정) - 반대로 견주려고 두 번 고르지 않아도 된다
- 최근 목록은 화살표만 남겼다

옵션 서랍
- 빠른 비교 · 하위 폴더 포함 · 보기 · 포함 · 제외를 [옵션 v] 안으로. [폴더 안 찾기] 도 여기에
- 서랍에 넣으면 "내가 뭘 켜 뒀더라" 를 알 수 없게 된다 - 특히 제외 규칙은 켠 줄 모르고
  "왜 이 파일이 안 보이지" 로 이어진다. 기본과 다른 것만 한 줄에 짧게 적는다(FolderOptionText).
  기본 그대로면 아무것도 적지 않는다 - 늘 떠 있는 글은 눈에 띄지 않는다

찾기 · 색 범례
- 찾기는 늘 한 줄을 먹던 것을 Ctrl+F 로 부를 때만 편다. Esc · [찾기 접기] 로 접는다
- 색 범례는 아래 상태 줄로 내렸다 - 색은 목록을 보는 동안 필요한 것이라 결과 요약 옆이 제자리다

화면으로 확인
- 위쪽 높이 305px -> 77px (203 -> 51 DIP, -75%)
- 좌우 바꾸기: 누르면 맞바뀌고 다시 누르면 되돌아온다
- 옵션 서랍에 넣은 것이 다 있고, 빠른 비교를 켜면 줄에 "빠른 비교", 제외 규칙이 있어 "규칙 1"
- Ctrl+F 로 77 -> 158px, Esc 로 다시 77px
- 비교 그대로 동작(전체 2개 · 다름 2 · 왼쪽만 0 · 오른쪽만 0 · 같음 0)
- 되던 것이 하나도 없어지지 않았다: 폴더 고르기 · 최근 · 빠른 비교 · 하위 폴더 · 보기 거르기 ·
  포함/제외 · 폴더 안 찾기 · 결과 내보내기 · 복사/삭제 · 줄 단위 비교 · 색 구분(아래로)
- 좁은 창(1920px = 1280 DIP)에서도 여전히 77px 한 줄, [비교] · [옵션] 다 보임

화면을 보고 고친 것: [옵션 v] 를 ToggleButton 으로 뒀더니 플랫폼 기본 스타일이 스위치 모양이라
같은 줄의 다른 단추와 따로 놀았다. 여느 단추로 바꿨다. 최근 목록도 폭을 못 박지 않아
넓게 벌어지던 것을 고쳤다.

하네스 FolderTest 118 -> 131건, 전체 28벌 1,865건 · 실패 0. 빌드 경고 0 · 오류 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 15:25:17 +09:00
101deb2627 12차 E-1 - 저장하지 않은 편집이 껐다 켜도 남는다 (notepad++ 식)
E-2 가 만든 보관함(탭이 사라져도 고친 글이 짝 이름으로 남는 자리)을 그대로 파일로
옮겨 적는 일이다. 새로 정할 것은 언제 쓰고 언제 되살리는가뿐이었다.

어디에 · 어떤 모양으로

  {설정 폴더}\Session\MergeHub\
      목록.txt        차례 · 짝 경로 · 캐럿 · 스크롤 · 저장 형식 · 보고 있던 탭 · 그때 파일의 자취
      01-오른쪽.txt    저장하지 않은 오른쪽 글(있을 때만)
      01-왼쪽.txt      저장하지 않은 왼쪽 글(12차 B-1, 있을 때만)

11차 F-9 백업과 다른 자리다 - 백업은 "저장하기 전 파일", 이것은 "저장하지 않은 편집" 이라
뜻도 수명도 다르다.

- JSON 을 쓰지 않는다(최근 목록과 같은 까닭). 사람이 열어 보고 고칠 수 있고, 무엇보다
  글 안에 무엇이 들었든(따옴표 · 줄바꿈 · 제어 문자) 본문을 따로 파일로 두면 탈이 없다
- 원본 글은 담지 않는다. 파일에서 다시 읽으면 되고, 담아 두면 그 사이 파일이 바뀌었을 때
  낡은 것을 보여 준다. 대신 그때 파일의 길이 · 마지막으로 쓴 때(FileStamp)만 적어 둔다
- 목록은 맨 나중에 쓴다 - 목록이 있다는 것이 곧 글 파일이 다 있다는 뜻이다
- 못 읽거나 못 써도 조용히 넘어간다. 보관 때문에 프로그램이 안 켜지면 안 된다

언제 쓰나

- 글자가 멈춘 뒤 2초에 한 번(DispatcherTimer)
- 탭을 옮길 때 · 닫을 때 · 새 짝을 열 때는 그 자리에서 바로 - 그 순간이 곧 끄기 직전일 수 있다
- 쓰는 일은 다른 실로 넘긴다(Task.Run). 화면 상태를 뜰 때 ParkCurrentTab 을 쓰지 않는다 -
  그쪽은 되돌리기 목록을 글 단위로 눌러 담는데, 2초마다 이력을 주무를 까닭이 없다.
  보관할 것은 글과 자리뿐이고 되돌리기 이력은 되살리지 않기로 정했다(사용자 결정)

언제 되살리나

켤 때 묻지 않고 그대로(사용자 결정). 다만 사용자가 지금 열라고 한 것이 먼저다 -
충돌 파일 열기 · git mergetool · 폴더 비교에서 고른 파일. 설정의 기본 저장소 · 기본 짝보다는 앞선다.
원본 글은 파일에서 다시 읽고 그 위에 편집을 얹는다(E-2 의 ResumePendingTab 그대로).
원본이 그 사이 바뀌었으면 되살리고 알린다 - "연 시점" 은 새로 읽은 글로 두어
[전체 되돌리기] 가 지금 파일로 가고, 고친 글은 편집으로 남는다.
두 파일이 다 없어진 짝은 되살릴 자리가 없으므로 버린다.

확인

하네스 SessionTest 65건 새로 만듦(글로 옮기고 되읽기 · 빈칸/한글/UNC 경로 ·
알아볼 수 없는 줄 건너뛰기 · 보관할 것 없으면 치우기 · 옛 글 파일 치우기 ·
글 파일이 없어져도 넘어가기 · FileStamp · 보관함 주고받기). 전체 28벌 1,852건 · 실패 0.

화면 24건: 두 짝을 고치고 작업 관리자로 끄듯 강제로 끝낸 뒤 다시 켜면 묻지 않고
탭 2개가 이름 · 차례 · 글 그대로 되살아나고, 한쪽을 저장하면 가운뎃점이 2->1,
둘 다 저장하면 보관본이 사라지고, 다시 켜면 되살릴 것이 없다.
원본이 그 사이 바뀌면 되살리고 "저장하면 바뀐 파일을 덮어씁니다" 를 알린다.

빠르기에 대해: 5,003줄(113KB) 짝에서 글자당 31ms 로 4줄짜리와 같았다(앱이 입력에 밀리지 않는다).
다만 "친 뒤 화면에 보이기까지" 는 값으로 확정하지 못했다 - 그것을 재려면 편집기 글을
UIA 로 읽어야 하는데 5,003줄 창에서는 그 읽기가 타이핑보다 훨씬 오래 걸려 값이 오염된다.
재는 법을 따로 마련해야 한다.

시험 도구: E-1 이 도니 지난 시험이 남긴 편집이 다음 시험에서 되살아나 화면이 달라졌다
(e3-verify 가 왼쪽 7줄 대신 14줄을 봤다). 시험마다 보관 자리를 치우고 시작해 끝에
되돌리도록 고쳤다 - 기능이 제대로 도는 증거이기도 하다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 14:53:23 +09:00
06d78e2fc7 [<] 단추가 있는 줄만 글이 밀리던 것 - 단추 자리를 늘 잡아 둔다
사용자가 그림으로 짚어 준 것. 12차 B-1 로 넣은 [<] 단추가 든 칸의 폭이 Auto 여서,
단추가 보이는 줄에서만 칸이 벌어져 뒤 칸이 통째로 오른쪽으로 밀렸다.
같은 칸 안에서도 줄마다 글이 시작하는 자리가 달랐다.

  BASE                          LOCAL
    01  class Axis                01  class Axis
  < 03      int speed = 100;      03 >     int speed = 200;   <- 밀림
  < 04      // left only line     05 > <     void Stop() { }  <- 더 밀림
    05  void Home() { }           06  }

BASE 는 [<] 가 맨 앞 칸이라 번호까지, LOCAL 은 번호 뒤 칸이라 코드가 밀렸고,
단추가 하나 늘 때마다 한 칸씩 더 밀렸다.

Visibility="Collapsed" 은 자리까지 없애므로, 자리를 지키려면 칸 폭이 고정이어야 한다.

- GutterBothWaysWidth 를 두어 BASE · LOCAL 이 같은 자리를 늘 잡아 둔다
- 화면이 모드에 따라 갈아 끼운다(ApplyGutterReserve) -
  두 파일 비교는 단추 폭, Git 모드와 네 칸은 0(그 단추가 아예 없다 - 여백이 예전 그대로)
- 투명한 편집기가 아래 표시층과 어긋나면 캐럿이 글자와 다른 자리에 선다 -
  EditorGutterMargin 도 같은 값만큼 민다
- 글자 크기(11차 F-5)를 바꾸면 단추 폭이 따라 바뀌므로, 잡아 두는 자리도
  CodeFontMetrics 한 벌에서 나온다

화면으로 확인: 한 칸의 모든 줄이 같은 x(BASE 137 · LOCAL 1283),
글자 크기를 키워도 한 x(147), Git 모드도 한 x(459). 오류 글 0.
E-2 화면 세 벌 · 왼쪽 번호 한 벌 모두 그대로 통과. 빌드 경고 0 · 오류 0.

남겨 둔 것: BASE 줄 끝의 [>](11차 F-10)는 아직 Auto 다. 왼쪽 가장자리를 밀지 않고
오른쪽에서 코드 칸을 조금 좁힐 뿐이라 눈에 띄지 않는다. 워드랩을 켜면 그 줄만
접히는 자리가 달라질 수 있다 - 워드랩은 기본이 꺼짐이라 이번에는 두었다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 14:09:52 +09:00
62126e5e0e 왼쪽 줄 번호가 밀리던 것 - 왼쪽도 다시 번호를 맨다
사용자 보고. 12차 B-1 로 왼쪽에도 반영할 수 있게 되면서 왼쪽 줄이 늘고 주는데,
번호는 정렬기가 처음 준 것 그대로였다. 오른쪽에만 있던 줄을 왼쪽에 넣으면
그 줄은 번호가 비고 뒤 줄은 옛 번호를 달아 01 02 (빈) 03 04 05 (빈) 06 으로 보였다.
왼쪽 파일은 8줄이 됐는데 번호는 6까지라 어느 줄을 고쳤는지 짚을 수 없었다.

MergeLine.BaseNumber 의 설명에는 이미 "이제 두 파일 비교에서는 번호도 따라 바뀐다" 고
적혀 있었지만 실제로 다시 매기는 자리가 없었다 - 적어 둔 뜻과 코드가 어긋나 있었다.

- RenumberBase 를 RenumberMerged(8차 B-3) 옆에 두고, 그릴 때마다 왼쪽에 있는 줄만 1부터 다시 맨다
- 두 파일 비교(BothWays)에서만. Git 모드 · 네 칸의 BASE 는 지난 커밋 · 공통 조상이라
  고칠 수 없고, 그때 번호는 그 파일의 번호이므로 건드리지 않는다
- "머지 반영함" 표시는 BASE 줄 번호를 열쇠로 붙들고 있다(_appliedBaseNumbers) -
  번호가 실제로 달라질 때만 그 열쇠도 옛 번호에서 새 번호로 옮겨 담는다.
  안 그러면 규칙을 한 번 건드리는 순간 엉뚱한 줄이 반영함으로 칠해진다

화면으로 확인: 연 직후 01..07, 두 줄 넣으면 01..09 로 이어짐, 되돌려도 이어짐,
왼쪽 저장하면 파일 9줄 = 칸의 끝 번호 09, Git 모드 회귀 그대로.
하네스 27벌 1,787건 · 실패 0. 빌드 경고 0 · 오류 0.

아직 확정하지 못한 것: 왼쪽에만 있는 줄을 [<] 로 뺄 때 왼쪽 줄 수가 하나 주는지.
따로 열어 한 번 누르면 잘 빠지는데(3줄 -> 2줄), 다른 반영을 몇 번 한 뒤에는
시험 도구가 읽은 줄 칸이 낡아 옆 줄 단추를 눌러 값이 흔들렸다.
번호 불변식(늘 1부터 이어지고 빠진 번호가 없다)은 어느 경우에도 지켜졌다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 13:58:46 +09:00
42 changed files with 5816 additions and 331 deletions

250
doc/HANDOFF.md Normal file
View File

@ -0,0 +1,250 @@
# 인수인계 — MergeHub (2026-09-30 세션 끝)
다음 세션(다른 AI 또는 사람)이 **먼저 읽는** 문서. "어디에 무엇이 있는지" 보다
**"왜 그렇게 되어 있고, 무엇을 건드리면 깨지는지"** 를 적는다.
| 문서 | 내용 |
| --- | --- |
| `doc/진행상황.md` | 차수별 항목과 상태 — 무엇이 남았는지 |
| `doc/history/2026-09-23-12차.md` | 12차 진행 기록 — 항목마다 재현·고침·확인값 |
| `doc/history/changelog.md` | 변경 이력, **최신이 위** |
| `doc/작업지시/12차.md` | 이번 차수 지시서 |
---
## 1. 지켜야 할 것 (어기면 사고)
- **커밋은 로컬만. 절대 푸시하지 않는다.** 올리는 것은 사용자가 한다.
- 커밋 메시지 끝에 `Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`,
그 위에 `Agent: 이름 (모델)`.
- **실제 업무 저장소를 읽지 않는다.** 시험 재료는 만들어 쓴다.
- **화면 시험 전에 떠 있는 창을 확인하고, 사용자 창을 끄기 전에 묻는다.**
- 웹 문서는 **차수 끝에 한 번에** — 차수 중간에는 고치거나 올리지 않는다.
(11차 F-1~F-11 + G-1 + 12차 전체가 **아직 밀려 있다.**)
- `D:\UbisamConfig` 아래는 하네스가 `Remove-Item` 을 막는다 →
`[IO.File]::Delete($f)` 로 지운다.
- `HKLM` 쓰기 · 셸 확장 설치 · 보안/백신/디스크/디스플레이 설정 변경은 하지 않는다.
### 설정 파일은 바이트까지 되돌린다
화면 시험은 사용자의 설정을 건드린다. 시험 뒤 **반드시** 아래 넷을 시작 기준본과
바이트까지 같게 되돌린다(기준본은 scratchpad 의 `h8-*.bak`).
```
D:\UbisamConfig\Settings\MergeHubSettings.json
D:\UbisamConfig\Settings\UiSettingsService.json
D:\UbisamConfig\Settings\MergeHub.Recent.txt ← Settings 아래다. 헷갈리기 쉽다
D:\UbisamConfig\Settings\ShellWindowPlacement.json
```
보관함도 비운다: `D:\UbisamConfig\Memo`, `D:\UbisamConfig\Session\MergeHub`.
---
## 2. 항목 하나를 끝내는 순서
이 순서를 줄이지 않는다. 특히 **먼저 재현** 을 건너뛰면 엉뚱한 것을 고친다
(12차 G-1 에서 두 번 헛짚었다 — 아래 6절).
```
1) 먼저 재현한다 — 값으로. "그럴 것이다" 로 시작하지 않는다
2) 하네스를 먼저 만든다 — 순수 로직은 scratchpad 의 net8 콘솔 프로젝트로
3) 빌드 0 경고 · 0 오류 — 경고가 나면 고치고 넘어간다
4) 화면으로 확인(UIA) — 값을 찍는다. "보인다" 가 아니라 "몇 px" 로
5) 세 문서에 적는다 — 차수 기록 · 진행상황 · changelog
6) 로컬 커밋 — 담당 단위로 나눠서
```
**검증 안 된 동작 변경은 남기지 않는다.** 만들어 봤는데 증상이 안 없어지면 되돌린다
(G-1 에서 `EditorUndoGate` 와 재정렬 타이머 고침을 둘 다 되돌렸다).
---
## 3. 시험 도구
### 자리
```
C:\Users\Ubisam\AppData\Local\Temp\claude\C--Users-Ubisam\8f71cefa-dbf5-4aa8-bcb8-fdb89ccc518f\scratchpad
```
**새 세션은 scratchpad 경로가 달라진다.** 위 폴더는 디스크에 그대로 남아 있으니,
새 세션에서 쓰려면 **그 경로를 직접 가리키거나 복사해 온다.** 안에 든 것:
| 무엇 | 것 |
|---|---|
| 공용 라이브러리 | `e2fold.ps1`(재료 만들기 · 폴더 비교 · 앱 띄우기), `e2lib.ps1`(`Editor-Text` · `Set-Editor` · `Dialog-Window`), `a2lib.ps1`(프로세스 · 창 잡기) |
| 화면 시험 벌 | `g1-verify` `g2-verify` `f1-verify` `d1-verify` `e1-verify` `e2-verify` `e2-reg` `e2-start` `e3-verify` `e4-verify` `c3-verify` |
| 몰이 대본 | `run-all-merge.ps1`(아홉 벌 차례로 · 앞 벌 뒤처리까지) |
| 하네스 | `EditorTest` `ShortcutTest` `DocTabTest` `MemoTest` `FolderTest` `SessionTest` `PlatformCfgTest` 등 31개 |
| 설정 기준본 | `h8-*.bak` (세션 시작 상태) |
하네스는 net8 콘솔이고 `.csproj` 에서 MergeHub 의 `.cs` 를 **직접 가리켜** 컴파일한다.
새 파일을 시험하려면 그 `.csproj` 에 `<Compile Include="D:\Sorce\...\X.cs" />` 를 더한다.
### 반드시 아는 함정
- **`.ps1` 은 UTF-8 BOM 으로 저장한다.** Write 도구나 bash 히어독은 BOM 을 안 붙이고,
PowerShell 5.1 은 그러면 한글을 ANSI 로 읽어 **문법 오류로 죽는다.** 쓴 뒤 붙일 것.
- **bash 히어독은 역슬래시를 먹는다.** Python 편집 대본은 Write 도구로 만들고
**raw 문자열**(`r'...'`)을 쓴다.
- **빌드 전에 시험용 앱을 반드시 끈다.** 떠 있으면 dll 이 잠겨 빌드가 조용히 실패하고,
**옛 빌드로 시험하게 된다**(실제로 겪었다).
- PowerShell 함수 이름이 기본 별칭과 부딪힌다 — `Type` 은 `Get-Content` 다. `Send-Text` 처럼 짓는다.
- 하네스 변수 `$keep` 은 라이브러리 `$Keep` 을 가린다. 다른 이름을 쓴다.
### 흉내 낸 입력으로 못 하는 것 (도구 한계 · 사람 손이 필요)
| 무엇 | 왜 |
|---|---|
| `Alt+Shift+←` · `Alt+Shift+→` | 윈도우가 **입력 언어 전환**으로 먼저 채간다. `SendKeys` 는 아예 안 닿고 `keybd_event` 는 Shift 가 빠져 `Alt+←` 처럼 동작한다. **기존 `Alt+Shift+→` 로 견줘도 같아** 도구 한계임을 확인했다 → **단추로 우회해 확인한다** |
| 창 끌기(`DragMove`) | 흉내 낸 마우스로는 WPF 가 안 움직인다. 기존 헤더 끌기로 견줘도 같다 |
| 큰 파일 타이핑 지연 | UIA 로 읽는 값이 타이핑보다 비싸 잴 수 없다 |
UIA 참고: `Border` 와 맨 `ItemsControl` 은 컨트롤로 안 잡힌다(`ListBox` 는 잡힌다).
---
## 4. 저장소 · 배포 지금 상태
| 무엇 | 자리 | 상태 |
|---|---|---|
| MergeHub | `D:\Sorce\MergeTool\MergeHub` | 깨끗 · HEAD `a86dc15` · **푸시 안 함** |
| 공용 플랫폼 | `D:\Sorce\UbisamCommonPlatform` | 깨끗 · **`028a50b` 한 커밋이 안 올라감**(dist 배포본) |
| 옛 플랫폼 사본 | ~~`D:\Sorce\ubsrepo`~~ | **사용자가 지웠다.** 플랫폼은 공용 저장소에서만 고친다 |
| 배포 폴더 | `D:\UbisamPlatform\bin` | Release 51개 · 되돌릴 자리 `bin-backup-merge` · `bin-backup-c3` |
**플랫폼은 빌드하는 순간 `D:\UbisamPlatform\bin` 에 반영된다.** 시험 빌드로 덮지 않으려면
`-p:UbisamPublishDir=<딴 자리>\` 로 돌린다(명령줄 전역 속성이 PropertyGroup 보다 세다).
**`028a50b` 를 올리면 사내 다른 PC 가 다음 실행 때 업데이트를 묻는다.** 앞의 세 커밋은
`dist` 를 안 건드려 조용했지만 이것은 다르다. 올리기 전에 Release 빌드 위에서
화면 회귀를 한 번 더 돌리기를 권했고, 사용자는 아직 답하지 않았다.
**공용 플랫폼 저장소는 일하는 방식이 다르다** — 그 `CLAUDE.md`·`doc/HANDOFF.md` 가
"토큰을 아낀다, 앱을 띄워 클릭·캡처하는 검증 루프를 쓰지 않는다, **화면 확인은 사용자에게
한 줄로 부탁한다**" 고 못 박았다. 거기서는 UIA 회귀를 돌리지 않는다.
---
## 5. 지금 하던 일 (12차 G)
사용자가 준 다섯 가지 중 **둘은 끝났고 셋이 남았다.** 그 뒤 네 가지가 더 들어왔다.
### 끝난 것
| | |
|---|---|
| **G-1 undo 가 계속 이어진다** | `LocalEditor` 에 `IsUndoEnabled="False"` 한 줄. 커밋 `b95a07d` |
| **G-2 base 머지 단축키** | `Alt+←` 한 줄 왼쪽으로 · `Alt+Shift+←` 구간 왼쪽으로 · `[이 덩어리 왼쪽으로]` 단추. 커밋 `a86dc15` |
### 남은 것 — 결정은 다 받아 뒀다
| # | 무엇 | 사용자가 정한 것 |
|---|---|---|
| **G-3** | "덩어리" 라는 말이 어색하다 | **`구간 반영` · `구간 왼쪽으로`** 로 바꾼다 |
| **G-4** | 저장 형식 칸에 `원본 유지` 콤보가 **둘** 이라 구별이 안 된다 | **글자를 다르게** — 인코딩 쪽과 줄바꿈 쪽 문구를 갈라 적는다 |
| **G-5** | 사내 문의처가 배포본에 안 따라간다 | **`csproj` 에 받침값을 박는다.** ⚠ **문구를 아직 못 받았다 — 물어야 한다** |
| **G-6** | 프로그램 개요 탭 | **읽기 전용 안내 화면**(개요 · 만든사람 · 문의처 · 버전) |
| **G-7** | `Alt+↓` 로 이동하다 보면 화면이 잘린다 | 아직 재현 안 함 |
| **G-8** | 메모 탭에 문법 강조 + 마킹 | **`RichTextBox` 로 바꾸고** 기존 `CSharpTokenizer` 재사용. 마킹 **셋 다**: 고른 글과 같은 글 모두 칠하기 · 색 골라 여러 개 · 오른쪽 끝 위치 막대 |
### G-3 · G-4 를 고칠 자리 (이미 찾아 뒀다)
| 파일 | 줄 | 무엇 |
|---|---|---|
| `Views/HomeView.xaml` | 1783 · 1785 | `이 덩어리 반영` 단추 글 · 툴팁 |
| `Views/HomeView.xaml` | 1789 · 1793 | `이 덩어리 왼쪽으로` 단추 글 · 툴팁 |
| `Services/ShortcutMap.cs` | 132 · 142 | 단축키 설명 두 줄 |
| `ViewModels/HomeViewModel.cs` | `ApplyGroup(..., what: "덩어리")` · `ApplyGroupToBase(block, "덩어리")` | 로그 문구에 들어간다 |
| `Services/TextFile.cs` | 57 · 60 · 63 | `KeepOriginal = "원본 유지"` 와 두 목록 |
### ⚠ G-4 를 고치기 직전에 찾은 것 — 그냥 바꾸면 안 된다
`TextFile.KeepOriginal` 은 **화면에 보이는 글이면서 동시에 저장되는 값**이다.
- `HomeViewModel.SaveEncoding` · `SaveNewLine` 의 기본값이 이 문자열이고,
- 그 값이 **세션 보관본에 그대로 쓰인다** — `SessionStore.cs:165-166`(쓰기),
`:212-213`(읽기), `목록.txt` 의 7·8번째 칸.
그래서 문자열만 바꾸면 **이미 보관된 세션의 값이 목록에 없는 값이 되어** 되살릴 때
어긋난다. 갈 수 있는 길:
1. **보여 주는 글과 저장하는 값을 나눈다** — 목록은 `(값, 보일 글)` 짝으로 하고
콤보에 `DisplayMemberPath` 를 준다. 저장되는 것은 값 그대로라 옛 보관본이 산다. (권함)
2. 문자열을 바꾸고 `SessionStore` 읽을 때 옛 값(`"원본 유지"`)을 새 값으로 옮긴다.
어느 쪽이든 **세션 되살리기 회귀(`e1-verify`)를 반드시 돌린다.**
---
## 6. 12차 G-1 에서 배운 것 (같은 실수 반복 금지)
`undo 가 계속 이어진다` 를 잡는 데 오래 걸렸다. 두 번 헛짚었다.
| 짚었던 것 | 어떻게 아니라고 알았나 |
|---|---|
| "문서가 바뀔 때 TextBox 기록을 비우면 된다" — `EditorUndoGate` 를 만들어 하네스 16건까지 붙였다 | 앞 파일로 넘어가는 것은 막았지만 **오가는 것은 그대로**였다 |
| "재정렬 타이머가 되돌린 뒤 뒤늦게 터진다" | 타이머를 꺼도 **그대로**였다 |
**결국 답을 준 것은 기록(trace)이다.** 코드를 읽어 추리하지 말고,
`Undo` · `Reflow` · `SyncEditorFromLines` 에 한 줄씩 찍어 고리를 떴더니 바로 보였다.
임시 기록은 환경 변수로 켜고(`MERGEHUB_G1_TRACE`) **끝나면 뺐다.**
교훈 셋:
1. **재현 대본이 고치기 전에 실패하는지 먼저 확인한다.** 첫 `g1-verify` 는
고치기 전에도 통과했다 — 재현을 못 하고 있었던 것이다.
2. `Set-Editor`(UIA 로 통째로 넣기)와 **사람이 치는 것**은 기록이 다르다.
3. 증상이 여러 겹이면 **하나씩 떼어** 본다(글자 없이 · 글자 치고).
---
## 7. 코드 지형 (자주 가는 곳)
```
src/MergeHub/
├─ AppSetup.cs 탭 등록(AddMain 순서 = 화면 순서) · ViewIds
├─ MergeHubSettings.cs 설정 항목([DisplayName] 이 곧 설정 화면 글)
├─ Models/MergeLine.cs 줄 하나(BaseText/LocalText/RemoteText · Has* · CanTake*)
├─ Services/
│ ├─ ShortcutMap.cs 단축키 표 — 키 처리와 도움말 창이 <b>둘 다 이것만</b> 본다
│ ├─ TextFile.cs 인코딩 · 줄바꿈 · 저장
│ ├─ DocTabs.cs 문서 탭(12차 E-2) · 보관함(attic)
│ ├─ SessionStore.cs 껐다 켜도 남는 편집(12차 E-1)
│ ├─ Memos.cs · MemoStore 메모 탭(12차 F-1)
│ └─ CodeTokenizer.cs 문법 강조 — 메모에 쓸 재료
├─ ViewModels/
│ ├─ HomeViewModel.cs 4천 줄. 줄 편집 · 되돌리기 · 저장
│ ├─ HomeViewModel.Tabs.cs 문서 탭 · 세션
│ └─ MemoViewModel.cs
└─ Views/
├─ HomeView.xaml(.cs) 파일 비교. LocalEditor 는 맨 TextBox(글자 투명 + 아래층에 색칠)
├─ Controls/CodeTextBlock.cs · CSharpTokenizer.cs 문법 강조(보여주기 전용)
└─ MemoView.xaml(.cs) 메모 탭. 지금은 맨 TextBox
```
### 꼭 아는 것
- **되돌리기는 두 겹이다** — `_undo`(줄 단위 `LineEdit`)와 `_textUndo`(글 전문, 타이핑이
멈출 때마다 한 걸음). `FlattenHistory()` 가 줄 단위를 글 단위로 옮긴다.
`ClearHistory()` 는 **저장 직후에도 불린다** — "파일이 바뀌었다" 의 신호로 쓰면 안 된다.
- **덩어리 명령은 고른 줄이 아니라 `CaretRow`(편집기 캐럿)를 본다.**
- **CommunityToolkit 커맨드는 `CommandManager` 를 구독하지 않는다.** 새 명령을 만들면
`RaiseCanExecuteChanged()` 와 (캐럿을 보는 것이면) `OnCaretRowChanged` 에 **꼭 넣는다.**
안 넣으면 단추가 꺼진 채로 남는다(G-2 에서 겪었다).
- 편집기 글자는 **투명**이다. 색은 아래층이 그린다 — 그래서 "한참 뒤에 반응한다" 가
곧 "아무것도 안 보인다" 가 된다.
- 만든 사람은 설정이 비면 `csproj` 의 `<Company>` 로 받쳐지지만(`ProgramInfo.Prefer`),
**문의처는 받칠 것이 없다**(`Prefer(contact, null)`) — 그래서 G-5 가 나왔다.
---
## 8. 사용자와 일하는 방식
- **한국어로 답한다.** 코드 주석도 한국어이고 "무엇을" 이 아니라 **"왜"** 를 적는다.
- 사용자는 항목을 하나씩 준다(`X 진행해줘`). 여러 개를 한꺼번에 주면 **순서를 제안하고**
하나씩 끝낸다.
- 갈리는 결정(문구 · 단축키 · 화면 모양)은 **묻는다.** 사용자가 골라 주면 그대로 간다.
- **추측으로 원인을 말하지 않는다.** 값으로 확인한 뒤 말하고, 못 했으면
**"값으로 확정하지 못했다"** 고 분명히 적는다(문서와 커밋 메시지 양쪽에).
- 헛짚은 것도 기록에 남긴다 — 다음 사람이 같은 길을 또 파지 않게.

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,27 @@
| 날짜 | 항목 | 커밋 | | 날짜 | 항목 | 커밋 |
|---|---|---| |---|---|---|
| 2026-09-30 | **끌 때 저장하지 않은 편집을 묻는다(사용자 요청 · 결정)** — 파일 비교의 모든 탭(지금 화면 · Git 모드 · 뒤 탭 · 닫은 탭, 메모 제외)에 편집이 있으면 [저장하고 종료] · [저장하지 않고 종료] · [취소]. 저장은 기존 길(`SaveWorkingCopy` · `WritePlainFile`), 하나라도 실패하면 끄지 않음. 버리면 걸려 있던 보관 쓰기를 기다린 뒤 보관본을 비워 다음에 안 살아남. 플랫폼 `ICloseGuard` 사용(수정 없음) — 곧바로 답하면 셸의 `Close()` 가 Closing 안에서 불려 창이 안 꺼져 `await Task.Yield()` 로 비켜섬. 화면 `g19-exit-confirm` 다섯 경우 실패 0(Git 모드는 화면 시험 안 함). **기존 회귀는 사용자 지시로 돌리지 않음.** 빌드 경고 0 · 오류 0 | 아래 |
| 2026-09-30 | **엔터 꾹 · 확대 · 쭉 내리기에서 칸 어긋남(사용자 보고)** — ① 엔터를 꾹 누르면 캐럿 줄과 LOCAL 칸 줄이 벌어졌다 → 캐럿은 글에서 센 줄 자리로, 메아리 막기 중에도 LOCAL 칸은 편집기를 따라감. ② Ctrl+휠 확대 뒤 칸 위가 비고 내리면 BASE 가 다른 줄: 가상화 목록 높이 어림 차이(편집기 28,896 · 목록 27,940)를 네 칸 공통 이동값으로 메우다 467px 까지 커졌다 → 두 줄 넘으면 칸을 실제로 굴리고 칸별 보정으로 기억, BASE · REMOTE 는 같은 줄끼리 맞춤(한쪽에만 있는 묶음 안은 제외 — 짝을 억지로 불러 초당 600번 되풀이하던 것을 기록으로 잡음). ③ 편집기 `GetFirstVisibleLineIndex`(886 → 1033) · 확대 직후 `GetRectFromCharacterIndex`(1,892px 틀림)가 틀려 → 줄 높이 고정이면 계산. 화면 `g17-hold-enter` 통과(남은 ✗ 는 대본 한계 · 꾹 누름 흉내에서 Windows 가 반복 키를 합치는 것 — 고치기 전과 같음). **기존 회귀는 사용자 지시로 돌리지 않음.** 빌드 경고 0 · 오류 0 | 아래 |
| 2026-09-30 | **머지 주석 · 타이핑 속도 · 탭 알림(사용자 요청 · 보고)** — ① Accept 뒤 캐럿이 새 주석 줄에 서서 Alt+↓ 를 두 번 눌러야 했다 → 주석을 끼우기 직전 캐럿 행을 기억해 글을 갈아 끼운 뒤 그 행으로(글을 막 갈아 끼운 TextBox 의 줄 정보가 옛 글 기준이라는 것을 기록으로 확인 → 줄 자리는 글에서 직접 셈). ② 줄을 지우는 반영에도 주석 — 한 줄 `삭제`, 여러 줄 `삭제 N줄`(사용자 선택, 왼쪽 · 3-Way · 구간 · 메서드 모두). ③ 색 구분 "줄 안에서 바뀐 부분" 에 줄 수. ④ 타이핑: 1,806줄에서 재 보니 엔터마다 재정렬 0.4초의 대부분이 목록에 1,800줄을 한 줄씩 넣는 알림(파일 비교 자체는 0.012초) → `MergeLineCollection.ReplaceAll`(알림 한 번) + `Patch`(번호 말고 같은 줄은 기존 객체를 두고 번호만, 가운데만 빼고 넣기) → 엔터 한 번 약 0.55초 → 약 0.05초(뒤 다시 칠한 줄 290 → 0~8). ⑤ 탭이 20개를 넘어 조용히 닫히면 알림. 하네스 `NoteTest` 106 · `PatchTest`(새) 16, 화면 `g12-note` · `g14-inline` · `g15-typing-tabs` 통과. **기존 회귀는 사용자 지시로 돌리지 않음.** 빌드 경고 0 · 오류 0 | 아래 |
| 2026-09-30 | **커밋 뒤 사용자 요청 셋** — ① 폴더 비교에서 두 번 눌러 파일 비교로 가도 하단 탭이 옛 탭에 남던 것: 사용자가 쓰던 앱을 읽기만 해서 재현(화면 파일 비교 · 선택 0번), 원인은 공용 플랫폼 셸의 `NavList` 가 `SelectedMainTab` 을 따라가지 않는 것 — 허락받아 `UbisamCommonPlatform` 에서 고침(MergeHub 코드 변경 없음), `g10-drop` 선택 [0] → [1]. ② Git 탭에 폴더 · 파일을 끌어다 놓으면 그 저장소 최상위를 연다(`PathUtil.RepositoryFolderFor`, `[폴더...]` 와 같은 규칙, 탭 전체 Preview 로 받아 TextBox 가 먹는 문제 회피). `PathTest` 62 → 68건(먼저 실패 확인). 흉내 낸 OLE 끌기는 이미 되는 폴더 비교 칸에도 안 들어가 도구 한계로 보고 사용자가 직접 확인(로그 저장소 열기 두 번). ③ 파일 비교 편집 칸 바탕을 회색(페이지 바탕이 비침) → 밝음 #FAFAFA · 어두움 #252830(순검정 · #1E1E1E 를 거쳐 후보를 보고 사용자가 고름, `PaneBackgroundConverter`). `g11-pane` 두 테마 화소로 확인. 빌드 경고 0 · 오류 0 | 아래 |
| 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. **앞으로 플랫폼은 공용 저장소에서만 고친다** | 아래 |
| 2026-09-28 | **12차 C-3** — 플랫폼 **헤더 줄도 접을 수 있게**. 지금까지 `TopBarCollapsed` 설정은 있었지만 그것을 켜고 끄는 자리가 **설정 화면 체크박스 하나뿐**이었고, 접으면 되살릴 띠도 단축키도 없어 다시 설정 화면으로 들어가야 했다. 아래 탭 줄 접기(11차 F-4)와 같은 방식으로 맞췄다 — 창 맨 위에 16 DIP 짜리 띠(`⌄ 헤더 펴기 · Ctrl+F11`, 사용자 결정대로 화살표와 안내 글만 두고 시계 · 이름은 넣지 않았다), **Ctrl+F11** 로 접고 펴기(아래 탭 줄 F11 과 짝), 하단 동작 줄에 [헤더 접기]. 접은 상태는 그 항목만 파일에 남긴다(설정 화면의 저장하지 않은 값을 건드리지 않게). 띠는 단추가 아니라 Border 다 — 한 자리에서 끌기와 누르기를 둘 다 받아야 하는데 단추에 DragMove 를 붙이면 마우스를 놓을 때 Click 이 오지 않아 펴지지 않는다. 끌린 거리가 없으면 누른 것으로 본다. 값: 모듈 시작 **144px → 51px(93px · 62 DIP 벌었다)**, 다시 켜도 접힌 채, F11 은 그대로 아래 탭 줄만. 하네스 `PlatformCfgTest` 29 → 42건(옛 플랫폼 소스를 가리키던 것도 새 저장소로 옮김), 화면 21건 통과. **접은 띠를 끌면 창이 따라오는가는 값으로 확정하지 못했다** — 흉내 낸 마우스로는 WPF DragMove 가 움직이지 않고, 기존 헤더 끌기로 견줘 봐도 같았다(시험 도구 한계). **배포함**(사용자 확인 뒤) — 모든 사내 프로그램에 함께 간다. 기본은 펴짐이라 아무것도 안 하면 달라지는 것이 없다. 플랫폼이 바뀌었으니 **그 위에 얹혀 도는 12차 화면 시험 아홉 벌을 2026-09-30 에 전부 다시 돌렸다 — 208건 실패 0 · 예외 0**(`run-all-c3.ps1`, 한 벌마다 앞 벌이 남긴 창 · 보관함을 치우고 들어간다). 끝나고 설정 네 파일을 시작 기준본과 바이트까지 같게 되돌렸다 | 아래 |
| 2026-09-28 | **12차 F-1** — 하단 탭에 **메모장**을 새로 넣었다(사용자 요청). 메모 + 파일 열기 둘 다이고, **저장 단추 없이 계속 남는다** — 보관 자리는 `{설정 폴더}\Memo\` 로 E-1 의 `Session\` 과 따로 둔다(저쪽은 저장하면 사라지는 것, 메모는 지우기 전까지 남는 것이라 수명이 다르다). 이름 없는 메모는 `메모 1` · `메모 2` **번호만**(사용자 결정 — 첫 줄을 따오면 글을 고칠 때마다 탭 줄이 요동친다), 번호는 비어 있는 가장 작은 수를 준다. 파일로 연 메모와 한 탭 줄에 섞고, 탭 줄 · 저장 안 한 가운뎃점 · Ctrl+Tab · 가운데 단추 닫기는 12차 E-2 문서 탭과 **같은 모양**이다. 편집기는 줄 번호 · Ctrl+휠 글자 크기 · **찾기/바꾸기**(사용자 결정). 닫을 때 저장하지 않은 것이 있으면 세 갈래로 묻는다(저장하고 닫기 · 저장하지 않고 닫기 · 취소), 고친 것이 없으면 묻지 않는다. 하단 탭 맨 뒤에 넣어 앞 세 탭의 차례를 건드리지 않았다. 화면을 보고 세 가지를 고쳤다(사용자 보고) — 탭을 오가면 화면이 다시 만들어지는 틈에 빈 편집기를 거둬 글을 덮어쓰던 것, 줄 번호를 TextBlock 으로 그려 줄 높이가 어긋나던 것(편집기와 같은 TextBox 로 바꿈), 플랫폼 입력 칸 스타일이 글 칸을 세로 가운데에 붙박아 글이 한가운데에서 시작하던 것(템플릿 안 값이라 그 칸을 직접 늘림). 하네스 `MemoTest` 69건 새로 만듦 · 전체 29벌 1,934건 · 실패 0, 화면 여덟 벌 통과 | 아래 |
| 2026-09-28 | **12차 D-1** — 폴더 비교 위쪽을 한 줄로. 경로 · 옵션 · 찾기 · 색 범례가 네 줄로 **305px(203 DIP)** 를 먹었고 비교하기 전에도 그대로였다. 위 줄은 `[…] 왼쪽 경로 ⇆ […] 오른쪽 경로 [최근▾] 켠옵션 [비교] [취소] [옵션 ▾]` — 폴더 고르기는 `…` 로 줄이고 **좌우 바꾸기(⇆)** 를 새로 넣었다(사용자 결정). 빠른 비교 · 하위 폴더 포함 · 보기 · 포함 · 제외는 `[옵션 ▾]` 서랍으로 넣되, 서랍에 넣으면 "뭘 켜 뒀더라" 를 알 수 없게 되므로 **기본과 다른 것만** 한 줄에 짧게 적는다(`FolderOptionText`). 찾기는 늘 한 줄을 먹던 것을 **Ctrl+F 로 부를 때만** 펴고(Esc 로 접음), 색 범례는 아래 상태 줄로 내렸다(색은 목록을 보는 동안 필요한 것이다). 결과: **305px → 77px(203 → 51 DIP, −75%)**, 좁은 창 1280 DIP 에서도 한 줄. 되던 것은 하나도 없어지지 않았다 (폴더 고르기 · 최근 · 빠른 비교 · 하위 폴더 · 보기 거르기 · 포함/제외 · 폴더 안 찾기 · 결과 내보내기 · 복사/삭제 · 줄 단위 비교 — 값으로 확인). 화면을 보다 `[옵션 ▾]` 이 플랫폼 기본 ToggleButton 스타일 탓에 스위치 모양으로 그려지던 것과 최근 목록이 넓게 벌어지던 것을 고쳤다. 하네스 `FolderTest` 118 → 131건, 전체 28벌 1,865건 · 실패 0 | 아래 |
| 2026-09-28 | **12차 E-1** — 저장하지 않은 편집이 껐다 켜도 남는다(notepad++ 식). E-2 가 만든 보관함을 `{설정 폴더}\Session\MergeHub\` 에 그대로 옮겨 적는다 — 차례 · 자리는 `목록.txt` 에 탭으로 나눈 글, 편집한 글은 짝마다 따로 파일. JSON 을 쓰지 않은 까닭은 최근 목록과 같다(사람이 고칠 수 있고, 글 안에 무엇이 들었든 본문을 따로 두면 탈이 없다). **원본 글은 담지 않는다** — 파일에서 다시 읽고, 그때 파일의 길이 · 수정 시각만 적어 두었다가 다르면 알린다. 목록은 맨 나중에 쓴다(있다는 것이 곧 글 파일이 다 있다는 뜻). 보관은 글자가 멈춘 뒤 2초에 한 번, 탭을 옮기거나 닫거나 새 짝을 열 때는 그 자리에서 바로, 쓰는 일은 다른 실로 넘긴다. 켤 때는 묻지 않고 되살리되 사용자가 지금 열라고 한 것(충돌 파일 · mergetool · 폴더 비교에서 고른 파일)이 먼저다. 원본이 그 사이 바뀌었으면 되살리고 알린다("파일을 연 시점" 은 새로 읽은 글로 두어 [전체 되돌리기] 가 지금 파일로 간다). 다 저장하면 보관본을 치운다. 화면으로 확인: 두 짝을 고치고 **강제로 끝낸 뒤** 다시 켜면 묻지 않고 탭 2개가 글 그대로 되살아나고, 저장하면 가운뎃점이 빠지고, 다 저장하면 보관본이 사라진다. 하네스 `SessionTest` 65건 새로 만듦 · 전체 28벌 1,852건 · 실패 0. 빠르기는 글자당 31ms 로 4줄짜리와 같았으나, **"친 뒤 화면에 보이기까지" 는 UIA 로 재는 비용이 타이핑보다 커서 값으로 확정하지 못했다**. E-1 이 도니 지난 시험이 남긴 편집이 다음 시험에서 되살아나 화면 시험 도구도 보관 자리를 치우고 시작하도록 고쳤다 | 아래 |
| 2026-09-28 | **`[<]` 단추가 있는 줄만 글이 밀리던 것**(사용자 보고 — 그림으로 짚어 준 것이 이것이다). 12차 B-1 로 넣은 `[<]` 단추가 든 칸의 폭이 `Auto` 여서, 단추가 보이는 줄에서만 칸이 벌어져 뒤 칸이 통째로 오른쪽으로 밀렸다 — BASE 는 맨 앞 칸이라 <b>번호까지</b>, LOCAL 은 번호 뒤 칸이라 <b>코드가</b> 밀렸고 단추가 하나 늘 때마다 더 밀렸다. `Visibility="Collapsed"` 는 자리까지 없애므로 칸 폭이 고정이어야 한다. `GutterBothWaysWidth` 를 두어 두 칸이 같은 자리를 늘 잡아 두고, 두 파일 비교에서는 단추 폭 · Git 모드와 네 칸에서는 0 으로 화면이 갈아 끼운다(`ApplyGutterReserve`). 투명한 편집기가 아래 표시층과 어긋나지 않도록 `EditorGutterMargin` 도 같은 값만큼 민다. 글자 크기(11차 F-5)를 바꾸면 단추 폭이 따라 바뀌므로 잡아 두는 자리도 `CodeFontMetrics` 한 벌에서 나온다. 값으로 확인: 한 칸의 모든 줄이 같은 x(BASE 137 · LOCAL 1283), 글자 크기 · Git 모드도 그대로. BASE 줄 끝의 `[>]`(11차 F-10)는 왼쪽 가장자리를 밀지 않아 이번에는 `Auto` 로 두었다 | 아래 |
| 2026-09-28 | **왼쪽 줄 번호가 밀리던 것**(사용자 보고) — 12차 B-1 로 왼쪽에도 반영할 수 있게 되면서 왼쪽 줄이 늘고 주는데, 번호는 정렬기가 처음 준 것 그대로였다. 오른쪽에만 있던 줄을 왼쪽에 넣으면 그 줄은 번호가 비고 뒤 줄은 옛 번호를 달아 `01 02 · 03 04 05 · 06` 으로 보였다 — 왼쪽 파일은 8줄인데 번호는 6까지라 어느 줄을 고쳤는지 짚을 수 없었다. `RenumberBase` 를 `RenumberMerged`(8차 B-3) 옆에 두고 두 파일 비교에서만 왼쪽 줄을 1부터 다시 맨다. Git 모드 · 네 칸의 BASE 는 고칠 수 없는 쪽이라 건드리지 않는다. "머지 반영함" 표시가 BASE 줄 번호를 열쇠로 붙들고 있어 번호가 실제로 달라질 때만 그 열쇠도 옛 번호 → 새 번호로 옮긴다. 화면으로 넣기 · 빼기 · 되돌리기 · 저장 · Git 회귀 확인, 하네스 27벌 1,787건 · 실패 0. **왼쪽에만 있는 줄을 뺄 때 줄 수가 주는지는 값으로 확정하지 못했다**(시험 도구가 낡은 줄 칸을 집는 한계) | 아래 |
| 2026-09-28 | **12차 E-2** — 파일 비교가 짝 여러 개를 동시에 든다(문서 탭). 지금까지는 한 짝만 들어서 짝을 바꾸면 "저장하지 않은 편집이 있습니다" 를 묻고 버리면 고친 글이 사라졌다(재현: 16줄 → 15줄). 이제 떠 있던 탭이 그 글을 맡아 두므로 **묻지 않는다**. 탭마다 `HomeViewModel` 을 따로 만들지 않고(GitWorkspace 의 한 자리짜리 약속 때문) 화면은 하나로 두고 탭이 **파일에 없는 것만** 맡는다 — 고친 글 · 왼쪽 글(B-1) · 되돌리기 이력 · 두 기준점 · 캐럿 · 스크롤 · 저장 형식. 탭 이름은 파일 이름(다르면 `왼 ↔ 오`, 겹치면 위 폴더를 갈릴 때까지 덧붙임), 저장 안 한 탭은 `•`. 탭 줄은 둘 이상일 때만 보인다. `◀ 이전 · 다음 ▶` 은 지금 탭의 짝만 바꾸고(탭이 늘지 않음), 폴더 비교에서 두 번 누르면 새 탭(사용자 결정). Ctrl+Tab · Ctrl+Shift+Tab · Ctrl+W · 가운데 단추 닫기. 탭이 사라져도 고친 글은 **보관함**(짝 50개)에 남아 다시 열면 되살아난다 — E-1 은 이 보관함을 파일로 옮겨 적으면 된다. 원본이 그 사이 바뀌면 되살리고 알린다("연 시점"은 새로 읽은 글로 둔다). 탭 줄은 ItemsControl 이 아니라 ListBox 다(ItemsControl 은 UIA 에 컨트롤로 안 보여 화면 시험이 못 읽는다). 켤 때 저절로 열리는 짝만 탭이 없어 편집이 사라지던 것을 화면에서 찾아 `EnsureTabForCurrentPair` 로 고쳤다. 켜 놓고 눈으로 보다 두 가지를 더 고쳤다(사용자가 고름) — 중괄호 맞춤으로만 바뀐 것에는 점을 붙이지 않고(묻는 자리와 같은 기준), 탭 이름은 양쪽이 같은 부분을 한 번만 적는다(`WeldController_기준.cs ↔ WeldController_내작...` → `WeldController_기준 ↔ 내작업.cs`, 421 → 288px). 뒤쪽은 확장자일 때만 접는다 — 글자 단위로 세면 `a-lef ↔ right.cs` 처럼 낱말 가운데서 잘린다(하네스에서 걸렸다). 하네스 `DocTabTest` 106건, 화면 34건 · 회귀 19건 통과, 전체 27벌 1,787건 · 실패 0. **커밋 안 함**(사용자 지시) | 아래 | | 2026-09-28 | **12차 E-2** — 파일 비교가 짝 여러 개를 동시에 든다(문서 탭). 지금까지는 한 짝만 들어서 짝을 바꾸면 "저장하지 않은 편집이 있습니다" 를 묻고 버리면 고친 글이 사라졌다(재현: 16줄 → 15줄). 이제 떠 있던 탭이 그 글을 맡아 두므로 **묻지 않는다**. 탭마다 `HomeViewModel` 을 따로 만들지 않고(GitWorkspace 의 한 자리짜리 약속 때문) 화면은 하나로 두고 탭이 **파일에 없는 것만** 맡는다 — 고친 글 · 왼쪽 글(B-1) · 되돌리기 이력 · 두 기준점 · 캐럿 · 스크롤 · 저장 형식. 탭 이름은 파일 이름(다르면 `왼 ↔ 오`, 겹치면 위 폴더를 갈릴 때까지 덧붙임), 저장 안 한 탭은 `•`. 탭 줄은 둘 이상일 때만 보인다. `◀ 이전 · 다음 ▶` 은 지금 탭의 짝만 바꾸고(탭이 늘지 않음), 폴더 비교에서 두 번 누르면 새 탭(사용자 결정). Ctrl+Tab · Ctrl+Shift+Tab · Ctrl+W · 가운데 단추 닫기. 탭이 사라져도 고친 글은 **보관함**(짝 50개)에 남아 다시 열면 되살아난다 — E-1 은 이 보관함을 파일로 옮겨 적으면 된다. 원본이 그 사이 바뀌면 되살리고 알린다("연 시점"은 새로 읽은 글로 둔다). 탭 줄은 ItemsControl 이 아니라 ListBox 다(ItemsControl 은 UIA 에 컨트롤로 안 보여 화면 시험이 못 읽는다). 켤 때 저절로 열리는 짝만 탭이 없어 편집이 사라지던 것을 화면에서 찾아 `EnsureTabForCurrentPair` 로 고쳤다. 켜 놓고 눈으로 보다 두 가지를 더 고쳤다(사용자가 고름) — 중괄호 맞춤으로만 바뀐 것에는 점을 붙이지 않고(묻는 자리와 같은 기준), 탭 이름은 양쪽이 같은 부분을 한 번만 적는다(`WeldController_기준.cs ↔ WeldController_내작...` → `WeldController_기준 ↔ 내작업.cs`, 421 → 288px). 뒤쪽은 확장자일 때만 접는다 — 글자 단위로 세면 `a-lef ↔ right.cs` 처럼 낱말 가운데서 잘린다(하네스에서 걸렸다). 하네스 `DocTabTest` 106건, 화면 34건 · 회귀 19건 통과, 전체 27벌 1,787건 · 실패 0. **커밋 안 함**(사용자 지시) | 아래 |
| 2026-09-28 | **시험 도구를 런처에 맞춤** — 새 플랫폼은 `UbisamBase.Launcher.exe` 가 셸을 같은 프로세스에 올려 창 주인 프로세스 이름이 바뀌었고, 예전 이름만 찾던 화면 시험 도구가 전부 "창이 안 뜬다" 로 멈춰 있었다. `a2lib.ps1` 에 `Shell-Procs` · `Shell-Proc` · `Stop-App` 을 두고 `Start-App` · `Get-Win` 이 그것을 쓰게 했다(런처가 제목 없는 진행 창을 먼저 띄우는 경우를 대비해 제목 있는 창을 먼저 고름). 스크립트 76개 · 156곳을 함께 고침. 문법 187개 통과, 끄기 → 켜기 → 창 찾기 → 네 탭 → 단추 → 끄기까지 실제로 돌았고 설정 · 창 자리 네 파일 해시 그대로 되돌림. **제품 코드 변경 없음** | 없음 | | 2026-09-28 | **시험 도구를 런처에 맞춤** — 새 플랫폼은 `UbisamBase.Launcher.exe` 가 셸을 같은 프로세스에 올려 창 주인 프로세스 이름이 바뀌었고, 예전 이름만 찾던 화면 시험 도구가 전부 "창이 안 뜬다" 로 멈춰 있었다. `a2lib.ps1` 에 `Shell-Procs` · `Shell-Proc` · `Stop-App` 을 두고 `Start-App` · `Get-Win` 이 그것을 쓰게 했다(런처가 제목 없는 진행 창을 먼저 띄우는 경우를 대비해 제목 있는 창을 먼저 고름). 스크립트 76개 · 156곳을 함께 고침. 문법 187개 통과, 끄기 → 켜기 → 창 찾기 → 네 탭 → 단추 → 끄기까지 실제로 돌았고 설정 · 창 자리 네 파일 해시 그대로 되돌림. **제품 코드 변경 없음** | 없음 |
| 2026-09-28 | **12차 C-2** — 플랫폼 하단 탭 줄을 한 단계 작게(항목 74 → 61 DIP: 여백 13/14→9/10 · 아이콘 22→20 · 아이콘 아래 틈 7→5 · 글자 13.5→12.5). 실측 133 → 109px(−18%)이고 그만큼 콘텐츠가 늘어 파일 비교 코드 칸이 47 → 48줄. 라벨 잘림 · 선택 밑줄 · 탭 접기 띠 · 오른쪽 동작 항목 모두 그대로. 플랫폼 쪽이라 모든 사내 프로그램에 함께 간다. 처음에는 배포본을 백업해 잠깐 바꿔치기해 시험하고 되돌렸으나, 그 뒤 **새 플랫폼을 빌드하면서 함께 배포됐다** — 2026-09-28 배포본 `UbisamBase.Core.dll` 해시 일치 · 화면 실측 111px(항목 109px)로 확인. **커밋 안 함**(사용자 지시) | 없음 | | 2026-09-28 | **12차 C-2** — 플랫폼 하단 탭 줄을 한 단계 작게(항목 74 → 61 DIP: 여백 13/14→9/10 · 아이콘 22→20 · 아이콘 아래 틈 7→5 · 글자 13.5→12.5). 실측 133 → 109px(−18%)이고 그만큼 콘텐츠가 늘어 파일 비교 코드 칸이 47 → 48줄. 라벨 잘림 · 선택 밑줄 · 탭 접기 띠 · 오른쪽 동작 항목 모두 그대로. 플랫폼 쪽이라 모든 사내 프로그램에 함께 간다. 처음에는 배포본을 백업해 잠깐 바꿔치기해 시험하고 되돌렸으나, 그 뒤 **새 플랫폼을 빌드하면서 함께 배포됐다** — 2026-09-28 배포본 `UbisamBase.Core.dll` 해시 일치 · 화면 실측 111px(항목 109px)로 확인. **커밋 안 함**(사용자 지시) | 없음 |

View File

@ -64,7 +64,7 @@
- 지금은: 맨 위 헤더 줄(UbiSam 로고 · 프로그램 이름 · 가운데 아이콘 · 날짜 · 시계)이 늘 자리를 차지한다. 2560×1600 · 배율 150% 에서 약 68px. - 지금은: 맨 위 헤더 줄(UbiSam 로고 · 프로그램 이름 · 가운데 아이콘 · 날짜 · 시계)이 늘 자리를 차지한다. 2560×1600 · 배율 150% 에서 약 68px.
- **정한 것**: 아래 탭 줄 접기(11차 F-4)와 **같은 방식**으로 접을 수 있게 — 접으면 얇은 띠만 남고, 띠를 누르거나 단축키로 다시 편다. 접은 상태는 다음에 켤 때도 그대로. - **정한 것**: 아래 탭 줄 접기(11차 F-4)와 **같은 방식**으로 접을 수 있게 — 접으면 얇은 띠만 남고, 띠를 누르거나 단축키로 다시 편다. 접은 상태는 다음에 켤 때도 그대로.
- 새 플랫폼(`D:\Sorce\ubsrepo`) 쪽이라 **모든 사내 프로그램에 함께 간다**. 배포는 사용자 확인 뒤. - 새 플랫폼(`D:\Sorce\ubsrepo`) 쪽이라 **모든 사내 프로그램에 함께 간다**. 배포는 사용자 확인 뒤.
- **정할 것**: 단축키(아래 탭 줄이 F11 이니 F10 또는 Ctrl+F11), 접었을 때 시계 · 프로그램 이름을 띠에 남길지. - **만들면서 정한 것**: 단축키 **Ctrl+F11**(아래 탭 줄 F11 과 짝이 맞아 외우기 쉽다) · 띠에는 **화살표와 안내 글만**(시계 · 이름은 남기지 않는다) — 둘 다 사용자 결정 2026-09-28.
- **판정 기준**: 접으면 코드 칸이 그만큼 높아진다(값으로 잰다). 다시 켜도 접힌 채다. 창 끌기 · 최대화 · 두 번 누르기가 그대로 동작한다 — 헤더가 창을 끄는 자리라 접은 띠에서도 끌 수 있어야 한다. - **판정 기준**: 접으면 코드 칸이 그만큼 높아진다(값으로 잰다). 다시 켜도 접힌 채다. 창 끌기 · 최대화 · 두 번 누르기가 그대로 동작한다 — 헤더가 창을 끄는 자리라 접은 띠에서도 끌 수 있어야 한다.
## D. 폴더 비교 화면 다시 만들기 (2026-09-28 사용자 요청) ## D. 폴더 비교 화면 다시 만들기 (2026-09-28 사용자 요청)
@ -90,11 +90,15 @@
- **되살릴 범위**: 마지막에 **열려 있던 탭 전부**(E-2) — 탭마다 파일 짝 + 저장하지 않은 편집 글, 캐럿 자리 · 스크롤 위치, 어느 탭을 보고 있었는지. 되돌리기(Undo) 이력은 되살리지 않는다. - **되살릴 범위**: 마지막에 **열려 있던 탭 전부**(E-2) — 탭마다 파일 짝 + 저장하지 않은 편집 글, 캐럿 자리 · 스크롤 위치, 어느 탭을 보고 있었는지. 되돌리기(Undo) 이력은 되살리지 않는다.
- **되살리는 순간**: 묻지 않고 그대로 되살린다(notepad++ 처럼). 상태 줄에 "되살림" 을 잠깐 띄운다. - **되살리는 순간**: 묻지 않고 그대로 되살린다(notepad++ 처럼). 상태 줄에 "되살림" 을 잠깐 띄운다.
- **보관 대상**: **두 파일 비교만**. Git 탭 · 병합 중은 git 이 이미 상태를 들고 있어 건드리지 않는다. - **보관 대상**: **두 파일 비교만**. Git 탭 · 병합 중은 git 이 이미 상태를 들고 있어 건드리지 않는다.
- **정할 것**: - **만들면서 정한 것**:
- 언제 보관할지 — 글자가 멈춘 뒤 몇 초, 창을 닫을 때, 탭을 바꿀 때. - **언제 보관할지**: 글자가 멈춘 뒤 **2초**에 한 번. 탭을 옮길 때 · 닫을 때 · 새 짝을 열 때는 **그 자리에서 바로**
- 보관 자리 — `{설정 폴더}\Session\` 아래(11차 F-9 백업과 다른 자리, 백업은 "저장 전 파일", 이것은 "저장 안 한 편집"). (그 순간이 곧 끄기 직전일 수 있다). 쓰는 일은 다른 실로 넘긴다.
- 되살릴 때 **원본 파일이 그 사이 바뀌었으면** 어떻게 할지(알리고 되살림 · 알리고 버림 · 나란히 보여 주기). - **보관 자리**: `{설정 폴더}\Session\MergeHub\` — 차례 · 자리는 `목록.txt` 에 탭으로 나눈 글,
- 오래된 보관본을 언제 치울지. 편집한 글은 짝마다 따로 파일. **원본 글은 담지 않는다**(파일에서 다시 읽는다).
- **원본이 그 사이 바뀌면**: **되살리고 알린다**. "파일을 연 시점" 은 새로 읽은 글로 두어
[전체 되돌리기] 가 지금 파일로 가고, 고친 글은 편집으로 남는다 — 어느 쪽도 잃지 않는다.
- **오래된 보관본**: 저장해서 고친 것이 없어지면 그 몫이 빠지고, 하나도 안 남으면 자리를 통째로 치운다.
닫힌 짝의 편집은 E-2 보관함 한도(짝 50개)를 따른다. 두 파일이 다 없어진 짝은 켤 때 버린다.
- **판정 기준**: - **판정 기준**:
- 짝 두세 개를 탭으로 열어 고치고 **저장하지 않은 채** 창을 닫았다가 다시 켜면, 탭이 그 수·그 순서로 열리고 글이 그대로다(글자 · 줄 수 · 캐럿 자리를 값으로 대조). - 짝 두세 개를 탭으로 열어 고치고 **저장하지 않은 채** 창을 닫았다가 다시 켜면, 탭이 그 수·그 순서로 열리고 글이 그대로다(글자 · 줄 수 · 캐럿 자리를 값으로 대조).
- 저장한 뒤 닫으면 되살릴 것이 없다(보관본이 치워진다). - 저장한 뒤 닫으면 되살릴 것이 없다(보관본이 치워진다).
@ -131,12 +135,16 @@
- **성격**: **메모 + 파일 열기 둘 다**. 기본은 이름 없는 메모 여러 장이고, 원하면 파일로 여닫고 저장도 한다. - **성격**: **메모 + 파일 열기 둘 다**. 기본은 이름 없는 메모 여러 장이고, 원하면 파일로 여닫고 저장도 한다.
- **보관**: **자동으로 계속** — 쓰는 도중 알아서 보관하고, 껐다 켜도 그대로 있다(E-1 과 같은 생각). 저장 단추를 눌러야 남는 방식이 아니다. - **보관**: **자동으로 계속** — 쓰는 도중 알아서 보관하고, 껐다 켜도 그대로 있다(E-1 과 같은 생각). 저장 단추를 눌러야 남는 방식이 아니다.
- 탭 줄 · 저장 안 한 표시 `•` · Ctrl+Tab 넘기기는 **E-2 문서 탭과 같은 것을 쓴다**(같은 모양이어야 배우는 것이 하나다). - 탭 줄 · 저장 안 한 표시 `•` · Ctrl+Tab 넘기기는 **E-2 문서 탭과 같은 것을 쓴다**(같은 모양이어야 배우는 것이 하나다).
- **정할 것**: - **만들면서 정한 것**:
- 메모 보관 자리(`{설정 폴더}\Memo\` 또는 E-1 의 `Session\` 아래 같이 둘지). - **보관 자리**: `{설정 폴더}\Memo\` — E-1 의 `Session\` 과 **따로**. 저쪽은 "저장하면 사라지는 것",
- 이름 없는 메모의 탭 이름(`메모 1` · 첫 줄 몇 자 · 사용자가 붙이기). 메모는 "지우기 전까지 남는 것"이라 수명이 다르다. 모양(목록 한 장 + 글마다 파일)은 같다.
- 파일로 연 메모와 이름 없는 메모를 같은 탭 줄에 섞을지. - **이름 없는 메모의 이름**: `메모 1` · `메모 2` **번호만**(사용자 결정). 첫 줄을 따오면 글을 고칠 때마다
- 어디까지 파일 비교와 같게 할지 — 줄 번호 · 문법 강조 · 찾기/바꾸기 · 글자 크기(Ctrl+휠) · 줄바꿈. 탭 줄이 요동친다. 번호는 **비어 있는 가장 작은 수** — 메모 1 을 닫고 새로 만들면 다시 메모 1.
- 메모를 파일 비교로 보내는 길이 필요한지(메모 두 장을 견주기). - **섞기**: 파일로 연 메모와 이름 없는 메모를 **한 탭 줄에** 둔다(notepad++ 와 같다).
- **어디까지**: 줄 번호 · 글자 크기(Ctrl+휠) · **찾기/바꾸기**(사용자 결정). 문법 강조는 넣지 않았다.
- **닫을 때**: 저장하지 않은 것이 있으면 **묻는다**(사용자 결정 2026-09-28) —
`저장하고 닫기` · `저장하지 않고 닫기` · `취소` 세 갈래. 고친 것이 없으면 묻지 않는다.
- 메모를 파일 비교로 보내는 길은 **이번에 만들지 않았다**(쓰다 필요하면 그때).
- **판정 기준**: - **판정 기준**:
- 메모를 여러 장 만들어 각각 쓰고 껐다 켜면 장 수 · 순서 · 글이 그대로다(값으로 대조). - 메모를 여러 장 만들어 각각 쓰고 껐다 켜면 장 수 · 순서 · 글이 그대로다(값으로 대조).
- 이름 없는 메모도 저장 단추 없이 남는다. 강제로 끝내도 마지막 보관 시점까지 남는다. - 이름 없는 메모도 저장 단추 없이 남는다. 강제로 끝내도 마지막 보관 시점까지 남는다.

View File

@ -1,5 +1,8 @@
# MergeHub 진행 상황 # MergeHub 진행 상황
> **이어받는 사람은 [HANDOFF.md](HANDOFF.md) 를 먼저 읽는다** — 지켜야 할 것,
> 항목 하나를 끝내는 순서, 시험 도구가 있는 자리와 함정, 지금 하던 일이 적혀 있다.
**지금 무엇이 남았는지만** 적는다. 끝난 것의 상세는 [history/](history/) 로 옮겼다. **지금 무엇이 남았는지만** 적는다. 끝난 것의 상세는 [history/](history/) 로 옮겼다.
항목을 끝내면 이렇게 한다. 항목을 끝내면 이렇게 한다.
@ -417,14 +420,54 @@
| 상태 | 항목 | | 상태 | 항목 |
|---|---| |---|---|
| **완료** | C-1 트레이 아이콘 — 최소화하면 알림 영역으로, 왼쪽 단추로 되살리기 · 우클릭 [열기]/[종료]. 새 플랫폼(`D:\Sorce\ubsrepo`) 에 넣음(11차 뒤 G-2 와 같은 작업) | | **완료** | C-1 트레이 아이콘 — 최소화하면 알림 영역으로, 왼쪽 단추로 되살리기 · 우클릭 [열기]/[종료]. 새 플랫폼(`D:\Sorce\ubsrepo`) 에 넣음(11차 뒤 G-2 와 같은 작업) |
| **완료** | C-3 헤더 줄도 접을 수 있게 — 창 맨 위 16 DIP 띠(`⌄ 헤더 펴기 · Ctrl+F11`), **Ctrl+F11** 로 접고 펴기, 하단에 [헤더 접기]. 접은 상태는 그 항목만 파일에 남겨 다시 켜도 접힌 채. **모듈 시작 144px → 51px(62 DIP 벌었다)**. F11(아래 탭 줄)은 그대로. 띠는 Border 라 끌기와 누르기를 함께 받는다(끌린 거리로 가름). **배포함** — 사용자 확인 뒤 빌드, 배포본은 `bin-backup-c3` 에 백업. 하네스 `PlatformCfgTest` 29 → 42건, 화면 21건. **배포 뒤 12차 화면 시험 아홉 벌을 전부 다시 돌렸다(2026-09-30) — 208건 실패 0 · 예외 0.** 그 뒤 이 플랫폼 묶음을 **공용 플랫폼**(`D:\Sorce\UbisamCommonPlatform`)에 합쳐 배포하고 아홉 벌을 또 돌렸다 — **208건 실패 0**, MergeHub 재빌드 경고 0 · 오류 0 |
| **완료** | C-2 탭 줄 크기 줄이기 — 하단 탭 줄 항목을 74 → 61 DIP 로(여백 13/14→9/10 · 아이콘 22→20 · 틈 7→5 · 글자 13.5→12.5). 실측 133 → 109px(−18%), 파일 비교 코드 칸 47 → 48줄. **배포됨** — 새 플랫폼을 빌드하면서 함께 나갔고, 2026-09-28 배포본 해시 · 화면 실측(111px · 항목 109px)으로 확인 | | **완료** | C-2 탭 줄 크기 줄이기 — 하단 탭 줄 항목을 74 → 61 DIP 로(여백 13/14→9/10 · 아이콘 22→20 · 틈 7→5 · 글자 13.5→12.5). 실측 133 → 109px(−18%), 파일 비교 코드 칸 47 → 48줄. **배포됨** — 새 플랫폼을 빌드하면서 함께 나갔고, 2026-09-28 배포본 해시 · 화면 실측(111px · 항목 109px)으로 확인 |
### F. 메모장 탭
| 상태 | 항목 |
|---|---|
| **완료** | F-1 하단 탭에 메모장 — 메모 + 파일 열기 둘 다, **저장 단추 없이 계속 남는다**(`{설정 폴더}\Memo\`). 이름은 `메모 1` · `메모 2` 번호만(빈 번호를 다시 쓴다), 파일로 연 메모와 한 탭 줄에 섞는다. 탭 줄 · 가운뎃점 · Ctrl+Tab 은 E-2 문서 탭과 같은 모양. 줄 번호 · Ctrl+휠 글자 크기 · **찾기/바꾸기**. 닫을 때 저장하지 않은 것이 있으면 세 갈래로 묻는다(사용자 결정). 하네스 `MemoTest` 69건, 화면 30건(강제 종료 뒤 세 장 되살아남 포함) |
### D. 폴더 비교 화면 다시 만들기
| 상태 | 항목 |
|---|---|
| **완료** | D-1 위쪽을 한 줄로 · 옵션은 서랍으로 — 네 줄 305px(203 DIP) → **한 줄 77px(51 DIP)**. 위 줄은 `[…] 왼쪽 ⇆ […] 오른쪽 [최근▾] 켠옵션 [비교][취소][옵션 ▾]`, 좌우 바꾸기를 새로 넣음. 빠른 비교 · 하위 폴더 · 보기 · 포함 · 제외는 서랍으로 넣되 **켠 것만 한 줄에 짧게** 적는다. 찾기는 Ctrl+F 로 펼 때만, 색 범례는 아래 상태 줄로. 되던 것은 하나도 없어지지 않음(값으로 확인). 좁은 창 1280 DIP 에서도 한 줄 |
### E. 저장하지 않아도 남는 편집 ### E. 저장하지 않아도 남는 편집
| 상태 | 항목 | | 상태 | 항목 |
|---|---| |---|---|
| **완료** | E-2 여러 짝을 동시에(문서 탭) — 파일 비교 맨 위에 문서 탭 줄(둘 이상일 때만 보임). 탭 이름은 파일 이름 · 다르면 `왼 ↔ 오` · 겹치면 위 폴더를 덧붙임 · 저장 안 한 탭은 `•`. 짝을 바꿀 때 **묻지 않는다**(탭이 편집을 맡는다). 탭이 사라져도 고친 글은 보관함에 남아 다시 열면 되살아남(◀ ▶ 로 훑다 돌아와도). Ctrl+Tab · Ctrl+Shift+Tab · Ctrl+W · 가운데 단추 닫기. 탭마다 되돌리기 · 캐럿 · 스크롤 · 저장 형식. 상한 20(고친 탭은 안 닫음). 탭 이름은 **양쪽이 같은 부분을 한 번만** 적는다(`WeldController_기준 ↔ 내작업.cs` — 421 → 288px). 점은 **사람이 손댄 것만**(스타일 맞춤으로만 바뀐 것은 빼고 센다 — 묻는 자리와 같은 기준). 하네스 `DocTabTest` 106건, 화면 34건 · 회귀 19건 확인 | | **완료** | E-2 여러 짝을 동시에(문서 탭) — 파일 비교 맨 위에 문서 탭 줄(둘 이상일 때만 보임). 탭 이름은 파일 이름 · 다르면 `왼 ↔ 오` · 겹치면 위 폴더를 덧붙임 · 저장 안 한 탭은 `•`. 짝을 바꿀 때 **묻지 않는다**(탭이 편집을 맡는다). 탭이 사라져도 고친 글은 보관함에 남아 다시 열면 되살아남(◀ ▶ 로 훑다 돌아와도). Ctrl+Tab · Ctrl+Shift+Tab · Ctrl+W · 가운데 단추 닫기. 탭마다 되돌리기 · 캐럿 · 스크롤 · 저장 형식. 상한 20(고친 탭은 안 닫음). 탭 이름은 **양쪽이 같은 부분을 한 번만** 적는다(`WeldController_기준 ↔ 내작업.cs` — 421 → 288px). 점은 **사람이 손댄 것만**(스타일 맞춤으로만 바뀐 것은 빼고 센다 — 묻는 자리와 같은 기준). 하네스 `DocTabTest` 106건, 화면 34건 · 회귀 19건 확인 |
| 할 일 | E-1 꺼도 남는 편집(notepad++ 식) — E-2 의 보관함을 `{설정 폴더}\Session\` 에 옮겨 적고 켤 때 되살린다 | | **완료** | E-1 꺼도 작업 내용이 남아 있게(notepad++ 식) — E-2 보관함을 `{설정 폴더}\Session\MergeHub\` 에 옮겨 적는다(차례는 `목록.txt`, 글은 짝마다 따로 파일, 원본 글은 담지 않고 파일 자취만). 글자가 멈춘 뒤 2초 · 탭을 옮기거나 닫을 때는 바로, 쓰는 일은 다른 실로. 켤 때 묻지 않고 되살리되 사용자가 지금 열라고 한 것이 먼저. 원본이 그 사이 바뀌면 되살리고 알린다. 다 저장하면 보관본을 치운다. 하네스 `SessionTest` 65건, 화면 24건(강제 종료 뒤 되살아남 포함) |
### 쓰다 나온 것
| 상태 | 항목 |
|---|---|
| **완료** | **끌 때 저장하지 않은 편집을 묻는다**(사용자 요청 · 결정) — 파일 비교의 모든 탭(Git 모드 · 뒤 탭 · 닫은 탭 포함, 메모 제외)에 편집이 있으면 [저장하고 종료] · [저장하지 않고 종료] · [취소]. 버리면 보관본도 비워 다음에 안 살아남. 플랫폼 `ICloseGuard` 를 그대로 씀(플랫폼 수정 없음). 화면 `g19-exit-confirm` 실패 0. Git 모드는 화면 시험 안 함 · 기존 회귀는 사용자 지시로 안 돌림 |
| **완료** | **엔터 꾹 · Ctrl+휠 확대 · 쭉 내리기에서 칸이 어긋난다**(사용자 보고) — 캐럿 줄 ≠ LOCAL 칸 줄, 확대하면 칸 위가 비고, 내리면 BASE 가 다른 줄을 봤다. 가상화 목록의 높이 어림 차이를 이동값으로 메우지 않고 칸을 실제로 굴려 칸별로 기억, BASE 는 같은 줄끼리 맞춤, 편집기 보이는 줄은 줄 높이로 계산(편집기의 `GetFirstVisibleLineIndex` · 확대 직후 `GetRectFromCharacterIndex` 가 틀렸다). 화면 `g17-hold-enter` — 엔터 뒤 936=936, 확대 · 30칸 내린 뒤 LOCAL = BASE. 기존 회귀는 사용자 지시로 안 돌림 |
| **완료** | **파일 비교 타이핑 · 엔터 속도**(사용자 보고) — 재서 보니 엔터마다 도는 재정렬 0.4초의 대부분이 1,800줄을 목록에 한 줄씩 넣는 알림이었다. 한 번에 넣기(`ReplaceAll`) + 번호 말고 같은 줄은 기존 객체를 두고 가운데만 바꾸기(`Patch`)로 엔터 한 번 약 0.55초 → 약 0.05초. 하네스 `PatchTest` 16건, 화면 `g15` ① 새 줄 · 번호 제자리. 기존 회귀는 사용자 지시로 안 돌림 |
| **완료** | **머지 주석 두 가지**(사용자 보고) — Accept 뒤 캐럿이 새 주석 줄에 서서 Alt+↓ 를 두 번 눌러야 했던 것(캐럿을 누르기 전 행에 둔다), 줄을 지우는 반영에도 주석 + `삭제` · 여러 줄이면 `삭제 N줄`(사용자 선택). `NoteTest` 106건, 화면 `g12-note` 실패 0 |
| **완료** | **"줄 안에서 바뀐 부분" 줄 수 · 탭 20개 넘을 때 알림**(사용자 요청) — 색 구분에 개수(`g14-inline`), 오래 안 본 탭을 닫으면 알림(`g15` ②: 21번째에서 알림 · 20개 유지) |
| **완료** | **하단 탭이 따라가지 않는다**(사용자 보고) — 폴더 비교에서 두 번 눌러 파일 비교로 가도 하단 탭이 폴더 비교에 남았다. 원인은 공용 플랫폼 셸(`NavList` 가 `SelectedMainTab` 을 따라가지 않음) — 허락받아 플랫폼에서 고침. 화면 `g10-drop` 선택 [0] → [1] |
| **완료** | **Git 탭 끌어다 놓기**(사용자 요청) — 폴더 · 파일을 Git 탭 아무 데나 놓으면 그 저장소(최상위)를 연다. `[폴더...]` 와 같은 규칙(`PathUtil.RepositoryFolderFor`). 하네스 `PathTest` 62 → 68건. 흉내 낸 끌기는 도구 한계로 안 들어가 **사용자가 직접 확인**(로그의 저장소 열기 두 번) |
| **완료** | **파일 비교 편집 칸 바탕**(사용자 요청) — 회색이던 칸을 밝음 #FAFAFA · 어두움 #252830 으로(후보를 보고 사용자가 고름). 화면 `g11-pane` 두 테마 화소로 확인 |
| **완료** | **작성자가 비면 반영하지 않고 알린다**(사용자 요청) — '반영한 줄에 주석 남기기' 가 켜져 있는데 작성자가 비면 반영 명령 여덟 곳 맨 앞에서 알림 창("작성자를 적어 주세요", 설정 자리를 댐)을 띄우고 멈춘다(사용자 선택). 주석을 끄면 묻지 않는다(늘 켬으로 바꿨다가 사용자 지시로 원복). 미리보기도 대신 채울 이름을 안 보인다. 하네스 `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 모드도 그대로 |
| **완료** | 왼쪽 줄 번호가 밀린다(위를 쫓다 따로 찾음) — 12차 B-1 로 왼쪽에도 반영할 수 있게 됐는데 왼쪽 번호를 다시 매기는 자리가 없었다. 줄을 넣으면 그 줄은 번호가 비고 뒤 줄은 옛 번호(`01 02 · 03 04 05 · 06`), 저장하면 8줄인데 번호는 6까지. `RenumberBase` 를 두어 두 파일 비교에서만 왼쪽 줄을 1부터 다시 맨다. "머지 반영함" 표시가 BASE 번호를 열쇠로 쓰므로 그 열쇠도 함께 옮긴다. Git · 네 칸은 손대지 않는다 |
| 볼 것 | 왼쪽에만 있는 줄을 `[<]` 로 뺄 때 줄 수가 실제로 주는지 — 따로 열면 되는데 다른 반영 뒤에는 시험 도구가 낡은 줄 칸을 집어 값이 흔들렸다. 번호 불변식은 지켜짐 |
### 시험 도구 ### 시험 도구

View File

@ -13,7 +13,7 @@ namespace MergeHub
/// </summary> /// </summary>
public class AppSetup : IAppSetup public class AppSetup : IAppSetup
{ {
public string GetAppName() => "MergeHub - 사내 Git Merge/Diff"; public string GetAppName() => "MergeHub - 사내 Merge/Diff + Git";
public void RegisterViews(ITabManager manager) public void RegisterViews(ITabManager manager)
{ {
@ -29,11 +29,20 @@ namespace MergeHub
manager.AddMain<FolderView, FolderViewModel>(Icon.Folder, ViewIds.Folder, "폴더 비교"); manager.AddMain<FolderView, FolderViewModel>(Icon.Folder, ViewIds.Folder, "폴더 비교");
manager.AddMain<HomeView, HomeViewModel>(Icon.Document, ViewIds.Home, "파일 비교"); manager.AddMain<HomeView, HomeViewModel>(Icon.Document, ViewIds.Home, "파일 비교");
manager.AddMain<GitView, GitViewModel>(Icon.Link, ViewIds.Git, "Git"); manager.AddMain<GitView, GitViewModel>(Icon.Link, ViewIds.Git, "Git");
// 메모장은 <b>맨 뒤</b>에 둔다(12차 F-1). 앞 세 탭의 차례가 바뀌면 손버릇도 바뀐다 -
// 비교하다 잠깐 적어 두는 자리라 흐름의 끝에 있는 편이 맞기도 하다.
manager.AddMain<MemoView, MemoViewModel>(Icon.Document, ViewIds.Memo, "메모");
} }
public void RegisterSettings(ITabManager manager) public void RegisterSettings(ITabManager manager)
{ {
manager.AddSettingsSub<MergeHubSettings>(TabManager.SettingsTabKey, "MergeHub"); manager.AddSettingsSub<MergeHubSettings>(TabManager.SettingsTabKey, "MergeHub");
// 개발자 정보(12차 G-6). 처음에는 하단 탭(개요)으로 만들었으나 사용자가 설정 안 메뉴로 옮겼다 -
// 작업 흐름 밖의 안내라 매일 누르는 하단 탭 줄에 자리를 차지할 까닭이 없다. 플랫폼은 건드리지 않고
// 플랫폼이 이미 주는 AddSub(설정 탭 키)로 붙인다(플랫폼의 UI Setting · 백업 · 로그와 같은 길).
manager.AddSub<OverviewView, OverviewViewModel>(TabManager.SettingsTabKey, "개발자 정보");
} }
public void RegisterServices(IServiceCollection services) public void RegisterServices(IServiceCollection services)
@ -52,5 +61,6 @@ namespace MergeHub
public const string Home = "MergeHub.Home"; public const string Home = "MergeHub.Home";
public const string Folder = "MergeHub.Folder"; public const string Folder = "MergeHub.Folder";
public const string Git = "MergeHub.Git"; public const string Git = "MergeHub.Git";
public const string Memo = "MergeHub.Memo";
} }
} }

View File

@ -15,7 +15,11 @@
<Version>0.12.0</Version> <Version>0.12.0</Version>
<Product>MergeHub</Product> <Product>MergeHub</Product>
<Company>UbiSam</Company> <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) 배포용) --> <!-- 플랫폼 배포 폴더 (platform-guide 10-2 (B) 배포용) -->
<UbisamPlatformDir>D:\UbisamPlatform\bin\</UbisamPlatformDir> <UbisamPlatformDir>D:\UbisamPlatform\bin\</UbisamPlatformDir>
@ -38,6 +42,11 @@
<_Parameter1>BuildDate</_Parameter1> <_Parameter1>BuildDate</_Parameter1>
<_Parameter2>$([System.DateTime]::Now.ToString("yyyy-MM-dd HH:mm"))</_Parameter2> <_Parameter2>$([System.DateTime]::Now.ToString("yyyy-MM-dd HH:mm"))</_Parameter2>
</AssemblyAttribute> </AssemblyAttribute>
<!-- 문의처 받침값(12차 G-5). 비어 있어도 키는 박는다 - 읽는 쪽이 "자리는 있다" 를 확인할 수 있게. -->
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>SupportContact</_Parameter1>
<_Parameter2>$(SupportContact)</_Parameter2>
</AssemblyAttribute>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -9,15 +9,8 @@ namespace MergeHub
/// </summary> /// </summary>
public class MergeHubSettings public class MergeHubSettings
{ {
// 프로그램 정보 창(12차 B-2)에 적을 것. 버전 · 빌드 날짜는 실행 파일에서 읽으므로 여기 없다 - // "프로그램 정보" 칸(만든 사람 · 사내 문의처)은 2026-09-30 사용자 지시로 뺐다 - 설정 → 개발자 정보가 담당을 보인다.
// 사람이 적은 값과 실제가 어긋나면 그 창은 없느니만 못하다. 사람이 바뀌는 것만 여기서 적는다. // [정보] 창은 실행 파일에 박힌 값(회사 이름 · csproj <SupportContact>)만 읽는다. 옛 설정 파일에 남은 두 키는 읽을 때 무시된다.
[Category("프로그램 정보")]
[DisplayName("만든 사람 (비우면 실행 파일의 회사 이름)")]
public string ProgramAuthor { get; set; } = string.Empty;
[Category("프로그램 정보")]
[DisplayName("사내 문의처 (담당자 · 연락처 한 줄)")]
public string SupportContact { get; set; } = string.Empty;
[Category("저장소")] [Category("저장소")]
[DisplayName("기본 저장소 경로")] [DisplayName("기본 저장소 경로")]
@ -36,16 +29,6 @@ namespace MergeHub
[DisplayName("기본 오른쪽 파일")] [DisplayName("기본 오른쪽 파일")]
public string DefaultRightFile { get; set; } = string.Empty; 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")] [Category("사내 Gitea")]
[DisplayName("서버 주소")] [DisplayName("서버 주소")]
public string GiteaUrl { get; set; } = string.Empty; public string GiteaUrl { get; set; } = string.Empty;
@ -135,7 +118,7 @@ namespace MergeHub
public bool IgnoreMergeNotes { get; set; } = true; public bool IgnoreMergeNotes { get; set; } = true;
[Category("머지 주석")] [Category("머지 주석")]
[DisplayName("작성자 (비우면 git config user.name)")] [DisplayName("작성자 (주석을 남기면 꼭 적습니다 - 비어 있으면 반영하지 않고 알립니다)")]
public string MergeCommentAuthor { get; set; } = string.Empty; public string MergeCommentAuthor { get; set; } = string.Empty;
/// <summary> /// <summary>

View File

@ -101,6 +101,42 @@ namespace MergeHub.Models
private readonly Func<string, string>? _normalize; private readonly Func<string, string>? _normalize;
/// <summary>
/// 재정렬 뒤 이 줄 객체를 그대로 다시 써도 되는가(<see cref="MergeLineCollection.Patch"/>, 2026-09-30).
/// <b>번호(BASE · LOCAL) 말고 모든 값</b>이 같아야 한다 - 글 · 있음/없음 · 단어 차이 · 반영/직접 수정/주석/무시 표시 ·
/// 옮김 · 책갈피 · 블록 주석 이어받기 · 3-Way 칸 · 비교 규칙(정규화). 행 높이는 글이 같으면 같으므로 옛 값을 쓴다.
/// </summary>
public bool SameStateExceptNumbers(MergeLine o) =>
Equals(_normalize, o._normalize) &&
HasBase == o.HasBase && HasLocal == o.HasLocal &&
string.Equals(BaseText, o.BaseText, StringComparison.Ordinal) &&
string.Equals(LocalText, o.LocalText, StringComparison.Ordinal) &&
SameWords(BaseWords, o.BaseWords) && SameWords(LocalWords, o.LocalWords) && SameWords(RemoteWords, o.RemoteWords) &&
IsApplied == o.IsApplied && IsManuallyEdited == o.IsManuallyEdited && IsMergeNote == o.IsMergeNote && IsIgnored == o.IsIgnored &&
AppliedNote == o.AppliedNote && Problem == o.Problem &&
MoveHint == o.MoveHint && MoveGroup == o.MoveGroup && MoveFromBase == o.MoveFromBase &&
IsBookmarked == o.IsBookmarked &&
BaseInComment == o.BaseInComment && LocalInComment == o.LocalInComment && RemoteInComment == o.RemoteInComment &&
IsThreeWay == o.IsThreeWay && HasRemote == o.HasRemote && RemoteNumber == o.RemoteNumber &&
string.Equals(RemoteText, o.RemoteText, StringComparison.Ordinal) &&
HasOriginalLocal == o.HasOriginalLocal && OriginalLocalNumber == o.OriginalLocalNumber &&
string.Equals(OriginalLocalText, o.OriginalLocalText, StringComparison.Ordinal);
/// <summary>번호만 새 정렬 결과로. 나머지는 <see cref="SameStateExceptNumbers"/> 로 같다는 것을 확인한 뒤에만 부른다.</summary>
public void TakeNumbersFrom(MergeLine o)
{
BaseNumber = o.BaseNumber;
LocalNumber = o.LocalNumber;
}
private static bool SameWords(IReadOnlyList<WordDiffSegment>? a, IReadOnlyList<WordDiffSegment>? b)
{
if (ReferenceEquals(a, b)) return true;
if (a is null || b is null || a.Count != b.Count) return false;
for (var i = 0; i < a.Count; i++) if (!Equals(a[i], b[i])) return false;
return true;
}
/// <summary> /// <summary>
/// 무시 규칙까지 감안해 두 줄이 같은지. 사용자가 LOCAL 을 고쳐도 그때그때 다시 따진다. /// 무시 규칙까지 감안해 두 줄이 같은지. 사용자가 LOCAL 을 고쳐도 그때그때 다시 따진다.
/// </summary> /// </summary>

View File

@ -0,0 +1,70 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
namespace MergeHub.Models
{
/// <summary>
/// 비교 화면의 정렬 행 목록. <see cref="ReplaceAll"/> 로 <b>통째로 갈아 끼우고 알림은 한 번</b>만 보낸다.
///
/// <para>
/// 편집기에서 엔터를 치거나 손을 멈출 때마다 줄을 다시 정렬하는데, 예전에는 <c>Clear</c> 뒤 한 줄씩 <c>Add</c> 해서
/// 1,800줄이면 "하나 추가됨" 알림이 1,800번 가고 칸 목록 넷이 알림마다 반응했다. 재서 보니 재정렬 한 번 0.37초 가운데
/// <b>그 알림들이 0.33초</b>였다(파일 비교 자체는 0.01초) - 엔터마다 화면이 0.4초씩 멈춰 "글자가 뒤따라오는" 까닭(2026-09-30).
/// </para>
/// </summary>
public sealed class MergeLineCollection : ObservableCollection<MergeLine>
{
/// <summary>가운데(바뀐 줄) 이보다 많으면 줄마다 빼고 넣지 않고 통째로 갈아 끼운다 - 알림이 줄 수만큼 가면 도로 느려진다.</summary>
public const int PatchLimit = 200;
/// <summary>
/// 새 정렬 결과를 넣되, <b>번호 말고는 모든 값이 같은 줄</b>은 기존 객체를 그대로 두고 번호만 고친다(2026-09-30).
///
/// <para>
/// 통째로 갈아 끼우면(<see cref="ReplaceAll"/>) 줄 객체가 모두 새것이라 칸 목록이 보이는 줄 요소를 전부 다시 만든다 -
/// 재정렬 뒤 화면이 다시 그려지기까지 0.15초 가운데 0.12초가 그것이었다(색칠은 0.02초). 엔터를 치면 위 · 아래 줄은
/// 그대로이고 끼운 자리 아래는 번호만 밀리므로, 앞 · 뒤로 같은 줄을 걸러 내고 <b>가운데만</b> 빼고 넣는다.
/// 값이 하나라도 다른 줄(반영 표시 · 단어 차이 · 규칙)은 새 객체로 바꾼다 - 옛 객체에 새 값을 옮겨 적다 하나라도 빠뜨리면 틀린 색이 남는다.
/// </para>
/// </summary>
public void Patch(IList<MergeLine> rows)
{
var oldCount = Count;
var newCount = rows.Count;
var head = 0;
while (head < oldCount && head < newCount && this[head].SameStateExceptNumbers(rows[head])) head++;
var tail = 0;
while (tail < oldCount - head && tail < newCount - head &&
this[oldCount - 1 - tail].SameStateExceptNumbers(rows[newCount - 1 - tail])) tail++;
var oldMiddle = oldCount - head - tail;
var newMiddle = newCount - head - tail;
if (oldMiddle + newMiddle > PatchLimit)
{
ReplaceAll(rows);
return;
}
for (var i = 0; i < head; i++) this[i].TakeNumbersFrom(rows[i]);
for (var k = 0; k < tail; k++) this[oldCount - 1 - k].TakeNumbersFrom(rows[newCount - 1 - k]);
for (var i = 0; i < oldMiddle; i++) RemoveAt(head);
for (var i = 0; i < newMiddle; i++) Insert(head + i, rows[head + i]);
}
public void ReplaceAll(IEnumerable<MergeLine> rows)
{
CheckReentrancy();
Items.Clear();
foreach (var row in rows) Items.Add(row);
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
}
}

View File

@ -182,6 +182,59 @@ namespace MergeHub.Services
public DocTabState? Stashed(string left, string right) => public DocTabState? Stashed(string left, string right) =>
_attic.TryGetValue(KeyOf(left, right), out var state) ? state : null; _attic.TryGetValue(KeyOf(left, right), out var state) ? state : null;
/// <summary>보관함에 든 짝 하나(12차 E-1 이 파일로 옮겨 적을 때 쓴다).</summary>
public sealed class StashedEdit
{
public StashedEdit(string left, string right, DocTabState state)
{
Left = left;
Right = right;
State = state;
}
public string Left { get; }
public string Right { get; }
public DocTabState State { get; }
}
/// <summary>
/// 보관함에 든 것 전부(오래된 것부터). 탭에서 내려온 편집이라 <b>지금 열려 있지 않은</b> 짝들이다 -
/// 열려 있는 탭의 것은 화면이 들고 있으므로 부르는 쪽이 따로 담는다.
/// </summary>
public IReadOnlyList<StashedEdit> AllStashed()
{
var list = new List<StashedEdit>();
foreach (var key in _atticOrder)
{
if (!_attic.TryGetValue(key, out var state)) continue;
var at = key.IndexOf('\0');
if (at < 0) continue;
list.Add(new StashedEdit(key.Substring(0, at), key.Substring(at + 1), state));
}
return list;
}
/// <summary>
/// 보관함에 곧바로 넣는다(12차 E-1 이 켤 때 파일에서 읽어 되돌려 놓는 길).
/// 그 뒤 <see cref="Open"/> 으로 열면 그 편집이 탭에 실린다.
/// </summary>
public void Keep(string left, string right, DocTabState? state)
{
if (state is null || !state.HasEdits) return;
var key = KeyOf(left, right);
if (!_attic.ContainsKey(key)) _atticOrder.Add(key);
_attic[key] = state;
while (_atticOrder.Count > AtticMax)
{
var oldest = _atticOrder[0];
_atticOrder.RemoveAt(0);
_attic.Remove(oldest);
}
}
/// <summary>탭이 사라질 때 그 편집을 보관함으로 옮긴다. 고친 것이 없으면 보관하지 않고 지운다.</summary> /// <summary>탭이 사라질 때 그 편집을 보관함으로 옮긴다. 고친 것이 없으면 보관하지 않고 지운다.</summary>
private void Stash(DocTab tab) private void Stash(DocTab tab)
{ {

View File

@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
namespace MergeHub.Services
{
/// <summary>
/// 옵션 서랍에 넣어 둔 것 가운데 <b>지금 켜 둔 것</b>을 한 줄에 짧게 적는다(12차 D-1).
///
/// 서랍에 넣으면 자리는 아끼지만 "내가 뭘 켜 뒀더라" 를 알 수 없게 된다 -
/// 특히 제외 규칙은 켜 둔 줄 모르고 "왜 이 파일이 안 보이지" 로 이어진다.
/// 그래서 <b>기본과 다른 것만</b> 짧게 적는다. 기본 그대로면 아무것도 적지 않는다(눈에 띄어야 뜻이 있다).
/// </summary>
public static class FolderOptionText
{
/// <summary>기본값 - 이것과 같으면 적지 않는다.</summary>
public const bool DefaultQuick = false;
public const bool DefaultSubfolders = true;
/// <param name="filterName">보기 거르개 이름. 기본("다른 것만")이면 적지 않는다.</param>
public static string Of(bool quick, bool subfolders, string? filterName,
string? include, string? exclude)
{
var parts = new List<string>();
if (quick != DefaultQuick) parts.Add("빠른 비교");
// 하위 폴더는 켜 두는 것이 기본이라, <b>끈 것</b>이 알릴 일이다.
if (subfolders != DefaultSubfolders) parts.Add("이 폴더만");
if (!string.IsNullOrWhiteSpace(filterName) && filterName != "다른 것만") parts.Add(filterName!);
var rules = 0;
if (!string.IsNullOrWhiteSpace(include)) rules++;
if (!string.IsNullOrWhiteSpace(exclude)) rules++;
if (rules == 1) parts.Add("규칙 1");
else if (rules == 2) parts.Add("규칙 2");
return parts.Count == 0 ? string.Empty : string.Join(" · ", parts);
}
/// <summary>적을 것이 있는가. 없으면 화면이 그 자리를 아예 내주지 않는다.</summary>
public static bool Any(bool quick, bool subfolders, string? filterName, string? include, string? exclude) =>
Of(quick, subfolders, filterName, include, exclude).Length > 0;
}
}

View File

@ -86,6 +86,12 @@ namespace MergeHub.Services
public HashSet<string> TypeKeywords { get; } public HashSet<string> TypeKeywords { get; }
public bool HasBlockComment => BlockStart is { Length: > 0 } && BlockEnd is { Length: > 0 }; 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> /// <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

@ -0,0 +1,459 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
namespace MergeHub.Services
{
/// <summary>
/// 메모 한 장(12차 F-1). 이름 없는 메모이거나, 파일로 연 메모다.
///
/// 문서 탭(12차 E-2)과 달리 <b>글을 스스로 들고 있다</b> - 메모는 원래 작은 글이고,
/// 이름 없는 메모에는 다시 읽어 올 파일 자체가 없다.
/// </summary>
public sealed class Memo : INotifyPropertyChanged
{
/// <summary>이름 없는 메모의 번호(<c>메모 3</c>). 파일로 연 메모는 0.</summary>
public int Number { get; internal set; }
private string? _path;
/// <summary>파일로 연 메모의 경로. 이름 없는 메모는 null.</summary>
public string? Path
{
get => _path;
internal set
{
if (!Set(ref _path, value)) return;
Raise(nameof(Title));
Raise(nameof(Language)); // 다른 이름으로 저장하면 확장자가 바뀐다 - 고르지 않았으면 따라간다(G-8)
}
}
/// <summary>파일에 매인 메모인가.</summary>
public bool HasFile => !string.IsNullOrWhiteSpace(Path);
private string _title = string.Empty;
/// <summary>탭 줄에 그릴 이름. 파일 이름이거나 <c>메모 N</c>(사용자 결정 2026-09-28).</summary>
public string Title
{
get => _title;
internal set => Set(ref _title, value);
}
private string _text = string.Empty;
public string Text
{
get => _text;
set => Set(ref _text, value);
}
private bool _isDirty;
/// <summary>저장하지 않은 것이 있는가. 탭 이름 앞 가운뎃점에 쓴다(E-2 와 같은 표시).</summary>
public bool IsDirty
{
get => _isDirty;
set => Set(ref _isDirty, value);
}
private bool _isCurrent;
public bool IsCurrent
{
get => _isCurrent;
internal set => Set(ref _isCurrent, value);
}
/// <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>
internal long Touched { get; set; }
public event PropertyChangedEventHandler? PropertyChanged;
private bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
Raise(name);
return true;
}
private void Raise(string? name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name!));
}
/// <summary>
/// 메모 여러 장(12차 F-1). 계산만 한다 - 파일을 읽고 쓰는 것은 <see cref="MemoStore"/> 가,
/// 화면과 잇는 것은 ViewModel 이 한다.
///
/// <para>
/// 이름 짓기는 사용자가 정했다 - 이름 없는 메모는 <b>메모 1 · 메모 2</b> 로만(첫 줄을 따오지 않는다).
/// 글을 고칠 때마다 이름이 바뀌면 탭 줄이 요동친다.
/// 번호는 <b>비어 있는 가장 작은 수</b>를 준다 - 메모 1 을 닫고 새로 만들면 다시 메모 1 이다.
/// </para>
///
/// <para>
/// 파일로 연 메모와 이름 없는 메모는 <b>한 탭 줄에 섞어</b> 둔다(notepad++ 와 같다).
/// </para>
/// </summary>
public sealed class MemoBook
{
private readonly List<Memo> _memos = new List<Memo>();
private long _clock;
public IReadOnlyList<Memo> Memos => _memos;
public int Count => _memos.Count;
public Memo? Current { get; private set; }
public int CurrentIndex => Current is null ? -1 : _memos.IndexOf(Current);
/// <summary>메모가 있어야 편집기를 보여 준다.</summary>
public bool Any => _memos.Count > 0;
/// <summary>새 메모 한 장. 번호는 비어 있는 가장 작은 수.</summary>
public Memo Add(string text = "")
{
var memo = new Memo { Number = FreeNumber(), Text = text ?? string.Empty };
_memos.Add(memo);
Retitle();
Activate(memo);
return memo;
}
/// <summary>
/// 파일로 연다. 같은 파일이 이미 있으면 새로 만들지 않고 그 장을 띄운다 -
/// 같은 파일이 두 장에 있으면 어느 쪽이 맞는지 알 수 없다.
/// </summary>
public Memo Open(string path, string text)
{
var found = Find(path);
if (found is not null)
{
Activate(found);
return found;
}
var memo = new Memo { Path = path, Text = text ?? string.Empty };
_memos.Add(memo);
Retitle();
Activate(memo);
return memo;
}
public Memo? Find(string? path) =>
string.IsNullOrWhiteSpace(path)
? null
: _memos.FirstOrDefault(m => string.Equals(m.Path, path, StringComparison.OrdinalIgnoreCase));
/// <summary>이름 없는 메모에 파일 이름을 붙인다(다른 이름으로 저장).</summary>
public void Attach(Memo memo, string path)
{
if (memo is null || string.IsNullOrWhiteSpace(path)) return;
memo.Path = path;
memo.Number = 0;
Retitle();
}
public void Activate(Memo memo)
{
if (memo is null || !_memos.Contains(memo)) return;
Current = memo;
memo.Touched = ++_clock;
foreach (var other in _memos) other.IsCurrent = ReferenceEquals(other, memo);
}
public void Activate(int index)
{
if (index < 0 || index >= _memos.Count) return;
Activate(_memos[index]);
}
/// <summary>닫는다. 닫은 자리의 오른쪽으로 옮겨 가고, 오른쪽이 없으면 왼쪽으로(E-2 와 같다).</summary>
public Memo? Close(Memo memo)
{
var at = _memos.IndexOf(memo);
if (at < 0) return Current;
_memos.RemoveAt(at);
memo.IsCurrent = false;
Retitle();
if (!ReferenceEquals(Current, memo)) return Current;
if (_memos.Count == 0) { Current = null; return null; }
Activate(_memos[Math.Min(at, _memos.Count - 1)]);
return Current;
}
public Memo? Next() => Step(1);
public Memo? Previous() => Step(-1);
private Memo? Step(int by)
{
if (_memos.Count == 0) return null;
var at = CurrentIndex;
if (at < 0) { Activate(_memos[0]); return Current; }
var next = (at + by) % _memos.Count;
if (next < 0) next += _memos.Count;
Activate(_memos[next]);
return Current;
}
public void Clear()
{
foreach (var memo in _memos) memo.IsCurrent = false;
_memos.Clear();
Current = null;
}
/// <summary>되살릴 때 쓴다 - 이름 짓기와 번호를 건드리지 않고 그대로 담는다.</summary>
public Memo Adopt(Memo memo)
{
_memos.Add(memo);
Retitle();
return memo;
}
/// <summary>비어 있는 가장 작은 번호. 메모 1 을 닫고 새로 만들면 다시 메모 1 이다.</summary>
private int FreeNumber()
{
var used = new HashSet<int>(_memos.Where(m => m.Number > 0).Select(m => m.Number));
var n = 1;
while (used.Contains(n)) n++;
return n;
}
/// <summary>이름을 다시 짓는다. 파일 이름이 겹치면 갈릴 때까지 위 폴더를 한 겹씩 덧붙인다(E-2 와 같은 규칙).</summary>
private void Retitle()
{
foreach (var memo in _memos)
memo.Title = memo.HasFile ? DocTabs.Tail(memo.Path!, 0) : $"메모 {memo.Number}";
for (var depth = 1; depth <= 6; depth++)
{
var clashed = _memos.Where(m => m.HasFile)
.GroupBy(m => m.Title, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() > 1)
.SelectMany(g => g)
.ToList();
if (clashed.Count == 0) return;
var moved = false;
foreach (var memo in clashed)
{
var wider = DocTabs.Tail(memo.Path!, depth);
if (string.Equals(wider, memo.Title, StringComparison.Ordinal)) continue;
memo.Title = wider;
moved = true;
}
if (!moved) return;
}
}
/// <summary>탭 줄에 그릴 글. 저장하지 않은 것은 앞에 가운뎃점(E-2 와 같은 표시).</summary>
public static string Mark(Memo memo) => memo.IsDirty ? "• " + memo.Title : memo.Title;
}
/// <summary>
/// 메모를 파일로 보관한다(12차 F-1). <b>저장 단추 없이 계속</b> 남는 것이 사용자 결정이라,
/// 이름 없는 메모도 여기에 그대로 적힌다.
///
/// <para>
/// 자리는 <c>{설정 폴더}\Memo\</c> - 12차 E-1 의 <c>Session\</c> 과 <b>따로</b> 둔다.
/// 저쪽은 "저장하지 않은 편집" 이라 저장하면 사라지는 것이고, 메모는 <b>지우기 전까지 남는</b> 것이다.
/// 모양은 같다(목록 한 장 + 글마다 파일) - 같은 까닭에서다.
/// </para>
/// </summary>
public sealed class MemoStore
{
private const string Header = "# MergeHub 메모 (12차 F-1)";
private const int Version = 1;
public const string ListFileName = "목록.txt";
private readonly object _gate = new object();
public MemoStore(string root)
{
Root = root ?? string.Empty;
}
public string Root { get; }
public static string DefaultRoot(string configRoot) => Path.Combine(configRoot, "Memo");
public string ListPath => Path.Combine(Root, ListFileName);
public static string BodyName(int index) =>
string.Format(CultureInfo.InvariantCulture, "{0:00}.txt", index + 1);
/// <summary>보관한 메모 한 장(글은 따로 파일).</summary>
public sealed class Saved
{
public int Number { get; set; }
public string? Path { get; set; }
public bool Dirty { get; set; }
public int CaretIndex { get; set; }
public string Text { get; set; } = string.Empty;
}
public static string Serialize(IReadOnlyList<Saved> memos, int current)
{
var text = new StringBuilder();
text.AppendLine(Header);
text.AppendLine(string.Join("\t", "V",
Version.ToString(CultureInfo.InvariantCulture),
current.ToString(CultureInfo.InvariantCulture)));
for (var i = 0; i < memos.Count; i++)
{
var memo = memos[i];
text.AppendLine(string.Join("\t",
"M",
i.ToString(CultureInfo.InvariantCulture),
memo.Number.ToString(CultureInfo.InvariantCulture),
memo.Dirty ? "D" : "-",
memo.CaretIndex.ToString(CultureInfo.InvariantCulture),
string.IsNullOrEmpty(memo.Path) ? "-" : memo.Path!));
}
return text.ToString();
}
/// <summary>알아볼 수 없는 줄은 조용히 건너뛴다. 글은 채우지 않는다(부르는 쪽이 파일로 읽는다).</summary>
public static (List<Saved> Memos, int Current) Parse(string? content)
{
var list = new List<Saved>();
var current = -1;
if (string.IsNullOrWhiteSpace(content)) return (list, current);
foreach (var raw in content!.Replace("\r\n", "\n").Split('\n'))
{
var line = raw.TrimEnd();
if (line.Length == 0 || line.StartsWith("#", StringComparison.Ordinal)) continue;
var parts = line.Split('\t');
if (parts[0] == "V")
{
if (parts.Length >= 3 && int.TryParse(parts[2], out var at)) current = at;
continue;
}
if (parts[0] != "M" || parts.Length < 6) continue;
list.Add(new Saved
{
Number = Number(parts[2]),
Dirty = parts[3] == "D",
CaretIndex = Number(parts[4]),
Path = parts[5] == "-" ? null : parts[5]
});
}
if (current >= list.Count) current = list.Count - 1;
return (list, current);
}
private static int Number(string field) =>
int.TryParse(field, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) ? n : 0;
public void Save(IReadOnlyList<Saved> memos, int current)
{
lock (_gate)
{
try
{
if (memos.Count == 0) { ClearCore(); return; }
if (!Directory.Exists(Root)) Directory.CreateDirectory(Root);
foreach (var old in Directory.EnumerateFiles(Root, "*.txt"))
{
if (!string.Equals(Path.GetFileName(old), ListFileName, StringComparison.OrdinalIgnoreCase))
File.Delete(old);
}
var plain = new UTF8Encoding(false);
for (var i = 0; i < memos.Count; i++)
File.WriteAllText(Path.Combine(Root, BodyName(i)), memos[i].Text ?? string.Empty, plain);
// 목록은 맨 나중에 - 목록이 있다는 것이 곧 글 파일이 다 있다는 뜻이다.
File.WriteAllText(ListPath, Serialize(memos, current), new UTF8Encoding(true));
}
catch (IOException) { }
catch (UnauthorizedAccessException) { }
}
}
public (List<Saved> Memos, int Current) Load()
{
lock (_gate)
{
try
{
if (!File.Exists(ListPath)) return (new List<Saved>(), -1);
var (list, current) = Parse(File.ReadAllText(ListPath, Encoding.UTF8));
for (var i = 0; i < list.Count; i++)
{
var body = Path.Combine(Root, BodyName(i));
try { list[i].Text = File.Exists(body) ? File.ReadAllText(body, Encoding.UTF8) : string.Empty; }
catch (IOException) { list[i].Text = string.Empty; }
}
return (list, current);
}
catch (IOException) { return (new List<Saved>(), -1); }
catch (UnauthorizedAccessException) { return (new List<Saved>(), -1); }
}
}
public void Clear()
{
lock (_gate) { ClearCore(); }
}
private void ClearCore()
{
try
{
if (!Directory.Exists(Root)) return;
foreach (var file in Directory.EnumerateFiles(Root, "*.txt")) File.Delete(file);
}
catch (IOException) { }
catch (UnauthorizedAccessException) { }
}
}
}

View File

@ -31,6 +31,35 @@ namespace MergeHub.Services
public static string AuthorOf(string? author) => public static string AuthorOf(string? author) =>
string.IsNullOrWhiteSpace(author) ? Environment.UserName : author!.Trim(); string.IsNullOrWhiteSpace(author) ? Environment.UserName : author!.Trim();
/// <summary>
/// 줄을 지우는 반영의 표시(사용자 결정 2026-09-30) - 한 줄이면 "삭제", 여러 줄이면 한 번만 "삭제 N줄".
/// 지운 자리에 남는 주석이 보통 반영과 똑같으면 무엇이 지워졌는지 모른다. 지운 게 없으면 빈 글.
/// </summary>
public static string DeletionTag(int deletedLines) =>
deletedLines <= 0 ? string.Empty : deletedLines == 1 ? "삭제" : $"삭제 {deletedLines}줄";
/// <summary>출처 뒤에 삭제 표시를 붙인다. 주석 모양은 <c>[사람] 날짜 출처 삭제 [설명]</c>.</summary>
public static string WithDeletion(string source, int deletedLines)
{
var tag = DeletionTag(deletedLines);
return tag.Length == 0 ? source : $"{source} {tag}";
}
/// <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) => public static string Build(string? author, DateTime when, string? comment, string? path) =>
Build(author, when, source: null, comment, path); Build(author, when, source: null, comment, path);

View File

@ -77,6 +77,23 @@ namespace MergeHub.Services
return string.Empty; return string.Empty;
} }
/// <summary>
/// Git 탭에서 열 경로 - [폴더...] 로 고른 것이나 탭에 끌어다 놓은 것(2026-09-30 사용자 요청)에 같은 규칙을 쓴다.
/// 파일이면 그 파일이 든 폴더, 저장소 안이면 최상위. 저장소가 아니면 그 폴더 그대로 돌려준다 -
/// 거기서 [열기] 가 "저장소가 아니다" 라고 알린다(말없이 무시하면 놓은 사람이 무슨 일인지 모른다).
/// 없는 경로면 빈 문자열.
/// </summary>
public static string RepositoryFolderFor(string? path)
{
var folder = FullFolderPath(path);
if (folder.Length == 0) folder = FolderOfFile(path);
if (folder.Length == 0) return string.Empty;
var root = FindRepositoryRoot(folder);
return root.Length > 0 ? root : folder;
}
/// <summary>파일 경로가 든 폴더를 정규화해서 돌려준다. 확인할 수 없으면 빈 문자열.</summary> /// <summary>파일 경로가 든 폴더를 정규화해서 돌려준다. 확인할 수 없으면 빈 문자열.</summary>
public static string FolderOfFile(string? filePath) public static string FolderOfFile(string? filePath)
{ {

View File

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

@ -0,0 +1,350 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
namespace MergeHub.Services
{
/// <summary>
/// 파일 하나가 그때 어떤 모습이었는지(길이 · 마지막으로 쓴 때). 되살릴 때
/// <b>그 사이 원본이 바뀌었는지</b>를 이것으로 가른다 - 전문을 또 담아 두지 않아도 된다.
/// </summary>
public readonly struct FileStamp
{
public FileStamp(long length, long ticks)
{
Length = length;
Ticks = ticks;
}
public long Length { get; }
public long Ticks { get; }
/// <summary>파일이 없었으면 둘 다 -1.</summary>
public static readonly FileStamp None = new FileStamp(-1, -1);
public bool IsNone => Length < 0 && Ticks < 0;
public static FileStamp Of(string? path)
{
if (string.IsNullOrWhiteSpace(path)) return None;
try
{
var info = new FileInfo(path);
return info.Exists ? new FileStamp(info.Length, info.LastWriteTimeUtc.Ticks) : None;
}
catch (IOException) { return None; }
catch (UnauthorizedAccessException) { return None; }
catch (ArgumentException) { return None; }
}
/// <summary>지금 파일이 그때와 같은가.</summary>
public bool Matches(string? path) => Same(Of(path));
public bool Same(FileStamp other) => Length == other.Length && Ticks == other.Ticks;
}
/// <summary>보관해 둔 짝 하나(12차 E-1).</summary>
public sealed class SessionTab
{
public string Left { get; set; } = string.Empty;
public string Right { get; set; } = string.Empty;
/// <summary>탭으로 열려 있었는가. false 면 닫혔지만 글이 남은 것(E-2 보관함).</summary>
public bool Open { get; set; }
/// <summary>저장하지 않은 오른쪽(LOCAL) 글. null 이면 파일 그대로였다.</summary>
public string? Text { get; set; }
/// <summary>저장하지 않은 왼쪽(BASE) 글(12차 B-1).</summary>
public string? LeftText { get; set; }
public int CaretIndex { get; set; }
public int TopRow { get; set; }
public string? SaveEncoding { get; set; }
public string? SaveNewLine { get; set; }
public FileStamp RightStamp { get; set; } = FileStamp.None;
public FileStamp LeftStamp { get; set; } = FileStamp.None;
/// <summary>되살릴 글이 있는가. 없으면 보관할 까닭도 없다.</summary>
public bool HasEdits => Text is not null || LeftText is not null;
/// <summary>그 사이 원본이 바뀌었는가(둘 중 하나라도).</summary>
public bool FilesChanged =>
!RightStamp.Matches(Right) || !LeftStamp.Matches(Left);
}
/// <summary>껐을 때 남아 있던 것 한 벌.</summary>
public sealed class SessionSnapshot
{
/// <summary>보고 있던 탭의 자리(0부터). 열린 탭이 없으면 -1.</summary>
public int Current { get; set; } = -1;
public List<SessionTab> Tabs { get; } = new List<SessionTab>();
public IEnumerable<SessionTab> OpenTabs => Tabs.Where(t => t.Open);
}
/// <summary>
/// 저장하지 않은 편집을 껐다 켜도 남게 보관한다(12차 E-1, notepad++ 식).
///
/// <para>
/// <b>어디에</b> - <c>{설정 폴더}\Session\MergeHub\</c>. 11차 F-9 백업과 다른 자리다.
/// 백업은 "저장하기 <b>전</b> 파일", 이것은 "<b>저장하지 않은</b> 편집" 이라 뜻도 수명도 다르다.
/// </para>
///
/// <para>
/// <b>어떤 모양으로</b> - 차례와 자리는 <c>목록.txt</c> 에 탭으로 나눈 글로, 편집한 글은 짝마다
/// 따로 파일로 둔다. JSON 을 쓰지 않는 까닭은 최근 목록(<see cref="RecentPairs"/>)과 같다 -
/// 사람이 열어 보고 손으로 고칠 수 있고, 무엇보다 <b>글 안에 무엇이 들어 있든</b>(따옴표 · 줄바꿈 ·
/// 제어 문자) 파일을 따로 두면 탈이 없다.
/// </para>
///
/// <para>
/// <b>원본 글은 담지 않는다</b> - 파일에서 다시 읽으면 되고, 담아 두면 그 사이 파일이 바뀌었을 때
/// 낡은 것을 보여 준다. 대신 그때 파일의 <see cref="FileStamp"/> 만 적어 두고, 다를 때 알린다.
/// </para>
///
/// <para>
/// <b>못 읽거나 못 써도 조용히 넘어간다</b> - 보관은 거들어 주는 기능이지, 이것 때문에
/// 프로그램이 안 켜지거나 저장이 막히면 안 된다.
/// </para>
/// </summary>
public sealed class SessionStore
{
private const string Header = "# MergeHub 저장하지 않은 편집 (12차 E-1)";
private const int Version = 1;
/// <summary>차례 · 자리를 적는 파일.</summary>
public const string ListFileName = "목록.txt";
private readonly object _gate = new object();
public SessionStore(string root)
{
Root = root ?? string.Empty;
}
public string Root { get; }
/// <summary>설정 폴더 아래 보관 자리.</summary>
public static string DefaultRoot(string configRoot) => Path.Combine(configRoot, "Session", "MergeHub");
public string ListPath => Path.Combine(Root, ListFileName);
/// <summary>짝 하나의 글을 담는 파일 이름. 자리 번호로 가른다.</summary>
public static string BodyName(int index, bool left) =>
string.Format(CultureInfo.InvariantCulture, "{0:00}-{1}.txt", index + 1, left ? "왼쪽" : "오른쪽");
// ================= 글로 옮기기(검사할 수 있게 떼어 둔다) =================
/// <summary>목록 파일의 글. 글 본문은 여기 들어가지 않는다(짝마다 따로 파일).</summary>
public static string Serialize(SessionSnapshot snapshot)
{
var text = new StringBuilder();
text.AppendLine(Header);
text.AppendLine(string.Join("\t", "V",
Version.ToString(CultureInfo.InvariantCulture),
snapshot.Current.ToString(CultureInfo.InvariantCulture)));
for (var i = 0; i < snapshot.Tabs.Count; i++)
{
var tab = snapshot.Tabs[i];
text.AppendLine(string.Join("\t",
"T",
i.ToString(CultureInfo.InvariantCulture),
tab.Open ? "O" : "C",
tab.Text is null ? "-" : BodyName(i, left: false),
tab.LeftText is null ? "-" : BodyName(i, left: true),
tab.CaretIndex.ToString(CultureInfo.InvariantCulture),
tab.TopRow.ToString(CultureInfo.InvariantCulture),
Field(tab.SaveEncoding),
Field(tab.SaveNewLine),
tab.RightStamp.Length.ToString(CultureInfo.InvariantCulture),
tab.RightStamp.Ticks.ToString(CultureInfo.InvariantCulture),
tab.LeftStamp.Length.ToString(CultureInfo.InvariantCulture),
tab.LeftStamp.Ticks.ToString(CultureInfo.InvariantCulture),
tab.Left,
tab.Right));
}
return text.ToString();
}
/// <summary>
/// 목록 글을 되읽는다. <b>알아볼 수 없는 줄은 조용히 건너뛴다</b> -
/// 보관본 한 줄 때문에 프로그램이 안 켜지면 안 된다(최근 목록과 같은 원칙).
/// 글 본문은 채우지 않는다 - 부르는 쪽이 짝마다 파일을 읽어 넣는다.
/// </summary>
public static SessionSnapshot Parse(string? content)
{
var snapshot = new SessionSnapshot();
if (string.IsNullOrWhiteSpace(content)) return snapshot;
foreach (var raw in content!.Replace("\r\n", "\n").Split('\n'))
{
var line = raw.TrimEnd();
if (line.Length == 0 || line.StartsWith("#", StringComparison.Ordinal)) continue;
var parts = line.Split('\t');
if (parts[0] == "V")
{
if (parts.Length >= 3 && int.TryParse(parts[2], out var current)) snapshot.Current = current;
continue;
}
if (parts[0] != "T" || parts.Length < 15) continue;
snapshot.Tabs.Add(new SessionTab
{
Open = parts[2] == "O",
// 글 본문은 아직 없다. 어느 파일에 있는지는 자리 번호로 다시 만들 수 있으므로
// 여기서는 "있었는가" 만 기억해 둔다(null 이 아니면 읽을 것이 있다는 뜻).
Text = parts[3] == "-" ? null : string.Empty,
LeftText = parts[4] == "-" ? null : string.Empty,
CaretIndex = Number(parts[5]),
TopRow = Number(parts[6]),
SaveEncoding = Value(parts[7]),
SaveNewLine = Value(parts[8]),
RightStamp = new FileStamp(Long(parts[9]), Long(parts[10])),
LeftStamp = new FileStamp(Long(parts[11]), Long(parts[12])),
Left = parts[13],
Right = parts[14]
});
}
if (snapshot.Current >= snapshot.Tabs.Count) snapshot.Current = snapshot.Tabs.Count - 1;
return snapshot;
}
private static string Field(string? value) => string.IsNullOrEmpty(value) ? "-" : value!;
private static string? Value(string field) => field == "-" ? null : field;
private static int Number(string field) =>
int.TryParse(field, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) ? n : 0;
private static long Long(string field) =>
long.TryParse(field, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) ? n : -1;
// ================= 파일 =================
/// <summary>
/// 지금 것을 보관한다. 보관할 것이 하나도 없으면 자리를 <b>치운다</b> -
/// 저장하고 껐는데 다음에 켤 때 되살릴 것이 남아 있으면 안 된다.
/// </summary>
public void Save(SessionSnapshot snapshot)
{
lock (_gate)
{
try
{
var keep = snapshot.Tabs.Where(t => t.HasEdits).ToList();
if (keep.Count == 0) { ClearCore(); return; }
// 자리 번호가 글 파일 이름이 되므로, 버릴 것을 뺀 뒤의 차례로 다시 적는다.
var trimmed = new SessionSnapshot { Current = -1 };
var current = snapshot.Current >= 0 && snapshot.Current < snapshot.Tabs.Count
? snapshot.Tabs[snapshot.Current]
: null;
foreach (var tab in keep)
{
if (ReferenceEquals(tab, current)) trimmed.Current = trimmed.Tabs.Count;
trimmed.Tabs.Add(tab);
}
if (!Directory.Exists(Root)) Directory.CreateDirectory(Root);
// 남은 옛 글 파일을 먼저 치운다 - 번호가 밀리면 엉뚱한 글이 딸려 온다.
foreach (var old in Directory.EnumerateFiles(Root, "*.txt"))
{
if (!string.Equals(Path.GetFileName(old), ListFileName, StringComparison.OrdinalIgnoreCase))
File.Delete(old);
}
var plain = new UTF8Encoding(false);
for (var i = 0; i < trimmed.Tabs.Count; i++)
{
var tab = trimmed.Tabs[i];
if (tab.Text is not null)
File.WriteAllText(Path.Combine(Root, BodyName(i, left: false)), tab.Text, plain);
if (tab.LeftText is not null)
File.WriteAllText(Path.Combine(Root, BodyName(i, left: true)), tab.LeftText, plain);
}
// 목록은 <b>맨 나중에</b> 쓴다 - 여기까지 왔다는 것이 곧 글 파일이 다 있다는 뜻이다.
File.WriteAllText(ListPath, Serialize(trimmed), new UTF8Encoding(true));
}
catch (IOException) { }
catch (UnauthorizedAccessException) { }
}
}
/// <summary>보관해 둔 것을 읽는다. 없거나 못 읽으면 빈 것.</summary>
public SessionSnapshot Load()
{
lock (_gate)
{
try
{
if (!File.Exists(ListPath)) return new SessionSnapshot();
var snapshot = Parse(File.ReadAllText(ListPath, Encoding.UTF8));
for (var i = 0; i < snapshot.Tabs.Count; i++)
{
var tab = snapshot.Tabs[i];
tab.Text = ReadBody(i, left: false, had: tab.Text is not null);
tab.LeftText = ReadBody(i, left: true, had: tab.LeftText is not null);
}
// 글을 끝내 못 읽은 줄은 버린다(되살릴 것이 없다).
var gone = snapshot.Tabs.Where(t => !t.HasEdits).ToList();
foreach (var tab in gone)
{
var at = snapshot.Tabs.IndexOf(tab);
snapshot.Tabs.RemoveAt(at);
if (snapshot.Current > at) snapshot.Current--;
else if (snapshot.Current == at) snapshot.Current = -1;
}
return snapshot;
}
catch (IOException) { return new SessionSnapshot(); }
catch (UnauthorizedAccessException) { return new SessionSnapshot(); }
}
}
private string? ReadBody(int index, bool left, bool had)
{
if (!had) return null;
var path = Path.Combine(Root, BodyName(index, left));
try { return File.Exists(path) ? File.ReadAllText(path, Encoding.UTF8) : null; }
catch (IOException) { return null; }
catch (UnauthorizedAccessException) { return null; }
}
/// <summary>보관해 둔 것을 치운다(다 저장했거나 되살린 뒤).</summary>
public void Clear()
{
lock (_gate) { ClearCore(); }
}
private void ClearCore()
{
try
{
if (!Directory.Exists(Root)) return;
foreach (var file in Directory.EnumerateFiles(Root, "*.txt")) File.Delete(file);
}
catch (IOException) { }
catch (UnauthorizedAccessException) { }
}
}
}

View File

@ -24,6 +24,7 @@ namespace MergeHub.Services
NextFile, NextFile,
ToggleBookmark, ToggleBookmark,
AcceptBlock, AcceptBlock,
AcceptBlockToBase, // 12차 G-2 - 구간을 왼쪽 파일로 (G-3 에서 "덩어리" → "구간")
NextConflict, NextConflict,
PrevConflict, PrevConflict,
AutoMerge, AutoMerge,
@ -123,7 +124,13 @@ namespace MergeHub.Services
"화살표는 WinMerge 와 같이 '어느 쪽으로 복사하는가'를 가리킨다. 3-Way 에서 고칠 수 있는 칸은 " + "화살표는 WinMerge 와 같이 '어느 쪽으로 복사하는가'를 가리킨다. 3-Way 에서 고칠 수 있는 칸은 " +
"아래 MERGED 하나뿐이므로, 왼쪽 끝인 LOCAL(Mine) 을 결과로 가져오는 것이 여기서의 '오른쪽으로'다."), "아래 MERGED 하나뿐이므로, 왼쪽 끝인 LOCAL(Mine) 을 결과로 가져오는 것이 여기서의 '오른쪽으로'다."),
new ShortcutEntry(ShortcutAction.CopyRightToLeft, Key.Left, ModifierKeys.Alt, "병합·편집", new ShortcutEntry(ShortcutAction.CopyRightToLeft, Key.Left, ModifierKeys.Alt, "병합·편집",
"오른쪽 → 왼쪽 반영 (2-Way: 이 줄 되돌리기 / 3-Way: Take Theirs)"), "오른쪽 → 왼쪽 반영 (2-Way: 이 줄을 왼쪽 파일로 / 3-Way: Take Theirs)",
"12차 G-2 에 뜻을 바로잡았다. 예전에는 2-Way 에서 '이 줄 되돌리기'였는데, 이름과 화살표가 " +
"가리키는 '왼쪽으로 반영'이 12차 B-1 로 실제로 생겼다([<] 단추). 화살표는 늘 어느 쪽으로 " +
"가는가를 가리켜야 하므로 그쪽으로 넘겼다. 되돌리기는 Ctrl+Z 가 한다."),
new ShortcutEntry(ShortcutAction.AcceptBlockToBase, Key.Left, ModifierKeys.Alt | ModifierKeys.Shift, "병합·편집",
"구간 왼쪽으로 - 차이가 이어지는 구간을 한 번에 왼쪽 파일로 (2-Way)",
"오른쪽으로 보내는 Alt+Shift+→ 의 거울이다."),
new ShortcutEntry(ShortcutAction.NextConflict, Key.Down, ModifierKeys.Alt | ModifierKeys.Shift, "병합·편집", new ShortcutEntry(ShortcutAction.NextConflict, Key.Down, ModifierKeys.Alt | ModifierKeys.Shift, "병합·편집",
"다음 충돌 (3-Way)", "차이가 아니라 <아직 안 정한 충돌>만 딛는다. 자동 병합과 짝이다."), "다음 충돌 (3-Way)", "차이가 아니라 <아직 안 정한 충돌>만 딛는다. 자동 병합과 짝이다."),
@ -132,11 +139,12 @@ namespace MergeHub.Services
new ShortcutEntry(ShortcutAction.AutoMerge, Key.M, ModifierKeys.Alt, "병합·편집", new ShortcutEntry(ShortcutAction.AutoMerge, Key.M, ModifierKeys.Alt, "병합·편집",
"자동 병합 - 저쪽만 고친 줄을 한 번에 가져온다 (3-Way)"), "자동 병합 - 저쪽만 고친 줄을 한 번에 가져온다 (3-Way)"),
new ShortcutEntry(ShortcutAction.AcceptBlock, Key.Right, ModifierKeys.Alt | ModifierKeys.Shift, "병합·편집", new ShortcutEntry(ShortcutAction.AcceptBlock, Key.Right, ModifierKeys.Alt | ModifierKeys.Shift, "병합·편집",
"이 덩어리 반영 - 차이가 이어지는 구간을 한 번에", "구간 반영 - 차이가 이어지는 구간을 한 번에",
"줄 하나씩만 되면 연속 다섯 줄을 다섯 번 눌러야 한다."), "줄 하나씩만 되면 연속 다섯 줄을 다섯 번 눌러야 한다."),
new ShortcutEntry(ShortcutAction.Undo, Key.Z, ModifierKeys.Control, "병합·편집", "실행 취소", new ShortcutEntry(ShortcutAction.Undo, Key.Z, ModifierKeys.Control, "병합·편집", "실행 취소",
"머지 반영을 되돌린다. 되돌릴 머지 작업이 없으면 편집기의 글자 되돌리기가 그대로 동작한다."), "머지 반영과 편집기에 친 글을 함께 되돌린다. 한 걸음은 <타이핑이 멈춘 자리>다(12차 G-1). " +
"12차 G-2 부터 '이 줄만 되돌리기'도 이 키가 맡는다 - Alt+← 는 왼쪽으로 반영하는 자리가 됐다."),
new ShortcutEntry(ShortcutAction.Redo, Key.Y, ModifierKeys.Control, "병합·편집", "다시 실행"), new ShortcutEntry(ShortcutAction.Redo, Key.Y, ModifierKeys.Control, "병합·편집", "다시 실행"),
new ShortcutEntry(ShortcutAction.ToggleBookmark, Key.M, ModifierKeys.Control, "이동·북마크", "지금 줄 북마크 설정/해제"), new ShortcutEntry(ShortcutAction.ToggleBookmark, Key.M, ModifierKeys.Control, "이동·북마크", "지금 줄 북마크 설정/해제"),

View File

@ -56,11 +56,23 @@ namespace MergeHub.Services
/// <summary>저장할 때 고를 수 있는 인코딩. 빈 값(원본 유지)이 기본이다.</summary> /// <summary>저장할 때 고를 수 있는 인코딩. 빈 값(원본 유지)이 기본이다.</summary>
public const string KeepOriginal = "원본 유지"; public const string KeepOriginal = "원본 유지";
public static IReadOnlyList<string> SaveEncodings { get; } = /// <summary>
new[] { KeepOriginal, "UTF-8", "UTF-8 (BOM)", "CP949" }; /// 저장 형식 콤보의 칸들(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; } = public static IReadOnlyList<SaveChoice> SaveNewLineChoices { get; } = new[]
new[] { KeepOriginal, "CRLF", "LF" }; {
new SaveChoice(KeepOriginal, "원본 줄바꿈 유지"),
new SaveChoice("CRLF"), new SaveChoice("LF"),
};
/// <summary> /// <summary>
/// 원래 파일의 인코딩과 줄바꿈을 그대로 유지해 덮어쓴다. /// 원래 파일의 인코딩과 줄바꿈을 그대로 유지해 덮어쓴다.
@ -304,4 +316,23 @@ namespace MergeHub.Services
return crlf > 0 ? "CRLF" : lf > 0 ? "LF" : "CR"; 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

@ -255,7 +255,11 @@ namespace MergeHub.ViewModels
_ => "다른 것만" _ => "다른 것만"
}; };
partial void OnFilterChanged(FolderFilter value) => Rebuild(); partial void OnFilterChanged(FolderFilter value)
{
Rebuild();
RefreshOptionSummary(); // 12차 D-1 - 서랍에 넣은 것이라 한 줄에 켠 것을 알려야 한다
}
/// <summary>마지막 비교 결과. 필터를 바꿀 때 다시 훑지 않으려고 들고 있는다.</summary> /// <summary>마지막 비교 결과. 필터를 바꿀 때 다시 훑지 않으려고 들고 있는다.</summary>
private FolderCompareResult? _lastResult; private FolderCompareResult? _lastResult;
@ -268,6 +272,59 @@ namespace MergeHub.ViewModels
partial void OnSelectedNodeChanged(FolderNode? value) => RaiseCanExecuteChanged(); partial void OnSelectedNodeChanged(FolderNode? value) => RaiseCanExecuteChanged();
// ================= 위쪽 한 줄 · 옵션 서랍 (12차 D-1) =================
/// <summary>옵션 서랍이 열려 있는가. 서랍에 넣은 것은 빠른 비교 · 하위 폴더 · 보기 · 포함 · 제외다.</summary>
[ObservableProperty] private bool _isOptionsOpen;
/// <summary>
/// 폴더 안 찾기 줄이 열려 있는가(12차 D-1). 늘 한 줄을 차지하던 것을 <b>Ctrl+F 로 부를 때만</b> 편다 -
/// 찾기는 가끔 쓰는데 자리는 늘 먹었다. 파일 비교의 찾기와 같은 방식이다.
/// </summary>
[ObservableProperty] private bool _isFindOpen;
/// <summary>
/// 서랍에 넣어 둔 것 가운데 지금 켜 둔 것. 서랍에 넣으면 "내가 뭘 켜 뒀더라" 를 알 수 없게 되므로
/// <b>기본과 다른 것만</b> 한 줄에 짧게 적는다(<see cref="FolderOptionText"/>).
/// </summary>
public string OptionSummary =>
FolderOptionText.Of(QuickCompare, IncludeSubfolders, FilterName(Filter), IncludePattern, ExcludePattern);
public bool HasOptionSummary => OptionSummary.Length > 0;
private void RefreshOptionSummary()
{
OnPropertyChanged(nameof(OptionSummary));
OnPropertyChanged(nameof(HasOptionSummary));
}
partial void OnQuickCompareChanged(bool value) => RefreshOptionSummary();
partial void OnIncludeSubfoldersChanged(bool value) => RefreshOptionSummary();
partial void OnIncludePatternChanged(string value) => RefreshOptionSummary();
partial void OnExcludePatternChanged(string value) => RefreshOptionSummary();
/// <summary>왼쪽 · 오른쪽 폴더를 맞바꾼다(12차 D-1). 반대로 견주려고 두 번 고르지 않게.</summary>
[RelayCommand]
private void SwapFolders()
{
var left = LeftFolder;
LeftFolder = RightFolder;
RightFolder = left;
_workspace.AppendLog($"폴더 좌우 바꿈: {LeftFolder} ↔ {RightFolder}");
}
/// <summary>폴더 안 찾기 줄을 편다(Ctrl+F · 옵션 서랍).</summary>
[RelayCommand]
private void OpenFind()
{
IsFindOpen = true;
IsOptionsOpen = false;
}
/// <summary>찾기 줄을 접는다(Esc). 찾은 결과는 [결과 닫기] 가 따로 치운다.</summary>
[RelayCommand]
private void CloseFind() => IsFindOpen = false;
private void RaiseCanExecuteChanged() private void RaiseCanExecuteChanged()
{ {
CompareCommand.NotifyCanExecuteChanged(); CompareCommand.NotifyCanExecuteChanged();

View File

@ -0,0 +1,179 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using MergeHub.Services;
using UbisamBase.Core.Messaging;
using UbisamBase.Core.Modules;
namespace MergeHub.ViewModels
{
/// <summary>
/// 프로그램을 끌 때 저장하지 않은 편집을 묻는다(2026-09-30 사용자 요청).
///
/// 예전에는 묻지 않고 꺼졌다 - 편집은 보관본(12차 E-1)이 다음에 켤 때 되살렸다. 그런데 사용자 판단은
/// "잘못 고치고 있었으면 어떻게 하나 - 버릴 수 있어야 한다" 였다. 그래서 끌 때 한 번 묻는다:
/// [저장하고 종료] · [저장하지 않고 종료] · [취소]. 버리면 보관본도 비워 다음에 되살아나지 않게 한다.
///
/// <para>
/// 셸 창의 Closing 에 직접 붙지 않고 플랫폼의 <see cref="ICloseGuard"/> 를 쓴다 - 셸이 열린 탭 화면의
/// DataContext 에 물어 준다. 모듈이 저마다 Closing 에 붙으면 서로 Cancel/Close 를 걸어 부딪힌다.
/// </para>
///
/// <para>
/// 묻는 범위는 <b>파일 비교의 모든 탭</b>(사용자 결정): 지금 화면(두 파일 · Git 모드 · 네 칸),
/// 뒤에 맡겨 둔 탭, 그리고 닫았지만 편집이 남은 짝(보관함). 메모는 넣지 않는다(사용자 결정 - 메모는 늘 스스로 저장한다).
/// </para>
/// </summary>
public sealed partial class HomeViewModel : ICloseGuard
{
/// <summary>저장하지 않은 편집 하나. 창에 이름을 대고, 저장할 때 그대로 쓴다.</summary>
private sealed class PendingEdit
{
public string Name = string.Empty;
/// <summary>지금 화면이면 null - 화면의 저장 길(<see cref="SaveWorkingCopy"/>)로 쓴다.</summary>
public string? Left, Right;
public DocTabState? State;
}
/// <summary>창에 이름을 이만큼만 늘어놓는다. 넘치면 "외 N개".</summary>
private const int CloseListMax = 10;
public async Task<bool> CanCloseAsync()
{
// <b>먼저 한 번 비켜선다.</b> 셸은 창의 Closing 안에서 이것을 부르고, 답이 오면 Close() 를 다시 부른다.
// 여기서 곧바로 답하면 그 Close() 가 아직 Closing 안에서 불려 WPF 가 거절했다
// (기록: "창을 닫는 중에는 … Close 를 호출할 수 없습니다" - 창이 꺼지지 않고 남았다).
// 한 차례 넘기면 Closing 이 끝난 뒤에 묻고 답한다.
await Task.Yield();
var pending = CollectPendingEdits();
if (pending.Count == 0)
{
// 물을 것이 없다. 보관본에 옛 편집이 남아 있으면 안 되므로 지금 상태로 한 번 맞춰 둔다
// (보관은 2초 뒤에 쓰므로, 저장한 직후 바로 끄면 옛 글이 남아 있을 수 있다).
FlushSessionNow(clear: true);
return true;
}
var names = pending.Take(CloseListMax).Select(p => " · " + p.Name).ToList();
if (pending.Count > CloseListMax) names.Add($" · 외 {pending.Count - CloseListMax}개");
var choice = MessageUtil.ShowChoice(
"저장하지 않은 편집",
$"저장하지 않은 편집이 있는 파일이 {pending.Count}개 있습니다.\n\n{string.Join("\n", names)}\n\n" +
"저장하지 않고 종료하면 고친 내용이 사라지고, 다음에 켤 때도 되살아나지 않습니다.",
"저장하고 종료", "저장하지 않고 종료");
switch (choice)
{
case DialogChoice.Primary:
// 하나라도 못 쓰면 끄지 않는다 - 여기서 꺼지면 저장된 줄 알고 잃는다(ConfirmDiscard 와 같은 원칙).
if (!SavePendingEdits(pending)) return false;
FlushSessionNow(clear: true);
return true;
case DialogChoice.Secondary:
FlushSessionNow(clear: true);
_workspace.AppendLog($"종료: 저장하지 않은 편집 {pending.Count}개를 버림");
return true;
default:
return false;
}
}
/// <summary>
/// 저장하지 않은 편집을 모두 모은다. 지금 화면을 맨 앞에 둔다 - 사용자가 보고 있는 것이다.
/// </summary>
private List<PendingEdit> CollectPendingEdits()
{
var list = new List<PendingEdit>();
// 지금 화면. 스타일 맞춤으로만 바뀐 글은 세지 않는다(다시 열면 똑같이 만들어진다 - WillAskBeforeDiscard 와 같은 기준).
if ((HasChanges && !OnlyStyleChanged) || HasLeftChanges)
{
var editing = IsPlainMode ? RightFile : (_lastLoadedFile ?? SelectedFile);
list.Add(new PendingEdit
{
Name = string.IsNullOrWhiteSpace(editing) ? "지금 파일"
: IsPlainMode ? Path.GetFileName(editing) : editing!
});
}
// 뒤에 맡겨 둔 탭. 두 파일 비교 화면이면 떠 있는 탭은 화면이 주인이라 위에서 이미 셌다.
foreach (var tab in Tabs.Tabs)
{
if (TabsCanHoldEdits && ReferenceEquals(tab, Tabs.Current)) continue;
if (tab.Parked is not { HasEdits: true } state) continue;
// 탭 제목은 같은 이름 파일을 가르려고 폴더를 덧붙여 길어진다 - 창에는 지금 화면 · 닫은 탭과 같이 파일 이름만 댄다.
list.Add(new PendingEdit { Name = Path.GetFileName(tab.Right), Left = tab.Left, Right = tab.Right, State = state });
}
// 닫았지만 편집이 남은 짝. 탭 줄에 없어 사용자가 잊었을 수 있으니 그렇다고 적는다.
foreach (var kept in Tabs.AllStashed())
{
if (!kept.State.HasEdits) continue;
list.Add(new PendingEdit
{
Name = Path.GetFileName(kept.Right) + " (닫은 탭)",
Left = kept.Left,
Right = kept.Right,
State = kept.State
});
}
return list;
}
/// <summary>모은 편집을 파일에 쓴다. 하나라도 못 쓰면 거기서 멈추고 false.</summary>
private bool SavePendingEdits(IEnumerable<PendingEdit> pending)
{
foreach (var edit in pending)
{
if (edit.State is null)
{
// 지금 화면 - Git 모드의 충돌 파일 · 왼쪽 칸까지 챙기는 기존 길을 그대로 쓴다.
if (!SaveWorkingCopy()) return false;
continue;
}
try
{
// 맡겨 둔 글은 그 짝에 걸어 둔 저장 형식으로. 없으면(null) 원본 형식 그대로다.
if (edit.State.Text is not null && !string.IsNullOrWhiteSpace(edit.Right))
WritePlainFile(edit.Right!, edit.State.Text, edit.State.SaveEncoding, edit.State.SaveNewLine);
if (edit.State.LeftText is not null && !string.IsNullOrWhiteSpace(edit.Left))
WritePlainFile(edit.Left!, edit.State.LeftText, edit.State.SaveEncoding, edit.State.SaveNewLine);
_workspace.AppendLog($"종료하며 저장: {edit.Name}");
}
catch (Exception ex)
{
MessageUtil.ShowErrorToast($"{edit.Name} 을 저장하지 못했습니다: {ex.Message}");
return false;
}
}
return true;
}
/// <summary>
/// 보관본을 <b>지금 이 자리에서</b> 맞춘다. 평소의 보관(<see cref="SaveSession"/>)은 다른 실로 넘기는데,
/// 끄는 순간에는 그 실이 끝나기 전에 프로세스가 내려갈 수 있고, 거꾸로 늦게 도착한 옛 보관이
/// 방금 비운 보관본을 다시 채울 수도 있다 - 그래서 걸려 있던 쓰기를 먼저 기다린 뒤 비운다.
/// </summary>
private void FlushSessionNow(bool clear)
{
_sessionTimer.Stop();
try { _sessionWrite?.Wait(TimeSpan.FromSeconds(3)); }
catch (AggregateException) { } // 보관 실패는 끄는 것을 막지 않는다
if (clear) Session.Clear();
}
}
}

View File

@ -1,6 +1,9 @@
using System; using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using MergeHub.Services; using MergeHub.Services;
using UbisamBase.Core.Messaging; using UbisamBase.Core.Messaging;
@ -209,14 +212,20 @@ namespace MergeHub.ViewModels
SyncTabItems(); SyncTabItems();
if (Tabs.LastEvicted is { } gone) if (Tabs.LastEvicted is { } gone)
{
_workspace.AppendLog($"탭이 {DocTabs.Soft}개를 넘어 오래 안 본 탭을 닫음: {gone.Title}"); _workspace.AppendLog($"탭이 {DocTabs.Soft}개를 넘어 오래 안 본 탭을 닫음: {gone.Title}");
// 로그 한 줄로는 모르고 지나간다(사용자 2026-09-30). 닫히는 탭은 늘 저장할 것이 없는 탭이다 - 그것도 함께 알린다.
MessageUtil.ShowWarningToast($"탭이 {DocTabs.Soft}개를 넘어 오래 안 본 '{gone.Title}' 탭을 닫았습니다 (고친 것이 없던 탭)");
}
SaveSession(); // 12차 E-1
} }
/// <summary> /// <summary>
/// 지금 짝에 탭이 하나는 있게 한다. <see cref="LoadPlainFilesAsync"/> 가 맨 앞에서 부른다. /// 지금 짝에 탭이 하나는 있게 한다. <see cref="LoadPlainFilesAsync"/> 가 맨 앞에서 부른다.
/// ///
/// 여는 길(<c>OnPlainCompareRequested</c>)을 거치지 않고 바로 짝이 바뀌는 자리가 있다 - /// 여는 길(<c>OnPlainCompareRequested</c>)을 거치지 않고 바로 짝이 바뀌는 자리가 있다 -
/// 켤 때 설정의 기본 짝, [기준 파일...] · [내 파일...] 로 한쪽만 바꾸기, 데모 전환 뒤 돌아오기. /// 켤 때 설정의 기본 짝, [기준 파일...] · [내 파일...] 로 한쪽만 바꾸기.
/// 그 자리에 탭이 없으면 거기서 고친 것을 맡을 데가 없어 <b>다음에 짝을 바꿀 때 사라진다</b> - /// 그 자리에 탭이 없으면 거기서 고친 것을 맡을 데가 없어 <b>다음에 짝을 바꿀 때 사라진다</b> -
/// E-2 가 없애려던 바로 그 일이다. /// E-2 가 없애려던 바로 그 일이다.
/// ///
@ -253,6 +262,9 @@ namespace MergeHub.ViewModels
{ {
if (Tabs.Current is { } tab && TabsCanHoldEdits) if (Tabs.Current is { } tab && TabsCanHoldEdits)
tab.IsDirty = HasTypedEdits; tab.IsDirty = HasTypedEdits;
// 고친 것이 생겼거나(보관해야 한다) 없어졌다(보관본을 치워야 한다) - 둘 다 다시 쓸 일이다.
TouchSession();
} }
// [ObservableProperty] 가 만들어 주는 갈고리. 고친 것이 생기거나 없어지면 탭 표시가 따라간다. // [ObservableProperty] 가 만들어 주는 갈고리. 고친 것이 생기거나 없어지면 탭 표시가 따라간다.
@ -270,6 +282,7 @@ namespace MergeHub.ViewModels
Tabs.Activate(tab); Tabs.Activate(tab);
_resumeTab = tab; _resumeTab = tab;
SyncTabItems(); SyncTabItems();
SaveSession(); // 12차 E-1 - 옮기는 순간이 곧 끄기 직전일 수 있다. 미루지 않는다.
LeftFile = tab.Left; LeftFile = tab.Left;
RightFile = tab.Right; RightFile = tab.Right;
@ -304,6 +317,7 @@ namespace MergeHub.ViewModels
var next = Tabs.Close(tab); var next = Tabs.Close(tab);
SyncTabItems(); SyncTabItems();
SaveSession(); // 12차 E-1 - 닫아도 글은 남는다. 그 자리에서 적어 둔다.
if (next is null || ReferenceEquals(next, tab)) return; if (next is null || ReferenceEquals(next, tab)) return;
@ -332,5 +346,208 @@ namespace MergeHub.ViewModels
if (at < 0 || Tabs.Count < 2) return; if (at < 0 || Tabs.Count < 2) return;
GoToTab(Tabs.Tabs[(at - 1 + Tabs.Count) % Tabs.Count]); GoToTab(Tabs.Tabs[(at - 1 + Tabs.Count) % Tabs.Count]);
} }
// ================= 껐다 켜도 남는 편집 (12차 E-1) =================
/// <summary>
/// 저장하지 않은 편집을 파일로 보관한다(12차 E-1). E-2 의 보관함을 그대로 옮겨 적는 것이라
/// 여기서 새로 정할 것은 <b>언제 쓰고 언제 되살리는가</b>뿐이다.
/// </summary>
private SessionStore Session => _session ??= new SessionStore(
SessionStore.DefaultRoot(UbisamBase.Core.Bootstrap.UbisamAppBuilder.ConfigRoot));
private SessionStore? _session;
/// <summary>
/// 글자가 멈춘 뒤에 한 번만 쓴다. 글자마다 쓰면 큰 파일에서 타이핑이 걸린다 -
/// 강제로 끝내도 여기까지는 남는다는 것이 이 시간(2초)의 뜻이다.
/// </summary>
private readonly DispatcherTimer _sessionTimer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(2)
};
private bool _sessionReady;
/// <summary>마지막으로 넘긴 보관 쓰기. 끌 때 이것이 끝나기를 기다린 뒤 보관본을 비운다(HomeViewModel.Close).</summary>
private Task? _sessionWrite;
/// <summary>되살리는 중. 그때 일어나는 변화로 다시 보관하지 않는다.</summary>
private bool _restoringSession;
/// <summary>글이 바뀌었다고 알린다. 손이 멈추면 그때 한 번 쓴다.</summary>
internal void TouchSession()
{
if (_restoringSession) return;
if (!_sessionReady)
{
_sessionTimer.Tick += (_, _) => SaveSession();
_sessionReady = true;
}
_sessionTimer.Stop();
_sessionTimer.Start();
}
/// <summary>
/// 지금 화면의 편집을 <see cref="DocTabState"/> 로 뜬다.
///
/// <see cref="ParkCurrentTab"/> 을 쓰지 않는 까닭 - 그쪽은 되돌리기 목록을 글 단위로 눌러 담는데(FlattenHistory),
/// 보관은 2초마다 일어나므로 그때마다 이력을 주무르면 아깝다. 보관할 것은 <b>글과 자리</b>뿐이고
/// 되돌리기 이력은 되살리지 않기로 정했다(사용자 결정).
/// </summary>
private DocTabState? SessionStateOfScreen()
{
if (!TabsCanHoldEdits || !HasTypedEdits) return null;
return new DocTabState
{
Text = HasChanges && !OnlyStyleChanged ? BuildLocalText() : null,
LeftText = HasLeftChanges ? BuildBaseText() : null,
CaretIndex = ReadCaretIndex?.Invoke() ?? 0,
TopRow = CaretRow,
SaveEncoding = SaveEncoding,
SaveNewLine = SaveNewLine
};
}
/// <summary>
/// 지금 남아 있는 것을 모두 담아 파일에 쓴다. 보관할 것이 하나도 없으면 보관본을 치운다
/// (저장하고 껐는데 다음에 켤 때 되살릴 것이 남아 있으면 안 된다).
///
/// <b>쓰는 일은 다른 실로 넘긴다</b> - 큰 파일에서 글을 통째로 쓰는 동안 화면이 멈추면 안 된다.
/// </summary>
private void SaveSession()
{
_sessionTimer.Stop();
if (_restoringSession) return;
var snapshot = new SessionSnapshot();
// 열려 있는 탭부터 차례대로. 떠 있는 탭은 화면이 주인이라 그 자리에서 뜬다.
foreach (var tab in Tabs.Tabs)
{
var state = ReferenceEquals(tab, Tabs.Current) ? SessionStateOfScreen() : tab.Parked;
snapshot.Tabs.Add(new SessionTab
{
Left = tab.Left,
Right = tab.Right,
Open = true,
Text = state?.Text,
LeftText = state?.LeftText,
CaretIndex = state?.CaretIndex ?? 0,
TopRow = state?.TopRow ?? 0,
SaveEncoding = state?.SaveEncoding,
SaveNewLine = state?.SaveNewLine,
RightStamp = FileStamp.Of(tab.Right),
LeftStamp = FileStamp.Of(tab.Left)
});
if (ReferenceEquals(tab, Tabs.Current)) snapshot.Current = snapshot.Tabs.Count - 1;
}
// 닫혔지만 글이 남은 짝(E-2 보관함)도 함께 적는다 - 다시 열면 되살아나야 한다.
foreach (var kept in Tabs.AllStashed())
{
snapshot.Tabs.Add(new SessionTab
{
Left = kept.Left,
Right = kept.Right,
Open = false,
Text = kept.State.Text,
LeftText = kept.State.LeftText,
CaretIndex = kept.State.CaretIndex,
TopRow = kept.State.TopRow,
SaveEncoding = kept.State.SaveEncoding,
SaveNewLine = kept.State.SaveNewLine,
RightStamp = FileStamp.Of(kept.Right),
LeftStamp = FileStamp.Of(kept.Left)
});
}
var store = Session;
_sessionWrite = Task.Run(() => store.Save(snapshot));
}
/// <summary>
/// 켤 때 보관해 둔 것을 되살린다(12차 E-1). <b>묻지 않는다</b>(사용자 결정) -
/// notepad++ 처럼 껐을 때 그대로 다시 열린다.
///
/// 되살리는 것은 <b>탭 짝과 저장하지 않은 글 · 캐럿 · 스크롤</b>이다. 원본 글은 담아 두지 않았으므로
/// 파일에서 다시 읽고, 그 위에 편집을 얹는다(<see cref="ResumePendingTab"/> 가 하던 그대로).
/// </summary>
/// <returns>열어 놓은 탭이 있어 화면을 채웠으면 true. 그러면 설정의 기본 짝은 열지 않는다.</returns>
private bool RestoreSession()
{
SessionSnapshot snapshot;
try { snapshot = Session.Load(); }
catch (Exception) { return false; } // 보관본 때문에 프로그램이 안 켜지면 안 된다
if (snapshot.Tabs.Count == 0) return false;
_restoringSession = true;
try
{
// 두 파일이 다 없어진 짝은 되살릴 자리가 없다.
var alive = snapshot.Tabs
.Where(t => File.Exists(t.Right) || File.Exists(t.Left))
.ToList();
if (alive.Count == 0) { Session.Clear(); return false; }
// 먼저 보관함에 넣고, 열려 있던 것만 탭으로 연다 - 열 때 그 편집이 탭에 실린다.
foreach (var tab in alive)
{
Tabs.Keep(tab.Left, tab.Right, new DocTabState
{
Text = tab.Text,
LeftText = tab.LeftText,
CaretIndex = tab.CaretIndex,
TopRow = tab.TopRow,
SaveEncoding = tab.SaveEncoding,
SaveNewLine = tab.SaveNewLine
});
}
DocTab? current = null;
var changedFiles = 0;
foreach (var tab in alive.Where(t => t.Open))
{
var opened = Tabs.Open(tab.Left, tab.Right);
if (tab.FilesChanged) changedFiles++;
if (snapshot.Current >= 0 && snapshot.Current < snapshot.Tabs.Count &&
ReferenceEquals(snapshot.Tabs[snapshot.Current], tab))
{
current = opened;
}
}
if (Tabs.Count == 0) { SyncTabItems(); return false; }
current ??= Tabs.Tabs[Tabs.Count - 1];
Tabs.Activate(current);
SyncTabItems();
_resumeTab = current;
LeftFile = current.Left;
RightFile = current.Right;
OnPropertyChanged(nameof(BaseFileLocation));
OnPropertyChanged(nameof(SelectedFileFullPath));
_workspace.AppendLog($"저장하지 않은 편집을 되살림: 탭 {Tabs.Count}개" +
(changedFiles > 0 ? $" (그 사이 바뀐 파일 {changedFiles}개)" : string.Empty));
MessageUtil.ShowToast($"저장하지 않은 편집을 되살렸습니다 - 탭 {Tabs.Count}개");
return true;
}
finally
{
_restoringSession = false;
}
}
/// <summary>되살린 탭의 짝을 실제로 읽어 화면에 올린다. 생성자에서 <see cref="RestoreSession"/> 뒤에 부른다.</summary>
private Task LoadRestoredAsync() => LoadPlainFilesAsync();
} }
} }

View File

@ -96,6 +96,15 @@ namespace MergeHub.ViewModels
// 저장소가 아니면 설정에 적어 둔 두 파일로 비교 화면을 만든다 - Git 없이도 쓰는 // 저장소가 아니면 설정에 적어 둔 두 파일로 비교 화면을 만든다 - Git 없이도 쓰는
// 도구라는 것을 첫 화면에서 바로 보여 준다. 둘을 동시에 걸면 안 된다. // 도구라는 것을 첫 화면에서 바로 보여 준다. 둘을 동시에 걸면 안 된다.
// 작업 게이트가 하나뿐이라 먼저 건 쪽이 끝날 때까지 뒤엣것이 통째로 취소된다. // 작업 게이트가 하나뿐이라 먼저 건 쪽이 끝날 때까지 뒤엣것이 통째로 취소된다.
// 12차 E-1: 지난번에 저장하지 않고 끈 편집이 있으면 그것부터 되살린다(묻지 않는다).
// 위의 "시키는 일"(충돌 열기 · mergetool · 폴더 비교에서 고른 파일)이 있을 때는 여기까지 오지 않는다 -
// 사용자가 지금 열라고 한 것이 먼저다. 기본 저장소 · 기본 짝보다는 먼저다.
if (!_workspace.LooksLikeRepository && RestoreSession())
{
_ = LoadRestoredAsync();
return;
}
if (_workspace.LooksLikeRepository) if (_workspace.LooksLikeRepository)
_ = _workspace.TryAutoOpenAsync(); _ = _workspace.TryAutoOpenAsync();
else else
@ -226,8 +235,7 @@ namespace MergeHub.ViewModels
RefreshLanguage(); RefreshLanguage();
var rows = BuildThreeWayLines(baseText, localText, remoteText); var rows = BuildThreeWayLines(baseText, localText, remoteText);
Lines.Clear(); Lines.ReplaceAll(rows); // 알림 한 번(MergeLineCollection 참고)
foreach (var row in rows) Lines.Add(row);
BaseHeaderLead = string.Empty; BaseHeaderLead = string.Empty;
LocalHeaderLead = string.Empty; LocalHeaderLead = string.Empty;
@ -288,66 +296,6 @@ namespace MergeHub.ViewModels
TextFile.Write(target, content, encoding, newLine); 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> /// <summary>설정의 기본 비교 파일 두 개를 열어 둔다. 없거나 사라졌으면 조용히 넘어간다.</summary>
private Task LoadDefaultPlainFiles() private Task LoadDefaultPlainFiles()
{ {
@ -372,7 +320,7 @@ namespace MergeHub.ViewModels
// ================= 3분할 상태 ================= // ================= 3분할 상태 =================
/// <summary>BASE 와 LOCAL 을 같은 줄 번호로 묶은 행 목록.</summary> /// <summary>BASE 와 LOCAL 을 같은 줄 번호로 묶은 행 목록.</summary>
public ObservableCollection<MergeLine> Lines { get; } = new ObservableCollection<MergeLine>(); public MergeLineCollection Lines { get; } = new MergeLineCollection();
/// <summary>비교 대상 파일 목록(충돌 중이면 충돌 파일, 아니면 선택 커밋의 파일).</summary> /// <summary>비교 대상 파일 목록(충돌 중이면 충돌 파일, 아니면 선택 커밋의 파일).</summary>
public ObservableCollection<string> Files { get; } = new ObservableCollection<string>(); public ObservableCollection<string> Files { get; } = new ObservableCollection<string>();
@ -702,9 +650,12 @@ namespace MergeHub.ViewModels
} }
} }
/// <summary>실제로 줄 끝에 붙을 주석을 미리 보여준다.</summary> /// <summary>실제로 줄 끝에 붙을 주석을 미리 보여준다. 작성자가 비면 반영이 막히므로(<see cref="AuthorReady"/>)
/// 대신 채워질 이름(윈도우 계정)을 보이지 않는다 - 그 이름으로는 반영되지 않는다.</summary>
public string MergeNotePreview => 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(); partial void OnSelectedLineChanged(MergeLine? value) => RaiseCanExecuteChanged();
@ -817,9 +768,23 @@ namespace MergeHub.ViewModels
private void TakeLine(MergeLine? line) private void TakeLine(MergeLine? line)
{ {
if (line is null || !line.CanTakeBase) return; if (line is null || !line.CanTakeBase) return;
if (!AuthorReady()) return;
Apply(line); 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; private bool CanAccept() => SelectedLine?.CanTakeBase == true;
[RelayCommand(CanExecute = nameof(CanAccept))] [RelayCommand(CanExecute = nameof(CanAccept))]
@ -870,13 +835,11 @@ namespace MergeHub.ViewModels
bool afterHasBase; bool afterHasBase;
string? note = null; string? note = null;
if (line.HasLocal) // LOCAL 에 없던 줄(BASE 에서 지우는 반영)에도 주석을 남긴다 - 오른쪽 반영(Apply)과 같은 규칙(사용자 2026-09-30).
{
note = _workspace.Settings.InsertMergeComment && group == 0
? BuildMergeNote(MergeNames.LocalSource(LocalSourceName)) : null;
}
(after, afterHasBase) = PaneText.Plan(line, MergeLine.TextPane.Base); (after, afterHasBase) = PaneText.Plan(line, MergeLine.TextPane.Base);
note = _workspace.Settings.InsertMergeComment && group == 0
? BuildMergeNote(MergeNote.WithDeletion(MergeNames.LocalSource(LocalSourceName), beforeHasBase && !afterHasBase ? 1 : 0))
: null;
if (beforeHasBase == afterHasBase && string.Equals(before, after, StringComparison.Ordinal)) if (beforeHasBase == afterHasBase && string.Equals(before, after, StringComparison.Ordinal))
return false; return false;
@ -891,7 +854,7 @@ namespace MergeHub.ViewModels
EditKind.TakeBase, description) EditKind.TakeBase, description)
{ {
Side = MergeLine.TextPane.Base, Side = MergeLine.TextPane.Base,
InsertedNote = InsertBaseNoteAbove(line, note), InsertedNote = InsertBaseNoteAbove(line, note, indentFrom: before),
Group = group, Group = group,
}); });
@ -903,11 +866,86 @@ namespace MergeHub.ViewModels
private bool CanTakeLineToBase(MergeLine? line) => BothWays && line is { CanTakeBase: true }; private bool CanTakeLineToBase(MergeLine? line) => BothWays && line is { CanTakeBase: true };
/// <summary>
/// 캐럿이 있는 자리에서 <b>왼쪽으로 보낼 수 있는</b> 덩어리를 잡을 수 있는가(12차 G-2).
/// 2-Way 에서 <see cref="BlockAround"/> 는 <c>CanTakeBase</c> 로 덩어리를 잡으므로 그대로 쓴다 -
/// "가져올 수 있는 줄" 과 "보낼 수 있는 줄" 이 같은 줄이다. 방향만 반대다.
/// </summary>
private bool CanAcceptBlockToBase() => BothWays && BlockAround(CaretRow).Count > 0;
/// <summary>이 덩어리를 통째로 왼쪽 파일로 반영한다(12차 G-2). 한 줄짜리 <see cref="TakeLineToBase"/> 의 덩어리판.</summary>
[RelayCommand(CanExecute = nameof(CanAcceptBlockToBase))]
private void AcceptBlockToBase()
{
var block = BlockAround(CaretRow);
if (block.Count == 0) return;
if (!AuthorReady()) return;
var count = ApplyGroupToBase(block, "구간");
if (count > 0) MessageUtil.ShowSuccessToast($"{count}줄 왼쪽에 반영");
}
/// <summary>
/// 여러 줄을 <b>한 묶음으로</b> 왼쪽 파일에 반영한다(12차 G-2).
/// 오른쪽으로 보내는 <see cref="ApplyGroup"/> 의 거울 - 주석은 덩어리 첫 줄 위 한 줄,
/// 되돌리기는 한 번에(11차 F-2 와 같은 규칙).
/// </summary>
private int ApplyGroupToBase(IReadOnlyList<MergeLine> rows, string what)
{
var group = ++_lastGroup;
var changed = 0;
var deleted = 0; // 주석에 "삭제 N줄" 로 한 번만 적는다(사용자 2026-09-30)
int? first = null, last = null;
foreach (var line in rows.ToList())
{
if (line.IsMergeNote) continue;
var number = line.HasLocal ? line.LocalNumber : line.BaseNumber;
var hadBase = line.HasBase;
// group 이 0 이 아니므로 ApplyToBase 는 줄마다 주석을 달지 않는다 - 아래에서 한 줄만 단다.
if (!line.CanTakeBase || !ApplyToBase(line, requery: false, group: group)) continue;
if (hadBase && !line.HasBase) deleted++;
changed++;
if (number is int n) { first ??= n; last = n; }
}
if (changed == 0)
{
AfterEdit();
return 0;
}
var where = first is null ? string.Empty : first == last ? $"{first}행 " : $"{first}~{last}행 ";
var description = $"{where}{what} 왼쪽에 반영 ({changed}줄)";
// 묶음이 모두 지우는 반영이면 글이 남은 줄이 없다 - 그래도 지운 자리(첫 줄)에 주석을 남긴다(사용자 2026-09-30).
var at = rows.FirstOrDefault(r => r.HasLocal && !r.IsMergeNote && Lines.Contains(r))
?? rows.FirstOrDefault(r => !r.IsMergeNote && Lines.Contains(r));
if (at is not null && _workspace.Settings.InsertMergeComment)
{
var note = BuildMergeNote(MergeNote.WithDeletion(MergeNames.LocalSource(LocalSourceName), deleted));
_undo.Push(new LineEdit(at, at.BaseText, at.HasBase, at.BaseText, at.HasBase,
EditKind.NoteOnly, description)
{
Side = MergeLine.TextPane.Base,
InsertedNote = InsertBaseNoteAbove(at, note),
Group = group,
});
}
ClearRedo();
_workspace.AppendLog(description);
AfterEdit();
return changed;
}
/// <summary>[&lt;] - 이 줄을 왼쪽 파일로 반영한다(12차 B-1).</summary> /// <summary>[&lt;] - 이 줄을 왼쪽 파일로 반영한다(12차 B-1).</summary>
[RelayCommand(CanExecute = nameof(CanTakeLineToBase))] [RelayCommand(CanExecute = nameof(CanTakeLineToBase))]
private void TakeLineToBase(MergeLine? line) private void TakeLineToBase(MergeLine? line)
{ {
if (line is null) return; if (line is null) return;
if (!AuthorReady()) return;
ApplyToBase(line); ApplyToBase(line);
} }
@ -915,14 +953,15 @@ namespace MergeHub.ViewModels
/// 왼쪽으로 반영한 줄 <b>위에</b> 넣을 머지 주석 한 줄. 오른쪽 것(<see cref="InsertNoteAbove"/>)의 거울이라 /// 왼쪽으로 반영한 줄 <b>위에</b> 넣을 머지 주석 한 줄. 오른쪽 것(<see cref="InsertNoteAbove"/>)의 거울이라
/// <b>왼쪽에만 있는 줄</b>로 넣는다 - 오른쪽 파일에는 들어가지 않는다. /// <b>왼쪽에만 있는 줄</b>로 넣는다 - 오른쪽 파일에는 들어가지 않는다.
/// </summary> /// </summary>
private MergeLine? InsertBaseNoteAbove(MergeLine line, string? note) private MergeLine? InsertBaseNoteAbove(MergeLine line, string? note, string? indentFrom = null)
{ {
if (string.IsNullOrWhiteSpace(note)) return null; if (string.IsNullOrWhiteSpace(note)) return null;
var at = Lines.IndexOf(line); var at = Lines.IndexOf(line);
if (at < 0) return null; if (at < 0) return null;
var indent = new string(' ', line.BaseText.Length - line.BaseText.TrimStart().Length); // 지우는 반영이면 BASE 글이 이미 비었다 - 지우기 전 글이나 LOCAL 쪽 글에서 잰다.
var indent = IndentOf(line.BaseText, indentFrom, line.LocalText);
var row = new MergeLine(indent + note, null, null, null, Rules.IsPlain ? null : Rules.KeyOf) var row = new MergeLine(indent + note, null, null, null, Rules.IsPlain ? null : Rules.KeyOf)
{ {
@ -931,6 +970,7 @@ namespace MergeHub.ViewModels
}; };
Lines.Insert(at, row); Lines.Insert(at, row);
NoteRowMoved(at, +1);
return row; return row;
} }
@ -988,6 +1028,7 @@ namespace MergeHub.ViewModels
[RelayCommand(CanExecute = nameof(CanAcceptAll))] [RelayCommand(CanExecute = nameof(CanAcceptAll))]
private void AcceptAll() private void AcceptAll()
{ {
if (!AuthorReady()) return;
foreach (var line in Lines.Where(l => l.CanTakeBase).ToList()) foreach (var line in Lines.Where(l => l.CanTakeBase).ToList())
Apply(line, requery: false); Apply(line, requery: false);
@ -1313,6 +1354,7 @@ namespace MergeHub.ViewModels
SavePlainCommand.NotifyCanExecuteChanged(); SavePlainCommand.NotifyCanExecuteChanged();
SaveLeftPlainCommand.NotifyCanExecuteChanged(); // 12차 B-1 SaveLeftPlainCommand.NotifyCanExecuteChanged(); // 12차 B-1
TakeLineToBaseCommand.NotifyCanExecuteChanged(); // 12차 B-1 TakeLineToBaseCommand.NotifyCanExecuteChanged(); // 12차 B-1
AcceptBlockToBaseCommand.NotifyCanExecuteChanged(); // 12차 G-2
ExportReportCommand.NotifyCanExecuteChanged(); ExportReportCommand.NotifyCanExecuteChanged();
AutoMergeCommand.NotifyCanExecuteChanged(); AutoMergeCommand.NotifyCanExecuteChanged();
@ -1582,9 +1624,9 @@ namespace MergeHub.ViewModels
_workspace.OnUi(() => _workspace.OnUi(() =>
{ {
// 읽어 오는 사이에 파일 비교 모드로 넘어갔으면(데모 전환·저장소 닫기) 버린다. // 읽어 오는 사이에 파일 비교 모드로 넘어갔으면(저장소 닫기) 버린다.
// 늦게 끝난 Git 로드가 이미 바뀐 화면을 덮어쓰면, 경로는 새 파일인데 내용은 // 늦게 끝난 Git 로드가 이미 바뀐 화면을 덮어쓰면, 경로는 새 파일인데 내용은
// 옛 파일인 상태가 된다 - 데모 전환에서 실제로 겪었다. // 옛 파일인 상태가 된다 - 저장소를 닫는 길에서 실제로 겪었다.
if (!IsGitMode) return; if (!IsGitMode) return;
// 읽기 시작한 뒤 <b>다른 파일 · 다른 커밋을 골랐으면 버린다</b>(8차 A-1). // 읽기 시작한 뒤 <b>다른 파일 · 다른 커밋을 골랐으면 버린다</b>(8차 A-1).
@ -1633,8 +1675,7 @@ namespace MergeHub.ViewModels
SetDeletion(deletion, theirsName); SetDeletion(deletion, theirsName);
OnPropertyChanged(nameof(ConflictSideText)); OnPropertyChanged(nameof(ConflictSideText));
Lines.Clear(); Lines.ReplaceAll(rows); // 알림 한 번(MergeLineCollection 참고)
foreach (var row in rows) Lines.Add(row);
// BASE 가 비어 있으면 <b>그 파일이 공통 조상에 없었다</b>는 뜻이다 - 양쪽 브랜치가 // BASE 가 비어 있으면 <b>그 파일이 공통 조상에 없었다</b>는 뜻이다 - 양쪽 브랜치가
// 각자 새로 만든 파일이라 견줄 조상이 없다. 그냥 두면 빈 칸이 고장으로 보인다. // 각자 새로 만든 파일이라 견줄 조상이 없다. 그냥 두면 빈 칸이 고장으로 보인다.
@ -1989,16 +2030,17 @@ namespace MergeHub.ViewModels
bool afterHasLocal; bool afterHasLocal;
string? note = null; string? note = null;
if (line.HasBase) // 반영한 줄에는 누가·언제 반영했는지 주석을 남긴다.
{ // 주석은 줄 끝이 아니라 그 줄 "위"에 따로 들어간다 - 아래 InsertNoteAbove 참고.
// 반영한 줄에는 누가·언제 반영했는지 주석을 남긴다. // BASE 에 없던 줄(LOCAL 에서 지우는 반영)에도 남긴다 - 지운 자리에 누가 지웠는지가 남아야 한다(사용자 2026-09-30).
// 주석은 줄 끝이 아니라 그 줄 "위"에 따로 들어간다 - 아래 InsertNoteAbove 참고. // 예전에는 BASE 에 줄이 있을 때만 남겨, 지운 반영은 흔적이 없었다. 지운 반영이면 출처 뒤에 "삭제".
note = _workspace.Settings.InsertMergeComment && group == 0 ? BuildMergeNote(MergeNames.BaseSource(_baseSourceName)) : null;
}
// 반영하면 그 칸이 어떻게 되는지는 양쪽이 같은 규칙을 쓴다(12차 B-1, PaneText). // 반영하면 그 칸이 어떻게 되는지는 양쪽이 같은 규칙을 쓴다(12차 B-1, PaneText).
// BASE 에 없던 줄이면 LOCAL 에서도 지운다. // BASE 에 없던 줄이면 LOCAL 에서도 지운다.
(after, afterHasLocal) = PaneText.Plan(line, MergeLine.TextPane.Local); (after, afterHasLocal) = PaneText.Plan(line, MergeLine.TextPane.Local);
note = _workspace.Settings.InsertMergeComment && group == 0
? BuildMergeNote(MergeNote.WithDeletion(MergeNames.BaseSource(_baseSourceName), beforeHasLocal && !afterHasLocal ? 1 : 0))
: null;
if (beforeHasLocal == afterHasLocal && string.Equals(before, after, StringComparison.Ordinal)) if (beforeHasLocal == afterHasLocal && string.Equals(before, after, StringComparison.Ordinal))
return false; return false;
@ -2013,7 +2055,7 @@ namespace MergeHub.ViewModels
_undo.Push(new LineEdit(line, before, beforeHasLocal, after, afterHasLocal, _undo.Push(new LineEdit(line, before, beforeHasLocal, after, afterHasLocal,
EditKind.TakeBase, description) EditKind.TakeBase, description)
{ {
InsertedNote = InsertNoteAbove(line, note), InsertedNote = InsertNoteAbove(line, note, indentFrom: before),
Group = group, Group = group,
}); });
@ -2146,7 +2188,7 @@ namespace MergeHub.ViewModels
partial void OnRemoteBranchChanged(string value) partial void OnRemoteBranchChanged(string value)
{ {
// 3-Way 로 들고 나면 본문을 다시 읽는다. 고치던 것이 있으면 사라진다. // 3-Way 로 들고 나면 본문을 다시 읽는다. 고치던 것이 있으면 사라진다.
// _suspendReload 가 걸린 길(데모 전환·파일 비교 전환)은 다시 읽지 않으므로 묻지 않는다. // _suspendReload 가 걸린 길(파일 비교 전환)은 다시 읽지 않으므로 묻지 않는다.
if (!_revertingRemoteBranch && !_syncingBranches && IsGitMode && !_suspendReload && if (!_revertingRemoteBranch && !_syncingBranches && IsGitMode && !_suspendReload &&
!ConfirmDiscard("비교 방식을 바꿉니다.")) !ConfirmDiscard("비교 방식을 바꿉니다."))
{ {
@ -2214,6 +2256,7 @@ namespace MergeHub.ViewModels
private void TakeLocalLine(MergeLine? line) private void TakeLocalLine(MergeLine? line)
{ {
if (line is null || !line.CanTakeLocal) return; if (line is null || !line.CanTakeLocal) return;
if (!AuthorReady()) return;
var before = line.LocalText; var before = line.LocalText;
var beforeHasLocal = line.HasLocal; var beforeHasLocal = line.HasLocal;
@ -2292,6 +2335,7 @@ namespace MergeHub.ViewModels
private void TakeRemoteLine(MergeLine? line) private void TakeRemoteLine(MergeLine? line)
{ {
if (line is null || !line.CanTakeRemote) return; if (line is null || !line.CanTakeRemote) return;
if (!AuthorReady()) return;
ApplyRemote(line); ApplyRemote(line);
} }
@ -2314,11 +2358,13 @@ namespace MergeHub.ViewModels
bool afterHasLocal; bool afterHasLocal;
string? note = null; string? note = null;
// 저쪽에 없던 줄(내 쪽에서 지우는 반영)에도 주석을 남긴다 - Apply 와 같은 규칙(사용자 2026-09-30).
note = _workspace.Settings.InsertMergeComment && group == 0
? BuildMergeNote(MergeNote.WithDeletion(MergeNames.TheirsSource(TheirsSideName, TakesMergeSides),
beforeHasLocal && !line.HasRemote ? 1 : 0))
: null;
if (line.HasRemote) if (line.HasRemote)
{ {
note = _workspace.Settings.InsertMergeComment && group == 0
? BuildMergeNote(MergeNames.TheirsSource(TheirsSideName, TakesMergeSides))
: null;
after = line.RemoteText; after = line.RemoteText;
afterHasLocal = true; afterHasLocal = true;
} }
@ -2342,7 +2388,7 @@ namespace MergeHub.ViewModels
_undo.Push(new LineEdit(line, before, beforeHasLocal, after, afterHasLocal, _undo.Push(new LineEdit(line, before, beforeHasLocal, after, afterHasLocal,
EditKind.TakeBase, description) EditKind.TakeBase, description)
{ {
InsertedNote = InsertNoteAbove(line, note), InsertedNote = InsertNoteAbove(line, note, indentFrom: before),
Group = group, Group = group,
}); });
@ -2366,7 +2412,7 @@ namespace MergeHub.ViewModels
/// 정렬은 저절로 맞는다. 끼워 넣은 줄은 BASE 에 없는 "LOCAL 에만 있는 줄"이라, /// 정렬은 저절로 맞는다. 끼워 넣은 줄은 BASE 에 없는 "LOCAL 에만 있는 줄"이라,
/// 이미 추가된 줄을 다루던 방식 그대로 BASE 쪽에 빈 자리(필러)가 생긴다. /// 이미 추가된 줄을 다루던 방식 그대로 BASE 쪽에 빈 자리(필러)가 생긴다.
/// </summary> /// </summary>
private MergeLine? InsertNoteAbove(MergeLine line, string? note) private MergeLine? InsertNoteAbove(MergeLine line, string? note, string? indentFrom = null)
{ {
if (string.IsNullOrWhiteSpace(note)) return null; if (string.IsNullOrWhiteSpace(note)) return null;
@ -2374,7 +2420,8 @@ namespace MergeHub.ViewModels
if (at < 0) return null; if (at < 0) return null;
// 코드 줄과 들여쓰기를 맞춘다. 주석만 왼쪽 끝에 붙어 있으면 눈에 거슬린다. // 코드 줄과 들여쓰기를 맞춘다. 주석만 왼쪽 끝에 붙어 있으면 눈에 거슬린다.
var indent = new string(' ', line.LocalText.Length - line.LocalText.TrimStart().Length); // 지우는 반영이면 그 줄 글이 이미 비었으므로 지우기 전 글(indentFrom)이나 BASE 쪽 글에서 잰다.
var indent = IndentOf(line.LocalText, indentFrom, line.BaseText);
var row = new MergeLine(null, null, indent + note, null, var row = new MergeLine(null, null, indent + note, null,
Rules.IsPlain ? null : Rules.KeyOf) Rules.IsPlain ? null : Rules.KeyOf)
@ -2392,9 +2439,53 @@ namespace MergeHub.ViewModels
if (line.IsThreeWay) row.SetThreeWay(null, null, null, null); if (line.IsThreeWay) row.SetThreeWay(null, null, null, null);
Lines.Insert(at, row); Lines.Insert(at, row);
NoteRowMoved(at, +1);
return row; return row;
} }
/// <summary>처음으로 글이 있는 쪽의 앞 공백. 모두 비었으면 들여쓰지 않는다.</summary>
private static string IndentOf(params string?[] candidates)
{
foreach (var text in candidates)
{
if (string.IsNullOrWhiteSpace(text)) continue;
return new string(' ', text!.Length - text.TrimStart().Length);
}
return string.Empty;
}
/// <summary>
/// 주석 줄을 끼우거나 빼기 <b>직전</b> 캐럿이 있던 행(2026-09-30 사용자 보고 "Accept 뒤 Alt+↓ 를 두 번 눌러야 한다").
///
/// 편집기는 글을 갈아 끼울 때 캐럿을 <b>글자 수 자리</b>로 지킨다. 반영하면서 캐럿 위에 주석 한 줄이 끼면
/// 같은 글자 수 자리는 그 주석 줄이 되고, 그러면 "다음 차이" 가 방금 반영한 줄을 다음 차이로 잡았다.
/// 끼운 줄 수만큼 옮기는 셈은 지운 줄(편집기에 줄이 없는 행)에서 어긋나서, <b>행 자체</b>를 기억했다가
/// 글을 갈아 끼운 뒤 그 행이 몇 번째 편집기 줄이 됐는지(<see cref="EditorLayout.LineOfRow"/>)로 캐럿을 둔다.
/// </summary>
private MergeLine? _caretAnchor;
private void NoteRowMoved(int at, int delta)
{
// 처음 끼우거나 뺄 때만 잡는다 - 한 번 누른 반영이 주석을 여럿 넣어도 기준은 누르기 전 캐럿 행이다.
// 이미 바뀐 뒤라 캐럿 행 번호를 그만큼 되돌려 누르기 전 행을 찾는다.
if (_caretAnchor is not null) return;
var before = at <= CaretRow ? CaretRow + delta : CaretRow;
if (before >= 0 && before < Lines.Count) _caretAnchor = Lines[before];
}
/// <summary>
/// 편집기가 글을 갈아 끼운 뒤 캐럿을 둘 편집기 줄. 기억한 행이 없으면 -1(글자 수 자리를 그대로 지킨다).
/// 그 행이 LOCAL 에 없으면(지운 줄) 바로 다음 줄이다. 한 번 넘기면 비운다.
/// </summary>
public int TakeCaretLine()
{
var anchor = _caretAnchor;
_caretAnchor = null;
if (anchor is null) return -1;
var row = Lines.IndexOf(anchor);
return row < 0 ? -1 : EditorLayout.LineOfRow(_editorRows, row);
}
/// <summary>반영한 줄 위에 넣을 주석 한 줄. [사용자] 날짜 출처 설명(출처는 11차 F-1).</summary> /// <summary>반영한 줄 위에 넣을 주석 한 줄. [사용자] 날짜 출처 설명(출처는 11차 F-1).</summary>
private string BuildMergeNote(string? source) => MergeNote.Build( private string BuildMergeNote(string? source) => MergeNote.Build(
author: _workspace.AuthorName, author: _workspace.AuthorName,
@ -2518,6 +2609,7 @@ namespace MergeHub.ViewModels
{ {
if (_syncingEditor) return; if (_syncingEditor) return;
TouchSession(); // 12차 E-1 - 손이 멈추면 그때 한 번 보관한다
_reflowTimer.Stop(); _reflowTimer.Stop();
if (ShowTypedLineNow(text, caretIndex)) if (ShowTypedLineNow(text, caretIndex))
@ -2648,7 +2740,6 @@ namespace MergeHub.ViewModels
? ChangedLocalNumbers(_originalLocalText, localText) ? ChangedLocalNumbers(_originalLocalText, localText)
: new HashSet<int>(); : new HashSet<int>();
Lines.Clear();
foreach (var row in rows) foreach (var row in rows)
{ {
// 반영한 줄인지를 <b>먼저</b> 본다. // 반영한 줄인지를 <b>먼저</b> 본다.
@ -2667,10 +2758,12 @@ namespace MergeHub.ViewModels
if (!row.IsApplied && !row.IsMergeNote && row.LocalNumber is int number && touched.Contains(number)) if (!row.IsApplied && !row.IsMergeNote && row.LocalNumber is int number && touched.Contains(number))
row.IsManuallyEdited = true; row.IsManuallyEdited = true;
Lines.Add(row);
} }
// 줄마다 넣으면 알림이 줄 수만큼 간다 - 표시를 다 매긴 뒤 한 번에 넣는다. 위 · 아래로 그대로인 줄은 기존 객체를 두고
// 번호만 고치고, 바뀐 가운데만 빼고 넣는다 - 칸 목록이 보이는 줄 요소를 전부 다시 만들지 않게(MergeLineCollection.Patch).
Lines.Patch(rows);
if (keepAsEdited) { _undo.Clear(); _redo.Clear(); } // 위에서 글로 옮겨 이미 비었다 if (keepAsEdited) { _undo.Clear(); _redo.Clear(); } // 위에서 글로 옮겨 이미 비었다
else ClearHistory(); else ClearHistory();
@ -2750,8 +2843,6 @@ namespace MergeHub.ViewModels
[NotifyPropertyChangedFor(nameof(ModeLabel))] [NotifyPropertyChangedFor(nameof(ModeLabel))]
[NotifyPropertyChangedFor(nameof(BaseTitle))] [NotifyPropertyChangedFor(nameof(BaseTitle))]
[NotifyPropertyChangedFor(nameof(LocalTitle))] [NotifyPropertyChangedFor(nameof(LocalTitle))]
[NotifyPropertyChangedFor(nameof(DemoSwitchLabel))]
[NotifyPropertyChangedFor(nameof(CanSwitchDemo))]
private bool _isGitMode; private bool _isGitMode;
public bool IsPlainMode => !IsGitMode; public bool IsPlainMode => !IsGitMode;
@ -2915,7 +3006,7 @@ namespace MergeHub.ViewModels
if (string.IsNullOrWhiteSpace(LeftFile) && string.IsNullOrWhiteSpace(RightFile)) return; if (string.IsNullOrWhiteSpace(LeftFile) && string.IsNullOrWhiteSpace(RightFile)) return;
// 12차 E-2: 여는 길을 거치지 않고 바로 불린 경우에도 탭이 하나는 있게 한다 // 12차 E-2: 여는 길을 거치지 않고 바로 불린 경우에도 탭이 하나는 있게 한다
// (켤 때 기본 짝 · [기준 파일...] 로 한쪽만 바꾸기 · 데모 전환 뒤 돌아오기). // (켤 때 기본 짝 · [기준 파일...] 로 한쪽만 바꾸기).
EnsureTabForCurrentPair(); EnsureTabForCurrentPair();
// 파일 두 개 비교는 늘 두 칸이다. mergetool 네 칸에서 넘어왔으면 접는다. // 파일 두 개 비교는 늘 두 칸이다. mergetool 네 칸에서 넘어왔으면 접는다.
@ -2974,8 +3065,7 @@ namespace MergeHub.ViewModels
RefreshLanguage(); RefreshLanguage();
var rows = BuildLines(baseText, localText); var rows = BuildLines(baseText, localText);
Lines.Clear(); Lines.ReplaceAll(rows); // 알림 한 번(MergeLineCollection 참고)
foreach (var row in rows) Lines.Add(row);
SetPlainHeaders(); SetPlainHeaders();
@ -3538,10 +3628,11 @@ namespace MergeHub.ViewModels
/// <summary>지금 캐럿이 놓인 행. 편집기 줄 번호와 1:1 이다.</summary> /// <summary>지금 캐럿이 놓인 행. 편집기 줄 번호와 1:1 이다.</summary>
[ObservableProperty] private int _caretRow; [ObservableProperty] private int _caretRow;
/// <summary>줄을 옮기면 "이 덩어리 반영"이 켜지고 꺼진다.</summary> /// <summary>줄을 옮기면 [구간 반영] 이 켜지고 꺼진다.</summary>
partial void OnCaretRowChanged(int value) partial void OnCaretRowChanged(int value)
{ {
AcceptBlockCommand.NotifyCanExecuteChanged(); AcceptBlockCommand.NotifyCanExecuteChanged();
AcceptBlockToBaseCommand.NotifyCanExecuteChanged(); // 12차 G-2 - 왼쪽으로 보내는 짝
AcceptMemberCommand.NotifyCanExecuteChanged(); AcceptMemberCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(MemberHint)); OnPropertyChanged(nameof(MemberHint));
} }
@ -3975,14 +4066,21 @@ namespace MergeHub.ViewModels
if (toBefore) if (toBefore)
{ {
Lines.Remove(note); var was = Lines.IndexOf(note);
if (was >= 0)
{
Lines.RemoveAt(was);
NoteRowMoved(was, -1); // 되돌리기로 주석이 빠지면 캐럿도 한 줄 올라와야 같은 줄에 남는다
}
return; return;
} }
if (Lines.Contains(note)) return; if (Lines.Contains(note)) return;
var at = Lines.IndexOf(edit.Line); var at = Lines.IndexOf(edit.Line);
Lines.Insert(at < 0 ? Lines.Count : at, note); if (at < 0) at = Lines.Count;
Lines.Insert(at, note);
NoteRowMoved(at, +1);
} }
/// <summary> /// <summary>
@ -4104,6 +4202,7 @@ namespace MergeHub.ViewModels
{ {
// 줄을 고르거나 머지 주석이 끼어들면 결과 줄 수가 바뀐다. 그릴 때마다 번호를 다시 매긴다(8차 B-3). // 줄을 고르거나 머지 주석이 끼어들면 결과 줄 수가 바뀐다. 그릴 때마다 번호를 다시 매긴다(8차 B-3).
RenumberMerged(Lines); RenumberMerged(Lines);
RenumberBase(Lines); // 왼쪽도 마찬가지다(12차 B-1 로 왼쪽이 바뀔 수 있게 됐다)
var total = Lines.Count; var total = Lines.Count;
var diff = Lines.Count(l => l.IsDifferent); var diff = Lines.Count(l => l.IsDifferent);
@ -4215,8 +4314,8 @@ namespace MergeHub.ViewModels
/// 사내 소스에는 CP949 로 저장된 파일이 섞여 있어 새로 만드는 파일과 형식이 갈린다. /// 사내 소스에는 CP949 로 저장된 파일이 섞여 있어 새로 만드는 파일과 형식이 갈린다.
/// 읽어서 보여 주기만 하면 그 갈림을 못 고치므로 저장할 때 고를 수 있게 한다. /// 읽어서 보여 주기만 하면 그 갈림을 못 고치므로 저장할 때 고를 수 있게 한다.
/// </summary> /// </summary>
public IReadOnlyList<string> SaveEncodings => TextFile.SaveEncodings; public IReadOnlyList<SaveChoice> SaveEncodings => TextFile.SaveEncodingChoices;
public IReadOnlyList<string> SaveNewLines => TextFile.SaveNewLines; public IReadOnlyList<SaveChoice> SaveNewLines => TextFile.SaveNewLineChoices;
[ObservableProperty] private string _saveEncoding = TextFile.KeepOriginal; [ObservableProperty] private string _saveEncoding = TextFile.KeepOriginal;
[ObservableProperty] private string _saveNewLine = TextFile.KeepOriginal; [ObservableProperty] private string _saveNewLine = TextFile.KeepOriginal;
@ -4234,10 +4333,11 @@ namespace MergeHub.ViewModels
{ {
var block = BlockAround(CaretRow); var block = BlockAround(CaretRow);
if (block.Count == 0) return; if (block.Count == 0) return;
if (!AuthorReady()) return;
// 2-Way 는 BASE 를, 3-Way 는 저쪽(REMOTE)을 가져온다. BlockAround 주석 참고. // 2-Way 는 BASE 를, 3-Way 는 저쪽(REMOTE)을 가져온다. BlockAround 주석 참고.
// 주석은 덩어리 첫 줄 위 한 줄, 되돌리기는 한 번에(11차 F-2 - 예전에는 줄마다 주석이 붙었다). // 주석은 덩어리 첫 줄 위 한 줄, 되돌리기는 한 번에(11차 F-2 - 예전에는 줄마다 주석이 붙었다).
var count = ApplyGroup(block, anchor: null, what: "덩어리"); var count = ApplyGroup(block, anchor: null, what: "구간");
if (count > 0) MessageUtil.ShowSuccessToast($"{count}줄 반영"); if (count > 0) MessageUtil.ShowSuccessToast($"{count}줄 반영");
} }
@ -4253,17 +4353,20 @@ namespace MergeHub.ViewModels
{ {
var group = ++_lastGroup; var group = ++_lastGroup;
var changed = 0; var changed = 0;
var deleted = 0; // 주석에 "삭제 N줄" 로 한 번만 적는다(사용자 2026-09-30)
int? first = null, last = null; int? first = null, last = null;
foreach (var line in rows.ToList()) foreach (var line in rows.ToList())
{ {
if (line.IsMergeNote) continue; if (line.IsMergeNote) continue;
var number = IsThreeWay ? line.RemoteNumber : line.BaseNumber; var number = IsThreeWay ? line.RemoteNumber : line.BaseNumber;
var hadLocal = line.HasLocal;
var done = IsThreeWay var done = IsThreeWay
? line.CanTakeRemote && ApplyRemote(line, requery: false, group: group) ? line.CanTakeRemote && ApplyRemote(line, requery: false, group: group)
: line.CanTakeBase && Apply(line, requery: false, group: group); : line.CanTakeBase && Apply(line, requery: false, group: group);
if (!done) continue; if (!done) continue;
if (hadLocal && !line.HasLocal) deleted++;
changed++; changed++;
if (number is int n) { first ??= n; last = n; } if (number is int n) { first ??= n; last = n; }
} }
@ -4280,12 +4383,14 @@ namespace MergeHub.ViewModels
var where = first is null ? string.Empty : first == last ? $"{first}행 " : $"{first}~{last}행 "; var where = first is null ? string.Empty : first == last ? $"{first}행 " : $"{first}~{last}행 ";
var description = $"{where}{what} {source} 반영 ({changed}줄)"; var description = $"{where}{what} {source} 반영 ({changed}줄)";
// 묶음이 모두 지우는 반영이면 글이 남은 줄이 없다 - 그래도 지운 자리(첫 줄)에 주석을 남긴다(사용자 2026-09-30).
var at = anchor is { HasLocal: true } && Lines.Contains(anchor) var at = anchor is { HasLocal: true } && Lines.Contains(anchor)
? anchor ? anchor
: rows.FirstOrDefault(r => r.HasLocal && !r.IsMergeNote && Lines.Contains(r)); : rows.FirstOrDefault(r => r.HasLocal && !r.IsMergeNote && Lines.Contains(r))
?? rows.FirstOrDefault(r => !r.IsMergeNote && Lines.Contains(r));
if (at is not null && _workspace.Settings.InsertMergeComment) if (at is not null && _workspace.Settings.InsertMergeComment)
{ {
var note = BuildMergeNote(source); var note = BuildMergeNote(MergeNote.WithDeletion(source, deleted));
_undo.Push(new LineEdit(at, at.LocalText, at.HasLocal, at.LocalText, at.HasLocal, _undo.Push(new LineEdit(at, at.LocalText, at.HasLocal, at.LocalText, at.HasLocal,
EditKind.NoteOnly, description) EditKind.NoteOnly, description)
{ {
@ -4398,6 +4503,7 @@ namespace MergeHub.ViewModels
return; return;
} }
if (!AuthorReady()) return;
var (member, rows, _) = found.Value; var (member, rows, _) = found.Value;
var nameNumber = member.NameLine + 1; var nameNumber = member.NameLine + 1;
var anchor = rows.FirstOrDefault(r => (IsThreeWay ? r.RemoteNumber : r.BaseNumber) == nameNumber); var anchor = rows.FirstOrDefault(r => (IsThreeWay ? r.RemoteNumber : r.BaseNumber) == nameNumber);
@ -4576,11 +4682,12 @@ namespace MergeHub.ViewModels
private void UpdateLegendCounts() private void UpdateLegendCounts()
{ {
int changed = 0, applied = 0, edited = 0, added = 0, removed = 0, moved = 0, ignored = 0, same = 0; int changed = 0, applied = 0, edited = 0, added = 0, removed = 0, moved = 0, ignored = 0, same = 0;
int remote = 0, conflict = 0, needsDecision = 0; int remote = 0, conflict = 0, needsDecision = 0, inline = 0;
foreach (var line in Lines) foreach (var line in Lines)
{ {
if (line.NeedsDecision) needsDecision++; if (line.NeedsDecision) needsDecision++;
if (HasInlineChange(line)) inline++;
switch (line.Highlight) switch (line.Highlight)
{ {
@ -4608,9 +4715,24 @@ namespace MergeHub.ViewModels
RemoteChangeCount = remote; RemoteChangeCount = remote;
ConflictCount = conflict; ConflictCount = conflict;
UnresolvedConflictCount = needsDecision; UnresolvedConflictCount = needsDecision;
InlineChangeCount = inline;
SaveResolvedCommand.NotifyCanExecuteChanged(); SaveResolvedCommand.NotifyCanExecuteChanged();
} }
/// <summary>
/// 색 구분의 "줄 안에서 바뀐 부분" 줄 수(사용자 요청 2026-09-30 - 이 칸만 개수가 없었다).
/// 지금도 BASE 와 다른(<see cref="LineHighlight.Changed"/>) 줄 가운데 단어 단위로 달라진 구간이 있는 줄 - 화면에서 그 색이 칠해지는 줄이다.
/// 반영 · 무시로 색이 바뀐 줄은 세지 않는다.
/// </summary>
[ObservableProperty] private int _inlineChangeCount;
private static bool HasInlineChange(MergeLine line) =>
line.Highlight == LineHighlight.Changed &&
(HasChangedWord(line.BaseWords) || HasChangedWord(line.LocalWords));
private static bool HasChangedWord(IReadOnlyList<WordDiffSegment>? words) =>
words is not null && words.Any(w => w.Kind != WordDiffKind.Unchanged);
/// <summary> /// <summary>
/// MERGED 칸 번호를 <b>결과 파일 번호</b>로 다시 매긴다(8차 B-3). /// MERGED 칸 번호를 <b>결과 파일 번호</b>로 다시 매긴다(8차 B-3).
/// ///
@ -4628,6 +4750,63 @@ namespace MergeHub.ViewModels
for (var i = 0; i < rows.Count; i++) rows[i].LocalNumber = numbers[i]; for (var i = 0; i < rows.Count; i++) rows[i].LocalNumber = numbers[i];
} }
/// <summary>
/// 왼쪽(BASE) 칸 번호를 <b>왼쪽 파일 번호</b>로 다시 매긴다(12차 B-1 을 쓰다 나온 것, 2026-09-28).
///
/// <b>무엇이 잘못됐었나</b> - 12차 B-1 로 왼쪽에도 반영할 수 있게 되면서 왼쪽 줄이 늘고 줄게 됐는데,
/// 번호는 정렬기가 처음 준 것 그대로였다. 오른쪽에만 있던 줄을 왼쪽에 넣으면 그 줄은 <b>번호가 비고</b>
/// 뒤 줄은 옛 번호를 그대로 달고 있어, 왼쪽 파일이 8줄인데 칸에는 <c>01 02 (빈) 03 04 05 (빈) 06</c> 으로 보였다.
/// 저장하면 8줄이 되는데 번호는 6까지만 있으니 어느 줄을 고쳤는지 짚을 수가 없다.
///
/// <para>
/// Git 모드 · 네 칸(3-Way)의 BASE 는 지난 커밋 · 공통 조상이라 고칠 수 없다 - 그때 번호는 그 파일의 번호이므로
/// 건드리지 않는다. 두 파일 비교(<see cref="BothWays"/>)에서만 다시 맨다.
/// </para>
///
/// <para>
/// "머지 반영함" 표시는 <b>BASE 줄 번호를 열쇠로</b> 붙들고 있다(<see cref="_appliedBaseNumbers"/>) -
/// 번호를 바꾸면 그 열쇠도 같은 줄을 가리키도록 함께 옮긴다. 안 그러면 규칙을 한 번 건드리는 순간
/// 엉뚱한 줄이 "반영함"으로 칠해진다.
/// </para>
/// </summary>
private void RenumberBase(IList<MergeLine> rows)
{
if (!BothWays || rows is null || rows.Count == 0) return;
var inLeft = new bool[rows.Count];
for (var i = 0; i < rows.Count; i++) inLeft[i] = rows[i].HasBase;
var numbers = MergedNumbering.Assign(inLeft);
// 바뀐 것이 없으면(연 직후가 그렇다) 표시도 건드리지 않는다.
var changed = false;
for (var i = 0; i < rows.Count; i++)
{
if (rows[i].BaseNumber == numbers[i]) continue;
changed = true;
break;
}
if (!changed) return;
var moved = _appliedBaseNumbers.Count > 0 ? new HashSet<int>() : null;
for (var i = 0; i < rows.Count; i++)
{
if (moved is not null && rows[i].BaseNumber is int was && numbers[i] is int now &&
_appliedBaseNumbers.Contains(was))
{
moved.Add(now);
}
rows[i].BaseNumber = numbers[i];
}
if (moved is null) return;
_appliedBaseNumbers.Clear();
foreach (var number in moved) _appliedBaseNumbers.Add(number);
}
private string BuildLocalText() => PaneText.Build(Lines, MergeLine.TextPane.Local); private string BuildLocalText() => PaneText.Build(Lines, MergeLine.TextPane.Local);
} }
} }

View File

@ -0,0 +1,448 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MergeHub.Services;
using UbisamBase.Core.Messaging;
namespace MergeHub.ViewModels
{
/// <summary>
/// 메모장 탭(12차 F-1). 사용자가 정한 것 — <b>메모 + 파일 열기 둘 다</b>, 그리고
/// <b>저장 단추 없이 계속 남는다</b>.
///
/// <para>
/// 탭 줄 · 저장 안 한 표시(가운뎃점) · Ctrl+Tab 은 파일 비교의 문서 탭(12차 E-2)과 <b>같은 모양</b>이다 -
/// 두 화면이 서로 다르게 움직이면 배우는 것이 둘이 된다.
/// </para>
///
/// <para>
/// 보관 자리는 <c>{설정 폴더}\Memo\</c> — E-1 의 <c>Session\</c> 과 따로 둔다.
/// 저쪽은 "저장하면 사라지는 것"이고 메모는 "지우기 전까지 남는 것"이라 수명이 다르다.
/// </para>
/// </summary>
public sealed partial class MemoViewModel : ObservableObject
{
private readonly GitWorkspace _workspace;
private readonly MemoStore _store;
/// <summary>글자가 멈춘 뒤에 한 번만 쓴다(E-1 과 같은 2초).</summary>
private readonly DispatcherTimer _saveTimer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(2)
};
public MemoViewModel(GitWorkspace workspace)
{
_workspace = workspace;
_store = new MemoStore(MemoStore.DefaultRoot(UbisamBase.Core.Bootstrap.UbisamAppBuilder.ConfigRoot));
_saveTimer.Tick += (_, _) => SaveAll();
Restore();
// 한 장도 없으면 빈 메모 하나를 띄워 둔다 - 빈 화면에서는 무엇을 해야 할지 알 수 없다.
if (!Book.Any) NewMemo();
}
public MemoBook Book { get; } = new MemoBook();
/// <summary>탭 줄에 뿌릴 목록.</summary>
public ObservableCollection<Memo> Items { get; } = new ObservableCollection<Memo>();
/// <summary>탭 줄에서 고른 장. 고르는 것만으로 옮겨 간다.</summary>
public Memo? CurrentMemo
{
get => Book.Current;
set
{
if (value is null || ReferenceEquals(value, Book.Current)) return;
GoTo(value);
}
}
public bool HasMemos => Book.Any;
[ObservableProperty] private string _status = "메모는 저장하지 않아도 남습니다.";
// ================= 편집기와 잇는 자리 =================
/// <summary>
/// 편집기 글은 화면이 들고 있고 여기서는 필요할 때만 읽어 간다 -
/// 바인딩으로 글자마다 문서 전체를 넘기면 큰 글에서 눈에 띄게 느리다(파일 비교와 같은 까닭).
/// </summary>
public Func<string>? ReadEditorText { get; set; }
public Func<int>? ReadCaretIndex { get; set; }
/// <summary>편집기 글을 통째로 갈아 끼운다(장을 옮길 때).</summary>
public event EventHandler<string>? EditorTextReplaced;
/// <summary>캐럿을 이 자리로 옮겨 달라고 화면에 부탁한다.</summary>
public event EventHandler<int>? CaretIndexRequested;
private bool _loading;
/// <summary>사용자가 글을 고쳤다고 화면이 알려 준다.</summary>
public void NotifyEdited(string text, int caretIndex)
{
if (_loading || Book.Current is not { } memo) return;
if (!ReferenceEquals(_shown, memo)) return; // 화면이 아직 이 장을 싣지 않았다
memo.Text = text;
memo.CaretIndex = caretIndex;
memo.IsDirty = true;
Touch();
RaiseCanExecuteChanged();
}
private void Touch()
{
if (_loading) return;
_saveTimer.Stop();
_saveTimer.Start();
}
private void Sync()
{
if (!Items.SequenceEqual(Book.Memos))
{
Items.Clear();
foreach (var memo in Book.Memos) Items.Add(memo);
}
OnPropertyChanged(nameof(CurrentMemo));
OnPropertyChanged(nameof(HasMemos));
RaiseCanExecuteChanged();
}
private void RaiseCanExecuteChanged()
{
SaveCommand.NotifyCanExecuteChanged();
SaveAsCommand.NotifyCanExecuteChanged();
CloseMemoCommand.NotifyCanExecuteChanged();
NextMemoCommand.NotifyCanExecuteChanged();
PreviousMemoCommand.NotifyCanExecuteChanged();
}
/// <summary>그 장을 편집기에 올린다.</summary>
private void Show(Memo? memo)
{
_loading = true;
try
{
EditorTextReplaced?.Invoke(this, memo?.Text ?? string.Empty);
if (memo is not null && memo.CaretIndex > 0)
CaretIndexRequested?.Invoke(this, memo.CaretIndex);
_shown = memo;
}
finally
{
_loading = false;
}
}
private void GoTo(Memo memo)
{
// 떠 있던 장의 글을 먼저 거둔다 - 화면이 주인이라 여기 것이 낡았을 수 있다.
Harvest();
Book.Activate(memo);
Sync();
Show(memo);
SaveAll();
}
/// <summary>
/// 편집기가 지금 <b>어느 장</b>을 싣고 있는지. 화면에서 글을 거둬 올 때 이것이 맞아야 한다.
///
/// 탭을 옮겨 다니면 화면(MemoView)이 다시 만들어질 수 있는데, 그때 편집기는 잠깐 <b>비어 있다</b>.
/// 그 틈에 거두면 빈 글로 메모를 덮어쓴다 - 화면에서 보고 찾았다(2026-09-28).
/// </summary>
private Memo? _shown;
/// <summary>화면에 떠 있는 글을 그 장에 옮겨 담는다.</summary>
private void Harvest()
{
if (Book.Current is not { } memo || ReadEditorText is null) return;
// 편집기가 아직 이 장을 싣지 않았으면 거두지 않는다.
if (!ReferenceEquals(_shown, memo)) return;
memo.Text = ReadEditorText();
memo.CaretIndex = ReadCaretIndex?.Invoke() ?? memo.CaretIndex;
}
// ================= 명령 =================
[RelayCommand]
private void NewMemo()
{
Harvest();
var memo = Book.Add();
Sync();
Show(memo);
Status = $"{memo.Title} 을(를) 만들었습니다.";
SaveAll();
}
[RelayCommand]
private void OpenFile()
{
var dialog = new Microsoft.Win32.OpenFileDialog
{
Title = "메모로 열 파일 고르기",
Filter = "글 파일 (*.txt;*.md;*.log;*.cs;*.json;*.xml)|*.txt;*.md;*.log;*.cs;*.json;*.xml|모든 파일 (*.*)|*.*"
};
if (dialog.ShowDialog() != true) return;
try
{
var text = File.ReadAllText(dialog.FileName, Encoding.UTF8);
Harvest();
var memo = Book.Open(dialog.FileName, text);
memo.IsDirty = false;
Sync();
Show(memo);
Status = $"{memo.Title} 을(를) 열었습니다.";
SaveAll();
}
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
{
MessageUtil.ShowErrorToast($"파일을 열지 못했습니다: {ex.Message}");
}
}
private bool CanSave() => Book.Current is not null;
/// <summary>
/// 파일에 쓴다. 이름이 없는 메모면 <see cref="SaveAs"/> 로 넘어간다.
/// <b>메모 자체는 저장하지 않아도 남는다</b> - 이 단추는 "파일로 내보내 두기" 에 가깝다.
/// </summary>
[RelayCommand(CanExecute = nameof(CanSave))]
private void Save()
{
if (Book.Current is { } memo) SaveMemo(memo);
}
[RelayCommand(CanExecute = nameof(CanSave))]
private void SaveAs()
{
if (Book.Current is { } memo) SaveMemoAs(memo);
}
/// <summary>파일에 쓴다. 이름이 없으면 어디에 쓸지 묻는다. 썼으면 true.</summary>
private bool SaveMemo(Memo memo)
{
if (!memo.HasFile) return SaveMemoAs(memo);
if (ReferenceEquals(memo, Book.Current)) Harvest();
if (!WriteFile(memo, memo.Path!)) return false;
memo.IsDirty = false;
Status = $"{memo.Title} 에 저장했습니다.";
SaveAll();
return true;
}
/// <summary>어디에 쓸지 묻고 쓴다. 사용자가 그만두면 false.</summary>
private bool SaveMemoAs(Memo memo)
{
var dialog = new Microsoft.Win32.SaveFileDialog
{
Title = "메모를 파일로 저장",
FileName = memo.HasFile ? Path.GetFileName(memo.Path!) : memo.Title + ".txt",
Filter = "글 파일 (*.txt)|*.txt|Markdown (*.md)|*.md|모든 파일 (*.*)|*.*"
};
if (dialog.ShowDialog() != true) return false;
if (ReferenceEquals(memo, Book.Current)) Harvest();
if (!WriteFile(memo, dialog.FileName)) return false;
Book.Attach(memo, dialog.FileName);
memo.IsDirty = false;
Sync();
Status = $"{memo.Title} 에 저장했습니다.";
SaveAll();
return true;
}
private bool WriteFile(Memo memo, string path)
{
try
{
// 저장하기 전 백업(11차 F-9)은 파일 비교와 같은 자리를 쓴다.
_workspace.Backup.Keep(path);
File.WriteAllText(path, memo.Text, new UTF8Encoding(true));
_workspace.AppendLog($"메모 저장: {path}");
return true;
}
catch (Exception ex)
{
MessageUtil.ShowErrorToast($"저장하지 못했습니다: {ex.Message}");
return false;
}
}
private bool CanClose() => Book.Current is not null;
/// <summary>
/// 장을 닫는다.
///
/// <b>저장하지 않은 것이 있으면 묻는다</b>(사용자 결정 2026-09-28) - 닫는 것은 그 메모를 버리는 일이라
/// 되돌릴 수 없다. 예/아니오로는 "저장하고 닫기" 와 "그만두기" 를 가를 수 없어 세 갈래로 묻는다.
/// 이름 없는 메모에서 [저장하고 닫기] 를 고르면 어디에 쓸지 묻는 창으로 이어진다.
/// </summary>
[RelayCommand(CanExecute = nameof(CanClose))]
private void CloseMemo(Memo? memo)
{
memo ??= Book.Current;
if (memo is null) return;
// 묻기 전에 화면의 글을 거둔다 - 방금 친 글까지 저장되어야 한다.
if (ReferenceEquals(memo, Book.Current)) Harvest();
if (memo.IsDirty)
{
var choice = MessageUtil.ShowChoice(
"메모 닫기",
$"{memo.Title} 에 저장하지 않은 것이 있습니다.\n\n닫으면 이 메모는 사라집니다.",
"저장하고 닫기", "저장하지 않고 닫기");
if (choice == DialogChoice.Cancel) return;
if (choice == DialogChoice.Primary)
{
// 저장이 막히면(취소 · 쓰기 실패) 닫지 않는다 - 여기서 넘어가면 그대로 잃는다.
if (!SaveMemo(memo)) return;
}
}
var name = memo.Title;
var next = Book.Close(memo);
Sync();
Show(next);
Status = $"{name} 을(를) 닫았습니다.";
SaveAll();
}
private bool CanStep() => Book.Count > 1;
[RelayCommand(CanExecute = nameof(CanStep))]
private void NextMemo()
{
Harvest();
var at = Book.CurrentIndex;
if (at < 0 || Book.Count < 2) return;
GoTo(Book.Memos[(at + 1) % Book.Count]);
}
[RelayCommand(CanExecute = nameof(CanStep))]
private void PreviousMemo()
{
Harvest();
var at = Book.CurrentIndex;
if (at < 0 || Book.Count < 2) return;
GoTo(Book.Memos[(at - 1 + Book.Count) % Book.Count]);
}
[RelayCommand]
private void ActivateMemo(Memo? memo)
{
if (memo is not null) GoTo(memo);
}
// ================= 찾기 · 바꾸기 (사용자 결정) =================
[ObservableProperty] private bool _isFindOpen;
[ObservableProperty] private string _findText = string.Empty;
[ObservableProperty] private string _replaceText = string.Empty;
[ObservableProperty] private bool _findMatchCase;
[RelayCommand]
private void OpenFind() => IsFindOpen = true;
[RelayCommand]
private void CloseFind() => IsFindOpen = false;
// ================= 보관 =================
/// <summary>
/// 지금 있는 것을 모두 보관한다. 쓰는 일은 다른 실로 넘긴다(E-1 과 같다).
/// </summary>
private void SaveAll()
{
_saveTimer.Stop();
if (_loading) return;
Harvest();
var list = Book.Memos.Select(memo => new MemoStore.Saved
{
Number = memo.Number,
Path = memo.Path,
Dirty = memo.IsDirty,
CaretIndex = memo.CaretIndex,
Text = memo.Text
}).ToList();
var current = Book.CurrentIndex;
var store = _store;
Task.Run(() => store.Save(list, current));
}
/// <summary>켤 때 보관해 둔 메모를 되살린다. 묻지 않는다.</summary>
private void Restore()
{
List<MemoStore.Saved> saved;
int current;
try { (saved, current) = _store.Load(); }
catch (Exception) { return; }
if (saved.Count == 0) return;
_loading = true;
try
{
foreach (var one in saved)
{
var memo = new Memo
{
Number = one.Number,
Path = one.Path,
Text = one.Text,
IsDirty = one.Dirty,
CaretIndex = one.CaretIndex
};
// 파일로 연 메모인데 고친 것이 없으면 <b>파일에서 다시 읽는다</b> -
// 그 사이 남이 고쳤으면 그쪽이 맞다.
if (memo.HasFile && !memo.IsDirty && File.Exists(memo.Path))
{
try { memo.Text = File.ReadAllText(memo.Path!, Encoding.UTF8); }
catch (IOException) { }
catch (UnauthorizedAccessException) { }
}
Book.Adopt(memo);
}
Book.Activate(current >= 0 ? current : Book.Count - 1);
Sync();
Status = $"메모 {Book.Count}장을 되살렸습니다.";
}
finally
{
_loading = false;
}
}
/// <summary>화면이 처음 붙을 때 지금 장을 편집기에 올린다.</summary>
public void ShowCurrent() => Show(Book.Current);
}
}

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

@ -0,0 +1,44 @@
using System;
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;
namespace MergeHub.Views.Controls
{
/// <summary>
/// 파일 비교 편집 칸의 바탕 - 밝은 테마 #FAFAFA(부드러운 흰색), 어두운 테마 #252830(짙은 남색). 사용자가 후보를 보고 골랐다(2026-09-30).
/// 흰색 · 순검정으로 했다가 "어두운 쪽이 너무 까맣다", "둘 다 보기 편하게" 해서 바꿨다. 어두운 쪽은 페이지(#23262B)와 같은 남색 톤이라 튀지 않는다.
///
/// <para>
/// 예전에는 칸에 바탕이 없어 뒤의 페이지 바탕(밝음 #F2F2F3 · 어두움 #23262B)이 비쳤다 - 둘 다 회색이다.
/// 플랫폼 색 토큰에는 흰색/검은색 짝이 없어서, 코드 글자색(<see cref="CodeTextBlock"/>)과 같은 방법으로
/// <b>물려받은 글자색의 밝기</b>로 테마를 가린다. 테마를 바꾸면 글자색이 바뀌어 바인딩이 다시 돈다.
/// </para>
/// </summary>
public sealed class PaneBackgroundConverter : IValueConverter
{
public static readonly Brush Light = Freeze(Color.FromRgb(0xFA, 0xFA, 0xFA));
public static readonly Brush Dark = Freeze(Color.FromRgb(0x25, 0x28, 0x30));
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
IsLightText(value as SolidColorBrush) ? Dark : Light;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException();
/// <summary>글자색이 밝으면 어두운 테마다. 글자색을 모르면 밝은 테마로 본다.</summary>
public static bool IsLightText(SolidColorBrush? brush)
{
if (brush is null) return false;
var c = brush.Color;
return (0.299 * c.R + 0.587 * c.G + 0.114 * c.B) / 255.0 > 0.5;
}
private static Brush Freeze(Color color)
{
var b = new SolidColorBrush(color);
b.Freeze();
return b;
}
}
}

View File

@ -112,49 +112,67 @@
</UserControl.Resources> </UserControl.Resources>
<Grid Margin="10"> <Grid Margin="10">
<!--
12차 D-1: 위쪽을 <b>한 줄</b>로. 예전에는 경로 · 옵션 · 찾기 · 색 범례가 네 줄(203 DIP)을 먹었다.
자주 쓰는 것만 줄에 두고(경로 · 좌우 바꾸기 · 비교), 가끔 쓰는 것은 [옵션 ▾] 서랍으로 넣었다.
찾기는 Ctrl+F 로 부를 때만 펴고, 색 범례는 아래 상태 줄로 옮겼다.
-->
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> <!-- ① 위 한 줄 -->
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> <!-- ② 폴더 안 찾기(Ctrl+F 로 펼 때만) -->
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> <!-- ③ 목록 머리글 -->
<RowDefinition Height="Auto"/> <RowDefinition Height="*"/> <!-- ④ 트리 -->
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> <!-- ⑤ 아래 상태 줄(색 범례 · 요약 · 동작) -->
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<!-- ① 폴더 두 개 --> <!-- ① 위 한 줄: 왼쪽 경로 ⇄ 오른쪽 경로 · [비교] · [옵션 ▾] -->
<Border Grid.Row="0" Padding="8" BorderThickness="1" Margin="0,0,0,6" <Border Grid.Row="0" Padding="6,5" BorderThickness="1" Margin="0,0,0,4"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"> BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Button Grid.Column="0" Content="왼쪽 폴더..." Command="{Binding PickLeftFolderCommand}" <!-- 폴더 고르기는 글자 대신 점 셋으로. 경로 칸 바로 앞에 두어 어느 쪽 것인지 헷갈리지 않는다. -->
Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0"/> <Button Grid.Column="0" Content="..." Command="{Binding PickLeftFolderCommand}"
<TextBox Grid.Column="1" Text="{Binding LeftFolder, UpdateSourceTrigger=PropertyChanged}" Width="28" Height="26" MinWidth="28" Padding="0" FontSize="11" Margin="0,0,3,0"
ToolTip="왼쪽(기준) 폴더 고르기"/>
<TextBox Grid.Column="1" x:Name="LeftFolderBox"
Text="{Binding LeftFolder, UpdateSourceTrigger=PropertyChanged}"
AllowDrop="True" PreviewDragOver="View_DragOver" PreviewDrop="LeftFolder_Drop" AllowDrop="True" PreviewDragOver="View_DragOver" PreviewDrop="LeftFolder_Drop"
FontFamily="Consolas" FontSize="11" Height="28" VerticalContentAlignment="Center" FontFamily="Consolas" FontSize="11" Height="26" MinWidth="120"
Margin="0,0,14,0" ToolTip="비교 기준이 되는 폴더"/> VerticalContentAlignment="Center"
ToolTip="비교 기준이 되는 폴더. 끌어다 놓아도 됩니다"/>
<Button Grid.Column="2" Content="오른쪽 폴더..." Command="{Binding PickRightFolderCommand}" <!-- 좌우 바꾸기. 반대로 견주려고 두 번 고르지 않게 한다(12차 D-1). -->
Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0"/> <Button Grid.Column="2" x:Name="SwapButton" Content="&#8646;" Command="{Binding SwapFoldersCommand}"
<TextBox Grid.Column="3" Text="{Binding RightFolder, UpdateSourceTrigger=PropertyChanged}" Width="28" Height="26" MinWidth="28" Padding="0" FontSize="13" Margin="4,0,4,0"
ToolTip="왼쪽 · 오른쪽 폴더 맞바꾸기"/>
<Button Grid.Column="3" Content="..." Command="{Binding PickRightFolderCommand}"
Width="28" Height="26" MinWidth="28" Padding="0" FontSize="11" Margin="0,0,3,0"
ToolTip="오른쪽(비교 대상) 폴더 고르기"/>
<TextBox Grid.Column="4" x:Name="RightFolderBox"
Text="{Binding RightFolder, UpdateSourceTrigger=PropertyChanged}"
AllowDrop="True" PreviewDragOver="View_DragOver" PreviewDrop="RightFolder_Drop" AllowDrop="True" PreviewDragOver="View_DragOver" PreviewDrop="RightFolder_Drop"
FontFamily="Consolas" FontSize="11" Height="28" VerticalContentAlignment="Center" FontFamily="Consolas" FontSize="11" Height="26" MinWidth="120"
ToolTip="견줄 폴더"/> VerticalContentAlignment="Center"
ToolTip="견줄 폴더. 끌어다 놓아도 됩니다"/>
<TextBlock Grid.Column="4" Text="최근" VerticalAlignment="Center" Style="{StaticResource Muted}" <!-- 최근 짝. 글자 없이 화살표만 - 고르면 바로 연다. -->
FontSize="11" Margin="14,0,4,0"/>
<ComboBox x:Name="RecentBox" ItemsSource="{Binding RecentFolders}" <ComboBox x:Name="RecentBox" ItemsSource="{Binding RecentFolders}"
SelectionChanged="Recent_SelectionChanged" SelectionChanged="Recent_SelectionChanged" Grid.Column="5"
Width="200" Height="28" FontSize="11" Grid.Column="5" Margin="0" Width="34" MinWidth="34" MaxWidth="34" Height="26" FontSize="11" Margin="6,0,0,0"
VerticalContentAlignment="Center" HorizontalAlignment="Left" VerticalContentAlignment="Center"
ToolTip="최근에 견준 짝. 고르면 바로 엽니다"> ToolTip="최근에 견준 짝. 고르면 바로 엽니다">
<ComboBox.ItemTemplate> <ComboBox.ItemTemplate>
<DataTemplate> <DataTemplate>
@ -173,56 +191,88 @@
</TextBlock> </TextBlock>
</DataTemplate> </DataTemplate>
</ComboBox.ItemTemplate> </ComboBox.ItemTemplate>
</ComboBox> </Grid>
</Border>
<!-- ② 비교 방식 · 필터 -->
<Border Grid.Row="1" Padding="8" BorderThickness="1" Margin="0,0,0,6"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<WrapPanel Orientation="Horizontal">
<CheckBox Content="빠른 비교 (크기·수정일만)" IsChecked="{Binding QuickCompare}"
VerticalAlignment="Center" Margin="0,0,16,0"
ToolTip="내용을 읽지 않습니다. 파일이 아주 많거나 네트워크 드라이브일 때 씁니다"/>
<CheckBox Content="하위 폴더 포함" IsChecked="{Binding IncludeSubfolders}"
VerticalAlignment="Center" Margin="0,0,16,0"/>
<!-- 보기 필터는 다시 비교하지 않고 목록만 다시 그린다. -->
<TextBlock Text="보기" VerticalAlignment="Center" Style="{StaticResource Muted}"
FontSize="11" Margin="0,0,4,0"/>
<ComboBox ItemsSource="{Binding Filters}" SelectedItem="{Binding Filter}"
Width="110" Height="26" FontSize="11" Margin="0,0,16,0"
VerticalContentAlignment="Center"
ToolTip="파일이 수백 개면 눈으로 고르는 것이 제일 느립니다">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource FolderFilterName}}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox> </ComboBox>
<TextBlock Text="포함" VerticalAlignment="Center" Style="{StaticResource Muted}" <!-- 서랍에 넣은 것 가운데 켜 둔 것. 안 그러면 "왜 이 파일이 안 보이지" 가 된다. -->
FontSize="11" Margin="0,0,4,0"/> <TextBlock Grid.Column="6" Text="{Binding OptionSummary}" Style="{StaticResource Muted}"
<TextBox Text="{Binding IncludePattern, UpdateSourceTrigger=PropertyChanged}" FontSize="11" Margin="10,0,0,0" MaxWidth="260" TextTrimming="CharacterEllipsis"
Width="150" Height="26" FontFamily="Consolas" FontSize="11" ToolTip="지금 켜 둔 옵션입니다. [옵션 ▾] 에서 고칩니다"
VerticalContentAlignment="Center" Margin="0,0,12,0" Visibility="{Binding HasOptionSummary, Converter={StaticResource BoolToVis}}"/>
ToolTip="정규식. 비우면 전부 포함합니다 (예: \.cs$)"/>
<TextBlock Text="제외" VerticalAlignment="Center" Style="{StaticResource Muted}" <Button Grid.Column="7" Content="비교" Command="{Binding CompareCommand}"
FontSize="11" Margin="0,0,4,0"/> Height="26" MinWidth="70" Margin="10,0,4,0"
<TextBox Text="{Binding ExcludePattern, UpdateSourceTrigger=PropertyChanged}"
Width="260" Height="26" FontFamily="Consolas" FontSize="11"
VerticalContentAlignment="Center" Margin="0,0,16,0"
ToolTip="정규식. 포함 규칙보다 우선합니다"/>
<Button Content="비교" Command="{Binding CompareCommand}" Height="28" MinWidth="80"
Margin="0,0,6,0"
Style="{StaticResource FilledButton}" Style="{StaticResource FilledButton}"
Background="{DynamicResource Ubisam.Brush.Accent.Fill}" Background="{DynamicResource Ubisam.Brush.Accent.Fill}"
Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}" FontWeight="Bold"/> Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}" FontWeight="Bold"/>
<!-- 큰 폴더를 잘못 고르면 기다리는 수밖에 없었다. 비교하는 동안에만 켜진다. --> <!-- 큰 폴더를 잘못 고르면 기다리는 수밖에 없었다. 비교하는 동안에만 켜진다. -->
<Button Content="취소" Command="{Binding CancelCompareCommand}" Height="28" MinWidth="60" <Button Grid.Column="8" Content="취소" Command="{Binding CancelCompareCommand}"
Height="26" MinWidth="54" Margin="0,0,4,0"
ToolTip="비교를 멈춥니다. 앞서 본 결과는 그대로 남습니다"/> ToolTip="비교를 멈춥니다. 앞서 본 결과는 그대로 남습니다"/>
</WrapPanel>
<!-- 여느 단추로 둔다. 플랫폼 기본 ToggleButton 은 <b>스위치 모양</b>이라
이 줄의 다른 단추와 따로 논다(화면에서 보고 고침). -->
<Button Grid.Column="9" x:Name="OptionsButton" Content="옵션 &#9662;"
Click="Options_Click"
Height="26" MinWidth="62" Padding="8,0" FontSize="11"
ToolTip="빠른 비교 · 하위 폴더 · 보기 · 포함 · 제외 규칙"/>
<!-- 옵션 서랍. 가끔 쓰는 것을 여기에 넣어 위쪽 세 줄을 없앴다(12차 D-1). -->
<Popup Grid.Column="9" PlacementTarget="{Binding ElementName=OptionsButton}"
Placement="Bottom" HorizontalOffset="-220" StaysOpen="False"
IsOpen="{Binding IsOptionsOpen, Mode=TwoWay}" AllowsTransparency="True">
<Border Background="{DynamicResource Ubisam.Brush.ContentBackground}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}" BorderThickness="1"
Padding="12" Margin="0,2,8,8">
<Border.Effect>
<DropShadowEffect BlurRadius="10" ShadowDepth="2" Opacity="0.25"/>
</Border.Effect>
<StackPanel MinWidth="360">
<CheckBox Content="빠른 비교 (크기·수정일만)" IsChecked="{Binding QuickCompare}"
Margin="0,0,0,8"
ToolTip="내용을 읽지 않습니다. 파일이 아주 많거나 네트워크 드라이브일 때 씁니다"/>
<CheckBox Content="하위 폴더 포함" IsChecked="{Binding IncludeSubfolders}"
Margin="0,0,0,10"/>
<!-- 보기 필터는 다시 비교하지 않고 목록만 다시 그린다. -->
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="보기" Style="{StaticResource Muted}" FontSize="11"
Width="40"/>
<ComboBox ItemsSource="{Binding Filters}" SelectedItem="{Binding Filter}"
Width="160" Height="26" FontSize="11"
VerticalContentAlignment="Center"
ToolTip="파일이 수백 개면 눈으로 고르는 것이 제일 느립니다">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource FolderFilterName}}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="포함" Style="{StaticResource Muted}" FontSize="11" Width="40"/>
<TextBox Text="{Binding IncludePattern, UpdateSourceTrigger=PropertyChanged}"
Width="300" Height="26" FontFamily="Consolas" FontSize="11"
VerticalContentAlignment="Center"
ToolTip="정규식. 비우면 전부 포함합니다 (예: \.cs$)"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<TextBlock Text="제외" Style="{StaticResource Muted}" FontSize="11" Width="40"/>
<TextBox Text="{Binding ExcludePattern, UpdateSourceTrigger=PropertyChanged}"
Width="300" Height="26" FontFamily="Consolas" FontSize="11"
VerticalContentAlignment="Center"
ToolTip="정규식. 포함 규칙보다 우선합니다"/>
</StackPanel>
<Button Content="폴더 안 찾기 (Ctrl+F)" Command="{Binding OpenFindCommand}"
Height="26" HorizontalAlignment="Left" Padding="10,0" FontSize="11"
ToolTip="폴더 안에서 글자를 찾는 줄을 폅니다"/>
</StackPanel>
</Border>
</Popup>
</Grid>
</Border> </Border>
<!-- <!--
@ -231,7 +281,8 @@
폴더 비교는 "무엇이 다른가"에 답하지만 "이 함수를 쓰는 곳이 어디인가"에는 답하지 못한다. 폴더 비교는 "무엇이 다른가"에 답하지만 "이 함수를 쓰는 곳이 어디인가"에는 답하지 못한다.
그때마다 다른 도구를 켜는 대신 여기서 찾고 결과를 눌러 바로 그 줄로 넘어간다. 그때마다 다른 도구를 켜는 대신 여기서 찾고 결과를 눌러 바로 그 줄로 넘어간다.
--> -->
<Border Grid.Row="2" Padding="8" BorderThickness="1" Margin="0,0,0,6" <Border Grid.Row="1" Padding="8,5" BorderThickness="1" Margin="0,0,0,4"
Visibility="{Binding IsFindOpen, Converter={StaticResource BoolToVis}}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"> BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<WrapPanel Orientation="Horizontal"> <WrapPanel Orientation="Horizontal">
<TextBlock Text="폴더 안 찾기" VerticalAlignment="Center" FontSize="11" FontWeight="Bold" <TextBlock Text="폴더 안 찾기" VerticalAlignment="Center" FontSize="11" FontWeight="Bold"
@ -266,58 +317,20 @@
Margin="0,0,6,0" Margin="0,0,6,0"
ToolTip="고른 줄을 '파일 비교' 탭에서 그 자리에 서서 엽니다. 더블클릭·Enter 도 같습니다"/> ToolTip="고른 줄을 '파일 비교' 탭에서 그 자리에 서서 엽니다. 더블클릭·Enter 도 같습니다"/>
<Button Content="결과 닫기" Command="{Binding ClearSearchCommand}" Height="28" MinWidth="70" <Button Content="결과 닫기" Command="{Binding ClearSearchCommand}" Height="28" MinWidth="70"
Margin="0,0,12,0" Margin="0,0,6,0"
ToolTip="결과 칸을 접고 목록에 자리를 돌려줍니다"/> ToolTip="결과 칸을 접고 목록에 자리를 돌려줍니다"/>
<!-- 12차 D-1: 찾기 줄 자체를 접는다. Esc 로도 접힌다. -->
<Button Content="찾기 접기" Command="{Binding CloseFindCommand}" Height="28" MinWidth="70"
Margin="0,0,12,0"
ToolTip="찾기 줄을 접습니다 (Esc). 다시 부르려면 Ctrl+F"/>
<TextBlock Text="{Binding SearchSummary}" VerticalAlignment="Center" FontSize="11" <TextBlock Text="{Binding SearchSummary}" VerticalAlignment="Center" FontSize="11"
Style="{StaticResource Muted}" TextTrimming="CharacterEllipsis" MaxWidth="520"/> Style="{StaticResource Muted}" TextTrimming="CharacterEllipsis" MaxWidth="520"/>
</WrapPanel> </WrapPanel>
</Border> </Border>
<!--
④ 색 뜻풀이. 색만 칠해 두면 무슨 뜻인지 아무도 모른다 -
파일 비교 화면의 "색 구분" 줄과 같은 성격이다.
-->
<Border Grid.Row="3" Padding="8,4" BorderThickness="1,1,1,0" Margin="0,0,0,0"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<WrapPanel Orientation="Horizontal">
<TextBlock Text="색 구분" Style="{StaticResource Muted}" FontSize="11" Margin="0,0,10,0"
VerticalAlignment="Center"/>
<Border Width="12" Height="12" Margin="0,0,4,0" VerticalAlignment="Center"
Background="{Binding Source={x:Static svc:FolderEntryStatus.Different},
Converter={StaticResource StatusBrush}}"
BorderThickness="1" BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"/>
<TextBlock Text="다름" FontSize="11" Margin="0,0,14,0" VerticalAlignment="Center"/>
<Border Width="12" Height="12" Margin="0,0,4,0" VerticalAlignment="Center"
Background="{Binding Source={x:Static svc:FolderEntryStatus.CommentOnly},
Converter={StaticResource StatusBrush}}"
BorderThickness="1" BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"/>
<TextBlock Text="다름(코드는 같음)" FontSize="11" Margin="0,0,14,0" VerticalAlignment="Center"
ToolTip="코드는 같고 주석 · 공백 · 빈 줄 · 줄끝 · BOM · 인코딩만 다른 파일입니다"/>
<Border Width="12" Height="12" Margin="0,0,4,0" VerticalAlignment="Center"
Background="{Binding Source={x:Static svc:FolderEntryStatus.LeftOnly},
Converter={StaticResource StatusBrush}}"
BorderThickness="1" BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"/>
<TextBlock Text="왼쪽에만" FontSize="11" Margin="0,0,14,0" VerticalAlignment="Center"/>
<Border Width="12" Height="12" Margin="0,0,4,0" VerticalAlignment="Center"
Background="{Binding Source={x:Static svc:FolderEntryStatus.RightOnly},
Converter={StaticResource StatusBrush}}"
BorderThickness="1" BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"/>
<TextBlock Text="오른쪽에만" FontSize="11" Margin="0,0,14,0" VerticalAlignment="Center"/>
<Border Width="12" Height="12" Margin="0,0,4,0" VerticalAlignment="Center"
Background="{Binding Source={x:Static svc:FolderEntryStatus.Binary},
Converter={StaticResource StatusBrush}}"
BorderThickness="1" BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"/>
<TextBlock Text="다름(이진)" FontSize="11" VerticalAlignment="Center"/>
</WrapPanel>
</Border>
<!-- ③ 목록 머리글. 트리 항목의 칸 폭과 같은 값을 쓴다. --> <!-- ③ 목록 머리글. 트리 항목의 칸 폭과 같은 값을 쓴다. -->
<Border Grid.Row="4" Padding="8,4" BorderThickness="1,1,1,0" <Border Grid.Row="2" Padding="8,4" BorderThickness="1,1,1,0"
Background="{DynamicResource Ubisam.Brush.Sidebar.Background}" Background="{DynamicResource Ubisam.Brush.Sidebar.Background}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"> BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<Grid Margin="12,0,0,0"> <Grid Margin="12,0,0,0">
@ -347,7 +360,7 @@
채움 칸(*)이 0 이 되고 줄 배경이 글자 끝에서 끊긴다. 칸 폭이 고정이라 채움 칸(*)이 0 이 되고 줄 배경이 글자 끝에서 끊긴다. 칸 폭이 고정이라
가로로 넘칠 일이 없으므로 꺼 두는 편이 맞다. 가로로 넘칠 일이 없으므로 꺼 두는 편이 맞다.
--> -->
<Grid Grid.Row="5"> <Grid Grid.Row="3">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="*"/> <RowDefinition Height="*"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
@ -437,7 +450,7 @@
</Grid> </Grid>
<!-- ⑤ 요약 · 동작 --> <!-- ⑤ 요약 · 동작 -->
<Border Grid.Row="6" Padding="8" BorderThickness="1,0,1,1" Margin="0,0,0,0" <Border Grid.Row="4" Padding="8,6" BorderThickness="1,0,1,1" Margin="0,0,0,0"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"> BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
@ -447,8 +460,49 @@
<StackPanel Grid.Column="0" VerticalAlignment="Center"> <StackPanel Grid.Column="0" VerticalAlignment="Center">
<TextBlock Text="{Binding Summary}" FontSize="12" FontWeight="Bold"/> <TextBlock Text="{Binding Summary}" FontSize="12" FontWeight="Bold"/>
<!--
12차 D-1: 색 뜻풀이를 위에서 여기로 내렸다. 색은 <b>목록을 보는 동안</b> 필요한 것이라
결과 요약 옆이 제자리다 - 위에 두면 비교하기 전부터 한 줄을 먹었다.
-->
<WrapPanel Orientation="Horizontal" Margin="0,3,0,0">
<TextBlock Text="색 구분" Style="{StaticResource Muted}" FontSize="11" Margin="0,0,10,0"
VerticalAlignment="Center"/>
<TextBlock Text="색 구분" Style="{StaticResource Muted}" FontSize="11" Margin="0,0,10,0"
VerticalAlignment="Center"/>
<Border Width="12" Height="12" Margin="0,0,4,0" VerticalAlignment="Center"
Background="{Binding Source={x:Static svc:FolderEntryStatus.Different},
Converter={StaticResource StatusBrush}}"
BorderThickness="1" BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"/>
<TextBlock Text="다름" FontSize="11" Margin="0,0,14,0" VerticalAlignment="Center"/>
<Border Width="12" Height="12" Margin="0,0,4,0" VerticalAlignment="Center"
Background="{Binding Source={x:Static svc:FolderEntryStatus.CommentOnly},
Converter={StaticResource StatusBrush}}"
BorderThickness="1" BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"/>
<TextBlock Text="다름(코드는 같음)" FontSize="11" Margin="0,0,14,0" VerticalAlignment="Center"
ToolTip="코드는 같고 주석 · 공백 · 빈 줄 · 줄끝 · BOM · 인코딩만 다른 파일입니다"/>
<Border Width="12" Height="12" Margin="0,0,4,0" VerticalAlignment="Center"
Background="{Binding Source={x:Static svc:FolderEntryStatus.LeftOnly},
Converter={StaticResource StatusBrush}}"
BorderThickness="1" BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"/>
<TextBlock Text="왼쪽에만" FontSize="11" Margin="0,0,14,0" VerticalAlignment="Center"/>
<Border Width="12" Height="12" Margin="0,0,4,0" VerticalAlignment="Center"
Background="{Binding Source={x:Static svc:FolderEntryStatus.RightOnly},
Converter={StaticResource StatusBrush}}"
BorderThickness="1" BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"/>
<TextBlock Text="오른쪽에만" FontSize="11" Margin="0,0,14,0" VerticalAlignment="Center"/>
<Border Width="12" Height="12" Margin="0,0,4,0" VerticalAlignment="Center"
Background="{Binding Source={x:Static svc:FolderEntryStatus.Binary},
Converter={StaticResource StatusBrush}}"
BorderThickness="1" BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"/>
<TextBlock Text="다름(이진)" FontSize="11" VerticalAlignment="Center"/>
</WrapPanel>
<TextBlock Text="파일을 더블클릭하거나 Enter 를 누르면 &quot;파일 비교&quot; 탭에서 줄 단위로 엽니다. 한쪽에만 있는 파일도 열립니다." <TextBlock Text="파일을 더블클릭하거나 Enter 를 누르면 &quot;파일 비교&quot; 탭에서 줄 단위로 엽니다. 한쪽에만 있는 파일도 열립니다."
Style="{StaticResource Muted}" FontSize="11"/> Style="{StaticResource Muted}" FontSize="11" Margin="0,3,0,0"/>
</StackPanel> </StackPanel>
<WrapPanel Grid.Column="1" Orientation="Horizontal"> <WrapPanel Grid.Column="1" Orientation="Horizontal">

View File

@ -14,6 +14,45 @@ namespace MergeHub.Views
public FolderView() public FolderView()
{ {
InitializeComponent(); InitializeComponent();
// 12차 D-1: 찾기 줄은 늘 한 줄을 먹었다. 이제 Ctrl+F 로 부를 때만 편다(파일 비교의 찾기와 같은 방식).
PreviewKeyDown += OnPreviewKeyDown;
}
/// <summary>
/// Ctrl+F 로 찾기 줄을 펴고 칸에 손을 옮긴다. Esc 로 접는다(12차 D-1).
///
/// 손을 옮기는 것은 <b>배치가 끝난 뒤</b>여야 한다 - 접혀 있던 칸은 아직 화면에 없어서
/// 그 자리에서 Focus() 를 불러도 받지 않는다.
/// </summary>
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
if (DataContext is not FolderViewModel vm) return;
if (e.Key == Key.F && Keyboard.Modifiers == ModifierKeys.Control)
{
vm.OpenFindCommand.Execute(null);
Dispatcher.BeginInvoke(new System.Action(() =>
{
SearchBox.Focus();
SearchBox.SelectAll();
}), System.Windows.Threading.DispatcherPriority.Loaded);
e.Handled = true;
return;
}
// 찾기 칸이 접혀 있으면 Esc 는 손대지 않는다 - 다른 곳에서 쓸 자리를 뺏지 않는다.
if (e.Key == Key.Escape && vm.IsFindOpen)
{
vm.CloseFindCommand.Execute(null);
e.Handled = true;
}
}
/// <summary>[옵션 ▾] 를 누르면 서랍을 여닫는다(12차 D-1).</summary>
private void Options_Click(object sender, RoutedEventArgs e)
{
if (DataContext is FolderViewModel vm) vm.IsOptionsOpen = !vm.IsOptionsOpen;
} }
/// <summary>TreeView.SelectedItem 은 읽기 전용이라 바인딩할 수 없다. 여기서 옮겨 준다.</summary> /// <summary>TreeView.SelectedItem 은 읽기 전용이라 바인딩할 수 없다. 여기서 옮겨 준다.</summary>

View File

@ -6,6 +6,7 @@
xmlns:local="clr-namespace:MergeHub.Views" xmlns:local="clr-namespace:MergeHub.Views"
xmlns:shell="clr-namespace:UbisamBase.Core.Shell;assembly=UbisamBase.Core" xmlns:shell="clr-namespace:UbisamBase.Core.Shell;assembly=UbisamBase.Core"
shell:PlatformChrome.FullBleed="True" shell:PlatformChrome.FullBleed="True"
AllowDrop="True" PreviewDragOver="View_PreviewDragOver" PreviewDrop="View_PreviewDrop"
mc:Ignorable="d"> mc:Ignorable="d">
<UserControl.Resources> <UserControl.Resources>
<ResourceDictionary> <ResourceDictionary>

View File

@ -102,14 +102,46 @@ namespace MergeHub.Views
var picked = MergeHub.Services.FolderPicker.Pick("Git 저장소 폴더 고르기", vm.Workspace.RepositoryPath); var picked = MergeHub.Services.FolderPicker.Pick("Git 저장소 폴더 고르기", vm.Workspace.RepositoryPath);
if (picked is null) return; if (picked is null) return;
// .git 은 최상위에만 있다. 하위 폴더를 골랐어도 위로 거슬러 올라가 최상위를 연다 - OpenFolder(vm, picked);
// 사람은 지금 보고 있던 깊은 폴더를 고르기 마련이다. }
var root = MergeHub.Services.PathUtil.FindRepositoryRoot(picked);
vm.Workspace.RepositoryPath = root.Length > 0 ? root : picked;
/// <summary>
/// 경로 칸에 넣고 [열기]. [폴더...] 와 끌어다 놓기가 같은 길을 탄다.
/// .git 은 최상위에만 있다 - 하위 폴더 · 파일을 골랐어도 위로 거슬러 올라가 최상위를 연다(사람은 보던 깊은 곳을 고르기 마련이다).
/// </summary>
private static void OpenFolder(GitViewModel vm, string path)
{
var folder = MergeHub.Services.PathUtil.RepositoryFolderFor(path);
if (folder.Length == 0) return;
vm.Workspace.RepositoryPath = folder;
if (vm.OpenCommand.CanExecute(null)) vm.OpenCommand.Execute(null); if (vm.OpenCommand.CanExecute(null)) vm.OpenCommand.Execute(null);
} }
// ================= 끌어다 놓기 (2026-09-30 사용자 요청) =================
//
// 탐색기에서 폴더(또는 그 안의 파일)를 Git 탭 아무 데나 놓으면 그 저장소를 연다.
// Preview 로 받는 까닭 - 커밋 메시지 · 경로 칸 같은 TextBox 는 클래스 처리기가 Drop 을 먼저 먹고
// 파일 목록은 버린다(폴더 비교 7차 E-2 에서 겪었다). 파일 놓기만 가로채고 글자 끌어다 놓기는 그대로 둔다.
private void View_PreviewDragOver(object sender, DragEventArgs e)
{
if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
e.Effects = DragDropEffects.Copy;
e.Handled = true;
}
private void View_PreviewDrop(object sender, DragEventArgs e)
{
if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
e.Handled = true;
if (ViewModel is not { } vm) return;
if (e.Data.GetData(DataFormats.FileDrop) is not string[] { Length: > 0 } paths) return;
// 여럿을 놓았으면 첫 것만 - 저장소는 한 번에 하나만 열린다.
OpenFolder(vm, paths[0]);
}
// PasswordBox 는 보안상 Password 를 바인딩할 수 없어 코드 비하인드로 전달한다. // PasswordBox 는 보안상 Password 를 바인딩할 수 없어 코드 비하인드로 전달한다.
/// <summary>충돌 목록을 두 번 누르면 파일 비교 탭의 3분할에서 연다(6차 B-1). 항목 위가 아니면 무시한다.</summary> /// <summary>충돌 목록을 두 번 누르면 파일 비교 탭의 3분할에서 연다(6차 B-1). 항목 위가 아니면 무시한다.</summary>
private void Conflicts_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e) private void Conflicts_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)

View File

@ -63,7 +63,12 @@ namespace MergeHub.Views
LinkOverlay.Draw = DrawLinks; LinkOverlay.Draw = DrawLinks;
} }
private void OnLinesChanged(object? sender, NotifyCollectionChangedEventArgs e) => ScheduleResync(); private void OnLinesChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
// 통째로 갈아 끼우면(새 파일 · 큰 재정렬) 목록의 높이 어림도 새로 시작한다 - 옛 보정값은 버린다.
if (e.Action == NotifyCollectionChangedAction.Reset) _paneBias.Clear();
ScheduleResync();
}
private bool _resyncQueued; private bool _resyncQueued;
@ -159,12 +164,13 @@ namespace MergeHub.Views
if (FindScrollViewer(LocalEditor) is not { } editor) continue; if (FindScrollViewer(LocalEditor) is not { } editor) continue;
var y = Clamp(target - editor.ViewportHeight / 2, editor.ScrollableHeight); var y = Clamp(target - editor.ViewportHeight / 2, editor.ScrollableHeight);
editor.ScrollToVerticalOffset(y); editor.ScrollToVerticalOffset(y);
Scroll(LocalLines, y, null); Scroll(LocalLines, y + Bias(LocalLines), null);
continue; continue;
} }
if (FindScrollViewer(list) is not { } viewer) continue; if (FindScrollViewer(list) is not { } viewer) continue;
viewer.ScrollToVerticalOffset(Clamp(target - viewer.ViewportHeight / 2, viewer.ScrollableHeight)); // 칸 목록은 안 그린 줄 높이를 어림하므로 줄 기준 보정값을 더한다(_paneBias 참고).
viewer.ScrollToVerticalOffset(Clamp(target - viewer.ViewportHeight / 2 + Bias(list), viewer.ScrollableHeight));
} }
LinkOverlay.InvalidateVisual(); LinkOverlay.InvalidateVisual();

View File

@ -34,6 +34,10 @@
<GridLength x:Key="GutterNarrowNumberWidth">40</GridLength> <GridLength x:Key="GutterNarrowNumberWidth">40</GridLength>
<GridLength x:Key="GutterMarkWidth">6</GridLength> <GridLength x:Key="GutterMarkWidth">6</GridLength>
<GridLength x:Key="GutterButtonWidth">20</GridLength> <GridLength x:Key="GutterButtonWidth">20</GridLength>
<!-- 두 파일 비교에서만 나오는 [&lt;] 단추 자리(12차 B-1). Auto 로 두면 단추가 있는 줄만
글이 오른쪽으로 밀려, 같은 칸 안에서도 줄마다 글 시작 자리가 달랐다(2026-09-28 사용자 보고).
모드가 바뀔 때 화면이 0 ↔ 단추 폭으로 갈아 끼운다(ApplyGutterReserve). -->
<GridLength x:Key="GutterBothWaysWidth">0</GridLength>
<system:Double x:Key="GutterButtonHeight">14</system:Double> <system:Double x:Key="GutterButtonHeight">14</system:Double>
<system:Double x:Key="GutterButtonFontSize">9</system:Double> <system:Double x:Key="GutterButtonFontSize">9</system:Double>
<system:Double x:Key="BookmarkHeight">10</system:Double> <system:Double x:Key="BookmarkHeight">10</system:Double>
@ -44,6 +48,8 @@
<BooleanToVisibilityConverter x:Key="BoolToVis"/> <BooleanToVisibilityConverter x:Key="BoolToVis"/>
<ctl:LineHighlightBrushConverter x:Key="HighlightBrush"/> <ctl:LineHighlightBrushConverter x:Key="HighlightBrush"/>
<!-- 편집 칸 바탕: 밝음 #FAFAFA · 어두움 #252830(2026-09-30 사용자 선택). PaneBackgroundConverter 참고. -->
<ctl:PaneBackgroundConverter x:Key="PaneBackground"/>
<ctl:HighlightNameToBrushConverter x:Key="HighlightByName"/> <ctl:HighlightNameToBrushConverter x:Key="HighlightByName"/>
<ctl:TextToVisibilityConverter x:Key="TextToVis"/> <ctl:TextToVisibilityConverter x:Key="TextToVis"/>
@ -160,7 +166,8 @@
66 인 까닭은 LocalLineTemplate 에 적었다(6차 B-4). --> 66 인 까닭은 LocalLineTemplate 에 적었다(6차 B-4). -->
<Grid MinHeight="{Binding BaseRowHeight}"> <Grid MinHeight="{Binding BaseRowHeight}">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/> <!-- [<] 자리. 단추가 없는 줄에서도 자리를 잡아 둔다 - Auto 로 두면 이 줄만 글이 밀린다. -->
<ColumnDefinition Width="{DynamicResource GutterBothWaysWidth}"/>
<ColumnDefinition Width="{DynamicResource GutterWidth}"/> <ColumnDefinition Width="{DynamicResource GutterWidth}"/>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
@ -365,7 +372,8 @@
<ColumnDefinition Width="{DynamicResource GutterMarkWidth}"/> <ColumnDefinition Width="{DynamicResource GutterMarkWidth}"/>
<ColumnDefinition Width="{DynamicResource GutterNarrowNumberWidth}"/> <ColumnDefinition Width="{DynamicResource GutterNarrowNumberWidth}"/>
<ColumnDefinition Width="{DynamicResource GutterButtonWidth}"/> <ColumnDefinition Width="{DynamicResource GutterButtonWidth}"/>
<ColumnDefinition Width="Auto"/> <!-- [<] 자리(12차 B-1). BASE 와 같은 값이라 두 칸의 글 시작 자리가 늘 같다. -->
<ColumnDefinition Width="{DynamicResource GutterBothWaysWidth}"/>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
@ -490,10 +498,13 @@
</ItemsPanelTemplate> </ItemsPanelTemplate>
</ListBox.ItemsPanel> </ListBox.ItemsPanel>
<ListBox.ItemContainerStyle> <ListBox.ItemContainerStyle>
<!-- 고른 항목의 파란 바탕을 없앤다 - 탭 모양은 아래 DataTemplate 이 그린다. --> <!-- 고른 항목의 파란 바탕을 없앤다 - 탭 모양은 아래 DataTemplate 이 그린다.
이 스타일이 플랫폼 ListBoxItem 스타일을 대신하므로 글자색도 여기서 준다 - 없으면 WPF 기본(검정)이라
어두운 테마에서 탭 이름이 바탕에 묻혔다(12차 G-8 곁, 잉크 차이 157 · 116 으로 확인). -->
<Style TargetType="ListBoxItem"> <Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="0"/> <Setter Property="Padding" Value="0"/>
<Setter Property="Margin" Value="0"/> <Setter Property="Margin" Value="0"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.ContentForeground}"/>
<Setter Property="Template"> <Setter Property="Template">
<Setter.Value> <Setter.Value>
<ControlTemplate TargetType="ListBoxItem"> <ControlTemplate TargetType="ListBoxItem">
@ -587,13 +598,6 @@
Foreground="{DynamicResource Ubisam.Brush.Accent}"/> Foreground="{DynamicResource Ubisam.Brush.Accent}"/>
</Border> </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 이 아닐 때: 비교할 파일 두 개를 직접 고른다 --> <!-- Git 이 아닐 때: 비교할 파일 두 개를 직접 고른다 -->
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" <StackPanel Orientation="Horizontal" VerticalAlignment="Center"
Visibility="{Binding IsPlainMode, Converter={StaticResource BoolToVis}}"> Visibility="{Binding IsPlainMode, Converter={StaticResource BoolToVis}}">
@ -895,7 +899,9 @@
</Button> </Button>
<Border Style="{StaticResource LegendChip}" Background="#66E0A020"/> <Border Style="{StaticResource LegendChip}" Background="#66E0A020"/>
<TextBlock Text="줄 안에서 바뀐 부분" Style="{StaticResource LegendText}" Margin="0,0,14,0"/> <TextBlock Text="줄 안에서 바뀐 부분" Style="{StaticResource LegendText}"/>
<!-- 2026-09-30 사용자 요청: 이 칸만 개수가 없었다. 단어 단위로 달라진 곳이 있는 "BASE와 다름" 줄 수. -->
<TextBlock x:Name="InlineChangeCountText" Text="{Binding InlineChangeCount}" Style="{StaticResource LegendCount}" Margin="0,0,14,0"/>
</WrapPanel> </WrapPanel>
</Border> </Border>
@ -1179,7 +1185,8 @@
--> -->
<Border Grid.Column="2" Grid.Row="0" BorderThickness="1" <Border Grid.Column="2" Grid.Row="0" BorderThickness="1"
Visibility="{Binding IsThreeWay, Converter={StaticResource BoolToVis}}" Visibility="{Binding IsThreeWay, Converter={StaticResource BoolToVis}}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"> BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
Background="{Binding (TextElement.Foreground), RelativeSource={RelativeSource Self}, Converter={StaticResource PaneBackground}}">
<!-- 칸 밖으로 그려지지 않게 자른다. 표시층은 RenderTransform 으로 살짝 밀어 놓는데, <!-- 칸 밖으로 그려지지 않게 자른다. 표시층은 RenderTransform 으로 살짝 밀어 놓는데,
그건 배치가 아니라 그리기라 자르지 않으면 <b>아래 상태바 위에 코드가 겹쳐 그려진다.</b> 그건 배치가 아니라 그리기라 자르지 않으면 <b>아래 상태바 위에 코드가 겹쳐 그려진다.</b>
차이로 이동(Alt+↑↓)할 때 실제로 그렇게 보였다. --> 차이로 이동(Alt+↑↓)할 때 실제로 그렇게 보였다. -->
@ -1258,7 +1265,8 @@
<!-- ② BASE --> <!-- ② BASE -->
<Border Grid.Row="0" BorderThickness="1" <Border Grid.Row="0" BorderThickness="1"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"> BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
Background="{Binding (TextElement.Foreground), RelativeSource={RelativeSource Self}, Converter={StaticResource PaneBackground}}">
<Border.Style> <Border.Style>
<Style TargetType="Border"> <Style TargetType="Border">
<!-- 2-Way: 왼쪽 칸. 3-Way: 가운데 칸(LOCAL 과 REMOTE 사이가 공통 조상의 자리다). --> <!-- 2-Way: 왼쪽 칸. 3-Way: 가운데 칸(LOCAL 과 REMOTE 사이가 공통 조상의 자리다). -->
@ -1364,7 +1372,8 @@
폭 전체를 쓰고, 위 세 칸(LOCAL 원본 · BASE · REMOTE)은 읽기 전용 참고가 된다. 폭 전체를 쓰고, 위 세 칸(LOCAL 원본 · BASE · REMOTE)은 읽기 전용 참고가 된다.
--> -->
<Border BorderThickness="1" <Border BorderThickness="1"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"> BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
Background="{Binding (TextElement.Foreground), RelativeSource={RelativeSource Self}, Converter={StaticResource PaneBackground}}">
<Border.Style> <Border.Style>
<Style TargetType="Border"> <Style TargetType="Border">
<Setter Property="Grid.Row" Value="0"/> <Setter Property="Grid.Row" Value="0"/>
@ -1447,7 +1456,25 @@
세로 스크롤 막대를 늘 띄워 두는 이유 - 나타났다 사라지면 글 너비가 바뀌어 세로 스크롤 막대를 늘 띄워 두는 이유 - 나타났다 사라지면 글 너비가 바뀌어
워드랩 접히는 자리가 아래층과 어긋난다. 워드랩 접히는 자리가 아래층과 어긋난다.
--> -->
<!--
IsUndoEnabled="False" - 12차 G-1 "undo 가 계속 이어진다".
Ctrl+Z 는 우리 목록(_undo · _textUndo)이 비면 <b>키를 먹지 않는다</b>
(RunShortcut - 그래야 글자 되돌리기가 살아 있다는 뜻이었다). 그러면 그 키가
TextBox 자체 되돌리기로 넘어가는데, WPF 의 그것은 한도가 없고 <b>프로그램이
코드로 넣은 글까지</b> 기록한다. 그래서 짝을 바꿔 새 파일을 실으면 그 글도 기록에
쌓이고, Ctrl+Z 가 <b>앞 파일 글을 편집기에 되살렸다.</b>
거기서 끝이 아니다. 되살아난 앞 파일 글을 Reflow 가 "사용자가 친 것"으로 보고
모델을 그쪽으로 되돌리며 되돌리기에 다시 쌓고 다시하기를 지운다. 다음 Ctrl+Z 는
우리 목록으로 되돌리고, 그러면 또 TextBox 것이 되살리고 - <b>두 상태를 끝없이
오간다.</b> 실측: 짝 A(234자) ↔ 짝 B(228자) 를 40번을 눌러도 안 끝났다.
자체 되돌리기를 끄면 그 고리가 통째로 사라진다. 되돌리기는 우리 목록만 쓴다 -
줄 단위(_undo)와 글 단위(_textUndo, 타이핑이 멈출 때마다 한 걸음).
-->
<TextBox x:Name="LocalEditor" Style="{x:Null}" Margin="{DynamicResource EditorGutterMargin}" <TextBox x:Name="LocalEditor" Style="{x:Null}" Margin="{DynamicResource EditorGutterMargin}"
IsUndoEnabled="False"
FontFamily="Consolas" FontSize="{DynamicResource CodeFontSize}" FontFamily="Consolas" FontSize="{DynamicResource CodeFontSize}"
TextBlock.LineHeight="{DynamicResource CodeLineHeight}" TextBlock.LineHeight="{DynamicResource CodeLineHeight}"
TextBlock.LineStackingStrategy="BlockLineHeight" TextBlock.LineStackingStrategy="BlockLineHeight"
@ -1551,7 +1578,8 @@
--> -->
<Border Grid.Column="6" Grid.Row="0" BorderThickness="1" <Border Grid.Column="6" Grid.Row="0" BorderThickness="1"
Visibility="{Binding IsThreeWay, Converter={StaticResource BoolToVis}}" Visibility="{Binding IsThreeWay, Converter={StaticResource BoolToVis}}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"> BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
Background="{Binding (TextElement.Foreground), RelativeSource={RelativeSource Self}, Converter={StaticResource PaneBackground}}">
<!-- 칸 밖으로 그려지지 않게 자른다. 표시층은 RenderTransform 으로 살짝 밀어 놓는데, <!-- 칸 밖으로 그려지지 않게 자른다. 표시층은 RenderTransform 으로 살짝 밀어 놓는데,
그건 배치가 아니라 그리기라 자르지 않으면 <b>아래 상태바 위에 코드가 겹쳐 그려진다.</b> 그건 배치가 아니라 그리기라 자르지 않으면 <b>아래 상태바 위에 코드가 겹쳐 그려진다.</b>
차이로 이동(Alt+↑↓)할 때 실제로 그렇게 보였다. --> 차이로 이동(Alt+↑↓)할 때 실제로 그렇게 보였다. -->
@ -1756,10 +1784,18 @@
Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}" Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}"
ToolTip="선택한 줄을 REMOTE(Theirs) 내용으로"/> ToolTip="선택한 줄을 REMOTE(Theirs) 내용으로"/>
<Button Content="이 덩어리 반영" Command="{Binding AcceptBlockCommand}" <Button Content="구간 반영" x:Name="AcceptBlockButton" Command="{Binding AcceptBlockCommand}"
MinHeight="30" Margin="2,4,2,0" FontSize="11" MinHeight="30" Margin="2,4,2,0" FontSize="11"
ToolTip="차이가 이어지는 구간을 한 번에 반영합니다 (Alt+Shift+Right)"/> ToolTip="차이가 이어지는 구간을 한 번에 반영합니다 (Alt+Shift+Right)"/>
<!-- 12차 G-2: 위 단추의 거울. 두 파일 비교(BothWays)에서만 뜬다 -
Git 모드의 왼쪽은 지난 커밋이라 쓸 파일이 없다. -->
<Button Content="구간 왼쪽으로" x:Name="AcceptBlockToBaseButton"
Command="{Binding AcceptBlockToBaseCommand}"
MinHeight="30" Margin="2,4,2,0" FontSize="11"
Visibility="{Binding BothWays, Converter={StaticResource BoolToVis}}"
ToolTip="차이가 이어지는 구간을 한 번에 왼쪽 파일로 보냅니다 (Alt+Shift+Left)"/>
<!-- 11차 F-2: 캐럿이 놓인 클래스 멤버를 통째로. 못 잡으면 꺼지고 아래 한 줄이 까닭을 말한다. --> <!-- 11차 F-2: 캐럿이 놓인 클래스 멤버를 통째로. 못 잡으면 꺼지고 아래 한 줄이 까닭을 말한다. -->
<Button Content="이 메서드 반영" Command="{Binding AcceptMemberCommand}" <Button Content="이 메서드 반영" Command="{Binding AcceptMemberCommand}"
MinHeight="30" Margin="2,4,2,0" FontSize="11" MinHeight="30" Margin="2,4,2,0" FontSize="11"
@ -1912,10 +1948,13 @@
<TextBlock Text="저장 형식" Style="{StaticResource Muted}" FontSize="11" Margin="0,10,0,4" <TextBlock Text="저장 형식" Style="{StaticResource Muted}" FontSize="11" Margin="0,10,0,4"
Visibility="{Binding IsRulesOpen, Converter={StaticResource BoolToVis}}"/> Visibility="{Binding IsRulesOpen, Converter={StaticResource BoolToVis}}"/>
<UniformGrid Columns="2" 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" MinHeight="26" FontSize="11" Margin="2,0"
ToolTip="저장할 때 쓸 인코딩"/> 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" MinHeight="26" FontSize="11" Margin="2,0"
ToolTip="저장할 때 쓸 줄바꿈"/> ToolTip="저장할 때 쓸 줄바꿈"/>
</UniformGrid> </UniformGrid>

View File

@ -473,6 +473,14 @@ namespace MergeHub.Views
return; return;
} }
// 두 파일 비교 ↔ Git 을 오가면 [<] 단추가 생기고 사라진다 - 잡아 둘 자리도 함께 바뀐다.
if (e.PropertyName == nameof(HomeViewModel.BothWays))
{
ApplyGutterReserve();
ScheduleResync();
return;
}
if (e.PropertyName != nameof(HomeViewModel.IsThreeWay)) return; if (e.PropertyName != nameof(HomeViewModel.IsThreeWay)) return;
ScheduleResync(); ScheduleResync();
@ -507,22 +515,27 @@ namespace MergeHub.Views
viewer.ScrollChanged += (_, e) => viewer.ScrollChanged += (_, e) =>
{ {
if (e.VerticalChange != 0 || e.ViewportHeightChange != 0) LinkOverlay.InvalidateVisual(); if (e.VerticalChange != 0 || e.ViewportHeightChange != 0) LinkOverlay.InvalidateVisual();
if (Syncing) return;
if (e.VerticalChange == 0 && e.HorizontalChange == 0) return; if (e.VerticalChange == 0 && e.HorizontalChange == 0) return;
HoldSync(); // LOCAL 표시층(아래층 목록)과 글자 가로 밀기는 <b>메아리 막기 중에도</b> 편집기를 따라간다(2026-09-30 사용자 보고).
// 엔터를 꾹 누르면 재정렬마다 칸 다시 맞추기가 막기를 걸어 두는데, 그 사이 편집기가 캐럿을 들이려고 구른 것이
// 세로는 아래층·BASE·REMOTE 가 같이 굴러가고, // 메아리로 버려져 아래층이 몇 줄 뒤처졌다(캐럿은 929행, 아래층 글은 926행 자리) - G-7 과 같은 고리.
// 이 목록은 굴러도 편집기를 되움직이지 않으므로(Lines_ScrollChanged 가 LocalLines 는 받지 않는다) 늘 맞춰도 메아리가 없다.
// 가로는 네 패널 모두 "글자만" 민다(TextShiftX) - 여백(줄번호·[>])은 제자리에 있어야 한다. // 가로는 네 패널 모두 "글자만" 민다(TextShiftX) - 여백(줄번호·[>])은 제자리에 있어야 한다.
// 표시층 ListBox 를 가로로 굴리는 방법은 못 쓴다 - 행이 칸 너비에 맞춰 늘어나 // 표시층 ListBox 를 가로로 굴리는 방법은 못 쓴다 - 행이 칸 너비에 맞춰 늘어나
// 굴릴 여지(ScrollableWidth)가 0 이라 아무 일도 일어나지 않는다. // 굴릴 여지(ScrollableWidth)가 0 이라 아무 일도 일어나지 않는다.
Scroll(LocalLines, e.VerticalOffset, null); Scroll(LocalLines, e.VerticalOffset + Bias(LocalLines), null);
if (e.VerticalChange != 0) SyncPanesFrom(MergeLine.TextPane.Local, e.VerticalOffset, e.ViewportHeight);
TextShiftX = -e.HorizontalOffset; TextShiftX = -e.HorizontalOffset;
// 2-Way 는 BASE 와 LOCAL 의 폭이 같으니 한 덩어리로 움직인다. // 2-Way 는 BASE 와 LOCAL 의 폭이 같으니 한 덩어리로 움직인다(가로 글자 밀기 - 되움직임 없음).
// 3-Way 의 위 세 칸은 폭이 달라 따로 움직이므로 여기서 건드리지 않는다. // 3-Way 의 위 세 칸은 폭이 달라 따로 움직이므로 여기서 건드리지 않는다.
if (DataContext is not HomeViewModel { IsThreeWay: true }) SourceShiftX = TextShiftX; if (DataContext is not HomeViewModel { IsThreeWay: true }) SourceShiftX = TextShiftX;
if (Syncing) return;
HoldSync();
// 세로는 BASE·REMOTE 가 가운데를 맞춰 같이 굴러간다 - 이쪽은 굴리면 되돌아오는 칸이라 메아리를 막는다.
if (e.VerticalChange != 0) SyncPanesFrom(MergeLine.TextPane.Local, e.VerticalOffset, e.ViewportHeight);
}; };
} }
@ -539,6 +552,28 @@ namespace MergeHub.Views
/// <see cref="CodeFontMetrics"/> 한 벌로 바꾼다 - 넷 가운데 하나라도 따로 놀면 행이 어긋난다. /// <see cref="CodeFontMetrics"/> 한 벌로 바꾼다 - 넷 가운데 하나라도 따로 놀면 행이 어긋난다.
/// 행 높이의 하한(<see cref="MergeLine.MinimumRowHeight"/>)도 같이 옮겨야 글자를 줄였을 때 행이 준다. /// 행 높이의 하한(<see cref="MergeLine.MinimumRowHeight"/>)도 같이 옮겨야 글자를 줄였을 때 행이 준다.
/// </summary> /// </summary>
/// <summary>마지막으로 건 크기 한 벌. 모드가 바뀔 때 여백만 다시 잡으려고 들고 있는다.</summary>
private CodeFontMetrics _metrics = CodeFontMetrics.For(CodeFontMetrics.Default);
/// <summary>
/// 두 파일 비교에서만 나오는 <c>[&lt;]</c> 단추 자리를 <b>줄마다 늘 잡아 둔다</b>(2026-09-28 사용자 보고).
///
/// 그 칸을 <c>Auto</c> 로 두었더니 단추가 보이는 줄에서만 칸이 벌어져, <b>같은 칸 안에서도
/// 줄마다 글이 시작하는 자리가 달랐다</b> - 화면에서는 03 · 04 줄만 오른쪽으로 밀려 보였다.
/// 모드에 따라 0 ↔ 단추 폭으로 갈아 끼우면, 한 모드 안에서는 모든 줄이 같은 자리에서 시작한다.
/// Git 모드는 왼쪽이 지난 커밋이라 이 단추가 아예 없으므로 0 - 그때는 여백이 예전 그대로다.
///
/// <para>편집기 여백(<c>EditorGutterMargin</c>)도 같은 값만큼 밀어야 한다 - 투명한 편집기와
/// 아래 표시층이 어긋나면 캐럿이 글자와 다른 자리에 선다.</para>
/// </summary>
private void ApplyGutterReserve()
{
var reserve = (DataContext as HomeViewModel)?.BothWays == true ? _metrics.ButtonWidth : 0;
Resources["GutterBothWaysWidth"] = new GridLength(reserve);
Resources["EditorGutterMargin"] = new Thickness(_metrics.GutterWidth + reserve, 0, 0, 0);
}
private void ApplyCodeFont(double size) private void ApplyCodeFont(double size)
{ {
var m = CodeFontMetrics.For(size); var m = CodeFontMetrics.For(size);
@ -553,7 +588,9 @@ namespace MergeHub.Views
Resources["GutterButtonHeight"] = m.ButtonHeight; Resources["GutterButtonHeight"] = m.ButtonHeight;
Resources["GutterButtonFontSize"] = m.ButtonFontSize; Resources["GutterButtonFontSize"] = m.ButtonFontSize;
Resources["BookmarkHeight"] = m.BookmarkHeight; Resources["BookmarkHeight"] = m.BookmarkHeight;
Resources["EditorGutterMargin"] = new Thickness(m.GutterWidth, 0, 0, 0);
_metrics = m;
ApplyGutterReserve();
if (MergeLine.MinimumRowHeight != m.LineHeight) if (MergeLine.MinimumRowHeight != m.LineHeight)
{ {
@ -713,18 +750,54 @@ namespace MergeHub.Views
// 또 <b>칸 안에 보이는 행</b>이어야 한다(6차 B-4 확인 중 발견). "처음 만들어진 행" 은 멀리 건너뛴 직후에 // 또 <b>칸 안에 보이는 행</b>이어야 한다(6차 B-4 확인 중 발견). "처음 만들어진 행" 은 멀리 건너뛴 직후에
// 화면 밖에 남은 행일 수 있고, 편집기는 화면 밖 글자의 좌표를 어림으로 준다. 7,479줄 파일에서 // 화면 밖에 남은 행일 수 있고, 편집기는 화면 밖 글자의 좌표를 어림으로 준다. 7,479줄 파일에서
// "마지막 차이" 로 가자 그 어림이 9줄쯤 틀려 네 칸이 통째로 위로 밀리고 아래가 비었다. // "마지막 차이" 로 가자 그 어림이 9줄쯤 틀려 네 칸이 통째로 위로 밀리고 아래가 비었다.
if (FirstVisibleContainer(LocalLines) is not { } container) return; //
// 2026-09-30: 기준을 <b>편집기에 보이는 첫 줄</b>로 바꿨다. 예전에는 "목록에 보이는 첫 행" 을 편집기에서 찾았는데,
// 가상화 목록의 높이 어림이 틀려 목록이 편집기보다 수십 줄 앞서 있으면 그 줄이 편집기 화면 밖이라
// 맞추기를 포기했다(기록: 확대 뒤 95번 중 95번 "화면 밖") - 한 번도 바로잡지 못한 채 칸이 통째로 밀렸다.
// 편집기 첫 줄은 늘 화면 안이다. 그 행이 목록에 아직 안 그려졌으면 먼저 불러오고 다음 배치 때 맞춘다.
// 행 번호 ↔ 편집기 줄 번호는 지운 줄(편집기에 줄이 없는 행)이 있으면 다르다 - 표(RowOfEditorLine)로 바꾼다.
if (DataContext is not HomeViewModel vm || vm.Lines.Count == 0) return;
// GetFirstVisibleLineIndex 는 쓰지 않는다 - 글꼴 기본 줄 간격으로 어림해 우리가 늘린 줄 높이를 모른다
// (기록: 실제 첫 줄 886 에서 1033 을 줬다, 아래로 갈수록 더 틀린다). 점으로 글자를 찾는 것도 확대 직후엔
// 옛 배치로 답해 80줄쯤 위를 줬다(기록: 그 줄 top -1871). 줄 높이가 고정(BlockLineHeight)이니 나눗셈이 정확하다.
// 줄 바꿈을 켜면 한 줄이 여러 칸이라 나눗셈이 안 맞는다 - 그때만 점으로 찾는다.
int editorLine;
var fixedLines = LocalEditor.TextWrapping == TextWrapping.NoWrap;
if (fixedLines)
editorLine = Math.Min((int)Math.Ceiling((LocalEditor.VerticalOffset - 0.5) / LineHeight), LocalEditor.LineCount - 1); // 위가 잘리지 않은 첫 줄
else
{
var topChar = LocalEditor.GetCharacterIndexFromPoint(new Point(LocalEditor.Padding.Left + 2, 1), snapToText: true);
editorLine = topChar < 0 ? -1 : LocalEditor.GetLineIndexFromCharacterIndex(topChar);
if (editorLine >= 0 && LocalEditor.GetRectFromCharacterIndex(LocalEditor.GetCharacterIndexFromLineIndex(editorLine)).Top < -1 &&
editorLine + 1 < LocalEditor.LineCount) editorLine++; // 윗줄이 반쯤 잘려 있으면 다음 줄
}
if (editorLine < 0) return;
var row = vm.RowOfEditorLine(editorLine);
if (row < 0 || row >= vm.Lines.Count) return;
if (LocalLines.ItemContainerGenerator.ContainerFromIndex(row) is not ListBoxItem container || !container.IsVisible)
{
LocalLines.ScrollIntoView(vm.Lines[row]);
return;
}
if (FindFirst<Controls.CodeTextBlock>(container) is not { } sample) return; if (FindFirst<Controls.CodeTextBlock>(container) is not { } sample) return;
var row = LocalLines.ItemContainerGenerator.IndexFromContainer(container); var charIndex = LocalEditor.GetCharacterIndexFromLineIndex(editorLine);
if (row < 0) return;
var charIndex = CharacterIndexOfLine(LocalEditor.Text, row);
if (charIndex < 0) return; if (charIndex < 0) return;
try try
{ {
var charRect = LocalEditor.GetRectFromCharacterIndex(charIndex); var charRect = LocalEditor.GetRectFromCharacterIndex(charIndex);
if (fixedLines)
{
// 글자 크기를 바꾼 뒤 GetRectFromCharacterIndex 는 몇 초씩 옛 배치로 답했다(기록: 보이는 첫 줄 728 을
// 1,892px 위라고 했다 - 그래서 "화면 밖" 으로 빠져 한 번도 맞추지 못하고 칸이 69줄 어긋났다).
// 줄 높이가 고정이면 줄 자리는 계산으로 정확하다 - 줄 상자 위 + 글자 위 여백(lead, 아래에서 쓰는 값과 같은 식).
var lineTop = editorLine * LineHeight - LocalEditor.VerticalOffset + LocalEditor.BorderThickness.Top + LocalEditor.Padding.Top;
var glyphLead = sample.BaselineOffset - sample.FontFamily.Baseline * sample.FontSize;
charRect = new Rect(charRect.IsEmpty ? 0 : charRect.X, lineTop + glyphLead, 0, LineHeight);
}
// 편집기 화면 밖이면 어림값이다. 맞추지 않고 다음 배치 때 다시 본다. // 편집기 화면 밖이면 어림값이다. 맞추지 않고 다음 배치 때 다시 본다.
if (charRect.IsEmpty || charRect.Top < -1 || charRect.Top > LocalEditor.ActualHeight) return; if (charRect.IsEmpty || charRect.Top < -1 || charRect.Top > LocalEditor.ActualHeight) return;
@ -747,7 +820,27 @@ namespace MergeHub.Views
var wanted = editorPoint.Y - (decorationPoint.Y - _decorationShift.Y + lead); var wanted = editorPoint.Y - (decorationPoint.Y - _decorationShift.Y + lead);
// 이미 맞아 있으면 건드리지 않는다 - 매 프레임 흔들리는 것을 막는다. // 이미 맞아 있으면 건드리지 않는다 - 매 프레임 흔들리는 것을 막는다.
if (Math.Abs(wanted - _decorationShift.Y) < 0.5) return; if (Math.Abs(wanted - _decorationShift.Y) < 0.5)
{
AlignOtherPanesByRow();
return;
}
// 두 줄 넘게 어긋났으면 이동값으로 메우지 않고 <b>목록을 그만큼 실제로 굴린다</b>(2026-09-30 사용자 보고).
// 칸 목록은 가상화라 안 그린 줄의 높이를 어림으로 세어, 같은 픽셀 자리라도 편집기와 다른 줄을 본다
// (기록으로 잰 값: 1,806줄 파일 847행에서 전체 길이가 편집기 28,896 · 목록 27,940 → 이동값 467px).
// 이동값은 네 칸에 함께 걸리므로 커지면 칸이 통째로 밀려 위가 텅 비었다 - Ctrl+휠로 키우면 더 드러났다.
// 굴리고 나면 다음 배치 때 여기로 다시 와서 한 줄 안쪽의 차이만 이동값으로 맞춘다.
// LOCAL 목록은 굴러도 편집기를 되움직이지 않는다(Lines_ScrollChanged 가 받지 않는다) - 메아리 없음.
var rowHeight = Math.Max(1, container.ActualHeight);
if (Math.Abs(wanted) > rowHeight * 2 && FindScrollViewer(LocalLines) is { } listViewer)
{
_decorationShift.Y = 0;
_paneBias[LocalLines] = Bias(LocalLines) - wanted; // 다음 편집기 스크롤도 이만큼 더해 굴린다
listViewer.ScrollToVerticalOffset(Clamp(listViewer.VerticalOffset - wanted, listViewer.ScrollableHeight));
LinkOverlay.InvalidateVisual();
return;
}
// 가로는 건드리지 않는다. 여백(줄번호·[>])까지 밀려나기 때문이다 - // 가로는 건드리지 않는다. 여백(줄번호·[>])까지 밀려나기 때문이다 -
// 가로 스크롤은 글자에만 거는 TextShiftX 가 따로 맡는다. // 가로 스크롤은 글자에만 거는 TextShiftX 가 따로 맡는다.
@ -760,6 +853,73 @@ namespace MergeHub.Views
} }
} }
/// <summary>칸마다 마지막으로 불러온 짝 행. 같은 행을 배치마다 거듭 부르지 않게 한다.</summary>
private readonly System.Collections.Generic.Dictionary<ListBox, int> _pairRequested = new System.Collections.Generic.Dictionary<ListBox, int>();
/// <summary>
/// BASE · REMOTE · 원본 LOCAL 칸을 <b>같은 줄</b> 기준으로 LOCAL 칸에 맞춘다(2026-09-30 사용자 보고 "화면이 잘린다").
///
/// 이 칸들은 가운데 맞추기(<see cref="SyncPanesFrom"/>)가 픽셀 자리로 굴린다. 그런데 칸 목록은 가상화라 안 그린 줄의
/// 높이를 어림으로 세어, 파일 아래쪽에서는 같은 픽셀이 다른 줄을 가리켰다(사진: LOCAL 839~876 · BASE 807~845, 30줄 어긋남).
/// LOCAL 칸 가운데 보이는 줄이 그 칸에도 있으면 두 행의 윗변을 견줘 두 줄 넘게 다르면 그 칸을 그만큼 굴린다.
/// 두 줄 안쪽의 차이, 그리고 한쪽에만 있는 줄 묶음 한가운데의 어긋남은 가운데 맞추기가 일부러 만든 것이라 그대로 둔다.
/// 굴리는 동안 메아리 막기를 건다 - 이 칸이 구르면 편집기를 따라 움직이게 하는 길이 있다(Lines_ScrollChanged).
/// </summary>
private void AlignOtherPanesByRow()
{
if (DataContext is not HomeViewModel vm || vm.Lines.Count == 0) return;
if (ContainerAtMiddle(LocalLines) is not { } localItem) return;
var row = LocalLines.ItemContainerGenerator.IndexFromContainer(localItem);
if (row < 0) return;
var localTop = localItem.TranslatePoint(new Point(0, 0), LocalLines).Y;
var rowHeight = Math.Max(1, localItem.ActualHeight);
foreach (var (list, kind) in VisiblePanes(vm))
{
if (kind == MergeLine.TextPane.Local) continue;
// LOCAL 가운데 줄이 이 칸에도 있을 때만 맞춘다. 한쪽에만 있는 줄 묶음 한가운데라면 두 칸이 어긋나 보이는 것이
// 가운데 맞추기가 일부러 만든 모양이다. 거기서 짝(묶음 아래 줄)을 억지로 불러오면 가운데 맞추기가 도로 밀어내
// 배치마다 되풀이됐다(기록: 엔터 40번 뒤 한 줄(894→924)을 초당 600번 넘게 불러 타이핑 입력이 밀렸다).
if (vm.Lines[row].PaneHeight(kind, false) <= 0) continue;
if (FindScrollViewer(list) is not { } viewer) continue;
if (list.ItemContainerGenerator.ContainerFromIndex(row) is not ListBoxItem item || !item.IsVisible)
{
// 짝이 아직 안 그려졌다(어림이 크게 틀림) - 먼저 화면 안으로 부르고, 다음 배치 때 윗변을 맞춘다.
// 같은 줄은 한 번만 부른다 - 부른 뒤에도 안 보이면 배치마다 다시 부르며 멈추지 않는다.
if (_pairRequested.TryGetValue(list, out var asked) && asked == row) continue;
_pairRequested[list] = row;
HoldSync();
list.ScrollIntoView(vm.Lines[row]);
continue;
}
_pairRequested.Remove(list);
var dy = item.TranslatePoint(new Point(0, 0), list).Y - localTop;
if (Math.Abs(dy) <= rowHeight * 2) continue;
HoldSync();
_paneBias[list] = Bias(list) + dy; // 가운데 맞추기도 다음부터 이만큼 더해 굴린다 - 되돌려 밀고 당기지 않게
viewer.ScrollToVerticalOffset(Clamp(viewer.VerticalOffset + dy, viewer.ScrollableHeight));
LinkOverlay.InvalidateVisual();
}
}
/// <summary>목록 칸의 세로 가운데를 덮는 행. 없으면 null.</summary>
private static ListBoxItem? ContainerAtMiddle(ListBox list)
{
if (FindFirst<VirtualizingStackPanel>(list) is not { } panel) return null;
var middle = list.ActualHeight / 2;
foreach (UIElement child in panel.Children)
{
if (child is not ListBoxItem item || !item.IsVisible) continue;
var top = item.TranslatePoint(new Point(0, 0), list).Y;
if (top <= middle && top + item.ActualHeight > middle) return item;
}
return null;
}
/// <summary> /// <summary>
/// 목록 칸 안에서 위가 잘리지 않고 보이는 첫 행. 없으면 null. /// 목록 칸 안에서 위가 잘리지 않고 보이는 첫 행. 없으면 null.
/// 행 판(가상화 패널)의 자식만 훑는다 - 배치가 끝날 때마다 불리므로 시각 트리 전체를 뒤지면 무겁다. /// 행 판(가상화 패널)의 자식만 훑는다 - 배치가 끝날 때마다 불리므로 시각 트리 전체를 뒤지면 무겁다.
@ -882,9 +1042,23 @@ namespace MergeHub.Views
var kind = ReferenceEquals(source, BaseLines) ? MergeLine.TextPane.Base var kind = ReferenceEquals(source, BaseLines) ? MergeLine.TextPane.Base
: ReferenceEquals(source, RemoteLines) ? MergeLine.TextPane.Remote : ReferenceEquals(source, RemoteLines) ? MergeLine.TextPane.Remote
: MergeLine.TextPane.OriginalLocal; : MergeLine.TextPane.OriginalLocal;
SyncPanesFrom(kind, e.VerticalOffset, e.ViewportHeight); // 이 칸의 픽셀 자리에는 보정값이 들어 있다 - 빼서 "줄 기준" 자리로 바꾼 뒤 다른 칸을 맞춘다.
SyncPanesFrom(kind, e.VerticalOffset - Bias(source), e.ViewportHeight);
} }
/// <summary>
/// 칸 목록마다 "픽셀 자리 → 같은 줄" 보정값(2026-09-30 사용자 보고: 확대하면 화면이 잘린다 · 밑으로 쭉 내리면 두 칸 속도가 다르다).
///
/// 칸 목록은 가상화라 안 그린 줄의 높이를 어림으로 세어, 편집기와 같은 픽셀로 굴려도 파일 아래쪽에서는 다른 줄을 본다
/// (기록: 1,806줄 847행에서 편집기 전체 28,896 · 목록 27,940). 줄 기준 맞추기(<see cref="AlignDecorationToEditor"/> ·
/// <see cref="AlignOtherPanesByRow"/>)가 찾은 차이를 여기 쌓고, 픽셀로 굴리는 자리(편집기 스크롤 · 가운데 맞추기)는 늘 이 값을 더한다.
/// 처음에는 맞추는 쪽이 그냥 굴렸더니 다음 편집기 스크롤이 픽셀 자리로 되돌려 둘이 밀고 당겼다(기록: 12704.9 ↔ 13070.6).
/// 목록을 통째로 갈아 끼우면(새 파일) 비운다.
/// </summary>
private readonly System.Collections.Generic.Dictionary<ListBox, double> _paneBias = new System.Collections.Generic.Dictionary<ListBox, double>();
private double Bias(ListBox list) => _paneBias.TryGetValue(list, out var b) ? b : 0;
private static void Scroll(DependencyObject target, double? vertical, double? horizontal) private static void Scroll(DependencyObject target, double? vertical, double? horizontal)
{ {
if (FindScrollViewer(target) is not { } viewer) return; if (FindScrollViewer(target) is not { } viewer) return;
@ -945,6 +1119,12 @@ namespace MergeHub.Views
{ {
var caret = LocalEditor.CaretIndex; var caret = LocalEditor.CaretIndex;
// 머지 주석 줄이 끼거나 빠졌으면 캐럿을 <b>누르기 전과 같은 행</b>에 둔다(2026-09-30).
// 글자 수로만 지키면 Accept 뒤 캐럿이 새 주석 줄에 서서, Alt+↓ 가 방금 반영한 줄을 "다음 차이" 로 잡았다.
var targetLine = (DataContext as HomeViewModel)?.TakeCaretLine() ?? -1;
var oldLine = targetLine >= 0 ? LocalEditor.GetLineIndexFromCharacterIndex(caret) : -1;
var caretColumn = oldLine >= 0 ? caret - LocalEditor.GetCharacterIndexFromLineIndex(oldLine) : 0;
// 글을 갈아 끼우면 TextChanged 가 도는데, 그걸 사용자의 편집으로 오해하면 // 글을 갈아 끼우면 TextChanged 가 도는데, 그걸 사용자의 편집으로 오해하면
// 다시 정렬이 끝없이 되풀이된다. // 다시 정렬이 끝없이 되풀이된다.
_replacingText = true; _replacingText = true;
@ -957,9 +1137,37 @@ namespace MergeHub.Views
_replacingText = false; _replacingText = false;
} }
if (targetLine >= 0 && LineSpan(text, targetLine) is { } span)
{
LocalEditor.CaretIndex = span.Start + Math.Min(caretColumn, span.Length);
return;
}
LocalEditor.CaretIndex = Math.Min(caret, text.Length); LocalEditor.CaretIndex = Math.Min(caret, text.Length);
} }
/// <summary>
/// 글 안 <paramref name="line"/> 번째 줄(0부터)의 시작 자리와 길이(줄끝 글자 빼고). 줄이 모자라면 마지막 줄.
/// 편집기에 묻지 않고 글에서 직접 센다 - 글을 막 갈아 끼운 순간 TextBox 의 줄 정보는 아직 <b>옛 글</b> 기준이다
/// (기록으로 확인: 새 글 7번 줄을 물었더니 옛 글의 주석 줄 자리 · 길이가 돌아왔다).
/// </summary>
private static (int Start, int Length)? LineSpan(string text, int line)
{
if (text is null) return null;
var start = 0;
for (var i = 0; i < line; i++)
{
var next = text.IndexOf('\n', start);
if (next < 0) break;
start = next + 1;
}
var end = text.IndexOf('\n', start);
if (end < 0) end = text.Length;
var length = end - start;
if (length > 0 && text[end - 1] == '\r') length--;
return (start, length);
}
/// <summary>사용자가 글자를 고쳤다. 무거운 일은 ViewModel 이 잠시 뒤에 한다.</summary> /// <summary>사용자가 글자를 고쳤다. 무거운 일은 ViewModel 이 잠시 뒤에 한다.</summary>
private void LocalEditor_TextChanged(object sender, TextChangedEventArgs e) private void LocalEditor_TextChanged(object sender, TextChangedEventArgs e)
{ {
@ -1006,10 +1214,16 @@ namespace MergeHub.Views
case ShortcutAction.CopyLeftToRight: case ShortcutAction.CopyLeftToRight:
return Run(vm.IsThreeWay ? (ICommand)vm.AcceptLocalCommand : vm.AcceptCommand); return Run(vm.IsThreeWay ? (ICommand)vm.AcceptLocalCommand : vm.AcceptCommand);
// 2-Way 에서는 12차 G-2 부터 <이 줄을 왼쪽 파일로>다([<] 단추와 같은 일).
// 예전에는 '이 줄 되돌리기'였는데, 화살표가 가리키는 대로 왼쪽으로 보내는 자리로 넘겼다.
// 되돌리기는 Ctrl+Z 가 맡는다.
case ShortcutAction.CopyRightToLeft: case ShortcutAction.CopyRightToLeft:
return Run(vm.IsThreeWay ? (ICommand)vm.AcceptRemoteCommand : vm.RevertCommand); return Run(vm.IsThreeWay ? (ICommand)vm.AcceptRemoteCommand : vm.TakeLineToBaseCommand,
vm.IsThreeWay ? null : vm.SelectedLine);
// 이 덩어리를 왼쪽 파일로(12차 G-2). Alt+Shift+→ 의 거울이다.
case ShortcutAction.AcceptBlockToBase: return Run(vm.AcceptBlockToBaseCommand);
// 되돌릴 머지 작업이 없으면 키를 먹지 않는다 - 그래야 편집기의 글자 되돌리기가 살아 있다.
case ShortcutAction.Undo: return Run(vm.UndoCommand); case ShortcutAction.Undo: return Run(vm.UndoCommand);
case ShortcutAction.Redo: return Run(vm.RedoCommand); case ShortcutAction.Redo: return Run(vm.RedoCommand);
@ -1078,10 +1292,13 @@ namespace MergeHub.Views
} }
/// <summary>지금 못 하는 동작(예: 고칠 게 없는데 저장)이면 키를 먹지 않고 넘긴다.</summary> /// <summary>지금 못 하는 동작(예: 고칠 게 없는데 저장)이면 키를 먹지 않고 넘긴다.</summary>
private static bool Run(ICommand command) private static bool Run(ICommand command) => Run(command, null);
/// <summary>줄 하나를 받는 동작용(12차 G-2 - [&lt;] 처럼 고른 줄에 대고 하는 것).</summary>
private static bool Run(ICommand command, object? parameter)
{ {
if (!command.CanExecute(null)) return false; if (!command.CanExecute(parameter)) return false;
command.Execute(null); command.Execute(parameter);
return true; return true;
} }
@ -1199,12 +1416,9 @@ namespace MergeHub.Views
private void ShortcutHelp_Click(object sender, RoutedEventArgs e) => private void ShortcutHelp_Click(object sender, RoutedEventArgs e) =>
ShortcutHelpWindow.ShowOrActivate(Window.GetWindow(this)); ShortcutHelpWindow.ShowOrActivate(Window.GetWindow(this));
/// <summary>프로그램 정보 창(12차 B-2). 만든 사람 · 문의처는 설정에서 읽어 넘긴다.</summary> /// <summary>프로그램 정보 창(12차 B-2). 값은 모두 실행 파일에서 읽는다(설정 칸은 2026-09-30 에 뺐다).</summary>
private void About_Click(object sender, RoutedEventArgs e) private void About_Click(object sender, RoutedEventArgs e) =>
{ AboutWindow.ShowOrActivate(Window.GetWindow(this));
var settings = (DataContext as ViewModels.HomeViewModel)?.Workspace.Settings;
AboutWindow.ShowOrActivate(Window.GetWindow(this), settings?.ProgramAuthor, settings?.SupportContact);
}
// ================= 찾기 ================= // ================= 찾기 =================
@ -1485,7 +1699,18 @@ namespace MergeHub.Views
} }
if (double.IsInfinity(rect.Top) || double.IsNaN(rect.Top)) return; 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 + Bias(LocalLines), null);
SyncPanesFrom(MergeLine.TextPane.Local, y, viewer.ViewportHeight);
} }
/// <summary>글 안의 어느 자리든 화면에 들여 놓는다(캐럿·찾은 자리 공통).</summary> /// <summary>글 안의 어느 자리든 화면에 들여 놓는다(캐럿·찾은 자리 공통).</summary>

View File

@ -0,0 +1,253 @@
<UserControl x:Class="MergeHub.Views.MemoView"
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">
<UserControl.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="/MergeHub;component/Views/Controls/AppStyles.xaml"/>
</ResourceDictionary.MergedDictionaries>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
<Style x:Key="Muted" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Muted}"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
</ResourceDictionary>
</UserControl.Resources>
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <!-- ① 동작 한 줄 -->
<RowDefinition Height="Auto"/> <!-- ② 메모 탭 줄 -->
<RowDefinition Height="Auto"/> <!-- ③ 찾기 · 바꾸기(Ctrl+F 로 펼 때만) -->
<RowDefinition Height="*"/> <!-- ④ 편집기 -->
<RowDefinition Height="Auto"/> <!-- ⑤ 아래 상태 줄 -->
</Grid.RowDefinitions>
<!-- ① 동작 -->
<Border Grid.Row="0" Padding="6,5" BorderThickness="1" Margin="0,0,0,4"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<DockPanel>
<TextBlock DockPanel.Dock="Right" Style="{StaticResource Muted}" FontSize="11"
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}"
Background="{DynamicResource Ubisam.Brush.Accent.Fill}"
Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}" FontWeight="Bold"
ToolTip="이름 없는 메모를 한 장 더 만듭니다 (Ctrl+N)"/>
<Button Content="파일 열기..." Command="{Binding OpenFileCommand}"
Height="26" MinWidth="84" Padding="10,0" FontSize="11" Margin="0,0,4,0"
ToolTip="파일을 메모로 엽니다 (Ctrl+O)"/>
<Button Content="저장" Command="{Binding SaveCommand}"
Height="26" MinWidth="60" Padding="10,0" FontSize="11" Margin="0,0,4,0"
ToolTip="파일로 씁니다. 이름이 없으면 어디에 쓸지 묻습니다 (Ctrl+S)"/>
<Button Content="다른 이름으로..." Command="{Binding SaveAsCommand}"
Height="26" MinWidth="104" Padding="10,0" FontSize="11" Margin="0,0,4,0"/>
<Button Content="이 메모 닫기" Command="{Binding CloseMemoCommand}"
Height="26" MinWidth="86" Padding="10,0" FontSize="11" Margin="0,0,4,0"
ToolTip="이 메모를 닫습니다 (Ctrl+W). 저장하지 않은 것이 있으면 물어봅니다"/>
<TextBlock/>
</DockPanel>
</Border>
<!-- ② 메모 탭 줄. 파일 비교의 문서 탭(12차 E-2)과 같은 모양이다. -->
<ListBox Grid.Row="1" x:Name="MemoTabs"
ItemsSource="{Binding Items}"
SelectedItem="{Binding CurrentMemo, Mode=TwoWay}"
Margin="0,0,0,4" Padding="0" BorderThickness="0" Background="Transparent"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Disabled">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</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">
<ContentPresenter/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Border x:Name="TabShell" Margin="0,0,3,0" CornerRadius="3,3,0,0" BorderThickness="1,1,1,0"
Background="{DynamicResource Ubisam.Brush.Input.Background}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
MouseDown="MemoTab_MouseDown"
ToolTip="{Binding Tip}">
<StackPanel Orientation="Horizontal" Height="24">
<TextBlock Text="&#8226;" Width="10" FontSize="13" FontWeight="Bold"
VerticalAlignment="Center" TextAlignment="Center" Margin="4,0,0,0"
Foreground="{DynamicResource Ubisam.Brush.Accent}"
Visibility="{Binding IsDirty, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="{Binding Title}" FontSize="11" VerticalAlignment="Center"
Margin="2,0,2,0" MaxWidth="240" TextTrimming="CharacterEllipsis"/>
<Button Content="&#215;" FontSize="12" Width="18" Height="18" Padding="0" Margin="2,0,3,0"
VerticalAlignment="Center" Focusable="False"
Command="{Binding DataContext.CloseMemoCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}"
ToolTip="이 메모를 닫습니다. 저장하지 않은 것이 있으면 물어봅니다"/>
</StackPanel>
</Border>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding IsCurrent}" Value="True">
<Setter TargetName="TabShell" Property="Background"
Value="{DynamicResource Ubisam.Brush.ContentBackground}"/>
<Setter TargetName="TabShell" Property="BorderBrush"
Value="{DynamicResource Ubisam.Brush.Accent}"/>
<Setter TargetName="TabShell" Property="BorderThickness" Value="1,2,1,0"/>
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- ③ 찾기 · 바꾸기 -->
<Border Grid.Row="2" Padding="8,5" BorderThickness="1" Margin="0,0,0,4"
Visibility="{Binding IsFindOpen, Converter={StaticResource BoolToVis}}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<WrapPanel Orientation="Horizontal">
<TextBlock Text="찾기" Style="{StaticResource Muted}" FontSize="11" Margin="0,0,6,0"/>
<TextBox x:Name="FindBox" Text="{Binding FindText, UpdateSourceTrigger=PropertyChanged}"
Width="200" Height="26" FontFamily="Consolas" FontSize="11"
VerticalContentAlignment="Center" Margin="0,0,6,0"
KeyDown="FindBox_KeyDown"/>
<Button Content="다음" Click="FindNext_Click" Height="26" MinWidth="54" Margin="0,0,4,0"/>
<Button Content="이전" Click="FindPrev_Click" Height="26" MinWidth="54" Margin="0,0,12,0"/>
<TextBlock Text="바꾸기" Style="{StaticResource Muted}" FontSize="11" Margin="0,0,6,0"/>
<TextBox x:Name="ReplaceBox" Text="{Binding ReplaceText, UpdateSourceTrigger=PropertyChanged}"
Width="200" Height="26" FontFamily="Consolas" FontSize="11"
VerticalContentAlignment="Center" Margin="0,0,6,0"/>
<Button Content="바꾸기" Click="Replace_Click" Height="26" MinWidth="64" Margin="0,0,4,0"/>
<Button Content="모두 바꾸기" Click="ReplaceAll_Click" Height="26" MinWidth="86" Margin="0,0,12,0"/>
<CheckBox Content="대소문자 구분" IsChecked="{Binding FindMatchCase}"
VerticalAlignment="Center" Margin="0,0,12,0"/>
<Button Content="찾기 접기" Command="{Binding CloseFindCommand}" Height="26" MinWidth="70"
ToolTip="Esc 로도 접힙니다"/>
</WrapPanel>
</Border>
<!-- ④ 편집기. 왼쪽에 줄 번호, 그 오른쪽에 글. 둘이 같이 굴러야 줄이 어긋나지 않는다. -->
<Border Grid.Row="3" BorderThickness="1"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
Background="{DynamicResource Ubisam.Brush.Input.Background}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/> <!-- 12차 G-8: 위치 막대 -->
</Grid.ColumnDefinitions>
<!--
줄 번호도 <b>편집기와 같은 종류의 컨트롤</b>(TextBox)로 그린다.
TextBlock 으로 그렸더니 줄 높이가 조금씩 달라 아래로 갈수록 번호와 글이 어긋났다
(화면에서 보고 고침 - 사용자 보고 2026-09-28). 글꼴 · 크기 · 여백을 같은 값으로 두면
두 칸이 같은 자로 줄을 잰다.
-->
<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" Style="{x:Null}"
IsReadOnly="True" IsTabStop="False" Focusable="False"
IsHitTestVisible="False"
BorderThickness="0" Background="Transparent"
Foreground="{DynamicResource Ubisam.Brush.Muted}"
FontFamily="Consolas"
FontSize="{Binding ElementName=Editor, Path=FontSize}"
AcceptsReturn="True"
TextAlignment="Right" TextWrapping="NoWrap"
VerticalScrollBarVisibility="Hidden" HorizontalScrollBarVisibility="Hidden"
VerticalContentAlignment="Top"
Padding="6,2" MinWidth="40"/>
</Border>
<!-- VerticalContentAlignment 을 Top 으로 <b>못 박는다</b>. 플랫폼 입력 칸 스타일은
한 줄짜리 칸을 겨냥해 가운데로 두는데, 여러 줄 편집기에서는 글이
칸 한가운데에서 시작해 위아래로 자란다(사용자 보고 2026-09-28). -->
<!-- 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"
VerticalContentAlignment="Top"
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>
<!-- ⑤ 아래 상태 줄 -->
<Border Grid.Row="4" Padding="8,5" BorderThickness="1,0,1,1"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<DockPanel>
<TextBlock DockPanel.Dock="Right" x:Name="CaretText" Style="{StaticResource Muted}" FontSize="11"/>
<TextBlock Text="{Binding Status}" FontSize="11"/>
</DockPanel>
</Border>
</Grid>
</UserControl>

View File

@ -0,0 +1,494 @@
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
{
/// <summary>
/// 메모장 탭(12차 F-1).
///
/// 편집기 글은 <b>이 컨트롤이 들고 있고</b> ViewModel 은 필요할 때만 읽어 간다 -
/// 바인딩으로 글자마다 문서 전체를 넘기면 큰 글에서 눈에 띄게 느리다(파일 비교와 같은 까닭).
/// 줄 번호는 따로 그리고 편집기가 굴러갈 때 같이 굴린다.
/// </summary>
public partial class MemoView : UserControl
{
/// <summary>ViewModel 이 글을 갈아 끼우는 중. 사용자의 편집과 구분하려고 둔다.</summary>
private bool _replacing;
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 += (_, _) =>
{
StretchContent(Editor);
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 와 같은 손버릇).
PreviewMouseWheel += (_, e) =>
{
if (Keyboard.Modifiers != ModifierKeys.Control) return;
var size = Editor.FontSize + (e.Delta > 0 ? 1 : -1);
Editor.FontSize = Math.Max(8, Math.Min(28, size));
DrawNumbers();
Paint.InvalidateVisual();
e.Handled = true;
};
IsVisibleChanged += (_, e) =>
{
if (e.NewValue is true)
{
Dispatcher.BeginInvoke(new Action(() => Editor.Focus()),
System.Windows.Threading.DispatcherPriority.Input);
}
};
}
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (e.OldValue is MemoViewModel old)
{
old.EditorTextReplaced -= OnEditorTextReplaced;
old.CaretIndexRequested -= OnCaretIndexRequested;
old.ReadEditorText = null;
old.ReadCaretIndex = null;
}
if (e.NewValue is MemoViewModel now)
{
now.EditorTextReplaced += OnEditorTextReplaced;
now.CaretIndexRequested += OnCaretIndexRequested;
now.ReadEditorText = () => Editor.Text;
now.ReadCaretIndex = () => Editor.CaretIndex;
// 만들어질 때 되살린 메모를 화면에 올린다.
Dispatcher.BeginInvoke(new Action(now.ShowCurrent),
System.Windows.Threading.DispatcherPriority.Loaded);
}
}
private void OnEditorTextReplaced(object? sender, string text)
{
_replacing = true;
try
{
Editor.Text = text ?? string.Empty;
Editor.CaretIndex = 0;
}
finally
{
_replacing = false;
}
DrawNumbers();
QueueRefresh();
}
private void OnCaretIndexRequested(object? sender, int caretIndex)
{
Dispatcher.BeginInvoke(new Action(() =>
{
var at = Math.Max(0, Math.Min(caretIndex, Editor.Text.Length));
Editor.CaretIndex = at;
Editor.ScrollToLine(Editor.GetLineIndexFromCharacterIndex(at));
}), System.Windows.Threading.DispatcherPriority.Loaded);
}
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);
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>
private void DrawNumbers()
{
var lines = Editor.LineCount;
if (lines < 1) lines = 1;
var text = new StringBuilder();
for (var i = 1; i <= lines; i++)
{
text.Append(i);
if (i < lines) text.Append('\n');
}
var now = text.ToString();
if (LineNumbers.Text == now) return;
LineNumbers.Text = now;
// 글을 갈아 끼우면 줄 번호 칸이 맨 위로 돌아간다 - 편집기가 보던 자리로 다시 맞춘다.
if (_editorScroll is not null) LineNumbers.ScrollToVerticalOffset(_editorScroll.VerticalOffset);
}
/// <summary>
/// 편집기가 굴러가면 줄 번호도 같이 굴린다.
///
/// 줄 번호 칸도 TextBox 라 <see cref="TextBox.ScrollToVerticalOffset"/> 로 같은 자리에 맞춘다 -
/// 둘이 같은 종류 · 같은 글꼴이라 <b>같은 자로 잰 자리</b>가 나온다.
/// </summary>
private void HookScroll()
{
_editorScroll = FindScroll(Editor);
if (_editorScroll is null) return;
_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);
};
}
/// <summary>
/// 글이 <b>맨 위부터</b> 시작하게 한다(사용자 보고 2026-09-28).
///
/// 플랫폼 입력 칸 스타일은 한 줄짜리 칸을 겨냥해 글 칸(PART_ContentHost)을 <b>세로 가운데</b>에 붙박아 둔다.
/// 그래서 여러 줄 편집기에서는 글이 칸 한가운데에서 시작해 위아래로 자랐다.
/// <c>VerticalContentAlignment</c> 로는 안 풀린다 - 템플릿 안에 박힌 값이라 그 칸을 직접 늘려야 한다.
/// </summary>
private static void StretchContent(TextBox box)
{
if (FindScroll(box) is not { } host) return;
host.VerticalAlignment = VerticalAlignment.Stretch;
host.VerticalContentAlignment = VerticalAlignment.Top;
}
private static ScrollViewer? FindScroll(DependencyObject root)
{
if (root is ScrollViewer viewer) return viewer;
var count = System.Windows.Media.VisualTreeHelper.GetChildrenCount(root);
for (var i = 0; i < count; i++)
{
if (FindScroll(System.Windows.Media.VisualTreeHelper.GetChild(root, i)) is { } found) return found;
}
return null;
}
/// <summary>메모 탭을 가운데 단추로 누르면 닫는다(문서 탭과 같은 손버릇).</summary>
private void MemoTab_MouseDown(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton != MouseButton.Middle) return;
if (DataContext is not MemoViewModel vm) return;
if (sender is not FrameworkElement element || element.DataContext is not Memo memo) return;
if (vm.CloseMemoCommand.CanExecute(memo)) vm.CloseMemoCommand.Execute(memo);
e.Handled = true;
}
// ================= 단축키 =================
private void View_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (DataContext is not MemoViewModel vm) return;
var ctrl = Keyboard.Modifiers == ModifierKeys.Control;
var ctrlShift = Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Shift);
if (ctrl && e.Key == Key.N) { vm.NewMemoCommand.Execute(null); e.Handled = true; return; }
if (ctrl && e.Key == Key.O) { vm.OpenFileCommand.Execute(null); e.Handled = true; return; }
if (ctrl && e.Key == Key.S) { if (vm.SaveCommand.CanExecute(null)) vm.SaveCommand.Execute(null); e.Handled = true; return; }
if (ctrl && e.Key == Key.W) { if (vm.CloseMemoCommand.CanExecute(null)) vm.CloseMemoCommand.Execute(null); e.Handled = true; return; }
if (ctrl && e.Key == Key.Tab) { if (vm.NextMemoCommand.CanExecute(null)) vm.NextMemoCommand.Execute(null); e.Handled = true; return; }
if (ctrlShift && e.Key == Key.Tab) { if (vm.PreviousMemoCommand.CanExecute(null)) vm.PreviousMemoCommand.Execute(null); e.Handled = true; return; }
if (ctrl && e.Key == Key.F)
{
vm.OpenFindCommand.Execute(null);
Dispatcher.BeginInvoke(new Action(() => { FindBox.Focus(); FindBox.SelectAll(); }),
System.Windows.Threading.DispatcherPriority.Loaded);
e.Handled = true;
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);
Editor.Focus();
e.Handled = true;
}
}
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 =>
(DataContext as MemoViewModel)?.FindMatchCase == true
? StringComparison.Ordinal
: StringComparison.OrdinalIgnoreCase;
private void FindBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key != Key.Enter) return;
FindStep(forward: true);
e.Handled = true;
}
private void FindNext_Click(object sender, RoutedEventArgs e) => FindStep(forward: true);
private void FindPrev_Click(object sender, RoutedEventArgs e) => FindStep(forward: false);
/// <summary>
/// 다음(또는 앞) 자리로. 끝에 닿으면 <b>처음으로 돌아 한 바퀴</b> 돈다 -
/// 못 찾았다고 말하기 전에 나머지 반쪽도 봐야 한다.
/// </summary>
private void FindStep(bool forward)
{
if (DataContext is not MemoViewModel vm) return;
var what = vm.FindText;
if (string.IsNullOrEmpty(what)) return;
var text = Editor.Text;
if (text.Length == 0) { vm.Status = $"'{what}' 을(를) 찾지 못했습니다."; return; }
int at;
if (forward)
{
var from = Math.Min(Editor.SelectionStart + Math.Max(Editor.SelectionLength, 1), text.Length);
at = from >= text.Length ? -1 : text.IndexOf(what, from, Comparison);
if (at < 0) at = text.IndexOf(what, 0, Comparison);
}
else
{
var from = Editor.SelectionStart - 1;
at = from >= 0 ? text.LastIndexOf(what, Math.Min(from, text.Length - 1), Comparison) : -1;
if (at < 0) at = text.LastIndexOf(what, text.Length - 1, Comparison);
}
if (at < 0)
{
vm.Status = $"'{what}' 을(를) 찾지 못했습니다.";
return;
}
Editor.Select(at, what.Length);
Editor.ScrollToLine(Editor.GetLineIndexFromCharacterIndex(at));
Editor.Focus();
vm.Status = $"'{what}' 을(를) 찾았습니다.";
}
private void Replace_Click(object sender, RoutedEventArgs e)
{
if (DataContext is not MemoViewModel vm) return;
if (string.IsNullOrEmpty(vm.FindText)) return;
// 지금 고른 것이 찾는 글이면 그것을 바꾸고, 아니면 먼저 찾아만 둔다.
if (Editor.SelectedText.Equals(vm.FindText, Comparison))
{
var at = Editor.SelectionStart;
Editor.SelectedText = vm.ReplaceText ?? string.Empty;
Editor.Select(at + (vm.ReplaceText?.Length ?? 0), 0);
}
FindStep(forward: true);
}
private void ReplaceAll_Click(object sender, RoutedEventArgs e)
{
if (DataContext is not MemoViewModel vm) return;
var what = vm.FindText;
if (string.IsNullOrEmpty(what)) return;
var text = Editor.Text;
var made = new StringBuilder();
var from = 0;
var count = 0;
while (true)
{
var at = text.IndexOf(what, from, Comparison);
if (at < 0) break;
made.Append(text, from, at - from).Append(vm.ReplaceText ?? string.Empty);
from = at + what.Length;
count++;
}
if (count == 0) { vm.Status = $"'{what}' 을(를) 찾지 못했습니다."; return; }
made.Append(text, from, text.Length - from);
Editor.Text = made.ToString();
vm.Status = $"{count}곳을 바꿨습니다.";
}
}
}

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