Ubisam-MergeTool/src/MergeHub/ViewModels/HomeViewModel.Tabs.cs
ksj09406 0530832e79 머지 주석 · 타이핑 속도 · 탭 알림 (사용자 요청 · 보고)
Accept 뒤 Alt+Down 을 두 번 눌러야 다음 차이로 가던 것 - 반영하며 캐럿 위에
주석 줄이 끼면 글자 수로 지킨 캐럿 자리가 그 주석 줄이 됐다. 주석을 끼우거나
빼기 직전 캐럿 행을 기억했다가, 글을 갈아 끼운 뒤 그 행의 편집기 줄에 캐럿을
둔다. 임시 기록으로 확인한 것: 글을 막 갈아 끼운 TextBox 의 줄 정보는 아직
옛 글 기준이다 - 줄 시작 자리는 새 글에서 직접 센다.

줄을 지우는 반영(BASE 에 없는 줄)에도 주석을 남긴다. 한 줄이면 "삭제",
구간 · 메서드로 여러 줄이면 한 번만 "삭제 N줄"(사용자 선택). 왼쪽으로 반영 ·
3-Way 저쪽 반영에도 같은 규칙.

색 구분 "줄 안에서 바뀐 부분" 에 줄 수를 붙였다(이 칸만 없었다).

타이핑: 1,806줄에서 재 보니 엔터마다 도는 재정렬 0.4초의 대부분이 목록에
1,800줄을 한 줄씩 넣으며 알림이 1,800번 가는 것이었다(파일 비교 자체 0.012초).
MergeLineCollection 으로 한 번에 넣고(ReplaceAll), 재정렬에서는 번호 말고 모든
값이 같은 줄은 기존 객체를 두고 번호만 고쳐 가운데만 빼고 넣는다(Patch,
MergeLine.SameStateExceptNumbers). 엔터 한 번 약 0.55초 -> 약 0.05초.
화면 요소 재활용(Recycling)은 효과가 없어 넣지 않았다.

탭이 20개를 넘어 오래 안 본 탭이 조용히 닫히면 알림을 띄운다.

시험: 빌드 경고 0 · 오류 0. 하네스 NoteTest 106 · PatchTest(새) 16.
화면 g12-note(주석 · 캐럿 · 되돌리기) · g14-inline · g15-typing-tabs(새 줄
제자리 · 번호 896 -> 897 -> 898, 21번째 탭에서 알림 · 20개 유지) 통과.
기존 회귀는 사용자 지시("방금 요청한 것만")로 돌리지 않았다.

