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 | **시험 도구를 런처에 맞춤** — 새 플랫폼은 `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)로 확인. **커밋 안 함**(사용자 지시) | 없음 |

View File

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

View File

@ -1,5 +1,8 @@
# MergeHub 진행 상황
> **이어받는 사람은 [HANDOFF.md](HANDOFF.md) 를 먼저 읽는다** — 지켜야 할 것,
> 항목 하나를 끝내는 순서, 시험 도구가 있는 자리와 함정, 지금 하던 일이 적혀 있다.
**지금 무엇이 남았는지만** 적는다. 끝난 것의 상세는 [history/](history/) 로 옮겼다.
항목을 끝내면 이렇게 한다.
@ -417,14 +420,54 @@
| 상태 | 항목 |
|---|---|
| **완료** | 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)으로 확인 |
### 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-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>
public class AppSetup : IAppSetup
{
public string GetAppName() => "MergeHub - 사내 Git Merge/Diff";
public string GetAppName() => "MergeHub - 사내 Merge/Diff + Git";
public void RegisterViews(ITabManager manager)
{
@ -29,11 +29,20 @@ namespace MergeHub
manager.AddMain<FolderView, FolderViewModel>(Icon.Folder, ViewIds.Folder, "폴더 비교");
manager.AddMain<HomeView, HomeViewModel>(Icon.Document, ViewIds.Home, "파일 비교");
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)
{
manager.AddSettingsSub<MergeHubSettings>(TabManager.SettingsTabKey, "MergeHub");
// 개발자 정보(12차 G-6). 처음에는 하단 탭(개요)으로 만들었으나 사용자가 설정 안 메뉴로 옮겼다 -
// 작업 흐름 밖의 안내라 매일 누르는 하단 탭 줄에 자리를 차지할 까닭이 없다. 플랫폼은 건드리지 않고
// 플랫폼이 이미 주는 AddSub(설정 탭 키)로 붙인다(플랫폼의 UI Setting · 백업 · 로그와 같은 길).
manager.AddSub<OverviewView, OverviewViewModel>(TabManager.SettingsTabKey, "개발자 정보");
}
public void RegisterServices(IServiceCollection services)
@ -52,5 +61,6 @@ namespace MergeHub
public const string Home = "MergeHub.Home";
public const string Folder = "MergeHub.Folder";
public const string Git = "MergeHub.Git";
public const string Memo = "MergeHub.Memo";
}
}

View File

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

View File

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

View File

@ -101,6 +101,42 @@ namespace MergeHub.Models
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>
/// 무시 규칙까지 감안해 두 줄이 같은지. 사용자가 LOCAL 을 고쳐도 그때그때 다시 따진다.
/// </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) =>
_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>
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 bool HasBlockComment => BlockStart is { Length: > 0 } && BlockEnd is { Length: > 0 };
/// <summary>
/// 메모 강조 콤보(12차 G-8)의 선택 칸이 이 글을 그린다 - 플랫폼 콤보 템플릿은 선택 칸에 DisplayMemberPath 를
/// 안 써서, 없으면 형 이름(MergeHub.Services.LanguageProfile)이 보였다(사진으로 확인). UIA 이름도 이것이다.
/// </summary>
public override string ToString() => Name;
}
/// <summary>

View File

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

View File

@ -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) =>
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) =>
Build(author, when, source: null, comment, path);

View File

@ -77,6 +77,23 @@ namespace MergeHub.Services
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>
public static string FolderOfFile(string? filePath)
{

View File

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

View File

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

View File

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

View File

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

View File

@ -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>
private FolderCompareResult? _lastResult;
@ -268,6 +272,59 @@ namespace MergeHub.ViewModels
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()
{
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.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.Input;
using MergeHub.Services;
using UbisamBase.Core.Messaging;
@ -209,14 +212,20 @@ namespace MergeHub.ViewModels
SyncTabItems();
if (Tabs.LastEvicted is { } gone)
{
_workspace.AppendLog($"탭이 {DocTabs.Soft}개를 넘어 오래 안 본 탭을 닫음: {gone.Title}");
// 로그 한 줄로는 모르고 지나간다(사용자 2026-09-30). 닫히는 탭은 늘 저장할 것이 없는 탭이다 - 그것도 함께 알린다.
MessageUtil.ShowWarningToast($"탭이 {DocTabs.Soft}개를 넘어 오래 안 본 '{gone.Title}' 탭을 닫았습니다 (고친 것이 없던 탭)");
}
SaveSession(); // 12차 E-1
}
/// <summary>
/// 지금 짝에 탭이 하나는 있게 한다. <see cref="LoadPlainFilesAsync"/> 가 맨 앞에서 부른다.
///
/// 여는 길(<c>OnPlainCompareRequested</c>)을 거치지 않고 바로 짝이 바뀌는 자리가 있다 -
/// 켤 때 설정의 기본 짝, [기준 파일...] · [내 파일...] 로 한쪽만 바꾸기, 데모 전환 뒤 돌아오기.
/// 켤 때 설정의 기본 짝, [기준 파일...] · [내 파일...] 로 한쪽만 바꾸기.
/// 그 자리에 탭이 없으면 거기서 고친 것을 맡을 데가 없어 <b>다음에 짝을 바꿀 때 사라진다</b> -
/// E-2 가 없애려던 바로 그 일이다.
///
@ -253,6 +262,9 @@ namespace MergeHub.ViewModels
{
if (Tabs.Current is { } tab && TabsCanHoldEdits)
tab.IsDirty = HasTypedEdits;
// 고친 것이 생겼거나(보관해야 한다) 없어졌다(보관본을 치워야 한다) - 둘 다 다시 쓸 일이다.
TouchSession();
}
// [ObservableProperty] 가 만들어 주는 갈고리. 고친 것이 생기거나 없어지면 탭 표시가 따라간다.
@ -270,6 +282,7 @@ namespace MergeHub.ViewModels
Tabs.Activate(tab);
_resumeTab = tab;
SyncTabItems();
SaveSession(); // 12차 E-1 - 옮기는 순간이 곧 끄기 직전일 수 있다. 미루지 않는다.
LeftFile = tab.Left;
RightFile = tab.Right;
@ -304,6 +317,7 @@ namespace MergeHub.ViewModels
var next = Tabs.Close(tab);
SyncTabItems();
SaveSession(); // 12차 E-1 - 닫아도 글은 남는다. 그 자리에서 적어 둔다.
if (next is null || ReferenceEquals(next, tab)) return;
@ -332,5 +346,208 @@ namespace MergeHub.ViewModels
if (at < 0 || Tabs.Count < 2) return;
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 없이도 쓰는
// 도구라는 것을 첫 화면에서 바로 보여 준다. 둘을 동시에 걸면 안 된다.
// 작업 게이트가 하나뿐이라 먼저 건 쪽이 끝날 때까지 뒤엣것이 통째로 취소된다.
// 12차 E-1: 지난번에 저장하지 않고 끈 편집이 있으면 그것부터 되살린다(묻지 않는다).
// 위의 "시키는 일"(충돌 열기 · mergetool · 폴더 비교에서 고른 파일)이 있을 때는 여기까지 오지 않는다 -
// 사용자가 지금 열라고 한 것이 먼저다. 기본 저장소 · 기본 짝보다는 먼저다.
if (!_workspace.LooksLikeRepository && RestoreSession())
{
_ = LoadRestoredAsync();
return;
}
if (_workspace.LooksLikeRepository)
_ = _workspace.TryAutoOpenAsync();
else
@ -226,8 +235,7 @@ namespace MergeHub.ViewModels
RefreshLanguage();
var rows = BuildThreeWayLines(baseText, localText, remoteText);
Lines.Clear();
foreach (var row in rows) Lines.Add(row);
Lines.ReplaceAll(rows); // 알림 한 번(MergeLineCollection 참고)
BaseHeaderLead = string.Empty;
LocalHeaderLead = string.Empty;
@ -288,66 +296,6 @@ namespace MergeHub.ViewModels
TextFile.Write(target, content, encoding, newLine);
}
// ================= 데모 전환 (발표·교육용) =================
/// <summary>설정에 데모 경로가 둘 다 적혀 있을 때만 전환 버튼을 띄운다.</summary>
public bool CanSwitchDemo =>
Directory.Exists(_workspace.Settings.DemoGitPath) &&
Directory.Exists(_workspace.Settings.DemoPlainPath);
/// <summary>다음에 어디로 넘어가는지 버튼에 그대로 적는다.</summary>
public string DemoSwitchLabel => IsGitMode ? "데모 → .git 없음" : "데모 → .git 있음";
/// <summary>
/// Git 있는 폴더와 없는 폴더를 오간다.
///
/// 발표에서 "저장소면 이렇게, 아니면 이렇게"를 한 자리에서 보여 주려고 둔 것이다.
/// 그동안은 설정 파일을 고치고 프로그램을 다시 켜는 것 말고는 방법이 없었다.
/// </summary>
[RelayCommand]
private async Task SwitchDemoAsync()
{
if (!CanSwitchDemo) return;
if (!ConfirmDiscard("데모 폴더를 바꿉니다.")) return;
var toGit = !IsGitMode;
var target = toGit ? _workspace.Settings.DemoGitPath : _workspace.Settings.DemoPlainPath;
// 3-Way 를 끄되 그 때문에 본문을 다시 읽지는 않게 한다.
// 재로드가 작업 게이트를 먼저 잡아 버리면 바로 뒤의 저장소 닫기·파일 비교가
// "다른 작업 진행 중"으로 통째로 건너뛰어져 화면이 빈 채로 남는다.
_suspendReload = true;
try
{
RemoteBranch = NoRemoteBranch;
}
finally
{
_suspendReload = false;
}
_workspace.RepositoryPath = target;
if (toGit && _workspace.LooksLikeRepository)
{
await _workspace.RunAndRefreshAsync("데모 저장소 열기", () => _workspace.Git.Open(target));
}
else
{
// 저장소를 닫아야 Git 이력 칸이 접히고 "파일 비교" 모드로 넘어간다.
// IsGitMode 를 먼저 내리는 것이 중요하다 - 아직 돌고 있던 Git 본문 로드가
// 이 표식을 보고 결과를 버린다. 안 그러면 경로만 새 파일이고 내용은 옛 파일이 된다.
IsGitMode = false;
ClearPanes();
await _workspace.RunAsync("데모 저장소 닫기", () => _workspace.Git.Close());
await LoadDefaultPlainFiles();
}
OnPropertyChanged(nameof(DemoSwitchLabel));
_workspace.AppendLog($"데모 전환: {target}");
}
/// <summary>설정의 기본 비교 파일 두 개를 열어 둔다. 없거나 사라졌으면 조용히 넘어간다.</summary>
private Task LoadDefaultPlainFiles()
{
@ -372,7 +320,7 @@ namespace MergeHub.ViewModels
// ================= 3분할 상태 =================
/// <summary>BASE 와 LOCAL 을 같은 줄 번호로 묶은 행 목록.</summary>
public ObservableCollection<MergeLine> Lines { get; } = new ObservableCollection<MergeLine>();
public MergeLineCollection Lines { get; } = new MergeLineCollection();
/// <summary>비교 대상 파일 목록(충돌 중이면 충돌 파일, 아니면 선택 커밋의 파일).</summary>
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 =>
_workspace.Settings.InsertMergeComment ? BuildMergeNote(PreviewSource) : "(주석 남기기 꺼짐)";
!_workspace.Settings.InsertMergeComment ? "(주석 남기기 꺼짐)"
: MergeNote.AuthorMissing(true, _workspace.Settings.MergeCommentAuthor) ? "(작성자가 비어 있어 반영할 수 없습니다 - 설정 > 머지 주석 > 작성자)"
: BuildMergeNote(PreviewSource);
partial void OnSelectedLineChanged(MergeLine? value) => RaiseCanExecuteChanged();
@ -817,9 +768,23 @@ namespace MergeHub.ViewModels
private void TakeLine(MergeLine? line)
{
if (line is null || !line.CanTakeBase) return;
if (!AuthorReady()) return;
Apply(line);
}
/// <summary>
/// 반영 명령 <b>맨 앞</b>에서 부른다 - 주석을 남기게 해 두었는데 작성자가 비었으면 알리고 멈춘다(사용자 결정 2026-09-30).
/// 줄마다(<see cref="Apply"/> 안)가 아니라 명령마다 거는 까닭: [모두 반영] 이 줄 수만큼 알리거나,
/// 구간 반영이 반쯤 된 채 멈추는 일이 없게.
/// </summary>
private bool AuthorReady()
{
if (!MergeNote.AuthorMissing(_workspace.Settings.InsertMergeComment, _workspace.Settings.MergeCommentAuthor)) return true;
MessageUtil.ShowInfo(MergeNote.AuthorMissingTitle, MergeNote.AuthorMissingMessage);
_workspace.AppendLog("반영하지 않음 - 작성자가 비어 있다(설정 > 머지 주석 > 작성자)");
return false;
}
private bool CanAccept() => SelectedLine?.CanTakeBase == true;
[RelayCommand(CanExecute = nameof(CanAccept))]
@ -870,13 +835,11 @@ namespace MergeHub.ViewModels
bool afterHasBase;
string? note = null;
if (line.HasLocal)
{
note = _workspace.Settings.InsertMergeComment && group == 0
? BuildMergeNote(MergeNames.LocalSource(LocalSourceName)) : null;
}
// LOCAL 에 없던 줄(BASE 에서 지우는 반영)에도 주석을 남긴다 - 오른쪽 반영(Apply)과 같은 규칙(사용자 2026-09-30).
(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))
return false;
@ -891,7 +854,7 @@ namespace MergeHub.ViewModels
EditKind.TakeBase, description)
{
Side = MergeLine.TextPane.Base,
InsertedNote = InsertBaseNoteAbove(line, note),
InsertedNote = InsertBaseNoteAbove(line, note, indentFrom: before),
Group = group,
});
@ -903,11 +866,86 @@ namespace MergeHub.ViewModels
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>
[RelayCommand(CanExecute = nameof(CanTakeLineToBase))]
private void TakeLineToBase(MergeLine? line)
{
if (line is null) return;
if (!AuthorReady()) return;
ApplyToBase(line);
}
@ -915,14 +953,15 @@ namespace MergeHub.ViewModels
/// 왼쪽으로 반영한 줄 <b>위에</b> 넣을 머지 주석 한 줄. 오른쪽 것(<see cref="InsertNoteAbove"/>)의 거울이라
/// <b>왼쪽에만 있는 줄</b>로 넣는다 - 오른쪽 파일에는 들어가지 않는다.
/// </summary>
private MergeLine? InsertBaseNoteAbove(MergeLine line, string? note)
private MergeLine? InsertBaseNoteAbove(MergeLine line, string? note, string? indentFrom = null)
{
if (string.IsNullOrWhiteSpace(note)) return null;
var at = Lines.IndexOf(line);
if (at < 0) return null;
var indent = new string(' ', line.BaseText.Length - line.BaseText.TrimStart().Length);
// 지우는 반영이면 BASE 글이 이미 비었다 - 지우기 전 글이나 LOCAL 쪽 글에서 잰다.
var indent = IndentOf(line.BaseText, indentFrom, line.LocalText);
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);
NoteRowMoved(at, +1);
return row;
}
@ -988,6 +1028,7 @@ namespace MergeHub.ViewModels
[RelayCommand(CanExecute = nameof(CanAcceptAll))]
private void AcceptAll()
{
if (!AuthorReady()) return;
foreach (var line in Lines.Where(l => l.CanTakeBase).ToList())
Apply(line, requery: false);
@ -1313,6 +1354,7 @@ namespace MergeHub.ViewModels
SavePlainCommand.NotifyCanExecuteChanged();
SaveLeftPlainCommand.NotifyCanExecuteChanged(); // 12차 B-1
TakeLineToBaseCommand.NotifyCanExecuteChanged(); // 12차 B-1
AcceptBlockToBaseCommand.NotifyCanExecuteChanged(); // 12차 G-2
ExportReportCommand.NotifyCanExecuteChanged();
AutoMergeCommand.NotifyCanExecuteChanged();
@ -1582,9 +1624,9 @@ namespace MergeHub.ViewModels
_workspace.OnUi(() =>
{
// 읽어 오는 사이에 파일 비교 모드로 넘어갔으면(데모 전환·저장소 닫기) 버린다.
// 읽어 오는 사이에 파일 비교 모드로 넘어갔으면(저장소 닫기) 버린다.
// 늦게 끝난 Git 로드가 이미 바뀐 화면을 덮어쓰면, 경로는 새 파일인데 내용은
// 옛 파일인 상태가 된다 - 데모 전환에서 실제로 겪었다.
// 옛 파일인 상태가 된다 - 저장소를 닫는 길에서 실제로 겪었다.
if (!IsGitMode) return;
// 읽기 시작한 뒤 <b>다른 파일 · 다른 커밋을 골랐으면 버린다</b>(8차 A-1).
@ -1633,8 +1675,7 @@ namespace MergeHub.ViewModels
SetDeletion(deletion, theirsName);
OnPropertyChanged(nameof(ConflictSideText));
Lines.Clear();
foreach (var row in rows) Lines.Add(row);
Lines.ReplaceAll(rows); // 알림 한 번(MergeLineCollection 참고)
// BASE 가 비어 있으면 <b>그 파일이 공통 조상에 없었다</b>는 뜻이다 - 양쪽 브랜치가
// 각자 새로 만든 파일이라 견줄 조상이 없다. 그냥 두면 빈 칸이 고장으로 보인다.
@ -1989,16 +2030,17 @@ namespace MergeHub.ViewModels
bool afterHasLocal;
string? note = null;
if (line.HasBase)
{
// 반영한 줄에는 누가·언제 반영했는지 주석을 남긴다.
// 주석은 줄 끝이 아니라 그 줄 "위"에 따로 들어간다 - 아래 InsertNoteAbove 참고.
note = _workspace.Settings.InsertMergeComment && group == 0 ? BuildMergeNote(MergeNames.BaseSource(_baseSourceName)) : null;
}
// BASE 에 없던 줄(LOCAL 에서 지우는 반영)에도 남긴다 - 지운 자리에 누가 지웠는지가 남아야 한다(사용자 2026-09-30).
// 예전에는 BASE 에 줄이 있을 때만 남겨, 지운 반영은 흔적이 없었다. 지운 반영이면 출처 뒤에 "삭제".
// 반영하면 그 칸이 어떻게 되는지는 양쪽이 같은 규칙을 쓴다(12차 B-1, PaneText).
// BASE 에 없던 줄이면 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))
return false;
@ -2013,7 +2055,7 @@ namespace MergeHub.ViewModels
_undo.Push(new LineEdit(line, before, beforeHasLocal, after, afterHasLocal,
EditKind.TakeBase, description)
{
InsertedNote = InsertNoteAbove(line, note),
InsertedNote = InsertNoteAbove(line, note, indentFrom: before),
Group = group,
});
@ -2146,7 +2188,7 @@ namespace MergeHub.ViewModels
partial void OnRemoteBranchChanged(string value)
{
// 3-Way 로 들고 나면 본문을 다시 읽는다. 고치던 것이 있으면 사라진다.
// _suspendReload 가 걸린 길(데모 전환·파일 비교 전환)은 다시 읽지 않으므로 묻지 않는다.
// _suspendReload 가 걸린 길(파일 비교 전환)은 다시 읽지 않으므로 묻지 않는다.
if (!_revertingRemoteBranch && !_syncingBranches && IsGitMode && !_suspendReload &&
!ConfirmDiscard("비교 방식을 바꿉니다."))
{
@ -2214,6 +2256,7 @@ namespace MergeHub.ViewModels
private void TakeLocalLine(MergeLine? line)
{
if (line is null || !line.CanTakeLocal) return;
if (!AuthorReady()) return;
var before = line.LocalText;
var beforeHasLocal = line.HasLocal;
@ -2292,6 +2335,7 @@ namespace MergeHub.ViewModels
private void TakeRemoteLine(MergeLine? line)
{
if (line is null || !line.CanTakeRemote) return;
if (!AuthorReady()) return;
ApplyRemote(line);
}
@ -2314,11 +2358,13 @@ namespace MergeHub.ViewModels
bool afterHasLocal;
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)
{
note = _workspace.Settings.InsertMergeComment && group == 0
? BuildMergeNote(MergeNames.TheirsSource(TheirsSideName, TakesMergeSides))
: null;
after = line.RemoteText;
afterHasLocal = true;
}
@ -2342,7 +2388,7 @@ namespace MergeHub.ViewModels
_undo.Push(new LineEdit(line, before, beforeHasLocal, after, afterHasLocal,
EditKind.TakeBase, description)
{
InsertedNote = InsertNoteAbove(line, note),
InsertedNote = InsertNoteAbove(line, note, indentFrom: before),
Group = group,
});
@ -2366,7 +2412,7 @@ namespace MergeHub.ViewModels
/// 정렬은 저절로 맞는다. 끼워 넣은 줄은 BASE 에 없는 "LOCAL 에만 있는 줄"이라,
/// 이미 추가된 줄을 다루던 방식 그대로 BASE 쪽에 빈 자리(필러)가 생긴다.
/// </summary>
private MergeLine? InsertNoteAbove(MergeLine line, string? note)
private MergeLine? InsertNoteAbove(MergeLine line, string? note, string? indentFrom = null)
{
if (string.IsNullOrWhiteSpace(note)) return null;
@ -2374,7 +2420,8 @@ namespace MergeHub.ViewModels
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,
Rules.IsPlain ? null : Rules.KeyOf)
@ -2392,9 +2439,53 @@ namespace MergeHub.ViewModels
if (line.IsThreeWay) row.SetThreeWay(null, null, null, null);
Lines.Insert(at, row);
NoteRowMoved(at, +1);
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>
private string BuildMergeNote(string? source) => MergeNote.Build(
author: _workspace.AuthorName,
@ -2518,6 +2609,7 @@ namespace MergeHub.ViewModels
{
if (_syncingEditor) return;
TouchSession(); // 12차 E-1 - 손이 멈추면 그때 한 번 보관한다
_reflowTimer.Stop();
if (ShowTypedLineNow(text, caretIndex))
@ -2648,7 +2740,6 @@ namespace MergeHub.ViewModels
? ChangedLocalNumbers(_originalLocalText, localText)
: new HashSet<int>();
Lines.Clear();
foreach (var row in rows)
{
// 반영한 줄인지를 <b>먼저</b> 본다.
@ -2667,10 +2758,12 @@ namespace MergeHub.ViewModels
if (!row.IsApplied && !row.IsMergeNote && row.LocalNumber is int number && touched.Contains(number))
row.IsManuallyEdited = true;
Lines.Add(row);
}
// 줄마다 넣으면 알림이 줄 수만큼 간다 - 표시를 다 매긴 뒤 한 번에 넣는다. 위 · 아래로 그대로인 줄은 기존 객체를 두고
// 번호만 고치고, 바뀐 가운데만 빼고 넣는다 - 칸 목록이 보이는 줄 요소를 전부 다시 만들지 않게(MergeLineCollection.Patch).
Lines.Patch(rows);
if (keepAsEdited) { _undo.Clear(); _redo.Clear(); } // 위에서 글로 옮겨 이미 비었다
else ClearHistory();
@ -2750,8 +2843,6 @@ namespace MergeHub.ViewModels
[NotifyPropertyChangedFor(nameof(ModeLabel))]
[NotifyPropertyChangedFor(nameof(BaseTitle))]
[NotifyPropertyChangedFor(nameof(LocalTitle))]
[NotifyPropertyChangedFor(nameof(DemoSwitchLabel))]
[NotifyPropertyChangedFor(nameof(CanSwitchDemo))]
private bool _isGitMode;
public bool IsPlainMode => !IsGitMode;
@ -2915,7 +3006,7 @@ namespace MergeHub.ViewModels
if (string.IsNullOrWhiteSpace(LeftFile) && string.IsNullOrWhiteSpace(RightFile)) return;
// 12차 E-2: 여는 길을 거치지 않고 바로 불린 경우에도 탭이 하나는 있게 한다
// (켤 때 기본 짝 · [기준 파일...] 로 한쪽만 바꾸기 · 데모 전환 뒤 돌아오기).
// (켤 때 기본 짝 · [기준 파일...] 로 한쪽만 바꾸기).
EnsureTabForCurrentPair();
// 파일 두 개 비교는 늘 두 칸이다. mergetool 네 칸에서 넘어왔으면 접는다.
@ -2974,8 +3065,7 @@ namespace MergeHub.ViewModels
RefreshLanguage();
var rows = BuildLines(baseText, localText);
Lines.Clear();
foreach (var row in rows) Lines.Add(row);
Lines.ReplaceAll(rows); // 알림 한 번(MergeLineCollection 참고)
SetPlainHeaders();
@ -3538,10 +3628,11 @@ namespace MergeHub.ViewModels
/// <summary>지금 캐럿이 놓인 행. 편집기 줄 번호와 1:1 이다.</summary>
[ObservableProperty] private int _caretRow;
/// <summary>줄을 옮기면 "이 덩어리 반영"이 켜지고 꺼진다.</summary>
/// <summary>줄을 옮기면 [구간 반영] 이 켜지고 꺼진다.</summary>
partial void OnCaretRowChanged(int value)
{
AcceptBlockCommand.NotifyCanExecuteChanged();
AcceptBlockToBaseCommand.NotifyCanExecuteChanged(); // 12차 G-2 - 왼쪽으로 보내는 짝
AcceptMemberCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(MemberHint));
}
@ -3975,14 +4066,21 @@ namespace MergeHub.ViewModels
if (toBefore)
{
Lines.Remove(note);
var was = Lines.IndexOf(note);
if (was >= 0)
{
Lines.RemoveAt(was);
NoteRowMoved(was, -1); // 되돌리기로 주석이 빠지면 캐럿도 한 줄 올라와야 같은 줄에 남는다
}
return;
}
if (Lines.Contains(note)) return;
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>
@ -4104,6 +4202,7 @@ namespace MergeHub.ViewModels
{
// 줄을 고르거나 머지 주석이 끼어들면 결과 줄 수가 바뀐다. 그릴 때마다 번호를 다시 매긴다(8차 B-3).
RenumberMerged(Lines);
RenumberBase(Lines); // 왼쪽도 마찬가지다(12차 B-1 로 왼쪽이 바뀔 수 있게 됐다)
var total = Lines.Count;
var diff = Lines.Count(l => l.IsDifferent);
@ -4215,8 +4314,8 @@ namespace MergeHub.ViewModels
/// 사내 소스에는 CP949 로 저장된 파일이 섞여 있어 새로 만드는 파일과 형식이 갈린다.
/// 읽어서 보여 주기만 하면 그 갈림을 못 고치므로 저장할 때 고를 수 있게 한다.
/// </summary>
public IReadOnlyList<string> SaveEncodings => TextFile.SaveEncodings;
public IReadOnlyList<string> SaveNewLines => TextFile.SaveNewLines;
public IReadOnlyList<SaveChoice> SaveEncodings => TextFile.SaveEncodingChoices;
public IReadOnlyList<SaveChoice> SaveNewLines => TextFile.SaveNewLineChoices;
[ObservableProperty] private string _saveEncoding = TextFile.KeepOriginal;
[ObservableProperty] private string _saveNewLine = TextFile.KeepOriginal;
@ -4234,10 +4333,11 @@ namespace MergeHub.ViewModels
{
var block = BlockAround(CaretRow);
if (block.Count == 0) return;
if (!AuthorReady()) return;
// 2-Way 는 BASE 를, 3-Way 는 저쪽(REMOTE)을 가져온다. BlockAround 주석 참고.
// 주석은 덩어리 첫 줄 위 한 줄, 되돌리기는 한 번에(11차 F-2 - 예전에는 줄마다 주석이 붙었다).
var count = ApplyGroup(block, anchor: null, what: "덩어리");
var count = ApplyGroup(block, anchor: null, what: "구간");
if (count > 0) MessageUtil.ShowSuccessToast($"{count}줄 반영");
}
@ -4253,17 +4353,20 @@ namespace MergeHub.ViewModels
{
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 = IsThreeWay ? line.RemoteNumber : line.BaseNumber;
var hadLocal = line.HasLocal;
var done = IsThreeWay
? line.CanTakeRemote && ApplyRemote(line, requery: false, group: group)
: line.CanTakeBase && Apply(line, requery: false, group: group);
if (!done) continue;
if (hadLocal && !line.HasLocal) deleted++;
changed++;
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 description = $"{where}{what} {source} 반영 ({changed}줄)";
// 묶음이 모두 지우는 반영이면 글이 남은 줄이 없다 - 그래도 지운 자리(첫 줄)에 주석을 남긴다(사용자 2026-09-30).
var at = anchor is { HasLocal: true } && Lines.Contains(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)
{
var note = BuildMergeNote(source);
var note = BuildMergeNote(MergeNote.WithDeletion(source, deleted));
_undo.Push(new LineEdit(at, at.LocalText, at.HasLocal, at.LocalText, at.HasLocal,
EditKind.NoteOnly, description)
{
@ -4398,6 +4503,7 @@ namespace MergeHub.ViewModels
return;
}
if (!AuthorReady()) return;
var (member, rows, _) = found.Value;
var nameNumber = member.NameLine + 1;
var anchor = rows.FirstOrDefault(r => (IsThreeWay ? r.RemoteNumber : r.BaseNumber) == nameNumber);
@ -4576,11 +4682,12 @@ namespace MergeHub.ViewModels
private void UpdateLegendCounts()
{
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)
{
if (line.NeedsDecision) needsDecision++;
if (HasInlineChange(line)) inline++;
switch (line.Highlight)
{
@ -4608,9 +4715,24 @@ namespace MergeHub.ViewModels
RemoteChangeCount = remote;
ConflictCount = conflict;
UnresolvedConflictCount = needsDecision;
InlineChangeCount = inline;
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>
/// 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];
}
/// <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);
}
}

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>
private static AboutWindow? open;
public static void ShowOrActivate(Window? owner, string? author, string? contact)
public static void ShowOrActivate(Window? owner)
{
if (open is { IsLoaded: true })
{
@ -94,9 +94,9 @@ namespace MergeHub.Views
}
var assembly = Assembly.GetExecutingAssembly();
var info = ProgramInfoReader.For(assembly, author, contact);
var info = ProgramInfoReader.For(assembly);
var description = assembly.GetCustomAttribute<AssemblyDescriptionAttribute>()?.Description
?? "사내 Git Merge/Diff 도구";
?? "사내 Merge/Diff + Git 도구";
open = new AboutWindow(info, description);
if (owner is not null) open.Owner = owner;

View File

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

View File

@ -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>
<Grid Margin="10">
<!--
12차 D-1: 위쪽을 <b>한 줄</b>로. 예전에는 경로 · 옵션 · 찾기 · 색 범례가 네 줄(203 DIP)을 먹었다.
자주 쓰는 것만 줄에 두고(경로 · 좌우 바꾸기 · 비교), 가끔 쓰는 것은 [옵션 ▾] 서랍으로 넣었다.
찾기는 Ctrl+F 로 부를 때만 펴고, 색 범례는 아래 상태 줄로 옮겼다.
-->
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <!-- ① 위 한 줄 -->
<RowDefinition Height="Auto"/> <!-- ② 폴더 안 찾기(Ctrl+F 로 펼 때만) -->
<RowDefinition Height="Auto"/> <!-- ③ 목록 머리글 -->
<RowDefinition Height="*"/> <!-- ④ 트리 -->
<RowDefinition Height="Auto"/> <!-- ⑤ 아래 상태 줄(색 범례 · 요약 · 동작) -->
</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}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Button Grid.Column="0" Content="왼쪽 폴더..." Command="{Binding PickLeftFolderCommand}"
Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0"/>
<TextBox Grid.Column="1" Text="{Binding LeftFolder, UpdateSourceTrigger=PropertyChanged}"
<!-- 폴더 고르기는 글자 대신 점 셋으로. 경로 칸 바로 앞에 두어 어느 쪽 것인지 헷갈리지 않는다. -->
<Button Grid.Column="0" Content="..." Command="{Binding PickLeftFolderCommand}"
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"
FontFamily="Consolas" FontSize="11" Height="28" VerticalContentAlignment="Center"
Margin="0,0,14,0" ToolTip="비교 기준이 되는 폴더"/>
<Button Grid.Column="2" Content="오른쪽 폴더..." Command="{Binding PickRightFolderCommand}"
Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0"/>
<TextBox Grid.Column="3" Text="{Binding RightFolder, UpdateSourceTrigger=PropertyChanged}"
AllowDrop="True" PreviewDragOver="View_DragOver" PreviewDrop="RightFolder_Drop"
FontFamily="Consolas" FontSize="11" Height="28" 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}"
SelectionChanged="Recent_SelectionChanged"
Width="200" Height="28" FontSize="11" Grid.Column="5" Margin="0"
FontFamily="Consolas" FontSize="11" Height="26" MinWidth="120"
VerticalContentAlignment="Center"
ToolTip="비교 기준이 되는 폴더. 끌어다 놓아도 됩니다"/>
<!-- 좌우 바꾸기. 반대로 견주려고 두 번 고르지 않게 한다(12차 D-1). -->
<Button Grid.Column="2" x:Name="SwapButton" Content="&#8646;" Command="{Binding SwapFoldersCommand}"
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"
FontFamily="Consolas" FontSize="11" Height="26" MinWidth="120"
VerticalContentAlignment="Center"
ToolTip="견줄 폴더. 끌어다 놓아도 됩니다"/>
<!-- 최근 짝. 글자 없이 화살표만 - 고르면 바로 연다. -->
<ComboBox x:Name="RecentBox" ItemsSource="{Binding RecentFolders}"
SelectionChanged="Recent_SelectionChanged" Grid.Column="5"
Width="34" MinWidth="34" MaxWidth="34" Height="26" FontSize="11" Margin="6,0,0,0"
HorizontalAlignment="Left" VerticalContentAlignment="Center"
ToolTip="최근에 견준 짝. 고르면 바로 엽니다">
<ComboBox.ItemTemplate>
<DataTemplate>
@ -173,23 +191,55 @@
</TextBlock>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox> </Grid>
</Border>
</ComboBox>
<!-- ② 비교 방식 · 필터 -->
<Border Grid.Row="1" Padding="8" BorderThickness="1" Margin="0,0,0,6"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<WrapPanel Orientation="Horizontal">
<!-- 서랍에 넣은 것 가운데 켜 둔 것. 안 그러면 "왜 이 파일이 안 보이지" 가 된다. -->
<TextBlock Grid.Column="6" Text="{Binding OptionSummary}" Style="{StaticResource Muted}"
FontSize="11" Margin="10,0,0,0" MaxWidth="260" TextTrimming="CharacterEllipsis"
ToolTip="지금 켜 둔 옵션입니다. [옵션 ▾] 에서 고칩니다"
Visibility="{Binding HasOptionSummary, Converter={StaticResource BoolToVis}}"/>
<Button Grid.Column="7" Content="비교" Command="{Binding CompareCommand}"
Height="26" MinWidth="70" Margin="10,0,4,0"
Style="{StaticResource FilledButton}"
Background="{DynamicResource Ubisam.Brush.Accent.Fill}"
Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}" FontWeight="Bold"/>
<!-- 큰 폴더를 잘못 고르면 기다리는 수밖에 없었다. 비교하는 동안에만 켜진다. -->
<Button Grid.Column="8" Content="취소" Command="{Binding CancelCompareCommand}"
Height="26" MinWidth="54" Margin="0,0,4,0"
ToolTip="비교를 멈춥니다. 앞서 본 결과는 그대로 남습니다"/>
<!-- 여느 단추로 둔다. 플랫폼 기본 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}"
VerticalAlignment="Center" Margin="0,0,16,0"
Margin="0,0,0,8"
ToolTip="내용을 읽지 않습니다. 파일이 아주 많거나 네트워크 드라이브일 때 씁니다"/>
<CheckBox Content="하위 폴더 포함" IsChecked="{Binding IncludeSubfolders}"
VerticalAlignment="Center" Margin="0,0,16,0"/>
Margin="0,0,0,10"/>
<!-- 보기 필터는 다시 비교하지 않고 목록만 다시 그린다. -->
<TextBlock Text="보기" VerticalAlignment="Center" Style="{StaticResource Muted}"
FontSize="11" Margin="0,0,4,0"/>
<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="110" Height="26" FontSize="11" Margin="0,0,16,0"
Width="160" Height="26" FontSize="11"
VerticalContentAlignment="Center"
ToolTip="파일이 수백 개면 눈으로 고르는 것이 제일 느립니다">
<ComboBox.ItemTemplate>
@ -198,31 +248,31 @@
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<TextBlock Text="포함" VerticalAlignment="Center" Style="{StaticResource Muted}"
FontSize="11" Margin="0,0,4,0"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="포함" Style="{StaticResource Muted}" FontSize="11" Width="40"/>
<TextBox Text="{Binding IncludePattern, UpdateSourceTrigger=PropertyChanged}"
Width="150" Height="26" FontFamily="Consolas" FontSize="11"
VerticalContentAlignment="Center" Margin="0,0,12,0"
Width="300" Height="26" FontFamily="Consolas" FontSize="11"
VerticalContentAlignment="Center"
ToolTip="정규식. 비우면 전부 포함합니다 (예: \.cs$)"/>
</StackPanel>
<TextBlock Text="제외" VerticalAlignment="Center" Style="{StaticResource Muted}"
FontSize="11" Margin="0,0,4,0"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<TextBlock Text="제외" Style="{StaticResource Muted}" FontSize="11" Width="40"/>
<TextBox Text="{Binding ExcludePattern, UpdateSourceTrigger=PropertyChanged}"
Width="260" Height="26" FontFamily="Consolas" FontSize="11"
VerticalContentAlignment="Center" Margin="0,0,16,0"
Width="300" Height="26" FontFamily="Consolas" FontSize="11"
VerticalContentAlignment="Center"
ToolTip="정규식. 포함 규칙보다 우선합니다"/>
</StackPanel>
<Button Content="비교" Command="{Binding CompareCommand}" Height="28" MinWidth="80"
Margin="0,0,6,0"
Style="{StaticResource FilledButton}"
Background="{DynamicResource Ubisam.Brush.Accent.Fill}"
Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}" FontWeight="Bold"/>
<!-- 큰 폴더를 잘못 고르면 기다리는 수밖에 없었다. 비교하는 동안에만 켜진다. -->
<Button Content="취소" Command="{Binding CancelCompareCommand}" Height="28" MinWidth="60"
ToolTip="비교를 멈춥니다. 앞서 본 결과는 그대로 남습니다"/>
</WrapPanel>
<Button Content="폴더 안 찾기 (Ctrl+F)" Command="{Binding OpenFindCommand}"
Height="26" HorizontalAlignment="Left" Padding="10,0" FontSize="11"
ToolTip="폴더 안에서 글자를 찾는 줄을 폅니다"/>
</StackPanel>
</Border>
</Popup>
</Grid>
</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}">
<WrapPanel Orientation="Horizontal">
<TextBlock Text="폴더 안 찾기" VerticalAlignment="Center" FontSize="11" FontWeight="Bold"
@ -266,58 +317,20 @@
Margin="0,0,6,0"
ToolTip="고른 줄을 '파일 비교' 탭에서 그 자리에 서서 엽니다. 더블클릭·Enter 도 같습니다"/>
<Button Content="결과 닫기" Command="{Binding ClearSearchCommand}" Height="28" MinWidth="70"
Margin="0,0,12,0"
Margin="0,0,6,0"
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"
Style="{StaticResource Muted}" TextTrimming="CharacterEllipsis" MaxWidth="520"/>
</WrapPanel>
</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}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<Grid Margin="12,0,0,0">
@ -347,7 +360,7 @@
채움 칸(*)이 0 이 되고 줄 배경이 글자 끝에서 끊긴다. 칸 폭이 고정이라
가로로 넘칠 일이 없으므로 꺼 두는 편이 맞다.
-->
<Grid Grid.Row="5">
<Grid Grid.Row="3">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
@ -437,7 +450,7 @@
</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}">
<Grid>
<Grid.ColumnDefinitions>
@ -447,8 +460,49 @@
<StackPanel Grid.Column="0" VerticalAlignment="Center">
<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; 탭에서 줄 단위로 엽니다. 한쪽에만 있는 파일도 열립니다."
Style="{StaticResource Muted}" FontSize="11"/>
Style="{StaticResource Muted}" FontSize="11" Margin="0,3,0,0"/>
</StackPanel>
<WrapPanel Grid.Column="1" Orientation="Horizontal">

View File

@ -14,6 +14,45 @@ namespace MergeHub.Views
public FolderView()
{
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>

View File

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

View File

@ -102,14 +102,46 @@ namespace MergeHub.Views
var picked = MergeHub.Services.FolderPicker.Pick("Git 저장소 폴더 고르기", vm.Workspace.RepositoryPath);
if (picked is null) return;
// .git 은 최상위에만 있다. 하위 폴더를 골랐어도 위로 거슬러 올라가 최상위를 연다 -
// 사람은 지금 보고 있던 깊은 폴더를 고르기 마련이다.
var root = MergeHub.Services.PathUtil.FindRepositoryRoot(picked);
vm.Workspace.RepositoryPath = root.Length > 0 ? root : picked;
OpenFolder(vm, 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);
}
// ================= 끌어다 놓기 (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 를 바인딩할 수 없어 코드 비하인드로 전달한다.
/// <summary>충돌 목록을 두 번 누르면 파일 비교 탭의 3분할에서 연다(6차 B-1). 항목 위가 아니면 무시한다.</summary>
private void Conflicts_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)

View File

@ -63,7 +63,12 @@ namespace MergeHub.Views
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;
@ -159,12 +164,13 @@ namespace MergeHub.Views
if (FindScrollViewer(LocalEditor) is not { } editor) continue;
var y = Clamp(target - editor.ViewportHeight / 2, editor.ScrollableHeight);
editor.ScrollToVerticalOffset(y);
Scroll(LocalLines, y, null);
Scroll(LocalLines, y + Bias(LocalLines), null);
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();

View File

@ -34,6 +34,10 @@
<GridLength x:Key="GutterNarrowNumberWidth">40</GridLength>
<GridLength x:Key="GutterMarkWidth">6</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="GutterButtonFontSize">9</system:Double>
<system:Double x:Key="BookmarkHeight">10</system:Double>
@ -44,6 +48,8 @@
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
<ctl:LineHighlightBrushConverter x:Key="HighlightBrush"/>
<!-- 편집 칸 바탕: 밝음 #FAFAFA · 어두움 #252830(2026-09-30 사용자 선택). PaneBackgroundConverter 참고. -->
<ctl:PaneBackgroundConverter x:Key="PaneBackground"/>
<ctl:HighlightNameToBrushConverter x:Key="HighlightByName"/>
<ctl:TextToVisibilityConverter x:Key="TextToVis"/>
@ -160,7 +166,8 @@
66 인 까닭은 LocalLineTemplate 에 적었다(6차 B-4). -->
<Grid MinHeight="{Binding BaseRowHeight}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<!-- [<] 자리. 단추가 없는 줄에서도 자리를 잡아 둔다 - Auto 로 두면 이 줄만 글이 밀린다. -->
<ColumnDefinition Width="{DynamicResource GutterBothWaysWidth}"/>
<ColumnDefinition Width="{DynamicResource GutterWidth}"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@ -365,7 +372,8 @@
<ColumnDefinition Width="{DynamicResource GutterMarkWidth}"/>
<ColumnDefinition Width="{DynamicResource GutterNarrowNumberWidth}"/>
<ColumnDefinition Width="{DynamicResource GutterButtonWidth}"/>
<ColumnDefinition Width="Auto"/>
<!-- [<] 자리(12차 B-1). BASE 와 같은 값이라 두 칸의 글 시작 자리가 늘 같다. -->
<ColumnDefinition Width="{DynamicResource GutterBothWaysWidth}"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
@ -490,10 +498,13 @@
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemContainerStyle>
<!-- 고른 항목의 파란 바탕을 없앤다 - 탭 모양은 아래 DataTemplate 이 그린다. -->
<!-- 고른 항목의 파란 바탕을 없앤다 - 탭 모양은 아래 DataTemplate 이 그린다.
이 스타일이 플랫폼 ListBoxItem 스타일을 대신하므로 글자색도 여기서 준다 - 없으면 WPF 기본(검정)이라
어두운 테마에서 탭 이름이 바탕에 묻혔다(12차 G-8 곁, 잉크 차이 157 · 116 으로 확인). -->
<Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="0"/>
<Setter Property="Margin" Value="0"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.ContentForeground}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
@ -587,13 +598,6 @@
Foreground="{DynamicResource Ubisam.Brush.Accent}"/>
</Border>
<!-- 발표용. .git 있는 폴더 ↔ 없는 폴더를 한 번에 오간다. 설정에 두 경로가
모두 적혀 있을 때만 보인다. -->
<Button Content="{Binding DemoSwitchLabel}" Command="{Binding SwitchDemoCommand}"
Height="26" MinHeight="26" Padding="8,2" FontSize="11" Margin="0,0,10,0"
Visibility="{Binding CanSwitchDemo, Converter={StaticResource BoolToVis}}"
ToolTip="Git 저장소 화면과 파일 비교 화면을 오갑니다"/>
<!-- Git 이 아닐 때: 비교할 파일 두 개를 직접 고른다 -->
<StackPanel Orientation="Horizontal" VerticalAlignment="Center"
Visibility="{Binding IsPlainMode, Converter={StaticResource BoolToVis}}">
@ -895,7 +899,9 @@
</Button>
<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>
</Border>
@ -1179,7 +1185,8 @@
-->
<Border Grid.Column="2" Grid.Row="0" BorderThickness="1"
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 으로 살짝 밀어 놓는데,
그건 배치가 아니라 그리기라 자르지 않으면 <b>아래 상태바 위에 코드가 겹쳐 그려진다.</b>
차이로 이동(Alt+↑↓)할 때 실제로 그렇게 보였다. -->
@ -1258,7 +1265,8 @@
<!-- ② BASE -->
<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>
<Style TargetType="Border">
<!-- 2-Way: 왼쪽 칸. 3-Way: 가운데 칸(LOCAL 과 REMOTE 사이가 공통 조상의 자리다). -->
@ -1364,7 +1372,8 @@
폭 전체를 쓰고, 위 세 칸(LOCAL 원본 · BASE · REMOTE)은 읽기 전용 참고가 된다.
-->
<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>
<Style TargetType="Border">
<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}"
IsUndoEnabled="False"
FontFamily="Consolas" FontSize="{DynamicResource CodeFontSize}"
TextBlock.LineHeight="{DynamicResource CodeLineHeight}"
TextBlock.LineStackingStrategy="BlockLineHeight"
@ -1551,7 +1578,8 @@
-->
<Border Grid.Column="6" Grid.Row="0" BorderThickness="1"
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 으로 살짝 밀어 놓는데,
그건 배치가 아니라 그리기라 자르지 않으면 <b>아래 상태바 위에 코드가 겹쳐 그려진다.</b>
차이로 이동(Alt+↑↓)할 때 실제로 그렇게 보였다. -->
@ -1756,10 +1784,18 @@
Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}"
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"
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: 캐럿이 놓인 클래스 멤버를 통째로. 못 잡으면 꺼지고 아래 한 줄이 까닭을 말한다. -->
<Button Content="이 메서드 반영" Command="{Binding AcceptMemberCommand}"
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"
Visibility="{Binding IsRulesOpen, Converter={StaticResource BoolToVis}}"/>
<UniformGrid Columns="2" Visibility="{Binding IsRulesOpen, Converter={StaticResource BoolToVis}}">
<ComboBox ItemsSource="{Binding SaveEncodings}" SelectedItem="{Binding SaveEncoding}"
<!-- 12차 G-4: 보이는 글(Label)과 저장되는 값(Value)을 가른다. 값은 세션 보관본에 그대로 적힌다. -->
<ComboBox ItemsSource="{Binding SaveEncodings}" x:Name="SaveEncodingBox"
DisplayMemberPath="Label" SelectedValuePath="Value" SelectedValue="{Binding SaveEncoding}"
MinHeight="26" FontSize="11" Margin="2,0"
ToolTip="저장할 때 쓸 인코딩"/>
<ComboBox ItemsSource="{Binding SaveNewLines}" SelectedItem="{Binding SaveNewLine}"
<ComboBox ItemsSource="{Binding SaveNewLines}" x:Name="SaveNewLineBox"
DisplayMemberPath="Label" SelectedValuePath="Value" SelectedValue="{Binding SaveNewLine}"
MinHeight="26" FontSize="11" Margin="2,0"
ToolTip="저장할 때 쓸 줄바꿈"/>
</UniformGrid>

View File

@ -473,6 +473,14 @@ namespace MergeHub.Views
return;
}
// 두 파일 비교 ↔ Git 을 오가면 [<] 단추가 생기고 사라진다 - 잡아 둘 자리도 함께 바뀐다.
if (e.PropertyName == nameof(HomeViewModel.BothWays))
{
ApplyGutterReserve();
ScheduleResync();
return;
}
if (e.PropertyName != nameof(HomeViewModel.IsThreeWay)) return;
ScheduleResync();
@ -507,22 +515,27 @@ namespace MergeHub.Views
viewer.ScrollChanged += (_, e) =>
{
if (e.VerticalChange != 0 || e.ViewportHeightChange != 0) LinkOverlay.InvalidateVisual();
if (Syncing) return;
if (e.VerticalChange == 0 && e.HorizontalChange == 0) return;
HoldSync();
// 세로는 아래층·BASE·REMOTE 가 같이 굴러가고,
// LOCAL 표시층(아래층 목록)과 글자 가로 밀기는 <b>메아리 막기 중에도</b> 편집기를 따라간다(2026-09-30 사용자 보고).
// 엔터를 꾹 누르면 재정렬마다 칸 다시 맞추기가 막기를 걸어 두는데, 그 사이 편집기가 캐럿을 들이려고 구른 것이
// 메아리로 버려져 아래층이 몇 줄 뒤처졌다(캐럿은 929행, 아래층 글은 926행 자리) - G-7 과 같은 고리.
// 이 목록은 굴러도 편집기를 되움직이지 않으므로(Lines_ScrollChanged 가 LocalLines 는 받지 않는다) 늘 맞춰도 메아리가 없다.
// 가로는 네 패널 모두 "글자만" 민다(TextShiftX) - 여백(줄번호·[>])은 제자리에 있어야 한다.
// 표시층 ListBox 를 가로로 굴리는 방법은 못 쓴다 - 행이 칸 너비에 맞춰 늘어나
// 굴릴 여지(ScrollableWidth)가 0 이라 아무 일도 일어나지 않는다.
Scroll(LocalLines, e.VerticalOffset, null);
if (e.VerticalChange != 0) SyncPanesFrom(MergeLine.TextPane.Local, e.VerticalOffset, e.ViewportHeight);
Scroll(LocalLines, e.VerticalOffset + Bias(LocalLines), null);
TextShiftX = -e.HorizontalOffset;
// 2-Way 는 BASE 와 LOCAL 의 폭이 같으니 한 덩어리로 움직인다.
// 2-Way 는 BASE 와 LOCAL 의 폭이 같으니 한 덩어리로 움직인다(가로 글자 밀기 - 되움직임 없음).
// 3-Way 의 위 세 칸은 폭이 달라 따로 움직이므로 여기서 건드리지 않는다.
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="MergeLine.MinimumRowHeight"/>)도 같이 옮겨야 글자를 줄였을 때 행이 준다.
/// </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)
{
var m = CodeFontMetrics.For(size);
@ -553,7 +588,9 @@ namespace MergeHub.Views
Resources["GutterButtonHeight"] = m.ButtonHeight;
Resources["GutterButtonFontSize"] = m.ButtonFontSize;
Resources["BookmarkHeight"] = m.BookmarkHeight;
Resources["EditorGutterMargin"] = new Thickness(m.GutterWidth, 0, 0, 0);
_metrics = m;
ApplyGutterReserve();
if (MergeLine.MinimumRowHeight != m.LineHeight)
{
@ -713,18 +750,54 @@ namespace MergeHub.Views
// 또 <b>칸 안에 보이는 행</b>이어야 한다(6차 B-4 확인 중 발견). "처음 만들어진 행" 은 멀리 건너뛴 직후에
// 화면 밖에 남은 행일 수 있고, 편집기는 화면 밖 글자의 좌표를 어림으로 준다. 7,479줄 파일에서
// "마지막 차이" 로 가자 그 어림이 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;
var row = LocalLines.ItemContainerGenerator.IndexFromContainer(container);
if (row < 0) return;
var charIndex = CharacterIndexOfLine(LocalEditor.Text, row);
var charIndex = LocalEditor.GetCharacterIndexFromLineIndex(editorLine);
if (charIndex < 0) return;
try
{
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;
@ -747,7 +820,27 @@ namespace MergeHub.Views
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 가 따로 맡는다.
@ -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>
/// 목록 칸 안에서 위가 잘리지 않고 보이는 첫 행. 없으면 null.
/// 행 판(가상화 패널)의 자식만 훑는다 - 배치가 끝날 때마다 불리므로 시각 트리 전체를 뒤지면 무겁다.
@ -882,9 +1042,23 @@ namespace MergeHub.Views
var kind = ReferenceEquals(source, BaseLines) ? MergeLine.TextPane.Base
: ReferenceEquals(source, RemoteLines) ? MergeLine.TextPane.Remote
: 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)
{
if (FindScrollViewer(target) is not { } viewer) return;
@ -945,6 +1119,12 @@ namespace MergeHub.Views
{
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 가 도는데, 그걸 사용자의 편집으로 오해하면
// 다시 정렬이 끝없이 되풀이된다.
_replacingText = true;
@ -957,9 +1137,37 @@ namespace MergeHub.Views
_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);
}
/// <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>
private void LocalEditor_TextChanged(object sender, TextChangedEventArgs e)
{
@ -1006,10 +1214,16 @@ namespace MergeHub.Views
case ShortcutAction.CopyLeftToRight:
return Run(vm.IsThreeWay ? (ICommand)vm.AcceptLocalCommand : vm.AcceptCommand);
// 2-Way 에서는 12차 G-2 부터 <이 줄을 왼쪽 파일로>다([<] 단추와 같은 일).
// 예전에는 '이 줄 되돌리기'였는데, 화살표가 가리키는 대로 왼쪽으로 보내는 자리로 넘겼다.
// 되돌리기는 Ctrl+Z 가 맡는다.
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.Redo: return Run(vm.RedoCommand);
@ -1078,10 +1292,13 @@ namespace MergeHub.Views
}
/// <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;
command.Execute(null);
if (!command.CanExecute(parameter)) return false;
command.Execute(parameter);
return true;
}
@ -1199,12 +1416,9 @@ namespace MergeHub.Views
private void ShortcutHelp_Click(object sender, RoutedEventArgs e) =>
ShortcutHelpWindow.ShowOrActivate(Window.GetWindow(this));
/// <summary>프로그램 정보 창(12차 B-2). 만든 사람 · 문의처는 설정에서 읽어 넘긴다.</summary>
private void About_Click(object sender, RoutedEventArgs e)
{
var settings = (DataContext as ViewModels.HomeViewModel)?.Workspace.Settings;
AboutWindow.ShowOrActivate(Window.GetWindow(this), settings?.ProgramAuthor, settings?.SupportContact);
}
/// <summary>프로그램 정보 창(12차 B-2). 값은 모두 실행 파일에서 읽는다(설정 칸은 2026-09-30 에 뺐다).</summary>
private void About_Click(object sender, RoutedEventArgs e) =>
AboutWindow.ShowOrActivate(Window.GetWindow(this));
// ================= 찾기 =================
@ -1485,7 +1699,18 @@ namespace MergeHub.Views
}
if (double.IsInfinity(rect.Top) || double.IsNaN(rect.Top)) return;
viewer.ScrollToVerticalOffset(Math.Max(0, viewer.VerticalOffset + rect.Top - viewer.ViewportHeight / 2));
var y = Clamp(viewer.VerticalOffset + rect.Top - viewer.ViewportHeight / 2, viewer.ScrollableHeight);
viewer.ScrollToVerticalOffset(y);
// 다른 칸은 <b>여기서 직접</b> 맞춘다(12차 G-7). 편집기의 ScrollChanged 에 맡기면 안 된다 -
// 캐럿을 옮기는 순간 편집기가 "캐럿을 화면에 들이려고" 먼저 한 번 구르고, 그 스크롤이 건 메아리 막기
// (HoldSync)는 Input 우선순위에서 풀리는데 이 가운데 맞추기는 그보다 앞선 Loaded 에서 돈다.
// 그래서 이 두 번째 스크롤은 메아리로 여겨져 버려졌고, 목록 칸은 첫 스크롤 자리(차이 줄이 칸 맨 아래)에
// 남았다. 뒤로 크게 돌면 표시층 맞추기가 그 차이를 이동값으로 메워 두 칸이 통째로 반 칸 밀려 아래가 잘렸다
// (기록: 편집기 74.4 · 목록 436.7 · 이동값 357).
HoldSync();
Scroll(LocalLines, y + Bias(LocalLines), null);
SyncPanesFrom(MergeLine.TextPane.Local, y, viewer.ViewportHeight);
}
/// <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();
};
}
}
}