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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +