diff --git a/doc/history/2026-09-23-12차.md b/doc/history/2026-09-23-12차.md
index 3e091eb..b126531 100644
--- a/doc/history/2026-09-23-12차.md
+++ b/doc/history/2026-09-23-12차.md
@@ -2,6 +2,23 @@
지시서: [../작업지시/12차.md](../작업지시/12차.md)
+## 끌 때 저장하지 않은 편집을 묻는다 (2026-09-30 밤, 사용자 요청 · 결정 · 완료)
+
+사용자 보고: 편집이 있는데 하단 [종료] 를 누르면 그냥 꺼진다. 편집은 보관본(E-1)이 다음에 되살렸지만(g16 로 확인), 사용자 판단은
+"잘못 고치고 있었으면 어떻게 하나 - 버릴 수 있어야 한다" 였다. 결정(사용자): **파일 비교의 모든 탭**(Git 모드 포함), 메모는 제외,
+[저장하고 종료] · [저장하지 않고 종료] · [취소], 버리면 **보관본도 비워** 다음에 되살아나지 않게.
+
+- 플랫폼에 이미 있는 `ICloseGuard` 를 `HomeViewModel` 이 구현(`HomeViewModel.Close.cs`) — 셸이 열린 탭 화면의 DataContext 에 묻는다. 플랫폼은 고치지 않음.
+- 묻는 범위: 지금 화면(두 파일 · Git 모드 · 네 칸 — 스타일 맞춤만 바뀐 글은 빼는 `WillAskBeforeDiscard` 와 같은 기준) · 뒤에 맡겨 둔 탭 · 닫았지만 편집이 남은 짝(`(닫은 탭)` 이라고 적는다). 창에 파일 이름을 10개까지.
+- 저장: 지금 화면은 기존 `SaveWorkingCopy`(Git 충돌 파일 · 왼쪽 칸까지), 맡겨 둔 글은 그 짝의 저장 형식으로 `WritePlainFile`(백업 포함). **하나라도 못 쓰면 끄지 않는다.**
+- 보관본: 평소 쓰기는 다른 실(`Task.Run`)이라, 끌 때는 걸려 있던 쓰기(`_sessionWrite`)를 기다린 뒤 **그 자리에서** 비운다 — 늦게 도착한 옛 쓰기가 방금 비운 것을 다시 채우지 않게. 묻지 않고 끄는 경우(고친 것 없음)도 비워 맞춘다.
+- **처음엔 창이 꺼지지 않았다** — 기록: `Shell: 닫기 확인 중 오류 … 창을 닫는 중에는 … Close 를 호출할 수 없습니다`. 셸은 답을 받은 뒤 `Close()` 를 다시 부르는데,
+ 곧바로 답하면 그게 아직 Closing 안이다. `await Task.Yield()` 로 한 차례 비켜선 뒤 묻게 해 풀었다(플랫폼은 그대로 — 동기로 답하는 가드가 있으면 같은 일이 난다는 점만 적어 둔다).
+
+시험 `g19-exit-confirm`(새, 사용자 지시로 이것만): ① 고친 것 없음 → 묻지 않고 꺼짐 ② 고친 뒤 종료 → 창(파일 이름 · 단추 셋) → [취소] 면 앱 · 편집 그대로
+③ [저장하지 않고 종료] → 꺼짐 · 파일 그대로 · 보관본 빔 · 다시 켜도 안 살아남 ④ 고친 직후(0.3초) [저장하고 종료] → 파일에 씀 · 보관본 빔
+⑤ 뒤 탭에만 편집(alpha, 화면은 bravo) → 창에 alpha.cs · [저장하고 종료] 로 alpha 만 씀. **실패 0.** 대본의 [종료] 누름은 다른 실에서 한다(창이 뜨면 그 누름이 돌아오지 않는다).
+**Git 모드는 화면으로 시험하지 않았다** — 지금 화면의 저장은 기존 `SaveWorkingCopy` 를 그대로 쓴다. **기존 회귀는 사용자 지시로 돌리지 않았다.**
## 엔터를 꾹 누르면 줄이 어긋난다 · Ctrl+휠로 키우면 화면이 잘린다 · 내리면 BASE 와 LOCAL 이 따로 논다 (2026-09-30 밤, 사용자 보고 · 완료)
### 엔터 꾹 — 캐럿과 LOCAL 칸의 줄이 다르다 (사진)
diff --git a/doc/history/changelog.md b/doc/history/changelog.md
index c9ee4b9..d21680d 100644
--- a/doc/history/changelog.md
+++ b/doc/history/changelog.md
@@ -8,6 +8,7 @@
| 날짜 | 항목 | 커밋 |
|---|---|---|
+| 2026-09-30 | **끌 때 저장하지 않은 편집을 묻는다(사용자 요청 · 결정)** — 파일 비교의 모든 탭(지금 화면 · Git 모드 · 뒤 탭 · 닫은 탭, 메모 제외)에 편집이 있으면 [저장하고 종료] · [저장하지 않고 종료] · [취소]. 저장은 기존 길(`SaveWorkingCopy` · `WritePlainFile`), 하나라도 실패하면 끄지 않음. 버리면 걸려 있던 보관 쓰기를 기다린 뒤 보관본을 비워 다음에 안 살아남. 플랫폼 `ICloseGuard` 사용(수정 없음) — 곧바로 답하면 셸의 `Close()` 가 Closing 안에서 불려 창이 안 꺼져 `await Task.Yield()` 로 비켜섬. 화면 `g19-exit-confirm` 다섯 경우 실패 0(Git 모드는 화면 시험 안 함). **기존 회귀는 사용자 지시로 돌리지 않음.** 빌드 경고 0 · 오류 0 | 아래 |
| 2026-09-30 | **엔터 꾹 · 확대 · 쭉 내리기에서 칸 어긋남(사용자 보고)** — ① 엔터를 꾹 누르면 캐럿 줄과 LOCAL 칸 줄이 벌어졌다 → 캐럿은 글에서 센 줄 자리로, 메아리 막기 중에도 LOCAL 칸은 편집기를 따라감. ② Ctrl+휠 확대 뒤 칸 위가 비고 내리면 BASE 가 다른 줄: 가상화 목록 높이 어림 차이(편집기 28,896 · 목록 27,940)를 네 칸 공통 이동값으로 메우다 467px 까지 커졌다 → 두 줄 넘으면 칸을 실제로 굴리고 칸별 보정으로 기억, BASE · REMOTE 는 같은 줄끼리 맞춤(한쪽에만 있는 묶음 안은 제외 — 짝을 억지로 불러 초당 600번 되풀이하던 것을 기록으로 잡음). ③ 편집기 `GetFirstVisibleLineIndex`(886 → 1033) · 확대 직후 `GetRectFromCharacterIndex`(1,892px 틀림)가 틀려 → 줄 높이 고정이면 계산. 화면 `g17-hold-enter` 통과(남은 ✗ 는 대본 한계 · 꾹 누름 흉내에서 Windows 가 반복 키를 합치는 것 — 고치기 전과 같음). **기존 회귀는 사용자 지시로 돌리지 않음.** 빌드 경고 0 · 오류 0 | 아래 |
| 2026-09-30 | **머지 주석 · 타이핑 속도 · 탭 알림(사용자 요청 · 보고)** — ① Accept 뒤 캐럿이 새 주석 줄에 서서 Alt+↓ 를 두 번 눌러야 했다 → 주석을 끼우기 직전 캐럿 행을 기억해 글을 갈아 끼운 뒤 그 행으로(글을 막 갈아 끼운 TextBox 의 줄 정보가 옛 글 기준이라는 것을 기록으로 확인 → 줄 자리는 글에서 직접 셈). ② 줄을 지우는 반영에도 주석 — 한 줄 `삭제`, 여러 줄 `삭제 N줄`(사용자 선택, 왼쪽 · 3-Way · 구간 · 메서드 모두). ③ 색 구분 "줄 안에서 바뀐 부분" 에 줄 수. ④ 타이핑: 1,806줄에서 재 보니 엔터마다 재정렬 0.4초의 대부분이 목록에 1,800줄을 한 줄씩 넣는 알림(파일 비교 자체는 0.012초) → `MergeLineCollection.ReplaceAll`(알림 한 번) + `Patch`(번호 말고 같은 줄은 기존 객체를 두고 번호만, 가운데만 빼고 넣기) → 엔터 한 번 약 0.55초 → 약 0.05초(뒤 다시 칠한 줄 290 → 0~8). ⑤ 탭이 20개를 넘어 조용히 닫히면 알림. 하네스 `NoteTest` 106 · `PatchTest`(새) 16, 화면 `g12-note` · `g14-inline` · `g15-typing-tabs` 통과. **기존 회귀는 사용자 지시로 돌리지 않음.** 빌드 경고 0 · 오류 0 | 아래 |
| 2026-09-30 | **커밋 뒤 사용자 요청 셋** — ① 폴더 비교에서 두 번 눌러 파일 비교로 가도 하단 탭이 옛 탭에 남던 것: 사용자가 쓰던 앱을 읽기만 해서 재현(화면 파일 비교 · 선택 0번), 원인은 공용 플랫폼 셸의 `NavList` 가 `SelectedMainTab` 을 따라가지 않는 것 — 허락받아 `UbisamCommonPlatform` 에서 고침(MergeHub 코드 변경 없음), `g10-drop` 선택 [0] → [1]. ② Git 탭에 폴더 · 파일을 끌어다 놓으면 그 저장소 최상위를 연다(`PathUtil.RepositoryFolderFor`, `[폴더...]` 와 같은 규칙, 탭 전체 Preview 로 받아 TextBox 가 먹는 문제 회피). `PathTest` 62 → 68건(먼저 실패 확인). 흉내 낸 OLE 끌기는 이미 되는 폴더 비교 칸에도 안 들어가 도구 한계로 보고 사용자가 직접 확인(로그 저장소 열기 두 번). ③ 파일 비교 편집 칸 바탕을 회색(페이지 바탕이 비침) → 밝음 #FAFAFA · 어두움 #252830(순검정 · #1E1E1E 를 거쳐 후보를 보고 사용자가 고름, `PaneBackgroundConverter`). `g11-pane` 두 테마 화소로 확인. 빌드 경고 0 · 오류 0 | 아래 |
diff --git a/doc/진행상황.md b/doc/진행상황.md
index dce5ce0..2485786 100644
--- a/doc/진행상황.md
+++ b/doc/진행상황.md
@@ -446,6 +446,7 @@
| 상태 | 항목 |
|---|---|
+| **완료** | **끌 때 저장하지 않은 편집을 묻는다**(사용자 요청 · 결정) — 파일 비교의 모든 탭(Git 모드 · 뒤 탭 · 닫은 탭 포함, 메모 제외)에 편집이 있으면 [저장하고 종료] · [저장하지 않고 종료] · [취소]. 버리면 보관본도 비워 다음에 안 살아남. 플랫폼 `ICloseGuard` 를 그대로 씀(플랫폼 수정 없음). 화면 `g19-exit-confirm` 실패 0. Git 모드는 화면 시험 안 함 · 기존 회귀는 사용자 지시로 안 돌림 |
| **완료** | **엔터 꾹 · Ctrl+휠 확대 · 쭉 내리기에서 칸이 어긋난다**(사용자 보고) — 캐럿 줄 ≠ LOCAL 칸 줄, 확대하면 칸 위가 비고, 내리면 BASE 가 다른 줄을 봤다. 가상화 목록의 높이 어림 차이를 이동값으로 메우지 않고 칸을 실제로 굴려 칸별로 기억, BASE 는 같은 줄끼리 맞춤, 편집기 보이는 줄은 줄 높이로 계산(편집기의 `GetFirstVisibleLineIndex` · 확대 직후 `GetRectFromCharacterIndex` 가 틀렸다). 화면 `g17-hold-enter` — 엔터 뒤 936=936, 확대 · 30칸 내린 뒤 LOCAL = BASE. 기존 회귀는 사용자 지시로 안 돌림 |
| **완료** | **파일 비교 타이핑 · 엔터 속도**(사용자 보고) — 재서 보니 엔터마다 도는 재정렬 0.4초의 대부분이 1,800줄을 목록에 한 줄씩 넣는 알림이었다. 한 번에 넣기(`ReplaceAll`) + 번호 말고 같은 줄은 기존 객체를 두고 가운데만 바꾸기(`Patch`)로 엔터 한 번 약 0.55초 → 약 0.05초. 하네스 `PatchTest` 16건, 화면 `g15` ① 새 줄 · 번호 제자리. 기존 회귀는 사용자 지시로 안 돌림 |
| **완료** | **머지 주석 두 가지**(사용자 보고) — Accept 뒤 캐럿이 새 주석 줄에 서서 Alt+↓ 를 두 번 눌러야 했던 것(캐럿을 누르기 전 행에 둔다), 줄을 지우는 반영에도 주석 + `삭제` · 여러 줄이면 `삭제 N줄`(사용자 선택). `NoteTest` 106건, 화면 `g12-note` 실패 0 |
diff --git a/src/MergeHub/ViewModels/HomeViewModel.Close.cs b/src/MergeHub/ViewModels/HomeViewModel.Close.cs
new file mode 100644
index 0000000..2e55a60
--- /dev/null
+++ b/src/MergeHub/ViewModels/HomeViewModel.Close.cs
@@ -0,0 +1,179 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using MergeHub.Services;
+using UbisamBase.Core.Messaging;
+using UbisamBase.Core.Modules;
+
+namespace MergeHub.ViewModels
+{
+ ///
+ /// 프로그램을 끌 때 저장하지 않은 편집을 묻는다(2026-09-30 사용자 요청).
+ ///
+ /// 예전에는 묻지 않고 꺼졌다 - 편집은 보관본(12차 E-1)이 다음에 켤 때 되살렸다. 그런데 사용자 판단은
+ /// "잘못 고치고 있었으면 어떻게 하나 - 버릴 수 있어야 한다" 였다. 그래서 끌 때 한 번 묻는다:
+ /// [저장하고 종료] · [저장하지 않고 종료] · [취소]. 버리면 보관본도 비워 다음에 되살아나지 않게 한다.
+ ///
+ ///
+ /// 셸 창의 Closing 에 직접 붙지 않고 플랫폼의 를 쓴다 - 셸이 열린 탭 화면의
+ /// DataContext 에 물어 준다. 모듈이 저마다 Closing 에 붙으면 서로 Cancel/Close 를 걸어 부딪힌다.
+ ///
+ ///
+ ///
+ /// 묻는 범위는 파일 비교의 모든 탭(사용자 결정): 지금 화면(두 파일 · Git 모드 · 네 칸),
+ /// 뒤에 맡겨 둔 탭, 그리고 닫았지만 편집이 남은 짝(보관함). 메모는 넣지 않는다(사용자 결정 - 메모는 늘 스스로 저장한다).
+ ///
+ ///
+ public sealed partial class HomeViewModel : ICloseGuard
+ {
+ /// 저장하지 않은 편집 하나. 창에 이름을 대고, 저장할 때 그대로 쓴다.
+ private sealed class PendingEdit
+ {
+ public string Name = string.Empty;
+
+ /// 지금 화면이면 null - 화면의 저장 길()로 쓴다.
+ public string? Left, Right;
+ public DocTabState? State;
+ }
+
+ /// 창에 이름을 이만큼만 늘어놓는다. 넘치면 "외 N개".
+ private const int CloseListMax = 10;
+
+ public async Task CanCloseAsync()
+ {
+ // 먼저 한 번 비켜선다. 셸은 창의 Closing 안에서 이것을 부르고, 답이 오면 Close() 를 다시 부른다.
+ // 여기서 곧바로 답하면 그 Close() 가 아직 Closing 안에서 불려 WPF 가 거절했다
+ // (기록: "창을 닫는 중에는 … Close 를 호출할 수 없습니다" - 창이 꺼지지 않고 남았다).
+ // 한 차례 넘기면 Closing 이 끝난 뒤에 묻고 답한다.
+ await Task.Yield();
+
+ var pending = CollectPendingEdits();
+
+ if (pending.Count == 0)
+ {
+ // 물을 것이 없다. 보관본에 옛 편집이 남아 있으면 안 되므로 지금 상태로 한 번 맞춰 둔다
+ // (보관은 2초 뒤에 쓰므로, 저장한 직후 바로 끄면 옛 글이 남아 있을 수 있다).
+ FlushSessionNow(clear: true);
+ return true;
+ }
+
+ var names = pending.Take(CloseListMax).Select(p => " · " + p.Name).ToList();
+ if (pending.Count > CloseListMax) names.Add($" · 외 {pending.Count - CloseListMax}개");
+
+ var choice = MessageUtil.ShowChoice(
+ "저장하지 않은 편집",
+ $"저장하지 않은 편집이 있는 파일이 {pending.Count}개 있습니다.\n\n{string.Join("\n", names)}\n\n" +
+ "저장하지 않고 종료하면 고친 내용이 사라지고, 다음에 켤 때도 되살아나지 않습니다.",
+ "저장하고 종료", "저장하지 않고 종료");
+
+ switch (choice)
+ {
+ case DialogChoice.Primary:
+ // 하나라도 못 쓰면 끄지 않는다 - 여기서 꺼지면 저장된 줄 알고 잃는다(ConfirmDiscard 와 같은 원칙).
+ if (!SavePendingEdits(pending)) return false;
+ FlushSessionNow(clear: true);
+ return true;
+
+ case DialogChoice.Secondary:
+ FlushSessionNow(clear: true);
+ _workspace.AppendLog($"종료: 저장하지 않은 편집 {pending.Count}개를 버림");
+ return true;
+
+ default:
+ return false;
+ }
+ }
+
+ ///
+ /// 저장하지 않은 편집을 모두 모은다. 지금 화면을 맨 앞에 둔다 - 사용자가 보고 있는 것이다.
+ ///
+ private List CollectPendingEdits()
+ {
+ var list = new List();
+
+ // 지금 화면. 스타일 맞춤으로만 바뀐 글은 세지 않는다(다시 열면 똑같이 만들어진다 - WillAskBeforeDiscard 와 같은 기준).
+ if ((HasChanges && !OnlyStyleChanged) || HasLeftChanges)
+ {
+ var editing = IsPlainMode ? RightFile : (_lastLoadedFile ?? SelectedFile);
+ list.Add(new PendingEdit
+ {
+ Name = string.IsNullOrWhiteSpace(editing) ? "지금 파일"
+ : IsPlainMode ? Path.GetFileName(editing) : editing!
+ });
+ }
+
+ // 뒤에 맡겨 둔 탭. 두 파일 비교 화면이면 떠 있는 탭은 화면이 주인이라 위에서 이미 셌다.
+ foreach (var tab in Tabs.Tabs)
+ {
+ if (TabsCanHoldEdits && ReferenceEquals(tab, Tabs.Current)) continue;
+ if (tab.Parked is not { HasEdits: true } state) continue;
+ // 탭 제목은 같은 이름 파일을 가르려고 폴더를 덧붙여 길어진다 - 창에는 지금 화면 · 닫은 탭과 같이 파일 이름만 댄다.
+ list.Add(new PendingEdit { Name = Path.GetFileName(tab.Right), Left = tab.Left, Right = tab.Right, State = state });
+ }
+
+ // 닫았지만 편집이 남은 짝. 탭 줄에 없어 사용자가 잊었을 수 있으니 그렇다고 적는다.
+ foreach (var kept in Tabs.AllStashed())
+ {
+ if (!kept.State.HasEdits) continue;
+ list.Add(new PendingEdit
+ {
+ Name = Path.GetFileName(kept.Right) + " (닫은 탭)",
+ Left = kept.Left,
+ Right = kept.Right,
+ State = kept.State
+ });
+ }
+
+ return list;
+ }
+
+ /// 모은 편집을 파일에 쓴다. 하나라도 못 쓰면 거기서 멈추고 false.
+ private bool SavePendingEdits(IEnumerable pending)
+ {
+ foreach (var edit in pending)
+ {
+ if (edit.State is null)
+ {
+ // 지금 화면 - Git 모드의 충돌 파일 · 왼쪽 칸까지 챙기는 기존 길을 그대로 쓴다.
+ if (!SaveWorkingCopy()) return false;
+ continue;
+ }
+
+ try
+ {
+ // 맡겨 둔 글은 그 짝에 걸어 둔 저장 형식으로. 없으면(null) 원본 형식 그대로다.
+ if (edit.State.Text is not null && !string.IsNullOrWhiteSpace(edit.Right))
+ WritePlainFile(edit.Right!, edit.State.Text, edit.State.SaveEncoding, edit.State.SaveNewLine);
+ if (edit.State.LeftText is not null && !string.IsNullOrWhiteSpace(edit.Left))
+ WritePlainFile(edit.Left!, edit.State.LeftText, edit.State.SaveEncoding, edit.State.SaveNewLine);
+
+ _workspace.AppendLog($"종료하며 저장: {edit.Name}");
+ }
+ catch (Exception ex)
+ {
+ MessageUtil.ShowErrorToast($"{edit.Name} 을 저장하지 못했습니다: {ex.Message}");
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// 보관본을 지금 이 자리에서 맞춘다. 평소의 보관()은 다른 실로 넘기는데,
+ /// 끄는 순간에는 그 실이 끝나기 전에 프로세스가 내려갈 수 있고, 거꾸로 늦게 도착한 옛 보관이
+ /// 방금 비운 보관본을 다시 채울 수도 있다 - 그래서 걸려 있던 쓰기를 먼저 기다린 뒤 비운다.
+ ///
+ private void FlushSessionNow(bool clear)
+ {
+ _sessionTimer.Stop();
+ try { _sessionWrite?.Wait(TimeSpan.FromSeconds(3)); }
+ catch (AggregateException) { } // 보관 실패는 끄는 것을 막지 않는다
+
+ if (clear) Session.Clear();
+ }
+ }
+}
diff --git a/src/MergeHub/ViewModels/HomeViewModel.Tabs.cs b/src/MergeHub/ViewModels/HomeViewModel.Tabs.cs
index 4c385b2..9551757 100644
--- a/src/MergeHub/ViewModels/HomeViewModel.Tabs.cs
+++ b/src/MergeHub/ViewModels/HomeViewModel.Tabs.cs
@@ -369,6 +369,9 @@ namespace MergeHub.ViewModels
private bool _sessionReady;
+ /// 마지막으로 넘긴 보관 쓰기. 끌 때 이것이 끝나기를 기다린 뒤 보관본을 비운다(HomeViewModel.Close).
+ private Task? _sessionWrite;
+
/// 되살리는 중. 그때 일어나는 변화로 다시 보관하지 않는다.
private bool _restoringSession;
@@ -465,7 +468,7 @@ namespace MergeHub.ViewModels
}
var store = Session;
- Task.Run(() => store.Save(snapshot));
+ _sessionWrite = Task.Run(() => store.Save(snapshot));
}
///