G-3 "덩어리" 를 "구간" 으로 - 단추 [구간 반영] · [구간 왼쪽으로], 단축키 설명, 로그.
G-4 저장 형식 콤보 둘을 구별 - 원본 인코딩 유지 · 원본 줄바꿈 유지. 저장값은
"원본 유지" 그대로(세션 보관본 7·8칸) - SaveChoice(Value, Label) 로 보이는 글만 가른다.
G-5 사내 문의처 받침값 - csproj <SupportContact> 를 실행 파일에 박는다(문구는 아직 비움).
G-6 설정 > 개발자 정보. 담당 카드는 Platform 최민영 대리 · MergeHub 김승준 사원,
소개 문구는 "사내 Merge/Diff + Git 도구"(Merge 먼저 - 창 제목 · csproj · 정보 창).
G-7 Alt+Down 화면 잘림 - 가운데 맞추기의 두 번째 스크롤이 메아리 막기에 걸려 버려졌다.
CenterCharInView 가 다른 칸을 직접 맞춘다(g7-verify 고치기 전 10건 실패 -> 0).
G-8 메모 문법 강조 · 같은 글 칠하기 · 색 마킹(Ctrl+1~5) · 위치 막대. 투명 TextBox +
아래층(사용자 결정). 화면에서 찾은 셋: 플랫폼 TextBox 스타일이 아래층을 덮음
(Style={x:Null}), 콤보에 형 이름(LanguageProfile.ToString), 어두운 테마에서 메모 탭 ·
문서 탭 제목이 검정(ItemContainerStyle 에 Foreground).
마무리(사용자 지시)
- 설정의 "프로그램 정보" 칸(만든 사람 · 사내 문의처)을 뺐다. [정보] 창은 실행 파일 값만.
- 주석 남기기가 켜져 있는데 작성자가 비면 반영 명령 맨 앞에서 알리고 반영하지 않는다
(예전에는 git 이름 · 윈도우 계정으로 조용히 채웠다). 주석을 끄면 묻지 않는다.
- 데모(설정 두 칸 · [데모 ->] 단추 · 전환 코드)를 걷어냈다.
시험: 빌드 경고 0 · 오류 0. 하네스 MemoHlTest 45 · MemoTest 69 · OverviewTest 15 ·
AboutTest 30 · NoteTest 99. 화면 최종 회귀 22벌 423건(실패 4건은 대본이 툴팁을
읽은 것 - 고쳐 통과), 마무리 뒤 g5 · g6 · g9-author 실패 0. 한 번 나오고 재현 안 된
g8 가로 스크롤 항목은 12차 기록에 "값으로 확정 못 함" 으로 남겼다.
Agent: main (claude-opus-5-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
547 lines
24 KiB
C#
547 lines
24 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}");
|
|
|
|
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<T> 의 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();
|
|
}
|
|
}
|