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 { /// /// 파일 비교 화면의 문서 탭(12차 E-2). /// /// 지금까지 이 화면은 한 번에 한 짝만 들었다. 짝을 바꾸면 앞 짝의 편집 글이 갈 곳이 없어서 /// "저장하지 않은 편집이 있습니다" 를 묻고, [저장하지 않고 계속] 을 고르면 그 글이 사라졌다. /// 이제는 떠 있던 탭이 그 글을 맡아 둔다 - 그래서 짝을 바꿀 때 묻지 않는다. /// /// /// 탭마다 을 따로 만들지 않는다 - 이 화면은 GitWorkspace 의 여러 사건과 /// 한 자리짜리 약속(ConfirmDiscardEdits · ConfirmLeaveCommit)에 물려 있어서, /// 여럿을 만들면 같은 사건에 여러 화면이 동시에 반응하고 약속은 마지막 것만 남는다. /// 대신 화면은 하나로 두고 탭이 편집만 맡는다 - 파일에서 다시 읽을 수 있는 것(원본 글 · 줄 정렬)은 /// 맡기지 않고, 파일에 없는 것(고친 글 · 되돌리기 이력 · 캐럿)만 맡긴다. /// /// /// /// Git 탭 · 병합 중(네 칸)은 탭이 맡지 않는다 - 그쪽 상태는 git 이 이미 들고 있고, /// 사용자가 정한 범위도 "두 파일 비교만" 이다. /// /// public sealed partial class HomeViewModel { /// 문서 탭 목록. 계산은 가 하고 여기서는 화면과 이어 준다. public DocTabs Tabs { get; } = new DocTabs(); /// 탭 줄에 뿌릴 목록. 를 그대로 옮겨 담는다. public ObservableCollection TabItems { get; } = new ObservableCollection(); /// 탭이 둘 이상이고 두 파일 비교일 때만 탭 줄을 보여 준다. public bool ShowTabStrip => Tabs.ShowStrip && TabsCanHoldEdits; /// /// 지금 탭이 편집을 맡을 수 있는 자리인가. 두 파일 비교(2-Way)일 때만이다. /// Git 모드 · 병합 중 네 칸에서는 맡을 탭이 없으니 예전처럼 묻고 넘어간다. /// private bool TabsCanHoldEdits => IsPlainMode && !IsThreeWay && _mergeTool is null; /// 탭 줄에서 고른 탭. 고르는 것만으로 그 탭으로 옮겨 간다. public DocTab? CurrentTab { get => Tabs.Current; set { if (value is null || ReferenceEquals(value, Tabs.Current)) return; GoToTab(value); } } /// 파일을 다시 읽은 뒤에 되살릴 탭. 가 쓰고 비운다. private DocTab? _resumeTab; /// 지금 탭을 바꾸는 중(◀ 이전 파일 · 다음 파일 ▶). 새 탭을 만들지 않는다. private bool _replaceTab; // ================= 화면 ↔ 탭 ================= /// 편집기의 캐럿 자리를 읽어 오는 길. View 가 꽂아 준다( 와 같은 방식). public Func? ReadCaretIndex { get; set; } /// 캐럿을 이 자리로 옮겨 달라고 View 에 부탁한다. public event EventHandler? 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(); } /// /// 지금 화면의 "저장하지 않은 것" 을 떠 있는 탭에 맡긴다. /// /// 줄 단위 되돌리기 목록은 줄 객체를 가리켜 다른 짝을 실으면 쓸 수 없다 - /// 맡기기 전에 글 단위로 옮긴다(11차 F-8 FlattenHistory 와 같은 이유). /// 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 }; } /// /// 파일을 다시 읽은 직후에 맡겨 둔 편집을 화면에 되살린다. /// /// 원본 파일이 그 사이 바뀌었으면 어떻게 하는가 - 되살리고 알린다. /// 사용자가 아까운 것은 자기가 고친 글이다. 파일을 연 시점(Opened)은 새로 읽은 글로 두어 /// [전체 되돌리기]가 지금 파일로 가고, 고친 글은 편집으로 남는다 - 그래야 어느 쪽도 잃지 않는다. /// 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}"); } /// /// 짝 하나를 탭으로 연다. 여는 길(OnPlainCompareRequested)이 파일을 읽기 직전에 부른다. /// /// 지금 탭의 짝만 바꾼다(◀ 이전 파일 · 다음 파일 ▶). 탭이 늘지 않는다. 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 } /// /// 지금 짝에 탭이 하나는 있게 한다. 가 맨 앞에서 부른다. /// /// 여는 길(OnPlainCompareRequested)을 거치지 않고 바로 짝이 바뀌는 자리가 있다 - /// 켤 때 설정의 기본 짝, [기준 파일...] · [내 파일...] 로 한쪽만 바꾸기. /// 그 자리에 탭이 없으면 거기서 고친 것을 맡을 데가 없어 다음에 짝을 바꿀 때 사라진다 - /// E-2 가 없애려던 바로 그 일이다. /// /// 이미 떠 있는 탭이 있으면 그 탭의 짝을 바꾼다(탭이 늘지 않는다) - /// 한쪽 파일만 바꾸는 것은 새 문서를 여는 것이 아니라 지금 보는 것을 바꾸는 일이다. /// 내려간 짝의 편집은 보관함으로 가므로 되돌아오면 되살아난다. /// 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(); } /// /// 사람이 손댄 것이 있는가. 탭에 가운뎃점을 붙이는 기준이자 맡길 것이 있는지 보는 기준이다. /// /// 스타일 맞춤(C# 중괄호)으로만 바뀐 글은 세지 않는다 - 그것은 파일을 다시 열면 똑같이 만들어지므로 /// 잃을 것이 없고, "저장하지 않은 편집이 있습니다" 를 묻는 자리(WillAskBeforeDiscard)도 같은 기준이다. /// 여기서만 세면 중괄호 맞춤을 켜 둔 사람은 .cs 를 여는 것만으로 점이 붙어, 늘 붙어 있는 점이 되어 뜻을 잃는다 /// (화면을 보다 찾았다 - 2026-09-28). /// private bool HasTypedEdits => (HasChanges && !OnlyStyleChanged) || HasLeftChanges; /// 떠 있는 탭의 "저장 안 한 표시" 를 지금 상태에 맞춘다. internal void RefreshTabDirty() { if (Tabs.Current is { } tab && TabsCanHoldEdits) tab.IsDirty = HasTypedEdits; // 고친 것이 생겼거나(보관해야 한다) 없어졌다(보관본을 치워야 한다) - 둘 다 다시 쓸 일이다. TouchSession(); } // [ObservableProperty] 가 만들어 주는 갈고리. 고친 것이 생기거나 없어지면 탭 표시가 따라간다. // 오른쪽(HasChanges)은 HomeViewModel.cs 에 이미 구현이 있어 거기서 RefreshTabDirty 를 부른다. partial void OnHasLeftChangesChanged(bool value) => RefreshTabDirty(); // ================= 명령 ================= /// 탭으로 옮겨 간다. 떠 있던 것을 맡기고 그 탭의 짝을 다시 읽는다. 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; /// /// 탭을 닫는다. 저장 안 한 편집이 있어도 묻지 않는다 - /// 글은 그대로 보관되고 E-1(껐다 켜도 남는 편집)이 되살린다. /// [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) ================= /// /// 저장하지 않은 편집을 파일로 보관한다(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(); } }