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))