using System; using System.Collections.Generic; using System.ComponentModel; using System.IO; using System.Linq; using System.Runtime.CompilerServices; namespace MergeHub.Services { /// /// 문서 탭 하나가 들고 있는 "저장하지 않은 것"(12차 E-2). /// /// 탭을 옮기면 지금 화면의 이것을 떠 있던 탭에 맡겨 두고, 가려는 탭에 맡겨 둔 것을 되살린다. /// 파일에서 다시 읽어 오는 것(원본 글 · 줄 정렬)은 여기에 담지 않는다 - 그건 언제든 다시 읽을 수 있고, /// 담아 두면 파일이 그 사이 바뀌었을 때 낡은 것을 보여 주게 된다. 여기에는 파일에 없는 것만 담는다. /// public sealed class DocTabState { /// 오른쪽(LOCAL) 편집기의 글. null 이면 파일 그대로라 되살릴 것이 없다. public string? Text { get; set; } /// 왼쪽(BASE) 칸의 글(12차 B-1 양방향). null 이면 파일 그대로. public string? LeftText { get; set; } public string[] Undo { get; set; } = Array.Empty(); public string[] Redo { get; set; } = Array.Empty(); /// 연 시점 · 저장한 시점의 글(11차 F-8 ). public string Opened { get; set; } = string.Empty; public string Saved { get; set; } = string.Empty; /// 왼쪽 칸의 연 시점 · 저장한 시점(12차 B-1 은 왼쪽 기준점을 따로 센다). public string LeftOpened { get; set; } = string.Empty; public string LeftSaved { get; set; } = string.Empty; /// 캐럿이 있던 글자 자리와 맨 위에 보이던 행. public int CaretIndex { get; set; } public int TopRow { get; set; } /// 저장 형식 · 비교 규칙처럼 이 짝에만 걸어 둔 것. public string? SaveEncoding { get; set; } public string? SaveNewLine { get; set; } /// 되살릴 것이 있는가. 없으면 파일만 다시 읽으면 된다. public bool HasEdits => Text is not null || LeftText is not null; } /// /// 파일 비교 화면의 문서 탭 하나(12차 E-2). /// /// 탭 줄에 그릴 이름()과 저장 안 한 표시()를 들고, /// 화면에 없는 동안 편집 글을 에 맡아 둔다. /// public sealed class DocTab : INotifyPropertyChanged { public DocTab(string left, string right) { Left = left ?? string.Empty; Right = right ?? string.Empty; } /// 왼쪽(BASE) 파일 경로. public string Left { get; } /// 오른쪽(LOCAL) 파일 경로. 저장은 이 이름으로 한다. public string Right { get; } /// 화면에 없는 동안 맡아 둔 편집. 떠 있는 탭은 화면이 주인이라 비어 있다. public DocTabState? Parked { get; set; } private bool _isDirty; /// 저장하지 않은 편집이 있는가. 탭 이름 앞 · 표시에 쓴다. public bool IsDirty { get => _isDirty; set => Set(ref _isDirty, value); } private bool _isCurrent; /// 지금 화면에 떠 있는 탭인가. 탭 줄에서 이 탭을 도드라지게 그리는 데 쓴다. public bool IsCurrent { get => _isCurrent; internal set => Set(ref _isCurrent, value); } private string _title = string.Empty; /// 탭 줄에 그릴 이름. 같은 이름이 여럿이면 가 위 폴더를 덧붙인다. public string Title { get => _title; internal set => Set(ref _title, value); } /// 마우스를 올렸을 때 보여 줄 전체 경로. public string Tip => Left + Environment.NewLine + Right; /// 같은 짝인가. 대소문자는 가리지 않는다(윈도우 경로). public bool Same(string left, string right) => string.Equals(Left, left ?? string.Empty, StringComparison.OrdinalIgnoreCase) && string.Equals(Right, right ?? string.Empty, StringComparison.OrdinalIgnoreCase); /// 마지막으로 떠 있던 순서. 자리가 모자랄 때 오래 안 본 탭부터 치우는 데 쓴다. internal long Touched { get; set; } public event PropertyChangedEventHandler? PropertyChanged; private void Set(ref T field, T value, [CallerMemberName] string? name = null) { if (EqualityComparer.Default.Equals(field, value)) return; field = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name!)); } } /// /// 문서 탭 목록(12차 E-2). 화면 없이 검사할 수 있도록 계산만 한다 - /// 파일을 읽거나 화면을 고치는 일은 부르는 쪽(HomeViewModel)이 한다. /// /// /// 이름 짓는 규칙은 최근 목록()과 같다 - 양쪽 이름이 같으면 그 이름 하나, /// 다르면 왼 ↔ 오. 그래도 같은 이름이 여럿이면 갈릴 때까지 위 폴더를 한 겹씩 덧붙인다. /// 폴더가 다른 같은 이름 파일(Unit0.cs 가 여러 폴더에 있는 것)이 사내 소스에 흔하다. /// /// public sealed class DocTabs { /// 이보다 많으면 저장할 것이 없는 탭 가운데 가장 오래 안 본 것을 치운다. public const int Soft = 20; private readonly List _tabs = new List(); private long _clock; public IReadOnlyList Tabs => _tabs; public int Count => _tabs.Count; /// 지금 떠 있는 탭. 하나도 없으면 null. public DocTab? Current { get; private set; } public int CurrentIndex => Current is null ? -1 : _tabs.IndexOf(Current); /// 탭이 여럿일 때만 탭 줄을 보여 준다 - 한 짝만 보는 동안 줄을 낭비하지 않는다. public bool ShowStrip => _tabs.Count > 1; /// 자리가 모자라 조용히 치운 탭. 부르는 쪽이 알림에 쓴다. public DocTab? LastEvicted { get; private set; } /// 같은 짝을 든 탭. 없으면 null. public DocTab? Find(string left, string right) => _tabs.FirstOrDefault(t => t.Same(left, right)); // ================= 보관함 ================= /// /// 탭에서 내려온 편집을 짝 이름으로 보관한다. /// /// 탭을 닫거나() 짝을 바꿔() 탭 자체가 사라져도 /// 고친 글은 남아야 한다 - 목록을 ◀ ▶ 로 훑다가 앞 파일로 돌아왔을 때 고친 것이 사라지면 /// 그것이 곧 E-2 가 없애려던 일이다. 다시 그 짝을 열면 여기서 되살아난다. /// E-1(껐다 켜도 남는 편집)은 이 보관함을 그대로 파일에 옮겨 적으면 된다. /// private readonly Dictionary _attic = new Dictionary(StringComparer.OrdinalIgnoreCase); /// 보관 순서(오래된 것이 앞). 넘치면 앞에서 버린다. private readonly List _atticOrder = new List(); /// 보관함에 이만큼만 둔다. 짝마다 파일 전문 한 벌이라 끝없이 쌓을 수 없다. public const int AtticMax = 50; public int StashedCount => _attic.Count; /// 짝 하나를 가리키는 이름. 윈도우 경로에는 널 문자가 들어갈 수 없어 가름자로 쓴다. public static string KeyOf(string? left, string? right) => (left ?? string.Empty) + "\0" + (right ?? string.Empty); /// 이 짝으로 보관해 둔 편집. 없으면 null. 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) { var key = KeyOf(tab.Left, tab.Right); if (tab.Parked is { } state && state.HasEdits) { if (!_attic.ContainsKey(key)) _atticOrder.Add(key); _attic[key] = state; while (_atticOrder.Count > AtticMax) { var oldest = _atticOrder[0]; _atticOrder.RemoveAt(0); _attic.Remove(oldest); } return; } if (_attic.Remove(key)) _atticOrder.Remove(key); } /// 새 탭을 만들 때 보관해 둔 편집이 있으면 되찾아 얹는다. private DocTab NewTab(string left, string right) { var tab = new DocTab(left, right); var key = KeyOf(left, right); if (_attic.TryGetValue(key, out var kept)) { tab.Parked = kept; tab.IsDirty = true; _attic.Remove(key); _atticOrder.Remove(key); } return tab; } /// /// 짝을 연다. 같은 짝이 이미 있으면 새로 만들지 않고 그 탭을 띄운다 - /// 같은 파일이 두 탭에 있으면 어느 쪽 편집이 맞는지 알 수 없다. /// public DocTab Open(string left, string right) { LastEvicted = null; var found = Find(left, right); if (found is not null) { Activate(found); return found; } Evict(); var tab = NewTab(left, right); _tabs.Add(tab); Retitle(); Activate(tab); return tab; } /// /// 떠 있는 탭의 짝을 다른 짝으로 바꾼다(◀ 이전 파일 · 다음 파일 ▶). /// 탭이 늘지 않아 목록을 훑기 좋다 - 사용자가 정한 것. /// public DocTab Replace(string left, string right) { if (Current is null) return Open(left, right); // 바꾸려는 짝을 다른 탭이 이미 들고 있으면 그 탭으로 간다(같은 짝 두 탭 금지). var found = Find(left, right); if (found is not null && !ReferenceEquals(found, Current)) { Activate(found); return found; } if (found is not null) return found; var at = _tabs.IndexOf(Current); // 내려가는 탭의 편집은 보관함으로 간다 - 훑다가 돌아오면 되살아난다. Stash(Current); Current.IsCurrent = false; var tab = NewTab(left, right); _tabs[at] = tab; Retitle(); Activate(tab); return tab; } public void Activate(DocTab tab) { if (tab is null || !_tabs.Contains(tab)) return; Current = tab; tab.Touched = ++_clock; foreach (var other in _tabs) other.IsCurrent = ReferenceEquals(other, tab); } public void Activate(int index) { if (index < 0 || index >= _tabs.Count) return; Activate(_tabs[index]); } /// /// 탭을 닫는다. 닫은 자리의 오른쪽 탭으로 옮겨 가고, 오른쪽이 없으면 왼쪽으로 간다 - /// 여러 장을 차례로 닫을 때 손이 한 자리에 머문다. /// public DocTab? Close(DocTab tab) { var at = _tabs.IndexOf(tab); if (at < 0) return Current; _tabs.RemoveAt(at); // 닫아도 고친 글은 보관함에 남는다 - 그래서 닫을 때 묻지 않는다. Stash(tab); Retitle(); if (!ReferenceEquals(Current, tab)) return Current; tab.IsCurrent = false; if (_tabs.Count == 0) { Current = null; return null; } Activate(_tabs[Math.Min(at, _tabs.Count - 1)]); return Current; } /// 다음 탭으로(Ctrl+Tab). 끝에서는 처음으로 돈다. public DocTab? Next() => Step(1); /// 앞 탭으로(Ctrl+Shift+Tab). public DocTab? Previous() => Step(-1); private DocTab? Step(int by) { if (_tabs.Count == 0) return null; var at = CurrentIndex; if (at < 0) { Activate(_tabs[0]); return Current; } var next = (at + by) % _tabs.Count; if (next < 0) next += _tabs.Count; Activate(_tabs[next]); return Current; } /// 모두 닫는다. public void Clear() { foreach (var tab in _tabs) { Stash(tab); tab.IsCurrent = false; } _tabs.Clear(); Current = null; LastEvicted = null; } /// 저장하지 않은 편집이 있는 탭들. public IReadOnlyList Dirty => _tabs.Where(t => t.IsDirty).ToList(); /// /// 자리가 모자라면 저장할 것이 없는 탭 가운데 가장 오래 안 본 것을 치운다. /// 고친 탭은 절대 치우지 않는다 - 그것이 E-1 이 지키려는 바로 그 글이다. /// 모두 고쳐진 상태면 를 넘겨서라도 새 탭을 연다. /// private void Evict() { if (_tabs.Count < Soft) return; var victim = _tabs.Where(t => !t.IsDirty && !ReferenceEquals(t, Current)) .OrderBy(t => t.Touched) .FirstOrDefault(); if (victim is null) return; _tabs.Remove(victim); Stash(victim); victim.IsCurrent = false; LastEvicted = victim; } /// /// 탭 이름을 다시 짓는다. 이름이 겹치는 탭끼리만 갈릴 때까지 위 폴더를 한 겹씩 덧붙인다. /// private void Retitle() { foreach (var tab in _tabs) tab.Title = BaseTitle(tab, 0); for (var depth = 1; depth <= 6; depth++) { var clashed = _tabs.GroupBy(t => t.Title, StringComparer.OrdinalIgnoreCase) .Where(g => g.Count() > 1) .SelectMany(g => g) .ToList(); if (clashed.Count == 0) return; var moved = false; foreach (var tab in clashed) { var wider = BaseTitle(tab, depth); if (string.Equals(wider, tab.Title, StringComparison.Ordinal)) continue; tab.Title = wider; moved = true; } // 더 붙일 폴더가 없으면(뿌리까지 갔다) 그대로 둔다 - 끝없이 돌지 않게. if (!moved) return; } } /// /// 탭 이름. 만큼 위 폴더를 앞에 붙인다. /// 양쪽 이름이 같으면 하나만, 다르면 왼 ↔ 오 로 적는다. /// public static string BaseTitle(DocTab tab, int depth) => Pair(Tail(tab.Left, depth), Tail(tab.Right, depth)); /// /// 두 이름을 한 줄로. 같으면 하나만, 다르면 왼 ↔ 오 인데 /// 양쪽이 같은 부분은 한 번만 적는다(사용자 결정 2026-09-28). /// /// /// WeldController_기준.cs ↔ WeldController_내작업.cs → WeldController_기준 ↔ 내작업.cs /// /// /// 사내 소스에는 _기준 · _내작업 처럼 이름이 살짝만 다른 짝이 흔해서, 그대로 두면 /// 탭 하나가 탭 줄의 1/9(280 DIP)을 먹는다. 겹치는 부분이 짧으면(우연히 첫 글자가 같은 정도) /// 접지 않는다 - 접어서 얻는 것보다 읽기 어려워지는 것이 크다. /// public static string Pair(string left, string right) { if (string.Equals(left, right, StringComparison.OrdinalIgnoreCase)) return left; var head = CommonHead(left, right); // 우연히 한 글자 같은 정도로는 접지 않는다 - 접어서 얻는 것보다 읽기 어려워지는 것이 크다. if (head < 2) return left + " ↔ " + right; // 뒤쪽은 확장자일 때만 접는다. 글자 단위로 같은 꼬리를 세면 낱말 가운데서 잘린다 - // a-left.cs ↔ a-right.cs 에서 "t.cs" 가 같다고 "a-lef ↔ right.cs" 가 되어 읽을 수 없다(하네스에서 걸렸다). var ext = Path.GetExtension(left); var same = ext.Length > 1 && string.Equals(ext, Path.GetExtension(right), StringComparison.OrdinalIgnoreCase) && left.Length - ext.Length > head; var shown = same ? left.Substring(0, left.Length - ext.Length) : left; return shown + " ↔ " + right.Substring(head); } private static int CommonHead(string a, string b) { var n = Math.Min(a.Length, b.Length); var i = 0; while (i < n && char.ToUpperInvariant(a[i]) == char.ToUpperInvariant(b[i])) i++; return i; } /// 경로 끝에서 +1 조각. 조각이 모자라면 있는 만큼. public static string Tail(string path, int depth) { if (string.IsNullOrWhiteSpace(path)) return "-"; var trimmed = path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var parts = trimmed.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var take = Math.Min(depth + 1, parts.Length); var tail = parts.Skip(parts.Length - take).Where(p => p.Length > 0).ToArray(); if (tail.Length == 0) return trimmed; return string.Join("\\", tail); } /// /// 탭 줄에 그릴 글. 저장하지 않은 탭은 앞에 가운뎃점을 붙인다(notepad++ 와 같은 표시). /// public static string Mark(DocTab tab) => tab.IsDirty ? "• " + tab.Title : tab.Title; } }