12차 E-1 - 저장하지 않은 편집이 껐다 켜도 남는다 (notepad++ 식)
E-2 가 만든 보관함(탭이 사라져도 고친 글이 짝 이름으로 남는 자리)을 그대로 파일로
옮겨 적는 일이다. 새로 정할 것은 언제 쓰고 언제 되살리는가뿐이었다.
어디에 · 어떤 모양으로
{설정 폴더}\Session\MergeHub\
목록.txt 차례 · 짝 경로 · 캐럿 · 스크롤 · 저장 형식 · 보고 있던 탭 · 그때 파일의 자취
01-오른쪽.txt 저장하지 않은 오른쪽 글(있을 때만)
01-왼쪽.txt 저장하지 않은 왼쪽 글(12차 B-1, 있을 때만)
11차 F-9 백업과 다른 자리다 - 백업은 "저장하기 전 파일", 이것은 "저장하지 않은 편집" 이라
뜻도 수명도 다르다.
- JSON 을 쓰지 않는다(최근 목록과 같은 까닭). 사람이 열어 보고 고칠 수 있고, 무엇보다
글 안에 무엇이 들었든(따옴표 · 줄바꿈 · 제어 문자) 본문을 따로 파일로 두면 탈이 없다
- 원본 글은 담지 않는다. 파일에서 다시 읽으면 되고, 담아 두면 그 사이 파일이 바뀌었을 때
낡은 것을 보여 준다. 대신 그때 파일의 길이 · 마지막으로 쓴 때(FileStamp)만 적어 둔다
- 목록은 맨 나중에 쓴다 - 목록이 있다는 것이 곧 글 파일이 다 있다는 뜻이다
- 못 읽거나 못 써도 조용히 넘어간다. 보관 때문에 프로그램이 안 켜지면 안 된다
언제 쓰나
- 글자가 멈춘 뒤 2초에 한 번(DispatcherTimer)
- 탭을 옮길 때 · 닫을 때 · 새 짝을 열 때는 그 자리에서 바로 - 그 순간이 곧 끄기 직전일 수 있다
- 쓰는 일은 다른 실로 넘긴다(Task.Run). 화면 상태를 뜰 때 ParkCurrentTab 을 쓰지 않는다 -
그쪽은 되돌리기 목록을 글 단위로 눌러 담는데, 2초마다 이력을 주무를 까닭이 없다.
보관할 것은 글과 자리뿐이고 되돌리기 이력은 되살리지 않기로 정했다(사용자 결정)
언제 되살리나
켤 때 묻지 않고 그대로(사용자 결정). 다만 사용자가 지금 열라고 한 것이 먼저다 -
충돌 파일 열기 · git mergetool · 폴더 비교에서 고른 파일. 설정의 기본 저장소 · 기본 짝보다는 앞선다.
원본 글은 파일에서 다시 읽고 그 위에 편집을 얹는다(E-2 의 ResumePendingTab 그대로).
원본이 그 사이 바뀌었으면 되살리고 알린다 - "연 시점" 은 새로 읽은 글로 두어
[전체 되돌리기] 가 지금 파일로 가고, 고친 글은 편집으로 남는다.
두 파일이 다 없어진 짝은 되살릴 자리가 없으므로 버린다.
확인
하네스 SessionTest 65건 새로 만듦(글로 옮기고 되읽기 · 빈칸/한글/UNC 경로 ·
알아볼 수 없는 줄 건너뛰기 · 보관할 것 없으면 치우기 · 옛 글 파일 치우기 ·
글 파일이 없어져도 넘어가기 · FileStamp · 보관함 주고받기). 전체 28벌 1,852건 · 실패 0.
화면 24건: 두 짝을 고치고 작업 관리자로 끄듯 강제로 끝낸 뒤 다시 켜면 묻지 않고
탭 2개가 이름 · 차례 · 글 그대로 되살아나고, 한쪽을 저장하면 가운뎃점이 2->1,
둘 다 저장하면 보관본이 사라지고, 다시 켜면 되살릴 것이 없다.
원본이 그 사이 바뀌면 되살리고 "저장하면 바뀐 파일을 덮어씁니다" 를 알린다.
빠르기에 대해: 5,003줄(113KB) 짝에서 글자당 31ms 로 4줄짜리와 같았다(앱이 입력에 밀리지 않는다).
다만 "친 뒤 화면에 보이기까지" 는 값으로 확정하지 못했다 - 그것을 재려면 편집기 글을
UIA 로 읽어야 하는데 5,003줄 창에서는 그 읽기가 타이핑보다 훨씬 오래 걸려 값이 오염된다.
재는 법을 따로 마련해야 한다.
시험 도구: E-1 이 도니 지난 시험이 남긴 편집이 다음 시험에서 되살아나 화면이 달라졌다
(e3-verify 가 왼쪽 7줄 대신 14줄을 봤다). 시험마다 보관 자리를 치우고 시작해 끝에
되돌리도록 고쳤다 - 기능이 제대로 도는 증거이기도 하다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
06d78e2fc7
commit
101deb2627
@ -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, 사용자 보고)
|
||||
|
||||
사용자가 그림으로 짚어 준 것이 <b>이것</b>이었다. 아래 "왼쪽 줄 번호" 건은 이것을 쫓다 따로 찾은 다른 흠이다.
|
||||
|
||||
@ -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 는 맨 앞 칸이라 <b>번호까지</b>, LOCAL 은 번호 뒤 칸이라 <b>코드가</b> 밀렸고 단추가 하나 늘 때마다 더 밀렸다. `Visibility="Collapsed"` 는 자리까지 없애므로 칸 폭이 고정이어야 한다. `GutterBothWaysWidth` 를 두어 두 칸이 같은 자리를 늘 잡아 두고, 두 파일 비교에서는 단추 폭 · Git 모드와 네 칸에서는 0 으로 화면이 갈아 끼운다(`ApplyGutterReserve`). 투명한 편집기가 아래 표시층과 어긋나지 않도록 `EditorGutterMargin` 도 같은 값만큼 민다. 글자 크기(11차 F-5)를 바꾸면 단추 폭이 따라 바뀌므로 잡아 두는 자리도 `CodeFontMetrics` 한 벌에서 나온다. 값으로 확인: 한 칸의 모든 줄이 같은 x(BASE 137 · LOCAL 1283), 글자 크기 · Git 모드도 그대로. BASE 줄 끝의 `[>]`(11차 F-10)는 왼쪽 가장자리를 밀지 않아 이번에는 `Auto` 로 두었다 | 아래 |
|
||||
| 2026-09-28 | **왼쪽 줄 번호가 밀리던 것**(사용자 보고) — 12차 B-1 로 왼쪽에도 반영할 수 있게 되면서 왼쪽 줄이 늘고 주는데, 번호는 정렬기가 처음 준 것 그대로였다. 오른쪽에만 있던 줄을 왼쪽에 넣으면 그 줄은 번호가 비고 뒤 줄은 옛 번호를 달아 `01 02 · 03 04 05 · 06` 으로 보였다 — 왼쪽 파일은 8줄인데 번호는 6까지라 어느 줄을 고쳤는지 짚을 수 없었다. `RenumberBase` 를 `RenumberMerged`(8차 B-3) 옆에 두고 두 파일 비교에서만 왼쪽 줄을 1부터 다시 맨다. Git 모드 · 네 칸의 BASE 는 고칠 수 없는 쪽이라 건드리지 않는다. "머지 반영함" 표시가 BASE 줄 번호를 열쇠로 붙들고 있어 번호가 실제로 달라질 때만 그 열쇠도 옛 번호 → 새 번호로 옮긴다. 화면으로 넣기 · 빼기 · 되돌리기 · 저장 · Git 회귀 확인, 하네스 27벌 1,787건 · 실패 0. **왼쪽에만 있는 줄을 뺄 때 줄 수가 주는지는 값으로 확정하지 못했다**(시험 도구가 낡은 줄 칸을 집는 한계) | 아래 |
|
||||
| 2026-09-28 | **12차 E-2** — 파일 비교가 짝 여러 개를 동시에 든다(문서 탭). 지금까지는 한 짝만 들어서 짝을 바꾸면 "저장하지 않은 편집이 있습니다" 를 묻고 버리면 고친 글이 사라졌다(재현: 16줄 → 15줄). 이제 떠 있던 탭이 그 글을 맡아 두므로 **묻지 않는다**. 탭마다 `HomeViewModel` 을 따로 만들지 않고(GitWorkspace 의 한 자리짜리 약속 때문) 화면은 하나로 두고 탭이 **파일에 없는 것만** 맡는다 — 고친 글 · 왼쪽 글(B-1) · 되돌리기 이력 · 두 기준점 · 캐럿 · 스크롤 · 저장 형식. 탭 이름은 파일 이름(다르면 `왼 ↔ 오`, 겹치면 위 폴더를 갈릴 때까지 덧붙임), 저장 안 한 탭은 `•`. 탭 줄은 둘 이상일 때만 보인다. `◀ 이전 · 다음 ▶` 은 지금 탭의 짝만 바꾸고(탭이 늘지 않음), 폴더 비교에서 두 번 누르면 새 탭(사용자 결정). Ctrl+Tab · Ctrl+Shift+Tab · Ctrl+W · 가운데 단추 닫기. 탭이 사라져도 고친 글은 **보관함**(짝 50개)에 남아 다시 열면 되살아난다 — E-1 은 이 보관함을 파일로 옮겨 적으면 된다. 원본이 그 사이 바뀌면 되살리고 알린다("연 시점"은 새로 읽은 글로 둔다). 탭 줄은 ItemsControl 이 아니라 ListBox 다(ItemsControl 은 UIA 에 컨트롤로 안 보여 화면 시험이 못 읽는다). 켤 때 저절로 열리는 짝만 탭이 없어 편집이 사라지던 것을 화면에서 찾아 `EnsureTabForCurrentPair` 로 고쳤다. 켜 놓고 눈으로 보다 두 가지를 더 고쳤다(사용자가 고름) — 중괄호 맞춤으로만 바뀐 것에는 점을 붙이지 않고(묻는 자리와 같은 기준), 탭 이름은 양쪽이 같은 부분을 한 번만 적는다(`WeldController_기준.cs ↔ WeldController_내작...` → `WeldController_기준 ↔ 내작업.cs`, 421 → 288px). 뒤쪽은 확장자일 때만 접는다 — 글자 단위로 세면 `a-lef ↔ right.cs` 처럼 낱말 가운데서 잘린다(하네스에서 걸렸다). 하네스 `DocTabTest` 106건, 화면 34건 · 회귀 19건 통과, 전체 27벌 1,787건 · 실패 0. **커밋 안 함**(사용자 지시) | 아래 |
|
||||
|
||||
@ -90,11 +90,15 @@
|
||||
- **되살릴 범위**: 마지막에 **열려 있던 탭 전부**(E-2) — 탭마다 파일 짝 + 저장하지 않은 편집 글, 캐럿 자리 · 스크롤 위치, 어느 탭을 보고 있었는지. 되돌리기(Undo) 이력은 되살리지 않는다.
|
||||
- **되살리는 순간**: 묻지 않고 그대로 되살린다(notepad++ 처럼). 상태 줄에 "되살림" 을 잠깐 띄운다.
|
||||
- **보관 대상**: **두 파일 비교만**. Git 탭 · 병합 중은 git 이 이미 상태를 들고 있어 건드리지 않는다.
|
||||
- **정할 것**:
|
||||
- 언제 보관할지 — 글자가 멈춘 뒤 몇 초, 창을 닫을 때, 탭을 바꿀 때.
|
||||
- 보관 자리 — `{설정 폴더}\Session\` 아래(11차 F-9 백업과 다른 자리, 백업은 "저장 전 파일", 이것은 "저장 안 한 편집").
|
||||
- 되살릴 때 **원본 파일이 그 사이 바뀌었으면** 어떻게 할지(알리고 되살림 · 알리고 버림 · 나란히 보여 주기).
|
||||
- 오래된 보관본을 언제 치울지.
|
||||
- **만들면서 정한 것**:
|
||||
- **언제 보관할지**: 글자가 멈춘 뒤 **2초**에 한 번. 탭을 옮길 때 · 닫을 때 · 새 짝을 열 때는 **그 자리에서 바로**
|
||||
(그 순간이 곧 끄기 직전일 수 있다). 쓰는 일은 다른 실로 넘긴다.
|
||||
- **보관 자리**: `{설정 폴더}\Session\MergeHub\` — 차례 · 자리는 `목록.txt` 에 탭으로 나눈 글,
|
||||
편집한 글은 짝마다 따로 파일. **원본 글은 담지 않는다**(파일에서 다시 읽는다).
|
||||
- **원본이 그 사이 바뀌면**: **되살리고 알린다**. "파일을 연 시점" 은 새로 읽은 글로 두어
|
||||
[전체 되돌리기] 가 지금 파일로 가고, 고친 글은 편집으로 남는다 — 어느 쪽도 잃지 않는다.
|
||||
- **오래된 보관본**: 저장해서 고친 것이 없어지면 그 몫이 빠지고, 하나도 안 남으면 자리를 통째로 치운다.
|
||||
닫힌 짝의 편집은 E-2 보관함 한도(짝 50개)를 따른다. 두 파일이 다 없어진 짝은 켤 때 버린다.
|
||||
- **판정 기준**:
|
||||
- 짝 두세 개를 탭으로 열어 고치고 **저장하지 않은 채** 창을 닫았다가 다시 켜면, 탭이 그 수·그 순서로 열리고 글이 그대로다(글자 · 줄 수 · 캐럿 자리를 값으로 대조).
|
||||
- 저장한 뒤 닫으면 되살릴 것이 없다(보관본이 치워진다).
|
||||
|
||||
@ -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건(강제 종료 뒤 되살아남 포함) |
|
||||
|
||||
### 쓰다 나온 것
|
||||
|
||||
|
||||
@ -182,6 +182,59 @@ namespace MergeHub.Services
|
||||
public DocTabState? Stashed(string left, string right) =>
|
||||
_attic.TryGetValue(KeyOf(left, right), out var state) ? state : null;
|
||||
|
||||
/// <summary>보관함에 든 짝 하나(12차 E-1 이 파일로 옮겨 적을 때 쓴다).</summary>
|
||||
public sealed class StashedEdit
|
||||
{
|
||||
public StashedEdit(string left, string right, DocTabState state)
|
||||
{
|
||||
Left = left;
|
||||
Right = right;
|
||||
State = state;
|
||||
}
|
||||
|
||||
public string Left { get; }
|
||||
public string Right { get; }
|
||||
public DocTabState State { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보관함에 든 것 전부(오래된 것부터). 탭에서 내려온 편집이라 <b>지금 열려 있지 않은</b> 짝들이다 -
|
||||
/// 열려 있는 탭의 것은 화면이 들고 있으므로 부르는 쪽이 따로 담는다.
|
||||
/// </summary>
|
||||
public IReadOnlyList<StashedEdit> AllStashed()
|
||||
{
|
||||
var list = new List<StashedEdit>();
|
||||
foreach (var key in _atticOrder)
|
||||
{
|
||||
if (!_attic.TryGetValue(key, out var state)) continue;
|
||||
|
||||
var at = key.IndexOf('\0');
|
||||
if (at < 0) continue;
|
||||
list.Add(new StashedEdit(key.Substring(0, at), key.Substring(at + 1), state));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보관함에 곧바로 넣는다(12차 E-1 이 켤 때 파일에서 읽어 되돌려 놓는 길).
|
||||
/// 그 뒤 <see cref="Open"/> 으로 열면 그 편집이 탭에 실린다.
|
||||
/// </summary>
|
||||
public void Keep(string left, string right, DocTabState? state)
|
||||
{
|
||||
if (state is null || !state.HasEdits) return;
|
||||
|
||||
var key = KeyOf(left, right);
|
||||
if (!_attic.ContainsKey(key)) _atticOrder.Add(key);
|
||||
_attic[key] = state;
|
||||
|
||||
while (_atticOrder.Count > AtticMax)
|
||||
{
|
||||
var oldest = _atticOrder[0];
|
||||
_atticOrder.RemoveAt(0);
|
||||
_attic.Remove(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>탭이 사라질 때 그 편집을 보관함으로 옮긴다. 고친 것이 없으면 보관하지 않고 지운다.</summary>
|
||||
private void Stash(DocTab tab)
|
||||
{
|
||||
|
||||
350
src/MergeHub/Services/SessionStore.cs
Normal file
350
src/MergeHub/Services/SessionStore.cs
Normal file
@ -0,0 +1,350 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace MergeHub.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// 파일 하나가 그때 어떤 모습이었는지(길이 · 마지막으로 쓴 때). 되살릴 때
|
||||
/// <b>그 사이 원본이 바뀌었는지</b>를 이것으로 가른다 - 전문을 또 담아 두지 않아도 된다.
|
||||
/// </summary>
|
||||
public readonly struct FileStamp
|
||||
{
|
||||
public FileStamp(long length, long ticks)
|
||||
{
|
||||
Length = length;
|
||||
Ticks = ticks;
|
||||
}
|
||||
|
||||
public long Length { get; }
|
||||
public long Ticks { get; }
|
||||
|
||||
/// <summary>파일이 없었으면 둘 다 -1.</summary>
|
||||
public static readonly FileStamp None = new FileStamp(-1, -1);
|
||||
|
||||
public bool IsNone => Length < 0 && Ticks < 0;
|
||||
|
||||
public static FileStamp Of(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return None;
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
return info.Exists ? new FileStamp(info.Length, info.LastWriteTimeUtc.Ticks) : None;
|
||||
}
|
||||
catch (IOException) { return None; }
|
||||
catch (UnauthorizedAccessException) { return None; }
|
||||
catch (ArgumentException) { return None; }
|
||||
}
|
||||
|
||||
/// <summary>지금 파일이 그때와 같은가.</summary>
|
||||
public bool Matches(string? path) => Same(Of(path));
|
||||
|
||||
public bool Same(FileStamp other) => Length == other.Length && Ticks == other.Ticks;
|
||||
}
|
||||
|
||||
/// <summary>보관해 둔 짝 하나(12차 E-1).</summary>
|
||||
public sealed class SessionTab
|
||||
{
|
||||
public string Left { get; set; } = string.Empty;
|
||||
public string Right { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>탭으로 열려 있었는가. false 면 닫혔지만 글이 남은 것(E-2 보관함).</summary>
|
||||
public bool Open { get; set; }
|
||||
|
||||
/// <summary>저장하지 않은 오른쪽(LOCAL) 글. null 이면 파일 그대로였다.</summary>
|
||||
public string? Text { get; set; }
|
||||
|
||||
/// <summary>저장하지 않은 왼쪽(BASE) 글(12차 B-1).</summary>
|
||||
public string? LeftText { get; set; }
|
||||
|
||||
public int CaretIndex { get; set; }
|
||||
public int TopRow { get; set; }
|
||||
|
||||
public string? SaveEncoding { get; set; }
|
||||
public string? SaveNewLine { get; set; }
|
||||
|
||||
public FileStamp RightStamp { get; set; } = FileStamp.None;
|
||||
public FileStamp LeftStamp { get; set; } = FileStamp.None;
|
||||
|
||||
/// <summary>되살릴 글이 있는가. 없으면 보관할 까닭도 없다.</summary>
|
||||
public bool HasEdits => Text is not null || LeftText is not null;
|
||||
|
||||
/// <summary>그 사이 원본이 바뀌었는가(둘 중 하나라도).</summary>
|
||||
public bool FilesChanged =>
|
||||
!RightStamp.Matches(Right) || !LeftStamp.Matches(Left);
|
||||
}
|
||||
|
||||
/// <summary>껐을 때 남아 있던 것 한 벌.</summary>
|
||||
public sealed class SessionSnapshot
|
||||
{
|
||||
/// <summary>보고 있던 탭의 자리(0부터). 열린 탭이 없으면 -1.</summary>
|
||||
public int Current { get; set; } = -1;
|
||||
|
||||
public List<SessionTab> Tabs { get; } = new List<SessionTab>();
|
||||
|
||||
public IEnumerable<SessionTab> OpenTabs => Tabs.Where(t => t.Open);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 저장하지 않은 편집을 껐다 켜도 남게 보관한다(12차 E-1, notepad++ 식).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>어디에</b> - <c>{설정 폴더}\Session\MergeHub\</c>. 11차 F-9 백업과 다른 자리다.
|
||||
/// 백업은 "저장하기 <b>전</b> 파일", 이것은 "<b>저장하지 않은</b> 편집" 이라 뜻도 수명도 다르다.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>어떤 모양으로</b> - 차례와 자리는 <c>목록.txt</c> 에 탭으로 나눈 글로, 편집한 글은 짝마다
|
||||
/// 따로 파일로 둔다. JSON 을 쓰지 않는 까닭은 최근 목록(<see cref="RecentPairs"/>)과 같다 -
|
||||
/// 사람이 열어 보고 손으로 고칠 수 있고, 무엇보다 <b>글 안에 무엇이 들어 있든</b>(따옴표 · 줄바꿈 ·
|
||||
/// 제어 문자) 파일을 따로 두면 탈이 없다.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>원본 글은 담지 않는다</b> - 파일에서 다시 읽으면 되고, 담아 두면 그 사이 파일이 바뀌었을 때
|
||||
/// 낡은 것을 보여 준다. 대신 그때 파일의 <see cref="FileStamp"/> 만 적어 두고, 다를 때 알린다.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>못 읽거나 못 써도 조용히 넘어간다</b> - 보관은 거들어 주는 기능이지, 이것 때문에
|
||||
/// 프로그램이 안 켜지거나 저장이 막히면 안 된다.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SessionStore
|
||||
{
|
||||
private const string Header = "# MergeHub 저장하지 않은 편집 (12차 E-1)";
|
||||
private const int Version = 1;
|
||||
|
||||
/// <summary>차례 · 자리를 적는 파일.</summary>
|
||||
public const string ListFileName = "목록.txt";
|
||||
|
||||
private readonly object _gate = new object();
|
||||
|
||||
public SessionStore(string root)
|
||||
{
|
||||
Root = root ?? string.Empty;
|
||||
}
|
||||
|
||||
public string Root { get; }
|
||||
|
||||
/// <summary>설정 폴더 아래 보관 자리.</summary>
|
||||
public static string DefaultRoot(string configRoot) => Path.Combine(configRoot, "Session", "MergeHub");
|
||||
|
||||
public string ListPath => Path.Combine(Root, ListFileName);
|
||||
|
||||
/// <summary>짝 하나의 글을 담는 파일 이름. 자리 번호로 가른다.</summary>
|
||||
public static string BodyName(int index, bool left) =>
|
||||
string.Format(CultureInfo.InvariantCulture, "{0:00}-{1}.txt", index + 1, left ? "왼쪽" : "오른쪽");
|
||||
|
||||
// ================= 글로 옮기기(검사할 수 있게 떼어 둔다) =================
|
||||
|
||||
/// <summary>목록 파일의 글. 글 본문은 여기 들어가지 않는다(짝마다 따로 파일).</summary>
|
||||
public static string Serialize(SessionSnapshot snapshot)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
text.AppendLine(Header);
|
||||
text.AppendLine(string.Join("\t", "V",
|
||||
Version.ToString(CultureInfo.InvariantCulture),
|
||||
snapshot.Current.ToString(CultureInfo.InvariantCulture)));
|
||||
|
||||
for (var i = 0; i < snapshot.Tabs.Count; i++)
|
||||
{
|
||||
var tab = snapshot.Tabs[i];
|
||||
text.AppendLine(string.Join("\t",
|
||||
"T",
|
||||
i.ToString(CultureInfo.InvariantCulture),
|
||||
tab.Open ? "O" : "C",
|
||||
tab.Text is null ? "-" : BodyName(i, left: false),
|
||||
tab.LeftText is null ? "-" : BodyName(i, left: true),
|
||||
tab.CaretIndex.ToString(CultureInfo.InvariantCulture),
|
||||
tab.TopRow.ToString(CultureInfo.InvariantCulture),
|
||||
Field(tab.SaveEncoding),
|
||||
Field(tab.SaveNewLine),
|
||||
tab.RightStamp.Length.ToString(CultureInfo.InvariantCulture),
|
||||
tab.RightStamp.Ticks.ToString(CultureInfo.InvariantCulture),
|
||||
tab.LeftStamp.Length.ToString(CultureInfo.InvariantCulture),
|
||||
tab.LeftStamp.Ticks.ToString(CultureInfo.InvariantCulture),
|
||||
tab.Left,
|
||||
tab.Right));
|
||||
}
|
||||
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 목록 글을 되읽는다. <b>알아볼 수 없는 줄은 조용히 건너뛴다</b> -
|
||||
/// 보관본 한 줄 때문에 프로그램이 안 켜지면 안 된다(최근 목록과 같은 원칙).
|
||||
/// 글 본문은 채우지 않는다 - 부르는 쪽이 짝마다 파일을 읽어 넣는다.
|
||||
/// </summary>
|
||||
public static SessionSnapshot Parse(string? content)
|
||||
{
|
||||
var snapshot = new SessionSnapshot();
|
||||
if (string.IsNullOrWhiteSpace(content)) return snapshot;
|
||||
|
||||
foreach (var raw in content!.Replace("\r\n", "\n").Split('\n'))
|
||||
{
|
||||
var line = raw.TrimEnd();
|
||||
if (line.Length == 0 || line.StartsWith("#", StringComparison.Ordinal)) continue;
|
||||
|
||||
var parts = line.Split('\t');
|
||||
|
||||
if (parts[0] == "V")
|
||||
{
|
||||
if (parts.Length >= 3 && int.TryParse(parts[2], out var current)) snapshot.Current = current;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parts[0] != "T" || parts.Length < 15) continue;
|
||||
|
||||
snapshot.Tabs.Add(new SessionTab
|
||||
{
|
||||
Open = parts[2] == "O",
|
||||
// 글 본문은 아직 없다. 어느 파일에 있는지는 자리 번호로 다시 만들 수 있으므로
|
||||
// 여기서는 "있었는가" 만 기억해 둔다(null 이 아니면 읽을 것이 있다는 뜻).
|
||||
Text = parts[3] == "-" ? null : string.Empty,
|
||||
LeftText = parts[4] == "-" ? null : string.Empty,
|
||||
CaretIndex = Number(parts[5]),
|
||||
TopRow = Number(parts[6]),
|
||||
SaveEncoding = Value(parts[7]),
|
||||
SaveNewLine = Value(parts[8]),
|
||||
RightStamp = new FileStamp(Long(parts[9]), Long(parts[10])),
|
||||
LeftStamp = new FileStamp(Long(parts[11]), Long(parts[12])),
|
||||
Left = parts[13],
|
||||
Right = parts[14]
|
||||
});
|
||||
}
|
||||
|
||||
if (snapshot.Current >= snapshot.Tabs.Count) snapshot.Current = snapshot.Tabs.Count - 1;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static string Field(string? value) => string.IsNullOrEmpty(value) ? "-" : value!;
|
||||
|
||||
private static string? Value(string field) => field == "-" ? null : field;
|
||||
|
||||
private static int Number(string field) =>
|
||||
int.TryParse(field, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) ? n : 0;
|
||||
|
||||
private static long Long(string field) =>
|
||||
long.TryParse(field, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) ? n : -1;
|
||||
|
||||
// ================= 파일 =================
|
||||
|
||||
/// <summary>
|
||||
/// 지금 것을 보관한다. 보관할 것이 하나도 없으면 자리를 <b>치운다</b> -
|
||||
/// 저장하고 껐는데 다음에 켤 때 되살릴 것이 남아 있으면 안 된다.
|
||||
/// </summary>
|
||||
public void Save(SessionSnapshot snapshot)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
try
|
||||
{
|
||||
var keep = snapshot.Tabs.Where(t => t.HasEdits).ToList();
|
||||
if (keep.Count == 0) { ClearCore(); return; }
|
||||
|
||||
// 자리 번호가 글 파일 이름이 되므로, 버릴 것을 뺀 뒤의 차례로 다시 적는다.
|
||||
var trimmed = new SessionSnapshot { Current = -1 };
|
||||
var current = snapshot.Current >= 0 && snapshot.Current < snapshot.Tabs.Count
|
||||
? snapshot.Tabs[snapshot.Current]
|
||||
: null;
|
||||
|
||||
foreach (var tab in keep)
|
||||
{
|
||||
if (ReferenceEquals(tab, current)) trimmed.Current = trimmed.Tabs.Count;
|
||||
trimmed.Tabs.Add(tab);
|
||||
}
|
||||
|
||||
if (!Directory.Exists(Root)) Directory.CreateDirectory(Root);
|
||||
|
||||
// 남은 옛 글 파일을 먼저 치운다 - 번호가 밀리면 엉뚱한 글이 딸려 온다.
|
||||
foreach (var old in Directory.EnumerateFiles(Root, "*.txt"))
|
||||
{
|
||||
if (!string.Equals(Path.GetFileName(old), ListFileName, StringComparison.OrdinalIgnoreCase))
|
||||
File.Delete(old);
|
||||
}
|
||||
|
||||
var plain = new UTF8Encoding(false);
|
||||
for (var i = 0; i < trimmed.Tabs.Count; i++)
|
||||
{
|
||||
var tab = trimmed.Tabs[i];
|
||||
if (tab.Text is not null)
|
||||
File.WriteAllText(Path.Combine(Root, BodyName(i, left: false)), tab.Text, plain);
|
||||
if (tab.LeftText is not null)
|
||||
File.WriteAllText(Path.Combine(Root, BodyName(i, left: true)), tab.LeftText, plain);
|
||||
}
|
||||
|
||||
// 목록은 <b>맨 나중에</b> 쓴다 - 여기까지 왔다는 것이 곧 글 파일이 다 있다는 뜻이다.
|
||||
File.WriteAllText(ListPath, Serialize(trimmed), new UTF8Encoding(true));
|
||||
}
|
||||
catch (IOException) { }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>보관해 둔 것을 읽는다. 없거나 못 읽으면 빈 것.</summary>
|
||||
public SessionSnapshot Load()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(ListPath)) return new SessionSnapshot();
|
||||
|
||||
var snapshot = Parse(File.ReadAllText(ListPath, Encoding.UTF8));
|
||||
|
||||
for (var i = 0; i < snapshot.Tabs.Count; i++)
|
||||
{
|
||||
var tab = snapshot.Tabs[i];
|
||||
tab.Text = ReadBody(i, left: false, had: tab.Text is not null);
|
||||
tab.LeftText = ReadBody(i, left: true, had: tab.LeftText is not null);
|
||||
}
|
||||
|
||||
// 글을 끝내 못 읽은 줄은 버린다(되살릴 것이 없다).
|
||||
var gone = snapshot.Tabs.Where(t => !t.HasEdits).ToList();
|
||||
foreach (var tab in gone)
|
||||
{
|
||||
var at = snapshot.Tabs.IndexOf(tab);
|
||||
snapshot.Tabs.RemoveAt(at);
|
||||
if (snapshot.Current > at) snapshot.Current--;
|
||||
else if (snapshot.Current == at) snapshot.Current = -1;
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
catch (IOException) { return new SessionSnapshot(); }
|
||||
catch (UnauthorizedAccessException) { return new SessionSnapshot(); }
|
||||
}
|
||||
}
|
||||
|
||||
private string? ReadBody(int index, bool left, bool had)
|
||||
{
|
||||
if (!had) return null;
|
||||
var path = Path.Combine(Root, BodyName(index, left));
|
||||
try { return File.Exists(path) ? File.ReadAllText(path, Encoding.UTF8) : null; }
|
||||
catch (IOException) { return null; }
|
||||
catch (UnauthorizedAccessException) { return null; }
|
||||
}
|
||||
|
||||
/// <summary>보관해 둔 것을 치운다(다 저장했거나 되살린 뒤).</summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_gate) { ClearCore(); }
|
||||
}
|
||||
|
||||
private void ClearCore()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(Root)) return;
|
||||
foreach (var file in Directory.EnumerateFiles(Root, "*.txt")) File.Delete(file);
|
||||
}
|
||||
catch (IOException) { }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -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) =================
|
||||
|
||||
/// <summary>
|
||||
/// 저장하지 않은 편집을 파일로 보관한다(12차 E-1). E-2 의 보관함을 그대로 옮겨 적는 것이라
|
||||
/// 여기서 새로 정할 것은 <b>언제 쓰고 언제 되살리는가</b>뿐이다.
|
||||
/// </summary>
|
||||
private SessionStore Session => _session ??= new SessionStore(
|
||||
SessionStore.DefaultRoot(UbisamBase.Core.Bootstrap.UbisamAppBuilder.ConfigRoot));
|
||||
|
||||
private SessionStore? _session;
|
||||
|
||||
/// <summary>
|
||||
/// 글자가 멈춘 뒤에 한 번만 쓴다. 글자마다 쓰면 큰 파일에서 타이핑이 걸린다 -
|
||||
/// 강제로 끝내도 여기까지는 남는다는 것이 이 시간(2초)의 뜻이다.
|
||||
/// </summary>
|
||||
private readonly DispatcherTimer _sessionTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(2)
|
||||
};
|
||||
|
||||
private bool _sessionReady;
|
||||
|
||||
/// <summary>되살리는 중. 그때 일어나는 변화로 다시 보관하지 않는다.</summary>
|
||||
private bool _restoringSession;
|
||||
|
||||
/// <summary>글이 바뀌었다고 알린다. 손이 멈추면 그때 한 번 쓴다.</summary>
|
||||
internal void TouchSession()
|
||||
{
|
||||
if (_restoringSession) return;
|
||||
|
||||
if (!_sessionReady)
|
||||
{
|
||||
_sessionTimer.Tick += (_, _) => SaveSession();
|
||||
_sessionReady = true;
|
||||
}
|
||||
|
||||
_sessionTimer.Stop();
|
||||
_sessionTimer.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 지금 화면의 편집을 <see cref="DocTabState"/> 로 뜬다.
|
||||
///
|
||||
/// <see cref="ParkCurrentTab"/> 을 쓰지 않는 까닭 - 그쪽은 되돌리기 목록을 글 단위로 눌러 담는데(FlattenHistory),
|
||||
/// 보관은 2초마다 일어나므로 그때마다 이력을 주무르면 아깝다. 보관할 것은 <b>글과 자리</b>뿐이고
|
||||
/// 되돌리기 이력은 되살리지 않기로 정했다(사용자 결정).
|
||||
/// </summary>
|
||||
private DocTabState? SessionStateOfScreen()
|
||||
{
|
||||
if (!TabsCanHoldEdits || !HasTypedEdits) return null;
|
||||
|
||||
return new DocTabState
|
||||
{
|
||||
Text = HasChanges && !OnlyStyleChanged ? BuildLocalText() : null,
|
||||
LeftText = HasLeftChanges ? BuildBaseText() : null,
|
||||
CaretIndex = ReadCaretIndex?.Invoke() ?? 0,
|
||||
TopRow = CaretRow,
|
||||
SaveEncoding = SaveEncoding,
|
||||
SaveNewLine = SaveNewLine
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 지금 남아 있는 것을 모두 담아 파일에 쓴다. 보관할 것이 하나도 없으면 보관본을 치운다
|
||||
/// (저장하고 껐는데 다음에 켤 때 되살릴 것이 남아 있으면 안 된다).
|
||||
///
|
||||
/// <b>쓰는 일은 다른 실로 넘긴다</b> - 큰 파일에서 글을 통째로 쓰는 동안 화면이 멈추면 안 된다.
|
||||
/// </summary>
|
||||
private void SaveSession()
|
||||
{
|
||||
_sessionTimer.Stop();
|
||||
if (_restoringSession) return;
|
||||
|
||||
var snapshot = new SessionSnapshot();
|
||||
|
||||
// 열려 있는 탭부터 차례대로. 떠 있는 탭은 화면이 주인이라 그 자리에서 뜬다.
|
||||
foreach (var tab in Tabs.Tabs)
|
||||
{
|
||||
var state = ReferenceEquals(tab, Tabs.Current) ? SessionStateOfScreen() : tab.Parked;
|
||||
|
||||
snapshot.Tabs.Add(new SessionTab
|
||||
{
|
||||
Left = tab.Left,
|
||||
Right = tab.Right,
|
||||
Open = true,
|
||||
Text = state?.Text,
|
||||
LeftText = state?.LeftText,
|
||||
CaretIndex = state?.CaretIndex ?? 0,
|
||||
TopRow = state?.TopRow ?? 0,
|
||||
SaveEncoding = state?.SaveEncoding,
|
||||
SaveNewLine = state?.SaveNewLine,
|
||||
RightStamp = FileStamp.Of(tab.Right),
|
||||
LeftStamp = FileStamp.Of(tab.Left)
|
||||
});
|
||||
|
||||
if (ReferenceEquals(tab, Tabs.Current)) snapshot.Current = snapshot.Tabs.Count - 1;
|
||||
}
|
||||
|
||||
// 닫혔지만 글이 남은 짝(E-2 보관함)도 함께 적는다 - 다시 열면 되살아나야 한다.
|
||||
foreach (var kept in Tabs.AllStashed())
|
||||
{
|
||||
snapshot.Tabs.Add(new SessionTab
|
||||
{
|
||||
Left = kept.Left,
|
||||
Right = kept.Right,
|
||||
Open = false,
|
||||
Text = kept.State.Text,
|
||||
LeftText = kept.State.LeftText,
|
||||
CaretIndex = kept.State.CaretIndex,
|
||||
TopRow = kept.State.TopRow,
|
||||
SaveEncoding = kept.State.SaveEncoding,
|
||||
SaveNewLine = kept.State.SaveNewLine,
|
||||
RightStamp = FileStamp.Of(kept.Right),
|
||||
LeftStamp = FileStamp.Of(kept.Left)
|
||||
});
|
||||
}
|
||||
|
||||
var store = Session;
|
||||
Task.Run(() => store.Save(snapshot));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 켤 때 보관해 둔 것을 되살린다(12차 E-1). <b>묻지 않는다</b>(사용자 결정) -
|
||||
/// notepad++ 처럼 껐을 때 그대로 다시 열린다.
|
||||
///
|
||||
/// 되살리는 것은 <b>탭 짝과 저장하지 않은 글 · 캐럿 · 스크롤</b>이다. 원본 글은 담아 두지 않았으므로
|
||||
/// 파일에서 다시 읽고, 그 위에 편집을 얹는다(<see cref="ResumePendingTab"/> 가 하던 그대로).
|
||||
/// </summary>
|
||||
/// <returns>열어 놓은 탭이 있어 화면을 채웠으면 true. 그러면 설정의 기본 짝은 열지 않는다.</returns>
|
||||
private bool RestoreSession()
|
||||
{
|
||||
SessionSnapshot snapshot;
|
||||
try { snapshot = Session.Load(); }
|
||||
catch (Exception) { return false; } // 보관본 때문에 프로그램이 안 켜지면 안 된다
|
||||
|
||||
if (snapshot.Tabs.Count == 0) return false;
|
||||
|
||||
_restoringSession = true;
|
||||
try
|
||||
{
|
||||
// 두 파일이 다 없어진 짝은 되살릴 자리가 없다.
|
||||
var alive = snapshot.Tabs
|
||||
.Where(t => File.Exists(t.Right) || File.Exists(t.Left))
|
||||
.ToList();
|
||||
if (alive.Count == 0) { Session.Clear(); return false; }
|
||||
|
||||
// 먼저 보관함에 넣고, 열려 있던 것만 탭으로 연다 - 열 때 그 편집이 탭에 실린다.
|
||||
foreach (var tab in alive)
|
||||
{
|
||||
Tabs.Keep(tab.Left, tab.Right, new DocTabState
|
||||
{
|
||||
Text = tab.Text,
|
||||
LeftText = tab.LeftText,
|
||||
CaretIndex = tab.CaretIndex,
|
||||
TopRow = tab.TopRow,
|
||||
SaveEncoding = tab.SaveEncoding,
|
||||
SaveNewLine = tab.SaveNewLine
|
||||
});
|
||||
}
|
||||
|
||||
DocTab? current = null;
|
||||
var changedFiles = 0;
|
||||
|
||||
foreach (var tab in alive.Where(t => t.Open))
|
||||
{
|
||||
var opened = Tabs.Open(tab.Left, tab.Right);
|
||||
if (tab.FilesChanged) changedFiles++;
|
||||
if (snapshot.Current >= 0 && snapshot.Current < snapshot.Tabs.Count &&
|
||||
ReferenceEquals(snapshot.Tabs[snapshot.Current], tab))
|
||||
{
|
||||
current = opened;
|
||||
}
|
||||
}
|
||||
|
||||
if (Tabs.Count == 0) { SyncTabItems(); return false; }
|
||||
|
||||
current ??= Tabs.Tabs[Tabs.Count - 1];
|
||||
Tabs.Activate(current);
|
||||
SyncTabItems();
|
||||
|
||||
_resumeTab = current;
|
||||
LeftFile = current.Left;
|
||||
RightFile = current.Right;
|
||||
OnPropertyChanged(nameof(BaseFileLocation));
|
||||
OnPropertyChanged(nameof(SelectedFileFullPath));
|
||||
|
||||
_workspace.AppendLog($"저장하지 않은 편집을 되살림: 탭 {Tabs.Count}개" +
|
||||
(changedFiles > 0 ? $" (그 사이 바뀐 파일 {changedFiles}개)" : string.Empty));
|
||||
MessageUtil.ShowToast($"저장하지 않은 편집을 되살렸습니다 - 탭 {Tabs.Count}개");
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_restoringSession = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>되살린 탭의 짝을 실제로 읽어 화면에 올린다. 생성자에서 <see cref="RestoreSession"/> 뒤에 부른다.</summary>
|
||||
private Task LoadRestoredAsync() => LoadPlainFilesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user