diff --git a/doc/history/2026-09-23-12차.md b/doc/history/2026-09-23-12차.md index a334434..f61d5c0 100644 --- a/doc/history/2026-09-23-12차.md +++ b/doc/history/2026-09-23-12차.md @@ -285,6 +285,90 @@ BASE 칸 자신은 견줄 것이 없어 `4줄 · 기준` 이라고만 적는다. 실제로 켜서 잰 하단 탭 줄이 **111px · 항목 109px**(배율 150% 기준 72.7 DIP)로 "후" 값과 맞았다. 앞서 적었던 "지금 배포 폴더에는 예전 것이 그대로" 는 그 뒤 빌드로 낡은 글이 되어 고쳤다. +## E-1. 꺼도 작업 내용이 남아 있게 (2026-09-28, 완료) + +E-2 가 만든 **보관함**(탭이 사라져도 고친 글이 짝 이름으로 남는 자리)을 그대로 파일로 옮겨 적는 일이다. +새로 정할 것은 **언제 쓰고 언제 되살리는가**뿐이었다. + +### 어디에 · 어떤 모양으로 + +`{설정 폴더}\Session\MergeHub\` — 11차 F-9 백업과 **다른 자리**다. 백업은 "저장하기 전 파일", +이것은 "저장하지 않은 편집" 이라 뜻도 수명도 다르다. + +``` +목록.txt 차례 · 짝 경로 · 캐럿 · 스크롤 · 저장 형식 · 보고 있던 탭 · 그때 파일의 자취 +01-오른쪽.txt 저장하지 않은 오른쪽 글(있을 때만) +01-왼쪽.txt 저장하지 않은 왼쪽 글(12차 B-1, 있을 때만) +``` + +- **JSON 을 쓰지 않는다**(최근 목록과 같은 까닭). 사람이 열어 보고 손으로 고칠 수 있고, 무엇보다 + **글 안에 무엇이 들었든**(따옴표 · 줄바꿈 · 제어 문자) 본문을 따로 파일로 두면 탈이 없다. +- **원본 글은 담지 않는다.** 파일에서 다시 읽으면 되고, 담아 두면 그 사이 파일이 바뀌었을 때 낡은 것을 보여 준다. + 대신 그때 파일의 **길이 · 마지막으로 쓴 때**(`FileStamp`)만 적어 두고, 다를 때 알린다. +- 목록은 **맨 나중에** 쓴다 — 목록이 있다는 것이 곧 글 파일이 다 있다는 뜻이다. +- 못 읽거나 못 써도 **조용히 넘어간다**. 보관 때문에 프로그램이 안 켜지거나 저장이 막히면 안 된다. + +### 언제 쓰나 + +| 때 | 어떻게 | +|---|---| +| 글자가 멈춘 뒤 | **2초**에 한 번(`DispatcherTimer`). 글자마다 쓰면 큰 파일에서 걸린다 | +| 탭을 옮길 때 · 닫을 때 · 새 짝을 열 때 | **그 자리에서 바로** — 그 순간이 곧 끄기 직전일 수 있다 | +| 고친 것이 생기거나 없어질 때 | 2초 뒤에 한 번(저장하면 보관본이 빠져야 한다) | + +쓰는 일은 **다른 실로 넘긴다**(`Task.Run`). 지금 화면의 상태를 뜰 때 `ParkCurrentTab` 을 쓰지 않는다 - +그쪽은 되돌리기 목록을 글 단위로 눌러 담는데(`FlattenHistory`), 2초마다 이력을 주무를 까닭이 없다. +보관할 것은 **글과 자리**뿐이고 되돌리기 이력은 되살리지 않기로 정했다(사용자 결정). + +### 언제 되살리나 + +켤 때 **묻지 않고 그대로**(사용자 결정). 다만 **사용자가 지금 열라고 한 것이 먼저**다 - +충돌 파일 열기 · `git mergetool` · 폴더 비교에서 고른 파일이 있으면 그것을 연다. +설정의 기본 저장소 · 기본 짝보다는 **앞선다**. + +되살리는 것은 탭 짝 · 저장하지 않은 글 · 캐럿 · 스크롤 · 어느 탭을 보고 있었는지다. +원본 글은 파일에서 다시 읽고 그 위에 편집을 얹는다(E-2 의 `ResumePendingTab` 이 하던 그대로). +두 파일이 다 없어진 짝은 되살릴 자리가 없으므로 버린다. + +### 하네스 + +`SessionTest` **65건** 통과 — 글로 옮기고 되읽기(빈칸 · 한글 · UNC 경로, 알아볼 수 없는 줄 건너뛰기) · +파일로 쓰고 되읽기 · 보관할 것이 없으면 치우기 · 고친 것 없는 줄 빼고 번호 다시 매기기 · +옛 글 파일 치우기 · 글 파일이 없어져도 넘어가기 · `FileStamp` · 보관함을 내주고 되받기. + +### 화면으로 확인 (값) + +두 짝을 열어 둘 다 고치고 **작업 관리자로 끄듯 강제로** 끝낸 뒤 다시 켰다. + +| 확인 | 값 | +|---|---| +| 손을 멈춘 뒤 | 보관본 `01-오른쪽.txt` · `02-오른쪽.txt` · `목록.txt` 생김 | +| 강제 종료 뒤 | 보관본 그대로 남음 | +| 다시 켜면 | **묻지 않고** 탭 2개 되살아남(`alpha.cs` · `bravo.cs`), 이름 · 차례 그대로 | +| 보고 있던 탭 | `bravo` 의 고친 글 그대로 · 가운뎃점 2개 | +| 다른 탭 | `alpha` 의 고친 글도 그대로 · 서로 섞이지 않음 | +| 한쪽을 저장 | 가운뎃점 2 → 1 · 파일에 써짐 | +| 둘 다 저장 | 가운뎃점 0 · **보관본이 치워짐** | +| 다시 켜면 | 되살릴 것 없음 · 설정의 기본 짝이 열림 | +| 원본이 그 사이 바뀌면 | 고친 글 되살아나고 **"그 사이 파일이 바뀌었습니다 … 저장하면 바뀐 파일을 덮어씁니다"** 알림 | + +하네스 28벌 **1,852건** 통과 · 실패 0, 화면 여섯 벌 모두 통과(빌드 경고 0 · 오류 0). + +### 빠르기에 대해 말할 수 있는 것과 없는 것 + +5,003줄(113KB) 짝에서 글자를 보내 보니 **글자당 31ms 로 4줄짜리와 같았다** - 앱이 입력에 밀리지 않는다. +보관은 손을 멈춘 뒤 한 번, 다른 실에서 107KB 를 적었다. + +다만 **"글자를 친 뒤 화면에 보이기까지" 는 값으로 확정하지 못했다.** 그것을 재려면 편집기 글을 +UIA 로 읽어야 하는데, 5,003줄 창에서는 그 읽기가 타이핑보다 훨씬 오래 걸려(한 번에 수 초) 값이 통째로 오염된다. +재는 법을 따로 마련해야 한다. + +### 시험 도구도 함께 고쳤다 + +E-1 이 도니 **지난 시험이 남긴 "저장하지 않은 편집" 이 다음 시험에서 되살아나** 화면이 달라졌다 +(`e3-verify` 가 왼쪽 7줄 대신 14줄을 봤다). 시험마다 보관 자리를 치우고 시작해 끝에 되돌리도록 +`Backup-Cfg` · `Restore-Cfg` 를 고쳤다 - 기능이 제대로 도는 증거이기도 하다. + ## B-1 을 쓰다 나온 것 (2) - [<] 단추가 있는 줄만 글이 밀린다 (2026-09-28, 사용자 보고) 사용자가 그림으로 짚어 준 것이 이것이었다. 아래 "왼쪽 줄 번호" 건은 이것을 쫓다 따로 찾은 다른 흠이다. diff --git a/doc/history/changelog.md b/doc/history/changelog.md index e1b842c..cae3360 100644 --- a/doc/history/changelog.md +++ b/doc/history/changelog.md @@ -8,6 +8,7 @@ | 날짜 | 항목 | 커밋 | |---|---|---| +| 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` 로 두었다 | 아래 | | 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. **커밋 안 함**(사용자 지시) | 아래 | diff --git a/doc/작업지시/12차.md b/doc/작업지시/12차.md index fc67768..117f2d7 100644 --- a/doc/작업지시/12차.md +++ b/doc/작업지시/12차.md @@ -90,11 +90,15 @@ - **되살릴 범위**: 마지막에 **열려 있던 탭 전부**(E-2) — 탭마다 파일 짝 + 저장하지 않은 편집 글, 캐럿 자리 · 스크롤 위치, 어느 탭을 보고 있었는지. 되돌리기(Undo) 이력은 되살리지 않는다. - **되살리는 순간**: 묻지 않고 그대로 되살린다(notepad++ 처럼). 상태 줄에 "되살림" 을 잠깐 띄운다. - **보관 대상**: **두 파일 비교만**. Git 탭 · 병합 중은 git 이 이미 상태를 들고 있어 건드리지 않는다. -- **정할 것**: - - 언제 보관할지 — 글자가 멈춘 뒤 몇 초, 창을 닫을 때, 탭을 바꿀 때. - - 보관 자리 — `{설정 폴더}\Session\` 아래(11차 F-9 백업과 다른 자리, 백업은 "저장 전 파일", 이것은 "저장 안 한 편집"). - - 되살릴 때 **원본 파일이 그 사이 바뀌었으면** 어떻게 할지(알리고 되살림 · 알리고 버림 · 나란히 보여 주기). - - 오래된 보관본을 언제 치울지. +- **만들면서 정한 것**: + - **언제 보관할지**: 글자가 멈춘 뒤 **2초**에 한 번. 탭을 옮길 때 · 닫을 때 · 새 짝을 열 때는 **그 자리에서 바로** + (그 순간이 곧 끄기 직전일 수 있다). 쓰는 일은 다른 실로 넘긴다. + - **보관 자리**: `{설정 폴더}\Session\MergeHub\` — 차례 · 자리는 `목록.txt` 에 탭으로 나눈 글, + 편집한 글은 짝마다 따로 파일. **원본 글은 담지 않는다**(파일에서 다시 읽는다). + - **원본이 그 사이 바뀌면**: **되살리고 알린다**. "파일을 연 시점" 은 새로 읽은 글로 두어 + [전체 되돌리기] 가 지금 파일로 가고, 고친 글은 편집으로 남는다 — 어느 쪽도 잃지 않는다. + - **오래된 보관본**: 저장해서 고친 것이 없어지면 그 몫이 빠지고, 하나도 안 남으면 자리를 통째로 치운다. + 닫힌 짝의 편집은 E-2 보관함 한도(짝 50개)를 따른다. 두 파일이 다 없어진 짝은 켤 때 버린다. - **판정 기준**: - 짝 두세 개를 탭으로 열어 고치고 **저장하지 않은 채** 창을 닫았다가 다시 켜면, 탭이 그 수·그 순서로 열리고 글이 그대로다(글자 · 줄 수 · 캐럿 자리를 값으로 대조). - 저장한 뒤 닫으면 되살릴 것이 없다(보관본이 치워진다). diff --git a/doc/진행상황.md b/doc/진행상황.md index b45f8e3..6a73ab9 100644 --- a/doc/진행상황.md +++ b/doc/진행상황.md @@ -424,7 +424,7 @@ | 상태 | 항목 | |---|---| | **완료** | 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건(강제 종료 뒤 되살아남 포함) | ### 쓰다 나온 것 diff --git a/src/MergeHub/Services/DocTabs.cs b/src/MergeHub/Services/DocTabs.cs index e36e520..2d40427 100644 --- a/src/MergeHub/Services/DocTabs.cs +++ b/src/MergeHub/Services/DocTabs.cs @@ -182,6 +182,59 @@ namespace MergeHub.Services public DocTabState? Stashed(string left, string right) => _attic.TryGetValue(KeyOf(left, right), out var state) ? state : null; + /// 보관함에 든 짝 하나(12차 E-1 이 파일로 옮겨 적을 때 쓴다). + 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; } + } + + /// + /// 보관함에 든 것 전부(오래된 것부터). 탭에서 내려온 편집이라 지금 열려 있지 않은 짝들이다 - + /// 열려 있는 탭의 것은 화면이 들고 있으므로 부르는 쪽이 따로 담는다. + /// + public IReadOnlyList AllStashed() + { + var list = new List(); + 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; + } + + /// + /// 보관함에 곧바로 넣는다(12차 E-1 이 켤 때 파일에서 읽어 되돌려 놓는 길). + /// 그 뒤 으로 열면 그 편집이 탭에 실린다. + /// + 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); + } + } + /// 탭이 사라질 때 그 편집을 보관함으로 옮긴다. 고친 것이 없으면 보관하지 않고 지운다. private void Stash(DocTab tab) { diff --git a/src/MergeHub/Services/SessionStore.cs b/src/MergeHub/Services/SessionStore.cs new file mode 100644 index 0000000..a0f7913 --- /dev/null +++ b/src/MergeHub/Services/SessionStore.cs @@ -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 +{ + /// + /// 파일 하나가 그때 어떤 모습이었는지(길이 · 마지막으로 쓴 때). 되살릴 때 + /// 그 사이 원본이 바뀌었는지를 이것으로 가른다 - 전문을 또 담아 두지 않아도 된다. + /// + public readonly struct FileStamp + { + public FileStamp(long length, long ticks) + { + Length = length; + Ticks = ticks; + } + + public long Length { get; } + public long Ticks { get; } + + /// 파일이 없었으면 둘 다 -1. + 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; } + } + + /// 지금 파일이 그때와 같은가. + public bool Matches(string? path) => Same(Of(path)); + + public bool Same(FileStamp other) => Length == other.Length && Ticks == other.Ticks; + } + + /// 보관해 둔 짝 하나(12차 E-1). + public sealed class SessionTab + { + public string Left { get; set; } = string.Empty; + public string Right { get; set; } = string.Empty; + + /// 탭으로 열려 있었는가. false 면 닫혔지만 글이 남은 것(E-2 보관함). + public bool Open { get; set; } + + /// 저장하지 않은 오른쪽(LOCAL) 글. null 이면 파일 그대로였다. + public string? Text { get; set; } + + /// 저장하지 않은 왼쪽(BASE) 글(12차 B-1). + 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; + + /// 되살릴 글이 있는가. 없으면 보관할 까닭도 없다. + public bool HasEdits => Text is not null || LeftText is not null; + + /// 그 사이 원본이 바뀌었는가(둘 중 하나라도). + public bool FilesChanged => + !RightStamp.Matches(Right) || !LeftStamp.Matches(Left); + } + + /// 껐을 때 남아 있던 것 한 벌. + public sealed class SessionSnapshot + { + /// 보고 있던 탭의 자리(0부터). 열린 탭이 없으면 -1. + public int Current { get; set; } = -1; + + public List Tabs { get; } = new List(); + + public IEnumerable OpenTabs => Tabs.Where(t => t.Open); + } + + /// + /// 저장하지 않은 편집을 껐다 켜도 남게 보관한다(12차 E-1, notepad++ 식). + /// + /// + /// 어디에 - {설정 폴더}\Session\MergeHub\. 11차 F-9 백업과 다른 자리다. + /// 백업은 "저장하기 전 파일", 이것은 "저장하지 않은 편집" 이라 뜻도 수명도 다르다. + /// + /// + /// + /// 어떤 모양으로 - 차례와 자리는 목록.txt 에 탭으로 나눈 글로, 편집한 글은 짝마다 + /// 따로 파일로 둔다. JSON 을 쓰지 않는 까닭은 최근 목록()과 같다 - + /// 사람이 열어 보고 손으로 고칠 수 있고, 무엇보다 글 안에 무엇이 들어 있든(따옴표 · 줄바꿈 · + /// 제어 문자) 파일을 따로 두면 탈이 없다. + /// + /// + /// + /// 원본 글은 담지 않는다 - 파일에서 다시 읽으면 되고, 담아 두면 그 사이 파일이 바뀌었을 때 + /// 낡은 것을 보여 준다. 대신 그때 파일의 만 적어 두고, 다를 때 알린다. + /// + /// + /// + /// 못 읽거나 못 써도 조용히 넘어간다 - 보관은 거들어 주는 기능이지, 이것 때문에 + /// 프로그램이 안 켜지거나 저장이 막히면 안 된다. + /// + /// + public sealed class SessionStore + { + private const string Header = "# MergeHub 저장하지 않은 편집 (12차 E-1)"; + private const int Version = 1; + + /// 차례 · 자리를 적는 파일. + public const string ListFileName = "목록.txt"; + + private readonly object _gate = new object(); + + public SessionStore(string root) + { + Root = root ?? string.Empty; + } + + public string Root { get; } + + /// 설정 폴더 아래 보관 자리. + public static string DefaultRoot(string configRoot) => Path.Combine(configRoot, "Session", "MergeHub"); + + public string ListPath => Path.Combine(Root, ListFileName); + + /// 짝 하나의 글을 담는 파일 이름. 자리 번호로 가른다. + public static string BodyName(int index, bool left) => + string.Format(CultureInfo.InvariantCulture, "{0:00}-{1}.txt", index + 1, left ? "왼쪽" : "오른쪽"); + + // ================= 글로 옮기기(검사할 수 있게 떼어 둔다) ================= + + /// 목록 파일의 글. 글 본문은 여기 들어가지 않는다(짝마다 따로 파일). + 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(); + } + + /// + /// 목록 글을 되읽는다. 알아볼 수 없는 줄은 조용히 건너뛴다 - + /// 보관본 한 줄 때문에 프로그램이 안 켜지면 안 된다(최근 목록과 같은 원칙). + /// 글 본문은 채우지 않는다 - 부르는 쪽이 짝마다 파일을 읽어 넣는다. + /// + 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; + + // ================= 파일 ================= + + /// + /// 지금 것을 보관한다. 보관할 것이 하나도 없으면 자리를 치운다 - + /// 저장하고 껐는데 다음에 켤 때 되살릴 것이 남아 있으면 안 된다. + /// + 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); + } + + // 목록은 맨 나중에 쓴다 - 여기까지 왔다는 것이 곧 글 파일이 다 있다는 뜻이다. + File.WriteAllText(ListPath, Serialize(trimmed), new UTF8Encoding(true)); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + } + + /// 보관해 둔 것을 읽는다. 없거나 못 읽으면 빈 것. + 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; } + } + + /// 보관해 둔 것을 치운다(다 저장했거나 되살린 뒤). + 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/HomeViewModel.Tabs.cs b/src/MergeHub/ViewModels/HomeViewModel.Tabs.cs index 35f2266..711987e 100644 --- a/src/MergeHub/ViewModels/HomeViewModel.Tabs.cs +++ b/src/MergeHub/ViewModels/HomeViewModel.Tabs.cs @@ -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; @@ -210,6 +213,8 @@ namespace MergeHub.ViewModels if (Tabs.LastEvicted is { } gone) _workspace.AppendLog($"탭이 {DocTabs.Soft}개를 넘어 오래 안 본 탭을 닫음: {gone.Title}"); + + SaveSession(); // 12차 E-1 } /// @@ -253,6 +258,9 @@ namespace MergeHub.ViewModels { if (Tabs.Current is { } tab && TabsCanHoldEdits) tab.IsDirty = HasTypedEdits; + + // 고친 것이 생겼거나(보관해야 한다) 없어졌다(보관본을 치워야 한다) - 둘 다 다시 쓸 일이다. + TouchSession(); } // [ObservableProperty] 가 만들어 주는 갈고리. 고친 것이 생기거나 없어지면 탭 표시가 따라간다. @@ -270,6 +278,7 @@ namespace MergeHub.ViewModels Tabs.Activate(tab); _resumeTab = tab; SyncTabItems(); + SaveSession(); // 12차 E-1 - 옮기는 순간이 곧 끄기 직전일 수 있다. 미루지 않는다. LeftFile = tab.Left; RightFile = tab.Right; @@ -304,6 +313,7 @@ namespace MergeHub.ViewModels var next = Tabs.Close(tab); SyncTabItems(); + SaveSession(); // 12차 E-1 - 닫아도 글은 남는다. 그 자리에서 적어 둔다. if (next is null || ReferenceEquals(next, tab)) return; @@ -332,5 +342,205 @@ namespace MergeHub.ViewModels if (at < 0 || Tabs.Count < 2) return; GoToTab(Tabs.Tabs[(at - 1 + Tabs.Count) % Tabs.Count]); } + + // ================= 껐다 켜도 남는 편집 (12차 E-1) ================= + + /// + /// 저장하지 않은 편집을 파일로 보관한다(12차 E-1). E-2 의 보관함을 그대로 옮겨 적는 것이라 + /// 여기서 새로 정할 것은 언제 쓰고 언제 되살리는가뿐이다. + /// + private SessionStore Session => _session ??= new SessionStore( + SessionStore.DefaultRoot(UbisamBase.Core.Bootstrap.UbisamAppBuilder.ConfigRoot)); + + private SessionStore? _session; + + /// + /// 글자가 멈춘 뒤에 한 번만 쓴다. 글자마다 쓰면 큰 파일에서 타이핑이 걸린다 - + /// 강제로 끝내도 여기까지는 남는다는 것이 이 시간(2초)의 뜻이다. + /// + private readonly DispatcherTimer _sessionTimer = new DispatcherTimer + { + Interval = TimeSpan.FromSeconds(2) + }; + + private bool _sessionReady; + + /// 되살리는 중. 그때 일어나는 변화로 다시 보관하지 않는다. + private bool _restoringSession; + + /// 글이 바뀌었다고 알린다. 손이 멈추면 그때 한 번 쓴다. + internal void TouchSession() + { + if (_restoringSession) return; + + if (!_sessionReady) + { + _sessionTimer.Tick += (_, _) => SaveSession(); + _sessionReady = true; + } + + _sessionTimer.Stop(); + _sessionTimer.Start(); + } + + /// + /// 지금 화면의 편집을 로 뜬다. + /// + /// 을 쓰지 않는 까닭 - 그쪽은 되돌리기 목록을 글 단위로 눌러 담는데(FlattenHistory), + /// 보관은 2초마다 일어나므로 그때마다 이력을 주무르면 아깝다. 보관할 것은 글과 자리뿐이고 + /// 되돌리기 이력은 되살리지 않기로 정했다(사용자 결정). + /// + 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 + }; + } + + /// + /// 지금 남아 있는 것을 모두 담아 파일에 쓴다. 보관할 것이 하나도 없으면 보관본을 치운다 + /// (저장하고 껐는데 다음에 켤 때 되살릴 것이 남아 있으면 안 된다). + /// + /// 쓰는 일은 다른 실로 넘긴다 - 큰 파일에서 글을 통째로 쓰는 동안 화면이 멈추면 안 된다. + /// + 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; + Task.Run(() => store.Save(snapshot)); + } + + /// + /// 켤 때 보관해 둔 것을 되살린다(12차 E-1). 묻지 않는다(사용자 결정) - + /// notepad++ 처럼 껐을 때 그대로 다시 열린다. + /// + /// 되살리는 것은 탭 짝과 저장하지 않은 글 · 캐럿 · 스크롤이다. 원본 글은 담아 두지 않았으므로 + /// 파일에서 다시 읽고, 그 위에 편집을 얹는다( 가 하던 그대로). + /// + /// 열어 놓은 탭이 있어 화면을 채웠으면 true. 그러면 설정의 기본 짝은 열지 않는다. + 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; + } + } + + /// 되살린 탭의 짝을 실제로 읽어 화면에 올린다. 생성자에서 뒤에 부른다. + private Task LoadRestoredAsync() => LoadPlainFilesAsync(); } } diff --git a/src/MergeHub/ViewModels/HomeViewModel.cs b/src/MergeHub/ViewModels/HomeViewModel.cs index f019840..c9dda5a 100644 --- a/src/MergeHub/ViewModels/HomeViewModel.cs +++ b/src/MergeHub/ViewModels/HomeViewModel.cs @@ -96,6 +96,15 @@ namespace MergeHub.ViewModels // 저장소가 아니면 설정에 적어 둔 두 파일로 비교 화면을 만든다 - Git 없이도 쓰는 // 도구라는 것을 첫 화면에서 바로 보여 준다. 둘을 동시에 걸면 안 된다. // 작업 게이트가 하나뿐이라 먼저 건 쪽이 끝날 때까지 뒤엣것이 통째로 취소된다. + // 12차 E-1: 지난번에 저장하지 않고 끈 편집이 있으면 그것부터 되살린다(묻지 않는다). + // 위의 "시키는 일"(충돌 열기 · mergetool · 폴더 비교에서 고른 파일)이 있을 때는 여기까지 오지 않는다 - + // 사용자가 지금 열라고 한 것이 먼저다. 기본 저장소 · 기본 짝보다는 먼저다. + if (!_workspace.LooksLikeRepository && RestoreSession()) + { + _ = LoadRestoredAsync(); + return; + } + if (_workspace.LooksLikeRepository) _ = _workspace.TryAutoOpenAsync(); else @@ -2518,6 +2527,7 @@ namespace MergeHub.ViewModels { if (_syncingEditor) return; + TouchSession(); // 12차 E-1 - 손이 멈추면 그때 한 번 보관한다 _reflowTimer.Stop(); if (ShowTypedLineNow(text, caretIndex))