E-2 가 만든 보관함(탭이 사라져도 고친 글이 짝 이름으로 남는 자리)을 그대로 파일로
옮겨 적는 일이다. 새로 정할 것은 언제 쓰고 언제 되살리는가뿐이었다.
어디에 · 어떤 모양으로
{설정 폴더}\Session\MergeHub\
목록.txt 차례 · 짝 경로 · 캐럿 · 스크롤 · 저장 형식 · 보고 있던 탭 · 그때 파일의 자취
01-오른쪽.txt 저장하지 않은 오른쪽 글(있을 때만)
01-왼쪽.txt 저장하지 않은 왼쪽 글(12차 B-1, 있을 때만)
11차 F-9 백업과 다른 자리다 - 백업은 "저장하기 전 파일", 이것은 "저장하지 않은 편집" 이라
뜻도 수명도 다르다.
- JSON 을 쓰지 않는다(최근 목록과 같은 까닭). 사람이 열어 보고 고칠 수 있고, 무엇보다
글 안에 무엇이 들었든(따옴표 · 줄바꿈 · 제어 문자) 본문을 따로 파일로 두면 탈이 없다
- 원본 글은 담지 않는다. 파일에서 다시 읽으면 되고, 담아 두면 그 사이 파일이 바뀌었을 때
낡은 것을 보여 준다. 대신 그때 파일의 길이 · 마지막으로 쓴 때(FileStamp)만 적어 둔다
- 목록은 맨 나중에 쓴다 - 목록이 있다는 것이 곧 글 파일이 다 있다는 뜻이다
- 못 읽거나 못 써도 조용히 넘어간다. 보관 때문에 프로그램이 안 켜지면 안 된다
언제 쓰나
- 글자가 멈춘 뒤 2초에 한 번(DispatcherTimer)
- 탭을 옮길 때 · 닫을 때 · 새 짝을 열 때는 그 자리에서 바로 - 그 순간이 곧 끄기 직전일 수 있다
- 쓰는 일은 다른 실로 넘긴다(Task.Run). 화면 상태를 뜰 때 ParkCurrentTab 을 쓰지 않는다 -
그쪽은 되돌리기 목록을 글 단위로 눌러 담는데, 2초마다 이력을 주무를 까닭이 없다.
보관할 것은 글과 자리뿐이고 되돌리기 이력은 되살리지 않기로 정했다(사용자 결정)
언제 되살리나
켤 때 묻지 않고 그대로(사용자 결정). 다만 사용자가 지금 열라고 한 것이 먼저다 -
충돌 파일 열기 · git mergetool · 폴더 비교에서 고른 파일. 설정의 기본 저장소 · 기본 짝보다는 앞선다.
원본 글은 파일에서 다시 읽고 그 위에 편집을 얹는다(E-2 의 ResumePendingTab 그대로).
원본이 그 사이 바뀌었으면 되살리고 알린다 - "연 시점" 은 새로 읽은 글로 두어
[전체 되돌리기] 가 지금 파일로 가고, 고친 글은 편집으로 남는다.
두 파일이 다 없어진 짝은 되살릴 자리가 없으므로 버린다.
확인
하네스 SessionTest 65건 새로 만듦(글로 옮기고 되읽기 · 빈칸/한글/UNC 경로 ·
알아볼 수 없는 줄 건너뛰기 · 보관할 것 없으면 치우기 · 옛 글 파일 치우기 ·
글 파일이 없어져도 넘어가기 · FileStamp · 보관함 주고받기). 전체 28벌 1,852건 · 실패 0.
화면 24건: 두 짝을 고치고 작업 관리자로 끄듯 강제로 끝낸 뒤 다시 켜면 묻지 않고
탭 2개가 이름 · 차례 · 글 그대로 되살아나고, 한쪽을 저장하면 가운뎃점이 2->1,
둘 다 저장하면 보관본이 사라지고, 다시 켜면 되살릴 것이 없다.
원본이 그 사이 바뀌면 되살리고 "저장하면 바뀐 파일을 덮어씁니다" 를 알린다.
빠르기에 대해: 5,003줄(113KB) 짝에서 글자당 31ms 로 4줄짜리와 같았다(앱이 입력에 밀리지 않는다).
다만 "친 뒤 화면에 보이기까지" 는 값으로 확정하지 못했다 - 그것을 재려면 편집기 글을
UIA 로 읽어야 하는데 5,003줄 창에서는 그 읽기가 타이핑보다 훨씬 오래 걸려 값이 오염된다.
재는 법을 따로 마련해야 한다.
시험 도구: E-1 이 도니 지난 시험이 남긴 편집이 다음 시험에서 되살아나 화면이 달라졌다
(e3-verify 가 왼쪽 7줄 대신 14줄을 봤다). 시험마다 보관 자리를 치우고 시작해 끝에
되돌리도록 고쳤다 - 기능이 제대로 도는 증거이기도 하다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
515 lines
21 KiB
C#
515 lines
21 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.ComponentModel;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Runtime.CompilerServices;
|
|
|
|
namespace MergeHub.Services
|
|
{
|
|
/// <summary>
|
|
/// 문서 탭 하나가 들고 있는 "저장하지 않은 것"(12차 E-2).
|
|
///
|
|
/// 탭을 옮기면 지금 화면의 이것을 떠 있던 탭에 맡겨 두고, 가려는 탭에 맡겨 둔 것을 되살린다.
|
|
/// 파일에서 다시 읽어 오는 것(원본 글 · 줄 정렬)은 여기에 담지 않는다 - 그건 언제든 다시 읽을 수 있고,
|
|
/// 담아 두면 파일이 그 사이 바뀌었을 때 낡은 것을 보여 주게 된다. 여기에는 <b>파일에 없는 것만</b> 담는다.
|
|
/// </summary>
|
|
public sealed class DocTabState
|
|
{
|
|
/// <summary>오른쪽(LOCAL) 편집기의 글. null 이면 파일 그대로라 되살릴 것이 없다.</summary>
|
|
public string? Text { get; set; }
|
|
|
|
/// <summary>왼쪽(BASE) 칸의 글(12차 B-1 양방향). null 이면 파일 그대로.</summary>
|
|
public string? LeftText { get; set; }
|
|
|
|
public string[] Undo { get; set; } = Array.Empty<string>();
|
|
public string[] Redo { get; set; } = Array.Empty<string>();
|
|
|
|
/// <summary>연 시점 · 저장한 시점의 글(11차 F-8 <see cref="SaveBaseline"/>).</summary>
|
|
public string Opened { get; set; } = string.Empty;
|
|
public string Saved { get; set; } = string.Empty;
|
|
|
|
/// <summary>왼쪽 칸의 연 시점 · 저장한 시점(12차 B-1 은 왼쪽 기준점을 따로 센다).</summary>
|
|
public string LeftOpened { get; set; } = string.Empty;
|
|
public string LeftSaved { get; set; } = string.Empty;
|
|
|
|
/// <summary>캐럿이 있던 글자 자리와 맨 위에 보이던 행.</summary>
|
|
public int CaretIndex { get; set; }
|
|
public int TopRow { get; set; }
|
|
|
|
/// <summary>저장 형식 · 비교 규칙처럼 이 짝에만 걸어 둔 것.</summary>
|
|
public string? SaveEncoding { get; set; }
|
|
public string? SaveNewLine { get; set; }
|
|
|
|
/// <summary>되살릴 것이 있는가. 없으면 파일만 다시 읽으면 된다.</summary>
|
|
public bool HasEdits => Text is not null || LeftText is not null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 파일 비교 화면의 문서 탭 하나(12차 E-2).
|
|
///
|
|
/// 탭 줄에 그릴 이름(<see cref="Title"/>)과 저장 안 한 표시(<see cref="IsDirty"/>)를 들고,
|
|
/// 화면에 없는 동안 편집 글을 <see cref="Parked"/> 에 맡아 둔다.
|
|
/// </summary>
|
|
public sealed class DocTab : INotifyPropertyChanged
|
|
{
|
|
public DocTab(string left, string right)
|
|
{
|
|
Left = left ?? string.Empty;
|
|
Right = right ?? string.Empty;
|
|
}
|
|
|
|
/// <summary>왼쪽(BASE) 파일 경로.</summary>
|
|
public string Left { get; }
|
|
|
|
/// <summary>오른쪽(LOCAL) 파일 경로. 저장은 이 이름으로 한다.</summary>
|
|
public string Right { get; }
|
|
|
|
/// <summary>화면에 없는 동안 맡아 둔 편집. 떠 있는 탭은 화면이 주인이라 비어 있다.</summary>
|
|
public DocTabState? Parked { get; set; }
|
|
|
|
private bool _isDirty;
|
|
|
|
/// <summary>저장하지 않은 편집이 있는가. 탭 이름 앞 · 표시에 쓴다.</summary>
|
|
public bool IsDirty
|
|
{
|
|
get => _isDirty;
|
|
set => Set(ref _isDirty, value);
|
|
}
|
|
|
|
private bool _isCurrent;
|
|
|
|
/// <summary>지금 화면에 떠 있는 탭인가. 탭 줄에서 이 탭을 도드라지게 그리는 데 쓴다.</summary>
|
|
public bool IsCurrent
|
|
{
|
|
get => _isCurrent;
|
|
internal set => Set(ref _isCurrent, value);
|
|
}
|
|
|
|
private string _title = string.Empty;
|
|
|
|
/// <summary>탭 줄에 그릴 이름. 같은 이름이 여럿이면 <see cref="DocTabs"/> 가 위 폴더를 덧붙인다.</summary>
|
|
public string Title
|
|
{
|
|
get => _title;
|
|
internal set => Set(ref _title, value);
|
|
}
|
|
|
|
/// <summary>마우스를 올렸을 때 보여 줄 전체 경로.</summary>
|
|
public string Tip => Left + Environment.NewLine + Right;
|
|
|
|
/// <summary>같은 짝인가. 대소문자는 가리지 않는다(윈도우 경로).</summary>
|
|
public bool Same(string left, string right) =>
|
|
string.Equals(Left, left ?? string.Empty, StringComparison.OrdinalIgnoreCase) &&
|
|
string.Equals(Right, right ?? string.Empty, StringComparison.OrdinalIgnoreCase);
|
|
|
|
/// <summary>마지막으로 떠 있던 순서. 자리가 모자랄 때 오래 안 본 탭부터 치우는 데 쓴다.</summary>
|
|
internal long Touched { get; set; }
|
|
|
|
public event PropertyChangedEventHandler? PropertyChanged;
|
|
|
|
private void Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
|
{
|
|
if (EqualityComparer<T>.Default.Equals(field, value)) return;
|
|
field = value;
|
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name!));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 문서 탭 목록(12차 E-2). 화면 없이 검사할 수 있도록 <b>계산만</b> 한다 -
|
|
/// 파일을 읽거나 화면을 고치는 일은 부르는 쪽(HomeViewModel)이 한다.
|
|
///
|
|
/// <para>
|
|
/// 이름 짓는 규칙은 최근 목록(<see cref="RecentPair.Label"/>)과 같다 - 양쪽 이름이 같으면 그 이름 하나,
|
|
/// 다르면 <c>왼 ↔ 오</c>. 그래도 같은 이름이 여럿이면 갈릴 때까지 위 폴더를 한 겹씩 덧붙인다.
|
|
/// 폴더가 다른 같은 이름 파일(Unit0.cs 가 여러 폴더에 있는 것)이 사내 소스에 흔하다.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class DocTabs
|
|
{
|
|
/// <summary>이보다 많으면 <b>저장할 것이 없는</b> 탭 가운데 가장 오래 안 본 것을 치운다.</summary>
|
|
public const int Soft = 20;
|
|
|
|
private readonly List<DocTab> _tabs = new List<DocTab>();
|
|
private long _clock;
|
|
|
|
public IReadOnlyList<DocTab> Tabs => _tabs;
|
|
|
|
public int Count => _tabs.Count;
|
|
|
|
/// <summary>지금 떠 있는 탭. 하나도 없으면 null.</summary>
|
|
public DocTab? Current { get; private set; }
|
|
|
|
public int CurrentIndex => Current is null ? -1 : _tabs.IndexOf(Current);
|
|
|
|
/// <summary>탭이 여럿일 때만 탭 줄을 보여 준다 - 한 짝만 보는 동안 줄을 낭비하지 않는다.</summary>
|
|
public bool ShowStrip => _tabs.Count > 1;
|
|
|
|
/// <summary>자리가 모자라 조용히 치운 탭. 부르는 쪽이 알림에 쓴다.</summary>
|
|
public DocTab? LastEvicted { get; private set; }
|
|
|
|
/// <summary>같은 짝을 든 탭. 없으면 null.</summary>
|
|
public DocTab? Find(string left, string right) =>
|
|
_tabs.FirstOrDefault(t => t.Same(left, right));
|
|
|
|
// ================= 보관함 =================
|
|
|
|
/// <summary>
|
|
/// 탭에서 내려온 편집을 <b>짝 이름으로</b> 보관한다.
|
|
///
|
|
/// 탭을 닫거나(<see cref="Close"/>) 짝을 바꿔(<see cref="Replace"/>) 탭 자체가 사라져도
|
|
/// 고친 글은 남아야 한다 - 목록을 ◀ ▶ 로 훑다가 앞 파일로 돌아왔을 때 고친 것이 사라지면
|
|
/// 그것이 곧 E-2 가 없애려던 일이다. 다시 그 짝을 열면 여기서 되살아난다.
|
|
/// E-1(껐다 켜도 남는 편집)은 이 보관함을 그대로 파일에 옮겨 적으면 된다.
|
|
/// </summary>
|
|
private readonly Dictionary<string, DocTabState> _attic =
|
|
new Dictionary<string, DocTabState>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
/// <summary>보관 순서(오래된 것이 앞). 넘치면 앞에서 버린다.</summary>
|
|
private readonly List<string> _atticOrder = new List<string>();
|
|
|
|
/// <summary>보관함에 이만큼만 둔다. 짝마다 파일 전문 한 벌이라 끝없이 쌓을 수 없다.</summary>
|
|
public const int AtticMax = 50;
|
|
|
|
public int StashedCount => _attic.Count;
|
|
|
|
/// <summary>짝 하나를 가리키는 이름. 윈도우 경로에는 널 문자가 들어갈 수 없어 가름자로 쓴다.</summary>
|
|
public static string KeyOf(string? left, string? right) =>
|
|
(left ?? string.Empty) + "\0" + (right ?? string.Empty);
|
|
|
|
/// <summary>이 짝으로 보관해 둔 편집. 없으면 null.</summary>
|
|
public DocTabState? Stashed(string left, string right) =>
|
|
_attic.TryGetValue(KeyOf(left, right), out var state) ? state : null;
|
|
|
|
/// <summary>보관함에 든 짝 하나(12차 E-1 이 파일로 옮겨 적을 때 쓴다).</summary>
|
|
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; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 보관함에 든 것 전부(오래된 것부터). 탭에서 내려온 편집이라 <b>지금 열려 있지 않은</b> 짝들이다 -
|
|
/// 열려 있는 탭의 것은 화면이 들고 있으므로 부르는 쪽이 따로 담는다.
|
|
/// </summary>
|
|
public IReadOnlyList<StashedEdit> AllStashed()
|
|
{
|
|
var list = new List<StashedEdit>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 보관함에 곧바로 넣는다(12차 E-1 이 켤 때 파일에서 읽어 되돌려 놓는 길).
|
|
/// 그 뒤 <see cref="Open"/> 으로 열면 그 편집이 탭에 실린다.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>탭이 사라질 때 그 편집을 보관함으로 옮긴다. 고친 것이 없으면 보관하지 않고 지운다.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>새 탭을 만들 때 보관해 둔 편집이 있으면 되찾아 얹는다.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 짝을 연다. 같은 짝이 이미 있으면 <b>새로 만들지 않고 그 탭을 띄운다</b> -
|
|
/// 같은 파일이 두 탭에 있으면 어느 쪽 편집이 맞는지 알 수 없다.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 떠 있는 탭의 짝을 다른 짝으로 바꾼다(◀ 이전 파일 · 다음 파일 ▶).
|
|
/// 탭이 늘지 않아 목록을 훑기 좋다 - 사용자가 정한 것.
|
|
/// </summary>
|
|
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]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 탭을 닫는다. 닫은 자리의 <b>오른쪽</b> 탭으로 옮겨 가고, 오른쪽이 없으면 왼쪽으로 간다 -
|
|
/// 여러 장을 차례로 닫을 때 손이 한 자리에 머문다.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>다음 탭으로(Ctrl+Tab). 끝에서는 처음으로 돈다.</summary>
|
|
public DocTab? Next() => Step(1);
|
|
|
|
/// <summary>앞 탭으로(Ctrl+Shift+Tab).</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>모두 닫는다.</summary>
|
|
public void Clear()
|
|
{
|
|
foreach (var tab in _tabs) { Stash(tab); tab.IsCurrent = false; }
|
|
_tabs.Clear();
|
|
Current = null;
|
|
LastEvicted = null;
|
|
}
|
|
|
|
/// <summary>저장하지 않은 편집이 있는 탭들.</summary>
|
|
public IReadOnlyList<DocTab> Dirty => _tabs.Where(t => t.IsDirty).ToList();
|
|
|
|
/// <summary>
|
|
/// 자리가 모자라면 <b>저장할 것이 없는</b> 탭 가운데 가장 오래 안 본 것을 치운다.
|
|
/// 고친 탭은 절대 치우지 않는다 - 그것이 E-1 이 지키려는 바로 그 글이다.
|
|
/// 모두 고쳐진 상태면 <see cref="Soft"/> 를 넘겨서라도 새 탭을 연다.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 탭 이름을 다시 짓는다. 이름이 겹치는 탭끼리만 갈릴 때까지 위 폴더를 한 겹씩 덧붙인다.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 탭 이름. <paramref name="depth"/> 만큼 위 폴더를 앞에 붙인다.
|
|
/// 양쪽 이름이 같으면 하나만, 다르면 <c>왼 ↔ 오</c> 로 적는다.
|
|
/// </summary>
|
|
public static string BaseTitle(DocTab tab, int depth) =>
|
|
Pair(Tail(tab.Left, depth), Tail(tab.Right, depth));
|
|
|
|
/// <summary>
|
|
/// 두 이름을 한 줄로. 같으면 하나만, 다르면 <c>왼 ↔ 오</c> 인데
|
|
/// <b>양쪽이 같은 부분은 한 번만 적는다</b>(사용자 결정 2026-09-28).
|
|
///
|
|
/// <code>
|
|
/// WeldController_기준.cs ↔ WeldController_내작업.cs → WeldController_기준 ↔ 내작업.cs
|
|
/// </code>
|
|
///
|
|
/// 사내 소스에는 <c>_기준</c> · <c>_내작업</c> 처럼 <b>이름이 살짝만 다른</b> 짝이 흔해서, 그대로 두면
|
|
/// 탭 하나가 탭 줄의 1/9(280 DIP)을 먹는다. 겹치는 부분이 짧으면(우연히 첫 글자가 같은 정도)
|
|
/// 접지 않는다 - 접어서 얻는 것보다 읽기 어려워지는 것이 크다.
|
|
/// </summary>
|
|
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;
|
|
|
|
// 뒤쪽은 <b>확장자일 때만</b> 접는다. 글자 단위로 같은 꼬리를 세면 낱말 가운데서 잘린다 -
|
|
// 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;
|
|
}
|
|
|
|
/// <summary>경로 끝에서 <paramref name="depth"/>+1 조각. 조각이 모자라면 있는 만큼.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 탭 줄에 그릴 글. 저장하지 않은 탭은 앞에 가운뎃점을 붙인다(notepad++ 와 같은 표시).
|
|
/// </summary>
|
|
public static string Mark(DocTab tab) => tab.IsDirty ? "• " + tab.Title : tab.Title;
|
|
}
|
|
}
|