Agent: main (claude-opus-5-5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:27:47 +09:00

551 lines
25 KiB
C#

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;
namespace MergeHub.ViewModels
{
/// <summary>
/// 파일 비교 화면의 <b>문서 탭</b>(12차 E-2).
///
/// 지금까지 이 화면은 한 번에 한 짝만 들었다. 짝을 바꾸면 앞 짝의 편집 글이 갈 곳이 없어서
/// "저장하지 않은 편집이 있습니다" 를 묻고, [저장하지 않고 계속] 을 고르면 그 글이 사라졌다.
/// 이제는 <b>떠 있던 탭이 그 글을 맡아 둔다</b> - 그래서 짝을 바꿀 때 묻지 않는다.
///
/// <para>
/// 탭마다 <see cref="HomeViewModel"/> 을 따로 만들지 않는다 - 이 화면은 GitWorkspace 의 여러 사건과
/// 한 자리짜리 약속(<c>ConfirmDiscardEdits</c> · <c>ConfirmLeaveCommit</c>)에 물려 있어서,
/// 여럿을 만들면 같은 사건에 여러 화면이 동시에 반응하고 약속은 마지막 것만 남는다.
/// 대신 <b>화면은 하나로 두고 탭이 편집만 맡는다</b> - 파일에서 다시 읽을 수 있는 것(원본 글 · 줄 정렬)은
/// 맡기지 않고, 파일에 없는 것(고친 글 · 되돌리기 이력 · 캐럿)만 맡긴다.
/// </para>
///
/// <para>
/// Git 탭 · 병합 중(네 칸)은 탭이 맡지 않는다 - 그쪽 상태는 git 이 이미 들고 있고,
/// 사용자가 정한 범위도 "두 파일 비교만" 이다.
/// </para>
/// </summary>
public sealed partial class HomeViewModel
{
/// <summary>문서 탭 목록. 계산은 <see cref="DocTabs"/> 가 하고 여기서는 화면과 이어 준다.</summary>
public DocTabs Tabs { get; } = new DocTabs();
/// <summary>탭 줄에 뿌릴 목록. <see cref="DocTabs.Tabs"/> 를 그대로 옮겨 담는다.</summary>
public ObservableCollection<DocTab> TabItems { get; } = new ObservableCollection<DocTab>();
/// <summary>탭이 둘 이상이고 두 파일 비교일 때만 탭 줄을 보여 준다.</summary>
public bool ShowTabStrip => Tabs.ShowStrip && TabsCanHoldEdits;
/// <summary>
/// 지금 탭이 편집을 맡을 수 있는 자리인가. 두 파일 비교(2-Way)일 때만이다.
/// Git 모드 · 병합 중 네 칸에서는 맡을 탭이 없으니 예전처럼 묻고 넘어간다.
/// </summary>
private bool TabsCanHoldEdits => IsPlainMode && !IsThreeWay && _mergeTool is null;
/// <summary>탭 줄에서 고른 탭. 고르는 것만으로 그 탭으로 옮겨 간다.</summary>
public DocTab? CurrentTab
{
get => Tabs.Current;
set
{
if (value is null || ReferenceEquals(value, Tabs.Current)) return;
GoToTab(value);
}
}
/// <summary>파일을 다시 읽은 뒤에 되살릴 탭. <see cref="ResumePendingTab"/> 가 쓰고 비운다.</summary>
private DocTab? _resumeTab;
/// <summary>지금 탭을 바꾸는 중(◀ 이전 파일 · 다음 파일 ▶). 새 탭을 만들지 않는다.</summary>
private bool _replaceTab;
// ================= 화면 ↔ 탭 =================
/// <summary>편집기의 캐럿 자리를 읽어 오는 길. View 가 꽂아 준다(<see cref="ReadEditorText"/> 와 같은 방식).</summary>
public Func<int>? ReadCaretIndex { get; set; }
/// <summary>캐럿을 이 자리로 옮겨 달라고 View 에 부탁한다.</summary>
public event EventHandler<int>? CaretIndexRequested;
private void SyncTabItems()
{
// 목록이 그대로면 손대지 않는다 - 탭을 옮길 때마다 비우고 다시 담으면
// 탭 줄(ListBox)의 고르기가 풀려 깜빡이고, 가로 스크롤도 처음으로 돌아간다.
if (!TabItems.SequenceEqual(Tabs.Tabs))
{
TabItems.Clear();
foreach (var tab in Tabs.Tabs) TabItems.Add(tab);
}
OnPropertyChanged(nameof(ShowTabStrip));
OnPropertyChanged(nameof(CurrentTab));
CloseTabCommand.NotifyCanExecuteChanged();
NextTabCommand.NotifyCanExecuteChanged();
PreviousTabCommand.NotifyCanExecuteChanged();
}
/// <summary>
/// 지금 화면의 "저장하지 않은 것" 을 떠 있는 탭에 맡긴다.
///
/// 줄 단위 되돌리기 목록은 줄 객체를 가리켜 다른 짝을 실으면 쓸 수 없다 -
/// 맡기기 전에 글 단위로 옮긴다(11차 F-8 <c>FlattenHistory</c> 와 같은 이유).
/// </summary>
private void ParkCurrentTab()
{
var tab = Tabs.Current;
if (tab is null || !TabsCanHoldEdits) return;
FlattenHistory();
var right = BuildLocalText();
var left = BuildBaseText();
// 스타일 맞춤으로만 바뀐 글은 맡기지 않는다 - 다시 열면 똑같이 만들어진다.
var typed = HasChanges && !OnlyStyleChanged;
tab.IsDirty = HasTypedEdits;
tab.Parked = new DocTabState
{
Text = typed ? right : null,
LeftText = HasLeftChanges ? left : null,
Undo = _textUndo.ToArray(),
Redo = _textRedo.ToArray(),
Opened = _baseline.Opened,
Saved = _baseline.Saved,
LeftOpened = _leftBaseline.Opened,
LeftSaved = _leftBaseline.Saved,
CaretIndex = ReadCaretIndex?.Invoke() ?? 0,
TopRow = CaretRow,
SaveEncoding = SaveEncoding,
SaveNewLine = SaveNewLine
};
}
/// <summary>
/// 파일을 다시 읽은 직후에 맡겨 둔 편집을 화면에 되살린다.
///
/// <b>원본 파일이 그 사이 바뀌었으면</b> 어떻게 하는가 - 되살리고 알린다.
/// 사용자가 아까운 것은 자기가 고친 글이다. 파일을 연 시점(<c>Opened</c>)은 <b>새로 읽은 글</b>로 두어
/// [전체 되돌리기]가 지금 파일로 가고, 고친 글은 편집으로 남는다 - 그래야 어느 쪽도 잃지 않는다.
/// </summary>
private void ResumePendingTab()
{
var tab = _resumeTab;
_resumeTab = null;
if (tab is null) return;
var parked = tab.Parked;
tab.Parked = null;
if (parked is null || !parked.HasEdits) { RefreshTabDirty(); return; }
// 새로 읽은 글. 맡길 때의 "연 시점" 과 견줘 파일이 바뀌었는지 본다.
var fresh = BuildLocalText();
var fileChanged = !SaveBaseline.SameText(parked.Opened, fresh);
if (parked.SaveEncoding is not null) SaveEncoding = parked.SaveEncoding;
if (parked.SaveNewLine is not null) SaveNewLine = parked.SaveNewLine;
// 왼쪽(BASE) 칸에 고친 것이 있었으면 그 글로 다시 세운다(12차 B-1).
if (parked.LeftText is not null)
{
_baseTextSnapshot = parked.LeftText;
if (!fileChanged) _leftBaseline.Resume(parked.LeftOpened, parked.LeftSaved);
}
if (parked.Text is not null)
{
ClearHistory();
for (var i = parked.Undo.Length - 1; i >= 0; i--) _textUndo.Push(parked.Undo[i]);
for (var i = parked.Redo.Length - 1; i >= 0; i--) _textRedo.Push(parked.Redo[i]);
if (!fileChanged) _baseline.Resume(parked.Opened, parked.Saved);
ReloadLinesFrom(parked.Text, keepAsEdited: true, moveSelection: false);
}
else
{
ReloadLinesFrom(BuildLocalText(), keepAsEdited: true, moveSelection: false);
}
HasLeftChanges = _leftBaseline.IsDirty(BuildBaseText(), stylePending: false);
// 캐럿과 스크롤을 그 자리로.
if (parked.TopRow > 0 && parked.TopRow < Lines.Count) GoToRow(parked.TopRow);
if (parked.CaretIndex > 0) CaretIndexRequested?.Invoke(this, parked.CaretIndex);
RefreshTabDirty();
UpdateSummaries();
RaiseCanExecuteChanged();
if (fileChanged)
MessageUtil.ShowWarningToast(
$"{tab.Title} 은 그 사이 파일이 바뀌었습니다. 저장하지 않은 편집을 그대로 되살렸습니다 - 저장하면 바뀐 파일을 덮어씁니다.");
else
_workspace.AppendLog($"탭 되살림: {tab.Title}");
}
/// <summary>
/// 짝 하나를 탭으로 연다. 여는 길(<c>OnPlainCompareRequested</c>)이 파일을 읽기 <b>직전</b>에 부른다.
/// </summary>
/// <param name="replace">지금 탭의 짝만 바꾼다(◀ 이전 파일 · 다음 파일 ▶). 탭이 늘지 않는다.</param>
private void OpenPairInTab(string left, string right, bool replace)
{
if (!TabsCanHoldEdits)
{
// Git · 네 칸에서 두 파일 비교로 넘어온다. 여기서부터 탭이 맡는다 -
// 앞의 것은 이미 물어서 정리했으므로 맡길 것이 없다.
Tabs.Clear();
var fresh = Tabs.Open(left, right);
_resumeTab = fresh;
SyncTabItems();
return;
}
ParkCurrentTab();
var tab = replace ? Tabs.Replace(left, right) : Tabs.Open(left, right);
_resumeTab = tab;
SyncTabItems();
if (Tabs.LastEvicted is { } gone)
{
_workspace.AppendLog($"탭이 {DocTabs.Soft}개를 넘어 오래 안 본 탭을 닫음: {gone.Title}");
// 로그 한 줄로는 모르고 지나간다(사용자 2026-09-30). 닫히는 탭은 늘 저장할 것이 없는 탭이다 - 그것도 함께 알린다.
MessageUtil.ShowWarningToast($"탭이 {DocTabs.Soft}개를 넘어 오래 안 본 '{gone.Title}' 탭을 닫았습니다 (고친 것이 없던 탭)");
}
SaveSession(); // 12차 E-1
}
/// <summary>
/// 지금 짝에 탭이 하나는 있게 한다. <see cref="LoadPlainFilesAsync"/> 가 맨 앞에서 부른다.
///
/// 여는 길(<c>OnPlainCompareRequested</c>)을 거치지 않고 바로 짝이 바뀌는 자리가 있다 -
/// 켤 때 설정의 기본 짝, [기준 파일...] · [내 파일...] 로 한쪽만 바꾸기.
/// 그 자리에 탭이 없으면 거기서 고친 것을 맡을 데가 없어 <b>다음에 짝을 바꿀 때 사라진다</b> -
/// E-2 가 없애려던 바로 그 일이다.
///
/// 이미 떠 있는 탭이 있으면 <b>그 탭의 짝을 바꾼다</b>(탭이 늘지 않는다) -
/// 한쪽 파일만 바꾸는 것은 새 문서를 여는 것이 아니라 지금 보는 것을 바꾸는 일이다.
/// 내려간 짝의 편집은 보관함으로 가므로 되돌아오면 되살아난다.
/// </summary>
private void EnsureTabForCurrentPair()
{
if (!TabsCanHoldEdits) return;
if (string.IsNullOrWhiteSpace(LeftFile) && string.IsNullOrWhiteSpace(RightFile)) return;
// 여는 길이 이미 잡아 두었다(그쪽이 _resumeTab 도 정해 놓았다).
if (Tabs.Find(LeftFile, RightFile) is not null) return;
ParkCurrentTab();
var tab = Tabs.Current is null ? Tabs.Open(LeftFile, RightFile) : Tabs.Replace(LeftFile, RightFile);
_resumeTab ??= tab;
SyncTabItems();
}
/// <summary>
/// 사람이 손댄 것이 있는가. 탭에 가운뎃점을 붙이는 기준이자 맡길 것이 있는지 보는 기준이다.
///
/// <b>스타일 맞춤(C# 중괄호)으로만 바뀐 글은 세지 않는다</b> - 그것은 파일을 다시 열면 똑같이 만들어지므로
/// 잃을 것이 없고, "저장하지 않은 편집이 있습니다" 를 묻는 자리(<c>WillAskBeforeDiscard</c>)도 같은 기준이다.
/// 여기서만 세면 중괄호 맞춤을 켜 둔 사람은 .cs 를 여는 것만으로 점이 붙어, 늘 붙어 있는 점이 되어 뜻을 잃는다
/// (화면을 보다 찾았다 - 2026-09-28).
/// </summary>
private bool HasTypedEdits => (HasChanges && !OnlyStyleChanged) || HasLeftChanges;
/// <summary>떠 있는 탭의 "저장 안 한 표시" 를 지금 상태에 맞춘다.</summary>
internal void RefreshTabDirty()
{
if (Tabs.Current is { } tab && TabsCanHoldEdits)
tab.IsDirty = HasTypedEdits;
// 고친 것이 생겼거나(보관해야 한다) 없어졌다(보관본을 치워야 한다) - 둘 다 다시 쓸 일이다.
TouchSession();
}
// [ObservableProperty] 가 만들어 주는 갈고리. 고친 것이 생기거나 없어지면 탭 표시가 따라간다.
// 오른쪽(HasChanges)은 HomeViewModel.cs 에 이미 구현이 있어 거기서 RefreshTabDirty 를 부른다.
partial void OnHasLeftChangesChanged(bool value) => RefreshTabDirty();
// ================= 명령 =================
/// <summary>탭으로 옮겨 간다. 떠 있던 것을 맡기고 그 탭의 짝을 다시 읽는다.</summary>
private void GoToTab(DocTab tab)
{
if (tab is null || ReferenceEquals(tab, Tabs.Current)) return;
ParkCurrentTab();
Tabs.Activate(tab);
_resumeTab = tab;
SyncTabItems();
SaveSession(); // 12차 E-1 - 옮기는 순간이 곧 끄기 직전일 수 있다. 미루지 않는다.
LeftFile = tab.Left;
RightFile = tab.Right;
OnPropertyChanged(nameof(BaseFileLocation));
OnPropertyChanged(nameof(SelectedFileFullPath));
_ = LoadPlainFilesAsync();
}
[RelayCommand]
private void ActivateTab(DocTab? tab)
{
if (tab is not null) GoToTab(tab);
}
// RelayCommand&lt;T&gt; 의 CanExecute 는 같은 매개변수를 받아야 한다. 단축키(Ctrl+W)는 인자 없이 부르므로
// null 이 들어오는데, 그때는 "떠 있는 탭" 을 뜻한다.
private bool CanCloseTab(DocTab? tab) => (tab ?? Tabs.Current) is not null;
/// <summary>
/// 탭을 닫는다. <b>저장 안 한 편집이 있어도 묻지 않는다</b> -
/// 글은 그대로 보관되고 E-1(껐다 켜도 남는 편집)이 되살린다.
/// </summary>
[RelayCommand(CanExecute = nameof(CanCloseTab))]
private void CloseTab(DocTab? tab)
{
tab ??= Tabs.Current;
if (tab is null) return;
// 닫는 것이 지금 떠 있는 탭이면, 그 편집을 먼저 맡겨 둔다(보관은 계속된다).
if (ReferenceEquals(tab, Tabs.Current)) ParkCurrentTab();
var next = Tabs.Close(tab);
SyncTabItems();
SaveSession(); // 12차 E-1 - 닫아도 글은 남는다. 그 자리에서 적어 둔다.
if (next is null || ReferenceEquals(next, tab)) return;
_resumeTab = next;
LeftFile = next.Left;
RightFile = next.Right;
OnPropertyChanged(nameof(BaseFileLocation));
OnPropertyChanged(nameof(SelectedFileFullPath));
_ = LoadPlainFilesAsync();
}
private bool CanStepTab() => Tabs.Count > 1;
[RelayCommand(CanExecute = nameof(CanStepTab))]
private void NextTab()
{
var at = Tabs.CurrentIndex;
if (at < 0 || Tabs.Count < 2) return;
GoToTab(Tabs.Tabs[(at + 1) % Tabs.Count]);
}
[RelayCommand(CanExecute = nameof(CanStepTab))]
private void PreviousTab()
{
var at = Tabs.CurrentIndex;
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();
}
}