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;
}
}