diff --git a/doc/history/2026-09-23-12차.md b/doc/history/2026-09-23-12차.md
index 343b63c..9183bb7 100644
--- a/doc/history/2026-09-23-12차.md
+++ b/doc/history/2026-09-23-12차.md
@@ -285,6 +285,65 @@ BASE 칸 자신은 견줄 것이 없어 `4줄 · 기준` 이라고만 적는다.
실제로 켜서 잰 하단 탭 줄이 **111px · 항목 109px**(배율 150% 기준 72.7 DIP)로 "후" 값과 맞았다.
앞서 적었던 "지금 배포 폴더에는 예전 것이 그대로" 는 그 뒤 빌드로 낡은 글이 되어 고쳤다.
+## F-1. 하단 탭에 메모장 자리 (2026-09-28, 완료)
+
+사용자가 정한 것 — **메모 + 파일 열기 둘 다**, **저장 단추 없이 계속 남는다**,
+이름은 **`메모 1` · `메모 2` 번호만**, 편집기는 줄 번호 · 글자 크기 · 줄바꿈 **+ 찾기/바꾸기**.
+
+### 무엇을 만들었나
+
+- `Services/Memos.cs` — `Memo`(한 장) · `MemoBook`(번호 · 이름 · 넘기기 · 닫기, 계산만) ·
+ `MemoStore`(보관, `{설정 폴더}\Memo\`).
+- `ViewModels/MemoViewModel.cs` — 새 메모 · 파일 열기 · 저장 · 다른 이름으로 · 닫기 · 자동 보관 · 되살리기.
+- `Views/MemoView.xaml(.cs)` — 동작 한 줄 · 메모 탭 줄 · 찾기/바꾸기 줄 · 줄 번호 편집기 · 아래 상태 줄.
+- `AppSetup` — 하단 탭 **맨 뒤**에 넣었다. 앞 세 탭의 차례가 바뀌면 손버릇도 바뀐다.
+
+문서 탭(12차 E-2)과 **같은 모양**을 쓴다 — 탭 줄 · 저장 안 한 가운뎃점 · Ctrl+Tab · 가운데 단추로 닫기.
+두 화면이 서로 다르게 움직이면 배우는 것이 둘이 된다.
+
+메모는 문서 탭과 달리 **글을 스스로 들고 있다** — 메모는 원래 작은 글이고, 이름 없는 메모에는
+다시 읽어 올 파일 자체가 없다.
+
+### 하네스
+
+`MemoTest` **69건** 통과 — 번호 매기기(빈 번호 다시 쓰기) · 파일 이름 겹칠 때 폴더 덧붙이기 ·
+같은 파일 두 장 금지 · 이름 붙이기 · 넘기기 · 닫기 자리 · 떠 있는 장 표시 ·
+글로 옮기고 되읽기(빈칸 · 한글 · UNC) · 파일로 쓰고 되읽기 · 옛 글 파일 치우기 · 되살려 담기.
+
+### 화면으로 확인 (값)
+
+| 확인 | 값 |
+|---|---|
+| 세 장 만들어 각각 쓰기 | `메모 1` `메모 2` `메모 3` · 가운뎃점 3개 · 글이 섞이지 않음 |
+| 손 멈춘 뒤 | 보관본 `01.txt` `02.txt` `03.txt` `목록.txt` |
+| **강제 종료 → 다시 켜기** | **세 장이 차례 · 글 그대로**(저장 단추를 누른 적이 없다) |
+| 찾기/바꾸기 | Ctrl+F 로 펴짐 · `사과`→`포도` 모두 바꾸기 3곳 |
+| 닫기 | 고친 메모는 **세 갈래로 물음** · [취소] 면 그대로 3장 · [저장하지 않고 닫기] 면 2장 · **빈 메모는 묻지 않음** |
+| 줄 번호 | 40줄에서 번호 40개, 글과 한 줄씩 맞음 |
+| 다른 탭 | 폴더 비교 · 파일 비교 그대로 · 탭을 오가도 메모 글 40줄 그대로 |
+
+하네스 29벌 **1,934건** 통과 · 실패 0, 화면 여덟 벌 모두 통과(빌드 경고 0 · 오류 0).
+
+### 화면을 보고 고친 세 가지 (사용자 보고)
+
+**(가) 다른 탭에 갔다 오면 메모 글이 비어 보였다.** 탭을 오가면 화면이 다시 만들어지는데 그때 편집기가
+잠깐 비어 있다. 그 틈에 글을 거두면 빈 글로 메모를 덮어썼다. 편집기가 **지금 어느 장을 싣고 있는지**(`_shown`)를
+두고, 맞지 않으면 거두지 않게 했다.
+
+**(나) 줄 번호와 글이 어긋났다.** 줄 번호를 `TextBlock` 으로 그렸더니 줄 높이가 조금씩 달라 아래로 갈수록
+벌어졌다. **편집기와 같은 종류(TextBox)** 로 바꾸고 글꼴 · 크기 · 여백을 같은 값으로 두니 같은 자로 줄을 잰다.
+
+**(다) 글이 칸 한가운데에서 시작했다.** 플랫폼 입력 칸 스타일이 글 칸(PART_ContentHost)을 세로 가운데에
+붙박아 둔다 — 한 줄짜리 칸을 겨냥한 값이다. `VerticalContentAlignment` 로는 안 풀려(템플릿 안에 박힌 값)
+그 칸을 직접 늘려 **맨 위부터** 쓰게 했다.
+
+### 시험 도구
+
+시험 스크립트가 변수 이름을 `$keep` 로 써서 **라이브러리의 `$Keep`(되돌릴 설정 파일 목록)을 덮어쓰고** 있었다 —
+그래서 시험이 끝나도 설정이 안 돌아왔다(두 번 겪고 나서야 찾았다). 이름을 갈랐다.
+묻는 창을 찾는 도구도 단추 이름을 `저장하지 않고 계속` 로 못 박아 두어, 말이 다른 창(`저장하지 않고 닫기`)을
+못 알아봤다 — 앞부분만 맞춰 보게 했다.
+
## D-1. 폴더 비교 위쪽을 한 줄로 · 옵션은 서랍으로 (2026-09-28, 완료)
### 먼저 재현 (화면, 값)
diff --git a/doc/history/changelog.md b/doc/history/changelog.md
index ed3d964..3cea36b 100644
--- a/doc/history/changelog.md
+++ b/doc/history/changelog.md
@@ -8,6 +8,7 @@
| 날짜 | 항목 | 커밋 |
|---|---|---|
+| 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 는 맨 앞 칸이라 번호까지, LOCAL 은 번호 뒤 칸이라 코드가 밀렸고 단추가 하나 늘 때마다 더 밀렸다. `Visibility="Collapsed"` 는 자리까지 없애므로 칸 폭이 고정이어야 한다. `GutterBothWaysWidth` 를 두어 두 칸이 같은 자리를 늘 잡아 두고, 두 파일 비교에서는 단추 폭 · Git 모드와 네 칸에서는 0 으로 화면이 갈아 끼운다(`ApplyGutterReserve`). 투명한 편집기가 아래 표시층과 어긋나지 않도록 `EditorGutterMargin` 도 같은 값만큼 민다. 글자 크기(11차 F-5)를 바꾸면 단추 폭이 따라 바뀌므로 잡아 두는 자리도 `CodeFontMetrics` 한 벌에서 나온다. 값으로 확인: 한 칸의 모든 줄이 같은 x(BASE 137 · LOCAL 1283), 글자 크기 · Git 모드도 그대로. BASE 줄 끝의 `[>]`(11차 F-10)는 왼쪽 가장자리를 밀지 않아 이번에는 `Auto` 로 두었다 | 아래 |
diff --git a/doc/작업지시/12차.md b/doc/작업지시/12차.md
index 117f2d7..8b70216 100644
--- a/doc/작업지시/12차.md
+++ b/doc/작업지시/12차.md
@@ -135,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) —
+ `저장하고 닫기` · `저장하지 않고 닫기` · `취소` 세 갈래. 고친 것이 없으면 묻지 않는다.
+ - 메모를 파일 비교로 보내는 길은 **이번에 만들지 않았다**(쓰다 필요하면 그때).
- **판정 기준**:
- 메모를 여러 장 만들어 각각 쓰고 껐다 켜면 장 수 · 순서 · 글이 그대로다(값으로 대조).
- 이름 없는 메모도 저장 단추 없이 남는다. 강제로 끝내도 마지막 보관 시점까지 남는다.
diff --git a/doc/진행상황.md b/doc/진행상황.md
index e119763..509a128 100644
--- a/doc/진행상황.md
+++ b/doc/진행상황.md
@@ -419,6 +419,12 @@
| **완료** | C-1 트레이 아이콘 — 최소화하면 알림 영역으로, 왼쪽 단추로 되살리기 · 우클릭 [열기]/[종료]. 새 플랫폼(`D:\Sorce\ubsrepo`) 에 넣음(11차 뒤 G-2 와 같은 작업) |
| **완료** | C-2 탭 줄 크기 줄이기 — 하단 탭 줄 항목을 74 → 61 DIP 로(여백 13/14→9/10 · 아이콘 22→20 · 틈 7→5 · 글자 13.5→12.5). 실측 133 → 109px(−18%), 파일 비교 코드 칸 47 → 48줄. **배포됨** — 새 플랫폼을 빌드하면서 함께 나갔고, 2026-09-28 배포본 해시 · 화면 실측(111px · 항목 109px)으로 확인 |
+### F. 메모장 탭
+
+| 상태 | 항목 |
+|---|---|
+| **완료** | F-1 하단 탭에 메모장 — 메모 + 파일 열기 둘 다, **저장 단추 없이 계속 남는다**(`{설정 폴더}\Memo\`). 이름은 `메모 1` · `메모 2` 번호만(빈 번호를 다시 쓴다), 파일로 연 메모와 한 탭 줄에 섞는다. 탭 줄 · 가운뎃점 · Ctrl+Tab 은 E-2 문서 탭과 같은 모양. 줄 번호 · Ctrl+휠 글자 크기 · **찾기/바꾸기**. 닫을 때 저장하지 않은 것이 있으면 세 갈래로 묻는다(사용자 결정). 하네스 `MemoTest` 69건, 화면 30건(강제 종료 뒤 세 장 되살아남 포함) |
+
### D. 폴더 비교 화면 다시 만들기
| 상태 | 항목 |
diff --git a/src/MergeHub/AppSetup.cs b/src/MergeHub/AppSetup.cs
index 29bb804..d24e4b0 100644
--- a/src/MergeHub/AppSetup.cs
+++ b/src/MergeHub/AppSetup.cs
@@ -29,6 +29,10 @@ namespace MergeHub
manager.AddMain(Icon.Folder, ViewIds.Folder, "폴더 비교");
manager.AddMain(Icon.Document, ViewIds.Home, "파일 비교");
manager.AddMain(Icon.Link, ViewIds.Git, "Git");
+
+ // 메모장은 맨 뒤에 둔다(12차 F-1). 앞 세 탭의 차례가 바뀌면 손버릇도 바뀐다 -
+ // 비교하다 잠깐 적어 두는 자리라 흐름의 끝에 있는 편이 맞기도 하다.
+ manager.AddMain(Icon.Document, ViewIds.Memo, "메모");
}
public void RegisterSettings(ITabManager manager)
@@ -52,5 +56,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";
}
}
diff --git a/src/MergeHub/Services/Memos.cs b/src/MergeHub/Services/Memos.cs
new file mode 100644
index 0000000..19f1db9
--- /dev/null
+++ b/src/MergeHub/Services/Memos.cs
@@ -0,0 +1,439 @@
+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
+{
+ ///
+ /// 메모 한 장(12차 F-1). 이름 없는 메모이거나, 파일로 연 메모다.
+ ///
+ /// 문서 탭(12차 E-2)과 달리 글을 스스로 들고 있다 - 메모는 원래 작은 글이고,
+ /// 이름 없는 메모에는 다시 읽어 올 파일 자체가 없다.
+ ///
+ public sealed class Memo : INotifyPropertyChanged
+ {
+ /// 이름 없는 메모의 번호(메모 3). 파일로 연 메모는 0.
+ public int Number { get; internal set; }
+
+ private string? _path;
+
+ /// 파일로 연 메모의 경로. 이름 없는 메모는 null.
+ public string? Path
+ {
+ get => _path;
+ internal set { if (Set(ref _path, value)) Raise(nameof(Title)); }
+ }
+
+ /// 파일에 매인 메모인가.
+ public bool HasFile => !string.IsNullOrWhiteSpace(Path);
+
+ private string _title = string.Empty;
+
+ /// 탭 줄에 그릴 이름. 파일 이름이거나 메모 N(사용자 결정 2026-09-28).
+ 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;
+
+ /// 저장하지 않은 것이 있는가. 탭 이름 앞 가운뎃점에 쓴다(E-2 와 같은 표시).
+ public bool IsDirty
+ {
+ get => _isDirty;
+ set => Set(ref _isDirty, value);
+ }
+
+ private bool _isCurrent;
+
+ public bool IsCurrent
+ {
+ get => _isCurrent;
+ internal set => Set(ref _isCurrent, value);
+ }
+
+ /// 캐럿 자리. 메모를 오갈 때 그 자리로 돌아가려고 든다.
+ public int CaretIndex { get; set; }
+
+ public string Tip => HasFile ? Path! : "이름 없는 메모입니다. 저장하지 않아도 남습니다";
+
+ /// 마지막으로 떠 있던 순서.
+ internal long Touched { get; set; }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ private bool Set(ref T field, T value, [CallerMemberName] string? name = null)
+ {
+ if (EqualityComparer.Default.Equals(field, value)) return false;
+ field = value;
+ Raise(name);
+ return true;
+ }
+
+ private void Raise(string? name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name!));
+ }
+
+ ///
+ /// 메모 여러 장(12차 F-1). 계산만 한다 - 파일을 읽고 쓰는 것은 가,
+ /// 화면과 잇는 것은 ViewModel 이 한다.
+ ///
+ ///
+ /// 이름 짓기는 사용자가 정했다 - 이름 없는 메모는 메모 1 · 메모 2 로만(첫 줄을 따오지 않는다).
+ /// 글을 고칠 때마다 이름이 바뀌면 탭 줄이 요동친다.
+ /// 번호는 비어 있는 가장 작은 수를 준다 - 메모 1 을 닫고 새로 만들면 다시 메모 1 이다.
+ ///
+ ///
+ ///
+ /// 파일로 연 메모와 이름 없는 메모는 한 탭 줄에 섞어 둔다(notepad++ 와 같다).
+ ///
+ ///
+ public sealed class MemoBook
+ {
+ private readonly List _memos = new List();
+ private long _clock;
+
+ public IReadOnlyList Memos => _memos;
+
+ public int Count => _memos.Count;
+
+ public Memo? Current { get; private set; }
+
+ public int CurrentIndex => Current is null ? -1 : _memos.IndexOf(Current);
+
+ /// 메모가 있어야 편집기를 보여 준다.
+ public bool Any => _memos.Count > 0;
+
+ /// 새 메모 한 장. 번호는 비어 있는 가장 작은 수.
+ public Memo Add(string text = "")
+ {
+ var memo = new Memo { Number = FreeNumber(), Text = text ?? string.Empty };
+ _memos.Add(memo);
+ Retitle();
+ Activate(memo);
+ return memo;
+ }
+
+ ///
+ /// 파일로 연다. 같은 파일이 이미 있으면 새로 만들지 않고 그 장을 띄운다 -
+ /// 같은 파일이 두 장에 있으면 어느 쪽이 맞는지 알 수 없다.
+ ///
+ 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));
+
+ /// 이름 없는 메모에 파일 이름을 붙인다(다른 이름으로 저장).
+ 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]);
+ }
+
+ /// 닫는다. 닫은 자리의 오른쪽으로 옮겨 가고, 오른쪽이 없으면 왼쪽으로(E-2 와 같다).
+ 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;
+ }
+
+ /// 되살릴 때 쓴다 - 이름 짓기와 번호를 건드리지 않고 그대로 담는다.
+ public Memo Adopt(Memo memo)
+ {
+ _memos.Add(memo);
+ Retitle();
+ return memo;
+ }
+
+ /// 비어 있는 가장 작은 번호. 메모 1 을 닫고 새로 만들면 다시 메모 1 이다.
+ private int FreeNumber()
+ {
+ var used = new HashSet(_memos.Where(m => m.Number > 0).Select(m => m.Number));
+ var n = 1;
+ while (used.Contains(n)) n++;
+ return n;
+ }
+
+ /// 이름을 다시 짓는다. 파일 이름이 겹치면 갈릴 때까지 위 폴더를 한 겹씩 덧붙인다(E-2 와 같은 규칙).
+ 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;
+ }
+ }
+
+ /// 탭 줄에 그릴 글. 저장하지 않은 것은 앞에 가운뎃점(E-2 와 같은 표시).
+ public static string Mark(Memo memo) => memo.IsDirty ? "• " + memo.Title : memo.Title;
+ }
+
+ ///
+ /// 메모를 파일로 보관한다(12차 F-1). 저장 단추 없이 계속 남는 것이 사용자 결정이라,
+ /// 이름 없는 메모도 여기에 그대로 적힌다.
+ ///
+ ///
+ /// 자리는 {설정 폴더}\Memo\ - 12차 E-1 의 Session\ 과 따로 둔다.
+ /// 저쪽은 "저장하지 않은 편집" 이라 저장하면 사라지는 것이고, 메모는 지우기 전까지 남는 것이다.
+ /// 모양은 같다(목록 한 장 + 글마다 파일) - 같은 까닭에서다.
+ ///
+ ///
+ 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);
+
+ /// 보관한 메모 한 장(글은 따로 파일).
+ 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 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();
+ }
+
+ /// 알아볼 수 없는 줄은 조용히 건너뛴다. 글은 채우지 않는다(부르는 쪽이 파일로 읽는다).
+ public static (List Memos, int Current) Parse(string? content)
+ {
+ var list = new List();
+ 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 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 Memos, int Current) Load()
+ {
+ lock (_gate)
+ {
+ try
+ {
+ if (!File.Exists(ListPath)) return (new List(), -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(), -1); }
+ catch (UnauthorizedAccessException) { return (new List(), -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) { }
+ }
+ }
+}
diff --git a/src/MergeHub/ViewModels/MemoViewModel.cs b/src/MergeHub/ViewModels/MemoViewModel.cs
new file mode 100644
index 0000000..4597744
--- /dev/null
+++ b/src/MergeHub/ViewModels/MemoViewModel.cs
@@ -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
+{
+ ///
+ /// 메모장 탭(12차 F-1). 사용자가 정한 것 — 메모 + 파일 열기 둘 다, 그리고
+ /// 저장 단추 없이 계속 남는다.
+ ///
+ ///
+ /// 탭 줄 · 저장 안 한 표시(가운뎃점) · Ctrl+Tab 은 파일 비교의 문서 탭(12차 E-2)과 같은 모양이다 -
+ /// 두 화면이 서로 다르게 움직이면 배우는 것이 둘이 된다.
+ ///
+ ///
+ ///
+ /// 보관 자리는 {설정 폴더}\Memo\ — E-1 의 Session\ 과 따로 둔다.
+ /// 저쪽은 "저장하면 사라지는 것"이고 메모는 "지우기 전까지 남는 것"이라 수명이 다르다.
+ ///
+ ///
+ public sealed partial class MemoViewModel : ObservableObject
+ {
+ private readonly GitWorkspace _workspace;
+ private readonly MemoStore _store;
+
+ /// 글자가 멈춘 뒤에 한 번만 쓴다(E-1 과 같은 2초).
+ 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();
+
+ /// 탭 줄에 뿌릴 목록.
+ public ObservableCollection Items { get; } = new ObservableCollection();
+
+ /// 탭 줄에서 고른 장. 고르는 것만으로 옮겨 간다.
+ 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 = "메모는 저장하지 않아도 남습니다.";
+
+ // ================= 편집기와 잇는 자리 =================
+
+ ///
+ /// 편집기 글은 화면이 들고 있고 여기서는 필요할 때만 읽어 간다 -
+ /// 바인딩으로 글자마다 문서 전체를 넘기면 큰 글에서 눈에 띄게 느리다(파일 비교와 같은 까닭).
+ ///
+ public Func? ReadEditorText { get; set; }
+
+ public Func? ReadCaretIndex { get; set; }
+
+ /// 편집기 글을 통째로 갈아 끼운다(장을 옮길 때).
+ public event EventHandler? EditorTextReplaced;
+
+ /// 캐럿을 이 자리로 옮겨 달라고 화면에 부탁한다.
+ public event EventHandler? CaretIndexRequested;
+
+ private bool _loading;
+
+ /// 사용자가 글을 고쳤다고 화면이 알려 준다.
+ 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();
+ }
+
+ /// 그 장을 편집기에 올린다.
+ 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();
+ }
+
+ ///
+ /// 편집기가 지금 어느 장을 싣고 있는지. 화면에서 글을 거둬 올 때 이것이 맞아야 한다.
+ ///
+ /// 탭을 옮겨 다니면 화면(MemoView)이 다시 만들어질 수 있는데, 그때 편집기는 잠깐 비어 있다.
+ /// 그 틈에 거두면 빈 글로 메모를 덮어쓴다 - 화면에서 보고 찾았다(2026-09-28).
+ ///
+ private Memo? _shown;
+
+ /// 화면에 떠 있는 글을 그 장에 옮겨 담는다.
+ 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;
+
+ ///
+ /// 파일에 쓴다. 이름이 없는 메모면 로 넘어간다.
+ /// 메모 자체는 저장하지 않아도 남는다 - 이 단추는 "파일로 내보내 두기" 에 가깝다.
+ ///
+ [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);
+ }
+
+ /// 파일에 쓴다. 이름이 없으면 어디에 쓸지 묻는다. 썼으면 true.
+ 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;
+ }
+
+ /// 어디에 쓸지 묻고 쓴다. 사용자가 그만두면 false.
+ 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;
+
+ ///
+ /// 장을 닫는다.
+ ///
+ /// 저장하지 않은 것이 있으면 묻는다(사용자 결정 2026-09-28) - 닫는 것은 그 메모를 버리는 일이라
+ /// 되돌릴 수 없다. 예/아니오로는 "저장하고 닫기" 와 "그만두기" 를 가를 수 없어 세 갈래로 묻는다.
+ /// 이름 없는 메모에서 [저장하고 닫기] 를 고르면 어디에 쓸지 묻는 창으로 이어진다.
+ ///
+ [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;
+
+ // ================= 보관 =================
+
+ ///
+ /// 지금 있는 것을 모두 보관한다. 쓰는 일은 다른 실로 넘긴다(E-1 과 같다).
+ ///
+ 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));
+ }
+
+ /// 켤 때 보관해 둔 메모를 되살린다. 묻지 않는다.
+ private void Restore()
+ {
+ List 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
+ };
+
+ // 파일로 연 메모인데 고친 것이 없으면 파일에서 다시 읽는다 -
+ // 그 사이 남이 고쳤으면 그쪽이 맞다.
+ 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;
+ }
+ }
+
+ /// 화면이 처음 붙을 때 지금 장을 편집기에 올린다.
+ public void ShowCurrent() => Show(Book.Current);
+ }
+}
diff --git a/src/MergeHub/Views/MemoView.xaml b/src/MergeHub/Views/MemoView.xaml
new file mode 100644
index 0000000..093bb9b
--- /dev/null
+++ b/src/MergeHub/Views/MemoView.xaml
@@ -0,0 +1,202 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/MergeHub/Views/MemoView.xaml.cs b/src/MergeHub/Views/MemoView.xaml.cs
new file mode 100644
index 0000000..716b5bb
--- /dev/null
+++ b/src/MergeHub/Views/MemoView.xaml.cs
@@ -0,0 +1,332 @@
+using System;
+using System.Text;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using MergeHub.Services;
+using MergeHub.ViewModels;
+
+namespace MergeHub.Views
+{
+ ///
+ /// 메모장 탭(12차 F-1).
+ ///
+ /// 편집기 글은 이 컨트롤이 들고 있고 ViewModel 은 필요할 때만 읽어 간다 -
+ /// 바인딩으로 글자마다 문서 전체를 넘기면 큰 글에서 눈에 띄게 느리다(파일 비교와 같은 까닭).
+ /// 줄 번호는 따로 그리고 편집기가 굴러갈 때 같이 굴린다.
+ ///
+ public partial class MemoView : UserControl
+ {
+ /// ViewModel 이 글을 갈아 끼우는 중. 사용자의 편집과 구분하려고 둔다.
+ private bool _replacing;
+
+ private ScrollViewer? _editorScroll;
+
+ public MemoView()
+ {
+ InitializeComponent();
+
+ DataContextChanged += OnDataContextChanged;
+
+ Editor.Loaded += (_, _) =>
+ {
+ StretchContent(Editor);
+ StretchContent(LineNumbers);
+ HookScroll();
+ DrawNumbers();
+ };
+
+ // 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();
+ 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();
+ }
+
+ 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();
+ if (_replacing) return;
+ if (DataContext is MemoViewModel vm) vm.NotifyEdited(Editor.Text, Editor.CaretIndex);
+ }
+
+ private void Editor_SelectionChanged(object sender, RoutedEventArgs e)
+ {
+ var line = Editor.GetLineIndexFromCharacterIndex(Editor.CaretIndex);
+ var column = Editor.CaretIndex - Editor.GetCharacterIndexFromLineIndex(line);
+ CaretText.Text = $"{line + 1}행 {column + 1}열 · 총 {Editor.LineCount}줄";
+ }
+
+ /// 줄 번호를 다시 그린다. 줄 수가 그대로면 손대지 않는다.
+ 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);
+ }
+
+ ///
+ /// 편집기가 굴러가면 줄 번호도 같이 굴린다.
+ ///
+ /// 줄 번호 칸도 TextBox 라 로 같은 자리에 맞춘다 -
+ /// 둘이 같은 종류 · 같은 글꼴이라 같은 자로 잰 자리가 나온다.
+ ///
+ private void HookScroll()
+ {
+ _editorScroll = FindScroll(Editor);
+ if (_editorScroll is null) return;
+
+ _editorScroll.ScrollChanged += (_, e) =>
+ {
+ if (Math.Abs(e.VerticalChange) < 0.01) return;
+ LineNumbers.ScrollToVerticalOffset(_editorScroll.VerticalOffset);
+ };
+ }
+
+ ///
+ /// 글이 맨 위부터 시작하게 한다(사용자 보고 2026-09-28).
+ ///
+ /// 플랫폼 입력 칸 스타일은 한 줄짜리 칸을 겨냥해 글 칸(PART_ContentHost)을 세로 가운데에 붙박아 둔다.
+ /// 그래서 여러 줄 편집기에서는 글이 칸 한가운데에서 시작해 위아래로 자랐다.
+ /// VerticalContentAlignment 로는 안 풀린다 - 템플릿 안에 박힌 값이라 그 칸을 직접 늘려야 한다.
+ ///
+ 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;
+ }
+
+ /// 메모 탭을 가운데 단추로 누르면 닫는다(문서 탭과 같은 손버릇).
+ 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;
+ }
+
+ if (e.Key == Key.Escape && vm.IsFindOpen)
+ {
+ vm.CloseFindCommand.Execute(null);
+ Editor.Focus();
+ e.Handled = true;
+ }
+ }
+
+ // ================= 찾기 · 바꾸기 =================
+
+ 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);
+
+ ///
+ /// 다음(또는 앞) 자리로. 끝에 닿으면 처음으로 돌아 한 바퀴 돈다 -
+ /// 못 찾았다고 말하기 전에 나머지 반쪽도 봐야 한다.
+ ///
+ 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}곳을 바꿨습니다.";
+ }
+ }
+}